/** * Elenco delle ricette che passano il filtro ricevuto dalla rotta. * * Il filtro arriva da Categorie: tutte, una categoria, oppure quelle senza. È * l'unica cosa che decide quali ricette si vedono, e non si tocca qui. * * La ricerca filtra dentro quello che si sta guardando: dentro una categoria * cerca dentro quella categoria. Quella che ignora le categorie sta sulla * schermata principale (su iPhone, nella lente del menù) ed è un gesto * diverso, di proposito. Il campo qui in cima compare solo su Android — la * regola è ricercaNellaPagina() in logica-menu.ts — e comunque non dove non * c'è niente da cercare, come nella gemella. * * L'ordine alfabetico italiano arriva già fatto da elencoRicette(): qui non si * riordina. * * I titoli possono essere duplicati: alla schermata Dosatore si passa sempre * l'id, mai il titolo. * * I colori non si scrivono qui: vengono da tema.ts, esattamente come in * Categorie.tsx, la schermata gemella. * * Come è disegnata una riga. Il titolo è il soggetto e sta a un corpo più * grande; sotto, in piccolo, quanti ingredienti ha la ricetta e per quante * porzioni è scritta — due numeri che abbiamo già in mano, perché * `elencoRicette` restituisce i gruppi completi, e che sono quelli che * distinguono due ricette dal titolo simile. * * A sinistra c'è la foto, ma solo se in elenco ce n'è almeno una: senza, la * colonna sparisce e i titoli si prendono la riga. Era un rettangolo grigio per * tutte, e siccome le foto non le ha quasi nessuno l'elenco era una colonna di * buchi. Chi la foto non ce l'ha, in un elenco dove qualcuno ce l'ha, mette la * pastiglia colorata della sua categoria — la stessa della schermata * principale — così i titoli restano incolonnati. Lo decide `colonnaElenco` in * logica-elenco.ts, non questo file. * * Guardando tutte le ricette insieme la categoria si legge, perché lì le * categorie sono mescolate e sapere da dove arriva una riga aggiunge qualcosa, * mentre dentro una categoria sarebbe la stessa parola ripetuta su ogni riga. * Lo decide logica-elenco.ts. * * Le due viste. L'elenco si può guardare a righe o a griglia, e sceglie * l'utente col comando in alto a destra: la scelta resta salvata, ed è sua, * distinta da quella della schermata principale — sono due liste diverse. * A righe la ricetta è una scheda larga con la foto a sinistra; a griglia è una * tessera con la foto grande e il titolo su fondo scuro. Quale sia la vista, e * dove porta il comando, lo decide logica-viste.ts. */ import { useCallback, useMemo, useState } from 'react'; import { FlatList, Image, Platform, Pressable, StyleSheet, Text, TextInput, View, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useFocusEffect } from '@react-navigation/native'; import type { PropsSfoglia } from '../navigazione.ts'; import { useApp } from '../contesto.ts'; import { SceltaNuovaRicetta } from '../componenti/SceltaNuovaRicetta.tsx'; import { usePannelli } from '../pannelli.ts'; import type { Categoria, Ricetta } from '../../domain/types.ts'; import { elencoRicette } from '../../data/ricette.ts'; import { leggiCategoria } from '../../data/categorie.ts'; import { caricaMiniature } from '../../data/foto.ts'; import { elencoCategorie } from '../../data/categorie.ts'; import { vocabolario } from '../../domain/lingua/index.ts'; import { fondoListaSezione, margineSotto, piuNellaBarra, ricercaNellaPagina } from '../logica-menu.ts'; import { CHIAVE_VISTA_ELENCO, colonne, comandoVista } from '../logica-viste.ts'; import { useVista } from '../vista.ts'; import TesseraRicetta from '../componenti/TesseraRicetta.tsx'; import { categoriaDi, colonnaElenco, datiTessera, filtraRicette, indiceCategorie, nomeCategoriaInRiga, statoElenco, titoloElenco, } from '../logica-elenco.ts'; import Icona from '../componenti/Icona.tsx'; import Segno from '../componenti/Segno.tsx'; import Barra from '../componenti/Barra.tsx'; import type { Colori } from '../tema.ts'; import { RAGGIO, OMBRA, SPAZIO, TESTO, useColori, useTinte } from '../tema.ts'; import type { Tinta } from '../tinte.ts'; import { tintaDi } from '../tinte.ts'; export default function Elenco({ route, navigation }: PropsSfoglia<'Elenco'>) { // La barra dei tasti di Android (e l'indicatore home dell'iPhone) sta sopra // l'app, che disegna fino al bordo: senza questo il tondo col «+» finiva // mezzo sotto i tasti, sul Redmi di prova. Dove il contenuto finisce sopra // il menù (Android) quello spazio l'ha già preso la barra: margineSotto lo // sa e non lo aggiunge due volte. const insetSotto = useSafeAreaInsets().bottom; const sotto = margineSotto(Platform.OS, insetSotto); // Lo spazio sotto l'ultima riga: il + tondo su Android, un respiro sopra la // capsula di vetro su iPhone. Lo decide fondoListaSezione(). const spazioFondo = fondoListaSezione(Platform.OS, insetSotto); // Dove il + sta e dove si cerca lo decide logica-menu.ts, non qui: su // iPhone il + sale nella barra e si cerca dalla lente del menù, su Android // restano il tondo in fondo e la ricerca in cima. const piuInAlto = piuNellaBarra(Platform.OS); const cercaQui = ricercaNellaPagina(Platform.OS); const { apriRicetta, larghezzaSfoglia, due, selezionata } = usePannelli(); const { db, testo, lingua } = useApp(); const colori = useColori(); const tinte = useTinte(); const stili = useMemo(() => creaStili(colori), [colori]); const voc = useMemo(() => vocabolario(lingua), [lingua]); const [vista, commutaVista] = useVista(CHIAVE_VISTA_ELENCO); const { filtro } = route.params; const [ricette, setRicette] = useState([]); const [categoria, setCategoria] = useState(null); /** id della ricetta -> URI della sua miniatura. Solo quelle che esistono davvero. */ const [miniature, setMiniature] = useState>({}); /** * Le categorie di tutto il ricettario, per id. Servono alle righe che la * foto non ce l'hanno: al suo posto va la pastiglia della loro categoria, e * per disegnarla serve l'icona, che nella ricetta non c'è — lì c'è solo * `categoriaId`. È una lettura sola per visita, e sono al massimo qualche * decina di righe. */ const [perId, setPerId] = useState>({}); const [ricerca, setRicerca] = useState(''); const [errore, setErrore] = useState(false); // Finché il primo caricamento non è finito non si mostra l'invito: senza // questa sentinella un elenco pieno lampeggerebbe "nessuna ricetta". const [caricato, setCaricato] = useState(false); // Si ricarica ad ogni ritorno sulla schermata: tornando da Modifica, la // ricetta nuova, il titolo corretto, la foto cambiata o la cancellazione // devono comparire subito. La sentinella `vivo` evita di scrivere lo stato // dopo lo smontaggio. useFocusEffect( useCallback(() => { let vivo = true; (async () => { try { const elenco = await elencoRicette(db, filtro); const cat = filtro.tipo === 'categoria' ? await leggiCategoria(db, filtro.id) : null; const tutte = await elencoCategorie(db); // Le miniature si risolvono qui, una volta per caricamento, perché // percorsoFoto() (dentro caricaMiniature) controlla che il file // esista davvero e non può aspettare una promessa. La stessa // lettura la fa anche Cerca.tsx: caricaMiniature() e // indiceCategorie() in logica-elenco.ts la fanno una volta sola per // le due schermate. const mappa = await caricaMiniature(elenco); if (!vivo) return; // L'elenco non scrive nel database per correggere il campo `foto` // di chi non ha trovato il file: è una schermata di lettura. const indice = indiceCategorie(tutte); setRicette(elenco); setCategoria(cat); setPerId(indice); setMiniature(mappa); setErrore(false); setCaricato(true); } catch { if (vivo) setErrore(true); } })(); return () => { vivo = false; }; }, [db, filtro]), ); // Il pulsante dell'intestazione: dichiarato qui perché useLayoutEffect lo // mette fra le sue dipendenze, e una const letta prima della sua riga fa // saltare il render. const comando = comandoVista(vista); // Il titolo lo sceglie titoloElenco(), qui si traduce soltanto. La decisione // — nome fisso, nome della categoria, etichetta generica durante il // caricamento, o «Categoria eliminata» — sta tutta in logica-elenco.ts. const titolo = titoloElenco(filtro, categoria, caricato); const scritta = titolo.tipo === 'nome' ? titolo.nome : testo(titolo.chiave); const visibili = useMemo(() => filtraRicette(ricette, ricerca), [ricette, ricerca]); const stato = statoElenco(visibili.length, ricerca, caricato); // Se le righe portano qualcosa a sinistra del titolo lo decide // logica-elenco.ts, guardando le foto che ci sono davvero e il filtro. const colonna = colonnaElenco(visibili, miniature); /** * La pastiglia che prende il posto della foto quando non c'è. È quella della * categoria della ricetta, uguale alla schermata principale. Le ricette senza * categoria prendono i grigi del tema: `testoTenue` su `bordo` fa 4.54:1 nel * chiaro e 5.18:1 nello scuro. * * `Object.hasOwn` e non il lookup nudo: `categoriaId` può arrivare da un * archivio importato, e un id che si chiama `toString` restituirebbe una * funzione al posto della categoria. */ const neutra = useMemo(() => ({ forte: colori.testoTenue, tenue: colori.bordo }), [colori]); // A ricettario vuoto la barra di ricerca non compare: cercare fra zero // ricette non serve a niente, e la prima cosa che si vede aprendo l'app // sarebbe un comando invece dell'invito a scrivere la prima ricetta. Non è // una decisione nuova: 'vuoto' vuol dire già "non si sta cercando e non c'è // niente" — lo decide statoElenco in logica-elenco.ts — e appena una ricetta // c'è la barra torna. La gemella Categorie.tsx fa la stessa cosa con la sua // uscita 'vuoto': prima la mostravano tutt'e due anche a ricettario vuoto. const vuoto = !errore && stato === 'vuoto'; const scrivine = () => navigation.navigate('Modifica', { ricettaId: null }); // Il «+» chiede da dove arriva la ricetta (SceltaNuovaRicetta, un menù che // nasce dal «+» stesso); lo stato vuoto invece porta dritto alla scrittura, // che è quello che dice. return ( // `collapsable={false}`: senza, questa vista (solo un colore di fondo) // verrebbe montata vuota DAVANTI ai suoi figli. Vedi Categorie.tsx. {/* Il corpo viene PRIMA della testata nell'albero, e la testata torna in cima con `column-reverse` (in stili.schermo): il menù di vetro dell'iPhone cerca la lista solo lungo i primi figli. Il perché per intero sta nella gemella Categorie.tsx. */} {/* Quando la lettura fallisce l'errore prende tutto il corpo, come in Categorie: un elenco che sembra svuotato è la cosa peggiore da mostrare proprio nel momento in cui non siamo riusciti a leggerlo. */} {errore ? ( {testo('errore.db')} ) : stato === 'vuoto' ? ( {testo('elenco.vuoto.titolo')} {testo('elenco.vuoto.invito')} ) : ( r.id} // Le righe dipendono anche dalla ricetta aperta a destra (il bordo): // senza dirlo, la FlatList potrebbe non ridisegnarle quando cambia. extraData={due ? selezionata : null} keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" // Scritto qui e non lasciato a react-native-screens, che lo accende // da sé sulla lista che trova: vedi la gemella Categorie.tsx. contentInsetAdjustmentBehavior="automatic" contentContainerStyle={[stili.fondoLista, { paddingBottom: spazioFondo }]} // Dipende dallo stato vero, non dal solo "la lista è vuota": finché // il primo caricamento non è finito `stato` vale 'caricando' (vedi // statoElenco in logica-elenco.ts) e qui non si scrive niente. Il // testo compare solo a 'nessun-risultato'; a stato 'vuoto' il ramo // qui sopra ha già preso il posto. ListEmptyComponent={ stato === 'nessun-risultato' ? ( {testo('ricerca.nulla')} ) : null } // La chiave cambia con la vista perché `numColumns` non si può // cambiare su una FlatList già montata, né il numero di colonne: // React Native lo dice a voce alta in sviluppo e in produzione // lascerebbe la lista a una colonna dentro un contenitore da due. key={`${vista}-${colonne(vista, larghezzaSfoglia)}`} numColumns={colonne(vista, larghezzaSfoglia)} columnWrapperStyle={vista === 'griglia' ? stili.colonne : undefined} renderItem={({ item }) => { const cat = categoriaDi(perId, item); const tinta = cat === null ? neutra : tintaDi(cat, tinte); const nome = nomeCategoriaInRiga(filtro, cat); // Qui la categoria non entra nel testo: quando c'è, la mostra già // la pastiglia sotto (nome !== null), e ripeterla nel testo // sarebbe la stessa parola due volte sulla stessa riga. const numeri = datiTessera(item, voc, testo, null); const apri = () => apriRicetta(item.id); // Sugli schermi larghi la ricetta aperta a destra si riconosce // anche qui, col bordo nell'accento; sul telefono non c'è un // «accanto» e nessuna riga è diversa dalle altre. const aperta = due && item.id === selezionata; if (vista === 'griglia') { return ( ); } return ( [stili.scheda, aperta && stili.schedaAperta, pressed && stili.schedaPremuta]} accessibilityRole="button" accessibilityState={{ selected: aperta }} accessibilityLabel={`${item.titolo}, ${nome === null ? '' : `${nome}, `}${numeri}`} onPress={apri} > {colonna === 'immagini' && (Object.hasOwn(miniature, item.id) ? ( ) : ( // La ricetta senza categoria tiene il posto e basta: un // glifo di ripiego, ripetuto su mezza lista, sembrerebbe // un guasto piuttosto che l'assenza di una scelta. {cat !== null && ( )} ))} {item.titolo} {numeri} {/* La categoria come pastiglia colorata e non come parola in testa alla riga: è lo stesso colore della schermata principale, e si riconosce prima di leggerlo. Compare solo dove serve — lo decide nomeCategoriaInRiga. */} {nome !== null && ( {cat !== null && ( )} {nome} )} ); }} /> )} {/* Il tondo è la regola di Android: su iPhone il + è già salito nella barra in cima, e un secondo + in fondo sarebbe un doppione. Dove sta lo decide piuNellaBarra() in logica-menu.ts. Sopra tutti i rami: stessa posizione e stesso gesto di Categorie, così scrivere una ricetta si fa sempre allo stesso modo. */} {!piuInAlto && ( {/* Un glifo e non un `+` scritto: il segno di testo cresceva con l'impostazione di corpo del sistema, e al massimo dell'accessibilità sfondava il cerchio. Qui il `+` è un disegno, non una parola da leggere: chi ascolta la schermata sente l'etichetta qui sopra. */} )} {/* Dentro una categoria il titolo prende la sua tinta: è lo stesso colore della pastiglia che si è appena toccata, e dice dove si è finiti senza aggiungere niente a schermo. Le due viste che categorie non sono tengono il colore del testo. */} {/* Il + sale qui solo su iPhone, come nelle app di Apple (Note, Promemoria): la regola, e il perché, stanno in piuNellaBarra() dentro logica-menu.ts. Su Android resta il tondo in fondo. */} {piuInAlto && ( )} } /> {cercaQui && !vuoto && ( {ricerca !== '' && ( setRicerca('')} hitSlop={12} accessibilityRole="button" // Dice quello che fa, non dove sta: l'etichetta del campo di // ricerca su questo tasto annuncerebbe «Cerca una ricetta, // pulsante» su un tasto che invece cancella quel che si è appena // scritto. accessibilityLabel={testo('ricerca.svuota')} > )} )} ); } /** Lo spazio attorno al tondo del «+» per la sua ombra (ombraTondo). */ const MARGINE_OMBRA = 12; const creaStili = (colori: Colori) => StyleSheet.create({ /** `column-reverse`: il corpo è primo nell'albero ma sta sotto la testata. Il perché è nel render. */ schermo: { flex: 1, backgroundColor: colori.fondo, flexDirection: 'column-reverse' }, /** Stessa pastiglia piena della gemella: il gesto è lo stesso, e si vede. */ barra: { flexDirection: 'row', alignItems: 'center', backgroundColor: colori.superficie, borderRadius: RAGGIO.tondo, marginHorizontal: SPAZIO.l, marginTop: SPAZIO.m, marginBottom: SPAZIO.s, paddingHorizontal: SPAZIO.l, }, campo: { flex: 1, paddingVertical: SPAZIO.m, paddingLeft: SPAZIO.s, ...TESTO.corpo, color: colori.testo, }, svuota: { paddingHorizontal: SPAZIO.xs, paddingVertical: SPAZIO.xs }, svuotaSegno: { fontSize: 16, color: colori.testoTenue }, centro: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: SPAZIO.xxl }, errore: { ...TESTO.etichetta, color: colori.errore, textAlign: 'center' }, nulla: { ...TESTO.corpo, color: colori.testoTenue, textAlign: 'center' }, fondoLista: { paddingTop: SPAZIO.xs }, /** La griglia: due tessere per riga, con lo stesso spazio fra loro e ai lati. */ colonne: { gap: SPAZIO.m, paddingHorizontal: SPAZIO.l, marginBottom: SPAZIO.m }, /** * La ricetta a righe è una scheda: staccata dal fondo, con la foto a filo * del bordo sinistro. Il riquadro le dà il corpo di un oggetto invece che * di una riga in una tabella — è la differenza che si vede sfogliando. * * Niente linea di separazione fra una scheda e l'altra: separa già lo * spazio, e una linea in mezzo a due riquadri è un segno che non serve. */ scheda: { flexDirection: 'row', marginHorizontal: SPAZIO.l, marginBottom: SPAZIO.m, borderRadius: RAGGIO.foto, overflow: 'hidden', backgroundColor: colori.superficie, borderWidth: StyleSheet.hairlineWidth, borderColor: colori.bordo, }, schedaAperta: { borderColor: colori.accento, borderWidth: 2 }, schedaPremuta: { opacity: 0.7 }, /** * Centoventidue per centosei: la foto occupa tutta l'altezza della scheda e * poco meno di un terzo della larghezza, così al titolo ne restano due * terzi buoni. Non è quadrata perché le foto di cibo non lo sono, e un * quadrato ritaglierebbe più del necessario. */ miniatura: { width: 122, height: 106, alignItems: 'center', justifyContent: 'center' }, testi: { flex: 1, minWidth: 0, paddingHorizontal: SPAZIO.m, paddingVertical: SPAZIO.s, justifyContent: 'center', gap: 4 }, titolo: { ...TESTO.corpo, fontSize: 17.5, fontWeight: '600', color: colori.testo, letterSpacing: -0.2 }, sotto: { ...TESTO.piccolo, color: colori.testoTenue }, /** La categoria come pastiglia: il colore prima della parola. */ bollo: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', gap: 4, marginTop: 2, paddingHorizontal: SPAZIO.s, paddingVertical: 3, borderRadius: RAGGIO.tondo, }, bolloNome: { fontSize: 11, fontWeight: '600', letterSpacing: 0.4, textTransform: 'uppercase' }, vuoto: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: SPAZIO.xxl }, vuotoTitolo: { ...TESTO.titolo, color: colori.testo, textAlign: 'center' }, vuotoBottone: { marginTop: SPAZIO.l, paddingVertical: SPAZIO.m, paddingHorizontal: SPAZIO.xl, borderRadius: RAGGIO.tondo, backgroundColor: colori.accento, }, vuotoInvito: { ...TESTO.corpo, color: colori.accentoSopra, textAlign: 'center' }, // Il tondo sta dentro il menù (SceltaNuovaRicetta), e a stare fermo in // basso a destra è il suo contenitore. sopraTondo: { position: 'absolute', right: SPAZIO.xl - MARGINE_OMBRA, zIndex: 1 }, // Il menù di Android ritaglia quello che esce dal suo contenitore, ombra // compresa: un margine attorno al tondo la lascia vedere intera. ombraTondo: { padding: MARGINE_OMBRA }, // Il + nella barra ha l'area di tocco di un comando, 44 punti. Il margine // negativo sta fuori dal menù: dentro, il menù di SwiftUI prenderebbe la // larghezza già stretta e i bordi del + non si toccherebbero. fuoriPiu: { marginHorizontal: -9 }, piuInBarra: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, tondo: { width: 60, height: 60, borderRadius: RAGGIO.tondo, backgroundColor: colori.accento, alignItems: 'center', justifyContent: 'center', ...OMBRA, }, });