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 /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) } }