301 lines
10 KiB
Swift
301 lines
10 KiB
Swift
import SwiftUI
|
|
|
|
private let loginAccent = Color(red: 0.12, green: 0.40, blue: 0.31)
|
|
private let loginGold = Color(red: 0.90, green: 0.79, blue: 0.60)
|
|
|
|
struct LoginRootView: View {
|
|
@ObservedObject var model: AppViewModel
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: compactLayout ? 18 : 24) {
|
|
LoginHeroPanel(model: model, compactLayout: compactLayout)
|
|
PairingConsoleCard(model: model, compactLayout: compactLayout)
|
|
TrustFootprintCard(model: model, compactLayout: compactLayout)
|
|
}
|
|
.frame(maxWidth: 1040)
|
|
.padding(compactLayout ? 18 : 28)
|
|
}
|
|
.sheet(isPresented: $model.isScannerPresented) {
|
|
QRScannerSheet(
|
|
seededPayload: model.suggestedQRCodePayload,
|
|
onCodeScanned: { payload in
|
|
model.manualQRCodePayload = payload
|
|
Task {
|
|
await model.signIn(with: payload)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
private var compactLayout: Bool {
|
|
#if os(iOS)
|
|
horizontalSizeClass == .compact
|
|
#else
|
|
false
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private struct LoginHeroPanel: View {
|
|
@ObservedObject var model: AppViewModel
|
|
let compactLayout: Bool
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .bottomLeading) {
|
|
RoundedRectangle(cornerRadius: 36, style: .continuous)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
Color(red: 0.13, green: 0.22, blue: 0.19),
|
|
Color(red: 0.20, green: 0.41, blue: 0.33),
|
|
loginGold
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: compactLayout ? 16 : 18) {
|
|
Text("Bind this device to your idp.global account")
|
|
.font(.system(size: compactLayout ? 32 : 44, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
|
|
Text("Scan the pairing QR from your account to turn this device into your approval and notification app.")
|
|
.font(compactLayout ? .body : .title3)
|
|
.foregroundStyle(.white.opacity(0.88))
|
|
|
|
if compactLayout {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HeroTag(title: "Account binding")
|
|
HeroTag(title: "QR pairing")
|
|
HeroTag(title: "iPhone, iPad, Mac")
|
|
}
|
|
} else {
|
|
HStack(spacing: 12) {
|
|
HeroTag(title: "Account binding")
|
|
HeroTag(title: "QR pairing")
|
|
HeroTag(title: "iPhone, iPad, Mac")
|
|
}
|
|
}
|
|
|
|
if model.isBootstrapping {
|
|
ProgressView("Preparing preview pairing payload…")
|
|
.tint(.white)
|
|
}
|
|
}
|
|
.padding(compactLayout ? 22 : 32)
|
|
}
|
|
.frame(minHeight: compactLayout ? 280 : 320)
|
|
}
|
|
}
|
|
|
|
private struct PairingConsoleCard: View {
|
|
@ObservedObject var model: AppViewModel
|
|
let compactLayout: Bool
|
|
|
|
var body: some View {
|
|
LoginCard(title: "Bind your account", subtitle: "Scan the QR code from your idp.global account or use the preview payload while backend wiring is still in progress.") {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Open your account pairing screen, then scan the QR code here.")
|
|
.font(.headline)
|
|
Text("If you are testing the preview build without the live backend yet, the seeded payload below will still bind the mock session.")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
TextEditor(text: $model.manualQRCodePayload)
|
|
.font(.body.monospaced())
|
|
.scrollContentBackground(.hidden)
|
|
.padding(16)
|
|
.frame(minHeight: compactLayout ? 130 : 150)
|
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
|
|
|
if model.isAuthenticating {
|
|
HStack(spacing: 10) {
|
|
ProgressView()
|
|
Text("Binding this device to your account…")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Group {
|
|
if compactLayout {
|
|
VStack(spacing: 12) {
|
|
primaryButtons
|
|
secondaryButtons
|
|
}
|
|
} else {
|
|
VStack(spacing: 12) {
|
|
HStack(spacing: 12) {
|
|
primaryButtons
|
|
}
|
|
HStack(spacing: 12) {
|
|
secondaryButtons
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var primaryButtons: some View {
|
|
Button {
|
|
model.isScannerPresented = true
|
|
} label: {
|
|
Label("Bind With QR Code", systemImage: "qrcode.viewfinder")
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
|
|
Button {
|
|
Task {
|
|
await model.signInWithManualCode()
|
|
}
|
|
} label: {
|
|
if model.isAuthenticating {
|
|
ProgressView()
|
|
} else {
|
|
Label("Bind With Payload", systemImage: "arrow.right.circle.fill")
|
|
}
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(model.isAuthenticating)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var secondaryButtons: some View {
|
|
Button {
|
|
Task {
|
|
await model.signInWithSuggestedCode()
|
|
}
|
|
} label: {
|
|
Label("Use Preview QR", systemImage: "wand.and.stars")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
Text("This preview keeps the account-binding flow realistic while the live API is still being wired in.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .trailing)
|
|
}
|
|
}
|
|
|
|
private struct TrustFootprintCard: View {
|
|
@ObservedObject var model: AppViewModel
|
|
let compactLayout: Bool
|
|
|
|
var body: some View {
|
|
LoginCard(title: "About this build", subtitle: "Keep the first-run screen simple, but still explain the trust context and preview status clearly.") {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
if compactLayout {
|
|
VStack(spacing: 12) {
|
|
trustFacts
|
|
}
|
|
} else {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
trustFacts
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Preview Pairing Payload")
|
|
.font(.headline)
|
|
Text(model.suggestedQRCodePayload.isEmpty ? "Preparing preview payload…" : model.suggestedQRCodePayload)
|
|
.font(.footnote.monospaced())
|
|
.foregroundStyle(.secondary)
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var trustFacts: some View {
|
|
TrustFactCard(
|
|
icon: "person.badge.key.fill",
|
|
title: "Account Binding",
|
|
message: "This device binds to your idp.global account and becomes your place for approvals and alerts."
|
|
)
|
|
TrustFactCard(
|
|
icon: "person.2.badge.gearshape.fill",
|
|
title: "Built by foss.global",
|
|
message: "foss.global is the open-source collective behind idp.global and the current preview environment."
|
|
)
|
|
TrustFactCard(
|
|
icon: "bolt.badge.clock",
|
|
title: "Preview Backend",
|
|
message: "Login, requests, and notifications are mocked behind a clean service boundary until live integration is ready."
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct LoginCard<Content: View>: View {
|
|
let title: String
|
|
let subtitle: String
|
|
let content: () -> Content
|
|
|
|
init(title: String, subtitle: String, @ViewBuilder content: @escaping () -> Content) {
|
|
self.title = title
|
|
self.subtitle = subtitle
|
|
self.content = content
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(title)
|
|
.font(.title2.weight(.semibold))
|
|
Text(subtitle)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
content()
|
|
}
|
|
.padding(24)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color.white.opacity(0.68), in: RoundedRectangle(cornerRadius: 32, style: .continuous))
|
|
}
|
|
}
|
|
|
|
private struct HeroTag: View {
|
|
let title: String
|
|
|
|
var body: some View {
|
|
Text(title)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 9)
|
|
.background(.white.opacity(0.14), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
}
|
|
}
|
|
|
|
private struct TrustFactCard: View {
|
|
let icon: String
|
|
let title: String
|
|
let message: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Image(systemName: icon)
|
|
.font(.title2)
|
|
.foregroundStyle(loginAccent)
|
|
|
|
Text(title)
|
|
.font(.headline)
|
|
|
|
Text(message)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(18)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
|
}
|
|
}
|