mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
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:
66
apple/VniDrop/UI/Components/AdaptiveDrawer.swift
Normal file
66
apple/VniDrop/UI/Components/AdaptiveDrawer.swift
Normal 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
|
||||
))
|
||||
}
|
||||
}
|
||||
50
apple/VniDrop/UI/Components/Buttons.swift
Normal file
50
apple/VniDrop/UI/Components/Buttons.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
112
apple/VniDrop/UI/Components/Components.swift
Normal file
112
apple/VniDrop/UI/Components/Components.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
20
apple/VniDrop/UI/Components/PlatformImage.swift
Normal file
20
apple/VniDrop/UI/Components/PlatformImage.swift
Normal 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
|
||||
79
apple/VniDrop/UI/Feedback/SnackbarHost.swift
Normal file
79
apple/VniDrop/UI/Feedback/SnackbarHost.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
78
apple/VniDrop/UI/Feedback/UiMessage.swift
Normal file
78
apple/VniDrop/UI/Feedback/UiMessage.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
126
apple/VniDrop/UI/Feedback/UserFacingError.swift
Normal file
126
apple/VniDrop/UI/Feedback/UserFacingError.swift
Normal 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
|
||||
}
|
||||
27
apple/VniDrop/UI/Navigation/AppDestination.swift
Normal file
27
apple/VniDrop/UI/Navigation/AppDestination.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
11
apple/VniDrop/UI/Theme/Typography.swift
Normal file
11
apple/VniDrop/UI/Theme/Typography.swift
Normal 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)
|
||||
}
|
||||
189
apple/VniDrop/UI/Theme/VniDropColors.swift
Normal file
189
apple/VniDrop/UI/Theme/VniDropColors.swift
Normal 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))
|
||||
}
|
||||
56
apple/VniDrop/UI/Theme/VniDropTheme.swift
Normal file
56
apple/VniDrop/UI/Theme/VniDropTheme.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user