initial commit: Proximity iOS app proposal, full Swift code scaffold, reference backend, testing docs, generated Xcode project

This commit is contained in:
2026-08-12 00:22:01 +00:00
commit 9d1b0f8dd9
52 changed files with 6271 additions and 0 deletions

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

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

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

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

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

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

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

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

View 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: " · ")
}
}