Birdie was my iOS exam project on the Datamatiker (AP Computer Science) programme at Erhvervsakademi Aarhus in May 2025. It got a 10 (of 12, on the Danish 7-point scale), and afterwards I went back and tidied it up (dependency injection, error handling, unit tests, CI), because exam code is exam code.
It's a birdwatching app in SwiftUI and Firebase: log a sighting at your current location, browse sightings in a list or on a map, and filter by species, distance or your own entries.
Screenshots are captured automatically in CI by running the app in demo mode on an iOS simulator (see Demo mode).
- Authentication: register and log in with email and password (Firebase Auth)
- Log sightings: pick a species, add an optional note, and the sighting is tagged with your GPS location
- Sightings list: all sightings, newest first, with swipe-to-delete on your own entries and pull to refresh
- Map view: sightings plotted on a MapKit map; tap a pin for details
- Filters: up to 10 species, maximum distance from you, or only your own sightings
- Profile: change display name, email (Firebase sends a verification mail first) and password
- SwiftUI with the Observation framework (
@Observable) and Swift Concurrency - Firebase Auth and Cloud Firestore (Swift Package Manager)
- MapKit and Core Location
- AlertToast for toasts
- Swift Testing for unit tests, GitHub Actions for CI
- Debug-only data seeding uses randomuser.me, DiceBear avatars and, optionally, the OpenCage geocoder
flowchart TD
V[SwiftUI views] --> C[Observable controllers]
C --> P[Service protocols]
P -.-> F[Firebase services]
P -.-> M[In-memory services]
Views only talk to the controllers, and the controllers only know the protocols in Contracts/.
App/AppServices.swiftis the composition root.AppMode.resolve()picks the Firebase services for normal runs and the in-memory ones for--demo, unit tests and SwiftUI previews.- Business rules live outside the views so they can be unit tested:
SpotFilterData.apply(species / "only mine" / distance filtering and newest-first ordering),FormValidationandProfileChanges(form validation), plus the controllers themselves. - Errors are logged with
os.Loggerand shown to the user as alerts or toasts instead of being printed.
Birdie/
├── App/ Composition root and launch modes
├── Contracts/ Service protocols
├── Controllers/ @Observable state + logic used by the views
├── Models/ BirdSpot, BirdSpecies, Location, filters, validation
├── Services/
│ ├── Firebase/ Firebase Auth + Firestore implementations
│ ├── InMemory/ In-memory implementations and demo data
│ └── Seeders/ DEBUG-only sample data generators
├── Support/ Logging, preview helpers, view extensions
└── Views/ SwiftUI screens
BirdieTests/ Unit tests (Swift Testing)
- Xcode 16.3 or newer (the code uses Swift 6.1 syntax; the project stays in Swift 5 language mode)
- iOS 18.4+
- For real data: a Firebase project with Email/Password auth and Cloud Firestore enabled
-
Clone the repo
git clone https://github.com/CMaintz/birdie-ios.git cd birdie-ios -
Add your Firebase config
cp Birdie/GoogleService-Info.plist.example Birdie/GoogleService-Info.plist
Replace the placeholder values with the ones from your app in the Firebase console, or download the file from there directly. Sightings are stored in a
birdSpotscollection. Combining the species or "only mine" filters with date ordering needs composite indexes; Firestore logs a link that creates them the first time such a query runs. -
(Optional) Add an OpenCage API key
cp Birdie/Secrets.plist.example Birdie/Secrets.plist
Only the debug-only global seeder (
SpotSeederService.createGlobalBirdSpots) uses it, to check whether random coordinates are on land. The app itself runs without it. -
Open and run
open Birdie.xcodeproj
Xcode resolves the Swift packages on first open. Build and run the
Birdiescheme on a simulator or device.
GoogleService-Info.plist and Secrets.plist are in .gitignore; only the .example templates are committed.
Launch with the --demo argument to run without Firebase: you are signed in as a demo user at a fixed location in Copenhagen with a handful of sample sightings, all kept in memory. In Xcode, enable the --demo argument under Product › Scheme › Edit Scheme › Run › Arguments (it is already there, just unchecked). --tab home|list|map|profile opens a specific tab and --skip-splash skips the intro animation; the screenshot workflow uses both.
Debug builds can fill an empty Firebase project with sample data: ten users from randomuser.me, each with 5-15 random sightings around Denmark. This creates real Firebase Auth accounts, so it only runs when you ask for it: enable the --seed-firebase launch argument in the scheme (or set BIRDIE_SEED=1). It runs once per install; release builds don't include the seeder.
The BirdieTests target uses Swift Testing and runs against the in-memory services, so it needs no Firebase config or network. It covers:
BirdSpotController: loading, filtering and sorting, add validation, delete ownership checks, and error handling, using a mock repositorySpotFilterData.apply: species, "only mine" and distance filters, alone and combinedLocation: distance,isWithinand distance formattingFormValidationandProfileChanges: login, registration and profile form rulesAuthControllerandUserController: sign-in/up/out and profile updates- Launch mode selection, demo data and the in-memory repository
Run them with ⌘U in Xcode or from the command line:
xcodebuild test -project Birdie.xcodeproj -scheme Birdie \
-destination 'platform=iOS Simulator,name=iPhone 16'CI (.github/workflows/ci.yml) builds the app and runs the tests on a macOS runner (Xcode 16.4, iOS 18 simulator) for every push to main and every pull request, and adds the coverage report to the job summary. The Firebase-backed services (FirebaseAuthService, FirestoreSpotRepository) and the SwiftUI views are not unit tested; that would need the Firebase Emulator Suite and UI tests.
- Species come from a small hardcoded list. The plan is to pull them from a bird API instead, so you could log any species.
- No Firestore security rules are included in the repo; configure them in your Firebase project.
- Distance filtering happens on the device after fetching, so every matching sighting gets downloaded. Fine for a demo, less fine if it ever had real users.
- Profile data (name, photo) lives on the Firebase Auth user rather than in Firestore.


