SafeWalk is a single-screen SwiftUI iOS app (deployment target iOS 18.5) built
entirely on Apple frameworks — SwiftUI, Core Location, MapKit, and
UserNotifications — plus a thin URLSession client for the Google Gemini REST
API. There is no SPM/CocoaPods dependency graph and no backend of its own.
Party_WatcherApp (@main)
│
▼
ContentView
│
▼
SafetyWatcherView ──────────────────────────────────────────────┐
│ card-based dashboard: status hero, map, walk timer, │
│ quick actions, check-in chat, emergency contacts. Uses │
│ Theme for color, surfaces (.card()), dark-mode-aware │
│ styling, Reduce Motion + Dynamic Type support. │
│ Messages are modeled as ChatMessage{id,text,isUser} so │
│ bubble alignment is explicit (not index-parity). │
│ Pure decision logic is delegated to: │
│ • SafetyEngine — escalation decision, movement rule, │
│ mm:ss countdown (tested) │
│ • WalkSession/WalkTimer — ETA + overrun rule (tested) │
│ • Escalation — sms:/tel: + group-SMS builders, │
│ phone normalization (tested) │
│ • QuickReplies — deterministic canned chat replies │
│ + label→effect mapping (tested) │
│ owns / drives │
├── LocationManager (StateObject, CLLocationManagerDelegate)
│ • requestWhenInUse → escalates to requestAlways
│ • allowsBackgroundLocationUpdates (guarded on Always auth)
│ • publishes lastLocation; fires onMovement when moved > 5 m
│
├── GeminiManager.shared (singleton)
│ • POST gemini-2.0-flash:generateContent via URLSession
│ • Codable request/response; key from Secrets.geminiAPIKey
│
├── NotificationDelegate.shared (UNUserNotificationCenterDelegate)
│ • "Call UT Police" → tel://5124714441
│ • "Text <contact>" → sms: deep link w/ help msg + Maps link
│
├── Check-in timer (60 s, repeating) ── prompts "Reply if you're okay"
├── Display timer (1 s, repeating) ── advances the visible countdown
└── Inactivity timer (polls every 5 s)
• escalates if no reply > 120 s
• OR no movement > 120 s
• flips status hero safe → checking when a prompt awaits a reply
▼
triggerAutoAlert() + sendPoliceNotification()
(also reachable instantly via the "I need help" quick action;
"I'm safe" resets the inactivity clock + countdown and returns to safe)
│
▼
Escalation (UNNotification, actionable category)
• contact saved → notify + offer SMS to contact AND call to UTPD
• no contact → call to UTPD only (fallback)
- On appear
SafetyWatcherViewstarts location tracking, the 60 s check-in timer, and the 5 s inactivity poll, and requests notification permission. - Check-in loop — every 60 s a bot message asks the user to confirm they're okay. Replying (or any inbound location movement) resets the inactivity clock.
- Inactivity detection — a timer fires every 5 s and compares "now" against the last reply time and last movement time. If either exceeds the 120 s threshold, escalation runs.
- Escalation — an in-app alert is shown and an actionable local
notification is posted. The notification always offers "Call UT Police"
(
tel://5124714441). If the user has saved at least one emergency contact, the notification also names that contact and offers a "Text " action that opens ansms:deep link prefilled with a help message and a Maps link to the last known coordinate.
Emergency contacts are Codable EmergencyContact values stored in
UserDefaults under emergencyContacts (encode/decode helpers live in a
UserDefaults extension). There is no database or remote sync.
GeminiManager reads Secrets.geminiAPIKey from Secrets.swift, which is
gitignored. Secrets.example.swift documents the shape; its declaration is
intentionally commented out because the file is part of the app's synchronized
source group and an active enum Secrets there would collide with the real one
("invalid redeclaration of 'Secrets'"). CI writes a stub Secrets.swift with an
empty key so the project compiles without any real credential.
Background location is configured via build settings (the project uses
GENERATE_INFOPLIST_FILE = YES, so usage strings and background modes are set
through INFOPLIST_KEY_* rather than a checked-in Info.plist). The app target
sets, in both Debug and Release:
INFOPLIST_KEY_NSLocationWhenInUseUsageDescriptionINFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescriptionINFOPLIST_KEY_UIBackgroundModes = location
LocationManager requests when-in-use first, escalates to Always via the
authorization callback, and only sets allowsBackgroundLocationUpdates = true
once Always authorization is granted (setting it without the entitlement +
background mode crashes at runtime).
- Verified in CI / locally: the project compiles and the unit-test target
builds and runs on the iOS Simulator with the shared scheme; Swift sources
are type-checked. The test target covers the pure types and decision logic
(37 tests across 5 suites):
ChatMessagesender/identity,SafetyStatuspresentation,EmergencyContactround-tripping, theSafetyEngineescalation decision + movement rule + countdown clamp, theWalkSession/WalkTimeroverrun rule, theEscalationdeep-link + group-SMS builders,GeminiManager's status-code/retry classification, and theQuickRepliescatalog + label→effect mapping (all network-free). - Needs device/simulator verification by the user: runtime background-GPS
delivery and lock-screen wakeups, the "Always" authorization prompt flow,
notification delivery, and the
tel:/sms:deep links (these require user interaction and real system services that CI does not exercise). The unit test target covers the pure model/presentation types; the timer-driven safety flow, escalation, and UI are exercised manually in the Simulator.