A local-first, native iOS app (Swift / SwiftUI) for creating cases and attaching evidence-like media — photo, audio, and video — with metadata, a checksum, a simulated upload queue, and live device/connectivity status.
It is a portfolio demo, not a production evidence-management system: there is no backend, no authentication, and no real upload. Everything stays on the device.
Built to demonstrate the iOS skills relevant to media-capture / evidence apps: SwiftUI, SwiftData, AVFoundation, PhotosUI, FileManager, the Network framework, CoreBluetooth, privacy permissions, and async state management.
The full core flow on the iOS Simulator — browse a case → inspect evidence (real photo + checksum) → simulated upload queue → toggle offline → retry. No device required.
- Cases — create, edit, and browse cases; each groups its evidence.
- Capture & import — take a photo (camera on device; photo picker on the Simulator), record an audio note, or import a photo/video from the library.
- Local storage — media bytes are written to the app sandbox with a real file size and a content-addressed SHA-256 checksum; metadata lives in SwiftData.
- Evidence detail — real media preview (image / video player / audio playback), editable notes and tags, checksum, file export via the share sheet, and a delete that removes both the record and its sandbox file.
- Simulated upload queue —
queued → uploading → uploaded/failedwith progress, retry, and offline behavior. "Simulate offline" and "Simulate failure" toggles drive the offline and retry demos deterministically. Nothing leaves the device. - Device status — live network path (Network framework) and Bluetooth state (CoreBluetooth). A "Simulate offline" toggle drives the queue's offline demo.
Layered and local-first, with a deliberate split between metadata and binary media:
SwiftUI screens
│
▼
@Observable view state · @Query (SwiftData)
│
▼
Services ─────────────────────────────────────────────┐
├─ FileStorageService save/load/delete + SHA-256│
├─ EvidenceImporter bytes → sandbox + record │
├─ EvidenceDeleter record + file (in sync) │
├─ CameraService / AudioRecorderService │
├─ UploadQueueService ← SimulatedUpload (pure FSM) │
├─ NetworkMonitor (NWPathMonitor + simulateOffline) │
└─ BluetoothStatusService (lazy CBCentralManager) │
│ │
▼ ▼
SwiftData (CaseRecord, EvidenceItem, UploadJob) app-sandbox files
- Persistence is split. SwiftData stores metadata; the media bytes live as files
in the app sandbox. Deleting an item removes both —
EvidenceDeleteris the single seam that keeps them in sync (and guards against wiping the storage directory). - Uploads are simulated.
SimulatedUploadis a pure, time-free state machine (fully unit-tested);UploadQueueServiceticks it on a timer and mirrors the state onto the live records.NetworkMonitorsupplies real connectivity AND-ed with asimulateOfflineoverride — one signal, not two code paths. - No backend, by design. Connectivity and Bluetooth are read for real, but no device integration or network upload is performed.
For the reasoning behind these choices, the tradeoffs, and what a production version
would need, see docs/ARCHITECTURE.md.
| Screen | What it shows |
|---|---|
| Dashboard | Case / evidence / pending / failed counts + live network status |
| Cases | All cases with status and evidence count; create / edit |
| Case Detail | Case metadata, evidence timeline, the "Add evidence" menu |
| Evidence Detail | Media preview, metadata, checksum, notes/tags, export, delete |
| Uploads | Simulated queue, progress, retry, offline + failure toggles, banner |
| Device | Live network path + connection type, Bluetooth state |
Screenshots live in docs/screenshots/. They are reproducible —
ScreenshotTests skips in the normal test loop and regenerates on demand:
xcodebuild test -scheme EvidenceCaptureLite \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-only-testing:EvidenceCaptureLiteUITests/ScreenshotTests \
OTHER_SWIFT_FLAGS='-DSCREENSHOTS' -resultBundlePath /tmp/shots.xcresult
xcrun xcresulttool export attachments --path /tmp/shots.xcresult --output-path docs/screenshots| iOS signal | Where it shows up |
|---|---|
| SwiftUI + state | Every screen; @Query-driven lists, observable services |
| SwiftData | CaseRecord / EvidenceItem / UploadJob, cascade delete, persistence test |
| AVFoundation | CameraService (photo), AudioRecorderService / AudioPreviewPlayer |
| PhotosUI | Photo/video import via PhotosPicker |
| FileManager | FileStorageService (sandbox files, SHA-256), delete + export |
| Networking | Simulated upload queue, retry, offline; NWPathMonitor |
| CoreBluetooth | BluetoothStatusService (lazy, permission-safe) |
| Privacy | Explicit usage strings, local-only storage, no backend |
| Testing | Pure state-machine tests + XCUITests that tap the real app |
- Xcode 16+, iOS 17+ (SwiftData).
- XcodeGen (
brew install xcodegen) — the.xcodeprojis generated fromproject.yml.
git clone https://github.com/AKaLee-IK27/evidence-capture-lite.git
cd evidence-capture-lite
xcodegen generate # generate the .xcodeproj from project.yml
./init.sh # build (generic simulator) + test (iPhone 17 simulator)./init.sh regenerates the project automatically if the .xcodeproj is missing.
Override the test simulator if needed:
DESTINATION='platform=iOS Simulator,name=iPhone 15' ./init.shproject.yml is canonical; after adding/removing files run xcodegen generate, then
./init.sh. The raw test command:
xcodebuild test -scheme EvidenceCaptureLite \
-destination 'platform=iOS Simulator,name=iPhone 17'24 tests (15 unit + 9 UI):
- Unit — file storage (save/checksum/round-trip/delete), evidence import,
evidence delete (record and file; fileless item is safe), tag parsing, the
SimulatedUploadstate machine (happy path, offline pause/resume, fail→retry), and the SwiftData models (round-trip, cascade delete). - UI (XCUITest) — real taps through navigation, create→relaunch persistence, note editing, the add-evidence menu, photo-preview rendering from real bytes, delete-from-detail, the offline toggle + banner, and the Device Status screen.
The app is fully exercisable on the iOS Simulator. Two things behave differently because the Simulator lacks the hardware, and both are handled honestly:
- Camera — the Simulator has no camera, so "Take photo" falls back to the photo
picker (a runtime
AVCaptureDevicecheck; live capture still runs on a device). - Bluetooth — CoreBluetooth reports
Unsupported (Simulator); the Device Status screen displays that state rather than faking a connection.
A real-device pass (live camera, real Bluetooth, and a demo video) is the only remaining step that hardware requires.
- All media is stored locally in the app sandbox. Nothing is uploaded.
- Usage strings explain camera, microphone, photo-library, and Bluetooth access.
- Use only non-sensitive demo content in screenshots and any demo video.
| Milestone | Status |
|---|---|
| 1 — Project shell & navigation | ✅ |
| 2 — Case & evidence metadata (SwiftData) | ✅ |
| 3 — Media capture & local storage | ✅ |
| 4 — Evidence detail & file actions | ✅ |
| 5 — Upload queue & device status | ✅ |
| 6 — Portfolio finish (README, screenshots, tests) | ✅ (demo video pending a device) |
See docs/PLAN.md for the full spec and progress.md
for current state.
