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: truecarica tutto il file in memoria: su un video da 500 MB è un crash garantito. Su mobile usapathoreadStream.- Su Web
pathè semprenull: lì devi usarebytesoreadStream. allowedExtensionsfunziona solo conFileType.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). sendTimeoutin Dio misura l'intervallo senza trasferimento dati, ma su reti lente i default sono comunque troppo aggressivi per file di qualche MB.contentTypeesplicito: molti backend (e i validator di Laravel/Rails) rifiutano unapplication/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
sendTimeoutgeneroso, passa sempre unCancelToken, ricostruisci ilFormDataa ogni tentativo. - Valida dimensione ed estensione lato client (feedback immediato) e lato server (sicurezza).
- Su Web usa
bytes, su mobilepathoreadStream: 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.