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_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchScreenBackground;
|
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchScreenBackground;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -306,8 +306,8 @@
|
|||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchScreenBackground;
|
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchScreenBackground;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ enum RosettaColors {
|
|||||||
|
|
||||||
// MARK: Auth Backgrounds
|
// MARK: Auth Backgrounds
|
||||||
|
|
||||||
static let authBackground = Color(hex: 0x1B1B1B)
|
static let authBackground = Color.black
|
||||||
static let authSurface = Color(hex: 0x2A2A2A)
|
static let authSurface = Color(hex: 0x2A2A2A)
|
||||||
|
|
||||||
// MARK: Shared Neutral
|
// MARK: Shared Neutral
|
||||||
@@ -60,7 +60,7 @@ enum RosettaColors {
|
|||||||
// MARK: Dark Theme
|
// MARK: Dark Theme
|
||||||
|
|
||||||
enum Dark {
|
enum Dark {
|
||||||
static let background = Color(hex: 0x1E1E1E)
|
static let background = Color.black
|
||||||
static let backgroundSecondary = Color(hex: 0x2A2A2A)
|
static let backgroundSecondary = Color(hex: 0x2A2A2A)
|
||||||
static let surface = Color(hex: 0x242424)
|
static let surface = Color(hex: 0x242424)
|
||||||
static let text = Color.white
|
static let text = Color.white
|
||||||
|
|||||||
@@ -11,14 +11,8 @@ struct AuthNavigationBar: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
Button(action: onBack) {
|
GlassBackButton(action: onBack)
|
||||||
Image(systemName: "chevron.left")
|
.accessibilityLabel("Back")
|
||||||
.font(.system(size: 18, weight: .semibold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.frame(width: 44, height: 44)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
.accessibilityLabel("Back")
|
|
||||||
|
|
||||||
if let title {
|
if let title {
|
||||||
Text(title)
|
Text(title)
|
||||||
|
|||||||
@@ -1,16 +1,56 @@
|
|||||||
import SwiftUI
|
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)
|
// MARK: - Primary Button (Liquid Glass)
|
||||||
|
|
||||||
struct RosettaPrimaryButtonStyle: ButtonStyle {
|
struct RosettaPrimaryButtonStyle: ButtonStyle {
|
||||||
var isEnabled: Bool = true
|
var isEnabled: Bool = true
|
||||||
|
|
||||||
|
private var fillColor: Color {
|
||||||
|
isEnabled ? RosettaColors.primaryBlue : Color(white: 0.22)
|
||||||
|
}
|
||||||
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
Group {
|
Group {
|
||||||
if #available(iOS 26, *) {
|
if #available(iOS 26, *) {
|
||||||
configuration.label
|
configuration.label
|
||||||
.background {
|
.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())
|
.glassEffect(.regular, in: Capsule())
|
||||||
} else {
|
} else {
|
||||||
@@ -19,21 +59,20 @@ struct RosettaPrimaryButtonStyle: ButtonStyle {
|
|||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.opacity(isEnabled ? 1.0 : 0.5)
|
.scaleEffect(configuration.isPressed && isEnabled ? 0.97 : 1.0)
|
||||||
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
|
|
||||||
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
|
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
|
||||||
.allowsHitTesting(isEnabled)
|
.allowsHitTesting(isEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func glassBackground(isPressed: Bool) -> some View {
|
private func glassBackground(isPressed: Bool) -> some View {
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(RosettaColors.primaryBlue.opacity(isPressed ? 0.7 : 1.0))
|
.fill(fillColor.opacity(isPressed ? 0.7 : 1.0))
|
||||||
.overlay {
|
.overlay {
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(
|
.fill(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
Color.white.opacity(0.18),
|
Color.white.opacity(isEnabled ? 0.18 : 0.05),
|
||||||
Color.clear,
|
Color.clear,
|
||||||
Color.black.opacity(0.08),
|
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 {
|
struct ShineModifier: ViewModifier {
|
||||||
@State private var phase: CGFloat = -1.0
|
let speed: Double
|
||||||
|
let highlightWidth: CGFloat
|
||||||
|
let tintColor: Color
|
||||||
|
let delay: Double
|
||||||
|
|
||||||
|
@State private var phase: CGFloat = 0
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content
|
content
|
||||||
.overlay {
|
.overlay {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geo in
|
||||||
let width = geometry.size.width * 0.6
|
let w = geo.size.width * highlightWidth
|
||||||
|
let travel = geo.size.width + w
|
||||||
|
let currentX = -w + phase * travel
|
||||||
|
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [
|
stops: [
|
||||||
Color.white.opacity(0),
|
.init(color: .clear, location: 0),
|
||||||
Color.white.opacity(0.25),
|
.init(color: tintColor, location: 0.45),
|
||||||
Color.white.opacity(0),
|
.init(color: tintColor, location: 0.55),
|
||||||
|
.init(color: .clear, location: 1),
|
||||||
],
|
],
|
||||||
startPoint: .leading,
|
startPoint: .leading,
|
||||||
endPoint: .trailing
|
endPoint: .trailing
|
||||||
)
|
)
|
||||||
.frame(width: width)
|
.frame(width: w, height: geo.size.height)
|
||||||
.offset(x: phase * (geometry.size.width + width))
|
.offset(x: currentX)
|
||||||
.clipShape(Capsule())
|
.frame(width: geo.size.width, height: geo.size.height, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
withAnimation(
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||||
.linear(duration: 2.0)
|
withAnimation(
|
||||||
.repeatForever(autoreverses: false)
|
.easeInOut(duration: speed)
|
||||||
) {
|
.repeatForever(autoreverses: false)
|
||||||
phase = 1.0
|
) {
|
||||||
|
phase = 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension View {
|
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 {
|
func shimmer() -> some View {
|
||||||
modifier(ShimmerModifier())
|
shine()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Stagger Animation Helper
|
// MARK: - Stagger Animation Helper
|
||||||
|
|
||||||
struct StaggeredAppearance: ViewModifier {
|
struct StaggeredAppearance: ViewModifier {
|
||||||
|
let key: String
|
||||||
let index: Int
|
let index: Int
|
||||||
let baseDelay: Double
|
let baseDelay: Double
|
||||||
let stagger: 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 {
|
func body(content: Content) -> some View {
|
||||||
content
|
content
|
||||||
.opacity(isVisible ? 1.0 : 0.0)
|
.opacity(isVisible ? 1.0 : 0.0)
|
||||||
.offset(y: isVisible ? 0 : 12)
|
.offset(y: isVisible ? 0 : 12)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
guard !isVisible else { return }
|
||||||
let delay = baseDelay + Double(index) * stagger
|
let delay = baseDelay + Double(index) * stagger
|
||||||
withAnimation(.easeOut(duration: 0.4).delay(delay)) {
|
withAnimation(.easeOut(duration: 0.4).delay(delay)) {
|
||||||
isVisible = true
|
isVisible = true
|
||||||
}
|
}
|
||||||
|
Self.animatedKeys.insert(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
func staggeredAppearance(index: Int, baseDelay: Double = 0.2, stagger: Double = 0.05) -> some View {
|
func staggeredAppearance(
|
||||||
modifier(StaggeredAppearance(index: index, baseDelay: baseDelay, stagger: stagger))
|
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
|
currentScreenView
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background { RosettaColors.authBackground.ignoresSafeArea() }
|
.background { RosettaColors.authBackground.ignoresSafeArea() }
|
||||||
.overlay(alignment: .leading) {
|
.transition(.move(edge: navigationDirection == .forward ? .trailing : .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)
|
|
||||||
.id(currentScreen)
|
.id(currentScreen)
|
||||||
.offset(x: swipeOffset)
|
.offset(x: swipeOffset)
|
||||||
}
|
}
|
||||||
.overlay(alignment: .leading) {
|
.overlay(alignment: .leading) {
|
||||||
if canSwipeBack {
|
if canSwipeBack {
|
||||||
Color.clear
|
Color.clear
|
||||||
.frame(width: 44)
|
.frame(width: 20)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
.padding(.top, 60)
|
||||||
.gesture(swipeBackGesture(screenWidth: screenWidth))
|
.gesture(swipeBackGesture(screenWidth: screenWidth))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,38 +152,21 @@ private extension AuthCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Transitions
|
// MARK: - Transitions (kept minimal — pure slide, no opacity, to avoid flash)
|
||||||
|
|
||||||
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: - Navigation
|
// MARK: - Navigation
|
||||||
|
|
||||||
private extension AuthCoordinator {
|
private extension AuthCoordinator {
|
||||||
func navigateTo(_ screen: AuthScreen) {
|
func navigateTo(_ screen: AuthScreen) {
|
||||||
navigationDirection = .forward
|
navigationDirection = .forward
|
||||||
withAnimation(.easeInOut(duration: 0.3)) {
|
withAnimation(.spring(response: 0.45, dampingFraction: 0.92)) {
|
||||||
currentScreen = screen
|
currentScreen = screen
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func navigateBack(to screen: AuthScreen) {
|
func navigateBack(to screen: AuthScreen) {
|
||||||
navigationDirection = .backward
|
navigationDirection = .backward
|
||||||
withAnimation(.easeInOut(duration: 0.3)) {
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.95)) {
|
||||||
currentScreen = screen
|
currentScreen = screen
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,13 +74,13 @@ private extension ConfirmSeedPhraseView {
|
|||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
ForEach(0..<6, id: \.self) { index in
|
ForEach(0..<6, id: \.self) { index in
|
||||||
wordRow(seedIndex: index, displayNumber: index + 1)
|
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) {
|
VStack(spacing: 10) {
|
||||||
ForEach(6..<12, id: \.self) { index in
|
ForEach(6..<12, id: \.self) { index in
|
||||||
wordRow(seedIndex: index, displayNumber: index + 1)
|
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) {
|
VStack(spacing: 10) {
|
||||||
ForEach(0..<6, id: \.self) { index in
|
ForEach(0..<6, id: \.self) { index in
|
||||||
inputRow(index: index)
|
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) {
|
VStack(spacing: 10) {
|
||||||
ForEach(6..<12, id: \.self) { index in
|
ForEach(6..<12, id: \.self) { index in
|
||||||
inputRow(index: index)
|
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
|
let onBack: () -> Void
|
||||||
|
|
||||||
@State private var showCopiedToast = false
|
@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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -75,7 +84,7 @@ private extension SeedPhraseView {
|
|||||||
ForEach(Array(words.enumerated()), id: \.offset) { offset, word in
|
ForEach(Array(words.enumerated()), id: \.offset) { offset, word in
|
||||||
let globalIndex = startIndex + offset - 1
|
let globalIndex = startIndex + offset - 1
|
||||||
wordCard(number: startIndex + offset, word: word, colorIndex: globalIndex)
|
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 {
|
do {
|
||||||
seedPhrase = try CryptoManager.shared.generateMnemonic()
|
seedPhrase = try CryptoManager.shared.generateMnemonic()
|
||||||
} catch {
|
} catch {
|
||||||
// Entropy failure is extremely rare; show empty state rather than crash
|
|
||||||
seedPhrase = []
|
seedPhrase = []
|
||||||
}
|
}
|
||||||
|
guard !isContentVisible else { return }
|
||||||
withAnimation { isContentVisible = true }
|
withAnimation { isContentVisible = true }
|
||||||
|
Self.hasAnimated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ struct SetPasswordView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
AuthNavigationBar(title: "Set Password", onBack: onBack)
|
AuthNavigationBar(onBack: onBack)
|
||||||
|
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
@@ -46,7 +46,6 @@ struct SetPasswordView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
WeakPasswordWarning(password: password)
|
WeakPasswordWarning(password: password)
|
||||||
infoCard
|
|
||||||
|
|
||||||
if let message = errorMessage {
|
if let message = errorMessage {
|
||||||
Text(message)
|
Text(message)
|
||||||
@@ -55,9 +54,6 @@ struct SetPasswordView: View {
|
|||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
||||||
}
|
}
|
||||||
|
|
||||||
createButton
|
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
@@ -66,7 +62,17 @@ struct SetPasswordView: View {
|
|||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.onTapGesture(count: 1) { focusedField = nil }
|
.onTapGesture(count: 1) { focusedField = nil }
|
||||||
.simultaneousGesture(TapGesture().onEnded {})
|
.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
|
field: Field
|
||||||
) -> some View {
|
) -> some View {
|
||||||
HStack(spacing: 12) {
|
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 {
|
if isSecure {
|
||||||
SecureField(placeholder, text: text)
|
SecureField("", text: text)
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.focused($focusedField, equals: field)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
} else {
|
} 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))
|
.frame(height: 22)
|
||||||
.foregroundStyle(.white)
|
|
||||||
.focused($focusedField, equals: field)
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
|
|
||||||
Button(action: toggleAction) {
|
Image(systemName: isRevealed ? "eye.slash" : "eye")
|
||||||
Image(systemName: isRevealed ? "eye.slash.fill" : "eye.fill")
|
.font(.system(size: 16))
|
||||||
.font(.system(size: 16))
|
.foregroundStyle(RosettaColors.secondaryText)
|
||||||
.foregroundStyle(RosettaColors.secondaryText)
|
.frame(width: 30, height: 30)
|
||||||
}
|
.contentShape(Rectangle())
|
||||||
.accessibilityLabel(isRevealed ? "Hide password" : "Show password")
|
.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(.horizontal, 16)
|
||||||
.padding(.vertical, 14)
|
.padding(.vertical, 10)
|
||||||
.background {
|
.background {
|
||||||
let isFocused = focusedField == field
|
let isFocused = focusedField == field
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
@@ -204,6 +231,7 @@ private extension SetPasswordView {
|
|||||||
// MARK: - Create Button
|
// MARK: - Create Button
|
||||||
|
|
||||||
private extension SetPasswordView {
|
private extension SetPasswordView {
|
||||||
|
@ViewBuilder
|
||||||
var createButton: some View {
|
var createButton: some View {
|
||||||
Button(action: createAccount) {
|
Button(action: createAccount) {
|
||||||
Group {
|
Group {
|
||||||
@@ -220,6 +248,9 @@ private extension SetPasswordView {
|
|||||||
.frame(height: 56)
|
.frame(height: 56)
|
||||||
}
|
}
|
||||||
.buttonStyle(RosettaPrimaryButtonStyle(isEnabled: canCreate))
|
.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")
|
.accessibilityHint(canCreate ? "Creates your encrypted account" : "Enter matching passwords first")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,13 +134,11 @@ private extension UnlockView {
|
|||||||
.submitLabel(.done)
|
.submitLabel(.done)
|
||||||
.onSubmit { unlock() }
|
.onSubmit { unlock() }
|
||||||
|
|
||||||
Button {
|
Image(systemName: showPassword ? "eye.slash" : "eye")
|
||||||
showPassword.toggle()
|
.font(.system(size: 18))
|
||||||
} label: {
|
.foregroundStyle(Color(white: 0.45))
|
||||||
Image(systemName: showPassword ? "eye.slash" : "eye")
|
.contentShape(Rectangle())
|
||||||
.font(.system(size: 18))
|
.onTapGesture { showPassword.toggle() }
|
||||||
.foregroundStyle(Color(white: 0.45))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 14)
|
.padding(.vertical, 14)
|
||||||
|
|||||||
@@ -6,7 +6,15 @@ struct WelcomeView: View {
|
|||||||
let onImportSeed: () -> Void
|
let onImportSeed: () -> Void
|
||||||
var onBack: (() -> 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 {
|
var body: some View {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
@@ -35,19 +43,16 @@ struct WelcomeView: View {
|
|||||||
|
|
||||||
// Back button (only shows when coming from Unlock screen)
|
// Back button (only shows when coming from Unlock screen)
|
||||||
if let onBack {
|
if let onBack {
|
||||||
Button(action: onBack) {
|
GlassBackButton(action: onBack)
|
||||||
Image(systemName: "chevron.left")
|
.padding(.leading, 12)
|
||||||
.font(.system(size: 18, weight: .medium))
|
.padding(.top, 8)
|
||||||
.foregroundStyle(.white)
|
.accessibilityLabel("Back")
|
||||||
.frame(width: 44, height: 44)
|
|
||||||
}
|
|
||||||
.padding(.leading, 12)
|
|
||||||
.padding(.top, 8)
|
|
||||||
.opacity(isVisible ? 1.0 : 0.0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
guard !isVisible else { return }
|
||||||
withAnimation(.easeOut(duration: 0.5)) { isVisible = true }
|
withAnimation(.easeOut(duration: 0.5)) { isVisible = true }
|
||||||
|
Self.hasAnimated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,6 +146,7 @@ private extension WelcomeView {
|
|||||||
.frame(height: 56)
|
.frame(height: 56)
|
||||||
}
|
}
|
||||||
.buttonStyle(RosettaPrimaryButtonStyle())
|
.buttonStyle(RosettaPrimaryButtonStyle())
|
||||||
|
.shine()
|
||||||
.accessibilityHint("Creates a new encrypted account")
|
.accessibilityHint("Creates a new encrypted account")
|
||||||
|
|
||||||
Button(action: onImportSeed) {
|
Button(action: onImportSeed) {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// UIPageViewController wrapper that handles paging entirely in UIKit.
|
/// 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 {
|
struct OnboardingPager<Page: View>: UIViewControllerRepresentable {
|
||||||
@Binding var currentIndex: Int
|
@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 count: Int
|
||||||
let buildPage: (Int) -> Page
|
let buildPage: (Int) -> Page
|
||||||
|
|
||||||
@@ -22,9 +24,13 @@ struct OnboardingPager<Page: View>: UIViewControllerRepresentable {
|
|||||||
direction: .forward,
|
direction: .forward,
|
||||||
animated: false
|
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 {
|
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
|
return vc
|
||||||
}
|
}
|
||||||
@@ -35,18 +41,28 @@ struct OnboardingPager<Page: View>: UIViewControllerRepresentable {
|
|||||||
|
|
||||||
// MARK: - Coordinator
|
// MARK: - Coordinator
|
||||||
|
|
||||||
final class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
|
final class Coordinator: NSObject,
|
||||||
|
UIPageViewControllerDataSource,
|
||||||
|
UIPageViewControllerDelegate,
|
||||||
|
UIScrollViewDelegate
|
||||||
|
{
|
||||||
var parent: OnboardingPager
|
var parent: OnboardingPager
|
||||||
let controllers: [UIHostingController<Page>]
|
let controllers: [UIHostingController<Page>]
|
||||||
|
var pageWidth: CGFloat = 0
|
||||||
|
private var pendingIndex: Int = 0
|
||||||
|
|
||||||
init(_ parent: OnboardingPager) {
|
init(_ parent: OnboardingPager) {
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
// Create hosting controllers without triggering view loading
|
self.pendingIndex = parent.currentIndex
|
||||||
self.controllers = (0..<parent.count).map { i in
|
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(
|
func pageViewController(
|
||||||
_ pvc: UIPageViewController,
|
_ pvc: UIPageViewController,
|
||||||
viewControllerBefore vc: UIViewController
|
viewControllerBefore vc: UIViewController
|
||||||
@@ -64,6 +80,18 @@ struct OnboardingPager<Page: View>: UIViewControllerRepresentable {
|
|||||||
return controllers[idx + 1]
|
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(
|
func pageViewController(
|
||||||
_ pvc: UIPageViewController,
|
_ pvc: UIPageViewController,
|
||||||
didFinishAnimating finished: Bool,
|
didFinishAnimating finished: Bool,
|
||||||
@@ -75,5 +103,18 @@ struct OnboardingPager<Page: View>: UIViewControllerRepresentable {
|
|||||||
let idx = controllers.firstIndex(where: { $0 === current }) else { return }
|
let idx = controllers.firstIndex(where: { $0 === current }) else { return }
|
||||||
parent.currentIndex = idx
|
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
|
let onStartMessaging: () -> Void
|
||||||
|
|
||||||
@State private var currentPage = 0
|
@State private var currentPage = 0
|
||||||
|
@State private var dragProgress: CGFloat = 0
|
||||||
private let pages = OnboardingPages.all
|
private let pages = OnboardingPages.all
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
let pagerHeight = geometry.size.height * 0.32 + 152
|
|
||||||
|
|
||||||
ZStack {
|
ZStack {
|
||||||
RosettaColors.Dark.background.ignoresSafeArea()
|
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) {
|
VStack(spacing: 0) {
|
||||||
OnboardingPager(
|
// Match slide layout: top spacer + lottie + gap + text
|
||||||
currentIndex: $currentPage,
|
let dotsTop = geometry.size.height * 0.12
|
||||||
count: pages.count
|
+ geometry.size.height * 0.22
|
||||||
) { i in
|
+ 32 + 150 + 20
|
||||||
OnboardingPageSlide(
|
|
||||||
page: pages[i],
|
Spacer().frame(height: dotsTop)
|
||||||
screenWidth: geometry.size.width,
|
|
||||||
screenHeight: geometry.size.height
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.frame(height: pagerHeight)
|
|
||||||
|
|
||||||
pageIndicator()
|
pageIndicator()
|
||||||
.padding(.top, 24)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@@ -44,8 +52,6 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Page Slide
|
// MARK: - Page Slide
|
||||||
// Standalone view — has no dependency on parent state.
|
|
||||||
// Lottie playback controlled entirely by .onAppear / .onDisappear.
|
|
||||||
|
|
||||||
private struct OnboardingPageSlide: View {
|
private struct OnboardingPageSlide: View {
|
||||||
let page: OnboardingPage
|
let page: OnboardingPage
|
||||||
@@ -56,7 +62,7 @@ private struct OnboardingPageSlide: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Spacer().frame(height: screenHeight * 0.1)
|
Spacer().frame(height: screenHeight * 0.12)
|
||||||
|
|
||||||
LottieView(
|
LottieView(
|
||||||
animationName: page.animationName,
|
animationName: page.animationName,
|
||||||
@@ -69,7 +75,9 @@ private struct OnboardingPageSlide: View {
|
|||||||
Spacer().frame(height: 32)
|
Spacer().frame(height: 32)
|
||||||
|
|
||||||
textBlock()
|
textBlock()
|
||||||
.frame(height: 120)
|
.frame(height: 150)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(RosettaColors.Dark.background)
|
.background(RosettaColors.Dark.background)
|
||||||
@@ -104,13 +112,13 @@ private struct OnboardingPageSlide: View {
|
|||||||
var attributed = AttributedString(line)
|
var attributed = AttributedString(line)
|
||||||
for word in page.highlightedWords {
|
for word in page.highlightedWords {
|
||||||
if let range = attributed.range(of: word, options: .caseInsensitive) {
|
if let range = attributed.range(of: word, options: .caseInsensitive) {
|
||||||
attributed[range].foregroundColor = RosettaColors.primaryBlue
|
attributed[range].foregroundColor = Color.white
|
||||||
attributed[range].font = .system(size: 17, weight: .semibold)
|
attributed[range].font = .system(size: 17, weight: .bold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Text(attributed)
|
return Text(attributed)
|
||||||
.font(.system(size: 17))
|
.font(.system(size: 17))
|
||||||
.foregroundStyle(RosettaColors.secondaryText)
|
.foregroundStyle(Color.white)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,13 +132,20 @@ private extension OnboardingView {
|
|||||||
return HStack(spacing: spacing - dotSize) {
|
return HStack(spacing: spacing - dotSize) {
|
||||||
ForEach(0..<pages.count, id: \.self) { i in
|
ForEach(0..<pages.count, id: \.self) { i in
|
||||||
Circle()
|
Circle()
|
||||||
.fill(currentPage == i ? RosettaColors.primaryBlue : Color.white.opacity(0.3))
|
.fill(dotColor(for: i))
|
||||||
.frame(width: dotSize, height: dotSize)
|
.frame(width: dotSize, height: dotSize)
|
||||||
.animation(.easeInOut(duration: 0.25), value: currentPage)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: dotSize + 4)
|
.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 {
|
func startButton() -> some View {
|
||||||
@@ -142,7 +157,7 @@ private extension OnboardingView {
|
|||||||
.frame(height: 54)
|
.frame(height: 54)
|
||||||
}
|
}
|
||||||
.buttonStyle(RosettaPrimaryButtonStyle())
|
.buttonStyle(RosettaPrimaryButtonStyle())
|
||||||
.shimmer()
|
.shine()
|
||||||
.accessibilityHint("Opens account setup")
|
.accessibilityHint("Opens account setup")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,10 +39,11 @@ struct RosettaApp: App {
|
|||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ZStack {
|
ZStack {
|
||||||
RosettaColors.Adaptive.background
|
Color.black
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
rootView
|
rootView
|
||||||
|
.transition(.opacity.animation(.easeInOut(duration: 0.5)))
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
}
|
}
|
||||||
@@ -53,14 +54,14 @@ struct RosettaApp: App {
|
|||||||
switch appState {
|
switch appState {
|
||||||
case .splash:
|
case .splash:
|
||||||
SplashView {
|
SplashView {
|
||||||
withAnimation(.easeInOut(duration: 0.4)) {
|
withAnimation(.easeInOut(duration: 0.55)) {
|
||||||
determineNextState()
|
determineNextState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case .onboarding:
|
case .onboarding:
|
||||||
OnboardingView {
|
OnboardingView {
|
||||||
withAnimation(.easeInOut(duration: 0.4)) {
|
withAnimation(.easeInOut(duration: 0.55)) {
|
||||||
hasCompletedOnboarding = true
|
hasCompletedOnboarding = true
|
||||||
appState = .auth
|
appState = .auth
|
||||||
}
|
}
|
||||||
@@ -69,14 +70,14 @@ struct RosettaApp: App {
|
|||||||
case .auth:
|
case .auth:
|
||||||
AuthCoordinator(
|
AuthCoordinator(
|
||||||
onAuthComplete: {
|
onAuthComplete: {
|
||||||
withAnimation(.easeInOut(duration: 0.4)) {
|
withAnimation(.easeInOut(duration: 0.55)) {
|
||||||
isLoggedIn = true
|
isLoggedIn = true
|
||||||
appState = .main
|
appState = .main
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onBackToUnlock: AccountManager.shared.hasAccount ? {
|
onBackToUnlock: AccountManager.shared.hasAccount ? {
|
||||||
// Go back to unlock screen if an account exists
|
// Go back to unlock screen if an account exists
|
||||||
withAnimation(.easeInOut(duration: 0.4)) {
|
withAnimation(.easeInOut(duration: 0.55)) {
|
||||||
appState = .unlock
|
appState = .unlock
|
||||||
}
|
}
|
||||||
} : nil
|
} : nil
|
||||||
@@ -85,7 +86,7 @@ struct RosettaApp: App {
|
|||||||
case .unlock:
|
case .unlock:
|
||||||
UnlockView(
|
UnlockView(
|
||||||
onUnlocked: {
|
onUnlocked: {
|
||||||
withAnimation(.easeInOut(duration: 0.4)) {
|
withAnimation(.easeInOut(duration: 0.55)) {
|
||||||
isLoggedIn = true
|
isLoggedIn = true
|
||||||
appState = .main
|
appState = .main
|
||||||
}
|
}
|
||||||
@@ -93,7 +94,7 @@ struct RosettaApp: App {
|
|||||||
onCreateNewAccount: {
|
onCreateNewAccount: {
|
||||||
// Go to auth flow (Welcome screen with back button)
|
// Go to auth flow (Welcome screen with back button)
|
||||||
// Does NOT delete the old account — Android keeps multiple accounts
|
// Does NOT delete the old account — Android keeps multiple accounts
|
||||||
withAnimation(.easeInOut(duration: 0.4)) {
|
withAnimation(.easeInOut(duration: 0.55)) {
|
||||||
appState = .auth
|
appState = .auth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,7 +102,7 @@ struct RosettaApp: App {
|
|||||||
|
|
||||||
case .main:
|
case .main:
|
||||||
MainTabView(onLogout: {
|
MainTabView(onLogout: {
|
||||||
withAnimation(.easeInOut(duration: 0.4)) {
|
withAnimation(.easeInOut(duration: 0.55)) {
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
appState = .unlock
|
appState = .unlock
|
||||||
}
|
}
|
||||||
@@ -110,15 +111,13 @@ struct RosettaApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func determineNextState() {
|
private func determineNextState() {
|
||||||
if !hasCompletedOnboarding {
|
if AccountManager.shared.hasAccount {
|
||||||
// New install or fresh user — show onboarding first
|
|
||||||
appState = .onboarding
|
|
||||||
} else if AccountManager.shared.hasAccount {
|
|
||||||
// Existing user — unlock with password
|
// Existing user — unlock with password
|
||||||
appState = .unlock
|
appState = .unlock
|
||||||
} else {
|
} else {
|
||||||
// Onboarding done but no account — go to auth
|
// No account — always show onboarding first, then auth
|
||||||
appState = .auth
|
hasCompletedOnboarding = false
|
||||||
|
appState = .onboarding
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user