/** * La sezione Cerca del menù, solo su iPhone (su Android la ricerca sta in * cima alla pagina: logica-menu.ts). Fatta come la ricerca di Foto, che * l'utente conosce già: toccando la lente il campo si attiva da solo con la * tastiera e la X per chiudere; a campo vuoto i Recenti, le ultime ricette * aperte da qui, in una fila che si scorre di lato; scrivendo, le ricette * trovate in tutte le categorie, a tessere come l'elenco a griglia. * * Il campo è quello di sistema (headerSearchBarOptions): su iOS `autoFocus` * non c'è, quindi il fuoco lo dà il ref quando la sezione diventa quella in * vista. `obscureBackground: false` perché sotto il campo ci sono i Recenti e * devono vedersi mentre si scrive. * * Aprire una ricetta passa da apriRicetta, come dagli elenchi: sul telefono * la pagina a tutto schermo, sugli schermi larghi il pannello (e il menù * torna su Ricette). Prima la ricetta va in testa ai Recenti. */ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { FlatList, ScrollView, StyleSheet, Text, View } from 'react-native'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { NativeBottomTabNavigationProp } from '@react-navigation/bottom-tabs/unstable'; import type { SearchBarCommands } from 'react-native-screens'; import type { Categoria, Ricetta } from '../../domain/types.ts'; import { elencoCategorie } from '../../data/categorie.ts'; import { elencoRicette } from '../../data/ricette.ts'; import { caricaMiniature } from '../../data/foto.ts'; import { leggiImpostazione, salvaImpostazione } from '../../data/impostazioni.ts'; import { vocabolario } from '../../domain/lingua/index.ts'; import { useApp } from '../contesto.ts'; import { categoriaDi, datiTessera, filtraRicette, indiceCategorie } from '../logica-elenco.ts'; import { CHIAVE_RECENTI, conRecente, leggiRecenti, recentiVisibili } from '../logica-ricerca.ts'; import type { ParametriMenu } from '../navigazione.ts'; import { usePannelli } from '../pannelli.ts'; import type { Colori } from '../tema.ts'; import { SPAZIO, TESTO, useColori, useTinte } from '../tema.ts'; import type { Tinta } from '../tinte.ts'; import { tintaDi } from '../tinte.ts'; import TesseraRicetta from '../componenti/TesseraRicetta.tsx'; export default function Cerca() { const navigation = useNavigation>(); const { db, testo, lingua } = useApp(); const { apriRicetta } = usePannelli(); const colori = useColori(); const tinte = useTinte(); const stili = useMemo(() => creaStili(colori), [colori]); const voc = useMemo(() => vocabolario(lingua), [lingua]); const barra = useRef(null); const [query, setQuery] = useState(''); const [ricette, setRicette] = useState([]); const [perId, setPerId] = useState>({}); const [miniature, setMiniature] = useState>({}); const [recenti, setRecenti] = useState([]); const [errore, setErrore] = useState(false); // Mirror di `recenti`, letto e scritto in apri(): toccando due tessere di // fila, prima che React rifaccia il render, i due tocchi partirebbero // dagli stessi Recenti letti al render e l'ultimo a scrivere farebbe // sparire il precedente. Il ref è sempre allineato allo stato appena // scrive, quindi la seconda apertura riparte da quella vera. const recentiRef = useRef([]); // Quante volte apri() ha scritto un Recente nuovo. Se cresce mentre il // ricaricamento del focus è ancora in volo, quel tocco è più fresco di // quello che il ricaricamento sta per scrivere: senza questo controllo il // ricaricamento lo cancellava da recentiRef, e la prossima apertura // scriveva sul disco una lista senza quel tocco, perdendolo per sempre. const versioneRecenti = useRef(0); useLayoutEffect(() => { navigation.setOptions({ headerSearchBarOptions: { ref: barra, placeholder: testo('cerca.segnaposto'), onChangeText: (e) => setQuery(e.nativeEvent.text), obscureBackground: false, hideWhenScrolling: false, tintColor: colori.accento, }, }); }, [navigation, testo, colori]); // A ogni ritorno sulla sezione: le ricette (possono essere cambiate) e i // Recenti. Il fuoco al campo non sta più qui (vedi sotto): con useFocusEffect // riapriva la tastiera anche tornando da una ricetta appena aperta — il pop // della pila radice rifocalizza questa sezione — e Foto non lo fa. useFocusEffect( useCallback(() => { let vivo = true; // La versione all'inizio di questo giro: se apri() ne scrive una più // nuova prima che la lettura finisca, quello che si sta per leggere dal // disco è già superato e non va rimesso sopra (vedi versioneRecenti). const partenza = versioneRecenti.current; (async () => { try { const [elenco, categorie, salvato] = await Promise.all([ elencoRicette(db, { tipo: 'tutte' }), elencoCategorie(db), leggiImpostazione(db, CHIAVE_RECENTI), ]); // Stessa lettura di Elenco.tsx: caricaMiniature() (percorsoFoto() // controlla che il file esista davvero, quindi si aspetta la // promessa) e indiceCategorie() in logica-elenco.ts la fanno una // volta sola per le due schermate. const mappa = await caricaMiniature(elenco); if (!vivo) return; const indice = indiceCategorie(categorie); setRicette(elenco); setPerId(indice); setMiniature(mappa); setErrore(false); if (versioneRecenti.current === partenza) { const iniziali = leggiRecenti(salvato); recentiRef.current = iniziali; setRecenti(iniziali); } } catch (errore) { // Uno schermo vuoto e muto non distingue "non hai ancora ricette" // da "la lettura si è rotta": si dice l'errore, come nella gemella // Elenco.tsx. console.warn('lettura per la ricerca fallita', errore); if (vivo) setErrore(true); } })(); return () => { vivo = false; }; }, [db]), ); // Il fuoco al campo, come in Foto: solo sul tabPress della voce "Cerca" nel // menù, non al montaggio. Le tab native hanno `lazy = false` di default // (NativeBottomTabView.native.tsx): Cerca è già montata all'avvio insieme // alle altre sezioni, quindi un fuoco dato al montaggio scatterebbe 350 ms // dopo l'avvio dell'app anche se si sta guardando Ricette — la tastiera si // apre sopra la sezione sbagliata. `tabPress` va ascoltato sulla // navigazione del genitore perché `navigation` qui è quella della pila // interna (PilaCerca): quella della voce "Cerca" nel menù è un livello // sopra. Il ritardo resta necessario: tabPress arriva subito, prima che la // transizione della tab sia finita, e il fuoco dato troppo presto si perde. // Il timer sta in un ref e non in una chiusura locale perché due tabPress // ravvicinati (l'utente che tocca la lente due volte) devono cancellare // quello precedente, non accumularne due. const timerFuoco = useRef | undefined>(undefined); useEffect(() => { const fuoco = () => { clearTimeout(timerFuoco.current); timerFuoco.current = setTimeout(() => barra.current?.focus(), 350); }; const menu = navigation.getParent>(); const ascolto = menu?.addListener('tabPress', fuoco); return () => { clearTimeout(timerFuoco.current); ascolto?.(); }; }, [navigation]); const apri = (id: string) => { // Si parte da recentiRef, non dallo stato `recenti` letto a render: due // aperture rapide altrimenti calcolerebbero conRecente due volte dagli // stessi Recenti di partenza, e la seconda scrittura cancellerebbe la // prima. Il ref è aggiornato qui stesso, quindi la seconda apri() vede già // il risultato della prima. const nuovi = conRecente(recentiRef.current, id); recentiRef.current = nuovi; versioneRecenti.current += 1; setRecenti(nuovi); salvaImpostazione(db, CHIAVE_RECENTI, JSON.stringify(nuovi)).catch((errore) => { console.warn('salvataggio dei Recenti fallito', errore); }); apriRicetta(id); }; const tessera = (r: Ricetta) => { const cat = categoriaDi(perId, r); const tinta: Tinta = cat === null ? { forte: colori.testoTenue, tenue: colori.bordo } : tintaDi(cat, tinte); // Qui, a differenza di Elenco, la categoria entra sempre nel testo: è // l'unico posto in cui si vede da dove viene una ricetta trovata. const dati = datiTessera(r, voc, testo, cat?.nome ?? null); return ( apri(r.id)} /> ); }; const trovate = useMemo(() => filtraRicette(ricette, query), [ricette, query]); const ricetteRecenti = useMemo(() => recentiVisibili(recenti, ricette), [recenti, ricette]); // Quando la lettura fallisce l'errore prende tutto il corpo, come in // Elenco.tsx: niente Recenti né «nessun risultato» a sproposito. if (errore) { return ( {testo('errore.db')} ); } if (query.trim() === '') { return ( {ricetteRecenti.length > 0 && ( <> {testo('cerca.recenti')} {ricetteRecenti.map((r) => ( {tessera(r)} ))} )} ); } return ( r.id} numColumns={2} columnWrapperStyle={stili.colonne} contentContainerStyle={stili.griglia} ListEmptyComponent={{testo('ricerca.nulla')}} renderItem={({ item }) => tessera(item)} /> ); } const creaStili = (colori: Colori) => StyleSheet.create({ pagina: { flex: 1, backgroundColor: colori.fondo }, centro: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: SPAZIO.xxl, backgroundColor: colori.fondo }, errore: { ...TESTO.etichetta, color: colori.errore, textAlign: 'center' }, sezione: { ...TESTO.titolo, color: colori.testo, marginHorizontal: SPAZIO.l, marginTop: SPAZIO.m, marginBottom: SPAZIO.s }, fila: { paddingHorizontal: SPAZIO.l, gap: SPAZIO.m }, // Solo la larghezza: l'altezza la fissa già TesseraRicetta (ALTEZZA in // TesseraRicetta.tsx), qui basta dare al `flex: 1` della tessera un // contenitore largo quanto deve essere in una fila che si scorre di lato. recente: { width: 160 }, griglia: { padding: SPAZIO.l, gap: SPAZIO.m }, colonne: { gap: SPAZIO.m }, nulla: { ...TESTO.corpo, color: colori.testoTenue, textAlign: 'center', marginTop: SPAZIO.xl }, });