Skip to main content

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 logic
  • FeatureRepository.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)
}
}
}

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:

StatusAppError CaseUser-Facing Behavior
400.validation(details:)Show inline field errors from details array
401.unauthorizedClear 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.networkErrorSame 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):

  1. Sheet dismisses immediately (never block the user)
  2. Warning toast appears on the parent screen: "Couldn't save · Will retry" (amber/warning color, 4s auto-dismiss)
  3. Request enters the retry queue (exponential backoff)
  4. On eventual success: silent dashboard/timeline refresh (no success toast for retried items)
  5. 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 wrapping SecItemAdd/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.md for 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 generate after 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_TEAM left 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

  1. Build: xcodegen generate && xcodebuild build succeeds
  2. First launch: Shows onboarding (no token in Keychain)
  3. Return launch: Shows dashboard (token exists in Keychain)
  4. Auth persist: Login, close app, reopen → still authenticated
  5. 401 handling: Expired token → automatically shows login screen
  6. GET failure: Airplane mode → inline error view with "Couldn't load dashboard" + retry button
  7. POST failure + toast: Submit while offline → sheet dismisses, warning toast appears, request queued
  8. Retry success: POST queued → connection restored → retries → dashboard refreshes silently
  9. Permanent failure: POST fails 5 times → persistent red toast "Failed to save [type] · Tap to retry"
  10. Tap to re-retry: Tap persistent error toast → re-enqueues for 5 more attempts
  11. Dark mode: Toggle system appearance → app updates colors (including toasts/snackbars)
  12. 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)