feat(challenges): 添加自定义挑战功能和多语言支持
- 新增自定义挑战创建页面,支持设置挑战类型、时间范围、目标值等 - 实现挑战邀请码系统,支持通过邀请码加入自定义挑战 - 完善挑战详情页面的多语言翻译支持 - 优化用户认证状态检查逻辑,使用token作为主要判断依据 - 添加阿里字体文件支持,提升UI显示效果 - 改进确认弹窗组件,支持Liquid Glass效果和自定义内容 - 优化应用启动流程,直接读取onboarding状态而非预加载用户数据
This commit is contained in:
976
app/challenges/create-custom.tsx
Normal file
976
app/challenges/create-custom.tsx
Normal file
@@ -0,0 +1,976 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user