Nav apraksta

index.tsx 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. import { useCallback, useEffect, useMemo, useState } from 'react';
  2. import {
  3. ActivityIndicator,
  4. FlatList,
  5. KeyboardAvoidingView,
  6. Platform,
  7. Pressable,
  8. StyleSheet,
  9. View,
  10. } from 'react-native';
  11. import { Image } from 'expo-image';
  12. import { ResizeMode, Video } from 'expo-av';
  13. import { Link, useLocalSearchParams, useRouter } from 'expo-router';
  14. import { useFocusEffect, useNavigation } from '@react-navigation/native';
  15. import { ThemedText } from '@/components/themed-text';
  16. import { ThemedView } from '@/components/themed-view';
  17. import { Colors, Fonts } from '@/constants/theme';
  18. import { ThemedButton } from '@/components/themed-button';
  19. import { useTranslation } from '@/localization/i18n';
  20. import { dbPromise, initCoreTables } from '@/services/db';
  21. import { useColorScheme } from '@/hooks/use-color-scheme';
  22. import { ZoomImageModal } from '@/components/zoom-image-modal';
  23. import { IconSymbol } from '@/components/ui/icon-symbol';
  24. type TaskRow = {
  25. id: number;
  26. name: string;
  27. description: string | null;
  28. entry_id: number | null;
  29. status: string | null;
  30. notes: string | null;
  31. meta_json: string | null;
  32. media_uri?: string | null;
  33. };
  34. export default function DailyTasksScreen() {
  35. const { t } = useTranslation();
  36. const router = useRouter();
  37. const navigation = useNavigation();
  38. const params = useLocalSearchParams<{ from?: string | string[] }>();
  39. const theme = useColorScheme() ?? 'light';
  40. const palette = Colors[theme];
  41. const fromParam = Array.isArray(params.from) ? params.from[0] : params.from;
  42. const defaultTasks = [
  43. {
  44. name: t('tasks.default.fieldCheck'),
  45. description: t('tasks.default.fieldCheckDesc'),
  46. default_time: '08:00',
  47. },
  48. {
  49. name: t('tasks.default.scouting'),
  50. description: t('tasks.default.scoutingDesc'),
  51. default_time: '10:00',
  52. },
  53. {
  54. name: t('tasks.default.sensors'),
  55. description: t('tasks.default.sensorsDesc'),
  56. default_time: '15:00',
  57. },
  58. ];
  59. const pageSize = 10;
  60. const [tasks, setTasks] = useState<TaskRow[]>([]);
  61. const [status, setStatus] = useState(t('tasks.loading'));
  62. useEffect(() => {
  63. navigation.setOptions({
  64. headerLeft: () => (
  65. <Pressable
  66. onPress={() => {
  67. if (fromParam === 'logbook') {
  68. router.replace('/logbook');
  69. return;
  70. }
  71. if (fromParam === 'home') {
  72. router.replace('/');
  73. return;
  74. }
  75. router.back();
  76. }}
  77. hitSlop={10}
  78. style={{ paddingHorizontal: 8 }}>
  79. <IconSymbol name="chevron.left" size={20} color={palette.text} />
  80. </Pressable>
  81. ),
  82. });
  83. }, [fromParam, navigation, palette.text, router]);
  84. const [photoByTaskId, setPhotoByTaskId] = useState<Record<number, string>>({});
  85. const [zoomUri, setZoomUri] = useState<string | null>(null);
  86. const [pendingZoomUri, setPendingZoomUri] = useState<string | null>(null);
  87. const [page, setPage] = useState(1);
  88. const [hasMore, setHasMore] = useState(true);
  89. const [loadingMore, setLoadingMore] = useState(false);
  90. useEffect(() => {
  91. if (pendingZoomUri) {
  92. const uri = pendingZoomUri;
  93. setPendingZoomUri(null);
  94. setTimeout(() => setZoomUri(uri), 150);
  95. }
  96. }, [pendingZoomUri]);
  97. const todayKey = useMemo(() => new Date().toISOString().slice(0, 10), []);
  98. useEffect(() => {
  99. let isActive = true;
  100. async function loadTasks() {
  101. try {
  102. await initCoreTables();
  103. const db = await dbPromise;
  104. await ensureDefaultTasks(db, defaultTasks);
  105. await fetchTasksPage(1, true, isActive);
  106. } catch (error) {
  107. if (isActive) setStatus(`Error: ${String(error)}`);
  108. }
  109. }
  110. loadTasks();
  111. return () => {
  112. isActive = false;
  113. };
  114. }, [t, todayKey]);
  115. useFocusEffect(
  116. useCallback(() => {
  117. fetchTasksPage(1, true);
  118. }, [])
  119. );
  120. async function fetchTasksPage(pageToLoad: number, replace: boolean, isActive = true) {
  121. try {
  122. const db = await dbPromise;
  123. const rows = await db.getAllAsync<TaskRow>(
  124. `SELECT t.id, t.name, t.description,
  125. e.id as entry_id, e.status, e.notes, e.meta_json,
  126. (SELECT uri FROM task_entry_media m WHERE m.entry_id = e.id ORDER BY created_at DESC LIMIT 1) as media_uri
  127. FROM daily_tasks t
  128. LEFT JOIN daily_task_entries e
  129. ON e.task_id = t.id
  130. AND substr(e.completed_at, 1, 10) = ?
  131. WHERE t.is_active = 1
  132. ORDER BY t.id ASC
  133. LIMIT ? OFFSET ?;`,
  134. todayKey,
  135. pageSize,
  136. (pageToLoad - 1) * pageSize
  137. );
  138. if (!isActive) return;
  139. setTasks((prev) => (replace ? rows : [...prev, ...rows]));
  140. setHasMore(rows.length === pageSize);
  141. setPage(pageToLoad);
  142. if (replace) {
  143. setStatus(rows.length === 0 ? t('tasks.empty') : '');
  144. }
  145. setPhotoByTaskId((prev) => {
  146. const next = replace ? {} : { ...prev };
  147. rows.forEach((row) => {
  148. const meta = parseTaskMeta(row.meta_json);
  149. const uri = normalizeMediaUri(row.media_uri) ?? normalizeMediaUri(meta.photoUri);
  150. if (uri) next[row.id] = uri;
  151. });
  152. return next;
  153. });
  154. } catch (error) {
  155. if (isActive) setStatus(`Error: ${String(error)}`);
  156. } finally {
  157. if (isActive) setLoadingMore(false);
  158. }
  159. }
  160. async function handleLoadMore() {
  161. if (loadingMore || !hasMore) return;
  162. setLoadingMore(true);
  163. const nextPage = page + 1;
  164. await fetchTasksPage(nextPage, false);
  165. }
  166. return (
  167. <KeyboardAvoidingView
  168. behavior={Platform.OS === 'ios' ? 'padding' : undefined}
  169. keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
  170. style={styles.keyboardAvoid}>
  171. <FlatList
  172. data={tasks}
  173. keyExtractor={(item) => String(item.id)}
  174. keyboardShouldPersistTaps="handled"
  175. keyboardDismissMode="on-drag"
  176. contentContainerStyle={styles.listContent}
  177. renderItem={({ item }) => {
  178. const isDone = (item.status ?? '').toLowerCase() === 'done';
  179. return (
  180. <Pressable onPress={() => router.push(`/tasks/${item.id}`)}>
  181. <ThemedView
  182. style={[styles.card, { backgroundColor: palette.card, borderColor: palette.border }]}>
  183. <View style={styles.cardHeader}>
  184. <ThemedText type="subtitle" style={[styles.taskTitle, { color: palette.tint }]}>
  185. {item.name}
  186. </ThemedText>
  187. <View style={[styles.statusBadge, isDone ? styles.statusDone : styles.statusPending]}>
  188. <View style={[styles.statusDot, isDone ? styles.statusDotDone : styles.statusDotPending]} />
  189. <ThemedText style={[styles.statusText, isDone ? styles.statusTextDone : styles.statusTextPending]}>
  190. {isDone ? t('tasks.done') : t('tasks.pending')}
  191. </ThemedText>
  192. </View>
  193. </View>
  194. {item.description ? (
  195. <ThemedText style={styles.description}>{item.description}</ThemedText>
  196. ) : null}
  197. {item.notes ? (
  198. <ThemedText style={styles.description}>{item.notes}</ThemedText>
  199. ) : null}
  200. {normalizeMediaUri(photoByTaskId[item.id]) ? (
  201. isVideoUri(normalizeMediaUri(photoByTaskId[item.id]) as string) ? (
  202. <Video
  203. source={{ uri: normalizeMediaUri(photoByTaskId[item.id]) as string }}
  204. style={styles.videoPreview}
  205. useNativeControls
  206. resizeMode={ResizeMode.CONTAIN}
  207. isMuted
  208. />
  209. ) : (
  210. <Pressable
  211. onPress={() => {
  212. setPendingZoomUri(normalizeMediaUri(photoByTaskId[item.id]) as string);
  213. }}>
  214. <Image
  215. source={{ uri: normalizeMediaUri(photoByTaskId[item.id]) as string }}
  216. style={styles.photoPreview}
  217. contentFit="contain"
  218. />
  219. </Pressable>
  220. )
  221. ) : null}
  222. </ThemedView>
  223. </Pressable>
  224. );
  225. }}
  226. ItemSeparatorComponent={() => <View style={styles.separator} />}
  227. ListHeaderComponent={
  228. <View>
  229. <ThemedView style={styles.hero}>
  230. <Image source={require('@/assets/images/dailytask.jpg')} style={styles.heroImage} />
  231. </ThemedView>
  232. <ThemedView style={styles.titleContainer}>
  233. <ThemedText type="title" style={{ fontFamily: Fonts.rounded }}>
  234. {t('tasks.title')}
  235. </ThemedText>
  236. <ThemedText style={styles.subtitle}>{t('tasks.subtitle')}</ThemedText>
  237. <Link href="/task-history" push>
  238. <ThemedText type="link">{t('tasks.historyTitle')}</ThemedText>
  239. </Link>
  240. </ThemedView>
  241. {status ? (
  242. <ThemedView style={styles.section}>
  243. <ThemedText>{status}</ThemedText>
  244. </ThemedView>
  245. ) : null}
  246. </View>
  247. }
  248. onEndReached={handleLoadMore}
  249. onEndReachedThreshold={0.4}
  250. ListFooterComponent={
  251. <View style={styles.footer}>
  252. {loadingMore ? <ActivityIndicator /> : null}
  253. </View>
  254. }
  255. />
  256. <ZoomImageModal
  257. uri={zoomUri}
  258. visible={Boolean(zoomUri)}
  259. onClose={() => setZoomUri(null)}
  260. />
  261. </KeyboardAvoidingView>
  262. );
  263. }
  264. async function ensureDefaultTasks(
  265. db: Awaited<typeof dbPromise>,
  266. tasks: { name: string; description: string; default_time: string }[]
  267. ) {
  268. const row = await db.getFirstAsync<{ count: number }>(
  269. 'SELECT COUNT(*) as count FROM daily_tasks;'
  270. );
  271. if (row?.count && row.count > 0) return;
  272. const now = new Date().toISOString();
  273. for (const task of tasks) {
  274. await db.runAsync(
  275. 'INSERT INTO daily_tasks (name, description, default_time, is_active, created_at) VALUES (?, ?, ?, 1, ?);',
  276. task.name,
  277. task.description,
  278. task.default_time,
  279. now
  280. );
  281. }
  282. }
  283. function parseTaskMeta(raw: string | null) {
  284. if (!raw) return {} as { photoUri?: string };
  285. try {
  286. return JSON.parse(raw) as { photoUri?: string };
  287. } catch {
  288. return {} as { photoUri?: string };
  289. }
  290. }
  291. function serializeTaskMeta(meta: { photoUri?: string }) {
  292. if (!meta.photoUri) return null;
  293. return JSON.stringify(meta);
  294. }
  295. function isVideoUri(uri: string) {
  296. return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
  297. }
  298. function normalizeMediaUri(uri?: string | null) {
  299. if (typeof uri !== 'string') return null;
  300. const trimmed = uri.trim();
  301. return trimmed ? trimmed : null;
  302. }
  303. const styles = StyleSheet.create({
  304. hero: {
  305. backgroundColor: '#E8E6DA',
  306. aspectRatio: 16 / 9,
  307. width: '100%',
  308. },
  309. heroImage: {
  310. width: '100%',
  311. height: '100%',
  312. },
  313. titleContainer: {
  314. gap: 8,
  315. paddingHorizontal: 16,
  316. paddingVertical: 12,
  317. },
  318. subtitle: {
  319. opacity: 0.7,
  320. },
  321. section: {
  322. gap: 8,
  323. marginBottom: 16,
  324. paddingHorizontal: 16,
  325. },
  326. card: {
  327. borderRadius: 12,
  328. borderWidth: 1,
  329. borderColor: '#C6C6C6',
  330. padding: 12,
  331. marginHorizontal: 16,
  332. marginBottom: 12,
  333. gap: 8,
  334. backgroundColor: '#FFFFFF',
  335. },
  336. cardHeader: {
  337. flexDirection: 'row',
  338. justifyContent: 'space-between',
  339. alignItems: 'center',
  340. gap: 8,
  341. },
  342. description: {
  343. opacity: 0.8,
  344. },
  345. photoLabel: {
  346. opacity: 0.7,
  347. },
  348. photoPreview: {
  349. height: 200,
  350. width: '100%',
  351. borderRadius: 12,
  352. },
  353. videoPreview: {
  354. width: '100%',
  355. height: 200,
  356. borderRadius: 12,
  357. backgroundColor: '#1C1C1C',
  358. },
  359. photoRow: {
  360. flexDirection: 'row',
  361. gap: 8,
  362. },
  363. statusBadge: {
  364. flexDirection: 'row',
  365. alignItems: 'center',
  366. gap: 6,
  367. paddingHorizontal: 10,
  368. paddingVertical: 4,
  369. borderRadius: 999,
  370. borderWidth: 1,
  371. },
  372. statusDone: {
  373. backgroundColor: '#E6F4EA',
  374. borderColor: '#B7E1C1',
  375. },
  376. statusPending: {
  377. backgroundColor: '#F1ECE0',
  378. borderColor: '#D6CCB9',
  379. },
  380. statusDot: {
  381. width: 6,
  382. height: 6,
  383. borderRadius: 3,
  384. },
  385. statusDotDone: {
  386. backgroundColor: '#2F7D4F',
  387. },
  388. statusDotPending: {
  389. backgroundColor: '#8A7A5B',
  390. },
  391. statusText: {
  392. fontSize: 12,
  393. fontWeight: '600',
  394. },
  395. statusTextDone: {
  396. color: '#2F7D4F',
  397. },
  398. statusTextPending: {
  399. color: '#7B6A4A',
  400. },
  401. taskTitle: {
  402. color: '#2F7D4F',
  403. },
  404. input: {
  405. borderRadius: 10,
  406. borderWidth: 1,
  407. borderColor: '#B9B9B9',
  408. paddingHorizontal: 12,
  409. paddingVertical: 10,
  410. fontSize: 15,
  411. },
  412. buttonRow: {
  413. alignSelf: 'flex-start',
  414. },
  415. separator: {
  416. height: 12,
  417. },
  418. footer: {
  419. height: 24,
  420. },
  421. keyboardAvoid: {
  422. flex: 1,
  423. },
  424. listContent: {
  425. paddingBottom: 80,
  426. },
  427. });