Perché pubblicare un package

Ogni progetto Flutter accumula prima o poi codice riutilizzabile: un client HTTP configurato, un set di widget di design system, un wrapper attorno a una API di piattaforma. Estrarre quel codice in un package porta tre vantaggi concreti:

  • Riuso reale tra app diverse (o tra i moduli di un monorepo);
  • Confini architetturali chiari: un package non può accedere ai dettagli interni della tua app, quindi ti obbliga a disegnare un'API;
  • Testabilità: la superficie pubblica è piccola e ben definita.

Pubblicarlo su pub.dev è il passo finale, ma non è obbligatorio: puoi tenerlo privato con path, git o un pub server self-hosted. In questa guida vediamo tutto il percorso, con particolare attenzione alle cose che di solito si scoprono tardi: il punteggio di pub.dev, il versionamento e la pubblicazione automatizzata.

Package o plugin?

La terminologia di Dart distingue tre casi:

Tipo Comando Quando usarlo
Dart package dart create -t package nome Solo codice Dart, nessuna dipendenza da Flutter (parser, client HTTP, utility)
Flutter package flutter create -t package nome Widget, ThemeExtension, codice che importa package:flutter
Flutter plugin flutter create -t plugin --platforms=android,ios nome Serve codice nativo (Kotlin/Swift) o FFI

Regola pratica: non dipendere da Flutter se non serve. Un package Dart puro può essere usato anche da un backend Dart o da uno script CLI, e ottiene punti in più nella sezione "Platform support" di pub.dev.

flutter create --template=package --org com.esempio pretty_duration
cd pretty_duration

Struttura consigliata

pretty_duration/
├── lib/
│   ├── pretty_duration.dart        # barrel file: unica API pubblica
│   └── src/                        # implementazione (non esportata direttamente)
│       ├── formatter.dart
│       └── locale_data.dart
├── test/
│   └── formatter_test.dart
├── example/                        # app o progetto Dart di esempio
├── CHANGELOG.md
├── LICENSE
├── README.md
├── analysis_options.yaml
└── pubspec.yaml

Il pattern chiave è lib/src/ + barrel file: tutto ciò che sta in src/ è considerato implementazione interna, e solo il file di ingresso decide cosa esporre.

// lib/pretty_duration.dart
/// Formattazione leggibile di [Duration] in più lingue.
library;

export 'src/formatter.dart' show DurationFormatter, DurationStyle;
export 'src/locale_data.dart' show DurationLocale;

Usare show esplicito evita di esporre per sbaglio classi interne che poi non potrai più rimuovere senza una breaking change.

Progettare l'API pubblica

Un package si giudica dalla sua API, non dalla sua implementazione. Alcune linee guida che pagano nel lungo periodo:

  1. Parametri nominati e opzionali con default, così potrai aggiungerne altri senza rompere nulla.
  2. Nessun singleton globale obbligatorio: offri un'istanza di default, ma consenti di crearne altre.
  3. Errori tipizzati: definisci le tue eccezioni invece di lanciare Exception('...').
  4. Immutabilità dove possibile: costruttori const, campi final, copyWith.
// lib/src/formatter.dart
import 'locale_data.dart';

enum DurationStyle { compact, verbose }

/// Formatta una [Duration] in una stringa leggibile.
///
/// Esempio:
/// ```dart
/// const formatter = DurationFormatter();
/// formatter.format(const Duration(minutes: 95)); // '1h 35m'
/// ```
class DurationFormatter {
  const DurationFormatter({
    this.locale = DurationLocale.it,
    this.style = DurationStyle.compact,
    this.maxUnits = 2,
  }) : assert(maxUnits > 0, 'maxUnits deve essere positivo');

  final DurationLocale locale;
  final DurationStyle style;
  final int maxUnits;

  String format(Duration duration) {
    if (duration.isNegative) {
      throw ArgumentError.value(duration, 'duration', 'Durata negativa');
    }
    final parts = <String>[];
    var remaining = duration;

    for (final unit in _units) {
      if (parts.length == maxUnits) break;
      final value = remaining.inSeconds ~/ unit.seconds;
      if (value == 0) continue;
      parts.add(locale.label(unit, value, style));
      remaining -= Duration(seconds: value * unit.seconds);
    }

    return parts.isEmpty ? locale.zero(style) : parts.join(' ');
  }

  DurationFormatter copyWith({
    DurationLocale? locale,
    DurationStyle? style,
    int? maxUnits,
  }) =>
      DurationFormatter(
        locale: locale ?? this.locale,
        style: style ?? this.style,
        maxUnits: maxUnits ?? this.maxUnits,
      );
}

Documentazione: dartdoc conta davvero

pub.dev assegna punti in base alla percentuale di API pubbliche documentate (l'obiettivo è almeno il 20%, ma punta molto più in alto). Regole utili:

  • La prima riga del commento /// è una frase breve che finisce con un punto: è quella che appare negli elenchi.
  • Usa i riferimenti tra parentesi quadre ([Duration], [DurationFormatter.format]): dartdoc li trasforma in link.
  • Inserisci esempi eseguibili nei commenti; con dart doc diventano blocchi formattati.
  • Aggiungi dartdoc_options.yaml se vuoi categorie personalizzate.

Genera e controlla la documentazione in locale:

dart doc .
# apre doc/api/index.html

Abilita anche il lint che segnala i membri pubblici non documentati:

# analysis_options.yaml
include: package:flutter_lints/flutter.yaml

linter:
  rules:
    - public_member_api_docs
    - prefer_relative_imports
    - require_trailing_commas

L'esempio non è opzionale

La cartella example/ è il secondo punto più guardato dopo il README, e pub.dev le dà un peso nel punteggio. Per un Flutter package crea una vera app:

flutter create example

nel cui pubspec.yaml referenzi il package in locale:

dependencies:
  pretty_duration:
    path: ../

Per un package Dart puro basta un singolo example/example.dart con un main() commentato. Tieni l'esempio minimale e compilabile: se non compila, chi valuta il package se ne accorge subito.

Test

I test di un package sono la tua rete di sicurezza contro le regressioni segnalate dagli utenti. Per la logica pura usa package:test, per i widget flutter_test.

// test/formatter_test.dart
import 'package:pretty_duration/pretty_duration.dart';
import 'package:test/test.dart';

void main() {
  group('DurationFormatter', () {
    const formatter = DurationFormatter();

    test('formatta ore e minuti', () {
      expect(formatter.format(const Duration(minutes: 95)), '1h 35m');
    });

    test('rispetta maxUnits', () {
      final f = formatter.copyWith(maxUnits: 1);
      expect(f.format(const Duration(hours: 2, minutes: 30)), '2h');
    });

    test('rifiuta durate negative', () {
      expect(
        () => formatter.format(const Duration(seconds: -1)),
        throwsArgumentError,
      );
    });
  });
}

Se il package espone widget, aggiungi golden test per fissare l'aspetto visivo: chi lo integra si aspetta stabilità grafica tra versioni patch.

Il pubspec.yaml che fa punteggio

Questo è il file che determina buona parte della scheda su pub.dev.

name: pretty_duration
description: >-
  Formattazione leggibile e localizzata di oggetti Duration,
  con stili compatti o estesi e supporto multi-lingua.
version: 1.0.0
repository: https://github.com/utente/pretty_duration
issue_tracker: https://github.com/utente/pretty_duration/issues
documentation: https://github.com/utente/pretty_duration#readme
funding:
  - https://github.com/sponsors/utente

topics:
  - duration
  - formatting
  - i18n

environment:
  sdk: ^3.5.0

dependencies:
  meta: ^1.15.0

dev_dependencies:
  lints: ^5.0.0
  test: ^1.25.0

Attenzione a questi dettagli:

  • description tra 60 e 180 caratteri: fuori da questo intervallo perdi punti.
  • topics (massimo 5, minuscolo, kebab-case) rende il package trovabile per argomento.
  • repository abilita il badge del codice sorgente e la verifica del publisher su GitHub.
  • screenshots (con path e description) è molto efficace per i package di UI.
  • Vincoli di versione: usa il caret (^) e mantieni i limiti superiori aggiornati, altrimenti i tuoi utenti restano bloccati.

Un errore frequente è mettere in dependencies pacchetti che servono solo per lo sviluppo (linter, generatori, mock): vanno in dev_dependencies, perché le dipendenze di runtime vengono scaricate da tutti.

LICENSE e CHANGELOG

Senza un file LICENSE riconosciuto (MIT, BSD-3, Apache-2.0…) perdi punti e molte aziende non potranno adottare il package. Il CHANGELOG.md deve avere una voce per ogni versione pubblicata, con l'intestazione esattamente uguale al numero di versione:

## 1.1.0

* Aggiunto `DurationStyle.verbose`.
* `maxUnits` ora accetta valori fino a 4.

## 1.0.0

* Prima release stabile.

Semantic versioning in pratica

Dart applica il semantic versioning in modo rigoroso, e il caret nei vincoli dipende da questo:

  • PATCH (1.0.0 → 1.0.1): bugfix, nessun cambiamento di API.
  • MINOR (1.0.0 → 1.1.0): nuove funzionalità retrocompatibili.
  • MAJOR (1.0.0 → 2.0.0): breaking change.

Cosa conta come breaking change in Dart, spesso sottovalutato:

  • rimuovere o rinominare qualsiasi membro pubblico;
  • aggiungere un parametro richiesto a un costruttore o metodo;
  • aggiungere un metodo astratto a una classe che gli utenti possono implementare;
  • alzare il vincolo minimo dell'SDK.

Per le versioni 0.x.y la regola cambia: è la seconda cifra a rappresentare le breaking change (^0.3.0 accetta solo >=0.3.0 <0.4.0).

Invece di rimuovere subito un'API, deprecala per almeno un ciclo:

@Deprecated('Usa format() al posto di formatDuration(). Rimosso in 2.0.0.')
String formatDuration(Duration d) => format(d);

Verificare il punteggio prima di pubblicare

pub.dev calcola il punteggio con pana. Puoi eseguirlo in locale ed evitare sorprese:

dart pub global activate pana
pana .

Il report elenca punto per punto cosa manca: documentazione, esempio, licenza, dipendenze obsolete, supporto piattaforme, compatibilità con la null safety e con l'ultima versione dell'SDK.

Poi esegui la simulazione di pubblicazione:

dart pub publish --dry-run

Controlla l'elenco dei file inclusi: se compaiono cartelle pesanti o segreti, escludili con .pubignore (ha la stessa sintassi di .gitignore e, se presente, sostituisce .gitignore ai fini della pubblicazione).

# .pubignore
doc/
coverage/
example/build/
*.log

Pubblicare

La prima pubblicazione si fa da terminale, con l'account Google che diventerà uploader del package:

dart pub publish

Ricorda che una versione pubblicata non si può cancellare. Se ti accorgi di un problema entro 7 giorni puoi usare retract dalla dashboard di pub.dev: la versione resta risolvibile per chi l'ha già nel lockfile, ma non viene più selezionata per le nuove installazioni.

Subito dopo, valuta di creare un publisher verificato (basato su un dominio che controlli): dà molta più credibilità rispetto a un account personale.

Pubblicazione automatica con GitHub Actions

pub.dev supporta la pubblicazione automatizzata via OIDC, senza credenziali da conservare nei secret. La configurazione ha due passaggi.

1. Su pub.dev, nella pagina Admin del package, abilita "Automated publishing" indicando il repository GitHub e il pattern dei tag (per esempio v{{version}}).

2. Nel repository, crea il workflow:

# .github/workflows/publish.yml
name: Publish to pub.dev

on:
  push:
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+*'

jobs:
  publish:
    permissions:
      id-token: write   # necessario per l'autenticazione OIDC
    uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1

Da quel momento il rilascio è: aggiorni version nel pubspec, aggiungi la voce nel CHANGELOG, fai il commit, crei il tag v1.1.0 e lo pushi. Il workflow esegue dart pub publish per te.

Affianca sempre un workflow di verifica su ogni pull request:

name: CI
on: [push, pull_request]

jobs:
  analyze-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          channel: stable
      - run: flutter pub get
      - run: dart format --output=none --set-exit-if-changed .
      - run: flutter analyze --fatal-infos
      - run: flutter test

Alternative alla pubblicazione pubblica

Non tutto deve finire su pub.dev. Per codice aziendale hai tre strade:

dependencies:
  # 1. Repository Git privato (con SSH configurato in CI)
  design_system:
    git:
      url: git@github.com:azienda/design_system.git
      ref: v2.3.0
      path: packages/design_system

  # 2. Percorso locale, tipico dei monorepo
  core_models:
    path: ../core_models

  # 3. Server pub privato
  billing_client:
    hosted: https://pub.azienda.it
    version: ^1.2.0

In un package destinato a restare privato, aggiungi anche questa riga al pubspec per evitare pubblicazioni accidentali:

publish_to: none

Checklist finale

Prima di premere invio su dart pub publish:

  • [ ] README.md con installazione, esempio d'uso e screenshot;
  • [ ] CHANGELOG.md aggiornato con la versione esatta;
  • [ ] LICENSE presente e riconosciuto;
  • [ ] example/ compilabile;
  • [ ] description tra 60 e 180 caratteri e topics compilati;
  • [ ] repository e issue_tracker valorizzati;
  • [ ] dart format, flutter analyze e flutter test verdi;
  • [ ] pana . senza penalità gravi;
  • [ ] dart pub publish --dry-run senza warning;
  • [ ] versione coerente con il semantic versioning.

Un package ben confezionato non è solo cortesia verso la community: è codice che il tuo team futuro riuscirà ad aggiornare senza doverlo riscrivere. E il lavoro di documentazione e test che fai per pubblicarlo ripaga anche quando il package resta dentro il tuo monorepo.