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.
170 lines
5.1 KiB
Swift
170 lines
5.1 KiB
Swift
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)
|
|
}
|
|
}
|