Feature: iOS App Shell
Version: 1.3.0 Last Reviewed: 2026-02-12 Status: Approved
User Story
As a developer, I have a properly scaffolded iOS app with MVVM + Repository architecture, auth flow, network layer, and navigation structure so I can build features on a solid foundation.
MVP Scope
- Xcode project via xcodegen (project.yml)
- SwiftUI app targeting iOS 17+
- 3-tab TabView: Dashboard, Today, Settings
- MVVM + Repository pattern with @Observable
- URLSession-based network service (APIClient) with auth header injection
- Keychain token storage (hand-written KeychainService, no external dependency)
- Theme system (light + dark mode, semantic colors)
- Error states (loading, error with retry, empty state)
- In-memory retry queue for network resilience
NOT in MVP
- SwiftData offline cache + persistent sync queue (post-MVP #1)
- Notification hub for sync events (post-MVP - aggregates success/failure/retry status, caregiver activity)
- Widgets (post-MVP #16)
- Live Activities (post-MVP - sleep timer)
- Apple Watch (post-MVP #15)
- Child switcher in nav bar (post-MVP, unless trivial to add)
Architecture
Project Structure
apps/ios/
├── project.yml # xcodegen config
└── BabyBasics/
├── App/
│ ├── BabyBasicsApp.swift # @main, app entry point
│ └── AppState.swift # Global app state (@Observable)
├── Core/
│ ├── Network/
│ │ ├── APIClient.swift # URLSession wrapper, auth injection
│ │ ├── APIError.swift # Typed API errors
│ │ ├── Endpoints.swift # URL construction
│ │ └── RetryQueue.swift # In-memory retry for failed requests
│ ├── Models/
│ │ ├── User.swift
│ │ ├── Child.swift
│ │ ├── Feeding.swift
│ │ ├── Diaper.swift
│ │ ├── Sleep.swift
│ │ ├── Note.swift
│ │ ├── DashboardData.swift
│ │ └── TimelineEvent.swift
│ ├── Auth/
│ │ ├── AuthManager.swift # Token storage, login state
│ │ └── KeychainService.swift
│ └── Theme/
│ ├── Theme.swift # Semantic colors, dark mode
│ └── Spacing.swift # Consistent spacing scale
├── Features/
│ ├── Dashboard/
│ ├── Timeline/
│ ├── Feeding/
│ ├── Diaper/
│ ├── Sleep/
│ ├── Notes/
│ ├── Children/
│ ├── Onboarding/
│ └── Settings/
├── Components/
│ ├── Buttons/
│ │ ├── BBPrimaryButton.swift # Full-width primary action button with loading state
│ │ ├── BBSecondaryButton.swift # Outlined/text secondary action button
│ │ └── BBQuickActionButton.swift # Large tap target for quick-log (e.g., "Left", "Poop")
│ ├── Cards/
│ │ └── BBDashboardCard.swift # Dashboard card (icon + label + elapsed time + secondary info)
│ ├── Timeline/
│ │ ├── BBTimelineEntry.swift # Timeline row (time + icon + detail line)
│ │ └── BBTypeIcon.swift # SF Symbol mapping per event type (feeding/diaper/sleep/note)
│ ├── Forms/
│ │ ├── BBTimePicker.swift # Date/time picker with "now" default
│ │ ├── BBNumberField.swift # Numeric input with label + unit suffix (minutes, oz)
│ │ └── BBSegmentedPicker.swift # Segmented control for type toggles (breast/bottle, pee/poop/both)
│ ├── Sheets/
│ │ ├── BBSheetHeader.swift # Consistent sheet header (title + close button)
│ │ └── BBExpandableSection.swift # "More details" collapsible section
│ ├── Feedback/
│ │ └── BBFeedbackBanner.swift # Unified feedback: undo (10s), warning (4s), error (persistent). See ios-design-system.md.
│ ├── States/
│ │ ├── LoadingView.swift # Skeleton/shimmer loading state
│ │ ├── ErrorView.swift # Error message + retry button
│ │ └── EmptyStateView.swift # Centered message + subtext for empty data
│ └── ElapsedTimeText.swift # Live-updating "Xh Ym ago" counter (60s Timer)
└── Tests/
MVVM + Repository Pattern
View (SwiftUI) → ViewModel (@Observable) → Repository (Protocol) → APIClient
Each feature folder contains:
FeatureView.swift- SwiftUI view (thin, delegates to VM)FeatureViewModel.swift- @Observable, owns state + business logicFeatureRepository.swift- Protocol + implementation, talks to APIClient
@Observable Pattern (iOS 17+)
@Observable @MainActor
final class DashboardViewModel {
var dashboard: DashboardData?
var isLoading = false
var error: AppError?
private let repository: DashboardRepositoryProtocol
init(repository: DashboardRepositoryProtocol = DashboardRepository()) {
self.repository = repository
}
func load(childId: UUID) async {
isLoading = true
defer { isLoading = false }
do {
dashboard = try await repository.getDashboard(childId: childId)
} catch {
self.error = AppError(error)
}
}
}
Navigation Architecture
3-Tab TabView
TabView {
DashboardTab()
.tabItem { Label("Dashboard", systemImage: "house") }
TimelineTab()
.tabItem { Label("Today", systemImage: "clock") }
SettingsTab()
.tabItem { Label("Settings", systemImage: "gear") }
}
Dashboard Tab
- NavigationStack wrapping DashboardView
- Dashboard cards are tappable → open .sheet() bottom sheets
- Each card shows: icon, label, "Xh Ym ago" counter, today's count
- Tapping a card opens the corresponding logging sheet
- Cards: Feed, Diaper, Sleep, Note (2x2 grid)
- Active sleep timer: Sleep card shows "Sleeping: Xh Ym" with pulsing indicator
Today Tab
- NavigationStack wrapping TimelineView
- Chronological list of today's events (newest first)
-
- button in .toolbar(placement: .topBarTrailing) for secondary logging entry
- Tapping + opens .confirmationDialog with Feed/Diaper/Sleep/Note options
- Tapping a timeline entry opens edit sheet for that event
- Pull-to-refresh
Settings Tab
- NavigationStack wrapping SettingsView
- Sections: Baby profiles, Account, Server configuration, About
- Baby profile management: create, edit (name, DOB)
- Share link generation + system share sheet
- Server URL display (editable)
- Logout button
- Day start time preference picker
- Post-MVP: child switcher, timezone, unit preferences
Sheet Presentation
- Quick actions (diaper: 3 buttons):
.presentationDetents([.medium])- half height - Detailed forms (feeding "more details", sleep manual entry):
.presentationDetents([.medium, .large])- starts half, draggable to full - All sheets have
.presentationDragIndicator(.visible) - Dismiss via swipe down or explicit close button
Network Layer (APIClient)
Design
actor APIClient— actor provides automatic thread safety for mutable state (auth token, base URL) under Swift 6 strict concurrency- Base URL from build configuration (pre-filled, editable in settings)
- Auth header injected automatically on every request when token exists
- JSON encoding/decoding with ISO8601 date strategy
- Generic request method:
func request<T: Decodable & Sendable>(_ endpoint: Endpoint) async throws -> T
Error Handling
Maps HTTP status codes to typed AppError cases:
| Status | AppError Case | User-Facing Behavior |
|---|---|---|
| 400 | .validation(details:) | Show inline field errors from details array |
| 401 | .unauthorized | Clear Keychain token, navigate to login (pre-fill email from UserDefaults) |
| 403 | .forbidden | "You don't have access to this baby" |
| 404 | .notFound | "Not found" (or silent handling for expected cases) |
| 409 | .conflict | "Email already registered" (context-dependent message from response) |
| 429 | .rateLimited | "Too many requests. Please wait." (do NOT retry automatically) |
| 500+ | .serverError | "Something went wrong" (retry eligible) |
| URLError | .networkError | "No connection" (retry eligible for mutating requests) |
| Timeout | .networkError | Same as network error (30-second timeout) |
Retry eligibility: Only network errors and 5xx server errors go to retry queue. 4xx errors (400, 401, 403, 404, 409, 429) are NEVER retried — they represent client errors or auth issues that won't resolve with retry.
Retry Queue (In-Memory)
actor RetryQueue— actor provides automatic thread safety for queue state under Swift 6 strict concurrency- Failed mutating requests (POST, PUT, DELETE) are queued
- Retry on: network errors (
URLError), 5xx server errors - Do NOT retry: 4xx errors (400, 401, 403, 404, 409, 429) — these are permanent failures
- Queue retries with exponential backoff (2s, 4s, 8s, 16s, max 5 attempts, no jitter for MVP)
- Queue is in-memory only (lost on app close - acceptable for MVP)
- Non-mutating requests (GET) are NOT queued (just show error state)
- User-triggered re-retry: tapping persistent error toast re-enqueues for 5 more attempts
- Post-MVP: persistent queue via SwiftData
Network Error UX (User-Facing Feedback)
When a mutating request fails (quick-tap or form submit):
- Sheet dismisses immediately (never block the user)
- Warning toast appears on the parent screen:
"Couldn't save · Will retry"(amber/warning color, 4s auto-dismiss) - Request enters the retry queue (exponential backoff)
- On eventual success: silent dashboard/timeline refresh (no success toast for retried items)
- On permanent failure (5 attempts exhausted): persistent error toast:
"Failed to save [type] · Tap to retry"(red/danger color, does NOT auto-dismiss)- Tapping the toast re-enqueues the request for 5 more attempts
- User can also dismiss by swiping
For GET requests (dashboard load, timeline load, pull-to-refresh):
- Show ErrorView inline (not a toast):
"Couldn't load dashboard"+"Tap to retry"button - If stale data exists from a previous successful load, show stale data with a subtle banner:
"Offline · Showing last known data"
Post-MVP: A dedicated notification hub (accessible from nav bar) will aggregate all sync events: successes, failures, retries in progress, and items from other caregivers. For MVP, toasts provide sufficient feedback.
Auto-Refresh
- Dashboard polls every 30 seconds while app is active
- Refreshes on foreground (
scenePhase == .active) - Post-MVP: SSE or silent push notifications
Auth Flow
Token Storage
- Session token stored in Keychain via hand-written
KeychainService(~50 lines wrappingSecItemAdd/SecItemCopyMatching/SecItemDelete— no external dependency) - Keychain accessibility:
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly— allows background access, prevents backup migration to other devices - Server URL stored in UserDefaults (not sensitive)
- User email stored in UserDefaults (for pre-filling login on re-auth, not sensitive enough for Keychain)
- On app launch: check Keychain for token → if exists, go to dashboard; if not, show onboarding
Reinstall Detection
Keychain items survive app uninstall/reinstall on iOS. To prevent stale tokens:
let hasRunBefore = UserDefaults.standard.bool(forKey: "hasRunBefore")
if !hasRunBefore {
KeychainService.deleteAll()
UserDefaults.standard.set(true, forKey: "hasRunBefore")
}
This runs in BabyBasicsApp.init() before any Keychain reads. UserDefaults ARE cleared on uninstall, so this reliably detects a fresh install.
Login State
- AppState tracks:
.onboarding,.authenticated(token),.unauthenticated - 401 response from any API call → clear Keychain token, transition to
.unauthenticated - On re-auth: login screen pre-fills email from UserDefaults, user only needs to enter password
- Post-MVP: modal overlay re-auth that preserves navigation state (MVP just navigates to login screen)
Theme System
Colors (Semantic)
extension Color {
static let bbPrimary = Color("Primary") // Indigo/purple - main actions (see ios-design-system.md)
static let bbSecondary = Color("Secondary") // Muted complement
static let bbBackground = Color("Background") // Adapts light/dark
static let bbSurface = Color("Surface") // Card backgrounds
static let bbText = Color("Text") // Primary text
static let bbTextSecondary = Color("TextSecondary")
static let bbSuccess = Color("Success") // Green - within expected window
static let bbWarning = Color("Warning") // Amber - approaching threshold
static let bbDanger = Color("Danger") // Red - overdue (used sparingly)
}
Dark Mode
- Both light and dark mode supported
- Respects system setting by default
- Dark mode optimized for 3 AM: muted indigo/purple tones, high contrast text, avoid bright whites (see ios-design-system.md for full palette)
- All colors defined as adaptive Color Sets in asset catalog
Typography
- Use Apple's semantic fonts: .headline, .body, .caption, .title
- Support Dynamic Type (no hardcoded font sizes)
Spacing Scale
enum Spacing {
static let xs: CGFloat = 4
static let s: CGFloat = 8
static let m: CGFloat = 16
static let l: CGFloat = 24
static let xl: CGFloat = 32
}
Icons
- SF Symbols exclusively (no custom icons for MVP)
- Feeding:
drop.fill - Diaper:
arrow.triangle.2.circlepath - Sleep:
moon.zzz.fill - Note:
note.text - See
ios-design-system.mdfor complete icon mapping and usage guide
Build Configuration
Server URL
- Pre-filled from build configuration (xcconfig or Info.plist)
- Default:
https://baby.bretzfam.com - Editable in Settings tab and during onboarding
- Stored in UserDefaults after user confirms
xcodegen (project.yml)
- Generates .xcodeproj (gitignored)
- Run
xcodegen generateafter editing project.yml
# apps/ios/project.yml
name: BabyBasics
options:
bundleIdPrefix: com.bretzfam
deploymentTarget:
iOS: "17.0"
xcodeVersion: "16.0"
groupSortPosition: top
settings:
base:
SWIFT_VERSION: "6.0"
MARKETING_VERSION: "0.1.0"
CURRENT_PROJECT_VERSION: 1
DEVELOPMENT_TEAM: "" # Set via local xcconfig override
CODE_SIGN_STYLE: Automatic
targets:
BabyBasics:
type: application
platform: iOS
sources:
- path: BabyBasics
settings:
base:
INFOPLIST_FILE: BabyBasics/App/Info.plist
PRODUCT_BUNDLE_IDENTIFIER: com.bretzfam.babybasics
entitlements:
path: BabyBasics/App/BabyBasics.entitlements
properties:
com.apple.developer.associated-domains:
- "webcredentials:baby.bretzfam.com"
keychain-access-groups:
- "$(AppIdentifierPrefix)com.bretzfam.babybasics"
BabyBasicsTests:
type: bundle.unit-test
platform: iOS
sources:
- path: BabyBasics/Tests
dependencies:
- target: BabyBasics
Notes:
DEVELOPMENT_TEAMleft empty — set via local xcconfig file (not committed)- Associated Domains entitlement enables password AutoFill (requires AASA file on server — see infrastructure.md)
- No external SPM dependencies (Keychain access is hand-written ~50 lines)
Acceptance Criteria
- Xcode project builds successfully via xcodegen
- App launches to onboarding flow on first run
- App launches to dashboard on subsequent runs (token in Keychain)
- 3-tab TabView renders: Dashboard, Today, Settings
- Can configure server URL (pre-filled from build config)
- Can register and log in
- Auth token persists in Keychain across app restarts
- 401 response triggers logout flow
- Failed mutating network requests retry automatically (in-memory)
- Network failure shows warning toast "Couldn't save · Will retry"
- Permanent failure (5 retries exhausted) shows persistent error toast
- GET failure shows inline error view with retry button
- Shared components render consistently across all features
- Light and dark mode both render correctly
- Dynamic Type sizes render correctly
Test Cases
- Build:
xcodegen generate && xcodebuild buildsucceeds - First launch: Shows onboarding (no token in Keychain)
- Return launch: Shows dashboard (token exists in Keychain)
- Auth persist: Login, close app, reopen → still authenticated
- 401 handling: Expired token → automatically shows login screen
- GET failure: Airplane mode → inline error view with "Couldn't load dashboard" + retry button
- POST failure + toast: Submit while offline → sheet dismisses, warning toast appears, request queued
- Retry success: POST queued → connection restored → retries → dashboard refreshes silently
- Permanent failure: POST fails 5 times → persistent red toast "Failed to save [type] · Tap to retry"
- Tap to re-retry: Tap persistent error toast → re-enqueues for 5 more attempts
- Dark mode: Toggle system appearance → app updates colors (including toasts/snackbars)
- Dynamic Type: Increase text size in Settings → app text scales (including toasts/snackbars)
Boundaries
- No offline data caching (post-MVP #1 - SwiftData)
- No notification hub (post-MVP - toasts provide MVP-level feedback)
- No widgets or Live Activities
- No iPad layout optimization (iPhone only for MVP)
- No localization (English only)
- Child switcher deferred to post-MVP (nav bar title area reserved for it)
- Retry queue is in-memory only (lost on app close)
- No persistent sync status beyond toasts (post-MVP notification hub)