diff --git a/src/components/settings/KeywordSettings.tsx b/src/components/settings/KeywordSettings.tsx index 7ac2dceb..072eec9a 100644 --- a/src/components/settings/KeywordSettings.tsx +++ b/src/components/settings/KeywordSettings.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { View, Text, StyleSheet, Pressable, TextInput, Alert, ActivityIndicator } from 'react-native'; import { Plus, Pencil, Trash2, Check, X, RotateCcw, ScanSearch } from '../icons'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; +import { SettingsKeyboardContext } from './keyboard-reveal'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useKeywordsStore, currentTagsLogin, type KeywordDef, type KeywordVisibility } from '../../stores/keywords-store'; @@ -343,6 +344,9 @@ function KeywordForm({ initial, existingIds, keywords, nested, onSave, onCancel ...parentKeywordOptions(keywords).map((o) => ({ value: o.id, label: o.label })), ]; const initialParent = initial ? getParentKeywordId(initial.id) : null; + // The whole form goes above the keyboard, the parent picker included. + const revealOnKeyboard = React.useContext(SettingsKeyboardContext); + const formRef = React.useRef(null); const handleSave = () => { if (!isValid) return; @@ -350,7 +354,7 @@ function KeywordForm({ initial, existingIds, keywords, nested, onSave, onCancel }; return ( - + {nested && !initial && keywords.length > 0 && ( {t('settings.keywords.parent_field', 'Parent tag')} @@ -368,6 +372,7 @@ function KeywordForm({ initial, existingIds, keywords, nested, onSave, onCancel revealOnKeyboard?.(formRef.current)} placeholder={t('settings.keywords.label_placeholder', "e.g. Work, Personal, Urgent")} placeholderTextColor={c.mutedForeground} style={styles.input} diff --git a/src/components/settings/__tests__/keyboard-reveal.test.ts b/src/components/settings/__tests__/keyboard-reveal.test.ts new file mode 100644 index 00000000..7ab1e0aa --- /dev/null +++ b/src/components/settings/__tests__/keyboard-reveal.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { revealScrollY, settingsPagePaddingBottom, visibleHeightAboveKeyboard } from '../keyboard-reveal'; + +// Settings > Tags > Add tag: the form at the end of the page stayed under the +// keyboard (the window does not shrink for it on Android edge to edge), so +// taps meant for "No parent" landed on the keys. + +const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', '..', rel), 'utf8'); + +describe('settings keyboard reveal', () => { + // A phone: page from y=80 to y=800, keyboard top at y=500. + const visible = visibleHeightAboveKeyboard(80, 720, 500); + + it('measures the page height left above the keyboard', () => { + expect(visible).toBe(420); + expect(visibleHeightAboveKeyboard(80, 720, 900)).toBe(720); + expect(visibleHeightAboveKeyboard(80, 720, 60)).toBe(0); + }); + + it('scrolls a form under the keyboard up until its end clears it', () => { + // Form at page y 600..800 with the page at the top: its end is hidden. + expect(revealScrollY(0, 600, 800, visible, 12)).toBe(392); + }); + + it('keeps the top of a form taller than the space above the keyboard', () => { + expect(revealScrollY(0, 600, 1200, visible, 12)).toBe(588); + }); + + it('leaves a form that already fits alone', () => { + expect(revealScrollY(0, 100, 300, visible, 12)).toBeNull(); + }); + + it('pads the page end by the keyboard so it can scroll that far', () => { + expect(settingsPagePaddingBottom(40, 0)).toBe(40); + expect(settingsPagePaddingBottom(40, 300)).toBe(340); + }); + + it('pads the settings page and hands the tag form to it on focus', () => { + const screen = read('screens/SettingsScreen.tsx'); + expect(screen).toMatch(/paddingBottom: settingsPagePaddingBottom\(DETAIL_PADDING_BOTTOM, keyboard\)/); + expect(screen).toMatch(//); + const tags = read('components/settings/KeywordSettings.tsx'); + expect(tags).toMatch(//); + expect(tags).toMatch(/onFocus=\{\(\) => revealOnKeyboard\?\.\(formRef\.current\)\}/); + }); +}); diff --git a/src/components/settings/keyboard-reveal.ts b/src/components/settings/keyboard-reveal.ts new file mode 100644 index 00000000..f6076e0a --- /dev/null +++ b/src/components/settings/keyboard-reveal.ts @@ -0,0 +1,55 @@ +import { createContext } from 'react'; +import type { View } from 'react-native'; + +/** + * Keeps a settings form in view while the keyboard is up. Android draws the + * app edge to edge, so the window does not shrink for the keyboard: a form at + * the bottom of a settings page (Settings > Tags > Add tag) stayed under the + * keyboard, and taps meant for its fields landed on the keys. The page pads + * its bottom by the keyboard and scrolls the form the user is typing in above + * it. + */ + +/** + * Hand a form's outer View to the settings page when one of its fields gets + * focus; the page scrolls the whole form above the keyboard. Without a form, + * the page scrolls the focused field into view. + */ +export const SettingsKeyboardContext = createContext<((form: View | null) => void) | null>(null); + +/** Space kept between the form and the keyboard or the page top. */ +export const REVEAL_MARGIN = 12; + +/** + * The height of the scroll area still visible above the keyboard. + * `viewportTop` and `keyboardTop` are window coordinates. + */ +export function visibleHeightAboveKeyboard(viewportTop: number, viewportHeight: number, keyboardTop: number): number { + return Math.max(0, Math.min(viewportHeight, keyboardTop - viewportTop)); +} + +/** + * Where to scroll so the form (content coordinates `formTop`..`formBottom`) + * fits in the `visibleHeight` above the keyboard, or null when it already + * does. A form taller than that keeps its top in view. + */ +export function revealScrollY( + scrollY: number, + formTop: number, + formBottom: number, + visibleHeight: number, + margin: number = REVEAL_MARGIN, +): number | null { + if (visibleHeight <= 0) return null; + const keepTop = formTop - margin; + let target = scrollY; + if (formBottom + margin > scrollY + visibleHeight) target = Math.min(formBottom + margin - visibleHeight, keepTop); + else if (keepTop < scrollY) target = keepTop; + target = Math.max(0, Math.round(target)); + return target === Math.round(scrollY) ? null : target; +} + +/** Bottom padding of a settings page: room to scroll its end above the keyboard. */ +export function settingsPagePaddingBottom(base: number, keyboard: number): number { + return base + Math.max(0, Math.round(keyboard)); +} diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 309761f7..aa9c55b2 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; -import { View, Text, StyleSheet, ScrollView, Pressable, TextInput, Keyboard } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { View, Text, StyleSheet, ScrollView, Pressable, TextInput, Keyboard, Dimensions } from 'react-native'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction'; import { useLayout } from '../lib/use-layout'; import { confirmSignOut } from '../lib/sign-out-confirm'; import { @@ -71,11 +72,16 @@ import { type SettingsTabId, type SubResult, } from '../lib/settings-search'; import { SearchHighlightContext, type SearchHighlight } from '../components/settings/search-highlight'; +import { + SettingsKeyboardContext, revealScrollY, settingsPagePaddingBottom, visibleHeightAboveKeyboard, +} from '../components/settings/keyboard-reveal'; import { BUILTIN_THEMES } from '../theme/builtin-themes'; import { getDictionary } from '../i18n'; type Tab = SettingsTabId; +const DETAIL_PADDING_BOTTOM = 40; + type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced'; interface TabDef { @@ -391,6 +397,46 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin } setPendingHighlight(null); }, []); + // The window does not shrink for the keyboard (Android edge to edge, iOS): + // the page pads its end by the keyboard and scrolls the form being typed + // in above it (Settings > Tags > Add tag sat under the keyboard). + const insets = useSafeAreaInsets(); + const keyboard = useKeyboardObstruction(insets.bottom); + const keyboardRef = React.useRef(keyboard); + keyboardRef.current = keyboard; + const paneScrollY = React.useRef(0); + const revealForm = React.useRef(null); + const revealFocused = React.useCallback(() => { + if (keyboardRef.current <= 0) return; + const scroll = paneScrollRef.current; + const viewport = scroll?.getNativeScrollRef(); + const target = revealForm.current ?? (TextInput.State.currentlyFocusedInput() as unknown as View | null); + if (!scroll || !viewport || !target) return; + viewport.measureInWindow((_vx, viewportTop, _vw, viewportHeight) => { + target.measureInWindow((_x, top, _w, height) => { + const keyboardTop = Dimensions.get('screen').height - keyboardRef.current; + const visible = visibleHeightAboveKeyboard(viewportTop, viewportHeight, keyboardTop); + // Window coordinates turned into the page's own. + const formTop = top - viewportTop + paneScrollY.current; + const y = revealScrollY(paneScrollY.current, formTop, formTop + height, visible); + if (y !== null) scroll.scrollTo({ y, animated: true }); + }); + }); + }, []); + const revealOnKeyboard = React.useCallback((form: View | null) => { + revealForm.current = form; + revealFocused(); + }, [revealFocused]); + useEffect(() => { + if (keyboard <= 0) { + revealForm.current = null; + return undefined; + } + // After the keyboard padding is laid out, so the page can scroll that far. + const frame = requestAnimationFrame(revealFocused); + return () => cancelAnimationFrame(frame); + }, [keyboard, revealFocused]); + const searchHighlight = React.useMemo( () => (pendingHighlight && pendingHighlight.tab === selectedTab ? { label: pendingHighlight.label, reveal: revealHighlight } @@ -456,8 +502,17 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin {/* Keyed per page: React would otherwise reuse the list's ScrollView and open the page at the list's scroll offset. */} - + { paneScrollY.current = e.nativeEvent.contentOffset.y; }} + scrollEventThrottle={64} + > + {scopeBanner} {selectedTab === 'filters' ? ( @@ -470,6 +525,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin openPage('debug')} /> ) : Component ? : null} + @@ -662,7 +718,7 @@ function makeStyles(c: ThemePalette) { scrollContent: { paddingTop: spacing.md, paddingBottom: 40 }, // A page keeps form width on a wide pane: labels and their controls // 700 dp apart are hard to read across. - detailContent: { padding: spacing.lg, paddingBottom: 40, width: '100%', maxWidth: 720, alignSelf: 'center' }, + detailContent: { padding: spacing.lg, paddingBottom: DETAIL_PADDING_BOTTOM, width: '100%', maxWidth: 720, alignSelf: 'center' }, accountCard: { flexDirection: 'row',