Files
carry-your-live/carry-your-live/src/components/RepeatSelector.tsx
T
2026-08-09 21:42:54 +02:00

487 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
},
});