Flutter ProgressIndicator Localization: Loading States, Progress Messages, and Accessibility
Progress indicators are essential for communicating loading states and operation progress in Flutter apps. Proper localization of progress messages, percentage displays, and accessibility announcements ensures users worldwide understand what's happening in your app. This guide covers everything you need to know about localizing progress indicators effectively.
Understanding Progress Indicator Localization Needs
Progress indicators require localization for:
- Status messages: Loading, processing, downloading descriptions
- Progress percentages: Number formatting per locale
- Time estimates: Remaining time in local format
- Accessibility labels: Screen reader announcements
- Error states: Failure and retry messages
- Completion messages: Success notifications
Basic Loading Indicator with Localized Message
Start with a simple loading indicator that shows localized text:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedLoadingIndicator extends StatelessWidget {
final String? customMessage;
const LocalizedLoadingIndicator({
super.key,
this.customMessage,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
customMessage ?? l10n.loadingGeneral,
style: Theme.of(context).textTheme.bodyMedium,
),
],
);
}
}
ARB file entries:
{
"loadingGeneral": "Loading...",
"@loadingGeneral": {
"description": "Generic loading message"
}
}
Context-Specific Loading Messages
Different operations need different loading messages:
enum LoadingContext {
fetchingData,
uploading,
downloading,
processing,
authenticating,
syncing,
searching,
}
class ContextualLoadingIndicator extends StatelessWidget {
final LoadingContext context;
const ContextualLoadingIndicator({
super.key,
required this.context,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Semantics(
label: _getMessage(l10n),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(_getMessage(l10n)),
],
),
);
}
String _getMessage(AppLocalizations l10n) {
switch (context) {
case LoadingContext.fetchingData:
return l10n.loadingFetchingData;
case LoadingContext.uploading:
return l10n.loadingUploading;
case LoadingContext.downloading:
return l10n.loadingDownloading;
case LoadingContext.processing:
return l10n.loadingProcessing;
case LoadingContext.authenticating:
return l10n.loadingAuthenticating;
case LoadingContext.syncing:
return l10n.loadingSyncing;
case LoadingContext.searching:
return l10n.loadingSearching;
}
}
}
ARB entries:
{
"loadingFetchingData": "Fetching data...",
"@loadingFetchingData": {
"description": "Loading message when fetching data from server"
},
"loadingUploading": "Uploading...",
"@loadingUploading": {
"description": "Loading message during file upload"
},
"loadingDownloading": "Downloading...",
"@loadingDownloading": {
"description": "Loading message during file download"
},
"loadingProcessing": "Processing...",
"@loadingProcessing": {
"description": "Loading message during data processing"
},
"loadingAuthenticating": "Signing in...",
"@loadingAuthenticating": {
"description": "Loading message during authentication"
},
"loadingSyncing": "Syncing...",
"@loadingSyncing": {
"description": "Loading message during data sync"
},
"loadingSearching": "Searching...",
"@loadingSearching": {
"description": "Loading message during search operation"
}
}
Linear Progress with Percentage Display
Show progress percentage with proper locale number formatting:
class LocalizedLinearProgress extends StatelessWidget {
final double progress; // 0.0 to 1.0
final String? itemName;
const LocalizedLinearProgress({
super.key,
required this.progress,
this.itemName,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
final percentFormat = NumberFormat.percentPattern(locale.toString());
final percentText = percentFormat.format(progress);
return Semantics(
label: l10n.progressAccessibilityLabel(percentText),
value: percentText,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
itemName != null
? l10n.progressDownloadingItem(itemName!)
: l10n.progressDownloading,
),
Text(
percentText,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: progress,
),
],
),
);
}
}
ARB entries:
{
"progressDownloading": "Downloading",
"@progressDownloading": {
"description": "Label for download progress"
},
"progressDownloadingItem": "Downloading {itemName}",
"@progressDownloadingItem": {
"description": "Label for download progress with item name",
"placeholders": {
"itemName": {
"type": "String"
}
}
},
"progressAccessibilityLabel": "Progress: {percent}",
"@progressAccessibilityLabel": {
"description": "Accessibility label for progress indicator",
"placeholders": {
"percent": {
"type": "String"
}
}
}
}
Progress with Time Remaining
Display estimated time remaining with proper duration formatting:
class ProgressWithTimeRemaining extends StatelessWidget {
final double progress;
final Duration? timeRemaining;
final int bytesDownloaded;
final int totalBytes;
const ProgressWithTimeRemaining({
super.key,
required this.progress,
this.timeRemaining,
required this.bytesDownloaded,
required this.totalBytes,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LinearProgressIndicator(value: progress),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_formatBytes(l10n, locale, bytesDownloaded, totalBytes)),
if (timeRemaining != null)
Text(_formatTimeRemaining(l10n, timeRemaining!)),
],
),
],
);
}
String _formatBytes(
AppLocalizations l10n,
Locale locale,
int downloaded,
int total,
) {
final downloadedMB = downloaded / (1024 * 1024);
final totalMB = total / (1024 * 1024);
final format = NumberFormat('#.#', locale.toString());
return l10n.progressBytesDownloaded(
format.format(downloadedMB),
format.format(totalMB),
);
}
String _formatTimeRemaining(AppLocalizations l10n, Duration duration) {
if (duration.inHours > 0) {
return l10n.progressTimeRemainingHours(
duration.inHours,
duration.inMinutes.remainder(60),
);
} else if (duration.inMinutes > 0) {
return l10n.progressTimeRemainingMinutes(
duration.inMinutes,
duration.inSeconds.remainder(60),
);
} else {
return l10n.progressTimeRemainingSeconds(duration.inSeconds);
}
}
}
ARB entries:
{
"progressBytesDownloaded": "{downloaded} MB of {total} MB",
"@progressBytesDownloaded": {
"description": "Shows downloaded vs total bytes",
"placeholders": {
"downloaded": {"type": "String"},
"total": {"type": "String"}
}
},
"progressTimeRemainingHours": "{hours}h {minutes}m remaining",
"@progressTimeRemainingHours": {
"description": "Time remaining in hours and minutes",
"placeholders": {
"hours": {"type": "int"},
"minutes": {"type": "int"}
}
},
"progressTimeRemainingMinutes": "{minutes}m {seconds}s remaining",
"@progressTimeRemainingMinutes": {
"description": "Time remaining in minutes and seconds",
"placeholders": {
"minutes": {"type": "int"},
"seconds": {"type": "int"}
}
},
"progressTimeRemainingSeconds": "{seconds}s remaining",
"@progressTimeRemainingSeconds": {
"description": "Time remaining in seconds only",
"placeholders": {
"seconds": {"type": "int"}
}
}
}
Multi-Step Progress Indicator
Show progress through multiple steps:
class MultiStepProgress extends StatelessWidget {
final int currentStep;
final int totalSteps;
final String currentStepName;
const MultiStepProgress({
super.key,
required this.currentStep,
required this.totalSteps,
required this.currentStepName,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
currentStepName,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
l10n.progressStepCounter(currentStep, totalSteps),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
const SizedBox(height: 12),
Row(
children: List.generate(totalSteps, (index) {
final isCompleted = index < currentStep;
final isCurrent = index == currentStep;
return Expanded(
child: Container(
height: 4,
margin: EdgeInsets.only(
right: index < totalSteps - 1 ? 4 : 0,
),
decoration: BoxDecoration(
color: isCompleted
? Theme.of(context).colorScheme.primary
: isCurrent
? Theme.of(context).colorScheme.primary.withOpacity(0.5)
: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(2),
),
),
);
}),
),
],
);
}
}
// Usage with localized step names
class SetupWizard extends StatefulWidget {
@override
State<SetupWizard> createState() => _SetupWizardState();
}
class _SetupWizardState extends State<SetupWizard> {
int _currentStep = 0;
List<String> _getStepNames(AppLocalizations l10n) {
return [
l10n.setupStepAccount,
l10n.setupStepProfile,
l10n.setupStepPreferences,
l10n.setupStepComplete,
];
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final stepNames = _getStepNames(l10n);
return Column(
children: [
MultiStepProgress(
currentStep: _currentStep,
totalSteps: stepNames.length,
currentStepName: stepNames[_currentStep],
),
// Step content...
],
);
}
}
ARB entries:
{
"progressStepCounter": "Step {current} of {total}",
"@progressStepCounter": {
"description": "Shows current step out of total steps",
"placeholders": {
"current": {"type": "int"},
"total": {"type": "int"}
}
},
"setupStepAccount": "Create account",
"setupStepProfile": "Set up profile",
"setupStepPreferences": "Choose preferences",
"setupStepComplete": "Complete setup"
}
Upload Progress with File Details
Handle file upload progress with size formatting:
class LocalizedUploadProgress extends StatelessWidget {
final String fileName;
final int uploadedBytes;
final int totalBytes;
final UploadState state;
const LocalizedUploadProgress({
super.key,
required this.fileName,
required this.uploadedBytes,
required this.totalBytes,
required this.state,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(_getStateIcon()),
const SizedBox(width: 8),
Expanded(
child: Text(
fileName,
overflow: TextOverflow.ellipsis,
),
),
Text(
_getStateText(l10n),
style: TextStyle(color: _getStateColor(context)),
),
],
),
if (state == UploadState.uploading) ...[
const SizedBox(height: 12),
LinearProgressIndicator(
value: uploadedBytes / totalBytes,
),
const SizedBox(height: 8),
Text(
_formatProgress(l10n, locale),
style: Theme.of(context).textTheme.bodySmall,
),
],
if (state == UploadState.failed) ...[
const SizedBox(height: 8),
Text(
l10n.uploadFailedMessage,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
TextButton(
onPressed: () {},
child: Text(l10n.uploadRetry),
),
],
],
),
),
);
}
String _formatProgress(AppLocalizations l10n, Locale locale) {
final format = NumberFormat.compact(locale: locale.toString());
final uploaded = _formatBytes(uploadedBytes);
final total = _formatBytes(totalBytes);
return l10n.uploadProgress(uploaded, total);
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '${bytes}B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)}KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)}MB';
}
String _getStateText(AppLocalizations l10n) {
switch (state) {
case UploadState.pending:
return l10n.uploadStatePending;
case UploadState.uploading:
return l10n.uploadStateUploading;
case UploadState.completed:
return l10n.uploadStateCompleted;
case UploadState.failed:
return l10n.uploadStateFailed;
}
}
IconData _getStateIcon() {
switch (state) {
case UploadState.pending:
return Icons.hourglass_empty;
case UploadState.uploading:
return Icons.cloud_upload;
case UploadState.completed:
return Icons.check_circle;
case UploadState.failed:
return Icons.error;
}
}
Color _getStateColor(BuildContext context) {
switch (state) {
case UploadState.pending:
return Colors.grey;
case UploadState.uploading:
return Theme.of(context).colorScheme.primary;
case UploadState.completed:
return Colors.green;
case UploadState.failed:
return Theme.of(context).colorScheme.error;
}
}
}
enum UploadState { pending, uploading, completed, failed }
ARB entries:
{
"uploadProgress": "{uploaded} of {total}",
"@uploadProgress": {
"description": "Upload progress showing bytes uploaded vs total",
"placeholders": {
"uploaded": {"type": "String"},
"total": {"type": "String"}
}
},
"uploadStatePending": "Waiting",
"uploadStateUploading": "Uploading",
"uploadStateCompleted": "Complete",
"uploadStateFailed": "Failed",
"uploadFailedMessage": "Upload failed. Please check your connection and try again.",
"uploadRetry": "Retry upload"
}
Accessible Progress Announcements
Implement proper screen reader announcements for progress updates:
class AccessibleProgressIndicator extends StatefulWidget {
final Stream<double> progressStream;
const AccessibleProgressIndicator({
super.key,
required this.progressStream,
});
@override
State<AccessibleProgressIndicator> createState() =>
_AccessibleProgressIndicatorState();
}
class _AccessibleProgressIndicatorState
extends State<AccessibleProgressIndicator> {
double _progress = 0;
int _lastAnnouncedPercent = -1;
@override
void initState() {
super.initState();
widget.progressStream.listen(_onProgressUpdate);
}
void _onProgressUpdate(double progress) {
setState(() => _progress = progress);
// Announce every 25%
final currentPercent = (progress * 100).toInt();
final milestone = (currentPercent ~/ 25) * 25;
if (milestone > _lastAnnouncedPercent && milestone <= 100) {
_lastAnnouncedPercent = milestone;
_announceProgress(milestone);
}
}
void _announceProgress(int percent) {
final l10n = AppLocalizations.of(context)!;
SemanticsService.announce(
percent == 100
? l10n.progressComplete
: l10n.progressAnnouncement(percent),
TextDirection.ltr,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
final percentFormat = NumberFormat.percentPattern(locale.toString());
return Semantics(
label: l10n.progressLabel,
value: percentFormat.format(_progress),
child: Column(
children: [
LinearProgressIndicator(value: _progress),
const SizedBox(height: 8),
Text(percentFormat.format(_progress)),
],
),
);
}
}
ARB entries:
{
"progressLabel": "Operation progress",
"@progressLabel": {
"description": "Accessibility label for progress indicator"
},
"progressAnnouncement": "{percent}% complete",
"@progressAnnouncement": {
"description": "Screen reader announcement for progress",
"placeholders": {
"percent": {"type": "int"}
}
},
"progressComplete": "Operation complete",
"@progressComplete": {
"description": "Screen reader announcement when progress reaches 100%"
}
}
Indeterminate Progress with Animated Messages
Show rotating messages during long operations:
class AnimatedLoadingMessages extends StatefulWidget {
const AnimatedLoadingMessages({super.key});
@override
State<AnimatedLoadingMessages> createState() =>
_AnimatedLoadingMessagesState();
}
class _AnimatedLoadingMessagesState extends State<AnimatedLoadingMessages> {
int _messageIndex = 0;
late Timer _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 3), (_) {
setState(() {
_messageIndex = (_messageIndex + 1) % 4;
});
});
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
List<String> _getMessages(AppLocalizations l10n) {
return [
l10n.loadingMessage1,
l10n.loadingMessage2,
l10n.loadingMessage3,
l10n.loadingMessage4,
];
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final messages = _getMessages(l10n);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 24),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Text(
messages[_messageIndex],
key: ValueKey(_messageIndex),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
),
],
);
}
}
ARB entries:
{
"loadingMessage1": "Preparing your data...",
"loadingMessage2": "Almost there...",
"loadingMessage3": "This might take a moment...",
"loadingMessage4": "Thanks for your patience..."
}
Skeleton Loading with Localized Placeholders
Implement skeleton screens with localized fallback text:
class LocalizedSkeletonCard extends StatelessWidget {
final bool isLoading;
final String? title;
final String? subtitle;
const LocalizedSkeletonCard({
super.key,
required this.isLoading,
this.title,
this.subtitle,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (isLoading) {
return Semantics(
label: l10n.skeletonLoading,
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SkeletonBox(width: 200, height: 20),
const SizedBox(height: 8),
_SkeletonBox(width: 150, height: 16),
],
),
),
),
);
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title ?? l10n.placeholderTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
subtitle ?? l10n.placeholderSubtitle,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
);
}
}
class _SkeletonBox extends StatelessWidget {
final double width;
final double height;
const _SkeletonBox({
required this.width,
required this.height,
});
@override
Widget build(BuildContext context) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(4),
),
);
}
}
ARB entries:
{
"skeletonLoading": "Content loading",
"@skeletonLoading": {
"description": "Accessibility label for skeleton loading state"
},
"placeholderTitle": "Loading title...",
"placeholderSubtitle": "Loading content..."
}
Testing Progress Indicator Localization
Write comprehensive tests for progress indicators:
void main() {
group('LocalizedProgressIndicator Tests', () {
testWidgets('shows correct loading message', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: const Scaffold(
body: ContextualLoadingIndicator(
context: LoadingContext.downloading,
),
),
),
);
expect(find.text('Downloading...'), findsOneWidget);
});
testWidgets('formats percentage per locale', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('de'),
home: const Scaffold(
body: LocalizedLinearProgress(progress: 0.75),
),
),
);
// German uses comma as decimal separator
expect(find.textContaining('75'), findsOneWidget);
});
testWidgets('announces progress milestones', (tester) async {
final progressController = StreamController<double>();
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: AccessibleProgressIndicator(
progressStream: progressController.stream,
),
),
),
);
progressController.add(0.25);
await tester.pump();
// Verify semantic announcement was made
// (Implementation depends on test setup)
});
});
}
Best Practices Summary
- Use contextual messages: Different operations need different loading text
- Format numbers per locale: Percentages and bytes should respect locale
- Announce progress milestones: Screen readers need periodic updates
- Show time estimates: Help users know how long to wait
- Handle error states: Provide localized retry messages
- Use semantic labels: Every indicator needs accessibility descriptions
- Keep messages concise: Loading text should be brief but informative
Conclusion
Localizing progress indicators in Flutter requires attention to loading messages, percentage formatting, time estimates, and accessibility announcements. By implementing context-specific messages, proper number formatting per locale, and comprehensive screen reader support, you ensure users worldwide understand exactly what's happening in your app during loading states.