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

351 lines
11 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, TouchableOpacity, StyleSheet, Modal, FlatList, ActivityIndicator, TextInput, KeyboardAvoidingView } from 'react-native';
import { useSettings } from '@/theme';
import { useFriends } from '@/hooks/useFriends';
interface AssigneeSelectorProps {
value: string | null;
onChange: (assigneeId: string | null) => void;
disabled?: boolean;
friends: string[];
}
export function AssigneeSelector({ value, onChange, disabled = false }: AssigneeSelectorProps) {
const { theme } = useSettings();
const { friends, searchUsers, loading: friendsLoading } = useFriends();
const [showModal, setShowModal] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState('');
const [searchResults, setSearchResults] = React.useState<{ id: string; username: string }[]>([]);
const [searching, setSearching] = React.useState(false);
const selectedFriend = value ? friends.find(f => f.id === value) : null;
const searchKey = `${showModal}:${searchQuery}`;
const [prevSearchKey, setPrevSearchKey] = React.useState(searchKey);
if (prevSearchKey !== searchKey && (!showModal || searchQuery.length < 2)) {
setPrevSearchKey(searchKey);
setSearchResults([]);
}
React.useEffect(() => {
if (showModal && searchQuery.length >= 2) {
const timeout = setTimeout(async () => {
setSearching(true);
try {
const results = await searchUsers(searchQuery);
setSearchResults(results.filter(r => !friends.some(f => f.id === r.id)));
} catch (err) {
console.error('Search failed:', err);
} finally {
setSearching(false);
}
}, 300);
return () => clearTimeout(timeout);
}
}, [searchQuery, showModal, friends, searchUsers]);
const handleSelectFriend = (friendId: string) => {
onChange(friendId);
setShowModal(false);
};
const handleClear = () => {
onChange(null);
};
const handleSearchAdd = async (friendId: string, username: string) => {
try {
const { sendFriendRequest } = await import('@/services/friends');
await sendFriendRequest(username);
onChange(friendId);
setShowModal(false);
} catch (err: any) {
console.error('Failed to add friend:', err.message, err.details || '');
if (err.details) {
console.error('Validation details:', JSON.stringify(err.details, null, 2));
}
}
};
return (
<View style={styles.container}>
<TouchableOpacity
style={[
styles.selectorButton,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
disabled && { opacity: 0.6 },
]}
onPress={() => !disabled && setShowModal(true)}
activeOpacity={0.8}
>
<Text style={[styles.selectorIcon, { color: theme.accentStrong }]}>👤</Text>
<View style={styles.selectorContent}>
<Text style={[styles.selectorLabel, { color: theme.textMuted }]}>Assignee</Text>
<Text
style={[
styles.selectorValue,
{ color: value ? theme.text : theme.textMuted },
]}
>
{selectedFriend ? selectedFriend.username : 'Unassigned'}
</Text>
</View>
{value && !disabled && (
<TouchableOpacity
style={styles.clearButton}
onPress={(e) => {
e.stopPropagation();
handleClear();
}}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<Text style={[styles.clearText, { color: theme.textMuted }]}>✕</Text>
</TouchableOpacity>
)}
</TouchableOpacity>
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
<KeyboardAvoidingView
style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
behavior="padding"
>
<View style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: theme.text }]}>Assign Task</Text>
<TouchableOpacity onPress={() => setShowModal(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Text style={[styles.closeText, { color: theme.textMuted }]}>✕</Text>
</TouchableOpacity>
</View>
<View style={styles.modalSection}>
<TouchableOpacity
style={[
styles.optionRow,
{ backgroundColor: selectedFriend ? theme.accentSoft : 'transparent', borderColor: selectedFriend ? theme.accent : 'transparent' },
]}
onPress={() => handleClear()}
>
<View style={styles.optionIcon}>
<Text style={styles.optionIconText}>🚫</Text>
</View>
<Text style={[styles.optionText, { color: theme.text }]}>Unassigned</Text>
{selectedFriend && (
<Text style={[styles.checkmark, { color: theme.accentStrong }]}>✓</Text>
)}
</TouchableOpacity>
</View>
<View style={styles.modalSection}>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Friends</Text>
{friendsLoading ? (
<View style={styles.loading}>
<ActivityIndicator color={theme.accent} size="large" />
</View>
) : friends.length === 0 ? (
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No friends yet. Add friends to assign tasks.</Text>
) : (
<FlatList
data={friends}
keyExtractor={f => f.id}
renderItem={({ item }) => (
<TouchableOpacity
style={[
styles.optionRow,
{ backgroundColor: value === item.id ? theme.accentSoft : 'transparent', borderColor: value === item.id ? theme.accent : 'transparent' },
]}
onPress={() => handleSelectFriend(item.id)}
>
<View style={styles.optionIcon}>
<Text style={styles.optionIconText}>👤</Text>
</View>
<Text style={[styles.optionText, { color: theme.text }]}>{item.username}</Text>
{value === item.id && (
<Text style={[styles.checkmark, { color: theme.accentStrong }]}>✓</Text>
)}
</TouchableOpacity>
)}
/>
)}
</View>
<View style={styles.modalSection}>
<View style={styles.searchContainer}>
<TextInput
style={[styles.searchInput, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
placeholder="Search users by username"
placeholderTextColor={theme.textMuted}
value={searchQuery}
onChangeText={setSearchQuery}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
{searching ? (
<View style={styles.loading}>
<ActivityIndicator color={theme.accent} size="large" />
</View>
) : searchQuery.length >= 2 && searchResults.length > 0 ? (
<FlatList
data={searchResults}
keyExtractor={u => u.id}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.optionRow}
onPress={() => handleSearchAdd(item.id, item.username)}
>
<View style={styles.optionIcon}>
<Text style={styles.optionIconText}>➕</Text>
</View>
<Text style={[styles.optionText, { color: theme.text }]}>{item.username}</Text>
<Text style={[styles.optionSubtext, { color: theme.textMuted }]}>Add as friend & assign</Text>
</TouchableOpacity>
)}
/>
) : searchQuery.length >= 2 && searchResults.length === 0 && !searching ? (
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No users found</Text>
) : searchQuery.length < 2 ? (
<Text style={[styles.emptyText, { color: theme.textFaint }]}>Type at least 2 characters to search</Text>
) : null}
</View>
</View>
</KeyboardAvoidingView>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
width: '100%',
},
selectorButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingVertical: 12,
paddingHorizontal: 10,
borderRadius: 10,
borderWidth: 1,
minHeight: 48,
},
selectorIcon: {
fontSize: 18,
},
selectorContent: {
flex: 1,
justifyContent: 'center',
},
selectorLabel: {
fontSize: 10,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 1,
},
selectorValue: {
fontSize: 14,
fontWeight: '500',
},
clearButton: {
padding: 3,
},
clearText: {
fontSize: 16,
fontWeight: '300',
},
modalOverlay: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
modalSheet: {
width: '100%',
maxWidth: 360,
borderRadius: 16,
overflow: 'hidden',
maxHeight: '85%',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
borderBottomWidth: 1,
},
modalTitle: {
fontSize: 16,
fontWeight: '700',
},
closeText: {
fontSize: 20,
fontWeight: '300',
},
modalSection: {
padding: 10,
paddingBottom: 16,
borderBottomWidth: 1,
},
sectionTitle: {
fontSize: 11,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 6,
paddingHorizontal: 6,
},
optionRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 9,
borderWidth: 1,
},
optionIcon: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.05)',
},
optionIconText: {
fontSize: 15,
},
optionText: {
fontSize: 15,
fontWeight: '500',
flex: 1,
},
optionSubtext: {
fontSize: 11,
marginTop: 1,
},
checkmark: {
fontSize: 16,
fontWeight: '700',
},
loading: {
padding: 16,
alignItems: 'center',
},
emptyText: {
fontSize: 13,
textAlign: 'center',
paddingHorizontal: 16,
},
searchContainer: {
padding: 10,
paddingBottom: 6,
},
searchInput: {
fontSize: 15,
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 10,
borderWidth: 1,
},
});