Adopt root-level tsswift app layout
CI / test (push) Has been cancelled

Move the app payload under swift/ while keeping git, package.json, and .smartconfig.json at the repo root. This standardizes the Swift app setup so build, test, run, and watch workflows match the other repos.
This commit is contained in:
2026-04-19 01:21:43 +02:00
parent d534964601
commit a6939453f8
61 changed files with 2341 additions and 3 deletions
+233
View File
@@ -0,0 +1,233 @@
import SwiftUI
struct AppSectionTitle: View {
let title: String
var subtitle: String? = nil
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.title3.weight(.semibold))
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
}
struct AppNotice: View {
let message: String
var tone: Color = AppTheme.accent
var body: some View {
HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.font(.footnote.weight(.bold))
.foregroundStyle(tone)
Text(message)
.font(.subheadline.weight(.semibold))
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(tone.opacity(0.08), in: Capsule())
.overlay(
Capsule()
.stroke(AppTheme.border, lineWidth: 1)
)
}
}
struct AppStatusTag: View {
let title: String
var tone: Color = AppTheme.accent
var body: some View {
Text(title)
.font(.caption.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.8)
.fixedSize(horizontal: true, vertical: false)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(tone.opacity(0.12), in: Capsule())
.foregroundStyle(tone)
}
}
struct AppKeyValue: View {
let label: String
let value: String
var monospaced: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(label.uppercased())
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
Text(value)
.font(monospaced ? .subheadline.monospaced() : .subheadline.weight(.semibold))
.lineLimit(2)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct AppMetric: View {
let title: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title.uppercased())
.font(.caption.weight(.bold))
.foregroundStyle(.secondary)
Text(value)
.font(.title3.weight(.bold))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct AppTextSurface: View {
let text: String
var monospaced: Bool = false
var body: some View {
content
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(AppTheme.border, lineWidth: 1)
)
}
@ViewBuilder
private var content: some View {
#if os(watchOS)
Text(text)
.font(monospaced ? .body.monospaced() : .body)
#else
Text(text)
.font(monospaced ? .body.monospaced() : .body)
.textSelection(.enabled)
#endif
}
}
struct AppTextEditorField: View {
@Binding var text: String
var minHeight: CGFloat = 120
var monospaced: Bool = true
var body: some View {
editor
.frame(minHeight: minHeight)
.background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(AppTheme.border, lineWidth: 1)
)
}
@ViewBuilder
private var editor: some View {
#if os(watchOS)
Text(text)
.font(monospaced ? .body.monospaced() : .body)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
#else
TextEditor(text: $text)
.font(monospaced ? .body.monospaced() : .body)
.scrollContentBackground(.hidden)
.autocorrectionDisabled()
.padding(14)
#endif
}
}
struct AppActionRow: View {
let title: String
var subtitle: String? = nil
let systemImage: String
var tone: Color = AppTheme.accent
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: systemImage)
.font(.subheadline.weight(.semibold))
.foregroundStyle(tone)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
}
}
Spacer(minLength: 0)
Image(systemName: "arrow.right")
.font(.footnote.weight(.bold))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct AppActionTile: View {
let title: String
let systemImage: String
var tone: Color = AppTheme.accent
var isBusy: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .center) {
ZStack {
Circle()
.fill(tone.opacity(0.10))
.frame(width: 38, height: 38)
if isBusy {
ProgressView()
.tint(tone)
} else {
Image(systemName: systemImage)
.font(.headline.weight(.semibold))
.foregroundStyle(tone)
}
}
Spacer(minLength: 0)
Image(systemName: "arrow.up.right")
.font(.caption.weight(.bold))
.foregroundStyle(.secondary)
}
Text(title)
.font(.headline)
.multilineTextAlignment(.leading)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(16)
.frame(maxWidth: .infinity, minHeight: 92, alignment: .topLeading)
.appSurface(radius: 22)
}
}
+258
View File
@@ -0,0 +1,258 @@
import SwiftUI
#if os(macOS)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif
private extension Color {
static func adaptive(
light: (red: Double, green: Double, blue: Double, opacity: Double),
dark: (red: Double, green: Double, blue: Double, opacity: Double)
) -> Color {
#if os(macOS)
Color(
nsColor: NSColor(name: nil) { appearance in
let matchedAppearance = appearance.bestMatch(from: [.darkAqua, .vibrantDark, .aqua, .vibrantLight])
let components = matchedAppearance == .darkAqua || matchedAppearance == .vibrantDark ? dark : light
return NSColor(
red: components.red,
green: components.green,
blue: components.blue,
alpha: components.opacity
)
}
)
#elseif canImport(UIKit) && !os(watchOS)
Color(
uiColor: UIColor { traits in
let components = traits.userInterfaceStyle == .dark ? dark : light
return UIColor(
red: components.red,
green: components.green,
blue: components.blue,
alpha: components.opacity
)
}
)
#elseif os(watchOS)
Color(
red: dark.red,
green: dark.green,
blue: dark.blue,
opacity: dark.opacity
)
#else
Color(
red: light.red,
green: light.green,
blue: light.blue,
opacity: light.opacity
)
#endif
}
}
enum AppTheme {
static let accent = Color(red: 0.12, green: 0.40, blue: 0.31)
static let warmAccent = Color(red: 0.84, green: 0.71, blue: 0.48)
static let border = Color.adaptive(
light: (0.00, 0.00, 0.00, 0.08),
dark: (1.00, 1.00, 1.00, 0.12)
)
static let shadow = Color.adaptive(
light: (0.00, 0.00, 0.00, 0.05),
dark: (0.00, 0.00, 0.00, 0.32)
)
static let cardFill = Color.adaptive(
light: (1.00, 1.00, 1.00, 0.96),
dark: (0.11, 0.12, 0.14, 0.96)
)
static let mutedFill = Color.adaptive(
light: (0.972, 0.976, 0.970, 1.00),
dark: (0.16, 0.17, 0.19, 1.00)
)
static let backgroundTop = Color.adaptive(
light: (0.975, 0.978, 0.972, 1.00),
dark: (0.08, 0.09, 0.10, 1.00)
)
static let backgroundBottom = Color.adaptive(
light: (1.00, 1.00, 1.00, 1.00),
dark: (0.05, 0.06, 0.07, 1.00)
)
static let backgroundGlow = Color.adaptive(
light: (0.00, 0.00, 0.00, 0.02),
dark: (1.00, 1.00, 1.00, 0.06)
)
static let chromeFill = Color.adaptive(
light: (1.00, 1.00, 1.00, 0.98),
dark: (0.10, 0.11, 0.13, 0.98)
)
}
enum AppLayout {
static let compactHorizontalPadding: CGFloat = 16
static let regularHorizontalPadding: CGFloat = 28
static let compactVerticalPadding: CGFloat = 18
static let regularVerticalPadding: CGFloat = 28
static let compactContentWidth: CGFloat = 720
static let regularContentWidth: CGFloat = 920
static let cardRadius: CGFloat = 24
static let largeCardRadius: CGFloat = 30
static let compactSectionPadding: CGFloat = 18
static let regularSectionPadding: CGFloat = 24
static let compactSectionSpacing: CGFloat = 18
static let regularSectionSpacing: CGFloat = 24
static let compactBottomDockPadding: CGFloat = 120
static let regularBottomPadding: CGFloat = 56
static func horizontalPadding(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactHorizontalPadding : regularHorizontalPadding
}
static func verticalPadding(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactVerticalPadding : regularVerticalPadding
}
static func contentWidth(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactContentWidth : regularContentWidth
}
static func sectionPadding(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactSectionPadding : regularSectionPadding
}
static func sectionSpacing(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactSectionSpacing : regularSectionSpacing
}
}
extension View {
func appSurface(radius: CGFloat = AppLayout.cardRadius, fill: Color = AppTheme.cardFill) -> some View {
background(
fill,
in: RoundedRectangle(cornerRadius: radius, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: radius, style: .continuous)
.stroke(AppTheme.border, lineWidth: 1)
)
.shadow(color: AppTheme.shadow, radius: 12, y: 3)
}
}
struct AppBackground: View {
var body: some View {
LinearGradient(
colors: [
AppTheme.backgroundTop,
AppTheme.backgroundBottom
],
startPoint: .top,
endPoint: .bottom
)
.overlay(alignment: .top) {
Rectangle()
.fill(AppTheme.backgroundGlow)
.frame(height: 160)
.blur(radius: 60)
.offset(y: -90)
}
.ignoresSafeArea()
}
}
struct AppScrollScreen<Content: View>: View {
let compactLayout: Bool
var bottomPadding: CGFloat? = nil
let content: () -> Content
init(
compactLayout: Bool,
bottomPadding: CGFloat? = nil,
@ViewBuilder content: @escaping () -> Content
) {
self.compactLayout = compactLayout
self.bottomPadding = bottomPadding
self.content = content
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
content()
}
.frame(maxWidth: AppLayout.contentWidth(for: compactLayout), alignment: .leading)
.padding(.horizontal, AppLayout.horizontalPadding(for: compactLayout))
.padding(.top, AppLayout.verticalPadding(for: compactLayout))
.padding(.bottom, bottomPadding ?? AppLayout.verticalPadding(for: compactLayout))
.frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .center)
}
.scrollIndicators(.hidden)
}
}
struct AppPanel<Content: View>: View {
let compactLayout: Bool
let radius: CGFloat
let content: () -> Content
init(
compactLayout: Bool,
radius: CGFloat = AppLayout.cardRadius,
@ViewBuilder content: @escaping () -> Content
) {
self.compactLayout = compactLayout
self.radius = radius
self.content = content
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
content()
}
.padding(AppLayout.sectionPadding(for: compactLayout))
.frame(maxWidth: .infinity, alignment: .leading)
.appSurface(radius: radius)
}
}
struct AppBadge: View {
let title: String
var tone: Color = AppTheme.accent
var body: some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(tone)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(tone.opacity(0.10), in: Capsule())
}
}
struct AppSectionCard<Content: View>: View {
let title: String
var subtitle: String? = nil
let compactLayout: Bool
let content: () -> Content
init(
title: String,
subtitle: String? = nil,
compactLayout: Bool,
@ViewBuilder content: @escaping () -> Content
) {
self.title = title
self.subtitle = subtitle
self.compactLayout = compactLayout
self.content = content
}
var body: some View {
AppPanel(compactLayout: compactLayout) {
AppSectionTitle(title: title, subtitle: subtitle)
content()
}
}
}
+359
View File
@@ -0,0 +1,359 @@
import Combine
import Foundation
@MainActor
final class AppViewModel: ObservableObject {
@Published var suggestedPairingPayload = ""
@Published var manualPairingPayload = ""
@Published var session: AuthSession?
@Published var profile: MemberProfile?
@Published var requests: [ApprovalRequest] = []
@Published var notifications: [AppNotification] = []
@Published var notificationPermission: NotificationPermissionState = .unknown
@Published var selectedSection: AppSection = .overview
@Published var isBootstrapping = false
@Published var isAuthenticating = false
@Published var isIdentifying = false
@Published var isRefreshing = false
@Published var isNotificationCenterPresented = false
@Published var activeRequestID: ApprovalRequest.ID?
@Published var isScannerPresented = false
@Published var errorMessage: String?
private var hasBootstrapped = false
private let service: IDPServicing
private let notificationCoordinator: NotificationCoordinating
private let appStateStore: AppStateStoring
private let launchArguments: [String]
private var preferredLaunchSection: AppSection? {
guard let argument = launchArguments.first(where: { $0.hasPrefix("--mock-section=") }) else {
return nil
}
let rawValue = String(argument.dropFirst("--mock-section=".count))
if rawValue == "notifications" {
return .activity
}
return AppSection(rawValue: rawValue)
}
init(
service: IDPServicing = MockIDPService(),
notificationCoordinator: NotificationCoordinating = NotificationCoordinator(),
appStateStore: AppStateStoring = UserDefaultsAppStateStore(),
launchArguments: [String] = ProcessInfo.processInfo.arguments
) {
self.service = service
self.notificationCoordinator = notificationCoordinator
self.appStateStore = appStateStore
self.launchArguments = launchArguments
}
var pendingRequests: [ApprovalRequest] {
requests
.filter { $0.status == .pending }
.sorted { $0.createdAt > $1.createdAt }
}
var handledRequests: [ApprovalRequest] {
requests
.filter { $0.status != .pending }
.sorted { $0.createdAt > $1.createdAt }
}
var unreadNotificationCount: Int {
notifications.filter(\.isUnread).count
}
var elevatedPendingCount: Int {
pendingRequests.filter { $0.risk == .elevated }.count
}
var latestNotification: AppNotification? {
notifications.first
}
var pairedDeviceSummary: String {
session?.deviceName ?? "No active device"
}
func bootstrap() async {
guard !hasBootstrapped else { return }
hasBootstrapped = true
restorePersistedState()
isBootstrapping = true
defer { isBootstrapping = false }
notificationPermission = await notificationCoordinator.authorizationStatus()
do {
let bootstrap = try await service.bootstrap()
suggestedPairingPayload = bootstrap.suggestedPairingPayload
manualPairingPayload = session?.pairingCode ?? bootstrap.suggestedPairingPayload
if launchArguments.contains("--mock-auto-pair"),
session == nil {
await signIn(with: bootstrap.suggestedPairingPayload, transport: .preview)
if let preferredLaunchSection {
selectedSection = preferredLaunchSection
}
}
} catch {
if session == nil {
errorMessage = "Unable to prepare the app."
}
}
}
func signInWithManualPayload() async {
await signIn(with: manualPairingPayload, transport: .manual)
}
func signInWithSuggestedPayload() async {
manualPairingPayload = suggestedPairingPayload
await signIn(with: suggestedPairingPayload, transport: .preview)
}
func signIn(
with payload: String,
transport: PairingTransport = .manual,
signedGPSPosition: SignedGPSPosition? = nil
) async {
await signIn(
with: PairingAuthenticationRequest(
pairingPayload: payload,
transport: transport,
signedGPSPosition: signedGPSPosition
)
)
}
func signIn(with request: PairingAuthenticationRequest) async {
let trimmed = request.pairingPayload.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
errorMessage = "Paste or scan a pairing payload first."
return
}
let normalizedRequest = PairingAuthenticationRequest(
pairingPayload: trimmed,
transport: request.transport,
signedGPSPosition: request.signedGPSPosition
)
isAuthenticating = true
defer { isAuthenticating = false }
do {
let result = try await service.signIn(with: normalizedRequest)
session = result.session
apply(snapshot: result.snapshot)
persistCurrentState()
notificationPermission = await notificationCoordinator.authorizationStatus()
selectedSection = .overview
errorMessage = nil
isScannerPresented = false
} catch let error as AppError {
errorMessage = error.errorDescription
} catch {
errorMessage = "Unable to complete sign-in."
}
}
func identifyWithNFC(_ request: PairingAuthenticationRequest) async {
guard session != nil else {
errorMessage = "Set up this passport before proving your identity with NFC."
return
}
await submitIdentityProof(
payload: request.pairingPayload,
transport: .nfc,
signedGPSPosition: request.signedGPSPosition
)
}
func identifyWithPayload(_ payload: String, transport: PairingTransport = .qr) async {
guard session != nil else {
errorMessage = "Set up this passport before proving your identity."
return
}
await submitIdentityProof(payload: payload, transport: transport)
}
private func submitIdentityProof(
payload: String,
transport: PairingTransport,
signedGPSPosition: SignedGPSPosition? = nil
) async {
let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
errorMessage = "The provided idp.global payload was empty."
return
}
let normalizedRequest = PairingAuthenticationRequest(
pairingPayload: trimmed,
transport: transport,
signedGPSPosition: signedGPSPosition
)
isIdentifying = true
defer { isIdentifying = false }
do {
let snapshot = try await service.identify(with: normalizedRequest)
apply(snapshot: snapshot)
persistCurrentState()
errorMessage = nil
isScannerPresented = false
} catch let error as AppError {
errorMessage = error.errorDescription
} catch {
errorMessage = "Unable to complete identity proof."
}
}
func refreshDashboard() async {
guard session != nil else { return }
isRefreshing = true
defer { isRefreshing = false }
do {
let snapshot = try await service.refreshDashboard()
apply(snapshot: snapshot)
persistCurrentState()
errorMessage = nil
} catch {
errorMessage = "Unable to refresh the dashboard."
}
}
func approve(_ request: ApprovalRequest) async {
await mutateRequest(request, approve: true)
}
func reject(_ request: ApprovalRequest) async {
await mutateRequest(request, approve: false)
}
func simulateIncomingRequest() async {
guard session != nil else { return }
do {
let snapshot = try await service.simulateIncomingRequest()
apply(snapshot: snapshot)
persistCurrentState()
selectedSection = .requests
errorMessage = nil
} catch {
errorMessage = "Unable to create a mock identity check right now."
}
}
func requestNotificationAccess() async {
do {
notificationPermission = try await notificationCoordinator.requestAuthorization()
errorMessage = nil
} catch {
errorMessage = "Unable to update notification permission."
}
}
func sendTestNotification() async {
do {
try await notificationCoordinator.scheduleTestNotification(
title: "idp.global identity proof requested",
body: "A mock identity proof request is waiting in the app."
)
notificationPermission = await notificationCoordinator.authorizationStatus()
errorMessage = nil
} catch {
errorMessage = "Unable to schedule a test notification."
}
}
func markNotificationRead(_ notification: AppNotification) async {
do {
let snapshot = try await service.markNotificationRead(id: notification.id)
apply(snapshot: snapshot)
persistCurrentState()
errorMessage = nil
} catch {
errorMessage = "Unable to update the notification."
}
}
func signOut() {
appStateStore.clear()
session = nil
profile = nil
requests = []
notifications = []
selectedSection = .overview
manualPairingPayload = suggestedPairingPayload
errorMessage = nil
}
private func mutateRequest(_ request: ApprovalRequest, approve: Bool) async {
guard session != nil else { return }
activeRequestID = request.id
defer { activeRequestID = nil }
do {
let snapshot = approve
? try await service.approveRequest(id: request.id)
: try await service.rejectRequest(id: request.id)
apply(snapshot: snapshot)
persistCurrentState()
errorMessage = nil
} catch {
errorMessage = "Unable to update the identity check."
}
}
private func restorePersistedState() {
guard let state = appStateStore.load() else {
return
}
session = state.session
manualPairingPayload = state.session.pairingCode
apply(
snapshot: DashboardSnapshot(
profile: state.profile,
requests: state.requests,
notifications: state.notifications
)
)
}
private func persistCurrentState() {
guard let session, let profile else {
appStateStore.clear()
return
}
appStateStore.save(
PersistedAppState(
session: session,
profile: profile,
requests: requests,
notifications: notifications
)
)
}
private func apply(snapshot: DashboardSnapshot) {
profile = snapshot.profile
requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt }
notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt }
}
}
+54
View File
@@ -0,0 +1,54 @@
import SwiftUI
@main
struct IDPGlobalApp: App {
@StateObject private var model = AppViewModel()
var body: some Scene {
WindowGroup {
RootView(model: model)
.tint(AppTheme.accent)
.task {
await model.bootstrap()
}
.alert("Something went wrong", isPresented: errorPresented) {
Button("OK") {
model.errorMessage = nil
}
} message: {
Text(model.errorMessage ?? "")
}
}
#if os(macOS)
.defaultSize(width: 1380, height: 920)
#endif
}
private var errorPresented: Binding<Bool> {
Binding(
get: { model.errorMessage != nil },
set: { isPresented in
if !isPresented {
model.errorMessage = nil
}
}
)
}
}
private struct RootView: View {
@ObservedObject var model: AppViewModel
var body: some View {
Group {
if model.session == nil {
LoginRootView(model: model)
} else {
HomeRootView(model: model)
}
}
.background {
AppBackground()
}
}
}