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.
274 lines
7.9 KiB
Swift
274 lines
7.9 KiB
Swift
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)
|
|
}
|
|
}
|