import SwiftUI import Share struct SetupView: View { @EnvironmentObject private var model: AppModel @Binding var showImporter: Bool var body: some View { VStack(spacing: 16) { if model.mode == .send { filesCard } codeCard relayCard startButton } .animation(.easeInOut(duration: 0.22), value: model.mode) } // MARK: - Files private var filesCard: some View { GlassCard { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 3) { Text("Files") .font(.system(.headline, design: .rounded).weight(.semibold)) .foregroundStyle(.white) Text("Everything stays on your devices.") .font(.footnote) .foregroundStyle(Theme.secondaryText) } Spacer() if !model.pickedFiles.isEmpty { Button("Clear") { withAnimation(.easeInOut(duration: 0.2)) { model.clearPickedFiles() } } .buttonStyle(QuietButtonStyle(tint: Theme.secondaryText)) } } if model.pickedFiles.isEmpty { chooseButton .padding(.top, 14) } else { VStack(spacing: 8) { ForEach(model.pickedFiles) { file in pickedFileRow(file) } } .padding(.top, 14) Button { showImporter = true } label: { Label("Add more files", systemImage: "plus") .font(.system(.subheadline, design: .rounded).weight(.semibold)) .foregroundStyle(Theme.mint) .frame(maxWidth: .infinity) .padding(.vertical, 11) .background(Theme.mint.opacity(0.08), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) } .buttonStyle(.plain) .padding(.top, 10) .disabled(model.isBusy) } } } private var chooseButton: some View { Button { showImporter = true } label: { VStack(spacing: 10) { ZStack { Circle() .fill(Theme.mint.opacity(0.11)) .frame(width: 54, height: 54) if model.isBusy { ProgressView() .tint(Theme.mint) } else { Image(systemName: "folder.badge.plus") .font(.system(size: 21, weight: .medium)) .foregroundStyle(Theme.mint) } } VStack(spacing: 3) { Text(model.isBusy ? "Preparing files…" : "Choose files") .font(.system(.headline, design: .rounded).weight(.semibold)) .foregroundStyle(.white) Text("Any file type · multiple selection") .font(.footnote) .foregroundStyle(Theme.secondaryText) } } .frame(maxWidth: .infinity) .padding(.vertical, 24) .background( RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(Color.white.opacity(0.025)) ) .overlay { RoundedRectangle(cornerRadius: 18, style: .continuous) .strokeBorder( Theme.mint.opacity(0.35), style: StrokeStyle(lineWidth: 1.2, dash: [6, 5]) ) } } .buttonStyle(.plain) .disabled(model.isBusy) } private func pickedFileRow(_ file: PickedFile) -> some View { HStack(spacing: 11) { ZStack { RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Theme.blue.opacity(0.12)) .frame(width: 34, height: 34) Image(systemName: "doc.fill") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(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(file.formattedSize) .font(.caption) .foregroundStyle(Theme.secondaryText) } Spacer(minLength: 8) Button { withAnimation(.easeInOut(duration: 0.2)) { model.removePickedFile(file) } } label: { Image(systemName: "xmark") .font(.system(size: 11, weight: .bold)) .foregroundStyle(Theme.secondaryText) .frame(width: 28, height: 28) .background(Color.white.opacity(0.06), in: Circle()) } .buttonStyle(.plain) .accessibilityLabel("Remove \(file.name)") } .padding(10) .background(Color.white.opacity(0.035), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) } // MARK: - Code private var codeCard: some View { GlassCard { HStack(alignment: .firstTextBaseline) { FieldLabel( title: "Room code", hint: model.mode == .send ? "Share this with the device that should receive the files." : "Enter the code shown on the sending device." ) Spacer(minLength: 12) if model.mode == .send { Button { withAnimation(.easeInOut(duration: 0.2)) { model.newCode() } } label: { Label("New", systemImage: "arrow.clockwise") } .buttonStyle(QuietButtonStyle(tint: Theme.mint)) .accessibilityLabel("Generate a new code") } else { Button { model.pasteCode() } label: { Label("Paste", systemImage: "doc.on.clipboard") } .buttonStyle(QuietButtonStyle(tint: Theme.blue)) .accessibilityLabel("Paste a code") } } TextField("1234-word-word-word-word", text: $model.code) .font(.system(size: 15, weight: .semibold, design: .monospaced)) .foregroundStyle(.white) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.asciiCapable) .submitLabel(model.mode == .send ? .go : .join) .onSubmit { if model.mode == .send { model.startSend() } else { model.startReceive() } } .padding(.horizontal, 13) .padding(.vertical, 12) .background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 13, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 13, style: .continuous) .strokeBorder(Theme.stroke, lineWidth: 1) } .padding(.top, 12) if model.mode == .send { Text("On the Mac, run `share \(model.trimmedCode.isEmpty ? "…" : model.trimmedCode)`") .font(.system(size: 11.5, design: .monospaced)) .foregroundStyle(Theme.tertiaryText) .padding(.top, 9) .lineLimit(2) } else { Text("The code is also the encryption passphrase. It never leaves your devices.") .font(.caption) .foregroundStyle(Theme.tertiaryText) .padding(.top, 9) } } } // MARK: - Relay private var relayCard: some View { GlassCard(padding: 16) { Button { withAnimation(.easeInOut(duration: 0.22)) { model.showAdvanced.toggle() } } label: { HStack(spacing: 11) { ZStack { RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Theme.blue.opacity(0.1)) .frame(width: 34, height: 34) Image(systemName: "network") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Theme.blue) } VStack(alignment: .leading, spacing: 2) { Text("Relay") .font(.system(.subheadline, design: .rounded).weight(.semibold)) .foregroundStyle(.white) Text(model.relay.isEmpty ? ShareRelayDefault() : model.relay) .font(.system(size: 11.5, design: .monospaced)) .foregroundStyle(Theme.secondaryText) .lineLimit(1) .truncationMode(.middle) } Spacer() Image(systemName: "chevron.down") .font(.system(size: 11, weight: .bold)) .foregroundStyle(Theme.tertiaryText) .rotationEffect(.degrees(model.showAdvanced ? 180 : 0)) } .contentShape(Rectangle()) } .buttonStyle(.plain) relayStatusRow if model.showAdvanced { VStack(alignment: .leading, spacing: 9) { TextField("host:port", text: $model.relay) .font(.system(.subheadline, design: .monospaced)) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) .padding(.horizontal, 12) .padding(.vertical, 10) .background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(Theme.stroke, lineWidth: 1) } HStack(alignment: .top, spacing: 7) { Image(systemName: "info.circle") .font(.system(size: 11, weight: .semibold)) .padding(.top, 1) Text("Use your Mac's LAN address, like 192.168.1.20:9009, when you run `share relay` there. Leave it empty for the tzgyn relay.") .font(.caption) .fixedSize(horizontal: false, vertical: true) } .foregroundStyle(Theme.tertiaryText) if !model.relay.isEmpty { Button("Use tzgyn relay") { model.relay = ShareRelayDefault() } .buttonStyle(QuietButtonStyle(tint: Theme.mint)) } } .padding(.top, 13) .transition(.opacity.combined(with: .move(edge: .top))) } } .task { model.scheduleRelayCheck() } .onChange(of: model.relay) { _ in model.scheduleRelayCheck() } } private var relayStatusRow: some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 7) { if model.isCheckingRelay { ProgressView() .controlSize(.mini) .tint(Theme.blue) } else { Circle() .fill(relayStatusColor) .frame(width: 7, height: 7) } Text(relayStatusText) .font(.system(size: 11.5, weight: .medium, design: .rounded)) .foregroundStyle(relayStatusColor) .lineLimit(1) Spacer(minLength: 8) Button { model.checkRelay() } label: { Image(systemName: "arrow.clockwise") .font(.system(size: 11, weight: .bold)) .foregroundStyle(Theme.secondaryText) .frame(width: 28, height: 28) .background(Color.white.opacity(0.05), in: Circle()) } .buttonStyle(.plain) .disabled(model.isCheckingRelay) .accessibilityLabel("Check backend connectivity") } if let error = model.relayCheck?.error, !error.isEmpty, model.relayCheck?.ok == false { Text(error) .font(.system(size: 10, design: .rounded)) .foregroundStyle(Theme.danger.opacity(0.85)) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) } } .padding(.top, 10) } private var relayStatusColor: Color { if model.isCheckingRelay { return Theme.blue } guard let check = model.relayCheck else { return Theme.tertiaryText } return check.ok ? Theme.mint : Theme.danger } private var relayStatusText: String { if model.isCheckingRelay { return "Checking backend…" } guard let check = model.relayCheck else { return "Backend status unknown" } if check.ok { return "Backend reachable · \(check.latencyMs) ms" } return "Backend unreachable" } // MARK: - Start private var startButton: some View { Button { if model.mode == .send { model.startSend() } else { model.startReceive() } } label: { HStack(spacing: 9) { if model.isBusy && !model.hasRoom { ProgressView() .tint(Color(hex: 0x06211C)) } else { Image(systemName: model.mode == .send ? "paperplane.fill" : "arrow.down.circle.fill") .font(.system(size: 15, weight: .semibold)) } Text(buttonTitle) } } .buttonStyle( PrimaryButtonStyle( tint: model.mode == .send ? Theme.mint : Theme.blue, enabled: canStart ) ) .disabled(!canStart) .padding(.top, 2) } private var canStart: Bool { model.mode == .send ? model.canStartSend : model.canStartReceive } private var buttonTitle: String { if model.isBusy && !model.hasRoom { return model.pickedFiles.isEmpty ? "Preparing…" : "Joining relay…" } if model.mode == .send { return model.pickedFiles.isEmpty ? "Choose files to start" : "Start sharing" } return model.trimmedCode.isEmpty ? "Enter a room code" : "Join room" } }