mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-18 11:44:44 +02:00
607 lines
19 KiB
TypeScript
607 lines
19 KiB
TypeScript
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;
|