- Introduced new translation files for medication, personal, and weight management in Chinese. - Updated the main index file to include the new translation modules. - Enhanced the medication type definitions to include 'ointment'. - Refactored workout type labels to utilize i18n for better localization support. - Improved sleep quality descriptions and recommendations with i18n integration.
846 lines
24 KiB
TypeScript
846 lines
24 KiB
TypeScript
import dayjs from 'dayjs';
|
|
|
|
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
|
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
|
import { Colors } from '@/constants/Colors';
|
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
|
import { useI18n } from '@/hooks/useI18n';
|
|
import {
|
|
fetchChallenges,
|
|
joinChallengeByCode,
|
|
resetJoinByCodeState,
|
|
selectChallengeCards,
|
|
selectChallengesListError,
|
|
selectChallengesListStatus,
|
|
selectCustomChallengeCards,
|
|
selectJoinByCodeError,
|
|
selectJoinByCodeStatus,
|
|
selectOfficialChallengeCards,
|
|
type ChallengeCardViewModel,
|
|
} from '@/store/challengesSlice';
|
|
import { Toast } from '@/utils/toast.utils';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
|
import { Image } from 'expo-image';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
import { useRouter } from 'expo-router';
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
Animated,
|
|
FlatList,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
TextInput,
|
|
TouchableOpacity,
|
|
View,
|
|
useWindowDimensions
|
|
} from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
|
|
const AVATAR_SIZE = 36;
|
|
const CARD_IMAGE_WIDTH = 132;
|
|
const CARD_IMAGE_HEIGHT = 96;
|
|
|
|
const CAROUSEL_ITEM_SPACING = 16;
|
|
const MIN_CAROUSEL_CARD_WIDTH = 280;
|
|
const DOT_BASE_SIZE = 6;
|
|
|
|
export default function ChallengesScreen() {
|
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
|
const insets = useSafeAreaInsets();
|
|
const { t } = useI18n();
|
|
|
|
const { ensureLoggedIn } = useAuthGuard();
|
|
|
|
const colorTokens = Colors[theme];
|
|
const router = useRouter();
|
|
const dispatch = useAppDispatch();
|
|
const glassAvailable = isLiquidGlassAvailable();
|
|
const allChallenges = useAppSelector(selectChallengeCards);
|
|
const customChallenges = useAppSelector(selectCustomChallengeCards);
|
|
|
|
|
|
const officialChallenges = useAppSelector(selectOfficialChallengeCards);
|
|
const joinedCustomChallenges = useMemo(
|
|
() => customChallenges.filter((item) => item.isJoined),
|
|
[customChallenges]
|
|
);
|
|
const listStatus = useAppSelector(selectChallengesListStatus);
|
|
const listError = useAppSelector(selectChallengesListError);
|
|
const joinByCodeStatus = useAppSelector(selectJoinByCodeStatus);
|
|
const joinByCodeError = useAppSelector(selectJoinByCodeError);
|
|
const [joinModalVisible, setJoinModalVisible] = useState(false);
|
|
const [shareCodeInput, setShareCodeInput] = useState('');
|
|
const ongoingChallenges = useMemo(() => {
|
|
const now = dayjs();
|
|
return allChallenges.filter((challenge) => {
|
|
if (challenge.status !== 'ongoing' || !challenge.isJoined || !challenge.progress) {
|
|
return false;
|
|
}
|
|
|
|
if (challenge.endAt) {
|
|
const endDate = dayjs(challenge.endAt);
|
|
if (endDate.isValid() && endDate.isBefore(now)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}, [allChallenges]);
|
|
const progressTrackColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.08)' : '#eceffa';
|
|
const progressInactiveColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.24)' : '#dfe4f6';
|
|
|
|
useEffect(() => {
|
|
if (listStatus === 'idle') {
|
|
dispatch(fetchChallenges());
|
|
}
|
|
}, [dispatch, listStatus]);
|
|
|
|
const gradientColors: [string, string] =
|
|
theme === 'dark'
|
|
? ['#1f2230', '#10131e']
|
|
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
|
|
|
|
useEffect(() => {
|
|
if (!joinModalVisible) {
|
|
dispatch(resetJoinByCodeState());
|
|
setShareCodeInput('');
|
|
}
|
|
}, [dispatch, joinModalVisible]);
|
|
|
|
const handleCreatePress = useCallback(async () => {
|
|
const ok = await ensureLoggedIn();
|
|
if (!ok) return;
|
|
router.push('/challenges/create-custom');
|
|
}, [ensureLoggedIn, router]);
|
|
|
|
const handleOpenJoin = useCallback(async () => {
|
|
const ok = await ensureLoggedIn();
|
|
if (!ok) return;
|
|
setJoinModalVisible(true);
|
|
}, [ensureLoggedIn]);
|
|
|
|
const isJoiningByCode = joinByCodeStatus === 'loading';
|
|
|
|
const handleSubmitShareCode = useCallback(async () => {
|
|
if (isJoiningByCode) return;
|
|
const ok = await ensureLoggedIn();
|
|
if (!ok) return;
|
|
if (!shareCodeInput.trim()) {
|
|
Toast.warning(t('challenges.invalidInviteCode'));
|
|
return;
|
|
}
|
|
const formatted = shareCodeInput.trim().toUpperCase();
|
|
try {
|
|
const result = await dispatch(joinChallengeByCode(formatted)).unwrap();
|
|
await dispatch(fetchChallenges());
|
|
setJoinModalVisible(false);
|
|
Toast.success(t('challenges.joinSuccess'));
|
|
router.push({ pathname: '/challenges/[id]', params: { id: result.challenge.id } });
|
|
} catch (error) {
|
|
const message = typeof error === 'string' ? error : t('challenges.joinFailed');
|
|
Toast.error(message);
|
|
}
|
|
}, [dispatch, ensureLoggedIn, isJoiningByCode, router, shareCodeInput]);
|
|
|
|
const renderChallenges = () => {
|
|
if (listStatus === 'loading' && allChallenges.length === 0) {
|
|
return (
|
|
<View style={styles.stateContainer}>
|
|
<ActivityIndicator color={colorTokens.primary} />
|
|
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.loading')}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (listStatus === 'failed' && allChallenges.length === 0) {
|
|
return (
|
|
<View style={styles.stateContainer}>
|
|
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>
|
|
{listError ?? t('challenges.loadFailed')}
|
|
</Text>
|
|
<TouchableOpacity
|
|
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
|
|
activeOpacity={0.9}
|
|
onPress={() => dispatch(fetchChallenges())}
|
|
>
|
|
<Text style={[styles.retryText, { color: colorTokens.primary }]}>{t('challenges.retry')}</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (customChallenges.length === 0 && officialChallenges.length === 0) {
|
|
return (
|
|
<View style={styles.stateContainer}>
|
|
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.empty')}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={styles.cardGroups}>
|
|
{joinedCustomChallenges.length ? (
|
|
<>
|
|
<View style={styles.sectionHeaderRow}>
|
|
<Text style={[styles.sectionHeaderText, { color: colorTokens.text }]}>{t('challenges.customChallenges')}</Text>
|
|
</View>
|
|
<View style={styles.cardsContainer}>
|
|
{joinedCustomChallenges.map((challenge) => (
|
|
<ChallengeCard
|
|
key={challenge.id}
|
|
challenge={challenge}
|
|
surfaceColor={colorTokens.surface}
|
|
textColor={colorTokens.text}
|
|
mutedColor={colorTokens.textSecondary}
|
|
onPress={() =>
|
|
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
|
}
|
|
/>
|
|
))}
|
|
</View>
|
|
</>
|
|
) : null}
|
|
|
|
<View style={[styles.sectionHeaderRow, { marginTop: joinedCustomChallenges.length ? 12 : 0 }]}>
|
|
<Text style={[styles.sectionHeaderText, { color: colorTokens.text }]}>{t('challenges.officialChallengesTitle')}</Text>
|
|
</View>
|
|
{officialChallenges.length ? (
|
|
<View style={styles.cardsContainer}>
|
|
{officialChallenges.map((challenge) => (
|
|
<ChallengeCard
|
|
key={challenge.id}
|
|
challenge={challenge}
|
|
surfaceColor={colorTokens.surface}
|
|
textColor={colorTokens.text}
|
|
mutedColor={colorTokens.textSecondary}
|
|
onPress={() =>
|
|
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
|
}
|
|
/>
|
|
))}
|
|
</View>
|
|
) : (
|
|
<View style={[styles.stateContainer, styles.customEmpty]}>
|
|
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.officialChallenges')}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
|
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
|
|
<ScrollView
|
|
contentContainerStyle={[styles.scrollContent, {
|
|
paddingTop: insets.top,
|
|
}]}
|
|
showsVerticalScrollIndicator={false}
|
|
bounces
|
|
>
|
|
<View style={styles.headerRow}>
|
|
<View>
|
|
<Text style={[styles.title, { color: colorTokens.text }]}>{t('challenges.title')}</Text>
|
|
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}>{t('challenges.subtitle')}</Text>
|
|
</View>
|
|
<View style={styles.headerActions}>
|
|
<TouchableOpacity activeOpacity={0.85} onPress={handleOpenJoin}>
|
|
{glassAvailable ? (
|
|
<GlassView
|
|
style={styles.joinButtonGlass}
|
|
glassEffectStyle="regular"
|
|
tintColor="rgba(255,255,255,0.18)"
|
|
isInteractive
|
|
>
|
|
<Text style={styles.joinButtonLabel}>{t('challenges.join')}</Text>
|
|
</GlassView>
|
|
) : (
|
|
<View style={[styles.joinButtonGlass, styles.joinButtonFallback]}>
|
|
<Text style={[styles.joinButtonLabel, { color: colorTokens.text }]}>{t('challenges.join')}</Text>
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
<TouchableOpacity activeOpacity={0.9} onPress={handleCreatePress} style={{ marginLeft: 10 }}>
|
|
{glassAvailable ? (
|
|
<GlassView
|
|
style={styles.createButton}
|
|
tintColor="rgba(255,255,255,0.22)"
|
|
isInteractive
|
|
>
|
|
<Ionicons name="add" size={18} color="#0f1528" />
|
|
</GlassView>
|
|
) : (
|
|
<View style={[styles.createButton, styles.createButtonFallback]}>
|
|
<Ionicons name="add" size={18} color={colorTokens.text} />
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
|
|
{ongoingChallenges.length ? (
|
|
<OngoingChallengesCarousel
|
|
challenges={ongoingChallenges}
|
|
colorTokens={colorTokens}
|
|
trackColor={progressTrackColor}
|
|
inactiveColor={progressInactiveColor}
|
|
onPress={(challenge) =>
|
|
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
|
}
|
|
/>
|
|
) : null}
|
|
|
|
<View style={styles.cardsContainer}>{renderChallenges()}</View>
|
|
</ScrollView>
|
|
<ConfirmationSheet
|
|
visible={joinModalVisible}
|
|
onClose={() => setJoinModalVisible(false)}
|
|
onConfirm={handleSubmitShareCode}
|
|
title={t('challenges.joinModal.title')}
|
|
description={t('challenges.joinModal.description')}
|
|
confirmText={isJoiningByCode ? t('challenges.joinModal.joining') : t('challenges.joinModal.confirm')}
|
|
cancelText={t('challenges.joinModal.cancel')}
|
|
loading={isJoiningByCode}
|
|
content={
|
|
<View style={styles.modalInputWrapper}>
|
|
<TextInput
|
|
style={styles.modalInput}
|
|
placeholder={t('challenges.joinModal.placeholder')}
|
|
placeholderTextColor="#9ca3af"
|
|
value={shareCodeInput}
|
|
onChangeText={(text) => setShareCodeInput(text.toUpperCase())}
|
|
autoCapitalize="characters"
|
|
autoCorrect={false}
|
|
keyboardType="default"
|
|
maxLength={12}
|
|
/>
|
|
{joinByCodeError && joinModalVisible ? (
|
|
<Text style={styles.modalError}>{joinByCodeError}</Text>
|
|
) : null}
|
|
</View>
|
|
}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
type ChallengeCardProps = {
|
|
challenge: ChallengeCardViewModel;
|
|
surfaceColor: string;
|
|
textColor: string;
|
|
mutedColor: string;
|
|
onPress: () => void;
|
|
};
|
|
|
|
function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) {
|
|
const { t } = useI18n();
|
|
const statusLabel = t(`challenges.statusLabels.${challenge.status}`) ?? challenge.status;
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
activeOpacity={0.92}
|
|
onPress={onPress}
|
|
style={[
|
|
styles.card,
|
|
{
|
|
backgroundColor: surfaceColor,
|
|
shadowColor: 'rgba(15, 23, 42, 0.18)',
|
|
},
|
|
]}
|
|
>
|
|
<View style={styles.cardInner}>
|
|
<View style={styles.cardMedia}>
|
|
<Image
|
|
source={{ uri: challenge.image }}
|
|
style={styles.cardImage}
|
|
cachePolicy={'memory-disk'}
|
|
/>
|
|
|
|
<>
|
|
<LinearGradient
|
|
pointerEvents="none"
|
|
colors={['rgba(17, 21, 32, 0.05)', 'rgba(13, 17, 28, 0.4)']}
|
|
style={styles.cardImageOverlay}
|
|
/>
|
|
<View style={styles.expiredBadge}>
|
|
<Text style={styles.expiredBadgeText}>{statusLabel}</Text>
|
|
</View>
|
|
</>
|
|
</View>
|
|
|
|
<View style={styles.cardContent}>
|
|
<Text
|
|
style={[styles.cardTitle, { color: textColor }]}
|
|
numberOfLines={1}
|
|
>
|
|
{challenge.title}
|
|
</Text>
|
|
<Text
|
|
style={[styles.cardDate, { color: mutedColor }]}
|
|
>
|
|
{challenge.dateRange}
|
|
</Text>
|
|
<Text
|
|
style={[styles.cardParticipants, { color: mutedColor }]}
|
|
>
|
|
{challenge.participantsLabel}
|
|
{challenge.isJoined ? ` · ${t('challenges.joined')}` : ''}
|
|
</Text>
|
|
{challenge.avatars.length ? (
|
|
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
|
|
</TouchableOpacity>
|
|
);
|
|
}
|
|
|
|
type ThemeColorTokens = (typeof Colors)['light'] | (typeof Colors)['dark'];
|
|
|
|
type OngoingChallengesCarouselProps = {
|
|
challenges: ChallengeCardViewModel[];
|
|
colorTokens: ThemeColorTokens;
|
|
trackColor: string;
|
|
inactiveColor: string;
|
|
onPress: (challenge: ChallengeCardViewModel) => void;
|
|
};
|
|
|
|
function OngoingChallengesCarousel({
|
|
challenges,
|
|
colorTokens,
|
|
trackColor,
|
|
inactiveColor,
|
|
onPress,
|
|
}: OngoingChallengesCarouselProps) {
|
|
const { width } = useWindowDimensions();
|
|
const cardWidth = Math.max(width - 40, MIN_CAROUSEL_CARD_WIDTH);
|
|
const snapInterval = cardWidth + CAROUSEL_ITEM_SPACING;
|
|
const scrollX = useRef(new Animated.Value(0)).current;
|
|
const listRef = useRef<FlatList<ChallengeCardViewModel> | null>(null);
|
|
|
|
useEffect(() => {
|
|
scrollX.setValue(0);
|
|
listRef.current?.scrollToOffset({ offset: 0, animated: false });
|
|
}, [scrollX, challenges.length]);
|
|
|
|
const onScroll = useMemo(
|
|
() =>
|
|
Animated.event(
|
|
[
|
|
{
|
|
nativeEvent: {
|
|
contentOffset: { x: scrollX },
|
|
},
|
|
},
|
|
],
|
|
{ useNativeDriver: true }
|
|
),
|
|
[scrollX]
|
|
);
|
|
|
|
const renderItem = useCallback(
|
|
({ item, index }: { item: ChallengeCardViewModel; index: number }) => {
|
|
const inputRange = [
|
|
(index - 1) * snapInterval,
|
|
index * snapInterval,
|
|
(index + 1) * snapInterval,
|
|
];
|
|
const scale = scrollX.interpolate({
|
|
inputRange,
|
|
outputRange: [0.94, 1, 0.94],
|
|
extrapolate: 'clamp',
|
|
});
|
|
const translateY = scrollX.interpolate({
|
|
inputRange,
|
|
outputRange: [10, 0, 10],
|
|
extrapolate: 'clamp',
|
|
});
|
|
|
|
return (
|
|
<Animated.View
|
|
style={[
|
|
styles.carouselCard,
|
|
{
|
|
width: cardWidth,
|
|
transform: [{ scale }, { translateY }],
|
|
},
|
|
]}
|
|
>
|
|
<TouchableOpacity
|
|
activeOpacity={0.92}
|
|
style={styles.carouselTouchable}
|
|
onPress={() => onPress(item)}
|
|
>
|
|
<ChallengeProgressCard
|
|
title={item.title}
|
|
endAt={item.endAt as string}
|
|
progress={item.progress}
|
|
style={styles.carouselProgressCard}
|
|
backgroundColors={[colorTokens.card, colorTokens.card]}
|
|
titleColor={colorTokens.text}
|
|
subtitleColor={colorTokens.textSecondary}
|
|
metaColor={colorTokens.primary}
|
|
metaSuffixColor={colorTokens.textSecondary}
|
|
accentColor={colorTokens.primary}
|
|
trackColor={trackColor}
|
|
inactiveColor={inactiveColor}
|
|
/>
|
|
</TouchableOpacity>
|
|
</Animated.View>
|
|
);
|
|
},
|
|
[cardWidth, colorTokens, inactiveColor, onPress, scrollX, snapInterval, trackColor]
|
|
);
|
|
|
|
return (
|
|
<View style={styles.carouselContainer}>
|
|
<Animated.FlatList
|
|
ref={listRef}
|
|
data={challenges}
|
|
keyExtractor={(item) => item.id}
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
bounces
|
|
decelerationRate="fast"
|
|
snapToAlignment="start"
|
|
snapToInterval={snapInterval}
|
|
|
|
ItemSeparatorComponent={() => <View style={{ width: CAROUSEL_ITEM_SPACING }} />}
|
|
onScroll={onScroll}
|
|
scrollEventThrottle={16}
|
|
overScrollMode="never"
|
|
renderItem={renderItem}
|
|
/>
|
|
|
|
{challenges.length > 1 ? (
|
|
<View style={styles.carouselIndicators}>
|
|
{challenges.map((challenge, index) => {
|
|
const inputRange = [
|
|
(index - 1) * snapInterval,
|
|
index * snapInterval,
|
|
(index + 1) * snapInterval,
|
|
];
|
|
const scaleX = scrollX.interpolate({
|
|
inputRange,
|
|
outputRange: [1, 2.6, 1],
|
|
extrapolate: 'clamp',
|
|
});
|
|
const dotOpacity = scrollX.interpolate({
|
|
inputRange,
|
|
outputRange: [0.35, 1, 0.35],
|
|
extrapolate: 'clamp',
|
|
});
|
|
|
|
return (
|
|
<Animated.View
|
|
key={challenge.id}
|
|
style={[
|
|
styles.carouselDot,
|
|
{
|
|
opacity: dotOpacity,
|
|
backgroundColor: colorTokens.primary,
|
|
transform: [{ scaleX }],
|
|
},
|
|
]}
|
|
/>
|
|
);
|
|
})}
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
type AvatarStackProps = {
|
|
avatars: string[];
|
|
borderColor: string;
|
|
};
|
|
|
|
function AvatarStack({ avatars, borderColor }: AvatarStackProps) {
|
|
return (
|
|
<View style={styles.avatarRow}>
|
|
{avatars
|
|
.filter(Boolean)
|
|
.map((avatar, index) => (
|
|
<Image
|
|
key={`${avatar}-${index}`}
|
|
source={{ uri: avatar }}
|
|
style={[
|
|
styles.avatar,
|
|
{ borderColor },
|
|
index === 0 ? null : styles.avatarOffset,
|
|
]}
|
|
/>
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
screen: {
|
|
flex: 1,
|
|
},
|
|
safeArea: {
|
|
flex: 1,
|
|
},
|
|
scrollContent: {
|
|
paddingHorizontal: 20,
|
|
paddingBottom: 120,
|
|
},
|
|
headerRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginTop: 8,
|
|
marginBottom: 26,
|
|
},
|
|
title: {
|
|
fontSize: 24,
|
|
fontWeight: '700',
|
|
letterSpacing: 1,
|
|
fontFamily: 'AliBold'
|
|
},
|
|
subtitle: {
|
|
marginTop: 6,
|
|
fontSize: 12,
|
|
fontWeight: '500',
|
|
opacity: 0.8,
|
|
fontFamily: 'AliRegular'
|
|
},
|
|
headerActions: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
joinButtonGlass: {
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 8,
|
|
borderRadius: 16,
|
|
minWidth: 70,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderWidth: StyleSheet.hairlineWidth,
|
|
borderColor: 'rgba(255,255,255,0.45)',
|
|
},
|
|
joinButtonLabel: {
|
|
fontSize: 12,
|
|
fontWeight: '700',
|
|
color: '#0f1528',
|
|
letterSpacing: 0.5,
|
|
fontFamily: 'AliBold'
|
|
},
|
|
joinButtonFallback: {
|
|
backgroundColor: 'rgba(255,255,255,0.7)',
|
|
},
|
|
createButton: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 20,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderWidth: StyleSheet.hairlineWidth,
|
|
borderColor: 'rgba(255,255,255,0.6)',
|
|
backgroundColor: 'rgba(255,255,255,0.85)',
|
|
},
|
|
createButtonFallback: {
|
|
backgroundColor: 'rgba(255,255,255,0.75)',
|
|
},
|
|
cardsContainer: {
|
|
gap: 18,
|
|
},
|
|
cardGroups: {
|
|
gap: 20,
|
|
},
|
|
sectionHeaderRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 8,
|
|
},
|
|
sectionHeaderText: {
|
|
fontSize: 16,
|
|
fontWeight: '800',
|
|
},
|
|
customEmpty: {
|
|
borderRadius: 18,
|
|
backgroundColor: 'rgba(255,255,255,0.08)',
|
|
},
|
|
primaryGhostButton: {
|
|
marginTop: 12,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 8,
|
|
borderWidth: StyleSheet.hairlineWidth,
|
|
borderRadius: 14,
|
|
},
|
|
carouselContainer: {
|
|
marginBottom: 24,
|
|
},
|
|
carouselCard: {
|
|
width: '100%',
|
|
},
|
|
carouselTouchable: {
|
|
flex: 1,
|
|
},
|
|
carouselProgressCard: {
|
|
width: '100%',
|
|
},
|
|
carouselIndicators: {
|
|
marginTop: 18,
|
|
flexDirection: 'row',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
carouselDot: {
|
|
width: DOT_BASE_SIZE,
|
|
height: DOT_BASE_SIZE,
|
|
borderRadius: DOT_BASE_SIZE / 2,
|
|
marginHorizontal: 4,
|
|
backgroundColor: 'transparent',
|
|
},
|
|
stateContainer: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingVertical: 40,
|
|
paddingHorizontal: 20,
|
|
},
|
|
stateText: {
|
|
marginTop: 12,
|
|
fontSize: 14,
|
|
textAlign: 'center',
|
|
lineHeight: 20,
|
|
},
|
|
retryButton: {
|
|
marginTop: 16,
|
|
paddingHorizontal: 18,
|
|
paddingVertical: 8,
|
|
borderRadius: 18,
|
|
borderWidth: StyleSheet.hairlineWidth,
|
|
},
|
|
retryText: {
|
|
fontSize: 13,
|
|
fontWeight: '600',
|
|
},
|
|
card: {
|
|
borderRadius: 28,
|
|
padding: 18,
|
|
shadowOffset: { width: 0, height: 16 },
|
|
shadowOpacity: 0.18,
|
|
shadowRadius: 24,
|
|
elevation: 6,
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
},
|
|
cardInner: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
cardImage: {
|
|
width: CARD_IMAGE_WIDTH,
|
|
height: CARD_IMAGE_HEIGHT,
|
|
borderRadius: 22,
|
|
},
|
|
cardMedia: {
|
|
borderRadius: 22,
|
|
overflow: 'hidden',
|
|
position: 'relative',
|
|
},
|
|
cardContent: {
|
|
flex: 1,
|
|
marginLeft: 16,
|
|
},
|
|
cardTitle: {
|
|
fontSize: 18,
|
|
fontWeight: '700',
|
|
marginBottom: 4,
|
|
fontFamily: 'AliBold',
|
|
},
|
|
|
|
cardDate: {
|
|
fontSize: 13,
|
|
fontWeight: '500',
|
|
marginBottom: 4,
|
|
fontFamily: 'AliRegular',
|
|
},
|
|
cardParticipants: {
|
|
fontSize: 13,
|
|
fontWeight: '500',
|
|
fontFamily: 'AliRegular'
|
|
},
|
|
cardExpired: {
|
|
borderWidth: StyleSheet.hairlineWidth,
|
|
borderColor: 'rgba(148, 163, 184, 0.22)',
|
|
},
|
|
cardExpiredText: {
|
|
opacity: 0.7,
|
|
},
|
|
cardDimOverlay: {
|
|
...StyleSheet.absoluteFillObject,
|
|
borderRadius: 28,
|
|
},
|
|
cardImageOverlay: {
|
|
...StyleSheet.absoluteFillObject,
|
|
},
|
|
expiredBadge: {
|
|
position: 'absolute',
|
|
left: 12,
|
|
bottom: 12,
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 4,
|
|
borderRadius: 12,
|
|
backgroundColor: 'rgba(12, 16, 28, 0.45)',
|
|
},
|
|
expiredBadgeText: {
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
color: '#f7f9ff',
|
|
letterSpacing: 0.3,
|
|
fontFamily: 'AliRegular',
|
|
},
|
|
cardProgress: {
|
|
marginTop: 8,
|
|
fontSize: 13,
|
|
fontWeight: '600',
|
|
},
|
|
avatarRow: {
|
|
flexDirection: 'row',
|
|
marginTop: 16,
|
|
alignItems: 'center',
|
|
},
|
|
avatar: {
|
|
width: AVATAR_SIZE,
|
|
height: AVATAR_SIZE,
|
|
borderRadius: AVATAR_SIZE / 2,
|
|
borderWidth: 2,
|
|
},
|
|
avatarOffset: {
|
|
marginLeft: -12,
|
|
},
|
|
modalInputWrapper: {
|
|
borderRadius: 14,
|
|
borderWidth: 1,
|
|
borderColor: '#e5e7eb',
|
|
backgroundColor: '#f8fafc',
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 10,
|
|
gap: 6,
|
|
},
|
|
modalInput: {
|
|
paddingVertical: 12,
|
|
fontSize: 16,
|
|
fontWeight: '700',
|
|
letterSpacing: 1.5,
|
|
color: '#0f1528',
|
|
},
|
|
modalError: {
|
|
marginTop: 10,
|
|
fontSize: 12,
|
|
color: '#ef4444',
|
|
},
|
|
});
|