Add a native iOS client for relay-based file sharing
The SwiftUI app bundles the Go swarm engine through gomobile, so an iPhone is a real peer using the existing PAKE handshake, chunk verification, and relay fallback. XcodeGen builds the project and devicectl installs it on the paired phone.
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
init(hex: UInt32, opacity: Double = 1) {
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double((hex >> 16) & 0xFF) / 255,
|
||||
green: Double((hex >> 8) & 0xFF) / 255,
|
||||
blue: Double(hex & 0xFF) / 255,
|
||||
opacity: opacity
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum Theme {
|
||||
static let background = Color.black
|
||||
static let card = Color.white.opacity(0.055)
|
||||
static let cardStrong = Color.white.opacity(0.08)
|
||||
static let stroke = Color.white.opacity(0.09)
|
||||
static let mint = Color(hex: 0x5EEAD4)
|
||||
static let blue = Color(hex: 0x60A5FA)
|
||||
static let amber = Color(hex: 0xFBBF24)
|
||||
static let danger = Color(hex: 0xFB7185)
|
||||
static let secondaryText = Color.white.opacity(0.62)
|
||||
static let tertiaryText = Color.white.opacity(0.4)
|
||||
}
|
||||
|
||||
struct AppBackground: View {
|
||||
var body: some View {
|
||||
Theme.background.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
struct LogoMark: View {
|
||||
var size: CGFloat = 44
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: size * 0.29, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Theme.mint, Theme.blue],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.shadow(color: Theme.mint.opacity(0.35), radius: 16, y: 6)
|
||||
|
||||
Image(systemName: "arrow.up.arrow.down")
|
||||
.font(.system(size: size * 0.4, weight: .bold))
|
||||
.foregroundStyle(Color(hex: 0x06211C))
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct GlassCard<Content: View>: View {
|
||||
private let padding: CGFloat
|
||||
private let content: Content
|
||||
|
||||
init(padding: CGFloat = 20, @ViewBuilder content: () -> Content) {
|
||||
self.padding = padding
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Wrap the builder's tuple in a real layout container. Applying the
|
||||
// card background directly to a TupleView lets SwiftUI flatten the
|
||||
// children into the parent VStack, which visually breaks one card into
|
||||
// several rounded boxes.
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
content
|
||||
}
|
||||
.padding(padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Theme.card, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.strokeBorder(Theme.stroke, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FieldLabel: View {
|
||||
let title: String
|
||||
var hint: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||
.tracking(1.4)
|
||||
.foregroundStyle(Theme.tertiaryText)
|
||||
if let hint {
|
||||
Text(hint)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PrimaryButtonStyle: ButtonStyle {
|
||||
var tint: Color = Theme.mint
|
||||
var enabled: Bool = true
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.system(.headline, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(Color(hex: 0x06211C))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 15)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: enabled ? [tint, tint.opacity(0.78)] : [Color.white.opacity(0.16), Color.white.opacity(0.12)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
)
|
||||
.shadow(color: enabled ? tint.opacity(0.24) : .clear, radius: 14, y: 6)
|
||||
.scaleEffect(configuration.isPressed && enabled ? 0.985 : 1)
|
||||
.opacity(enabled ? 1 : 0.55)
|
||||
.animation(.easeOut(duration: 0.14), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
struct QuietButtonStyle: ButtonStyle {
|
||||
var tint: Color = Theme.secondaryText
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(tint)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.white.opacity(0.06), in: Capsule())
|
||||
.opacity(configuration.isPressed ? 0.65 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusPill: View {
|
||||
let running: Bool
|
||||
let peers: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 7) {
|
||||
Circle()
|
||||
.fill(running ? Theme.mint : Theme.tertiaryText)
|
||||
.frame(width: 7, height: 7)
|
||||
.shadow(color: running ? Theme.mint.opacity(0.8) : .clear, radius: 5)
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(running ? Theme.mint : Theme.secondaryText)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 11)
|
||||
.padding(.vertical, 7)
|
||||
.background(
|
||||
(running ? Theme.mint : Color.white).opacity(0.08),
|
||||
in: Capsule()
|
||||
)
|
||||
}
|
||||
|
||||
private var label: String {
|
||||
guard running else { return "Stopped" }
|
||||
if peers == 0 { return "Waiting for peer" }
|
||||
return "Live · \(peers) peer\(peers == 1 ? "" : "s")"
|
||||
}
|
||||
}
|
||||
|
||||
struct FileRow: View {
|
||||
let file: FileProgress
|
||||
let shareURL: URL?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 11) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Theme.blue.opacity(0.12))
|
||||
.frame(width: 36, height: 36)
|
||||
Image(systemName: file.complete ? "checkmark" : "doc.fill")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(file.complete ? Theme.mint : Theme.blue)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(file.name)
|
||||
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(.white.opacity(0.92))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if let shareURL {
|
||||
ShareLink(item: shareURL) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Theme.mint)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Theme.mint.opacity(0.1), in: Circle())
|
||||
}
|
||||
.accessibilityLabel("Share \(file.name)")
|
||||
}
|
||||
}
|
||||
|
||||
if !file.complete {
|
||||
ProgressView(value: file.complete ? 1 : file.fraction)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(Theme.blue)
|
||||
.scaleEffect(x: 1, y: 0.75, anchor: .center)
|
||||
}
|
||||
}
|
||||
.padding(13)
|
||||
.background(Color.white.opacity(0.035), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
if file.complete { return "\(file.formattedSize) · Complete" }
|
||||
let percent = Int((file.fraction * 100).rounded())
|
||||
return "\(file.formattedSize) · \(percent)% · \(file.haveChunks)/\(file.totalChunks) chunks"
|
||||
}
|
||||
}
|
||||
|
||||
struct ToastView: View {
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Theme.mint)
|
||||
Text(message)
|
||||
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 11)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.overlay { Capsule().strokeBorder(Theme.stroke, lineWidth: 1) }
|
||||
.shadow(color: .black.opacity(0.3), radius: 18, y: 8)
|
||||
}
|
||||
}
|
||||
|
||||
struct PulsingDot: View {
|
||||
@State private var animate = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.mint.opacity(0.18))
|
||||
.frame(width: 28, height: 28)
|
||||
.scaleEffect(animate ? 1.25 : 0.8)
|
||||
.opacity(animate ? 0 : 1)
|
||||
Circle()
|
||||
.fill(Theme.mint)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.easeOut(duration: 1.4).repeatForever(autoreverses: false)) {
|
||||
animate = true
|
||||
}
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user