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
|
||||
Reference in New Issue
Block a user