import { useState } from 'react'; import { ActivityIndicator, ScrollView, StyleSheet, View, useWindowDimensions, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Animated, { FadeIn, FadeInDown, FadeOutUp, LinearTransition, ReduceMotion, } from 'react-native-reanimated'; import { Canvas, LinearGradient, Rect, vec } from '@shopify/react-native-skia'; import { Ionicons } from '@expo/vector-icons'; import { AstraLogo } from '@/components/AstraLogo'; import { Text } from '@/components/Text'; import { ScanProgress } from '@/components/library/ScanProgress'; import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow'; import { ScopeStyleCards } from '@/components/settings/ScopeStyleCards'; import { StepHeader } from '@/components/onboarding/StepHeader'; import { NotificationStep } from '@/components/onboarding/NotificationStep'; import { ArtistImageStep } from '@/components/onboarding/ArtistImageStep'; import { formatFolderCount, formatTrackCount } from '@/components/settings/SettingsPanels'; import { radius, spacing } from '@/theme'; import { motion } from '@/theme/motion'; import { createThemedStyles, useColors } from '@/theme/themed'; import { AppPressable } from '@/components/AppPressable'; import { playHaptic } from '@/lib/haptics'; import type { BaseThemeId } from '@/theme/resolve'; import { useScanNotificationPermission } from '@/library/useScanNotificationPermission'; import { useLibraryStore } from '@/stores/libraryStore'; import { useSettingsStore, type NowPlayingScopeStyle } from '@/stores/settingsStore'; import { useThemeStore } from '@/stores/themeStore'; type StepId = | 'welcome' | 'library' | 'notifications' | 'artistImages' | 'theme' | 'player' | 'done'; // Folders first so the notification ask lands with a visible reason ("your scan // is running"), then the Deezer consent. Each is its own page: they are three // unrelated decisions and stacking them made the library step's real action — // picking a folder — the third thing on screen. const STEP_ORDER: StepId[] = [ 'welcome', 'library', 'notifications', 'artistImages', 'theme', 'player', 'done', ]; const WIZARD_THEME_OPTIONS: { id: BaseThemeId; title: string }[] = [ { id: 'system', title: 'System' }, { id: 'midnight', title: 'Midnight' }, { id: 'dark', title: 'Dark' }, { id: 'amoled', title: 'AMOLED' }, { id: 'light', title: 'Light' }, { id: 'materialYou', title: 'Material You' }, ]; const DOT_TRANSITION = LinearTransition.duration(motion.snap.duration).reduceMotion( ReduceMotion.System ); /** * First-run wizard. Rendered by the root layout instead of the app tree while * `onboarding_complete` is unset. Purely presentational — it drives the existing * library + theme stores and calls `onDone` (→ markComplete) at the end. */ export function OnboardingFlow({ onDone }: { onDone: () => void }) { const styles = useStyles(); const colors = useColors(); const insets = useSafeAreaInsets(); const { width, height } = useWindowDimensions(); const [stepIndex, setStepIndex] = useState(0); const step = STEP_ORDER[stepIndex]; const foldersCount = useLibraryStore((s) => s.folders.length); const isScanning = useLibraryStore((s) => s.isScanning); // Owned here so the footer label can distinguish "Continue" from "Skip for // now" using the same state the notification step renders. const notificationPermission = useScanNotificationPermission(); // Deliberately unset until tapped: preselecting a card would bias the // pre-release style feedback. Skipping through keeps the store default. const [scopeStyleChoice, setScopeStyleChoice] = useState(null); const chooseScopeStyle = (style: NowPlayingScopeStyle) => { setScopeStyleChoice(style); void useSettingsStore.getState().setNowPlayingScopeStyle(style); }; const goNext = () => { if (stepIndex < STEP_ORDER.length - 1) setStepIndex((i) => i + 1); else onDone(); }; const goBack = () => setStepIndex((i) => Math.max(0, i - 1)); const canGoBack = stepIndex > 0 && step !== 'done'; const primaryLabel = step === 'welcome' ? 'Get started' : step === 'library' ? // A scan is orchestrated by the store (not this component), so it keeps // running after "Continue" — never call the forward action "Skip" while // it is actively working. foldersCount > 0 || isScanning ? 'Continue' : 'Skip for now' : step === 'notifications' ? // The grant is optional, so moving on without it really is skipping. notificationPermission.granted ? 'Continue' : 'Skip for now' : step === 'artistImages' || step === 'theme' || step === 'player' ? 'Continue' : 'Start listening'; return ( {/* Once past the library step, a subtle banner keeps reminding the user the scan they started is still running in the background (it was not stopped by continuing). */} {step !== 'library' ? : null} {step === 'welcome' ? : null} {step === 'library' ? : null} {step === 'notifications' ? ( ) : null} {step === 'artistImages' ? : null} {step === 'theme' ? : null} {step === 'player' ? ( ) : null} {step === 'done' ? : null} {STEP_ORDER.map((id, i) => ( ))} {canGoBack ? ( Back ) : null} {primaryLabel} ); } function WelcomeStep() { const styles = useStyles(); const colors = useColors(); return ( Welcome to Astra Your music, beautifully played. Set up your library in a few taps. ); } function LibraryStep() { const styles = useStyles(); const colors = useColors(); const folders = useLibraryStore((s) => s.folders); const totalTrackCount = useLibraryStore((s) => s.totalTrackCount); const isScanning = useLibraryStore((s) => s.isScanning); const addFolder = useLibraryStore((s) => s.addFolder); return ( void addFolder()} accessibilityRole="button" > {folders.length > 0 ? 'Add another folder' : 'Choose music folder'} {isScanning ? ( Scanning keeps running in the background — continue whenever you like, or add more folders. ) : folders.length > 0 ? ( {formatFolderCount(folders.length)} · {formatTrackCount(totalTrackCount)} ) : ( You can skip this and add folders later from Settings › Library. )} ); } function ThemeStep() { const styles = useStyles(); const colors = useColors(); const baseTheme = useThemeStore((s) => s.baseTheme); const materialYouAvailable = useThemeStore((s) => s.materialYouAvailable); const resolvedId = useThemeStore((s) => s.theme.id); const setBaseTheme = useThemeStore((s) => s.setBaseTheme); const options = WIZARD_THEME_OPTIONS.filter( (option) => option.id !== 'materialYou' || materialYouAvailable ); const accentApplies = !resolvedId.startsWith('materialYou'); return ( {options.map((option) => { const selected = option.id === baseTheme; return ( { if (selected) return; playHaptic('selection'); void setBaseTheme(option.id); }} style={[styles.themePill, selected && styles.themePillSelected]} accessibilityRole="radio" accessibilityState={{ selected }} > {option.title} ); })} {accentApplies ? ( ) : null} ); } function PlayerStep({ choice, onChoose, }: { choice: NowPlayingScopeStyle | null; onChoose: (style: NowPlayingScopeStyle) => void; }) { const styles = useStyles(); return ( ); } function DoneStep() { const styles = useStyles(); const colors = useColors(); const totalTrackCount = useLibraryStore((s) => s.totalTrackCount); return ( All set {totalTrackCount > 0 ? `${formatTrackCount(totalTrackCount)} ready to play.` : 'Add music anytime from Settings › Library.'} ); } /** Subtle "still scanning" pill shown at the top of steps after the library step. */ function ScanBanner() { const styles = useStyles(); const colors = useColors(); const isScanning = useLibraryStore((s) => s.isScanning); const isCancelling = useLibraryStore((s) => s.isCancelling); const progress = useLibraryStore((s) => s.scanProgress); const cancelScan = useLibraryStore((s) => s.cancelScan); if (!isScanning) return null; const detail = (progress.phase === 'extracting' || progress.phase === 'analyzing') && progress.total > 0 ? `${progress.processed}/${progress.total}` : null; return ( {isCancelling ? 'Cancelling library scan…' : `Scanning your library${detail ? ` · ${detail}` : '…'}`} {isCancelling ? 'Cancelling…' : 'Cancel'} ); } /** Page indicator dot — widens + brightens when active. Animated View, not an icon. */ function Dot({ active }: { active: boolean }) { const styles = useStyles(); return ( ); } const useStyles = createThemedStyles((colors) => ({ root: { flex: 1, backgroundColor: colors.bgPrimary, }, content: { flex: 1, paddingHorizontal: spacing.lg, }, scanBanner: { flexDirection: 'row', alignItems: 'center', alignSelf: 'center', gap: spacing.sm, paddingVertical: spacing.sm, paddingHorizontal: spacing.md, marginBottom: spacing.sm, borderRadius: radius.pill, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.glassBorder, backgroundColor: colors.glassBg, }, scanBannerText: { flexShrink: 1, }, scanBannerCancel: { minHeight: 32, justifyContent: 'center', paddingHorizontal: spacing.xs, }, scroll: { flex: 1, }, scrollContent: { flexGrow: 1, justifyContent: 'center', paddingVertical: spacing.xl, }, stepWrap: { width: '100%', }, centered: { alignItems: 'center', gap: spacing.xl, }, centeredText: { alignItems: 'center', gap: spacing.sm, }, centeredTitle: { textAlign: 'center', }, centeredSubtitle: { textAlign: 'center', maxWidth: 340, }, doneBadge: { width: 88, height: 88, borderRadius: 44, backgroundColor: colors.accent, alignItems: 'center', justifyContent: 'center', }, stepBody: { width: '100%', gap: spacing.lg, }, choiceButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing.sm, minHeight: 52, paddingVertical: spacing.md + 2, paddingHorizontal: spacing.lg, borderRadius: radius.md, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.glassBorder, backgroundColor: colors.glassBg, }, disabled: { opacity: 0.5, }, summaryCard: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing.sm, paddingVertical: spacing.md, paddingHorizontal: spacing.lg, borderRadius: radius.md, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.glassBorder, backgroundColor: colors.glassBg, }, hint: { textAlign: 'center', }, themeGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, justifyContent: 'center', }, themePill: { paddingVertical: spacing.sm, paddingHorizontal: spacing.md, borderRadius: radius.pill, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.glassBorder, backgroundColor: colors.glassBg, }, themePillSelected: { borderColor: colors.accent, backgroundColor: colors.accentGlow, }, accentBlock: { alignItems: 'center', marginTop: spacing.sm, }, footer: { gap: spacing.lg, paddingTop: spacing.md, }, dots: { flexDirection: 'row', gap: spacing.sm, justifyContent: 'center', alignItems: 'center', }, dot: { height: 8, borderRadius: 4, backgroundColor: colors.accent, }, dotActive: { width: 22, opacity: 1, }, dotInactive: { width: 8, opacity: 0.3, }, navRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, }, secondaryButton: { minHeight: 52, paddingHorizontal: spacing.lg, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.glassBorder, }, primaryButton: { flex: 1, minHeight: 52, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md, backgroundColor: colors.accent, }, primaryButtonText: { fontSize: 15, }, })); export default OnboardingFlow;