Add onboarding, auth flow, design system and project structure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 11:35:29 +05:00
parent 2a3e57c2fd
commit 7ae8da53f0
26 changed files with 67032 additions and 12 deletions

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Exclude from repo
rosetta-android/
sprints/
CLAUDE.md
# Xcode
build/
DerivedData/
*.xcuserstate
*.xccheckout
*.moved-aside
*.hmap
*.ipa
*.dSYM.zip
*.dSYM
# Swift Package Manager
.build/
Packages/
Package.resolved
# macOS
.DS_Store
*.swp
*~

View File

@@ -6,6 +6,10 @@
objectVersion = 77; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */
853F29992F4B63D20092AD05 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29982F4B63D20092AD05 /* Lottie */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
853F29622F4B50410092AD05 /* Rosetta.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Rosetta.app; sourceTree = BUILT_PRODUCTS_DIR; }; 853F29622F4B50410092AD05 /* Rosetta.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Rosetta.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -23,6 +27,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
853F29992F4B63D20092AD05 /* Lottie in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -65,6 +70,7 @@
); );
name = Rosetta; name = Rosetta;
packageProductDependencies = ( packageProductDependencies = (
853F29982F4B63D20092AD05 /* Lottie */,
); );
productName = Rosetta; productName = Rosetta;
productReference = 853F29622F4B50410092AD05 /* Rosetta.app */; productReference = 853F29622F4B50410092AD05 /* Rosetta.app */;
@@ -94,6 +100,9 @@
); );
mainGroup = 853F29592F4B50410092AD05; mainGroup = 853F29592F4B50410092AD05;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = (
853F29972F4B63D20092AD05 /* XCRemoteSwiftPackageReference "lottie-ios" */,
);
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = 853F29632F4B50410092AD05 /* Products */; productRefGroup = 853F29632F4B50410092AD05 /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -251,6 +260,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = U6DMAKWNV3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -282,6 +292,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = U6DMAKWNV3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -328,6 +339,25 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
853F29972F4B63D20092AD05 /* XCRemoteSwiftPackageReference "lottie-ios" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/airbnb/lottie-ios.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 4.0.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
853F29982F4B63D20092AD05 /* Lottie */ = {
isa = XCSwiftPackageProductDependency;
package = 853F29972F4B63D20092AD05 /* XCRemoteSwiftPackageReference "lottie-ios" */;
productName = Lottie;
};
/* End XCSwiftPackageProductDependency section */
}; };
rootObject = 853F295A2F4B50410092AD05 /* Project object */; rootObject = 853F295A2F4B50410092AD05 /* Project object */;
} }

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "853F29612F4B50410092AD05"
BuildableName = "Rosetta.app"
BlueprintName = "Rosetta"
ReferencedContainer = "container:Rosetta.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "853F29612F4B50410092AD05"
BuildableName = "Rosetta.app"
BlueprintName = "Rosetta"
ReferencedContainer = "container:Rosetta.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "853F29612F4B50410092AD05"
BuildableName = "Rosetta.app"
BlueprintName = "Rosetta"
ReferencedContainer = "container:Rosetta.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -10,5 +10,13 @@
<integer>0</integer> <integer>0</integer>
</dict> </dict>
</dict> </dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>853F29612F4B50410092AD05</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict> </dict>
</plist> </plist>

View File

@@ -5,20 +5,14 @@
// Created by Gaidar Timirbaev on 22.02.2026. // Created by Gaidar Timirbaev on 22.02.2026.
// //
// This file is intentionally left as a placeholder.
// The app entry point is managed by RosettaApp.swift
// which routes to OnboardingView AuthCoordinator Main app.
import SwiftUI import SwiftUI
struct ContentView: View { struct ContentView: View {
var body: some View { var body: some View {
VStack { EmptyView()
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
} }
} }
#Preview {
ContentView()
}

View File

@@ -0,0 +1,111 @@
import SwiftUI
// MARK: - Rosetta Color Tokens
enum RosettaColors {
// MARK: Brand
static let primaryBlue = Color(hex: 0x248AE6)
static let primaryBlueDark = Color(hex: 0x238BE6)
static let primaryBluePressed = Color(hex: 0x2B7CD3)
static let primaryBlueDisabled = Color(hex: 0x9BCDFF)
// MARK: Semantic
static let error = Color(hex: 0xFF3B30)
static let success = Color(hex: 0x34C759)
static let warning = Color(hex: 0xFF9500)
static let accent = Color(hex: 0xE91E63)
static let online = Color(hex: 0x34C759)
// MARK: Auth Backgrounds
static let authBackground = Color(hex: 0x1B1B1B)
static let authSurface = Color(hex: 0x2A2A2A)
// MARK: Shared Neutral
static let secondaryText = Color(hex: 0x8E8E93)
static let tertiaryText = Color(hex: 0x666666)
static let numberGray = Color(hex: 0x888888)
static let hintText = Color(hex: 0x555555)
static let subtleBorder = Color.white.opacity(0.15)
static let cardFill = Color.white.opacity(0.06)
// MARK: Light Theme
enum Light {
static let background = Color.white
static let backgroundSecondary = Color(hex: 0xF2F3F5)
static let surface = Color(hex: 0xF5F5F5)
static let text = Color.black
static let textSecondary = Color(hex: 0x666666)
static let textTertiary = Color(hex: 0x999999)
static let border = Color(hex: 0xE0E0E0)
static let divider = Color(hex: 0xEEEEEE)
static let messageBubble = Color(hex: 0xF5F5F5)
static let messageBubbleOwn = Color(hex: 0xDCF8C6)
static let inputBackground = Color(hex: 0xF2F3F5)
}
// MARK: Dark Theme
enum Dark {
static let background = Color(hex: 0x1E1E1E)
static let backgroundSecondary = Color(hex: 0x2A2A2A)
static let surface = Color(hex: 0x242424)
static let text = Color.white
static let textSecondary = Color(hex: 0x8E8E93)
static let textTertiary = Color(hex: 0x666666)
static let border = Color(hex: 0x2E2E2E)
static let divider = Color(hex: 0x333333)
static let messageBubble = Color(hex: 0x2A2A2A)
static let messageBubbleOwn = Color(hex: 0x263341)
static let inputBackground = Color(hex: 0x2A2A2A)
}
// MARK: Seed Word Colors (12 unique, matching Android)
static let seedWordColors: [Color] = [
Color(hex: 0x5E9FFF),
Color(hex: 0xFF7EB3),
Color(hex: 0x7B68EE),
Color(hex: 0x50C878),
Color(hex: 0xFF6B6B),
Color(hex: 0x4ECDC4),
Color(hex: 0xFFB347),
Color(hex: 0xBA55D3),
Color(hex: 0x87CEEB),
Color(hex: 0xDDA0DD),
Color(hex: 0x98D8C8),
Color(hex: 0xF7DC6F),
]
// MARK: Avatar Palette
static let avatarColors: [(background: Color, text: Color)] = [
(Color(hex: 0xFF6B6B), .white),
(Color(hex: 0x4ECDC4), .white),
(Color(hex: 0x45B7D1), .white),
(Color(hex: 0xF7B731), .white),
(Color(hex: 0x5F27CD), .white),
(Color(hex: 0x00D2D3), .white),
(Color(hex: 0xFF9FF3), .white),
(Color(hex: 0x54A0FF), .white),
]
}
// MARK: - Color Hex Initializer
extension Color {
init(hex: UInt, alpha: Double = 1.0) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xFF) / 255.0,
green: Double((hex >> 8) & 0xFF) / 255.0,
blue: Double(hex & 0xFF) / 255.0,
opacity: alpha
)
}
}

View File

@@ -0,0 +1,39 @@
import SwiftUI
struct AuthNavigationBar: View {
let title: String?
let onBack: () -> Void
init(title: String? = nil, onBack: @escaping () -> Void) {
self.title = title
self.onBack = onBack
}
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")
if let title {
Text(title)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
}
Spacer()
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
}
}
#Preview {
AuthNavigationBar(title: "Set Password", onBack: {})
.background(RosettaColors.authBackground)
}

View File

@@ -0,0 +1,115 @@
import SwiftUI
// MARK: - Primary Button (Liquid Glass)
struct RosettaPrimaryButtonStyle: ButtonStyle {
var isEnabled: Bool = true
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))
}
.glassEffect(.regular, in: Capsule())
} else {
configuration.label
.background { glassBackground(isPressed: configuration.isPressed) }
.clipShape(Capsule())
}
}
.opacity(isEnabled ? 1.0 : 0.5)
.scaleEffect(configuration.isPressed ? 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))
.overlay {
Capsule()
.fill(
LinearGradient(
colors: [
Color.white.opacity(0.18),
Color.clear,
Color.black.opacity(0.08),
],
startPoint: .top,
endPoint: .bottom
)
)
}
}
}
// MARK: - Shimmer Overlay
struct ShimmerModifier: ViewModifier {
@State private var phase: CGFloat = -1.0
func body(content: Content) -> some View {
content
.overlay {
GeometryReader { geometry in
let width = geometry.size.width * 0.6
LinearGradient(
colors: [
Color.white.opacity(0),
Color.white.opacity(0.25),
Color.white.opacity(0),
],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: width)
.offset(x: phase * (geometry.size.width + width))
.clipShape(Capsule())
}
}
.onAppear {
withAnimation(
.linear(duration: 2.0)
.repeatForever(autoreverses: false)
) {
phase = 1.0
}
}
}
}
extension View {
func shimmer() -> some View {
modifier(ShimmerModifier())
}
}
// MARK: - Stagger Animation Helper
struct StaggeredAppearance: ViewModifier {
let index: Int
let baseDelay: Double
let stagger: Double
@State private var isVisible = false
func body(content: Content) -> some View {
content
.opacity(isVisible ? 1.0 : 0.0)
.offset(y: isVisible ? 0 : 12)
.onAppear {
let delay = baseDelay + Double(index) * stagger
withAnimation(.easeOut(duration: 0.4).delay(delay)) {
isVisible = true
}
}
}
}
extension View {
func staggeredAppearance(index: Int, baseDelay: Double = 0.2, stagger: Double = 0.05) -> some View {
modifier(StaggeredAppearance(index: index, baseDelay: baseDelay, stagger: stagger))
}
}

View File

@@ -0,0 +1,34 @@
import SwiftUI
struct GlassCard<Content: View>: View {
let cornerRadius: CGFloat
let fillOpacity: Double
let content: () -> Content
init(
cornerRadius: CGFloat = 12,
fillOpacity: Double = 0.06,
@ViewBuilder content: @escaping () -> Content
) {
self.cornerRadius = cornerRadius
self.fillOpacity = fillOpacity
self.content = content
}
var body: some View {
if #available(iOS 26, *) {
content()
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius))
} else {
content()
.background {
RoundedRectangle(cornerRadius: cornerRadius)
.fill(Color.white.opacity(fillOpacity))
.overlay {
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(Color.white.opacity(0.08), lineWidth: 0.5)
}
}
}
}
}

View File

@@ -0,0 +1,50 @@
import SwiftUI
import Lottie
struct LottieView: UIViewRepresentable {
let animationName: String
var loopMode: LottieLoopMode = .playOnce
var animationSpeed: CGFloat = 1.5
var isPlaying: Bool = true
func makeUIView(context: Context) -> LottieAnimationView {
let animationView = LottieAnimationView(name: animationName)
animationView.contentMode = .scaleAspectFit
animationView.loopMode = loopMode
animationView.animationSpeed = animationSpeed
animationView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
animationView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
if isPlaying {
animationView.play()
}
return animationView
}
func updateUIView(_ uiView: LottieAnimationView, context: Context) {
uiView.loopMode = loopMode
uiView.animationSpeed = animationSpeed
if isPlaying && !uiView.isAnimationPlaying {
uiView.play()
} else if !isPlaying {
uiView.stop()
}
}
}
// MARK: - Crossfading Lottie Container
struct CrossfadingLottieView: View {
let animationName: String
let animationID: Int
var body: some View {
LottieView(
animationName: animationName,
loopMode: .playOnce,
animationSpeed: 1.5
)
.id(animationID)
.transition(.opacity.animation(.easeInOut(duration: 0.4)))
}
}

View File

@@ -0,0 +1,28 @@
import SwiftUI
// MARK: - Rosetta Typography
enum RosettaFont {
static let headlineLarge = Font.system(size: 20, weight: .bold)
static let titleLarge = Font.system(size: 17, weight: .medium)
static let bodyLarge = Font.system(size: 16, weight: .regular)
static let bodyMedium = Font.system(size: 16, weight: .regular)
static let bodySmall = Font.system(size: 13, weight: .regular)
static let labelLarge = Font.system(size: 14, weight: .medium)
static let labelMedium = Font.system(size: 14, weight: .regular)
static let labelSmall = Font.system(size: 13, weight: .regular)
// MARK: Onboarding Specific
static let onboardingTitle = Font.system(size: 30, weight: .bold)
static let onboardingBody = Font.system(size: 17, weight: .regular)
static let onboardingBodyBold = Font.system(size: 17, weight: .semibold)
// MARK: Auth Specific
static let authTitle = Font.system(size: 24, weight: .bold)
static let authSubtitle = Font.system(size: 15, weight: .regular)
static let seedWord = Font.system(size: 17, weight: .medium, design: .monospaced)
static let seedWordNumber = Font.system(size: 14, weight: .regular)
}

View File

@@ -0,0 +1,250 @@
import SwiftUI
// MARK: - Auth Screen Enum
enum AuthScreen: Equatable {
case welcome
case seedPhrase
case confirmSeed
case importSeed
case setPassword
}
// MARK: - Auth Coordinator
struct AuthCoordinator: View {
let onAuthComplete: () -> Void
@State private var currentScreen: AuthScreen = .welcome
@State private var seedPhrase: [String] = []
@State private var isImportMode = false
@State private var navigationDirection: NavigationDirection = .forward
@State private var swipeOffset: CGFloat = 0
private var canSwipeBack: Bool {
currentScreen != .welcome
}
var body: some View {
GeometryReader { geometry in
let screenWidth = geometry.size.width
ZStack {
RosettaColors.authBackground
.ignoresSafeArea()
// Previous screen peeks from behind during swipe
if canSwipeBack {
previousScreenView
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background { RosettaColors.authBackground.ignoresSafeArea() }
.offset(x: -screenWidth * 0.3 + swipeOffset * 0.3)
.overlay {
Color.black
.opacity(swipeOffset > 0
? 0.5 * (1.0 - swipeOffset / screenWidth)
: 1.0)
.ignoresSafeArea()
}
.allowsHitTesting(false)
}
// Current screen slides right during swipe
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)
.id(currentScreen)
.offset(x: swipeOffset)
}
.overlay(alignment: .leading) {
if canSwipeBack {
Color.clear
.frame(width: 44)
.contentShape(Rectangle())
.gesture(swipeBackGesture(screenWidth: screenWidth))
}
}
.preferredColorScheme(.dark)
}
}
}
// MARK: - Screen Views
private extension AuthCoordinator {
@ViewBuilder
var currentScreenView: some View {
switch currentScreen {
case .welcome:
WelcomeView(
onGenerateSeed: { navigateTo(.seedPhrase) },
onImportSeed: {
isImportMode = true
navigateTo(.importSeed)
}
)
case .seedPhrase:
SeedPhraseView(
seedPhrase: $seedPhrase,
onContinue: { navigateTo(.confirmSeed) },
onBack: { navigateBack(to: .welcome) }
)
case .confirmSeed:
ConfirmSeedPhraseView(
seedPhrase: seedPhrase,
onConfirmed: {
isImportMode = false
navigateTo(.setPassword)
},
onBack: { navigateBack(to: .seedPhrase) }
)
case .importSeed:
ImportSeedPhraseView(
seedPhrase: $seedPhrase,
onContinue: {
isImportMode = true
navigateTo(.setPassword)
},
onBack: { navigateBack(to: .welcome) }
)
case .setPassword:
SetPasswordView(
seedPhrase: seedPhrase,
isImportMode: isImportMode,
onAccountCreated: onAuthComplete,
onBack: {
if isImportMode {
navigateBack(to: .importSeed)
} else {
navigateBack(to: .confirmSeed)
}
}
)
}
}
@ViewBuilder
var previousScreenView: some View {
switch currentScreen {
case .welcome:
EmptyView()
case .seedPhrase:
WelcomeView(onGenerateSeed: {}, onImportSeed: {})
case .confirmSeed:
SeedPhraseView(seedPhrase: .constant(seedPhrase), onContinue: {}, onBack: {})
case .importSeed:
WelcomeView(onGenerateSeed: {}, onImportSeed: {})
case .setPassword:
if isImportMode {
ImportSeedPhraseView(seedPhrase: .constant(seedPhrase), onContinue: {}, onBack: {})
} else {
ConfirmSeedPhraseView(seedPhrase: seedPhrase, onConfirmed: {}, onBack: {})
}
}
}
}
// 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: - Navigation
private extension AuthCoordinator {
func navigateTo(_ screen: AuthScreen) {
navigationDirection = .forward
withAnimation(.easeInOut(duration: 0.3)) {
currentScreen = screen
}
}
func navigateBack(to screen: AuthScreen) {
navigationDirection = .backward
withAnimation(.easeInOut(duration: 0.3)) {
currentScreen = screen
}
}
}
// MARK: - Swipe Back Gesture
private extension AuthCoordinator {
func swipeBackGesture(screenWidth: CGFloat) -> some Gesture {
DragGesture(minimumDistance: 10)
.onChanged { value in
swipeOffset = max(value.translation.width, 0)
}
.onEnded { value in
let shouldGoBack = value.translation.width > 100
|| value.predictedEndTranslation.width > 200
if shouldGoBack {
withAnimation(.easeOut(duration: 0.25)) {
swipeOffset = screenWidth
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
swipeOffset = 0
navigationDirection = .backward
currentScreen = backDestination
}
} else {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
swipeOffset = 0
}
}
}
}
var backDestination: AuthScreen {
switch currentScreen {
case .welcome: return .welcome
case .seedPhrase: return .welcome
case .confirmSeed: return .seedPhrase
case .importSeed: return .welcome
case .setPassword: return isImportMode ? .importSeed : .confirmSeed
}
}
}
// MARK: - Navigation Direction
private enum NavigationDirection {
case forward
case backward
}
#Preview {
AuthCoordinator(onAuthComplete: {})
}

View File

@@ -0,0 +1,289 @@
import SwiftUI
struct ConfirmSeedPhraseView: View {
let seedPhrase: [String]
let onConfirmed: () -> Void
let onBack: () -> Void
@State private var confirmationInputs: [String] = Array(repeating: "", count: 4)
@State private var showError = false
@State private var showPasteSuccess = false
@FocusState private var focusedInputIndex: Int?
private let confirmPositions = [1, 4, 8, 11]
private var allCorrect: Bool {
for (inputIndex, seedIndex) in confirmPositions.enumerated() {
let input = confirmationInputs[inputIndex].lowercased().trimmingCharacters(in: .whitespaces)
if input != seedPhrase[seedIndex].lowercased() { return false }
}
return true
}
var body: some View {
VStack(spacing: 0) {
AuthNavigationBar(onBack: onBack)
ScrollView(showsIndicators: false) {
VStack(spacing: 24) {
headerSection
pasteButton
pasteSuccessMessage
wordGrid
errorMessage
}
.padding(.horizontal, 24)
.padding(.top, 16)
.padding(.bottom, 100)
}
.scrollDismissesKeyboard(.interactively)
.onTapGesture { focusedInputIndex = nil }
confirmButton
.padding(.horizontal, 24)
.padding(.bottom, 16)
}
}
}
// MARK: - Header
private extension ConfirmSeedPhraseView {
var headerSection: some View {
VStack(spacing: 12) {
Text("Confirm Backup")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white)
Text("Enter words #2, #5, #9, #12 to confirm\nyou've backed up your phrase.")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.secondaryText)
.multilineTextAlignment(.center)
.lineSpacing(3)
}
.accessibilityElement(children: .combine)
}
}
// MARK: - Word Grid
private extension ConfirmSeedPhraseView {
var wordGrid: some View {
HStack(alignment: .top, spacing: 12) {
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)
}
}
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)
}
}
}
}
@ViewBuilder
func wordRow(seedIndex: Int, displayNumber: Int) -> some View {
if let inputIndex = confirmPositions.firstIndex(of: seedIndex) {
editableWordRow(number: displayNumber, inputIndex: inputIndex, expected: seedPhrase[seedIndex])
} else {
readOnlyWordRow(number: displayNumber, word: seedPhrase[seedIndex])
}
}
func readOnlyWordRow(number: Int, word: String) -> some View {
HStack(spacing: 8) {
Text("\(number).")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.numberGray)
.frame(width: 28, alignment: .trailing)
Text(word)
.font(.system(size: 17, weight: .semibold, design: .monospaced))
.foregroundStyle(RosettaColors.tertiaryText)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background {
RoundedRectangle(cornerRadius: 12)
.fill(RosettaColors.cardFill)
}
.accessibilityLabel("Word \(number): \(word)")
}
func editableWordRow(number: Int, inputIndex: Int, expected: String) -> some View {
let text = confirmationInputs[inputIndex].lowercased().trimmingCharacters(in: .whitespaces)
let isCorrect = !text.isEmpty && text == expected.lowercased()
let isWrong = !text.isEmpty && text != expected.lowercased()
return HStack(spacing: 8) {
Text("\(number).")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.numberGray)
.frame(width: 28, alignment: .trailing)
TextField("enter word", text: $confirmationInputs[inputIndex])
.font(.system(size: 17, weight: .semibold, design: .monospaced))
.foregroundStyle(.white)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($focusedInputIndex, equals: inputIndex)
.onChange(of: confirmationInputs[inputIndex]) { _, newValue in
confirmationInputs[inputIndex] = newValue.lowercased()
if showError { withAnimation { showError = false } }
}
Spacer()
validationIcon(isCorrect: isCorrect, isWrong: isWrong)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background {
inputBorder(isCorrect: isCorrect, isWrong: isWrong, isFocused: focusedInputIndex == inputIndex)
}
.accessibilityLabel("Enter word \(number)")
.accessibilityValue(confirmationInputs[inputIndex].isEmpty ? "Empty" : confirmationInputs[inputIndex])
}
@ViewBuilder
func validationIcon(isCorrect: Bool, isWrong: Bool) -> some View {
if isCorrect {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(RosettaColors.success)
.font(.system(size: 18))
.transition(.scale.combined(with: .opacity))
} else if isWrong {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(RosettaColors.error)
.font(.system(size: 18))
.transition(.scale.combined(with: .opacity))
}
}
func inputBorder(isCorrect: Bool, isWrong: Bool, isFocused: Bool) -> some View {
let borderColor: Color =
isCorrect ? RosettaColors.success :
isWrong ? RosettaColors.error :
isFocused ? RosettaColors.primaryBlue :
RosettaColors.subtleBorder
return RoundedRectangle(cornerRadius: 12)
.fill(RosettaColors.cardFill)
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(borderColor, lineWidth: isCorrect || isWrong || isFocused ? 2 : 1)
}
.animation(.easeInOut(duration: 0.2), value: borderColor)
}
}
// MARK: - Paste
private extension ConfirmSeedPhraseView {
var pasteButton: some View {
Button(action: pasteFromClipboard) {
HStack(spacing: 6) {
Image(systemName: "doc.on.clipboard")
.font(.system(size: 14))
Text("Paste from Clipboard")
.font(.system(size: 16, weight: .medium))
}
.foregroundStyle(RosettaColors.primaryBlue)
.frame(maxWidth: .infinity)
.frame(height: 48)
.background {
RoundedRectangle(cornerRadius: 12)
.stroke(RosettaColors.primaryBlue, lineWidth: 1)
}
}
.accessibilityHint("Pastes your saved seed phrase")
}
@ViewBuilder
var pasteSuccessMessage: some View {
if showPasteSuccess {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(RosettaColors.success)
Text("Words pasted successfully!")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(RosettaColors.success)
}
.padding(12)
.frame(maxWidth: .infinity)
.background {
RoundedRectangle(cornerRadius: 8)
.fill(RosettaColors.success.opacity(0.15))
}
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
}
func pasteFromClipboard() {
guard let text = UIPasteboard.general.string else { return }
let words = text
.components(separatedBy: CharacterSet.whitespacesAndNewlines.union(.init(charactersIn: ",")))
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
.filter { !$0.isEmpty }
guard words.count >= 12 else { return }
for (inputIndex, seedIndex) in confirmPositions.enumerated() {
confirmationInputs[inputIndex] = words[seedIndex]
}
withAnimation(.spring(response: 0.3)) { showPasteSuccess = true }
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation { showPasteSuccess = false }
}
}
}
// MARK: - Error & Confirm
private extension ConfirmSeedPhraseView {
@ViewBuilder
var errorMessage: some View {
if showError {
Text("Some words don't match. Please check and try again.")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.error)
.multilineTextAlignment(.center)
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
}
var confirmButton: some View {
Button {
if allCorrect {
onConfirmed()
} else {
withAnimation(.spring(response: 0.3)) { showError = true }
}
} label: {
Text("Confirm")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 50)
}
.buttonStyle(RosettaPrimaryButtonStyle(isEnabled: allCorrect))
.accessibilityHint(allCorrect ? "Confirms your seed phrase backup" : "Fill in all words correctly first")
}
}
#Preview {
ConfirmSeedPhraseView(
seedPhrase: SeedPhraseGenerator.generate(),
onConfirmed: {},
onBack: {}
)
.preferredColorScheme(.dark)
.background(RosettaColors.authBackground)
}

View File

@@ -0,0 +1,216 @@
import SwiftUI
struct ImportSeedPhraseView: View {
@Binding var seedPhrase: [String]
let onContinue: () -> Void
let onBack: () -> Void
@State private var importedWords: [String] = Array(repeating: "", count: 12)
@State private var errorMessage: String?
@FocusState private var focusedWordIndex: Int?
private var allFilled: Bool {
importedWords.allSatisfy { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
}
var body: some View {
VStack(spacing: 0) {
AuthNavigationBar(onBack: onBack)
ScrollView(showsIndicators: false) {
VStack(spacing: 24) {
headerSection
pasteButton
wordGrid
errorSection
}
.padding(.horizontal, 24)
.padding(.top, 16)
.padding(.bottom, 100)
}
.scrollDismissesKeyboard(.interactively)
.onTapGesture { focusedWordIndex = nil }
continueButton
.padding(.horizontal, 24)
.padding(.bottom, 16)
}
}
}
// MARK: - Header
private extension ImportSeedPhraseView {
var headerSection: some View {
VStack(spacing: 12) {
Text("Import Account")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white)
Text("Enter your 12-word recovery phrase\nto restore your account.")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.secondaryText)
.multilineTextAlignment(.center)
.lineSpacing(3)
}
.accessibilityElement(children: .combine)
}
}
// MARK: - Paste Button
private extension ImportSeedPhraseView {
var pasteButton: some View {
Button(action: pasteFromClipboard) {
HStack(spacing: 6) {
Image(systemName: "doc.on.clipboard")
.font(.system(size: 14))
Text("Paste all 12 words")
.font(.system(size: 15, weight: .medium))
}
.foregroundStyle(RosettaColors.primaryBlue)
}
.accessibilityHint("Pastes seed phrase from clipboard")
}
func pasteFromClipboard() {
guard let text = UIPasteboard.general.string, !text.isEmpty else {
showError("Clipboard is empty")
return
}
let parsed = text
.components(separatedBy: CharacterSet.whitespacesAndNewlines.union(.init(charactersIn: ",;")))
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
.filter { !$0.isEmpty }
guard !parsed.isEmpty else {
showError("No valid words found in clipboard")
return
}
guard parsed.count == 12 else {
showError("Clipboard contains \(parsed.count) words, need 12")
return
}
importedWords = parsed
withAnimation { errorMessage = nil }
}
}
// MARK: - Word Grid
private extension ImportSeedPhraseView {
var wordGrid: some View {
HStack(alignment: .top, spacing: 12) {
VStack(spacing: 10) {
ForEach(0..<6, id: \.self) { index in
inputRow(index: index)
.staggeredAppearance(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)
}
}
}
}
func inputRow(index: Int) -> some View {
let color = RosettaColors.seedWordColors[index % RosettaColors.seedWordColors.count]
let isFocused = focusedWordIndex == index
let hasContent = !importedWords[index].trimmingCharacters(in: .whitespaces).isEmpty
return HStack(spacing: 8) {
Text("\(index + 1).")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.numberGray)
.frame(width: 28, alignment: .trailing)
TextField("word", text: $importedWords[index])
.font(.system(size: 17, weight: .semibold, design: .monospaced))
.foregroundStyle(hasContent ? color : .white)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($focusedWordIndex, equals: index)
.submitLabel(index < 11 ? .next : .done)
.onSubmit {
focusedWordIndex = index < 11 ? index + 1 : nil
}
.onChange(of: importedWords[index]) { _, newValue in
importedWords[index] = newValue.lowercased()
if errorMessage != nil { withAnimation { errorMessage = nil } }
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background {
RoundedRectangle(cornerRadius: 12)
.fill(RosettaColors.cardFill)
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(
isFocused ? RosettaColors.primaryBlue : Color.clear,
lineWidth: 2
)
}
.animation(.easeInOut(duration: 0.2), value: isFocused)
}
.accessibilityLabel("Word \(index + 1)")
.accessibilityValue(importedWords[index].isEmpty ? "Empty" : importedWords[index])
}
}
// MARK: - Error & Continue
private extension ImportSeedPhraseView {
@ViewBuilder
var errorSection: some View {
if let message = errorMessage {
Text(message)
.font(.system(size: 14))
.foregroundStyle(RosettaColors.error)
.multilineTextAlignment(.center)
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
}
func showError(_ message: String) {
withAnimation(.spring(response: 0.3)) { errorMessage = message }
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
withAnimation { errorMessage = nil }
}
}
var continueButton: some View {
Button {
guard allFilled else {
showError("Please fill in all words")
return
}
// TODO: Validate seed phrase with CryptoManager.validateSeedPhrase()
seedPhrase = importedWords
onContinue()
} label: {
Text("Continue")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 50)
}
.buttonStyle(RosettaPrimaryButtonStyle(isEnabled: allFilled))
.accessibilityHint(allFilled ? "Proceeds to password setup" : "Fill in all 12 words first")
}
}
#Preview {
ImportSeedPhraseView(seedPhrase: .constant([]), onContinue: {}, onBack: {})
.preferredColorScheme(.dark)
.background(RosettaColors.authBackground)
}

View File

@@ -0,0 +1,134 @@
import SwiftUI
// MARK: - Password Strength
enum PasswordStrength: Int {
case weak = 0
case medium = 1
case strong = 2
static let minimumLength = 6
static let strongLength = 10
init(password: String) {
if password.count < Self.minimumLength {
self = .weak
} else if password.count < Self.strongLength {
self = .medium
} else {
self = .strong
}
}
var label: String {
switch self {
case .weak: return "Weak"
case .medium: return "Medium"
case .strong: return "Strong"
}
}
var color: Color {
switch self {
case .weak: return RosettaColors.error
case .medium: return RosettaColors.warning
case .strong: return RosettaColors.success
}
}
}
// MARK: - Strength Indicator View
struct PasswordStrengthIndicator: View {
let password: String
private var strength: PasswordStrength {
PasswordStrength(password: password)
}
var body: some View {
if !password.isEmpty {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 4) {
ForEach(0..<3, id: \.self) { index in
RoundedRectangle(cornerRadius: 2)
.fill(index <= strength.rawValue ? strength.color : Color.white.opacity(0.1))
.frame(height: 4)
.animation(.easeInOut(duration: 0.25), value: strength)
}
}
Text("Password strength: \(strength.label)")
.font(.system(size: 12))
.foregroundStyle(strength.color)
}
.transition(.opacity)
.accessibilityLabel("Password strength: \(strength.label)")
}
}
}
// MARK: - Match Indicator View
struct PasswordMatchIndicator: View {
let password: String
let confirmPassword: String
private var matches: Bool {
password == confirmPassword
}
var body: some View {
if !confirmPassword.isEmpty {
HStack(spacing: 6) {
Image(systemName: matches ? "checkmark.circle.fill" : "xmark.circle.fill")
.font(.system(size: 14))
Text(matches ? "Passwords match" : "Passwords don't match")
.font(.system(size: 12))
}
.foregroundStyle(matches ? RosettaColors.success : RosettaColors.error)
.transition(.opacity)
.accessibilityLabel(matches ? "Passwords match" : "Passwords do not match")
}
}
}
// MARK: - Weak Password Warning
struct WeakPasswordWarning: View {
let password: String
var body: some View {
if !password.isEmpty && PasswordStrength(password: password) == .weak {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(RosettaColors.warning)
.font(.system(size: 16))
Text("Your password is too weak. Consider using at least 6 characters for better security.")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.secondaryText)
.lineSpacing(2)
}
.padding(14)
.background {
RoundedRectangle(cornerRadius: 10)
.fill(RosettaColors.warning.opacity(0.1))
}
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
}
}
#Preview {
VStack(spacing: 20) {
PasswordStrengthIndicator(password: "abc")
PasswordStrengthIndicator(password: "abcdefgh")
PasswordStrengthIndicator(password: "abcdefghijk")
PasswordMatchIndicator(password: "test", confirmPassword: "test")
PasswordMatchIndicator(password: "test", confirmPassword: "wrong")
WeakPasswordWarning(password: "ab")
}
.padding()
.background(RosettaColors.authBackground)
}

View File

@@ -0,0 +1,206 @@
import SwiftUI
struct SeedPhraseView: View {
@Binding var seedPhrase: [String]
let onContinue: () -> Void
let onBack: () -> Void
@State private var showCopiedToast = false
@State private var isContentVisible = false
var body: some View {
VStack(spacing: 0) {
AuthNavigationBar(onBack: onBack)
ScrollView(showsIndicators: false) {
VStack(spacing: 24) {
headerSection
wordGrid
copyButton
}
.padding(.horizontal, 24)
.padding(.top, 16)
.padding(.bottom, 100)
}
continueButton
.padding(.horizontal, 24)
.padding(.bottom, 16)
}
.onAppear(perform: generateSeedPhraseIfNeeded)
}
}
// MARK: - Header
private extension SeedPhraseView {
var headerSection: some View {
VStack(spacing: 12) {
Text("Your Recovery Phrase")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white)
.opacity(isContentVisible ? 1.0 : 0.0)
.animation(.easeOut(duration: 0.3), value: isContentVisible)
Text("Write down these 12 words in order.\nYou'll need them to restore your account.")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.secondaryText)
.multilineTextAlignment(.center)
.lineSpacing(3)
.opacity(isContentVisible ? 1.0 : 0.0)
.animation(.easeOut(duration: 0.4).delay(0.1), value: isContentVisible)
}
.accessibilityElement(children: .combine)
}
}
// MARK: - Word Grid
private extension SeedPhraseView {
var wordGrid: some View {
let leftColumn = Array(seedPhrase.prefix(6))
let rightColumn = Array(seedPhrase.dropFirst(6))
return HStack(alignment: .top, spacing: 12) {
wordColumn(words: leftColumn, startIndex: 1)
wordColumn(words: rightColumn, startIndex: 7)
}
.opacity(isContentVisible ? 1.0 : 0.0)
.animation(.easeOut(duration: 0.5).delay(0.2), value: isContentVisible)
}
func wordColumn(words: [String], startIndex: Int) -> some View {
VStack(spacing: 10) {
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)
}
}
}
func wordCard(number: Int, word: String, colorIndex: Int) -> some View {
let color = RosettaColors.seedWordColors[colorIndex % RosettaColors.seedWordColors.count]
return HStack(spacing: 8) {
Text("\(number).")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.numberGray)
.frame(width: 28, alignment: .trailing)
Text(word)
.font(.system(size: 17, weight: .semibold, design: .monospaced))
.foregroundStyle(color)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.modifier(SeedCardStyle(color: color))
.accessibilityLabel("Word \(number): \(word)")
}
}
// MARK: - Seed Card Glass Style
private struct SeedCardStyle: ViewModifier {
let color: Color
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 12))
} else {
content
.background {
RoundedRectangle(cornerRadius: 12)
.fill(color.opacity(0.12))
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(color.opacity(0.18), lineWidth: 0.5)
}
}
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
// MARK: - Copy Button
private extension SeedPhraseView {
var copyButton: some View {
Button {
UIPasteboard.general.string = seedPhrase.joined(separator: " ")
withAnimation { showCopiedToast = true }
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation { showCopiedToast = false }
}
} label: {
HStack(spacing: 6) {
Image(systemName: showCopiedToast ? "checkmark.circle.fill" : "doc.on.doc")
.font(.system(size: 14))
Text(showCopiedToast ? "Copied!" : "Copy to clipboard")
.font(.system(size: 15, weight: .medium))
}
.foregroundStyle(showCopiedToast ? RosettaColors.success : RosettaColors.primaryBlue)
.contentTransition(.symbolEffect(.replace))
}
.accessibilityLabel(showCopiedToast ? "Copied to clipboard" : "Copy seed phrase to clipboard")
}
}
// MARK: - Continue Button
private extension SeedPhraseView {
var continueButton: some View {
Button(action: onContinue) {
Text("Continue")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 50)
}
.buttonStyle(RosettaPrimaryButtonStyle())
.accessibilityHint("Proceed to confirm your seed phrase")
}
}
// MARK: - Seed Generation
private extension SeedPhraseView {
func generateSeedPhraseIfNeeded() {
guard seedPhrase.isEmpty else {
isContentVisible = true
return
}
// TODO: Replace with real BIP39 generation from CryptoManager
seedPhrase = SeedPhraseGenerator.generate()
withAnimation { isContentVisible = true }
}
}
// MARK: - Placeholder BIP39 Generator
enum SeedPhraseGenerator {
private static let wordList = [
"abandon", "ability", "able", "about", "above", "absent",
"absorb", "abstract", "absurd", "abuse", "access", "accident",
"account", "accuse", "achieve", "acid", "acoustic", "acquire",
"across", "act", "action", "actor", "actress", "actual",
"adapt", "add", "addict", "address", "adjust", "admit",
"adult", "advance", "advice", "aerobic", "affair", "afford",
"afraid", "again", "age", "agent", "agree", "ahead",
"aim", "air", "airport", "aisle", "alarm", "album",
]
static func generate() -> [String] {
(0..<12).map { _ in wordList.randomElement() ?? "abandon" }
}
}
#Preview {
SeedPhraseView(seedPhrase: .constant([]), onContinue: {}, onBack: {})
.preferredColorScheme(.dark)
.background(RosettaColors.authBackground)
}

View File

@@ -0,0 +1,245 @@
import SwiftUI
struct SetPasswordView: View {
let seedPhrase: [String]
let isImportMode: Bool
let onAccountCreated: () -> Void
let onBack: () -> Void
@State private var password = ""
@State private var confirmPassword = ""
@State private var showPassword = false
@State private var showConfirmPassword = false
@State private var isCreating = false
@FocusState private var focusedField: Field?
fileprivate enum Field {
case password, confirm
}
private var passwordsMatch: Bool {
!password.isEmpty && password == confirmPassword
}
private var canCreate: Bool {
passwordsMatch && !isCreating
}
var body: some View {
VStack(spacing: 0) {
AuthNavigationBar(title: "Set Password", onBack: onBack)
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
lockIcon
headerSection
VStack(spacing: 8) {
passwordField
PasswordStrengthIndicator(password: password)
}
VStack(spacing: 8) {
confirmField
PasswordMatchIndicator(password: password, confirmPassword: confirmPassword)
}
WeakPasswordWarning(password: password)
infoCard
}
.padding(.horizontal, 24)
.padding(.top, 8)
.padding(.bottom, 100)
}
.scrollDismissesKeyboard(.interactively)
.onTapGesture { focusedField = nil }
createButton
.padding(.horizontal, 24)
.padding(.bottom, 16)
}
.geometryGroup()
}
}
// MARK: - Lock Icon
private extension SetPasswordView {
var lockIcon: some View {
GlassCard(cornerRadius: 20, fillOpacity: 0.1) {
Image(systemName: "lock.shield.fill")
.font(.system(size: 32))
.foregroundStyle(RosettaColors.primaryBlue)
.frame(width: 72, height: 72)
}
.accessibilityHidden(true)
}
}
// MARK: - Header
private extension SetPasswordView {
var headerSection: some View {
VStack(spacing: 8) {
Text(isImportMode ? "Recover Account" : "Protect Your Account")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(.white)
Text(isImportMode
? "Set a password to protect your recovered account.\nYou'll need it to unlock Rosetta."
: "This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.secondaryText)
.multilineTextAlignment(.center)
.lineSpacing(2)
}
.accessibilityElement(children: .combine)
}
}
// MARK: - Password Fields
private extension SetPasswordView {
var passwordField: some View {
secureInputField(
placeholder: "Password",
text: $password,
isSecure: !showPassword,
toggleAction: { showPassword.toggle() },
isRevealed: showPassword,
field: .password
)
.accessibilityLabel("Password input")
}
var confirmField: some View {
secureInputField(
placeholder: "Confirm Password",
text: $confirmPassword,
isSecure: !showConfirmPassword,
toggleAction: { showConfirmPassword.toggle() },
isRevealed: showConfirmPassword,
field: .confirm
)
.accessibilityLabel("Confirm password input")
}
func secureInputField(
placeholder: String,
text: Binding<String>,
isSecure: Bool,
toggleAction: @escaping () -> Void,
isRevealed: Bool,
field: Field
) -> some View {
HStack(spacing: 12) {
Group {
if isSecure {
SecureField(placeholder, text: text)
} else {
TextField(placeholder, text: text)
}
}
.font(.system(size: 16))
.foregroundStyle(.white)
.focused($focusedField, equals: field)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button(action: toggleAction) {
Image(systemName: isRevealed ? "eye.slash.fill" : "eye.fill")
.font(.system(size: 16))
.foregroundStyle(RosettaColors.secondaryText)
}
.accessibilityLabel(isRevealed ? "Hide password" : "Show password")
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background {
let isFocused = focusedField == field
RoundedRectangle(cornerRadius: 12)
.fill(RosettaColors.cardFill)
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(
isFocused ? RosettaColors.primaryBlue : RosettaColors.subtleBorder,
lineWidth: isFocused ? 2 : 1
)
}
.animation(.easeInOut(duration: 0.2), value: isFocused)
}
}
}
// MARK: - Info Card
private extension SetPasswordView {
var infoCard: some View {
GlassCard(cornerRadius: 12, fillOpacity: 0.08) {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "info.circle.fill")
.foregroundStyle(RosettaColors.primaryBlue)
.font(.system(size: 16))
Text("Your password is never stored or sent anywhere. It's only used to encrypt your keys locally.")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.secondaryText)
.lineSpacing(2)
}
.padding(16)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("Your password is only used for local encryption and is never sent anywhere.")
}
}
// MARK: - Create Button
private extension SetPasswordView {
var createButton: some View {
Button(action: createAccount) {
Group {
if isCreating {
ProgressView()
.tint(.white)
} else {
Text(isImportMode ? "Recover Account" : "Create Account")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
}
}
.frame(maxWidth: .infinity)
.frame(height: 56)
}
.buttonStyle(RosettaPrimaryButtonStyle(isEnabled: canCreate))
.accessibilityHint(canCreate ? "Creates your encrypted account" : "Enter matching passwords first")
}
func createAccount() {
guard canCreate else { return }
isCreating = true
// TODO: Implement real account creation:
// 1. CryptoManager.generateKeyPairFromSeed(seedPhrase)
// 2. CryptoManager.encryptWithPassword(privateKey, password)
// 3. CryptoManager.encryptWithPassword(seedPhrase.joined(separator: " "), password)
// 4. Save EncryptedAccount to persistence
// 5. Authenticate with server via Protocol handshake
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
isCreating = false
onAccountCreated()
}
}
}
#Preview {
SetPasswordView(
seedPhrase: SeedPhraseGenerator.generate(),
isImportMode: false,
onAccountCreated: {},
onBack: {}
)
.preferredColorScheme(.dark)
.background(RosettaColors.authBackground)
}

View File

@@ -0,0 +1,152 @@
import SwiftUI
import Lottie
struct WelcomeView: View {
let onGenerateSeed: () -> Void
let onImportSeed: () -> Void
@State private var isVisible = false
var body: some View {
VStack(spacing: 0) {
Spacer()
lockAnimation
.padding(.bottom, 32)
titleSection
.padding(.bottom, 16)
subtitleSection
.padding(.bottom, 24)
featureBadges
.padding(.bottom, 32)
Spacer()
Spacer().frame(height: 16)
buttonsSection
.padding(.horizontal, 24)
.padding(.bottom, 16)
}
.onAppear {
withAnimation(.easeOut(duration: 0.5)) { isVisible = true }
}
}
}
// MARK: - Lock Animation
private extension WelcomeView {
var lockAnimation: some View {
LottieView(
animationName: "lock",
loopMode: .loop,
animationSpeed: 1.0
)
.frame(width: 120, height: 120)
.opacity(isVisible ? 1.0 : 0.0)
.scaleEffect(isVisible ? 1.0 : 0.5)
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1), value: isVisible)
.accessibilityHidden(true)
}
}
// MARK: - Text Sections
private extension WelcomeView {
var titleSection: some View {
Text("Your Keys,\nYour Messages")
.font(.system(size: 32, weight: .bold))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.opacity(isVisible ? 1.0 : 0.0)
.offset(y: isVisible ? 0 : 16)
.animation(.easeOut(duration: 0.4).delay(0.2), value: isVisible)
}
var subtitleSection: some View {
Text("Secure messaging with\ncryptographic keys")
.font(.system(size: 16))
.foregroundStyle(RosettaColors.secondaryText)
.multilineTextAlignment(.center)
.opacity(isVisible ? 1.0 : 0.0)
.offset(y: isVisible ? 0 : 12)
.animation(.easeOut(duration: 0.4).delay(0.3), value: isVisible)
}
}
// MARK: - Feature Badges
private extension WelcomeView {
var featureBadges: some View {
HStack(spacing: 24) {
featureBadge(icon: "lock.shield.fill", label: "Encrypted", index: 0)
featureBadge(icon: "person.crop.circle.badge.minus", label: "No Phone", index: 1)
featureBadge(icon: "key.fill", label: "Your Keys", index: 2)
}
.opacity(isVisible ? 1.0 : 0.0)
.animation(.easeOut(duration: 0.5).delay(0.4), value: isVisible)
}
func featureBadge(icon: String, label: String, index: Int) -> some View {
VStack(spacing: 8) {
GlassCard(cornerRadius: 24, fillOpacity: 0.12) {
Image(systemName: icon)
.font(.system(size: 22))
.foregroundStyle(RosettaColors.primaryBlue)
.frame(width: 48, height: 48)
}
Text(label)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(RosettaColors.secondaryText)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(label)
}
}
// MARK: - Buttons
private extension WelcomeView {
var buttonsSection: some View {
VStack(spacing: 12) {
Button(action: onGenerateSeed) {
HStack(spacing: 10) {
Image(systemName: "sparkles")
.font(.system(size: 18))
Text("Generate New Seed Phrase")
.font(.system(size: 16, weight: .semibold))
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 56)
}
.buttonStyle(RosettaPrimaryButtonStyle())
.accessibilityHint("Creates a new encrypted account")
Button(action: onImportSeed) {
HStack(spacing: 8) {
Image(systemName: "square.and.arrow.down")
.font(.system(size: 16))
Text("I Already Have a Seed Phrase")
.font(.system(size: 15, weight: .medium))
}
.foregroundStyle(RosettaColors.primaryBlue)
.frame(maxWidth: .infinity)
.frame(height: 44)
}
.accessibilityHint("Restores an existing account")
}
.opacity(isVisible ? 1.0 : 0.0)
.animation(.easeOut(duration: 0.4).delay(0.6), value: isVisible)
}
}
#Preview {
WelcomeView(onGenerateSeed: {}, onImportSeed: {})
.preferredColorScheme(.dark)
.background(RosettaColors.authBackground)
}

View File

@@ -0,0 +1,49 @@
import Foundation
struct OnboardingPage: Identifiable {
let id: Int
let animationName: String
let title: String
let description: String
let highlightedWords: [String]
}
enum OnboardingPages {
static let all: [OnboardingPage] = [
OnboardingPage(
id: 0,
animationName: "letter",
title: "Rosetta",
description: "A local-based messaging app.\nYour data stays on your device.",
highlightedWords: ["local-based", "your device"]
),
OnboardingPage(
id: 1,
animationName: "Idea",
title: "Fast",
description: "Rosetta delivers messages\nfaster than any other application.",
highlightedWords: ["faster"]
),
OnboardingPage(
id: 2,
animationName: "Money",
title: "Free",
description: "Rosetta is free forever. No ads.\nNo subscription fees. Ever.",
highlightedWords: ["free forever", "No ads", "Ever"]
),
OnboardingPage(
id: 3,
animationName: "lock",
title: "Secure",
description: "Rosetta keeps your messages safe\nwith local storage and encryption.",
highlightedWords: ["safe", "local storage", "encryption"]
),
OnboardingPage(
id: 4,
animationName: "Book",
title: "Private",
description: "No servers. No tracking.\nEverything stays on your device.",
highlightedWords: ["No servers", "No tracking", "your device"]
),
]
}

View File

@@ -0,0 +1,221 @@
import SwiftUI
import Lottie
struct OnboardingView: View {
let onStartMessaging: () -> Void
@State private var currentPage = 0
@State private var dragOffset: CGFloat = 0
@State private var activeIndex: CGFloat = 0
private let pages = OnboardingPages.all
private let springResponse: Double = 0.4
private let springDamping: Double = 0.85
var body: some View {
GeometryReader { geometry in
let screenWidth = geometry.size.width
ZStack {
RosettaColors.Dark.background.ignoresSafeArea()
VStack(spacing: 0) {
Spacer()
.frame(height: geometry.size.height * 0.1)
lottieSection(screenWidth: screenWidth)
.frame(height: geometry.size.height * 0.22)
Spacer().frame(height: 32)
textSection(screenWidth: screenWidth)
.frame(height: 120)
pageIndicator(screenWidth: screenWidth)
.padding(.top, 24)
Spacer()
startButton()
.padding(.horizontal, 20)
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36)
}
}
.gesture(swipeGesture(screenWidth: screenWidth))
}
.preferredColorScheme(.dark)
.statusBarHidden(false)
}
}
// MARK: - Lottie Section
private extension OnboardingView {
func lottieSection(screenWidth: CGFloat) -> some View {
ZStack {
ForEach(pages) { page in
LottieView(
animationName: page.animationName,
loopMode: .playOnce,
animationSpeed: 1.5,
isPlaying: currentPage == page.id
)
.frame(width: screenWidth * 0.42, height: screenWidth * 0.42)
.offset(x: textOffset(for: page.id, screenWidth: screenWidth))
}
}
.frame(width: screenWidth, height: screenWidth * 0.42)
.clipped()
.accessibilityLabel("Illustration for \(pages[currentPage].title)")
}
}
// MARK: - Text Section
private extension OnboardingView {
func textSection(screenWidth: CGFloat) -> some View {
ZStack {
ForEach(pages) { page in
VStack(spacing: 14) {
Text(page.title)
.font(.system(size: 32, weight: .bold))
.foregroundStyle(.white)
highlightedDescription(page: page)
}
.frame(width: screenWidth)
.offset(x: textOffset(for: page.id, screenWidth: screenWidth))
.opacity(textOpacity(for: page.id, screenWidth: screenWidth))
}
}
.clipped()
.accessibilityElement(children: .combine)
.accessibilityLabel("\(pages[currentPage].title). \(pages[currentPage].description)")
}
func highlightedDescription(page: OnboardingPage) -> some View {
let lines = page.description.components(separatedBy: "\n")
return VStack(spacing: 4) {
ForEach(lines, id: \.self) { line in
buildHighlightedLine(line, highlights: page.highlightedWords)
}
}
.multilineTextAlignment(.center)
}
func buildHighlightedLine(_ line: String, highlights: [String]) -> some View {
var attributed = AttributedString(line)
for word in highlights {
if let range = attributed.range(of: word, options: .caseInsensitive) {
attributed[range].foregroundColor = RosettaColors.primaryBlue
attributed[range].font = .system(size: 17, weight: .semibold)
}
}
return Text(attributed)
.font(.system(size: 17))
.foregroundStyle(RosettaColors.secondaryText)
}
func textOffset(for index: Int, screenWidth: CGFloat) -> CGFloat {
CGFloat(index - currentPage) * screenWidth + dragOffset
}
func textOpacity(for index: Int, screenWidth: CGFloat) -> Double {
let offset = abs(textOffset(for: index, screenWidth: screenWidth))
return max(1.0 - (offset / screenWidth) * 1.5, 0.0)
}
}
// MARK: - Page Indicator
private extension OnboardingView {
func pageIndicator(screenWidth: CGFloat) -> some View {
let dotSize: CGFloat = 7
let spacing: CGFloat = 15
let count = pages.count
let center = CGFloat(count - 1) / 2.0
let frac = activeIndex - floor(activeIndex)
let distFromWhole = min(frac, 1.0 - frac)
let stretchFactor = distFromWhole * 2.0
let capsuleWidth = dotSize + stretchFactor * spacing * 0.85
let capsuleHeight = dotSize * (1.0 - stretchFactor * 0.12)
let capsuleX = (activeIndex - center) * spacing
return ZStack {
ForEach(0..<count, id: \.self) { i in
let dist = abs(CGFloat(i) - activeIndex)
Circle()
.fill(Color.white.opacity(dist < 0.6 ? 0.0 : 0.3))
.frame(width: dotSize, height: dotSize)
.offset(x: (CGFloat(i) - center) * spacing)
}
Capsule()
.fill(RosettaColors.primaryBlue)
.frame(width: capsuleWidth, height: capsuleHeight)
.offset(x: capsuleX)
}
.frame(height: dotSize + 4)
.accessibilityLabel("Page \(currentPage + 1) of \(pages.count)")
}
}
// MARK: - Start Button
private extension OnboardingView {
func startButton() -> some View {
Button(action: onStartMessaging) {
Text("Start Messaging")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 54)
}
.buttonStyle(RosettaPrimaryButtonStyle())
.shimmer()
.accessibilityHint("Opens account setup")
}
}
// MARK: - Swipe Gesture
private extension OnboardingView {
func swipeGesture(screenWidth: CGFloat) -> some Gesture {
DragGesture(minimumDistance: 20)
.onChanged { value in
let translation = value.translation.width
let isAtStart = currentPage == 0 && translation > 0
let isAtEnd = currentPage == pages.count - 1 && translation < 0
dragOffset = (isAtStart || isAtEnd) ? translation * 0.25 : translation
let progress = -dragOffset / screenWidth
activeIndex = min(max(CGFloat(currentPage) + progress, 0), CGFloat(pages.count - 1))
}
.onEnded { value in
let threshold = screenWidth * 0.25
let velocity = value.predictedEndTranslation.width - value.translation.width
var newPage = currentPage
if value.translation.width < -threshold || velocity < -150 {
newPage = min(currentPage + 1, pages.count - 1)
} else if value.translation.width > threshold || velocity > 150 {
newPage = max(currentPage - 1, 0)
}
withAnimation(.spring(response: springResponse, dampingFraction: springDamping)) {
currentPage = newPage
dragOffset = 0
activeIndex = CGFloat(newPage)
}
}
}
}
// MARK: - Preview
#Preview {
OnboardingView(onStartMessaging: {})
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,37 @@ import SwiftUI
@main @main
struct RosettaApp: App { struct RosettaApp: App {
@State private var hasCompletedOnboarding = false
@AppStorage("isLoggedIn") private var isLoggedIn = false
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() rootView
.preferredColorScheme(.dark)
}
}
@ViewBuilder
private var rootView: some View {
if !hasCompletedOnboarding {
OnboardingView {
withAnimation(.easeInOut(duration: 0.4)) {
hasCompletedOnboarding = true
}
}
} else if !isLoggedIn {
AuthCoordinator {
withAnimation(.easeInOut(duration: 0.4)) {
isLoggedIn = true
}
}
} else {
// TODO: Replace with main ChatListView
Text("Welcome to Rosetta")
.font(RosettaFont.headlineLarge)
.foregroundStyle(.white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(RosettaColors.Dark.background)
} }
} }
} }