Files
share/apps/ios/Share/RootView.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

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