Add onboarding, auth flow, design system and project structure
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal 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
|
||||
*~
|
||||
@@ -6,6 +6,10 @@
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
853F29992F4B63D20092AD05 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29982F4B63D20092AD05 /* Lottie */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
853F29622F4B50410092AD05 /* Rosetta.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Rosetta.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
@@ -23,6 +27,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
853F29992F4B63D20092AD05 /* Lottie in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -65,6 +70,7 @@
|
||||
);
|
||||
name = Rosetta;
|
||||
packageProductDependencies = (
|
||||
853F29982F4B63D20092AD05 /* Lottie */,
|
||||
);
|
||||
productName = Rosetta;
|
||||
productReference = 853F29622F4B50410092AD05 /* Rosetta.app */;
|
||||
@@ -94,6 +100,9 @@
|
||||
);
|
||||
mainGroup = 853F29592F4B50410092AD05;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
853F29972F4B63D20092AD05 /* XCRemoteSwiftPackageReference "lottie-ios" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 853F29632F4B50410092AD05 /* Products */;
|
||||
projectDirPath = "";
|
||||
@@ -251,6 +260,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = U6DMAKWNV3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -282,6 +292,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = U6DMAKWNV3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -328,6 +339,25 @@
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
|
||||
78
Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme
Normal file
78
Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme
Normal 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>
|
||||
@@ -10,5 +10,13 @@
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<dict>
|
||||
<key>853F29612F4B50410092AD05</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -5,20 +5,14 @@
|
||||
// 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
|
||||
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
Image(systemName: "globe")
|
||||
.imageScale(.large)
|
||||
.foregroundStyle(.tint)
|
||||
Text("Hello, world!")
|
||||
}
|
||||
.padding()
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
||||
|
||||
111
Rosetta/DesignSystem/Colors.swift
Normal file
111
Rosetta/DesignSystem/Colors.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
39
Rosetta/DesignSystem/Components/AuthNavigationBar.swift
Normal file
39
Rosetta/DesignSystem/Components/AuthNavigationBar.swift
Normal 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)
|
||||
}
|
||||
115
Rosetta/DesignSystem/Components/ButtonStyles.swift
Normal file
115
Rosetta/DesignSystem/Components/ButtonStyles.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
34
Rosetta/DesignSystem/Components/GlassCard.swift
Normal file
34
Rosetta/DesignSystem/Components/GlassCard.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
Rosetta/DesignSystem/Components/LottieView.swift
Normal file
50
Rosetta/DesignSystem/Components/LottieView.swift
Normal 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)))
|
||||
}
|
||||
}
|
||||
28
Rosetta/DesignSystem/Typography.swift
Normal file
28
Rosetta/DesignSystem/Typography.swift
Normal 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)
|
||||
}
|
||||
250
Rosetta/Features/Auth/AuthCoordinator.swift
Normal file
250
Rosetta/Features/Auth/AuthCoordinator.swift
Normal 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: {})
|
||||
}
|
||||
289
Rosetta/Features/Auth/ConfirmSeedPhraseView.swift
Normal file
289
Rosetta/Features/Auth/ConfirmSeedPhraseView.swift
Normal 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)
|
||||
}
|
||||
216
Rosetta/Features/Auth/ImportSeedPhraseView.swift
Normal file
216
Rosetta/Features/Auth/ImportSeedPhraseView.swift
Normal 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)
|
||||
}
|
||||
134
Rosetta/Features/Auth/PasswordStrengthView.swift
Normal file
134
Rosetta/Features/Auth/PasswordStrengthView.swift
Normal 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)
|
||||
}
|
||||
206
Rosetta/Features/Auth/SeedPhraseView.swift
Normal file
206
Rosetta/Features/Auth/SeedPhraseView.swift
Normal 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)
|
||||
}
|
||||
245
Rosetta/Features/Auth/SetPasswordView.swift
Normal file
245
Rosetta/Features/Auth/SetPasswordView.swift
Normal 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)
|
||||
}
|
||||
152
Rosetta/Features/Auth/WelcomeView.swift
Normal file
152
Rosetta/Features/Auth/WelcomeView.swift
Normal 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)
|
||||
}
|
||||
49
Rosetta/Features/Onboarding/OnboardingData.swift
Normal file
49
Rosetta/Features/Onboarding/OnboardingData.swift
Normal 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"]
|
||||
),
|
||||
]
|
||||
}
|
||||
221
Rosetta/Features/Onboarding/OnboardingView.swift
Normal file
221
Rosetta/Features/Onboarding/OnboardingView.swift
Normal 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: {})
|
||||
}
|
||||
1
Rosetta/Resources/Lottie/Book.json
Normal file
1
Rosetta/Resources/Lottie/Book.json
Normal file
File diff suppressed because one or more lines are too long
1
Rosetta/Resources/Lottie/Idea.json
Normal file
1
Rosetta/Resources/Lottie/Idea.json
Normal file
File diff suppressed because one or more lines are too long
1
Rosetta/Resources/Lottie/Money.json
Normal file
1
Rosetta/Resources/Lottie/Money.json
Normal file
File diff suppressed because one or more lines are too long
49656
Rosetta/Resources/Lottie/letter.json
Normal file
49656
Rosetta/Resources/Lottie/letter.json
Normal file
File diff suppressed because it is too large
Load Diff
15059
Rosetta/Resources/Lottie/lock.json
Normal file
15059
Rosetta/Resources/Lottie/lock.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,37 @@ import SwiftUI
|
||||
|
||||
@main
|
||||
struct RosettaApp: App {
|
||||
@State private var hasCompletedOnboarding = false
|
||||
@AppStorage("isLoggedIn") private var isLoggedIn = false
|
||||
|
||||
var body: some Scene {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user