Files
astra-mobile/src/components/onboarding/OnboardingFlow.tsx
T
2026-08-12 16:18:31 -04:00

607 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<NowPlayingScopeStyle | null>(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 (
<View style={styles.root}>
<Canvas style={StyleSheet.absoluteFill}>
<Rect x={0} y={0} width={width} height={height}>
<LinearGradient
start={vec(0, 0)}
end={vec(width * 0.5, height)}
colors={[colors.accentGlow, colors.bgPrimary, colors.bgPrimary]}
positions={[0, 0.55, 1]}
/>
</Rect>
</Canvas>
<View
style={[
styles.content,
{ paddingTop: insets.top + spacing.lg, paddingBottom: insets.bottom + spacing.lg },
]}
>
{/* 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' ? <ScanBanner /> : null}
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<Animated.View key={step} entering={FadeIn.duration(220)} style={styles.stepWrap}>
{step === 'welcome' ? <WelcomeStep /> : null}
{step === 'library' ? <LibraryStep /> : null}
{step === 'notifications' ? (
<NotificationStep permission={notificationPermission} />
) : null}
{step === 'artistImages' ? <ArtistImageStep /> : null}
{step === 'theme' ? <ThemeStep /> : null}
{step === 'player' ? (
<PlayerStep choice={scopeStyleChoice} onChoose={chooseScopeStyle} />
) : null}
{step === 'done' ? <DoneStep /> : null}
</Animated.View>
</ScrollView>
<View style={styles.footer}>
<View style={styles.dots}>
{STEP_ORDER.map((id, i) => (
<Dot key={id} active={i === stepIndex} />
))}
</View>
<View style={styles.navRow}>
{canGoBack ? (
<AppPressable feedback="control"
onPress={goBack}
style={styles.secondaryButton}
accessibilityRole="button"
accessibilityLabel="Go back"
>
<Text variant="label" color={colors.textSecondary}>
Back
</Text>
</AppPressable>
) : null}
<AppPressable feedback="accent"
onPress={goNext}
style={styles.primaryButton}
accessibilityRole="button"
accessibilityLabel={primaryLabel}
>
<Text variant="label" color={colors.bgPrimary} style={styles.primaryButtonText}>
{primaryLabel}
</Text>
</AppPressable>
</View>
</View>
</View>
</View>
);
}
function WelcomeStep() {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.centered}>
<Animated.View entering={FadeInDown.duration(500)}>
<AstraLogo size={96} />
</Animated.View>
<Animated.View entering={FadeInDown.delay(120).duration(500)} style={styles.centeredText}>
<Text variant="title" style={styles.centeredTitle}>
Welcome to Astra
</Text>
<Text variant="body" color={colors.textSecondary} style={styles.centeredSubtitle}>
Your music, beautifully played. Set up your library in a few taps.
</Text>
</Animated.View>
</View>
);
}
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 (
<View style={styles.stepBody}>
<StepHeader
icon="musical-notes-outline"
title="Add your music"
subtitle="Point Astra at the folders where your music lives. It scans them into your library — files on disk are never modified."
/>
<AppPressable
style={[styles.choiceButton, isScanning && styles.disabled]}
disabled={isScanning}
onPress={() => void addFolder()}
accessibilityRole="button"
>
<Ionicons name="folder-open-outline" size={20} color={colors.accent} />
<Text variant="body" color={colors.textPrimary}>
{folders.length > 0 ? 'Add another folder' : 'Choose music folder'}
</Text>
</AppPressable>
<ScanProgress />
{isScanning ? (
<Text variant="caption" color={colors.textTertiary} style={styles.hint}>
Scanning keeps running in the background continue whenever you like, or add
more folders.
</Text>
) : folders.length > 0 ? (
<View style={styles.summaryCard}>
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
<Text variant="body" color={colors.textPrimary}>
{formatFolderCount(folders.length)} · {formatTrackCount(totalTrackCount)}
</Text>
</View>
) : (
<Text variant="caption" color={colors.textTertiary} style={styles.hint}>
You can skip this and add folders later from Settings Library.
</Text>
)}
</View>
);
}
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 (
<View style={styles.stepBody}>
<StepHeader
icon="color-palette-outline"
title="Make it yours"
subtitle="Pick a theme. You can change it anytime in Settings."
/>
<View style={styles.themeGrid}>
{options.map((option) => {
const selected = option.id === baseTheme;
return (
<AppPressable
key={option.id}
onPress={() => {
if (selected) return;
playHaptic('selection');
void setBaseTheme(option.id);
}}
style={[styles.themePill, selected && styles.themePillSelected]}
accessibilityRole="radio"
accessibilityState={{ selected }}
>
<Text
variant="label"
color={selected ? colors.accentTextStrong : colors.textSecondary}
>
{option.title}
</Text>
</AppPressable>
);
})}
</View>
{accentApplies ? (
<View style={styles.accentBlock}>
<AccentSwatchRow />
</View>
) : null}
</View>
);
}
function PlayerStep({
choice,
onChoose,
}: {
choice: NowPlayingScopeStyle | null;
onChoose: (style: NowPlayingScopeStyle) => void;
}) {
const styles = useStyles();
return (
<View style={styles.stepBody}>
<StepHeader
icon="pulse-outline"
title="Pick your player look"
subtitle="The player can show live scopes of the music. Choose where they live — you can change this anytime in Settings."
/>
<ScopeStyleCards value={choice} onChange={onChoose} />
</View>
);
}
function DoneStep() {
const styles = useStyles();
const colors = useColors();
const totalTrackCount = useLibraryStore((s) => s.totalTrackCount);
return (
<View style={styles.centered}>
<Animated.View entering={FadeInDown.duration(400)} style={styles.doneBadge}>
<Ionicons name="checkmark" size={44} color={colors.bgPrimary} />
</Animated.View>
<Animated.View entering={FadeInDown.delay(100).duration(400)} style={styles.centeredText}>
<Text variant="title" style={styles.centeredTitle}>
All set
</Text>
<Text variant="body" color={colors.textSecondary} style={styles.centeredSubtitle}>
{totalTrackCount > 0
? `${formatTrackCount(totalTrackCount)} ready to play.`
: 'Add music anytime from Settings Library.'}
</Text>
</Animated.View>
</View>
);
}
/** 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 (
<Animated.View
entering={FadeInDown.duration(220)}
exiting={FadeOutUp.duration(160)}
style={styles.scanBanner}
>
<ActivityIndicator size="small" color={colors.accent} />
<Text
variant="caption"
color={colors.textSecondary}
numberOfLines={1}
style={styles.scanBannerText}
>
{isCancelling
? 'Cancelling library scan…'
: `Scanning your library${detail ? ` · ${detail}` : '…'}`}
</Text>
<AppPressable feedback="control"
disabled={isCancelling}
onPress={cancelScan}
accessibilityRole="button"
accessibilityLabel={isCancelling ? 'Cancelling library scan' : 'Cancel library scan'}
accessibilityState={{ disabled: isCancelling, busy: isCancelling }}
hitSlop={6}
style={styles.scanBannerCancel}
>
<Text
variant="caption"
color={isCancelling ? colors.textTertiary : colors.warning}
>
{isCancelling ? 'Cancelling…' : 'Cancel'}
</Text>
</AppPressable>
</Animated.View>
);
}
/** Page indicator dot — widens + brightens when active. Animated View, not an icon. */
function Dot({ active }: { active: boolean }) {
const styles = useStyles();
return (
<Animated.View
layout={DOT_TRANSITION}
style={[styles.dot, active ? styles.dotActive : styles.dotInactive]}
/>
);
}
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;