/** * La schermata principale: le categorie del ricettario. * * In cima la ricerca, solo su Android (ricercaNellaPagina() in * logica-menu.ts): su iPhone si cerca dalla lente del menù, che cerca fra * TUTTE le ricette ignorando le categorie, come questa. Appena si scrive * qualcosa il corpo diventa l'elenco delle ricette trovate: chi sa come si * chiama quello che cerca non deve passare da nessuna parte. * * Sotto, sempre in quest'ordine: "tutte le ricette" col totale, "senza * categoria" solo se c'è almeno una ricetta senza, e le categorie dell'utente * con icona, nome e conteggio nel loro ordine. * * A ricettario vuoto non si scrive "nessuna categoria": si invita a scrivere la * prima ricetta, e la ricerca in cima non compare — cercare fra zero ricette * non serve a niente, e l'invito deve essere la prima cosa che si legge, non la * seconda. Vuoto significa nessuna ricetta E nessuna categoria: se le * categorie ci sono si mostrano coi loro zeri, perché nascondere il lavoro che * l'utente ha già fatto è peggio che mostrare dei numeri a zero. * * I risultati della ricerca sono righe di solo titolo, senza miniatura: la * ricerca è la scorciatoia di chi il nome lo sa già, e la foto lì non aiuta a * riconoscere niente. Le miniature stanno nell'elenco, che è dove si sfoglia. * * Come sono disegnate le voci. Ogni categoria porta la sua pastiglia colorata, * col colore che le dà tinte.ts a partire dal suo id: prima le icone erano * tutte terracotta, cioè l'elenco era una colonna di righe identiche che si * potevano solo leggere una per una. Il nome sta a un corpo più grande del * conteggio, e non allo stesso: in una riga qualcosa deve pesare più del resto, * altrimenti l'occhio non sa dove posarsi. * * "Tutte le ricette" sta in cima ed è una scheda, non una riga: disegnata come * un'intestazione — icona, nome, numero, una linea sotto — si leggeva come * un'informazione e non come un posto dove andare, e nessuno la toccava. Il * riquadro pieno le dà la stessa forma delle tessere che ha sotto, che si * toccano tutte. "Senza categoria" sta in fondo, dopo il lavoro * che l'utente ha fatto, e in tono minore: è un residuo da sistemare. L'ordine * lo decide `vociCategorie`, qui non si riordina niente. * * Le due viste. Si può guardare a righe o a griglia, e sceglie l'utente col * comando in alto: la scelta resta salvata ed è distinta da quella dell'elenco * delle ricette, perché sono due liste diverse. Nella griglia il colore non è * più una pastiglia accanto al nome, è tutta la tessera; e il portone diventa * l'intestazione sopra le tessere, perché una voce larga due colonne la lista * di serie non la sa fare — e perché lì sta bene comunque. * * Ogni decisione (quale dei quattro corpi mostrare, come si dice un conteggio * a parole, quali voci e in che ordine) sta in logica-categorie.ts, non qui: * `node --test` non carica i .tsx, quindi una decisione lasciata qui dentro * può rompersi senza che nessun test se ne accorga. Stessa regola della * gemella Elenco.tsx. * * I colori non si scrivono qui: vengono da tema.ts, che li dà nelle due * versioni, chiara e scura, e cambia da solo con l'impostazione del telefono. */ import { useCallback, useMemo, useState } from 'react'; import { FlatList, 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 { ComandoLeggero } from '../componenti/Bottoni.tsx'; import { usePannelli } from '../pannelli.ts'; import type { Categoria, Ricetta } from '../../domain/types.ts'; import type { Conteggi, FiltroElenco } from '../../data/ricette.ts'; import { conteggiPerCategoria, elencoRicette } from '../../data/ricette.ts'; import { elencoCategorie } from '../../data/categorie.ts'; import { filtraRicette, statoElenco } from '../logica-elenco.ts'; import type { VoceCategorie } from '../logica-categorie.ts'; import { descriviConteggio, separaPortone, statoCategorie, vociCategorie } from '../logica-categorie.ts'; import { fondoListaSezione, margineSotto, piuNellaBarra, ricercaNellaPagina } from '../logica-menu.ts'; import { CHIAVE_VISTA_CATEGORIE, colonne, comandoVista } from '../logica-viste.ts'; import { useVista } from '../vista.ts'; import Barra from '../componenti/Barra.tsx'; import { Marchio } from '../App.tsx'; import Icona from '../componenti/Icona.tsx'; import Segno from '../componenti/Segno.tsx'; import type { Colori } from '../tema.ts'; import { CIFRE, OMBRA, RAGGIO, SPAZIO, TESTO, useColori, useTinte } from '../tema.ts'; import type { Tinta } from '../tinte.ts'; import { tintaDi } from '../tinte.ts'; const CONTEGGI_VUOTI: Conteggi = { totale: 0, senza: 0, perCategoria: {} }; /** Il filtro con cui aprire l'elenco toccando una voce. */ function filtroDaVoce(voce: VoceCategorie): FiltroElenco { if (voce.tipo === 'categoria') return { tipo: 'categoria', id: voce.categoria.id }; if (voce.tipo === 'senza') return { tipo: 'senza' }; return { tipo: 'tutte' }; } /** Chiave di lista stabile: gli id delle categorie non collidono con le due voci fisse. */ function chiaveVoce(voce: VoceCategorie): string { return voce.tipo === 'categoria' ? `categoria:${voce.categoria.id}` : voce.tipo; } export default function Categorie({ navigation }: PropsSfoglia<'Categorie'>) { // 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 } = usePannelli(); const { db, testo, lingua } = useApp(); const colori = useColori(); const tinte = useTinte(); const stili = useMemo(() => creaStili(colori), [colori]); /** * Il colore delle due voci che categorie non sono. Non viene dalla tavolozza * — non sono categorie, e dargli un tono a caso le farebbe sembrare tali — * ma dai grigi del tema. `testoTenue` su `bordo` fa 4.54:1 nel chiaro e * 5.18:1 nello scuro, verificati con la stessa formula delle altre. */ const neutra = useMemo( () => ({ forte: colori.testoTenue, tenue: colori.bordo }), [colori], ); const [vista, commutaVista] = useVista(CHIAVE_VISTA_CATEGORIE); const comando = comandoVista(vista); const [categorie, setCategorie] = useState([]); const [conteggi, setConteggi] = useState(CONTEGGI_VUOTI); const [ricette, setRicette] = useState([]); const [ricerca, setRicerca] = useState(''); const [errore, setErrore] = useState(false); // Finché il primo caricamento non è finito non si mostra l'invito: senza // questa sentinella il ricettario pieno lampeggerebbe "è vuoto" per un frame. const [caricato, setCaricato] = useState(false); /** * Rilegge tutto quello che la schermata mostra. La passa `useFocusEffect`, che * la richiama ad ogni ritorno sulla schermata: tornando dal dosatore o dalla * gestione categorie, i conteggi e le categorie devono essere già aggiornati. * Ha un nome perché `useFocusEffect` vuole un callback stabile — `useCallback` * con `db` come sola dipendenza — e perché scritta in linea non si leggerebbe. */ const ricarica = useCallback(() => { let vivo = true; Promise.all([ elencoCategorie(db), conteggiPerCategoria(db), // Le ricette servono alla ricerca, che ignora le categorie e quindi le // vuole tutte. Si caricano in memoria una volta per visita: su // ricettari da qualche centinaio di ricette non si sente. Se un giorno // si sentisse, si caricano solo quando la ricerca non è vuota. elencoRicette(db, { tipo: 'tutte' }), ]) .then(([cat, cont, ric]) => { if (!vivo) return; setCategorie(cat); setConteggi(cont); setRicette(ric); setErrore(false); setCaricato(true); }) .catch(() => { if (vivo) setErrore(true); }); return () => { vivo = false; }; }, [db]); useFocusEffect(ricarica); const trovate = useMemo(() => filtraRicette(ricette, ricerca), [ricette, ricerca]); const voci = useMemo(() => vociCategorie(categorie, conteggi), [categorie, conteggi]); // Quale dei quattro corpi mostrare lo decide statoCategorie, come sulla // gemella Elenco.tsx: qui non si riscrive «se il ricettario è vuoto». const stato = statoCategorie(categorie, conteggi, ricerca, caricato, errore); 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. const nomeVoce = (voce: VoceCategorie): string => { if (voce.tipo === 'categoria') return voce.categoria.nome; return voce.tipo === 'senza' ? testo('categorie.senza') : testo('categorie.tutte'); }; /** * Il conteggio a parole, per chi la schermata la ascolta invece di guardarla. * Quale delle tre forme dirlo lo sa descriviConteggio, in logica-categorie.ts: * qui si traduce e basta. */ const conteggioAParole = (n: number): string => { const { chiave, valori } = descriviConteggio(n); return testo(chiave, valori); }; // Nella griglia il portone diventa l'intestazione sopra le tessere: la // decisione, e il perché, stanno in separaPortone. const { portone, altre } = separaPortone(voci); /** Una voce come riga: pastiglia, nome, conteggio. */ const rigaVoce = (item: VoceCategorie) => { const tinta = item.tipo === 'categoria' ? tintaDi(item.categoria, tinte) : neutra; const isPortone = item.tipo === 'tutte'; return ( [ stili.voce, isPortone && stili.vocePortone, pressed && (isPortone ? stili.vocePortonePremuto : stili.vocePremuta), ]} accessibilityRole="button" accessibilityLabel={`${nomeVoce(item)}, ${conteggioAParole(item.conteggio)}`} onPress={() => navigation.navigate('Elenco', { filtro: filtroDaVoce(item) })} > {isPortone ? ( ) : ( {item.tipo === 'categoria' ? ( ) : ( )} )} {nomeVoce(item)} {item.conteggio} ); }; /** * Una voce come tessera. Qui il colore non accompagna il nome: è la tessera * intera, ed è quello che rende la griglia leggibile di sfuggita. Il testo è * la tinta forte sulla sua tenue, che è la coppia verificata a 6,07:1 nel * caso peggiore. */ const tesseraVoce = (item: VoceCategorie) => { const tinta = item.tipo === 'categoria' ? tintaDi(item.categoria, tinte) : neutra; return ( [ stili.tessera, { backgroundColor: tinta.tenue }, pressed && stili.vocePremuta, ]} accessibilityRole="button" accessibilityLabel={`${nomeVoce(item)}, ${conteggioAParole(item.conteggio)}`} onPress={() => navigation.navigate('Elenco', { filtro: filtroDaVoce(item) })} > {item.tipo === 'categoria' ? ( ) : ( )} {item.conteggio} {nomeVoce(item)} ); }; return ( // `collapsable={false}` tiene questa vista come contenitore vero. Senza, // React Native la "appiattisce": avendo solo il colore di fondo, la monta // come una vista vuota e mette i suoi figli accanto a lei, DOPO. Il primo // figlio della schermata diventava quel fondo vuoto, e la lista non si // trovava più (visto nell'albero delle viste sul simulatore). {/* Il corpo viene PRIMA della testata nell'albero, e la testata torna in cima con `column-reverse` (in stili.schermo). Serve al menù di vetro dell'iPhone: si rimpicciolisce scorrendo solo se trova la lista, e iOS (con react-native-screens) la cerca scendendo sempre e solo nel PRIMO figlio di ogni vista. Con la Barra davanti non la trovava mai. Su Android a schermo non cambia niente: stessa colonna, stesso ordine visto dall'alto. */} {/* I quattro corpi, nell'ordine in cui statoCategorie li sceglie: il perché di quell'ordine sta lì, in logica-categorie.ts, insieme al test che lo tiene fermo. Le due liste hanno `contentInsetAdjustmentBehavior="automatic"` scritto qui e non lasciato al caso: react-native-screens lo accende da sé sulla lista che trova, e il fondo cambierebbe a seconda di quando la trova. In cima non aggiunge niente, perché la lista comincia sotto la testata e non sotto l'isola dinamica. */} {stato === 'errore' ? ( {testo('errore.db')} ) : stato === 'ricerca' ? ( r.id} keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" contentInsetAdjustmentBehavior="automatic" contentContainerStyle={[stili.fondoLista, { paddingBottom: spazioFondo }]} // Stessa finestra di caricamento dell'elenco, chiusa dalla stessa // funzione pura: senza passare `caricato`, scrivendo nella ricerca // mentre la prima lettura di `ricette` è ancora in corso `trovate` è // vuoto quanto una ricerca senza esito, e comparirebbe "Nessuna // ricetta con questo nome" a sproposito. ListEmptyComponent={ statoElenco(trovate.length, ricerca, caricato) === 'nessun-risultato' ? ( {testo('ricerca.nulla')} ) : null } renderItem={({ item }) => ( apriRicetta(item.id)} > {item.titolo} )} /> ) : stato === 'vuoto' ? ( {testo('categorie.vuoto.titolo')} {testo('categorie.vuoto.invito')} {/* La seconda strada per chi non ha un quaderno: partire dalle ricette di Quanto Basta e aggiungere quelle che piacciono. */} navigation.navigate('RicetteItaliane')} stile={stili.vuotoCatalogo} /> ) : ( (vista === 'griglia' ? tesseraVoce(item) : rigaVoce(item))} /> )} {/* Il tondo è la regola di Android (Material 3): 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 e tre i rami: il bottone dello stato vuoto sparisce appena c'è una ricetta o una categoria, e senza questo tondo da qui non si scriverebbe più niente. Stessa posizione e stesso gesto dell'elenco, 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. */} )} {/* Il marchio al centro invece di un titolo: questa è l'unica schermata che porta il nome dell'app, e lo porta scritto a mano. Gestire le categorie e le impostazioni sono passate al menù in basso, come sue sezioni: qui a destra resta solo il cambio vista, che è il comando che si usa spesso. */} } azioni={ <> {/* 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 && ( )} } /> {/* Il campo di ricerca sta in cima solo dove non c'è la lente del menù, cioè su Android: la regola è ricercaNellaPagina() in logica-menu.ts. Su iPhone si cerca fra tutte le ricette dalla sezione Cerca del menù, e `ricerca` qui resta sempre ''. A ricettario vuoto la ricerca non compare comunque: 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. La decisione è già quella di statoCategorie — 'vuoto' vuol dire nessuna ricetta e nessuna categoria, e solo a lettura finita — e appena c'è qualcosa la barra torna. Stessa cosa nella gemella Elenco.tsx, che fino a ieri la mostrava anche lei sopra un elenco vuoto. */} {cercaQui && stato !== 'vuoto' && ( {ricerca !== '' && ( setRicerca('')} hitSlop={12} accessibilityRole="button" // Dice quello che fa, non dove sta: con l'etichetta del campo di // ricerca un lettore di schermo annunciava «Cerca in tutte le // ricette, 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' }, comandi: { flexDirection: 'row', alignItems: 'center', gap: 12 }, /** * La ricerca è una pastiglia piena, senza contorno. Il bordo la faceva * leggere come un campo di modulo da riempire: qui invece è un comando che * si usa di passaggio, e sul fondo crema la sola superficie bianca basta a * dire che è toccabile. */ 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 }, /** * Le voci non hanno separatori. Il ritmo lo fanno la pastiglia colorata e * lo spazio: le righe divise da una linea si leggono come le celle di una * tabella, cioè come dati da consultare, mentre queste sono posti dove * andare. I margini laterali stanno stretti — SPAZIO.s contro SPAZIO.l del * testo — perché il rettangolo che si accende premendo deve sporgere oltre * il contenuto, altrimenti sembra che il dito abbia mancato la riga. */ voce: { flexDirection: 'row', alignItems: 'center', paddingVertical: SPAZIO.s, paddingHorizontal: SPAZIO.s, marginHorizontal: SPAZIO.s, borderRadius: RAGGIO.riga, }, vocePremuta: { backgroundColor: colori.superficie }, vocePortonePremuto: { opacity: 0.7 }, /** * Il portone è una scheda piena, non una riga con una linea sotto: quella * versione si leggeva come un'intestazione, cioè come un'informazione, e * non come un posto dove andare. Qui ha la stessa superficie e lo stesso * raggio delle tessere sotto, che si toccano tutte. */ vocePortone: { backgroundColor: colori.superficie, borderRadius: RAGGIO.foto, marginHorizontal: SPAZIO.l, marginBottom: SPAZIO.l, paddingVertical: SPAZIO.m, paddingHorizontal: SPAZIO.m, }, /** L'icona del portone sta al posto della pastiglia, e occupa meno. */ segnoPortone: { width: 30 }, /** * Quadrata e non tonda: il tondo in quest'app è già il tasto che aggiunge, * e due tondi della stessa misura a schermo si confondono. Il lato è 52 e * la riga arriva così a 68 punti d'altezza, ben oltre i 44 del bersaglio * minimo. */ pastiglia: { width: 52, height: 52, borderRadius: RAGGIO.pastiglia, alignItems: 'center', justifyContent: 'center', }, /** La griglia: due tessere per riga, stesso spazio fra loro e ai lati. */ colonne: { gap: SPAZIO.m, paddingHorizontal: SPAZIO.l, marginBottom: SPAZIO.m }, /** * La tessera è alta 116: due righe di nome ci stanno, e con l'icona in * alto restano sei categorie a schermata invece delle otto della vista a * righe. È il prezzo del colpo d'occhio, ed è il motivo per cui le due * viste convivono invece di sostituirsi. */ tessera: { flex: 1, height: 116, borderRadius: RAGGIO.foto, padding: SPAZIO.m, justifyContent: 'space-between', }, tesseraAlto: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between' }, tesseraConteggio: { ...TESTO.voce, ...CIFRE, opacity: 0.72 }, tesseraNome: { ...TESTO.corpo, fontWeight: '600', letterSpacing: -0.2 }, voceNome: { flex: 1, ...TESTO.voce, color: colori.testo, marginLeft: SPAZIO.m }, voceNomePortone: { ...TESTO.titolo }, /** "Senza categoria" si legge in tono minore: è un residuo, non un posto. */ voceNomeTenue: { color: colori.testoTenue }, voceConteggio: { marginLeft: SPAZIO.m, ...TESTO.etichetta, ...CIFRE, fontWeight: '600', color: colori.testoTenue, }, /** Il totale del ricettario è l'unico numero grande della schermata. */ voceConteggioPortone: { ...TESTO.titolo, ...CIFRE, color: colori.accento }, riga: { paddingVertical: SPAZIO.m, paddingHorizontal: SPAZIO.l, marginHorizontal: SPAZIO.s, borderRadius: RAGGIO.riga, }, titolo: { ...TESTO.voce, color: colori.testo }, 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, }, vuotoCatalogo: { alignSelf: 'center', marginTop: SPAZIO.m }, 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, }, });