nn_clippers 0.0.2
nn_clippers: ^0.0.2 copied to clipboard
A gallery of reusable CustomClipper shapes for Flutter: waves, polygons, diagonal/oval borders, and ticket/coupon shapes with notches and scalloped edges.
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:nn_clippers/nn_clippers.dart';
void main() => runApp(const ShowcaseApp());
const _slateDark = Color(0xFF0F172A);
const _slateCard = Color(0xFF1E293B);
const _accentColor = Color(0xFF6366F1); // Indigo accent
/// All clippers supported in the interactive sandbox
enum ClipperType {
waveOne('WaveClipperOne', 'A single gentle wave along the bottom/top edge.'),
waveTwo('WaveClipperTwo', 'A bolder, higher-amplitude wave along the bottom/top edge.'),
ovalTop('OvalTopBorderClipper', 'A convex, oval-shaped top border.'),
roundedDiagonal('RoundedDiagonalPathClipper', 'Rounded rectangle with a smooth top-left diagonal sweep.'),
octagonal('OctagonalClipper', 'A regular octagon with corner cuts.'),
hexagonal('HexagonalClipper', 'A pointy-side or flat-side hexagon.'),
parallelogram('ParallelogramClipper', 'A rectangle sheared horizontally.'),
ticket('TicketClipper', 'Classic ticket/coupon with opposite side notches.'),
scalloped('ScallopedRectClipper', 'Postage-stamp style rectangle with scalloped punch-holes.');
const ClipperType(this.displayName, this.description);
final String displayName;
final String description;
}
/// The preview templates inside the clipped region
enum PreviewContentType {
color('Solid Color'),
gradient('Gradient'),
image('Image'),
profile('Profile Card');
const PreviewContentType(this.displayName);
final String displayName;
}
class ShowcaseApp extends StatelessWidget {
const ShowcaseApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'nn_clippers Showcase',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: ColorScheme.fromSeed(
brightness: Brightness.dark,
seedColor: _accentColor,
surface: _slateDark,
primary: _accentColor,
),
),
home: const ShowcaseHomePage(),
);
}
}
class ShowcaseHomePage extends StatelessWidget {
const ShowcaseHomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _slateDark,
appBar: AppBar(
title: const Text(
'nn_clippers Showcase',
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.1),
),
elevation: 0,
backgroundColor: _slateDark,
),
body: const InteractiveSandboxView(),
);
}
}
// =============================================================================
// INTERACTIVE SANDBOX VIEW
// =============================================================================
class InteractiveSandboxView extends StatefulWidget {
const InteractiveSandboxView({super.key});
@override
State<InteractiveSandboxView> createState() => _InteractiveSandboxViewState();
}
class _InteractiveSandboxViewState extends State<InteractiveSandboxView> {
ClipperType _clipperType = ClipperType.waveOne;
PreviewContentType _contentType = PreviewContentType.gradient;
// Clipper configs
bool _waveOneReverse = false;
bool _waveTwoReverse = false;
double _ovalTopHoleRadius = 20.0;
double _roundedDiagonalRadius = 40.0;
double _octagonalCornerFraction = 0.25;
bool _hexagonalReverse = false;
double _parallelogramSlant = 0.25;
// Ticket config
TicketNotchAxis _ticketAxis = TicketNotchAxis.horizontal;
double _ticketPosition = 0.66;
double _ticketNotchRadius = 16.0;
double _ticketCornerRadius = 12.0;
// Scalloped config
double _scallopedNotchRadius = 10.0;
final Set<ScallopEdge> _scallopedEdges = {ScallopEdge.left, ScallopEdge.right};
// Preview dimensions
double _canvasWidth = 320.0;
double _canvasHeight = 220.0;
bool _codeCopied = false;
void _copyToClipboard(String text) {
Clipboard.setData(ClipboardData(text: text));
setState(() => _codeCopied = true);
Future.delayed(const Duration(seconds: 2), () {
if (mounted) {
setState(() => _codeCopied = false);
}
});
}
CustomClipper<Path> _getActiveClipper() {
switch (_clipperType) {
case ClipperType.waveOne:
return WaveClipperOne(reverse: _waveOneReverse);
case ClipperType.waveTwo:
return WaveClipperTwo(reverse: _waveTwoReverse);
case ClipperType.ovalTop:
return OvalTopBorderClipper(holeRadius: _ovalTopHoleRadius);
case ClipperType.roundedDiagonal:
return RoundedDiagonalPathClipper(radius: _roundedDiagonalRadius);
case ClipperType.octagonal:
return OctagonalClipper(cornerFraction: _octagonalCornerFraction);
case ClipperType.hexagonal:
return HexagonalClipper(reverse: _hexagonalReverse);
case ClipperType.parallelogram:
return ParallelogramClipper(slant: _parallelogramSlant);
case ClipperType.ticket:
return TicketClipper(
axis: _ticketAxis,
position: _ticketPosition,
notchRadius: _ticketNotchRadius,
cornerRadius: _ticketCornerRadius,
);
case ClipperType.scalloped:
return ScallopedRectClipper(
edges: _scallopedEdges,
notchRadius: _scallopedNotchRadius,
);
}
}
String _getGeneratedCode() {
final String clipperCode;
switch (_clipperType) {
case ClipperType.waveOne:
clipperCode = 'const WaveClipperOne(reverse: $_waveOneReverse)';
break;
case ClipperType.waveTwo:
clipperCode = 'const WaveClipperTwo(reverse: $_waveTwoReverse)';
break;
case ClipperType.ovalTop:
clipperCode = 'const OvalTopBorderClipper(holeRadius: ${_ovalTopHoleRadius.toStringAsFixed(1)})';
break;
case ClipperType.roundedDiagonal:
clipperCode = 'const RoundedDiagonalPathClipper(radius: ${_roundedDiagonalRadius.toStringAsFixed(1)})';
break;
case ClipperType.octagonal:
clipperCode = 'const OctagonalClipper(cornerFraction: ${_octagonalCornerFraction.toStringAsFixed(2)})';
break;
case ClipperType.hexagonal:
clipperCode = 'const HexagonalClipper(reverse: $_hexagonalReverse)';
break;
case ClipperType.parallelogram:
clipperCode = 'const ParallelogramClipper(slant: ${_parallelogramSlant.toStringAsFixed(2)})';
break;
case ClipperType.ticket:
clipperCode = '''const TicketClipper(
axis: TicketNotchAxis.${_ticketAxis.name},
position: ${_ticketPosition.toStringAsFixed(2)},
notchRadius: ${_ticketNotchRadius.toStringAsFixed(1)},
cornerRadius: ${_ticketCornerRadius.toStringAsFixed(1)},
)''';
break;
case ClipperType.scalloped:
final edgesStr = _scallopedEdges.map((e) => 'ScallopEdge.${e.name}').join(', ');
clipperCode = '''const ScallopedRectClipper(
edges: {$edgesStr},
notchRadius: ${_scallopedNotchRadius.toStringAsFixed(1)},
)''';
break;
}
return '''ClipPath(
clipper: $clipperCode,
child: Container(
width: ${_canvasWidth.toInt()},
height: ${_canvasHeight.toInt()},
// your widget contents
),
)''';
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final isDesktop = constraints.maxWidth > 850;
final previewCanvas = Card(
color: _slateCard.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Column(
children: [
// Dimension controllers
Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Width: ${_canvasWidth.toInt()}px', style: const TextStyle(fontSize: 12, color: Colors.white70)),
Slider(
min: 150.0,
max: 500.0,
value: _canvasWidth,
onChanged: (v) => setState(() => _canvasWidth = v),
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Height: ${_canvasHeight.toInt()}px', style: const TextStyle(fontSize: 12, color: Colors.white70)),
Slider(
min: 100.0,
max: 400.0,
value: _canvasHeight,
onChanged: (v) => setState(() => _canvasHeight = v),
),
],
),
),
],
),
),
// The Visual Playground
Expanded(
child: Container(
width: double.infinity,
color: Colors.black26,
child: Stack(
alignment: Alignment.center,
children: [
// Design/blueprint grid lines behind the clipped element
const Positioned.fill(
child: CustomPaint(
painter: GridPainter(),
),
),
// Clipped object
Center(
child: Container(
width: _canvasWidth,
height: _canvasHeight,
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 20,
spreadRadius: 2,
),
],
),
child: ClipPath(
clipper: _getActiveClipper(),
child: _buildPreviewContent(),
),
),
),
],
),
),
),
// Template Selector
Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: PreviewContentType.values.map((type) {
final isSelected = _contentType == type;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ChoiceChip(
label: Text(type.displayName),
selected: isSelected,
onSelected: (val) {
if (val) setState(() => _contentType = type);
},
),
);
}).toList(),
),
),
],
),
);
final controlsPanel = SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Select Clipper Shape',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 12),
// Dropdown Selection for Clippers
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: _slateCard,
borderRadius: BorderRadius.circular(8),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<ClipperType>(
value: _clipperType,
isExpanded: true,
dropdownColor: _slateCard,
items: ClipperType.values.map((type) {
return DropdownMenuItem(
value: type,
child: Text(type.displayName, style: const TextStyle(fontWeight: FontWeight.w600)),
);
}).toList(),
onChanged: (val) {
if (val != null) {
setState(() => _clipperType = val);
}
},
),
),
),
const SizedBox(height: 6),
Text(
_clipperType.description,
style: const TextStyle(fontSize: 13, color: Colors.white60, fontStyle: FontStyle.italic),
),
const Divider(height: 32, color: Colors.white24),
const Text(
'Shape Parameters',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 12),
_buildShapeParametersControls(),
const Divider(height: 32, color: Colors.white24),
// Code output block
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Code Snippet',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white),
),
TextButton.icon(
onPressed: () => _copyToClipboard(_getGeneratedCode()),
icon: Icon(_codeCopied ? Icons.check : Icons.copy_all, size: 16, color: _codeCopied ? Colors.green : Colors.white70),
label: Text(_codeCopied ? 'Copied!' : 'Copy Code', style: TextStyle(color: _codeCopied ? Colors.green : Colors.white70)),
),
],
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black38,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white10),
),
child: SelectableText(
_getGeneratedCode(),
style: const TextStyle(
fontFamily: 'Courier',
fontSize: 13,
color: Color(0xFFAEF8A8), // Light green console look
),
),
),
],
),
);
if (isDesktop) {
return Row(
children: [
Expanded(
flex: 6,
child: previewCanvas,
),
const VerticalDivider(width: 1, color: Colors.white10),
Expanded(
flex: 4,
child: Container(
color: _slateDark.withValues(alpha: 0.7),
child: controlsPanel,
),
),
],
);
} else {
return SingleChildScrollView(
child: Column(
children: [
SizedBox(
height: 480,
child: previewCanvas,
),
const Divider(height: 1, color: Colors.white10),
controlsPanel,
],
),
);
}
},
);
}
Widget _buildPreviewContent() {
switch (_contentType) {
case PreviewContentType.color:
return Container(
color: _accentColor,
child: const Center(
child: Text(
'nn_clippers',
style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold, letterSpacing: 1.2),
),
),
);
case PreviewContentType.gradient:
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF8B5CF6), Color(0xFFEC4899), Color(0xFFF59E0B)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: const Center(
child: Text(
'Gradient Fill',
style: TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.w900),
),
),
);
case PreviewContentType.image:
return Image.network(
'https://images.unsplash.com/photo-1550751827-4bd374c3f58b?w=600&auto=format&fit=crop',
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
color: _slateCard,
child: const Center(child: CircularProgressIndicator()),
);
},
errorBuilder: (c, o, s) => Container(
color: _slateCard,
child: const Center(child: Icon(Icons.broken_image, size: 48)),
),
);
case PreviewContentType.profile:
return Container(
color: Colors.white,
child: Stack(
children: [
Container(
height: 100,
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF3B82F6), Color(0xFF1D4ED8)],
),
),
),
Positioned(
top: 60,
left: 16,
child: CircleAvatar(
radius: 36,
backgroundColor: Colors.white,
child: CircleAvatar(
radius: 32,
backgroundColor: Colors.grey.shade300,
child: const Icon(Icons.person, size: 40, color: Colors.black54),
),
),
),
Positioned(
top: 140,
left: 16,
right: 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text(
'Antigravity Dev',
style: TextStyle(color: Colors.black87, fontSize: 18, fontWeight: FontWeight.bold),
),
SizedBox(height: 4),
Text(
'Senior Flutter Engineer',
style: TextStyle(color: Colors.black54, fontSize: 13),
),
],
),
),
Positioned(
top: 14,
right: 16,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(20),
),
child: const Text(
'PRO',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 11),
),
),
),
],
),
);
}
}
Widget _buildShapeParametersControls() {
switch (_clipperType) {
case ClipperType.waveOne:
return SwitchListTile(
title: const Text('Reverse (Top Edge)'),
value: _waveOneReverse,
activeThumbColor: _accentColor,
contentPadding: EdgeInsets.zero,
onChanged: (v) => setState(() => _waveOneReverse = v),
);
case ClipperType.waveTwo:
return SwitchListTile(
title: const Text('Reverse (Top Edge)'),
value: _waveTwoReverse,
activeThumbColor: _accentColor,
contentPadding: EdgeInsets.zero,
onChanged: (v) => setState(() => _waveTwoReverse = v),
);
case ClipperType.ovalTop:
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Hole Radius (Bulge): ${_ovalTopHoleRadius.toStringAsFixed(1)}px'),
Slider(
min: -50.0,
max: 80.0,
activeColor: _accentColor,
value: _ovalTopHoleRadius,
onChanged: (v) => setState(() => _ovalTopHoleRadius = v),
),
],
);
case ClipperType.roundedDiagonal:
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Corner Radius: ${_roundedDiagonalRadius.toStringAsFixed(1)}px'),
Slider(
min: 0.0,
max: 80.0,
activeColor: _accentColor,
value: _roundedDiagonalRadius,
onChanged: (v) => setState(() => _roundedDiagonalRadius = v),
),
],
);
case ClipperType.octagonal:
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Corner Fraction: ${_octagonalCornerFraction.toStringAsFixed(2)}'),
Slider(
min: 0.0,
max: 0.5,
activeColor: _accentColor,
value: _octagonalCornerFraction,
onChanged: (v) => setState(() => _octagonalCornerFraction = v),
),
],
);
case ClipperType.hexagonal:
return SwitchListTile(
title: const Text('Reverse (Top/Bottom Pointy)'),
value: _hexagonalReverse,
activeThumbColor: _accentColor,
contentPadding: EdgeInsets.zero,
onChanged: (v) => setState(() => _hexagonalReverse = v),
);
case ClipperType.parallelogram:
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Slant Fraction: ${_parallelogramSlant.toStringAsFixed(2)}'),
Slider(
min: -0.5,
max: 0.5,
activeColor: _accentColor,
value: _parallelogramSlant,
onChanged: (v) => setState(() => _parallelogramSlant = v),
),
],
);
case ClipperType.ticket:
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Notch Axis'),
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: ChoiceChip(
label: const Center(child: Text('Horizontal')),
selected: _ticketAxis == TicketNotchAxis.horizontal,
onSelected: (val) {
if (val) setState(() => _ticketAxis = TicketNotchAxis.horizontal);
},
),
),
const SizedBox(width: 8),
Expanded(
child: ChoiceChip(
label: const Center(child: Text('Vertical')),
selected: _ticketAxis == TicketNotchAxis.vertical,
onSelected: (val) {
if (val) setState(() => _ticketAxis = TicketNotchAxis.vertical);
},
),
),
],
),
const SizedBox(height: 16),
Text('Notch Position: ${_ticketPosition.toStringAsFixed(2)}'),
Slider(
min: 0.1,
max: 0.9,
activeColor: _accentColor,
value: _ticketPosition,
onChanged: (v) => setState(() => _ticketPosition = v),
),
const SizedBox(height: 8),
Text('Notch Radius: ${_ticketNotchRadius.toStringAsFixed(1)}px'),
Slider(
min: 4.0,
max: 32.0,
activeColor: _accentColor,
value: _ticketNotchRadius,
onChanged: (v) => setState(() => _ticketNotchRadius = v),
),
const SizedBox(height: 8),
Text('Corner Radius: ${_ticketCornerRadius.toStringAsFixed(1)}px'),
Slider(
min: 0.0,
max: 32.0,
activeColor: _accentColor,
value: _ticketCornerRadius,
onChanged: (v) => setState(() => _ticketCornerRadius = v),
),
],
);
case ClipperType.scalloped:
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Notch Radius: ${_scallopedNotchRadius.toStringAsFixed(1)}px'),
Slider(
min: 4.0,
max: 24.0,
activeColor: _accentColor,
value: _scallopedNotchRadius,
onChanged: (v) => setState(() => _scallopedNotchRadius = v),
),
const SizedBox(height: 12),
const Text('Scallop Edges'),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: ScallopEdge.values.map((edge) {
final isSelected = _scallopedEdges.contains(edge);
return FilterChip(
label: Text(edge.name.toUpperCase()),
selected: isSelected,
selectedColor: _accentColor.withValues(alpha: 0.3),
checkmarkColor: _accentColor,
onSelected: (val) {
setState(() {
if (val) {
_scallopedEdges.add(edge);
} else {
if (_scallopedEdges.length > 1) {
_scallopedEdges.remove(edge);
}
}
});
},
);
}).toList(),
),
],
);
}
}
}
// Custom Grid Painter for Sandbox preview
class GridPainter extends CustomPainter {
const GridPainter();
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.04)
..strokeWidth = 1.0;
const spacing = 20.0;
for (double x = 0; x < size.width; x += spacing) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
}
for (double y = 0; y < size.height; y += spacing) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
}
@override
bool shouldRepaint(covariant GridPainter oldDelegate) => false;
}