/** * Gestione delle categorie: crearle, rinominarle, cambiargli icona, * riordinarle, cancellarle. * * Ci si arriva dalla schermata principale. db e testo arrivano da useApp(): la * schermata è registrata con `component={GestioneCategorie}` e non riceve props * oltre a quelle della navigazione. * * La conferma di cancellazione dice per intero che cosa succede alle ricette * che stavano dentro: non vengono cancellate, finiscono in «Senza categoria». * «Elimina categoria» spaventa, e la promessa serve nel punto in cui la paura * nasce, non in una nota da qualche altra parte. * * Riordinare si fa trascinando la riga, che è il gesto della cosa: le due * frecce di prima erano un ripiego. Chi lo schermo non lo vede trascinare non * può, e per quello le stesse due mosse restano come azioni di accessibilità * sul nome della riga. * * I nomi delle categorie li scrive l'utente: carattere del dispositivo, non il * corsivo del marchio, che è solo per "Quanto Basta" (vedi tema.ts). * * I colori non si scrivono qui: vengono da tema.ts, come nelle altre schermate. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useFocusEffect } from '@react-navigation/native'; import { Alert, Animated, KeyboardAvoidingView, Modal, PanResponder, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons'; import { cancellaCategoria, elencoCategorie, riordinaCategorie, salvaCategoria, } from '../../data/categorie.ts'; import { ICONA_PREDEFINITA } from '../../domain/icone.ts'; import Icona from '../componenti/Icona.tsx'; import Segno from '../componenti/Segno.tsx'; import Barra from '../componenti/Barra.tsx'; import { SceltaIcona } from '../componenti/SceltaIcona.tsx'; import { useApp } from '../contesto.ts'; import { statoElenco } from '../logica-elenco.ts'; import { creaScrittoreUnico } from '../scritturaUnica.ts'; import { categoriaModificata, categoriaNuova, indiceDestinazione, nomeDuplicato, siPuoSalvare, sposta, trascina, } from '../logica-categorie.ts'; import type { Colori } from '../tema.ts'; import { OMBRA, RAGGIO, SPAZIO, TESTO, useColori } from '../tema.ts'; import type { ChiaveIcona } from '../../domain/icone.ts'; import type { Categoria } from '../../domain/types.ts'; import type { PropsSchermata } from '../navigazione.ts'; /** null = finestra chiusa. Dentro la finestra, id null significa categoria nuova. */ type Editor = { id: string | null; nome: string; icona: ChiaveIcona } | null; /** * Quanto spazio lasciare in fondo all'elenco. Il tondo col `+` galleggia sopra * il contenuto: senza questo margine l'ultima riga finisce sotto il cerchio, * nascosta a metà e, nell'angolo, nemmeno toccabile. Stesso difetto e stesso * rimedio di Categorie.tsx ed Elenco.tsx. */ const FONDO_LISTA = 96; /** * Quanto deve muoversi il dito in verticale perché sia un trascinamento e non * un tocco. * * È l'unico punto in cui i due gesti si separano, ed è il più delicato di * tutto il riordino. Sotto la soglia non si prende niente: il tocco resta al * Pressable del nome e apre la rinomina, come ha sempre fatto. Sopra la soglia * il PanResponder si prende il gesto, il sistema dei responder avvisa il * Pressable, e la rinomina non parte più. * * Otto punti: sotto, un dito che trema mentre apre la rinomina si porta via la * riga; molto sopra, il trascinamento parte in ritardo e sembra incollato. */ const SOGLIA_TRASCINAMENTO = 8; /** Quanto ci mette la riga lasciata a posarsi nel suo nuovo posto. */ const DURATA_POSA = 140; export default function GestioneCategorie({ navigation }: PropsSchermata<'GestioneCategorie'>) { const { db, testo } = useApp(); const colori = useColori(); const stili = useMemo(() => creaStili(colori), [colori]); const [categorie, setCategorie] = useState([]); const [errore, setErrore] = useState(false); const [editor, setEditor] = useState(null); // Finché il primo caricamento non è finito non si mostra né l'invito né // l'elenco: senza questa sentinella un elenco pieno lampeggerebbe "nessuna // categoria" per un frame. const [caricato, setCaricato] = useState(false); // Quanto è alta una riga, misurata con onLayout invece che calcolata: sono // tutte alte uguale, ma quanto lo siano dipende dal corpo del testo scelto // nelle impostazioni del dispositivo. Finché vale 0 il trascinamento non // parte: senza una misura non si sa quante righe ha scavalcato il dito. const [altezza, setAltezza] = useState(0); // L'indice della riga sollevata, o null se nessuna. Serve solo al disegno: // il gesto tiene il suo indice in `rigaPresa`, che non aspetta un render. const [trascinato, setTrascinato] = useState(null); // Di quanto si è mosso il dito da quando ha toccato. Ci si appendono tutte // le righe: quella presa lo segue, le altre si scostano al momento giusto. const dy = useRef(new Animated.Value(0)).current; // Quale riga è sotto il dito. L'id e non l'indice, perché l'indice è quello // di un elenco che nel frattempo può essere cambiato. const rigaToccata = useRef(null); // La riga che si sta trascinando davvero, cioè da quando il gesto è stato // riconosciuto. Vive in un ref e non in uno stato perché la deve leggere il // rilascio, che arriva prima che React abbia ridisegnato alcunché. const rigaPresa = useRef(null); // Siamo ancora sulla schermata? Le altre tre tengono la sentinella dentro // l'effetto, dove la lettura sta tutta; qui `ricarica` la chiamano anche il // salvataggio e la cancellazione, quindi la sentinella vive nel componente e // l'effetto la spegne uscendo. Serve contro la risposta che arriva tardi: si // esce mentre la lettura è in volo, si rientra, e la vecchia risposta // sovrascriverebbe l'elenco appena riletto. const vivo = useRef(true); // Una scrittura per volta: il perché e il come stanno in scritturaUnica.ts, // dove il meccanismo è collaudato e da dove lo prende anche il foglio // «Nuova categoria» di SceltaCategoria.tsx, che salva la stessa cosa. const scriviUnaSola = useRef(creaScrittoreUnico()).current; const ricarica = useCallback(async () => { try { const elenco = await elencoCategorie(db); if (!vivo.current) return; setCategorie(elenco); setErrore(false); setCaricato(true); } catch { if (vivo.current) setErrore(true); } }, [db]); // Si ricarica ad ogni ritorno sulla schermata: una categoria creata al volo // dalla schermata di modifica ricetta deve comparire anche qui. useFocusEffect( useCallback(() => { vivo.current = true; void ricarica(); return () => { vivo.current = false; }; }, [ricarica]), ); // Questa schermata non cerca niente: la ricerca fissa a '' basta a riusare // statoElenco per la stessa finestra di caricamento delle altre due // schermate, senza scrivere una seconda funzione che decide la stessa cosa. const stato = statoElenco(categorie.length, '', caricato); /** * Scrive il nuovo ordine, e se il database non lo accetta rimette quello di * prima. * * `precedente` arriva da fuori invece che dalla chiusura: la chiama anche il * trascinamento, da dentro un PanResponder creato una volta sola, che lo * stato di questo render non lo vede. * * La guardia è quella che la schermata ha già, `creaScrittoreUnico`, e non * `creaScrittoreUltimo` che a rigore descriverebbe meglio il gesto — ogni * rilascio porta un ordine nuovo, mai un doppione. Il motivo è che la * guardia deve restare **una**: due guardie su una schermata sola non si * escludono a vicenda, e due transazioni sovrapposte sulla stessa * connessione sono esattamente il guasto che scritturaUnica.ts esiste per * impedire. Fra le due, quella che serve a «Salva» è `unico`: con `ultimo` * il secondo tocco su «Salva» non verrebbe buttato ma accodato, e creerebbe * una seconda categoria. E qui non si perde niente davvero: le altre due * scritture di questa schermata stanno dietro il foglio o dietro l'avviso di * cancellazione, e mentre uno dei due è aperto non c'è nessun dito che * trascina una riga. */ const riordina = (nuovo: Categoria[], precedente: Categoria[]) => scriviUnaSola(async () => { // La riga si muove subito sotto il dito, il disco insegue. setCategorie(nuovo); try { await riordinaCategorie( db, nuovo.map((c) => c.id), ); } catch { setCategorie(precedente); Alert.alert(testo('app.nome'), testo('errore.scrittura')); } }); /** Un posto in su o in giù: lo chiedono le azioni di accessibilità. */ const muovi = (indice: number, passi: -1 | 1) => { const nuovo = sposta(categorie, indice, passi); // Ai bordi sposta() restituisce lo stesso array: non c'è niente da scrivere. if (nuovo === categorie) return; void riordina(nuovo, categorie); }; // Il PanResponder qui sotto è creato una volta sola, e vede solo le chiusure // del primo render: quello che gli serve — l'elenco di adesso, l'altezza // misurata, chi scrive il nuovo ordine — lo legge da qui, che ogni render // aggiorna. const gesto = useRef({ categorie, altezza, riordina }); useEffect(() => { gesto.current = { categorie, altezza, riordina }; }); /** * Il trascinamento. * * Due scelte qui dentro sembrano sbagliate e sono le due che lo fanno * funzionare. * * **Sta sopra la lista, non sulle righe.** Sarebbe naturale attaccarlo a * ogni riga, che è quello che si trascina. Ma su iOS una lista che scorre è * una UIScrollView vera, che al primo movimento si prende il dito e annulla * il tocco a chi sta dentro: il trascinamento non partirebbe mai, o * partirebbe una volta su tre. React Native ha una sola via d'uscita, e la * documenta nel suo RCTScrollView: la lista smette di scorrere quando chi si * è preso il gesto sta **sopra** di lei nella gerarchia, non dentro * (`_shouldDisableScrollInteraction` risale gli antenati della lista, e se * trova il responder blocca lo scorrimento sul nascere). Quindi il gesto lo * prende la schermata, e quale riga si stia toccando glielo dice * `rigaToccata`, che ogni riga scrive al primo contatto — al contatto, cioè * prima di qualunque movimento, quindi quando il dito comincia a muoversi il * nome della riga è già lì. * * **È creato una volta sola.** Dentro un PanResponder c'è lo stato del gesto * — da dove è partito il dito, quanto si è mosso — e uno ricreato a metà * trascinamento riparte da zero: la riga tornerebbe di scatto al suo posto, * col dito ancora giù, ad ogni render che capita mentre la si trascina. Da * qui il giro di `gesto`, che gli porta lo stato di adesso. */ const pan = useRef( PanResponder.create({ // Al primo contatto non si prende niente: il tocco deve poter arrivare // al nome, che apre la rinomina, alla ×, e al tondo del `+`. onStartShouldSetPanResponder: () => false, onMoveShouldSetPanResponder: (_evento, movimento) => { if (rigaToccata.current === null) return false; // dito non partito da una riga if (rigaPresa.current !== null) return false; // una riga per volta if (gesto.current.altezza <= 0) return false; // riga non ancora misurata if (gesto.current.categorie.length < 2) return false; // niente da riordinare // Più verticale che orizzontale: così lo scorrimento all'indietro, che // si tira dal bordo sinistro, non diventa un trascinamento se il dito // per strada scende un po'. return ( Math.abs(movimento.dy) > SOGLIA_TRASCINAMENTO && Math.abs(movimento.dy) > Math.abs(movimento.dx) ); }, // Preso il gesto non lo si molla: dentro c'è una ScrollView, e se le si // restituisse il dito la riga resterebbe sollevata a mezz'aria. onPanResponderTerminationRequest: () => false, onPanResponderGrant: () => { const indice = gesto.current.categorie.findIndex((c) => c.id === rigaToccata.current); if (indice < 0) return; // riga sparita fra il tocco e il movimento rigaPresa.current = indice; setTrascinato(indice); }, onPanResponderMove: Animated.event([null, { dy }], { useNativeDriver: false }), onPanResponderRelease: (_evento, movimento) => { const indice = rigaPresa.current; if (indice === null) { // Il gesto è stato preso ma nessuna riga era sotto: si rimette a // zero lo scostamento, che intanto ha continuato a raccogliere il // movimento del dito, e non si scrive niente. dy.setValue(0); return; } const { categorie, altezza, riordina } = gesto.current; const nuovo = trascina(categorie, indice, movimento.dy, altezza); const destinazione = indiceDestinazione(indice, movimento.dy, altezza, categorie.length); // Prima la riga si posa dove andrà a stare — e i vicini, che seguono // lo stesso valore, si sistemano con lei — poi, a movimento fermo, // l'elenco cambia ordine e lo scostamento torna a zero. Le due cose // insieme lasciano ogni riga esattamente dov'era un istante prima: // separate, si vedrebbe un salto. Animated.timing(dy, { toValue: (destinazione - indice) * altezza, duration: DURATA_POSA, useNativeDriver: false, }).start(() => { dy.setValue(0); rigaPresa.current = null; setTrascinato(null); // Ai bordi trascina() restituisce lo stesso elenco: niente da scrivere. if (nuovo !== categorie) void riordina(nuovo, categorie); }); }, // Il gesto lo può togliere il sistema, per esempio se arriva una // telefonata: la riga torna al suo posto e non si scrive niente. onPanResponderTerminate: () => { dy.setValue(0); rigaPresa.current = null; setTrascinato(null); }, }), ).current; /** * Di quanto è scostata dal suo posto la riga `i`, mentre se ne trascina * un'altra. * * Durante il gesto l'elenco non si riordina: resta com'è, e si sposta solo * il disegno. La riga presa segue il dito e nient'altro — è il vincolo che * conta: fra il dito e la riga che sta trascinando non deve cambiare niente, * il resto si riordina attorno — e le altre si fanno da parte di una riga * esatta, in su o in giù. * * Il varco si apre nell'ultima mezza riga di corsa prima dello scambio, e * finisce di aprirsi nel punto preciso in cui indiceDestinazione() cambia * numero: si vede il posto libero un attimo prima di posarci dentro la riga, * e quello che si vede e quello che si scrive cadono nello stesso punto. */ const scostamento = (i: number) => { if (trascinato === null || altezza <= 0) return 0; if (i === trascinato) { // Segue il dito, ma non esce dall'elenco: oltre la prima e oltre // l'ultima posizione si ferma. È un'interpolazione che restituisce // quello che riceve, limata ai due capi. const su = -trascinato * altezza; const giu = (categorie.length - 1 - trascinato) * altezza; return dy.interpolate({ inputRange: [su, giu], outputRange: [su, giu], extrapolate: 'clamp' }); } const soglia = (i - trascinato + (i > trascinato ? -0.5 : 0.5)) * altezza; const rampa = altezza / 2; return i > trascinato ? dy.interpolate({ inputRange: [soglia - rampa, soglia], outputRange: [0, -altezza], extrapolate: 'clamp', }) : dy.interpolate({ inputRange: [soglia, soglia + rampa], outputRange: [altezza, 0], extrapolate: 'clamp', }); }; const elimina = (c: Categoria) => { // Il nome va passato: 'categorie.conferma.elimina' ha il segnaposto {nome}, // e senza valore t() lancia invece di mostrarlo in chiaro. Alert.alert(testo('categorie.elimina'), testo('categorie.conferma.elimina', { nome: c.nome }), [ { text: testo('modifica.annulla'), style: 'cancel' }, { text: testo('categorie.elimina'), style: 'destructive', onPress: () => void scriviUnaSola(async () => { try { await cancellaCategoria(db, c.id); await ricarica(); } catch { Alert.alert(testo('app.nome'), testo('errore.scrittura')); } }), }, ]); }; const salvabile = editor !== null && siPuoSalvare(editor.nome); const salva = () => scriviUnaSola(async () => { if (editor === null || !siPuoSalvare(editor.nome)) return; const adesso = new Date().toISOString(); const esistente = editor.id === null ? null : (categorie.find((c) => c.id === editor.id) ?? null); // salvaCategoria non timbra: la data di modifica la mette chi modifica. const categoria = esistente === null ? categoriaNuova(editor.nome, editor.icona, categorie, adesso) : categoriaModificata(esistente, editor.nome, editor.icona, adesso); try { await salvaCategoria(db, categoria); setEditor(null); await ricarica(); } catch { Alert.alert(testo('app.nome'), testo('errore.scrittura')); } }); return ( { rigaToccata.current = null; // E quello del PanResponder va chiamato lo stesso: è lì che azzera il // conto di quanto si è mosso il dito. return pan.panHandlers.onStartShouldSetResponderCapture?.(evento) ?? false; }} > {errore ? ( {testo('errore.db')} ) : stato === 'vuoto' ? ( // Si dice lo stato, non il gesto: il gesto lo offre il tondo qui sotto, // che si annuncia già «Nuova categoria», e la stessa frase in tutt'e // due i posti il lettore di schermo la legge due volte di fila. Non si // riusa nemmeno l'invito a scrivere la prima ricetta, che è dello stato // vuoto della schermata principale: lì manca il ricettario, qui mancano // solo le categorie, e le ricette possono esserci benissimo. {testo('categorie.vuoto.nessuna')} ) : stato === 'ricette' ? ( // Mentre una riga è in viaggio la lista non scorre. Il grosso del // lavoro lo fa già React Native da sé — vedi il PanResponder qui sopra // — ma solo impedendo allo scorrimento di *cominciare*: nella nuova // architettura, se è cominciato un istante prima che il gesto fosse // riconosciuto, non c'è niente che lo fermi. Questo lo ferma. {categorie.map((c, i) => ( setAltezza(evento.nativeEvent.layout.height)} // Chi si prende il gesto è la schermata, che da sola non saprebbe // quale riga si sta toccando: glielo dice questo, al contatto e // prima di qualunque movimento. `false` perché la riga il gesto // non lo vuole: deve arrivare al nome, che apre la rinomina. onStartShouldSetResponderCapture={() => { rigaToccata.current = c.id; return false; }} style={[ stili.riga, trascinato === i ? stili.sollevata : null, { transform: [{ translateY: scostamento(i) }] }, ]} > setEditor({ id: c.id, nome: c.nome, icona: c.icona })} accessibilityRole="button" accessibilityLabel={`${testo('categorie.rinomina')} ${c.nome}`} // Trascinare è un gesto che senza vedere lo schermo non si fa, // quindi il riordino resta raggiungibile di qui, dal rotore. Le // azioni stanno sul nome e non sulla riga perché è il nome // l'elemento su cui il lettore di schermo si ferma. Sulla prima // riga non si offre «su» e sull'ultima non si offre «giù»: // un'azione che non fa niente è peggio di un'azione che non c'è. accessibilityActions={[ ...(i > 0 ? [{ name: 'su', label: testo('categorie.sposta.su') }] : []), ...(i < categorie.length - 1 ? [{ name: 'giu', label: testo('categorie.sposta.giu') }] : []), ]} onAccessibilityAction={(evento) => { if (evento.nativeEvent.actionName === 'su') muovi(i, -1); if (evento.nativeEvent.actionName === 'giu') muovi(i, 1); }} > {c.nome} {/* Quadrato da 44: la misura sotto la quale un bersaglio non si prende con sicurezza. `disabled` mentre si trascina perché la × cancella, e un comando che cancella non deve mai trovarsi sotto un dito che sta facendo altro. */} elimina(c)} disabled={trascinato !== null} style={stili.comando} accessibilityRole="button" accessibilityLabel={`${testo('categorie.elimina')} ${c.nome}`} > ))} ) : null} setEditor({ id: null, nome: '', icona: ICONA_PREDEFINITA })} > + setEditor(null)} > {editor !== null ? ( // Il campo del nome ha autoFocus: la tastiera è aperta per // costruzione, e senza questo il foglio resta incollato in fondo allo // schermo con «Annulla», «Salva» e mezza griglia sotto i tasti. Su // Android non serve: la finestra si ridimensiona già da sé // (softwareKeyboardLayoutMode "resize", il default di Expo), e // scansarsi due volte porterebbe il foglio troppo in alto. {editor.id === null ? testo('categorie.crea') : testo('categorie.rinomina')} setEditor({ ...editor, nome: v })} placeholder={testo('categorie.nome')} placeholderTextColor={colori.testoTenue} autoFocus /> {/* Avviso, non divieto: due categorie con lo stesso nome sono ammesse, perché l'identità è l'id. Quasi sempre però è una svista. Scritto in `attenzione` e non in `errore`: «Salva» resta acceso, e col rosso del guasto l'utente si fermerebbe credendo di non poter andare avanti. */} {nomeDuplicato(editor.nome, categorie, editor.id) ? ( {testo('categorie.nome.duplicato')} ) : null} {testo('categorie.icona')} {/* `keyboardShouldPersistTaps` non è un dettaglio: col valore di partenza ("never") una ScrollView, mentre la tastiera è su, si prende il tocco in fase di cattura per chiuderla, e il primo tocco su un'icona non arriva mai all'icona. Qui la tastiera è sempre su, quindi ogni prima scelta andava persa. Con "handled" il tocco lo prende la cella, che lo gestisce. */} setEditor({ ...editor, icona })} /> setEditor(null)} style={stili.tastoChiaro} accessibilityRole="button" > {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 salva()} disabled={!salvabile} style={[stili.tasto, salvabile ? null : stili.tastoSpento]} accessibilityRole="button" > {testo('modifica.salva')} ) : null} ); } const creaStili = (colori: Colori) => StyleSheet.create({ schermo: { flex: 1, backgroundColor: colori.fondo }, centro: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: SPAZIO.xxl }, errore: { ...TESTO.etichetta, color: colori.errore, textAlign: 'center' }, invito: { ...TESTO.corpo, color: colori.testoTenue, textAlign: 'center' }, fondoLista: { padding: SPAZIO.l, paddingBottom: FONDO_LISTA }, riga: { flexDirection: 'row', alignItems: 'center', gap: SPAZIO.m, // L'altezza della riga la danno i comandi, che sono alti 44: qui basta // il respiro attorno. Con SPAZIO.m la riga sarebbe di 68 punti. paddingVertical: SPAZIO.xs, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colori.bordo, }, // `alignSelf: stretch` contro l'`alignItems: center` della riga: il nome // apre la rinomina, ed è un bersaglio anche lui. Centrato varrebbe l'altezza // della sola scritta, una ventina di punti; steso, prende i 44 della riga. nome: { flex: 1, alignSelf: 'stretch', justifyContent: 'center' }, nomeTesto: { ...TESTO.corpo, color: colori.testo }, // 44x44: la misura sotto la quale un bersaglio non si prende con sicurezza. // Dichiarata e non ottenuta con hitSlop, perché un hitSlop generoso qui // sconfinerebbe nel nome, che apre la rinomina, e nelle righe vicine. comando: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, // La riga mentre la si trascina: staccata dal fondo, non colorata. Prende // il colore delle superfici e l'ombra del tondo del `+`, cioè lo stesso // stacco che l'app usa già per dire "questo sta sopra". Il filo del // separatore si spegne nel colore della riga stessa: un cartoncino // sollevato con una riga sotto sembrerebbe ancora incastrato nell'elenco. sollevata: { backgroundColor: colori.superficie, borderRadius: RAGGIO.riga, borderBottomColor: colori.superficie, zIndex: 1, ...OMBRA, }, tondo: { position: 'absolute', right: SPAZIO.xl, bottom: SPAZIO.xl, width: 60, height: 60, borderRadius: RAGGIO.tondo, backgroundColor: colori.accento, alignItems: 'center', justifyContent: 'center', ...OMBRA, }, piu: { fontSize: 34, lineHeight: 38, 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, // Scansato dalla tastiera, quello che resta può non bastare: su un SE il // foglio intero è più alto dello spazio libero. Il tetto qui e il // flexShrink sulla griglia fanno sì che a stringersi sia la griglia, che // scorre, e non «Salva», che sparirebbe. 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 }, intestazione: { ...TESTO.piccolo, textTransform: 'uppercase', color: colori.testoTenue, marginTop: SPAZIO.s, }, // Le trenta icone occupano 5 righe da 44 con 8 di gap, cioè 252 punti, su // ogni iPhone dal 375 in su (7 celle per riga a 402, 6 a 375). Con il // vecchio tetto di 220 l'ultima riga era tagliata e chi aveva scelto // «cuore» o «segnalibro» riapriva la griglia senza vedere la propria icona. 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 }, // Bianco su `bordo` fa 1.30:1: una pillola pallida senza parole sopra. // `testoTenue` sta a 4.54:1 in chiaro e 5.18:1 al buio. tastoTestoSpento: { color: colori.testoTenue }, });