Quasi ogni app reale, prima o poi, chiede all'utente di selezionare qualcosa: la foto del profilo, un allegato PDF, una galleria di immagini per un annuncio. Sembra un'operazione banale, ma tra permessi di sistema, differenze tra Android e iOS, file da 12 MP scattati con la fotocamera e connessioni instabili, è uno dei punti in cui le app si rompono più spesso.

In questa guida costruiamo una pipeline completa: selezione → normalizzazione (crop + compressione) → upload con progresso e annullamento, tenendo conto anche del Web.

Le dipendenze

dependencies:
  image_picker: ^1.1.2        # foto e video da galleria/fotocamera
  file_picker: ^8.1.2         # file generici (PDF, zip, csv...)
  flutter_image_compress: ^2.3.0
  image_cropper: ^8.0.2
  dio: ^5.7.0
  http_parser: ^4.0.2         # per il contentType del multipart
  path_provider: ^2.1.4

Regola generale: image_picker per le immagini, file_picker per tutto il resto. Il primo si integra con i selettori nativi di foto (che su Android 13+ e iOS 14+ non richiedono permessi), il secondo apre il document picker di sistema.

Configurazione nativa minima

Su iOS, in ios/Runner/Info.plist servono le descrizioni d'uso, altrimenti l'app viene terminata dal sistema:

<key>NSPhotoLibraryUsageDescription</key>
<string>Serve per selezionare le immagini da allegare.</string>
<key>NSCameraUsageDescription</key>
<string>Serve per scattare foto da allegare.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Serve per registrare l'audio dei video.</string>

Su Android conviene attivare esplicitamente il Photo Picker di sistema, che evita del tutto la richiesta dei permessi di storage:

import 'package:image_picker_android/image_picker_android.dart';
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';

void configurePicker() {
  final platform = ImagePickerPlatform.instance;
  if (platform is ImagePickerAndroid) {
    platform.useAndroidPhotoPicker = true;
  }
}

Chiama configurePicker() una volta in main(). Se hai già permission_handler nel progetto, ricorda che per la galleria su Android moderno non serve più chiedere READ_EXTERNAL_STORAGE: usare il Photo Picker è la strada consigliata da Google (ed evita rifiuti in review).

Selezionare immagini

L'API di image_picker restituisce XFile, un'astrazione che funziona anche sul Web (dove il concetto di path non esiste).

final _picker = ImagePicker();

Future<XFile?> pickPhoto({required bool fromCamera}) {
  return _picker.pickImage(
    source: fromCamera ? ImageSource.camera : ImageSource.gallery,
    // Ridimensionamento lato plugin: primo livello di difesa
    maxWidth: 2048,
    maxHeight: 2048,
    imageQuality: 88,
    preferredCameraDevice: CameraDevice.rear,
  );
}

Future<List<XFile>> pickPhotos() {
  return _picker.pickMultiImage(maxWidth: 2048, imageQuality: 88, limit: 10);
}

I parametri maxWidth/imageQuality sono la cosa più economica che puoi fare per non trascinarti dietro JPEG da 8 MB. Attenzione però: se ti serve l'immagine originale (per esempio per un'elaborazione OCR), non impostarli.

Il caso dimenticato: la lost data su Android

Quando l'utente apre la fotocamera, Android può terminare la tua activity per liberare memoria. Al rientro, la foto esiste ma il Future della chiamata è morto insieme al processo. Va recuperata a mano:

Future<void> recoverLostPhoto() async {
  final response = await _picker.retrieveLostData();
  if (response.isEmpty) return;

  final files = response.files;
  if (files != null && files.isNotEmpty) {
    // reinserisci i file nello stato dell'app
    _onPicked(files);
  } else if (response.exception != null) {
    debugPrint('Recupero fallito: ${response.exception!.code}');
  }
}

Chiamala nell'initState della schermata di upload (o all'avvio dell'app). È due righe di codice che eliminano una classe intera di bug report irriproducibili.

Selezionare file generici

Future<void> pickDocuments() async {
  final result = await FilePicker.platform.pickFiles(
    type: FileType.custom,
    allowedExtensions: const ['pdf', 'docx', 'csv'],
    allowMultiple: true,
    withData: false,      // niente bytes in RAM su mobile
    withReadStream: true, // utile per file grandi
  );

  if (result == null) return; // l'utente ha annullato

  for (final file in result.files) {
    debugPrint('${file.name} — ${file.size} byte — ${file.extension}');
  }
}

Tre avvertenze importanti:

  • withData: true carica tutto il file in memoria: su un video da 500 MB è un crash garantito. Su mobile usa path o readStream.
  • Su Web path è sempre null: lì devi usare bytes o readStream.
  • allowedExtensions funziona solo con FileType.custom; su iOS il filtro si basa sugli UTI, quindi estensioni esotiche possono non comparire.

Normalizzare: crop e compressione

Prima di caricare, conviene portare i file in un formato prevedibile. Per l'avatar quadrato, image_cropper apre l'editor nativo:

Future<String?> cropSquare(String sourcePath) async {
  final cropped = await ImageCropper().cropImage(
    sourcePath: sourcePath,
    aspectRatio: const CropAspectRatio(ratioX: 1, ratioY: 1),
    compressFormat: ImageCompressFormat.jpg,
    compressQuality: 90,
    uiSettings: [
      AndroidUiSettings(
        toolbarTitle: 'Ritaglia',
        lockAspectRatio: true,
        hideBottomControls: true,
      ),
      IOSUiSettings(title: 'Ritaglia', aspectRatioLockEnabled: true),
    ],
  );
  return cropped?.path;
}

Per la compressione, flutter_image_compress lavora in codice nativo: non blocca la UI e non ha bisogno di un Isolate come farebbe il pacchetto image in puro Dart.

import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';

Future<XFile?> compressImage(XFile input, {int maxSide = 1600}) async {
  final dir = await getTemporaryDirectory();
  final target = p.join(
    dir.path,
    '${DateTime.now().microsecondsSinceEpoch}.jpg',
  );

  return FlutterImageCompress.compressAndGetFile(
    input.path,
    target,
    quality: 82,
    minWidth: maxSide,
    minHeight: maxSide,
    format: CompressFormat.jpeg,
    keepExif: false, // via i metadati GPS: privacy e byte in meno
  );
}

minWidth/minHeight non forzano un ridimensionamento esatto: il plugin scala mantenendo le proporzioni in modo che il lato minore non scenda sotto quei valori. keepExif: false è una scelta consapevole: rimuove la geolocalizzazione dello scatto, che difficilmente vuoi spedire al tuo backend senza avvisare l'utente.

Usa sempre la cartella temporanea per i file derivati: il sistema può ripulirla e non ti ritrovi la cache dell'app gonfiata di duplicati.

Upload multipart con Dio

Ecco il cuore: un servizio che carica un file con progresso, annullamento e gestione degli errori tipici.

import 'package:dio/dio.dart';
import 'package:http_parser/http_parser.dart';

class UploadService {
  UploadService(this._dio);
  final Dio _dio;

  Future<String> uploadImage({
    required XFile file,
    required String albumId,
    void Function(double progress)? onProgress,
    CancelToken? cancelToken,
  }) async {
    final multipart = kIsWeb
        ? MultipartFile.fromBytes(
            await file.readAsBytes(),
            filename: file.name,
            contentType: MediaType('image', 'jpeg'),
          )
        : await MultipartFile.fromFile(
            file.path,
            filename: p.basename(file.path),
            contentType: MediaType('image', 'jpeg'),
          );

    final formData = FormData.fromMap({
      'album_id': albumId,
      'file': multipart,
    });

    final response = await _dio.post<Map<String, dynamic>>(
      '/media',
      data: formData,
      cancelToken: cancelToken,
      onSendProgress: (sent, total) {
        if (total > 0) onProgress?.call(sent / total);
      },
      options: Options(
        // l'upload può essere lento: non usare il timeout di default
        sendTimeout: const Duration(minutes: 5),
        receiveTimeout: const Duration(minutes: 1),
      ),
    );

    return response.data!['url'] as String;
  }
}

Punti da non sbagliare:

  • Non riutilizzare un FormData: dopo un invio è consumato. Per un retry va ricostruito da zero (per questo la creazione sta dentro il metodo).
  • sendTimeout in Dio misura l'intervallo senza trasferimento dati, ma su reti lente i default sono comunque troppo aggressivi per file di qualche MB.
  • contentType esplicito: molti backend (e i validator di Laravel/Rails) rifiutano un application/octet-stream.

Annullare e mostrare il progresso

class UploadController extends ChangeNotifier {
  UploadController(this._service);
  final UploadService _service;

  CancelToken? _token;
  double progress = 0;
  String? error;

  Future<void> start(XFile file) async {
    _token = CancelToken();
    progress = 0;
    error = null;
    notifyListeners();

    try {
      await _service.uploadImage(
        file: file,
        albumId: 'demo',
        cancelToken: _token,
        onProgress: (p) {
          progress = p;
          notifyListeners();
        },
      );
    } on DioException catch (e) {
      if (CancelToken.isCancel(e)) return; // annullamento volontario
      error = switch (e.type) {
        DioExceptionType.sendTimeout ||
        DioExceptionType.connectionTimeout =>
          'Connessione troppo lenta, riprova.',
        DioExceptionType.badResponse when e.response?.statusCode == 413 =>
          'File troppo grande.',
        _ => 'Caricamento non riuscito.',
      };
    } finally {
      notifyListeners();
    }
  }

  void cancel() => _token?.cancel('user');
}

Nella UI basta un LinearProgressIndicator(value: controller.progress) e un pulsante che chiama cancel(). Il 413 Payload Too Large è l'errore più frequente in produzione: intercettarlo e suggerire una compressione più aggressiva vale più di dieci messaggi generici.

File grandi: streaming invece dei bytes

Per video o archivi, non leggere mai tutto in memoria. Con file_picker e withReadStream: true:

final picked = result.files.single;
final multipart = MultipartFile.fromStream(
  () => picked.readStream!,
  picked.size,
  filename: picked.name,
);

Nota che uno stream è consumabile una sola volta: se prevedi retry, apri lo stream dal File (() => File(path).openRead()), così ogni tentativo ne crea uno nuovo.

Retry con backoff

Un upload su rete mobile fallisce spesso per motivi transitori. Un piccolo wrapper risolve il 90% dei casi:

Future<T> withRetry<T>(Future<T> Function() action, {int attempts = 3}) async {
  for (var i = 0; i < attempts; i++) {
    try {
      return await action();
    } on DioException catch (e) {
      final retriable = e.type == DioExceptionType.connectionError ||
          e.type == DioExceptionType.sendTimeout ||
          (e.response?.statusCode ?? 0) >= 500;
      if (!retriable || i == attempts - 1) rethrow;
      await Future.delayed(Duration(seconds: 1 << i)); // 1s, 2s, 4s
    }
  }
  throw StateError('unreachable');
}

Non ritentare mai su 400, 401, 413 o 422: sono errori deterministici, il retry serve solo a bruciare batteria.

Checklist finale

  • Attiva il Photo Picker su Android ed evita i permessi di storage.
  • Gestisci retrieveLostData() dopo l'uso della fotocamera.
  • Comprimi e ridimensiona prima di caricare, e rimuovi gli EXIF sensibili.
  • Scrivi i file derivati nella directory temporanea.
  • Imposta sendTimeout generoso, passa sempre un CancelToken, ricostruisci il FormData a ogni tentativo.
  • Valida dimensione ed estensione lato client (feedback immediato) e lato server (sicurezza).
  • Su Web usa bytes, su mobile path o readStream: incapsula la differenza in un unico punto del codice.

Con questi accorgimenti la funzionalità "allega un file" passa dall'essere una fonte costante di ticket a un flusso prevedibile, veloce e rispettoso della batteria e del traffico dati dei tuoi utenti.