From af1adc066e2f5407cdf85901c27e6b1a08f37f8f Mon Sep 17 00:00:00 2001 From: senseiGai Date: Fri, 27 Feb 2026 23:38:29 +0500 Subject: [PATCH] feat: Refactor UI components and improve animations for onboarding and authentication flows --- Rosetta.xcodeproj/project.pbxproj | 8 +- Rosetta/DesignSystem/Colors.swift | 4 +- .../Components/AuthNavigationBar.swift | 10 +- .../Components/ButtonStyles.swift | 141 ++++++++++++++---- Rosetta/Features/Auth/AuthCoordinator.swift | 40 +---- .../Features/Auth/ConfirmSeedPhraseView.swift | 4 +- .../Features/Auth/ImportSeedPhraseView.swift | 4 +- Rosetta/Features/Auth/SeedPhraseView.swift | 16 +- Rosetta/Features/Auth/SetPasswordView.swift | 71 ++++++--- Rosetta/Features/Auth/UnlockView.swift | 12 +- Rosetta/Features/Auth/WelcomeView.swift | 26 ++-- .../Features/Onboarding/OnboardingPager.swift | 53 ++++++- .../Features/Onboarding/OnboardingView.swift | 65 ++++---- Rosetta/RosettaApp.swift | 27 ++-- 14 files changed, 318 insertions(+), 163 deletions(-) diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index a1f0dd1..cfc9c2a 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -272,8 +272,8 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchScreenBackground; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -306,8 +306,8 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchScreenBackground; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Rosetta/DesignSystem/Colors.swift b/Rosetta/DesignSystem/Colors.swift index d95eb71..593b788 100644 --- a/Rosetta/DesignSystem/Colors.swift +++ b/Rosetta/DesignSystem/Colors.swift @@ -22,7 +22,7 @@ enum RosettaColors { // MARK: Auth Backgrounds - static let authBackground = Color(hex: 0x1B1B1B) + static let authBackground = Color.black static let authSurface = Color(hex: 0x2A2A2A) // MARK: Shared Neutral @@ -60,7 +60,7 @@ enum RosettaColors { // MARK: Dark Theme enum Dark { - static let background = Color(hex: 0x1E1E1E) + static let background = Color.black static let backgroundSecondary = Color(hex: 0x2A2A2A) static let surface = Color(hex: 0x242424) static let text = Color.white diff --git a/Rosetta/DesignSystem/Components/AuthNavigationBar.swift b/Rosetta/DesignSystem/Components/AuthNavigationBar.swift index b0ac388..26241e9 100644 --- a/Rosetta/DesignSystem/Components/AuthNavigationBar.swift +++ b/Rosetta/DesignSystem/Components/AuthNavigationBar.swift @@ -11,14 +11,8 @@ struct AuthNavigationBar: View { var body: some View { HStack { - Button(action: onBack) { - Image(systemName: "chevron.left") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - } - .accessibilityLabel("Back") + GlassBackButton(action: onBack) + .accessibilityLabel("Back") if let title { Text(title) diff --git a/Rosetta/DesignSystem/Components/ButtonStyles.swift b/Rosetta/DesignSystem/Components/ButtonStyles.swift index bd91822..d7e28ef 100644 --- a/Rosetta/DesignSystem/Components/ButtonStyles.swift +++ b/Rosetta/DesignSystem/Components/ButtonStyles.swift @@ -1,16 +1,56 @@ import SwiftUI +// MARK: - Glass Back Button (circular, like screenshot reference) + +struct GlassBackButton: View { + let action: () -> Void + var size: CGFloat = 44 + + var body: some View { + Button(action: action) { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: size, height: size) + } + .background { + glassCircle + } + .clipShape(Circle()) + } + + @ViewBuilder + private var glassCircle: some View { + if #available(iOS 26, *) { + Circle() + .fill(Color.white.opacity(0.08)) + .glassEffect(.regular, in: .circle) + } else { + Circle() + .fill(Color.white.opacity(0.08)) + .overlay { + Circle() + .stroke(Color.white.opacity(0.12), lineWidth: 0.5) + } + } + } +} + // MARK: - Primary Button (Liquid Glass) struct RosettaPrimaryButtonStyle: ButtonStyle { var isEnabled: Bool = true + private var fillColor: Color { + isEnabled ? RosettaColors.primaryBlue : Color(white: 0.22) + } + func makeBody(configuration: Configuration) -> some View { Group { if #available(iOS 26, *) { configuration.label .background { - Capsule().fill(RosettaColors.primaryBlue.opacity(configuration.isPressed ? 0.7 : 1.0)) + Capsule().fill(fillColor.opacity(configuration.isPressed ? 0.7 : 1.0)) } .glassEffect(.regular, in: Capsule()) } else { @@ -19,21 +59,20 @@ struct RosettaPrimaryButtonStyle: ButtonStyle { .clipShape(Capsule()) } } - .opacity(isEnabled ? 1.0 : 0.5) - .scaleEffect(configuration.isPressed ? 0.97 : 1.0) + .scaleEffect(configuration.isPressed && isEnabled ? 0.97 : 1.0) .animation(.easeOut(duration: 0.15), value: configuration.isPressed) .allowsHitTesting(isEnabled) } private func glassBackground(isPressed: Bool) -> some View { Capsule() - .fill(RosettaColors.primaryBlue.opacity(isPressed ? 0.7 : 1.0)) + .fill(fillColor.opacity(isPressed ? 0.7 : 1.0)) .overlay { Capsule() .fill( LinearGradient( colors: [ - Color.white.opacity(0.18), + Color.white.opacity(isEnabled ? 0.18 : 0.05), Color.clear, Color.black.opacity(0.08), ], @@ -45,71 +84,121 @@ struct RosettaPrimaryButtonStyle: ButtonStyle { } } -// MARK: - Shimmer Overlay +// MARK: - Shine Overlay (Telegram-style color sweep) +// +// Instead of a white glare, a lighter/cyan tint of the button's own colour +// glides across — exactly the look Telegram uses on its primary CTA. -struct ShimmerModifier: ViewModifier { - @State private var phase: CGFloat = -1.0 +struct ShineModifier: ViewModifier { + let speed: Double + let highlightWidth: CGFloat + let tintColor: Color + let delay: Double + + @State private var phase: CGFloat = 0 func body(content: Content) -> some View { content .overlay { - GeometryReader { geometry in - let width = geometry.size.width * 0.6 + GeometryReader { geo in + let w = geo.size.width * highlightWidth + let travel = geo.size.width + w + let currentX = -w + phase * travel LinearGradient( - colors: [ - Color.white.opacity(0), - Color.white.opacity(0.25), - Color.white.opacity(0), + stops: [ + .init(color: .clear, location: 0), + .init(color: tintColor, location: 0.45), + .init(color: tintColor, location: 0.55), + .init(color: .clear, location: 1), ], startPoint: .leading, endPoint: .trailing ) - .frame(width: width) - .offset(x: phase * (geometry.size.width + width)) - .clipShape(Capsule()) + .frame(width: w, height: geo.size.height) + .offset(x: currentX) + .frame(width: geo.size.width, height: geo.size.height, alignment: .leading) } + .clipShape(Capsule()) + .allowsHitTesting(false) } .onAppear { - withAnimation( - .linear(duration: 2.0) - .repeatForever(autoreverses: false) - ) { - phase = 1.0 + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + withAnimation( + .easeInOut(duration: speed) + .repeatForever(autoreverses: false) + ) { + phase = 1 + } } } } } extension View { + func shine( + speed: Double = 2.0, + highlightWidth: CGFloat = 0.55, + tintColor: Color = Color(red: 0.35, green: 0.82, blue: 0.98).opacity(0.50), + delay: Double = 0.3 + ) -> some View { + modifier(ShineModifier( + speed: speed, + highlightWidth: highlightWidth, + tintColor: tintColor, + delay: delay + )) + } + + /// Legacy alias func shimmer() -> some View { - modifier(ShimmerModifier()) + shine() } } // MARK: - Stagger Animation Helper struct StaggeredAppearance: ViewModifier { + let key: String let index: Int let baseDelay: Double let stagger: Double - @State private var isVisible = false + @State private var isVisible: Bool + + /// Tracks which screen keys have already animated so we don't replay on re-navigation. + private static var animatedKeys: Set = [] + + init(key: String, index: Int, baseDelay: Double, stagger: Double) { + self.key = key + self.index = index + self.baseDelay = baseDelay + self.stagger = stagger + // Start visible immediately if this screen already animated — no flash frame. + _isVisible = State(initialValue: Self.animatedKeys.contains(key)) + } func body(content: Content) -> some View { content .opacity(isVisible ? 1.0 : 0.0) .offset(y: isVisible ? 0 : 12) .onAppear { + guard !isVisible else { return } let delay = baseDelay + Double(index) * stagger withAnimation(.easeOut(duration: 0.4).delay(delay)) { isVisible = true } + Self.animatedKeys.insert(key) } } } extension View { - func staggeredAppearance(index: Int, baseDelay: Double = 0.2, stagger: Double = 0.05) -> some View { - modifier(StaggeredAppearance(index: index, baseDelay: baseDelay, stagger: stagger)) + func staggeredAppearance( + key: String = "default", + index: Int, + baseDelay: Double = 0.2, + stagger: Double = 0.05 + ) -> some View { + modifier(StaggeredAppearance(key: key, index: index, baseDelay: baseDelay, stagger: stagger)) } } diff --git a/Rosetta/Features/Auth/AuthCoordinator.swift b/Rosetta/Features/Auth/AuthCoordinator.swift index 1e4ec98..b55fd5b 100644 --- a/Rosetta/Features/Auth/AuthCoordinator.swift +++ b/Rosetta/Features/Auth/AuthCoordinator.swift @@ -54,27 +54,16 @@ struct AuthCoordinator: View { currentScreenView .frame(maxWidth: .infinity, maxHeight: .infinity) .background { RosettaColors.authBackground.ignoresSafeArea() } - .overlay(alignment: .leading) { - if swipeOffset > 0 { - LinearGradient( - colors: [.black.opacity(0.12), .black.opacity(0.03), .clear], - startPoint: .leading, - endPoint: .trailing - ) - .frame(width: 8) - .offset(x: -8) - .ignoresSafeArea() - } - } - .transition(slideTransition) + .transition(.move(edge: navigationDirection == .forward ? .trailing : .leading)) .id(currentScreen) .offset(x: swipeOffset) } .overlay(alignment: .leading) { if canSwipeBack { Color.clear - .frame(width: 44) + .frame(width: 20) .contentShape(Rectangle()) + .padding(.top, 60) .gesture(swipeBackGesture(screenWidth: screenWidth)) } } @@ -163,38 +152,21 @@ private extension AuthCoordinator { } } -// MARK: - Transitions - -private extension AuthCoordinator { - var slideTransition: AnyTransition { - switch navigationDirection { - case .forward: - return .asymmetric( - insertion: .move(edge: .trailing).combined(with: .opacity), - removal: .move(edge: .leading).combined(with: .opacity) - ) - case .backward: - return .asymmetric( - insertion: .move(edge: .leading).combined(with: .opacity), - removal: .move(edge: .trailing).combined(with: .opacity) - ) - } - } -} +// MARK: - Transitions (kept minimal — pure slide, no opacity, to avoid flash) // MARK: - Navigation private extension AuthCoordinator { func navigateTo(_ screen: AuthScreen) { navigationDirection = .forward - withAnimation(.easeInOut(duration: 0.3)) { + withAnimation(.spring(response: 0.45, dampingFraction: 0.92)) { currentScreen = screen } } func navigateBack(to screen: AuthScreen) { navigationDirection = .backward - withAnimation(.easeInOut(duration: 0.3)) { + withAnimation(.spring(response: 0.4, dampingFraction: 0.95)) { currentScreen = screen } } diff --git a/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift b/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift index c3f12b8..587733e 100644 --- a/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift +++ b/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift @@ -74,13 +74,13 @@ private extension ConfirmSeedPhraseView { VStack(spacing: 10) { ForEach(0..<6, id: \.self) { index in wordRow(seedIndex: index, displayNumber: index + 1) - .staggeredAppearance(index: index, baseDelay: 0.2, stagger: 0.04) + .staggeredAppearance(key: "confirmSeed", index: index, baseDelay: 0.2, stagger: 0.04) } } VStack(spacing: 10) { ForEach(6..<12, id: \.self) { index in wordRow(seedIndex: index, displayNumber: index + 1) - .staggeredAppearance(index: index, baseDelay: 0.2, stagger: 0.04) + .staggeredAppearance(key: "confirmSeed", index: index, baseDelay: 0.2, stagger: 0.04) } } } diff --git a/Rosetta/Features/Auth/ImportSeedPhraseView.swift b/Rosetta/Features/Auth/ImportSeedPhraseView.swift index 716c402..8444528 100644 --- a/Rosetta/Features/Auth/ImportSeedPhraseView.swift +++ b/Rosetta/Features/Auth/ImportSeedPhraseView.swift @@ -109,13 +109,13 @@ private extension ImportSeedPhraseView { VStack(spacing: 10) { ForEach(0..<6, id: \.self) { index in inputRow(index: index) - .staggeredAppearance(index: index, baseDelay: 0.15, stagger: 0.04) + .staggeredAppearance(key: "importSeed", index: index, baseDelay: 0.15, stagger: 0.04) } } VStack(spacing: 10) { ForEach(6..<12, id: \.self) { index in inputRow(index: index) - .staggeredAppearance(index: index, baseDelay: 0.15, stagger: 0.04) + .staggeredAppearance(key: "importSeed", index: index, baseDelay: 0.15, stagger: 0.04) } } } diff --git a/Rosetta/Features/Auth/SeedPhraseView.swift b/Rosetta/Features/Auth/SeedPhraseView.swift index 77ccfb3..01ff5e6 100644 --- a/Rosetta/Features/Auth/SeedPhraseView.swift +++ b/Rosetta/Features/Auth/SeedPhraseView.swift @@ -7,7 +7,16 @@ struct SeedPhraseView: View { let onBack: () -> Void @State private var showCopiedToast = false - @State private var isContentVisible = false + @State private var isContentVisible: Bool + private static var hasAnimated = false + + init(seedPhrase: Binding<[String]>, onContinue: @escaping () -> Void, onBack: @escaping () -> Void) { + _seedPhrase = seedPhrase + self.onContinue = onContinue + self.onBack = onBack + _showCopiedToast = State(initialValue: false) + _isContentVisible = State(initialValue: Self.hasAnimated) + } var body: some View { VStack(spacing: 0) { @@ -75,7 +84,7 @@ private extension SeedPhraseView { ForEach(Array(words.enumerated()), id: \.offset) { offset, word in let globalIndex = startIndex + offset - 1 wordCard(number: startIndex + offset, word: word, colorIndex: globalIndex) - .staggeredAppearance(index: globalIndex, baseDelay: 0.3, stagger: 0.04) + .staggeredAppearance(key: "seedPhrase", index: globalIndex, baseDelay: 0.3, stagger: 0.04) } } } @@ -177,10 +186,11 @@ private extension SeedPhraseView { do { seedPhrase = try CryptoManager.shared.generateMnemonic() } catch { - // Entropy failure is extremely rare; show empty state rather than crash seedPhrase = [] } + guard !isContentVisible else { return } withAnimation { isContentVisible = true } + Self.hasAnimated = true } } diff --git a/Rosetta/Features/Auth/SetPasswordView.swift b/Rosetta/Features/Auth/SetPasswordView.swift index d4280f7..6525c5c 100644 --- a/Rosetta/Features/Auth/SetPasswordView.swift +++ b/Rosetta/Features/Auth/SetPasswordView.swift @@ -28,7 +28,7 @@ struct SetPasswordView: View { var body: some View { VStack(spacing: 0) { - AuthNavigationBar(title: "Set Password", onBack: onBack) + AuthNavigationBar(onBack: onBack) ScrollView(showsIndicators: false) { VStack(spacing: 20) { @@ -46,7 +46,6 @@ struct SetPasswordView: View { } WeakPasswordWarning(password: password) - infoCard if let message = errorMessage { Text(message) @@ -55,9 +54,6 @@ struct SetPasswordView: View { .multilineTextAlignment(.center) .transition(.opacity.combined(with: .scale(scale: 0.95))) } - - createButton - .padding(.top, 4) } .padding(.horizontal, 24) .padding(.top, 8) @@ -66,7 +62,17 @@ struct SetPasswordView: View { .scrollDismissesKeyboard(.interactively) .onTapGesture(count: 1) { focusedField = nil } .simultaneousGesture(TapGesture().onEnded {}) + + Spacer() + + VStack(spacing: 16) { + infoCard + createButton + } + .padding(.horizontal, 24) + .padding(.bottom, 16) } + .ignoresSafeArea(.keyboard) } } @@ -141,28 +147,49 @@ private extension SetPasswordView { field: Field ) -> some View { HStack(spacing: 12) { - Group { + ZStack(alignment: .leading) { + // Placeholder (shown when text is empty) + if text.wrappedValue.isEmpty { + Text(placeholder) + .font(.system(size: 16)) + .foregroundStyle(RosettaColors.tertiaryText) + } + // Actual input — always the same type trick: use overlay to keep focus if isSecure { - SecureField(placeholder, text: text) + SecureField("", text: text) + .font(.system(size: 16)) + .foregroundStyle(.white) + .focused($focusedField, equals: field) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() } else { - TextField(placeholder, text: text) + TextField("", text: text) + .font(.system(size: 16)) + .foregroundStyle(.white) + .focused($focusedField, equals: field) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() } } - .font(.system(size: 16)) - .foregroundStyle(.white) - .focused($focusedField, equals: field) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + .frame(height: 22) - Button(action: toggleAction) { - Image(systemName: isRevealed ? "eye.slash.fill" : "eye.fill") - .font(.system(size: 16)) - .foregroundStyle(RosettaColors.secondaryText) - } - .accessibilityLabel(isRevealed ? "Hide password" : "Show password") + Image(systemName: isRevealed ? "eye.slash" : "eye") + .font(.system(size: 16)) + .foregroundStyle(RosettaColors.secondaryText) + .frame(width: 30, height: 30) + .contentShape(Rectangle()) + .onTapGesture { + // Save and restore focus to prevent drop + let currentFocus = focusedField + toggleAction() + DispatchQueue.main.async { + focusedField = currentFocus + } + } + .accessibilityLabel(isRevealed ? "Hide password" : "Show password") } .padding(.horizontal, 16) - .padding(.vertical, 14) + .padding(.vertical, 10) .background { let isFocused = focusedField == field RoundedRectangle(cornerRadius: 12) @@ -204,6 +231,7 @@ private extension SetPasswordView { // MARK: - Create Button private extension SetPasswordView { + @ViewBuilder var createButton: some View { Button(action: createAccount) { Group { @@ -220,6 +248,9 @@ private extension SetPasswordView { .frame(height: 56) } .buttonStyle(RosettaPrimaryButtonStyle(isEnabled: canCreate)) + .shine(tintColor: canCreate + ? Color(red: 0.35, green: 0.82, blue: 0.98).opacity(0.50) + : .clear) .accessibilityHint(canCreate ? "Creates your encrypted account" : "Enter matching passwords first") } diff --git a/Rosetta/Features/Auth/UnlockView.swift b/Rosetta/Features/Auth/UnlockView.swift index 9fd860c..01f8f03 100644 --- a/Rosetta/Features/Auth/UnlockView.swift +++ b/Rosetta/Features/Auth/UnlockView.swift @@ -134,13 +134,11 @@ private extension UnlockView { .submitLabel(.done) .onSubmit { unlock() } - Button { - showPassword.toggle() - } label: { - Image(systemName: showPassword ? "eye.slash" : "eye") - .font(.system(size: 18)) - .foregroundStyle(Color(white: 0.45)) - } + Image(systemName: showPassword ? "eye.slash" : "eye") + .font(.system(size: 18)) + .foregroundStyle(Color(white: 0.45)) + .contentShape(Rectangle()) + .onTapGesture { showPassword.toggle() } } .padding(.horizontal, 16) .padding(.vertical, 14) diff --git a/Rosetta/Features/Auth/WelcomeView.swift b/Rosetta/Features/Auth/WelcomeView.swift index c7f8560..56f0a39 100644 --- a/Rosetta/Features/Auth/WelcomeView.swift +++ b/Rosetta/Features/Auth/WelcomeView.swift @@ -6,7 +6,15 @@ struct WelcomeView: View { let onImportSeed: () -> Void var onBack: (() -> Void)? - @State private var isVisible = false + @State private var isVisible: Bool + private static var hasAnimated = false + + init(onGenerateSeed: @escaping () -> Void, onImportSeed: @escaping () -> Void, onBack: (() -> Void)? = nil) { + self.onGenerateSeed = onGenerateSeed + self.onImportSeed = onImportSeed + self.onBack = onBack + _isVisible = State(initialValue: Self.hasAnimated) + } var body: some View { ZStack(alignment: .topLeading) { @@ -35,19 +43,16 @@ struct WelcomeView: View { // Back button (only shows when coming from Unlock screen) if let onBack { - Button(action: onBack) { - Image(systemName: "chevron.left") - .font(.system(size: 18, weight: .medium)) - .foregroundStyle(.white) - .frame(width: 44, height: 44) - } - .padding(.leading, 12) - .padding(.top, 8) - .opacity(isVisible ? 1.0 : 0.0) + GlassBackButton(action: onBack) + .padding(.leading, 12) + .padding(.top, 8) + .accessibilityLabel("Back") } } .onAppear { + guard !isVisible else { return } withAnimation(.easeOut(duration: 0.5)) { isVisible = true } + Self.hasAnimated = true } } } @@ -141,6 +146,7 @@ private extension WelcomeView { .frame(height: 56) } .buttonStyle(RosettaPrimaryButtonStyle()) + .shine() .accessibilityHint("Creates a new encrypted account") Button(action: onImportSeed) { diff --git a/Rosetta/Features/Onboarding/OnboardingPager.swift b/Rosetta/Features/Onboarding/OnboardingPager.swift index a8574ae..484c603 100644 --- a/Rosetta/Features/Onboarding/OnboardingPager.swift +++ b/Rosetta/Features/Onboarding/OnboardingPager.swift @@ -1,9 +1,11 @@ import SwiftUI /// UIPageViewController wrapper that handles paging entirely in UIKit. -/// SwiftUI state is only updated after a page fully settles — zero overhead during swipe. +/// Exposes both the settled page index AND a continuous drag progress for smooth dot tracking. struct OnboardingPager: UIViewControllerRepresentable { @Binding var currentIndex: Int + /// Continuous progress: 0.0 = page 0, 1.0 = page 1, etc. Tracks the finger in real time. + @Binding var continuousProgress: CGFloat let count: Int let buildPage: (Int) -> Page @@ -22,9 +24,13 @@ struct OnboardingPager: UIViewControllerRepresentable { direction: .forward, animated: false ) - // Make the inner scroll view transparent + // Hook into the inner scroll view for real-time offset tracking for sub in vc.view.subviews { - (sub as? UIScrollView)?.backgroundColor = .clear + if let scrollView = sub as? UIScrollView { + scrollView.backgroundColor = .clear + scrollView.delegate = context.coordinator + context.coordinator.pageWidth = scrollView.frame.width + } } return vc } @@ -35,18 +41,28 @@ struct OnboardingPager: UIViewControllerRepresentable { // MARK: - Coordinator - final class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate { + final class Coordinator: NSObject, + UIPageViewControllerDataSource, + UIPageViewControllerDelegate, + UIScrollViewDelegate + { var parent: OnboardingPager let controllers: [UIHostingController] + var pageWidth: CGFloat = 0 + private var pendingIndex: Int = 0 init(_ parent: OnboardingPager) { self.parent = parent - // Create hosting controllers without triggering view loading + self.pendingIndex = parent.currentIndex self.controllers = (0..: UIViewControllerRepresentable { return controllers[idx + 1] } + // MARK: Delegate + + func pageViewController( + _ pvc: UIPageViewController, + willTransitionTo pendingVCs: [UIViewController] + ) { + if let vc = pendingVCs.first, + let idx = controllers.firstIndex(where: { $0 === vc }) { + pendingIndex = idx + } + } + func pageViewController( _ pvc: UIPageViewController, didFinishAnimating finished: Bool, @@ -75,5 +103,18 @@ struct OnboardingPager: UIViewControllerRepresentable { let idx = controllers.firstIndex(where: { $0 === current }) else { return } parent.currentIndex = idx } + + // MARK: ScrollView — real-time progress + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let w = scrollView.frame.width + guard w > 0 else { return } + // UIPageViewController's scroll view rests at x = width. + // Dragging left (next page) increases x; dragging right decreases. + let offsetFromCenter = scrollView.contentOffset.x - w + let fraction = offsetFromCenter / w + let progress = CGFloat(parent.currentIndex) + fraction + parent.continuousProgress = max(0, min(CGFloat(parent.count - 1), progress)) + } } } diff --git a/Rosetta/Features/Onboarding/OnboardingView.swift b/Rosetta/Features/Onboarding/OnboardingView.swift index a45ebc4..60a2d5f 100644 --- a/Rosetta/Features/Onboarding/OnboardingView.swift +++ b/Rosetta/Features/Onboarding/OnboardingView.swift @@ -5,30 +5,38 @@ struct OnboardingView: View { let onStartMessaging: () -> Void @State private var currentPage = 0 + @State private var dragProgress: CGFloat = 0 private let pages = OnboardingPages.all var body: some View { GeometryReader { geometry in - let pagerHeight = geometry.size.height * 0.32 + 152 - ZStack { RosettaColors.Dark.background.ignoresSafeArea() + // Pager fills the entire screen — swipe works everywhere + OnboardingPager( + currentIndex: $currentPage, + continuousProgress: $dragProgress, + count: pages.count + ) { i in + OnboardingPageSlide( + page: pages[i], + screenWidth: geometry.size.width, + screenHeight: geometry.size.height + ) + } + .ignoresSafeArea() + + // Dots positioned relative to content, button at bottom VStack(spacing: 0) { - OnboardingPager( - currentIndex: $currentPage, - count: pages.count - ) { i in - OnboardingPageSlide( - page: pages[i], - screenWidth: geometry.size.width, - screenHeight: geometry.size.height - ) - } - .frame(height: pagerHeight) + // Match slide layout: top spacer + lottie + gap + text + let dotsTop = geometry.size.height * 0.12 + + geometry.size.height * 0.22 + + 32 + 150 + 20 + + Spacer().frame(height: dotsTop) pageIndicator() - .padding(.top, 24) Spacer() @@ -44,8 +52,6 @@ struct OnboardingView: View { } // MARK: - Page Slide -// Standalone view — has no dependency on parent state. -// Lottie playback controlled entirely by .onAppear / .onDisappear. private struct OnboardingPageSlide: View { let page: OnboardingPage @@ -56,7 +62,7 @@ private struct OnboardingPageSlide: View { var body: some View { VStack(spacing: 0) { - Spacer().frame(height: screenHeight * 0.1) + Spacer().frame(height: screenHeight * 0.12) LottieView( animationName: page.animationName, @@ -69,7 +75,9 @@ private struct OnboardingPageSlide: View { Spacer().frame(height: 32) textBlock() - .frame(height: 120) + .frame(height: 150) + + Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(RosettaColors.Dark.background) @@ -104,13 +112,13 @@ private struct OnboardingPageSlide: View { var attributed = AttributedString(line) for word in page.highlightedWords { if let range = attributed.range(of: word, options: .caseInsensitive) { - attributed[range].foregroundColor = RosettaColors.primaryBlue - attributed[range].font = .system(size: 17, weight: .semibold) + attributed[range].foregroundColor = Color.white + attributed[range].font = .system(size: 17, weight: .bold) } } return Text(attributed) .font(.system(size: 17)) - .foregroundStyle(RosettaColors.secondaryText) + .foregroundStyle(Color.white) } } @@ -124,13 +132,20 @@ private extension OnboardingView { return HStack(spacing: spacing - dotSize) { ForEach(0.. Color { + let nearest = Int(dragProgress.rounded()) + return index == nearest + ? RosettaColors.primaryBlue + : Color.white.opacity(0.3) } func startButton() -> some View { @@ -142,7 +157,7 @@ private extension OnboardingView { .frame(height: 54) } .buttonStyle(RosettaPrimaryButtonStyle()) - .shimmer() + .shine() .accessibilityHint("Opens account setup") } } diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 0a7397e..769c332 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -39,10 +39,11 @@ struct RosettaApp: App { var body: some Scene { WindowGroup { ZStack { - RosettaColors.Adaptive.background + Color.black .ignoresSafeArea() rootView + .transition(.opacity.animation(.easeInOut(duration: 0.5))) } .preferredColorScheme(.dark) } @@ -53,14 +54,14 @@ struct RosettaApp: App { switch appState { case .splash: SplashView { - withAnimation(.easeInOut(duration: 0.4)) { + withAnimation(.easeInOut(duration: 0.55)) { determineNextState() } } case .onboarding: OnboardingView { - withAnimation(.easeInOut(duration: 0.4)) { + withAnimation(.easeInOut(duration: 0.55)) { hasCompletedOnboarding = true appState = .auth } @@ -69,14 +70,14 @@ struct RosettaApp: App { case .auth: AuthCoordinator( onAuthComplete: { - withAnimation(.easeInOut(duration: 0.4)) { + withAnimation(.easeInOut(duration: 0.55)) { isLoggedIn = true appState = .main } }, onBackToUnlock: AccountManager.shared.hasAccount ? { // Go back to unlock screen if an account exists - withAnimation(.easeInOut(duration: 0.4)) { + withAnimation(.easeInOut(duration: 0.55)) { appState = .unlock } } : nil @@ -85,7 +86,7 @@ struct RosettaApp: App { case .unlock: UnlockView( onUnlocked: { - withAnimation(.easeInOut(duration: 0.4)) { + withAnimation(.easeInOut(duration: 0.55)) { isLoggedIn = true appState = .main } @@ -93,7 +94,7 @@ struct RosettaApp: App { onCreateNewAccount: { // Go to auth flow (Welcome screen with back button) // Does NOT delete the old account — Android keeps multiple accounts - withAnimation(.easeInOut(duration: 0.4)) { + withAnimation(.easeInOut(duration: 0.55)) { appState = .auth } } @@ -101,7 +102,7 @@ struct RosettaApp: App { case .main: MainTabView(onLogout: { - withAnimation(.easeInOut(duration: 0.4)) { + withAnimation(.easeInOut(duration: 0.55)) { isLoggedIn = false appState = .unlock } @@ -110,15 +111,13 @@ struct RosettaApp: App { } private func determineNextState() { - if !hasCompletedOnboarding { - // New install or fresh user — show onboarding first - appState = .onboarding - } else if AccountManager.shared.hasAccount { + if AccountManager.shared.hasAccount { // Existing user — unlock with password appState = .unlock } else { - // Onboarding done but no account — go to auth - appState = .auth + // No account — always show onboarding first, then auth + hasCompletedOnboarding = false + appState = .onboarding } } }