Files
digital-pilates/app/challenges/create-custom.tsx
richarjiang 39671ed70f feat(challenges): 添加自定义挑战功能和多语言支持
- 新增自定义挑战创建页面,支持设置挑战类型、时间范围、目标值等
- 实现挑战邀请码系统,支持通过邀请码加入自定义挑战
- 完善挑战详情页面的多语言翻译支持
- 优化用户认证状态检查逻辑,使用token作为主要判断依据
- 添加阿里字体文件支持,提升UI显示效果
- 改进确认弹窗组件,支持Liquid Glass效果和自定义内容
- 优化应用启动流程,直接读取onboarding状态而非预加载用户数据
2025-11-26 16:39:01 +08:00

977 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import dayjs from 'dayjs';
import { BlurView } from 'expo-blur';
import * as Clipboard from 'expo-clipboard';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
KeyboardAvoidingView,
Modal,
Platform,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import DateTimePickerModal from 'react-native-modal-datetime-picker';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload';
import { ChallengeType, type CreateCustomChallengePayload } from '@/services/challengesApi';
import {
createCustomChallengeThunk,
fetchChallenges,
selectCreateChallengeError,
selectCreateChallengeStatus,
} from '@/store/challengesSlice';
import { Toast } from '@/utils/toast.utils';
const typeOptions: { value: ChallengeType; label: string; accent: string }[] = [
{ value: ChallengeType.WATER, label: '喝水', accent: '#5E8BFF' },
{ value: ChallengeType.EXERCISE, label: '运动', accent: '#6B6CFF' },
{ value: ChallengeType.DIET, label: '饮食', accent: '#38BDF8' },
{ value: ChallengeType.SLEEP, label: '睡眠', accent: '#7C3AED' },
{ value: ChallengeType.MOOD, label: '心情', accent: '#F97316' },
{ value: ChallengeType.WEIGHT, label: '体重', accent: '#22C55E' },
];
const FALLBACK_IMAGE =
'https://images.unsplash.com/photo-1506126613408-eca07ce68773?auto=format&fit=crop&w=1200&q=80';
type PickerType = 'start' | 'end' | null;
export default function CreateCustomChallengeScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch();
const router = useRouter();
const insets = useSafeAreaInsets();
const createStatus = useAppSelector(selectCreateChallengeStatus);
const createError = useAppSelector(selectCreateChallengeError);
const isCreating = createStatus === 'loading';
const today = useMemo(() => dayjs().startOf('day').toDate(), []);
const defaultEnd = useMemo(() => dayjs().add(21, 'day').startOf('day').toDate(), []);
const [title, setTitle] = useState('');
const [image, setImage] = useState<string | undefined>(FALLBACK_IMAGE);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const { upload, uploading } = useCosUpload({ prefix: 'images/challenges' });
const [type, setType] = useState<ChallengeType>(ChallengeType.WATER);
const [startDate, setStartDate] = useState<Date>(today);
const [endDate, setEndDate] = useState<Date>(defaultEnd);
const [targetValue, setTargetValue] = useState('');
const [minimumCheckInDays, setMinimumCheckInDays] = useState('');
const [requirementLabel, setRequirementLabel] = useState('');
const [summary, setSummary] = useState('');
const [progressUnit] = useState('天');
const [periodLabel, setPeriodLabel] = useState('');
const [periodEdited, setPeriodEdited] = useState(false);
const [rankingDescription] = useState('连续打卡榜');
const [isPublic, setIsPublic] = useState(true);
const [maxParticipants, setMaxParticipants] = useState('100');
const [minimumEdited, setMinimumEdited] = useState(false);
const [shareCode, setShareCode] = useState<string | null>(null);
const [shareModalVisible, setShareModalVisible] = useState(false);
const [createdChallengeId, setCreatedChallengeId] = useState<string | null>(null);
const [pickerType, setPickerType] = useState<PickerType>(null);
const durationDays = useMemo(
() =>
Math.max(
1,
dayjs(endDate).startOf('day').diff(dayjs(startDate).startOf('day'), 'day') + 1
),
[startDate, endDate]
);
const durationLabel = useMemo(() => `持续${durationDays}`, [durationDays]);
useEffect(() => {
if (!periodEdited) {
setPeriodLabel(`${durationDays}天挑战`);
}
if (!minimumEdited) {
setMinimumCheckInDays(String(durationDays));
}
}, [durationDays, minimumEdited, periodEdited]);
const handleConfirmDate = (date: Date) => {
if (!pickerType) return;
const normalized = dayjs(date).startOf('day');
if (pickerType === 'start') {
const nextStart = normalized.isAfter(dayjs(), 'day')
? normalized
: dayjs().add(1, 'day').startOf('day');
setStartDate(nextStart.toDate());
if (dayjs(endDate).isSameOrBefore(nextStart)) {
const nextEnd = nextStart.add(20, 'day').toDate();
setEndDate(nextEnd);
}
} else {
const minEnd = dayjs(startDate).add(1, 'day').startOf('day');
const nextEnd = normalized.isAfter(minEnd) ? normalized : minEnd;
setEndDate(nextEnd.toDate());
}
setPickerType(null);
};
const handleSubmit = async () => {
if (isCreating) return;
if (!title.trim()) {
Toast.warning('请填写挑战标题');
return;
}
if (!requirementLabel.trim()) {
Toast.warning('请填写挑战要求说明');
return;
}
const startTimestamp = dayjs(startDate).valueOf();
const endTimestamp = dayjs(endDate).valueOf();
if (endTimestamp <= startTimestamp) {
Toast.warning('结束时间需要晚于开始时间');
return;
}
const target = Number(targetValue);
if (!Number.isFinite(target) || target < 1 || target > 1000) {
Toast.warning('每日目标值需在 1-1000 之间');
return;
}
const minDays = Number(minimumCheckInDays) || durationDays;
if (!Number.isFinite(minDays) || minDays < 1 || minDays > 365) {
Toast.warning('最少打卡天数需在 1-365 之间');
return;
}
if (minDays > durationDays) {
Toast.warning('最少打卡天数不能超过持续天数');
return;
}
const maxP = maxParticipants ? Number(maxParticipants) : null;
if (maxP !== null && (!Number.isFinite(maxP) || maxP < 2 || maxP > 10000)) {
Toast.warning('参与人数需在 2-10000 之间,或留空表示无限制');
return;
}
const safeTitle = title.trim() || '自定义挑战';
const payload: CreateCustomChallengePayload = {
title: safeTitle,
type,
image: image?.trim() || undefined,
startAt: startTimestamp,
endAt: endTimestamp,
targetValue: target,
minimumCheckInDays: minDays,
durationLabel,
requirementLabel: requirementLabel.trim() || '请填写挑战要求',
summary: summary.trim() || undefined,
progressUnit: progressUnit.trim() || '天',
periodLabel: periodLabel.trim() || undefined,
rankingDescription: rankingDescription.trim() || undefined,
isPublic,
maxParticipants: maxP,
};
try {
const created = await dispatch(createCustomChallengeThunk(payload)).unwrap();
setShareCode(created.shareCode ?? null);
setCreatedChallengeId(created.id);
setShareModalVisible(true);
Toast.success('自定义挑战已创建');
dispatch(fetchChallenges());
} catch (error) {
const message = typeof error === 'string' ? error : '创建失败,请稍后再试';
Toast.error(message);
}
};
const handleCopyShareCode = async () => {
if (!shareCode) return;
await Clipboard.setStringAsync(shareCode);
Toast.success('邀请码已复制');
};
const handleTargetInputChange = (value: string) => {
const digits = value.replace(/\D/g, '');
if (!digits) {
setTargetValue('');
return;
}
const num = Math.min(1000, parseInt(digits, 10));
setTargetValue(String(num));
};
const handleMinimumDaysChange = (value: string) => {
const digits = value.replace(/\D/g, '');
if (!digits) {
setMinimumCheckInDays('');
setMinimumEdited(true);
return;
}
const num = Math.max(1, Math.min(365, parseInt(digits, 10)));
if (num > durationDays) {
setMinimumCheckInDays(String(durationDays));
setMinimumEdited(true);
return;
}
setMinimumEdited(true);
setMinimumCheckInDays(String(num));
};
const handlePickImage = useCallback(() => {
Alert.alert(
'选择封面图',
'请选择封面来源',
[
{
text: '拍照',
onPress: async () => {
try {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (permission.status !== 'granted') {
Alert.alert('权限不足', '需要相机权限以拍摄封面');
return;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
quality: 0.6,
aspect: [16, 9],
});
if (result.canceled || !result.assets?.length) return;
const asset = result.assets[0];
setImagePreview(asset.uri);
setImage(undefined);
try {
const { url } = await upload(
{
uri: asset.uri,
name: asset.fileName ?? `challenge-${Date.now()}.jpg`,
type: asset.mimeType ?? 'image/jpeg',
},
{ prefix: 'images/challenges' }
);
setImage(url);
setImagePreview(null);
} catch (error) {
console.error('[CHALLENGE] 封面上传失败', error);
Alert.alert('上传失败', '封面上传失败,请稍后重试');
}
} catch (error) {
console.error('[CHALLENGE] 拍照失败', error);
Alert.alert('拍照失败', '无法打开相机,请稍后再试');
}
},
},
{
text: '从相册选择',
onPress: async () => {
try {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permission.status !== 'granted') {
Alert.alert('权限不足', '需要相册权限以选择封面');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
quality: 0.9,
});
if (result.canceled || !result.assets?.length) return;
const asset = result.assets[0];
setImagePreview(asset.uri);
setImage(undefined);
try {
const { url } = await upload(
{
uri: asset.uri,
name: asset.fileName ?? `challenge-${Date.now()}.jpg`,
type: asset.mimeType ?? 'image/jpeg',
},
{ prefix: 'images/challenges' }
);
setImage(url);
setImagePreview(null);
} catch (error) {
console.error('[CHALLENGE] 封面上传失败', error);
Alert.alert('上传失败', '封面上传失败,请稍后重试');
}
} catch (error) {
console.error('[CHALLENGE] 选择封面失败', error);
Alert.alert('选择失败', '无法打开相册,请稍后再试');
}
},
},
{ text: '取消', style: 'cancel' },
],
{ cancelable: true }
);
}, [upload]);
const handleViewChallenge = () => {
setShareModalVisible(false);
if (createdChallengeId) {
router.replace({ pathname: '/challenges/[id]', params: { id: createdChallengeId } });
}
};
const renderField = (
label: string,
value: string,
onChange: (val: string) => void,
placeholder?: string,
keyboardType: 'default' | 'numeric' = 'default',
onFocus?: () => void
) => (
<View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}>{label}</Text>
<TextInput
value={value}
onChangeText={onChange}
placeholder={placeholder}
placeholderTextColor="#9ca3af"
style={styles.input}
keyboardType={keyboardType}
onFocus={onFocus}
/>
</View>
);
const renderTextarea = (
label: string,
value: string,
onChange: (val: string) => void,
placeholder?: string
) => (
<View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}>{label}</Text>
<TextInput
value={value}
onChangeText={onChange}
placeholder={placeholder}
placeholderTextColor="#9ca3af"
style={[styles.input, styles.textarea]}
multiline
textAlignVertical="top"
/>
</View>
);
const progressMeta = `${durationDays} 天 · ${progressUnit || '天'}`;
const heroImageSource = imagePreview || image || FALLBACK_IMAGE;
return (
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<LinearGradient
colors={[colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd]}
style={StyleSheet.absoluteFillObject}
/>
<HeaderBar title="新建挑战" transparent />
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={80}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: (Platform.OS === 'ios' ? 180 : 140) + insets.bottom },
]}
>
<View style={styles.heroContainer}>
<Image
source={{ uri: heroImageSource }}
style={styles.heroImage}
cachePolicy={'memory-disk'}
/>
<LinearGradient
colors={['rgba(0,0,0,0.35)', 'rgba(0,0,0,0.15)', 'rgba(244, 246, 255, 1)']}
style={StyleSheet.absoluteFillObject}
/>
<View style={styles.heroOverlay}>
<Text style={styles.heroKicker}></Text>
<Text style={styles.heroTitle}>{title || '你的专属挑战'}</Text>
<Text style={styles.heroMeta}>{progressMeta}</Text>
</View>
</View>
<View style={styles.formCard}>
<View style={styles.formHeader}>
<Text style={styles.sectionTitle}></Text>
{createError ? <Text style={styles.inlineError}>{createError}</Text> : null}
</View>
{renderField('标题', title, setTitle, '挑战标题最多100字')}
<View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text>
<View style={styles.uploadRow}>
<TouchableOpacity
activeOpacity={0.9}
style={[styles.uploadButton, uploading && styles.uploadButtonDisabled]}
onPress={handlePickImage}
disabled={uploading}
>
<Text style={styles.uploadButtonLabel}>{uploading ? '上传中…' : '上传封面'}</Text>
</TouchableOpacity>
{image || imagePreview ? (
<TouchableOpacity
activeOpacity={0.8}
onPress={() => {
setImagePreview(null);
setImage(undefined);
}}
>
<Text style={styles.clearUpload}></Text>
</TouchableOpacity>
) : null}
</View>
<Text style={styles.helperText}> 16:9</Text>
</View>
{renderTextarea('挑战说明', summary, setSummary, '简单介绍这个挑战的目标与要求')}
</View>
<View style={styles.formCard}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text>
<View style={styles.chipRow}>
{typeOptions.map((option) => {
const active = option.value === type;
return (
<TouchableOpacity
key={option.value}
activeOpacity={0.9}
onPress={() => setType(option.value)}
style={[
styles.chip,
active && { backgroundColor: `${option.accent}1A`, borderColor: option.accent },
]}
>
<Text
style={[
styles.chipLabel,
active && { color: option.accent, fontWeight: '700' },
]}
>
{option.label}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
<View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text>
<View style={styles.dateRow}>
<TouchableOpacity
activeOpacity={0.9}
style={styles.datePill}
onPress={() => setPickerType('start')}
>
<Text style={styles.dateLabel}></Text>
<Text style={styles.dateValue}>{dayjs(startDate).format('YYYY.MM.DD')}</Text>
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.9}
style={styles.datePill}
onPress={() => setPickerType('end')}
>
<Text style={styles.dateLabel}></Text>
<Text style={styles.dateValue}>{dayjs(endDate).format('YYYY.MM.DD')}</Text>
</TouchableOpacity>
</View>
</View>
<View style={styles.inlineFields}>
<View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text>
<View style={styles.readonlyPill}>
<Text style={styles.readonlyText}>{durationLabel}</Text>
</View>
</View>
{renderField('周期标签', periodLabel, (v) => {
setPeriodEdited(true);
setPeriodLabel(v);
}, '如21天挑战')}
</View>
<View style={styles.inlineFields}>
{renderField('每日目标值', targetValue, handleTargetInputChange, '如8', 'numeric')}
<View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text>
<View style={styles.readonlyPill}>
<Text style={styles.readonlyText}>{progressUnit}</Text>
</View>
</View>
</View>
{renderField('最少打卡天数', minimumCheckInDays, handleMinimumDaysChange, '至少1天', 'numeric')}
{renderField('挑战要求说明', requirementLabel, setRequirementLabel, '例如:每日完成 30 分钟运动')}
</View>
<View style={styles.formCard}>
<Text style={styles.sectionTitle}>&</Text>
<View style={styles.inlineFields}>
{renderField('参与人数上限', maxParticipants, (v) => {
const digits = v.replace(/\D/g, '');
if (!digits) {
setMaxParticipants('');
return;
}
setMaxParticipants(String(parseInt(digits, 10)));
}, '留空表示无限制', 'numeric')}
</View>
<View style={styles.switchRow}>
<View>
<Text style={styles.fieldLabel}></Text>
<Text style={styles.switchHint}></Text>
</View>
<Switch
value={isPublic}
onValueChange={setIsPublic}
trackColor={{ true: colorTokens.primary, false: '#cbd5e1' }}
thumbColor={isPublic ? '#ffffff' : undefined}
/>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
<View pointerEvents="box-none" style={[styles.floatingCTA, { paddingBottom: insets.bottom + 12 }]}>
<BlurView intensity={14} tint="light" style={styles.floatingBlur}>
<View style={styles.floatingContent}>
<View style={styles.floatingCopy}>
<Text style={styles.floatingTitle}></Text>
<Text style={styles.floatingSubtitle}></Text>
</View>
<TouchableOpacity
activeOpacity={0.9}
style={styles.floatingButton}
onPress={handleSubmit}
disabled={isCreating}
>
<LinearGradient
colors={['#5E8BFF', '#6B6CFF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.floatingButtonBackground}
>
<Text style={styles.floatingButtonLabel}>
{isCreating ? '创建中…' : '创建并生成邀请码'}
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</BlurView>
</View>
<DateTimePickerModal
isVisible={pickerType !== null}
mode="date"
date={pickerType === 'end' ? endDate : startDate}
minimumDate={pickerType === 'end' ? dayjs(startDate).add(1, 'day').toDate() : dayjs().add(1, 'day').toDate()}
onConfirm={handleConfirmDate}
onCancel={() => setPickerType(null)}
/>
<Modal
visible={shareModalVisible}
transparent
animationType="fade"
onRequestClose={() => setShareModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.shareCard}>
<Text style={styles.shareTitle}></Text>
<Text style={styles.shareSubtitle}></Text>
<View style={styles.shareCodeBadge}>
<Text style={styles.shareCode}>{shareCode ?? '获取中…'}</Text>
</View>
<View style={styles.shareActions}>
<TouchableOpacity
activeOpacity={0.85}
style={styles.shareButtonGhost}
onPress={handleCopyShareCode}
disabled={!shareCode}
>
<Text style={styles.shareButtonGhostLabel}></Text>
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.9}
style={styles.shareButtonPrimary}
onPress={handleViewChallenge}
>
<LinearGradient
colors={['#5E8BFF', '#6B6CFF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.shareButtonPrimary}
>
<Text style={styles.shareButtonPrimaryLabel}></Text>
</LinearGradient>
</TouchableOpacity>
</View>
<TouchableOpacity
style={styles.shareClose}
activeOpacity={0.8}
onPress={() => setShareModalVisible(false)}
>
<Text style={styles.shareCloseLabel}></Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
},
scrollContent: {
paddingBottom: 160,
},
heroContainer: {
height: 260,
width: '100%',
overflow: 'hidden',
},
heroImage: {
width: '100%',
height: '100%',
},
heroOverlay: {
position: 'absolute',
bottom: 22,
left: 20,
right: 20,
},
heroKicker: {
color: '#f8fafc',
fontSize: 13,
letterSpacing: 1.2,
fontWeight: '700',
},
heroTitle: {
marginTop: 8,
fontSize: 26,
fontWeight: '800',
color: '#ffffff',
textShadowColor: 'rgba(0,0,0,0.25)',
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 6,
},
heroMeta: {
marginTop: 6,
fontSize: 14,
color: '#e2e8f0',
fontWeight: '600',
},
formCard: {
marginTop: 14,
marginHorizontal: 20,
padding: 18,
borderRadius: 22,
backgroundColor: '#ffffff',
shadowColor: 'rgba(30, 41, 59, 0.12)',
shadowOpacity: 0.2,
shadowRadius: 20,
shadowOffset: { width: 0, height: 12 },
elevation: 8,
gap: 10,
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
color: '#0f172a',
},
fieldBlock: {
gap: 6,
},
fieldLabel: {
fontSize: 14,
fontWeight: '700',
color: '#0f172a',
},
input: {
paddingHorizontal: 14,
paddingVertical: 12,
borderRadius: 14,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#f8fafc',
fontSize: 15,
color: '#111827',
},
textarea: {
minHeight: 90,
},
chipRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
chip: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 14,
backgroundColor: '#f8fafc',
borderWidth: 1,
borderColor: '#e5e7eb',
},
chipLabel: {
fontSize: 13,
color: '#334155',
},
uploadRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
uploadButton: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 12,
backgroundColor: '#EEF1FF',
borderWidth: 1,
borderColor: '#d1d5db',
},
uploadButtonDisabled: {
opacity: 0.7,
},
uploadButtonLabel: {
fontSize: 14,
fontWeight: '700',
color: '#4F5BD5',
},
clearUpload: {
fontSize: 13,
fontWeight: '600',
color: '#9ca3af',
},
helperText: {
marginTop: 6,
fontSize: 12,
color: '#6b7280',
},
dateRow: {
flexDirection: 'row',
gap: 12,
},
datePill: {
flex: 1,
padding: 12,
borderRadius: 14,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#f8fafc',
},
dateLabel: {
fontSize: 12,
color: '#6b7280',
},
dateValue: {
marginTop: 4,
fontSize: 15,
fontWeight: '700',
color: '#0f172a',
},
readonlyPill: {
marginTop: 6,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 14,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#f8fafc',
},
readonlyText: {
fontSize: 15,
fontWeight: '700',
color: '#0f172a',
},
inlineFields: {
gap: 12,
},
switchRow: {
marginTop: 6,
padding: 12,
borderRadius: 14,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#f8fafc',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
switchHint: {
marginTop: 4,
fontSize: 12,
color: '#6b7280',
},
formHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
inlineError: {
fontSize: 12,
color: '#ef4444',
},
floatingCTA: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingHorizontal: 16,
paddingTop: 10,
},
floatingBlur: {
borderRadius: 24,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.6)',
backgroundColor: 'rgba(243, 244, 251, 0.9)',
},
floatingContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
paddingHorizontal: 16,
paddingVertical: 14,
},
floatingCopy: {
flex: 1,
},
floatingTitle: {
fontSize: 15,
fontWeight: '800',
color: '#0f172a',
},
floatingSubtitle: {
marginTop: 4,
fontSize: 12,
color: '#6b7280',
},
floatingButton: {
borderRadius: 16,
overflow: 'hidden',
},
floatingButtonBackground: {
paddingHorizontal: 18,
paddingVertical: 12,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
floatingButtonLabel: {
fontSize: 14,
fontWeight: '800',
color: '#ffffff',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.35)',
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
shareCard: {
width: '100%',
padding: 20,
borderRadius: 22,
backgroundColor: '#ffffff',
shadowColor: 'rgba(15, 23, 42, 0.18)',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.25,
shadowRadius: 20,
elevation: 12,
alignItems: 'center',
gap: 10,
},
shareTitle: {
fontSize: 18,
fontWeight: '800',
color: '#0f172a',
},
shareSubtitle: {
fontSize: 13,
color: '#6b7280',
},
shareCodeBadge: {
marginTop: 10,
paddingHorizontal: 18,
paddingVertical: 12,
borderRadius: 16,
backgroundColor: '#EEF1FF',
},
shareCode: {
fontSize: 22,
fontWeight: '800',
color: '#4F5BD5',
letterSpacing: 2,
},
shareActions: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
width: '100%',
marginTop: 8,
},
shareButtonGhost: {
flex: 1,
paddingVertical: 12,
borderRadius: 14,
borderWidth: 1,
borderColor: '#d1d5db',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f8fafc',
},
shareButtonGhostLabel: {
fontSize: 14,
fontWeight: '700',
color: '#475569',
},
shareButtonPrimary: {
flex: 1,
borderRadius: 14,
overflow: 'hidden',
},
shareButtonPrimaryLabel: {
textAlign: 'center',
fontSize: 14,
fontWeight: '800',
color: '#ffffff',
paddingVertical: 12,
},
shareClose: {
marginTop: 8,
paddingVertical: 10,
paddingHorizontal: 12,
},
shareCloseLabel: {
fontSize: 13,
color: '#6b7280',
},
});