491 lines
14 KiB
TypeScript
491 lines
14 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
FlatList,
|
|
StyleSheet,
|
|
TouchableOpacity,
|
|
Image,
|
|
RefreshControl,
|
|
TextInput,
|
|
} from 'react-native';
|
|
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
import { RootStackParamList } from '../navigation/types';
|
|
import { Project } from '../types';
|
|
import { getAllProjects } from '../lib/db/repositories';
|
|
import { Button, Card } from '../components';
|
|
import { colors, spacing, typography, borderRadius } from '../lib/theme';
|
|
import { formatDate } from '../lib/utils/datetime';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
|
|
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
|
|
|
type SortOption = 'newest' | 'oldest' | 'name';
|
|
|
|
export const ProjectsScreen: React.FC = () => {
|
|
const navigation = useNavigation<NavigationProp>();
|
|
const { user } = useAuth();
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
const [filteredProjects, setFilteredProjects] = useState<Project[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [sortBy, setSortBy] = useState<SortOption>('newest');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
const sortProjects = (projectsToSort: Project[]) => {
|
|
const sorted = [...projectsToSort];
|
|
switch (sortBy) {
|
|
case 'newest':
|
|
return sorted.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
case 'oldest':
|
|
return sorted.sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime());
|
|
case 'name':
|
|
return sorted.sort((a, b) => a.title.localeCompare(b.title));
|
|
default:
|
|
return sorted;
|
|
}
|
|
};
|
|
|
|
const filterProjects = (projectsToFilter: Project[], query: string) => {
|
|
if (!query.trim()) return projectsToFilter;
|
|
|
|
const lowerQuery = query.toLowerCase();
|
|
return projectsToFilter.filter(project =>
|
|
project.title.toLowerCase().includes(lowerQuery) ||
|
|
project.tags.some(tag => tag.toLowerCase().includes(lowerQuery))
|
|
);
|
|
};
|
|
|
|
const loadProjects = async () => {
|
|
if (!user) return;
|
|
|
|
try {
|
|
const allProjects = await getAllProjects(user.id);
|
|
const sorted = sortProjects(allProjects);
|
|
setProjects(sorted);
|
|
setFilteredProjects(filterProjects(sorted, searchQuery));
|
|
} catch (error) {
|
|
console.error('Failed to load projects:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
};
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
loadProjects();
|
|
}, [])
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (projects.length > 0) {
|
|
const sorted = sortProjects(projects);
|
|
setProjects(sorted);
|
|
setFilteredProjects(filterProjects(sorted, searchQuery));
|
|
}
|
|
}, [sortBy]);
|
|
|
|
useEffect(() => {
|
|
setFilteredProjects(filterProjects(projects, searchQuery));
|
|
}, [searchQuery]);
|
|
|
|
const handleRefresh = () => {
|
|
setRefreshing(true);
|
|
loadProjects();
|
|
};
|
|
|
|
const handleCreateProject = () => {
|
|
navigation.navigate('ProjectDetail', { projectId: 'new' });
|
|
};
|
|
|
|
const handleProjectPress = (projectId: string) => {
|
|
navigation.navigate('ProjectDetail', { projectId });
|
|
};
|
|
|
|
const getStepCount = (projectId: string) => {
|
|
// This will be calculated from actual steps
|
|
return 0; // Placeholder
|
|
};
|
|
|
|
const renderProject = ({ item }: { item: Project }) => {
|
|
const stepCount = getStepCount(item.id);
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
onPress={() => handleProjectPress(item.id)}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={`Open project ${item.title}`}
|
|
>
|
|
<Card style={styles.projectCard}>
|
|
{item.coverImageUri && (
|
|
<View style={styles.imageContainer}>
|
|
<Image
|
|
source={{ uri: item.coverImageUri }}
|
|
style={styles.coverImage}
|
|
accessibilityLabel={`Cover image for ${item.title}`}
|
|
/>
|
|
{stepCount > 0 && (
|
|
<View style={styles.stepCountBadge}>
|
|
<Text style={styles.stepCountText}>{stepCount} steps</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
<View style={styles.projectInfo}>
|
|
<View style={styles.titleRow}>
|
|
<Text style={styles.projectTitle}>{item.title}</Text>
|
|
</View>
|
|
<View style={styles.projectMeta}>
|
|
<View style={[
|
|
styles.statusBadge,
|
|
item.status === 'in_progress' && styles.statusBadgeInProgress,
|
|
item.status === 'done' && styles.statusBadgeDone,
|
|
]}>
|
|
<Text style={styles.statusText}>
|
|
{item.status === 'in_progress' ? 'In Progress' : 'Done'}
|
|
</Text>
|
|
</View>
|
|
<Text style={styles.date}>{formatDate(item.updatedAt)}</Text>
|
|
</View>
|
|
{item.tags.length > 0 && (
|
|
<View style={styles.tags}>
|
|
{item.tags.map((tag, index) => (
|
|
<View key={index} style={styles.tag}>
|
|
<Text style={styles.tagText}>{tag}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
)}
|
|
</View>
|
|
</Card>
|
|
</TouchableOpacity>
|
|
);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<View style={styles.centerContainer}>
|
|
<Text>Loading projects...</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
{filteredProjects.length === 0 && searchQuery.length > 0 ? (
|
|
<View style={styles.emptyContainer}>
|
|
<Text style={styles.emptyIcon}>🔍</Text>
|
|
<Text style={styles.emptyTitle}>No matches found</Text>
|
|
<Text style={styles.emptyText}>Try a different search term</Text>
|
|
</View>
|
|
) : projects.length === 0 ? (
|
|
<View style={styles.emptyContainer}>
|
|
<Text style={styles.emptyIcon}>🏺</Text>
|
|
<Text style={styles.emptyTitle}>No projects yet</Text>
|
|
<Text style={styles.emptyText}>Start your first piece!</Text>
|
|
</View>
|
|
) : (
|
|
<FlatList
|
|
data={filteredProjects}
|
|
renderItem={renderProject}
|
|
keyExtractor={(item) => item.id}
|
|
contentContainerStyle={styles.listContent}
|
|
refreshControl={
|
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
|
}
|
|
ListHeaderComponent={
|
|
<View>
|
|
{/* MY PROJECTS Title */}
|
|
<View style={styles.header}>
|
|
<Text style={styles.headerTitle}>My Projects</Text>
|
|
</View>
|
|
|
|
{/* Create Project Button */}
|
|
<View style={styles.buttonContainer}>
|
|
<Button
|
|
title="New Project"
|
|
onPress={handleCreateProject}
|
|
size="sm"
|
|
accessibilityLabel="Create new project"
|
|
/>
|
|
</View>
|
|
|
|
{/* Search Bar */}
|
|
<View style={styles.searchContainer}>
|
|
<TextInput
|
|
style={styles.searchInput}
|
|
placeholder="Search projects or tags..."
|
|
value={searchQuery}
|
|
onChangeText={setSearchQuery}
|
|
placeholderTextColor={colors.textSecondary}
|
|
/>
|
|
{searchQuery.length > 0 && (
|
|
<TouchableOpacity onPress={() => setSearchQuery('')} style={styles.clearButton}>
|
|
<Text style={styles.clearButtonText}>✕</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
|
|
{/* Sort Buttons */}
|
|
<View style={styles.sortContainer}>
|
|
<Text style={styles.sortLabel}>Sort:</Text>
|
|
<TouchableOpacity
|
|
style={[styles.sortButton, sortBy === 'newest' && styles.sortButtonActive]}
|
|
onPress={() => setSortBy('newest')}
|
|
>
|
|
<Text style={[styles.sortButtonText, sortBy === 'newest' && styles.sortButtonTextActive]}>
|
|
Newest
|
|
</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.sortButton, sortBy === 'oldest' && styles.sortButtonActive]}
|
|
onPress={() => setSortBy('oldest')}
|
|
>
|
|
<Text style={[styles.sortButtonText, sortBy === 'oldest' && styles.sortButtonTextActive]}>
|
|
Oldest
|
|
</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.sortButton, sortBy === 'name' && styles.sortButtonActive]}
|
|
onPress={() => setSortBy('name')}
|
|
>
|
|
<Text style={[styles.sortButtonText, sortBy === 'name' && styles.sortButtonTextActive]}>
|
|
Name
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
}
|
|
/>
|
|
)}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: colors.backgroundSecondary,
|
|
},
|
|
centerContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
buttonContainer: {
|
|
paddingHorizontal: spacing.lg,
|
|
paddingTop: spacing.lg,
|
|
paddingBottom: spacing.md,
|
|
backgroundColor: colors.background,
|
|
},
|
|
header: {
|
|
paddingTop: 64,
|
|
paddingBottom: spacing.lg,
|
|
backgroundColor: colors.background,
|
|
borderBottomWidth: 3,
|
|
borderBottomColor: colors.primary,
|
|
},
|
|
headerTitle: {
|
|
fontSize: typography.fontSize.xl,
|
|
fontWeight: typography.fontWeight.bold,
|
|
color: colors.primary,
|
|
letterSpacing: 0.5,
|
|
textTransform: 'uppercase',
|
|
paddingHorizontal: spacing.lg,
|
|
},
|
|
searchContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: spacing.lg,
|
|
paddingBottom: spacing.md,
|
|
},
|
|
searchInput: {
|
|
flex: 1,
|
|
backgroundColor: colors.backgroundSecondary,
|
|
borderRadius: borderRadius.md,
|
|
borderWidth: 2,
|
|
borderColor: colors.border,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.md,
|
|
fontSize: typography.fontSize.md,
|
|
color: colors.text,
|
|
fontWeight: typography.fontWeight.medium,
|
|
},
|
|
clearButton: {
|
|
position: 'absolute',
|
|
right: spacing.lg + spacing.md,
|
|
paddingHorizontal: spacing.sm,
|
|
},
|
|
clearButtonText: {
|
|
fontSize: typography.fontSize.lg,
|
|
color: colors.textSecondary,
|
|
fontWeight: typography.fontWeight.bold,
|
|
},
|
|
sortContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: spacing.lg,
|
|
paddingBottom: spacing.md,
|
|
gap: spacing.sm,
|
|
},
|
|
sortLabel: {
|
|
fontSize: typography.fontSize.sm,
|
|
fontWeight: typography.fontWeight.bold,
|
|
color: colors.textSecondary,
|
|
letterSpacing: 0.5,
|
|
textTransform: 'uppercase',
|
|
},
|
|
sortButton: {
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.sm,
|
|
borderRadius: borderRadius.full,
|
|
borderWidth: 2,
|
|
borderColor: colors.border,
|
|
backgroundColor: colors.backgroundSecondary,
|
|
},
|
|
sortButtonActive: {
|
|
borderColor: colors.primary,
|
|
backgroundColor: colors.primaryLight,
|
|
},
|
|
sortButtonText: {
|
|
fontSize: typography.fontSize.xs,
|
|
fontWeight: typography.fontWeight.bold,
|
|
color: colors.textSecondary,
|
|
letterSpacing: 0.3,
|
|
},
|
|
sortButtonTextActive: {
|
|
color: colors.text,
|
|
},
|
|
listContent: {
|
|
paddingBottom: 100, // Extra space for floating tab bar
|
|
},
|
|
projectCard: {
|
|
marginHorizontal: spacing.md,
|
|
marginBottom: spacing.md,
|
|
},
|
|
imageContainer: {
|
|
position: 'relative',
|
|
marginBottom: spacing.md,
|
|
},
|
|
coverImage: {
|
|
width: '100%',
|
|
height: 200,
|
|
borderRadius: borderRadius.md,
|
|
backgroundColor: colors.backgroundSecondary,
|
|
borderWidth: 2,
|
|
borderColor: colors.border,
|
|
},
|
|
stepCountBadge: {
|
|
position: 'absolute',
|
|
top: spacing.md,
|
|
right: spacing.md,
|
|
backgroundColor: colors.primary,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.sm,
|
|
borderRadius: borderRadius.full,
|
|
borderWidth: 2,
|
|
borderColor: colors.primaryDark,
|
|
},
|
|
stepCountText: {
|
|
fontSize: typography.fontSize.xs,
|
|
fontWeight: typography.fontWeight.bold,
|
|
color: colors.background,
|
|
letterSpacing: 0.5,
|
|
textTransform: 'uppercase',
|
|
},
|
|
projectInfo: {
|
|
gap: spacing.sm,
|
|
},
|
|
titleRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
},
|
|
projectTitle: {
|
|
fontSize: typography.fontSize.lg,
|
|
fontWeight: typography.fontWeight.bold,
|
|
color: colors.text,
|
|
letterSpacing: 0.3,
|
|
flex: 1,
|
|
},
|
|
projectMeta: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
},
|
|
statusBadge: {
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.xs,
|
|
borderRadius: borderRadius.full,
|
|
borderWidth: 2,
|
|
},
|
|
statusBadgeInProgress: {
|
|
backgroundColor: '#FFF4ED',
|
|
borderColor: '#E8A87C',
|
|
},
|
|
statusBadgeDone: {
|
|
backgroundColor: '#E8F9F8',
|
|
borderColor: '#85CDCA',
|
|
},
|
|
statusBadgeArchived: {
|
|
backgroundColor: '#F9EFF3',
|
|
borderColor: '#C38D9E',
|
|
},
|
|
statusText: {
|
|
fontSize: typography.fontSize.xs,
|
|
fontWeight: typography.fontWeight.bold,
|
|
color: colors.text,
|
|
letterSpacing: 0.5,
|
|
textTransform: 'uppercase',
|
|
},
|
|
date: {
|
|
fontSize: typography.fontSize.sm,
|
|
color: colors.textSecondary,
|
|
},
|
|
tags: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
gap: spacing.xs,
|
|
},
|
|
tag: {
|
|
backgroundColor: colors.primaryLight,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.sm,
|
|
borderRadius: borderRadius.full,
|
|
borderWidth: 2,
|
|
borderColor: colors.primaryDark,
|
|
},
|
|
tagText: {
|
|
fontSize: typography.fontSize.xs,
|
|
color: colors.background,
|
|
fontWeight: typography.fontWeight.bold,
|
|
letterSpacing: 0.5,
|
|
},
|
|
emptyContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
padding: spacing.xl,
|
|
},
|
|
emptyIcon: {
|
|
fontSize: 64,
|
|
marginBottom: spacing.md,
|
|
},
|
|
emptyTitle: {
|
|
fontSize: typography.fontSize.xl,
|
|
fontWeight: typography.fontWeight.semiBold,
|
|
color: colors.text,
|
|
marginBottom: spacing.sm,
|
|
},
|
|
emptyText: {
|
|
fontSize: typography.fontSize.md,
|
|
color: colors.textSecondary,
|
|
marginBottom: spacing.lg,
|
|
},
|
|
emptyButton: {
|
|
marginTop: spacing.md,
|
|
},
|
|
});
|