Files
share/apps/ios/Share/Components.swift
T
TZGyn 9a37e93cfc 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.
2026-10-26 22:24:47 +08:00

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)
}
}