# Scambio di ricette — Piano di implementazione

> **Per chi esegue con agenti:** SOTTO-SKILL RICHIESTA. Usare
> `superpowers:subagent-driven-development` (consigliata) oppure
> `superpowers:executing-plans` per eseguire il piano un task alla volta.
> I passi hanno le caselle `- [ ]` per tenere il segno.

**Obiettivo.** Backup (tutto o una categoria) dalle impostazioni, condivisione
della singola ricetta dalla ricetta, file `.quantobasta` che il sistema apre con
l'app e che entra solo dopo un'anteprima con il confronto riga per riga, più
una pagina Impostazioni con tema e lingua.

**Architettura.** Le decisioni stanno nei moduli puri, verificabili con
`node --test`: il piano di import (`src/io/piano.ts`), la selezione e il nome
dell'export (`src/io/esporta.ts`), la ricetta in testo (`src/domain/testo.ts`),
le preferenze (`src/ui/logica-impostazioni.ts`), le righe dell'anteprima
(`src/ui/scambio.ts`). Le schermate leggono e mostrano. L'import esegue lo
stesso piano che l'anteprima ha mostrato, ricalcolato dentro la transazione
con la stessa funzione.

**Tecnologie.** Expo SDK 57, React Native 0.86, TypeScript, `expo-sqlite`,
`expo-file-system`, `expo-sharing`, `expo-document-picker`, `jszip`; nuove:
`expo-clipboard`, `expo-linking`, `expo-constants` (già presente come
dipendenza transitiva, si dichiara). Test con `node --test`.

**Spec di riferimento.** `docs/superpowers/specs/2026-09-16-scambio-ricette-design.md`

## Vincoli globali

- Si lavora in `/Users/paolo/dev/quantobasta`, non sul NAS.
- Nessuna decisione in un `.tsx`: `node --test` non li carica.
- Estensione del file: `quantobasta`. UTI iOS: `it.duebytes.quantobasta.ricettario`.
- Commit e push liberi, in italiano, senza chiedere.
- Ogni task finisce con `npm test` verde e `npx tsc --noEmit` pulito.
- I testi passano da `src/i18n` (it e en) e le chiavi si aggiungono al tipo `Chiave`.
- Dopo l'ultimo task: `npx expo prebuild --platform ios --clean --no-install`,
  archivio firmato, installazione sull'iPhone (procedura in `docs/BUILD.md`).

---

### Task 1: il piano di import, in un modulo puro

**Files:**
- Create: `src/io/piano.ts`, `src/io/piano.test.ts`
- Modify: `src/io/importa.ts` (togliere `vinceIlFile`, usare il piano in `applica`)

**Interfaces:**
- Produces:
  ```ts
  export type Segno = 'nuova' | 'aggiorna' | 'ferma';
  export interface VocePiano { id: string; nome: string; segno: Segno; foto: boolean }
  export interface Piano {
    ricette: VocePiano[];
    categorie: VocePiano[];
    /** Quante ricette e categorie entrano davvero (nuova + aggiorna). */
    entrano: { ricette: number; categorie: number };
  }
  export function vinceIlFile(dalFile, locale): boolean   // spostata da importa.ts
  export function pianoImport(file: RicettarioLetto, localiRicette: Ricetta[], localiCategorie: Categoria[]): Piano
  ```
- `applica` in `importa.ts` resta com'è nella firma; dentro usa `pianoImport`.

- [ ] **Step 1: test del piano**

```ts
// src/io/piano.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { pianoImport, vinceIlFile } from './piano.ts';
import { ICONA_PREDEFINITA } from '../domain/icone.ts';
import type { Categoria, Ricetta } from '../domain/types.ts';
import type { RicettarioLetto } from './formato.ts';

const T0 = '2026-01-01T10:00:00.000Z';
const T1 = '2026-03-01T10:00:00.000Z';

function ricetta(id: string, titolo: string, modificataIl: string, extra: Partial<Ricetta> = {}): Ricetta {
  return {
    id, titolo, descrizione: '', porzioni: 4, categoriaId: null, foto: null,
    gruppi: [], creataIl: T0, modificataIl, cancellataIl: null, ...extra,
  };
}
function categoria(id: string, nome: string, modificataIl: string, extra: Partial<Categoria> = {}): Categoria {
  return {
    id, nome, icona: ICONA_PREDEFINITA, ordine: 0, tinta: null,
    creataIl: T0, modificataIl, cancellataIl: null, ...extra,
  };
}
const file = (ricette: Ricetta[], categorie: Categoria[] = []): RicettarioLetto => ({
  formato: 'quantobasta/ricettario', versione: 2, esportatoIl: T1, ricette, categorie, scartate: 0,
});

test('vince il file se e\' piu\' recente, o se qui e\' una tombstone e nel file e\' viva', () => {
  assert.equal(vinceIlFile({ modificataIl: T1, cancellataIl: null }, { modificataIl: T0, cancellataIl: null }), true);
  assert.equal(vinceIlFile({ modificataIl: T0, cancellataIl: null }, { modificataIl: T1, cancellataIl: null }), false);
  assert.equal(vinceIlFile({ modificataIl: T0, cancellataIl: null }, { modificataIl: T0, cancellataIl: null }), false);
  assert.equal(vinceIlFile({ modificataIl: T0, cancellataIl: null }, { modificataIl: T1, cancellataIl: T1 }), true);
  assert.equal(vinceIlFile({ modificataIl: T0, cancellataIl: T0 }, { modificataIl: T1, cancellataIl: null }), false);
});

test('ogni ricetta del file ha il suo segno, e il totale conta solo chi entra', () => {
  const piano = pianoImport(
    file([
      ricetta('nuova', 'Farinata', T1, { foto: 'a.jpg' }),
      ricetta('vecchia', 'Ciambella', T0),
      ricetta('recente', 'Pasta', T1),
    ]),
    [ricetta('vecchia', 'Ciambella locale', T1), ricetta('recente', 'Pasta locale', T0)],
    [],
  );
  assert.deepEqual(piano.ricette, [
    { id: 'nuova', nome: 'Farinata', segno: 'nuova', foto: true },
    { id: 'vecchia', nome: 'Ciambella', segno: 'ferma', foto: false },
    { id: 'recente', nome: 'Pasta', segno: 'aggiorna', foto: false },
  ]);
  assert.deepEqual(piano.entrano, { ricette: 2, categorie: 0 });
});

test('le tombstone del file non compaiono nell\'anteprima ma contano se cancellano', () => {
  // Una cancellazione che vince cancella qui: non e' una riga da mostrare
  // come «nuova», ma entra. Si mostra con segno 'aggiorna' e nome della locale.
  const piano = pianoImport(
    file([ricetta('x', 'Farinata', T1, { cancellataIl: T1 })]),
    [ricetta('x', 'Farinata', T0)],
    [],
  );
  assert.deepEqual(piano.ricette, [{ id: 'x', nome: 'Farinata', segno: 'aggiorna', foto: false }]);
  // Una tombstone che non vince, o che qui non esiste, non si mostra affatto.
  assert.deepEqual(pianoImport(file([ricetta('y', 'Niente', T0, { cancellataIl: T0 })]), [], []).ricette, []);
});

test('le categorie seguono la stessa regola', () => {
  const piano = pianoImport(
    file([], [categoria('c1', 'Primi', T1), categoria('c2', 'Dolci', T0)]),
    [],
    [categoria('c2', 'Dolci', T1)],
  );
  assert.deepEqual(piano.categorie.map((c) => c.segno), ['nuova', 'ferma']);
  assert.equal(piano.entrano.categorie, 1);
});

test('lo stesso id due volte nel file: conta l\'ultima che vince, una volta sola', () => {
  const piano = pianoImport(
    file([ricetta('r', 'Prima', T0), ricetta('r', 'Seconda', T1)]),
    [],
    [],
  );
  assert.deepEqual(piano.ricette.map((r) => [r.nome, r.segno]), [['Prima', 'nuova'], ['Seconda', 'aggiorna']]);
  assert.equal(piano.entrano.ricette, 1);
});
```

- [ ] **Step 2: eseguire, deve fallire**

Run: `npm test 2>&1 | grep -E "piano|^ℹ fail"`
Expected: errore di modulo mancante su `./piano.ts`.

- [ ] **Step 3: scrivere `src/io/piano.ts`**

```ts
/**
 * Il piano di un import: per ogni voce del file, cosa le succederebbe qui.
 *
 * E' lo stesso confronto che poi l'import esegue, tirato fuori perche'
 * l'anteprima deve dirlo prima di scrivere. La regola vive in un posto solo:
 * anteprima ed esecuzione non possono dire due cose diverse.
 *
 * Il piano si calcola su una fotografia del ricettario locale. L'import lo
 * ricalcola dentro la propria transazione — fra l'anteprima e il tocco su
 * «Importa» puo' essere passata una scrittura — e conta quello che ha
 * eseguito davvero.
 */
import type { Categoria, Ricetta } from '../domain/types.ts';
import type { RicettarioLetto } from './formato.ts';

export type Segno = 'nuova' | 'aggiorna' | 'ferma';

export interface VocePiano {
  id: string;
  nome: string;
  segno: Segno;
  /** Solo per le ricette: la voce porta una foto. */
  foto: boolean;
}

export interface Piano {
  ricette: VocePiano[];
  categorie: VocePiano[];
  /** Quante entrano davvero: nuove piu' aggiornate, ogni id contato una volta. */
  entrano: { ricette: number; categorie: number };
}

interface Datata {
  modificataIl: string;
  cancellataIl: string | null;
}

/**
 * A parita' di id, entra la versione del file?
 *
 * Di regola vince la modificata piu' di recente — date ISO 8601 in UTC, il
 * confronto fra stringhe e' il confronto fra date — e a parita' resta la locale.
 *
 * L'eccezione e' la tombstone locale contro una versione viva nel file: vince
 * il file, sempre. Senza, un backup non poteva riportare indietro niente di
 * cancellato, perche' la cancellazione e' per forza piu' recente del file. E'
 * il caso di chi esporta, cancella per provare «da zero» e reimporta — Paolo,
 * sul telefono, il primo giorno — ed e' il motivo per cui un backup esiste. Il
 * verso opposto non cambia: una tombstone nel file cancella qui solo se e' piu'
 * recente.
 */
export function vinceIlFile(dalFile: Datata, locale: Datata): boolean {
  if (locale.cancellataIl !== null && dalFile.cancellataIl === null) return true;
  return dalFile.modificataIl > locale.modificataIl;
}

/**
 * Il segno di una voce, aggiornando la fotografia locale man mano: lo stesso
 * id due volte nel file si confronta la seconda volta con la prima, non con
 * quello che c'era prima del file. E' la stessa mappa che aggiorna l'import.
 */
function segna<T extends Datata & { id: string }>(
  voci: T[],
  locali: Map<string, T>,
  nome: (v: T) => string,
  foto: (v: T) => boolean,
): { voci: VocePiano[]; entrano: number } {
  const uscita: VocePiano[] = [];
  const entrate = new Set<string>();
  for (const dalFile of voci) {
    const locale = locali.get(dalFile.id);
    let segno: Segno;
    if (locale === undefined) {
      segno = 'nuova';
    } else if (vinceIlFile(dalFile, locale)) {
      segno = 'aggiorna';
    } else {
      segno = 'ferma';
    }
    // Una tombstone che arriva e non trova niente, o che perde, non e' una
    // notizia: non si mostra. Se vince, cancella qui, e questo si mostra.
    if (dalFile.cancellataIl !== null && segno !== 'aggiorna') {
      if (segno === 'nuova') locali.set(dalFile.id, dalFile);
      continue;
    }
    if (segno !== 'ferma') {
      locali.set(dalFile.id, dalFile);
      entrate.add(dalFile.id);
    }
    uscita.push({ id: dalFile.id, nome: nome(dalFile), segno, foto: foto(dalFile) });
  }
  return { voci: uscita, entrano: entrate.size };
}

export function pianoImport(
  file: RicettarioLetto,
  localiRicette: Ricetta[],
  localiCategorie: Categoria[],
): Piano {
  const categorie = segna(
    file.categorie,
    new Map(localiCategorie.map((c) => [c.id, c])),
    (c) => c.nome,
    () => false,
  );
  const ricette = segna(
    file.ricette,
    new Map(localiRicette.map((r) => [r.id, r])),
    (r) => r.titolo,
    (r) => r.foto !== null,
  );
  return {
    ricette: ricette.voci,
    categorie: categorie.voci,
    entrano: { ricette: ricette.entrano, categorie: categorie.entrano },
  };
}
```

Attenzione al test «tombstone che vince»: nel test il nome mostrato è quello
della voce del file (`'Farinata'`), che coincide con la locale. Va bene così:
la voce del file porta il titolo anche da tombstone.

- [ ] **Step 4: eseguire, deve passare**

Run: `npm test 2>&1 | grep -E "^ℹ (pass|fail)"`
Expected: `fail 0`.

- [ ] **Step 5: `importa.ts` usa il piano**

In `src/io/importa.ts`:
- togliere la funzione `vinceIlFile` e il suo commento;
- aggiungere `import { vinceIlFile } from './piano.ts';`
- nessun'altra modifica: `applica` continua a usare `vinceIlFile` riga per
  riga con la mappa aggiornata, che è esattamente ciò che `segna` replica. Il
  legame fra i due è il test seguente.

Aggiungere in fondo a `src/io/importa.test.ts`:

```ts
test('l\'esito dell\'import e\' quello che il piano aveva annunciato', async () => {
  // Il piano lo mostra l'anteprima, l'import lo esegue: devono contare uguale,
  // anche con duplicati e tombstone nel file.
  const locali = [ricetta('a', 'A locale', '2026-03-01T10:00:00.000Z'), ricetta('b', 'B locale', '2026-01-01T10:00:00.000Z')];
  const db = await dbCon(locali);
  const dalFile = [
    ricetta('a', 'A vecchia', '2026-01-01T10:00:00.000Z'),
    ricetta('b', 'B nuova', '2026-06-01T10:00:00.000Z'),
    ricetta('c', 'C', '2026-06-01T10:00:00.000Z'),
    ricetta('c', 'C bis', '2026-07-01T10:00:00.000Z'),
    ricetta('d', 'D', '2026-06-01T10:00:00.000Z', { cancellataIl: '2026-06-01T10:00:00.000Z' }),
  ];
  const testoFile = comeFile(dalFile);
  const piano = pianoImport(validaFile(JSON.parse(testoFile))!, locali, []);
  const esito = await importaJson(db, testoFile);
  assert.ok(esito.ok);
  assert.equal(esito.aggiunte + esito.aggiornate, piano.entrano.ricette + 1); // +1: la tombstone 'd' entra ma non si mostra
});
```

con gli import `import { pianoImport } from './piano.ts';` e
`import { FORMATO, validaFile } from './formato.ts';` (FORMATO è già importato:
si estende quella riga).

- [ ] **Step 6: test e tsc**

Run: `npm test 2>&1 | grep -E "^ℹ (pass|fail)" && npx tsc --noEmit && echo ok`
Expected: `fail 0`, `ok`.

- [ ] **Step 7: commit**

```bash
git add src/io/piano.ts src/io/piano.test.ts src/io/importa.ts src/io/importa.test.ts
git commit -m "Il piano di import: la regola in un posto solo, per l'anteprima e per l'esecuzione"
```

---

### Task 2: export con selezione e nome del file

**Files:**
- Modify: `src/io/esporta.ts`, `src/io/esporta.test.ts`
- Modify: `src/ui/componenti/BarraIo.tsx` (solo la chiamata, per compilare)

**Interfaces:**
- Produces:
  ```ts
  export type Selezione = { tipo: 'tutto' } | { tipo: 'categoria'; id: string } | { tipo: 'ricetta'; id: string };
  export function contenutoExport(ricette: Ricetta[], categorie: Categoria[], selezione: Selezione): { ricette: Ricetta[]; categorie: Categoria[] }
  export function nomeFile(selezione: Selezione, nome: string | null, adesso: string): string
  export async function esporta(db: SQLiteDatabase, selezione: Selezione): Promise<EsitoExport>
  ```
  `EsitoExport` guadagna `nome: string` (il nome del file scritto).

- [ ] **Step 1: test**

In `src/io/esporta.test.ts` aggiungere (le fabbriche `ricetta`/`categoria` e le
costanti ci sono già nel file: riusarle):

```ts
test('tutto: escono tutte, anche le tombstone, con le categorie', () => {
  const r = [ricettaViva('r1', 'c1'), ricettaTombstone('r2')];
  const c = [primi];
  const dentro = contenutoExport(r, c, { tipo: 'tutto' });
  assert.deepEqual(dentro.ricette.map((x) => x.id), ['r1', 'r2']);
  assert.deepEqual(dentro.categorie.map((x) => x.id), ['c-primi']);
});

test('una categoria: solo le sue ricette vive, e solo lei', () => {
  const r = [ricettaViva('r1', 'c-primi'), ricettaViva('r2', 'altra'), ricettaViva('r3', null), ricettaTombstone('r4', 'c-primi')];
  const dentro = contenutoExport(r, [primi, altra], { tipo: 'categoria', id: 'c-primi' });
  assert.deepEqual(dentro.ricette.map((x) => x.id), ['r1']);
  assert.deepEqual(dentro.categorie.map((x) => x.id), ['c-primi']);
});

test('una ricetta: esce senza categoria e senza l\'elenco delle categorie', () => {
  const dentro = contenutoExport([ricettaViva('r1', 'c-primi'), ricettaViva('r2', 'c-primi')], [primi], { tipo: 'ricetta', id: 'r1' });
  assert.equal(dentro.ricette.length, 1);
  assert.equal(dentro.ricette[0].categoriaId, null);
  assert.deepEqual(dentro.categorie, []);
});

test('il nome del file dice cosa contiene, e non ha caratteri vietati', () => {
  assert.equal(nomeFile({ tipo: 'tutto' }, null, '2026-09-16T10:00:00.000Z'), 'Ricettario 2026-09-16.quantobasta');
  assert.equal(nomeFile({ tipo: 'categoria', id: 'x' }, 'Primi', ''), 'Primi.quantobasta');
  assert.equal(nomeFile({ tipo: 'ricetta', id: 'x' }, 'Pasta: aglio/olio', ''), 'Pasta  aglio olio.quantobasta');
  // Senza nome (cancellata nel frattempo) si torna al nome del ricettario.
  assert.equal(nomeFile({ tipo: 'ricetta', id: 'x' }, null, '2026-09-16T10:00:00.000Z'), 'Ricettario 2026-09-16.quantobasta');
  assert.equal(nomeFile({ tipo: 'ricetta', id: 'x' }, '   ', '2026-09-16T10:00:00.000Z'), 'Ricettario 2026-09-16.quantobasta');
});
```

Definire in cima al test, accanto a `primi`:

```ts
const altra: Categoria = { ...primi, id: 'altra', nome: 'Altra' };
const ricettaViva = (id: string, categoriaId: string | null): Ricetta => ({
  id, titolo: id, descrizione: '', porzioni: null, categoriaId, foto: null, gruppi: [],
  creataIl: '2026-01-01T00:00:00.000Z', modificataIl: '2026-01-01T00:00:00.000Z', cancellataIl: null,
});
const ricettaTombstone = (id: string, categoriaId: string | null = null): Ricetta =>
  ({ ...ricettaViva(id, categoriaId), cancellataIl: '2026-02-01T00:00:00.000Z' });
```

e importare `contenutoExport, nomeFile` da `./esporta.ts`.

- [ ] **Step 2: eseguire, deve fallire**

Run: `npm test 2>&1 | grep -E "esporta|^ℹ fail" | head`
Expected: fallimento per export mancanti.

- [ ] **Step 3: implementare**

In `src/io/esporta.ts`, dopo `creaFile`:

```ts
/** Cosa si vuole nel file: tutto, una categoria, una ricetta sola. */
export type Selezione =
  | { tipo: 'tutto' }
  | { tipo: 'categoria'; id: string }
  | { tipo: 'ricetta'; id: string };

/** L'estensione con cui il sistema riconosce il file come nostro. */
export const ESTENSIONE = 'quantobasta';

/**
 * Cosa finisce nel file, data la selezione.
 *
 * Tutto: anche le tombstone e tutte le categorie, perche' e' una copia e
 * reimportata altrove deve portarsi dietro anche le cancellazioni.
 *
 * Una categoria: le sue ricette vive e lei sola. E' una copia parziale, e le
 * tombstone di altre categorie non c'entrano.
 *
 * Una ricetta: senza categoria e senza elenco delle categorie. Chi la riceve
 * non ha le mie categorie e non deve trovarsele create: la mette lui dove
 * vuole. La regola sta qui, in chi scrive; l'import applica quello che trova.
 */
export function contenutoExport(
  ricette: Ricetta[],
  categorie: Categoria[],
  selezione: Selezione,
): { ricette: Ricetta[]; categorie: Categoria[] } {
  switch (selezione.tipo) {
    case 'tutto':
      return { ricette, categorie };
    case 'categoria':
      return {
        ricette: ricette.filter((r) => r.categoriaId === selezione.id && r.cancellataIl === null),
        categorie: categorie.filter((c) => c.id === selezione.id),
      };
    case 'ricetta':
      return {
        ricette: ricette
          .filter((r) => r.id === selezione.id && r.cancellataIl === null)
          .map((r) => ({ ...r, categoriaId: null })),
        categorie: [],
      };
  }
}

/**
 * Il nome del file: dice cosa contiene, cosi' si riconosce nei Download. I
 * caratteri che un file system rifiuta diventano spazi. Senza un nome (la
 * ricetta o la categoria e' sparita nel frattempo) si torna a quello del
 * ricettario intero, che e' sempre vero.
 */
export function nomeFile(selezione: Selezione, nome: string | null, adesso: string): string {
  const pulito = nome === null ? '' : nome.replace(/[/\\:*?"<>|]/g, ' ').trim();
  const base = selezione.tipo === 'tutto' || pulito === '' ? `Ricettario ${adesso.slice(0, 10)}` : pulito;
  return `${base}.${ESTENSIONE}`;
}
```

Poi `EsitoExport` guadagna `/** Il nome del file scritto, da mostrare. */ nome: string;`
e `esporta` diventa:

```ts
export async function esporta(db: SQLiteDatabase, selezione: Selezione): Promise<EsitoExport> {
  const adesso = new Date().toISOString();
  const tutte = await ricetteDaEsportare(db);
  const tutteCat = await categorieDaEsportare(db);
  const dentro = contenutoExport(tutte, tutteCat, selezione);
  const file = creaFile(dentro.ricette, dentro.categorie, adesso);

  const nomeScelto =
    selezione.tipo === 'categoria'
      ? (tutteCat.find((c) => c.id === selezione.id)?.nome ?? null)
      : selezione.tipo === 'ricetta'
        ? (tutte.find((r) => r.id === selezione.id)?.titolo ?? null)
        : null;
  const nome = nomeFile(selezione, nomeScelto, adesso);

  const { Directory, File, Paths } = await import('expo-file-system');
  const cartella = new Directory(Paths.document, CARTELLA_FOTO);
  const foto = new Map<string, Uint8Array>();
  for (const n of fotoDaCercare(file.ricette)) {
    const immagine = new File(cartella, n);
    if (immagine.exists) foto.set(n, immagine.bytesSync());
  }

  const uscita = new File(Paths.cache, nome);
  uscita.create({ overwrite: true });
  uscita.write(await creaArchivio(file, foto));

  return { uri: uscita.uri, nome, ricette: ricetteAnnunciate(file.ricette), foto: foto.size };
}
```

(conservare i commenti esistenti sul caricamento dinamico di expo-file-system
e sulla foto assente).

In `src/ui/componenti/BarraIo.tsx` la chiamata diventa
`await esporta(db, { tipo: 'tutto' })`. Nel `Sharing.shareAsync` di `condividi`
cambiare `UTI: 'public.zip-archive'` in `UTI: 'it.duebytes.quantobasta.ricettario'`.

- [ ] **Step 4: test e tsc**

Run: `npm test 2>&1 | grep -E "^ℹ (pass|fail)" && npx tsc --noEmit && echo ok`
Expected: `fail 0`, `ok`.

- [ ] **Step 5: commit**

```bash
git add src/io/esporta.ts src/io/esporta.test.ts src/ui/componenti/BarraIo.tsx
git commit -m "Export con selezione: tutto, una categoria, una ricetta; e il file si chiama .quantobasta"
```

---

### Task 3: la ricetta in testo

**Files:**
- Create: `src/domain/testo.ts`, `src/domain/testo.test.ts`

**Interfaces:**
- Produces: `export function ricettaInTesto(ricetta: Ricetta, voc: Vocabolario): string`

Il testo deve rientrare dal parser (`leggiBloccoMultilingua`) con gli stessi
gruppi, nomi, quantità e unità. Questo fissa il formato:

```
Farinata
Per 4 porzioni

Ingredienti
250 g farina di ceci
750 ml acqua
sale q.b.

Impasto          ← nome di un gruppo, se c'è
2 uova

Procedimento
Mescolare...
```

Regole: titolo sulla prima riga; «Per N porzioni» / «Serves N» se le porzioni
ci sono; riga vuota; l'intestazione neutra della lingua (`voc.intestazioneNeutra[0]`,
con l'iniziale maiuscola); i gruppi con nome vanno con il nome su una riga
sola; le quantità con `formattaQuantita`; il procedimento sotto la prima parola
di stop della lingua (`voc.stop[0]`, maiuscola) solo se la descrizione non è
vuota.

- [ ] **Step 1: test**

```ts
// src/domain/testo.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { ricettaInTesto } from './testo.ts';
import { leggiBloccoMultilingua } from './parser/blocco.ts';
import { vocabolario } from './lingua/index.ts';
import type { Ricetta } from './types.ts';

const farinata: Ricetta = {
  id: 'r', titolo: 'Farinata', descrizione: 'Mescolare e cuocere.', porzioni: 4, categoriaId: null, foto: null,
  gruppi: [
    { id: 'g1', nome: null, ingredienti: [
      { id: 'i1', nome: 'farina di ceci', quantita: 250, unita: 'g' },
      { id: 'i2', nome: 'acqua', quantita: 750, unita: 'ml' },
      { id: 'i3', nome: 'sale', quantita: null, unita: null },
    ] },
    { id: 'g2', nome: 'Condimento', ingredienti: [
      { id: 'i4', nome: 'rosmarino', quantita: 1.5, unita: 'cucchiaio' },
    ] },
  ],
  creataIl: '', modificataIl: '', cancellataIl: null,
};

test('il testo e\' leggibile e ordinato come lo scriverebbe una persona', () => {
  assert.equal(ricettaInTesto(farinata, vocabolario('it')), [
    'Farinata',
    'Per 4 porzioni',
    '',
    'Ingredienti',
    '250 g farina di ceci',
    '750 ml acqua',
    'sale q.b.',
    '',
    'Condimento',
    '1 ½ cucchiai rosmarino',
    '',
    'Procedimento',
    'Mescolare e cuocere.',
  ].join('\n'));
});

test('quello che scrive rientra dal parser con gli stessi ingredienti', () => {
  for (const lingua of ['it', 'en'] as const) {
    const esito = leggiBloccoMultilingua(ricettaInTesto(farinata, vocabolario(lingua)));
    assert.equal(esito.lingua, lingua);
    const letti = esito.gruppi.map((g) => ({
      nome: g.nome,
      ingredienti: g.ingredienti.map((i) => [i.nome.toLowerCase(), i.quantita, i.unita]),
    }));
    assert.deepEqual(letti, [
      { nome: null, ingredienti: [['farina di ceci', 250, 'g'], ['acqua', 750, 'ml'], ['sale', null, null]] },
      { nome: 'Condimento', ingredienti: [['rosmarino', 1.5, lingua === 'it' ? 'cucchiaio' : 'tbsp']] },
    ]);
  }
});

test('senza porzioni e senza procedimento restano solo titolo e ingredienti', () => {
  const nuda = { ...farinata, porzioni: null, descrizione: '  ', gruppi: [farinata.gruppi[0]] };
  const righe = ricettaInTesto(nuda, vocabolario('it')).split('\n');
  assert.equal(righe[0], 'Farinata');
  assert.equal(righe[1], '');
  assert.ok(!righe.includes('Procedimento'));
  assert.ok(!righe.some((r) => r.startsWith('Per ')));
});
```

Nota: il secondo test dipende da come `formattaQuantita` scrive «1 ½ cucchiai»
e da come il parser rilegge la frazione e accorda l'unità. Se `unita` rientra
come `'cucchiai'` (plurale) invece di `'cucchiaio'`, adeguare l'atteso a
quello che il parser restituisce oggi per `"1 ½ cucchiai rosmarino"`: si
verifica con `npx tsx -e` prima di fissare il test. Lo stesso per l'inglese
(`tbsp`). Quello che non si negozia è la quantità `1.5` e il nome.

- [ ] **Step 2: eseguire, deve fallire**

Run: `npm test 2>&1 | grep -E "testo|^ℹ fail" | head`

- [ ] **Step 3: implementare**

```ts
// src/domain/testo.ts
/**
 * La ricetta scritta in chiaro, come la scriverebbe una persona: e' quello
 * che finisce negli appunti con «Copia come testo».
 *
 * E' il contrario del parser, e il test lo tiene onesto: quello che questa
 * funzione scrive deve rientrare da `leggiBlocco` con gli stessi ingredienti.
 * Per questo le intestazioni sono quelle che il parser conosce — la neutra
 * («Ingredienti») e la parola di stop («Procedimento») del vocabolario — e le
 * quantita' passano da `formattaQuantita`, che scrive nella forma che
 * `leggiRiga` sa leggere.
 */
import { formattaQuantita } from './format.ts';
import type { Vocabolario } from './lingua/index.ts';
import type { Ricetta } from './types.ts';

const PORZIONI = {
  it: (n: number) => `Per ${n} porzioni`,
  en: (n: number) => `Serves ${n}`,
} as const;

const maiuscola = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);

export function ricettaInTesto(ricetta: Ricetta, voc: Vocabolario): string {
  const righe: string[] = [ricetta.titolo];
  if (ricetta.porzioni !== null) righe.push(PORZIONI[voc.lingua](ricetta.porzioni));
  righe.push('');

  righe.push(maiuscola(voc.intestazioneNeutra[0]));
  ricetta.gruppi.forEach((gruppo, indice) => {
    if (gruppo.nome !== null) {
      if (indice > 0) righe.push('');
      righe.push(gruppo.nome);
    }
    for (const i of gruppo.ingredienti) {
      // «sale q.b.» e non «q.b. sale»: il parser legge la quantita' in testa
      // alla riga, e un «q.b.» in testa lo tratterebbe come nome.
      righe.push(i.quantita === null
        ? `${i.nome} ${formattaQuantita(null, null, voc)}`
        : `${formattaQuantita(i.quantita, i.unita, voc)} ${i.nome}`);
    }
  });

  const procedimento = ricetta.descrizione.trim();
  if (procedimento !== '') {
    righe.push('', maiuscola(voc.stop[0]), procedimento);
  }
  return righe.join('\n');
}
```

Verificare su `src/domain/lingua/it.ts` e `en.ts` che `intestazioneNeutra[0]`
sia `'ingredienti'` / `'ingredients'` e `stop[0]` sia `'procedimento'` /
`'directions'` o simile; se l'ordine è diverso, usare l'indice della parola
giusta e dirlo nel commento.

- [ ] **Step 4: test e tsc**

Run: `npm test 2>&1 | grep -E "^ℹ (pass|fail)" && npx tsc --noEmit && echo ok`

- [ ] **Step 5: commit**

```bash
git add src/domain/testo.ts src/domain/testo.test.ts
git commit -m "La ricetta in testo, il contrario del parser, per «Copia come testo»"
```

---

### Task 4: le preferenze di tema e lingua, e il contesto che le applica

**Files:**
- Create: `src/ui/logica-impostazioni.ts`, `src/ui/logica-impostazioni.test.ts`
- Modify: `src/ui/tema.ts` (`useColori`, `useTinte` leggono il tema forzato), `src/ui/contesto.ts`, `src/ui/App.tsx`

**Interfaces:**
- Produces:
  ```ts
  export type Tema = 'sistema' | 'chiaro' | 'scuro';
  export type SceltaLingua = 'sistema' | Lingua;
  export const CHIAVE_TEMA = 'tema'; export const CHIAVE_LINGUA = 'lingua';
  export const TEMI: Tema[]; export const SCELTE_LINGUA: SceltaLingua[];
  export function temaSalvato(valore: string | null): Tema          // 'sistema' se sconosciuto
  export function linguaSalvata(valore: string | null): SceltaLingua
  export function schemaEffettivo(tema: Tema, sistema: 'light' | 'dark' | null | undefined): 'light' | 'dark'
  export function linguaEffettiva(scelta: SceltaLingua, sistema: Lingua): Lingua
  ```
- `StatoApp` guadagna `tema: Tema`, `sceltaLingua: SceltaLingua`,
  `impostaTema(t: Tema): Promise<void>`, `impostaLingua(s: SceltaLingua): Promise<void>`.
- `tema.ts` esporta `ContestoSchema = createContext<'light' | 'dark' | null>(null)`.

- [ ] **Step 1: test**

```ts
// src/ui/logica-impostazioni.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';

import {
  linguaEffettiva, linguaSalvata, schemaEffettivo, temaSalvato, TEMI, SCELTE_LINGUA,
} from './logica-impostazioni.ts';

test('un valore salvato sconosciuto vale «come il telefono»', () => {
  assert.equal(temaSalvato(null), 'sistema');
  assert.equal(temaSalvato('scuro'), 'scuro');
  assert.equal(temaSalvato('viola'), 'sistema');
  assert.equal(linguaSalvata(null), 'sistema');
  assert.equal(linguaSalvata('en'), 'en');
  assert.equal(linguaSalvata('de'), 'sistema');
});

test('il tema scelto vince sul sistema, «sistema» lo segue, e senza sistema e\' chiaro', () => {
  assert.equal(schemaEffettivo('scuro', 'light'), 'dark');
  assert.equal(schemaEffettivo('chiaro', 'dark'), 'light');
  assert.equal(schemaEffettivo('sistema', 'dark'), 'dark');
  assert.equal(schemaEffettivo('sistema', null), 'light');
  assert.equal(schemaEffettivo('sistema', undefined), 'light');
});

test('la lingua scelta vince sul sistema', () => {
  assert.equal(linguaEffettiva('en', 'it'), 'en');
  assert.equal(linguaEffettiva('sistema', 'it'), 'it');
});

test('le liste per la pagina hanno «sistema» per primo', () => {
  assert.equal(TEMI[0], 'sistema');
  assert.equal(SCELTE_LINGUA[0], 'sistema');
  assert.deepEqual(SCELTE_LINGUA.slice(1), ['it', 'en']);
});
```

- [ ] **Step 2: eseguire, deve fallire**

- [ ] **Step 3: implementare**

```ts
// src/ui/logica-impostazioni.ts
/**
 * Le due preferenze della pagina Impostazioni: tema e lingua.
 *
 * Stanno nella tabella `impostazioni` come testo, con le chiavi qui sotto.
 * Un valore assente o sconosciuto vale «come il telefono»: e' il punto di
 * partenza, e un file di preferenze scritto da una versione futura non deve
 * rompere quella di oggi.
 */
import type { Lingua } from '../domain/lingua/index.ts';
import { LINGUE } from '../domain/lingua/index.ts';

export type Tema = 'sistema' | 'chiaro' | 'scuro';
export type SceltaLingua = 'sistema' | Lingua;

export const CHIAVE_TEMA = 'tema';
export const CHIAVE_LINGUA = 'lingua';

export const TEMI: Tema[] = ['sistema', 'chiaro', 'scuro'];
export const SCELTE_LINGUA: SceltaLingua[] = ['sistema', ...LINGUE];

export function temaSalvato(valore: string | null): Tema {
  return (TEMI as string[]).includes(valore ?? '') ? (valore as Tema) : 'sistema';
}

export function linguaSalvata(valore: string | null): SceltaLingua {
  return (SCELTE_LINGUA as string[]).includes(valore ?? '') ? (valore as SceltaLingua) : 'sistema';
}

/** Lo schema di colori da usare: la scelta, o quello del sistema, o chiaro. */
export function schemaEffettivo(
  tema: Tema,
  sistema: 'light' | 'dark' | null | undefined,
): 'light' | 'dark' {
  if (tema === 'chiaro') return 'light';
  if (tema === 'scuro') return 'dark';
  return sistema === 'dark' ? 'dark' : 'light';
}

export function linguaEffettiva(scelta: SceltaLingua, sistema: Lingua): Lingua {
  return scelta === 'sistema' ? sistema : scelta;
}
```

- [ ] **Step 4: test**

Run: `npm test 2>&1 | grep -E "^ℹ (pass|fail)"` → `fail 0`.

- [ ] **Step 5: `tema.ts` legge il tema forzato**

In `src/ui/tema.ts`:

```ts
import { createContext, useContext } from 'react';
// ...
/**
 * Lo schema deciso dalle impostazioni, o null per seguire il sistema. Lo
 * fornisce App.tsx; fuori dal provider (la schermata di attesa all'avvio)
 * si segue il sistema, che e' anche l'unica cosa che si sa a quel punto.
 */
export const ContestoSchema = createContext<'light' | 'dark' | null>(null);

function useSchema(): 'light' | 'dark' {
  const forzato = useContext(ContestoSchema);
  const sistema = useColorScheme();
  return forzato ?? (sistema === 'dark' ? 'dark' : 'light');
}

export function useColori(): Colori {
  return useSchema() === 'dark' ? SCURO : CHIARO;
}

export function useTinte(): readonly Tinta[] {
  return useSchema() === 'dark' ? TINTE_SCURE : TINTE_CHIARE;
}
```

- [ ] **Step 6: `contesto.ts` e `App.tsx`**

`StatoApp` in `src/ui/contesto.ts`:

```ts
export interface StatoApp {
  db: SQLiteDatabase;
  lingua: Lingua;
  testo: (chiave: Chiave, valori?: Record<string, string>) => string;
  tema: Tema;
  sceltaLingua: SceltaLingua;
  impostaTema: (tema: Tema) => Promise<void>;
  impostaLingua: (scelta: SceltaLingua) => Promise<void>;
}
```

con `import type { SceltaLingua, Tema } from './logica-impostazioni.ts';`.

In `src/ui/App.tsx`:
- togliere `const lingua = useMemo(() => linguaDispositivo(), []);`
- aggiungere stato `const [tema, setTema] = useState<Tema>('sistema');` e
  `const [sceltaLingua, setSceltaLingua] = useState<SceltaLingua>('sistema');`
- nella `then` di `apriDb()`, prima di `setFase`, leggere le due preferenze:
  ```ts
  .then(async (db) => {
    const [t, l] = await Promise.all([
      leggiImpostazione(db, CHIAVE_TEMA),
      leggiImpostazione(db, CHIAVE_LINGUA),
    ]);
    if (!vivo) return;
    setTema(temaSalvato(t));
    setSceltaLingua(linguaSalvata(l));
    setFase({ nome: 'pronto', db });
  })
  ```
- `const lingua = linguaEffettiva(sceltaLingua, linguaDispositivo());`
- nel `contesto` aggiungere `tema, sceltaLingua` e le due funzioni:
  ```ts
  impostaTema: async (t) => { setTema(t); await salvaImpostazione(fase.db, CHIAVE_TEMA, t); },
  impostaLingua: async (s) => { setSceltaLingua(s); await salvaImpostazione(fase.db, CHIAVE_LINGUA, s); },
  ```
  (dipendenze del `useMemo`: `[fase, lingua, tema, sceltaLingua]`).
- `const sistema = useColorScheme();` e
  `const schema = schemaEffettivo(tema, sistema);`; avvolgere il render finale
  (e anche i due render di attesa/errore, per coerenza) in
  `<ContestoSchema.Provider value={schema}>`. Attenzione: `useColori()` in
  App.tsx va chiamato **dentro** un componente figlio del provider per vedere
  il tema forzato; il modo più corto è spostare il corpo attuale di `App` in
  un componente `Radice` che riceve `schema` e fa il resto, e `App` diventa:
  ```tsx
  export default function App() {
    // ...stati e apertura db...
    return (
      <ContestoSchema.Provider value={schema}>
        <Radice fase={fase} contesto={contesto} fontPronto={fontPronto} erroreFont={erroreFont} lingua={lingua} />
      </ContestoSchema.Provider>
    );
  }
  ```
  dove `Radice` contiene gli `if` di attesa/errore e il `NavigationContainer`,
  e chiama `useColori()` lei.
- `contentStyle: { backgroundColor: colori.fondo }` nel navigatore resta.

- [ ] **Step 7: tsc, test, e prova a occhio**

Run: `npx tsc --noEmit && npm test 2>&1 | grep -E "^ℹ (pass|fail)"`
Expected: pulito, `fail 0`. Nel simulatore l'app parte come prima (nessuna
impostazione salvata = come il telefono).

- [ ] **Step 8: commit**

```bash
git add src/ui/logica-impostazioni.ts src/ui/logica-impostazioni.test.ts src/ui/tema.ts src/ui/contesto.ts src/ui/App.tsx
git commit -m "Tema e lingua come preferenze: lette all'avvio, cambiate a caldo"
```

---

### Task 5: il segno «ingranaggio» e i testi nuovi

**Files:**
- Modify: `scripts/genera-segni.py`, `src/ui/segni.ts`, `src/ui/componenti/Segno.tsx`
- Create: `assets/segni/ingranaggio.png` (generato)
- Modify: `src/i18n/index.ts`, `src/i18n/it.ts`, `src/i18n/en.ts`
- Modify: `docs/DESIGN.md` (il numero dei segni: da dodici a tredici)

- [ ] **Step 1: il segno**

In `scripts/genera-segni.py`, prima di `SEGNI = {`:

```python
def ingranaggio(d):
    """Ingranaggio: un cerchio e otto denti corti. E' «impostazioni».

    I denti sono segmenti radiali e non un profilo dentato: a 26 pixel un
    profilo si impasta, otto trattini restano otto trattini.
    """
    import math
    cerchio(d, 16, 16, 5.5)
    for k in range(8):
        a = k * math.pi / 4
        linea(d, (16 + 8.5 * math.cos(a), 16 + 8.5 * math.sin(a)),
                 (16 + 12 * math.cos(a), 16 + 12 * math.sin(a)))
```

e nella mappa `SEGNI` aggiungere `'ingranaggio': ingranaggio,`. Aggiornare il
commento `# --- gli undici segni` in `# --- i tredici segni`.

Run: `python3 scripts/genera-segni.py`
Expected: `13 segni in .../assets/segni`, con `ingranaggio.png` fra le righe.

Guardarlo: `open assets/segni/ingranaggio.png`. Se i denti si impastano,
allungare il raggio esterno a 12.5.

- [ ] **Step 2: catalogo e componente**

In `src/ui/segni.ts` aggiungere `'ingranaggio',` nell'array (in ordine
alfabetico, fra `'indietro'` e `'libro'`). In `src/ui/componenti/Segno.tsx`
aggiungere la riga
`ingranaggio: require('../../../assets/segni/ingranaggio.png'),`
nella stessa posizione.

Run: `npm test 2>&1 | grep -E "segni|^ℹ fail"` → il test dei segni deve
restare verde (lega nomi, file e require).

- [ ] **Step 3: le chiavi di testo**

In `src/i18n/index.ts`, nel tipo `Chiave`, aggiungere:

```ts
  | 'impostazioni.titolo' | 'impostazioni.aspetto' | 'impostazioni.tema'
  | 'impostazioni.tema.sistema' | 'impostazioni.tema.chiaro' | 'impostazioni.tema.scuro'
  | 'impostazioni.lingua' | 'impostazioni.lingua.sistema'
  | 'impostazioni.lingua.it' | 'impostazioni.lingua.en'
  | 'impostazioni.ricette' | 'impostazioni.esporta' | 'impostazioni.importa'
  | 'impostazioni.esporta.tutte' | 'impostazioni.esporta.scegli'
  | 'impostazioni.info' | 'impostazioni.privacy' | 'impostazioni.versione'
  | 'anteprima.titolo' | 'anteprima.categorie'
  | 'anteprima.nuova' | 'anteprima.aggiorna' | 'anteprima.ferma' | 'anteprima.foto'
  | 'anteprima.importa.zero' | 'anteprima.importa.ricette' | 'anteprima.importa.categorie'
  | 'anteprima.importa.entrambe'
  | 'dosatore.altro' | 'dosatore.condividi' | 'dosatore.copia' | 'dosatore.copiata'
```

In `src/i18n/it.ts`:

```ts
  'impostazioni.titolo': 'Impostazioni',
  'impostazioni.aspetto': 'Aspetto',
  'impostazioni.tema': 'Tema',
  'impostazioni.tema.sistema': 'Come il telefono',
  'impostazioni.tema.chiaro': 'Chiaro',
  'impostazioni.tema.scuro': 'Scuro',
  'impostazioni.lingua': 'Lingua',
  'impostazioni.lingua.sistema': 'Come il telefono',
  'impostazioni.lingua.it': 'Italiano',
  'impostazioni.lingua.en': 'English',
  'impostazioni.ricette': 'Ricette',
  'impostazioni.esporta': 'Esporta…',
  'impostazioni.importa': 'Importa…',
  'impostazioni.esporta.tutte': 'Tutte le ricette',
  'impostazioni.esporta.scegli': 'Cosa vuoi esportare?',
  'impostazioni.info': 'Informazioni',
  'impostazioni.privacy': 'Informativa sulla privacy',
  'impostazioni.versione': 'Quanto Basta {versione} ({build})',
  'anteprima.titolo': 'Importa',
  'anteprima.categorie': 'Categorie',
  'anteprima.nuova': 'nuova',
  'anteprima.aggiorna': 'aggiorna la tua',
  'anteprima.ferma': 'già aggiornata',
  'anteprima.foto': 'con foto',
  'anteprima.importa.zero': 'Niente da importare',
  'anteprima.importa.ricette': 'Importa {ricette}',
  'anteprima.importa.categorie': 'Importa {categorie}',
  'anteprima.importa.entrambe': 'Importa {ricette} e {categorie}',
  'dosatore.altro': 'Altro',
  'dosatore.condividi': 'Condividi ricetta',
  'dosatore.copia': 'Copia come testo',
  'dosatore.copiata': 'Copiata',
```

e in `src/i18n/en.ts`:

```ts
  'impostazioni.titolo': 'Settings',
  'impostazioni.aspetto': 'Appearance',
  'impostazioni.tema': 'Theme',
  'impostazioni.tema.sistema': 'Same as phone',
  'impostazioni.tema.chiaro': 'Light',
  'impostazioni.tema.scuro': 'Dark',
  'impostazioni.lingua': 'Language',
  'impostazioni.lingua.sistema': 'Same as phone',
  'impostazioni.lingua.it': 'Italiano',
  'impostazioni.lingua.en': 'English',
  'impostazioni.ricette': 'Recipes',
  'impostazioni.esporta': 'Export…',
  'impostazioni.importa': 'Import…',
  'impostazioni.esporta.tutte': 'All recipes',
  'impostazioni.esporta.scegli': 'What do you want to export?',
  'impostazioni.info': 'About',
  'impostazioni.privacy': 'Privacy policy',
  'impostazioni.versione': 'Quanto Basta {versione} ({build})',
  'anteprima.titolo': 'Import',
  'anteprima.categorie': 'Categories',
  'anteprima.nuova': 'new',
  'anteprima.aggiorna': 'updates yours',
  'anteprima.ferma': 'already up to date',
  'anteprima.foto': 'with photo',
  'anteprima.importa.zero': 'Nothing to import',
  'anteprima.importa.ricette': 'Import {ricette}',
  'anteprima.importa.categorie': 'Import {categorie}',
  'anteprima.importa.entrambe': 'Import {ricette} and {categorie}',
  'dosatore.altro': 'More',
  'dosatore.condividi': 'Share recipe',
  'dosatore.copia': 'Copy as text',
  'dosatore.copiata': 'Copied',
```

I segnaposto `{ricette}` e `{categorie}` ricevono già la forma contata
(«2 ricette», «1 categoria»): quelle forme esistono, sono
`elenco.dato.ricette.*` o simili — verificare in `it.ts` le chiavi con
`ricett` e `categor` che contano, e riusarle in `scambio.ts` (Task 7). Se non
esistono forme «N ricette / una ricetta», aggiungere
`'conta.ricette.una': 'una ricetta'`, `'conta.ricette.molte': '{n} ricette'`,
`'conta.categorie.una': 'una categoria'`, `'conta.categorie.molte': '{n} categorie'`
(en: `'one recipe'`, `'{n} recipes'`, `'one category'`, `'{n} categories'`) e le
relative voci nel tipo.

In `docs/DESIGN.md` cercare «dodici» accanto a «segni» e portarlo a tredici,
aggiungendo `ingranaggio` all'elenco.

- [ ] **Step 4: tsc e test**

Run: `npx tsc --noEmit && npm test 2>&1 | grep -E "^ℹ (pass|fail)"`
(il test di i18n, se controlla che it ed en abbiano le stesse chiavi, deve
restare verde).

- [ ] **Step 5: commit**

```bash
git add scripts/genera-segni.py assets/segni/ingranaggio.png src/ui/segni.ts src/ui/componenti/Segno.tsx src/i18n docs/DESIGN.md
git commit -m "Il segno ingranaggio e i testi di impostazioni, anteprima e condivisione"
```

---

### Task 6: la pagina Impostazioni, e la home perde export e import

**Files:**
- Create: `src/ui/schermate/Impostazioni.tsx`
- Modify: `src/ui/navigazione.ts` (rotta `Impostazioni: undefined`), `src/ui/App.tsx` (Stack.Screen), `src/ui/schermate/Categorie.tsx` (ingranaggio al posto di `BarraIo`)
- Modify: `package.json` (`npx expo install expo-constants`)
- Modify: `src/ui/componenti/BarraIo.tsx` → il suo contenuto migra in Impostazioni; il file si cancella nel Task 8.

**Interfaces:**
- Consumes: `esporta(db, selezione)` (Task 2), `StatoApp.impostaTema/impostaLingua` (Task 4), `Segno "ingranaggio"` (Task 5).
- Produces: la rotta `Impostazioni`; la funzione `apriAnteprima(uri)` NON è qui: l'import da file scelto naviga a `Anteprima` (Task 7). In questo task il tasto «Importa…» apre il picker e naviga a `Anteprima` con l'uri; finché il Task 7 non c'è, la rotta non esiste: per compilare, in questo task «Importa…» chiama l'import diretto di oggi (il codice di `BarraIo`), e il Task 7 lo sostituisce.

- [ ] **Step 1: dipendenza**

Run: `npx expo install expo-constants` — versione allineata al SDK 57.

- [ ] **Step 2: la schermata**

```tsx
// src/ui/schermate/Impostazioni.tsx
/**
 * Impostazioni: tema, lingua, export e import, informazioni.
 *
 * Export e import stavano nell'intestazione della home, come due icone senza
 * intenzione. Qui hanno un posto e un motivo: sono la copia del ricettario,
 * intera o di una categoria. La condivisione di una ricetta sola sta nella
 * ricetta.
 *
 * Le decisioni (cosa vale una preferenza salvata, cosa finisce nel file, come
 * si chiama) stanno in logica-impostazioni.ts e in src/io: qui si mostra.
 */
import { useEffect, useMemo, useRef, useState } from 'react';
import {
  ActivityIndicator, Alert, Linking, Modal, Pressable, ScrollView, StyleSheet, Text, View,
} from 'react-native';
import Constants from 'expo-constants';
import * as DocumentPicker from 'expo-document-picker';
import * as Sharing from 'expo-sharing';

import Barra from '../componenti/Barra.tsx';
import { elencoCategorie } from '../../data/categorie.ts';
import type { Categoria } from '../../domain/types.ts';
import { esporta } from '../../io/esporta.ts';
import type { Selezione } from '../../io/esporta.ts';
import { useApp } from '../contesto.ts';
import { SCELTE_LINGUA, TEMI } from '../logica-impostazioni.ts';
import type { PropsSchermata } from '../navigazione.ts';
import { avvisoExport, uriDiFile } from '../scambio.ts';
import { creaScrittoreUnico } from '../scritturaUnica.ts';
import type { Colori } from '../tema.ts';
import { RAGGIO, SPAZIO, TESTO, useColori } from '../tema.ts';

/** L'indirizzo pubblico dell'informativa: si aggiorna quando Paolo la pubblica. */
const INDIRIZZO_PRIVACY = 'https://duebytes.it/quantobasta/privacy';

export default function Impostazioni({ navigation }: PropsSchermata<'Impostazioni'>) {
  const { db, testo, tema, sceltaLingua, impostaTema, impostaLingua } = useApp();
  const colori = useColori();
  const stili = useMemo(() => creaStili(colori), [colori]);

  const [categorie, setCategorie] = useState<Categoria[]>([]);
  const [scegliExport, setScegliExport] = useState(false);
  const [inCorso, setInCorso] = useState(false);
  const scrittoreUnico = useRef(creaScrittoreUnico()).current;

  useEffect(() => {
    let vivo = true;
    elencoCategorie(db).then((c) => { if (vivo) setCategorie(c); }).catch(() => {});
    return () => { vivo = false; };
  }, [db]);

  const condividi = async (uri: string) => {
    try {
      if (await Sharing.isAvailableAsync()) {
        await Sharing.shareAsync(uri, {
          mimeType: 'application/zip',
          UTI: 'it.duebytes.quantobasta.ricettario',
          dialogTitle: testo('impostazioni.esporta'),
        });
      } else {
        Alert.alert(testo('impostazioni.esporta'), uri);
      }
    } catch {
      Alert.alert(testo('app.nome'), testo('io.condividi.errore'));
    }
  };

  const esportaCon = (selezione: Selezione) => {
    setScegliExport(false);
    void scrittoreUnico(async () => {
      setInCorso(true);
      try {
        const esito = await esporta(db, selezione);
        const avviso = avvisoExport(esito);
        Alert.alert(esito.nome, testo(avviso.chiave, avviso.valori), [
          { text: testo('modifica.annulla'), style: 'cancel' },
          { text: testo('io.esporta'), onPress: () => void condividi(uriDiFile(esito.uri)) },
        ]);
      } catch {
        Alert.alert(testo('app.nome'), testo('errore.scrittura'));
      } finally {
        setInCorso(false);
      }
    });
  };

  const importa = async () => {
    let scelta: DocumentPicker.DocumentPickerResult;
    try {
      scelta = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true, multiple: false });
    } catch {
      return;
    }
    if (scelta.canceled) return;
    navigation.navigate('Anteprima', { uri: scelta.assets[0].uri });
  };

  const versione = Constants.expoConfig?.version ?? '';
  const build = String(Constants.expoConfig?.ios?.buildNumber ?? Constants.expoConfig?.android?.versionCode ?? '1');

  return (
    <View style={stili.pagina}>
      <Barra onIndietro={navigation.goBack} etichettaIndietro={testo('indietro')} titolo={testo('impostazioni.titolo')} />
      <ScrollView contentContainerStyle={stili.contenuto}>
        <Text style={stili.sezione}>{testo('impostazioni.aspetto')}</Text>
        <Text style={stili.etichetta}>{testo('impostazioni.tema')}</Text>
        <View style={stili.scelte}>
          {TEMI.map((t) => (
            <Pressable
              key={t}
              onPress={() => void impostaTema(t)}
              style={[stili.scelta, tema === t ? stili.sceltaAttiva : null]}
              accessibilityRole="button"
              accessibilityState={{ selected: tema === t }}
            >
              <Text style={[stili.sceltaTesto, tema === t ? stili.sceltaTestoAttivo : null]}>
                {testo(`impostazioni.tema.${t}`)}
              </Text>
            </Pressable>
          ))}
        </View>
        <Text style={stili.etichetta}>{testo('impostazioni.lingua')}</Text>
        <View style={stili.scelte}>
          {SCELTE_LINGUA.map((l) => (
            <Pressable
              key={l}
              onPress={() => void impostaLingua(l)}
              style={[stili.scelta, sceltaLingua === l ? stili.sceltaAttiva : null]}
              accessibilityRole="button"
              accessibilityState={{ selected: sceltaLingua === l }}
            >
              <Text style={[stili.sceltaTesto, sceltaLingua === l ? stili.sceltaTestoAttivo : null]}>
                {testo(`impostazioni.lingua.${l}`)}
              </Text>
            </Pressable>
          ))}
        </View>

        <Text style={stili.sezione}>{testo('impostazioni.ricette')}</Text>
        <Pressable style={stili.riga} onPress={() => setScegliExport(true)} disabled={inCorso} accessibilityRole="button">
          <Text style={stili.rigaTesto}>{testo('impostazioni.esporta')}</Text>
          {inCorso ? <ActivityIndicator color={colori.accento} /> : null}
        </Pressable>
        <Pressable style={stili.riga} onPress={() => void importa()} disabled={inCorso} accessibilityRole="button">
          <Text style={stili.rigaTesto}>{testo('impostazioni.importa')}</Text>
        </Pressable>

        <Text style={stili.sezione}>{testo('impostazioni.info')}</Text>
        <Pressable style={stili.riga} onPress={() => void Linking.openURL(INDIRIZZO_PRIVACY)} accessibilityRole="link">
          <Text style={stili.rigaTesto}>{testo('impostazioni.privacy')}</Text>
        </Pressable>
        <Text style={stili.versione}>{testo('impostazioni.versione', { versione, build })}</Text>
      </ScrollView>

      <Modal visible={scegliExport} animationType="slide" transparent onRequestClose={() => setScegliExport(false)}>
        <Pressable style={stili.velo} onPress={() => setScegliExport(false)} />
        <View style={stili.foglio}>
          <Text style={stili.titoloFoglio}>{testo('impostazioni.esporta.scegli')}</Text>
          <ScrollView>
            <Pressable style={stili.riga} onPress={() => esportaCon({ tipo: 'tutto' })} accessibilityRole="button">
              <Text style={stili.rigaTesto}>{testo('impostazioni.esporta.tutte')}</Text>
            </Pressable>
            {categorie.map((c) => (
              <Pressable key={c.id} style={stili.riga} onPress={() => esportaCon({ tipo: 'categoria', id: c.id })} accessibilityRole="button">
                <Text style={stili.rigaTesto}>{c.nome}</Text>
              </Pressable>
            ))}
          </ScrollView>
        </View>
      </Modal>
    </View>
  );
}

const creaStili = (colori: Colori) =>
  StyleSheet.create({
    pagina: { flex: 1, backgroundColor: colori.fondo },
    contenuto: { padding: SPAZIO.m, paddingBottom: SPAZIO.xl },
    sezione: { ...TESTO.intestazione, color: colori.testoTenue, marginTop: SPAZIO.l, marginBottom: SPAZIO.s },
    etichetta: { ...TESTO.corpo, color: colori.testo, marginTop: SPAZIO.s },
    scelte: { flexDirection: 'row', gap: SPAZIO.s, marginTop: SPAZIO.xs, flexWrap: 'wrap' },
    scelta: {
      paddingVertical: SPAZIO.s, paddingHorizontal: SPAZIO.m, borderRadius: RAGGIO.campo,
      backgroundColor: colori.superficie, borderWidth: StyleSheet.hairlineWidth, borderColor: colori.bordo,
    },
    sceltaAttiva: { backgroundColor: colori.accento, borderColor: colori.accento },
    sceltaTesto: { ...TESTO.corpo, color: colori.testo },
    sceltaTestoAttivo: { color: colori.accentoSopra },
    riga: {
      flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
      paddingVertical: SPAZIO.m, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colori.bordo,
    },
    rigaTesto: { ...TESTO.corpo, color: colori.testo },
    versione: { ...TESTO.didascalia, color: colori.testoTenue, textAlign: 'center', marginTop: SPAZIO.xl },
    velo: { flex: 1, backgroundColor: '#00000066' },
    foglio: {
      backgroundColor: colori.fondo, padding: SPAZIO.m, borderTopLeftRadius: RAGGIO.foglio, borderTopRightRadius: RAGGIO.foglio,
      maxHeight: '70%',
    },
    titoloFoglio: { ...TESTO.titolo, color: colori.testo, marginBottom: SPAZIO.s },
  });
```

Adeguare i nomi di `TESTO.*`, `RAGGIO.*` e `SPAZIO.*` a quelli che esistono in
`src/ui/tema.ts` (leggerlo: se `TESTO.intestazione`, `TESTO.didascalia` o
`RAGGIO.foglio` non ci sono, usare gli equivalenti presenti, com'è fatto in
`GestioneCategorie.tsx` per il foglio dell'editor). Le chiavi composte come
`` `impostazioni.tema.${t}` `` vanno passate a `testo` con `as Chiave`
(`import type { Chiave } from '../../i18n/index.ts'`).

Finché il Task 7 non esiste, `navigation.navigate('Anteprima', …)` non
compila: in questo task lasciare al suo posto il corpo di `importaRicettario`
di `BarraIo.tsx` (lettura del file, `sembraZip`, `importaArchivio`/`importaJson`,
avviso con `righeImport`), e il Task 7 lo sostituisce con la riga sopra.

- [ ] **Step 3: rotta, Stack.Screen, ingranaggio**

`src/ui/navigazione.ts`: aggiungere `Impostazioni: undefined;` a `ParametriNav`.
`src/ui/App.tsx`: `import Impostazioni from './schermate/Impostazioni.tsx';` e
`<Stack.Screen name="Impostazioni" component={Impostazioni} />`.
`src/ui/schermate/Categorie.tsx`: togliere l'import e l'uso di `BarraIo`; al
suo posto, nelle `azioni` della `Barra`:

```tsx
<Pressable
  onPress={() => navigation.navigate('Impostazioni')}
  hitSlop={12}
  accessibilityRole="button"
  accessibilityLabel={testo('impostazioni.titolo')}
>
  <Segno nome="ingranaggio" dimensione={26} colore={colori.accento} />
</Pressable>
```

La home perde `onImportato={ricarica}`: al ritorno da un import, la home si
rilegge perché già ricarica su `useFocusEffect` (verificare in `Categorie.tsx`
che il caricamento sia legato al fuoco; se è solo in `useEffect([db])`,
passare a `useFocusEffect` di `@react-navigation/native` con lo stesso corpo).

- [ ] **Step 4: tsc, test, prova**

Run: `npx tsc --noEmit && npm test 2>&1 | grep -E "^ℹ (pass|fail)"`
Nel simulatore: l'ingranaggio apre la pagina; cambiare tema e lingua cambia
subito; «Esporta…» apre il foglio, «Tutte le ricette» produce
`Ricettario <data>.quantobasta` e il foglio di condivisione; la stessa scelta
su una categoria produce `<Nome>.quantobasta`.

- [ ] **Step 5: commit**

```bash
git add -A
git commit -m "La pagina Impostazioni: tema, lingua, export per selezione, import, privacy, versione"
```

---

### Task 7: l'anteprima dell'import

**Files:**
- Modify: `src/io/importa.ts` (nuova `leggiFile(byte)`), `src/ui/scambio.ts`, `src/ui/scambio.test.ts`
- Create: `src/ui/schermate/Anteprima.tsx`
- Modify: `src/ui/navigazione.ts` (`Anteprima: { uri: string }`), `src/ui/App.tsx` (Stack.Screen, `presentation: 'modal'`), `src/ui/schermate/Impostazioni.tsx` (naviga ad Anteprima)

**Interfaces:**
- Produces in `importa.ts`:
  ```ts
  /** Zip o JSON nudo, dai byte: null se non e' nostro. */
  export async function leggiFile(byte: Uint8Array): Promise<{ file: RicettarioLetto; foto: Map<string, Uint8Array> } | null>
  /** Esegue l'import di un file gia' letto. */
  export async function importaLetto(db, letto: { file; foto }): Promise<EsitoImport>
  ```
- Produces in `scambio.ts`:
  ```ts
  export interface RigaAnteprima { id: string; nome: string; segno: Chiave; foto: boolean }
  export function righeAnteprima(voci: VocePiano[]): RigaAnteprima[]
  export function tastoImporta(piano: Piano, lingua: Lingua): Avviso   // chiave e valori per il tasto
  ```

- [ ] **Step 1: test di `scambio.ts`**

In `src/ui/scambio.test.ts` aggiungere:

```ts
test('le righe dell\'anteprima portano la chiave del segno', () => {
  const righe = righeAnteprima([
    { id: 'a', nome: 'Farinata', segno: 'nuova', foto: true },
    { id: 'b', nome: 'Pasta', segno: 'aggiorna', foto: false },
    { id: 'c', nome: 'Torta', segno: 'ferma', foto: false },
  ]);
  assert.deepEqual(righe.map((r) => r.segno), ['anteprima.nuova', 'anteprima.aggiorna', 'anteprima.ferma']);
});

test('il tasto dice quante entrano, e «niente» quando nessuna', () => {
  const piano = (ricette: number, categorie: number): Piano => ({ ricette: [], categorie: [], entrano: { ricette, categorie } });
  assert.deepEqual(tastoImporta(piano(0, 0), 'it'), { chiave: 'anteprima.importa.zero', valori: {}, etichetta: null });
  assert.deepEqual(tastoImporta(piano(1, 0), 'it'), { chiave: 'anteprima.importa.ricette', valori: { ricette: 'una ricetta' }, etichetta: null });
  assert.deepEqual(tastoImporta(piano(2, 1), 'it'), { chiave: 'anteprima.importa.entrambe', valori: { ricette: '2 ricette', categorie: 'una categoria' }, etichetta: null });
  assert.deepEqual(tastoImporta(piano(0, 3), 'en'), { chiave: 'anteprima.importa.categorie', valori: { categorie: '3 categories' }, etichetta: null });
});
```

con `import type { Piano } from '../io/piano.ts';` e `righeAnteprima, tastoImporta`
da `./scambio.ts`.

- [ ] **Step 2: eseguire, deve fallire**

- [ ] **Step 3: implementare in `scambio.ts`**

```ts
import type { Piano, VocePiano } from '../io/piano.ts';
import { t } from '../i18n/index.ts';

export interface RigaAnteprima {
  id: string;
  nome: string;
  segno: Chiave;
  foto: boolean;
}

const SEGNO: Record<VocePiano['segno'], Chiave> = {
  nuova: 'anteprima.nuova',
  aggiorna: 'anteprima.aggiorna',
  ferma: 'anteprima.ferma',
};

export function righeAnteprima(voci: VocePiano[]): RigaAnteprima[] {
  return voci.map((v) => ({ id: v.id, nome: v.nome, segno: SEGNO[v.segno], foto: v.foto }));
}

/** «una ricetta», «3 ricette»: la forma contata, gia' tradotta. */
function contate(n: number, una: Chiave, molte: Chiave, lingua: Lingua): string {
  return n === 1 ? t(una, lingua) : t(molte, lingua, { n: String(n) });
}

export function tastoImporta(piano: Piano, lingua: Lingua): Avviso {
  const { ricette, categorie } = piano.entrano;
  if (ricette === 0 && categorie === 0) return { chiave: 'anteprima.importa.zero', valori: {}, etichetta: null };
  const r = contate(ricette, 'conta.ricette.una', 'conta.ricette.molte', lingua);
  const c = contate(categorie, 'conta.categorie.una', 'conta.categorie.molte', lingua);
  if (categorie === 0) return { chiave: 'anteprima.importa.ricette', valori: { ricette: r }, etichetta: null };
  if (ricette === 0) return { chiave: 'anteprima.importa.categorie', valori: { categorie: c }, etichetta: null };
  return { chiave: 'anteprima.importa.entrambe', valori: { ricette: r, categorie: c }, etichetta: null };
}
```

`scambio.ts` oggi importa solo tipi da `i18n`: importare `t` porta dentro
`it.ts` ed `en.ts`, che sono oggetti puri, e va bene sotto `node --test`. Le
chiavi `conta.*` sono quelle del Task 5, Step 3 (aggiungerle se non fatte).

- [ ] **Step 4: `leggiFile` e `importaLetto` in `importa.ts`**

```ts
/**
 * I byte di un file, zip o JSON nudo: lo dicono i primi quattro byte, non
 * l'estensione. null se non e' nostro. E' l'unico punto d'ingresso
 * dell'anteprima, che deve leggere senza scrivere.
 */
export async function leggiFile(
  byte: Uint8Array,
): Promise<{ file: RicettarioLetto; foto: Map<string, Uint8Array> } | null> {
  if (byte.length >= 4 && byte[0] === 0x50 && byte[1] === 0x4b && byte[2] === 0x03 && byte[3] === 0x04) {
    return leggiArchivio(byte);
  }
  let json: unknown;
  try {
    json = JSON.parse(new TextDecoder().decode(byte));
  } catch {
    return null;
  }
  const file = validaFile(json);
  return file === null ? null : { file, foto: new Map() };
}

/** L'import di un file gia' letto: e' quello che fa l'anteprima su «Importa». */
export async function importaLetto(
  db: SQLiteDatabase,
  letto: { file: RicettarioLetto; foto: Map<string, Uint8Array> },
): Promise<EsitoImport> {
  return applica(db, letto.file, letto.foto);
}
```

`sembraZip` in `scambio.ts` resta (lo usa il test), ma `leggiFile` fa il
controllo da sé per non dipendere da `ui`.

Test in `importa.test.ts`:

```ts
test('leggiFile riconosce zip e JSON dai byte, e rifiuta il resto', async () => {
  const json = new TextEncoder().encode(comeFile([ricetta('r1', 'A', '2026-01-01T10:00:00.000Z')]));
  assert.equal((await leggiFile(json))?.file.ricette.length, 1);
  const zip = await creaArchivio(creaFile([ricetta('r1', 'A', '2026-01-01T10:00:00.000Z')], [], '2026-01-01T10:00:00.000Z'), new Map());
  assert.equal((await leggiFile(zip))?.file.ricette.length, 1);
  assert.equal(await leggiFile(new TextEncoder().encode('ciao')), null);
});
```

- [ ] **Step 5: la schermata**

```tsx
// src/ui/schermate/Anteprima.tsx
/**
 * L'anteprima di un import: cosa c'e' nel file e cosa succederebbe a ognuna
 * delle sue voci, prima di scrivere. Ci si arriva da Impostazioni → Importa,
 * o perche' il sistema ha aperto l'app con un file .quantobasta.
 *
 * Legge senza scrivere. Il piano lo calcola src/io/piano.ts, lo stesso che
 * l'import eseguira': qui si mostra e basta.
 */
import { useEffect, useMemo, useRef, useState } from 'react';
import { ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { File } from 'expo-file-system';

import Barra from '../componenti/Barra.tsx';
import { categorieDaEsportare } from '../../data/categorie.ts';
import { ricetteDaEsportare } from '../../data/ricette.ts';
import { importaLetto, leggiFile } from '../../io/importa.ts';
import { pianoImport } from '../../io/piano.ts';
import type { Piano } from '../../io/piano.ts';
import type { RicettarioLetto } from '../../io/formato.ts';
import { useApp } from '../contesto.ts';
import type { PropsSchermata } from '../navigazione.ts';
import { righeAnteprima, righeImport, tastoImporta } from '../scambio.ts';
import type { RigaAnteprima } from '../scambio.ts';
import { creaScrittoreUnico } from '../scritturaUnica.ts';
import type { Colori } from '../tema.ts';
import { RAGGIO, SPAZIO, TESTO, useColori } from '../tema.ts';

type Letto = { file: RicettarioLetto; foto: Map<string, Uint8Array> };
type Stato =
  | { nome: 'lettura' }
  | { nome: 'rifiutato' }
  | { nome: 'pronto'; letto: Letto; piano: Piano };

export default function Anteprima({ navigation, route }: PropsSchermata<'Anteprima'>) {
  const { db, lingua, testo } = useApp();
  const colori = useColori();
  const stili = useMemo(() => creaStili(colori), [colori]);
  const [stato, setStato] = useState<Stato>({ nome: 'lettura' });
  const [inCorso, setInCorso] = useState(false);
  const scrittoreUnico = useRef(creaScrittoreUnico()).current;

  useEffect(() => {
    let vivo = true;
    (async () => {
      let letto: Letto | null = null;
      try {
        const byte = await new File(route.params.uri).bytes();
        letto = await leggiFile(byte);
      } catch {
        letto = null;
      }
      if (!vivo) return;
      if (letto === null) {
        setStato({ nome: 'rifiutato' });
        return;
      }
      const [ricette, categorie] = await Promise.all([ricetteDaEsportare(db), categorieDaEsportare(db)]);
      if (!vivo) return;
      setStato({ nome: 'pronto', letto, piano: pianoImport(letto.file, ricette, categorie) });
    })();
    return () => { vivo = false; };
  }, [db, route.params.uri]);

  const importa = () =>
    scrittoreUnico(async () => {
      if (stato.nome !== 'pronto') return;
      setInCorso(true);
      try {
        const esito = await importaLetto(db, stato.letto);
        const righe = righeImport(esito, lingua)
          .map((r) => (r.etichetta === null ? '' : `${testo(r.etichetta)}: `) + testo(r.chiave, r.valori))
          .join('\n');
        Alert.alert(testo('anteprima.titolo'), righe, [{ text: 'OK', onPress: () => navigation.goBack() }]);
      } finally {
        setInCorso(false);
      }
    });

  const Riga = ({ r }: { r: RigaAnteprima }) => (
    <View style={stili.riga}>
      <Text style={stili.nome} numberOfLines={1}>{r.nome}</Text>
      <Text style={[stili.segno, r.segno === 'anteprima.ferma' ? stili.segnoFermo : null]}>
        {testo(r.segno)}{r.foto ? ` · ${testo('anteprima.foto')}` : ''}
      </Text>
    </View>
  );

  const tasto = stato.nome === 'pronto' ? tastoImporta(stato.piano, lingua) : null;
  const attivo = tasto !== null && tasto.chiave !== 'anteprima.importa.zero' && !inCorso;

  return (
    <View style={stili.pagina}>
      <Barra onIndietro={navigation.goBack} etichettaIndietro={testo('modifica.annulla')} titolo={testo('anteprima.titolo')} />
      {stato.nome === 'lettura' ? (
        <ActivityIndicator color={colori.accento} style={stili.centro} />
      ) : stato.nome === 'rifiutato' ? (
        <Text style={stili.rifiuto}>{testo('io.import.rifiutato', { motivo: righeImport({ ok: false, motivo: 'file-non-valido' }, lingua)[0].valori.motivo })}</Text>
      ) : (
        <>
          <ScrollView contentContainerStyle={stili.contenuto}>
            {righeAnteprima(stato.piano.ricette).map((r) => <Riga key={r.id} r={r} />)}
            {stato.piano.categorie.length > 0 ? (
              <>
                <Text style={stili.sezione}>{testo('anteprima.categorie')}</Text>
                {righeAnteprima(stato.piano.categorie).map((r) => <Riga key={r.id} r={r} />)}
              </>
            ) : null}
          </ScrollView>
          <View style={stili.azioni}>
            <Pressable
              onPress={() => void importa()}
              disabled={!attivo}
              style={[stili.tasto, attivo ? null : stili.tastoSpento]}
              accessibilityRole="button"
            >
              {inCorso ? <ActivityIndicator color={colori.accentoSopra} /> : (
                <Text style={[stili.tastoTesto, attivo ? null : stili.tastoTestoSpento]}>
                  {tasto === null ? '' : testo(tasto.chiave, tasto.valori)}
                </Text>
              )}
            </Pressable>
          </View>
        </>
      )}
    </View>
  );
}

const creaStili = (colori: Colori) =>
  StyleSheet.create({
    pagina: { flex: 1, backgroundColor: colori.fondo },
    centro: { flex: 1 },
    contenuto: { padding: SPAZIO.m },
    riga: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between', gap: SPAZIO.m, paddingVertical: SPAZIO.s },
    nome: { ...TESTO.corpo, color: colori.testo, flexShrink: 1 },
    segno: { ...TESTO.didascalia, color: colori.accento },
    segnoFermo: { color: colori.testoTenue },
    sezione: { ...TESTO.intestazione, color: colori.testoTenue, marginTop: SPAZIO.l, marginBottom: SPAZIO.xs },
    rifiuto: { ...TESTO.corpo, color: colori.testo, padding: SPAZIO.l, textAlign: 'center' },
    azioni: { padding: SPAZIO.m },
    tasto: { backgroundColor: colori.accento, borderRadius: RAGGIO.campo, paddingVertical: SPAZIO.m, alignItems: 'center' },
    tastoSpento: { backgroundColor: colori.superficie },
    tastoTesto: { ...TESTO.corpo, color: colori.accentoSopra, fontWeight: '600' },
    tastoTestoSpento: { color: colori.testoTenue },
  });
```

Stessa avvertenza del Task 6 sui nomi di `TESTO`/`RAGGIO`/`SPAZIO`: usare
quelli veri di `tema.ts`, e i nomi di `tasto`/`tastoSpento` come li ha
`GestioneCategorie.tsx`.

- [ ] **Step 6: rotta e navigazione**

`navigazione.ts`: `Anteprima: { uri: string };`.
`App.tsx`: `<Stack.Screen name="Anteprima" component={Anteprima} options={{ presentation: 'modal' }} />`.
`Impostazioni.tsx`: la funzione `importa` naviga a `Anteprima` (togliere il
corpo preso da `BarraIo`).

- [ ] **Step 7: tsc, test, prova**

Nel simulatore: Impostazioni → Importa… → un archivio esportato prima. Si
vede l'anteprima con i segni; il tasto dice «Importa N …»; dopo, l'esito e
si torna indietro; la home mostra le ricette entrate. Con un file che non è
nostro: l'avviso di rifiuto nella pagina.

- [ ] **Step 8: commit**

```bash
git add -A
git commit -m "L'anteprima dell'import: si vede cosa entra prima di scrivere"
```

---

### Task 8: nella ricetta, «Condividi ricetta» e «Copia come testo»

**Files:**
- Modify: `src/ui/schermate/Dosatore.tsx`, `package.json` (`expo-clipboard`)
- Delete: `src/ui/componenti/BarraIo.tsx`

- [ ] **Step 1: dipendenza**

Run: `npx expo install expo-clipboard`

- [ ] **Step 2: il menù**

In `Dosatore.tsx`, accanto al tasto matita nelle `azioni` della `Barra`,
aggiungere un secondo `Pressable` con `<Segno nome="altro" dimensione={24} colore={colori.accento} />`
e `accessibilityLabel={testo('dosatore.altro')}`, che chiama `apriAltro`:

```tsx
const apriAltro = () =>
  Alert.alert(ricetta.titolo, undefined, [
    { text: testo('dosatore.condividi'), onPress: () => void condividiRicetta() },
    { text: testo('dosatore.copia'), onPress: () => void copiaComeTesto() },
    { text: testo('modifica.annulla'), style: 'cancel' },
  ]);

const condividiRicetta = async () => {
  try {
    const esito = await esporta(db, { tipo: 'ricetta', id: ricettaId });
    if (await Sharing.isAvailableAsync()) {
      await Sharing.shareAsync(uriDiFile(esito.uri), {
        mimeType: 'application/zip',
        UTI: 'it.duebytes.quantobasta.ricettario',
        dialogTitle: testo('dosatore.condividi'),
      });
    }
  } catch {
    Alert.alert(testo('app.nome'), testo('io.condividi.errore'));
  }
};

const copiaComeTesto = async () => {
  await Clipboard.setStringAsync(ricettaInTesto(ricetta, vocabolario(lingua)));
  Alert.alert(testo('dosatore.copiata'));
};
```

con gli import `import * as Clipboard from 'expo-clipboard'; import * as Sharing from 'expo-sharing';`,
`import { esporta } from '../../io/esporta.ts';`, `import { ricettaInTesto } from '../../domain/testo.ts';`,
`import { vocabolario } from '../../domain/lingua/index.ts';`, `import { uriDiFile } from '../scambio.ts';`.
`ricetta` e `ricettaId` sono già nello scope della schermata (verificare i
nomi reali delle variabili in `Dosatore.tsx`, righe 140–200). `lingua` viene
da `useApp()`.

Il cenno «Copiata» è un `Alert` con solo OK: è la cosa più corta che funziona
su tutti e due i sistemi. Su Android si potrebbe usare `ToastAndroid`; si
lascia per dopo.

- [ ] **Step 3: cancellare `BarraIo.tsx`**

`git rm src/ui/componenti/BarraIo.tsx`. Verificare con
`grep -rn BarraIo src` che nessuno lo importi più.

- [ ] **Step 4: tsc, test, prova**

Nel simulatore: dalla ricetta, «···» → «Condividi ricetta» apre il foglio con
`<Titolo>.quantobasta`; «Copia come testo» e poi incollare in Note mostra la
ricetta come nel Task 3.

- [ ] **Step 5: commit**

```bash
git add -A
git commit -m "Nella ricetta: condividi il file, o copia come testo; via la vecchia barra di export e import"
```

---

### Task 9: il sistema apre l'app con un `.quantobasta`

**Files:**
- Modify: `app.json`, `package.json` (`expo-linking`), `src/ui/App.tsx`
- Create: `src/ui/logica-apertura.ts`, `src/ui/logica-apertura.test.ts`

**Interfaces:**
- Produces: `export function eFileDaImportare(url: string | null): boolean` — vero
  per `file://…/x.quantobasta`, `file://…/x.zip`, `file://…/x.json` e per ogni
  `content://…`; falso per `null`, per lo schema `quantobasta://` e per tutto
  il resto.

- [ ] **Step 1: test**

```ts
// src/ui/logica-apertura.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { eFileDaImportare } from './logica-apertura.ts';

test('un file .quantobasta, .zip o .json aperto dal sistema si importa; il resto no', () => {
  assert.equal(eFileDaImportare('file:///private/var/mobile/Inbox/Farinata.quantobasta'), true);
  assert.equal(eFileDaImportare('file:///x/Ricettario%202026.QUANTOBASTA'), true);
  assert.equal(eFileDaImportare('file:///x/vecchio.zip'), true);
  assert.equal(eFileDaImportare('file:///x/nudo.json'), true);
  assert.equal(eFileDaImportare('content://com.android.providers.downloads/document/12'), true);
  assert.equal(eFileDaImportare('quantobasta://'), false);
  assert.equal(eFileDaImportare('file:///x/foto.jpg'), false);
  assert.equal(eFileDaImportare(null), false);
});
```

- [ ] **Step 2: implementare**

```ts
// src/ui/logica-apertura.ts
/**
 * L'app puo' essere aperta dal sistema con un URL: il nostro schema
 * `quantobasta://` (che oggi non porta niente) o un file. Solo il file va
 * all'anteprima. Su Android i file arrivano come `content://` senza
 * estensione: si prova a leggerli tutti, e a dire di no ci pensa leggiFile.
 */
export function eFileDaImportare(url: string | null): boolean {
  if (url === null) return false;
  if (url.startsWith('content://')) return true;
  if (!url.startsWith('file://')) return false;
  const percorso = url.split('?')[0].toLowerCase();
  return /\.(quantobasta|zip|json)$/.test(percorso);
}
```

- [ ] **Step 3: `app.json`**

Run: `npx expo install expo-linking`.

In `app.json`, dentro `"ios"`:

```json
"infoPlist": {
  "CFBundleDocumentTypes": [
    {
      "CFBundleTypeName": "Ricettario Quanto Basta",
      "LSHandlerRank": "Owner",
      "LSItemContentTypes": ["it.duebytes.quantobasta.ricettario"]
    }
  ],
  "UTExportedTypeDeclarations": [
    {
      "UTTypeIdentifier": "it.duebytes.quantobasta.ricettario",
      "UTTypeDescription": "Ricettario Quanto Basta",
      "UTTypeConformsTo": ["public.data", "public.archive", "com.pkware.zip-archive"],
      "UTTypeTagSpecification": {
        "public.filename-extension": ["quantobasta"],
        "public.mime-type": ["application/vnd.quantobasta"]
      }
    }
  ]
}
```

dentro `"android"`:

```json
"intentFilters": [
  {
    "action": "VIEW",
    "category": ["DEFAULT", "BROWSABLE"],
    "data": [
      { "scheme": "content", "mimeType": "*/*", "pathPattern": ".*\\.quantobasta" },
      { "scheme": "file", "mimeType": "*/*", "pathPattern": ".*\\.quantobasta" },
      { "scheme": "content", "mimeType": "application/zip" },
      { "scheme": "content", "mimeType": "application/octet-stream", "pathPattern": ".*\\.quantobasta" }
    ]
  }
]
```

(Android è permissivo di natura e restrittivo per caso: i quattro `data`
coprono i modi in cui WhatsApp, Files e Gmail passano lo stesso file. Se dopo
la prova su un telefono vero uno risulta di troppo, si toglie.)

- [ ] **Step 4: `App.tsx` ascolta l'URL**

```tsx
import * as Linking from 'expo-linking';
import { createNavigationContainerRef } from '@react-navigation/native';
import { eFileDaImportare } from './logica-apertura.ts';

const navigazione = createNavigationContainerRef<ParametriNav>();
```

Dentro `Radice` (o dove sta il `NavigationContainer`), passare `ref={navigazione}`
e aggiungere:

```tsx
const url = Linking.useLinkingURL();
useEffect(() => {
  if (!eFileDaImportare(url) || !navigazione.isReady()) return;
  navigazione.navigate('Anteprima', { uri: url! });
}, [url]);
```

Se `navigazione.isReady()` è falso al primo giro (avvio a freddo con file), si
riprova con `onReady` del `NavigationContainer`:

```tsx
<NavigationContainer ref={navigazione} onReady={() => { if (eFileDaImportare(url)) navigazione.navigate('Anteprima', { uri: url! }); }}>
```

Su iOS l'URL è in una cartella `Inbox` temporanea dell'app: `Anteprima` lo
legge subito con `File(uri).bytes()`, che è tutto ciò che serve; non si copia
altrove perché i byte stanno in memoria finché l'utente decide. Se il sistema
toglie il file prima della lettura, `leggiFile` fallisce e si mostra il
rifiuto: accettabile e raro.

- [ ] **Step 5: prebuild e verifica delle dichiarazioni**

Run: `npx expo prebuild --platform ios --clean --no-install && grep -c quantobasta ios/QuantoBasta/Info.plist`
Expected: almeno 3 occorrenze.
Run: `npx expo prebuild --platform android --clean --no-install && grep -c quantobasta android/app/src/main/AndroidManifest.xml`
Expected: almeno 2.

- [ ] **Step 6: tsc, test, commit**

```bash
git add -A
git commit -m "Il sistema apre l'app con un .quantobasta, e l'app porta all'anteprima"
```

---

### Task 10: sull'iPhone, e i documenti

**Files:**
- Modify: `docs/STATO.md`, `docs/BUILD.md`, `docs/DESIGN.md`, memoria `quantobasta-dove-siamo.md`

- [ ] **Step 1: build firmata e installazione**

Seguire `docs/BUILD.md`, sezione «Sull'iPhone di Paolo, firmata». Lanciare
l'archivio (con il secondo tentativo per la gara fra script), installare con
`devicectl`, avviare.

- [ ] **Step 2: collaudo sul telefono, da fare con Paolo**

1. Impostazioni: tema scuro → l'app diventa scura subito; lingua English →
   testi in inglese; tornare a «Come il telefono».
2. Esporta → Tutte le ricette → salvare in File. Poi Esporta → una categoria.
3. Aprire il file da File: l'app si apre sull'anteprima, i segni sono
   «già aggiornata», il tasto dice «Niente da importare» ed è spento.
4. Cancellare una ricetta, riaprire il file da File: la ricetta è «aggiorna la
   tua» (torna indietro), il tasto dice «Importa una ricetta». Importare.
5. Dalla ricetta: «···» → Condividi ricetta → mandarsela per Mail/WhatsApp;
   aprire l'allegato dall'altra parte: anteprima con «già aggiornata».
6. «Copia come testo» → incollare in Note.

- [ ] **Step 3: documenti**

`docs/STATO.md`: nella sezione «Cosa manca per pubblicare», la voce sui tre
moduli nativi mai provati diventa «provati sull'iPhone il 2026-09-16:
fotocamera, condivisione, importazione da file». Aggiungere una riga su
`.quantobasta` e sull'anteprima. Aggiungere nell'indice dei documenti la spec
`2026-09-16-scambio-ricette-design.md` e questo piano.

`docs/BUILD.md`: sotto «La trappola di iOS 27», una sezione «Il tipo di file»
con due righe: le dichiarazioni stanno in `app.json`, Expo Go non le
registra, e il collaudo si fa mandando il file a se stessi per Mail.

`docs/DESIGN.md`: la pagina Impostazioni e l'anteprima come schermate
dell'app; l'ingranaggio fra i segni (già fatto nel Task 5).

Memoria `quantobasta-dove-siamo.md`: aggiornare «Cosa non è mai stato
provato» e la data di stato.

- [ ] **Step 4: commit e push, NAS**

```bash
git add -A && git commit -m "Documenta lo scambio di ricette e il collaudo sull'iPhone" && git push
cd "/Users/paolo/Server/Siti Web/_private/dosatore-app" && git fetch && git reset --hard origin/main
```

---

## Autoverifica del piano

- Copertura della spec: formato e nome (T2), export da impostazioni con
  selezione (T2, T6), condividi e copia dalla ricetta (T3, T8), due porte di
  import con anteprima e segni (T1, T7, T9), impostazioni con tema, lingua,
  privacy e versione (T4, T5, T6), tipo di file iOS e Android (T9), moduli
  puri per ogni decisione (T1, T2, T3, T4, T7, T9), collaudo e documenti (T10).
- Nomi coerenti: `Selezione`, `esporta(db, selezione)`, `EsitoExport.nome`,
  `pianoImport`, `Piano`, `VocePiano`, `leggiFile`, `importaLetto`,
  `righeAnteprima`, `tastoImporta`, `eFileDaImportare`, `ContestoSchema`,
  `StatoApp.impostaTema/impostaLingua`, rotte `Impostazioni` e `Anteprima`.
- I punti dove il codice del piano dipende da nomi reali non letti (`TESTO.*`,
  `RAGGIO.*`, variabili di `Dosatore.tsx`, ordine di `intestazioneNeutra` e
  `stop` nei vocabolari) sono segnati nel task con l'istruzione di leggerli
  prima.
