feat: Refactor UI components and improve animations for onboarding and authentication flows

This commit is contained in:
2026-02-27 23:38:29 +05:00
parent 5f163af1d8
commit af1adc066e
14 changed files with 318 additions and 163 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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)

View File

@@ -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<String> = []
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))
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

@@ -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")
}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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<Page: View>: 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<Page: View>: 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<Page: View>: UIViewControllerRepresentable {
// MARK: - Coordinator
final class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
final class Coordinator: NSObject,
UIPageViewControllerDataSource,
UIPageViewControllerDelegate,
UIScrollViewDelegate
{
var parent: OnboardingPager
let controllers: [UIHostingController<Page>]
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..<parent.count).map { i in
UIHostingController(rootView: parent.buildPage(i))
let hc = UIHostingController(rootView: parent.buildPage(i))
hc.view.backgroundColor = .clear
return hc
}
}
// MARK: DataSource
func pageViewController(
_ pvc: UIPageViewController,
viewControllerBefore vc: UIViewController
@@ -64,6 +80,18 @@ struct OnboardingPager<Page: View>: 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<Page: View>: 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))
}
}
}

View File

@@ -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..<pages.count, id: \.self) { i in
Circle()
.fill(currentPage == i ? RosettaColors.primaryBlue : Color.white.opacity(0.3))
.fill(dotColor(for: i))
.frame(width: dotSize, height: dotSize)
.animation(.easeInOut(duration: 0.25), value: currentPage)
}
}
.frame(height: dotSize + 4)
.accessibilityLabel("Page \(currentPage + 1) of \(pages.count)")
.accessibilityLabel("Page \(currentPage + 2) of \(pages.count)")
}
/// Only the nearest dot is active no blending between two dots.
func dotColor(for index: Int) -> 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")
}
}

View File

@@ -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
}
}
}