import i18n from '@/i18n'; 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 { useLocalSearchParams, 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 { useI18n } from '@/hooks/useI18n'; import { ChallengeType, type CreateCustomChallengePayload, type UpdateCustomChallengePayload, } from '@/services/challengesApi'; import { store } from '@/store'; import { createCustomChallengeThunk, fetchChallenges, selectChallengeById, selectCreateChallengeError, selectCreateChallengeStatus, selectUpdateChallengeError, selectUpdateChallengeStatus, updateCustomChallengeThunk } from '@/store/challengesSlice'; import { Toast } from '@/utils/toast.utils'; const getTypeOptions = (t: (key: string) => string): { value: ChallengeType; label: string; accent: string }[] => [ { value: ChallengeType.WATER, label: t('challenges.createCustom.typeLabels.water'), accent: '#5E8BFF' }, { value: ChallengeType.EXERCISE, label: t('challenges.createCustom.typeLabels.exercise'), accent: '#6B6CFF' }, { value: ChallengeType.DIET, label: t('challenges.createCustom.typeLabels.diet'), accent: '#38BDF8' }, { value: ChallengeType.SLEEP, label: t('challenges.createCustom.typeLabels.sleep'), accent: '#7C3AED' }, { value: ChallengeType.MOOD, label: t('challenges.createCustom.typeLabels.mood'), accent: '#F97316' }, { value: ChallengeType.WEIGHT, label: t('challenges.createCustom.typeLabels.weight'), accent: '#22C55E' }, { value: ChallengeType.CUSTOM, label: t('challenges.createCustom.typeLabels.custom'), accent: '#8B5CF6' }, ]; 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 { id, mode } = useLocalSearchParams<{ id?: string; mode?: 'edit' }>(); const isEditMode = mode === 'edit' && !!id; 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 updateError = useAppSelector(selectUpdateChallengeError); const updateStatus = useAppSelector(selectUpdateChallengeStatus); const inlineError = isEditMode ? updateError : createError; const isCreating = createStatus === 'loading'; const isUpdating = updateStatus === 'loading'; const { t } = useI18n(); const typeOptions = useMemo(() => getTypeOptions(t), [t]); 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, setProgressUnit] = useState(''); const [periodLabel, setPeriodLabel] = useState(''); const [periodEdited, setPeriodEdited] = useState(false); const [rankingDescription] = useState(t('challenges.createCustom.rankingDescription')); 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); // 编辑模式下预填充数据 useEffect(() => { if (isEditMode && id) { const challengeSelector = selectChallengeById(id); const challenge = challengeSelector(store.getState()); if (challenge) { setTitle(challenge.title || ''); setImage(challenge.image); setType(challenge.type); setStartDate(new Date(challenge.startAt || Date.now())); setEndDate(new Date(challenge.endAt || Date.now())); setTargetValue(String(challenge.progress?.target || '')); setMinimumCheckInDays(String(challenge.minimumCheckInDays || '')); setSummary(challenge.summary || ''); setProgressUnit(challenge.unit || ''); setPeriodLabel(challenge.periodLabel || ''); setIsPublic(challenge.isPublic ?? true); setMaxParticipants(challenge.maxParticipants?.toString() || '100'); } } }, [isEditMode, id]); const durationDays = useMemo( () => Math.max( 1, dayjs(endDate).startOf('day').diff(dayjs(startDate).startOf('day'), 'day') + 1 ), [startDate, endDate] ); const durationLabel = useMemo(() => t('challenges.createCustom.durationDays', { days: durationDays }), [durationDays, t]); useEffect(() => { if (!periodEdited) { setPeriodLabel(t('challenges.createCustom.durationDaysChallenge', { days: durationDays })); } if (!minimumEdited) { setMinimumCheckInDays(String(durationDays)); } }, [durationDays, minimumEdited, periodEdited, t]); 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 || isUpdating) return; if (!title.trim()) { Toast.warning(t('challenges.createCustom.alerts.titleRequired')); return; } const startTimestamp = dayjs(startDate).valueOf(); const endTimestamp = dayjs(endDate).valueOf(); if (endTimestamp <= startTimestamp) { Toast.warning(t('challenges.createCustom.alerts.endTimeError')); return; } const target = Number(targetValue); if (!Number.isFinite(target) || target < 1 || target > 1000) { Toast.warning(t('challenges.createCustom.alerts.targetValueError')); return; } const minDays = Number(minimumCheckInDays) || durationDays; if (!Number.isFinite(minDays) || minDays < 1 || minDays > 365) { Toast.warning(t('challenges.createCustom.alerts.minimumDaysError')); return; } if (minDays > durationDays) { Toast.warning(t('challenges.createCustom.alerts.minimumDaysExceedError')); return; } const maxP = maxParticipants ? Number(maxParticipants) : null; if (maxP !== null && (!Number.isFinite(maxP) || maxP < 2 || maxP > 10000)) { Toast.warning(t('challenges.createCustom.alerts.participantsError')); return; } const safeTitle = title.trim() || t('challenges.createCustom.defaultTitle'); const payload: CreateCustomChallengePayload = { title: safeTitle, type, image: image?.trim() || undefined, startAt: startTimestamp, endAt: endTimestamp, targetValue: target, minimumCheckInDays: minDays, durationLabel, requirementLabel: '', summary: summary.trim() || undefined, progressUnit: progressUnit.trim(), periodLabel: periodLabel.trim() || undefined, rankingDescription: rankingDescription.trim() || undefined, isPublic, maxParticipants: maxP, }; const updatePayload: UpdateCustomChallengePayload = { title: safeTitle, image: image?.trim() || undefined, summary: summary.trim() || undefined, isPublic, maxParticipants: maxP ?? undefined, }; try { if (isEditMode && id) { await dispatch(updateCustomChallengeThunk({ id, payload: updatePayload })).unwrap(); Toast.success(t('challenges.createCustom.alerts.updateSuccess')); dispatch(fetchChallenges()); return; } const created = await dispatch(createCustomChallengeThunk(payload)).unwrap(); setShareCode(created.shareCode ?? null); setCreatedChallengeId(created.id); setShareModalVisible(true); Toast.success(t('challenges.createCustom.alerts.createSuccess')); dispatch(fetchChallenges()); } catch (error) { const message = typeof error === 'string' ? error : t('challenges.createCustom.alerts.createFailed'); Toast.error(message); } }; const handleCopyShareCode = async () => { if (!shareCode) return; await Clipboard.setStringAsync(shareCode); Toast.success(t('challenges.createCustom.shareModal.copyCode')); }; 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( t('challenges.createCustom.imageUpload.selectSource'), t('challenges.createCustom.imageUpload.selectMessage'), [ { text: t('challenges.createCustom.imageUpload.camera'), onPress: async () => { try { const permission = await ImagePicker.requestCameraPermissionsAsync(); if (permission.status !== 'granted') { Alert.alert(t('challenges.createCustom.imageUpload.cameraPermission'), t('challenges.createCustom.imageUpload.cameraPermissionMessage')); 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(t('challenges.createCustom.imageUpload.uploadFailed'), t('challenges.createCustom.imageUpload.uploadFailedMessage')); } } catch (error) { console.error('[CHALLENGE] 拍照失败', error); Alert.alert(t('challenges.createCustom.imageUpload.cameraFailed'), t('challenges.createCustom.imageUpload.cameraFailedMessage')); } }, }, { text: t('challenges.createCustom.imageUpload.album'), onPress: async () => { try { const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (permission.status !== 'granted') { Alert.alert(t('challenges.createCustom.imageUpload.cameraPermission'), t('challenges.createCustom.imageUpload.albumPermissionMessage')); 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(t('challenges.createCustom.imageUpload.uploadFailed'), t('challenges.createCustom.imageUpload.uploadFailedMessage')); } } catch (error) { console.error('[CHALLENGE] 选择封面失败', error); Alert.alert(t('challenges.createCustom.imageUpload.selectFailed'), t('challenges.createCustom.imageUpload.selectFailedMessage')); } }, }, { text: t('challenges.createCustom.imageUpload.cancel'), style: 'cancel' }, ], { cancelable: true } ); }, [upload, t]); 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} ${t('challenges.createCustom.dayUnit')}${progressUnit ? ` · ${progressUnit}` : ''}`; const heroImageSource = imagePreview || image || FALLBACK_IMAGE; return ( {t('challenges.customChallenges')} {title || t('challenges.createCustom.yourChallenge')} {progressMeta} {t('challenges.createCustom.basicInfo')} {inlineError ? {inlineError} : null} {renderField(t('challenges.createCustom.fields.title'), title, setTitle, t('challenges.createCustom.fields.titlePlaceholder'))} {t('challenges.createCustom.fields.coverImage')} {uploading ? t('challenges.createCustom.imageUpload.uploading') : t('challenges.createCustom.fields.uploadCover')} {image || imagePreview ? ( { setImagePreview(null); setImage(undefined); }} > {t('challenges.createCustom.imageUpload.clear')} ) : null} {t('challenges.createCustom.imageUpload.helper')} {renderTextarea(t('challenges.createCustom.fields.challengeDescription'), summary, setSummary, t('challenges.createCustom.fields.descriptionPlaceholder'))} {t('challenges.createCustom.challengeSettings')} {t('challenges.createCustom.fields.challengeType')} {typeOptions.map((option) => { const active = option.value === type; return ( !isEditMode && setType(option.value)} disabled={isEditMode} style={[ styles.chip, active && { backgroundColor: `${option.accent}1A`, borderColor: option.accent }, isEditMode && styles.chipDisabled, ]} > {option.label} ); })} {t('challenges.createCustom.fields.challengeTypeHelper')} {t('challenges.createCustom.fields.timeRange')} setPickerType('start')} > {t('challenges.createCustom.fields.start')} {dayjs(startDate).format('YYYY.MM.DD')} setPickerType('end')} > {t('challenges.createCustom.fields.end')} {dayjs(endDate).format('YYYY.MM.DD')} {t('challenges.createCustom.fields.duration')} {durationLabel} {renderField(t('challenges.createCustom.fields.periodLabel'), periodLabel, (v) => { setPeriodEdited(true); setPeriodLabel(v); }, t('challenges.createCustom.fields.periodLabelPlaceholder'))} {t('challenges.createCustom.fields.dailyTargetAndUnit')} {t('challenges.createCustom.fields.unitHelper')} {renderField(t('challenges.createCustom.fields.minimumCheckInDays'), minimumCheckInDays, handleMinimumDaysChange, t('challenges.createCustom.fields.minimumCheckInDaysPlaceholder'), 'numeric')} {t('challenges.createCustom.displayInteraction')} {renderField(t('challenges.createCustom.fields.maxParticipants'), maxParticipants, (v) => { const digits = v.replace(/\D/g, ''); if (!digits) { setMaxParticipants(''); return; } setMaxParticipants(String(parseInt(digits, 10))); }, t('challenges.createCustom.fields.noLimit'), 'numeric')} {t('challenges.createCustom.fields.isPublic')} {t('challenges.createCustom.fields.publicDescription')} {isEditMode ? t('challenges.createCustom.floatingCTA.editTitle') : t('challenges.createCustom.floatingCTA.title') } {isEditMode ? t('challenges.createCustom.floatingCTA.editSubtitle') : t('challenges.createCustom.floatingCTA.subtitle') } {isCreating ? t('challenges.createCustom.buttons.creating') : isUpdating ? t('challenges.createCustom.buttons.updating') : isEditMode ? t('challenges.createCustom.buttons.updateAndSave') : t('challenges.createCustom.buttons.createAndGenerateCode') } setPickerType(null)} locale={i18n.language} confirmTextIOS={t('challenges.createCustom.datePicker.confirm')} cancelTextIOS={t('challenges.createCustom.datePicker.cancel')} /> setShareModalVisible(false)} > {t('challenges.createCustom.shareModal.title')} {t('challenges.createCustom.shareModal.subtitle')} {shareCode ?? t('challenges.createCustom.shareModal.generatingCode')} {t('challenges.createCustom.shareModal.copyCode')} {t('challenges.createCustom.shareModal.viewChallenge')} setShareModalVisible(false)} > {t('challenges.createCustom.shareModal.later')} ); } 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', }, targetUnitRow: { flexDirection: 'row', gap: 12, }, targetInput: { flex: 1, }, unitInput: { flex: 1, }, textarea: { minHeight: 90, }, chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, }, chip: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 14, backgroundColor: '#f8fafc', borderWidth: 1, borderColor: '#e5e7eb', }, chipDisabled: { opacity: 0.5, backgroundColor: '#f1f5f9', }, chipLabel: { fontSize: 13, color: '#334155', }, chipLabelDisabled: { color: '#94a3b8', }, 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', }, });