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(FALLBACK_IMAGE); const [imagePreview, setImagePreview] = useState(null); const { upload, uploading } = useCosUpload({ prefix: 'images/challenges' }); const [type, setType] = useState(ChallengeType.WATER); const [startDate, setStartDate] = useState(today); const [endDate, setEndDate] = useState(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(null); const [shareModalVisible, setShareModalVisible] = useState(false); const [createdChallengeId, setCreatedChallengeId] = useState(null); const [pickerType, setPickerType] = useState(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 ) => ( {label} ); const renderTextarea = ( label: string, value: string, onChange: (val: string) => void, placeholder?: string ) => ( {label} ); const progressMeta = `${durationDays} 天 · ${progressUnit || '天'}`; const heroImageSource = imagePreview || image || FALLBACK_IMAGE; return ( 自定义挑战 {title || '你的专属挑战'} {progressMeta} 基础信息 {createError ? {createError} : null} {renderField('标题', title, setTitle, '挑战标题(最多100字)')} 封面图 {uploading ? '上传中…' : '上传封面'} {image || imagePreview ? ( { setImagePreview(null); setImage(undefined); }} > 清除 ) : null} 建议比例 16:9,清晰展示挑战氛围 {renderTextarea('挑战说明', summary, setSummary, '简单介绍这个挑战的目标与要求')} 挑战设置 挑战类型 {typeOptions.map((option) => { const active = option.value === type; return ( setType(option.value)} style={[ styles.chip, active && { backgroundColor: `${option.accent}1A`, borderColor: option.accent }, ]} > {option.label} ); })} 时间范围 setPickerType('start')} > 开始 {dayjs(startDate).format('YYYY.MM.DD')} setPickerType('end')} > 结束 {dayjs(endDate).format('YYYY.MM.DD')} 持续时间 {durationLabel} {renderField('周期标签', periodLabel, (v) => { setPeriodEdited(true); setPeriodLabel(v); }, '如:21天挑战')} {renderField('每日目标值', targetValue, handleTargetInputChange, '如:8', 'numeric')} 进度单位 {progressUnit} {renderField('最少打卡天数', minimumCheckInDays, handleMinimumDaysChange, '至少1天', 'numeric')} {renderField('挑战要求说明', requirementLabel, setRequirementLabel, '例如:每日完成 30 分钟运动')} 展示&互动 {renderField('参与人数上限', maxParticipants, (v) => { const digits = v.replace(/\D/g, ''); if (!digits) { setMaxParticipants(''); return; } setMaxParticipants(String(parseInt(digits, 10))); }, '留空表示无限制', 'numeric')} 是否公开 公开后其他用户可通过邀请码加入 生成自定义挑战 自动创建分享码,邀请好友一起挑战 {isCreating ? '创建中…' : '创建并生成邀请码'} setPickerType(null)} /> setShareModalVisible(false)} > 邀请码已生成 分享给好友即可加入挑战 {shareCode ?? '获取中…'} 复制邀请码 查看挑战 setShareModalVisible(false)} > 稍后再说 ); } 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', }, });