487 lines
15 KiB
TypeScript
487 lines
15 KiB
TypeScript
import React from 'react';
|
||
import { View, Text, StyleSheet, TouchableOpacity, Modal, TextInput, Pressable, KeyboardAvoidingView } from 'react-native';
|
||
import { Repeat, REPEAT_OPTIONS, WEEKDAY_LABELS, WEEKDAY_ORDER, weekdaysInDisplayOrder, repeatDaysFromString } from '@/types';
|
||
import { useSettings } from '@/theme';
|
||
import { useRepeatProfiles } from '@/hooks/useDatabase';
|
||
import { createRepeatProfile, deleteRepeatProfile } from '@/utils/repeatProfileActions';
|
||
import { confirmAction } from '@/utils/confirm';
|
||
import Svg, { Path } from 'react-native-svg';
|
||
|
||
interface RepeatSelectorProps {
|
||
value: Repeat;
|
||
interval: number;
|
||
days: number[];
|
||
onChange: (value: Repeat, interval: number, days: number[]) => void;
|
||
}
|
||
|
||
function RepeatIcon({ repeat, color }: { repeat: Repeat; color: string }) {
|
||
if (repeat === 'none') {
|
||
return (
|
||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||
<Path d="M4 4l16 16" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||
<Path
|
||
d="M20 12a8 8 0 0 0-14.5-5M4 12a8 8 0 0 0 14.5 5"
|
||
stroke={color}
|
||
strokeWidth={2}
|
||
strokeLinecap="round"
|
||
fill="none"
|
||
/>
|
||
</Svg>
|
||
);
|
||
}
|
||
return (
|
||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||
<Path
|
||
d="M17 2l4 4-4 4M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v1a4 4 0 0 1-4 4H3"
|
||
stroke={color}
|
||
strokeWidth={2}
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
fill="none"
|
||
/>
|
||
</Svg>
|
||
);
|
||
}
|
||
|
||
const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||
|
||
function unitLabel(value: Repeat): string {
|
||
switch (value) {
|
||
case 'daily':
|
||
return 'day';
|
||
case 'monthly':
|
||
return 'month';
|
||
default:
|
||
return 'week';
|
||
}
|
||
}
|
||
|
||
function isDaysBased(value: Repeat): boolean {
|
||
return value === 'weekly' || value === 'custom';
|
||
}
|
||
|
||
export function RepeatSelector({ value, interval, days, onChange }: RepeatSelectorProps) {
|
||
const { theme } = useSettings();
|
||
const profiles = useRepeatProfiles();
|
||
const [saveModalVisible, setSaveModalVisible] = React.useState(false);
|
||
const [profileName, setProfileName] = React.useState('');
|
||
|
||
const selectRepeat = (next: Repeat) => {
|
||
if (next === value) return;
|
||
const nextDays = next === 'custom' && days.length === 0 ? [new Date().getDay()] : days;
|
||
onChange(next, interval, nextDays);
|
||
};
|
||
|
||
const bumpInterval = (delta: number) => {
|
||
onChange(value, Math.min(30, Math.max(1, interval + delta)), days);
|
||
};
|
||
|
||
const toggleDay = (day: number) => {
|
||
const nextDays = days.includes(day) ? days.filter((d) => d !== day) : [...days, day].sort();
|
||
if (value === 'custom' && nextDays.length === 0) return;
|
||
onChange(value, interval, nextDays);
|
||
};
|
||
|
||
const applyProfile = (profile: { repeat: Repeat; repeatInterval: number; repeatDays: string }) => {
|
||
onChange(profile.repeat, profile.repeatInterval, repeatDaysFromString(profile.repeatDays));
|
||
};
|
||
|
||
const handleDeleteProfile = (id: string, name: string) => {
|
||
confirmAction({
|
||
title: 'Delete repeat profile',
|
||
message: `"${name}" will be removed. Existing tasks are not affected.`,
|
||
destructive: true,
|
||
onConfirm: () => {
|
||
deleteRepeatProfile(id).catch(() => {});
|
||
},
|
||
});
|
||
};
|
||
|
||
const handleSaveProfile = () => {
|
||
const name = profileName.trim();
|
||
if (!name || value === 'none') return;
|
||
createRepeatProfile(name, value, interval, days)
|
||
.then(() => {
|
||
setSaveModalVisible(false);
|
||
setProfileName('');
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
|
||
return (
|
||
<View style={styles.container}>
|
||
<Text style={[styles.label, { color: theme.text }]}>Repeat</Text>
|
||
|
||
{profiles.length > 0 && (
|
||
<View style={styles.profileRow}>
|
||
<Text style={[styles.profileLabel, { color: theme.textFaint }]}>Profiles</Text>
|
||
<View style={styles.profileChips}>
|
||
{profiles.map((profile) => (
|
||
<View
|
||
key={profile.id}
|
||
style={[
|
||
styles.profileChip,
|
||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||
profile.repeat === value &&
|
||
profile.repeatInterval === interval &&
|
||
profile.repeatDays === [...days].sort().join(',') && {
|
||
borderColor: theme.accent,
|
||
backgroundColor: theme.accentSoft,
|
||
borderWidth: 2,
|
||
},
|
||
]}
|
||
>
|
||
<TouchableOpacity onPress={() => applyProfile(profile)} activeOpacity={0.8} accessibilityRole="button" accessibilityLabel={`Apply repeat profile ${profile.name}`}>
|
||
<Text style={[styles.profileChipText, { color: theme.textSecondary }]}>{profile.name}</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
onPress={() => handleDeleteProfile(profile.id, profile.name)}
|
||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={`Delete repeat profile ${profile.name}`}
|
||
>
|
||
<Text style={[styles.profileChipX, { color: theme.textFaint }]}>×</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
))}
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
<View style={styles.chipRow} accessibilityRole="radiogroup" accessibilityLabel="Repeat">
|
||
{REPEAT_OPTIONS.map((option) => (
|
||
<TouchableOpacity
|
||
key={option.value}
|
||
style={[
|
||
styles.chip,
|
||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||
value === option.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
|
||
]}
|
||
onPress={() => selectRepeat(option.value)}
|
||
activeOpacity={0.8}
|
||
accessibilityRole="radio"
|
||
accessibilityLabel={`Repeat ${option.label.replace('No Repeat', 'none')}`}
|
||
accessibilityState={{ selected: value === option.value }}
|
||
>
|
||
<RepeatIcon repeat={option.value} color={value === option.value ? theme.accent : theme.textFaint} />
|
||
<Text
|
||
style={[
|
||
styles.chipText,
|
||
{ color: theme.textFaint },
|
||
value === option.value && { color: theme.accentStrong, fontWeight: '600' },
|
||
]}
|
||
>
|
||
{option.label.replace('No Repeat', 'None')}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
|
||
{value !== 'none' && (
|
||
<View style={styles.settings}>
|
||
<View style={[styles.intervalRow, { backgroundColor: theme.card, borderColor: theme.borderStrong }]}>
|
||
<Text style={[styles.intervalLabel, { color: theme.textSecondary }]}>Every</Text>
|
||
<TouchableOpacity
|
||
style={[styles.stepButton, { borderColor: theme.borderStrong }]}
|
||
onPress={() => bumpInterval(-1)}
|
||
disabled={interval <= 1}
|
||
activeOpacity={0.7}
|
||
accessibilityRole="button"
|
||
accessibilityLabel="Decrease repeat interval"
|
||
accessibilityState={{ disabled: interval <= 1 }}
|
||
>
|
||
<Text style={[styles.stepButtonText, { color: interval <= 1 ? theme.textMuted : theme.text }]}>−</Text>
|
||
</TouchableOpacity>
|
||
<Text style={[styles.intervalValue, { color: theme.text }]}>{interval}</Text>
|
||
<TouchableOpacity
|
||
style={[styles.stepButton, { borderColor: theme.borderStrong }]}
|
||
onPress={() => bumpInterval(1)}
|
||
disabled={interval >= 30}
|
||
activeOpacity={0.7}
|
||
accessibilityRole="button"
|
||
accessibilityLabel="Increase repeat interval"
|
||
accessibilityState={{ disabled: interval >= 30 }}
|
||
>
|
||
<Text style={[styles.stepButtonText, { color: interval >= 30 ? theme.textMuted : theme.text }]}>+</Text>
|
||
</TouchableOpacity>
|
||
<Text style={[styles.intervalLabel, { color: theme.textSecondary }]}>
|
||
{unitLabel(value)}
|
||
{interval > 1 ? 's' : ''}
|
||
</Text>
|
||
</View>
|
||
|
||
{isDaysBased(value) && (
|
||
<View style={styles.dayRow}>
|
||
{WEEKDAY_ORDER.map((day) => (
|
||
<TouchableOpacity
|
||
key={`${WEEKDAY_LABELS[day]}-${day}`}
|
||
style={[
|
||
styles.dayChip,
|
||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||
days.includes(day) && { backgroundColor: theme.accent, borderColor: theme.accent },
|
||
value === 'custom' && days.length === 1 && days.includes(day) && styles.dayChipLast,
|
||
]}
|
||
onPress={() => toggleDay(day)}
|
||
activeOpacity={0.8}
|
||
accessibilityRole="checkbox"
|
||
accessibilityLabel={WEEKDAY_NAMES[day]}
|
||
accessibilityState={{ checked: days.includes(day) }}
|
||
>
|
||
<Text
|
||
style={[
|
||
styles.dayChipText,
|
||
{ color: theme.textFaint },
|
||
days.includes(day) && { color: '#FFFFFF', fontWeight: '700' },
|
||
]}
|
||
>
|
||
{WEEKDAY_LABELS[day]}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
)}
|
||
</View>
|
||
)}
|
||
|
||
{value !== 'none' && (
|
||
<TouchableOpacity
|
||
style={[styles.saveProfileButton, { borderColor: theme.borderStrong }]}
|
||
onPress={() => setSaveModalVisible(true)}
|
||
activeOpacity={0.8}
|
||
>
|
||
<Svg width={13} height={13} viewBox="0 0 24 24">
|
||
<Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" />
|
||
</Svg>
|
||
<Text style={[styles.saveProfileText, { color: theme.accentStrong }]}>Save as profile</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
|
||
<Modal
|
||
visible={saveModalVisible}
|
||
transparent
|
||
animationType="fade"
|
||
onRequestClose={() => setSaveModalVisible(false)}
|
||
>
|
||
<KeyboardAvoidingView
|
||
style={styles.modalBackdrop}
|
||
behavior="padding"
|
||
>
|
||
<Pressable style={styles.modalBackdrop} onPress={() => setSaveModalVisible(false)}>
|
||
<Pressable style={[styles.modalCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||
<Text style={[styles.modalTitle, { color: theme.text }]}>Save repeat profile</Text>
|
||
<Text style={[styles.modalHint, { color: theme.textFaint }]}>
|
||
{`${REPEAT_OPTIONS.find((o) => o.value === value)?.label.replace('No Repeat', 'None')}, every ${interval} ${unitLabel(value)}${interval > 1 ? 's' : ''}`}
|
||
{isDaysBased(value) && days.length > 0 ? ` · ${weekdaysInDisplayOrder(days).map((d) => WEEKDAY_LABELS[d]).join(' ')}` : ''}
|
||
</Text>
|
||
<TextInput
|
||
style={[styles.modalInput, { backgroundColor: theme.cardAlt, borderColor: theme.borderStrong, color: theme.text }]}
|
||
value={profileName}
|
||
onChangeText={setProfileName}
|
||
placeholder="Profile name (e.g. Every weekday)"
|
||
placeholderTextColor={theme.textFaint}
|
||
autoFocus
|
||
returnKeyType="done"
|
||
onSubmitEditing={handleSaveProfile}
|
||
maxLength={50}
|
||
/>
|
||
<View style={styles.modalButtons}>
|
||
<TouchableOpacity
|
||
style={[styles.modalButton, { borderColor: theme.borderStrong }]}
|
||
onPress={() => setSaveModalVisible(false)}
|
||
activeOpacity={0.8}
|
||
>
|
||
<Text style={[styles.modalButtonText, { color: theme.textSecondary }]}>Cancel</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
style={[styles.modalButton, styles.modalButtonPrimary, { backgroundColor: theme.accent }]}
|
||
onPress={handleSaveProfile}
|
||
disabled={!profileName.trim()}
|
||
activeOpacity={0.8}
|
||
>
|
||
<Text style={[styles.modalButtonText, { color: theme.accentText, fontWeight: '600' }]}>Save</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</Pressable>
|
||
</Pressable>
|
||
</KeyboardAvoidingView>
|
||
</Modal>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
gap: 8,
|
||
},
|
||
label: {
|
||
fontSize: 13,
|
||
fontWeight: '600',
|
||
},
|
||
chipRow: {
|
||
flexDirection: 'row',
|
||
flexWrap: 'wrap',
|
||
gap: 6,
|
||
},
|
||
chip: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 5,
|
||
paddingVertical: 8,
|
||
paddingHorizontal: 10,
|
||
borderRadius: 18,
|
||
borderWidth: 1,
|
||
},
|
||
chipText: {
|
||
fontSize: 12,
|
||
fontWeight: '500',
|
||
},
|
||
settings: {
|
||
gap: 8,
|
||
},
|
||
intervalRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 8,
|
||
borderRadius: 10,
|
||
borderWidth: 1,
|
||
},
|
||
intervalLabel: {
|
||
fontSize: 13,
|
||
fontWeight: '500',
|
||
},
|
||
intervalValue: {
|
||
fontSize: 15,
|
||
fontWeight: '700',
|
||
minWidth: 22,
|
||
textAlign: 'center',
|
||
},
|
||
stepButton: {
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: 7,
|
||
borderWidth: 1,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
stepButtonText: {
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
lineHeight: 18,
|
||
},
|
||
dayRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
gap: 5,
|
||
},
|
||
dayChip: {
|
||
flex: 1,
|
||
height: 36,
|
||
borderRadius: 9,
|
||
borderWidth: 1,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
dayChipLast: {},
|
||
dayChipText: {
|
||
fontSize: 12,
|
||
fontWeight: '600',
|
||
},
|
||
profileRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'flex-start',
|
||
gap: 6,
|
||
},
|
||
profileLabel: {
|
||
fontSize: 11,
|
||
fontWeight: '600',
|
||
paddingTop: 6,
|
||
},
|
||
profileChips: {
|
||
flex: 1,
|
||
flexDirection: 'row',
|
||
flexWrap: 'wrap',
|
||
gap: 6,
|
||
},
|
||
profileChip: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 5,
|
||
paddingVertical: 5,
|
||
paddingHorizontal: 8,
|
||
borderRadius: 10,
|
||
borderWidth: 1,
|
||
},
|
||
profileChipText: {
|
||
fontSize: 12,
|
||
fontWeight: '500',
|
||
},
|
||
profileChipX: {
|
||
fontSize: 13,
|
||
lineHeight: 15,
|
||
fontWeight: '700',
|
||
paddingHorizontal: 2,
|
||
},
|
||
saveProfileButton: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 5,
|
||
paddingVertical: 8,
|
||
borderRadius: 10,
|
||
borderWidth: 1,
|
||
borderStyle: 'dashed',
|
||
},
|
||
saveProfileText: {
|
||
fontSize: 12,
|
||
fontWeight: '600',
|
||
},
|
||
modalBackdrop: {
|
||
flex: 1,
|
||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
padding: 20,
|
||
},
|
||
modalCard: {
|
||
width: '100%',
|
||
maxWidth: 360,
|
||
borderRadius: 14,
|
||
borderWidth: 1,
|
||
padding: 16,
|
||
gap: 10,
|
||
},
|
||
modalTitle: {
|
||
fontSize: 15,
|
||
fontWeight: '700',
|
||
},
|
||
modalHint: {
|
||
fontSize: 12,
|
||
},
|
||
modalInput: {
|
||
borderRadius: 9,
|
||
borderWidth: 1,
|
||
paddingHorizontal: 10,
|
||
paddingVertical: 8,
|
||
fontSize: 13,
|
||
},
|
||
modalButtons: {
|
||
flexDirection: 'row',
|
||
gap: 8,
|
||
},
|
||
modalButton: {
|
||
flex: 1,
|
||
alignItems: 'center',
|
||
paddingVertical: 10,
|
||
borderRadius: 9,
|
||
borderWidth: 1,
|
||
},
|
||
modalButtonPrimary: {
|
||
borderWidth: 0,
|
||
},
|
||
modalButtonText: {
|
||
fontSize: 13,
|
||
},
|
||
});
|