diff --git a/package.json b/package.json index a8df1dd..400f882 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "buffer": "^6.0.3", "countly-sdk-react-native-bridge": "^25.4.0", "date-fns": "^4.1.0", + "dompurify": "^3.3.1", "expo": "~53.0.23", "expo-application": "~6.1.5", "expo-asset": "~11.1.7", diff --git a/src/app/call/[id].web.tsx b/src/app/call/[id].web.tsx new file mode 100644 index 0000000..befbe72 --- /dev/null +++ b/src/app/call/[id].web.tsx @@ -0,0 +1,955 @@ +import { format } from 'date-fns'; +import DOMPurify from 'dompurify'; +import { type Href, Stack, useLocalSearchParams, useRouter } from 'expo-router'; +import { ClockIcon, EditIcon, FileTextIcon, ImageIcon, InfoIcon, LoaderIcon, MapPinIcon, PaperclipIcon, RouteIcon, UserIcon, UsersIcon, XCircleIcon } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; + +import { Loading } from '@/components/common/loading'; +import ZeroState from '@/components/common/zero-state'; +import StaticMap from '@/components/maps/static-map'; +import { Box } from '@/components/ui/box'; +import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { Text } from '@/components/ui/text'; +import { useAnalytics } from '@/hooks/use-analytics'; +import { logger } from '@/lib/logging'; +import { openMapsWithDirections } from '@/lib/navigation'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useLocationStore } from '@/stores/app/location-store'; +import { useCallDetailStore } from '@/stores/calls/detail-store'; +import { useSecurityStore } from '@/stores/security/store'; +import { useStatusBottomSheetStore } from '@/stores/status/store'; +import { useToastStore } from '@/stores/toast/store'; + +import { useCallDetailMenu } from '../../components/calls/call-detail-menu'; +import CallFilesModal from '../../components/calls/call-files-modal'; +import CallImagesModal from '../../components/calls/call-images-modal'; +import CallNotesModal from '../../components/calls/call-notes-modal'; +import { CloseCallBottomSheet } from '../../components/calls/close-call-bottom-sheet'; +import { StatusBottomSheet } from '../../components/status/status-bottom-sheet'; + +type TabKey = 'info' | 'contact' | 'protocols' | 'dispatched' | 'timeline'; + +export default function CallDetailWeb() { + const { id } = useLocalSearchParams(); + const callId = Array.isArray(id) ? id[0] : id; + const router = useRouter(); + const { t } = useTranslation(); + const { trackEvent } = useAnalytics(); + const { width, height } = useWindowDimensions(); + const { colorScheme } = useColorScheme(); + + const isWideScreen = width >= 1024; + const isDark = colorScheme === 'dark'; + + const [coordinates, setCoordinates] = useState<{ latitude: number | null; longitude: number | null }>({ + latitude: null, + longitude: null, + }); + const [activeTab, setActiveTab] = useState('info'); + const [isNotesModalOpen, setIsNotesModalOpen] = useState(false); + const [isImagesModalOpen, setIsImagesModalOpen] = useState(false); + const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); + const [isCloseCallModalOpen, setIsCloseCallModalOpen] = useState(false); + const [isSettingActive, setIsSettingActive] = useState(false); + + const { call, callExtraData, callPriority, isLoading, error, fetchCallDetail, reset } = useCallDetailStore(); + const { canUserCreateCalls } = useSecurityStore(); + const { activeCall, activeUnit } = useCoreStore(); + const { setIsOpen: setStatusBottomSheetOpen, setSelectedCall } = useStatusBottomSheetStore(); + const showToast = useToastStore((state) => state.showToast); + + const userLocation = useLocationStore((state) => ({ + latitude: state.latitude, + longitude: state.longitude, + })); + + const handleBack = () => router.back(); + + const openNotesModal = () => { + useCallDetailStore.getState().fetchCallNotes(callId); + setIsNotesModalOpen(true); + }; + + const handleEditCall = useCallback(() => router.push(`/call/${callId}/edit` as Href), [router, callId]); + const handleCloseCall = () => setIsCloseCallModalOpen(true); + + const handleSetActive = async () => { + if (!call) return; + setIsSettingActive(true); + try { + await useCoreStore.getState().setActiveCall(call.CallId); + setSelectedCall(call); + setStatusBottomSheetOpen(true); + showToast('success', t('call_detail.set_active_success')); + } catch (err) { + logger.error({ message: 'Failed to set call as active', context: { error: err, callId: call.CallId } }); + showToast('error', t('call_detail.set_active_error')); + } finally { + setIsSettingActive(false); + } + }; + + const { HeaderRightMenu, CallDetailActionSheet } = useCallDetailMenu({ + onEditCall: handleEditCall, + onCloseCall: handleCloseCall, + canUserCreateCalls, + }); + + useEffect(() => { + reset(); + if (callId) fetchCallDetail(callId); + }, [callId, fetchCallDetail, reset]); + + useEffect(() => { + if (call) { + if (call.Latitude && call.Longitude) { + setCoordinates({ latitude: parseFloat(call.Latitude), longitude: parseFloat(call.Longitude) }); + } else if (call.Geolocation) { + const [lat, lng] = call.Geolocation.split(','); + setCoordinates({ latitude: parseFloat(lat), longitude: parseFloat(lng) }); + } + } + }, [call]); + + useEffect(() => { + if (call) { + trackEvent('call_detail_web_view_rendered', { + callId: call.CallId || '', + callName: call.Name || '', + hasCoordinates: !!(call.Latitude && call.Longitude), + }); + } + }, [trackEvent, call]); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + router.back(); + } + if (e.key === 'e' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + handleEditCall(); + } + // Tab navigation with number keys + if (e.key >= '1' && e.key <= '5' && !e.ctrlKey && !e.metaKey && !e.altKey) { + const tabs: TabKey[] = ['info', 'contact', 'protocols', 'dispatched', 'timeline']; + const index = parseInt(e.key) - 1; + if (tabs[index]) setActiveTab(tabs[index]); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleEditCall, router, setActiveTab]); + + const handleRoute = async () => { + if (!coordinates.latitude || !coordinates.longitude) { + showToast('error', t('call_detail.no_location_for_routing')); + return; + } + try { + const destinationName = call?.Address || t('call_detail.call_location'); + const success = await openMapsWithDirections(coordinates.latitude, coordinates.longitude, destinationName, userLocation.latitude || undefined, userLocation.longitude || undefined); + if (!success) showToast('error', t('call_detail.failed_to_open_maps')); + } catch (err) { + logger.error({ message: 'Failed to open maps for routing', context: { error: err, callId, coordinates } }); + showToast('error', t('call_detail.failed_to_open_maps')); + } + }; + + const tabs = useMemo( + () => [ + { key: 'info' as const, title: t('call_detail.tabs.info'), icon: InfoIcon }, + { key: 'contact' as const, title: t('call_detail.tabs.contact'), icon: UserIcon }, + { key: 'protocols' as const, title: t('call_detail.tabs.protocols'), icon: FileTextIcon }, + { key: 'dispatched' as const, title: t('call_detail.tabs.dispatched'), icon: UsersIcon }, + { key: 'timeline' as const, title: t('call_detail.tabs.timeline'), icon: ClockIcon, badge: callExtraData?.Activity?.length || 0 }, + ], + [t, callExtraData?.Activity?.length] + ); + + if (isLoading) { + return ( + <> + , headerBackTitle: '' }} /> + + + + + ); + } + + if (error) { + return ( + <> + , headerBackTitle: '' }} /> + + + + + + + ); + } + + if (!call) { + return ( + <> + + + + {t('call_detail.not_found')} + + + + + ); + } + + const renderTabContent = () => { + switch (activeTab) { + case 'info': + return ( + + + + + + + {t('call_detail.note')} + +
No notes') }} /> + + + + ); + case 'contact': + return ( + + + + + + + ); + case 'protocols': + return ( + + {callExtraData?.Protocols && callExtraData.Protocols.length > 0 ? ( + callExtraData.Protocols.map((protocol, index) => ( + + {protocol.Name} + {protocol.Description} + +
+ + + )) + ) : ( + {t('call_detail.no_protocols')} + )} + + ); + case 'dispatched': + return ( + + {callExtraData?.Dispatches && callExtraData.Dispatches.length > 0 ? ( + callExtraData.Dispatches.map((dispatched, index) => ( + + {dispatched.Name} + + + {t('call_detail.group')}: {dispatched.Group} + + + {t('call_detail.type')}: {dispatched.Type} + + + + )) + ) : ( + {t('call_detail.no_dispatched')} + )} + + ); + case 'timeline': + return ( + + {callExtraData?.Activity && callExtraData.Activity.length > 0 ? ( + + {callExtraData.Activity.map((event, index) => ( + + + + {event.StatusText} + + {event.Name} - {event.Group} + + {new Date(event.Timestamp).toLocaleString()} + {event.Note ? {event.Note} : null} + + + ))} + + ) : ( + {t('call_detail.no_timeline')} + )} + + ); + } + }; + + return ( + <> + + , headerBackTitle: '' }} /> + + + + {/* Header Section */} + + + + #{call.Number} + {call.Name} + + + {activeUnit && activeCall?.CallId !== call.CallId ? ( + + {isSettingActive ? : null} + {isSettingActive ? t('call_detail.setting_active') : t('call_detail.set_active')} + + ) : null} + + + {t('common.edit')} + + + + + {/* Call Nature */} + +
+ + + {/* Priority Badge */} + {callPriority ? ( + + + {callPriority.Name} + + ) : null} + + + {/* Main Content - Two Column on Wide Screens */} + + {/* Left Column - Map & Actions */} + + {/* Map */} + {coordinates.latitude && coordinates.longitude ? ( + + + + + {t('common.route')} + + + ) : null} + + {/* Quick Actions */} + + + setIsImagesModalOpen(true)} isDark={isDark} /> + setIsFilesModalOpen(true)} isDark={isDark} /> + + + + + {/* Right Column - Tabs */} + + + {/* Tab Navigation */} + + {tabs.map((tab) => ( + setActiveTab(tab.key)} + > + + {tab.title} + {tab.badge ? ( + + {tab.badge} + + ) : null} + + ))} + + + {/* Tab Content */} + {renderTabContent()} + + + + + {/* Keyboard Shortcuts Hint */} + + + {t('call_detail.keyboard_shortcuts', 'Tip: Press 1-5 to switch tabs, Ctrl+E to edit, Escape to go back')} + + + + + + setIsNotesModalOpen(false)} callId={callId} /> + setIsImagesModalOpen(false)} callId={callId} /> + setIsFilesModalOpen(false)} callId={callId} /> + setIsCloseCallModalOpen(false)} callId={callId} /> + + + + ); +} + +// Helper Components +interface InfoRowProps { + label: string; + value: string; + valueColor?: string; + isDark: boolean; +} + +const InfoRow: React.FC = ({ label, value, valueColor, isDark }) => ( + + {label} + {value} + +); + +interface ActionButtonProps { + icon: React.ComponentType<{ size: number; color: string }>; + label: string; + badge?: number; + onPress: () => void; + isDark: boolean; + variant?: 'default' | 'danger'; +} + +const ActionButton: React.FC = ({ icon: Icon, label, badge, onPress, isDark, variant = 'default' }) => ( + + + + {badge ? ( + + {badge} + + ) : null} + + {label} + +); + +const styles = StyleSheet.create({ + container: { + flex: 1, + width: '100%', + height: '100%', + }, + containerDark: { + backgroundColor: '#0a0a0a', + }, + containerLight: { + backgroundColor: '#fafafa', + }, + scrollView: { + flex: 1, + }, + scrollContent: { + padding: 24, + maxWidth: 1400, + alignSelf: 'center', + width: '100%', + }, + header: { + marginBottom: 24, + }, + headerTop: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 16, + }, + headerTitleContainer: { + flex: 1, + }, + callNumber: { + fontSize: 14, + fontWeight: '500', + marginBottom: 4, + }, + callNumberDark: { + color: '#9ca3af', + }, + callNumberLight: { + color: '#6b7280', + }, + callName: { + fontSize: 28, + fontWeight: '700', + }, + callNameDark: { + color: '#ffffff', + }, + callNameLight: { + color: '#111827', + }, + headerActions: { + flexDirection: 'row', + gap: 12, + }, + setActiveButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 8, + backgroundColor: '#2563eb', + }, + setActiveButtonDisabled: { + opacity: 0.6, + }, + setActiveButtonText: { + color: '#ffffff', + fontSize: 14, + fontWeight: '600', + }, + editButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 8, + borderWidth: 1, + }, + editButtonDark: { + borderColor: '#404040', + backgroundColor: '#171717', + }, + editButtonLight: { + borderColor: '#d1d5db', + backgroundColor: '#ffffff', + }, + editButtonText: { + fontSize: 14, + fontWeight: '500', + }, + editButtonTextDark: { + color: '#d1d5db', + }, + editButtonTextLight: { + color: '#374151', + }, + natureContainer: { + padding: 16, + borderRadius: 8, + marginBottom: 12, + }, + natureContainerDark: { + backgroundColor: '#171717', + }, + natureContainerLight: { + backgroundColor: '#f3f4f6', + }, + priorityBadge: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-start', + gap: 8, + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 20, + borderWidth: 1, + }, + priorityDot: { + width: 8, + height: 8, + borderRadius: 4, + }, + priorityText: { + fontSize: 13, + fontWeight: '600', + }, + twoColumnLayout: { + flexDirection: 'row', + gap: 24, + }, + singleColumnLayout: { + flexDirection: 'column', + gap: 16, + }, + leftColumn: { + width: 400, + gap: 16, + }, + rightColumn: { + flex: 1, + }, + fullWidth: { + width: '100%', + marginBottom: 16, + }, + mapCard: { + borderRadius: 12, + overflow: 'hidden', + position: 'relative', + }, + mapCardDark: { + backgroundColor: '#171717', + }, + mapCardLight: { + backgroundColor: '#ffffff', + }, + routeOverlay: { + position: 'absolute', + bottom: 12, + right: 12, + flexDirection: 'row', + alignItems: 'center', + gap: 6, + backgroundColor: '#2563eb', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 8, + }, + routeOverlayText: { + color: '#ffffff', + fontSize: 14, + fontWeight: '600', + }, + quickActions: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + }, + actionButton: { + flex: 1, + minWidth: 80, + alignItems: 'center', + padding: 16, + borderRadius: 12, + borderWidth: 1, + }, + actionButtonDark: { + backgroundColor: '#171717', + borderColor: '#262626', + }, + actionButtonLight: { + backgroundColor: '#ffffff', + borderColor: '#e5e7eb', + }, + actionButtonDanger: { + borderColor: '#fecaca', + }, + actionButtonIconContainer: { + position: 'relative', + marginBottom: 8, + }, + actionButtonBadge: { + position: 'absolute', + top: -8, + right: -8, + backgroundColor: '#ef4444', + borderRadius: 10, + minWidth: 20, + height: 20, + alignItems: 'center', + justifyContent: 'center', + }, + actionButtonBadgeText: { + color: '#ffffff', + fontSize: 11, + fontWeight: '600', + }, + actionButtonText: { + fontSize: 12, + fontWeight: '500', + }, + actionButtonTextDark: { + color: '#d1d5db', + }, + actionButtonTextLight: { + color: '#374151', + }, + actionButtonTextDanger: { + color: '#ef4444', + }, + tabsCard: { + borderRadius: 12, + overflow: 'hidden', + borderWidth: 1, + }, + tabsCardDark: { + backgroundColor: '#171717', + borderColor: '#262626', + }, + tabsCardLight: { + backgroundColor: '#ffffff', + borderColor: '#e5e7eb', + }, + tabNav: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: '#e5e7eb', + }, + tabButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 16, + paddingVertical: 14, + }, + tabButtonActiveDark: { + borderBottomWidth: 2, + borderBottomColor: '#2563eb', + }, + tabButtonActiveLight: { + borderBottomWidth: 2, + borderBottomColor: '#2563eb', + }, + tabButtonText: { + fontSize: 14, + fontWeight: '500', + }, + tabButtonTextActive: { + color: '#2563eb', + }, + tabButtonTextDark: { + color: '#9ca3af', + }, + tabButtonTextLight: { + color: '#6b7280', + }, + tabBadge: { + backgroundColor: '#2563eb', + borderRadius: 10, + minWidth: 20, + height: 20, + alignItems: 'center', + justifyContent: 'center', + }, + tabBadgeText: { + color: '#ffffff', + fontSize: 11, + fontWeight: '600', + }, + tabContent: { + padding: 20, + }, + infoRow: { + marginBottom: 16, + }, + infoLabel: { + fontSize: 12, + fontWeight: '500', + marginBottom: 4, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + infoLabelDark: { + color: '#9ca3af', + }, + infoLabelLight: { + color: '#6b7280', + }, + infoValue: { + fontSize: 15, + fontWeight: '500', + }, + infoValueDark: { + color: '#ffffff', + }, + infoValueLight: { + color: '#111827', + }, + noteContainer: { + padding: 16, + borderRadius: 8, + marginTop: 4, + }, + noteContainerDark: { + backgroundColor: '#262626', + }, + noteContainerLight: { + backgroundColor: '#f9fafb', + }, + protocolCard: { + padding: 16, + borderRadius: 8, + marginBottom: 12, + borderWidth: 1, + }, + protocolCardDark: { + backgroundColor: '#262626', + borderColor: '#404040', + }, + protocolCardLight: { + backgroundColor: '#f9fafb', + borderColor: '#e5e7eb', + }, + protocolName: { + fontSize: 16, + fontWeight: '600', + marginBottom: 4, + }, + protocolNameDark: { + color: '#ffffff', + }, + protocolNameLight: { + color: '#111827', + }, + protocolDescription: { + fontSize: 14, + marginBottom: 12, + }, + protocolDescriptionDark: { + color: '#9ca3af', + }, + protocolDescriptionLight: { + color: '#6b7280', + }, + protocolText: { + padding: 12, + borderRadius: 6, + }, + protocolTextDark: { + backgroundColor: '#171717', + }, + protocolTextLight: { + backgroundColor: '#ffffff', + }, + dispatchCard: { + padding: 16, + borderRadius: 8, + marginBottom: 12, + borderWidth: 1, + }, + dispatchCardDark: { + backgroundColor: '#262626', + borderColor: '#404040', + }, + dispatchCardLight: { + backgroundColor: '#f9fafb', + borderColor: '#e5e7eb', + }, + dispatchName: { + fontSize: 15, + fontWeight: '600', + marginBottom: 4, + }, + dispatchNameDark: { + color: '#ffffff', + }, + dispatchNameLight: { + color: '#111827', + }, + dispatchMeta: { + flexDirection: 'row', + gap: 16, + }, + dispatchMetaText: { + fontSize: 13, + }, + dispatchMetaTextDark: { + color: '#9ca3af', + }, + dispatchMetaTextLight: { + color: '#6b7280', + }, + timeline: { + position: 'relative', + }, + timelineItem: { + flexDirection: 'row', + marginBottom: 20, + }, + timelineDot: { + width: 12, + height: 12, + borderRadius: 6, + marginRight: 16, + marginTop: 4, + }, + timelineContent: { + flex: 1, + }, + timelineStatus: { + fontSize: 15, + fontWeight: '600', + marginBottom: 2, + }, + timelineInfo: { + fontSize: 14, + marginBottom: 2, + }, + timelineInfoDark: { + color: '#d1d5db', + }, + timelineInfoLight: { + color: '#374151', + }, + timelineTime: { + fontSize: 12, + marginBottom: 4, + }, + timelineTimeDark: { + color: '#9ca3af', + }, + timelineTimeLight: { + color: '#6b7280', + }, + timelineNote: { + fontSize: 13, + fontStyle: 'italic', + }, + timelineNoteDark: { + color: '#9ca3af', + }, + timelineNoteLight: { + color: '#6b7280', + }, + emptyText: { + fontSize: 14, + fontStyle: 'italic', + textAlign: 'center', + paddingVertical: 24, + }, + emptyTextDark: { + color: '#9ca3af', + }, + emptyTextLight: { + color: '#6b7280', + }, + shortcutHint: { + marginTop: 24, + alignItems: 'center', + }, + shortcutText: { + fontSize: 12, + }, + shortcutTextDark: { + color: '#6b7280', + }, + shortcutTextLight: { + color: '#9ca3af', + }, +}); diff --git a/src/app/call/[id]/edit.web.tsx b/src/app/call/[id]/edit.web.tsx new file mode 100644 index 0000000..b7f636c --- /dev/null +++ b/src/app/call/[id]/edit.web.tsx @@ -0,0 +1,1213 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import axios from 'axios'; +import { router, Stack, useLocalSearchParams } from 'expo-router'; +import { ChevronDownIcon, MapPinIcon, SaveIcon, SearchIcon, XIcon } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import React, { useCallback, useEffect, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; +import * as z from 'zod'; + +import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal'; +import { Loading } from '@/components/common/loading'; +import FullScreenLocationPicker from '@/components/maps/full-screen-location-picker'; +import LocationPicker from '@/components/maps/location-picker'; +import { Box } from '@/components/ui/box'; +import { Card } from '@/components/ui/card'; +import { Text } from '@/components/ui/text'; +import { useToast } from '@/components/ui/toast'; +import { useAnalytics } from '@/hooks/use-analytics'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useCallDetailStore } from '@/stores/calls/detail-store'; +import { useCallsStore } from '@/stores/calls/store'; +import { type DispatchSelection } from '@/stores/dispatch/store'; + +// Form validation schema +const formSchema = z.object({ + name: z.string().min(1, 'Name is required'), + nature: z.string().min(1, 'Nature is required'), + note: z.string().optional(), + address: z.string().optional(), + coordinates: z.string().optional(), + what3words: z.string().optional(), + plusCode: z.string().optional(), + latitude: z.number().optional(), + longitude: z.number().optional(), + priority: z.string().min(1, 'Priority is required'), + type: z.string().min(1, 'Type is required'), + contactName: z.string().optional(), + contactInfo: z.string().optional(), + dispatchSelection: z.object({ + everyone: z.boolean(), + users: z.array(z.string()), + groups: z.array(z.string()), + roles: z.array(z.string()), + units: z.array(z.string()), + }), +}); + +type FormValues = z.infer; + +interface GeocodingResult { + place_id: string; + formatted_address: string; + geometry: { location: { lat: number; lng: number } }; +} + +interface GeocodingResponse { + results: GeocodingResult[]; + status: string; +} + +// Web-optimized input component +interface WebInputProps { + label: string; + placeholder: string; + value: string; + onChange: (value: string) => void; + onBlur?: () => void; + error?: string; + multiline?: boolean; + rows?: number; + required?: boolean; + onKeyDown?: (e: React.KeyboardEvent) => void; + autoFocus?: boolean; + testID?: string; + disabled?: boolean; + rightElement?: React.ReactNode; +} + +const WebInput: React.FC = ({ label, placeholder, value, onChange, onBlur, error, multiline = false, rows = 1, required = false, onKeyDown, autoFocus = false, testID, disabled = false, rightElement }) => { + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === 'dark'; + + const inputStyles = StyleSheet.flatten([ + styles.webInput, + isDark ? styles.webInputDark : styles.webInputLight, + error ? styles.webInputError : {}, + disabled ? styles.webInputDisabled : {}, + multiline ? { minHeight: rows * 24 + 16 } : {}, + ]); + + return ( + + + {label} + {required ? * : null} + + + {multiline ? ( +