/** * La schermata principale: le categorie del ricettario. * * In cima la ricerca, che cerca fra TUTTE le ricette ignorando le categorie. * 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, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; 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 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 { 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: {} }; /** * Quanto spazio lasciare in fondo alle liste. 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. */ const FONDO_LISTA = 96; /** 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 }: PropsSchermata<'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. const sotto = useSafeAreaInsets().bottom; 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 }); 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 ( {/* Il marchio al centro invece di un titolo: questa è l'unica schermata che porta il nome dell'app, e lo porta scritto a mano. I comandi in fila a destra, col cambio vista per primo perché è quello che si usa più spesso — gestire le categorie e spostare un archivio sono gesti rari. */} } azioni={ <> navigation.navigate('GestioneCategorie')} hitSlop={12} accessibilityRole="button" accessibilityLabel={testo('categorie.gestisci')} > navigation.navigate('Impostazioni')} hitSlop={12} accessibilityRole="button" accessibilityLabel={testo('impostazioni.titolo')} > } /> {/* A ricettario vuoto la 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. 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. */} {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')} > )} )} {/* 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. */} {stato === 'errore' ? ( {testo('errore.db')} ) : stato === 'ricerca' ? ( r.id} keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" contentContainerStyle={[stili.fondoLista, { paddingBottom: FONDO_LISTA + sotto }]} // 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 }) => ( navigation.navigate('Dosatore', { ricettaId: item.id })} > {item.titolo} )} /> ) : stato === 'vuoto' ? ( {testo('categorie.vuoto.titolo')} {testo('categorie.vuoto.invito')} ) : ( (vista === 'griglia' ? tesseraVoce(item) : rigaVoce(item))} /> )} {/* Sempre a schermo, 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. */} {/* 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 }, 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: { paddingBottom: FONDO_LISTA, 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, }, 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, }, });