diff --git a/formulus/src/components/MenuDrawer.tsx b/formulus/src/components/MenuDrawer.tsx index 63fc154fe..8d7ac58d4 100644 --- a/formulus/src/components/MenuDrawer.tsx +++ b/formulus/src/components/MenuDrawer.tsx @@ -6,8 +6,8 @@ import { Modal, TouchableOpacity, ScrollView, - SafeAreaView, } from 'react-native'; +import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; import {getUserInfo, UserInfo, UserRole} from '../api/synkronus/Auth'; @@ -48,6 +48,9 @@ const MenuDrawer: React.FC = ({ allowClose = true, }) => { const [userInfo, setUserInfo] = useState(null); + const insets = useSafeAreaInsets(); + const TAB_BAR_HEIGHT = 60; + const bottomPadding = TAB_BAR_HEIGHT + insets.bottom; useEffect(() => { if (visible) { @@ -109,8 +112,10 @@ const MenuDrawer: React.FC = ({ onPress={onClose} /> )} - - + + Menu {allowClose && ( @@ -190,7 +195,6 @@ const styles = StyleSheet.create({ position: 'absolute', right: 0, top: 0, - bottom: 0, width: '80%', maxWidth: 320, backgroundColor: '#FFFFFF', diff --git a/formulus/src/components/common/EmptyState.tsx b/formulus/src/components/common/EmptyState.tsx new file mode 100644 index 000000000..fcce36d4b --- /dev/null +++ b/formulus/src/components/common/EmptyState.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import {View, Text, StyleSheet} from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import colors from '../../theme/colors'; + +interface EmptyStateProps { + icon?: string; + title: string; + message: string; + actionLabel?: string; + onAction?: () => void; +} + +const EmptyState: React.FC = ({ + icon = 'information-outline', + title, + message, + actionLabel, + onAction, +}) => { + return ( + + + {title} + {message} + {actionLabel && onAction && ( + + {actionLabel} + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + title: { + fontSize: 20, + fontWeight: '600', + color: colors.neutral[900], + marginTop: 16, + marginBottom: 8, + textAlign: 'center', + }, + message: { + fontSize: 14, + color: colors.neutral[600], + textAlign: 'center', + lineHeight: 20, + marginBottom: 16, + }, + actionText: { + fontSize: 16, + color: colors.brand.primary[500], + fontWeight: '500', + marginTop: 8, + }, +}); + +export default EmptyState; + diff --git a/formulus/src/components/common/FilterBar.tsx b/formulus/src/components/common/FilterBar.tsx new file mode 100644 index 000000000..d716356ac --- /dev/null +++ b/formulus/src/components/common/FilterBar.tsx @@ -0,0 +1,180 @@ +import React from 'react'; +import {View, Text, TouchableOpacity, StyleSheet, TextInput} from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import {SortOption, FilterOption} from './FilterBar.types'; + +export type {SortOption, FilterOption}; + +interface FilterBarProps { + searchQuery: string; + onSearchChange: (query: string) => void; + sortOption: SortOption; + onSortChange: (option: SortOption) => void; + filterOption?: FilterOption; + onFilterChange?: (option: FilterOption) => void; + showFilter?: boolean; +} + +const FilterBar: React.FC = ({ + searchQuery, + onSearchChange, + sortOption, + onSortChange, + filterOption = 'all', + onFilterChange, + showFilter = false, +}) => { + const sortOptions: {value: SortOption; label: string}[] = [ + {value: 'date-desc', label: 'Newest'}, + {value: 'date-asc', label: 'Oldest'}, + {value: 'form-type', label: 'Form Type'}, + {value: 'sync-status', label: 'Sync Status'}, + ]; + + const filterOptions: {value: FilterOption; label: string}[] = [ + {value: 'all', label: 'All'}, + {value: 'synced', label: 'Synced'}, + {value: 'pending', label: 'Pending'}, + ]; + + return ( + + + + + {searchQuery.length > 0 && ( + onSearchChange('')}> + + + )} + + + + + Sort: + {sortOptions.map(option => ( + onSortChange(option.value)} + > + + {option.label} + + + ))} + + + {showFilter && onFilterChange && ( + + Filter: + {filterOptions.map(option => ( + onFilterChange(option.value)} + > + + {option.label} + + + ))} + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#FFFFFF', + padding: 12, + borderBottomWidth: 1, + borderBottomColor: '#E5E5E5', + }, + searchContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#F5F5F5', + borderRadius: 8, + paddingHorizontal: 12, + marginBottom: 12, + }, + searchIcon: { + marginRight: 8, + }, + searchInput: { + flex: 1, + fontSize: 14, + color: '#333', + paddingVertical: 8, + }, + controlsRow: { + flexDirection: 'row', + justifyContent: 'space-between', + flexWrap: 'wrap', + }, + sortContainer: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + flex: 1, + }, + filterContainer: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + flex: 1, + }, + label: { + fontSize: 12, + color: '#666', + marginRight: 8, + fontWeight: '500', + }, + optionButton: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 12, + backgroundColor: '#F5F5F5', + marginRight: 6, + marginBottom: 4, + }, + optionButtonActive: { + backgroundColor: '#007AFF', + }, + optionText: { + fontSize: 12, + color: '#666', + fontWeight: '500', + }, + optionTextActive: { + color: '#FFFFFF', + }, +}); + +export default FilterBar; + diff --git a/formulus/src/components/common/FilterBar.types.ts b/formulus/src/components/common/FilterBar.types.ts new file mode 100644 index 000000000..847206f6f --- /dev/null +++ b/formulus/src/components/common/FilterBar.types.ts @@ -0,0 +1,3 @@ +export type SortOption = 'date-desc' | 'date-asc' | 'form-type' | 'sync-status'; +export type FilterOption = 'all' | 'synced' | 'pending'; + diff --git a/formulus/src/components/common/FormCard.tsx b/formulus/src/components/common/FormCard.tsx new file mode 100644 index 000000000..50d3d421e --- /dev/null +++ b/formulus/src/components/common/FormCard.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import {View, Text, TouchableOpacity, StyleSheet} from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import {FormSpec} from '../../services/FormService'; +import colors from '../../theme/colors'; + +interface FormCardProps { + form: FormSpec; + observationCount?: number; + onPress: () => void; +} + +const FormCard: React.FC = ({ + form, + observationCount = 0, + onPress, +}) => { + return ( + + + + + + + {form.name} + {form.description && ( + + {form.description} + + )} + + v{form.schemaVersion} + {observationCount > 0 && ( + + + {observationCount} {observationCount === 1 ? 'entry' : 'entries'} + + + )} + + + + + + ); +}; + +const styles = StyleSheet.create({ + card: { + backgroundColor: colors.neutral.white, + borderRadius: 12, + marginHorizontal: 16, + marginVertical: 6, + padding: 16, + shadowColor: colors.neutral.black, + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + content: { + flexDirection: 'row', + alignItems: 'center', + }, + iconContainer: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: colors.brand.primary[50], + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + textContainer: { + flex: 1, + }, + name: { + fontSize: 16, + fontWeight: '600', + color: colors.neutral[900], + marginBottom: 4, + }, + description: { + fontSize: 14, + color: colors.neutral[600], + marginBottom: 8, + }, + metaContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + version: { + fontSize: 12, + color: colors.neutral[500], + }, + countBadge: { + backgroundColor: colors.brand.primary[50], + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 10, + }, + countText: { + fontSize: 11, + color: colors.brand.primary[500], + fontWeight: '500', + }, +}); + +export default FormCard; + diff --git a/formulus/src/components/common/FormTypeSelector.tsx b/formulus/src/components/common/FormTypeSelector.tsx new file mode 100644 index 000000000..7e156e995 --- /dev/null +++ b/formulus/src/components/common/FormTypeSelector.tsx @@ -0,0 +1,177 @@ +import React, {useState} from 'react'; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + Modal, + FlatList, +} from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import colors from '../../theme/colors'; + +interface FormTypeOption { + id: string; + name: string; +} + +interface FormTypeSelectorProps { + options: FormTypeOption[]; + selectedId: string | null; + onSelect: (id: string | null) => void; + placeholder?: string; +} + +const FormTypeSelector: React.FC = ({ + options, + selectedId, + onSelect, + placeholder = 'All Forms', +}) => { + const [modalVisible, setModalVisible] = useState(false); + + const selectedOption = options.find(opt => opt.id === selectedId); + + return ( + <> + setModalVisible(true)} + activeOpacity={0.7} + > + + + {selectedOption ? selectedOption.name : placeholder} + + + + + setModalVisible(false)} + > + setModalVisible(false)} + > + + + Select Form Type + setModalVisible(false)}> + + + + + item.id || 'all'} + renderItem={({item}) => ( + { + onSelect(item.id); + setModalVisible(false); + }} + > + + {item.name} + + {selectedId === item.id && ( + + )} + + )} + /> + + + + + ); +}; + +const styles = StyleSheet.create({ + selector: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.neutral.white, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.neutral[200], + gap: 8, + flex: 1, + maxWidth: 300, + alignSelf: 'center', + }, + selectorText: { + flex: 1, + fontSize: 14, + color: colors.neutral[900], + fontWeight: '500', + }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center', + alignItems: 'center', + }, + modalContent: { + backgroundColor: colors.neutral.white, + borderRadius: 12, + width: '80%', + maxWidth: 400, + maxHeight: '70%', + shadowColor: colors.neutral.black, + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.25, + shadowRadius: 8, + elevation: 5, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: colors.neutral[200], + }, + modalTitle: { + fontSize: 18, + fontWeight: '600', + color: colors.neutral[900], + }, + optionItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: colors.neutral[100], + }, + optionItemSelected: { + backgroundColor: colors.brand.primary[50], + }, + optionText: { + fontSize: 16, + color: colors.neutral[900], + }, + optionTextSelected: { + color: colors.brand.primary[500], + fontWeight: '600', + }, +}); + +export default FormTypeSelector; + diff --git a/formulus/src/components/common/ObservationCard.tsx b/formulus/src/components/common/ObservationCard.tsx new file mode 100644 index 000000000..2584b46fe --- /dev/null +++ b/formulus/src/components/common/ObservationCard.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import {View, Text, TouchableOpacity, StyleSheet} from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import {Observation} from '../../database/models/Observation'; +import colors from '../../theme/colors'; + +interface ObservationCardProps { + observation: Observation; + formName?: string; + onPress: () => void; + onEdit?: () => void; + onDelete?: () => void; +} + +const ObservationCard: React.FC = ({ + observation, + formName, + onPress, + onEdit, + onDelete, +}) => { + const isSynced = observation.syncedAt && observation.syncedAt.getTime() > new Date('1980-01-01').getTime(); + const dateStr = observation.createdAt.toLocaleDateString(); + const timeStr = observation.createdAt.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}); + + const getDataPreview = () => { + try { + const data = typeof observation.data === 'string' + ? JSON.parse(observation.data) + : observation.data; + const keys = Object.keys(data).slice(0, 2); + if (keys.length === 0) return 'No data'; + return keys.map(key => `${key}: ${String(data[key]).substring(0, 20)}`).join(', '); + } catch { + return 'No data'; + } + }; + + return ( + + + + + + + {formName && ( + {formName} + )} + + ID: {observation.observationId.substring(0, 20)}... + + + {getDataPreview()} + + + + {dateStr} at {timeStr} + + + + {isSynced ? 'Synced' : 'Pending'} + + + + + + {onEdit && ( + { + e.stopPropagation(); + onEdit(); + }} + > + + + )} + {onDelete && ( + { + e.stopPropagation(); + onDelete(); + }} + > + + + )} + + + + ); +}; + +const styles = StyleSheet.create({ + card: { + backgroundColor: colors.neutral.white, + borderRadius: 12, + marginHorizontal: 16, + marginVertical: 6, + padding: 16, + shadowColor: colors.neutral.black, + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + content: { + flexDirection: 'row', + alignItems: 'center', + }, + iconContainer: { + marginRight: 12, + }, + textContainer: { + flex: 1, + }, + formName: { + fontSize: 14, + fontWeight: '600', + color: colors.brand.primary[500], + marginBottom: 4, + }, + id: { + fontSize: 12, + color: colors.neutral[500], + fontFamily: 'monospace', + marginBottom: 4, + }, + preview: { + fontSize: 14, + color: colors.neutral[600], + marginBottom: 8, + }, + metaContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + date: { + fontSize: 12, + color: colors.neutral[500], + }, + statusBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 10, + }, + syncedBadge: { + backgroundColor: colors.semantic.success[50], + }, + pendingBadge: { + backgroundColor: colors.semantic.warning[50], + }, + statusText: { + fontSize: 11, + fontWeight: '500', + color: colors.neutral[900], + }, + actions: { + flexDirection: 'row', + gap: 8, + }, + actionButton: { + padding: 4, + }, +}); + +export default ObservationCard; + diff --git a/formulus/src/components/common/StatusTabs.tsx b/formulus/src/components/common/StatusTabs.tsx new file mode 100644 index 000000000..116749f8e --- /dev/null +++ b/formulus/src/components/common/StatusTabs.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import {View, Text, TouchableOpacity, StyleSheet} from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import colors from '../../theme/colors'; + +export interface StatusTab { + id: string; + label: string; + icon?: string; + iconColor?: string; +} + +interface StatusTabsProps { + tabs: StatusTab[]; + activeTab: string; + onTabChange: (tabId: string) => void; +} + +const StatusTabs: React.FC = ({tabs, activeTab, onTabChange}) => { + return ( + + {tabs.map(tab => { + const isActive = activeTab === tab.id; + return ( + onTabChange(tab.id)} + activeOpacity={0.7} + > + {tab.icon && ( + + )} + + {tab.label} + + + ); + })} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + backgroundColor: colors.neutral.white, + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: colors.neutral[200], + }, + tab: { + flexDirection: 'row', + alignItems: 'center', + marginRight: 24, + paddingVertical: 4, + }, + tabActive: { + borderBottomWidth: 2, + borderBottomColor: colors.brand.primary[500], + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + marginRight: 6, + }, + tabLabel: { + fontSize: 14, + color: colors.neutral[600], + fontWeight: '500', + }, + tabLabelActive: { + color: colors.brand.primary[500], + fontWeight: '600', + }, +}); + +export default StatusTabs; + diff --git a/formulus/src/components/common/SyncStatusButtons.tsx b/formulus/src/components/common/SyncStatusButtons.tsx new file mode 100644 index 000000000..a9f17b411 --- /dev/null +++ b/formulus/src/components/common/SyncStatusButtons.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import {View, Text, TouchableOpacity, StyleSheet} from 'react-native'; +import colors from '../../theme/colors'; + +export type SyncStatus = 'all' | 'synced' | 'pending'; + +interface SyncStatusButtonsProps { + selectedStatus: SyncStatus; + onStatusChange: (status: SyncStatus) => void; +} + +const SyncStatusButtons: React.FC = ({ + selectedStatus, + onStatusChange, +}) => { + const buttons: {id: SyncStatus; label: string}[] = [ + {id: 'all', label: 'All'}, + {id: 'synced', label: 'Synced'}, + {id: 'pending', label: 'Pending'}, + ]; + + return ( + + {buttons.map(button => { + const isActive = selectedStatus === button.id; + return ( + onStatusChange(button.id)} + activeOpacity={0.7} + > + + {button.label} + + + ); + })} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + backgroundColor: colors.neutral[100], + borderRadius: 8, + padding: 4, + gap: 4, + flex: 1, + maxWidth: 300, + alignSelf: 'center', + }, + button: { + flex: 1, + paddingVertical: 8, + paddingHorizontal: 8, + borderRadius: 6, + alignItems: 'center', + justifyContent: 'center', + }, + buttonActive: { + backgroundColor: colors.neutral.white, + shadowColor: colors.neutral.black, + shadowOffset: {width: 0, height: 1}, + shadowOpacity: 0.1, + shadowRadius: 2, + elevation: 2, + }, + buttonText: { + fontSize: 13, + fontWeight: '500', + color: colors.neutral[600], + textAlign: 'center', + }, + buttonTextActive: { + color: colors.brand.primary[500], + fontWeight: '600', + }, +}); + +export default SyncStatusButtons; + diff --git a/formulus/src/components/common/index.ts b/formulus/src/components/common/index.ts index 6975374b1..70b56ef3a 100644 --- a/formulus/src/components/common/index.ts +++ b/formulus/src/components/common/index.ts @@ -1,4 +1,14 @@ export { default as Button } from './Button'; export { default as Input } from './Input'; +export { default as FormCard } from './FormCard'; +export { default as ObservationCard } from './ObservationCard'; +export { default as EmptyState } from './EmptyState'; +export { default as FilterBar } from './FilterBar'; +export { default as StatusTabs } from './StatusTabs'; +export { default as FormTypeSelector } from './FormTypeSelector'; +export { default as SyncStatusButtons } from './SyncStatusButtons'; +export type { SortOption, FilterOption } from './FilterBar.types'; +export type { StatusTab } from './StatusTabs'; +export type { SyncStatus } from './SyncStatusButtons'; export { default as PasswordInput } from './PasswordInput'; export type { PasswordInputProps } from './PasswordInput'; diff --git a/formulus/src/hooks/useForms.ts b/formulus/src/hooks/useForms.ts new file mode 100644 index 000000000..7a1afa050 --- /dev/null +++ b/formulus/src/hooks/useForms.ts @@ -0,0 +1,73 @@ +import {useState, useEffect, useCallback} from 'react'; +import {FormService, FormSpec} from '../services/FormService'; +import {Observation} from '../database/models/Observation'; + +interface UseFormsResult { + forms: FormSpec[]; + loading: boolean; + error: string | null; + refresh: () => Promise; + getObservationCount: (formId: string) => number; + observationCounts: Record; +} + +export const useForms = (): UseFormsResult => { + const [forms, setForms] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [observationCounts, setObservationCounts] = useState>({}); + + const loadForms = useCallback(async () => { + try { + setLoading(true); + setError(null); + const formService = await FormService.getInstance(); + const formSpecs = formService.getFormSpecs(); + setForms(formSpecs); + + const counts: Record = {}; + for (const form of formSpecs) { + try { + const observations = await formService.getObservationsByFormType(form.id); + counts[form.id] = observations.length; + } catch (err) { + console.error(`Failed to load observations for form ${form.id}:`, err); + counts[form.id] = 0; + } + } + setObservationCounts(counts); + } catch (err) { + console.error('Failed to load forms:', err); + setError(err instanceof Error ? err.message : 'Failed to load forms'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadForms(); + const formServicePromise = FormService.getInstance(); + formServicePromise.then(service => { + service.onCacheInvalidated(() => { + loadForms(); + }); + }); + }, [loadForms]); + + const getObservationCount = useCallback( + (formId: string): number => { + return observationCounts[formId] || 0; + }, + [observationCounts], + ); + + return { + forms, + loading, + error, + refresh: loadForms, + getObservationCount, + observationCounts, + }; +}; + diff --git a/formulus/src/hooks/useObservations.ts b/formulus/src/hooks/useObservations.ts new file mode 100644 index 000000000..c8cf824dc --- /dev/null +++ b/formulus/src/hooks/useObservations.ts @@ -0,0 +1,121 @@ +import {useState, useEffect, useCallback, useMemo} from 'react'; +import {FormService} from '../services/FormService'; +import {Observation} from '../database/models/Observation'; +import {SortOption, FilterOption} from '../components/common/FilterBar'; + +interface UseObservationsResult { + observations: Observation[]; + loading: boolean; + error: string | null; + refresh: () => Promise; + searchQuery: string; + setSearchQuery: (query: string) => void; + sortOption: SortOption; + setSortOption: (option: SortOption) => void; + filterOption: FilterOption; + setFilterOption: (option: FilterOption) => void; + filteredAndSorted: Observation[]; +} + +export const useObservations = (): UseObservationsResult => { + const [observations, setObservations] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [sortOption, setSortOption] = useState('date-desc'); + const [filterOption, setFilterOption] = useState('all'); + + const loadObservations = useCallback(async () => { + try { + setLoading(true); + setError(null); + const formService = await FormService.getInstance(); + const formSpecs = formService.getFormSpecs(); + const allObservations: Observation[] = []; + for (const formSpec of formSpecs) { + try { + const formObservations = await formService.getObservationsByFormType(formSpec.id); + allObservations.push(...formObservations); + } catch (err) { + console.error(`Failed to load observations for form ${formSpec.id}:`, err); + } + } + + setObservations(allObservations); + } catch (err) { + console.error('Failed to load observations:', err); + setError(err instanceof Error ? err.message : 'Failed to load observations'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadObservations(); + }, [loadObservations]); + + const filteredAndSorted = useMemo(() => { + let filtered = [...observations]; + + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter(obs => { + try { + const data = typeof obs.data === 'string' ? JSON.parse(obs.data) : obs.data; + const dataStr = JSON.stringify(data).toLowerCase(); + return ( + obs.observationId.toLowerCase().includes(query) || + obs.formType.toLowerCase().includes(query) || + dataStr.includes(query) + ); + } catch { + return obs.observationId.toLowerCase().includes(query) || + obs.formType.toLowerCase().includes(query); + } + }); + } + + if (filterOption !== 'all') { + filtered = filtered.filter(obs => { + const isSynced = obs.syncedAt && obs.syncedAt.getTime() > new Date('1980-01-01').getTime(); + return filterOption === 'synced' ? isSynced : !isSynced; + }); + } + + filtered.sort((a, b) => { + switch (sortOption) { + case 'date-desc': + return b.createdAt.getTime() - a.createdAt.getTime(); + case 'date-asc': + return a.createdAt.getTime() - b.createdAt.getTime(); + case 'form-type': + return a.formType.localeCompare(b.formType); + case 'sync-status': { + const aSynced = a.syncedAt && a.syncedAt.getTime() > new Date('1980-01-01').getTime(); + const bSynced = b.syncedAt && b.syncedAt.getTime() > new Date('1980-01-01').getTime(); + if (aSynced === bSynced) return 0; + return aSynced ? 1 : -1; + } + default: + return 0; + } + }); + + return filtered; + }, [observations, searchQuery, sortOption, filterOption]); + + return { + observations, + loading, + error, + refresh: loadObservations, + searchQuery, + setSearchQuery, + sortOption, + setSortOption, + filterOption, + setFilterOption, + filteredAndSorted, + }; +}; + diff --git a/formulus/src/navigation/MainAppNavigator.tsx b/formulus/src/navigation/MainAppNavigator.tsx index 08c588016..527cbe5a5 100644 --- a/formulus/src/navigation/MainAppNavigator.tsx +++ b/formulus/src/navigation/MainAppNavigator.tsx @@ -5,6 +5,7 @@ import MainTabNavigator from './MainTabNavigator'; import WelcomeScreen from '../screens/WelcomeScreen'; import SettingsScreen from '../screens/SettingsScreen'; import FormManagementScreen from '../screens/FormManagementScreen'; +import ObservationDetailScreen from '../screens/ObservationDetailScreen'; import {MainAppStackParamList} from '../types/NavigationTypes'; import {serverConfigService} from '../services/ServerConfigService'; @@ -60,6 +61,11 @@ const MainAppNavigator: React.FC = () => { component={FormManagementScreen} options={{title: 'Form Management'}} /> + ); }; diff --git a/formulus/src/screens/FormManagementScreen.tsx b/formulus/src/screens/FormManagementScreen.tsx index 6b7b8fec1..a2ef7c357 100644 --- a/formulus/src/screens/FormManagementScreen.tsx +++ b/formulus/src/screens/FormManagementScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, {useState, useEffect} from 'react'; import { View, Text, @@ -6,22 +6,38 @@ import { FlatList, TouchableOpacity, ActivityIndicator, - Alert + Alert, + SafeAreaView, + RefreshControl, } from 'react-native'; -import { FormService, FormSpec } from '../services'; -import { Observation } from '../database/models/Observation'; -import { openFormplayerFromNative } from '../webview/FormulusMessageHandlers'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import {FormService, FormSpec} from '../services'; +import {Observation} from '../database/models/Observation'; +import {openFormplayerFromNative} from '../webview/FormulusMessageHandlers'; +import {ObservationCard, EmptyState} from '../components/common'; +import {useNavigation} from '@react-navigation/native'; +import {StackNavigationProp} from '@react-navigation/stack'; +import {MainAppStackParamList} from '../types/NavigationTypes'; + +type FormManagementScreenNavigationProp = StackNavigationProp< + MainAppStackParamList, + 'ObservationDetail' +>; /** * Screen for managing forms and observations (admin only) */ -const FormManagementScreen = ({ navigation }: any) => { +const FormManagementScreen = () => { + const navigation = useNavigation(); const [formSpecs, setFormSpecs] = useState([]); - const [observations, setObservations] = useState>({}); + const [observations, setObservations] = useState< + Record + >({}); const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); const [expandedFormId, setExpandedFormId] = useState(null); const [formService, setFormService] = useState(null); - + // Load form types and observations useEffect(() => { const initFormService = async () => { @@ -29,17 +45,15 @@ const FormManagementScreen = ({ navigation }: any) => { const service = await FormService.getInstance(); setFormService(service); const specs = service.getFormSpecs(); - console.log('FormSpecs:', specs); setFormSpecs(specs); - console.log('FormService initialized successfully'); } catch (error) { console.error('Failed to initialize FormService:', error); } }; - + initFormService(); }, []); - + useEffect(() => { if (formService) { loadData(); @@ -54,56 +68,96 @@ const FormManagementScreen = ({ navigation }: any) => { } try { setLoading(true); - + // Get all form types - const types = await formService.getFormSpecs(); + const types = formService.getFormSpecs(); setFormSpecs(types); - + // Get observations for each form type const observationsMap: Record = {}; - + for (const formType of types) { - const formObservations = await formService.getObservationsByFormType(formType.id); + const formObservations = await formService.getObservationsByFormType( + formType.id, + ); observationsMap[formType.id] = formObservations; } - + setObservations(observationsMap); } catch (error) { console.error('Error loading form data:', error); Alert.alert('Error', 'Failed to load form data'); } finally { setLoading(false); + setRefreshing(false); } }; - + + const handleRefresh = async () => { + setRefreshing(true); + await loadData(); + }; + // Handle adding a new observation using the promise-based Formplayer API const handleAddObservation = async (formType: FormSpec) => { try { const result = await openFormplayerFromNative(formType.id, {}, {}); - if (result.status === 'form_submitted' || result.status === 'form_updated') { + if ( + result.status === 'form_submitted' || + result.status === 'form_updated' + ) { await loadData(); } } catch (error) { - console.error('Error while opening Formplayer for new observation:', error); + console.error( + 'Error while opening Formplayer for new observation:', + error, + ); Alert.alert('Error', 'Failed to open form for new observation'); } }; - + // Handle editing an observation using the promise-based Formplayer API - const handleEditObservation = async (formType: FormSpec, observation: Observation) => { + const handleEditObservation = async ( + formType: FormSpec, + observation: Observation, + ) => { try { - const result = await openFormplayerFromNative(formType.id, {}, observation.data || {}); - if (result.status === 'form_submitted' || result.status === 'form_updated') { + const result = await openFormplayerFromNative( + formType.id, + {}, + typeof observation.data === 'string' + ? JSON.parse(observation.data) + : observation.data, + observation.observationId, + ); + if ( + result.status === 'form_submitted' || + result.status === 'form_updated' + ) { await loadData(); } } catch (error) { - console.error('Error while opening Formplayer for editing observation:', error); + console.error( + 'Error while opening Formplayer for editing observation:', + error, + ); Alert.alert('Error', 'Failed to open form for editing observation'); } }; - + + // Handle viewing an observation + const handleViewObservation = (observation: Observation) => { + navigation.navigate('ObservationDetail', { + observationId: observation.observationId, + }); + }; + // Handle deleting an observation - const handleDeleteObservation = async (formTypeId: string, observation: Observation) => { + const handleDeleteObservation = async ( + formTypeId: string, + observation: Observation, + ) => { if (!formService) { Alert.alert('Error', 'FormService is not initialized'); return; @@ -113,18 +167,17 @@ const FormManagementScreen = ({ navigation }: any) => { 'Confirm Delete', 'Are you sure you want to delete this observation?', [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Delete', + {text: 'Cancel', style: 'cancel'}, + { + text: 'Delete', style: 'destructive', onPress: async () => { setLoading(true); await formService.deleteObservation(observation.observationId); - // Reload data after deletion await loadData(); - } + }, }, - ] + ], ); } catch (error) { console.error('Error deleting observation:', error); @@ -132,7 +185,7 @@ const FormManagementScreen = ({ navigation }: any) => { setLoading(false); } }; - + // Handle database reset const handleResetDatabase = async () => { if (!formService) { @@ -144,19 +197,18 @@ const FormManagementScreen = ({ navigation }: any) => { 'Reset Database', 'Are you sure you want to delete ALL observations? This action cannot be undone.', [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Reset Database', + {text: 'Cancel', style: 'cancel'}, + { + text: 'Reset Database', style: 'destructive', onPress: async () => { setLoading(true); await formService.resetDatabase(); - // Reload data after reset await loadData(); Alert.alert('Success', 'Database has been reset successfully.'); - } + }, }, - ] + ], ); } catch (error) { console.error('Error resetting database:', error); @@ -164,7 +216,7 @@ const FormManagementScreen = ({ navigation }: any) => { setLoading(false); } }; - + // Toggle expanded state for a form const toggleExpanded = (formType: string) => { if (expandedFormId === formType) { @@ -173,317 +225,295 @@ const FormManagementScreen = ({ navigation }: any) => { setExpandedFormId(formType); } }; - + // Render an observation item - const renderObservationItem = ({ item }: { item: Observation }) => { - // For backward compatibility: if formTypeId is not set, use the parent form type - const currentFormTypeId = item.formType; - const parentFormType = expandedFormId ? formSpecs.find(ft => ft.id === expandedFormId) : null; - - // Use either the observation's formTypeId or the parent form type if we're in a specific form's context - const formType = formSpecs.find(ft => ft.id === currentFormTypeId) || parentFormType; - - console.log('Rendering observation:', item.observationId, 'formTypeId:', currentFormTypeId, 'formType found:', !!formType); - + const renderObservationItem = ( + observation: Observation, + formType: FormSpec, + ) => { return ( - - ID: {item.observationId} - Created: {item.createdAt.toLocaleString()} - Synced: {item.syncedAt && item.syncedAt.getTime() > new Date('1980-01-01').getTime() ? 'Yes' : 'No'} - - - Alert.alert('View Observation', JSON.stringify(item.data, null, 2))} - > - View Data - - - {formType && ( - handleEditObservation(formType, item)} - > - Edit - - )} - - handleDeleteObservation(currentFormTypeId, item)} - > - Delete - - - + handleViewObservation(observation)} + onEdit={() => handleEditObservation(formType, observation)} + onDelete={() => handleDeleteObservation(formType.id, observation)} + /> ); }; - + // Render a form spec item - const renderFormSpecItem = ({ item }: { item: FormSpec }) => { + const renderFormSpecItem = ({item}: {item: FormSpec}) => { const formObservations = observations[item.id] || []; const isExpanded = expandedFormId === item.id; - + return ( - toggleExpanded(item.id)} - > + activeOpacity={0.7}> - {item.name} - {item.description} - Version: {item.schemaVersion} + + + + + {item.name} + {item.description && ( + + {item.description} + + )} + + v{item.schemaVersion} + + + {formObservations.length}{' '} + {formObservations.length === 1 ? 'entry' : 'entries'} + + + + - - {formObservations.length} observation{formObservations.length !== 1 ? 's' : ''} - - handleAddObservation(item)} - > - Add Observation + onPress={e => { + e.stopPropagation(); + handleAddObservation(item); + }}> + + Add + - - {isExpanded && formObservations.length > 0 && ( + + {isExpanded && ( - {formObservations.map(observation => ( - - {renderObservationItem({ item: observation })} - - ))} + {formObservations.length > 0 ? ( + formObservations.map(observation => + renderObservationItem(observation, item), + ) + ) : ( + + + + )} )} - - {isExpanded && formObservations.length === 0 && ( - No observations found - )} ); }; - + + if (loading && formSpecs.length === 0) { + return ( + + + + Loading forms... + + + ); + } + return ( - - Form Management - - {loading ? ( - - ) : formSpecs.length > 0 ? ( + + + Form Management + + {formSpecs.length} form{formSpecs.length !== 1 ? 's' : ''} available + + + + {formSpecs.length > 0 ? ( <> item.id} + keyExtractor={item => item.id} style={styles.formTypesList} + contentContainerStyle={styles.listContent} + refreshControl={ + + } /> - - - Reset Database - - - {/* Debug button */} - { - if (!formService) { - Alert.alert('Error', 'FormService is not initialized'); - return; - } - try { - await formService.debugDatabase(); - Alert.alert('Debug', 'Check console logs for debug information'); - // Reload data after debugging - await loadData(); - } catch (error) { - console.error('Debug error:', error); - Alert.alert('Error', 'Debug failed'); - } - }} - > - Debug Database - + + + + + Reset Database + + ) : ( - - No Forms Available - - No form specifications have been downloaded yet. To get started: - - 1. Go to Settings and configure your server URL - 2. Log in with your credentials - 3. Go to Sync screen and tap "Update App Bundle" - - This will download the latest forms and app content from your server. - - + )} - + ); }; const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: '#F0F2F5', + }, + header: { padding: 16, - backgroundColor: '#f5f5f5', + backgroundColor: '#FFFFFF', + borderBottomWidth: 1, + borderBottomColor: '#E5E5E5', }, title: { - fontSize: 24, + fontSize: 28, fontWeight: 'bold', - marginBottom: 16, + color: '#333333', + marginBottom: 4, + }, + subtitle: { + fontSize: 14, + color: '#666666', }, formTypesList: { flex: 1, }, + listContent: { + paddingVertical: 8, + }, formTypeContainer: { - marginBottom: 16, - backgroundColor: '#fff', - borderRadius: 8, + marginBottom: 12, + backgroundColor: '#FFFFFF', + borderRadius: 12, + marginHorizontal: 16, overflow: 'hidden', - elevation: 2, shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.2, - shadowRadius: 1.41, + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, }, formTypeHeader: { - padding: 16, flexDirection: 'row', - justifyContent: 'space-between', + alignItems: 'center', + padding: 16, }, formTypeInfo: { flex: 1, + flexDirection: 'row', + alignItems: 'center', + }, + iconContainer: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: '#E3F2FD', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + textContainer: { + flex: 1, }, formTypeName: { - fontSize: 18, - fontWeight: 'bold', + fontSize: 16, + fontWeight: '600', + color: '#333', + marginBottom: 4, }, formTypeDescription: { fontSize: 14, color: '#666', - marginTop: 4, + marginBottom: 8, + }, + metaContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, }, - formTypeVersion: { + version: { fontSize: 12, - color: '#888', - marginTop: 4, + color: '#999', }, - formTypeActions: { - alignItems: 'flex-end', - justifyContent: 'center', + countBadge: { + backgroundColor: '#E3F2FD', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 10, }, - observationCount: { - fontSize: 14, - color: '#666', - marginBottom: 8, + countText: { + fontSize: 11, + color: '#007AFF', + fontWeight: '500', + }, + formTypeActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, }, addButton: { - backgroundColor: '#007bff', + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#007AFF', paddingHorizontal: 12, paddingVertical: 6, - borderRadius: 4, + borderRadius: 8, + gap: 4, }, buttonText: { - color: '#fff', - fontWeight: 'bold', + color: '#FFFFFF', + fontWeight: '600', + fontSize: 14, }, observationsWrapper: { borderTopWidth: 1, - borderTopColor: '#eee', - }, - observationItem: { - padding: 12, - borderTopWidth: 1, - borderTopColor: '#eee', - backgroundColor: '#f9f9f9', - }, - observationId: { - fontWeight: 'bold', - marginBottom: 4, - }, - observationActions: { - flexDirection: 'row', - justifyContent: 'flex-start', - marginTop: 8, - gap: 8, - }, - actionButton: { - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 4, - backgroundColor: '#28a745', + borderTopColor: '#E5E5E5', + paddingTop: 8, }, - editButton: { - backgroundColor: '#007bff', + noObservationsContainer: { + padding: 16, }, - deleteButton: { - backgroundColor: '#dc3545', + footer: { + padding: 16, + backgroundColor: '#FFFFFF', + borderTopWidth: 1, + borderTopColor: '#E5E5E5', }, resetButton: { - backgroundColor: '#dc3545', - padding: 12, - borderRadius: 4, + flexDirection: 'row', alignItems: 'center', - marginHorizontal: 16, - }, - noForms: { - fontSize: 16, - textAlign: 'center', - marginTop: 32, - color: '#666', + justifyContent: 'center', + backgroundColor: '#FF3B30', + padding: 12, + borderRadius: 8, + gap: 8, }, - noFormsContainer: { + loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', - paddingHorizontal: 32, }, - noFormsTitle: { - fontSize: 20, - fontWeight: 'bold', - color: '#333', - marginBottom: 16, - textAlign: 'center', - }, - noFormsMessage: { + loadingText: { + marginTop: 12, fontSize: 16, color: '#666', - textAlign: 'center', - marginBottom: 20, - lineHeight: 22, - }, - noFormsStep: { - fontSize: 14, - color: '#555', - marginBottom: 8, - textAlign: 'left', - alignSelf: 'stretch', - }, - noFormsNote: { - fontSize: 14, - color: '#888', - textAlign: 'center', - marginTop: 16, - fontStyle: 'italic', - lineHeight: 20, - }, - noObservations: { - padding: 16, - textAlign: 'center', - color: '#666', - borderTopWidth: 1, - borderTopColor: '#eee', - }, - loader: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', }, }); diff --git a/formulus/src/screens/FormsScreen.tsx b/formulus/src/screens/FormsScreen.tsx index e8a774c2a..4f314559a 100644 --- a/formulus/src/screens/FormsScreen.tsx +++ b/formulus/src/screens/FormsScreen.tsx @@ -1,13 +1,118 @@ -import React from 'react'; -import {View, Text, StyleSheet, SafeAreaView} from 'react-native'; +import React, {useState} from 'react'; +import { + View, + Text, + StyleSheet, + SafeAreaView, + FlatList, + RefreshControl, + ActivityIndicator, + Alert, +} from 'react-native'; +import {useForms} from '../hooks/useForms'; +import {FormCard, EmptyState} from '../components/common'; +import {openFormplayerFromNative} from '../webview/FormulusMessageHandlers'; +import {useFocusEffect} from '@react-navigation/native'; +import colors from '../theme/colors'; const FormsScreen: React.FC = () => { + const {forms, loading, error, refresh, getObservationCount} = useForms(); + const [refreshing, setRefreshing] = useState(false); + + useFocusEffect( + React.useCallback(() => { + refresh(); + }, [refresh]), + ); + + const handleRefresh = async () => { + setRefreshing(true); + try { + await refresh(); + } finally { + setRefreshing(false); + } + }; + + const handleFormPress = async (formId: string) => { + try { + const result = await openFormplayerFromNative(formId, {}, {}); + if ( + result.status === 'form_submitted' || + result.status === 'form_updated' + ) { + await refresh(); + } + } catch (error) { + console.error('Error opening form:', error); + Alert.alert('Error', 'Failed to open form. Please try again.'); + } + }; + + const renderForm = ({item}: {item: any}) => { + const observationCount = getObservationCount(item.id); + return ( + handleFormPress(item.id)} + /> + ); + }; + + if (loading && forms.length === 0) { + return ( + + + + Loading forms... + + + ); + } + + if (error && forms.length === 0) { + return ( + + + + ); + } + return ( - + Forms - Forms will be displayed here + {forms.length > 0 && ( + + {forms.length} form{forms.length !== 1 ? 's' : ''} available + + )} + + {forms.length === 0 ? ( + + ) : ( + item.id} + contentContainerStyle={styles.listContent} + refreshControl={ + + } + /> + )} ); }; @@ -15,23 +120,36 @@ const FormsScreen: React.FC = () => { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#F0F2F5', + backgroundColor: colors.neutral[50], }, - content: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 24, + header: { + padding: 16, + backgroundColor: colors.neutral.white, + borderBottomWidth: 1, + borderBottomColor: colors.neutral[200], }, title: { - fontSize: 24, + fontSize: 28, fontWeight: 'bold', - color: '#333333', - marginBottom: 8, + color: colors.neutral[900], + marginBottom: 4, }, subtitle: { + fontSize: 14, + color: colors.neutral[600], + }, + listContent: { + paddingVertical: 8, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 12, fontSize: 16, - color: '#666666', + color: colors.neutral[600], }, }); diff --git a/formulus/src/screens/ObservationDetailScreen.tsx b/formulus/src/screens/ObservationDetailScreen.tsx new file mode 100644 index 000000000..be5c23124 --- /dev/null +++ b/formulus/src/screens/ObservationDetailScreen.tsx @@ -0,0 +1,419 @@ +import React, {useState, useEffect} from 'react'; +import { + View, + Text, + StyleSheet, + SafeAreaView, + ScrollView, + TouchableOpacity, + Alert, + ActivityIndicator, +} from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import {Observation} from '../database/models/Observation'; +import {FormService} from '../services/FormService'; +import {openFormplayerFromNative} from '../webview/FormulusMessageHandlers'; +import {useNavigation} from '@react-navigation/native'; +import colors from '../theme/colors'; + +interface ObservationDetailScreenProps { + route: { + params: { + observationId: string; + }; + }; +} + +const ObservationDetailScreen: React.FC = ({route}) => { + const {observationId} = route.params; + const navigation = useNavigation(); + const [observation, setObservation] = useState(null); + const [formName, setFormName] = useState(''); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadObservation(); + }, [observationId]); + + const loadObservation = async () => { + try { + setLoading(true); + const formService = await FormService.getInstance(); + + // Get all form types to find the observation + const formSpecs = formService.getFormSpecs(); + let foundObservation: Observation | null = null; + + for (const formSpec of formSpecs) { + const observations = await formService.getObservationsByFormType(formSpec.id); + const obs = observations.find(o => o.observationId === observationId); + if (obs) { + foundObservation = obs; + setFormName(formSpec.name); + break; + } + } + + if (!foundObservation) { + Alert.alert('Error', 'Observation not found'); + navigation.goBack(); + return; + } + + setObservation(foundObservation); + } catch (error) { + console.error('Error loading observation:', error); + Alert.alert('Error', 'Failed to load observation'); + navigation.goBack(); + } finally { + setLoading(false); + } + }; + + const handleEdit = async () => { + if (!observation) return; + + try { + const result = await openFormplayerFromNative( + observation.formType, + {}, + typeof observation.data === 'string' + ? JSON.parse(observation.data) + : observation.data, + observation.observationId, + ); + if (result.status === 'form_submitted' || result.status === 'form_updated') { + await loadObservation(); + Alert.alert('Success', 'Observation updated successfully'); + } + } catch (error) { + console.error('Error editing observation:', error); + Alert.alert('Error', 'Failed to edit observation'); + } + }; + + const handleDelete = () => { + if (!observation) return; + + Alert.alert( + 'Delete Observation', + 'Are you sure you want to delete this observation? This action cannot be undone.', + [ + {text: 'Cancel', style: 'cancel'}, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + try { + const formService = await FormService.getInstance(); + await formService.deleteObservation(observation.observationId); + Alert.alert('Success', 'Observation deleted successfully'); + navigation.goBack(); + } catch (error) { + console.error('Error deleting observation:', error); + Alert.alert('Error', 'Failed to delete observation'); + } + }, + }, + ], + ); + }; + + const renderDataField = (key: string, value: any, level: number = 0) => { + if (value === null || value === undefined) { + return ( + + {key}: + null + + ); + } + + if (typeof value === 'object' && !Array.isArray(value)) { + return ( + + {key}: + {Object.entries(value).map(([k, v]) => renderDataField(k, v, level + 1))} + + ); + } + + if (Array.isArray(value)) { + return ( + + {key}: + {value.map((item, index) => ( + + {typeof item === 'object' && item !== null + ? Object.entries(item).map(([k, v]) => renderDataField(k, v, level + 2)) + : renderDataField(`${index}`, item, level + 1)} + + ))} + + ); + } + + return ( + + {key}: + {String(value)} + + ); + }; + + if (loading) { + return ( + + + + Loading observation... + + + ); + } + + if (!observation) { + return ( + + + Observation not found + + + ); + } + + const isSynced = observation.syncedAt && observation.syncedAt.getTime() > new Date('1980-01-01').getTime(); + const data = typeof observation.data === 'string' ? JSON.parse(observation.data) : observation.data; + + return ( + + + navigation.goBack()} style={styles.backButton}> + + + Observation Details + + + + + + + + + + + + + Basic Information + + Form Type: + {formName || observation.formType} + + + Observation ID: + {observation.observationId} + + + Created: + + {observation.createdAt.toLocaleString()} + + + + Updated: + + {observation.updatedAt.toLocaleString()} + + + + Status: + + + + {isSynced ? 'Synced' : 'Pending'} + + + + {isSynced && observation.syncedAt && ( + + Synced At: + + {observation.syncedAt.toLocaleString()} + + + )} + + + {observation.geolocation && ( + + Location + + Latitude: + + {typeof observation.geolocation === 'string' + ? JSON.parse(observation.geolocation).latitude + : observation.geolocation.latitude} + + + + Longitude: + + {typeof observation.geolocation === 'string' + ? JSON.parse(observation.geolocation).longitude + : observation.geolocation.longitude} + + + + )} + + + Form Data + + {Object.entries(data).map(([key, value]) => renderDataField(key, value))} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.neutral[50], + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 16, + backgroundColor: colors.neutral.white, + borderBottomWidth: 1, + borderBottomColor: colors.neutral[200], + }, + backButton: { + padding: 4, + }, + headerTitle: { + fontSize: 18, + fontWeight: '600', + color: colors.neutral[900], + flex: 1, + textAlign: 'center', + }, + headerActions: { + flexDirection: 'row', + gap: 8, + }, + actionButton: { + padding: 4, + }, + content: { + flex: 1, + }, + contentContainer: { + padding: 16, + }, + section: { + backgroundColor: colors.neutral.white, + borderRadius: 12, + padding: 16, + marginBottom: 16, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + color: colors.neutral[900], + marginBottom: 12, + }, + infoRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + }, + infoLabel: { + fontSize: 14, + color: colors.neutral[600], + fontWeight: '500', + }, + infoValue: { + fontSize: 14, + color: colors.neutral[900], + flex: 1, + textAlign: 'right', + }, + monoText: { + fontFamily: 'monospace', + fontSize: 12, + }, + statusBadge: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 12, + gap: 4, + }, + syncedBadge: { + backgroundColor: colors.semantic.success[50], + }, + pendingBadge: { + backgroundColor: colors.semantic.warning[50], + }, + statusText: { + fontSize: 12, + fontWeight: '500', + color: colors.neutral[900], + }, + dataContainer: { + marginTop: 8, + }, + fieldContainer: { + marginBottom: 8, + paddingBottom: 8, + borderBottomWidth: 1, + borderBottomColor: colors.neutral[100], + }, + fieldKey: { + fontSize: 14, + fontWeight: '600', + color: colors.brand.primary[500], + marginBottom: 4, + }, + fieldValue: { + fontSize: 14, + color: colors.neutral[900], + marginLeft: 8, + }, + arrayItem: { + marginLeft: 16, + marginTop: 4, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 12, + fontSize: 16, + color: colors.neutral[600], + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + errorText: { + fontSize: 16, + color: colors.semantic.error[500], + }, +}); + +export default ObservationDetailScreen; + diff --git a/formulus/src/screens/ObservationsScreen.tsx b/formulus/src/screens/ObservationsScreen.tsx index 60d507142..9b0d3065d 100644 --- a/formulus/src/screens/ObservationsScreen.tsx +++ b/formulus/src/screens/ObservationsScreen.tsx @@ -1,13 +1,287 @@ -import React from 'react'; -import {View, Text, StyleSheet, SafeAreaView} from 'react-native'; +import React, {useState, useMemo, useCallback} from 'react'; +import { + View, + Text, + StyleSheet, + SafeAreaView, + FlatList, + RefreshControl, + ActivityIndicator, + Alert, + TouchableOpacity, + TextInput, + Modal, +} from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import {useObservations} from '../hooks/useObservations'; +import { + ObservationCard, + EmptyState, + FormTypeSelector, + SyncStatusButtons, + SyncStatus, +} from '../components/common'; +import {openFormplayerFromNative} from '../webview/FormulusMessageHandlers'; +import {FormService} from '../services/FormService'; +import {useFocusEffect, useNavigation} from '@react-navigation/native'; +import {StackNavigationProp} from '@react-navigation/stack'; +import {MainAppStackParamList} from '../types/NavigationTypes'; +import {Observation} from '../database/models/Observation'; +import colors from '../theme/colors'; + +type ObservationsScreenNavigationProp = StackNavigationProp< + MainAppStackParamList, + 'ObservationDetail' +>; const ObservationsScreen: React.FC = () => { + const navigation = useNavigation(); + const { + filteredAndSorted, + loading, + error, + refresh, + searchQuery, + setSearchQuery, + sortOption, + setSortOption, + } = useObservations(); + const [refreshing, setRefreshing] = useState(false); + const [formNames, setFormNames] = useState>({}); + const [formTypes, setFormTypes] = useState<{id: string; name: string}[]>([]); + const [selectedFormType, setSelectedFormType] = useState(null); + const [syncStatus, setSyncStatus] = useState('all'); + const [showSearch, setShowSearch] = useState(false); + + useFocusEffect( + React.useCallback(() => { + const loadFormData = async () => { + try { + const formService = await FormService.getInstance(); + const formSpecs = formService.getFormSpecs(); + const names: Record = {}; + const types: {id: string; name: string}[] = []; + formSpecs.forEach(form => { + names[form.id] = form.name; + types.push({id: form.id, name: form.name}); + }); + setFormNames(names); + setFormTypes(types); + } catch (err) { + console.error('Failed to load form data:', err); + } + }; + loadFormData(); + refresh(); + }, [refresh]), + ); + + const finalFiltered = useMemo(() => { + let filtered = filteredAndSorted; + + if (selectedFormType) { + filtered = filtered.filter(obs => obs.formType === selectedFormType); + } + + if (syncStatus !== 'all') { + filtered = filtered.filter(obs => { + const isSynced = + obs.syncedAt && + obs.syncedAt.getTime() > new Date('1980-01-01').getTime(); + return syncStatus === 'synced' ? isSynced : !isSynced; + }); + } + + return filtered; + }, [filteredAndSorted, selectedFormType, syncStatus]); + + const handleRefresh = async () => { + setRefreshing(true); + try { + await refresh(); + } finally { + setRefreshing(false); + } + }; + + const handleObservationPress = (observation: Observation) => { + navigation.navigate('ObservationDetail', { + observationId: observation.observationId, + }); + }; + + const handleEditObservation = async (observation: Observation) => { + try { + const result = await openFormplayerFromNative( + observation.formType, + {}, + typeof observation.data === 'string' + ? JSON.parse(observation.data) + : observation.data, + observation.observationId, + ); + if ( + result.status === 'form_submitted' || + result.status === 'form_updated' + ) { + await refresh(); + } + } catch (error) { + console.error('Error editing observation:', error); + Alert.alert('Error', 'Failed to edit observation. Please try again.'); + } + }; + + const handleDeleteObservation = async (observation: Observation) => { + Alert.alert( + 'Delete Observation', + 'Are you sure you want to delete this observation? This action cannot be undone.', + [ + {text: 'Cancel', style: 'cancel'}, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + try { + const formService = await FormService.getInstance(); + await formService.deleteObservation(observation.observationId); + await refresh(); + } catch (error) { + console.error('Error deleting observation:', error); + Alert.alert('Error', 'Failed to delete observation.'); + } + }, + }, + ], + ); + }; + + const renderObservation = ({item}: {item: Observation}) => { + return ( + handleObservationPress(item)} + onEdit={() => handleEditObservation(item)} + onDelete={() => handleDeleteObservation(item)} + /> + ); + }; + + if (loading && filteredAndSorted.length === 0) { + return ( + + + + Loading observations... + + + ); + } + + if (error && filteredAndSorted.length === 0) { + return ( + + + + ); + } + return ( - - Observations - Observations will be displayed here + + + Observations + {finalFiltered.length > 0 && ( + + {finalFiltered.length} observation + {finalFiltered.length !== 1 ? 's' : ''} + + )} + + setShowSearch(!showSearch)}> + + + + + {showSearch && ( + + + + {searchQuery.length > 0 && ( + setSearchQuery('')}> + + + )} + + )} + + + + + + + + + + {finalFiltered.length === 0 ? ( + + ) : ( + item.observationId} + contentContainerStyle={styles.listContent} + refreshControl={ + + } + /> + )} ); }; @@ -15,23 +289,80 @@ const ObservationsScreen: React.FC = () => { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#F0F2F5', + backgroundColor: colors.neutral[50], }, - content: { + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + padding: 16, + backgroundColor: colors.neutral.white, + borderBottomWidth: 1, + borderBottomColor: colors.neutral[200], + }, + headerLeft: { flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 24, }, title: { - fontSize: 24, + fontSize: 28, fontWeight: 'bold', - color: '#333333', - marginBottom: 8, + color: colors.neutral[900], + marginBottom: 4, }, subtitle: { + fontSize: 14, + color: colors.neutral[600], + }, + searchButton: { + padding: 4, + marginTop: 4, + }, + searchContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.neutral.white, + marginHorizontal: 16, + marginTop: 12, + marginBottom: 8, + paddingHorizontal: 12, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.neutral[200], + }, + searchIcon: { + marginRight: 8, + }, + searchInput: { + flex: 1, + fontSize: 16, + color: colors.neutral[900], + paddingVertical: 10, + }, + filtersContainer: { + backgroundColor: colors.neutral.white, + padding: 16, + borderBottomWidth: 1, + borderBottomColor: colors.neutral[200], + gap: 12, + }, + filterRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 12, + }, + listContent: { + paddingVertical: 8, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 12, fontSize: 16, - color: '#666666', + color: colors.neutral[600], }, }); diff --git a/formulus/src/screens/SyncScreen.tsx b/formulus/src/screens/SyncScreen.tsx index 5b76b8edf..0bfc88068 100644 --- a/formulus/src/screens/SyncScreen.tsx +++ b/formulus/src/screens/SyncScreen.tsx @@ -1,20 +1,23 @@ -import React, { useEffect, useState, useCallback } from 'react'; -import { - View, - Text, - StyleSheet, - TouchableOpacity, +import React, {useEffect, useState, useCallback} from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, Alert, SafeAreaView, ScrollView, - AppState + ActivityIndicator, } from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { syncService } from '../services/SyncService'; -import { useSyncContext } from '../contexts/SyncContext'; +import {formatRelativeTime} from '../utils/dateUtils'; +import {syncService} from '../services/SyncService'; +import {useSyncContext} from '../contexts/SyncContext'; import RNFS from 'react-native-fs'; -import { databaseService } from '../database/DatabaseService'; +import {databaseService} from '../database/DatabaseService'; import {getUserInfo} from '../api/synkronus/Auth'; +import colors from '../theme/colors'; const SyncScreen = () => { const { @@ -26,30 +29,21 @@ const SyncScreen = () => { clearError, } = useSyncContext(); const [lastSync, setLastSync] = useState(null); - const [status, setStatus] = useState('Loading...'); const [updateAvailable, setUpdateAvailable] = useState(false); - const [dataVersion, setDataVersion] = useState(0); const [pendingUploads, setPendingUploads] = useState<{ count: number; sizeMB: number; }>({count: 0, sizeMB: 0}); const [pendingObservations, setPendingObservations] = useState(0); - const [backgroundSyncEnabled, setBackgroundSyncEnabled] = - useState(false); const [isAdmin, setIsAdmin] = useState(false); const [appBundleVersion, setAppBundleVersion] = useState('0'); const [serverBundleVersion, setServerBundleVersion] = useState('Unknown'); - // Get pending upload info const updatePendingUploads = useCallback(async () => { try { const pendingUploadDirectory = `${RNFS.DocumentDirectoryPath}/attachments/pending_upload`; - - // Ensure directory exists await RNFS.mkdir(pendingUploadDirectory); - - // Get all files in pending_upload directory const files = await RNFS.readDir(pendingUploadDirectory); const attachmentFiles = files.filter(file => file.isFile()); @@ -67,7 +61,6 @@ const SyncScreen = () => { } }, []); - // Get pending observations count const updatePendingObservations = useCallback(async () => { try { const repo = databaseService.getLocalRepo(); @@ -79,53 +72,66 @@ const SyncScreen = () => { } }, []); - // Handle sync operations const handleSync = useCallback(async () => { - if (syncState.isActive) return; // Prevent multiple syncs + if (syncState.isActive) return; try { - startSync(true); // Allow cancellation - const version = await syncService.syncObservations(true); - setDataVersion(version); - // Update pending uploads and observations after sync + startSync(true); + await syncService.syncObservations(true); await updatePendingUploads(); await updatePendingObservations(); - finishSync(); // Success + finishSync(); + const syncTime = new Date().toISOString(); + setLastSync(syncTime); + await AsyncStorage.setItem('@lastSync', syncTime); } catch (error) { const errorMessage = (error as Error).message; - finishSync(errorMessage); // Finish with error + finishSync(errorMessage); Alert.alert('Error', 'Failed to sync!\n' + errorMessage); } - }, [updatePendingUploads, syncState.isActive, startSync, finishSync]); + }, [ + updatePendingUploads, + updatePendingObservations, + syncState.isActive, + startSync, + finishSync, + ]); - // Handle app updates const handleCustomAppUpdate = useCallback(async () => { - if (syncState.isActive) return; // Prevent multiple syncs + if (syncState.isActive) return; try { - startSync(false); // App updates can't be cancelled easily + startSync(false); await syncService.updateAppBundle(); - setLastSync(new Date().toLocaleTimeString()); + const syncTime = new Date().toISOString(); + setLastSync(syncTime); + await AsyncStorage.setItem('@lastSync', syncTime); setUpdateAvailable(false); - finishSync(); // Success + finishSync(); + await updatePendingUploads(); + await updatePendingObservations(); + const formService = await import('../services/FormService'); + const fs = await formService.FormService.getInstance(); + await fs.invalidateCache(); } catch (error) { const errorMessage = (error as Error).message; - finishSync(errorMessage); // Finish with error + finishSync(errorMessage); Alert.alert('Error', 'Failed to update app bundle!\n' + errorMessage); } - }, [syncState.isActive, startSync, finishSync]); + }, [ + syncState.isActive, + startSync, + finishSync, + updatePendingUploads, + updatePendingObservations, + ]); - // Check for updates const checkForUpdates = useCallback(async (force: boolean = false) => { try { const hasUpdate = await syncService.checkForUpdates(force); setUpdateAvailable(hasUpdate); - - // Get current app bundle version const currentVersion = (await AsyncStorage.getItem('@appVersion')) || '0'; setAppBundleVersion(currentVersion); - - // Get server bundle version try { const {synkronusApi} = await import('../api/synkronus/index'); const manifest = await synkronusApi.getManifest(); @@ -138,87 +144,219 @@ const SyncScreen = () => { } }, []); - // Initialize component + const getDataSyncStatus = (): string => { + if (syncState.isActive) { + return syncState.progress?.details || 'Syncing...'; + } + if (syncState.error) { + return 'Error'; + } + if (pendingObservations > 0 || pendingUploads.count > 0) { + return 'Pending Sync'; + } + return 'Ready'; + }; + + const status = getDataSyncStatus(); + const statusColor = syncState.isActive + ? colors.brand.primary[500] + : syncState.error + ? colors.semantic.error[500] + : pendingObservations > 0 || pendingUploads.count > 0 + ? colors.semantic.warning[500] + : colors.semantic.success[500]; + useEffect(() => { - // Set up status updates - const unsubscribe = syncService.subscribeToStatusUpdates(newStatus => { - setStatus(newStatus); - }); + const unsubscribe = syncService.subscribeToStatusUpdates(() => {}); - // Initialize sync service const initialize = async () => { await syncService.initialize(); - await checkForUpdates(true); // Check for updates on initial load - - // Check if user is admin + await checkForUpdates(true); const userInfo = await getUserInfo(); setIsAdmin(userInfo?.role === 'admin'); - - // Get last sync time const lastSyncTime = await AsyncStorage.getItem('@lastSync'); if (lastSyncTime) { setLastSync(lastSyncTime); } - - // Get last seen version - const lastSeenVersion = await AsyncStorage.getItem('@last_seen_version'); - if (lastSeenVersion) { - setDataVersion(parseInt(lastSeenVersion, 10)); - } - - // Get pending uploads and observations info await updatePendingUploads(); await updatePendingObservations(); }; initialize(); - // Clean up subscription return () => { unsubscribe(); }; - }, [checkForUpdates]); + }, [checkForUpdates, updatePendingUploads, updatePendingObservations]); + + useEffect(() => { + if (!syncState.isActive && !syncState.error) { + updatePendingUploads(); + updatePendingObservations(); + checkForUpdates(false); + } + }, [ + syncState.isActive, + syncState.error, + updatePendingUploads, + updatePendingObservations, + checkForUpdates, + ]); return ( - - Synchronization - - - Status: - {status} - - - - Last Sync: - - {lastSync || 'Never'} @ version {dataVersion} - - + + Sync + + {syncState.isActive ? 'Syncing...' : 'Synchronize your data'} + + + + + + 0 || pendingUploads.count > 0) && + styles.statusCardClickable, + ]} + onPress={() => { + if ( + !syncState.isActive && + (pendingObservations > 0 || pendingUploads.count > 0) + ) { + handleSync(); + } + }} + disabled={syncState.isActive} + activeOpacity={ + syncState.isActive || + (pendingObservations === 0 && pendingUploads.count === 0) + ? 1 + : 0.7 + }> + + 0 || pendingUploads.count > 0 + ? 'clock-alert-outline' + : 'check-circle' + } + size={20} + color={statusColor} + /> + Status + + + {status} + + {!syncState.isActive && + (pendingObservations > 0 || pendingUploads.count > 0) && ( + Tap to sync now + )} + {!syncState.isActive && + pendingObservations === 0 && + pendingUploads.count === 0 && ( + All synced + )} + - - App Bundle: - - Local: {appBundleVersion} | Server: {serverBundleVersion} - + + + + Last Sync + + + {lastSync ? formatRelativeTime(lastSync) : 'Never'} + + - - Pending Uploads: - - {pendingUploads.count} files ({pendingUploads.sizeMB.toFixed(2)} MB) - - + {(pendingObservations > 0 || pendingUploads.count > 0) && ( + + Pending Items + {pendingObservations > 0 && ( + + + + Observations + + {pendingObservations} record + {pendingObservations !== 1 ? 's' : ''} + + + + )} + {pendingUploads.count > 0 && ( + + + + Attachments + + {pendingUploads.count} file + {pendingUploads.count !== 1 ? 's' : ''} ( + {pendingUploads.sizeMB.toFixed(2)} MB) + + + + )} + + )} - - Pending Observations: - {pendingObservations} records + + + App Bundle + + + Local + {appBundleVersion} + + + + Server + + {serverBundleVersion} + + + + + {updateAvailable && ( + + + Update available + + )} - {/* Sync Progress Display */} {syncState.isActive && syncState.progress && ( - - Sync Progress + + + + Sync Progress + {syncState.progress.details || 'Syncing...'} @@ -246,67 +384,77 @@ const SyncScreen = () => { - Cancel Sync + Cancel )} )} - {/* Error Display */} {syncState.error && ( - + + + + Error + {syncState.error} - - Dismiss + + Dismiss )} - + - - {syncState.isActive ? 'Syncing...' : 'Sync data + attachments'} + {syncState.isActive ? ( + + ) : ( + + )} + + {syncState.isActive ? 'Syncing...' : 'Sync Data'} - - {syncState.isActive - ? 'Syncing...' - : 'Update forms and custom app'} + {syncState.isActive ? ( + + ) : ( + + )} + + {syncState.isActive ? 'Updating...' : 'Update App Bundle'} - {!updateAvailable && !isAdmin && ( - - No updates available. Check your connection and try again. - + + {updateAvailable && ( + Update available )} - - - Sync Details - - Server: - Synkronus - - - Last Updated: - - {new Date().toLocaleDateString()} - - + {!updateAvailable && !isAdmin && ( + No updates available + )} @@ -316,169 +464,301 @@ const SyncScreen = () => { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#f5f5f5', + backgroundColor: colors.neutral[50], }, - scrollContainer: { - flexGrow: 1, - padding: 20, + header: { + padding: 16, + backgroundColor: colors.neutral.white, + borderBottomWidth: 1, + borderBottomColor: colors.neutral[200], }, title: { - fontSize: 24, - fontWeight: '600', - marginBottom: 20, - color: '#333', - textAlign: 'center', + fontSize: 28, + fontWeight: 'bold', + color: colors.neutral[900], + marginBottom: 4, + }, + subtitle: { + fontSize: 14, + color: colors.neutral[600], + }, + scrollContent: { + padding: 16, + paddingBottom: 32, }, - statusContainer: { + statusCardsContainer: { flexDirection: 'row', - justifyContent: 'space-between', - marginBottom: 15, - padding: 15, - backgroundColor: 'white', - borderRadius: 8, - shadowColor: '#000', - shadowOffset: {width: 0, height: 1}, + gap: 12, + marginBottom: 16, + }, + statusCard: { + flex: 1, + backgroundColor: colors.neutral.white, + borderRadius: 12, + padding: 16, + shadowColor: colors.neutral.black, + shadowOffset: {width: 0, height: 2}, shadowOpacity: 0.1, - shadowRadius: 2, - elevation: 2, + shadowRadius: 4, + elevation: 3, }, - statusLabel: { - fontSize: 16, - color: '#666', + statusCardClickable: { + borderWidth: 2, + borderColor: colors.brand.primary[200], }, - statusValue: { - fontSize: 16, + statusCardHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 8, + }, + statusCardTitle: { + fontSize: 12, fontWeight: '500', - color: '#333', + color: colors.neutral[600], + textTransform: 'uppercase', }, - section: { - marginTop: 20, - backgroundColor: 'white', - borderRadius: 8, + statusCardValue: { + fontSize: 16, + fontWeight: '600', + color: colors.neutral[900], + }, + statusCardSubtext: { + fontSize: 12, + color: colors.neutral[500], + marginTop: 4, + }, + pendingSection: { + backgroundColor: colors.neutral.white, + borderRadius: 12, padding: 16, - shadowColor: '#000', - shadowOffset: {width: 0, height: 1}, + marginBottom: 16, + shadowColor: colors.neutral.black, + shadowOffset: {width: 0, height: 2}, shadowOpacity: 0.1, - shadowRadius: 2, - elevation: 2, - marginBottom: 20, + shadowRadius: 4, + elevation: 3, }, - sectionHeader: { - fontSize: 18, + sectionTitle: { + fontSize: 16, fontWeight: '600', - marginBottom: 16, - color: '#333', + color: colors.neutral[900], + marginBottom: 12, }, - button: { - backgroundColor: '#007AFF', - borderRadius: 8, - padding: 16, + pendingItem: { + flexDirection: 'row', alignItems: 'center', - justifyContent: 'center', - marginBottom: 10, + gap: 12, + paddingVertical: 8, }, - buttonText: { - color: '#fff', - fontSize: 16, + pendingItemContent: { + flex: 1, + }, + pendingItemLabel: { + fontSize: 14, + color: colors.neutral[600], + marginBottom: 2, + }, + pendingItemValue: { + fontSize: 14, fontWeight: '600', + color: colors.neutral[900], }, - buttonDisabled: { - opacity: 0.6, + versionCard: { + backgroundColor: colors.neutral.white, + borderRadius: 12, + padding: 16, + marginBottom: 16, + shadowColor: colors.neutral.black, + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, }, - detailItem: { + versionRow: { flexDirection: 'row', justifyContent: 'space-between', - marginBottom: 12, + alignItems: 'center', + }, + versionLabel: { + fontSize: 14, + fontWeight: '500', + color: colors.neutral[600], + }, + versionValues: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + versionItem: { + alignItems: 'flex-end', }, - detailLabel: { - color: '#666', - fontSize: 15, + versionItemLabel: { + fontSize: 11, + color: colors.neutral[500], + marginBottom: 2, }, - detailValue: { - color: '#333', - fontSize: 15, + versionItemValue: { + fontSize: 14, + fontWeight: '600', + color: colors.neutral[900], + }, + versionDivider: { + width: 1, + height: 20, + backgroundColor: colors.neutral[200], + }, + updateBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginTop: 12, + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: colors.neutral[200], + }, + updateBadgeText: { + fontSize: 12, + color: colors.semantic.success[500], fontWeight: '500', }, - progressContainer: { - marginTop: 15, - padding: 15, - backgroundColor: '#e3f2fd', - borderRadius: 8, + progressCard: { + backgroundColor: colors.brand.primary[50], + borderRadius: 12, + padding: 16, + marginBottom: 16, borderLeftWidth: 4, - borderLeftColor: '#2196f3', + borderLeftColor: colors.brand.primary[500], + }, + progressHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 12, }, progressTitle: { fontSize: 16, fontWeight: '600', - color: '#1976d2', - marginBottom: 8, + color: colors.brand.primary[500], }, progressDetails: { fontSize: 14, - color: '#666', - marginBottom: 10, + color: colors.neutral[600], + marginBottom: 12, }, progressBar: { height: 8, - backgroundColor: '#e0e0e0', + backgroundColor: colors.brand.primary[200], borderRadius: 4, marginBottom: 8, overflow: 'hidden', }, progressFill: { height: '100%', - backgroundColor: '#2196f3', + backgroundColor: colors.brand.primary[500], borderRadius: 4, }, progressText: { fontSize: 12, - color: '#666', + color: colors.neutral[600], textAlign: 'center', - marginBottom: 10, + marginBottom: 12, }, cancelButton: { - backgroundColor: '#f44336', + backgroundColor: colors.semantic.error[500], paddingVertical: 8, paddingHorizontal: 16, - borderRadius: 4, + borderRadius: 8, alignSelf: 'center', }, cancelButtonText: { - color: 'white', + color: colors.neutral.white, fontSize: 14, - fontWeight: '500', + fontWeight: '600', }, - errorContainer: { - marginTop: 15, - padding: 15, - backgroundColor: '#ffebee', - borderRadius: 8, + errorCard: { + backgroundColor: colors.semantic.error[50], + borderRadius: 12, + padding: 16, + marginBottom: 16, borderLeftWidth: 4, - borderLeftColor: '#f44336', + borderLeftColor: colors.semantic.error[500], + }, + errorHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 8, + }, + errorTitle: { + fontSize: 16, + fontWeight: '600', + color: colors.semantic.error[500], }, errorText: { fontSize: 14, - color: '#c62828', - marginBottom: 10, + color: colors.semantic.error[600], + marginBottom: 12, }, - clearErrorButton: { - backgroundColor: '#f44336', - paddingVertical: 6, - paddingHorizontal: 12, - borderRadius: 4, + dismissButton: { + backgroundColor: colors.semantic.error[500], + paddingVertical: 8, + paddingHorizontal: 16, + borderRadius: 8, alignSelf: 'flex-end', }, - clearErrorText: { - color: 'white', - fontSize: 12, - fontWeight: '500', + dismissButtonText: { + color: colors.neutral.white, + fontSize: 14, + fontWeight: '600', + }, + actionsSection: { + gap: 12, + }, + actionButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + padding: 16, + borderRadius: 12, + shadowColor: colors.neutral.black, + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + primaryButton: { + backgroundColor: colors.brand.primary[500], + }, + secondaryButton: { + backgroundColor: colors.neutral.white, + borderWidth: 2, + borderColor: colors.brand.primary[500], + }, + buttonDisabled: { + opacity: 0.6, + }, + actionButtonText: { + fontSize: 16, + fontWeight: '600', + color: colors.neutral.white, + }, + secondaryButtonText: { + color: colors.brand.primary[500], }, hintText: { fontSize: 12, - color: '#666', + color: colors.neutral[500], textAlign: 'center', - marginTop: 4, fontStyle: 'italic', + marginTop: 4, + }, + updateNotification: { + fontSize: 12, + color: colors.semantic.warning[600], + textAlign: 'center', + marginTop: 4, }, }); diff --git a/formulus/src/theme/colors.ts b/formulus/src/theme/colors.ts new file mode 100644 index 000000000..1924eea0a --- /dev/null +++ b/formulus/src/theme/colors.ts @@ -0,0 +1,75 @@ +/** + * ODE Design System Color Tokens + * Based on @ode/tokens package + * + * Primary: Green (#4F7F4E) + * Secondary: Gold (#E9B85B) + */ + +export const colors = { + brand: { + primary: { + 50: '#F0F7EF', + 100: '#D9E9D8', + 200: '#B9D5B8', + 300: '#90BD8F', + 400: '#6FA46E', + 500: '#4F7F4E', // Main brand color + 600: '#3F6A3E', + 700: '#30552F', + 800: '#224021', + 900: '#173016', + }, + secondary: { + 50: '#FEF9EE', + 100: '#FCEFD2', + 200: '#F9E0A8', + 300: '#F5CC75', + 400: '#F0B84D', + 500: '#E9B85B', // Secondary brand color (gold) + 600: '#D9A230', + 700: '#B8861C', + 800: '#976D1A', + 900: '#7C5818', + }, + }, + neutral: { + white: '#FFFFFF', + 50: '#FAFAFA', + 100: '#F5F5F5', + 200: '#EEEEEE', + 300: '#E0E0E0', + 400: '#BDBDBD', + 500: '#9E9E9E', + 600: '#757575', + 700: '#616161', + 800: '#424242', + 900: '#212121', + black: '#000000', + }, + semantic: { + success: { + 50: '#F0F9F0', + 500: '#34C759', + 600: '#2E7D32', + }, + error: { + 50: '#FEF2F2', + 500: '#F44336', + 600: '#DC2626', + }, + warning: { + 50: '#FFFBEB', + 500: '#FF9500', + 600: '#D97706', + }, + info: { + 50: '#EFF6FF', + 500: '#2196F3', + 600: '#2563EB', + }, + }, +}; + +export default colors; + diff --git a/formulus/src/types/NavigationTypes.ts b/formulus/src/types/NavigationTypes.ts index 5d51fc2d0..edf991768 100644 --- a/formulus/src/types/NavigationTypes.ts +++ b/formulus/src/types/NavigationTypes.ts @@ -11,4 +11,5 @@ export type MainAppStackParamList = { MainApp: undefined; Settings: undefined; FormManagement: undefined; + ObservationDetail: {observationId: string}; }; diff --git a/formulus/src/utils/dateUtils.ts b/formulus/src/utils/dateUtils.ts new file mode 100644 index 000000000..9b50dc618 --- /dev/null +++ b/formulus/src/utils/dateUtils.ts @@ -0,0 +1,49 @@ +export const formatRelativeTime = (date: Date | string | null): string => { + if (!date) { + return 'Never'; + } + + const dateObj = typeof date === 'string' ? new Date(date) : date; + if (isNaN(dateObj.getTime())) { + return 'Never'; + } + + const now = new Date(); + const diffMs = now.getTime() - dateObj.getTime(); + if (diffMs < 0) { + return dateObj.toLocaleDateString(); + } + + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const diffMonths = Math.floor(diffDays / 30); + const diffYears = Math.floor(diffDays / 365); + + if (diffSeconds < 60) { + return 'Just now'; + } + if (diffMinutes < 60) { + return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`; + } + if (diffHours < 24) { + return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; + } + if (diffDays < 30) { + return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`; + } + if (diffMonths < 12) { + return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`; + } + if (diffYears >= 1) { + return dateObj.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } + + return dateObj.toLocaleDateString(); +}; +