Ogni app, prima o poi, deve mostrare una data: lo storico degli ordini, la scadenza di un abbonamento, l'orario di una prenotazione. È uno degli ambiti in cui si annidano più bug silenziosi: l'evento che appare con un giorno di anticipo, il promemoria che scatta un'ora prima dopo il cambio dell'ora, il test che passa in Italia e fallisce sulla CI configurata in UTC.

In questa guida vediamo come ragionare correttamente su date e orari in Dart e Flutter: cosa fa (e cosa non fa) DateTime, come formattare in base al locale con intl, come lavorare con i fusi orari reali usando il pacchetto timezone e come rendere testabile il codice che dipende dall'ora corrente.

DateTime: solo due modalità, locale o UTC

La prima cosa da capire è che DateTime in Dart non contiene l'informazione di fuso orario. Internamente è un istante (microsecondi dall'epoch) più un flag booleano: locale oppure UTC.

final now = DateTime.now();        // ora locale del dispositivo
final nowUtc = DateTime.now().toUtc();

print(now.isUtc);                  // false
print(now.timeZoneName);           // es. CEST (dipende dall'OS)
print(now.timeZoneOffset);         // es. 2:00:00.000000

timeZoneName e timeZoneOffset riflettono la configurazione del sistema operativo, non un fuso arbitrario: non puoi costruire un DateTime "in Europe/Rome" se il dispositivo è a New York. Questo è il limite che, più tardi, risolveremo con il pacchetto timezone.

Parsing e serializzazione

Per scambiare date con un backend usa sempre ISO 8601 in UTC:

// Serializzazione
final json = {'createdAt': DateTime.now().toUtc().toIso8601String()};
// -> 2024-06-01T08:30:00.000Z

// Parsing
final a = DateTime.parse('2024-06-01T08:30:00Z');   // isUtc == true
final b = DateTime.parse('2024-06-01T10:30:00');    // isUtc == false (locale!)
final c = DateTime.tryParse('non-una-data');        // null, nessuna eccezione

Nota la differenza tra a e b: se il server invia una stringa senza suffisso Z o offset, Dart la interpreta come ora locale. È una delle cause più frequenti di date sfasate. Se sai che il backend restituisce sempre UTC ma dimentica il suffisso, normalizza esplicitamente:

DateTime parseUtc(String value) {
  final parsed = DateTime.parse(value);
  return parsed.isUtc ? parsed : DateTime.utc(
    parsed.year, parsed.month, parsed.day,
    parsed.hour, parsed.minute, parsed.second, parsed.millisecond,
  );
}

Aritmetica: durate vs calendario

add e subtract lavorano su Duration, cioè su tempo trascorso, non su giorni di calendario. Attraversando il cambio dell'ora legale il risultato può sorprendere:

// 30 marzo 2025, giorno prima del passaggio all'ora legale in Italia
final sabato = DateTime(2025, 3, 29, 9, 0);
print(sabato.add(const Duration(days: 1))); // 2025-03-30 10:00 locale

Sono passate esattamente 24 ore, ma l'orologio da parete segna un'ora in più. Se vuoi "lo stesso orario, il giorno dopo" usa il costruttore, che normalizza i valori fuori intervallo:

final domenica = DateTime(sabato.year, sabato.month, sabato.day + 1, 9, 0);
// 2025-03-30 09:00 locale

final fineMese = DateTime(2025, 2, 31); // -> 2025-03-03, nessun errore

Altre operazioni ricorrenti:

final diff = fine.difference(inizio);      // Duration
print(diff.inDays);                        // troncato per difetto

print(a.isBefore(b));
print(a.isAtSameMomentAs(b));              // confronta istanti, non flag isUtc

// Confronto per sola data (Flutter, package:flutter/material.dart)
print(DateUtils.isSameDay(a, b));
final soloData = DateUtils.dateOnly(a);    // mezzanotte locale

Evita DateTime.now().millisecondsSinceEpoch per misurare durate: per i tempi di esecuzione usa Stopwatch, che non è influenzato da cambi dell'orologio di sistema.

Formattazione con intl e il locale corrente

Mostrare date.toString() all'utente non è mai la scelta giusta. Il pacchetto intl offre DateFormat, che conosce i pattern localizzati di ogni lingua.

dependencies:
  intl: ^0.20.2
import 'package:intl/intl.dart';

final d = DateTime(2025, 6, 1, 18, 5);

DateFormat.yMMMMd('it').format(d);      // 1 giugno 2025
DateFormat.yMd('it').format(d);         // 1/6/2025
DateFormat.Hm('it').format(d);          // 18:05
DateFormat.jm('en_US').format(d);       // 6:05 PM
DateFormat('EEEE d MMMM', 'it').format(d); // domenica 1 giugno

Preferisci sempre i costruttori "skeleton" (yMMMMd, yMd, Hm, jm) ai pattern scritti a mano: si adattano automaticamente alle convenzioni del locale, incluso il formato 12/24 ore.

In un'app Flutter localizzata, il locale corrente si ricava dal BuildContext:

String formatData(BuildContext context, DateTime date) {
  final locale = Localizations.localeOf(context).toString();
  return DateFormat.yMMMMd(locale).format(date.toLocal());
}

Nota il toLocal(): se la data arriva dal backend in UTC, formattarla senza convertirla produce un orario sbagliato. Se lavori fuori da Flutter (test, codice Dart puro) inizializza i dati dei locale:

import 'package:intl/date_symbol_data_local.dart';

void main() {
  initializeDateFormatting('it_IT');
  Intl.defaultLocale = 'it_IT';
  // ...
}

Date relative ("3 ore fa")

Per i formati relativi puoi scrivere una funzione con Intl.plural, così resta traducibile:

String tempoRelativo(DateTime date, {DateTime? now}) {
  final diff = (now ?? DateTime.now()).difference(date);
  if (diff.inMinutes < 1) return 'adesso';
  if (diff.inHours < 1) {
    final m = diff.inMinutes;
    return Intl.plural(m, one: '1 minuto fa', other: '$m minuti fa');
  }
  if (diff.inHours < 24) {
    final h = diff.inHours;
    return Intl.plural(h, one: "1 ora fa", other: '$h ore fa');
  }
  if (diff.inDays < 7) {
    final g = diff.inDays;
    return Intl.plural(g, one: 'ieri', other: '$g giorni fa');
  }
  return DateFormat.yMMMd(Intl.getCurrentLocale()).format(date);
}

Fusi orari reali con il pacchetto timezone

Quando l'app deve ragionare su un fuso diverso da quello del dispositivo — un volo, un webinar con orario "Europe/Rome", un promemoria giornaliero alle 9:00 locali dell'utente — DateTime non basta. Serve il database IANA, fornito dal pacchetto timezone.

dependencies:
  timezone: ^0.10.0
  flutter_timezone: ^4.1.0   # per leggere il fuso del dispositivo
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:timezone/data/latest_all.dart' as tzdata;
import 'package:timezone/timezone.dart' as tz;

Future<void> initTimeZones() async {
  tzdata.initializeTimeZones();
  final name = await FlutterTimezone.getLocalTimezone(); // es. 'Europe/Rome'
  tz.setLocalLocation(tz.getLocation(name));
}

Ora puoi costruire e convertire istanti in qualunque fuso, con gestione corretta dell'ora legale:

final roma = tz.getLocation('Europe/Rome');
final newYork = tz.getLocation('America/New_York');

// Webinar: 1 giugno 2025, ore 18:00 ora di Roma
final webinar = tz.TZDateTime(roma, 2025, 6, 1, 18, 0);

print(webinar);                                  // 2025-06-01 18:00:00.000+0200
print(webinar.toUtc());                          // 2025-06-01 16:00:00.000Z
print(tz.TZDateTime.from(webinar, newYork));     // 2025-06-01 12:00:00.000-0400

TZDateTime estende DateTime, quindi funziona con DateFormat, con i confronti e con difference. Il pattern consigliato è: memorizza sempre UTC, converti nel fuso di visualizzazione solo all'ultimo momento.

String orarioNelFuso(DateTime utc, String tzName, String locale) {
  final location = tz.getLocation(tzName);
  final local = tz.TZDateTime.from(utc, location);
  return '${DateFormat.yMMMMd(locale).format(local)} '
         '${DateFormat.Hm(locale).format(local)} '
         '(${local.timeZoneName})';
}

Questo approccio è indispensabile anche con flutter_local_notifications, che per lo scheduling richiede proprio un TZDateTime: senza inizializzare il database dei fusi, i promemoria ricorrenti sbagliano dopo il cambio dell'ora.

Selettori di data e ora

Flutter offre widget Material pronti, che rispettano automaticamente il locale se hai configurato flutter_localizations:

final data = await showDatePicker(
  context: context,
  initialDate: DateTime.now(),
  firstDate: DateTime(2020),
  lastDate: DateTime(2030),
  helpText: 'Seleziona la data',
);

if (data == null) return;

final ora = await showTimePicker(
  context: context,
  initialTime: TimeOfDay.now(),
);

if (ora == null) return;

final scelta = DateTime(data.year, data.month, data.day, ora.hour, ora.minute);

TimeOfDay rappresenta solo ora e minuti: va sempre combinato con una data per ottenere un istante significativo. Per mostrarlo usa ora.format(context), che rispetta la preferenza 12/24 ore del sistema.

Rendere testabile il codice che dipende dall'ora

Chiamare DateTime.now() sparso nel codice rende i test fragili. Inietta una sorgente di tempo, oppure usa package:clock:

import 'package:clock/clock.dart';

bool isScaduto(DateTime scadenza) => clock.now().isAfter(scadenza);

// test
test('rileva un abbonamento scaduto', () {
  withClock(Clock.fixed(DateTime.utc(2025, 6, 1)), () {
    expect(isScaduto(DateTime.utc(2025, 5, 31)), isTrue);
  });
});

Nei test che coinvolgono formattazione, fissa sempre il locale (Intl.defaultLocale) e usa date in UTC: la CI gira quasi sempre con fuso UTC, e un test basato su DateTime.now() locale è una regressione in attesa di accadere.

Checklist operativa

  • Persisti e trasmetti in UTC, con toIso8601String(); converti solo per la UI.
  • Non fidarti delle stringhe senza offset: normalizzale a UTC esplicitamente.
  • Usa il costruttore per l'aritmetica di calendario, Duration per il tempo trascorso.
  • Formatta con DateFormat e i costruttori skeleton, passando il locale corrente.
  • Se il dominio richiede fusi orari espliciti (viaggi, eventi, scheduling), passa a timezone e TZDateTime.
  • Astrai DateTime.now() dietro clock o un servizio iniettabile.
  • Per dati puramente di calendario (compleanni, scadenze fiscali) considera di salvare una stringa yyyy-MM-dd invece di un istante: eviti qualsiasi ambiguità di fuso.

Applicando queste regole, le date smettono di essere una sorgente di bug intermittenti e diventano semplicemente un dettaglio di formattazione all'ultimo livello della tua UI.