Mobile: redesign, onboarding, QR codes, logo, Spotify setup
- Redesign matching the web: album-art backdrops, Reanimated equalizer, spinning live ring, drifting ambient background, gradient buttons with spring press feedback and haptics, Space Grotesk headings, animated feed (enter + layout transitions); fix invisible tab labels - Four-step onboarding for new accounts: which app you listen with (sets where songs open), the matching share setup, find friends (search, scan, invite), and a featured song. Skippable; stored server-side - QR codes: My code (share link) and an in-app scanner (camera only, no microphone permission) - Spotify setup via Last.fm or ListenBrainz with guided linking; Android shares Spotify automatically through the phone reader - New logo as app icon, adaptive/monochrome icon and splash - app.config.ts allows cleartext only for local http:// test builds Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { colors } from '@/lib/theme';
|
||||
|
||||
export default function OnboardingLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg }, animation: 'slide_from_right' }} />;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { Pressable, Share, StyleSheet, Text, View } from 'react-native';
|
||||
import useSWR from 'swr';
|
||||
import { profileUrl, type UserSummaryDTO } from '@musicshare/shared';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { OnboardingStep } from '@/components/OnboardingStep';
|
||||
import { Body, Button, Field, Row, Title } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { WEB_URL } from '@/lib/config';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { successHaptic } from '@/lib/haptics';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
import { useDebounced } from '@/lib/useDebounced';
|
||||
|
||||
/** Step 3: an empty feed is the fastest way to lose someone, so find friends now. */
|
||||
export default function FriendsStep() {
|
||||
const { me } = useSession();
|
||||
const [q, setQ] = useState('');
|
||||
const [followed, setFollowed] = useState<Set<string>>(new Set());
|
||||
const query = useDebounced(q.trim(), 250);
|
||||
const { data: results } = useSWR<UserSummaryDTO[]>(query.length >= 2 ? `/users/search?q=${encodeURIComponent(query)}` : null);
|
||||
|
||||
const follow = async (username: string) => {
|
||||
try {
|
||||
await api.follow(username);
|
||||
successHaptic();
|
||||
setFollowed((s) => new Set(s).add(username));
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const invite = () => {
|
||||
if (!me) return;
|
||||
const url = profileUrl(WEB_URL, me.username);
|
||||
Share.share({ message: `I'm on musicshare: follow me and see what I'm listening to → ${url}`, url }).catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingStep step={3}>
|
||||
<View style={{ gap: space.sm }}>
|
||||
<Title>Find your friends</Title>
|
||||
<Body muted>Follow people to see what they’re playing, live.</Body>
|
||||
</View>
|
||||
|
||||
<Row>
|
||||
<Button small variant="primary" title="Scan a code" onPress={() => router.push('/scan')} />
|
||||
<Button small title="Invite friends" onPress={invite} />
|
||||
</Row>
|
||||
|
||||
<View style={{ gap: space.sm }}>
|
||||
<Field placeholder="Search by name or username" value={q} onChangeText={setQ} autoCapitalize="none" autoCorrect={false} />
|
||||
{results
|
||||
?.filter((u) => u.id !== me?.id)
|
||||
.slice(0, 6)
|
||||
.map((u) => (
|
||||
<View key={u.id} style={styles.row}>
|
||||
<Pressable style={styles.who} onPress={() => router.push(`/u/${u.username}`)}>
|
||||
<Avatar user={u} size={36} />
|
||||
<View style={{ flexShrink: 1 }}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{u.displayName ?? u.username}
|
||||
</Text>
|
||||
<Text style={styles.handle}>@{u.username}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
<Button
|
||||
small
|
||||
variant={followed.has(u.username) ? 'secondary' : 'primary'}
|
||||
title={followed.has(u.username) ? 'Following' : 'Follow'}
|
||||
disabled={followed.has(u.username)}
|
||||
onPress={() => follow(u.username)}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
{query.length >= 2 && results?.length === 0 && <Body muted>No one by that name yet. Invite them!</Body>}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
variant={followed.size ? 'primary' : 'secondary'}
|
||||
title={followed.size ? `Continue (${followed.size} followed)` : 'Skip for now'}
|
||||
onPress={() => router.push('/onboarding/song')}
|
||||
/>
|
||||
</OnboardingStep>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: space.md },
|
||||
who: { flexDirection: 'row', alignItems: 'center', gap: space.md, flexShrink: 1 },
|
||||
name: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
||||
handle: { color: colors.muted, fontSize: 13 },
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||
import { OnboardingStep } from '@/components/OnboardingStep';
|
||||
import { Body, Button, Title } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { selectionHaptic } from '@/lib/haptics';
|
||||
import { LISTEN_APPS, finishOnboarding } from '@/lib/onboarding';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
|
||||
/** Step 1: where do you listen? Sets where friends' songs open, and picks the right setup next. */
|
||||
export default function ListenAppStep() {
|
||||
const { me, setMe } = useSession();
|
||||
const [picked, setPicked] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const next = async () => {
|
||||
const app = LISTEN_APPS.find((a) => a.id === picked);
|
||||
if (!app) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (app.service) setMe(await api.updateMe({ preferredService: app.service }));
|
||||
router.push({ pathname: '/onboarding/share', params: { app: app.id } });
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingStep step={1}>
|
||||
<View style={{ gap: space.sm }}>
|
||||
<Title>Hi {me?.displayName ?? me?.username} 👋</Title>
|
||||
<Body muted>Which app do you mostly listen with?</Body>
|
||||
</View>
|
||||
<View style={styles.grid}>
|
||||
{LISTEN_APPS.map((app, i) => (
|
||||
<Animated.View key={app.id} entering={FadeInDown.delay(80 + i * 35).springify().damping(16)} style={styles.cell}>
|
||||
<Pressable
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ checked: picked === app.id }}
|
||||
onPress={() => (selectionHaptic(), setPicked(app.id))}
|
||||
style={[styles.option, picked === app.id && styles.optionPicked]}>
|
||||
<Text style={[styles.optionText, picked === app.id && styles.optionTextPicked]}>{app.label}</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
))}
|
||||
</View>
|
||||
<View style={{ gap: space.sm }}>
|
||||
<Button variant="primary" title="Continue" disabled={!picked} loading={busy} onPress={next} />
|
||||
<Button title="Skip setup" onPress={() => finishOnboarding(setMe).catch(showError)} />
|
||||
</View>
|
||||
</OnboardingStep>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: space.sm },
|
||||
cell: { width: '48.5%' },
|
||||
option: {
|
||||
minHeight: 56,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: space.md,
|
||||
},
|
||||
optionPicked: { borderColor: colors.accent, backgroundColor: 'rgba(124,77,255,0.18)' },
|
||||
optionText: { color: colors.text, fontWeight: '600', fontSize: 15 },
|
||||
optionTextPicked: { color: '#fff' },
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { Platform } from 'react-native';
|
||||
import { OnboardingStep } from '@/components/OnboardingStep';
|
||||
import { AndroidShareSetup } from '@/components/setup/AndroidShareSetup';
|
||||
import { AppleMusicSetup } from '@/components/setup/AppleMusicSetup';
|
||||
import { ServiceSignInSetup } from '@/components/setup/ServiceSignInSetup';
|
||||
import { SpotifySetup } from '@/components/setup/SpotifySetup';
|
||||
import { Points, SetupActions, SetupHeader } from '@/components/setup/common';
|
||||
import { listenApp } from '@/lib/onboarding';
|
||||
|
||||
const next = () => router.push('/onboarding/friends');
|
||||
|
||||
/**
|
||||
* Step 2: share what you play, matched to the app and the phone.
|
||||
* Android: one toggle covers every app. iPhone: depends on the service.
|
||||
*/
|
||||
export default function ShareStep() {
|
||||
const { app: appId } = useLocalSearchParams<{ app?: string }>();
|
||||
const app = listenApp(appId);
|
||||
const props = { onDone: next, skipLabel: 'Skip for now' };
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (Platform.OS === 'android') body = <AndroidShareSetup {...props} />;
|
||||
else if (app.id === 'apple_music') body = <AppleMusicSetup {...props} />;
|
||||
else if (app.id === 'spotify') body = <SpotifySetup {...props} />;
|
||||
else if (app.id === 'plex') body = <ServiceSignInSetup service="plex" appName="Plex" {...props} />;
|
||||
else if (app.id === 'jellyfin') {
|
||||
body = (
|
||||
<>
|
||||
<SetupHeader
|
||||
title="Connect Jellyfin"
|
||||
subtitle="Jellyfin needs your server address and login, so set it up in Settings → Music services whenever you're ready."
|
||||
/>
|
||||
<Points items={['Your password is only used once to get a token; it isn’t stored.']} />
|
||||
<SetupActions primary="Continue" onPrimary={next} />
|
||||
</>
|
||||
);
|
||||
} else body = <ServiceSignInSetup service="lastfm" appName={app.label} {...props} />;
|
||||
|
||||
return <OnboardingStep step={2}>{body}</OnboardingStep>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { FeaturedPicker } from '@/components/FeaturedPicker';
|
||||
import { OnboardingStep } from '@/components/OnboardingStep';
|
||||
import { Body, Button, Title } from '@/components/ui';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { successHaptic } from '@/lib/haptics';
|
||||
import { finishOnboarding } from '@/lib/onboarding';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { space } from '@/lib/theme';
|
||||
|
||||
/** Step 4 (optional): a song on your profile, so it isn't empty for the friends you invite. */
|
||||
export default function SongStep() {
|
||||
const { setMe } = useSession();
|
||||
const [featured, setFeatured] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Finishing flips the navigator to the main app automatically.
|
||||
const finish = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await finishOnboarding(setMe);
|
||||
successHaptic();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingStep step={4}>
|
||||
<View style={{ gap: space.sm }}>
|
||||
<Title>Pick your song</Title>
|
||||
<Body muted>One song that says something about you. It goes at the top of your profile, with a note if you like.</Body>
|
||||
</View>
|
||||
<FeaturedPicker bare onSaved={() => setFeatured(true)} />
|
||||
<Button variant={featured ? 'primary' : 'secondary'} title={featured ? 'Finish' : 'Skip and finish'} loading={busy} onPress={finish} />
|
||||
</OnboardingStep>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user