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;
|
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 */;
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
<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>
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
|
|||||||
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
|
@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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user