mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
artist image search
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Linking,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { AppSheet, AppSheetTitle } from '@/components/sheets/AppSheet';
|
||||
import { Text } from '@/components/Text';
|
||||
import { radius, spacing } from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { useRipple } from '@/theme/ripple';
|
||||
import { searchArtistImageCandidates } from '@/library/artistImageLookup';
|
||||
import type { DeezerArtistCandidate } from '@/types/artistImages';
|
||||
|
||||
export function ArtistImageSearchSheet({
|
||||
artistName,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: {
|
||||
artistName: string;
|
||||
onClose: () => void;
|
||||
onSelect: (candidate: DeezerArtistCandidate) => Promise<void>;
|
||||
}) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
const [query, setQuery] = useState(artistName);
|
||||
const [candidates, setCandidates] = useState<DeezerArtistCandidate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const search = async (value = query) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || loading) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await searchArtistImageCandidates(trimmed);
|
||||
if (result.status === 'transient_error') {
|
||||
setCandidates([]);
|
||||
setError(result.message);
|
||||
} else {
|
||||
setCandidates(result.candidates);
|
||||
if (result.candidates.length === 0) {
|
||||
setError('No artist images matched that search.');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => void search(artistName));
|
||||
// Run once with the artist name used to open this sheet.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [artistName]);
|
||||
|
||||
const choose = async (candidate: DeezerArtistCandidate) => {
|
||||
if (selectedId) return;
|
||||
setSelectedId(candidate.id);
|
||||
setError(null);
|
||||
try {
|
||||
await onSelect(candidate);
|
||||
onClose();
|
||||
} catch {
|
||||
setError('That image could not be downloaded. Check your connection and try again.');
|
||||
setSelectedId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openDeezerLink = async (url: string) => {
|
||||
try {
|
||||
await Linking.openURL(url);
|
||||
} catch {
|
||||
setError('Astra could not open the Deezer link.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppSheet onClose={onClose} scrollable>
|
||||
<AppSheetTitle
|
||||
title="Search Deezer"
|
||||
subtitle="Choose the artist—not an album cover"
|
||||
/>
|
||||
<View style={styles.searchRow}>
|
||||
<BottomSheetTextInput
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
onSubmitEditing={() => void search()}
|
||||
placeholder="Artist name"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
returnKeyType="search"
|
||||
autoCapitalize="words"
|
||||
style={styles.input}
|
||||
accessibilityLabel="Deezer artist search"
|
||||
/>
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded}
|
||||
style={styles.searchButton}
|
||||
onPress={() => void search()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Search Deezer"
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={colors.accentTextStrong} />
|
||||
) : (
|
||||
<Ionicons name="search" size={20} color={colors.accentTextStrong} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{error ? (
|
||||
<View style={styles.message}>
|
||||
<Ionicons name="information-circle-outline" size={18} color={colors.textSecondary} />
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.messageText}>
|
||||
{error}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.results}>
|
||||
{candidates.map((candidate) => (
|
||||
<Pressable
|
||||
key={candidate.id}
|
||||
android_ripple={ripple.bounded}
|
||||
style={styles.candidate}
|
||||
onPress={() => void choose(candidate)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Use Deezer image for ${candidate.name}`}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: candidate.imageUrl }}
|
||||
style={styles.thumbnail}
|
||||
contentFit="cover"
|
||||
transition={100}
|
||||
/>
|
||||
<View style={styles.candidateText}>
|
||||
<Text variant="body" numberOfLines={1}>{candidate.name}</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
|
||||
{formatFans(candidate.fanCount)}
|
||||
</Text>
|
||||
</View>
|
||||
{candidate.linkUrl ? (
|
||||
<Pressable
|
||||
style={styles.providerLink}
|
||||
hitSlop={8}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation();
|
||||
void openDeezerLink(candidate.linkUrl!);
|
||||
}}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`Open ${candidate.name} on Deezer`}
|
||||
>
|
||||
<Ionicons name="open-outline" size={17} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
{selectedId === candidate.id ? (
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
) : (
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={styles.attribution}
|
||||
onPress={() => void openDeezerLink('https://www.deezer.com/')}
|
||||
accessibilityRole="link"
|
||||
>
|
||||
<Text variant="caption" color={colors.textSecondary}>Images and artist data from Deezer</Text>
|
||||
<Ionicons name="open-outline" size={14} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</AppSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function formatFans(count: number): string {
|
||||
return `${new Intl.NumberFormat().format(count)} Deezer fans`;
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
searchRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
alignItems: 'center',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
height: 48,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.bgTertiary,
|
||||
color: colors.textPrimary,
|
||||
paddingHorizontal: spacing.md,
|
||||
fontFamily: 'Inter_400Regular',
|
||||
fontSize: 16,
|
||||
},
|
||||
searchButton: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: radius.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.accentGlow,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
message: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.bgTertiary,
|
||||
},
|
||||
messageText: {
|
||||
flex: 1,
|
||||
},
|
||||
results: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
candidate: {
|
||||
minHeight: 76,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.glassBorder,
|
||||
},
|
||||
thumbnail: {
|
||||
width: 58,
|
||||
height: 58,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: colors.bgTertiary,
|
||||
},
|
||||
candidateText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: 3,
|
||||
},
|
||||
providerLink: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.pill,
|
||||
},
|
||||
attribution: {
|
||||
minHeight: 48,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '@/components/Text';
|
||||
import { radius, spacing } from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { useRipple } from '@/theme/ripple';
|
||||
import { useArtistImageStore } from '@/stores/artistImageStore';
|
||||
import { requeueMissingArtistImages } from '@/library/artistImageLookup';
|
||||
|
||||
const n = (value: number) => value.toLocaleString();
|
||||
|
||||
/**
|
||||
* Live sweep progress, or — when idle — how many artists still have no portrait
|
||||
* plus a way to look again. The retry exists because `not_found` is terminal in
|
||||
* the pending query: without it, re-checking those artists would mean rescanning
|
||||
* the whole library.
|
||||
*/
|
||||
export function ArtistImageSweepStatus({ enabled }: { enabled: boolean }) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
const running = useArtistImageStore((s) => s.running);
|
||||
const processed = useArtistImageStore((s) => s.processed);
|
||||
const total = useArtistImageStore((s) => s.total);
|
||||
const missing = useArtistImageStore((s) => s.missing);
|
||||
const refreshMissing = useArtistImageStore((s) => s.refreshMissing);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshMissing();
|
||||
}, [refreshMissing]);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
if (running) {
|
||||
// Clamped: the denominator is counted once up front and normalizes names
|
||||
// slightly differently than the grouping does, so it can drift by a few.
|
||||
const fraction = total > 0 ? Math.min(processed / total, 1) : 0;
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
|
||||
{total > 0
|
||||
? `Looking up artist images… ${n(processed)} of ${n(total)}`
|
||||
: 'Looking up artist images…'}
|
||||
</Text>
|
||||
<View style={styles.track}>
|
||||
<View
|
||||
style={[
|
||||
styles.fill,
|
||||
fraction > 0 ? { width: `${fraction * 100}%` } : styles.fillIndeterminate,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (missing <= 0) return null;
|
||||
|
||||
const retry = async () => {
|
||||
if (retrying) return;
|
||||
setRetrying(true);
|
||||
try {
|
||||
await requeueMissingArtistImages();
|
||||
} finally {
|
||||
setRetrying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text variant="caption" color={colors.textSecondary}>
|
||||
{missing === 1 ? '1 artist has no image' : `${n(missing)} artists have no image`}
|
||||
</Text>
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded}
|
||||
style={styles.button}
|
||||
disabled={retrying}
|
||||
onPress={() => void retry()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Look for missing artist images now"
|
||||
>
|
||||
{retrying ? (
|
||||
<ActivityIndicator size="small" color={colors.accentTextStrong} />
|
||||
) : (
|
||||
<Ionicons name="refresh" size={16} color={colors.accentTextStrong} />
|
||||
)}
|
||||
<Text variant="label" color={colors.accentTextStrong}>
|
||||
Look for missing images
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
container: {
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
track: {
|
||||
height: 2,
|
||||
backgroundColor: colors.glassBorder,
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fill: {
|
||||
height: 2,
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
fillIndeterminate: {
|
||||
width: '100%',
|
||||
opacity: 0.35,
|
||||
},
|
||||
button: {
|
||||
minHeight: 44,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.accentGlow,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}));
|
||||
|
||||
export default ArtistImageSweepStatus;
|
||||
@@ -128,6 +128,7 @@ export function CollapsingHeader({
|
||||
onBack,
|
||||
backLabel,
|
||||
onMore,
|
||||
moreAccessibilityLabel = 'More options',
|
||||
onPlay,
|
||||
onShuffle,
|
||||
scrollY,
|
||||
@@ -149,6 +150,7 @@ export function CollapsingHeader({
|
||||
/** Names the screen `onBack` returns to; must track the real action. */
|
||||
backLabel: string;
|
||||
onMore?: () => void;
|
||||
moreAccessibilityLabel?: string;
|
||||
onPlay: () => void;
|
||||
onShuffle: () => void;
|
||||
scrollY: SharedValue<number>;
|
||||
@@ -327,7 +329,7 @@ export function CollapsingHeader({
|
||||
hitSlop={8}
|
||||
style={[styles.moreButton, { top: barCenterY - 16, right: spacing.md }]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Playlist options"
|
||||
accessibilityLabel={moreAccessibilityLabel}
|
||||
>
|
||||
<Ionicons name="ellipsis-horizontal" size={22} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
View,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '@/components/Text';
|
||||
import { radius, spacing } from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { useRipple } from '@/theme/ripple';
|
||||
import { useScanNotificationPermission } from '@/library/useScanNotificationPermission';
|
||||
|
||||
/**
|
||||
* Dense settings-list form of the notification permission, rendered only while
|
||||
* there is still something to ask for. A permanently-satisfied "Allowed" row is
|
||||
* noise in a settings list, so the card removes itself once the permission is
|
||||
* held (or was never required) — including the surrounding spacing, which is why
|
||||
* the caller passes `style` instead of wrapping this in its own View.
|
||||
*
|
||||
* The onboarding wizard deliberately does not reuse this: it has a whole page to
|
||||
* fill, so it renders its own layout over the same
|
||||
* `useScanNotificationPermission` state.
|
||||
*/
|
||||
export function ScanNotificationPermissionCard({
|
||||
style,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
const { state, granted, denied, working, resolve } = useScanNotificationPermission();
|
||||
|
||||
// Also hidden while the first check is in flight, so the row never appears
|
||||
// just to vanish a frame later on an already-granted device.
|
||||
if (state === null || granted) return null;
|
||||
|
||||
return (
|
||||
<View style={[styles.card, style]}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.icon}>
|
||||
<Ionicons name="notifications-outline" size={20} color={colors.accent} />
|
||||
</View>
|
||||
<View style={styles.copy}>
|
||||
<Text variant="body">Scan progress notification</Text>
|
||||
<Text variant="caption" color={colors.textSecondary}>
|
||||
The temporary notification shows progress and lets Android keep a scan running
|
||||
after you leave Astra. Scans still work if you skip it.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded}
|
||||
style={styles.button}
|
||||
disabled={working}
|
||||
onPress={resolve}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
{working ? (
|
||||
<ActivityIndicator size="small" color={colors.accentTextStrong} />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={denied ? 'settings-outline' : 'notifications-outline'}
|
||||
size={18}
|
||||
color={colors.accentTextStrong}
|
||||
/>
|
||||
)}
|
||||
<Text variant="label" color={colors.accentTextStrong}>
|
||||
{denied ? 'Open Settings' : 'Allow scan notifications'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{denied ? (
|
||||
<Text variant="caption" color={colors.textTertiary}>
|
||||
Notification permission is denied. You can enable it in Android settings.
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
card: {
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.bgSecondary,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.md,
|
||||
},
|
||||
icon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: radius.pill,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.accentGlow,
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
button: {
|
||||
minHeight: 44,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.accentGlow,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState } from 'react';
|
||||
import { ActivityIndicator, Modal, Pressable, StyleSheet, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '@/components/Text';
|
||||
import { SegmentedControl } from '@/components/SegmentedControl';
|
||||
import { radius, spacing } from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { useRipple } from '@/theme/ripple';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
import type { ArtistImageAutoPolicy } from '@/types/artistImages';
|
||||
|
||||
export function ArtistImageDisclosurePrompt() {
|
||||
const loaded = useSettingsStore((s) => s.loaded);
|
||||
const seen = useSettingsStore((s) => s.artistImageDisclosureSeen);
|
||||
const policy = useSettingsStore((s) => s.artistImageAutoPolicy);
|
||||
if (!loaded || seen) return null;
|
||||
return <ArtistImageDisclosureContent initialPolicy={policy} />;
|
||||
}
|
||||
|
||||
function ArtistImageDisclosureContent({
|
||||
initialPolicy,
|
||||
}: {
|
||||
initialPolicy: ArtistImageAutoPolicy;
|
||||
}) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
const [policy, setPolicy] = useState<ArtistImageAutoPolicy>(initialPolicy);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const continueSetup = async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await useSettingsStore.getState().setArtistImageAutoPolicy(policy);
|
||||
await useSettingsStore.getState().acknowledgeArtistImageDisclosure();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
transparent
|
||||
animationType="fade"
|
||||
statusBarTranslucent
|
||||
onRequestClose={() => undefined}
|
||||
>
|
||||
<View style={styles.backdrop}>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.icon}>
|
||||
<Ionicons name="person-circle-outline" size={28} color={colors.accent} />
|
||||
</View>
|
||||
<View style={styles.copy}>
|
||||
<Text variant="heading">Set up artist images</Text>
|
||||
<Text variant="body" color={colors.textSecondary}>
|
||||
Astra can send artist names to Deezer, then store selected portraits locally
|
||||
so they still appear offline. Existing album art remains the fallback.
|
||||
</Text>
|
||||
</View>
|
||||
<SegmentedControl
|
||||
segments={[
|
||||
{ key: 'wifi', label: 'Wi-Fi' },
|
||||
{ key: 'any', label: 'Any network' },
|
||||
{ key: 'off', label: 'Off' },
|
||||
]}
|
||||
value={policy}
|
||||
onChange={(value) => setPolicy(value as ArtistImageAutoPolicy)}
|
||||
/>
|
||||
<Text variant="caption" color={colors.textTertiary}>
|
||||
Ethernet is included in Wi-Fi mode. You can change this later in Settings › Library,
|
||||
and manual searches work while automatic downloads are off.
|
||||
</Text>
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded}
|
||||
style={styles.button}
|
||||
onPress={() => void continueSetup()}
|
||||
disabled={saving}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
{saving ? (
|
||||
<ActivityIndicator size="small" color={colors.bgPrimary} />
|
||||
) : (
|
||||
<Text variant="label" color={colors.bgPrimary}>Continue</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
backgroundColor: colors.backdrop,
|
||||
},
|
||||
card: {
|
||||
gap: spacing.lg,
|
||||
padding: spacing.xl,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.bgSecondary,
|
||||
},
|
||||
icon: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: colors.accentGlow,
|
||||
},
|
||||
copy: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
button: {
|
||||
minHeight: 50,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.accent,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useEffect, type ComponentProps, type ReactNode } from 'react';
|
||||
import { Pressable, StyleSheet, View } from 'react-native';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '@/components/Text';
|
||||
import { StepHeader } from '@/components/onboarding/StepHeader';
|
||||
import { radius, spacing } from '@/theme';
|
||||
import { motion } from '@/theme/motion';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { useRipple } from '@/theme/ripple';
|
||||
import { playHaptic } from '@/lib/haptics';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
import type { ArtistImageAutoPolicy } from '@/types/artistImages';
|
||||
|
||||
type IoniconName = ComponentProps<typeof Ionicons>['name'];
|
||||
|
||||
const POLICY_OPTIONS: {
|
||||
policy: ArtistImageAutoPolicy;
|
||||
icon: IoniconName;
|
||||
title: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
policy: 'wifi',
|
||||
icon: 'wifi-outline',
|
||||
title: 'Wi-Fi or Ethernet',
|
||||
description: 'Recommended — never uses mobile data.',
|
||||
},
|
||||
{
|
||||
policy: 'any',
|
||||
icon: 'cellular-outline',
|
||||
title: 'Any network',
|
||||
description: 'Includes mobile data.',
|
||||
},
|
||||
{
|
||||
policy: 'off',
|
||||
icon: 'remove-circle-outline',
|
||||
title: 'Off',
|
||||
description: 'Nothing is sent. Manual search still works.',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Wizard page for the Deezer artist-image disclosure. Unlike the settings row
|
||||
* this flattens the toggle-plus-segmented-control into three equal-weight cards:
|
||||
* the network choice is the same decision as opting in, so hiding it behind a
|
||||
* switch made a consent screen read as two unrelated controls.
|
||||
*
|
||||
* The current policy is preselected (unlike the scope-style step, which stays
|
||||
* unset to avoid biasing taste feedback) — on a consent page the user needs to
|
||||
* see what will actually happen if they just tap Continue.
|
||||
*/
|
||||
export function ArtistImageStep() {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const policy = useSettingsStore((s) => s.artistImageAutoPolicy);
|
||||
const setPolicy = useSettingsStore((s) => s.setArtistImageAutoPolicy);
|
||||
|
||||
const choose = (next: ArtistImageAutoPolicy) => {
|
||||
if (next === policy) return;
|
||||
playHaptic('selection');
|
||||
void setPolicy(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.stepBody}>
|
||||
<StepHeader
|
||||
icon="person-circle-outline"
|
||||
title="Artist portraits"
|
||||
subtitle="Astra can look up artist photos on Deezer and store them on your device, so they show up offline too."
|
||||
/>
|
||||
|
||||
<PortraitPreview enabled={policy !== 'off'} />
|
||||
|
||||
<View style={styles.options}>
|
||||
{POLICY_OPTIONS.map((option) => (
|
||||
<PolicyCard
|
||||
key={option.policy}
|
||||
icon={option.icon}
|
||||
title={option.title}
|
||||
description={option.description}
|
||||
selected={option.policy === policy}
|
||||
onPress={() => choose(option.policy)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text variant="caption" color={colors.textTertiary} style={styles.footnote}>
|
||||
Only the artist name is sent. Change this anytime in Settings › Library.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Side-by-side sketch of an artist tile with a portrait vs. the album-art
|
||||
* fallback. The highlight follows the choice below — picking "Off" moves it to
|
||||
* the fallback tile, so the cards and the preview always agree.
|
||||
*/
|
||||
function PortraitPreview({ enabled }: { enabled: boolean }) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
return (
|
||||
<View style={styles.preview}>
|
||||
<PreviewTile active={enabled} label="With portraits">
|
||||
<Ionicons
|
||||
name="person"
|
||||
size={22}
|
||||
color={enabled ? colors.accent : colors.textTertiary}
|
||||
/>
|
||||
</PreviewTile>
|
||||
<PreviewTile active={!enabled} label="Album art only">
|
||||
<View style={styles.previewAlbumSquare} />
|
||||
</PreviewTile>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Two stacked circle layers cross-faded by opacity rather than an animated
|
||||
* border/background colour — the repo's established way to move a highlight
|
||||
* without handing colours to a worklet.
|
||||
*/
|
||||
function PreviewTile({
|
||||
active,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const progress = useSharedValue(active ? 1 : 0);
|
||||
useEffect(() => {
|
||||
progress.value = withTiming(active ? 1 : 0, motion.snap);
|
||||
}, [active, progress]);
|
||||
const tileStyle = useAnimatedStyle(() => ({ opacity: 0.42 + progress.value * 0.58 }));
|
||||
const accentStyle = useAnimatedStyle(() => ({ opacity: progress.value }));
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.previewItem, tileStyle]}>
|
||||
<View style={styles.previewCircleWrap}>
|
||||
<View style={[styles.previewCircleLayer, styles.previewCircleNeutral]} />
|
||||
<Animated.View
|
||||
style={[styles.previewCircleLayer, styles.previewCircleAccent, accentStyle]}
|
||||
/>
|
||||
{children}
|
||||
</View>
|
||||
<View style={styles.previewNameLine} />
|
||||
<Text variant="caption" color={active ? colors.textSecondary : colors.textTertiary}>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
selected,
|
||||
onPress,
|
||||
}: {
|
||||
icon: IoniconName;
|
||||
title: string;
|
||||
description: string;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
return (
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded}
|
||||
style={[styles.card, selected && styles.cardSelected]}
|
||||
onPress={onPress}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected }}
|
||||
accessibilityLabel={`${title}. ${description}`}
|
||||
>
|
||||
<View style={[styles.cardIcon, selected && styles.cardIconSelected]}>
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={20}
|
||||
color={selected ? colors.accentTextStrong : colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.cardCopy}>
|
||||
<Text variant="label" color={selected ? colors.accentTextStrong : colors.textPrimary}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.textSecondary}>
|
||||
{description}
|
||||
</Text>
|
||||
</View>
|
||||
{/* Always occupies its slot. Rendering the checkmark only when selected
|
||||
narrowed the copy column on tap, which re-wrapped the longer
|
||||
descriptions and changed the height of the whole page. */}
|
||||
<View style={styles.cardCheck}>
|
||||
{selected ? (
|
||||
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
|
||||
) : (
|
||||
<View style={styles.cardCheckEmpty} />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
stepBody: {
|
||||
width: '100%',
|
||||
gap: spacing.lg,
|
||||
},
|
||||
preview: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xl,
|
||||
},
|
||||
previewItem: {
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
previewCircleWrap: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
previewCircleLayer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 28,
|
||||
},
|
||||
previewCircleNeutral: {
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.bgTertiary,
|
||||
},
|
||||
previewCircleAccent: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.accent,
|
||||
backgroundColor: colors.accentGlow,
|
||||
},
|
||||
previewAlbumSquare: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 4,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.textTertiary,
|
||||
},
|
||||
previewNameLine: {
|
||||
width: 34,
|
||||
height: 5,
|
||||
borderRadius: 2.5,
|
||||
backgroundColor: colors.glassBorder,
|
||||
},
|
||||
options: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
minHeight: 68,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
},
|
||||
cardSelected: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.accent,
|
||||
backgroundColor: colors.accentGlow,
|
||||
},
|
||||
cardIcon: {
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 19,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgTertiary,
|
||||
},
|
||||
cardIconSelected: {
|
||||
backgroundColor: colors.bgSecondary,
|
||||
},
|
||||
cardCopy: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: 2,
|
||||
},
|
||||
cardCheck: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
cardCheckEmpty: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.glassBorder,
|
||||
},
|
||||
footnote: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
}));
|
||||
|
||||
export default ArtistImageStep;
|
||||
@@ -0,0 +1,195 @@
|
||||
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '@/components/Text';
|
||||
import { StepHeader } from '@/components/onboarding/StepHeader';
|
||||
import { radius, spacing } from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { useRipple } from '@/theme/ripple';
|
||||
import { useLibraryStore } from '@/stores/libraryStore';
|
||||
import type { ScanNotificationPermission } from '@/library/useScanNotificationPermission';
|
||||
|
||||
/**
|
||||
* Wizard page for the POST_NOTIFICATIONS grant. Deliberately not the settings
|
||||
* `ScanNotificationPermissionCard`: with a whole page to work with, the ask gets
|
||||
* a sketch of the actual notification and one unmistakable action instead of a
|
||||
* dense list row. The permission state is owned by OnboardingFlow so the footer
|
||||
* button can say "Continue" vs "Skip for now" from the same source of truth.
|
||||
*/
|
||||
export function NotificationStep({
|
||||
permission,
|
||||
}: {
|
||||
permission: ScanNotificationPermission;
|
||||
}) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
const isScanning = useLibraryStore((s) => s.isScanning);
|
||||
const { state, granted, denied, working, resolve } = permission;
|
||||
|
||||
return (
|
||||
<View style={styles.stepBody}>
|
||||
<StepHeader
|
||||
icon="notifications-outline"
|
||||
title="Keep scans running"
|
||||
subtitle={
|
||||
isScanning
|
||||
? 'Your scan is running now. Android needs permission to show its progress and keep it going after you leave Astra.'
|
||||
: 'Astra shows a temporary progress notification while scanning. It is what lets Android keep a scan running after you leave the app.'
|
||||
}
|
||||
/>
|
||||
|
||||
<NotificationSketch />
|
||||
|
||||
{state === null ? (
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
) : granted ? (
|
||||
<View style={styles.grantedRow}>
|
||||
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
|
||||
<Text variant="body" color={colors.textPrimary}>
|
||||
{state === 'not_required'
|
||||
? 'No permission needed on this Android version'
|
||||
: 'Notifications allowed'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded}
|
||||
style={styles.button}
|
||||
disabled={working}
|
||||
onPress={resolve}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={denied ? 'Open Android settings' : 'Allow scan notifications'}
|
||||
>
|
||||
{working ? (
|
||||
<ActivityIndicator size="small" color={colors.accentTextStrong} />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={denied ? 'settings-outline' : 'notifications-outline'}
|
||||
size={19}
|
||||
color={colors.accentTextStrong}
|
||||
/>
|
||||
)}
|
||||
<Text variant="label" color={colors.accentTextStrong}>
|
||||
{denied ? 'Open Android settings' : 'Allow notifications'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<Text variant="caption" color={colors.textTertiary} style={styles.footnote}>
|
||||
{denied
|
||||
? 'Notifications are currently blocked for Astra. Scans still work — they just stop early if Android needs the memory.'
|
||||
: 'Optional. Scans still work without it, but Android may stop a long scan once you leave the app.'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Miniature of the real scan notification. Static numbers on purpose — a live
|
||||
* counter here would compete with the actual ScanBanner above the page.
|
||||
*/
|
||||
function NotificationSketch() {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
return (
|
||||
<View style={styles.sketch}>
|
||||
<View style={styles.sketchHeader}>
|
||||
<View style={styles.sketchAppIcon}>
|
||||
<Ionicons name="musical-note" size={11} color={colors.bgPrimary} />
|
||||
</View>
|
||||
<Text variant="caption" color={colors.textSecondary}>
|
||||
Astra
|
||||
</Text>
|
||||
<View style={styles.sketchSeparator} />
|
||||
<Text variant="caption" color={colors.textTertiary}>
|
||||
now
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="body" numberOfLines={1}>
|
||||
Scanning your library
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
|
||||
1,204 of 3,180 tracks
|
||||
</Text>
|
||||
<View style={styles.sketchTrack}>
|
||||
<View style={styles.sketchFill} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
stepBody: {
|
||||
width: '100%',
|
||||
gap: spacing.lg,
|
||||
},
|
||||
sketch: {
|
||||
gap: spacing.xs,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.bgSecondary,
|
||||
},
|
||||
sketchHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
marginBottom: 2,
|
||||
},
|
||||
sketchAppIcon: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 5,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
sketchSeparator: {
|
||||
width: 3,
|
||||
height: 3,
|
||||
borderRadius: 1.5,
|
||||
backgroundColor: colors.textTertiary,
|
||||
},
|
||||
sketchTrack: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.bgTertiary,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
sketchFill: {
|
||||
width: '38%',
|
||||
height: '100%',
|
||||
borderRadius: 2,
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
button: {
|
||||
minHeight: 52,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.accentGlow,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
grantedRow: {
|
||||
minHeight: 52,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
},
|
||||
footnote: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
}));
|
||||
|
||||
export default NotificationStep;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, type ComponentProps } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
@@ -23,6 +23,9 @@ 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';
|
||||
@@ -30,14 +33,33 @@ import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { useRipple } from '@/theme/ripple';
|
||||
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 IoniconName = ComponentProps<typeof Ionicons>['name'];
|
||||
type StepId = 'welcome' | 'library' | 'theme' | 'player' | 'done';
|
||||
type StepId =
|
||||
| 'welcome'
|
||||
| 'library'
|
||||
| 'notifications'
|
||||
| 'artistImages'
|
||||
| 'theme'
|
||||
| 'player'
|
||||
| 'done';
|
||||
|
||||
const STEP_ORDER: StepId[] = ['welcome', 'library', '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' },
|
||||
@@ -63,6 +85,9 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) {
|
||||
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);
|
||||
@@ -77,7 +102,7 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) {
|
||||
};
|
||||
const goBack = () => setStepIndex((i) => Math.max(0, i - 1));
|
||||
|
||||
const canGoBack = step === 'library' || step === 'theme' || step === 'player';
|
||||
const canGoBack = stepIndex > 0 && step !== 'done';
|
||||
const primaryLabel =
|
||||
step === 'welcome'
|
||||
? 'Get started'
|
||||
@@ -88,9 +113,14 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) {
|
||||
foldersCount > 0 || isScanning
|
||||
? 'Continue'
|
||||
: 'Skip for now'
|
||||
: step === 'theme' || step === 'player'
|
||||
? 'Continue'
|
||||
: 'Start listening';
|
||||
: 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}>
|
||||
@@ -123,6 +153,10 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) {
|
||||
<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} />
|
||||
@@ -203,6 +237,7 @@ function LibraryStep() {
|
||||
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."
|
||||
/>
|
||||
|
||||
<Pressable android_ripple={ripple.bounded}
|
||||
style={[styles.choiceButton, isScanning && styles.disabled]}
|
||||
disabled={isScanning}
|
||||
@@ -336,32 +371,6 @@ function DoneStep() {
|
||||
);
|
||||
}
|
||||
|
||||
function StepHeader({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
icon: IoniconName;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
return (
|
||||
<View style={styles.stepHeader}>
|
||||
<View style={styles.stepIconWrap}>
|
||||
<Ionicons name={icon} size={26} color={colors.accent} />
|
||||
</View>
|
||||
<Text variant="heading" style={styles.centeredTitle}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text variant="body" color={colors.textSecondary} style={styles.centeredSubtitle}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** Subtle "still scanning" pill shown at the top of steps after the library step. */
|
||||
function ScanBanner() {
|
||||
const styles = useStyles();
|
||||
@@ -496,22 +505,6 @@ const useStyles = createThemedStyles((colors) => ({
|
||||
width: '100%',
|
||||
gap: spacing.lg,
|
||||
},
|
||||
stepHeader: {
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
stepIconWrap: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: colors.glassBg,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
choiceButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '@/components/Text';
|
||||
import { spacing } from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
|
||||
type IoniconName = ComponentProps<typeof Ionicons>['name'];
|
||||
|
||||
/**
|
||||
* Shared masthead for every wizard page: medallion icon, title, and the one-line
|
||||
* reason the page exists. Lives outside OnboardingFlow so the individual step
|
||||
* files can use it without importing their own parent.
|
||||
*/
|
||||
export function StepHeader({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
icon: IoniconName;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
return (
|
||||
<View style={styles.stepHeader}>
|
||||
<View style={styles.stepIconWrap}>
|
||||
<Ionicons name={icon} size={26} color={colors.accent} />
|
||||
</View>
|
||||
<Text variant="heading" style={styles.title}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text variant="body" color={colors.textSecondary} style={styles.subtitle}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
stepHeader: {
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
stepIconWrap: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: colors.glassBg,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
textAlign: 'center',
|
||||
maxWidth: 340,
|
||||
},
|
||||
}));
|
||||
|
||||
export default StepHeader;
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { EQSlider } from '@/components/eq/EQSlider';
|
||||
import { ScanProgress } from '@/components/library/ScanProgress';
|
||||
import { ScanNotificationPermissionCard } from '@/components/library/ScanNotificationPermissionCard';
|
||||
import { ArtistImageSweepStatus } from '@/components/library/ArtistImageSweepStatus';
|
||||
import { SegmentedControl } from '@/components/SegmentedControl';
|
||||
import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow';
|
||||
import { ScopeStyleCards } from '@/components/settings/ScopeStyleCards';
|
||||
@@ -393,11 +395,51 @@ export function LibrarySettingsPanel() {
|
||||
const setIncludeSingles = useSettingsStore((s) => s.setIncludeSingles);
|
||||
const includeCollabArtists = useLibraryStore((s) => s.includeCollabArtists);
|
||||
const setIncludeCollabArtists = useLibraryStore((s) => s.setIncludeCollabArtists);
|
||||
const artistImageAutoPolicy = useSettingsStore((s) => s.artistImageAutoPolicy);
|
||||
const setArtistImageAutoPolicy = useSettingsStore((s) => s.setArtistImageAutoPolicy);
|
||||
const artistImagesEnabled = artistImageAutoPolicy !== 'off';
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionLabel>LOCAL FOLDERS</SettingsSectionLabel>
|
||||
<LibraryFoldersSettings />
|
||||
{/* Renders nothing once the permission is granted — the style carries the
|
||||
spacing so no empty gap is left behind. */}
|
||||
<ScanNotificationPermissionCard style={styles.cardSpacing} />
|
||||
|
||||
<SettingsSectionLabel spaced>ARTIST IMAGES</SettingsSectionLabel>
|
||||
<SettingsCard>
|
||||
<SettingsToggleRow
|
||||
title="Automatic artist images"
|
||||
description="Send artist names to Deezer and cache selected images locally for offline use."
|
||||
value={artistImagesEnabled}
|
||||
onValueChange={(enabled) =>
|
||||
void setArtistImageAutoPolicy(enabled ? 'wifi' : 'off')
|
||||
}
|
||||
/>
|
||||
{artistImagesEnabled ? (
|
||||
<View style={styles.indent}>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
|
||||
Download network
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
segments={[
|
||||
{ key: 'wifi', label: 'Wi-Fi / Ethernet' },
|
||||
{ key: 'any', label: 'Any network' },
|
||||
]}
|
||||
value={artistImageAutoPolicy}
|
||||
onChange={(value) =>
|
||||
void setArtistImageAutoPolicy(value === 'any' ? 'any' : 'wifi')
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<Text variant="caption" color={colors.textTertiary} style={styles.settingNote}>
|
||||
Manual Deezer searches remain available from an artist’s menu.
|
||||
</Text>
|
||||
)}
|
||||
<ArtistImageSweepStatus enabled={artistImagesEnabled} />
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsSectionLabel spaced>LIBRARY VIEW</SettingsSectionLabel>
|
||||
<Text variant="body" style={styles.settingTitle}>
|
||||
|
||||
Reference in New Issue
Block a user