← Back to Blog

Flutter Dialog Localization: Alert, Confirmation, and Custom Dialogs

flutterdialogalertlocalizationconfirmationmodals

Flutter Dialog Localization: Alert, Confirmation, and Custom Dialogs

Dialogs are critical UI components for user interactions, confirmations, and important messages. Proper localization ensures users worldwide understand dialog content and can make informed decisions. This guide covers everything you need to know about localizing dialogs in Flutter.

Understanding Dialog Localization

Dialog localization involves several key elements:

  1. Dialog titles - Clear, descriptive headers
  2. Content messages - Explanatory text and descriptions
  3. Action buttons - Confirm, cancel, and custom actions
  4. Input labels - Form fields within dialogs
  5. Error messages - Validation feedback
  6. Accessibility - Screen reader support

Setting Up Dialog Localization

ARB File Structure

{
  "@@locale": "en",

  "dialogConfirmTitle": "Confirm Action",
  "@dialogConfirmTitle": {
    "description": "Generic confirmation dialog title"
  },

  "dialogConfirmMessage": "Are you sure you want to proceed?",
  "@dialogConfirmMessage": {
    "description": "Generic confirmation message"
  },

  "dialogDeleteTitle": "Delete {item}?",
  "@dialogDeleteTitle": {
    "description": "Delete confirmation title",
    "placeholders": {
      "item": {"type": "String"}
    }
  },

  "dialogDeleteMessage": "This action cannot be undone. All data associated with {item} will be permanently removed.",
  "@dialogDeleteMessage": {
    "description": "Delete confirmation message",
    "placeholders": {
      "item": {"type": "String"}
    }
  },

  "dialogDeleteMultipleTitle": "Delete {count, plural, =1{1 item} other{{count} items}}?",
  "@dialogDeleteMultipleTitle": {
    "description": "Delete multiple items title",
    "placeholders": {
      "count": {"type": "int"}
    }
  },

  "dialogCancel": "Cancel",
  "@dialogCancel": {
    "description": "Cancel button text"
  },

  "dialogConfirm": "Confirm",
  "@dialogConfirm": {
    "description": "Confirm button text"
  },

  "dialogDelete": "Delete",
  "@dialogDelete": {
    "description": "Delete button text"
  },

  "dialogSave": "Save",
  "@dialogSave": {
    "description": "Save button text"
  },

  "dialogDiscard": "Discard",
  "@dialogDiscard": {
    "description": "Discard button text"
  },

  "dialogOk": "OK",
  "@dialogOk": {
    "description": "OK button text"
  },

  "dialogClose": "Close",
  "@dialogClose": {
    "description": "Close button text"
  },

  "dialogYes": "Yes",
  "@dialogYes": {
    "description": "Yes button text"
  },

  "dialogNo": "No",
  "@dialogNo": {
    "description": "No button text"
  },

  "dialogErrorTitle": "Error",
  "@dialogErrorTitle": {
    "description": "Error dialog title"
  },

  "dialogSuccessTitle": "Success",
  "@dialogSuccessTitle": {
    "description": "Success dialog title"
  },

  "dialogWarningTitle": "Warning",
  "@dialogWarningTitle": {
    "description": "Warning dialog title"
  },

  "dialogUnsavedChangesTitle": "Unsaved Changes",
  "@dialogUnsavedChangesTitle": {
    "description": "Unsaved changes dialog title"
  },

  "dialogUnsavedChangesMessage": "You have unsaved changes. Do you want to save them before leaving?",
  "@dialogUnsavedChangesMessage": {
    "description": "Unsaved changes message"
  },

  "dialogLogoutTitle": "Log Out",
  "@dialogLogoutTitle": {
    "description": "Logout confirmation title"
  },

  "dialogLogoutMessage": "Are you sure you want to log out?",
  "@dialogLogoutMessage": {
    "description": "Logout confirmation message"
  },

  "dialogPermissionTitle": "Permission Required",
  "@dialogPermissionTitle": {
    "description": "Permission dialog title"
  },

  "dialogPermissionMessage": "This app needs {permission} access to continue.",
  "@dialogPermissionMessage": {
    "description": "Permission request message",
    "placeholders": {
      "permission": {"type": "String"}
    }
  },

  "dialogOpenSettings": "Open Settings",
  "@dialogOpenSettings": {
    "description": "Open settings button"
  }
}

Spanish Translations

{
  "@@locale": "es",

  "dialogConfirmTitle": "Confirmar acción",
  "dialogConfirmMessage": "¿Estás seguro de que deseas continuar?",
  "dialogDeleteTitle": "¿Eliminar {item}?",
  "dialogDeleteMessage": "Esta acción no se puede deshacer. Todos los datos asociados con {item} se eliminarán permanentemente.",
  "dialogDeleteMultipleTitle": "¿Eliminar {count, plural, =1{1 elemento} other{{count} elementos}}?",
  "dialogCancel": "Cancelar",
  "dialogConfirm": "Confirmar",
  "dialogDelete": "Eliminar",
  "dialogSave": "Guardar",
  "dialogDiscard": "Descartar",
  "dialogOk": "Aceptar",
  "dialogClose": "Cerrar",
  "dialogYes": "Sí",
  "dialogNo": "No",
  "dialogErrorTitle": "Error",
  "dialogSuccessTitle": "Éxito",
  "dialogWarningTitle": "Advertencia",
  "dialogUnsavedChangesTitle": "Cambios sin guardar",
  "dialogUnsavedChangesMessage": "Tienes cambios sin guardar. ¿Deseas guardarlos antes de salir?",
  "dialogLogoutTitle": "Cerrar sesión",
  "dialogLogoutMessage": "¿Estás seguro de que deseas cerrar sesión?",
  "dialogPermissionTitle": "Permiso requerido",
  "dialogPermissionMessage": "Esta aplicación necesita acceso a {permission} para continuar.",
  "dialogOpenSettings": "Abrir ajustes"
}

Japanese Translations

{
  "@@locale": "ja",

  "dialogConfirmTitle": "確認",
  "dialogConfirmMessage": "続行してもよろしいですか?",
  "dialogDeleteTitle": "{item}を削除しますか?",
  "dialogDeleteMessage": "この操作は取り消せません。{item}に関連するすべてのデータが完全に削除されます。",
  "dialogDeleteMultipleTitle": "{count}件のアイテムを削除しますか?",
  "dialogCancel": "キャンセル",
  "dialogConfirm": "確認",
  "dialogDelete": "削除",
  "dialogSave": "保存",
  "dialogDiscard": "破棄",
  "dialogOk": "OK",
  "dialogClose": "閉じる",
  "dialogYes": "はい",
  "dialogNo": "いいえ",
  "dialogErrorTitle": "エラー",
  "dialogSuccessTitle": "成功",
  "dialogWarningTitle": "警告",
  "dialogUnsavedChangesTitle": "未保存の変更",
  "dialogUnsavedChangesMessage": "未保存の変更があります。終了する前に保存しますか?",
  "dialogLogoutTitle": "ログアウト",
  "dialogLogoutMessage": "ログアウトしてもよろしいですか?",
  "dialogPermissionTitle": "権限が必要です",
  "dialogPermissionMessage": "続行するには{permission}へのアクセスが必要です。",
  "dialogOpenSettings": "設定を開く"
}

Building Localized Dialog Components

Dialog Service

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class DialogService {
  static Future<bool?> showConfirmation(
    BuildContext context, {
    required String title,
    required String message,
    String? confirmText,
    String? cancelText,
    bool isDangerous = false,
  }) async {
    final l10n = AppLocalizations.of(context)!;

    return showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(title),
        content: Text(message),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context, false),
            child: Text(cancelText ?? l10n.dialogCancel),
          ),
          TextButton(
            onPressed: () => Navigator.pop(context, true),
            style: isDangerous
                ? TextButton.styleFrom(
                    foregroundColor: Theme.of(context).colorScheme.error,
                  )
                : null,
            child: Text(confirmText ?? l10n.dialogConfirm),
          ),
        ],
      ),
    );
  }

  static Future<bool?> showDeleteConfirmation(
    BuildContext context, {
    required String itemName,
  }) async {
    final l10n = AppLocalizations.of(context)!;

    return showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(l10n.dialogDeleteTitle(itemName)),
        content: Text(l10n.dialogDeleteMessage(itemName)),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context, false),
            child: Text(l10n.dialogCancel),
          ),
          TextButton(
            onPressed: () => Navigator.pop(context, true),
            style: TextButton.styleFrom(
              foregroundColor: Theme.of(context).colorScheme.error,
            ),
            child: Text(l10n.dialogDelete),
          ),
        ],
      ),
    );
  }

  static Future<bool?> showDeleteMultipleConfirmation(
    BuildContext context, {
    required int count,
  }) async {
    final l10n = AppLocalizations.of(context)!;

    return showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(l10n.dialogDeleteMultipleTitle(count)),
        content: Text(l10n.dialogDeleteMessage('these items')),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context, false),
            child: Text(l10n.dialogCancel),
          ),
          TextButton(
            onPressed: () => Navigator.pop(context, true),
            style: TextButton.styleFrom(
              foregroundColor: Theme.of(context).colorScheme.error,
            ),
            child: Text(l10n.dialogDelete),
          ),
        ],
      ),
    );
  }

  static Future<UnsavedChangesResult?> showUnsavedChanges(
    BuildContext context,
  ) async {
    final l10n = AppLocalizations.of(context)!;

    return showDialog<UnsavedChangesResult>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(l10n.dialogUnsavedChangesTitle),
        content: Text(l10n.dialogUnsavedChangesMessage),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context, UnsavedChangesResult.discard),
            child: Text(l10n.dialogDiscard),
          ),
          TextButton(
            onPressed: () => Navigator.pop(context, UnsavedChangesResult.cancel),
            child: Text(l10n.dialogCancel),
          ),
          ElevatedButton(
            onPressed: () => Navigator.pop(context, UnsavedChangesResult.save),
            child: Text(l10n.dialogSave),
          ),
        ],
      ),
    );
  }

  static Future<void> showError(
    BuildContext context, {
    required String message,
  }) async {
    final l10n = AppLocalizations.of(context)!;

    return showDialog<void>(
      context: context,
      builder: (context) => AlertDialog(
        title: Row(
          children: [
            Icon(Icons.error, color: Theme.of(context).colorScheme.error),
            const SizedBox(width: 8),
            Text(l10n.dialogErrorTitle),
          ],
        ),
        content: Text(message),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text(l10n.dialogOk),
          ),
        ],
      ),
    );
  }

  static Future<void> showSuccess(
    BuildContext context, {
    required String message,
  }) async {
    final l10n = AppLocalizations.of(context)!;

    return showDialog<void>(
      context: context,
      builder: (context) => AlertDialog(
        title: Row(
          children: [
            const Icon(Icons.check_circle, color: Colors.green),
            const SizedBox(width: 8),
            Text(l10n.dialogSuccessTitle),
          ],
        ),
        content: Text(message),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text(l10n.dialogOk),
          ),
        ],
      ),
    );
  }

  static Future<bool?> showLogoutConfirmation(BuildContext context) async {
    final l10n = AppLocalizations.of(context)!;

    return showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(l10n.dialogLogoutTitle),
        content: Text(l10n.dialogLogoutMessage),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context, false),
            child: Text(l10n.dialogCancel),
          ),
          TextButton(
            onPressed: () => Navigator.pop(context, true),
            child: Text(l10n.dialogLogout),
          ),
        ],
      ),
    );
  }
}

enum UnsavedChangesResult { save, discard, cancel }

Input Dialog

class InputDialog extends StatefulWidget {
  final String title;
  final String? initialValue;
  final String? hintText;
  final String? labelText;
  final int? maxLength;
  final TextInputType? keyboardType;
  final String? Function(String?)? validator;

  const InputDialog({
    super.key,
    required this.title,
    this.initialValue,
    this.hintText,
    this.labelText,
    this.maxLength,
    this.keyboardType,
    this.validator,
  });

  static Future<String?> show(
    BuildContext context, {
    required String title,
    String? initialValue,
    String? hintText,
    String? labelText,
    int? maxLength,
    TextInputType? keyboardType,
    String? Function(String?)? validator,
  }) {
    return showDialog<String>(
      context: context,
      builder: (context) => InputDialog(
        title: title,
        initialValue: initialValue,
        hintText: hintText,
        labelText: labelText,
        maxLength: maxLength,
        keyboardType: keyboardType,
        validator: validator,
      ),
    );
  }

  @override
  State<InputDialog> createState() => _InputDialogState();
}

class _InputDialogState extends State<InputDialog> {
  late TextEditingController _controller;
  final _formKey = GlobalKey<FormState>();

  @override
  void initState() {
    super.initState();
    _controller = TextEditingController(text: widget.initialValue);
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return AlertDialog(
      title: Text(widget.title),
      content: Form(
        key: _formKey,
        child: TextFormField(
          controller: _controller,
          decoration: InputDecoration(
            hintText: widget.hintText,
            labelText: widget.labelText,
          ),
          maxLength: widget.maxLength,
          keyboardType: widget.keyboardType,
          validator: widget.validator,
          autofocus: true,
        ),
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: Text(l10n.dialogCancel),
        ),
        TextButton(
          onPressed: () {
            if (_formKey.currentState?.validate() ?? false) {
              Navigator.pop(context, _controller.text);
            }
          },
          child: Text(l10n.dialogConfirm),
        ),
      ],
    );
  }
}

Selection Dialog

class SelectionDialog<T> extends StatelessWidget {
  final String title;
  final List<SelectionOption<T>> options;
  final T? selectedValue;

  const SelectionDialog({
    super.key,
    required this.title,
    required this.options,
    this.selectedValue,
  });

  static Future<T?> show<T>(
    BuildContext context, {
    required String title,
    required List<SelectionOption<T>> options,
    T? selectedValue,
  }) {
    return showDialog<T>(
      context: context,
      builder: (context) => SelectionDialog<T>(
        title: title,
        options: options,
        selectedValue: selectedValue,
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return AlertDialog(
      title: Text(title),
      content: SingleChildScrollView(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: options.map((option) {
            final isSelected = option.value == selectedValue;
            return ListTile(
              title: Text(option.label),
              subtitle: option.description != null
                  ? Text(option.description!)
                  : null,
              leading: isSelected
                  ? Icon(Icons.check, color: Theme.of(context).colorScheme.primary)
                  : const SizedBox(width: 24),
              onTap: () => Navigator.pop(context, option.value),
            );
          }).toList(),
        ),
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: Text(l10n.dialogCancel),
        ),
      ],
    );
  }
}

class SelectionOption<T> {
  final T value;
  final String label;
  final String? description;

  const SelectionOption({
    required this.value,
    required this.label,
    this.description,
  });
}

Custom Dialog with Form

class FormDialog extends StatefulWidget {
  const FormDialog({super.key});

  static Future<FormData?> show(BuildContext context) {
    return showDialog<FormData>(
      context: context,
      builder: (context) => const FormDialog(),
    );
  }

  @override
  State<FormDialog> createState() => _FormDialogState();
}

class _FormDialogState extends State<FormDialog> {
  final _formKey = GlobalKey<FormState>();
  final _nameController = TextEditingController();
  final _emailController = TextEditingController();

  @override
  void dispose() {
    _nameController.dispose();
    _emailController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return AlertDialog(
      title: Text(l10n.dialogAddContact),
      content: Form(
        key: _formKey,
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            TextFormField(
              controller: _nameController,
              decoration: InputDecoration(
                labelText: l10n.formFieldName,
                hintText: l10n.formFieldNameHint,
              ),
              validator: (value) {
                if (value?.isEmpty ?? true) {
                  return l10n.validationRequired;
                }
                return null;
              },
            ),
            const SizedBox(height: 16),
            TextFormField(
              controller: _emailController,
              decoration: InputDecoration(
                labelText: l10n.formFieldEmail,
                hintText: l10n.formFieldEmailHint,
              ),
              keyboardType: TextInputType.emailAddress,
              validator: (value) {
                if (value?.isEmpty ?? true) {
                  return l10n.validationRequired;
                }
                if (!value!.contains('@')) {
                  return l10n.validationEmailInvalid;
                }
                return null;
              },
            ),
          ],
        ),
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: Text(l10n.dialogCancel),
        ),
        ElevatedButton(
          onPressed: _submit,
          child: Text(l10n.dialogSave),
        ),
      ],
    );
  }

  void _submit() {
    if (_formKey.currentState?.validate() ?? false) {
      Navigator.pop(
        context,
        FormData(
          name: _nameController.text,
          email: _emailController.text,
        ),
      );
    }
  }
}

class FormData {
  final String name;
  final String email;

  FormData({required this.name, required this.email});
}

Accessibility

Screen Reader Support

class AccessibleDialog extends StatelessWidget {
  final String title;
  final String message;

  const AccessibleDialog({
    super.key,
    required this.title,
    required this.message,
  });

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return AlertDialog(
      title: Semantics(
        header: true,
        child: Text(title),
      ),
      content: Semantics(
        liveRegion: true,
        child: Text(message),
      ),
      actions: [
        Semantics(
          button: true,
          label: l10n.dialogCancel,
          child: TextButton(
            onPressed: () => Navigator.pop(context, false),
            child: Text(l10n.dialogCancel),
          ),
        ),
        Semantics(
          button: true,
          label: l10n.dialogConfirm,
          child: ElevatedButton(
            onPressed: () => Navigator.pop(context, true),
            child: Text(l10n.dialogConfirm),
          ),
        ),
      ],
    );
  }
}

Testing Dialog Localization

void main() {
  group('Dialog Localization', () {
    testWidgets('displays localized delete confirmation', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          locale: const Locale('es'),
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: Builder(
            builder: (context) => Scaffold(
              body: ElevatedButton(
                onPressed: () => DialogService.showDeleteConfirmation(
                  context,
                  itemName: 'Document',
                ),
                child: const Text('Delete'),
              ),
            ),
          ),
        ),
      );

      await tester.tap(find.text('Delete'));
      await tester.pumpAndSettle();

      expect(find.text('¿Eliminar Document?'), findsOneWidget);
      expect(find.text('Cancelar'), findsOneWidget);
      expect(find.text('Eliminar'), findsOneWidget);
    });
  });
}

Best Practices

  1. Use clear, concise titles - Summarize the action or question
  2. Provide context in messages - Explain consequences of actions
  3. Use appropriate button labels - Match action to button text
  4. Mark dangerous actions - Use red/error colors for destructive actions
  5. Support keyboard navigation - Ensure dialogs work with keyboard
  6. Test all languages - Verify layout with different text lengths

Conclusion

Proper dialog localization ensures users worldwide can understand and interact with important app prompts. By following these patterns, your Flutter dialogs will communicate effectively in any language.

Additional Resources