diff options
Diffstat (limited to 'apps/mobile/app/(tabs)/tasks.tsx')
| -rw-r--r-- | apps/mobile/app/(tabs)/tasks.tsx | 231 |
1 files changed, 231 insertions, 0 deletions
diff --git a/apps/mobile/app/(tabs)/tasks.tsx b/apps/mobile/app/(tabs)/tasks.tsx new file mode 100644 index 0000000..5aac710 --- /dev/null +++ b/apps/mobile/app/(tabs)/tasks.tsx @@ -0,0 +1,231 @@ +import React, { useState, useCallback, useMemo } from 'react'; +import { + View, + Text, + StyleSheet, + SectionList, + RefreshControl, + useColorScheme, + TextInput, + TouchableOpacity, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/Colors'; +import { useTasks, groupTasksByStatus } from '../../hooks/useTasks'; +import { TaskListItem } from '../../components/TaskListItem'; +import { TaskListSkeleton } from '../../components/TaskListSkeleton'; +import { EmptyState } from '../../components/EmptyState'; +import type { TaskSummary } from '../../lib/api'; + +interface Section { + title: string; + data: TaskSummary[]; +} + +export default function TasksScreen() { + const colorScheme = useColorScheme() ?? 'light'; + const colors = Colors[colorScheme]; + const router = useRouter(); + const [searchQuery, setSearchQuery] = useState(''); + + const { data: tasks, isLoading, isError, refetch, isRefetching } = useTasks(); + + // Group and filter tasks + const sections = useMemo(() => { + if (!tasks) return []; + + // Filter by search query + const filteredTasks = searchQuery + ? tasks.filter( + (task) => + task.name.toLowerCase().includes(searchQuery.toLowerCase()) || + task.contractName?.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : tasks; + + // Group by status + const groups = groupTasksByStatus(filteredTasks); + + const result: Section[] = []; + + if (groups.running.length > 0) { + result.push({ title: 'Running', data: groups.running }); + } + if (groups.blocked.length > 0) { + result.push({ title: 'Needs Attention', data: groups.blocked }); + } + if (groups.pending.length > 0) { + result.push({ title: 'Pending', data: groups.pending }); + } + if (groups.completed.length > 0) { + result.push({ title: 'Completed', data: groups.completed }); + } + + return result; + }, [tasks, searchQuery]); + + const handleTaskPress = useCallback( + (task: TaskSummary) => { + router.push(`/task/${task.id}`); + }, + [router] + ); + + const handleRefresh = useCallback(() => { + refetch(); + }, [refetch]); + + const clearSearch = useCallback(() => { + setSearchQuery(''); + }, []); + + // Render loading state + if (isLoading) { + return ( + <View style={[styles.container, { backgroundColor: colors.background }]}> + <TaskListSkeleton /> + </View> + ); + } + + // Render error state + if (isError) { + return ( + <View style={[styles.container, { backgroundColor: colors.background }]}> + <EmptyState + icon="alert-circle-outline" + title="Failed to load tasks" + message="Pull down to retry" + /> + </View> + ); + } + + // Render empty state + if (!tasks || tasks.length === 0) { + return ( + <View style={[styles.container, { backgroundColor: colors.background }]}> + <EmptyState + icon="cube-outline" + title="No tasks yet" + message="Tasks created from contracts will appear here" + /> + </View> + ); + } + + return ( + <View style={[styles.container, { backgroundColor: colors.background }]}> + {/* Search bar */} + <View + style={[ + styles.searchContainer, + { + backgroundColor: colors.secondaryBackground, + borderColor: colors.border, + }, + ]} + > + <Ionicons name="search" size={18} color={colors.secondaryText} /> + <TextInput + style={[styles.searchInput, { color: colors.text }]} + placeholder="Search tasks..." + placeholderTextColor={colors.secondaryText} + value={searchQuery} + onChangeText={setSearchQuery} + autoCapitalize="none" + autoCorrect={false} + /> + {searchQuery.length > 0 && ( + <TouchableOpacity onPress={clearSearch}> + <Ionicons name="close-circle" size={18} color={colors.secondaryText} /> + </TouchableOpacity> + )} + </View> + + {/* Task list */} + <SectionList + sections={sections} + keyExtractor={(item) => item.id} + renderItem={({ item }) => ( + <TaskListItem task={item} onPress={handleTaskPress} /> + )} + renderSectionHeader={({ section }) => ( + <View + style={[ + styles.sectionHeader, + { backgroundColor: colors.secondaryBackground }, + ]} + > + <Text style={[styles.sectionTitle, { color: colors.text }]}> + {section.title} + </Text> + <Text style={[styles.sectionCount, { color: colors.secondaryText }]}> + {section.data.length} + </Text> + </View> + )} + refreshControl={ + <RefreshControl + refreshing={isRefetching} + onRefresh={handleRefresh} + tintColor={colors.tint} + /> + } + ListEmptyComponent={ + searchQuery ? ( + <EmptyState + icon="search-outline" + title="No matching tasks" + message={`No tasks match "${searchQuery}"`} + /> + ) : null + } + stickySectionHeadersEnabled + contentContainerStyle={sections.length === 0 ? styles.emptyContent : undefined} + /> + </View> + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + searchContainer: { + flexDirection: 'row', + alignItems: 'center', + margin: 16, + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 10, + borderWidth: StyleSheet.hairlineWidth, + gap: 8, + }, + searchInput: { + flex: 1, + fontSize: 16, + padding: 0, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 8, + }, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + sectionCount: { + fontSize: 14, + fontWeight: '500', + }, + emptyContent: { + flex: 1, + }, +}); |
