feat: Refactor UI components and improve animations for onboarding and authentication flows
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user