Нет описания

tasks.tsx 13KB

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