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:
@@ -1,19 +1,48 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useEffect } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import Animated, { Easing, cancelAnimation, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
|
||||
import type { UserSummaryDTO } from '@musicshare/shared';
|
||||
import { colors } from '@/lib/theme';
|
||||
import { colors, fonts } from '@/lib/theme';
|
||||
|
||||
/** `live` wraps the avatar in a spinning gradient ring (someone is listening right now). */
|
||||
export function Avatar({ user, size = 40, live }: { user: UserSummaryDTO; size?: number; live?: boolean }) {
|
||||
const rotation = useSharedValue(0);
|
||||
useEffect(() => {
|
||||
if (live) rotation.set(withRepeat(withTiming(360, { duration: 3500, easing: Easing.linear }), -1, false));
|
||||
else cancelAnimation(rotation);
|
||||
return () => cancelAnimation(rotation);
|
||||
}, [live, rotation]);
|
||||
const spin = useAnimatedStyle(() => ({ transform: [{ rotate: `${rotation.value}deg` }] }));
|
||||
|
||||
export function Avatar({ user, size = 40 }: { user: UserSummaryDTO; size?: number }) {
|
||||
const shape = { width: size, height: size, borderRadius: size / 2 };
|
||||
if (user.avatarUrl) return <Image source={user.avatarUrl} style={shape} contentFit="cover" />;
|
||||
return (
|
||||
<View style={[styles.empty, shape]}>
|
||||
const face = user.avatarUrl ? (
|
||||
<Image source={user.avatarUrl} style={shape} contentFit="cover" />
|
||||
) : (
|
||||
<LinearGradient colors={['#4a1278', '#1872cf']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={[styles.empty, shape]}>
|
||||
<Text style={[styles.letter, { fontSize: size * 0.45 }]}>{(user.displayName ?? user.username).slice(0, 1).toUpperCase()}</Text>
|
||||
</LinearGradient>
|
||||
);
|
||||
if (!live) return face;
|
||||
|
||||
const ring = size + 6;
|
||||
return (
|
||||
<View style={{ width: ring, height: ring, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Animated.View style={[StyleSheet.absoluteFill, spin]}>
|
||||
<LinearGradient
|
||||
colors={['#7b2ff7', '#2f8cff', colors.live, '#7b2ff7']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={{ width: ring, height: ring, borderRadius: ring / 2 }}
|
||||
/>
|
||||
</Animated.View>
|
||||
<View style={{ borderRadius: (size + 2) / 2, borderWidth: 2, borderColor: colors.bg }}>{face}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
empty: { backgroundColor: colors.surface2, alignItems: 'center', justifyContent: 'center' },
|
||||
letter: { color: colors.muted, fontWeight: '700' },
|
||||
empty: { alignItems: 'center', justifyContent: 'center' },
|
||||
letter: { color: '#fff', fontFamily: fonts.display },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user