mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 16:01:28 +00:00
1734 lines
68 KiB
Swift
1734 lines
68 KiB
Swift
import Combine
|
|
import SwiftUI
|
|
|
|
/// The signed-out experience: the website's airy-sky backdrop (painterly cloud
|
|
/// images, breathing gradient, sun glow) with a full-bleed bottom sheet that
|
|
/// docks flush to the screen edge and rides the keyboard. Steps mirror the web
|
|
/// (email -> password -> emailed 6-digit code -> optional TOTP) plus native
|
|
/// Sign in with Apple / Google. Registration confirm returns no tokens, so it
|
|
/// chains back into the login flow.
|
|
struct AuthFlowView: View {
|
|
private enum Step: Equatable {
|
|
case email
|
|
case password
|
|
case verify(session: String)
|
|
case twoFA(pendingToken: String)
|
|
case resetSent
|
|
}
|
|
|
|
private enum Mode {
|
|
case signIn, signUp
|
|
}
|
|
|
|
@Environment(AppEnvironment.self) private var env
|
|
|
|
@State private var step = Step.email
|
|
@State private var direction = 1.0
|
|
@State private var mode = Mode.signIn
|
|
@State private var email = ""
|
|
@State private var password = ""
|
|
@State private var code = ""
|
|
@State private var recoveryCode = ""
|
|
@State private var useRecovery = false
|
|
@State private var registerFlow = false
|
|
@State private var busy = false
|
|
@State private var errorMessage: String?
|
|
@State private var errorPulse = 0
|
|
@State private var infoMessage: String?
|
|
@State private var resendRemaining = 0
|
|
@State private var providers: AuthProvidersInfo?
|
|
@State private var showServerConfig = false
|
|
@State private var heroAppeared = false
|
|
@State private var modeSwitchEdge = Edge.trailing
|
|
|
|
@FocusState private var focusedField: Field?
|
|
|
|
private enum Field { case email, password, recovery }
|
|
|
|
/// The email step keeps the big cloud art; deeper steps clear the sky
|
|
/// for the step badge.
|
|
private var heroExpanded: Bool { step == .email }
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .bottom) {
|
|
SkyBackdrop(largeCloud: heroExpanded)
|
|
stepScreen
|
|
}
|
|
.sensoryFeedback(.impact(weight: .light), trigger: step)
|
|
.sensoryFeedback(.error, trigger: errorPulse)
|
|
.sheet(isPresented: $showServerConfig) { ServerConfigSheet() }
|
|
.task { providers = await env.session.fetchAuthProviders() }
|
|
.onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in
|
|
if resendRemaining > 0 {
|
|
withAnimation(.easeOut(duration: 0.3)) { resendRemaining -= 1 }
|
|
}
|
|
}
|
|
}
|
|
|
|
private var stepTransition: AnyTransition {
|
|
.asymmetric(
|
|
insertion: .move(edge: direction > 0 ? .trailing : .leading).combined(with: .opacity),
|
|
removal: .move(edge: direction > 0 ? .leading : .trailing).combined(with: .opacity)
|
|
)
|
|
}
|
|
|
|
private func go(to next: Step, direction dir: Double = 1) {
|
|
errorMessage = nil
|
|
direction = dir
|
|
withAnimation(.spring(response: 0.45, dampingFraction: 0.86)) {
|
|
step = next
|
|
}
|
|
}
|
|
|
|
// MARK: - Screen: sky hero + full-bleed bottom sheet
|
|
|
|
private var stepScreen: some View {
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 12) {
|
|
if step != .email {
|
|
skyIconButton("chevron.left", label: "Back") { goBackOneStep() }
|
|
.transition(.opacity.combined(with: .scale(scale: 0.7)))
|
|
}
|
|
compactLockup
|
|
Spacer()
|
|
skyIconButton("gearshape", label: "Server settings") { showServerConfig = true }
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 6)
|
|
// The top bar reflow animates here; the sheet is left alone so it
|
|
// tracks the keyboard's own curve with no gap.
|
|
.animation(.spring(response: 0.45, dampingFraction: 0.86), value: step == .email)
|
|
|
|
heroArea
|
|
.animation(.spring(response: 0.5, dampingFraction: 0.85), value: heroExpanded)
|
|
|
|
bottomSheet
|
|
}
|
|
}
|
|
|
|
/// The brand lockup, permanently docked in the top-left: the sky's center
|
|
/// stays free for the feature showcase.
|
|
private var compactLockup: some View {
|
|
HStack(spacing: 8) {
|
|
WarmblyLogo()
|
|
.fill(.white)
|
|
.frame(width: 27, height: 28)
|
|
Text("Warmbly")
|
|
.font(.system(size: 20, weight: .heavy))
|
|
.tracking(-0.4)
|
|
.foregroundStyle(.white)
|
|
.fixedSize()
|
|
}
|
|
.shadow(color: Color(hex: 0x0C4A6E).opacity(0.25), radius: 8, y: 3)
|
|
}
|
|
|
|
/// The sky between the top bar and the sheet. On the email step it shows
|
|
/// the richest thing the available height can hold: the showcase at full
|
|
/// size, the showcase gently scaled down (to ~72%) when the space is only
|
|
/// a little tight (error line, small screens), the one-line ticker when
|
|
/// the keyboard truly eats the room, plain sky as a last resort. Deeper
|
|
/// steps show the step badge. Always a tap-to-dismiss surface, and the
|
|
/// ambience never stops.
|
|
private var heroArea: some View {
|
|
ZStack {
|
|
Color.clear
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { focusedField = nil }
|
|
|
|
HeroFlightScene()
|
|
|
|
if step == .email {
|
|
GeometryReader { proxy in
|
|
let available = proxy.size.height
|
|
ZStack {
|
|
if available >= FeatureShowcase.idealHeight * 0.72 {
|
|
FeatureShowcase()
|
|
.scaleEffect(min(1, available / FeatureShowcase.idealHeight))
|
|
} else if available >= 64 {
|
|
FeatureTicker()
|
|
}
|
|
}
|
|
.frame(width: proxy.size.width, height: available)
|
|
}
|
|
.scaleEffect(heroAppeared ? 1 : 0.92)
|
|
.opacity(heroAppeared ? 1 : 0)
|
|
.transition(.opacity.combined(with: .scale(scale: 0.94)))
|
|
} else if let progress = stepProgress {
|
|
stepIndicator(progress)
|
|
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 40, maxHeight: .infinity)
|
|
.onAppear {
|
|
withAnimation(.spring(response: 0.7, dampingFraction: 0.75).delay(0.05)) {
|
|
heroAppeared = true
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Where the user is in the flow, shown in the freed-up sky on the
|
|
/// deeper steps: the step's icon, name, position, and segment bar.
|
|
private var stepProgress: (index: Int, icon: String, label: String)? {
|
|
switch step {
|
|
case .email, .resetSent:
|
|
return nil
|
|
case .password:
|
|
return (1, "lock.fill", mode == .signIn ? "Your password" : "Choose a password")
|
|
case .verify:
|
|
return (2, "envelope.open.fill", "Check your email")
|
|
case .twoFA:
|
|
return (2, "shield.lefthalf.filled", "Two-factor check")
|
|
}
|
|
}
|
|
|
|
/// Picks the tallest step badge that fits the sky left over above the
|
|
/// keyboard: icon bubble + name + segments, or a single compact row.
|
|
private func stepIndicator(_ progress: (index: Int, icon: String, label: String)) -> some View {
|
|
ViewThatFits(in: .vertical) {
|
|
VStack(spacing: 12) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(.white.opacity(0.16))
|
|
Circle()
|
|
.strokeBorder(.white.opacity(0.3), lineWidth: 1)
|
|
Image(systemName: progress.icon)
|
|
.font(.system(size: 20, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.contentTransition(.symbolEffect(.replace))
|
|
}
|
|
.frame(width: 54, height: 54)
|
|
|
|
VStack(spacing: 4) {
|
|
Text(progress.label)
|
|
.font(.system(size: 15.5, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
Text("Step \(progress.index + 1) of 3")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.white.opacity(0.7))
|
|
}
|
|
|
|
stepSegments(progress.index)
|
|
}
|
|
.padding(.vertical, 16)
|
|
|
|
HStack(spacing: 10) {
|
|
ZStack {
|
|
Circle().fill(.white.opacity(0.16))
|
|
Circle().strokeBorder(.white.opacity(0.3), lineWidth: 1)
|
|
Image(systemName: progress.icon)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.contentTransition(.symbolEffect(.replace))
|
|
}
|
|
.frame(width: 34, height: 34)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(progress.label)
|
|
.font(.system(size: 13.5, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
HStack(spacing: 7) {
|
|
stepSegments(progress.index)
|
|
Text("Step \(progress.index + 1) of 3")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.white.opacity(0.7))
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
.shadow(color: Color(hex: 0x0C4A6E).opacity(0.25), radius: 6, y: 2)
|
|
.animation(.spring(response: 0.4, dampingFraction: 0.85), value: progress.index)
|
|
}
|
|
|
|
private func stepSegments(_ active: Int) -> some View {
|
|
HStack(spacing: 6) {
|
|
ForEach(0 ..< 3, id: \.self) { index in
|
|
Capsule()
|
|
.fill(.white.opacity(index <= active ? 0.95 : 0.3))
|
|
.frame(width: index == active ? 26 : 14, height: 4)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The white area: full width, flush to the bottom edge (the background
|
|
/// shape extends under the home indicator and the keyboard), rounded only
|
|
/// at the top. Content resizes with a spring as steps swap.
|
|
private var bottomSheet: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Group {
|
|
switch step {
|
|
case .email:
|
|
emailStep
|
|
case .password:
|
|
passwordStep
|
|
case .verify:
|
|
verifyStep
|
|
case .twoFA:
|
|
twoFAStep
|
|
case .resetSent:
|
|
resetSentStep
|
|
}
|
|
}
|
|
.transition(stepTransition)
|
|
|
|
if infoMessage != nil || errorMessage != nil {
|
|
Group {
|
|
if let infoMessage {
|
|
Text(infoMessage)
|
|
.foregroundStyle(WTheme.positive)
|
|
}
|
|
if let errorMessage {
|
|
Text(errorMessage)
|
|
.foregroundStyle(WTheme.negative)
|
|
}
|
|
}
|
|
.font(.system(size: 13.5))
|
|
.frame(maxWidth: .infinity)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.top, 14)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.padding(.horizontal, 24)
|
|
.padding(.top, 30)
|
|
.padding(.bottom, 14)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { focusedField = nil }
|
|
.background {
|
|
// The extra tail below keeps the sheet sealed to the screen edge
|
|
// even mid-animation, when the sheet overshoots above the rising
|
|
// keyboard for a few frames.
|
|
UnevenRoundedRectangle(cornerRadii: .init(topLeading: 36, topTrailing: 36))
|
|
.fill(Color(.systemBackground)
|
|
.shadow(.drop(color: Color(hex: 0x0F172A).opacity(0.28), radius: 34, y: -6)))
|
|
.padding(.bottom, -600)
|
|
.ignoresSafeArea()
|
|
}
|
|
.geometryGroup()
|
|
}
|
|
|
|
private func skyIconButton(_ systemName: String, label: String, action: @escaping () -> Void) -> some View {
|
|
Button(action: action) {
|
|
Image(systemName: systemName)
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 40, height: 40)
|
|
.background(.white.opacity(0.16), in: Circle())
|
|
}
|
|
.buttonStyle(PressableButtonStyle())
|
|
.accessibilityLabel(label)
|
|
}
|
|
|
|
private func goBackOneStep() {
|
|
switch step {
|
|
case .password, .resetSent:
|
|
go(to: .email, direction: -1)
|
|
case .verify:
|
|
code = ""
|
|
infoMessage = nil
|
|
go(to: .password, direction: -1)
|
|
case .twoFA:
|
|
restart()
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
// MARK: - Shared sheet pieces
|
|
|
|
private func sheetTitle(_ title: String, _ subtitle: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 7) {
|
|
Text(title)
|
|
.font(.system(size: 30, weight: .bold))
|
|
.tracking(-0.6)
|
|
.foregroundStyle(.primary)
|
|
Text(subtitle)
|
|
.font(.system(size: 15.5))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.bottom, 22)
|
|
}
|
|
|
|
private func primaryButton(_ title: String, disabled: Bool, action: @escaping () async -> Void) -> some View {
|
|
Button {
|
|
Task { await action() }
|
|
} label: {
|
|
Group {
|
|
if busy {
|
|
ProgressView().tint(.white)
|
|
} else {
|
|
Text(title)
|
|
}
|
|
}
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 56)
|
|
.background(
|
|
LinearGradient(
|
|
colors: disabled && !busy
|
|
? [WTheme.accent.opacity(0.35), WTheme.accent.opacity(0.35)]
|
|
: [Color(hex: 0x0EA5E9), Color(hex: 0x0284C7)],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
),
|
|
in: RoundedRectangle(cornerRadius: 17)
|
|
)
|
|
.shadow(color: disabled ? .clear : Color(hex: 0x0284C7).opacity(0.32), radius: 12, y: 6)
|
|
}
|
|
.buttonStyle(PressableButtonStyle())
|
|
.disabled(busy || disabled)
|
|
.animation(.easeOut(duration: 0.18), value: disabled)
|
|
}
|
|
|
|
private func authField(_ content: some View, focused: Bool) -> some View {
|
|
content
|
|
.font(.system(size: 16.5))
|
|
.padding(.horizontal, 16)
|
|
.frame(height: 56)
|
|
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 17)
|
|
.strokeBorder(focused ? WTheme.accent : .clear, lineWidth: 1.8)
|
|
)
|
|
.animation(.easeOut(duration: 0.18), value: focused)
|
|
}
|
|
|
|
/// The address entered earlier, as a tappable chip that goes back a step.
|
|
private var emailPill: some View {
|
|
Button {
|
|
go(to: .email, direction: -1)
|
|
} label: {
|
|
HStack(spacing: 7) {
|
|
Image(systemName: "envelope")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.tertiary)
|
|
Text(email)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
Image(systemName: "pencil")
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 9)
|
|
.background(Color(.secondarySystemBackground), in: Capsule())
|
|
}
|
|
.buttonStyle(PressableButtonStyle())
|
|
}
|
|
|
|
// MARK: - Step: email (social-first)
|
|
|
|
/// Sign-in and create-account are two pages of the same sheet: toggling
|
|
/// pushes the whole step content sideways like a native page turn.
|
|
private var emailStep: some View {
|
|
Group {
|
|
emailStepContent
|
|
}
|
|
.id(mode)
|
|
.transition(.push(from: modeSwitchEdge))
|
|
.sensoryFeedback(.impact(weight: .light), trigger: mode == .signIn)
|
|
}
|
|
|
|
/// Shown when the app signed the user out because the session expired or
|
|
/// was revoked, so landing back here never feels like a glitch.
|
|
private var sessionExpiredNotice: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "clock.badge.exclamationmark")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(Tone.amber.color)
|
|
Text("You were signed out because your session expired. Sign in to continue.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Tone.amber.background, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.padding(.bottom, 14)
|
|
}
|
|
|
|
private var emailStepContent: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
if env.session.wasSignedOutBySession, mode == .signIn {
|
|
sessionExpiredNotice
|
|
}
|
|
sheetTitle(
|
|
mode == .signIn ? "Welcome back" : "Create your account",
|
|
mode == .signIn ? "Sign in to pick up where you left off." : "Free to start. No credit card required."
|
|
)
|
|
|
|
// Social options collapse while typing so the field and button
|
|
// stay right above the keyboard.
|
|
if focusedField != .email {
|
|
VStack(spacing: 12) {
|
|
SocialSignInRow(
|
|
providers: providers,
|
|
busy: $busy,
|
|
onError: { message in showError(message) },
|
|
onTwoFA: { pendingToken in
|
|
code = ""
|
|
go(to: .twoFA(pendingToken: pendingToken))
|
|
}
|
|
)
|
|
|
|
HStack(spacing: 12) {
|
|
Rectangle().fill(Color(.separator).opacity(0.45)).frame(height: 1)
|
|
Text("or")
|
|
.font(.system(size: 13.5))
|
|
.foregroundStyle(.tertiary)
|
|
.fixedSize()
|
|
Rectangle().fill(Color(.separator).opacity(0.45)).frame(height: 1)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
.padding(.bottom, 12)
|
|
.transition(.opacity.combined(with: .scale(scale: 0.96, anchor: .top)))
|
|
}
|
|
|
|
VStack(spacing: 12) {
|
|
authField(
|
|
TextField("name@company.com", text: $email)
|
|
.textContentType(.emailAddress)
|
|
.keyboardType(.emailAddress)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.focused($focusedField, equals: .email)
|
|
.submitLabel(.continue)
|
|
.onSubmit { continueFromEmail() },
|
|
focused: focusedField == .email
|
|
)
|
|
|
|
primaryButton("Continue with email", disabled: !isPlausibleEmail(email)) {
|
|
continueFromEmail()
|
|
}
|
|
}
|
|
|
|
HStack(spacing: 5) {
|
|
Text(mode == .signIn ? "New to Warmbly?" : "Already have an account?")
|
|
.foregroundStyle(.secondary)
|
|
Button(mode == .signIn ? "Create an account" : "Sign in") {
|
|
modeSwitchEdge = mode == .signIn ? .trailing : .leading
|
|
withAnimation(.spring(response: 0.45, dampingFraction: 0.88)) {
|
|
mode = mode == .signIn ? .signUp : .signIn
|
|
}
|
|
}
|
|
.foregroundStyle(WTheme.accent)
|
|
.fontWeight(.semibold)
|
|
}
|
|
.font(.system(size: 14.5))
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 18)
|
|
}
|
|
.animation(.spring(response: 0.4, dampingFraction: 0.85), value: focusedField)
|
|
}
|
|
|
|
private func continueFromEmail() {
|
|
guard isPlausibleEmail(email) else { return }
|
|
password = ""
|
|
go(to: .password)
|
|
}
|
|
|
|
// MARK: - Step: password
|
|
|
|
private var passwordStep: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
sheetTitle(
|
|
mode == .signIn ? "Enter your password" : "Choose a password",
|
|
mode == .signIn ? "Use the password for this account." : "At least 8 characters. Make it strong."
|
|
)
|
|
|
|
VStack(spacing: 12) {
|
|
HStack {
|
|
emailPill
|
|
Spacer()
|
|
}
|
|
|
|
authField(
|
|
SecureField("Password", text: $password)
|
|
.textContentType(mode == .signIn ? .password : .newPassword)
|
|
.focused($focusedField, equals: .password)
|
|
.submitLabel(.go)
|
|
.onSubmit { Task { await submitCredentials() } },
|
|
focused: focusedField == .password
|
|
)
|
|
|
|
if mode == .signUp, !password.isEmpty {
|
|
PasswordStrengthBar(password: password)
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
|
|
primaryButton(
|
|
mode == .signIn ? "Sign in" : "Create account",
|
|
disabled: password.count < (mode == .signIn ? 1 : 8)
|
|
) {
|
|
await submitCredentials()
|
|
}
|
|
}
|
|
.animation(.spring(response: 0.35, dampingFraction: 0.85), value: password.isEmpty)
|
|
|
|
Group {
|
|
if mode == .signIn {
|
|
Button("Forgot password?") {
|
|
Task { await submitReset() }
|
|
}
|
|
.font(.system(size: 14.5, weight: .medium))
|
|
.foregroundStyle(WTheme.accent)
|
|
.disabled(busy)
|
|
} else {
|
|
termsFootnote
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 18)
|
|
}
|
|
.onAppear { focusedField = .password }
|
|
}
|
|
|
|
private var termsFootnote: some View {
|
|
VStack(spacing: 2) {
|
|
Text("By creating an account, you agree to the")
|
|
HStack(spacing: 4) {
|
|
Link("Terms of Service", destination: URL(string: "https://warmbly.com/terms")!)
|
|
.foregroundStyle(WTheme.accent)
|
|
Text("and")
|
|
Link("Privacy Policy", destination: URL(string: "https://warmbly.com/privacy")!)
|
|
.foregroundStyle(WTheme.accent)
|
|
}
|
|
}
|
|
.font(.system(size: 12.5))
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
// MARK: - Step: verify (emailed code)
|
|
|
|
private var verifyStep: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
sheetTitle("Check your email", "We sent a 6-digit code to \(email).")
|
|
|
|
VStack(spacing: 18) {
|
|
OTPCodeField(code: $code) { complete in
|
|
Task { await submitEmailCode(complete) }
|
|
}
|
|
|
|
Group {
|
|
if busy {
|
|
ProgressView()
|
|
} else if resendRemaining > 0 {
|
|
Text("Resend code in \(resendRemaining)s")
|
|
.font(.system(size: 14.5))
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
.contentTransition(.numericText(countsDown: true))
|
|
} else {
|
|
Button("Resend code") {
|
|
Task { await resendCode() }
|
|
}
|
|
.font(.system(size: 14.5, weight: .semibold))
|
|
.foregroundStyle(WTheme.accent)
|
|
}
|
|
}
|
|
.frame(height: 24)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
// MARK: - Step: 2FA
|
|
|
|
private var twoFAStep: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
sheetTitle(
|
|
"Two-factor authentication",
|
|
useRecovery ? "Enter one of your recovery codes." : "Enter the code from your authenticator app."
|
|
)
|
|
|
|
VStack(spacing: 16) {
|
|
if useRecovery {
|
|
authField(
|
|
TextField("xxxxx-xxxxx", text: $recoveryCode)
|
|
.font(.system(size: 18, design: .monospaced))
|
|
.multilineTextAlignment(.center)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.focused($focusedField, equals: .recovery)
|
|
.submitLabel(.go)
|
|
.onSubmit { Task { await submitTwoFA(recoveryCode) } },
|
|
focused: focusedField == .recovery
|
|
)
|
|
primaryButton("Verify", disabled: recoveryCode.trimmingCharacters(in: .whitespaces).isEmpty) {
|
|
await submitTwoFA(recoveryCode)
|
|
}
|
|
} else {
|
|
OTPCodeField(code: $code) { complete in
|
|
Task { await submitTwoFA(complete) }
|
|
}
|
|
if busy { ProgressView() }
|
|
}
|
|
|
|
Button(useRecovery ? "Use your authenticator app" : "Use a recovery code") {
|
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
|
useRecovery.toggle()
|
|
code = ""
|
|
recoveryCode = ""
|
|
errorMessage = nil
|
|
}
|
|
}
|
|
.font(.system(size: 14.5, weight: .semibold))
|
|
.foregroundStyle(WTheme.accent)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
// MARK: - Step: reset sent
|
|
|
|
private var resetSentStep: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
sheetTitle("Reset link sent", "If an account exists for \(email), a reset link is on its way. Complete the reset from the email, then sign in with your new password.")
|
|
|
|
primaryButton("Back to sign in", disabled: false) {
|
|
restart()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func restart() {
|
|
code = ""
|
|
recoveryCode = ""
|
|
useRecovery = false
|
|
registerFlow = false
|
|
infoMessage = nil
|
|
go(to: .email, direction: -1)
|
|
}
|
|
|
|
private func showError(_ message: String) {
|
|
withAnimation(.easeOut(duration: 0.2)) {
|
|
errorMessage = message
|
|
infoMessage = nil
|
|
}
|
|
errorPulse += 1
|
|
}
|
|
|
|
private func run(_ work: () async throws -> Void) async {
|
|
busy = true
|
|
errorMessage = nil
|
|
do {
|
|
try await work()
|
|
} catch {
|
|
showError((error as? APIError)?.errorDescription ?? error.localizedDescription)
|
|
}
|
|
busy = false
|
|
}
|
|
|
|
private func submitCredentials() async {
|
|
guard !busy, password.count >= (mode == .signIn ? 1 : 8) else { return }
|
|
await run {
|
|
switch mode {
|
|
case .signIn:
|
|
registerFlow = false
|
|
// A deployment can sign someone in without emailing a code, so
|
|
// the first call may already be the whole login.
|
|
let outcome = try await env.session.startLogin(email: email, password: password)
|
|
switch outcome {
|
|
case .loggedIn:
|
|
return
|
|
case let .needsTwoFA(pendingToken):
|
|
code = ""
|
|
go(to: .twoFA(pendingToken: pendingToken))
|
|
return
|
|
case let .needsCode(session):
|
|
code = ""
|
|
infoMessage = nil
|
|
resendRemaining = 60
|
|
go(to: .verify(session: session))
|
|
}
|
|
case .signUp:
|
|
registerFlow = true
|
|
// With email verification off the account already exists, so
|
|
// fall straight through to signing in.
|
|
guard let session = try await env.session.startRegister(email: email, password: password) else {
|
|
registerFlow = false
|
|
let outcome = try await env.session.startLogin(email: email, password: password)
|
|
switch outcome {
|
|
case .loggedIn:
|
|
return
|
|
case let .needsTwoFA(pendingToken):
|
|
code = ""
|
|
go(to: .twoFA(pendingToken: pendingToken))
|
|
case let .needsCode(loginSession):
|
|
code = ""
|
|
resendRemaining = 60
|
|
go(to: .verify(session: loginSession))
|
|
}
|
|
return
|
|
}
|
|
code = ""
|
|
infoMessage = nil
|
|
resendRemaining = 60
|
|
go(to: .verify(session: session))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func submitEmailCode(_ submitted: String) async {
|
|
guard case let .verify(session) = step, !busy else { return }
|
|
await run {
|
|
if registerFlow {
|
|
try await env.session.confirmRegister(session: session, code: submitted)
|
|
// Registration returns no tokens; chain into login with the same credentials.
|
|
let outcome = try await env.session.startLogin(email: email, password: password)
|
|
registerFlow = false
|
|
code = ""
|
|
resendRemaining = 60
|
|
switch outcome {
|
|
case .loggedIn:
|
|
return
|
|
case let .needsTwoFA(pendingToken):
|
|
go(to: .twoFA(pendingToken: pendingToken))
|
|
case let .needsCode(loginSession):
|
|
withAnimation(.easeOut(duration: 0.2)) {
|
|
infoMessage = "Account created. We emailed you a sign-in code."
|
|
}
|
|
go(to: .verify(session: loginSession))
|
|
}
|
|
} else {
|
|
let outcome = try await env.session.confirmLogin(session: session, code: submitted)
|
|
if case let .needsTwoFA(pendingToken) = outcome {
|
|
code = ""
|
|
go(to: .twoFA(pendingToken: pendingToken))
|
|
}
|
|
}
|
|
}
|
|
if errorMessage != nil { code = "" }
|
|
}
|
|
|
|
private func resendCode() async {
|
|
guard case .verify = step else { return }
|
|
await run {
|
|
let session: String?
|
|
if registerFlow {
|
|
session = try await env.session.startRegister(email: email, password: password)
|
|
} else {
|
|
if case let .needsCode(loginSession) = try await env.session.startLogin(email: email, password: password) {
|
|
session = loginSession
|
|
} else {
|
|
// The retry resolved the login outright; nothing to resend.
|
|
return
|
|
}
|
|
}
|
|
guard let session else { return }
|
|
code = ""
|
|
resendRemaining = 60
|
|
step = .verify(session: session)
|
|
withAnimation(.easeOut(duration: 0.2)) {
|
|
infoMessage = "Code resent."
|
|
}
|
|
}
|
|
}
|
|
|
|
private func submitTwoFA(_ submitted: String) async {
|
|
guard case let .twoFA(pendingToken) = step, !busy else { return }
|
|
await run {
|
|
try await env.session.verifyTwoFA(pendingToken: pendingToken, code: submitted.trimmingCharacters(in: .whitespaces))
|
|
}
|
|
if errorMessage != nil { code = "" }
|
|
}
|
|
|
|
private func submitReset() async {
|
|
guard isPlausibleEmail(email) else {
|
|
showError("Enter your email address first.")
|
|
return
|
|
}
|
|
await run {
|
|
try await env.session.requestPasswordReset(email: email)
|
|
go(to: .resetSent)
|
|
}
|
|
}
|
|
|
|
private func isPlausibleEmail(_ value: String) -> Bool {
|
|
let trimmed = value.trimmingCharacters(in: .whitespaces)
|
|
guard let at = trimmed.firstIndex(of: "@"), at != trimmed.startIndex else { return false }
|
|
let domain = trimmed[trimmed.index(after: at)...]
|
|
return domain.contains(".") && !domain.hasPrefix(".") && !domain.hasSuffix(".")
|
|
}
|
|
}
|
|
|
|
// MARK: - Hero ambience
|
|
|
|
/// Ambient motion around the hero mark: a dashed flight path that keeps
|
|
/// redrawing itself across the sky, and a few envelopes drifting upward at
|
|
/// different depths. Pure decoration, drawn in one Canvas pass.
|
|
struct HeroFlightScene: View {
|
|
private struct EnvelopeSpec {
|
|
let x: CGFloat
|
|
let size: CGFloat
|
|
let period: Double
|
|
let phase: Double
|
|
let baseOpacity: Double
|
|
}
|
|
|
|
private static let envelopes: [EnvelopeSpec] = [
|
|
EnvelopeSpec(x: 0.14, size: 20, period: 13, phase: 0.0, baseOpacity: 0.4),
|
|
EnvelopeSpec(x: 0.84, size: 15, period: 17, phase: 0.45, baseOpacity: 0.3),
|
|
EnvelopeSpec(x: 0.32, size: 12, period: 21, phase: 0.8, baseOpacity: 0.22),
|
|
]
|
|
|
|
var body: some View {
|
|
TimelineView(.animation(minimumInterval: 1 / 40)) { context in
|
|
let t = context.date.timeIntervalSinceReferenceDate
|
|
Canvas { ctx, size in
|
|
let rect = CGRect(origin: .zero, size: size)
|
|
let trail = Self.trailPath(in: rect)
|
|
|
|
// The faint full route, plus a brighter segment forever
|
|
// travelling along it like a gust carrying the plane.
|
|
ctx.stroke(trail, with: .color(.white.opacity(0.14)), style: StrokeStyle(lineWidth: 2, lineCap: .round, dash: [2, 9]))
|
|
let phase = (t / 6).truncatingRemainder(dividingBy: 1)
|
|
let window: CGFloat = 0.26
|
|
let segment = trail.trimmedPath(from: CGFloat(phase), to: min(CGFloat(phase) + window, 1))
|
|
ctx.stroke(segment, with: .color(.white.opacity(0.5)), style: StrokeStyle(lineWidth: 2.4, lineCap: .round, dash: [2, 9]))
|
|
|
|
// Envelopes rising with a gentle sway, fading at both ends.
|
|
for spec in Self.envelopes {
|
|
let progress = ((t / spec.period) + spec.phase).truncatingRemainder(dividingBy: 1)
|
|
let y = size.height * (1.08 - 1.3 * progress)
|
|
let x = size.width * spec.x + sin(t / 2.6 + spec.phase * 9) * 13
|
|
let fade = sin(.pi * progress)
|
|
var image = ctx.resolve(Image(systemName: "envelope.fill"))
|
|
image.shading = .color(.white.opacity(spec.baseOpacity * fade))
|
|
let box = CGRect(x: x - spec.size / 2, y: y - spec.size / 2, width: spec.size, height: spec.size * 0.72)
|
|
ctx.draw(image, in: box)
|
|
}
|
|
}
|
|
}
|
|
.allowsHitTesting(false)
|
|
}
|
|
|
|
/// A wind current arcing over the mark from off-screen left to right.
|
|
private static func trailPath(in rect: CGRect) -> Path {
|
|
var path = Path()
|
|
path.move(to: CGPoint(x: rect.minX - 24, y: rect.height * 0.66))
|
|
path.addCurve(
|
|
to: CGPoint(x: rect.midX, y: rect.height * 0.14),
|
|
control1: CGPoint(x: rect.width * 0.18, y: rect.height * 0.62),
|
|
control2: CGPoint(x: rect.width * 0.3, y: rect.height * 0.14)
|
|
)
|
|
path.addCurve(
|
|
to: CGPoint(x: rect.maxX + 24, y: rect.height * 0.5),
|
|
control1: CGPoint(x: rect.width * 0.72, y: rect.height * 0.14),
|
|
control2: CGPoint(x: rect.width * 0.86, y: rect.height * 0.42)
|
|
)
|
|
return path
|
|
}
|
|
}
|
|
|
|
// MARK: - Feature showcase
|
|
|
|
/// Rotating product vignettes filling the sky while the email step is at
|
|
/// rest: glass cards with animated white-line scenes of what Warmbly does.
|
|
/// Auto-advances, swipeable, dots to jump.
|
|
struct FeatureShowcase: View {
|
|
/// Natural height (cards + dots); the auth screen scales the whole
|
|
/// showcase down from this when the sky gets a little tight.
|
|
static let idealHeight: CGFloat = 256
|
|
|
|
private struct Slide: Identifiable {
|
|
let id: Int
|
|
let title: String
|
|
let subtitle: String
|
|
}
|
|
|
|
private static let slides: [Slide] = [
|
|
Slide(id: 0, title: "Warm up on autopilot", subtitle: "Every inbox ramps up gradually and builds sender reputation on its own."),
|
|
Slide(id: 1, title: "Land in the inbox", subtitle: "Watch deliverability live and catch spam placement before it hurts."),
|
|
Slide(id: 2, title: "Every reply, one place", subtitle: "All your mailboxes and your team in a single unified inbox."),
|
|
Slide(id: 3, title: "Know what works", subtitle: "Opens, clicks, and replies per campaign, at a glance."),
|
|
]
|
|
|
|
@State private var index = 0
|
|
@State private var float = false
|
|
|
|
var body: some View {
|
|
VStack(spacing: 13) {
|
|
TabView(selection: $index) {
|
|
ForEach(Self.slides) { slide in
|
|
card(slide)
|
|
.padding(.horizontal, 5)
|
|
.tag(slide.id)
|
|
}
|
|
}
|
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
|
.frame(height: 226)
|
|
|
|
HStack(spacing: 7) {
|
|
ForEach(Self.slides) { slide in
|
|
Button {
|
|
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
|
|
index = slide.id
|
|
}
|
|
} label: {
|
|
Capsule()
|
|
.fill(.white.opacity(index == slide.id ? 0.95 : 0.35))
|
|
.frame(width: index == slide.id ? 22 : 7, height: 7)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.animation(.spring(response: 0.4, dampingFraction: 0.85), value: index)
|
|
}
|
|
.padding(.bottom, 10)
|
|
.offset(y: float ? -3 : 3)
|
|
.sensoryFeedback(.selection, trigger: index)
|
|
.onAppear {
|
|
withAnimation(.easeInOut(duration: 6).repeatForever(autoreverses: true)) {
|
|
float = true
|
|
}
|
|
}
|
|
.onReceive(Timer.publish(every: 5, on: .main, in: .common).autoconnect()) { _ in
|
|
withAnimation(.spring(response: 0.55, dampingFraction: 0.86)) {
|
|
index = (index + 1) % Self.slides.count
|
|
}
|
|
}
|
|
}
|
|
|
|
private func card(_ slide: Slide) -> some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
visual(for: slide.id)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 100)
|
|
.padding(.bottom, 14)
|
|
|
|
Text(slide.title)
|
|
.font(.system(size: 18, weight: .bold))
|
|
.tracking(-0.3)
|
|
.foregroundStyle(.white)
|
|
|
|
Text(slide.subtitle)
|
|
.font(.system(size: 13.5))
|
|
.foregroundStyle(.white.opacity(0.78))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.padding(.top, 5)
|
|
}
|
|
.padding(22)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: 26)
|
|
.fill(.white.opacity(0.13))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 26)
|
|
.strokeBorder(
|
|
LinearGradient(
|
|
colors: [.white.opacity(0.45), .white.opacity(0.08)],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
),
|
|
lineWidth: 1
|
|
)
|
|
)
|
|
.shadow(color: Color(hex: 0x0C4A6E).opacity(0.2), radius: 16, y: 9)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func visual(for id: Int) -> some View {
|
|
switch id {
|
|
case 0: WarmupRampVisual()
|
|
case 1: InboxPlacementVisual()
|
|
case 2: UnifiedInboxVisual()
|
|
default: CampaignStatsVisual()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The showcase distilled to one line: a glass chip cycling through the
|
|
/// feature headlines, for the slim strip of sky left above the keyboard.
|
|
struct FeatureTicker: View {
|
|
private static let items: [(icon: String, text: String)] = [
|
|
("chart.line.uptrend.xyaxis", "Warm up on autopilot"),
|
|
("checkmark.seal.fill", "Land in the inbox"),
|
|
("arrowshape.turn.up.left.fill", "Every reply, one place"),
|
|
("chart.bar.fill", "Know what works"),
|
|
]
|
|
|
|
@State private var index = 0
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: Self.items[index].icon)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
Text(Self.items[index].text)
|
|
.font(.system(size: 13.5, weight: .semibold))
|
|
.fixedSize()
|
|
}
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 15)
|
|
.frame(height: 36)
|
|
.background(.white.opacity(0.14), in: Capsule())
|
|
.overlay(Capsule().strokeBorder(.white.opacity(0.25), lineWidth: 1))
|
|
.id(index)
|
|
.transition(.blurReplace)
|
|
}
|
|
.onReceive(Timer.publish(every: 3.5, on: .main, in: .common).autoconnect()) { _ in
|
|
withAnimation(.spring(response: 0.5, dampingFraction: 0.85)) {
|
|
index = (index + 1) % Self.items.count
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A send-volume ramp drawing itself in over faint gridlines, with a glowing
|
|
/// live dot at today's volume.
|
|
struct WarmupRampVisual: View {
|
|
@State private var progress: CGFloat = 0
|
|
@State private var pulse = false
|
|
|
|
var body: some View {
|
|
GeometryReader { proxy in
|
|
let size = proxy.size
|
|
ZStack(alignment: .topLeading) {
|
|
VStack(spacing: 0) {
|
|
ForEach(0 ..< 3, id: \.self) { _ in
|
|
Rectangle().fill(.white.opacity(0.1)).frame(height: 1)
|
|
Spacer()
|
|
}
|
|
Rectangle().fill(.white.opacity(0.22)).frame(height: 1)
|
|
}
|
|
|
|
Self.area(in: size)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [.white.opacity(0.22), .white.opacity(0.02)],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
.opacity(progress)
|
|
|
|
Self.ramp(in: size)
|
|
.trim(from: 0, to: progress)
|
|
.stroke(.white, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
|
|
|
|
Circle()
|
|
.fill(.white)
|
|
.frame(width: 7, height: 7)
|
|
.background(
|
|
Circle()
|
|
.fill(.white.opacity(0.3))
|
|
.frame(width: pulse ? 24 : 10, height: pulse ? 24 : 10)
|
|
)
|
|
.position(x: size.width - 2, y: size.height * 0.08)
|
|
.opacity(progress > 0.96 ? 1 : 0)
|
|
.animation(.easeOut(duration: 0.25), value: progress > 0.96)
|
|
}
|
|
}
|
|
.onAppear {
|
|
withAnimation(.easeOut(duration: 1.4).delay(0.25)) { progress = 1 }
|
|
withAnimation(.easeInOut(duration: 1.3).repeatForever(autoreverses: true).delay(1.7)) { pulse = true }
|
|
}
|
|
}
|
|
|
|
private static func ramp(in size: CGSize) -> Path {
|
|
var path = Path()
|
|
path.move(to: CGPoint(x: 2, y: size.height * 0.94))
|
|
path.addCurve(
|
|
to: CGPoint(x: size.width * 0.55, y: size.height * 0.6),
|
|
control1: CGPoint(x: size.width * 0.28, y: size.height * 0.94),
|
|
control2: CGPoint(x: size.width * 0.4, y: size.height * 0.72)
|
|
)
|
|
path.addCurve(
|
|
to: CGPoint(x: size.width - 2, y: size.height * 0.08),
|
|
control1: CGPoint(x: size.width * 0.72, y: size.height * 0.47),
|
|
control2: CGPoint(x: size.width * 0.86, y: size.height * 0.1)
|
|
)
|
|
return path
|
|
}
|
|
|
|
private static func area(in size: CGSize) -> Path {
|
|
var path = ramp(in: size)
|
|
path.addLine(to: CGPoint(x: size.width - 2, y: size.height))
|
|
path.addLine(to: CGPoint(x: 2, y: size.height))
|
|
path.closeSubpath()
|
|
return path
|
|
}
|
|
}
|
|
|
|
/// A deliverability gauge sweeping up to 98% inbox placement, with the
|
|
/// folder split alongside.
|
|
struct InboxPlacementVisual: View {
|
|
@State private var progress: CGFloat = 0
|
|
@State private var shownValue = 0
|
|
|
|
var body: some View {
|
|
HStack(spacing: 26) {
|
|
ZStack {
|
|
Circle()
|
|
.stroke(.white.opacity(0.18), lineWidth: 9)
|
|
Circle()
|
|
.trim(from: 0, to: progress * 0.98)
|
|
.stroke(.white, style: StrokeStyle(lineWidth: 9, lineCap: .round))
|
|
.rotationEffect(.degrees(-90))
|
|
Text("\(shownValue)%")
|
|
.font(.system(size: 22, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
.monospacedDigit()
|
|
.contentTransition(.numericText())
|
|
}
|
|
.frame(width: 96, height: 96)
|
|
|
|
VStack(alignment: .leading, spacing: 13) {
|
|
placementRow(icon: "tray.full.fill", label: "Inbox", fraction: 0.98)
|
|
placementRow(icon: "xmark.bin", label: "Spam", fraction: 0.02)
|
|
}
|
|
}
|
|
.padding(.horizontal, 4)
|
|
.onAppear {
|
|
withAnimation(.easeOut(duration: 1.3).delay(0.25)) {
|
|
progress = 1
|
|
shownValue = 98
|
|
}
|
|
}
|
|
}
|
|
|
|
private func placementRow(icon: String, label: String, fraction: CGFloat) -> some View {
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 10.5))
|
|
Text(label)
|
|
.font(.system(size: 12.5, weight: .semibold))
|
|
}
|
|
.foregroundStyle(.white.opacity(0.85))
|
|
|
|
GeometryReader { proxy in
|
|
ZStack(alignment: .leading) {
|
|
Capsule().fill(.white.opacity(0.18))
|
|
Capsule()
|
|
.fill(.white.opacity(0.9))
|
|
.frame(width: max(5, proxy.size.width * fraction * progress))
|
|
}
|
|
}
|
|
.frame(height: 5)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Message rows from different providers sliding into one inbox, one of them
|
|
/// already answered.
|
|
struct UnifiedInboxVisual: View {
|
|
@State private var shown = false
|
|
|
|
private struct Row {
|
|
let initial: String
|
|
let lineWidth: CGFloat
|
|
let replied: Bool
|
|
let unread: Bool
|
|
}
|
|
|
|
private static let rows: [Row] = [
|
|
Row(initial: "G", lineWidth: 0.74, replied: false, unread: true),
|
|
Row(initial: "M", lineWidth: 0.52, replied: true, unread: false),
|
|
Row(initial: "O", lineWidth: 0.63, replied: false, unread: false),
|
|
]
|
|
|
|
var body: some View {
|
|
VStack(spacing: 8) {
|
|
ForEach(Array(Self.rows.enumerated()), id: \.offset) { pair in
|
|
let row = pair.element
|
|
HStack(spacing: 10) {
|
|
Circle()
|
|
.fill(.white.opacity(0.28))
|
|
.frame(width: 26, height: 26)
|
|
.overlay(
|
|
Text(row.initial)
|
|
.font(.system(size: 11.5, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
GeometryReader { proxy in
|
|
Capsule()
|
|
.fill(.white.opacity(0.75))
|
|
.frame(width: proxy.size.width * row.lineWidth, height: 5)
|
|
}
|
|
.frame(height: 5)
|
|
Capsule()
|
|
.fill(.white.opacity(0.3))
|
|
.frame(width: 52, height: 5)
|
|
}
|
|
|
|
if row.replied {
|
|
Image(systemName: "arrowshape.turn.up.left.fill")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.white.opacity(0.85))
|
|
}
|
|
if row.unread {
|
|
Circle().fill(.white).frame(width: 7, height: 7)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 13))
|
|
.offset(x: shown ? 0 : 46)
|
|
.opacity(shown ? 1 : 0)
|
|
.animation(
|
|
.spring(response: 0.55, dampingFraction: 0.8).delay(0.2 + Double(pair.offset) * 0.13),
|
|
value: shown
|
|
)
|
|
}
|
|
}
|
|
.onAppear { shown = true }
|
|
}
|
|
}
|
|
|
|
/// Campaign results growing in: opens, clicks, replies as labeled bars.
|
|
struct CampaignStatsVisual: View {
|
|
@State private var grown = false
|
|
|
|
private static let bars: [(label: String, fraction: CGFloat)] = [
|
|
("Opened", 0.72),
|
|
("Clicked", 0.44),
|
|
("Replied", 0.2),
|
|
]
|
|
|
|
var body: some View {
|
|
HStack(alignment: .bottom, spacing: 22) {
|
|
ForEach(Array(Self.bars.enumerated()), id: \.offset) { pair in
|
|
let bar = pair.element
|
|
VStack(spacing: 7) {
|
|
Text("\(Int(bar.fraction * 100))%")
|
|
.font(.system(size: 12, weight: .bold))
|
|
.foregroundStyle(.white.opacity(0.9))
|
|
.monospacedDigit()
|
|
.opacity(grown ? 1 : 0)
|
|
|
|
VStack {
|
|
Spacer(minLength: 0)
|
|
RoundedRectangle(cornerRadius: 5)
|
|
.fill(.white.opacity(0.9 - Double(pair.offset) * 0.18))
|
|
.frame(height: grown ? 66 * bar.fraction + 8 : 6)
|
|
}
|
|
.frame(height: 74)
|
|
|
|
Text(bar.label)
|
|
.font(.system(size: 11.5, weight: .semibold))
|
|
.foregroundStyle(.white.opacity(0.7))
|
|
}
|
|
.frame(width: 62)
|
|
.animation(
|
|
.spring(response: 0.6, dampingFraction: 0.75).delay(0.2 + Double(pair.offset) * 0.12),
|
|
value: grown
|
|
)
|
|
}
|
|
}
|
|
.onAppear { grown = true }
|
|
}
|
|
}
|
|
|
|
// MARK: - Button press feel
|
|
|
|
/// Springy scale-down on press, shared by every tappable in the auth flow.
|
|
struct PressableButtonStyle: ButtonStyle {
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.scaleEffect(configuration.isPressed ? 0.965 : 1)
|
|
.opacity(configuration.isPressed ? 0.88 : 1)
|
|
.animation(.spring(response: 0.28, dampingFraction: 0.7), value: configuration.isPressed)
|
|
}
|
|
}
|
|
|
|
// MARK: - Password strength
|
|
|
|
/// Lightweight local strength meter (the server only enforces the 8-char
|
|
/// minimum; this is guidance, not a gate).
|
|
struct PasswordStrengthBar: View {
|
|
let password: String
|
|
|
|
private var assessment: (score: Int, label: String, color: Color) {
|
|
var score = 0
|
|
if password.count >= 8 { score += 1 }
|
|
if password.count >= 12 { score += 1 }
|
|
var classes = 0
|
|
if password.contains(where: \.isLowercase) { classes += 1 }
|
|
if password.contains(where: \.isUppercase) { classes += 1 }
|
|
if password.contains(where: \.isNumber) { classes += 1 }
|
|
if password.contains(where: { !$0.isLetter && !$0.isNumber }) { classes += 1 }
|
|
if classes >= 2 { score += 1 }
|
|
if classes >= 3, password.count >= 10 { score += 1 }
|
|
|
|
switch score {
|
|
case 0 ... 1: return (max(score, 1), "Weak", WTheme.negative)
|
|
case 2: return (2, "Fair", WTheme.warning)
|
|
case 3: return (3, "Good", WTheme.info)
|
|
default: return (4, "Strong", WTheme.positive)
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
let result = assessment
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
GeometryReader { proxy in
|
|
ZStack(alignment: .leading) {
|
|
Capsule().fill(Color(.secondarySystemBackground))
|
|
Capsule()
|
|
.fill(result.color)
|
|
.frame(width: proxy.size.width * CGFloat(result.score) / 4)
|
|
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: result.score)
|
|
}
|
|
}
|
|
.frame(height: 5)
|
|
|
|
Text("\(result.label) password")
|
|
.font(.system(size: 12.5))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - The website's airy sky
|
|
|
|
/// The marketing site's auth backdrop, recreated from `web/src/global.css`:
|
|
/// a radial sky, a slow "breathing" light layer, a warm sun glow top-right,
|
|
/// and the site's painterly cloud renders gliding gently back and forth.
|
|
struct SkyBackdrop: View {
|
|
var largeCloud = false
|
|
|
|
@State private var breathe = false
|
|
@State private var glide = false
|
|
|
|
private static let skyStops: [Gradient.Stop] = [
|
|
.init(color: Color(hex: 0x7DD3FC), location: 0),
|
|
.init(color: Color(hex: 0x38BDF8), location: 0.20),
|
|
.init(color: Color(hex: 0x0EA5E9), location: 0.40),
|
|
.init(color: Color(hex: 0x0284C7), location: 0.62),
|
|
.init(color: Color(hex: 0x075985), location: 0.84),
|
|
.init(color: Color(hex: 0x0C4A6E), location: 1),
|
|
]
|
|
|
|
var body: some View {
|
|
GeometryReader { proxy in
|
|
let size = proxy.size
|
|
ZStack {
|
|
// sky-base: radial from the upper area, light to deep.
|
|
RadialGradient(
|
|
stops: Self.skyStops,
|
|
center: UnitPoint(x: 0.6, y: 0.18),
|
|
startRadius: 0,
|
|
endRadius: max(size.width, size.height) * 1.15
|
|
)
|
|
|
|
// sky-breathe: a soft light bloom that swells and fades.
|
|
RadialGradient(
|
|
stops: [
|
|
.init(color: Color(hex: 0xBAE6FD).opacity(0.8), location: 0),
|
|
.init(color: Color(hex: 0x38BDF8).opacity(0.4), location: 0.20),
|
|
.init(color: Color(hex: 0x0EA5E9).opacity(0.16), location: 0.40),
|
|
.init(color: .clear, location: 0.62),
|
|
],
|
|
center: UnitPoint(x: 0.6, y: 0.16),
|
|
startRadius: 0,
|
|
endRadius: max(size.width, size.height) * 1.05
|
|
)
|
|
.opacity(breathe ? 0.45 : 0.7)
|
|
.scaleEffect(breathe ? 1.05 : 1)
|
|
|
|
// sun-glow: warm haze off the top-right corner.
|
|
RadialGradient(
|
|
stops: [
|
|
.init(color: Color(hex: 0xFDE68A).opacity(0.34), location: 0),
|
|
.init(color: Color(hex: 0xFDBA74).opacity(0.12), location: 0.32),
|
|
.init(color: Color(hex: 0xBAE6FD).opacity(0.05), location: 0.55),
|
|
.init(color: .clear, location: 1),
|
|
],
|
|
center: .center,
|
|
startRadius: 0,
|
|
endRadius: 210
|
|
)
|
|
.frame(width: 420, height: 420)
|
|
.blur(radius: 36)
|
|
.position(x: size.width * 0.96, y: size.height * 0.02)
|
|
|
|
// Painterly clouds (the site's pre-rendered webp art),
|
|
// mirroring the web auth layout's placement and glide.
|
|
Image("Cloud3")
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(width: 280)
|
|
.opacity(0.6)
|
|
.position(x: size.width * -0.16 + 140, y: size.height * 0.04 + 73)
|
|
.offset(x: glide ? 40 : 0, y: glide ? -10 : 0)
|
|
|
|
Image("Cloud4")
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(width: 240)
|
|
.opacity(0.5)
|
|
.position(x: size.width * 1.14 - 120, y: size.height * 0.88 - 63)
|
|
.offset(x: glide ? -38 : 0, y: glide ? 8 : 0)
|
|
|
|
if largeCloud {
|
|
Image("Cloud1")
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(width: 360)
|
|
.opacity(0.35)
|
|
.position(x: size.width * 0.85, y: size.height * 0.42)
|
|
.offset(x: glide ? -24 : 0, y: glide ? 6 : 0)
|
|
}
|
|
}
|
|
}
|
|
.ignoresSafeArea()
|
|
.onAppear {
|
|
withAnimation(.easeInOut(duration: 15).repeatForever(autoreverses: true)) {
|
|
breathe = true
|
|
}
|
|
withAnimation(.easeInOut(duration: 19).repeatForever(autoreverses: true)) {
|
|
glide = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Where the app connects, for local dev or a self-hosted deployment.
|
|
/// Presets cover the common cases in one tap; the captcha token and realtime
|
|
/// origin live behind Advanced so the everyday view stays simple.
|
|
struct ServerConfigSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var origin = AppConfig.serverOrigin
|
|
@State private var turnstile = AppConfig.turnstileToken
|
|
@State private var realtime = AppConfig.realtimeOriginOverride
|
|
@State private var showAdvanced = false
|
|
|
|
@FocusState private var focused: Field?
|
|
private enum Field { case origin, turnstile, realtime }
|
|
|
|
private var isDefault: Bool {
|
|
origin == AppConfig.defaultOrigin
|
|
&& turnstile == AppConfig.defaultTurnstileToken
|
|
&& realtime.isEmpty
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack(alignment: .center, spacing: 14) {
|
|
Image(systemName: "globe")
|
|
.font(.system(size: 20, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 46, height: 46)
|
|
.background(
|
|
LinearGradient(colors: [Color(hex: 0x38BDF8), Color(hex: 0x0284C7)], startPoint: .topLeading, endPoint: .bottomTrailing),
|
|
in: RoundedRectangle(cornerRadius: 14)
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text("Connection")
|
|
.font(.system(size: 24, weight: .bold))
|
|
.tracking(-0.5)
|
|
Text("One app, any Warmbly server.")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer(minLength: 12)
|
|
|
|
Button {
|
|
dismiss()
|
|
} label: {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 32, height: 32)
|
|
.background(Color(.secondarySystemBackground), in: Circle())
|
|
}
|
|
.buttonStyle(PressableButtonStyle())
|
|
.accessibilityLabel("Close")
|
|
}
|
|
.padding(.bottom, 24)
|
|
|
|
// One-tap environments.
|
|
HStack(spacing: 10) {
|
|
presetChip("Warmbly Cloud", icon: "cloud.fill", active: origin == "https://api.warmbly.com") {
|
|
origin = "https://api.warmbly.com"
|
|
turnstile = ""
|
|
realtime = ""
|
|
}
|
|
presetChip("Local dev", icon: "laptopcomputer", active: origin == "http://localhost:8080") {
|
|
origin = "http://localhost:8080"
|
|
turnstile = "warmbly-local-turnstile-bypass"
|
|
realtime = ""
|
|
}
|
|
}
|
|
.padding(.bottom, 24)
|
|
|
|
fieldLabel("Server", icon: "server.rack")
|
|
configField(
|
|
TextField(AppConfig.defaultOrigin, text: $origin)
|
|
.keyboardType(.URL)
|
|
.textContentType(.URL)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.focused($focused, equals: .origin),
|
|
focused: focused == .origin
|
|
)
|
|
caption("Bare origin, no path. The app calls <origin>/v1. On a physical device, localhost won't reach your Mac; use your Mac's LAN IP or a tunnel URL instead.")
|
|
.padding(.bottom, 18)
|
|
|
|
// Advanced: rarely needed, one quiet toggle, fields styled
|
|
// exactly like the rest of the form.
|
|
Button {
|
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
|
showAdvanced.toggle()
|
|
}
|
|
} label: {
|
|
HStack(spacing: 5) {
|
|
Text(showAdvanced ? "Hide advanced options" : "Advanced options")
|
|
.font(.system(size: 14, weight: .medium))
|
|
Image(systemName: "chevron.down")
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.rotationEffect(.degrees(showAdvanced ? 180 : 0))
|
|
}
|
|
.foregroundStyle(WTheme.accent)
|
|
}
|
|
.buttonStyle(PressableButtonStyle())
|
|
.padding(.vertical, 2)
|
|
|
|
if showAdvanced {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
fieldLabel("Captcha token", icon: "checkmark.shield")
|
|
.padding(.top, 20)
|
|
configField(
|
|
TextField("Turnstile token", text: $turnstile)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.focused($focused, equals: .turnstile),
|
|
focused: focused == .turnstile
|
|
)
|
|
caption("Only for self-hosted setups. Dev backends accept the bypass token; the hosted service needs nothing here.")
|
|
.padding(.bottom, 18)
|
|
|
|
fieldLabel("Realtime server", icon: "bolt.horizontal")
|
|
configField(
|
|
TextField("Same as server (automatic)", text: $realtime)
|
|
.keyboardType(.URL)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.focused($focused, equals: .realtime),
|
|
focused: focused == .realtime
|
|
)
|
|
caption("Optional. Where live updates connect when your realtime service runs on a different host. Leave empty to let the server decide.")
|
|
}
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
|
|
if !isDefault {
|
|
Button("Reset to default") {
|
|
withAnimation(.easeOut(duration: 0.2)) {
|
|
origin = AppConfig.defaultOrigin
|
|
turnstile = AppConfig.defaultTurnstileToken
|
|
realtime = ""
|
|
}
|
|
}
|
|
.font(.system(size: 14.5, weight: .medium))
|
|
.foregroundStyle(WTheme.accent)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 22)
|
|
}
|
|
}
|
|
.padding(24)
|
|
}
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
.safeAreaInset(edge: .bottom) {
|
|
Button {
|
|
AppConfig.serverOrigin = AppConfig.normalizeOrigin(origin)
|
|
AppConfig.turnstileToken = turnstile.trimmingCharacters(in: .whitespaces)
|
|
AppConfig.realtimeOriginOverride = AppConfig.normalizeOrigin(realtime)
|
|
dismiss()
|
|
} label: {
|
|
Text("Save")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 56)
|
|
.background(
|
|
LinearGradient(colors: [Color(hex: 0x0EA5E9), Color(hex: 0x0284C7)], startPoint: .top, endPoint: .bottom),
|
|
in: RoundedRectangle(cornerRadius: 17)
|
|
)
|
|
.shadow(color: Color(hex: 0x0284C7).opacity(0.32), radius: 12, y: 6)
|
|
}
|
|
.buttonStyle(PressableButtonStyle())
|
|
.padding(.horizontal, 24)
|
|
.padding(.top, 10)
|
|
.padding(.bottom, 6)
|
|
.background(Color(.systemBackground))
|
|
}
|
|
.presentationBackground(Color(.systemBackground))
|
|
.presentationDetents([.medium, .large])
|
|
.presentationDragIndicator(.visible)
|
|
}
|
|
|
|
private func presetChip(_ title: String, icon: String, active: Bool, action: @escaping () -> Void) -> some View {
|
|
Button {
|
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { action() }
|
|
} label: {
|
|
HStack(spacing: 7) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 12.5))
|
|
Text(title)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
}
|
|
.foregroundStyle(active ? .white : .primary)
|
|
.padding(.horizontal, 15)
|
|
.frame(height: 38)
|
|
.background(
|
|
active ? AnyShapeStyle(WTheme.accent) : AnyShapeStyle(Color(.secondarySystemBackground)),
|
|
in: Capsule()
|
|
)
|
|
}
|
|
.buttonStyle(PressableButtonStyle())
|
|
}
|
|
|
|
private func fieldLabel(_ text: String, icon: String) -> some View {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 11))
|
|
Text(text.uppercased())
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.tracking(1.3)
|
|
}
|
|
.foregroundStyle(.secondary)
|
|
.padding(.bottom, 8)
|
|
}
|
|
|
|
private func configField(_ content: some View, focused: Bool) -> some View {
|
|
content
|
|
.font(.system(size: 15.5))
|
|
.padding(.horizontal, 15)
|
|
.frame(height: 52)
|
|
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 15))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 15)
|
|
.strokeBorder(focused ? WTheme.accent : .clear, lineWidth: 1.8)
|
|
)
|
|
.animation(.easeOut(duration: 0.18), value: focused)
|
|
.padding(.bottom, 8)
|
|
}
|
|
|
|
private func caption(_ text: String) -> some View {
|
|
Text(text)
|
|
.font(.system(size: 12.5))
|
|
.foregroundStyle(.tertiary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|