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,
Durationper il tempo trascorso. - Formatta con
DateFormate i costruttori skeleton, passando il locale corrente. - Se il dominio richiede fusi orari espliciti (viaggi, eventi, scheduling), passa a
timezoneeTZDateTime. - Astrai
DateTime.now()dietroclocko un servizio iniettabile. - Per dati puramente di calendario (compleanni, scadenze fiscali) considera di salvare una stringa
yyyy-MM-ddinvece 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.