camera_cutout 0.1.0
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.
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;
}