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,106 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import useSWR from 'swr';
|
||||
import type { TrackSearchResultDTO } from '@musicshare/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
import { useDebounced } from '@/lib/useDebounced';
|
||||
import { Body, Button, Card, Field, Heading } from './ui';
|
||||
|
||||
/**
|
||||
* Search for a song and pin it to your profile with an optional note.
|
||||
* `bare` drops the card and heading (for onboarding, which has its own title).
|
||||
*/
|
||||
export function FeaturedPicker({ bare, onSaved }: { bare?: boolean; onSaved?: () => void }) {
|
||||
const [q, setQ] = useState('');
|
||||
const [selected, setSelected] = useState<TrackSearchResultDTO | null>(null);
|
||||
const [comment, setComment] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const query = useDebounced(q.trim(), 300);
|
||||
const { data: results } = useSWR<TrackSearchResultDTO[]>(
|
||||
query.length >= 2 && !selected ? `/tracks/search?q=${encodeURIComponent(query)}` : null,
|
||||
);
|
||||
|
||||
const save = async () => {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.setFeatured({ deezerId: selected.deezerId, comment: comment.trim() || null });
|
||||
setSaved(true);
|
||||
onSaved?.();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{selected ? (
|
||||
<>
|
||||
<View style={styles.result}>
|
||||
{selected.artworkUrl && <Image source={selected.artworkUrl} style={styles.thumb} />}
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{selected.title}
|
||||
</Text>
|
||||
<Body muted style={styles.small}>
|
||||
{selected.artist}
|
||||
</Body>
|
||||
</View>
|
||||
<Button small title="Change" onPress={() => (setSelected(null), setSaved(false))} />
|
||||
</View>
|
||||
<Field
|
||||
placeholder="Say something about it (optional)"
|
||||
value={comment}
|
||||
onChangeText={setComment}
|
||||
maxLength={280}
|
||||
multiline
|
||||
style={{ minHeight: 64 }}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
title={saved ? 'Featured ✓' : 'Feature this song'}
|
||||
loading={busy}
|
||||
onPress={save}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field placeholder="Search for a song" value={q} onChangeText={setQ} autoCorrect={false} returnKeyType="search" />
|
||||
{results?.map((r) => (
|
||||
<Pressable key={r.deezerId} style={styles.result} onPress={() => setSelected(r)} accessibilityRole="button">
|
||||
{r.artworkUrl && <Image source={r.artworkUrl} style={styles.thumb} />}
|
||||
<Text style={styles.resultText} numberOfLines={1}>
|
||||
{r.title} <Text style={{ color: colors.muted }}>· {r.artist}</Text>
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (bare) return <View style={{ gap: space.md }}>{body}</View>;
|
||||
return (
|
||||
<Card>
|
||||
<Heading>Featured song</Heading>
|
||||
<Body muted style={styles.small}>
|
||||
Pin a song to your profile, with an optional note about why.
|
||||
</Body>
|
||||
{body}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
small: { fontSize: 13 },
|
||||
title: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
||||
result: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: space.xs },
|
||||
resultText: { color: colors.text, flex: 1, fontSize: 14 },
|
||||
thumb: { width: 40, height: 40, borderRadius: radius.sm },
|
||||
});
|
||||
Reference in New Issue
Block a user