/** * 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 ed è un gesto diverso, di proposito. Dove non c'è * niente da cercare la barra non compare, 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, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { useFocusEffect } from '@react-navigation/native'; import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons'; import type { PropsSchermata } from '../navigazione.ts'; import { useApp } from '../contesto.ts'; import type { Categoria, Ricetta } from '../../domain/types.ts'; import { elencoRicette } from '../../data/ricette.ts'; import { leggiCategoria } from '../../data/categorie.ts'; import { percorsoFoto } from '../../data/foto.ts'; import { elencoCategorie } from '../../data/categorie.ts'; import { vocabolario } from '../../domain/lingua/index.ts'; import { CHIAVE_VISTA_ELENCO, colonne, comandoVista } from '../logica-viste.ts'; import { useVista } from '../vista.ts'; import TesseraRicetta from '../componenti/TesseraRicetta.tsx'; import { colonnaElenco, datiRicetta, filtraRicette, 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'; /** * Quanto spazio lasciare in fondo alla lista. Il tondo col `+` galleggia sopra * il contenuto, quindi senza questo margine l'ultima riga finisce sotto il * cerchio: nascosta a metà e, nell'angolo, nemmeno toccabile, perché il tocco * lo prende il tondo e si apre una ricetta nuova invece della riga. Stesso * difetto e stesso rimedio di Categorie.tsx. */ const FONDO_LISTA = 96; export default function Elenco({ route, navigation }: PropsSchermata<'Elenco'>) { 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() controlla che il file esista davvero e non // può aspettare una promessa. Si chiede solo per le ricette che una // foto ce l'hanno: le altre non toccano il disco. const conFoto = elenco.filter((r) => r.foto !== null); const percorsi = await Promise.all(conFoto.map((r) => percorsoFoto(r.foto))); if (!vivo) return; const mappa: Record = {}; conFoto.forEach((r, i) => { const p = percorsi[i]; // Se il file non c'è la riga resta senza miniatura e non si dice // niente: succede importando un archivio senza le foto, e non è un // guasto. L'elenco non scrive nel database per correggere il campo: // è una schermata di lettura. if (p !== null) mappa[r.id] = p; }); const indice: Record = {}; for (const c of tutte) indice[c.id] = c; 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]); const categoriaDi = (r: Ricetta): Categoria | null => r.categoriaId !== null && Object.hasOwn(perId, r.categoriaId) ? perId[r.categoriaId] : null; // 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 }); return ( {/* 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. */} } /> {!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')} > )} )} {/* 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} keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" contentContainerStyle={stili.fondoLista} // 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: 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} numColumns={colonne(vista)} columnWrapperStyle={vista === 'griglia' ? stili.colonne : undefined} renderItem={({ item }) => { const cat = categoriaDi(item); const tinta = cat === null ? neutra : tintaDi(cat.id, tinte); const numeri = datiRicetta(item, voc) .map((d) => testo(d.chiave, d.valori)) .join(' · '); const nome = nomeCategoriaInRiga(filtro, cat); const apri = () => navigation.navigate('Dosatore', { ricettaId: item.id }); if (vista === 'griglia') { return ( ); } return ( [stili.scheda, pressed && stili.schedaPremuta]} accessibilityRole="button" 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} )} ); }} /> )} {/* Sempre a schermo, sopra tutti i rami: stessa posizione e stesso gesto di Categorie, così scrivere una ricetta si fa sempre allo stesso modo. */} {/* 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. */} ); } const creaStili = (colori: Colori) => StyleSheet.create({ schermo: { flex: 1, backgroundColor: colori.fondo }, /** 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: { paddingBottom: FONDO_LISTA, 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, }, 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' }, tondo: { position: 'absolute', right: SPAZIO.xl, bottom: SPAZIO.xl, width: 60, height: 60, borderRadius: RAGGIO.tondo, backgroundColor: colori.accento, alignItems: 'center', justifyContent: 'center', ...OMBRA, }, });