import { useCallback, useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, View, } from 'react-native'; import { Image } from 'expo-image'; import { ResizeMode, Video } from 'expo-av'; import { Link, useLocalSearchParams, useRouter } from 'expo-router'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { ThemedText } from '@/components/themed-text'; import { ThemedView } from '@/components/themed-view'; import { Colors, Fonts } from '@/constants/theme'; import { ThemedButton } from '@/components/themed-button'; import { useTranslation } from '@/localization/i18n'; import { dbPromise, initCoreTables } from '@/services/db'; import { useColorScheme } from '@/hooks/use-color-scheme'; import { ZoomImageModal } from '@/components/zoom-image-modal'; import { IconSymbol } from '@/components/ui/icon-symbol'; type TaskRow = { id: number; name: string; description: string | null; entry_id: number | null; status: string | null; notes: string | null; meta_json: string | null; media_uri?: string | null; }; export default function DailyTasksScreen() { const { t } = useTranslation(); const router = useRouter(); const navigation = useNavigation(); const params = useLocalSearchParams<{ from?: string | string[] }>(); const theme = useColorScheme() ?? 'light'; const palette = Colors[theme]; const fromParam = Array.isArray(params.from) ? params.from[0] : params.from; const defaultTasks = [ { name: t('tasks.default.fieldCheck'), description: t('tasks.default.fieldCheckDesc'), default_time: '08:00', }, { name: t('tasks.default.scouting'), description: t('tasks.default.scoutingDesc'), default_time: '10:00', }, { name: t('tasks.default.sensors'), description: t('tasks.default.sensorsDesc'), default_time: '15:00', }, ]; const pageSize = 10; const [tasks, setTasks] = useState([]); const [status, setStatus] = useState(t('tasks.loading')); useEffect(() => { navigation.setOptions({ headerLeft: () => ( { if (fromParam === 'logbook') { router.replace('/logbook'); return; } if (fromParam === 'home') { router.replace('/'); return; } router.back(); }} hitSlop={10} style={{ paddingHorizontal: 8 }}> ), }); }, [fromParam, navigation, palette.text, router]); const [photoByTaskId, setPhotoByTaskId] = useState>({}); const [zoomUri, setZoomUri] = useState(null); const [pendingZoomUri, setPendingZoomUri] = useState(null); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const [loadingMore, setLoadingMore] = useState(false); useEffect(() => { if (pendingZoomUri) { const uri = pendingZoomUri; setPendingZoomUri(null); setTimeout(() => setZoomUri(uri), 150); } }, [pendingZoomUri]); const todayKey = useMemo(() => new Date().toISOString().slice(0, 10), []); useEffect(() => { let isActive = true; async function loadTasks() { try { await initCoreTables(); const db = await dbPromise; await ensureDefaultTasks(db, defaultTasks); await fetchTasksPage(1, true, isActive); } catch (error) { if (isActive) setStatus(`Error: ${String(error)}`); } } loadTasks(); return () => { isActive = false; }; }, [t, todayKey]); useFocusEffect( useCallback(() => { fetchTasksPage(1, true); }, []) ); async function fetchTasksPage(pageToLoad: number, replace: boolean, isActive = true) { try { const db = await dbPromise; const rows = await db.getAllAsync( `SELECT t.id, t.name, t.description, e.id as entry_id, e.status, e.notes, e.meta_json, (SELECT uri FROM task_entry_media m WHERE m.entry_id = e.id ORDER BY created_at DESC LIMIT 1) as media_uri FROM daily_tasks t LEFT JOIN daily_task_entries e ON e.task_id = t.id AND substr(e.completed_at, 1, 10) = ? WHERE t.is_active = 1 ORDER BY t.id ASC LIMIT ? OFFSET ?;`, todayKey, pageSize, (pageToLoad - 1) * pageSize ); if (!isActive) return; setTasks((prev) => (replace ? rows : [...prev, ...rows])); setHasMore(rows.length === pageSize); setPage(pageToLoad); if (replace) { setStatus(rows.length === 0 ? t('tasks.empty') : ''); } setPhotoByTaskId((prev) => { const next = replace ? {} : { ...prev }; rows.forEach((row) => { const meta = parseTaskMeta(row.meta_json); const uri = normalizeMediaUri(row.media_uri) ?? normalizeMediaUri(meta.photoUri); if (uri) next[row.id] = uri; }); return next; }); } catch (error) { if (isActive) setStatus(`Error: ${String(error)}`); } finally { if (isActive) setLoadingMore(false); } } async function handleLoadMore() { if (loadingMore || !hasMore) return; setLoadingMore(true); const nextPage = page + 1; await fetchTasksPage(nextPage, false); } return ( String(item.id)} keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" contentContainerStyle={styles.listContent} renderItem={({ item }) => { const isDone = (item.status ?? '').toLowerCase() === 'done'; return ( router.push(`/tasks/${item.id}`)}> {item.name} {isDone ? t('tasks.done') : t('tasks.pending')} {item.description ? ( {item.description} ) : null} {item.notes ? ( {item.notes} ) : null} {normalizeMediaUri(photoByTaskId[item.id]) ? ( isVideoUri(normalizeMediaUri(photoByTaskId[item.id]) as string) ? ( ); }} ItemSeparatorComponent={() => } ListHeaderComponent={ {t('tasks.title')} {t('tasks.subtitle')} {t('tasks.historyTitle')} {status ? ( {status} ) : null} } onEndReached={handleLoadMore} onEndReachedThreshold={0.4} ListFooterComponent={ {loadingMore ? : null} } /> setZoomUri(null)} /> ); } async function ensureDefaultTasks( db: Awaited, tasks: { name: string; description: string; default_time: string }[] ) { const row = await db.getFirstAsync<{ count: number }>( 'SELECT COUNT(*) as count FROM daily_tasks;' ); if (row?.count && row.count > 0) return; const now = new Date().toISOString(); for (const task of tasks) { await db.runAsync( 'INSERT INTO daily_tasks (name, description, default_time, is_active, created_at) VALUES (?, ?, ?, 1, ?);', task.name, task.description, task.default_time, now ); } } function parseTaskMeta(raw: string | null) { if (!raw) return {} as { photoUri?: string }; try { return JSON.parse(raw) as { photoUri?: string }; } catch { return {} as { photoUri?: string }; } } function serializeTaskMeta(meta: { photoUri?: string }) { if (!meta.photoUri) return null; return JSON.stringify(meta); } function isVideoUri(uri: string) { return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri); } function normalizeMediaUri(uri?: string | null) { if (typeof uri !== 'string') return null; const trimmed = uri.trim(); return trimmed ? trimmed : null; } const styles = StyleSheet.create({ hero: { backgroundColor: '#E8E6DA', aspectRatio: 16 / 9, width: '100%', }, heroImage: { width: '100%', height: '100%', }, titleContainer: { gap: 8, paddingHorizontal: 16, paddingVertical: 12, }, subtitle: { opacity: 0.7, }, section: { gap: 8, marginBottom: 16, paddingHorizontal: 16, }, card: { borderRadius: 12, borderWidth: 1, borderColor: '#C6C6C6', padding: 12, marginHorizontal: 16, marginBottom: 12, gap: 8, backgroundColor: '#FFFFFF', }, cardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: 8, }, description: { opacity: 0.8, }, photoLabel: { opacity: 0.7, }, photoPreview: { height: 200, width: '100%', borderRadius: 12, }, videoPreview: { width: '100%', height: 200, borderRadius: 12, backgroundColor: '#1C1C1C', }, photoRow: { flexDirection: 'row', gap: 8, }, statusBadge: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 10, paddingVertical: 4, borderRadius: 999, borderWidth: 1, }, statusDone: { backgroundColor: '#E6F4EA', borderColor: '#B7E1C1', }, statusPending: { backgroundColor: '#F1ECE0', borderColor: '#D6CCB9', }, statusDot: { width: 6, height: 6, borderRadius: 3, }, statusDotDone: { backgroundColor: '#2F7D4F', }, statusDotPending: { backgroundColor: '#8A7A5B', }, statusText: { fontSize: 12, fontWeight: '600', }, statusTextDone: { color: '#2F7D4F', }, statusTextPending: { color: '#7B6A4A', }, taskTitle: { color: '#2F7D4F', }, input: { borderRadius: 10, borderWidth: 1, borderColor: '#B9B9B9', paddingHorizontal: 12, paddingVertical: 10, fontSize: 15, }, buttonRow: { alignSelf: 'flex-start', }, separator: { height: 12, }, footer: { height: 24, }, keyboardAvoid: { flex: 1, }, listContent: { paddingBottom: 80, }, });