/** * Scelta della categoria di una ricetta: una sola, oppure nessuna. * * Nella pagina è un campo come gli altri, con l'etichetta sopra: dentro c'è la * categoria scelta col suo colore e la sua icona, o «Nessuna categoria». * Toccandolo sale un foglio con tutte le categorie in fila, ognuna col suo * colore, la spunta su quella scelta, e in fondo «Nuova categoria». * * Prima era una fila di pillole che scorreva di lato: se ne vedevano due e * mezza, e quella scelta era piena di accento come un bottone principale, per * cui «Nessuna categoria» diventava la cosa più forte della pagina. Paolo ha * scelto la lista il 28/09 (docs/design/campionari/modifica.html). * * «Nuova categoria» sta nello stesso foglio, che cambia contenuto: chi scrive * la prima ricetta non ha ancora nessuna categoria, e mandarlo in gestione * categorie e poi indietro a ritrovare la bozza è il modo migliore per non * fargliene creare nessuna. * * Non prende `db` né `lingua`: come le schermate, se li prende da `useApp()`. * * I colori non si scrivono qui: vengono da tema.ts, come nelle altre schermate. */ import type { ReactNode } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; import type { StyleProp, ViewStyle } from 'react-native'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { elencoCategorie, salvaCategoria } from '../../data/categorie.ts'; import { ICONA_PREDEFINITA } from '../../domain/icone.ts'; import Icona from './Icona.tsx'; import { Foglio } from './Foglio.tsx'; import { SceltaIcona } from './SceltaIcona.tsx'; import { CampoScelta, CampoTesto } from './Campo.tsx'; import { ComandoLeggero } from './Bottoni.tsx'; import { TestaFoglio } from './TestaFoglio.tsx'; import Segno from './Segno.tsx'; import { tintaDi, tintaMenoUsata } from '../tinte.ts'; import { useApp } from '../contesto.ts'; import { categoriaNuova, nomeDuplicato, siPuoSalvare } from '../logica-categorie.ts'; import { creaScrittoreUnico } from '../scritturaUnica.ts'; import type { Colori } from '../tema.ts'; import { RAGGIO, SPAZIO, TESTO, useColori, useTinte } from '../tema.ts'; import type { ChiaveIcona } from '../../domain/icone.ts'; import type { Categoria } from '../../domain/types.ts'; export type PropsSceltaCategoria = { /** id della categoria scelta, null = nessuna. */ scelta: string | null; onScegli: (categoriaId: string | null) => void; stile?: StyleProp; }; export function SceltaCategoria({ scelta, onScegli, stile }: PropsSceltaCategoria) { const sotto = useSafeAreaInsets().bottom; const { db, testo } = useApp(); const colori = useColori(); const tinte = useTinte(); const stili = useMemo(() => creaStili(colori), [colori]); const [categorie, setCategorie] = useState([]); const [aperto, setAperto] = useState(false); const [crea, setCrea] = useState(false); const [nome, setNome] = useState(''); const [icona, setIcona] = useState(ICONA_PREDEFINITA); const [erroreScrittura, setErroreScrittura] = useState(false); // Una scrittura per volta: due tocchi su «Salva» creavano due categorie // identiche con id diversi, la ricetta si attaccava alla seconda e la prima // restava orfana, indistinguibile dall'altra in gestione categorie. È lo // stesso meccanismo, e lo stesso modulo, di GestioneCategorie.tsx. const scriviUnaSola = useRef(creaScrittoreUnico()).current; useEffect(() => { let vivo = true; elencoCategorie(db) .then((elenco) => { if (vivo) setCategorie(elenco); }) .catch(() => { // Il campo della categoria non è la sola via per salvare una ricetta: // se la lettura fallisce si offre solo «Nessuna categoria», e la // ricetta si scrive lo stesso. Un errore qui non deve bloccare una // schermata che sta scrivendo altro. if (vivo) setCategorie([]); }); return () => { vivo = false; }; }, [db]); const azzeraCrea = () => { setCrea(false); setNome(''); setIcona(ICONA_PREDEFINITA); setErroreScrittura(false); }; const chiudi = () => { setAperto(false); azzeraCrea(); }; const scegli = (id: string | null) => { onScegli(id); chiudi(); }; const salvaNuova = () => scriviUnaSola(async () => { if (!siPuoSalvare(nome)) return; // salvaCategoria non timbra: la data la mette chi salva. const categoria = categoriaNuova(nome, icona, tintaMenoUsata(categorie), categorie, new Date().toISOString()); try { setErroreScrittura(false); await salvaCategoria(db, categoria); setCategorie([...categorie, categoria]); scegli(categoria.id); // appena creata è quella scelta: è il motivo per cui l'ha creata } catch { // Errore di SCRITTURA, non di lettura: la categoria non si è salvata. setErroreScrittura(true); } }); const attuale = categorie.find((c) => c.id === scelta) ?? null; const pastiglia = (c: Categoria) => { const tinta = tintaDi(c, tinte); return ( ); }; return ( <> setAperto(true)} accessibilityLabel={`${testo('modifica.categoria')}: ${attuale?.nome ?? testo('modifica.categoria.nessuna')}`} stile={stile} > {attuale !== null ? ( <> {pastiglia(attuale)} {attuale.nome} ) : ( {testo('modifica.categoria.nessuna')} )} {/* Nel modo «crea» il campo del nome ha autoFocus: la tastiera è aperta per costruzione, e `evitaTastiera` fa scansare il foglio. */} {!crea ? ( <> scegli(null)} segno={ } /> {categorie.map((c) => ( // L'etichetta va detta: senza, il nome accessibile se lo // compone dai figli, e il primo figlio è l'icona, che // VoiceOver legge col suo glifo grezzo. scegli(c.id)} segno={pastiglia(c)} separata /> ))} setCrea(true)} stile={stili.nuova} /> ) : ( <> void salvaNuova(), spento: !siPuoSalvare(nome) }} /> {/* Avviso, non divieto: l'identità di una categoria è il suo id, due categorie con lo stesso nome sono ammesse. */} {nomeDuplicato(nome, categorie, null) ? ( {testo('categorie.nome.duplicato')} ) : null} {erroreScrittura ? {testo('errore.scrittura')} : null} {testo('categorie.icona')} {/* keyboardShouldPersistTaps: la tastiera è sempre su, e senza "handled" il primo tocco su un'icona chiuderebbe solo la tastiera invece di scegliere l'icona. */} )} ); } function RigaCategoria({ stili, colori, scelta, nome, onPress, segno, separata = false, }: { stili: ReturnType; colori: Colori; scelta: boolean; nome: string; onPress: () => void; segno: ReactNode; separata?: boolean; }) { return ( [stili.riga, separata ? stili.separata : null, pressed ? stili.premuta : null]} > {segno} {nome} {scelta ? : } ); } const creaStili = (colori: Colori) => StyleSheet.create({ valore: { flex: 1, ...TESTO.corpo, color: colori.testo }, valoreNessuna: { flex: 1, ...TESTO.corpo, color: colori.testoTenue }, pastiglia: { width: 28, height: 28, borderRadius: 9, alignItems: 'center', justifyContent: 'center' }, foglio: { backgroundColor: colori.fondo, borderTopLeftRadius: 22, borderTopRightRadius: 22, flexShrink: 1, }, lista: { flexShrink: 1 }, listaDentro: { paddingHorizontal: SPAZIO.l, paddingTop: SPAZIO.s, gap: SPAZIO.xs }, riquadro: { backgroundColor: colori.superficie, borderRadius: RAGGIO.riga, overflow: 'hidden' }, riga: { flexDirection: 'row', alignItems: 'center', gap: SPAZIO.m, minHeight: 52, paddingHorizontal: SPAZIO.l }, separata: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colori.bordo }, premuta: { backgroundColor: colori.bordo }, rigaNome: { flex: 1, ...TESTO.corpo, color: colori.testo }, rigaNomeScelta: { fontWeight: '600' }, nuova: { marginTop: SPAZIO.xs }, crea: { paddingHorizontal: SPAZIO.l, paddingTop: SPAZIO.s, gap: SPAZIO.s, flexShrink: 1 }, etichetta: { ...TESTO.piccolo, fontWeight: '600', color: colori.testoTenue, marginTop: SPAZIO.s }, avviso: { ...TESTO.piccolo, color: colori.attenzione }, errore: { ...TESTO.piccolo, color: colori.errore }, griglia: { maxHeight: 260, flexShrink: 1 }, });