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:
- Parametri nominati e opzionali con default, così potrai aggiungerne altri senza rompere nulla.
- Nessun singleton globale obbligatorio: offri un'istanza di default, ma consenti di crearne altre.
- Errori tipizzati: definisci le tue eccezioni invece di lanciare
Exception('...'). - Immutabilità dove possibile: costruttori
const, campifinal,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 docdiventano blocchi formattati. - Aggiungi
dartdoc_options.yamlse 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:
descriptiontra 60 e 180 caratteri: fuori da questo intervallo perdi punti.topics(massimo 5, minuscolo, kebab-case) rende il package trovabile per argomento.repositoryabilita il badge del codice sorgente e la verifica del publisher su GitHub.screenshots(conpathedescription) è 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.mdcon installazione, esempio d'uso e screenshot; - [ ]
CHANGELOG.mdaggiornato con la versione esatta; - [ ]
LICENSEpresente e riconosciuto; - [ ]
example/compilabile; - [ ]
descriptiontra 60 e 180 caratteri etopicscompilati; - [ ]
repositoryeissue_trackervalorizzati; - [ ]
dart format,flutter analyzeeflutter testverdi; - [ ]
pana .senza penalità gravi; - [ ]
dart pub publish --dry-runsenza 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.