diff --git a/apps/mobile/src/app/(tabs)/fertilizers.tsx b/apps/mobile/src/app/(tabs)/fertilizers.tsx
index c0f6c98..09c818a 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'
@@ -545,7 +545,7 @@ export function FertilizersScreen() {
)
}
-function AddEditFertilizerModal({
+export function AddEditFertilizerModal({
formData,
isVisible,
mode,
@@ -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/__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/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/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 8bb8587..a8c9ed7 100644
--- a/apps/mobile/src/components/TextInput.tsx
+++ b/apps/mobile/src/components/TextInput.tsx
@@ -1,24 +1,55 @@
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, type TextInputProps as RNTextInputProps, View, type StyleProp, type TextStyle, type ViewStyle } from 'react-native'
-import type { TrpcRouter } from '../trpc'
+import { TrpcRouter } from '@plannting/api/dist/routers/trpc'
import { InputError } from './InputError'
export type TextInputProps = RNTextInputProps & {
- containerStyle?: StyleProp
+ containerStyle?: StyleProp,
+ errorStyle?: StyleProp,
fieldError?: NonNullable['data']['fieldErrors']>[number],
}
-export const TextInput = ({ fieldError, ...props }: TextInputProps) => {
+export const TextInput = ({
+ containerStyle,
+ errorStyle,
+ fieldError,
+ ...props
+}: TextInputProps) => {
+ const [isFocused, setIsFocused] = React.useState(false)
+
+ const isMultiline = !!props.multiline
+
+ // 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 (
-
+
{
+ if (isMultiline) {
+ setIsFocused(true)
+ }
+
+ props.onFocus?.(event)
+ }}
+ onBlur={(event) => {
+ if (isMultiline) {
+ setIsFocused(false)
+ }
+
+ props.onBlur?.(event)
+ }}
/>
- {fieldError && }
+ {fieldError && }
)
}