- 新增自定义挑战的编辑模式,支持修改挑战信息 - 在详情页为创建者添加删除(归档)挑战的功能入口 - 全面完善挑战创建页面的国际化(i18n)文案适配 - 优化个人中心页面的字体样式,统一使用 AliBold/Regular - 更新 Store 逻辑以处理挑战更新、删除及列表数据映射调整
1074 lines
36 KiB
TypeScript
1074 lines
36 KiB
TypeScript
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' },
|
|
];
|
|
|
|
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<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, setProgressUnit] = 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);
|
|
|
|
// 编辑模式下预填充数据
|
|
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 || ''));
|
|
setRequirementLabel(challenge.requirementLabel || '');
|
|
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;
|
|
}
|
|
|
|
if (!requirementLabel.trim()) {
|
|
Toast.warning(t('challenges.createCustom.alerts.requirementRequired'));
|
|
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: requirementLabel.trim(),
|
|
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
|
|
) => (
|
|
<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} ${t('challenges.createCustom.dayUnit')}${progressUnit ? ` · ${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={isEditMode ? t('challenges.createCustom.editTitle') : t('challenges.createCustom.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}>{t('challenges.customChallenges')}</Text>
|
|
<Text style={styles.heroTitle}>{title || t('challenges.createCustom.yourChallenge')}</Text>
|
|
<Text style={styles.heroMeta}>{progressMeta}</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.formCard}>
|
|
<View style={styles.formHeader}>
|
|
<Text style={styles.sectionTitle}>{t('challenges.createCustom.basicInfo')}</Text>
|
|
{inlineError ? <Text style={styles.inlineError}>{inlineError}</Text> : null}
|
|
</View>
|
|
{renderField(t('challenges.createCustom.fields.title'), title, setTitle, t('challenges.createCustom.fields.titlePlaceholder'))}
|
|
<View style={styles.fieldBlock}>
|
|
<Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.coverImage')}</Text>
|
|
<View style={styles.uploadRow}>
|
|
<TouchableOpacity
|
|
activeOpacity={0.9}
|
|
style={[styles.uploadButton, uploading && styles.uploadButtonDisabled]}
|
|
onPress={handlePickImage}
|
|
disabled={uploading}
|
|
>
|
|
<Text style={styles.uploadButtonLabel}>{uploading ? t('challenges.createCustom.imageUpload.uploading') : t('challenges.createCustom.fields.uploadCover')}</Text>
|
|
</TouchableOpacity>
|
|
{image || imagePreview ? (
|
|
<TouchableOpacity
|
|
activeOpacity={0.8}
|
|
onPress={() => {
|
|
setImagePreview(null);
|
|
setImage(undefined);
|
|
}}
|
|
>
|
|
<Text style={styles.clearUpload}>{t('challenges.createCustom.imageUpload.clear')}</Text>
|
|
</TouchableOpacity>
|
|
) : null}
|
|
</View>
|
|
<Text style={styles.helperText}>{t('challenges.createCustom.imageUpload.helper')}</Text>
|
|
</View>
|
|
{renderTextarea(t('challenges.createCustom.fields.challengeDescription'), summary, setSummary, t('challenges.createCustom.fields.descriptionPlaceholder'))}
|
|
</View>
|
|
|
|
<View style={styles.formCard}>
|
|
<Text style={styles.sectionTitle}>{t('challenges.createCustom.challengeSettings')}</Text>
|
|
|
|
<View style={styles.fieldBlock}>
|
|
<Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.challengeType')}</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}>{t('challenges.createCustom.fields.timeRange')}</Text>
|
|
<View style={styles.dateRow}>
|
|
<TouchableOpacity
|
|
activeOpacity={0.9}
|
|
style={styles.datePill}
|
|
onPress={() => setPickerType('start')}
|
|
>
|
|
<Text style={styles.dateLabel}>{t('challenges.createCustom.fields.start')}</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}>{t('challenges.createCustom.fields.end')}</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}>{t('challenges.createCustom.fields.duration')}</Text>
|
|
<View style={styles.readonlyPill}>
|
|
<Text style={styles.readonlyText}>{durationLabel}</Text>
|
|
</View>
|
|
</View>
|
|
{renderField(t('challenges.createCustom.fields.periodLabel'), periodLabel, (v) => {
|
|
setPeriodEdited(true);
|
|
setPeriodLabel(v);
|
|
}, t('challenges.createCustom.fields.periodLabelPlaceholder'))}
|
|
</View>
|
|
|
|
<View style={styles.fieldBlock}>
|
|
<Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.dailyTargetAndUnit')}</Text>
|
|
<View style={styles.targetUnitRow}>
|
|
<TextInput
|
|
value={targetValue}
|
|
onChangeText={handleTargetInputChange}
|
|
placeholder={t('challenges.createCustom.fields.dailyTargetPlaceholder')}
|
|
placeholderTextColor="#9ca3af"
|
|
style={[styles.input, styles.targetInput]}
|
|
keyboardType="numeric"
|
|
/>
|
|
<TextInput
|
|
value={progressUnit}
|
|
onChangeText={setProgressUnit}
|
|
placeholder={t('challenges.createCustom.fields.unitPlaceholder')}
|
|
placeholderTextColor="#9ca3af"
|
|
style={[styles.input, styles.unitInput]}
|
|
/>
|
|
</View>
|
|
<Text style={styles.helperText}>{t('challenges.createCustom.fields.unitHelper')}</Text>
|
|
</View>
|
|
|
|
{renderField(t('challenges.createCustom.fields.minimumCheckInDays'), minimumCheckInDays, handleMinimumDaysChange, t('challenges.createCustom.fields.minimumCheckInDaysPlaceholder'), 'numeric')}
|
|
|
|
{renderField(t('challenges.createCustom.fields.challengeRequirement'), requirementLabel, setRequirementLabel, t('challenges.createCustom.fields.requirementPlaceholder'))}
|
|
</View>
|
|
|
|
<View style={styles.formCard}>
|
|
<Text style={styles.sectionTitle}>{t('challenges.createCustom.displayInteraction')}</Text>
|
|
<View style={styles.inlineFields}>
|
|
{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')}
|
|
</View>
|
|
<View style={styles.switchRow}>
|
|
<View>
|
|
<Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.isPublic')}</Text>
|
|
<Text style={styles.switchHint}>{t('challenges.createCustom.fields.publicDescription')}</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}>
|
|
{isEditMode
|
|
? t('challenges.createCustom.floatingCTA.editTitle')
|
|
: t('challenges.createCustom.floatingCTA.title')
|
|
}
|
|
</Text>
|
|
<Text style={styles.floatingSubtitle}>
|
|
{isEditMode
|
|
? t('challenges.createCustom.floatingCTA.editSubtitle')
|
|
: t('challenges.createCustom.floatingCTA.subtitle')
|
|
}
|
|
</Text>
|
|
</View>
|
|
<TouchableOpacity
|
|
activeOpacity={0.9}
|
|
style={styles.floatingButton}
|
|
onPress={handleSubmit}
|
|
disabled={isCreating || isUpdating}
|
|
>
|
|
<LinearGradient
|
|
colors={['#5E8BFF', '#6B6CFF']}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
style={styles.floatingButtonBackground}
|
|
>
|
|
<Text style={styles.floatingButtonLabel}>
|
|
{isCreating
|
|
? t('challenges.createCustom.buttons.creating')
|
|
: isUpdating
|
|
? t('challenges.createCustom.buttons.updating')
|
|
: isEditMode
|
|
? t('challenges.createCustom.buttons.updateAndSave')
|
|
: t('challenges.createCustom.buttons.createAndGenerateCode')
|
|
}
|
|
</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}>{t('challenges.createCustom.shareModal.title')}</Text>
|
|
<Text style={styles.shareSubtitle}>{t('challenges.createCustom.shareModal.subtitle')}</Text>
|
|
<View style={styles.shareCodeBadge}>
|
|
<Text style={styles.shareCode}>{shareCode ?? t('challenges.createCustom.shareModal.generatingCode')}</Text>
|
|
</View>
|
|
<View style={styles.shareActions}>
|
|
<TouchableOpacity
|
|
activeOpacity={0.85}
|
|
style={styles.shareButtonGhost}
|
|
onPress={handleCopyShareCode}
|
|
disabled={!shareCode}
|
|
>
|
|
<Text style={styles.shareButtonGhostLabel}>{t('challenges.createCustom.shareModal.copyCode')}</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}>{t('challenges.createCustom.shareModal.viewChallenge')}</Text>
|
|
</LinearGradient>
|
|
</TouchableOpacity>
|
|
</View>
|
|
<TouchableOpacity
|
|
style={styles.shareClose}
|
|
activeOpacity={0.8}
|
|
onPress={() => setShareModalVisible(false)}
|
|
>
|
|
<Text style={styles.shareCloseLabel}>{t('challenges.createCustom.shareModal.later')}</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',
|
|
},
|
|
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',
|
|
},
|
|
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',
|
|
},
|
|
});
|