/** * La foto della ricetta: il banner in cima alla schermata Modifica. * * Com'è fatto. Con la foto, è la copertina che la ricetta avrà nel Dosatore: * tutta larghezza, 210 punti, e in basso a destra la pillola «Cambia foto». * Senza, è un riquadro di accento tenue con la macchina fotografica e «Aggiungi * una foto», più basso, per non spingere giù il nome. Tutto il banner si * tocca, e solo allora si sceglie: scatta, scegli dalla libreria, rimuovi * (MenuNativo, il menù di Apple che nasce dal banner). Prima c'erano tre comandi di testo sempre a schermo, sotto il * titolo: i tester non capivano cosa fossero. * * Il permesso si chiede al momento del gesto, mai all'avvio: a chi ha appena * installato l'app e non ha ancora capito a cosa serve, chiedere la fotocamera * significa farsi rispondere di no una volta sola e per sempre. Se l'utente * nega, si mostra il messaggio tradotto, che dice dove si rimedia, e si * smette: non si richiede e non si insiste. * * Il file lo scrive salvaFoto, che comprime PRIMA di salvare e restituisce il * NOME del file, mai un percorso assoluto. Qui si tiene solo quel nome; il * percorso da dare a lo ricostruisce percorsoFoto ogni volta. Se il file * non c'è più, oltre a mostrare la ricetta senza immagine si azzera anche il * campo (deveAzzerareFoto, in logica-modifica.ts): è l'altra metà del * requisito, e senza di lei il nome sbagliato resta nel database per sempre. * * Ogni foto scritta ha un nome suo: rifarla non sovrascrive quella di prima, * che resta sul disco finché la schermata non decide chi tenere. Rimuovere la * foto è dire onCambia(null). In tutti e due i casi i file che avanzano li * cancella la schermata quando si esce, così «Annulla» non porta via niente. * * La compressione dura qualche secondo: `onInCorso` lo dice alla schermata, * che intanto tiene spento «Salva». Salvare nel mezzo scriverebbe una ricetta * senza foto e lascerebbe sul disco un file che nessuno nomina più. Il banner * intanto mostra la rotellina e non si tocca. * * I colori non si scrivono qui: vengono da tema.ts, come nelle altre schermate. */ import { useEffect, useMemo, useState } from 'react'; import type { ReactElement } from 'react'; import { ActivityIndicator, Image, Platform, StyleSheet, Text, useWindowDimensions, View } from 'react-native'; import { percorsoFoto, salvaFoto, scegliFoto } from '../../data/foto.ts'; import { useApp } from '../contesto.ts'; import type { VoceFoto } from '../logica-modifica.ts'; import { ancoraMenuFoto, chiaveMessaggioPermesso, deveAzzerareFoto, formaBanner, vociFoto } from '../logica-modifica.ts'; import type { Colori } from '../tema.ts'; import { RAGGIO, SPAZIO, TESTO, useColori } from '../tema.ts'; import type { OrigineFoto } from '../../data/foto.ts'; import type { VoceMenu } from './MenuNativo.tsx'; import { MenuNativo } from './MenuNativo.tsx'; import Segno from './Segno.tsx'; /** La stessa altezza della copertina del Dosatore (ALTEZZA_COPERTINA in SchedaRicetta.tsx). */ const ALTEZZA_FOTO = 210; const ALTEZZA_VUOTO = 148; export type PropsFotoRicetta = { /** NOME del file già salvato, o null. Mai un percorso assoluto. */ foto: string | null; onCambia: (foto: string | null) => void; /** Vero mentre la foto sta comprimendo: chi ci sta sopra spegne «Salva». */ onInCorso: (inCorso: boolean) => void; /** Il file della foto non c'è più: il campo va azzerato, ma non l'ha deciso l'utente. */ onSparita: () => void; /** * La foto sta arrivando da una pagina web (scaricaFoto, nella schermata): * il banner mostra la rotellina e non si tocca, come mentre comprime. */ inArrivo?: boolean; }; export function FotoRicetta({ foto, onCambia, onInCorso, onSparita, inArrivo = false }: PropsFotoRicetta) { const { testo } = useApp(); const colori = useColori(); const stili = useMemo(() => creaStili(colori), [colori]); const larghezza = useWindowDimensions().width; // undefined: il file si sta ancora cercando (vedi formaBanner). const [percorso, setPercorso] = useState(undefined); const [inCorso, setInCorso] = useState(false); const [permessoNegato, setPermessoNegato] = useState(null); // percorsoFoto restituisce null se il file non c'è, e non tocca il database: // una ricetta che dice di avere una foto sparita si mostra senza immagine, // senza messaggi di errore. // // onCambia non entra fra le dipendenze: chi monta il componente la passa come // funzione nuova a ogni render, e metterla lì rifarebbe il giro all'infinito. useEffect(() => { let vivo = true; percorsoFoto(foto) .then((p) => { if (!vivo) return; setPercorso(p); if (deveAzzerareFoto(p, foto)) onSparita(); }) .catch(() => { if (vivo) setPercorso(null); }); return () => { vivo = false; }; }, [foto]); // Lo stesso stato serve qui (il banner non si tocca) e alla schermata // (spegne «Salva»): si scrive nei due posti insieme. const segnaInCorso = (valore: boolean) => { setInCorso(valore); onInCorso(valore); }; const prendi = async (origine: OrigineFoto) => { setPermessoNegato(null); // Il permesso lo chiede scegliFoto, adesso, cioè quando l'utente ha appena // scelto la voce. const esito = await scegliFoto(origine); if (esito.tipo === 'annullata') return; if (esito.tipo === 'permessoNegato') { setPermessoNegato(origine); return; } segnaInCorso(true); try { onCambia(await salvaFoto(esito.uri)); } catch (errore) { // Nessun testo per questo caso: la foto semplicemente non compare, e il // resto della ricetta non è toccato. console.warn('salvataggio della foto fallito', errore); } finally { segnaInCorso(false); } }; const forma = formaBanner(foto, percorso); const occupato = inCorso || inArrivo; const voce = (v: VoceFoto): VoceMenu => { switch (v) { case 'fotocamera': return { etichetta: testo('foto.scatta'), segno: 'fotocamera', simbolo: 'camera', azione: () => void prendi('fotocamera') }; case 'libreria': return { etichetta: testo('foto.scegli'), segno: 'libreria', simbolo: 'photo.on.rectangle', azione: () => void prendi('libreria') }; case 'rimuovi': return { etichetta: testo('foto.rimuovi'), segno: 'cestino', simbolo: 'trash', pericolo: true, azione: () => onCambia(null) }; } }; const menu = (children: ReactElement) => ( {children} ); const pillola = ( // Il velo della pillola è al 62%: sopra il caso peggiore, una foto // bianca, il bianco ci si legge a 5,2:1 (la soglia del 53,5% sta in // DESIGN.md). {testo('foto.cambia')} ); // Da dove nasce il menù: il banner intero, o su Android, con la foto, la // pillola «Cambia foto» (ancoraMenuFoto). Dentro il menù di SwiftUI il // banner non prende la larghezza dal genitore: gliela si dà. const ancora = ancoraMenuFoto(Platform.OS, forma); return ( {forma === 'vuoto' ? ( {menu( {occupato ? ( ) : ( <> {testo('foto.aggiungi')} {testo('foto.aggiungi.spiega')} )} , )} ) : ancora === 'banner' ? ( menu( {forma === 'foto' && typeof percorso === 'string' ? ( ) : null} {occupato ? ( ) : forma === 'foto' ? ( {pillola} ) : null} , ) ) : ( {typeof percorso === 'string' ? ( ) : null} {occupato ? ( ) : ( {menu({pillola})} )} )} {permessoNegato !== null ? ( {testo(chiaveMessaggioPermesso(permessoNegato))} ) : null} ); } const creaStili = (colori: Colori) => StyleSheet.create({ fuoriVuoto: { marginHorizontal: SPAZIO.l, marginTop: SPAZIO.xs }, vuoto: { height: ALTEZZA_VUOTO, borderRadius: RAGGIO.foto, backgroundColor: colori.accentoTenue, alignItems: 'center', justifyContent: 'center', gap: 6, }, vuotoTitolo: { ...TESTO.corpo, fontWeight: '600', color: colori.accento }, vuotoSotto: { ...TESTO.piccolo, color: colori.testoTenue }, // Il carbone della copertina del Dosatore: aspettando il file, un lampo // di crema sarebbe più brusco del buio. copertina: { height: ALTEZZA_FOTO, backgroundColor: '#17120F' }, immagine: { width: '100%', height: '100%' }, attesa: { ...StyleSheet.absoluteFill, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(23,18,15,0.45)' }, postoPillola: { position: 'absolute', right: SPAZIO.m, bottom: SPAZIO.m }, // Su Android il menù nasce dalla pillola, alta 32: attorno c'è un margine // che porta l'area da toccare a 48, e la pillola resta dov'è. postoPillolaAmpio: { position: 'absolute', right: SPAZIO.m - 8, bottom: SPAZIO.m - 8 }, areaPillola: { padding: 8 }, pillola: { minHeight: 32, borderRadius: RAGGIO.tondo, paddingLeft: 10, paddingRight: SPAZIO.m, flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: 'rgba(23,18,15,0.62)', }, pillolaTesto: { ...TESTO.piccolo, fontWeight: '600', color: '#FFFFFF' }, // errore e non attenzione: il permesso negato non è "si può fare, ma // guarda", è "questo gesto adesso non si può fare" finché non si cambia // impostazione sul telefono. avviso: { ...TESTO.piccolo, color: colori.errore, marginHorizontal: SPAZIO.l, marginTop: SPAZIO.s }, });