pottery-diary/src/screens/ProjectsScreen.tsx

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,
},
});