camera_cutout 0.1.0 copy "camera_cutout: ^0.1.0" to clipboard
camera_cutout: ^0.1.0 copied to clipboard

A Flutter plugin for reading camera cutout bounds, safe insets, and drawable shape geometry on Android and iOS.

example/lib/main.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:camera_cutout/camera_cutout.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  runApp(const CameraCutoutExampleApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Camera Cutout',
      theme: ThemeData(
        brightness: Brightness.dark,
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xff4deeea),
          brightness: Brightness.dark,
        ),
        fontFamily: 'SF Pro Display',
        scaffoldBackgroundColor: Colors.transparent,
      ),
      home: const CameraCutoutDemo(),
    );
  }
}

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

  @override
  State<CameraCutoutDemo> createState() => _CameraCutoutDemoState();
}

class _CameraCutoutDemoState extends State<CameraCutoutDemo>
    with SingleTickerProviderStateMixin {
  final CameraCutout _cameraCutout = const CameraCutout();
  late final AnimationController _animationController;
  StreamSubscription<CameraCutoutInfo>? _subscription;
  CameraCutoutInfo? _info;
  Object? _error;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 2400),
    )..repeat();
    unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
    SystemChrome.setSystemUIOverlayStyle(
      const SystemUiOverlayStyle(
        statusBarColor: Colors.transparent,
        statusBarIconBrightness: Brightness.light,
        systemNavigationBarColor: Colors.transparent,
        systemNavigationBarIconBrightness: Brightness.light,
      ),
    );
    _subscription = _cameraCutout.changes.listen(
      _updateInfo,
      onError: _updateError,
    );
    unawaited(_refresh());
  }

  Future<void> _refresh() async {
    try {
      _updateInfo(await _cameraCutout.getInfo());
    } on Object catch (error) {
      _updateError(error);
    }
  }

  void _updateInfo(CameraCutoutInfo info) {
    if (!mounted) return;
    setState(() {
      _info = info;
      _error = null;
    });
  }

  void _updateError(Object error) {
    if (!mounted) return;
    setState(() {
      _error = error;
    });
  }

  @override
  void dispose() {
    unawaited(_subscription?.cancel());
    _animationController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnnotatedRegion<SystemUiOverlayStyle>(
      value: SystemUiOverlayStyle.light,
      child: Scaffold(
        body: Stack(
          fit: StackFit.expand,
          children: <Widget>[
            const _Background(),
            if (_info case final CameraCutoutInfo info)
              AnimatedBuilder(
                animation: _animationController,
                builder: (BuildContext context, Widget? child) {
                  return CustomPaint(
                    painter: _CutoutPainter(
                      info: info,
                      progress: _animationController.value,
                    ),
                  );
                },
              ),
            SafeArea(
              minimum: const EdgeInsets.fromLTRB(20, 14, 20, 20),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  const _Header(),
                  const Spacer(),
                  _StatusMessage(info: _info, error: _error),
                  const SizedBox(height: 20),
                  _InfoPanel(info: _info, error: _error, onRefresh: _refresh),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _Background extends StatelessWidget {
  const _Background();

  @override
  Widget build(BuildContext context) {
    return const DecoratedBox(
      decoration: BoxDecoration(
        gradient: RadialGradient(
          center: Alignment(0, -0.65),
          radius: 1.15,
          colors: <Color>[
            Color(0xff16333c),
            Color(0xff09161e),
            Color(0xff05080d),
          ],
          stops: <double>[0, 0.48, 1],
        ),
      ),
    );
  }
}

class _Header extends StatelessWidget {
  const _Header();

  @override
  Widget build(BuildContext context) {
    return const Row(
      children: <Widget>[
        DecoratedBox(
          decoration: BoxDecoration(
            color: Color(0x224deeea),
            borderRadius: BorderRadius.all(Radius.circular(12)),
            border: Border.fromBorderSide(BorderSide(color: Color(0x664deeea))),
          ),
          child: Padding(
            padding: EdgeInsets.all(10),
            child: Icon(Icons.camera_front_outlined, color: Color(0xff4deeea)),
          ),
        ),
        SizedBox(width: 12),
        Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(
              'CAMERA CUTOUT',
              style: TextStyle(
                fontSize: 15,
                fontWeight: FontWeight.w800,
                letterSpacing: 2.2,
              ),
            ),
            SizedBox(height: 3),
            Text(
              'Live screen geometry',
              style: TextStyle(color: Color(0xff91a9b4), fontSize: 12),
            ),
          ],
        ),
      ],
    );
  }
}

class _StatusMessage extends StatelessWidget {
  const _StatusMessage({required this.info, required this.error});

  final CameraCutoutInfo? info;
  final Object? error;

  @override
  Widget build(BuildContext context) {
    final String title;
    final String subtitle;
    final IconData icon;
    if (error != null) {
      title = 'Unable to read geometry';
      subtitle = error.toString();
      icon = Icons.error_outline;
    } else if (info == null) {
      title = 'Reading display';
      subtitle = 'Waiting for native window insets';
      icon = Icons.hourglass_top_rounded;
    } else if (!info!.isSupported) {
      title = 'Unknown device';
      subtitle = 'This device is not in the current geometry catalog';
      icon = Icons.device_unknown;
    } else if (!info!.hasCutout) {
      title = 'No camera cutout detected';
      subtitle = 'The full display area is unobstructed';
      icon = Icons.check_circle_outline;
    } else {
      final CameraCutoutGeometry cutout = info!.cutouts.first;
      title = _readableName(cutout.kind.name);
      subtitle = cutout.hasPath
          ? 'Drawable path aligned to the physical display'
          : 'Bounding geometry aligned to the physical display';
      icon = Icons.adjust_rounded;
    }

    return Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Icon(icon, color: const Color(0xff4deeea), size: 22),
        const SizedBox(width: 10),
        Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text(
                title,
                style: const TextStyle(
                  fontSize: 20,
                  fontWeight: FontWeight.w700,
                ),
              ),
              const SizedBox(height: 5),
              Text(
                subtitle,
                maxLines: 2,
                overflow: TextOverflow.ellipsis,
                style: const TextStyle(color: Color(0xff91a9b4), height: 1.35),
              ),
            ],
          ),
        ),
      ],
    );
  }
}

class _InfoPanel extends StatelessWidget {
  const _InfoPanel({
    required this.info,
    required this.error,
    required this.onRefresh,
  });

  final CameraCutoutInfo? info;
  final Object? error;
  final Future<void> Function() onRefresh;

  @override
  Widget build(BuildContext context) {
    final CameraCutoutGeometry? geometry = info?.cutouts.firstOrNull;
    return Container(
      padding: const EdgeInsets.fromLTRB(18, 16, 12, 16),
      decoration: BoxDecoration(
        color: const Color(0xd90c171e),
        borderRadius: BorderRadius.circular(22),
        border: Border.all(color: const Color(0x334deeea)),
        boxShadow: const <BoxShadow>[
          BoxShadow(
            color: Color(0x55000000),
            blurRadius: 28,
            offset: Offset(0, 14),
          ),
        ],
      ),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Expanded(
            child: Wrap(
              runSpacing: 14,
              spacing: 24,
              children: <Widget>[
                _Metric(label: 'DEVICE', value: info?.deviceModel ?? '—'),
                _Metric(
                  label: 'IDENTIFIER',
                  value: info?.deviceIdentifier ?? '—',
                ),
                _Metric(
                  label: 'BOUNDS',
                  value: geometry == null ? '—' : _formatRect(geometry.bounds),
                ),
                _Metric(
                  label: 'ACCURACY',
                  value: geometry == null
                      ? '—'
                      : _readableName(geometry.accuracy.name),
                ),
              ],
            ),
          ),
          IconButton(
            tooltip: 'Refresh',
            onPressed: () => unawaited(onRefresh()),
            icon: const Icon(Icons.refresh_rounded),
            color: const Color(0xff4deeea),
          ),
        ],
      ),
    );
  }
}

class _Metric extends StatelessWidget {
  const _Metric({required this.label, required this.value});

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 132,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Text(
            label,
            style: const TextStyle(
              color: Color(0xff66818c),
              fontSize: 10,
              fontWeight: FontWeight.w700,
              letterSpacing: 1.3,
            ),
          ),
          const SizedBox(height: 5),
          Text(
            value,
            maxLines: 2,
            overflow: TextOverflow.ellipsis,
            style: const TextStyle(
              color: Color(0xffd7e5ea),
              fontSize: 12,
              fontWeight: FontWeight.w600,
              height: 1.25,
            ),
          ),
        ],
      ),
    );
  }
}

class _CutoutPainter extends CustomPainter {
  const _CutoutPainter({required this.info, required this.progress});

  final CameraCutoutInfo info;
  final double progress;

  @override
  void paint(Canvas canvas, Size size) {
    for (final CameraCutoutGeometry geometry in info.cutouts) {
      final Path path = _pathFor(geometry);
      final Rect bounds = geometry.bounds;

      for (int index = 0; index < 3; index += 1) {
        final double phase = (progress + index / 3) % 1;
        final double scale = 1 + phase * 0.55;
        canvas.save();
        canvas.translate(bounds.center.dx, bounds.center.dy);
        canvas.scale(scale, scale);
        canvas.translate(-bounds.center.dx, -bounds.center.dy);
        canvas.drawPath(
          path,
          Paint()
            ..style = PaintingStyle.stroke
            ..strokeWidth = 1.8 / scale
            ..color = const Color(
              0xff4deeea,
            ).withValues(alpha: math.pow(1 - phase, 1.7).toDouble() * 0.62),
        );
        canvas.restore();
      }

      canvas.drawPath(
        path,
        Paint()
          ..style = PaintingStyle.stroke
          ..strokeWidth = 10
          ..color = const Color(0x994deeea)
          ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12),
      );
      canvas.drawPath(path, Paint()..color = Colors.black);
      canvas.drawPath(
        path,
        Paint()
          ..style = PaintingStyle.stroke
          ..strokeWidth = 2.25
          ..color = const Color(0xff7ffffb),
      );
    }
  }

  Path _pathFor(CameraCutoutGeometry geometry) {
    final CameraCutoutPath? path = geometry.path;
    if (path != null && !path.isEmpty) return path.toPath();
    if (geometry.kind == CameraCutoutKind.holePunch ||
        geometry.kind == CameraCutoutKind.dynamicIsland) {
      return Path()..addOval(geometry.bounds);
    }
    return Path()..addRRect(
      RRect.fromRectAndRadius(
        geometry.bounds,
        Radius.circular(geometry.bounds.shortestSide * 0.3),
      ),
    );
  }

  @override
  bool shouldRepaint(_CutoutPainter oldDelegate) {
    return oldDelegate.info != info || oldDelegate.progress != progress;
  }
}

String _formatRect(Rect rect) {
  return '${rect.left.toStringAsFixed(1)}, ${rect.top.toStringAsFixed(1)} · '
      '${rect.width.toStringAsFixed(1)} × ${rect.height.toStringAsFixed(1)}';
}

String _readableName(String value) {
  final String spaced = value.replaceAllMapped(
    RegExp('([a-z])([A-Z])'),
    (Match match) => '${match.group(1)} ${match.group(2)}',
  );
  return '${spaced[0].toUpperCase()}${spaced.substring(1)}';
}

extension<T> on List<T> {
  T? get firstOrNull => isEmpty ? null : first;
}
2
likes
150
points
42
downloads

Documentation

API reference

Publisher

verified publishercodegrowers.tech

Weekly Downloads

A Flutter plugin for reading camera cutout bounds, safe insets, and drawable shape geometry on Android and iOS.

Homepage
Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on camera_cutout

Packages that implement camera_cutout