diff --git a/app/(tabs)/new-dose.tsx b/app/(tabs)/new-dose.tsx index 1e3f2917..7b68e1a7 100644 --- a/app/(tabs)/new-dose.tsx +++ b/app/(tabs)/new-dose.tsx @@ -152,6 +152,11 @@ export default function NewDoseScreen() { doseCalculator.setScreenStep(step); }, [doseCalculator, setNavigatingFromIntro]); + // Preset handlers + const handlePresetSelected = useCallback((preset: any) => { + loadPreset(preset); + }, [loadPreset]); + // Handle screen focus events to ensure state is properly initialized after navigation useFocusEffect( React.useCallback(() => { @@ -270,6 +275,8 @@ export default function NewDoseScreen() { setSelectedInjectionSite, handleInjectionSiteSelected, handleInjectionSiteCancel, + // Preset functionality + loadPreset, } = doseCalculator; const [permission, requestPermission] = useCameraPermissions(); @@ -808,6 +815,7 @@ export default function NewDoseScreen() { setScreenStep={handleSetScreenStep} resetFullForm={resetFullForm} setNavigatingFromIntro={setNavigatingFromIntro} + onPresetSelected={handlePresetSelected} /> )} {screenStep === 'scan' && ( diff --git a/components/FinalResultDisplay.tsx b/components/FinalResultDisplay.tsx index 3aab97ae..523b7b13 100644 --- a/components/FinalResultDisplay.tsx +++ b/components/FinalResultDisplay.tsx @@ -1,12 +1,13 @@ import React, { useState, useCallback, useEffect } from 'react'; -import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Alert } from 'react-native'; -import { Plus, X, Info, ChevronDown, ChevronUp, RotateCcw, Save, Camera as CameraIcon } from 'lucide-react-native'; +import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Alert, Modal, TextInput } from 'react-native'; +import { Plus, X, Info, ChevronDown, ChevronUp, RotateCcw, Save, Camera as CameraIcon, Bookmark } from 'lucide-react-native'; import { signInWithPopup, GoogleAuthProvider } from 'firebase/auth'; import SyringeIllustration from './SyringeIllustration'; import { syringeOptions } from "../lib/utils"; import { useUserProfile } from '@/contexts/UserProfileContext'; import { useAuth } from '@/contexts/AuthContext'; import { useDoseLogging } from '@/lib/hooks/useDoseLogging'; +import { usePresetStorage } from '@/lib/hooks/usePresetStorage'; type Props = { calculationError: string | null; @@ -26,6 +27,11 @@ type Props = { isMobileWeb: boolean; usageData?: { scansUsed: number; limit: number; plan: string }; onTryAIScan?: () => void; + // Preset-related props + concentrationValue?: number | null; + totalAmount?: number | null; + totalAmountUnit?: 'mg' | 'mcg' | 'units'; + solutionVolume?: number | null; }; export default function FinalResultDisplay({ @@ -46,14 +52,23 @@ export default function FinalResultDisplay({ isMobileWeb, usageData, onTryAIScan, + concentrationValue, + totalAmount, + totalAmountUnit, + solutionVolume, }: Props) { const { disclaimerText } = useUserProfile(); const { user, auth } = useAuth(); const { logDose, isLogging } = useDoseLogging(); + const { savePreset, maxPresets } = usePresetStorage(); const [showCalculationBreakdown, setShowCalculationBreakdown] = useState(false); const [isSaving, setIsSaving] = useState(false); const [pendingSave, setPendingSave] = useState(false); // Track if we're waiting for auth to complete save + const [showPresetModal, setShowPresetModal] = useState(false); + const [presetName, setPresetName] = useState(''); + const [presetNotes, setPresetNotes] = useState(''); + const [isSavingPreset, setIsSavingPreset] = useState(false); // Effect to handle automatic saving after successful authentication useEffect(() => { @@ -169,6 +184,56 @@ export default function FinalResultDisplay({ manualSyringe, recommendedMarking ]); + // Preset handlers + const handleSavePreset = useCallback(() => { + setShowPresetModal(true); + }, []); + + const handleSavePresetConfirm = useCallback(async () => { + if (!presetName.trim()) { + Alert.alert('Error', 'Please enter a preset name'); + return; + } + + setIsSavingPreset(true); + try { + const presetData = { + name: presetName.trim(), + substanceName, + doseValue: doseValue || 0, + unit, + concentrationValue, + concentrationUnit, + totalAmount, + totalAmountUnit, + solutionVolume, + notes: presetNotes.trim() || undefined, + }; + + const result = await savePreset(presetData); + + if (result.success) { + Alert.alert('Preset Saved', `"${presetName}" has been saved as a preset.`); + setShowPresetModal(false); + setPresetName(''); + setPresetNotes(''); + } else { + Alert.alert('Save Failed', result.error || 'Failed to save preset'); + } + } catch (error) { + console.error('Error saving preset:', error); + Alert.alert('Save Failed', 'Failed to save preset'); + } finally { + setIsSavingPreset(false); + } + }, [presetName, presetNotes, substanceName, doseValue, unit, concentrationValue, concentrationUnit, totalAmount, totalAmountUnit, solutionVolume, savePreset]); + + const handlePresetModalClose = useCallback(() => { + setShowPresetModal(false); + setPresetName(''); + setPresetNotes(''); + }, []); + // Helper function to get the calculation formula based on unit types const getCalculationFormula = () => { if (!doseValue) return 'Formula not available'; @@ -217,7 +282,8 @@ export default function FinalResultDisplay({ calculatedConcentration }); return ( - + <> + {calculationError && !recommendedMarking && ( @@ -403,6 +469,20 @@ export default function FinalResultDisplay({ } + + + + {isSavingPreset ? 'Saving...' : 'Save Preset'} + + handleGoToFeedback('new_dose')} @@ -412,6 +492,91 @@ export default function FinalResultDisplay({ + + {/* Save Preset Modal */} + {showPresetModal && ( + + + + + Save as Preset + + + + + + + Preset Preview: + + {substanceName} • {doseValue} {unit} + + {concentrationValue && concentrationUnit && ( + + Concentration: {concentrationValue} {concentrationUnit} + + )} + {totalAmount && totalAmountUnit && ( + + Total: {totalAmount} {totalAmountUnit} + + )} + {solutionVolume && ( + + Solution: {solutionVolume} ml + + )} + + + + Preset Name (required) + + + + + Notes (optional) + + + + + + Cancel + + + + {isSavingPreset ? 'Saving...' : 'Save Preset'} + + + + + + + )} + ); } @@ -536,4 +701,104 @@ const styles = StyleSheet.create({ color: '#007AFF', fontWeight: '500', }, + // Modal styles + modalContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.5)', + }, + modalContent: { + backgroundColor: '#FFFFFF', + borderRadius: 12, + padding: 20, + width: '90%', + maxWidth: 400, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 20, + }, + modalTitle: { + fontSize: 18, + fontWeight: 'bold', + color: '#065F46', + }, + closeButton: { + padding: 4, + }, + presetPreview: { + backgroundColor: '#F9FAFB', + padding: 12, + borderRadius: 8, + marginBottom: 16, + borderWidth: 1, + borderColor: '#E5E7EB', + }, + previewTitle: { + fontSize: 14, + fontWeight: '600', + color: '#374151', + marginBottom: 8, + }, + previewText: { + fontSize: 13, + color: '#6B7280', + marginBottom: 2, + }, + inputContainer: { + marginBottom: 16, + }, + inputLabel: { + fontSize: 14, + fontWeight: '500', + color: '#374151', + marginBottom: 6, + }, + textInput: { + borderWidth: 1, + borderColor: '#D1D5DB', + borderRadius: 8, + padding: 12, + fontSize: 16, + backgroundColor: '#FFFFFF', + }, + notesInput: { + height: 80, + textAlignVertical: 'top', + }, + modalButtons: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: 12, + }, + modalButton: { + flex: 1, + paddingVertical: 12, + borderRadius: 8, + alignItems: 'center', + }, + cancelButton: { + backgroundColor: '#F3F4F6', + borderWidth: 1, + borderColor: '#D1D5DB', + }, + saveButton: { + backgroundColor: '#8B5CF6', + }, + disabledButton: { + backgroundColor: '#9CA3AF', + }, + cancelButtonText: { + fontSize: 16, + fontWeight: '500', + color: '#374151', + }, + saveButtonText: { + fontSize: 16, + fontWeight: '500', + color: '#FFFFFF', + }, }); diff --git a/components/IntroScreen.tsx b/components/IntroScreen.tsx index df9d0ff1..85149e33 100644 --- a/components/IntroScreen.tsx +++ b/components/IntroScreen.tsx @@ -9,6 +9,7 @@ import { LogOut, Info, User, + Bookmark, } from 'lucide-react-native'; import Animated, { FadeIn } from 'react-native-reanimated'; import { isMobileWeb } from '../lib/utils'; @@ -20,6 +21,8 @@ import { useRouter } from 'expo-router'; import { signInWithPopup, GoogleAuthProvider } from 'firebase/auth'; import Constants from 'expo-constants'; // env variables from app.config.js import ConfirmationModal from './ConfirmationModal'; +import PresetSelector from './PresetSelector'; +import { DosePreset } from '../types/preset'; interface IntroScreenProps { setScreenStep: (step: 'intro' | 'scan' | 'manualEntry') => void; @@ -34,12 +37,14 @@ interface IntroScreenProps { | 'finalResult', ) => void; setNavigatingFromIntro?: (value: boolean) => void; + onPresetSelected?: (preset: DosePreset) => void; } export default function IntroScreen({ setScreenStep, resetFullForm, setNavigatingFromIntro, + onPresetSelected, }: IntroScreenProps) { const { user, auth, logout, isSigningOut } = useAuth(); const { disclaimerText, profile, isLoading } = useUserProfile(); @@ -49,6 +54,7 @@ export default function IntroScreen({ // State for logout functionality const [isLoggingOut, setIsLoggingOut] = useState(false); const [showWebLogoutModal, setShowWebLogoutModal] = useState(false); + const [showPresetSelector, setShowPresetSelector] = useState(false); /* ========================================================================= LOGGING (remove or guard with __DEV__ as needed) @@ -198,6 +204,23 @@ export default function IntroScreen({ setScreenStep('manualEntry'); }, [resetFullForm, setScreenStep, setNavigatingFromIntro]); + const handlePresetPress = useCallback(() => { + setShowPresetSelector(true); + }, []); + + const handlePresetSelected = useCallback((preset: DosePreset) => { + setShowPresetSelector(false); + if (onPresetSelected) { + setNavigatingFromIntro?.(true); + onPresetSelected(preset); + setScreenStep('manualEntry'); + } + }, [onPresetSelected, setNavigatingFromIntro, setScreenStep]); + + const handleClosePresetSelector = useCallback(() => { + setShowPresetSelector(false); + }, []); + /* ========================================================================= RENDER ========================================================================= */ @@ -278,6 +301,18 @@ export default function IntroScreen({ Manual + + + + Presets + {/* Plan Reconstitution Link */} @@ -395,6 +430,13 @@ export default function IntroScreen({ /> )} + + {showPresetSelector && ( + + )} ); } @@ -549,6 +591,9 @@ const styles = StyleSheet.create({ secondaryButton: { backgroundColor: '#6366f1', }, + presetButton: { + backgroundColor: '#8B5CF6', + }, buttonText: { color: '#fff', fontSize: 16, diff --git a/components/ManualEntryScreen.tsx b/components/ManualEntryScreen.tsx index 38cf68bf..62b59dc9 100644 --- a/components/ManualEntryScreen.tsx +++ b/components/ManualEntryScreen.tsx @@ -378,6 +378,10 @@ export default function ManualEntryScreen({ isMobileWeb={isMobileWeb} usageData={usageData} onTryAIScan={onTryAIScan} + concentrationValue={concentrationAmount ? parseFloat(concentrationAmount) : null} + totalAmount={totalAmount ? parseFloat(totalAmount) : null} + totalAmountUnit={unit === 'mcg' ? 'mg' : unit} + solutionVolume={solutionVolume ? parseFloat(solutionVolume) : null} /> ); progress = 1; diff --git a/components/PresetSelector.tsx b/components/PresetSelector.tsx new file mode 100644 index 00000000..2dd9f066 --- /dev/null +++ b/components/PresetSelector.tsx @@ -0,0 +1,245 @@ +import React, { useState, useEffect } from 'react'; +import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Alert, Modal } from 'react-native'; +import { Bookmark, Trash2, X } from 'lucide-react-native'; +import { usePresetStorage } from '../lib/hooks/usePresetStorage'; +import { DosePreset } from '../types/preset'; + +type Props = { + onPresetSelected: (preset: DosePreset) => void; + onClose: () => void; +}; + +export default function PresetSelector({ onPresetSelected, onClose }: Props) { + const { getPresets, deletePreset, isLoading } = usePresetStorage(); + const [presets, setPresets] = useState([]); + + useEffect(() => { + loadPresets(); + }, []); + + const loadPresets = async () => { + try { + const loadedPresets = await getPresets(); + setPresets(loadedPresets); + } catch (error) { + console.error('Error loading presets:', error); + Alert.alert('Error', 'Failed to load presets'); + } + }; + + const handleDeletePreset = (preset: DosePreset) => { + Alert.alert( + 'Delete Preset', + `Are you sure you want to delete "${preset.name}"?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + const result = await deletePreset(preset.id); + if (result.success) { + await loadPresets(); + } else { + Alert.alert('Error', 'Failed to delete preset'); + } + }, + }, + ] + ); + }; + + const handlePresetSelect = (preset: DosePreset) => { + onPresetSelected(preset); + }; + + if (isLoading) { + return ( + + + + Loading presets... + + + + ); + } + + return ( + + + + + Dose Presets + + + + + + {presets.length === 0 ? ( + + + No Presets Saved + + Save your frequently used doses as presets for quick access. + + + ) : ( + + {presets.map((preset) => ( + + handlePresetSelect(preset)} + > + + {preset.name} + handleDeletePreset(preset)} + style={styles.deleteButton} + > + + + + + + + {preset.substanceName} • {preset.doseValue} {preset.unit} + + {preset.concentrationValue && preset.concentrationUnit && ( + + Concentration: {preset.concentrationValue} {preset.concentrationUnit} + + )} + {preset.totalAmount && preset.totalAmountUnit && ( + + Total: {preset.totalAmount} {preset.totalAmountUnit} + + )} + {preset.notes && ( + {preset.notes} + )} + + + + ))} + + )} + + + + Tap a preset to load it, or the trash icon to delete. + + + + + + ); +} + +const styles = StyleSheet.create({ + modalContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.5)', + }, + modalContent: { + backgroundColor: '#FFFFFF', + borderRadius: 12, + padding: 20, + width: '90%', + maxWidth: 500, + maxHeight: '80%', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 20, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + color: '#065F46', + }, + closeButton: { + padding: 4, + }, + loadingText: { + fontSize: 16, + color: '#6B7280', + textAlign: 'center', + padding: 20, + }, + emptyState: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 40, + }, + emptyStateTitle: { + fontSize: 18, + fontWeight: '600', + color: '#374151', + marginTop: 16, + marginBottom: 8, + }, + emptyStateText: { + fontSize: 14, + color: '#6B7280', + textAlign: 'center', + lineHeight: 20, + }, + presetList: { + maxHeight: 400, + }, + presetCard: { + backgroundColor: '#F9FAFB', + borderRadius: 8, + marginBottom: 12, + borderWidth: 1, + borderColor: '#E5E7EB', + }, + presetContent: { + padding: 16, + }, + presetHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + presetName: { + fontSize: 16, + fontWeight: '600', + color: '#065F46', + flex: 1, + }, + deleteButton: { + padding: 4, + }, + presetDetails: { + gap: 4, + }, + presetDetailText: { + fontSize: 14, + color: '#374151', + }, + presetNotes: { + fontSize: 13, + color: '#6B7280', + fontStyle: 'italic', + marginTop: 4, + }, + footer: { + marginTop: 16, + paddingTop: 16, + borderTopWidth: 1, + borderTopColor: '#E5E7EB', + }, + footerText: { + fontSize: 12, + color: '#6B7280', + textAlign: 'center', + }, +}); \ No newline at end of file diff --git a/for grouping b/for grouping new file mode 100644 index 00000000..c1d67d02 --- /dev/null +++ b/for grouping @@ -0,0 +1,262 @@ +diff --git a/lib/hooks/usePresetStorage.ts b/lib/hooks/usePresetStorage.ts +index ffdc506..885320b 100644 +--- a/lib/hooks/usePresetStorage.ts ++++ b/lib/hooks/usePresetStorage.ts +@@ -1,6 +1,8 @@ + import { useState, useCallback } from 'react'; + import { useAuth } from '../../contexts/AuthContext'; + import AsyncStorage from '@react-native-async-storage/async-storage'; ++import { collection, addDoc, getDocs, query, where, orderBy, deleteDoc, doc, updateDoc } from 'firebase/firestore'; ++import { db } from '../firebase'; + import { DosePreset } from '../../types/preset'; +  + const MAX_PRESETS = 10; // Keep UX clean as specified +@@ -33,25 +35,149 @@ export function usePresetStorage() { + } + }, [user]); +  +- // Get all presets from local storage ++ // Save preset to Firestore (for authenticated users) ++ const savePresetToFirestore = useCallback(async (preset: DosePreset): Promise => { ++ if (!user || user.isAnonymous) { ++ console.log('Skipping Firestore save for anonymous user'); ++ return null; ++ } ++ ++ try { ++ const presetsCollection = collection(db, 'dose_presets'); ++ const docRef = await addDoc(presetsCollection, { ++ ...preset, ++ userId: user.uid, ++ }); ++ console.log('Preset saved to Firestore:', preset.id, 'Document ID:', docRef.id); ++ return docRef.id; ++ } catch (error) { ++ console.error('Error saving preset to Firestore:', error); ++ // Don't throw error - local storage is the fallback ++ return null; ++ } ++ }, [user]); ++ ++ // Load presets from Firestore (for authenticated users) ++ const loadPresetsFromFirestore = useCallback(async (): Promise => { ++ if (!user || user.isAnonymous) { ++ console.log('Skipping Firestore load for anonymous user'); ++ return []; ++ } ++ ++ try { ++ const presetsCollection = collection(db, 'dose_presets'); ++ const q = query( ++ presetsCollection, ++ where('userId', '==', user.uid), ++ orderBy('timestamp', 'desc') ++ ); ++ const querySnapshot = await getDocs(q); ++  ++ const presets: DosePreset[] = []; ++ querySnapshot.forEach((doc) => { ++ const data = doc.data(); ++ presets.push({ ++ id: data.id, ++ userId: data.userId, ++ name: data.name, ++ substanceName: data.substanceName, ++ doseValue: data.doseValue, ++ unit: data.unit, ++ concentrationValue: data.concentrationValue, ++ concentrationUnit: data.concentrationUnit, ++ totalAmount: data.totalAmount, ++ totalAmountUnit: data.totalAmountUnit, ++ solutionVolume: data.solutionVolume, ++ notes: data.notes, ++ timestamp: data.timestamp, ++ firestoreId: doc.id, // Store the Firestore document ID ++ }); ++ }); ++  ++ console.log('Loaded', presets.length, 'presets from Firestore'); ++ return presets; ++ } catch (error) { ++ console.error('Error loading presets from Firestore:', error); ++ return []; ++ } ++ }, [user]); ++ ++ // Delete preset from Firestore ++ const deletePresetFromFirestore = useCallback(async (firestoreId: string): Promise => { ++ if (!user || user.isAnonymous) { ++ console.log('Skipping Firestore delete for anonymous user'); ++ return true; ++ } ++ ++ try { ++ const presetDoc = doc(db, 'dose_presets', firestoreId); ++ await deleteDoc(presetDoc); ++ console.log('Preset deleted from Firestore:', firestoreId); ++ return true; ++ } catch (error) { ++ console.error('Error deleting preset from Firestore:', error); ++ return false; ++ } ++ }, [user]); ++ ++ // Get all presets from both local storage and Firestore, merging intelligently + const getPresets = useCallback(async (): Promise => { + try { + setIsLoading(true); ++  ++ // Load from both sources ++ const localPresets = await getPresetsFromLocal(); ++ const firestorePresets = await loadPresetsFromFirestore(); ++  ++ // For authenticated users, prefer Firestore data and sync to local ++ if (!user?.isAnonymous && firestorePresets.length > 0) { ++ console.log('Using Firestore presets as source of truth for authenticated user'); ++ // Store Firestore presets locally for offline access ++ const storageKey = `dose_presets_${user?.uid || 'anonymous'}`; ++ await AsyncStorage.setItem(storageKey, JSON.stringify(firestorePresets)); ++ return firestorePresets.slice(0, MAX_PRESETS); // Enforce limit ++ } ++  ++ // For anonymous users or when Firestore is empty, use local presets ++ console.log('Using local presets'); ++ return localPresets.slice(0, MAX_PRESETS); // Enforce limit ++ } catch (error) { ++ console.error('Error loading presets:', error); ++ return []; ++ } finally { ++ setIsLoading(false); ++ } ++ }, [user]); ++ ++ // Helper to get presets from local storage only ++ const getPresetsFromLocal = useCallback(async (): Promise => { ++ try { + const storageKey = `dose_presets_${user?.uid || 'anonymous'}`; + const existingPresets = await AsyncStorage.getItem(storageKey); + const presetsList: DosePreset[] = existingPresets ? JSON.parse(existingPresets) : []; + return presetsList; + } catch (error) { +- console.error('Error loading presets:', error); ++ console.error('Error loading presets from local storage:', error); + return []; +- } finally { +- setIsLoading(false); + } + }, [user]); +  + // Delete a preset by ID + const deletePreset = useCallback(async (presetId: string) => { + try { ++ // Find the preset to get its Firestore ID ++ const localPresets = await getPresetsFromLocal(); ++ const presetToDelete = localPresets.find(preset => preset.id === presetId); ++  ++ // Delete from Firestore if it has a Firestore ID ++ if (presetToDelete?.firestoreId) { ++ const firestoreDeleteSuccess = await deletePresetFromFirestore(presetToDelete.firestoreId); ++ if (!firestoreDeleteSuccess) { ++ console.warn('Failed to delete preset from Firestore, continuing with local delete'); ++ } ++ } ++  ++ // Delete from local storage + const storageKey = `dose_presets_${user?.uid || 'anonymous'}`; + const existingPresets = await AsyncStorage.getItem(storageKey); + const presetsList: DosePreset[] = existingPresets ? JSON.parse(existingPresets) : []; +@@ -65,11 +191,12 @@ export function usePresetStorage() { + console.error('Error deleting preset:', error); + return { success: false, error: 'Failed to delete preset' }; + } +- }, [user]); ++ }, [user, getPresetsFromLocal, deletePresetFromFirestore]); +  + // Update a preset (for rename functionality) + const updatePreset = useCallback(async (presetId: string, updates: Partial) => { + try { ++ // Update in local storage + const storageKey = `dose_presets_${user?.uid || 'anonymous'}`; + const existingPresets = await AsyncStorage.getItem(storageKey); + const presetsList: DosePreset[] = existingPresets ? JSON.parse(existingPresets) : []; +@@ -79,9 +206,23 @@ export function usePresetStorage() { + return { success: false, error: 'Preset not found' }; + } +  +- presetsList[presetIndex] = { ...presetsList[presetIndex], ...updates }; ++ const updatedPreset = { ...presetsList[presetIndex], ...updates }; ++ presetsList[presetIndex] = updatedPreset; +  + await AsyncStorage.setItem(storageKey, JSON.stringify(presetsList)); ++  ++ // Update in Firestore if it has a Firestore ID ++ if (updatedPreset.firestoreId && !user?.isAnonymous) { ++ try { ++ const presetDoc = doc(db, 'dose_presets', updatedPreset.firestoreId); ++ await updateDoc(presetDoc, updates); ++ console.log('Preset updated in Firestore:', presetId); ++ } catch (firestoreError) { ++ console.warn('Failed to update preset in Firestore:', firestoreError); ++ // Continue anyway since local update succeeded ++ } ++ } ++  + console.log('Preset updated:', presetId); + return { success: true }; + } catch (error) { +@@ -90,7 +231,7 @@ export function usePresetStorage() { + } + }, [user]); +  +- // Main save function that generates ID and calls savePresetLocally ++ // Main save function that generates ID and saves to both local and Firestore + const savePreset = useCallback(async (presetData: { + name: string; + substanceName: string; +@@ -105,6 +246,12 @@ export function usePresetStorage() { + }) => { + setIsSaving(true); + try { ++ // Check local limit first ++ const existingLocalPresets = await getPresetsFromLocal(); ++ if (existingLocalPresets.length >= MAX_PRESETS) { ++ return { success: false, error: `Maximum ${MAX_PRESETS} presets allowed. Please delete an existing preset first.` }; ++ } ++ + const preset: DosePreset = { + id: `preset_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + userId: user?.uid, +@@ -112,15 +259,30 @@ export function usePresetStorage() { + ...presetData, + }; +  +- const result = await savePresetLocally(preset); +- return result; ++ // Save to local storage first (primary storage) ++ const localResult = await savePresetLocally(preset); ++ if (!localResult.success) { ++ return localResult; ++ } ++ ++ // Save to Firestore for authenticated users (for sync across devices) ++ if (!user?.isAnonymous) { ++ const firestoreId = await savePresetToFirestore(preset); ++ if (firestoreId) { ++ // Update the local copy with the Firestore ID for future operations ++ preset.firestoreId = firestoreId; ++ await savePresetLocally(preset); // Re-save with Firestore ID ++ } ++ } ++ ++ return { success: true }; + } catch (error) { + console.error('Error in savePreset:', error); + return { success: false, error: 'Failed to save preset' }; + } finally { + setIsSaving(false); + } +- }, [user, savePresetLocally]); ++ }, [user, savePresetLocally, savePresetToFirestore, getPresetsFromLocal]); +  + return { + savePreset, diff --git a/lib/hooks/useDoseCalculator.ts b/lib/hooks/useDoseCalculator.ts index e378f2ff..2a2f615c 100644 --- a/lib/hooks/useDoseCalculator.ts +++ b/lib/hooks/useDoseCalculator.ts @@ -642,6 +642,38 @@ export default function useDoseCalculator({ checkUsageLimit, trackInteraction }: lastActionTimestamp.current = Date.now(); }, [feedbackContext, resetFullForm, checkUsageLimit, logDose, trackInteraction]); + // Load preset data + const loadPreset = useCallback((preset: any) => { + resetFullForm('dose'); + + setDose(preset.doseValue?.toString() || ''); + setUnit(preset.unit || 'mg'); + setSubstanceName(preset.substanceName || ''); + setDoseValue(preset.doseValue || null); + + if (preset.concentrationValue && preset.concentrationUnit) { + setConcentrationAmount(preset.concentrationValue.toString()); + setConcentrationUnit(preset.concentrationUnit); + setConcentration(preset.concentrationValue); + setMedicationInputType('concentration'); + } + + if (preset.totalAmount && preset.totalAmountUnit) { + setTotalAmount(preset.totalAmount.toString()); + if (!preset.concentrationValue) { + setMedicationInputType('totalAmount'); + } + } + + if (preset.solutionVolume) { + setSolutionVolume(preset.solutionVolume.toString()); + } + + setSubstanceNameHint(`Loaded from preset: ${preset.name}`); + setManualStep('dose'); + setLastActionType('manual'); + }, [resetFullForm]); + // PMF Survey handlers const handlePMFSurveyComplete = useCallback(async (responses: any) => { console.log('[useDoseCalculator] PMF survey completed', responses); @@ -875,5 +907,7 @@ export default function useDoseCalculator({ checkUsageLimit, trackInteraction }: setSelectedInjectionSite, handleInjectionSiteSelected, handleInjectionSiteCancel, + // Preset functionality + loadPreset, }; } \ No newline at end of file diff --git a/lib/hooks/usePresetStorage.test.ts b/lib/hooks/usePresetStorage.test.ts new file mode 100644 index 00000000..0da17988 --- /dev/null +++ b/lib/hooks/usePresetStorage.test.ts @@ -0,0 +1,58 @@ +import { DosePreset } from '../../types/preset'; + +// Simple test to verify preset types and functionality +describe('Preset Feature Integration', () => { + test('preset type definitions are correct', () => { + const samplePreset: DosePreset = { + id: 'test-id', + name: 'Test Preset', + substanceName: 'Testosterone', + doseValue: 100, + unit: 'mg', + concentrationValue: 250, + concentrationUnit: 'mg/ml', + timestamp: new Date().toISOString(), + }; + + expect(samplePreset.id).toBe('test-id'); + expect(samplePreset.name).toBe('Test Preset'); + expect(samplePreset.substanceName).toBe('Testosterone'); + expect(samplePreset.doseValue).toBe(100); + expect(samplePreset.unit).toBe('mg'); + expect(samplePreset.concentrationValue).toBe(250); + expect(samplePreset.concentrationUnit).toBe('mg/ml'); + }); + + test('preset with total amount works correctly', () => { + const presetWithTotal: DosePreset = { + id: 'test-total', + name: 'Total Amount Preset', + substanceName: 'HCG', + doseValue: 250, + unit: 'units', + totalAmount: 5000, + totalAmountUnit: 'units', + solutionVolume: 1, + timestamp: new Date().toISOString(), + }; + + expect(presetWithTotal.totalAmount).toBe(5000); + expect(presetWithTotal.totalAmountUnit).toBe('units'); + expect(presetWithTotal.solutionVolume).toBe(1); + }); + + test('optional preset fields work correctly', () => { + const minimalPreset: DosePreset = { + id: 'minimal', + name: 'Minimal Preset', + substanceName: 'Test', + doseValue: 50, + unit: 'mg', + timestamp: new Date().toISOString(), + }; + + expect(minimalPreset.concentrationValue).toBeUndefined(); + expect(minimalPreset.notes).toBeUndefined(); + expect(minimalPreset.totalAmount).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/lib/hooks/usePresetStorage.ts b/lib/hooks/usePresetStorage.ts new file mode 100644 index 00000000..bcba5b20 --- /dev/null +++ b/lib/hooks/usePresetStorage.ts @@ -0,0 +1,76 @@ +import { useState, useCallback } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { DosePreset } from '../../types/preset'; + +const STORAGE_KEY = 'dose_presets'; +const MAX_PRESETS = 10; + +export function usePresetStorage() { + const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const savePreset = useCallback(async (presetData: Omit) => { + setIsSaving(true); + try { + const existingData = await AsyncStorage.getItem(STORAGE_KEY); + const presets: DosePreset[] = existingData ? JSON.parse(existingData) : []; + + if (presets.length >= MAX_PRESETS) { + return { success: false, error: `Maximum ${MAX_PRESETS} presets allowed` }; + } + + const preset: DosePreset = { + ...presetData, + id: `preset_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + timestamp: new Date().toISOString(), + }; + + presets.unshift(preset); + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); + + return { success: true }; + } catch (error) { + console.error('Error saving preset:', error); + return { success: false, error: 'Failed to save preset' }; + } finally { + setIsSaving(false); + } + }, []); + + const getPresets = useCallback(async (): Promise => { + setIsLoading(true); + try { + const data = await AsyncStorage.getItem(STORAGE_KEY); + return data ? JSON.parse(data) : []; + } catch (error) { + console.error('Error loading presets:', error); + return []; + } finally { + setIsLoading(false); + } + }, []); + + const deletePreset = useCallback(async (presetId: string) => { + try { + const existingData = await AsyncStorage.getItem(STORAGE_KEY); + const presets: DosePreset[] = existingData ? JSON.parse(existingData) : []; + + const updatedPresets = presets.filter(preset => preset.id !== presetId); + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updatedPresets)); + + return { success: true }; + } catch (error) { + console.error('Error deleting preset:', error); + return { success: false, error: 'Failed to delete preset' }; + } + }, []); + + return { + savePreset, + getPresets, + deletePreset, + isSaving, + isLoading, + maxPresets: MAX_PRESETS, + }; +} \ No newline at end of file diff --git a/tdout on a dedicated line b/tdout on a dedicated line new file mode 100644 index 00000000..333a0b57 --- /dev/null +++ b/tdout on a dedicated line @@ -0,0 +1,258 @@ + + SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS + + Commands marked with * may be preceded by a number, _N. + Notes in parentheses indicate the behavior if _N is given. + A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K. + + h H Display this help. + q :q Q :Q ZZ Exit. + --------------------------------------------------------------------------- + + MMOOVVIINNGG + + e ^E j ^N CR * Forward one line (or _N lines). + y ^Y k ^K ^P * Backward one line (or _N lines). + f ^F ^V SPACE * Forward one window (or _N lines). + b ^B ESC-v * Backward one window (or _N lines). + z * Forward one window (and set window to _N). + w * Backward one window (and set window to _N). + ESC-SPACE * Forward one window, but don't stop at end-of-file. + d ^D * Forward one half-window (and set half-window to _N). + u ^U * Backward one half-window (and set half-window to _N). + ESC-) RightArrow * Right one half screen width (or _N positions). + ESC-( LeftArrow * Left one half screen width (or _N positions). + ESC-} ^RightArrow Right to last column displayed. + ESC-{ ^LeftArrow Left to first column. + F Forward forever; like "tail -f". + ESC-F Like F but stop when search pattern is found. + r ^R ^L Repaint screen. + R Repaint screen, discarding buffered input. + --------------------------------------------------- + Default "window" is the screen height. + Default "half-window" is half of the screen height. + --------------------------------------------------------------------------- + + SSEEAARRCCHHIINNGG + + /_p_a_t_t_e_r_n * Search forward for (_N-th) matching line. + ?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line. + n * Repeat previous search (for _N-th occurrence). + N * Repeat previous search in reverse direction. + ESC-n * Repeat previous search, spanning files. + ESC-N * Repeat previous search, reverse dir. & spanning files. + ESC-u Undo (toggle) search highlighting. + ESC-U Clear search highlighting. + &_p_a_t_t_e_r_n * Display only matching lines. + --------------------------------------------------- + A search pattern may begin with one or more of: + ^N or ! Search for NON-matching lines. + ^E or * Search multiple files (pass thru END OF FILE). + ^F or @ Start search at FIRST file (for /) or last file (for ?). + ^K Highlight matches, but don't move (KEEP position). + ^R Don't use REGULAR EXPRESSIONS. + ^W WRAP search if no match found. + --------------------------------------------------------------------------- + + JJUUMMPPIINNGG + + g < ESC-< * Go to first line in file (or line _N). + G > ESC-> * Go to last line in file (or line _N). + p % * Go to beginning of file (or _N percent into file). + t * Go to the (_N-th) next tag. + T * Go to the (_N-th) previous tag. + { ( [ * Find close bracket } ) ]. + } ) ] * Find open bracket { ( [. + ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>. + ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>. + --------------------------------------------------- + Each "find close bracket" command goes forward to the close bracket + matching the (_N-th) open bracket in the top line. + Each "find open bracket" command goes backward to the open bracket + matching the (_N-th) close bracket in the bottom line. + + m_<_l_e_t_t_e_r_> Mark the current top line with . + M_<_l_e_t_t_e_r_> Mark the current bottom line with . + '_<_l_e_t_t_e_r_> Go to a previously marked position. + '' Go to the previous position. + ^X^X Same as '. + ESC-M_<_l_e_t_t_e_r_> Clear a mark. + --------------------------------------------------- + A mark is any upper-case or lower-case letter. + Certain marks are predefined: + ^ means beginning of the file + $ means end of the file + --------------------------------------------------------------------------- + + CCHHAANNGGIINNGG FFIILLEESS + + :e [_f_i_l_e] Examine a new file. + ^X^V Same as :e. + :n * Examine the (_N-th) next file from the command line. + :p * Examine the (_N-th) previous file from the command line. + :x * Examine the first (or _N-th) file from the command line. + :d Delete the current file from the command line list. + = ^G :f Print current file name. + --------------------------------------------------------------------------- + + MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS + + -_<_f_l_a_g_> Toggle a command line option [see OPTIONS below]. + --_<_n_a_m_e_> Toggle a command line option, by name. + __<_f_l_a_g_> Display the setting of a command line option. + ___<_n_a_m_e_> Display the setting of an option, by name. + +_c_m_d Execute the less cmd each time a new file is examined. + + !_c_o_m_m_a_n_d Execute the shell command with $SHELL. + |XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command. + s _f_i_l_e Save input to a file. + v Edit the current file with $VISUAL or $EDITOR. + V Print version number of "less". + --------------------------------------------------------------------------- + + OOPPTTIIOONNSS + + Most options may be changed either on the command line, + or from within less by using the - or -- command. + Options may be given in one of two forms: either a single + character preceded by a -, or a name preceded by --. + + -? ........ --help + Display help (from command line). + -a ........ --search-skip-screen + Search skips current screen. + -A ........ --SEARCH-SKIP-SCREEN + Search starts just after target line. + -b [_N] .... --buffers=[_N] + Number of buffers. + -B ........ --auto-buffers + Don't automatically allocate buffers for pipes. + -c ........ --clear-screen + Repaint by clearing rather than scrolling. + -d ........ --dumb + Dumb terminal. + -D xx_c_o_l_o_r . --color=xx_c_o_l_o_r + Set screen colors. + -e -E .... --quit-at-eof --QUIT-AT-EOF + Quit at end of file. + -f ........ --force + Force open non-regular files. + -F ........ --quit-if-one-screen + Quit if entire file fits on first screen. + -g ........ --hilite-search + Highlight only last match for searches. + -G ........ --HILITE-SEARCH + Don't highlight any matches for searches. + -h [_N] .... --max-back-scroll=[_N] + Backward scroll limit. + -i ........ --ignore-case + Ignore case in searches that do not contain uppercase. + -I ........ --IGNORE-CASE + Ignore case in all searches. + -j [_N] .... --jump-target=[_N] + Screen position of target lines. + -J ........ --status-column + Display a status column at left edge of screen. + -k [_f_i_l_e] . --lesskey-file=[_f_i_l_e] + Use a lesskey file. + -K ........ --quit-on-intr + Exit less in response to ctrl-C. + -L ........ --no-lessopen + Ignore the LESSOPEN environment variable. + -m -M .... --long-prompt --LONG-PROMPT + Set prompt style. + -n -N .... --line-numbers --LINE-NUMBERS + Don't use line numbers. + -o [_f_i_l_e] . --log-file=[_f_i_l_e] + Copy to log file (standard input only). + -O [_f_i_l_e] . --LOG-FILE=[_f_i_l_e] + Copy to log file (unconditionally overwrite). + -p [_p_a_t_t_e_r_n] --pattern=[_p_a_t_t_e_r_n] + Start at pattern (from command line). + -P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t] + Define new prompt. + -q -Q .... --quiet --QUIET --silent --SILENT + Quiet the terminal bell. + -r -R .... --raw-control-chars --RAW-CONTROL-CHARS + Output "raw" control characters. + -s ........ --squeeze-blank-lines + Squeeze multiple blank lines. + -S ........ --chop-long-lines + Chop (truncate) long lines rather than wrapping. + -t [_t_a_g] .. --tag=[_t_a_g] + Find a tag. + -T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e] + Use an alternate tags file. + -u -U .... --underline-special --UNDERLINE-SPECIAL + Change handling of backspaces. + -V ........ --version + Display the version number of "less". + -w ........ --hilite-unread + Highlight first new line after forward-screen. + -W ........ --HILITE-UNREAD + Highlight first new line after any forward movement. + -x [_N[,...]] --tabs=[_N[,...]] + Set tab stops. + -X ........ --no-init + Don't use termcap init/deinit strings. + -y [_N] .... --max-forw-scroll=[_N] + Forward scroll limit. + -z [_N] .... --window=[_N] + Set size of window. + -" [_c[_c]] . --quotes=[_c[_c]] + Set shell quote characters. + -~ ........ --tilde + Don't display tildes after end of file. + -# [_N] .... --shift=[_N] + Set horizontal scroll amount (0 = one half screen width). + --file-size + Automatically determine the size of the input file. + --follow-name + The F command changes files if the input file is renamed. + --incsearch + Search file as each pattern character is typed in. + --line-num-width=N + Set the width of the -N line number field to N characters. + --mouse + Enable mouse input. + --no-keypad + Don't send termcap keypad init/deinit strings. + --no-histdups + Remove duplicates from command history. + --rscroll=C + Set the character used to mark truncated lines. + --save-marks + Retain marks across invocations of less. + --status-col-width=N + Set the width of the -J status column to N characters. + --use-backslash + Subsequent options use backslash as escape char. + --use-color + Enables colored text. + --wheel-lines=N + Each click of the mouse wheel moves N lines. + + + --------------------------------------------------------------------------- + + LLIINNEE EEDDIITTIINNGG + + These keys can be used to edit text being entered + on the "command line" at the bottom of the screen. + + RightArrow ..................... ESC-l ... Move cursor right one character. + LeftArrow ...................... ESC-h ... Move cursor left one character. + ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word. + ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word. + HOME ........................... ESC-0 ... Move cursor to start of line. + END ............................ ESC-$ ... Move cursor to end of line. + BACKSPACE ................................ Delete char to left of cursor. + DELETE ......................... ESC-x ... Delete char under cursor. + ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor. + ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor. + ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line. + UpArrow ........................ ESC-k ... Retrieve previous command line. + DownArrow ...................... ESC-j ... Retrieve next command line. + TAB ...................................... Complete filename & cycle. + SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle. + ctrl-L ................................... Complete filename, list all. diff --git a/types/preset.ts b/types/preset.ts new file mode 100644 index 00000000..83da17f5 --- /dev/null +++ b/types/preset.ts @@ -0,0 +1,19 @@ +export interface DosePreset { + id: string; + name: string; + substanceName: string; + doseValue: number; + unit: 'mg' | 'mcg' | 'units' | 'mL'; + concentrationValue?: number; + concentrationUnit?: 'mg/ml' | 'mcg/ml' | 'units/ml'; + totalAmount?: number; + totalAmountUnit?: 'mg' | 'mcg' | 'units'; + solutionVolume?: number; + notes?: string; + timestamp: string; +} + +export interface PresetFormData { + name: string; + notes?: string; +} \ No newline at end of file