← Back to Blog

Flutter ClipOval Localization: Circular Clipping for Multilingual Apps

flutterclipovalclippingavatarslocalizationaccessibility

Flutter ClipOval Localization: Circular Clipping for Multilingual Apps

ClipOval clips its child to an oval or circular shape, ideal for creating avatars, circular buttons, profile images, and radial progress indicators. When building multilingual apps, ClipOval helps maintain consistent circular UI elements while the content adapts to different languages. This guide covers comprehensive strategies for localizing ClipOval widgets in Flutter multilingual applications.

Understanding ClipOval Localization

ClipOval widgets require localization for:

  • User avatars: Profile pictures with status indicators
  • Circular buttons: Action buttons with icons and labels
  • Status indicators: Online/offline presence markers
  • Progress rings: Circular progress with localized percentages
  • Floating elements: Circular floating action buttons
  • Badge overlays: Notification badges on circular elements

Basic ClipOval with Localized Content

Start with a simple oval clipping example:

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

class LocalizedClipOvalDemo extends StatelessWidget {
  const LocalizedClipOvalDemo({super.key});

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

    return Scaffold(
      appBar: AppBar(title: Text(l10n.clipOvalTitle)),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              l10n.clipOvalDescription,
              style: Theme.of(context).textTheme.bodyLarge,
            ),
            const SizedBox(height: 32),
            Center(
              child: Semantics(
                image: true,
                label: l10n.circularContentAccessibility,
                child: ClipOval(
                  child: Container(
                    width: 150,
                    height: 150,
                    color: Theme.of(context).colorScheme.primaryContainer,
                    child: Center(
                      child: Text(
                        l10n.circularSampleText,
                        style: TextStyle(
                          color: Theme.of(context).colorScheme.onPrimaryContainer,
                          fontSize: 16,
                          fontWeight: FontWeight.w500,
                        ),
                        textAlign: TextAlign.center,
                      ),
                    ),
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

ARB File Structure for ClipOval

{
  "clipOvalTitle": "Circular Clipping",
  "@clipOvalTitle": {
    "description": "Title for clip oval demo"
  },
  "clipOvalDescription": "Create perfect circles and ovals for avatars and buttons",
  "circularContentAccessibility": "Circular content area",
  "circularSampleText": "Circle"
}

User Profile Avatar with Status

Create an avatar component with online/offline status:

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

enum UserStatus { online, away, busy, offline }

class LocalizedProfileAvatar extends StatelessWidget {
  final String userName;
  final String? imageUrl;
  final UserStatus status;
  final double size;
  final VoidCallback? onTap;

  const LocalizedProfileAvatar({
    super.key,
    required this.userName,
    this.imageUrl,
    this.status = UserStatus.offline,
    this.size = 56,
    this.onTap,
  });

  String _getStatusLabel(AppLocalizations l10n) {
    switch (status) {
      case UserStatus.online:
        return l10n.statusOnline;
      case UserStatus.away:
        return l10n.statusAway;
      case UserStatus.busy:
        return l10n.statusBusy;
      case UserStatus.offline:
        return l10n.statusOffline;
    }
  }

  Color _getStatusColor() {
    switch (status) {
      case UserStatus.online:
        return Colors.green;
      case UserStatus.away:
        return Colors.orange;
      case UserStatus.busy:
        return Colors.red;
      case UserStatus.offline:
        return Colors.grey;
    }
  }

  String _getInitials(String name) {
    final parts = name.trim().split(RegExp(r'\s+'));
    if (parts.isEmpty) return '?';
    if (parts.length == 1) {
      return parts[0].isNotEmpty ? parts[0][0].toUpperCase() : '?';
    }
    return '${parts[0][0]}${parts[parts.length - 1][0]}'.toUpperCase();
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final statusLabel = _getStatusLabel(l10n);
    final statusColor = _getStatusColor();
    final initials = _getInitials(userName);

    return Semantics(
      label: l10n.profileAvatarAccessibility(userName, statusLabel),
      button: onTap != null,
      child: GestureDetector(
        onTap: onTap,
        child: SizedBox(
          width: size,
          height: size,
          child: Stack(
            children: [
              // Avatar
              ClipOval(
                child: Container(
                  width: size,
                  height: size,
                  color: Theme.of(context).colorScheme.primary,
                  child: imageUrl != null
                      ? Image.network(
                          imageUrl!,
                          fit: BoxFit.cover,
                          errorBuilder: (context, error, stackTrace) {
                            return _buildInitials(context, initials);
                          },
                        )
                      : _buildInitials(context, initials),
                ),
              ),
              // Status indicator
              Positioned(
                right: 0,
                bottom: 0,
                child: Container(
                  width: size * 0.3,
                  height: size * 0.3,
                  decoration: BoxDecoration(
                    color: statusColor,
                    shape: BoxShape.circle,
                    border: Border.all(
                      color: Theme.of(context).colorScheme.surface,
                      width: 2,
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildInitials(BuildContext context, String initials) {
    return Center(
      child: Text(
        initials,
        style: TextStyle(
          color: Theme.of(context).colorScheme.onPrimary,
          fontSize: size * 0.35,
          fontWeight: FontWeight.w600,
        ),
      ),
    );
  }
}

class ProfileListDemo extends StatelessWidget {
  const ProfileListDemo({super.key});

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

    final profiles = [
      _Profile(l10n.userName1, UserStatus.online),
      _Profile(l10n.userName2, UserStatus.away),
      _Profile(l10n.userName3, UserStatus.busy),
      _Profile(l10n.userName4, UserStatus.offline),
    ];

    return Scaffold(
      appBar: AppBar(title: Text(l10n.teamMembersTitle)),
      body: ListView.builder(
        padding: const EdgeInsets.all(16),
        itemCount: profiles.length,
        itemBuilder: (context, index) {
          final profile = profiles[index];
          return Padding(
            padding: const EdgeInsets.only(bottom: 12),
            child: Row(
              children: [
                LocalizedProfileAvatar(
                  userName: profile.name,
                  status: profile.status,
                  size: 56,
                  onTap: () {
                    ScaffoldMessenger.of(context).showSnackBar(
                      SnackBar(content: Text(l10n.viewingProfile(profile.name))),
                    );
                  },
                ),
                const SizedBox(width: 16),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        profile.name,
                        style: Theme.of(context).textTheme.titleMedium,
                      ),
                      Text(
                        _getStatusLabel(l10n, profile.status),
                        style: Theme.of(context).textTheme.bodySmall?.copyWith(
                          color: _getStatusColor(profile.status),
                        ),
                      ),
                    ],
                  ),
                ),
                IconButton(
                  icon: const Icon(Icons.message),
                  onPressed: () {},
                  tooltip: l10n.sendMessage,
                ),
              ],
            ),
          );
        },
      ),
    );
  }

  String _getStatusLabel(AppLocalizations l10n, UserStatus status) {
    switch (status) {
      case UserStatus.online:
        return l10n.statusOnline;
      case UserStatus.away:
        return l10n.statusAway;
      case UserStatus.busy:
        return l10n.statusBusy;
      case UserStatus.offline:
        return l10n.statusOffline;
    }
  }

  Color _getStatusColor(UserStatus status) {
    switch (status) {
      case UserStatus.online:
        return Colors.green;
      case UserStatus.away:
        return Colors.orange;
      case UserStatus.busy:
        return Colors.red;
      case UserStatus.offline:
        return Colors.grey;
    }
  }
}

class _Profile {
  final String name;
  final UserStatus status;

  _Profile(this.name, this.status);
}

Circular Progress Indicator

Create a circular progress ring with localized percentage:

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

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

  @override
  State<LocalizedCircularProgress> createState() => _LocalizedCircularProgressState();
}

class _LocalizedCircularProgressState extends State<LocalizedCircularProgress>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  double _progress = 0.75;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 2),
    );
    _controller.forward();
  }

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

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

    return Scaffold(
      appBar: AppBar(title: Text(l10n.progressTitle)),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          children: [
            Text(
              l10n.progressDescription,
              style: Theme.of(context).textTheme.bodyLarge,
              textAlign: TextAlign.center,
            ),
            const SizedBox(height: 48),
            // Main progress indicator
            _CircularProgressRing(
              progress: _progress,
              size: 200,
              strokeWidth: 12,
              label: l10n.overallProgress,
              valueLabel: l10n.percentComplete((_progress * 100).round()),
              controller: _controller,
            ),
            const SizedBox(height: 48),
            // Progress controls
            Text(
              l10n.adjustProgress,
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 16),
            Slider(
              value: _progress,
              onChanged: (value) {
                setState(() {
                  _progress = value;
                });
              },
              label: '${(_progress * 100).round()}%',
            ),
            const SizedBox(height: 32),
            // Mini progress indicators
            Text(
              l10n.taskProgress,
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 16),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                _MiniProgressRing(
                  progress: 1.0,
                  label: l10n.task1,
                  color: Colors.green,
                ),
                _MiniProgressRing(
                  progress: 0.6,
                  label: l10n.task2,
                  color: Colors.orange,
                ),
                _MiniProgressRing(
                  progress: 0.25,
                  label: l10n.task3,
                  color: Colors.red,
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

class _CircularProgressRing extends StatelessWidget {
  final double progress;
  final double size;
  final double strokeWidth;
  final String label;
  final String valueLabel;
  final AnimationController controller;

  const _CircularProgressRing({
    required this.progress,
    required this.size,
    required this.strokeWidth,
    required this.label,
    required this.valueLabel,
    required this.controller,
  });

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

    return Semantics(
      label: l10n.progressAccessibility(label, valueLabel),
      value: valueLabel,
      child: SizedBox(
        width: size,
        height: size,
        child: Stack(
          children: [
            // Background ring
            ClipOval(
              child: Container(
                width: size,
                height: size,
                color: Colors.transparent,
                child: CustomPaint(
                  painter: _CircularProgressPainter(
                    progress: 1.0,
                    strokeWidth: strokeWidth,
                    color: Theme.of(context).colorScheme.surfaceContainerHighest,
                  ),
                ),
              ),
            ),
            // Progress ring
            AnimatedBuilder(
              animation: controller,
              builder: (context, child) {
                return CustomPaint(
                  size: Size(size, size),
                  painter: _CircularProgressPainter(
                    progress: progress * controller.value,
                    strokeWidth: strokeWidth,
                    color: Theme.of(context).colorScheme.primary,
                  ),
                );
              },
            ),
            // Center content
            Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text(
                    '${(progress * 100).round()}%',
                    style: Theme.of(context).textTheme.headlineLarge?.copyWith(
                      fontWeight: FontWeight.bold,
                      color: Theme.of(context).colorScheme.primary,
                    ),
                  ),
                  Text(
                    label,
                    style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                      color: Theme.of(context).colorScheme.onSurfaceVariant,
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _MiniProgressRing extends StatelessWidget {
  final double progress;
  final String label;
  final Color color;

  const _MiniProgressRing({
    required this.progress,
    required this.label,
    required this.color,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        SizedBox(
          width: 60,
          height: 60,
          child: Stack(
            children: [
              ClipOval(
                child: Container(
                  color: Colors.transparent,
                  child: CustomPaint(
                    size: const Size(60, 60),
                    painter: _CircularProgressPainter(
                      progress: 1.0,
                      strokeWidth: 6,
                      color: color.withOpacity(0.2),
                    ),
                  ),
                ),
              ),
              CustomPaint(
                size: const Size(60, 60),
                painter: _CircularProgressPainter(
                  progress: progress,
                  strokeWidth: 6,
                  color: color,
                ),
              ),
              Center(
                child: Text(
                  '${(progress * 100).round()}%',
                  style: TextStyle(
                    fontSize: 12,
                    fontWeight: FontWeight.bold,
                    color: color,
                  ),
                ),
              ),
            ],
          ),
        ),
        const SizedBox(height: 8),
        Text(
          label,
          style: Theme.of(context).textTheme.bodySmall,
          textAlign: TextAlign.center,
        ),
      ],
    );
  }
}

class _CircularProgressPainter extends CustomPainter {
  final double progress;
  final double strokeWidth;
  final Color color;

  _CircularProgressPainter({
    required this.progress,
    required this.strokeWidth,
    required this.color,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = (size.width - strokeWidth) / 2;

    final paint = Paint()
      ..color = color
      ..strokeWidth = strokeWidth
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;

    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      -math.pi / 2,
      2 * math.pi * progress,
      false,
      paint,
    );
  }

  @override
  bool shouldRepaint(covariant _CircularProgressPainter oldDelegate) {
    return progress != oldDelegate.progress || color != oldDelegate.color;
  }
}

Circular Action Buttons

Create circular action buttons with tooltips:

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

class LocalizedCircularButtons extends StatelessWidget {
  const LocalizedCircularButtons({super.key});

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

    return Scaffold(
      appBar: AppBar(title: Text(l10n.actionsTitle)),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              l10n.actionsDescription,
              style: Theme.of(context).textTheme.bodyLarge,
            ),
            const SizedBox(height: 32),
            Text(
              l10n.primaryActions,
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 16),
            Wrap(
              spacing: 16,
              runSpacing: 16,
              children: [
                _CircularActionButton(
                  icon: Icons.add,
                  label: l10n.addAction,
                  color: Theme.of(context).colorScheme.primary,
                  onPressed: () => _showAction(context, l10n.addAction),
                ),
                _CircularActionButton(
                  icon: Icons.edit,
                  label: l10n.editAction,
                  color: Theme.of(context).colorScheme.secondary,
                  onPressed: () => _showAction(context, l10n.editAction),
                ),
                _CircularActionButton(
                  icon: Icons.share,
                  label: l10n.shareAction,
                  color: Theme.of(context).colorScheme.tertiary,
                  onPressed: () => _showAction(context, l10n.shareAction),
                ),
                _CircularActionButton(
                  icon: Icons.delete,
                  label: l10n.deleteAction,
                  color: Theme.of(context).colorScheme.error,
                  onPressed: () => _showAction(context, l10n.deleteAction),
                ),
              ],
            ),
            const SizedBox(height: 32),
            Text(
              l10n.mediaActions,
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 16),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                _CircularActionButton(
                  icon: Icons.skip_previous,
                  label: l10n.previousTrack,
                  size: 48,
                  onPressed: () => _showAction(context, l10n.previousTrack),
                ),
                const SizedBox(width: 16),
                _CircularActionButton(
                  icon: Icons.play_arrow,
                  label: l10n.playPause,
                  size: 72,
                  color: Theme.of(context).colorScheme.primary,
                  onPressed: () => _showAction(context, l10n.playPause),
                ),
                const SizedBox(width: 16),
                _CircularActionButton(
                  icon: Icons.skip_next,
                  label: l10n.nextTrack,
                  size: 48,
                  onPressed: () => _showAction(context, l10n.nextTrack),
                ),
              ],
            ),
            const SizedBox(height: 32),
            Text(
              l10n.quickActions,
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 16),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                _LabeledCircularButton(
                  icon: Icons.camera_alt,
                  label: l10n.cameraAction,
                  onPressed: () => _showAction(context, l10n.cameraAction),
                ),
                _LabeledCircularButton(
                  icon: Icons.photo,
                  label: l10n.galleryAction,
                  onPressed: () => _showAction(context, l10n.galleryAction),
                ),
                _LabeledCircularButton(
                  icon: Icons.mic,
                  label: l10n.voiceAction,
                  onPressed: () => _showAction(context, l10n.voiceAction),
                ),
                _LabeledCircularButton(
                  icon: Icons.attach_file,
                  label: l10n.fileAction,
                  onPressed: () => _showAction(context, l10n.fileAction),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  void _showAction(BuildContext context, String action) {
    final l10n = AppLocalizations.of(context)!;
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(l10n.actionTriggered(action))),
    );
  }
}

class _CircularActionButton extends StatelessWidget {
  final IconData icon;
  final String label;
  final double size;
  final Color? color;
  final VoidCallback onPressed;

  const _CircularActionButton({
    required this.icon,
    required this.label,
    this.size = 56,
    this.color,
    required this.onPressed,
  });

  @override
  Widget build(BuildContext context) {
    final buttonColor = color ?? Theme.of(context).colorScheme.surfaceContainerHighest;
    final iconColor = color != null
        ? Colors.white
        : Theme.of(context).colorScheme.onSurface;

    return Semantics(
      button: true,
      label: label,
      child: Tooltip(
        message: label,
        child: Material(
          color: Colors.transparent,
          child: InkWell(
            onTap: onPressed,
            customBorder: const CircleBorder(),
            child: ClipOval(
              child: Container(
                width: size,
                height: size,
                color: buttonColor,
                child: Icon(
                  icon,
                  color: iconColor,
                  size: size * 0.5,
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class _LabeledCircularButton extends StatelessWidget {
  final IconData icon;
  final String label;
  final VoidCallback onPressed;

  const _LabeledCircularButton({
    required this.icon,
    required this.label,
    required this.onPressed,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        _CircularActionButton(
          icon: icon,
          label: label,
          size: 56,
          color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
          onPressed: onPressed,
        ),
        const SizedBox(height: 8),
        Text(
          label,
          style: Theme.of(context).textTheme.bodySmall,
          textAlign: TextAlign.center,
        ),
      ],
    );
  }
}

Avatar Group with Overlap

Create overlapping avatar groups:

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

class LocalizedAvatarGroup extends StatelessWidget {
  final List<String> names;
  final int maxDisplay;
  final double avatarSize;
  final double overlap;

  const LocalizedAvatarGroup({
    super.key,
    required this.names,
    this.maxDisplay = 4,
    this.avatarSize = 40,
    this.overlap = 0.3,
  });

  Color _getAvatarColor(String name) {
    final colors = [
      Colors.blue,
      Colors.green,
      Colors.orange,
      Colors.purple,
      Colors.teal,
      Colors.pink,
      Colors.indigo,
      Colors.amber,
    ];
    return colors[name.hashCode.abs() % colors.length];
  }

  String _getInitials(String name) {
    final parts = name.trim().split(RegExp(r'\s+'));
    if (parts.isEmpty) return '?';
    if (parts.length == 1) {
      return parts[0].isNotEmpty ? parts[0][0].toUpperCase() : '?';
    }
    return '${parts[0][0]}${parts[parts.length - 1][0]}'.toUpperCase();
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final displayCount = names.length > maxDisplay ? maxDisplay : names.length;
    final remainingCount = names.length - displayCount;
    final overlapOffset = avatarSize * (1 - overlap);

    return Semantics(
      label: l10n.avatarGroupAccessibility(
        names.length,
        names.take(3).join(', '),
      ),
      child: SizedBox(
        height: avatarSize,
        width: overlapOffset * displayCount + avatarSize * overlap +
            (remainingCount > 0 ? overlapOffset : 0),
        child: Stack(
          children: [
            // Display avatars
            for (int i = 0; i < displayCount; i++)
              Positioned(
                left: i * overlapOffset,
                child: Container(
                  decoration: BoxDecoration(
                    shape: BoxShape.circle,
                    border: Border.all(
                      color: Theme.of(context).colorScheme.surface,
                      width: 2,
                    ),
                  ),
                  child: ClipOval(
                    child: Container(
                      width: avatarSize,
                      height: avatarSize,
                      color: _getAvatarColor(names[i]),
                      child: Center(
                        child: Text(
                          _getInitials(names[i]),
                          style: TextStyle(
                            color: Colors.white,
                            fontSize: avatarSize * 0.35,
                            fontWeight: FontWeight.w600,
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
              ),
            // Remaining count
            if (remainingCount > 0)
              Positioned(
                left: displayCount * overlapOffset,
                child: Container(
                  decoration: BoxDecoration(
                    shape: BoxShape.circle,
                    border: Border.all(
                      color: Theme.of(context).colorScheme.surface,
                      width: 2,
                    ),
                  ),
                  child: ClipOval(
                    child: Container(
                      width: avatarSize,
                      height: avatarSize,
                      color: Theme.of(context).colorScheme.surfaceContainerHighest,
                      child: Center(
                        child: Text(
                          '+$remainingCount',
                          style: TextStyle(
                            color: Theme.of(context).colorScheme.onSurface,
                            fontSize: avatarSize * 0.3,
                            fontWeight: FontWeight.w600,
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }
}

class AvatarGroupDemo extends StatelessWidget {
  const AvatarGroupDemo({super.key});

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

    return Scaffold(
      appBar: AppBar(title: Text(l10n.avatarGroupTitle)),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              l10n.avatarGroupDescription,
              style: Theme.of(context).textTheme.bodyLarge,
            ),
            const SizedBox(height: 32),
            _GroupCard(
              title: l10n.projectTeam,
              description: l10n.projectTeamDescription,
              names: [
                l10n.memberName1,
                l10n.memberName2,
                l10n.memberName3,
              ],
            ),
            const SizedBox(height: 16),
            _GroupCard(
              title: l10n.designTeam,
              description: l10n.designTeamDescription,
              names: [
                l10n.designerName1,
                l10n.designerName2,
                l10n.designerName3,
                l10n.designerName4,
                l10n.designerName5,
              ],
            ),
            const SizedBox(height: 16),
            _GroupCard(
              title: l10n.allHands,
              description: l10n.allHandsDescription,
              names: List.generate(12, (i) => l10n.participantName(i + 1)),
            ),
          ],
        ),
      ),
    );
  }
}

class _GroupCard extends StatelessWidget {
  final String title;
  final String description;
  final List<String> names;

  const _GroupCard({
    required this.title,
    required this.description,
    required this.names,
  });

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

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          children: [
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    title,
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                  const SizedBox(height: 4),
                  Text(
                    description,
                    style: Theme.of(context).textTheme.bodySmall?.copyWith(
                      color: Theme.of(context).colorScheme.onSurfaceVariant,
                    ),
                  ),
                  const SizedBox(height: 8),
                  Text(
                    l10n.memberCount(names.length),
                    style: Theme.of(context).textTheme.bodySmall?.copyWith(
                      color: Theme.of(context).colorScheme.primary,
                    ),
                  ),
                ],
              ),
            ),
            LocalizedAvatarGroup(names: names),
          ],
        ),
      ),
    );
  }
}

Notification Badge Overlay

Create badges on circular elements:

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

class LocalizedBadgedAvatar extends StatelessWidget {
  final String userName;
  final int notificationCount;
  final double size;
  final VoidCallback? onTap;

  const LocalizedBadgedAvatar({
    super.key,
    required this.userName,
    this.notificationCount = 0,
    this.size = 48,
    this.onTap,
  });

  String _getInitials(String name) {
    final parts = name.trim().split(RegExp(r'\s+'));
    if (parts.isEmpty) return '?';
    if (parts.length == 1) {
      return parts[0].isNotEmpty ? parts[0][0].toUpperCase() : '?';
    }
    return '${parts[0][0]}${parts[parts.length - 1][0]}'.toUpperCase();
  }

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

    return Semantics(
      label: notificationCount > 0
          ? l10n.badgedAvatarAccessibility(userName, notificationCount)
          : l10n.avatarAccessibility(userName),
      button: onTap != null,
      child: GestureDetector(
        onTap: onTap,
        child: SizedBox(
          width: size + 8,
          height: size + 8,
          child: Stack(
            clipBehavior: Clip.none,
            children: [
              // Avatar
              ClipOval(
                child: Container(
                  width: size,
                  height: size,
                  color: Theme.of(context).colorScheme.primary,
                  child: Center(
                    child: Text(
                      initials,
                      style: TextStyle(
                        color: Theme.of(context).colorScheme.onPrimary,
                        fontSize: size * 0.35,
                        fontWeight: FontWeight.w600,
                      ),
                    ),
                  ),
                ),
              ),
              // Badge
              if (notificationCount > 0)
                Positioned(
                  right: 0,
                  top: 0,
                  child: Container(
                    padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
                    decoration: BoxDecoration(
                      color: Theme.of(context).colorScheme.error,
                      borderRadius: BorderRadius.circular(10),
                      border: Border.all(
                        color: Theme.of(context).colorScheme.surface,
                        width: 2,
                      ),
                    ),
                    constraints: const BoxConstraints(
                      minWidth: 20,
                      minHeight: 20,
                    ),
                    child: Text(
                      notificationCount > 99 ? '99+' : '$notificationCount',
                      style: TextStyle(
                        color: Theme.of(context).colorScheme.onError,
                        fontSize: 11,
                        fontWeight: FontWeight.bold,
                      ),
                      textAlign: TextAlign.center,
                    ),
                  ),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

class NotificationDemo extends StatelessWidget {
  const NotificationDemo({super.key});

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

    final conversations = [
      _Conversation(l10n.conversationName1, 3),
      _Conversation(l10n.conversationName2, 0),
      _Conversation(l10n.conversationName3, 12),
      _Conversation(l10n.conversationName4, 1),
      _Conversation(l10n.conversationName5, 156),
    ];

    return Scaffold(
      appBar: AppBar(
        title: Text(l10n.messagesTitle),
        actions: [
          LocalizedBadgedAvatar(
            userName: l10n.currentUser,
            notificationCount: 5,
            size: 32,
            onTap: () {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text(l10n.profileTapped)),
              );
            },
          ),
          const SizedBox(width: 16),
        ],
      ),
      body: ListView.builder(
        padding: const EdgeInsets.all(16),
        itemCount: conversations.length,
        itemBuilder: (context, index) {
          final conversation = conversations[index];
          return ListTile(
            leading: LocalizedBadgedAvatar(
              userName: conversation.name,
              notificationCount: conversation.unreadCount,
            ),
            title: Text(conversation.name),
            subtitle: Text(
              conversation.unreadCount > 0
                  ? l10n.unreadMessages(conversation.unreadCount)
                  : l10n.noNewMessages,
            ),
            trailing: Text(
              l10n.messageTime,
              style: Theme.of(context).textTheme.bodySmall,
            ),
            onTap: () {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text(l10n.openingConversation(conversation.name))),
              );
            },
          );
        },
      ),
    );
  }
}

class _Conversation {
  final String name;
  final int unreadCount;

  _Conversation(this.name, this.unreadCount);
}

Complete ARB File for ClipOval

{
  "@@locale": "en",

  "clipOvalTitle": "Circular Clipping",
  "clipOvalDescription": "Create perfect circles and ovals for avatars and buttons",
  "circularContentAccessibility": "Circular content area",
  "circularSampleText": "Circle",

  "teamMembersTitle": "Team Members",
  "userName1": "Alice Johnson",
  "userName2": "Bob Smith",
  "userName3": "Carol Williams",
  "userName4": "David Brown",
  "statusOnline": "Online",
  "statusAway": "Away",
  "statusBusy": "Busy",
  "statusOffline": "Offline",
  "profileAvatarAccessibility": "{name}, {status}",
  "@profileAvatarAccessibility": {
    "placeholders": {
      "name": {"type": "String"},
      "status": {"type": "String"}
    }
  },
  "viewingProfile": "Viewing profile: {name}",
  "@viewingProfile": {
    "placeholders": {
      "name": {"type": "String"}
    }
  },
  "sendMessage": "Send message",

  "progressTitle": "Progress Tracking",
  "progressDescription": "Visualize completion with circular progress indicators",
  "overallProgress": "Overall Progress",
  "percentComplete": "{percent}% complete",
  "@percentComplete": {
    "placeholders": {
      "percent": {"type": "int"}
    }
  },
  "progressAccessibility": "{label}: {value}",
  "@progressAccessibility": {
    "placeholders": {
      "label": {"type": "String"},
      "value": {"type": "String"}
    }
  },
  "adjustProgress": "Adjust Progress",
  "taskProgress": "Task Progress",
  "task1": "Research",
  "task2": "Design",
  "task3": "Development",

  "actionsTitle": "Quick Actions",
  "actionsDescription": "Circular buttons for common actions",
  "primaryActions": "Primary Actions",
  "addAction": "Add",
  "editAction": "Edit",
  "shareAction": "Share",
  "deleteAction": "Delete",
  "mediaActions": "Media Controls",
  "previousTrack": "Previous",
  "playPause": "Play/Pause",
  "nextTrack": "Next",
  "quickActions": "Quick Actions",
  "cameraAction": "Camera",
  "galleryAction": "Gallery",
  "voiceAction": "Voice",
  "fileAction": "File",
  "actionTriggered": "Action: {action}",
  "@actionTriggered": {
    "placeholders": {
      "action": {"type": "String"}
    }
  },

  "avatarGroupTitle": "Team Groups",
  "avatarGroupDescription": "Display multiple team members in compact groups",
  "avatarGroupAccessibility": "{count} members: {names} and others",
  "@avatarGroupAccessibility": {
    "placeholders": {
      "count": {"type": "int"},
      "names": {"type": "String"}
    }
  },
  "projectTeam": "Project Alpha",
  "projectTeamDescription": "Core development team",
  "memberName1": "Emma Wilson",
  "memberName2": "James Lee",
  "memberName3": "Sophie Chen",
  "designTeam": "Design Team",
  "designTeamDescription": "UI/UX specialists",
  "designerName1": "Oliver Martin",
  "designerName2": "Ava Thompson",
  "designerName3": "Liam Garcia",
  "designerName4": "Mia Anderson",
  "designerName5": "Noah Taylor",
  "allHands": "All Hands Meeting",
  "allHandsDescription": "Monthly company sync",
  "participantName": "Participant {number}",
  "@participantName": {
    "placeholders": {
      "number": {"type": "int"}
    }
  },
  "memberCount": "{count} members",
  "@memberCount": {
    "placeholders": {
      "count": {"type": "int"}
    }
  },

  "messagesTitle": "Messages",
  "currentUser": "You",
  "profileTapped": "Opening profile settings",
  "conversationName1": "Tech Support",
  "conversationName2": "Sarah Parker",
  "conversationName3": "Marketing Team",
  "conversationName4": "John Doe",
  "conversationName5": "Announcements",
  "unreadMessages": "{count} unread messages",
  "@unreadMessages": {
    "placeholders": {
      "count": {"type": "int"}
    }
  },
  "noNewMessages": "No new messages",
  "messageTime": "2:30 PM",
  "avatarAccessibility": "Avatar for {name}",
  "@avatarAccessibility": {
    "placeholders": {
      "name": {"type": "String"}
    }
  },
  "badgedAvatarAccessibility": "{name}, {count} notifications",
  "@badgedAvatarAccessibility": {
    "placeholders": {
      "name": {"type": "String"},
      "count": {"type": "int"}
    }
  },
  "openingConversation": "Opening conversation with {name}",
  "@openingConversation": {
    "placeholders": {
      "name": {"type": "String"}
    }
  }
}

Best Practices Summary

  1. Maintain aspect ratio: ClipOval creates true circles only with square dimensions
  2. Add semantic labels: Describe both the content and any status indicators
  3. Handle status indicators: Position badges consistently relative to the avatar
  4. Use initials fallback: Generate readable initials from localized names
  5. Consider color contrast: Ensure initials are readable on background colors
  6. Test with long names: Verify initials work with various name formats
  7. Add tooltips: Provide context for circular action buttons
  8. Group avatars properly: Handle overlapping with correct z-ordering
  9. Format numbers correctly: Localize badge counts and percentages
  10. Test RTL layouts: Ensure badge positions adapt to text direction

Conclusion

ClipOval is essential for creating polished circular UI elements in Flutter apps. From user avatars with status indicators to circular progress rings and action buttons, proper use of ClipOval ensures consistent visual design across your multilingual app. The key is maintaining proper aspect ratios, providing accessible labels, and handling edge cases like missing images with localized initials fallback.

Remember to test your circular components with names from different languages to ensure initials generation works correctly, and verify that notification badges are properly positioned in both LTR and RTL layouts.