# Onboarding Cold-Launch Audit — 2026-05-28

## Context
Full audit of the UNSPOOLED onboarding wizard cold-launch flow: `MainActivity → LaunchScreen → Onboarding → ProfileSelect → Home`.
Found 7 issues — 2 critical (user-trapping), 3 medium (crash-risk), 2 UX.

## Cold-Launch Flow (annotated)
```
App start → MainActivity → NexStreamNavHost(startDestination=launch)
  → LaunchScreen → LaunchViewModel.combine(
      isFirstLaunch, activeProfileId, profileDao.observeAll()
    )
    → firstLaunch=true          → Onboarding (5-step wizard)
    → activeProfileId>0+exists  → Home (direct skip)
    → profiles.isNotEmpty()     → ProfileSelect
    → fallback                  → Onboarding
  → Onboarding.complete() → navigatePastOnboarding()
    → ProfileSelect → Home
```

## Critical Bugs Found

### 1. OnboardingViewModel.complete() / skipAll() — user TRAP
**Root cause:** Catch blocks in `complete()` and `skipAll()` caught `ensureActiveProfile()` or `setFirstLaunch(false)` failures and set `debridStatus = "failed"` + `debridError` — but NEVER set `isComplete = true`. User trapped in onboarding with misleading "Debrid failed" error.

**Fix in OnboardingViewModel.kt:**
```kotlin
fun complete() {
    viewModelScope.launch {
        try {
            // ... persist OTT selections, create profile, set firstLaunch=false
        } catch (e: Exception) {
            CrashLogger.logError("OnboardingViewModel", "complete() failed", e)
            try { preferencesManager.setFirstLaunch(false) } catch (_: Exception) {}
        }
        _uiState.update { it.copy(isComplete = true) } // ALWAYS set — after catch
    }
}
```

**Rule:** Onboarding completion methods MUST always set `isComplete = true` after the try/catch. Never gate user escape on I/O success.

### 2. checkExistingConnections() crashes onboarding at startup
**Root cause:** `LaunchedEffect(Unit) { viewModel.checkExistingConnections() }` calls `debridService.getActiveServices()` which can throw on DB failure, network failure, etc. Uncaught exception crashes the entire OnboardingScreen composable.

**Fix:** Wrap in try/catch:
```kotlin
LaunchedEffect(Unit) {
    try { viewModel.checkExistingConnections() } catch (_: Exception) {}
}
```

### 3. LaunchScreen combine fragility
**Root cause:** `LaunchViewModel` uses `combine(isFirstLaunch, activeProfileId, profileDao.observeAll())`. If any flow throws (e.g., DB migration failure on `observeAll()`), the combine throws and crashes the app at launch.

**Fix:** Add `.catch {}` to every flow:
```kotlin
combine(
    preferencesManager.isFirstLaunch.catch { emit(true) },
    preferencesManager.activeProfileId.catch { emit(0) },
    profileDao.observeAll().catch { emit(emptyList()) },
) { ... }
```
Fallbacks: isFirstLaunch→true (safe default), activeProfileId→0 (no profile), profiles→emptyList (triggers re-onboard).

### 4. D-pad Back navigation missing
No way to go back between onboarding steps. Fix: `onPreviewKeyEvent` at top-level Box intercepting `Key.Back` → `viewModel.previousStep()` or `complete()` from Welcome.

### 5. Misrouted navigation in ServicesOverviewPanel
Debrid "Set up →" called `goToStep(1)` (self-loop). Trakt "Set up →" called `goToStep(2)` (Debrid step, wrong). Fixed to `goToStep(2)` and `goToStep(3)`.

### 6. Hidden/conditional buttons
Debrid/Trakt "Continue →" buttons only rendered when `debridStatus == "connected"`. User saw only "Skip" and didn't know how to advance. Fixed: always show both buttons; "Skip →" ghosted (TextDisabled) when not connected.

### 7. Non-scrollable panels
Panels used `Arrangement.Center` on `fillMaxSize` without scroll. On lower-res TVs (720p), buttons cut off. Fixed: all panels `.verticalScroll(rememberScrollState())`.

## TV D-pad Key Handling (canonical pattern)
```kotlin
/** Works on Android TV (DirectionCenter) + Fire TV / keyboards (Enter). */
private fun isSelectKey(key: Key) = key == Key.DirectionCenter || key == Key.Enter
private fun isBackKey(key: Key) = key == Key.Back
```
Use on every interactive element via `onPreviewKeyEvent`.

## TV UI Design Rules (from redesign session)
- **Fonts:** 18sp minimum for body, 22-42sp for headings — visible from couch
- **Borders:** 3dp focus border minimum — 1dp invisible from 10 feet
- **Targets:** Buttons 56-68dp height, cards 130dp+ height
- **Scroll:** All panels with dynamic content MUST use `verticalScroll()`
- **Buttons:** Always visible — never conditionally render action buttons; ghost them instead
- **Glow:** `Modifier.shadow()` on focus for premium feel
- **Checkmarks:** Completed sidebar steps show green circle checkmark

## Trakt Credentials
`gradle.properties` has placeholder keys — real credentials needed for Trakt auth to work:
```
TRAKT_CLIENT_ID=
TRAKT_CLIENT_SECRET=
```
Get them at https://trakt.tv/oauth/applications

## Settings Access (post-onboarding)
All services are configurable later:
- Debrid: `Settings → Debrid Services` (`Screen.DebridServices`), `Settings → Debrid Settings` (`Screen.DebridSettings`)
- Trakt: `Settings → Trakt` (`Screen.TraktServices`)
- OTT Catalogs: `Settings → Catalog Order` (`Screen.CatalogOrder`)
