Flutter File Picker Localization: Dialogs, Permissions, and File Type Messages
Build file management features that users understand in any language. This guide covers localizing file picker dialogs, permission requests, file type descriptions, and upload feedback in Flutter applications.
File Picker Localization Challenges
File picker features require localization for:
- Dialog titles - "Select File", "Choose Image", "Pick Document"
- Permission messages - Storage access explanations
- File type filters - Document types, image formats, media files
- Error messages - File too large, unsupported format, access denied
- Progress feedback - Upload status, processing messages
Setting Up Localized File Picker
ARB File Structure
{
"@@locale": "en",
"filePickerTitle": "Select File",
"@filePickerTitle": {
"description": "Title for file picker dialog"
},
"filePickerImageTitle": "Choose Image",
"@filePickerImageTitle": {
"description": "Title for image picker dialog"
},
"filePickerDocumentTitle": "Select Document",
"@filePickerDocumentTitle": {
"description": "Title for document picker dialog"
},
"filePickerMultipleTitle": "Select Files ({count} max)",
"@filePickerMultipleTitle": {
"description": "Title for multiple file selection",
"placeholders": {
"count": {
"type": "int",
"description": "Maximum number of files"
}
}
},
"storagePermissionTitle": "Storage Access Required",
"@storagePermissionTitle": {
"description": "Title for storage permission dialog"
},
"storagePermissionMessage": "This app needs access to your files to upload documents and save downloads.",
"@storagePermissionMessage": {
"description": "Explanation for storage permission request"
},
"photosPermissionTitle": "Photo Library Access",
"@photosPermissionTitle": {
"description": "Title for photos permission dialog"
},
"photosPermissionMessage": "Allow access to select photos from your library.",
"@photosPermissionMessage": {
"description": "Explanation for photos permission request"
}
}
File Type Descriptions
{
"fileTypeAll": "All Files",
"@fileTypeAll": {
"description": "Filter option for all file types"
},
"fileTypeImages": "Images",
"@fileTypeImages": {
"description": "Filter option for image files"
},
"fileTypeDocuments": "Documents",
"@fileTypeDocuments": {
"description": "Filter option for document files"
},
"fileTypeVideos": "Videos",
"@fileTypeVideos": {
"description": "Filter option for video files"
},
"fileTypeAudio": "Audio Files",
"@fileTypeAudio": {
"description": "Filter option for audio files"
},
"fileTypePdf": "PDF Documents",
"@fileTypePdf": {
"description": "Filter option for PDF files"
},
"fileTypeSpreadsheet": "Spreadsheets",
"@fileTypeSpreadsheet": {
"description": "Filter option for spreadsheet files"
},
"fileTypePresentation": "Presentations",
"@fileTypePresentation": {
"description": "Filter option for presentation files"
},
"fileTypeArchive": "Archives (ZIP, RAR)",
"@fileTypeArchive": {
"description": "Filter option for archive files"
}
}
Implementing Localized File Picker Widget
Custom File Picker with Localization
import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
enum FilePickerType {
any,
image,
video,
audio,
document,
custom,
}
class LocalizedFilePicker extends StatefulWidget {
final FilePickerType type;
final List<String>? allowedExtensions;
final bool allowMultiple;
final int? maxFiles;
final int? maxFileSize; // in bytes
final Function(List<PlatformFile>) onFilesPicked;
final Function(String)? onError;
const LocalizedFilePicker({
Key? key,
this.type = FilePickerType.any,
this.allowedExtensions,
this.allowMultiple = false,
this.maxFiles,
this.maxFileSize,
required this.onFilesPicked,
this.onError,
}) : super(key: key);
@override
State<LocalizedFilePicker> createState() => _LocalizedFilePickerState();
}
class _LocalizedFilePickerState extends State<LocalizedFilePicker> {
bool _isLoading = false;
String _getDialogTitle(AppLocalizations l10n) {
switch (widget.type) {
case FilePickerType.image:
return l10n.filePickerImageTitle;
case FilePickerType.video:
return l10n.filePickerVideoTitle;
case FilePickerType.audio:
return l10n.filePickerAudioTitle;
case FilePickerType.document:
return l10n.filePickerDocumentTitle;
default:
if (widget.allowMultiple && widget.maxFiles != null) {
return l10n.filePickerMultipleTitle(widget.maxFiles!);
}
return l10n.filePickerTitle;
}
}
FileType _getFileType() {
switch (widget.type) {
case FilePickerType.image:
return FileType.image;
case FilePickerType.video:
return FileType.video;
case FilePickerType.audio:
return FileType.audio;
case FilePickerType.custom:
return FileType.custom;
default:
return FileType.any;
}
}
Future<void> _pickFiles() async {
final l10n = AppLocalizations.of(context)!;
setState(() => _isLoading = true);
try {
final result = await FilePicker.platform.pickFiles(
type: _getFileType(),
allowMultiple: widget.allowMultiple,
allowedExtensions: widget.allowedExtensions,
dialogTitle: _getDialogTitle(l10n),
);
if (result != null && result.files.isNotEmpty) {
// Validate file sizes
final validFiles = <PlatformFile>[];
final oversizedFiles = <String>[];
for (final file in result.files) {
if (widget.maxFileSize != null &&
file.size > widget.maxFileSize!) {
oversizedFiles.add(file.name);
} else {
validFiles.add(file);
}
}
if (oversizedFiles.isNotEmpty) {
_showFileSizeError(l10n, oversizedFiles);
}
if (validFiles.isNotEmpty) {
// Check max files limit
if (widget.maxFiles != null &&
validFiles.length > widget.maxFiles!) {
widget.onFilesPicked(validFiles.take(widget.maxFiles!).toList());
_showMaxFilesWarning(l10n);
} else {
widget.onFilesPicked(validFiles);
}
}
}
} on Exception catch (e) {
widget.onError?.call(l10n.filePickerErrorGeneric);
} finally {
setState(() => _isLoading = false);
}
}
void _showFileSizeError(AppLocalizations l10n, List<String> files) {
final maxSizeMB = (widget.maxFileSize! / (1024 * 1024)).toStringAsFixed(1);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
l10n.filePickerFileTooLarge(files.join(', '), maxSizeMB),
),
backgroundColor: Colors.orange,
),
);
}
void _showMaxFilesWarning(AppLocalizations l10n) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.filePickerMaxFilesWarning(widget.maxFiles!)),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ElevatedButton.icon(
onPressed: _isLoading ? null : _pickFiles,
icon: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(_getIcon()),
label: Text(_getButtonLabel(l10n)),
);
}
IconData _getIcon() {
switch (widget.type) {
case FilePickerType.image:
return Icons.image;
case FilePickerType.video:
return Icons.videocam;
case FilePickerType.audio:
return Icons.audiotrack;
case FilePickerType.document:
return Icons.description;
default:
return Icons.attach_file;
}
}
String _getButtonLabel(AppLocalizations l10n) {
switch (widget.type) {
case FilePickerType.image:
return l10n.selectImage;
case FilePickerType.video:
return l10n.selectVideo;
case FilePickerType.audio:
return l10n.selectAudio;
case FilePickerType.document:
return l10n.selectDocument;
default:
return widget.allowMultiple
? l10n.selectFiles
: l10n.selectFile;
}
}
}
Permission Request Dialogs
Localized Permission Handler
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedPermissionHandler {
static Future<bool> requestStoragePermission(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
var status = await Permission.storage.status;
if (status.isGranted) {
return true;
}
if (status.isDenied) {
// Show explanation dialog first
final shouldRequest = await _showPermissionDialog(
context,
title: l10n.storagePermissionTitle,
message: l10n.storagePermissionMessage,
icon: Icons.folder,
);
if (shouldRequest) {
status = await Permission.storage.request();
return status.isGranted;
}
}
if (status.isPermanentlyDenied) {
await _showSettingsDialog(
context,
title: l10n.storagePermissionTitle,
message: l10n.permissionPermanentlyDenied,
);
}
return false;
}
static Future<bool> requestPhotosPermission(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
var status = await Permission.photos.status;
if (status.isGranted) {
return true;
}
if (status.isDenied) {
final shouldRequest = await _showPermissionDialog(
context,
title: l10n.photosPermissionTitle,
message: l10n.photosPermissionMessage,
icon: Icons.photo_library,
);
if (shouldRequest) {
status = await Permission.photos.request();
return status.isGranted;
}
}
if (status.isPermanentlyDenied) {
await _showSettingsDialog(
context,
title: l10n.photosPermissionTitle,
message: l10n.permissionPermanentlyDenied,
);
}
return false;
}
static Future<bool> _showPermissionDialog(
BuildContext context, {
required String title,
required String message,
required IconData icon,
}) async {
final l10n = AppLocalizations.of(context)!;
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(icon, color: Theme.of(context).primaryColor),
const SizedBox(width: 12),
Expanded(child: Text(title)),
],
),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(l10n.cancel),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
child: Text(l10n.allow),
),
],
),
) ?? false;
}
static Future<void> _showSettingsDialog(
BuildContext context, {
required String title,
required String message,
}) async {
final l10n = AppLocalizations.of(context)!;
await showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancel),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
openAppSettings();
},
child: Text(l10n.openSettings),
),
],
),
);
}
}
Error Messages for File Operations
Comprehensive Error ARB Entries
{
"filePickerErrorGeneric": "Could not select file. Please try again.",
"@filePickerErrorGeneric": {
"description": "Generic file picker error"
},
"filePickerFileTooLarge": "File \"{fileName}\" exceeds the {maxSize}MB limit.",
"@filePickerFileTooLarge": {
"description": "File size exceeds limit error",
"placeholders": {
"fileName": {
"type": "String"
},
"maxSize": {
"type": "String"
}
}
},
"filePickerUnsupportedFormat": "File format not supported. Please select a {formats} file.",
"@filePickerUnsupportedFormat": {
"description": "Unsupported file format error",
"placeholders": {
"formats": {
"type": "String",
"description": "List of supported formats"
}
}
},
"filePickerMaxFilesWarning": "Only the first {count} files were selected.",
"@filePickerMaxFilesWarning": {
"description": "Warning when max files limit exceeded",
"placeholders": {
"count": {
"type": "int"
}
}
},
"filePickerNoFileSelected": "No file was selected.",
"@filePickerNoFileSelected": {
"description": "Message when user cancels file selection"
},
"filePickerAccessDenied": "Access to files was denied. Please grant permission in settings.",
"@filePickerAccessDenied": {
"description": "File access denied error"
},
"filePickerCorruptedFile": "The selected file appears to be corrupted.",
"@filePickerCorruptedFile": {
"description": "Corrupted file error"
},
"filePickerNetworkError": "Could not access cloud files. Check your internet connection.",
"@filePickerNetworkError": {
"description": "Network error for cloud file access"
}
}
File Upload Progress Localization
Upload Progress Widget
class LocalizedFileUploadProgress extends StatelessWidget {
final String fileName;
final double progress; // 0.0 to 1.0
final int bytesUploaded;
final int totalBytes;
final FileUploadStatus status;
final VoidCallback? onCancel;
final VoidCallback? onRetry;
const LocalizedFileUploadProgress({
Key? key,
required this.fileName,
required this.progress,
required this.bytesUploaded,
required this.totalBytes,
required this.status,
this.onCancel,
this.onRetry,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(_getStatusIcon(), color: _getStatusColor()),
const SizedBox(width: 8),
Expanded(
child: Text(
fileName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
if (status == FileUploadStatus.uploading && onCancel != null)
IconButton(
icon: const Icon(Icons.close),
onPressed: onCancel,
tooltip: l10n.cancelUpload,
),
if (status == FileUploadStatus.failed && onRetry != null)
IconButton(
icon: const Icon(Icons.refresh),
onPressed: onRetry,
tooltip: l10n.retryUpload,
),
],
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: status == FileUploadStatus.uploading ? progress : null,
backgroundColor: Colors.grey[300],
),
const SizedBox(height: 8),
Text(
_getStatusText(l10n),
style: TextStyle(
color: _getStatusColor(),
fontSize: 12,
),
),
],
),
),
);
}
String _getStatusText(AppLocalizations l10n) {
switch (status) {
case FileUploadStatus.pending:
return l10n.uploadStatusPending;
case FileUploadStatus.uploading:
final uploaded = _formatBytes(bytesUploaded, l10n);
final total = _formatBytes(totalBytes, l10n);
final percent = (progress * 100).toInt();
return l10n.uploadStatusProgress(uploaded, total, percent);
case FileUploadStatus.completed:
return l10n.uploadStatusCompleted;
case FileUploadStatus.failed:
return l10n.uploadStatusFailed;
case FileUploadStatus.cancelled:
return l10n.uploadStatusCancelled;
}
}
String _formatBytes(int bytes, AppLocalizations l10n) {
if (bytes < 1024) {
return l10n.fileSizeBytes(bytes);
} else if (bytes < 1024 * 1024) {
return l10n.fileSizeKB((bytes / 1024).toStringAsFixed(1));
} else if (bytes < 1024 * 1024 * 1024) {
return l10n.fileSizeMB((bytes / (1024 * 1024)).toStringAsFixed(1));
} else {
return l10n.fileSizeGB((bytes / (1024 * 1024 * 1024)).toStringAsFixed(2));
}
}
IconData _getStatusIcon() {
switch (status) {
case FileUploadStatus.pending:
return Icons.hourglass_empty;
case FileUploadStatus.uploading:
return Icons.cloud_upload;
case FileUploadStatus.completed:
return Icons.check_circle;
case FileUploadStatus.failed:
return Icons.error;
case FileUploadStatus.cancelled:
return Icons.cancel;
}
}
Color _getStatusColor() {
switch (status) {
case FileUploadStatus.pending:
return Colors.grey;
case FileUploadStatus.uploading:
return Colors.blue;
case FileUploadStatus.completed:
return Colors.green;
case FileUploadStatus.failed:
return Colors.red;
case FileUploadStatus.cancelled:
return Colors.orange;
}
}
}
enum FileUploadStatus {
pending,
uploading,
completed,
failed,
cancelled,
}
Upload Status ARB Entries
{
"uploadStatusPending": "Waiting to upload...",
"@uploadStatusPending": {
"description": "Upload pending status"
},
"uploadStatusProgress": "{uploaded} of {total} ({percent}%)",
"@uploadStatusProgress": {
"description": "Upload progress status",
"placeholders": {
"uploaded": {"type": "String"},
"total": {"type": "String"},
"percent": {"type": "int"}
}
},
"uploadStatusCompleted": "Upload complete",
"@uploadStatusCompleted": {
"description": "Upload completed status"
},
"uploadStatusFailed": "Upload failed. Tap to retry.",
"@uploadStatusFailed": {
"description": "Upload failed status"
},
"uploadStatusCancelled": "Upload cancelled",
"@uploadStatusCancelled": {
"description": "Upload cancelled status"
},
"cancelUpload": "Cancel upload",
"@cancelUpload": {
"description": "Cancel upload button tooltip"
},
"retryUpload": "Retry upload",
"@retryUpload": {
"description": "Retry upload button tooltip"
},
"fileSizeBytes": "{size} B",
"@fileSizeBytes": {
"description": "File size in bytes",
"placeholders": {"size": {"type": "int"}}
},
"fileSizeKB": "{size} KB",
"@fileSizeKB": {
"description": "File size in kilobytes",
"placeholders": {"size": {"type": "String"}}
},
"fileSizeMB": "{size} MB",
"@fileSizeMB": {
"description": "File size in megabytes",
"placeholders": {"size": {"type": "String"}}
},
"fileSizeGB": "{size} GB",
"@fileSizeGB": {
"description": "File size in gigabytes",
"placeholders": {"size": {"type": "String"}}
}
}
File Preview and Selection UI
Localized File Selection List
class LocalizedFileSelectionList extends StatelessWidget {
final List<SelectedFile> files;
final Function(SelectedFile) onRemove;
final bool showPreview;
const LocalizedFileSelectionList({
Key? key,
required this.files,
required this.onRemove,
this.showPreview = true,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (files.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.folder_open, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
l10n.noFilesSelected,
style: TextStyle(color: Colors.grey[600]),
),
],
),
);
}
return ListView.builder(
itemCount: files.length,
itemBuilder: (context, index) {
final file = files[index];
return _FileListTile(
file: file,
onRemove: () => onRemove(file),
showPreview: showPreview,
);
},
);
}
}
class _FileListTile extends StatelessWidget {
final SelectedFile file;
final VoidCallback onRemove;
final bool showPreview;
const _FileListTile({
required this.file,
required this.onRemove,
required this.showPreview,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ListTile(
leading: showPreview ? _buildPreview() : _buildIcon(),
title: Text(
file.name,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
_formatFileInfo(l10n),
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
trailing: IconButton(
icon: const Icon(Icons.close),
onPressed: onRemove,
tooltip: l10n.removeFile,
),
);
}
Widget _buildPreview() {
if (file.isImage && file.bytes != null) {
return ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.memory(
file.bytes!,
width: 48,
height: 48,
fit: BoxFit.cover,
),
);
}
return _buildIcon();
}
Widget _buildIcon() {
return Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: _getFileColor().withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Icon(
_getFileIcon(),
color: _getFileColor(),
),
);
}
String _formatFileInfo(AppLocalizations l10n) {
final size = _formatSize(file.size, l10n);
final type = _getFileTypeLabel(l10n);
return '$type - $size';
}
String _formatSize(int bytes, AppLocalizations l10n) {
if (bytes < 1024) return l10n.fileSizeBytes(bytes);
if (bytes < 1024 * 1024) {
return l10n.fileSizeKB((bytes / 1024).toStringAsFixed(1));
}
return l10n.fileSizeMB((bytes / (1024 * 1024)).toStringAsFixed(1));
}
String _getFileTypeLabel(AppLocalizations l10n) {
final ext = file.extension.toLowerCase();
switch (ext) {
case 'pdf':
return l10n.fileTypePdf;
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
case 'webp':
return l10n.fileTypeImages;
case 'mp4':
case 'mov':
case 'avi':
return l10n.fileTypeVideos;
case 'mp3':
case 'wav':
case 'aac':
return l10n.fileTypeAudio;
case 'doc':
case 'docx':
return l10n.fileTypeDocument;
case 'xls':
case 'xlsx':
return l10n.fileTypeSpreadsheet;
case 'zip':
case 'rar':
return l10n.fileTypeArchive;
default:
return ext.toUpperCase();
}
}
IconData _getFileIcon() {
final ext = file.extension.toLowerCase();
switch (ext) {
case 'pdf':
return Icons.picture_as_pdf;
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
return Icons.image;
case 'mp4':
case 'mov':
return Icons.videocam;
case 'mp3':
case 'wav':
return Icons.audiotrack;
case 'doc':
case 'docx':
return Icons.description;
case 'xls':
case 'xlsx':
return Icons.table_chart;
case 'zip':
case 'rar':
return Icons.archive;
default:
return Icons.insert_drive_file;
}
}
Color _getFileColor() {
final ext = file.extension.toLowerCase();
switch (ext) {
case 'pdf':
return Colors.red;
case 'jpg':
case 'jpeg':
case 'png':
return Colors.blue;
case 'mp4':
case 'mov':
return Colors.purple;
case 'mp3':
case 'wav':
return Colors.orange;
case 'doc':
case 'docx':
return Colors.blue[800]!;
case 'xls':
case 'xlsx':
return Colors.green[700]!;
default:
return Colors.grey;
}
}
}
Platform-Specific Considerations
iOS Photo Library Access
// iOS requires specific permission descriptions in Info.plist
// These should match your localized permission messages
// Info.plist entries:
// NSPhotoLibraryUsageDescription
// NSPhotoLibraryAddUsageDescription
// NSDocumentsFolderUsageDescription
class IOSFilePickerHelper {
static Future<void> showPhotoLibraryRationale(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
// iOS shows system dialog, but we can show pre-dialog
await showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.photosPermissionTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.photo_library, size: 64, color: Colors.blue),
const SizedBox(height: 16),
Text(l10n.photosPermissionRationale),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.continueButton),
),
],
),
);
}
}
Android Scoped Storage
class AndroidFilePickerHelper {
static Future<bool> checkAndRequestPermissions(
BuildContext context,
FilePickerType type,
) async {
final l10n = AppLocalizations.of(context)!;
// Android 10+ uses scoped storage
// Different permissions needed based on file type
if (type == FilePickerType.image || type == FilePickerType.video) {
// Media files - use READ_MEDIA_IMAGES/VIDEO on Android 13+
if (await _isAndroid13OrHigher()) {
final permission = type == FilePickerType.image
? Permission.photos
: Permission.videos;
return await _requestPermission(context, permission, l10n);
}
}
// For documents, scoped storage handles access
return true;
}
static Future<bool> _isAndroid13OrHigher() async {
// Check Android SDK version
return false; // Implementation depends on device_info_plus
}
static Future<bool> _requestPermission(
BuildContext context,
Permission permission,
AppLocalizations l10n,
) async {
final status = await permission.request();
if (status.isPermanentlyDenied) {
await _showSettingsPrompt(context, l10n);
return false;
}
return status.isGranted;
}
static Future<void> _showSettingsPrompt(
BuildContext context,
AppLocalizations l10n,
) async {
await showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.permissionRequired),
content: Text(l10n.permissionSettingsMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancel),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
openAppSettings();
},
child: Text(l10n.openSettings),
),
],
),
);
}
}
Testing File Picker Localization
Widget Tests
void main() {
group('LocalizedFilePicker', () {
testWidgets('displays localized button label', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
locale: const Locale('es'),
home: Scaffold(
body: LocalizedFilePicker(
type: FilePickerType.image,
onFilesPicked: (_) {},
),
),
),
);
expect(find.text('Seleccionar imagen'), findsOneWidget);
});
testWidgets('shows localized error for oversized file', (tester) async {
// Test implementation
});
});
}
Best Practices
- Always explain permissions - Tell users why you need file access
- Handle all error states - Provide clear, localized error messages
- Show progress feedback - Users need to know uploads are working
- Support RTL layouts - File lists and progress indicators must work RTL
- Test on all platforms - iOS and Android have different permission flows
- Use appropriate file type labels - Match user expectations per locale
Conclusion
Localized file picker implementations improve user trust and reduce confusion. By providing clear permission explanations, progress feedback, and error messages in the user's language, you create a more professional file management experience.
Remember to test file operations across all supported locales and platforms, paying special attention to permission dialogs and error states that users encounter most frequently.