nn_clippers 0.1.1
nn_clippers: ^0.1.1 copied to clipboard
A collection of reusable CustomClipper<Path> shapes for Flutter including waves, polygons, tickets, coupons, borders, and creative UI clipping effects.
// ignore_for_file: deprecated_member_use
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:nn_clippers/nn_clippers.dart';
void main() => runApp(const ShowcaseApp());
const _red = Color(0xFFD8262C);
const _navy = Color(0xFF0B2A5B);
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 StatefulWidget {
const ShowcaseHomePage({super.key});
@override
State<ShowcaseHomePage> createState() => _ShowcaseHomePageState();
}
class _ShowcaseHomePageState extends State<ShowcaseHomePage> with SingleTickerProviderStateMixin {
late final TabController _tabController;
// Global coupon data for Tab 2
CouponData _couponData = CouponData.initial;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@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,
bottom: TabBar(
controller: _tabController,
indicatorColor: _accentColor,
labelColor: Colors.white,
unselectedLabelColor: Colors.white60,
tabs: const [
Tab(icon: Icon(Icons.dashboard_customize), text: 'Interactive Sandbox'),
Tab(icon: Icon(Icons.confirmation_num), text: 'Coupons Showcase'),
Tab(icon: Icon(Icons.layers), text: 'Wave Headers'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
const InteractiveSandboxView(),
CouponsShowcaseView(
data: _couponData,
onEdit: () async {
final result = await showModalBottomSheet<CouponData>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _EditSheet(data: _couponData),
);
if (result != null) {
setState(() => _couponData = result);
}
},
),
const WaveHeadersView(),
],
),
);
}
}
// =============================================================================
// TAB 1: 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.withOpacity(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.withOpacity(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.withOpacity(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.withOpacity(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.withOpacity(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;
}
// =============================================================================
// TAB 2: COUPONS SHOWCASE VIEW (RE-USING PREVIOUS IMPLEMENTATION)
// =============================================================================
class CouponsShowcaseView extends StatelessWidget {
const CouponsShowcaseView({
super.key,
required this.data,
required this.onEdit,
});
final CouponData data;
final VoidCallback onEdit;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _navy,
floatingActionButton: FloatingActionButton.extended(
onPressed: onEdit,
backgroundColor: _red,
foregroundColor: Colors.white,
icon: const Icon(Icons.edit),
label: const Text('Edit Coupon'),
),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 96),
children: [
const Padding(
padding: EdgeInsets.only(bottom: 20.0),
child: Text(
'Real-world Coupon Designs using TicketClipper',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.white70,
fontWeight: FontWeight.w600,
),
),
),
CouponCard(data: data),
const SizedBox(height: 24),
DetailsCard(data: data),
const SizedBox(height: 24),
QrVoucherCard(data: data),
],
),
),
),
);
}
}
// =============================================================================
// TAB 3: WAVE HEADERS GALLERY
// =============================================================================
class WaveHeadersView extends StatelessWidget {
const WaveHeadersView({super.key});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 800),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Layering Waves for Stunning Headers',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Layering multiple WaveClipper instances is a popular design technique to create fluid, wavy headers. Try altering colors, gradients, heights, and opacities.',
style: TextStyle(color: Colors.white60, fontSize: 14),
),
const SizedBox(height: 24),
// Header Wave Composition 1 (Sunset Vibe)
const Text('Composition 1: Modern Hero Header', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
Container(
height: 240,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Colors.black26,
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// Layer 1 (Back Wave)
ClipPath(
clipper: const WaveClipperOne(),
child: Container(
height: 220,
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0x55F59E0B), Color(0x55EF4444)],
),
),
),
),
// Layer 2 (Middle Wave - Reversed for contrast)
ClipPath(
clipper: const WaveClipperTwo(),
child: Container(
height: 190,
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0x99EC4899), Color(0x998B5CF6)],
),
),
),
),
// Layer 3 (Front Wave)
ClipPath(
clipper: const WaveClipperOne(),
child: Container(
height: 170,
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF6366F1), Color(0xFF3B82F6)],
),
),
child: const Center(
child: Text(
'nn_clippers hero section',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 20),
),
),
),
),
],
),
),
const SizedBox(height: 36),
// Header Wave Composition 2 (Top Wavy Edge)
const Text('Composition 2: Wavy Footer/Feature Section (Reversed Wave)', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
Container(
height: 220,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Colors.black26,
),
clipBehavior: Clip.antiAlias,
child: Stack(
alignment: Alignment.bottomCenter,
children: [
// Layer 1 (Back Wave)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: ClipPath(
clipper: const WaveClipperTwo(reverse: true),
child: Container(
height: 190,
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0x6610B981), Color(0x663B82F6)],
),
),
),
),
),
// Layer 2 (Front Wave)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: ClipPath(
clipper: const WaveClipperOne(reverse: true),
child: Container(
height: 160,
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF10B981), Color(0xFF047857)],
),
),
child: const Center(
child: Text(
'wavy edge starts here',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16),
),
),
),
),
),
],
),
),
],
),
),
),
);
}
}
// =============================================================================
// HELPER MODELS AND WIDGETS
// =============================================================================
class CouponData {
const CouponData({
required this.percent,
required this.title,
required this.validTill,
required this.code,
required this.usedFor,
required this.validPeriod,
});
final String percent;
final String title;
final String validTill;
final String code;
final String usedFor;
final String validPeriod;
CouponData copyWith({
String? percent,
String? title,
String? validTill,
String? code,
String? usedFor,
String? validPeriod,
}) {
return CouponData(
percent: percent ?? this.percent,
title: title ?? this.title,
validTill: validTill ?? this.validTill,
code: code ?? this.code,
usedFor: usedFor ?? this.usedFor,
validPeriod: validPeriod ?? this.validPeriod,
);
}
static const initial = CouponData(
percent: '20%',
title: 'Name Off Campaign',
validTill: '31 FEB 2026',
code: '1234556677',
usedFor: 'Food delivery',
validPeriod: 'Feb 10 - Feb 17, 2026',
);
}
class _EditSheet extends StatefulWidget {
const _EditSheet({required this.data});
final CouponData data;
@override
State<_EditSheet> createState() => _EditSheetState();
}
class _EditSheetState extends State<_EditSheet> {
late final TextEditingController _percent = TextEditingController(text: widget.data.percent);
late final TextEditingController _title = TextEditingController(text: widget.data.title);
late final TextEditingController _validTill = TextEditingController(text: widget.data.validTill);
late final TextEditingController _code = TextEditingController(text: widget.data.code);
late final TextEditingController _usedFor = TextEditingController(text: widget.data.usedFor);
late final TextEditingController _validPeriod = TextEditingController(text: widget.data.validPeriod);
@override
void dispose() {
_percent.dispose();
_title.dispose();
_validTill.dispose();
_code.dispose();
_usedFor.dispose();
_validPeriod.dispose();
super.dispose();
}
void _save() {
Navigator.pop(
context,
widget.data.copyWith(
percent: _percent.text,
title: _title.text,
validTill: _validTill.text,
code: _code.text,
usedFor: _usedFor.text,
validPeriod: _validPeriod.text,
),
);
}
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
return Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, 20 + bottomInset),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Edit coupon', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
_field(_percent, 'Discount (e.g. 20%)'),
_field(_title, 'Title'),
_field(_validTill, 'Valid till'),
_field(_code, 'Code'),
_field(_usedFor, 'Used for'),
_field(_validPeriod, 'Valid period'),
const SizedBox(height: 16),
FilledButton(
onPressed: _save,
style: FilledButton.styleFrom(backgroundColor: _red),
child: const Text('Apply'),
),
],
),
);
}
Widget _field(TextEditingController c, String label) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: TextField(
controller: c,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
isDense: true,
),
),
);
}
}
class CouponCard extends StatelessWidget {
const CouponCard({super.key, required this.data});
final CouponData data;
@override
Widget build(BuildContext context) {
const railFraction = 0.16;
return PhysicalShape(
elevation: 6,
color: Colors.white,
shadowColor: Colors.black54,
clipper: const TicketClipper(
axis: TicketNotchAxis.vertical,
position: railFraction,
notchRadius: 14,
cornerRadius: 16,
),
child: SizedBox(
height: 170,
child: Row(
children: [
Expanded(
flex: (railFraction * 100).round(),
child: Container(
color: _red,
alignment: Alignment.center,
child: const RotatedBox(
quarterTurns: 3,
child: Text(
'FOOD COUPON',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
),
),
),
const _Perforation(),
Expanded(
flex: (100 - railFraction * 100).round(),
child: Stack(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 22, 20, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
data.percent,
style: const TextStyle(
color: _red,
fontSize: 44,
fontWeight: FontWeight.w900,
height: 1,
),
),
const SizedBox(width: 6),
const Text(
'OFF',
style: TextStyle(
color: _red,
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 6),
Text(
data.title,
style: const TextStyle(
color: Colors.black87,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 6),
Text('ValidTill : ${data.validTill}', style: const TextStyle(color: Colors.black45)),
Text('Code : ${data.code}', style: const TextStyle(color: Colors.black45)),
],
),
),
const Positioned(
top: 40,
right: 16,
child: Icon(Icons.delivery_dining, size: 56, color: _red),
),
const Positioned(
bottom: 14,
right: 16,
child: Icon(Icons.info_outline, color: Colors.black38),
),
Positioned(
top: -22,
right: -20,
child: Container(
padding: const EdgeInsets.fromLTRB(28, 30, 20, 10),
decoration: const BoxDecoration(
color: _red,
borderRadius: BorderRadius.only(bottomLeft: Radius.circular(40)),
),
child: const Text(
'USE NOW',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
],
),
),
);
}
}
class QrVoucherCard extends StatelessWidget {
const QrVoucherCard({super.key, required this.data});
final CouponData data;
@override
Widget build(BuildContext context) {
const railFraction = 0.34;
return PhysicalShape(
elevation: 6,
color: Colors.white,
shadowColor: Colors.black54,
clipper: const TicketClipper(
axis: TicketNotchAxis.vertical,
position: railFraction,
notchRadius: 12,
cornerRadius: 18,
),
child: SizedBox(
height: 150,
child: Row(
children: [
Expanded(
flex: (railFraction * 100).round(),
child: Container(
color: _red,
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.qr_code_2, size: 78, color: Colors.white),
Text('Code:${data.code}', style: const TextStyle(color: Colors.white, fontSize: 12)),
],
),
),
),
const _Perforation(),
Expanded(
flex: (100 - railFraction * 100).round(),
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 12, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(data.percent, style: const TextStyle(color: _red, fontSize: 30, fontWeight: FontWeight.w900)),
const SizedBox(width: 4),
const Text('OFF', style: TextStyle(color: _red, fontSize: 15, fontWeight: FontWeight.w700)),
],
),
const SizedBox(height: 4),
Text(data.title, style: const TextStyle(color: Colors.black87, fontSize: 15, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text('Used for : ${data.usedFor}', style: const TextStyle(color: Colors.black45)),
Text('ValidTill : ${data.validTill}', style: const TextStyle(color: Colors.black45)),
],
),
),
),
],
),
),
);
}
}
class DetailsCard extends StatelessWidget {
const DetailsCard({super.key, required this.data});
final CouponData data;
@override
Widget build(BuildContext context) {
return PhysicalShape(
elevation: 4,
color: Colors.white,
shadowColor: Colors.black54,
clipper: const TicketClipper(
axis: TicketNotchAxis.vertical,
position: 0.82,
notchRadius: 12,
cornerRadius: 14,
),
child: SizedBox(
height: 210,
child: Row(
children: [
Expanded(
flex: 82,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 12, 18),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_DetailRow('Event:', data.title),
_DetailRow('Discount:', '${data.percent} off on Food'),
const _DetailRow('MOV:', r'$3'),
const _DetailRow('Condition:', 'For new users (30 days)'),
const _DetailRow('Limit Use:', '1 per day'),
_DetailRow('Valid Period:', data.validPeriod),
],
),
),
),
Expanded(
flex: 18,
child: Container(
color: _red,
alignment: Alignment.center,
child: const RotatedBox(
quarterTurns: 3,
child: Text(
'FOOD COUPON',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
),
),
),
],
),
),
);
}
}
class _DetailRow extends StatelessWidget {
const _DetailRow(this.label, this.value);
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
SizedBox(
width: 92,
child: Text(label, style: const TextStyle(color: Colors.black87, fontWeight: FontWeight.w600, fontSize: 13)),
),
Expanded(
child: Text(value, style: const TextStyle(color: Colors.black54, fontSize: 13)),
),
],
),
);
}
}
class _Perforation extends StatelessWidget {
const _Perforation();
@override
Widget build(BuildContext context) {
return CustomPaint(
size: const Size(1, double.infinity),
painter: _DashedLinePainter(),
);
}
}
class _DashedLinePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.black26
..strokeWidth = 1.4;
const dash = 5.0;
const gap = 4.0;
var y = 6.0;
while (y < size.height - 6) {
canvas.drawLine(Offset(0, y), Offset(0, y + dash), paint);
y += dash + gap;
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}