feat(apple): native SwiftUI app for iOS and macOS

Add a native SwiftUI VniDrop app (Send/Receive/Settings) talking to the
Rust core via generated UniFFI Swift bindings, plus the uniffi-bindgen
helper crate. iOS uses a TabView, macOS a NavigationSplitView sidebar.
This commit is contained in:
2026-07-18 22:06:30 +02:00
parent 601cbc486e
commit ea376a382b
68 changed files with 8650 additions and 1 deletions

View File

@@ -0,0 +1,66 @@
import SwiftUI
/// Presents modal content in a native sheet. On phones it uses medium/large
/// detents with a grabber; on wider layouts the sheet is form-sized. Content is
/// wrapped in a `NavigationStack` so it gets a native title bar + Close button.
struct AdaptiveDrawer<DrawerContent: View>: ViewModifier {
@Binding var isPresented: Bool
let windowClass: WindowClass
let onDismiss: () -> Void
@ViewBuilder let drawerContent: () -> DrawerContent
func body(content: Content) -> some View {
content.sheet(
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } })
) {
SheetChrome(onClose: onDismiss) { drawerContent() }
.modifier(PhoneDetents(enabled: windowClass == .phone))
}
}
}
private struct PhoneDetents: ViewModifier {
let enabled: Bool
func body(content: Content) -> some View {
if enabled {
content
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
} else {
content.frame(minWidth: 460, minHeight: 480)
}
}
}
private struct SheetChrome<Content: View>: View {
let onClose: () -> Void
@ViewBuilder let content: () -> Content
var body: some View {
NavigationStack {
ScrollView { content().padding(.top, 4) }
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "button_close"), action: onClose)
}
}
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
}
extension View {
func adaptiveDrawer<DrawerContent: View>(
isPresented: Binding<Bool>,
windowClass: WindowClass,
onDismiss: @escaping () -> Void,
@ViewBuilder content: @escaping () -> DrawerContent
) -> some View {
modifier(AdaptiveDrawer(
isPresented: isPresented, windowClass: windowClass,
onDismiss: onDismiss, drawerContent: content
))
}
}

View File

@@ -0,0 +1,50 @@
import SwiftUI
/// Native SwiftUI button styles. Purple accent comes from the app-wide `.tint`.
/// Full-width filled button (`.borderedProminent`). Apply `.fixedSize()` at the
/// call site to shrink it to its content.
struct PrimaryButton: View {
let title: String
let action: () -> Void
var enabled: Bool = true
var body: some View {
Button(action: action) {
Text(title).frame(maxWidth: .infinity).frame(minHeight: 22)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(!enabled)
}
}
/// Full-width bordered (secondary) button.
struct SecondaryButton: View {
let title: String
let action: () -> Void
var enabled: Bool = true
var body: some View {
Button(action: action) {
Text(title).frame(maxWidth: .infinity).frame(minHeight: 22)
}
.buttonStyle(.bordered)
.controlSize(.large)
.disabled(!enabled)
}
}
/// Borderless tinted text button.
struct QuietButton: View {
let title: String
let action: () -> Void
var enabled: Bool = true
var body: some View {
Button(title, action: action)
.buttonStyle(.borderless)
.disabled(!enabled)
}
}

View File

@@ -0,0 +1,112 @@
import SwiftUI
// MARK: - StatusPill
enum PillTone { case neutral, success, warning, destructive, brand }
struct StatusPill: View {
let label: String
var tone: PillTone = .neutral
private var color: Color {
switch tone {
case .neutral: return .secondary
case .success, .brand: return VniDropColors.brandPurple
case .warning: return .orange
case .destructive: return .red
}
}
var body: some View {
HStack(spacing: 5) {
Circle().fill(color).frame(width: 6, height: 6)
Text(label).font(.caption).fontWeight(.medium).foregroundStyle(color).lineLimit(1)
}
.padding(.horizontal, 9).padding(.vertical, 4)
.background(color.opacity(0.14), in: Capsule())
}
}
// MARK: - ProgressRow
struct ProgressRow: View {
let labelKey: String
let progress: Double?
var detail: String? = nil
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(LocalizedStringKey(labelKey)).font(.subheadline).lineLimit(1)
Spacer()
if let progress {
Text("\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary)
}
}
if let detail {
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
if let progress {
ProgressView(value: progress)
} else {
ProgressView().progressViewStyle(.linear)
}
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Field
/// Labeled text field using native styling. Renders cleanly both inside a `Form`
/// row and standalone (e.g. inside a sheet).
struct Field: View {
let label: String
@Binding var value: String
var minLines: Int = 1
var enabled: Bool = true
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(label).font(.subheadline).foregroundStyle(.secondary)
Group {
if minLines > 1 {
TextField(label, text: $value, axis: .vertical)
.lineLimit(minLines, reservesSpace: true)
} else {
TextField(label, text: $value)
}
}
.textFieldStyle(.roundedBorder)
.disabled(!enabled)
}
}
}
// MARK: - EmptyStateView
/// Native-styled empty state (iOS 16 compatible; avoids iOS 17
/// `ContentUnavailableView`).
struct EmptyStateView<Actions: View>: View {
let systemImage: String
let title: String
let message: String
@ViewBuilder var actions: () -> Actions
var body: some View {
VStack(spacing: 12) {
Image(systemName: systemImage)
.font(.system(size: 52))
.foregroundStyle(.secondary)
Text(title).font(.title2).fontWeight(.semibold).multilineTextAlignment(.center)
Text(message)
.font(.body).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 420)
actions().padding(.top, 4)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 24)
.padding(.vertical, 48)
}
}

View File

@@ -0,0 +1,20 @@
import SwiftUI
/// Cross-platform decoding of raw image bytes into a SwiftUI `Image`.
enum PlatformImage {
static func from(data: Data) -> Image? {
#if os(iOS)
guard let ui = UIImage(data: data) else { return nil }
return Image(uiImage: ui)
#else
guard let ns = NSImage(data: data) else { return nil }
return Image(nsImage: ns)
#endif
}
}
#if os(iOS)
import UIKit
#else
import AppKit
#endif

View File

@@ -0,0 +1,79 @@
import SwiftUI
/// Bottom toast host driven by `UiMessageController`, ported from
/// `ui/feedback/VniDropSnackbarHost.kt`. Tone drives the accent color; errors get
/// a longer display duration.
struct SnackbarHost: View {
@ObservedObject var controller: UiMessageController
@State private var dismissTask: Task<Void, Never>?
var body: some View {
VStack {
Spacer()
if let message = controller.current {
content(for: message)
.frame(maxWidth: 520)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(.gray.opacity(0.25), lineWidth: 0.5))
.shadow(color: .black.opacity(0.15), radius: 8, y: 2)
.padding(.horizontal, 16)
.padding(.bottom, 8)
.transition(.move(edge: .bottom).combined(with: .opacity))
.id(message.id)
.onAppear { scheduleDismiss(for: message) }
}
}
.animation(.easeInOut(duration: 0.2), value: controller.current?.id)
}
@ViewBuilder
private func content(for message: UiMessage) -> some View {
let accent: Color = {
switch message.tone {
case .info: return VniDropColors.brandPurple
case .success: return .green
case .warning: return .orange
case .error: return .red
}
}()
HStack(alignment: .center, spacing: 8) {
Circle().fill(accent).frame(width: 8, height: 8)
Text(message.text.resolved())
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 10)
if let actionLabel = message.actionLabel {
Button(action: {
message.onAction?()
dismiss()
}) {
Text(actionLabel.resolved()).fontWeight(.semibold)
}
.buttonStyle(.borderless)
}
Button(action: dismiss) {
Image(systemName: "xmark")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
}
.padding(.leading, 16)
.padding(.trailing, 4)
}
private func scheduleDismiss(for message: UiMessage) {
dismissTask?.cancel()
let seconds: UInt64 = message.tone == .error ? 6 : 4
dismissTask = Task {
try? await Task.sleep(nanoseconds: seconds * 1_000_000_000)
if !Task.isCancelled { controller.advance() }
}
}
private func dismiss() {
dismissTask?.cancel()
controller.advance()
}
}

View File

@@ -0,0 +1,78 @@
import Foundation
import Combine
/// A localizable UI string: either a catalog key or dynamic text, ported from
/// `UiText` in `ui/feedback/UiMessageController.kt`.
enum UiText: Equatable {
case resource(String) // Localizable.xcstrings key
case dynamic(String)
/// Resolves to display text. Keys go through the string catalog.
func resolved() -> String {
switch self {
case .dynamic(let value): return value
case .resource(let key): return String(localized: String.LocalizationValue(key))
}
}
}
enum UiMessageTone {
case info
case success
case warning
case error
}
struct UiMessage: Identifiable {
let id = UUID()
let text: UiText
var tone: UiMessageTone = .info
var actionLabel: UiText? = nil
var onAction: (() -> Void)? = nil
}
/// Queues user-facing messages (snackbars) and dismissal requests. Ported from
/// `UiMessageController.kt`. Errors that are user cancellations are suppressed.
@MainActor
final class UiMessageController: ObservableObject {
@Published private(set) var current: UiMessage?
private var queue: [UiMessage] = []
func show(_ message: UiMessage) {
if current == nil {
current = message
} else {
queue.append(message)
}
}
@discardableResult
func tryShow(_ message: UiMessage) -> Bool {
show(message)
return true
}
/// Called by the host when the current message is dismissed or times out.
func advance() {
if queue.isEmpty {
current = nil
} else {
current = queue.removeFirst()
}
}
/// Surfaces a user-facing error. Logs the technical detail; suppresses user
/// cancellations. Mirrors `UiMessageController.error(Throwable)`.
func error(_ error: Error) {
if error.isUserCancellation {
AppLogger.info("ui", "suppressed user cancellation", ["detail": error.technicalDetail])
return
}
AppLogger.error("ui", "user-facing error", error)
show(UiMessage(text: error.toUiText(), tone: .error))
}
func error(_ text: UiText) {
show(UiMessage(text: text, tone: .error))
}
}

View File

@@ -0,0 +1,126 @@
import Foundation
import VnidropCore
/// Maps technical failures to stable, user-facing catalog keys. Ported from
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
extension Error {
func toUiText() -> UiText {
if let vni = self as? VnidropError {
switch vni {
case .Ticket:
return .resource("error_invalid_ticket")
case .Permission:
return .resource("error_permission")
case .Filesystem:
return .resource("error_filesystem")
case .Transfer(let reason):
return transferUiText(reason)
case .Repository:
return .resource("error_repository")
case .Initialization(let reason):
return initializationUiText(reason)
case .Internal(let reason):
return reasonHints(reason) ?? .resource("error_generic")
}
}
return reasonHints(technicalDetail) ?? .resource("error_generic")
}
/// True when the user intentionally backed out of a flow.
var isUserCancellation: Bool {
let haystack = technicalDetail.lowercased()
if haystack.isEmpty {
// URLError / CocoaError cancellation without a message.
if let urlError = self as? URLError, urlError.code == .cancelled { return true }
return (self as NSError).code == NSUserCancelledError
}
return haystack.contains("cancelled")
|| haystack.contains("canceled")
|| haystack.contains("user cancelled")
|| haystack.contains("user canceled")
}
/// Prefers a `VnidropError` reason; else the localized description.
var technicalDetail: String {
if let vni = self as? VnidropError {
switch vni {
case .Initialization(let r), .Ticket(let r), .Filesystem(let r),
.Transfer(let r), .Permission(let r), .Repository(let r), .Internal(let r):
return r
}
}
return (self as? LocalizedError)?.errorDescription ?? (self as NSError).localizedDescription
}
}
private func transferUiText(_ reason: String) -> UiText {
let detail = reason.lowercased()
if detail.contains("refused") || detail.contains("denied") || detail.contains("not approved") {
return .resource("error_permission")
}
return .resource("error_transfer")
}
private func initializationUiText(_ reason: String) -> UiText {
let detail = reason.lowercased()
if detail.contains("native") && detail.contains("library") {
return .resource("error_missing_native_library")
}
if detail.contains("socket") || detail.contains("bind") {
return .resource("error_socket_bind")
}
return .resource("error_initialization")
}
private func reasonHints(_ detailRaw: String) -> UiText? {
let detail = detailRaw.lowercased()
if detail.isEmpty { return nil }
if detail.contains("still starting") || detail.contains("starting up") {
return .resource("error_starting_up")
}
if detail.contains("empty") && (detail.contains("invitation") || detail.contains("ticket") || detail.contains("qr")) {
return .resource("error_invitation_empty")
}
if detail.contains("select at least one") || detail.contains("no files found") {
return .resource("error_share_empty")
}
if detail.contains("camera") {
return .resource("error_camera")
}
if detail.contains("nfc") || detail.contains("ndef")
|| (detail.contains("read-only") && detail.contains("tag"))
|| detail.contains("tag is too small") || detail.contains("no nfc tag") {
return .resource("error_nfc")
}
if detail.contains("native") && detail.contains("library") {
return .resource("error_missing_native_library")
}
if detail.contains("socket") || detail.contains("bind") {
return .resource("error_socket_bind")
}
if detail.contains("device information") || detail.contains("device info") {
return .resource("error_device_info")
}
if detail.contains("refused") || detail.contains("denied") || detail.contains("permission")
|| detail.contains("not approved") || detail.contains("waiting for approval") {
return .resource("error_permission")
}
if detail.contains("invalid ticket") || detail.contains("ticket error")
|| detail.contains("could not be read") || detail.contains("malformed")
|| detail.contains("invitation could not be opened") {
return .resource("error_invalid_ticket")
}
if detail.contains("selected")
&& (detail.contains("file") || detail.contains("folder") || detail.contains("document") || detail.contains("open")) {
return .resource("error_selection_failed")
}
if detail.contains("could not open the selected") || detail.contains("could not open selected") {
return .resource("error_selection_failed")
}
if detail.contains("document picker") || detail.contains("folder picker") || detail.contains("file descriptor")
|| detail.contains("view controller") {
return .resource("error_selection_failed")
}
return nil
}

View File

@@ -0,0 +1,27 @@
import Foundation
/// Top-level destinations, ported from `ui/navigation/AppDestination.kt`.
enum AppDestination: String, CaseIterable, Identifiable {
case send
case receive
case settings
var id: String { rawValue }
var labelKey: String {
switch self {
case .send: return "nav_send"
case .receive: return "nav_receive"
case .settings: return "nav_settings"
}
}
/// SF Symbol approximating the Compose line icon.
var systemImage: String {
switch self {
case .send: return "paperplane"
case .receive: return "tray.and.arrow.down"
case .settings: return "gearshape"
}
}
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
/// Semantic type scale mapped from the Material typography styles the Compose UI
/// uses, so screens reference the same names during the port.
enum VniType {
static let titleLarge = Font.system(size: 22, weight: .semibold)
static let bodyLarge = Font.system(size: 16)
static let bodyMedium = Font.system(size: 14)
static let bodySmall = Font.system(size: 12)
static let labelSmall = Font.system(size: 11, weight: .medium)
}

View File

@@ -0,0 +1,189 @@
import SwiftUI
/// Direct Compose port of the VniDrop semantic color tokens
/// (`shared/.../ui/theme/VniDropTheme.kt`). The app uses these semantic tokens
/// directly because a single SwiftUI/Material color scheme cannot represent the
/// full surface, border, and foreground stack.
struct VniDropColors {
let backgroundDefault: Color
let backgroundDashCanvas: Color
let backgroundDashSidebar: Color
let backgroundSurface75: Color
let backgroundSurface100: Color
let backgroundSurface200: Color
let backgroundSurface300: Color
let backgroundSurface400: Color
let backgroundMuted: Color
let backgroundControl: Color
let backgroundSelection: Color
let backgroundButton: Color
let backgroundOverlayHover: Color
let backgroundDialog: Color
let borderDefault: Color
let borderStrong: Color
let borderStronger: Color
let borderMuted: Color
let borderControl: Color
let foregroundDefault: Color
let foregroundLight: Color
let foregroundLighter: Color
let foregroundMuted: Color
let foregroundContrast: Color
let brandLink: Color
let brandButton: Color
let brandDefault: Color
let brand600: Color
let brand500: Color
let brand400: Color
let brand300: Color
let brand200: Color
let warningDefault: Color
let warning200: Color
let warning300: Color
let warning400: Color
let warning500: Color
let warning600: Color
let destructiveDefault: Color
let destructive200: Color
let destructive300: Color
let destructive400: Color
let destructive500: Color
let destructive600: Color
}
extension VniDropColors {
/// The single brand accent used app-wide as the SwiftUI tint.
static let brandPurple = Color.hsl(271, 91, 65)
static let light = VniDropColors(
backgroundDefault: .hsl(0, 0, 98.8),
backgroundDashCanvas: .hsl(0, 0, 97.3),
backgroundDashSidebar: .hsl(0, 0, 98.8),
backgroundSurface75: .hsl(0, 0, 100),
backgroundSurface100: .hsl(0, 0, 98.8),
backgroundSurface200: .hsl(0, 0, 95.3),
backgroundSurface300: .hsl(0, 0, 92.9),
backgroundSurface400: .hsl(0, 0, 89.8),
backgroundMuted: .hsl(0, 0, 96.9),
backgroundControl: .hsl(0, 0, 95.3),
backgroundSelection: .hsl(0, 0, 92.9),
backgroundButton: .hsl(0, 0, 91),
backgroundOverlayHover: .hsl(0, 0, 95.3),
backgroundDialog: .hsl(0, 0, 100),
borderDefault: .hsl(0, 0, 87.5),
borderStrong: .hsl(0, 0, 83.1),
borderStronger: .hsl(0, 0, 56.1),
borderMuted: .hsl(0, 0, 92.9),
borderControl: .hsl(0, 0, 78),
foregroundDefault: .hsl(0, 0, 9),
foregroundLight: .hsl(0, 0, 32.2),
foregroundLighter: .hsl(0, 0, 43.9),
foregroundMuted: .hsl(0, 0, 69.8),
foregroundContrast: .hsl(0, 0, 98.4),
brandLink: .hsl(271, 91, 65),
brandButton: .hsl(270, 95, 75),
brandDefault: .hsl(271, 91, 65),
brand600: .hsl(271, 81, 56),
brand500: .hsl(271, 91, 65),
brand400: .hsl(270, 95, 75),
brand300: .hsl(269, 97, 85),
brand200: .hsl(269, 100, 92),
warningDefault: .hsl(38.9, 100, 57.1),
warning200: .hsl(40, 81.8, 97.8),
warning300: .hsl(44.3, 100, 91.8),
warning400: .hsl(41.9, 100, 81.8),
warning500: .hsl(36.3, 85.7, 67.1),
warning600: .hsl(30.3, 80.3, 47.8),
destructiveDefault: .hsl(10.2, 77.9, 53.9),
destructive200: .hsl(0, 100, 99.4),
destructive300: .hsl(7.1, 100, 96.7),
destructive400: .hsl(7.1, 91.3, 91),
destructive500: .hsl(10.4, 77.1, 79.4),
destructive600: .hsl(9.9, 82, 43.5)
)
static let dark = VniDropColors(
backgroundDefault: .hsl(0, 0, 7.1),
backgroundDashCanvas: .hsl(0, 0, 7.1),
backgroundDashSidebar: .hsl(0, 0, 9),
backgroundSurface75: .hsl(0, 0, 9),
backgroundSurface100: .hsl(0, 0, 12.2),
backgroundSurface200: .hsl(0, 0, 12.9),
backgroundSurface300: .hsl(0, 0, 16.1),
backgroundSurface400: .hsl(0, 0, 16.1),
backgroundMuted: .hsl(0, 0, 14.1),
backgroundControl: .hsl(0, 0, 14.1),
backgroundSelection: .hsl(0, 0, 19.2),
backgroundButton: .hsl(0, 0, 18),
backgroundOverlayHover: .hsl(0, 0, 18),
backgroundDialog: .hsl(0, 0, 7.1),
borderDefault: .hsl(0, 0, 18),
borderStrong: .hsl(0, 0, 21.2),
borderStronger: .hsl(0, 0, 27.1),
borderMuted: .hsl(0, 0, 14.1),
borderControl: .hsl(0, 0, 22.4),
foregroundDefault: .hsl(0, 0, 98),
foregroundLight: .hsl(0, 0, 70.6),
foregroundLighter: .hsl(0, 0, 53.7),
foregroundMuted: .hsl(0, 0, 30.2),
foregroundContrast: .hsl(0, 0, 8.6),
brandLink: .hsl(270, 95, 75),
brandButton: .hsl(271, 81, 56),
brandDefault: .hsl(270, 95, 75),
brand600: .hsl(271, 91, 65),
brand500: .hsl(271, 81, 56),
brand400: .hsl(273, 67, 39),
brand300: .hsl(274, 66, 32),
brand200: .hsl(274, 87, 21),
warningDefault: .hsl(38.9, 100, 42.9),
warning200: .hsl(36.6, 100, 8),
warning300: .hsl(32.3, 100, 10.2),
warning400: .hsl(33.2, 100, 14.5),
warning500: .hsl(34.8, 90.9, 21.6),
warning600: .hsl(38.9, 100, 42.9),
destructiveDefault: .hsl(10.2, 77.9, 53.9),
destructive200: .hsl(10.9, 23.4, 9.2),
destructive300: .hsl(7.5, 51.3, 15.3),
destructive400: .hsl(6.7, 60, 20.6),
destructive500: .hsl(7.9, 71.6, 29),
destructive600: .hsl(9.7, 85.2, 62.9)
)
}
extension Color {
/// HSL constructor matching the Compose `hsl()` helper (hue in degrees,
/// saturation and lightness in percent).
static func hsl(_ hue: Double, _ saturation: Double, _ lightness: Double) -> Color {
let h = (hue.truncatingRemainder(dividingBy: 360) + 360)
.truncatingRemainder(dividingBy: 360) / 360
let s = min(max(saturation, 0), 100) / 100
let l = min(max(lightness, 0), 100) / 100
if s == 0 {
return Color(red: l, green: l, blue: l)
}
let q = l < 0.5 ? l * (1 + s) : l + s - l * s
let p = 2 * l - q
return Color(
red: hueToRgb(p, q, h + 1.0 / 3.0),
green: hueToRgb(p, q, h),
blue: hueToRgb(p, q, h - 1.0 / 3.0)
)
}
}
private func hueToRgb(_ p: Double, _ q: Double, _ input: Double) -> Double {
var t = input
if t < 0 { t += 1 }
if t > 1 { t -= 1 }
let value: Double
if t < 1.0 / 6.0 {
value = p + (q - p) * 6 * t
} else if t < 1.0 / 2.0 {
value = q
} else if t < 2.0 / 3.0 {
value = p + (q - p) * (2.0 / 3.0 - t) * 6
} else {
value = p
}
return min(1, max(0, value))
}

View File

@@ -0,0 +1,56 @@
import SwiftUI
/// User-facing theme selection, mirrors `ThemeMode` in the Compose theme.
enum ThemeMode: String, CaseIterable, Codable, Sendable {
case system
case light
case dark
/// SwiftUI color-scheme override (`nil` follows the system).
var preferredColorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
}
func resolveDarkTheme(_ mode: ThemeMode, systemDark: Bool) -> Bool {
switch mode {
case .system: return systemDark
case .light: return false
case .dark: return true
}
}
private struct VniDropColorsKey: EnvironmentKey {
static let defaultValue = VniDropColors.light
}
extension EnvironmentValues {
/// Semantic VniDrop tokens for the active theme. Read with
/// `@Environment(\.vniColors) private var colors`.
var vniColors: VniDropColors {
get { self[VniDropColorsKey.self] }
set { self[VniDropColorsKey.self] = newValue }
}
}
/// Provides the semantic token set matching the resolved light/dark theme to the
/// whole subtree. Apply once near the app root.
struct VniDropTheme: ViewModifier {
let isDark: Bool
func body(content: Content) -> some View {
content
.environment(\.vniColors, isDark ? .dark : .light)
.tint(VniDropColors.brandPurple)
}
}
extension View {
func vniDropTheme(isDark: Bool) -> some View {
modifier(VniDropTheme(isDark: isDark))
}
}