/** * Scelta della categoria di una ricetta: una sola, oppure nessuna. * * Le categorie sono raggruppamenti, non etichette: la fila si comporta come una * scelta singola, e «Nessuna categoria» è la prima voce e uno stato normale, non * un ripiego. * * C'è anche il tasto per crearne una al volo. Chi scrive la prima ricetta non ha * ancora nessuna categoria, e mandarlo fuori dalla schermata, 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 { useEffect, useMemo, useRef, useState } from 'react'; import { KeyboardAvoidingView, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; import { elencoCategorie, salvaCategoria } from '../../data/categorie.ts'; import { ICONA_PREDEFINITA } from '../../domain/icone.ts'; import Icona from './Icona.tsx'; import { SceltaIcona } from './SceltaIcona.tsx'; 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 } 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; }; export function SceltaCategoria({ scelta, onScegli }: PropsSceltaCategoria) { const { db, testo } = useApp(); const colori = useColori(); const stili = useMemo(() => creaStili(colori), [colori]); const [categorie, setCategorie] = useState([]); const [apri, setApri] = 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, che // salva la stessa cosa dall'altra schermata. const scriviUnaSola = useRef(creaScrittoreUnico()).current; useEffect(() => { let vivo = true; elencoCategorie(db) .then((elenco) => { if (vivo) setCategorie(elenco); }) .catch(() => { // La fila delle categorie non è la sola via per salvare una ricetta: // se la lettura fallisce si mostra solo "Nessuna categoria", e la // ricetta si scrive lo stesso, senza. Un errore qui non deve bloccare // una schermata che sta scrivendo altro. if (vivo) setCategorie([]); }); return () => { vivo = false; }; }, [db]); const crea = () => scriviUnaSola(async () => { if (!siPuoSalvare(nome)) return; // salvaCategoria non timbra: la data la mette chi salva. const categoria = categoriaNuova(nome, icona, categorie, new Date().toISOString()); try { setErroreScrittura(false); await salvaCategoria(db, categoria); setCategorie([...categorie, categoria]); onScegli(categoria.id); // appena creata è quella scelta: è il motivo per cui l'ha creata setApri(false); setNome(''); setIcona(ICONA_PREDEFINITA); } catch { // Errore di SCRITTURA, non di lettura: la categoria non si è salvata, // non "non riesco ad aprire il ricettario". setErroreScrittura(true); } }); const chiudi = () => { setApri(false); setNome(''); setIcona(ICONA_PREDEFINITA); setErroreScrittura(false); }; const salvabile = siPuoSalvare(nome); return ( {/* keyboardShouldPersistTaps: qui sopra c'è il titolo della ricetta, e appena scritto quello la tastiera resta su. Col valore di partenza ("never") la ScrollView si prende il primo tocco in fase di cattura per chiudere la tastiera, e la categoria non si sceglie: il tocco non le arriva nemmeno. Stesso difetto e stesso rimedio della griglia delle icone, qui sotto e in GestioneCategorie.tsx. */} onScegli(null)} style={[stili.pillola, scelta === null ? stili.pillolaScelta : null]} accessibilityRole="button" accessibilityState={{ selected: scelta === null }} > {testo('modifica.categoria.nessuna')} {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, un carattere di uso privato. Viene fuori // «􀰰, Dolci». Stessa cura di Categorie.tsx, dove le voci // dell'elenco hanno anche loro un'icona accanto. onScegli(c.id)} style={[stili.pillola, scelta === c.id ? stili.pillolaScelta : null]} accessibilityRole="button" accessibilityLabel={c.nome} accessibilityState={{ selected: scelta === c.id }} > {c.nome} ))} setApri(true)} style={stili.pillola} accessibilityRole="button" accessibilityLabel={testo('categorie.crea')} > + {/* Il campo del nome ha autoFocus: la tastiera è aperta per costruzione, e senza KeyboardAvoidingView il foglio resterebbe incollato in fondo allo schermo con «Annulla», «Salva» e mezza griglia sotto la tastiera. Su Android non serve: la finestra si ridimensiona già da sé. Stesso difetto e stesso rimedio di GestioneCategorie.tsx. */} {testo('categorie.crea')} {/* 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 (autoFocus qui sopra), e senza "handled" il primo tocco su un'icona chiuderebbe solo la tastiera invece di scegliere l'icona. */} {testo('modifica.annulla')} {/* Spento è lo stato in cui il foglio si apre ogni volta, il nome parte vuoto: la scritta deve restare leggibile. Si spegne il colore, non si fa sparire la parola. */} void crea()} disabled={!salvabile} style={[stili.tasto, salvabile ? null : stili.tastoSpento]} accessibilityRole="button" > {testo('modifica.salva')} ); } const creaStili = (colori: Colori) => StyleSheet.create({ fila: { flexDirection: 'row', alignItems: 'center', gap: SPAZIO.s, paddingVertical: SPAZIO.xs }, pillola: { flexDirection: 'row', alignItems: 'center', gap: SPAZIO.xs, minHeight: 44, borderRadius: RAGGIO.tondo, paddingVertical: SPAZIO.s, paddingHorizontal: SPAZIO.m, backgroundColor: colori.superficie, borderWidth: StyleSheet.hairlineWidth, borderColor: colori.bordo, }, pillolaScelta: { backgroundColor: colori.accento, borderColor: colori.accento }, pillolaTesto: { ...TESTO.etichetta, color: colori.testo }, pillolaTestoScelta: { color: colori.accentoSopra }, fondale: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0, 0, 0, 0.4)' }, foglio: { backgroundColor: colori.superficie, borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: SPAZIO.l, gap: SPAZIO.s, maxHeight: '100%', }, titoloFoglio: { ...TESTO.titolo, color: colori.testo }, campo: { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colori.bordo, paddingVertical: SPAZIO.s, ...TESTO.corpo, color: colori.testo, }, avviso: { ...TESTO.piccolo, color: colori.attenzione }, errore: { ...TESTO.piccolo, color: colori.errore }, intestazione: { ...TESTO.piccolo, textTransform: 'uppercase', color: colori.testoTenue, marginTop: SPAZIO.s, }, griglia: { maxHeight: 260, flexShrink: 1 }, azioni: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: SPAZIO.m, }, tastoChiaro: { paddingVertical: SPAZIO.m, paddingHorizontal: SPAZIO.m }, tastoChiaroTesto: { ...TESTO.corpo, color: colori.accento }, tasto: { backgroundColor: colori.accento, borderRadius: RAGGIO.campo, paddingVertical: SPAZIO.m, paddingHorizontal: SPAZIO.xl, }, tastoSpento: { backgroundColor: colori.bordo }, tastoTesto: { ...TESTO.corpo, fontWeight: '600', color: colori.accentoSopra }, tastoTestoSpento: { color: colori.testoTenue }, });