Add Expo mobile app for iOS and Android
Native tabs for feed, friend search, profile and settings, with feature parity with the web app. Sessions are stored in SecureStore, realtime disconnects in the background, and Spotify/Plex sign-in returns to the app via the URL scheme. 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,88 @@
|
||||
import { Link } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { KeyboardAvoidingView, Platform, StyleSheet, View } from 'react-native';
|
||||
import { api } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
import { Screen } from './Screen';
|
||||
import { Body, Button, Field, Title } from './ui';
|
||||
|
||||
export function AuthForm({ mode }: { mode: 'login' | 'register' }) {
|
||||
const { signIn } = useSession();
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = mode === 'login' ? await api.login({ email, password }) : await api.register({ username, email, password });
|
||||
await signIn(res); // the protected stack swaps to the tabs automatically
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||
<Screen edges={mode === 'login' ? ['top'] : []} contentContainerStyle={styles.content}>
|
||||
{mode === 'login' && (
|
||||
<View style={{ gap: space.sm }}>
|
||||
<Title style={{ fontSize: 34 }}>musicshare</Title>
|
||||
<Body muted>See what your friends are playing, and open it in your own app.</Body>
|
||||
</View>
|
||||
)}
|
||||
{mode === 'register' && (
|
||||
<Field
|
||||
label="Username"
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="username-new"
|
||||
textContentType="username"
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label="Email"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
keyboardType="email-address"
|
||||
textContentType="emailAddress"
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||
textContentType={mode === 'login' ? 'password' : 'newPassword'}
|
||||
onSubmitEditing={submit}
|
||||
returnKeyType="go"
|
||||
/>
|
||||
{error && <Body style={{ color: colors.danger }}>{error}</Body>}
|
||||
<Button variant="primary" title={mode === 'login' ? 'Log in' : 'Create account'} loading={busy} onPress={submit} />
|
||||
{mode === 'login' ? (
|
||||
<Link href="/register" style={styles.link}>
|
||||
New here? Create an account
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="/login" style={styles.link} dismissTo>
|
||||
Already have an account? Log in
|
||||
</Link>
|
||||
)}
|
||||
</Screen>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: { flexGrow: 1, justifyContent: 'center' },
|
||||
link: { color: colors.muted, textAlign: 'center', paddingVertical: space.sm },
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import type { UserSummaryDTO } from '@musicshare/shared';
|
||||
import { colors } from '@/lib/theme';
|
||||
|
||||
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]}>
|
||||
<Text style={[styles.letter, { fontSize: size * 0.45 }]}>{(user.displayName ?? user.username).slice(0, 1).toUpperCase()}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
empty: { backgroundColor: colors.surface2, alignItems: 'center', justifyContent: 'center' },
|
||||
letter: { color: colors.muted, fontWeight: '700' },
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { router } from 'expo-router';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { SERVICE_LABELS, timeAgo, type NowPlayingDTO, type StreamingService } from '@musicshare/shared';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
import { Avatar } from './Avatar';
|
||||
import { TrackCard } from './TrackCard';
|
||||
import { Card } from './ui';
|
||||
|
||||
export function NowPlayingCard({
|
||||
np,
|
||||
preferred,
|
||||
showUser = true,
|
||||
pinned,
|
||||
onTogglePin,
|
||||
}: {
|
||||
np: NowPlayingDTO;
|
||||
preferred: StreamingService | null | undefined;
|
||||
showUser?: boolean;
|
||||
pinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card highlighted={pinned}>
|
||||
<View style={styles.header}>
|
||||
{showUser && (
|
||||
<Pressable style={styles.user} onPress={() => router.push(`/u/${np.user.username}`)} accessibilityRole="link">
|
||||
<Avatar user={np.user} size={28} />
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{np.user.displayName ?? np.user.username}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<View style={styles.statusRow}>
|
||||
{np.isPlaying && <View style={styles.dot} />}
|
||||
<Text style={[styles.status, np.isPlaying && { color: colors.live }]} numberOfLines={1}>
|
||||
{np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
|
||||
<Text style={styles.via}> · {SERVICE_LABELS[np.source]}</Text>
|
||||
</Text>
|
||||
{onTogglePin && (
|
||||
<Pressable
|
||||
onPress={onTogglePin}
|
||||
hitSlop={10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={pinned ? 'Unpin from top of feed' : 'Pin to top of feed'}
|
||||
accessibilityState={{ selected: pinned }}>
|
||||
<Text style={[styles.pin, pinned && { color: colors.pin }]}>{pinned ? '★' : '☆'}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<TrackCard track={np.track} preferred={preferred} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: space.sm },
|
||||
user: { flexDirection: 'row', alignItems: 'center', gap: space.sm, flexShrink: 1 },
|
||||
name: { color: colors.text, fontWeight: '700', fontSize: 15, flexShrink: 1 },
|
||||
statusRow: { flexDirection: 'row', alignItems: 'center', gap: space.xs, flexShrink: 0 },
|
||||
dot: { width: 8, height: 8, borderRadius: 4, backgroundColor: colors.live },
|
||||
status: { color: colors.muted, fontSize: 13 },
|
||||
via: { color: colors.muted },
|
||||
pin: { color: colors.muted, fontSize: 20, marginLeft: space.xs },
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import useSWR from 'swr';
|
||||
import type { ProfileDTO } from '@musicshare/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { useRealtime } from '@/lib/useRealtime';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
import { Avatar } from './Avatar';
|
||||
import { NowPlayingCard } from './NowPlayingCard';
|
||||
import { Screen } from './Screen';
|
||||
import { TrackCard } from './TrackCard';
|
||||
import { Body, Button, Card, Heading, Row, Title } from './ui';
|
||||
|
||||
export function ProfileView({ username, edges }: { username: string; edges?: ('top' | 'bottom')[] }) {
|
||||
const { me } = useSession();
|
||||
const { data: profile, error, mutate } = useSWR<ProfileDTO>(`/users/${username}`);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useRealtime(true, (msg) => {
|
||||
if (msg.type === 'now_playing' && profile && msg.data.user.id === profile.user.id) {
|
||||
mutate({ ...profile, nowPlaying: msg.data }, { revalidate: false });
|
||||
}
|
||||
});
|
||||
|
||||
const act = async (fn: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
await mutate();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Screen edges={edges}>
|
||||
<Body muted>User not found.</Body>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
if (!profile) return <Screen edges={edges} />;
|
||||
const { user } = profile;
|
||||
|
||||
return (
|
||||
<Screen
|
||||
edges={edges}
|
||||
refreshing={refreshing}
|
||||
onRefresh={async () => {
|
||||
setRefreshing(true);
|
||||
await mutate().catch(() => {});
|
||||
setRefreshing(false);
|
||||
}}>
|
||||
<View style={styles.header}>
|
||||
<Avatar user={user} size={72} />
|
||||
<View style={{ flex: 1, gap: 2 }}>
|
||||
<Title numberOfLines={1}>{user.displayName ?? user.username}</Title>
|
||||
<Text style={styles.meta}>
|
||||
@{user.username} · {profile.followerCount} followers · {profile.followingCount} following
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{user.bio && <Body>{user.bio}</Body>}
|
||||
|
||||
<Row>
|
||||
{profile.isSelf ? (
|
||||
<Button small title="Edit profile" onPress={() => router.push('/settings')} />
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
small
|
||||
variant={profile.isFollowing ? 'secondary' : 'primary'}
|
||||
title={profile.isFollowing ? 'Following' : 'Follow'}
|
||||
disabled={busy}
|
||||
onPress={() => act(() => (profile.isFollowing ? api.unfollow(username) : api.follow(username)))}
|
||||
/>
|
||||
{profile.isFollowing && (
|
||||
<Button
|
||||
small
|
||||
title={profile.isPinned ? '★ Pinned' : '☆ Pin to top'}
|
||||
disabled={busy}
|
||||
onPress={() => act(() => (profile.isPinned ? api.unpin(username) : api.pin(username)))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{profile.featured && (
|
||||
<Card>
|
||||
<Heading>Featured song</Heading>
|
||||
<TrackCard track={profile.featured.track} preferred={me?.preferredService} />
|
||||
{profile.featured.comment && <Text style={styles.quote}>“{profile.featured.comment}”</Text>}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{profile.nowPlaying ? (
|
||||
<NowPlayingCard np={profile.nowPlaying} preferred={me?.preferredService} showUser={false} />
|
||||
) : (
|
||||
<Body muted>Nothing playing right now.</Body>
|
||||
)}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: { flexDirection: 'row', alignItems: 'center', gap: space.lg },
|
||||
meta: { color: colors.muted, fontSize: 13 },
|
||||
quote: {
|
||||
color: colors.text,
|
||||
fontStyle: 'italic',
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.accent,
|
||||
paddingLeft: space.md,
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { RefreshControl, ScrollView, StyleSheet, type ScrollViewProps } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
|
||||
/** Scrollable, safe-area-aware page with optional pull-to-refresh. */
|
||||
export function Screen({
|
||||
children,
|
||||
onRefresh,
|
||||
refreshing = false,
|
||||
edges = ['top'],
|
||||
contentContainerStyle,
|
||||
...props
|
||||
}: ScrollViewProps & { onRefresh?: () => void; refreshing?: boolean; edges?: ('top' | 'bottom')[] }) {
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={edges}>
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={[styles.content, contentContainerStyle]}
|
||||
refreshControl={
|
||||
onRefresh ? <RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.muted} /> : undefined
|
||||
}
|
||||
{...props}>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: colors.bg },
|
||||
content: { padding: space.lg, gap: space.lg, paddingBottom: space.xl * 3 },
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { SERVICE_LABELS, STREAMING_SERVICES, openUrl, type StreamingService, type TrackDTO } from '@musicshare/shared';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
import { Button } from './ui';
|
||||
|
||||
/**
|
||||
* Links are https universal/app links, so Linking.openURL hands them to the
|
||||
* native Spotify / Apple Music / YouTube Music app when it's installed.
|
||||
*/
|
||||
const open = (url: string) => Linking.openURL(url).catch((e) => showError(e, "Couldn't open link"));
|
||||
|
||||
export function TrackCard({ track, preferred }: { track: TrackDTO; preferred: StreamingService | null | undefined }) {
|
||||
const primary = preferred ? openUrl(track, preferred) : null;
|
||||
const others = STREAMING_SERVICES.filter((s) => s !== preferred);
|
||||
|
||||
return (
|
||||
<View style={styles.track}>
|
||||
{track.artworkUrl ? (
|
||||
<Image source={track.artworkUrl} style={styles.artwork} contentFit="cover" transition={150} />
|
||||
) : (
|
||||
<View style={[styles.artwork, styles.artworkEmpty]}>
|
||||
<Text style={styles.note}>♪</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.body}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{track.title}
|
||||
</Text>
|
||||
<Text style={styles.artist} numberOfLines={1}>
|
||||
{track.artist}
|
||||
{track.album ? <Text style={styles.muted}> · {track.album}</Text> : null}
|
||||
</Text>
|
||||
{preferred && primary && (
|
||||
<Button
|
||||
small
|
||||
variant="primary"
|
||||
title={`${primary.exact ? 'Open' : 'Search'} in ${SERVICE_LABELS[preferred]}`}
|
||||
onPress={() => open(primary.url)}
|
||||
style={styles.primary}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.services}>
|
||||
{others.map((s) => {
|
||||
const { url, exact } = openUrl(track, s);
|
||||
return (
|
||||
<Pressable key={s} onPress={() => open(url)} hitSlop={6} accessibilityRole="link">
|
||||
<Text style={[styles.service, !exact && { opacity: 0.6 }]}>{SERVICE_LABELS[s]}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
track: { flexDirection: 'row', gap: space.md, alignItems: 'flex-start' },
|
||||
artwork: { width: 88, height: 88, borderRadius: radius.md, backgroundColor: colors.surface2 },
|
||||
artworkEmpty: { alignItems: 'center', justifyContent: 'center' },
|
||||
note: { color: colors.muted, fontSize: 28 },
|
||||
body: { flex: 1, minWidth: 0, gap: 2 },
|
||||
title: { color: colors.text, fontWeight: '700', fontSize: 16 },
|
||||
artist: { color: colors.text, opacity: 0.85, fontSize: 14 },
|
||||
muted: { color: colors.muted },
|
||||
primary: { alignSelf: 'flex-start', marginTop: space.sm },
|
||||
services: { flexDirection: 'row', flexWrap: 'wrap', columnGap: space.md, rowGap: space.xs, marginTop: space.sm },
|
||||
service: { color: colors.muted, fontSize: 12 },
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { forwardRef } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
type PressableProps,
|
||||
type TextInputProps,
|
||||
type TextProps,
|
||||
type ViewProps,
|
||||
} from 'react-native';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
|
||||
export function Title({ style, ...props }: TextProps) {
|
||||
return <Text style={[styles.title, style]} {...props} />;
|
||||
}
|
||||
|
||||
export function Heading({ style, ...props }: TextProps) {
|
||||
return <Text style={[styles.heading, style]} {...props} />;
|
||||
}
|
||||
|
||||
export function Body({ style, muted, ...props }: TextProps & { muted?: boolean }) {
|
||||
return <Text style={[styles.body, muted && styles.muted, style]} {...props} />;
|
||||
}
|
||||
|
||||
export function Card({ style, highlighted, ...props }: ViewProps & { highlighted?: boolean }) {
|
||||
return <View style={[styles.card, highlighted && styles.cardHighlighted, style]} {...props} />;
|
||||
}
|
||||
|
||||
export function Row({ style, ...props }: ViewProps) {
|
||||
return <View style={[styles.row, style]} {...props} />;
|
||||
}
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'danger';
|
||||
|
||||
export function Button({
|
||||
title,
|
||||
variant = 'secondary',
|
||||
loading,
|
||||
small,
|
||||
style,
|
||||
disabled,
|
||||
...props
|
||||
}: PressableProps & { title: string; variant?: Variant; loading?: boolean; small?: boolean; style?: ViewProps['style'] }) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={disabled || loading}
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
small && styles.buttonSmall,
|
||||
variant === 'primary' && styles.buttonPrimary,
|
||||
variant === 'danger' && styles.buttonDanger,
|
||||
(pressed || disabled) && { opacity: 0.7 },
|
||||
style,
|
||||
]}
|
||||
{...props}>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={variant === 'secondary' ? colors.text : colors.accentText} />
|
||||
) : (
|
||||
<Text style={[styles.buttonText, small && styles.buttonTextSmall, variant !== 'secondary' && { color: colors.accentText }]}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export const Field = forwardRef<TextInput, TextInputProps & { label?: string }>(function Field(
|
||||
{ label, style, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<View style={{ gap: space.xs }}>
|
||||
{label && <Text style={styles.label}>{label}</Text>}
|
||||
<TextInput ref={ref} placeholderTextColor={colors.muted} style={[styles.input, style]} {...props} />
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export function Chip({ label, selected, onPress }: { label: string; selected?: boolean; onPress: () => void }) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected }}
|
||||
onPress={onPress}
|
||||
style={[styles.chip, selected && styles.chipSelected]}>
|
||||
<Text style={[styles.chipText, selected && { color: colors.accentText }]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
title: { color: colors.text, fontSize: 28, fontWeight: '800', letterSpacing: -0.5 },
|
||||
heading: { color: colors.text, fontSize: 17, fontWeight: '700', marginBottom: space.xs },
|
||||
body: { color: colors.text, fontSize: 15, lineHeight: 21 },
|
||||
muted: { color: colors.muted },
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderRadius: radius.lg,
|
||||
padding: space.lg,
|
||||
gap: space.md,
|
||||
},
|
||||
cardHighlighted: { borderColor: colors.accent, borderWidth: 1 },
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: space.sm, flexWrap: 'wrap' },
|
||||
button: {
|
||||
minHeight: 44,
|
||||
paddingHorizontal: space.lg,
|
||||
borderRadius: radius.pill,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.surface2,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
buttonSmall: { minHeight: 34, paddingHorizontal: space.md },
|
||||
buttonPrimary: { backgroundColor: colors.accent, borderColor: colors.accent },
|
||||
buttonDanger: { backgroundColor: colors.danger, borderColor: colors.danger },
|
||||
buttonText: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
||||
buttonTextSmall: { fontSize: 13 },
|
||||
label: { color: colors.text, fontWeight: '600', fontSize: 14 },
|
||||
input: {
|
||||
minHeight: 44,
|
||||
paddingHorizontal: space.md,
|
||||
paddingVertical: space.sm,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface2,
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: space.md,
|
||||
paddingVertical: space.sm,
|
||||
borderRadius: radius.pill,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface2,
|
||||
},
|
||||
chipSelected: { backgroundColor: colors.accent, borderColor: colors.accent },
|
||||
chipText: { color: colors.text, fontWeight: '600', fontSize: 13 },
|
||||
});
|
||||
Reference in New Issue
Block a user