initial commit: Proximity iOS app proposal, full Swift code scaffold, reference backend, testing docs, generated Xcode project
This commit is contained in:
110
Proximity/Views/ChatView.swift
Normal file
110
Proximity/Views/ChatView.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The conversation screen for a revealed connection.
|
||||
///
|
||||
/// This is where two people who met in the real world actually talk. The UI
|
||||
/// is deliberately calm and human — no engagement-baiting, no streaks, no
|
||||
/// algorithms. Just a conversation between two people who chose to meet.
|
||||
struct ChatView: View {
|
||||
@EnvironmentObject private var vm: ChatViewModel
|
||||
@EnvironmentObject private var revealFlow: RevealFlow
|
||||
@State private var input = ""
|
||||
|
||||
let connection: Connection
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(Color.green.opacity(0.2))
|
||||
.frame(width: 40, height: 40)
|
||||
.overlay(Text(String(connection.displayName.prefix(1))))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(connection.displayName)
|
||||
.font(.headline)
|
||||
Text("Met in person · \(connection.createdAt, format: .dateTime.month().day())")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption)
|
||||
.foregroundColor(.green)
|
||||
.accessibilityLabel("End-to-end encrypted")
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
|
||||
Divider()
|
||||
|
||||
// Messages
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(vm.messages) { message in
|
||||
MessageBubble(message: message, isMine: message.senderID == "me")
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onChange(of: vm.messages.count) { _, _ in
|
||||
if let last = vm.messages.last {
|
||||
withAnimation {
|
||||
proxy.scrollTo(last.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Input
|
||||
HStack(spacing: 12) {
|
||||
TextField("Say hello…", text: $input, axis: .vertical)
|
||||
.lineLimit(1...4)
|
||||
.padding(10)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
|
||||
Button {
|
||||
vm.draft = input
|
||||
input = ""
|
||||
Task { await vm.send() }
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.system(size: 28))
|
||||
}
|
||||
.disabled(input.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
// Use the E2E session key established at reveal time.
|
||||
vm.start(with: connection, sessionKey: revealFlow.currentSessionKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single message bubble.
|
||||
struct MessageBubble: View {
|
||||
@EnvironmentObject private var vm: ChatViewModel
|
||||
let message: ChatMessage
|
||||
let isMine: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if isMine { Spacer() }
|
||||
Text(vm.text(for: message))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.fill(isMine ? Color.green : Color(.secondarySystemBackground))
|
||||
)
|
||||
.foregroundColor(isMine ? .white : .primary)
|
||||
if !isMine { Spacer() }
|
||||
}
|
||||
}
|
||||
}
|
||||
67
Proximity/Views/ConnectionsView.swift
Normal file
67
Proximity/Views/ConnectionsView.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The list of revealed, mutually-consented connections.
|
||||
///
|
||||
/// This list is the *proof* of the product's thesis: every connection here
|
||||
/// began as a real-world encounter. There is no way to add someone you've
|
||||
/// never been near.
|
||||
struct ConnectionsView: View {
|
||||
@EnvironmentObject private var chatViewModel: ChatViewModel
|
||||
// In production, sourced from GraphStore via a ConnectionsViewModel.
|
||||
private let connections: [Connection] = []
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if connections.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
List(connections) { connection in
|
||||
NavigationLink(value: connection) {
|
||||
ConnectionRow(connection: connection)
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: Connection.self) { connection in
|
||||
ChatView(connection: connection)
|
||||
.environmentObject(chatViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connections")
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "person.2")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
Text("No connections yet")
|
||||
.font(.headline)
|
||||
Text("Connections only form when you're physically near someone —\nand you both choose to reveal. Go be present.")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectionRow: View {
|
||||
let connection: Connection
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color.green.opacity(0.2))
|
||||
.frame(width: 40, height: 40)
|
||||
.overlay(Text(String(connection.displayName.prefix(1))))
|
||||
VStack(alignment: .leading) {
|
||||
Text(connection.displayName)
|
||||
.font(.headline)
|
||||
Text("Met \(connection.createdAt, format: .dateTime.month().day())")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
Proximity/Views/ContentView.swift
Normal file
18
Proximity/Views/ContentView.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Root navigation. Tabs: Presence (the live feed) and Connections.
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
PresenceView()
|
||||
.tabItem {
|
||||
Label("Present", systemImage: "dot.radiowaves.left.and.right")
|
||||
}
|
||||
|
||||
ConnectionsView()
|
||||
.tabItem {
|
||||
Label("Connections", systemImage: "person.2")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
48
Proximity/Views/DebugMenu.swift
Normal file
48
Proximity/Views/DebugMenu.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Developer debug menu, available only in DEBUG builds.
|
||||
///
|
||||
/// This is the primary tool for testing the **interaction loop** without any
|
||||
/// hardware. It drives a `SimulatedAdapter` inside `PresenceEngine`, letting
|
||||
/// you simulate encounters, waves, and even a crowd — exactly as if real
|
||||
/// users were walking by. In the simulator, you can fully exercise silhouette
|
||||
/// → wave → reveal → chat with zero physical devices.
|
||||
///
|
||||
/// Enabled via the `#if DEBUG` guard so it never ships to production.
|
||||
struct DebugMenu: View {
|
||||
@EnvironmentObject private var appState: AppState
|
||||
|
||||
var body: some View {
|
||||
#if DEBUG
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Simulation") {
|
||||
Button("Simulate someone walking by") {
|
||||
appState.simulateEncounter()
|
||||
}
|
||||
Button("Simulate a crowd (20 people)") {
|
||||
appState.simulateCrowd()
|
||||
}
|
||||
Toggle("Auto-broadcast nearby users", isOn: $appState.autoBroadcast)
|
||||
}
|
||||
|
||||
Section("Backend") {
|
||||
Label("Local server (localhost:8080)", systemImage: "network")
|
||||
.foregroundColor(.secondary)
|
||||
Button("Reconnect socket") {
|
||||
appState.restartRealtime()
|
||||
}
|
||||
}
|
||||
|
||||
Section("Info") {
|
||||
Label("Simulator mode — no hardware", systemImage: "iphone.simulator")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Debug")
|
||||
}
|
||||
#else
|
||||
EmptyView()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
144
Proximity/Views/PresenceView.swift
Normal file
144
Proximity/Views/PresenceView.swift
Normal file
@@ -0,0 +1,144 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The main presence screen.
|
||||
///
|
||||
/// This is the emotional core of the app. A large, tactile "I am here"
|
||||
/// toggle that makes being present feel like an intentional act — not a
|
||||
/// passive background process. Below it, the live feed of silhouettes.
|
||||
struct PresenceView: View {
|
||||
@EnvironmentObject private var vm: PresenceViewModel
|
||||
@EnvironmentObject private var revealFlow: RevealFlow
|
||||
@State private var showReveal = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
presenceToggle
|
||||
radiusPicker
|
||||
encounterFeed
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Proximity")
|
||||
.toolbar {
|
||||
#if DEBUG
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
NavigationLink {
|
||||
DebugMenu()
|
||||
} label: {
|
||||
Image(systemName: "hammer")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.onChange(of: revealFlow.phase) { _, phase in
|
||||
if case .mutual = phase {
|
||||
showReveal = true
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showReveal) {
|
||||
RevealView()
|
||||
.environmentObject(revealFlow)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The presence toggle
|
||||
|
||||
private var presenceToggle: some View {
|
||||
Button(action: vm.togglePresence) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(vm.isPresent ? Color.green : Color.gray.opacity(0.2))
|
||||
.frame(width: 200, height: 200)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(vm.isPresent ? Color.green : Color.gray,
|
||||
lineWidth: 4)
|
||||
)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: vm.isPresent
|
||||
? "dot.radiowaves.left.and.right"
|
||||
: "person.crop.circle.dashed")
|
||||
.font(.system(size: 56))
|
||||
Text(vm.isPresent ? "I'm Here" : "Tap to be Present")
|
||||
.font(.headline)
|
||||
}
|
||||
.foregroundColor(vm.isPresent ? .white : .secondary)
|
||||
}
|
||||
.scaleEffect(vm.isPresent ? 1.0 : 0.98)
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.6),
|
||||
value: vm.isPresent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(vm.isPresent ? "You are present. Tap to go offline."
|
||||
: "Go present")
|
||||
}
|
||||
|
||||
// MARK: - Radius picker
|
||||
|
||||
private var radiusPicker: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("How far are you open to?")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Picker("Radius", selection: $vm.radiusTier) {
|
||||
ForEach(DistanceTier.allCases) { tier in
|
||||
Text(tier.title).tag(tier)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Text(vm.radiusTier.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Encounter feed
|
||||
|
||||
private var encounterFeed: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("People you've passed")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if let last = vm.lastScan {
|
||||
Text("last scan \(last, style: .time)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if vm.encounters.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ForEach(vm.encounters) { encounter in
|
||||
SilhouetteRow(encounter: encounter) {
|
||||
// Tap a silhouette → consider it → wave.
|
||||
revealFlow.consider(encounter)
|
||||
Task { await revealFlow.wave() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "eye.slash")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.secondary)
|
||||
Text(vm.isPresent
|
||||
? "Looking for people near you…"
|
||||
: "Go present to start seeing people.")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
}
|
||||
126
Proximity/Views/ProfileImportSheet.swift
Normal file
126
Proximity/Views/ProfileImportSheet.swift
Normal file
@@ -0,0 +1,126 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Shown at **reveal time** when a connection becomes mutual.
|
||||
///
|
||||
/// This is the only moment a social identity can be attached. The user can
|
||||
/// choose to enrich their revealed profile with an Instagram or X handle —
|
||||
/// or decline and stay minimal. Importing is always optional and always
|
||||
/// scoped to this specific connection.
|
||||
struct ProfileImportSheet: View {
|
||||
@EnvironmentObject private var profileService: ProfileImportService
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// The connection that just became mutual.
|
||||
let connection: Connection
|
||||
|
||||
@State private var imported: [ImportedProfile] = []
|
||||
@State private var isImporting = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 24) {
|
||||
// Header
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "person.crop.circle.badge.checkmark")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.green)
|
||||
Text("You're connected with \(connection.displayName)")
|
||||
.font(.headline)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("Want to share a bit more about who you are?")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.top, 24)
|
||||
|
||||
// Import buttons
|
||||
VStack(spacing: 12) {
|
||||
ForEach(ProfileProviderID.allCases) { provider in
|
||||
importButton(for: provider)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Already imported
|
||||
if !imported.isEmpty {
|
||||
importedList
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Done
|
||||
Button("Done") { dismiss() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.navigationTitle("Share your profile")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
private func importButton(for provider: ProfileProviderID) -> some View {
|
||||
Button {
|
||||
Task { await importProfile(provider) }
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: provider == .instagram ? "camera" : "xmark.square")
|
||||
Text("Import from \(provider.displayName)")
|
||||
Spacer()
|
||||
if isImporting {
|
||||
ProgressView()
|
||||
} else {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isImporting)
|
||||
}
|
||||
|
||||
private var importedList: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Imported")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
ForEach(imported) { profile in
|
||||
HStack {
|
||||
Text("@\(profile.handle)")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text(profile.providerID.displayName)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Button {
|
||||
profileService.remove(profile)
|
||||
imported.removeAll { $0.id == profile.id }
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private func importProfile(_ provider: ProfileProviderID) async {
|
||||
isImporting = true
|
||||
defer { isImporting = false }
|
||||
do {
|
||||
let profile = try await profileService.importProfile(from: provider)
|
||||
profileService.attach(profile, to: connection.id)
|
||||
imported.append(profile)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
94
Proximity/Views/RevealView.swift
Normal file
94
Proximity/Views/RevealView.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The emotional centerpiece of the app: the **reveal moment**.
|
||||
///
|
||||
/// Two strangers who were physically near each other both chose to be seen.
|
||||
/// Their silhouettes resolve into people. This screen is deliberately
|
||||
/// ceremonial — it's the payoff for being present in the real world, and it
|
||||
/// should feel like it.
|
||||
///
|
||||
/// The interaction is framed as a physical act: you were *near* this person,
|
||||
/// you both *waved*, and now you *see* each other. The copy and motion are
|
||||
/// designed to make this feel significant, not like a notification.
|
||||
struct RevealView: View {
|
||||
@EnvironmentObject private var revealFlow: RevealFlow
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var isRevealing = false
|
||||
@State private var showProfileImport = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
|
||||
// The two avatars converging.
|
||||
ZStack {
|
||||
// "You" avatar (left)
|
||||
avatar(systemImage: "person.fill", offset: isRevealing ? -70 : -110)
|
||||
// "Them" avatar (right) — resolves from silhouette to person.
|
||||
avatar(systemImage: "person.fill", offset: isRevealing ? 70 : 110)
|
||||
}
|
||||
.frame(height: 160)
|
||||
|
||||
// Status text
|
||||
VStack(spacing: 12) {
|
||||
Text(isRevealing ? "You both waved." : "You're connected.")
|
||||
.font(.title.bold())
|
||||
Text(isRevealing
|
||||
? "Two people who were near each other chose to be seen."
|
||||
: "You can now say hello.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Actions
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
revealFlow.proceedToConversation()
|
||||
showProfileImport = true
|
||||
} label: {
|
||||
Label("Say hello", systemImage: "bubble.left.and.bubble.right")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.green)
|
||||
|
||||
Button("Not now") {
|
||||
dismiss()
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.onAppear {
|
||||
// Animate the convergence on appear.
|
||||
withAnimation(.easeInOut(duration: 1.2)) {
|
||||
isRevealing = true
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showProfileImport) {
|
||||
if let connection = revealFlow.revealedConnection {
|
||||
ProfileImportSheet(connection: connection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func avatar(systemImage: String, offset: CGFloat) -> some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.green.opacity(0.2))
|
||||
.frame(width: 110, height: 110)
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
.offset(x: offset)
|
||||
.animation(.easeInOut(duration: 1.2), value: isRevealing)
|
||||
}
|
||||
}
|
||||
60
Proximity/Views/SignInView.swift
Normal file
60
Proximity/Views/SignInView.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The sign-in screen.
|
||||
///
|
||||
/// Auth here is about **account identity** — who you are to the app — not
|
||||
/// about what you reveal to others. The copy makes clear that signing in
|
||||
/// never makes you discoverable; you must separately choose to be present.
|
||||
struct SignInView: View {
|
||||
@EnvironmentObject private var authService: AuthService
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
.font(.system(size: 64))
|
||||
.foregroundColor(.green)
|
||||
Text("Proximity")
|
||||
.font(.largeTitle.bold())
|
||||
Text("A social network you can only enter\nby being physically present.")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
SignInWithAppleButton { request in
|
||||
request.requestedScopes = [.fullName, .email]
|
||||
} onCompletion: { result in
|
||||
// Handled via AppleAuthProvider in AuthService.
|
||||
}
|
||||
.frame(height: 50)
|
||||
.signInWithAppleButtonStyle(.black)
|
||||
|
||||
Button {
|
||||
Task { try? await authService.signIn(with: .google) }
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "g.circle.fill")
|
||||
Text("Continue with Google")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
Text("Signing in never makes you discoverable.\nYou choose to be present separately.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
}
|
||||
74
Proximity/Views/SilhouetteRow.swift
Normal file
74
Proximity/Views/SilhouetteRow.swift
Normal file
@@ -0,0 +1,74 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A single anonymous encounter in the feed.
|
||||
///
|
||||
/// Until mutual consent, the other person is a **silhouette** — a shape, not
|
||||
/// a profile. This visual language is the privacy promise made visible: you
|
||||
/// know a human was here, but you don't know who until they choose to be seen.
|
||||
struct SilhouetteRow: View {
|
||||
let encounter: Encounter
|
||||
/// Called when the user taps "Wave" on a silhouette.
|
||||
var onWave: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
// Silhouette avatar — deliberately anonymous.
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.25))
|
||||
.frame(width: 48, height: 48)
|
||||
Image(systemName: "person.fill")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Action depends on status.
|
||||
switch encounter.status {
|
||||
case .silhouette:
|
||||
Button("Wave", action: onWave)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.green)
|
||||
case .waved:
|
||||
Text("Waved ✓")
|
||||
.font(.caption)
|
||||
.foregroundColor(.green)
|
||||
case .mutual:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
case .declined:
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
)
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
switch encounter.status {
|
||||
case .mutual:
|
||||
return "Connected"
|
||||
default:
|
||||
return "Someone nearby"
|
||||
}
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
var parts: [String] = [encounter.tier.title]
|
||||
if let place = encounter.placeHint { parts.append(place) }
|
||||
parts.append(encounter.timestamp.formatted(.relative(presentation: .named)))
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user