/** * La quantità di un ingrediente: in evidenza e toccabile. * * Toccandola diventa un campo di testo dove si scrive quanta se ne ha davvero. * Si conferma con il tasto della tastiera o toccando fuori; su un valore * assurdo (zero, negativo, testo) il campo si chiude e non succede niente, * come vuole la spec. * * Il campo si apre col contenuto già selezionato — chi tocca una quantità la * vuole sostituire — e la bozza non sopravvive a un cambio della riga sotto: * un numero rimasto fermo mentre l'unità accanto cambia riscalerebbe la * ricetta per mille. * * Il numero digitato è nell'unità MOSTRATA sulla riga, che può differire da * quella scritta nella ricetta: la conversione la fa la schermata Dosatore con * quantitaPerRichiesta(). * * I colori vengono da tema.ts, come in Categorie.tsx ed Elenco.tsx: qui non * arrivano come prop perché l'interfaccia di questo componente è un contratto * che anche il Task 22 usa, e non ci si scrive dentro un colore. */ import { useMemo, useState } from 'react'; import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import type { Vocabolario } from '../../domain/lingua/index.ts'; import { leggiQuantita } from '../logica-dosatore.ts'; import type { Colori } from '../tema.ts'; import { RAGGIO, SPAZIO, TESTO, useColori } from '../tema.ts'; export interface PropsCampoQuantita { /** Quantità già formattata con l'unità, es. "1,16 kg" oppure "q.b.". */ testo: string; /** false per i q.b.: non si tocca e non riscala. */ attivo: boolean; /** Valore proposto nel campo, senza unità, già nella lingua giusta. */ proposta: string; /** Unità mostrata accanto al campo, così si sa in che unità si sta scrivendo. */ unita: string | null; voc: Vocabolario; /** Quantità letta, nell'unità mostrata. Non viene chiamata su valori assurdi. */ onQuantita: (quantita: number) => void; } export default function CampoQuantita({ testo, attivo, proposta, unita, voc, onQuantita, }: PropsCampoQuantita) { const colori = useColori(); const stili = useMemo(() => creaStili(colori), [colori]); // null = non si sta scrivendo: si vede la quantità, non il campo. const [bozza, setBozza] = useState(null); // Selezione imposta alla sola apertura, poi lasciata alla tastiera. const [selezione, setSelezione] = useState<{ start: number; end: number } | undefined>(undefined); // La riga com'era quando il campo si è aperto. Se cambia mentre si scrive — // le porzioni toccate con la tastiera aperta, il ritorno alle dosi originali // — la bozza è il numero di un'altra quantità: si butta e si torna a // mostrare la riga. Senza questo il campo direbbe ancora "580" accanto a // un'unità diventata "kg", e confermarlo riscalerebbe per mille. const riga = `${proposta}|${unita ?? ''}`; const [rigaAperta, setRigaAperta] = useState(riga); if (riga !== rigaAperta) { setRigaAperta(riga); setBozza(null); } if (bozza === null) { return ( { setBozza(proposta); // Con tutto selezionato, scrivere sostituisce: toccando "50 g" e // scrivendo 75 si intende 75, non 5075. selectTextOnFocus da solo // non basta, iOS non lo applica quando il fuoco arriva da autoFocus. setSelezione({ start: 0, end: proposta.length }); }} hitSlop={8} accessibilityRole={attivo ? 'button' : 'text'} accessibilityLabel={testo} > {testo} ); } const conferma = () => { const q = leggiQuantita(bozza, voc); setBozza(null); if (q !== null) onQuantita(q); }; return ( setSelezione(undefined)} onChangeText={(t) => { setSelezione(undefined); setBozza(t); }} keyboardType="decimal-pad" autoFocus selectTextOnFocus returnKeyType="done" onSubmitEditing={conferma} onBlur={conferma} /> {unita !== null && {unita}} ); } const creaStili = (colori: Colori) => StyleSheet.create({ // 44x44: la misura sotto la quale un bersaglio non si prende con // sicurezza. Dichiarata, non lasciata al solo hitSlop: la quantità da sola // è alta quanto la sua riga di testo, una ventina di punti, ed è il gesto // centrale dell'app — quello che si tocca con le mani sporche per dire // "ne ho davvero questa quantità". Il numero resta appoggiato a destra // (`alignItems`), così la colonna delle quantità non si scompone quando // uno è corto. L'hitSlop resta: allarga ancora, e non costa niente. bersaglio: { minWidth: 44, minHeight: 44, justifyContent: 'center', alignItems: 'flex-end' }, quantita: { ...TESTO.corpo, color: colori.testoTenue }, quantitaToccabile: { ...TESTO.corpo, fontWeight: '600', color: colori.accento, textDecorationLine: 'underline', }, // Alto 44 come il bersaglio qui sopra, così la riga non si accorcia di // colpo quando il campo si apre: si sta scrivendo dentro quella riga, ed è // il momento peggiore per farla saltare sotto il dito. modifica: { flexDirection: 'row', alignItems: 'center', minHeight: 44 }, campo: { minWidth: 72, paddingVertical: SPAZIO.xs, paddingHorizontal: SPAZIO.s, ...TESTO.corpo, fontWeight: '600', color: colori.testo, textAlign: 'right', backgroundColor: colori.superficie, borderWidth: StyleSheet.hairlineWidth, borderColor: colori.bordo, borderRadius: RAGGIO.campo, }, unita: { marginLeft: SPAZIO.xs, ...TESTO.corpo, color: colori.testoTenue }, });