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,169 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct RootView: View {
|
||||
@EnvironmentObject private var model: AppModel
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var showImporter = false
|
||||
@Namespace private var modeNamespace
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 22) {
|
||||
header
|
||||
modePicker
|
||||
content
|
||||
footer
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, 36)
|
||||
.frame(maxWidth: 620)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
|
||||
if let notice = model.notice {
|
||||
VStack {
|
||||
Spacer()
|
||||
ToastView(message: notice)
|
||||
.padding(.bottom, 24)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: model.notice)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showImporter,
|
||||
allowedContentTypes: [.data],
|
||||
allowsMultipleSelection: true
|
||||
) { result in
|
||||
model.handleImport(result)
|
||||
}
|
||||
.alert(
|
||||
"Something went wrong",
|
||||
isPresented: Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { if !$0 { model.errorMessage = nil } }
|
||||
)
|
||||
) {
|
||||
Button("OK", role: .cancel) { model.errorMessage = nil }
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
.onChange(of: scenePhase) { phase in
|
||||
model.handleScenePhase(phase)
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .center, spacing: 13) {
|
||||
LogoMark()
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("share")
|
||||
.font(.system(size: 27, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
Text("direct device-to-device transfer")
|
||||
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var modePicker: some View {
|
||||
HStack(spacing: 5) {
|
||||
ForEach(RoomMode.allCases) { mode in
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) {
|
||||
model.mode = mode
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: mode.icon)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
Text(mode.title)
|
||||
.font(.system(.subheadline, design: .rounded).weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
.foregroundStyle(
|
||||
model.mode == mode ? Color(hex: 0x06211C) : Theme.secondaryText
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background {
|
||||
if model.mode == mode {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: mode == .send
|
||||
? [Theme.mint, Theme.mint.opacity(0.8)]
|
||||
: [Theme.blue, Theme.blue.opacity(0.8)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.matchedGeometryEffect(id: "mode", in: modeNamespace)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityAddTraits(model.mode == mode ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(4)
|
||||
.background(Color.white.opacity(0.055), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(Theme.stroke, lineWidth: 1)
|
||||
}
|
||||
.disabled(model.hasRoom || model.isBusy)
|
||||
.opacity(model.hasRoom || model.isBusy ? 0.55 : 1)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if model.hasRoom {
|
||||
RoomView()
|
||||
.transition(.asymmetric(
|
||||
insertion: .move(edge: .trailing).combined(with: .opacity),
|
||||
removal: .move(edge: .leading).combined(with: .opacity)
|
||||
))
|
||||
} else {
|
||||
SetupView(showImporter: $showImporter)
|
||||
.transition(.asymmetric(
|
||||
insertion: .move(edge: .leading).combined(with: .opacity),
|
||||
removal: .move(edge: .trailing).combined(with: .opacity)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
VStack(spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "lock.shield")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
Text("End-to-end encrypted with a room-code key")
|
||||
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||||
}
|
||||
.foregroundStyle(Theme.tertiaryText)
|
||||
|
||||
Text("The relay only helps peers find each other. File data goes directly between devices, or through an encrypted relay pipe when a direct path is blocked.")
|
||||
.font(.system(size: 10.5, design: .rounded))
|
||||
.foregroundStyle(Theme.tertiaryText.opacity(0.8))
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(2)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user