import SwiftUI enum ServerUrl { static func normalize(_ raw: String, fallback: String = SessionStore.defaultApiBase) -> String { var s = raw.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: CharacterSet(charactersIn: "/")) if s.isEmpty { return fallback.trimmingCharacters(in: CharacterSet(charactersIn: "/")) } if !s.lowercased().hasPrefix("http://") && !s.lowercased().hasPrefix("https://") { s = "https://\(s)" } return s.trimmingCharacters(in: CharacterSet(charactersIn: "/")) } } private struct AuthBackground: View { var body: some View { LinearGradient( colors: [ Color(red: 0.12, green: 0.04, blue: 0.06), McColors.bg, Color(red: 0.04, green: 0.05, blue: 0.1), ], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() } } struct ServerChoiceScreen: View { @EnvironmentObject private var app: AppModel @State private var customMode = false @State private var urlInput = "" @State private var error: String? @State private var busy = false var body: some View { ZStack { AuthBackground() VStack(spacing: 16) { Text("VMC") .font(McFonts.brand) .foregroundStyle(McColors.accent) Text("Connect to your media") .font(McFonts.title) .foregroundStyle(McColors.text) Text("VMC account or your own self-hosted server") .font(McFonts.body) .foregroundStyle(McColors.textDim) .multilineTextAlignment(.center) if !customMode { Button("Continue with VMC") { useVmc() } .buttonStyle(McPrimaryButtonStyle()) .disabled(busy) Button("Connect to your server") { customMode = true error = nil if app.store.apiBase != SessionStore.defaultApiBase { urlInput = app.store.apiBase } } .buttonStyle(McSecondaryButtonStyle()) } else { TextField("https://media.example.com", text: $urlInput) .textInputAutocapitalization(.never) .keyboardType(.URL) .autocorrectionDisabled() .padding(14) .background(McColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .foregroundStyle(McColors.text) Button(busy ? "Connecting…" : "Continue") { useCustom() } .buttonStyle(McPrimaryButtonStyle()) .disabled(busy) Button("Back") { customMode = false error = nil } .buttonStyle(McSecondaryButtonStyle()) } if let error { Text(error).font(McFonts.body).foregroundStyle(McColors.danger) } } .padding(28) .frame(maxWidth: 420) .background(McColors.bgElevated.opacity(0.92), in: RoundedRectangle(cornerRadius: 22, style: .continuous)) .padding() } } private func useVmc() { busy = true error = nil app.store.setApiBase(SessionStore.defaultApiBase) Task { await app.api.configure(baseUrl: app.store.apiBase, token: nil) busy = false app.phase = .auth } } private func useCustom() { guard !urlInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { error = "Enter a server URL" return } busy = true error = nil app.store.setApiBase(ServerUrl.normalize(urlInput)) Task { await app.api.configure(baseUrl: app.store.apiBase, token: nil) busy = false app.phase = .auth } } } struct AuthChoiceScreen: View { @EnvironmentObject private var app: AppModel var body: some View { ZStack { AuthBackground() VStack(spacing: 16) { Text("VMC") .font(McFonts.brand) .foregroundStyle(McColors.accent) Text("Sign in") .font(McFonts.title) .foregroundStyle(McColors.text) Text("Use your account email, or link this device with a code") .font(McFonts.body) .foregroundStyle(McColors.textDim) .multilineTextAlignment(.center) Button("Sign in with email") { app.phase = .password } .buttonStyle(McPrimaryButtonStyle()) Button("Link with code") { app.phase = .link } .buttonStyle(McSecondaryButtonStyle()) Button("Change server") { app.phase = .server } .buttonStyle(McSecondaryButtonStyle()) } .padding(28) .frame(maxWidth: 420) .background(McColors.bgElevated.opacity(0.92), in: RoundedRectangle(cornerRadius: 22, style: .continuous)) .padding() } } } struct PasswordLoginScreen: View { @EnvironmentObject private var app: AppModel @State private var email = "" @State private var password = "" @State private var error: String? @State private var busy = false var body: some View { ZStack { AuthBackground() VStack(spacing: 14) { Text("Sign in with email") .font(McFonts.title) .foregroundStyle(McColors.text) Text("Your VMC account credentials") .font(McFonts.caption) .foregroundStyle(McColors.textDim) TextField("Email", text: $email) .textInputAutocapitalization(.never) .keyboardType(.emailAddress) .autocorrectionDisabled() .padding(14) .background(McColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .foregroundStyle(McColors.text) SecureField("Password", text: $password) .padding(14) .background(McColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .foregroundStyle(McColors.text) if let error { Text(error).font(McFonts.body).foregroundStyle(McColors.danger).multilineTextAlignment(.center) } Button(busy ? "Signing in…" : "Sign in") { Task { await submit() } } .buttonStyle(McPrimaryButtonStyle()) .disabled(busy) Button("Back") { app.phase = .auth } .buttonStyle(McSecondaryButtonStyle()) } .padding(28) .frame(maxWidth: 420) .background(McColors.bgElevated.opacity(0.92), in: RoundedRectangle(cornerRadius: 22, style: .continuous)) .padding() } } private func submit() async { guard !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !password.isEmpty else { error = "E-mail and password required" return } busy = true error = nil await app.api.configure(baseUrl: app.store.apiBase, token: nil) do { let result = try await app.api.login( email: email.trimmingCharacters(in: .whitespacesAndNewlines), password: password, deviceName: AppModel.deviceName ) await app.didLink(token: result.refreshToken) } catch { self.error = error.localizedDescription } busy = false } }