← Back to Blog

Flutter Camera UI Localization: Permissions, Controls, and Photo Gallery

fluttercamerapermissionsgallerylocalizationui

Flutter Camera Localization: Multilingual Camera UI and Permissions

Build camera apps that speak your users' language. This guide covers localizing camera permissions, UI controls, error messages, and accessibility features for Flutter camera applications.

Camera Localization Challenges

Camera apps need localization for:

  • Permission dialogs - System and custom permission requests
  • Camera controls - Flash, switch camera, capture buttons
  • Error messages - No camera, permission denied, storage full
  • Accessibility labels - Screen reader support
  • Gallery/media labels - Photo, video, album names

Setting Up Camera Permissions Localization

iOS Info.plist Localization

Create localized InfoPlist.strings files:

ios/
├── Runner/
│   ├── en.lproj/
│   │   └── InfoPlist.strings
│   ├── es.lproj/
│   │   └── InfoPlist.strings
│   ├── fr.lproj/
│   │   └── InfoPlist.strings
│   └── ar.lproj/
│       └── InfoPlist.strings

en.lproj/InfoPlist.strings:

"NSCameraUsageDescription" = "This app needs camera access to take photos and videos.";
"NSMicrophoneUsageDescription" = "This app needs microphone access to record audio with videos.";
"NSPhotoLibraryUsageDescription" = "This app needs photo library access to save and select photos.";
"NSPhotoLibraryAddUsageDescription" = "This app needs permission to save photos to your library.";

es.lproj/InfoPlist.strings:

"NSCameraUsageDescription" = "Esta aplicación necesita acceso a la cámara para tomar fotos y videos.";
"NSMicrophoneUsageDescription" = "Esta aplicación necesita acceso al micrófono para grabar audio con videos.";
"NSPhotoLibraryUsageDescription" = "Esta aplicación necesita acceso a la biblioteca de fotos para guardar y seleccionar fotos.";
"NSPhotoLibraryAddUsageDescription" = "Esta aplicación necesita permiso para guardar fotos en su biblioteca.";

ar.lproj/InfoPlist.strings:

"NSCameraUsageDescription" = "يحتاج هذا التطبيق إلى الوصول إلى الكاميرا لالتقاط الصور ومقاطع الفيديو.";
"NSMicrophoneUsageDescription" = "يحتاج هذا التطبيق إلى الوصول إلى الميكروفون لتسجيل الصوت مع مقاطع الفيديو.";
"NSPhotoLibraryUsageDescription" = "يحتاج هذا التطبيق إلى الوصول إلى مكتبة الصور لحفظ الصور واختيارها.";
"NSPhotoLibraryAddUsageDescription" = "يحتاج هذا التطبيق إلى إذن لحفظ الصور في مكتبتك.";

Android Permissions (No Localization Needed)

Android handles permission dialog localization automatically based on system language:

<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

Localized Permission Request Flow

Pre-Permission Dialog

class LocalizedPermissionHandler {
  final AppLocalizations l10n;

  LocalizedPermissionHandler(this.l10n);

  Future<bool> requestCameraPermission(BuildContext context) async {
    // Check current status
    final status = await Permission.camera.status;

    if (status.isGranted) return true;

    if (status.isDenied) {
      // Show custom pre-permission dialog
      final shouldRequest = await _showPrePermissionDialog(context);
      if (!shouldRequest) return false;

      final result = await Permission.camera.request();
      return result.isGranted;
    }

    if (status.isPermanentlyDenied) {
      await _showSettingsDialog(context);
      return false;
    }

    return false;
  }

  Future<bool> _showPrePermissionDialog(BuildContext context) async {
    return await showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(l10n.cameraPermissionTitle),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(
              Icons.camera_alt,
              size: 64,
              color: Theme.of(context).primaryColor,
            ),
            SizedBox(height: 16),
            Text(l10n.cameraPermissionMessage),
          ],
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context, false),
            child: Text(l10n.notNow),
          ),
          ElevatedButton(
            onPressed: () => Navigator.pop(context, true),
            child: Text(l10n.allowCamera),
          ),
        ],
      ),
    ) ?? false;
  }

  Future<void> _showSettingsDialog(BuildContext context) async {
    await showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(l10n.cameraPermissionDeniedTitle),
        content: Text(l10n.cameraPermissionDeniedMessage),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text(l10n.cancel),
          ),
          ElevatedButton(
            onPressed: () {
              Navigator.pop(context);
              openAppSettings();
            },
            child: Text(l10n.openSettings),
          ),
        ],
      ),
    );
  }
}

Localized Camera UI

Camera Screen with Full Localization

class LocalizedCameraScreen extends StatefulWidget {
  @override
  State<LocalizedCameraScreen> createState() => _LocalizedCameraScreenState();
}

class _LocalizedCameraScreenState extends State<LocalizedCameraScreen> {
  CameraController? _controller;
  bool _isRecording = false;
  FlashMode _flashMode = FlashMode.auto;
  bool _isFrontCamera = false;

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

    return Scaffold(
      backgroundColor: Colors.black,
      body: Stack(
        children: [
          // Camera preview
          _buildCameraPreview(l10n),

          // Top controls
          _buildTopControls(l10n),

          // Bottom controls
          _buildBottomControls(l10n),

          // Mode selector
          _buildModeSelector(l10n),
        ],
      ),
    );
  }

  Widget _buildCameraPreview(AppLocalizations l10n) {
    if (_controller == null || !_controller!.value.isInitialized) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            CircularProgressIndicator(color: Colors.white),
            SizedBox(height: 16),
            Text(
              l10n.initializingCamera,
              style: TextStyle(color: Colors.white),
            ),
          ],
        ),
      );
    }

    return CameraPreview(_controller!);
  }

  Widget _buildTopControls(AppLocalizations l10n) {
    return SafeArea(
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            // Close button
            _buildControlButton(
              icon: Icons.close,
              label: l10n.close,
              onPressed: () => Navigator.pop(context),
            ),

            // Flash control
            _buildFlashButton(l10n),

            // Settings
            _buildControlButton(
              icon: Icons.settings,
              label: l10n.settings,
              onPressed: _openSettings,
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildFlashButton(AppLocalizations l10n) {
    IconData icon;
    String label;

    switch (_flashMode) {
      case FlashMode.auto:
        icon = Icons.flash_auto;
        label = l10n.flashAuto;
        break;
      case FlashMode.always:
        icon = Icons.flash_on;
        label = l10n.flashOn;
        break;
      case FlashMode.off:
        icon = Icons.flash_off;
        label = l10n.flashOff;
        break;
      case FlashMode.torch:
        icon = Icons.flashlight_on;
        label = l10n.flashTorch;
        break;
    }

    return _buildControlButton(
      icon: icon,
      label: label,
      onPressed: _toggleFlash,
    );
  }

  Widget _buildControlButton({
    required IconData icon,
    required String label,
    required VoidCallback onPressed,
  }) {
    return Semantics(
      label: label,
      button: true,
      child: IconButton(
        icon: Icon(icon, color: Colors.white),
        onPressed: onPressed,
        tooltip: label,
      ),
    );
  }

  Widget _buildBottomControls(AppLocalizations l10n) {
    return Positioned(
      bottom: 0,
      left: 0,
      right: 0,
      child: SafeArea(
        child: Padding(
          padding: EdgeInsets.all(24),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              // Gallery button
              _buildControlButton(
                icon: Icons.photo_library,
                label: l10n.gallery,
                onPressed: _openGallery,
              ),

              // Capture button
              _buildCaptureButton(l10n),

              // Switch camera
              _buildControlButton(
                icon: Icons.flip_camera_ios,
                label: _isFrontCamera
                    ? l10n.switchToBackCamera
                    : l10n.switchToFrontCamera,
                onPressed: _switchCamera,
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildCaptureButton(AppLocalizations l10n) {
    return Semantics(
      label: _isRecording ? l10n.stopRecording : l10n.takePhoto,
      button: true,
      child: GestureDetector(
        onTap: _capturePhoto,
        onLongPress: _startVideoRecording,
        onLongPressUp: _stopVideoRecording,
        child: Container(
          width: 72,
          height: 72,
          decoration: BoxDecoration(
            shape: BoxShape.circle,
            border: Border.all(color: Colors.white, width: 4),
            color: _isRecording ? Colors.red : Colors.transparent,
          ),
          child: _isRecording
              ? Icon(Icons.stop, color: Colors.white, size: 32)
              : null,
        ),
      ),
    );
  }

  Widget _buildModeSelector(AppLocalizations l10n) {
    return Positioned(
      bottom: 120,
      left: 0,
      right: 0,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          _buildModeChip(l10n.modePhoto, CameraMode.photo),
          SizedBox(width: 16),
          _buildModeChip(l10n.modeVideo, CameraMode.video),
          SizedBox(width: 16),
          _buildModeChip(l10n.modePortrait, CameraMode.portrait),
        ],
      ),
    );
  }
}

enum CameraMode { photo, video, portrait }

Localized Error Handling

Camera Error Messages

class CameraErrorHandler {
  static String getErrorMessage(
    CameraException exception,
    AppLocalizations l10n,
  ) {
    switch (exception.code) {
      case 'CameraAccessDenied':
        return l10n.errorCameraAccessDenied;
      case 'CameraAccessDeniedWithoutPrompt':
        return l10n.errorCameraAccessDeniedWithoutPrompt;
      case 'CameraAccessRestricted':
        return l10n.errorCameraAccessRestricted;
      case 'AudioAccessDenied':
        return l10n.errorAudioAccessDenied;
      case 'AudioAccessDeniedWithoutPrompt':
        return l10n.errorAudioAccessDeniedWithoutPrompt;
      case 'AudioAccessRestricted':
        return l10n.errorAudioAccessRestricted;
      default:
        return l10n.errorCameraGeneric;
    }
  }

  static String getRecordingError(
    String errorCode,
    AppLocalizations l10n,
  ) {
    switch (errorCode) {
      case 'storage_full':
        return l10n.errorStorageFull;
      case 'max_duration':
        return l10n.errorMaxDurationReached;
      case 'recording_failed':
        return l10n.errorRecordingFailed;
      default:
        return l10n.errorUnknown;
    }
  }
}

// Usage
try {
  await _controller.startVideoRecording();
} on CameraException catch (e) {
  final message = CameraErrorHandler.getErrorMessage(e, l10n);
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(content: Text(message)),
  );
}

No Camera Available State

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

    return Scaffold(
      body: Center(
        child: Padding(
          padding: EdgeInsets.all(32),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(
                Icons.no_photography,
                size: 80,
                color: Colors.grey,
              ),
              SizedBox(height: 24),
              Text(
                l10n.noCameraAvailable,
                style: Theme.of(context).textTheme.headlineSmall,
                textAlign: TextAlign.center,
              ),
              SizedBox(height: 12),
              Text(
                l10n.noCameraDescription,
                style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                  color: Colors.grey[600],
                ),
                textAlign: TextAlign.center,
              ),
              SizedBox(height: 32),
              ElevatedButton.icon(
                onPressed: () => Navigator.pop(context),
                icon: Icon(Icons.arrow_back),
                label: Text(l10n.goBack),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Accessibility for Camera Apps

Screen Reader Support

class AccessibleCameraControls extends StatelessWidget {
  final VoidCallback onCapture;
  final VoidCallback onSwitchCamera;
  final VoidCallback onToggleFlash;
  final FlashMode flashMode;
  final bool isFrontCamera;

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

    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        // Flash with full semantic label
        Semantics(
          label: _getFlashSemanticLabel(l10n),
          hint: l10n.doubleTapToChange,
          button: true,
          child: IconButton(
            icon: Icon(_getFlashIcon()),
            onPressed: onToggleFlash,
          ),
        ),

        // Capture with context-aware label
        Semantics(
          label: l10n.capturePhotoButton,
          hint: l10n.doubleTapToCapture,
          button: true,
          child: GestureDetector(
            onTap: onCapture,
            child: CaptureButton(),
          ),
        ),

        // Switch camera with current state
        Semantics(
          label: isFrontCamera
              ? l10n.switchToBackCameraLabel
              : l10n.switchToFrontCameraLabel,
          hint: l10n.doubleTapToSwitch,
          button: true,
          child: IconButton(
            icon: Icon(Icons.flip_camera_ios),
            onPressed: onSwitchCamera,
          ),
        ),
      ],
    );
  }

  String _getFlashSemanticLabel(AppLocalizations l10n) {
    switch (flashMode) {
      case FlashMode.auto:
        return l10n.flashAutoLabel;
      case FlashMode.always:
        return l10n.flashOnLabel;
      case FlashMode.off:
        return l10n.flashOffLabel;
      case FlashMode.torch:
        return l10n.flashTorchLabel;
    }
  }
}

ARB File Structure

{
  "@@locale": "en",

  "initializingCamera": "Initializing camera...",
  "close": "Close",
  "settings": "Settings",

  "flashAuto": "Auto",
  "flashOn": "On",
  "flashOff": "Off",
  "flashTorch": "Torch",

  "gallery": "Gallery",
  "takePhoto": "Take photo",
  "stopRecording": "Stop recording",
  "switchToBackCamera": "Switch to back camera",
  "switchToFrontCamera": "Switch to front camera",

  "modePhoto": "Photo",
  "modeVideo": "Video",
  "modePortrait": "Portrait",

  "cameraPermissionTitle": "Camera Access Needed",
  "cameraPermissionMessage": "We need camera access to take photos and record videos. Your media stays private on your device.",
  "cameraPermissionDeniedTitle": "Camera Access Denied",
  "cameraPermissionDeniedMessage": "Camera access was denied. Please enable it in Settings to use this feature.",

  "notNow": "Not Now",
  "allowCamera": "Allow Camera",
  "cancel": "Cancel",
  "openSettings": "Open Settings",

  "errorCameraAccessDenied": "Camera access was denied. Please grant permission in settings.",
  "errorCameraAccessDeniedWithoutPrompt": "Camera permission is required. Please enable it in your device settings.",
  "errorCameraAccessRestricted": "Camera access is restricted on this device.",
  "errorAudioAccessDenied": "Microphone access was denied. Videos will be recorded without audio.",
  "errorAudioAccessDeniedWithoutPrompt": "Microphone permission is required for audio recording.",
  "errorAudioAccessRestricted": "Microphone access is restricted on this device.",
  "errorCameraGeneric": "An error occurred with the camera. Please try again.",
  "errorStorageFull": "Storage is full. Please free up space and try again.",
  "errorMaxDurationReached": "Maximum recording duration reached.",
  "errorRecordingFailed": "Recording failed. Please try again.",
  "errorUnknown": "An unexpected error occurred.",

  "noCameraAvailable": "No Camera Available",
  "noCameraDescription": "This device does not have a camera or the camera is not accessible.",
  "goBack": "Go Back",

  "flashAutoLabel": "Flash mode auto, double tap to change",
  "flashOnLabel": "Flash on, double tap to change",
  "flashOffLabel": "Flash off, double tap to change",
  "flashTorchLabel": "Torch on, double tap to change",
  "capturePhotoButton": "Capture photo button",
  "doubleTapToCapture": "Double tap to take a photo, long press to record video",
  "doubleTapToChange": "Double tap to change",
  "doubleTapToSwitch": "Double tap to switch",
  "switchToBackCameraLabel": "Currently front camera, switch to back",
  "switchToFrontCameraLabel": "Currently back camera, switch to front",

  "photoSaved": "Photo saved to gallery",
  "videoSaved": "Video saved to gallery",
  "saveFailed": "Failed to save. Please try again."
}

Conclusion

Camera app localization involves:

  1. iOS permission strings in localized InfoPlist.strings
  2. Custom permission dialogs with clear explanations
  3. All UI controls with translated labels
  4. Error messages for every failure scenario
  5. Accessibility labels for screen readers

With comprehensive localization, your camera app will feel native to users worldwide.

Related Resources