From a11df2629864e70feb003489b62a1fe8f5e99c33 Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Tue, 3 Mar 2026 21:43:54 -1000 Subject: [PATCH 1/3] Keyboard avoiding views for notes. Closes #227 --- apps/mobile/src/app/(tabs)/fertilizers.tsx | 36 +- apps/mobile/src/app/plants/add-edit.tsx | 437 +++++++++--------- .../src/components/AddEditChoreModal.tsx | 36 +- apps/mobile/src/components/TextInput.tsx | 39 +- 4 files changed, 297 insertions(+), 251 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/fertilizers.tsx b/apps/mobile/src/app/(tabs)/fertilizers.tsx index c0f6c98..7e6b225 100644 --- a/apps/mobile/src/app/(tabs)/fertilizers.tsx +++ b/apps/mobile/src/app/(tabs)/fertilizers.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Modal, ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native' +import { KeyboardAvoidingView, Modal, Platform, ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native' import { useLayoutEffect } from 'react' import { useLocalSearchParams, useNavigation } from 'expo-router' import { keepPreviousData } from '@tanstack/react-query' @@ -582,7 +582,11 @@ function AddEditFertilizerModal({ presentationStyle='fullScreen' onRequestClose={onClose} > - + {mode === 'add' ? 'Add New Fertilizer' : 'Edit Fertilizer'} @@ -591,7 +595,11 @@ function AddEditFertilizerModal({ - + Name onChange({ ...formData, name: text })} /> + Notes (optional) + onChange({ ...formData, notes: text })} + multiline + numberOfLines={3} + /> + Type - Notes (optional) - onChange({ ...formData, notes: text })} - multiline - numberOfLines={3} - /> - {mutation.error && ( Error: {mutation.error.message} @@ -699,7 +707,7 @@ function AddEditFertilizerModal({ - + ) } diff --git a/apps/mobile/src/app/plants/add-edit.tsx b/apps/mobile/src/app/plants/add-edit.tsx index c856a43..7f964eb 100644 --- a/apps/mobile/src/app/plants/add-edit.tsx +++ b/apps/mobile/src/app/plants/add-edit.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react' -import { ActivityIndicator, Image, Linking, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native' +import { ActivityIndicator, Image, KeyboardAvoidingView, Linking, Modal, Platform, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native' import * as Device from 'expo-device' import * as ImagePicker from 'expo-image-picker' import { Ionicons } from '@expo/vector-icons' @@ -375,233 +375,248 @@ export function AddEditPlantScreen() { )) || ( - { - if (showSpeciesSuggestions) { - setShowSpeciesSuggestions(false) - } - }} + - { + if (showSpeciesSuggestions) { + setShowSpeciesSuggestions(false) + } }} + keyboardShouldPersistTaps="handled" + contentContainerStyle={{ paddingBottom: 24 }} > - {identificationPhotoUri && ( - - )} + + {identificationPhotoUri && ( + + )} - - - - {identificationPhotoUri ? 'Retake' : 'Take'} Photo - - - - Or choose from library - + + + + {identificationPhotoUri ? 'Retake' : 'Take'} Photo + + + + Or choose from library + + - - {showNameInput && ( - <> - Name - setFormData(formData => ({ ...formData, name: text }))} - /> - - )} - {(selectedSpecies && !showSpeciesInput && ( - <> - Species - - {mode === 'add' && ( - <> - - Fertilizer Recommendations - - - - )} - - )) || ( - <> - Species - + {showNameInput && ( + <> + Name { - setSpeciesSearchQuery(text) - setShowSpeciesSuggestions(true) - }} - onFocus={() => setShowSpeciesSuggestions(true)} - onBlur={handleSpeciesInputBlur} + placeholder='Name' + value={formData.name} + onChangeText={text => setFormData(formData => ({ ...formData, name: text }))} /> - {(isLoadingSpecies || isLoadingIdentification) && ( - - - + + {( + mode !== 'add' || + !!formData.speciesId + ) && ( + <> + Notes (optional) + setFormData(formData => ({ ...formData, notes: text }))} + multiline + numberOfLines={3} + /> + )} - {showSpeciesSuggestions && speciesData?.species && speciesData.species.length > 0 && ( - + )} + {(selectedSpecies && !showSpeciesInput && ( + <> + Species + + {mode === 'add' && ( + <> + + Fertilizer Recommendations + + + + )} + + )) || ( + <> + Species + + { + setSpeciesSearchQuery(text) + setShowSpeciesSuggestions(true) }} - > - { - e.stopPropagation() - e.preventDefault() + onFocus={() => setShowSpeciesSuggestions(true)} + onBlur={handleSpeciesInputBlur} + /> + {(isLoadingSpecies || isLoadingIdentification) && ( + + + + )} + {showSpeciesSuggestions && speciesData?.species && speciesData.species.length > 0 && ( + - {speciesData.species.map((item) => ( - handleSpeciesSelect(item)} - > - {item.imageUrl && ( - - )} - - {item.commonName} - {item.scientificName && ( - {item.scientificName} - )} - {(item.family || item.genus) && ( - - {[item.genus, item.family].filter(Boolean).join(' • ')} - + { + e.stopPropagation() + e.preventDefault() + }} + > + {speciesData.species.map((item) => ( + handleSpeciesSelect(item)} + > + {item.imageUrl && ( + )} - - - ))} - - - )} - - - )} + + {item.commonName} + {item.scientificName && ( + {item.scientificName} + )} + {(item.family || item.genus) && ( + + {[item.genus, item.family].filter(Boolean).join(' • ')} + + )} + + + ))} + + + )} + + + )} - Planted On - { - if (selectedDate) { - setFormData(formData => ({ ...formData, plantedAt: selectedDate })) - } - }} - setShowPicker={setShowPlantedAtDatePicker} - showPicker={showPlantedAtDatePicker} - value={formData.plantedAt} - /> - - Current Lifecycle - setFormData(formData => ({ ...formData, lifecycle: value.toLowerCase() as PlantLifecycle }))} - icons={{ - 'Start': LIFECYCLE_ICONS.start, - 'Veg': LIFECYCLE_ICONS.veg, - 'Bloom': LIFECYCLE_ICONS.bloom, - 'Fruiting': LIFECYCLE_ICONS.fruiting, - }} - /> - - Notes (optional) - setFormData(formData => ({ ...formData, notes: text }))} - multiline - numberOfLines={3} - /> - - {mutation.error && ( - - Error: {mutation.error.message} - - )} + Planted On + { + if (selectedDate) { + setFormData(formData => ({ ...formData, plantedAt: selectedDate })) + } + }} + setShowPicker={setShowPlantedAtDatePicker} + showPicker={showPlantedAtDatePicker} + value={formData.plantedAt} + /> - - - {mutation.isPending ? 'Saving...' : 'Save'} - - - router.back()} - disabled={mutation.isPending} - > - - Cancel - - - + Current Lifecycle + setFormData(formData => ({ ...formData, lifecycle: value.toLowerCase() as PlantLifecycle }))} + icons={{ + 'Start': LIFECYCLE_ICONS.start, + 'Veg': LIFECYCLE_ICONS.veg, + 'Bloom': LIFECYCLE_ICONS.bloom, + 'Fruiting': LIFECYCLE_ICONS.fruiting, + }} + /> + + {mutation.error && ( + + Error: {mutation.error.message} + + )} + + + + {mutation.isPending ? 'Saving...' : 'Save'} + + + router.back()} + disabled={mutation.isPending} + > + + Cancel + + + + )} - + {mode === 'add' ? 'Add New Chore' : 'Edit Chore'} @@ -59,7 +63,11 @@ export function AddEditChoreModal({ - + Fertilizers {formData.fertilizers.map((fert, index) => ( @@ -131,6 +139,16 @@ export function AddEditChoreModal({ onChangeText={(text) => onChange({ ...formData, description: text })} /> + Notes (optional) + onChange({ ...formData, notes: text })} + multiline + numberOfLines={3} + /> + Repeat - Notes (optional) - onChange({ ...formData, notes: text })} - multiline - numberOfLines={3} - /> - {mutation.error && ( Error: {mutation.error.message} @@ -222,7 +230,7 @@ export function AddEditChoreModal({ Cancel - + ) } diff --git a/apps/mobile/src/components/TextInput.tsx b/apps/mobile/src/components/TextInput.tsx index 8bb8587..e0e1b96 100644 --- a/apps/mobile/src/components/TextInput.tsx +++ b/apps/mobile/src/components/TextInput.tsx @@ -10,23 +10,38 @@ export type TextInputProps = RNTextInputProps & { fieldError?: NonNullable['data']['fieldErrors']>[number], } -export const TextInput = ({ fieldError, ...props }: TextInputProps) => { +export const TextInput = (props: TextInputProps) => { + const [isFocused, setIsFocused] = React.useState(false) + + const isMultiline = !!props.multiline + + // KeyboardAvoidingView is buggy with multiline inputs. To avoid this, keep scrollEnabled=false when not focused. + const resolvedScrollEnabled = (isMultiline && !isFocused && false) ?? props.scrollEnabled + return ( - - - - {fieldError && } - + { + if (isMultiline) { + setTimeout(() => setIsFocused(true), 500) + } + + props.onFocus?.(event) + }} + onBlur={(event) => { + if (isMultiline) { + setIsFocused(false) + } + + props.onBlur?.(event) + }} + /> ) } const localStyles = StyleSheet.create({ - container: { - marginBottom: 16, - }, input: { backgroundColor: '#fff', borderWidth: 1, From 2ea72456a6e76102247f18972fd6b1747973df86 Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Wed, 4 Mar 2026 09:06:11 -1000 Subject: [PATCH 2/3] Mobile tests for KeyboardAvoidingView --- apps/mobile/src/app/(tabs)/fertilizers.tsx | 2 +- .../__tests__/keyboard-visibility.test.tsx | 260 ++++++++++++++++++ apps/mobile/src/components/TextInput.tsx | 22 +- 3 files changed, 269 insertions(+), 15 deletions(-) create mode 100644 apps/mobile/src/app/__tests__/keyboard-visibility.test.tsx diff --git a/apps/mobile/src/app/(tabs)/fertilizers.tsx b/apps/mobile/src/app/(tabs)/fertilizers.tsx index 7e6b225..09c818a 100644 --- a/apps/mobile/src/app/(tabs)/fertilizers.tsx +++ b/apps/mobile/src/app/(tabs)/fertilizers.tsx @@ -545,7 +545,7 @@ export function FertilizersScreen() { ) } -function AddEditFertilizerModal({ +export function AddEditFertilizerModal({ formData, isVisible, mode, diff --git a/apps/mobile/src/app/__tests__/keyboard-visibility.test.tsx b/apps/mobile/src/app/__tests__/keyboard-visibility.test.tsx new file mode 100644 index 0000000..7d383b6 --- /dev/null +++ b/apps/mobile/src/app/__tests__/keyboard-visibility.test.tsx @@ -0,0 +1,260 @@ +import React, { act } from 'react' +import { render, fireEvent, screen } from '@testing-library/react-native' + +import { AddEditChoreModal } from '../../components/AddEditChoreModal' + +import { AddEditFertilizerModal } from '../(tabs)/fertilizers' +import AddEditPlantScreen from '../plants/add-edit' + +// Mock vector icons so Icon components don't trigger async updates (e.g. font load) that cause act(...) warnings. +// Factory must not reference out-of-scope variables; require inside the factory. +jest.mock('@expo/vector-icons', () => { + const React = require('react') + const { View } = require('react-native') + + return { + Ionicons: (props: unknown) => React.createElement(View, { ...(props as object), testID: 'icon' }), + } +}) + +// Mocks for navigation and routing used by the screens +jest.mock('expo-router', () => ({ + useLocalSearchParams: jest.fn(() => ({})), + useNavigation: jest.fn(() => ({ + getParent: () => ({ setOptions: jest.fn() }), + })), + useRouter: jest.fn(() => ({ + back: jest.fn(), + })), +})) + +// Mock Alert context to avoid needing a real provider +jest.mock('../../contexts/AlertContext', () => ({ + useAlert: () => ({ + alert: jest.fn(), + }), +})) + +// Avoid needing a real React Query client for focus refresh logic +jest.mock('../../hooks/useRefetchOnFocus', () => ({ + useRefreshOnFocus: jest.fn(), +})) + +// Mock tRPC hooks used by the screens +jest.mock('../../trpc', () => { + const plantsListUseQuery = jest.fn() + const plantsCreateUseMutation = jest.fn() + const plantsUpdateUseMutation = jest.fn() + const plantsIdentifyUseMutation = jest.fn() + const speciesListUseQuery = jest.fn() + + const fertilizersListUseQuery = jest.fn() + const fertilizersCreateUseMutation = jest.fn() + const fertilizersUpdateUseMutation = jest.fn() + const fertilizersDeleteUseMutation = jest.fn() + const fertilizersArchiveUseMutation = jest.fn() + const fertilizersUnarchiveUseMutation = jest.fn() + + return { + trpc: { + plants: { + list: { useQuery: plantsListUseQuery }, + create: { useMutation: plantsCreateUseMutation }, + update: { useMutation: plantsUpdateUseMutation }, + identifyByImages: { useMutation: plantsIdentifyUseMutation }, + }, + species: { + list: { useQuery: speciesListUseQuery }, + }, + fertilizers: { + list: { useQuery: fertilizersListUseQuery }, + create: { useMutation: fertilizersCreateUseMutation }, + update: { useMutation: fertilizersUpdateUseMutation }, + delete: { useMutation: fertilizersDeleteUseMutation }, + archive: { useMutation: fertilizersArchiveUseMutation }, + unarchive: { useMutation: fertilizersUnarchiveUseMutation }, + }, + }, + } +}) + +const getTrpcMock = () => (jest.requireMock('../../trpc').trpc as any) + +describe('Keyboard visibility for multiline inputs', () => { + describe('Add/Edit Chore modal', () => { + it('keeps Notes input scrollable when focused so it can move above the keyboard', () => { + const formData = { + description: '', + lifecycles: [], + fertilizers: [], + recurAmount: '', + recurUnit: '', + notes: '', + } as any + + const { getByPlaceholderText } = render( + + ) + + const notesInput = getByPlaceholderText('Notes (optional)') + + expect(notesInput.props.multiline).toBe(true) + expect(notesInput.props.scrollEnabled).toBe(false) + + act(() => { + fireEvent(notesInput, 'focus') + }) + const notesFocused = getByPlaceholderText('Notes (optional)') + expect(notesFocused.props.scrollEnabled).toBe(true) + + act(() => { + fireEvent(notesFocused, 'blur') + }) + const notesBlurred = getByPlaceholderText('Notes (optional)') + expect(notesBlurred.props.scrollEnabled).toBe(false) + }) + }) + + describe('Add/Edit Fertilizer modal', () => { + it('keeps Notes input scrollable when focused so it can move above the keyboard', () => { + const { getByPlaceholderText } = render( + + ) + + const notesInput = getByPlaceholderText('Notes (optional)') + + expect(notesInput.props.multiline).toBe(true) + expect(notesInput.props.scrollEnabled).toBe(false) + + act(() => { + fireEvent(notesInput, 'focus') + }) + const notesFocused = getByPlaceholderText('Notes (optional)') + expect(notesFocused.props.scrollEnabled).toBe(true) + + act(() => { + fireEvent(notesFocused, 'blur') + }) + const notesBlurred = getByPlaceholderText('Notes (optional)') + expect(notesBlurred.props.scrollEnabled).toBe(false) + }) + }) + + describe('Add/Edit Plant screen', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('hides Notes on Add until a species is set, then keeps it scrollable when focused', () => { + const trpc = getTrpcMock() + + // In Add mode: plants.list is disabled, but species.list may be called + trpc.plants.list.useQuery.mockReturnValue({ + data: { plants: [] }, + isLoading: false, + isError: false, + error: null, + isRefetching: false, + refetch: jest.fn(), + }) + + trpc.species.list.useQuery.mockReturnValue({ + data: { species: [] }, + isLoading: false, + }) + + trpc.plants.create.useMutation.mockReturnValue({ + mutate: jest.fn(), + isPending: false, + error: null, + }) + + trpc.plants.update.useMutation.mockReturnValue({ + mutate: jest.fn(), + isPending: false, + error: null, + }) + + trpc.plants.identifyByImages.useMutation.mockReturnValue({ + mutate: jest.fn(), + isPending: false, + error: null, + }) + + // Add mode: no id in params + const expoRouter = jest.requireMock('expo-router') + expoRouter.useLocalSearchParams.mockReturnValue({}) + + const { rerender } = render() + + // Simulate that a species has been chosen by setting speciesId in form data via rerender. + // We can't access internal state directly, but we can re-render the screen in "edit" mode + // with a plant that has speciesId set, which uses the same Notes rendering logic. + expoRouter.useLocalSearchParams.mockReturnValue({ id: 'plant-1' }) + + trpc.plants.list.useQuery.mockReturnValue({ + data: { + plants: [ + { + _id: 'plant-1', + name: 'Tomato', + plantedAt: null, + lifecycle: 'start', + notes: '', + species: { _id: 'species-1' }, + }, + ], + }, + isLoading: false, + isError: false, + error: null, + isRefetching: false, + refetch: jest.fn(), + }) + + rerender() + + const notesInput = screen.getByPlaceholderText('Notes (optional)') + expect(notesInput.props.multiline).toBe(true) + expect(notesInput.props.scrollEnabled).toBe(false) + + act(() => { + fireEvent(notesInput, 'focus') + }) + const notesFocused = screen.getByPlaceholderText('Notes (optional)') + expect(notesFocused.props.scrollEnabled).toBe(true) + + act(() => { + fireEvent(notesFocused, 'blur') + }) + const notesBlurred = screen.getByPlaceholderText('Notes (optional)') + expect(notesBlurred.props.scrollEnabled).toBe(false) + }) + }) +}) + diff --git a/apps/mobile/src/components/TextInput.tsx b/apps/mobile/src/components/TextInput.tsx index e0e1b96..e740a43 100644 --- a/apps/mobile/src/components/TextInput.tsx +++ b/apps/mobile/src/components/TextInput.tsx @@ -1,22 +1,16 @@ import React from 'react' -import { type StyleProp, StyleSheet, TextInput as RNTextInput, TextInputProps as RNTextInputProps, View, type ViewStyle } from 'react-native' +import { StyleSheet, TextInput as RNTextInput, TextInputProps as RNTextInputProps } from 'react-native' -import type { TrpcRouter } from '../trpc' - -import { InputError } from './InputError' - -export type TextInputProps = RNTextInputProps & { - containerStyle?: StyleProp - fieldError?: NonNullable['data']['fieldErrors']>[number], -} - -export const TextInput = (props: TextInputProps) => { +export const TextInput = (props: RNTextInputProps) => { const [isFocused, setIsFocused] = React.useState(false) const isMultiline = !!props.multiline - // KeyboardAvoidingView is buggy with multiline inputs. To avoid this, keep scrollEnabled=false when not focused. - const resolvedScrollEnabled = (isMultiline && !isFocused && false) ?? props.scrollEnabled + // For multiline inputs, keep scrollEnabled=false when not focused, + // and true when focused so content can scroll above the keyboard. + const resolvedScrollEnabled = isMultiline + ? (props.scrollEnabled ?? isFocused) + : props.scrollEnabled return ( { scrollEnabled={resolvedScrollEnabled} onFocus={(event) => { if (isMultiline) { - setTimeout(() => setIsFocused(true), 500) + setIsFocused(true) } props.onFocus?.(event) From 496ca9cd01cc410ddb88c413dbe4872f70982cb7 Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Wed, 4 Mar 2026 09:33:35 -1000 Subject: [PATCH 3/3] Fix/restore InputError --- apps/mobile/src/components/InputError.tsx | 8 +-- apps/mobile/src/components/TextInput.tsx | 64 +++++++++++++++-------- 2 files changed, 48 insertions(+), 24 deletions(-) diff --git a/apps/mobile/src/components/InputError.tsx b/apps/mobile/src/components/InputError.tsx index 8b1e9c0..3e3c077 100644 --- a/apps/mobile/src/components/InputError.tsx +++ b/apps/mobile/src/components/InputError.tsx @@ -1,13 +1,15 @@ import React from 'react' -import { StyleSheet, Text, TextProps } from 'react-native' +import { StyleSheet, Text, type TextProps } from 'react-native' import { styles } from '../styles' -export const InputError = ({ error, ...props }: TextProps & { error?: string }) => { +export type InputErrorProps = TextProps & { error?: string } + +export const InputError = ({ error, style,...props }: InputErrorProps) => { return ( {error} diff --git a/apps/mobile/src/components/TextInput.tsx b/apps/mobile/src/components/TextInput.tsx index e740a43..a8c9ed7 100644 --- a/apps/mobile/src/components/TextInput.tsx +++ b/apps/mobile/src/components/TextInput.tsx @@ -1,7 +1,22 @@ import React from 'react' -import { StyleSheet, TextInput as RNTextInput, TextInputProps as RNTextInputProps } from 'react-native' +import { StyleSheet, TextInput as RNTextInput, type TextInputProps as RNTextInputProps, View, type StyleProp, type TextStyle, type ViewStyle } from 'react-native' -export const TextInput = (props: RNTextInputProps) => { +import { TrpcRouter } from '@plannting/api/dist/routers/trpc' + +import { InputError } from './InputError' + +export type TextInputProps = RNTextInputProps & { + containerStyle?: StyleProp, + errorStyle?: StyleProp, + fieldError?: NonNullable['data']['fieldErrors']>[number], +} + +export const TextInput = ({ + containerStyle, + errorStyle, + fieldError, + ...props +}: TextInputProps) => { const [isFocused, setIsFocused] = React.useState(false) const isMultiline = !!props.multiline @@ -13,29 +28,36 @@ export const TextInput = (props: RNTextInputProps) => { : props.scrollEnabled return ( - { - if (isMultiline) { - setIsFocused(true) - } - - props.onFocus?.(event) - }} - onBlur={(event) => { - if (isMultiline) { - setIsFocused(false) - } - - props.onBlur?.(event) - }} - /> + + { + if (isMultiline) { + setIsFocused(true) + } + + props.onFocus?.(event) + }} + onBlur={(event) => { + if (isMultiline) { + setIsFocused(false) + } + + props.onBlur?.(event) + }} + /> + + {fieldError && } + ) } const localStyles = StyleSheet.create({ + container: { + marginBottom: 16, + }, input: { backgroundColor: '#fff', borderWidth: 1,