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

A collection of reusable CustomClipper<Path> shapes for Flutter including waves, polygons, tickets, coupons, borders, and creative UI clipping effects.

example/lib/main.dart

// 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;
}
3
likes
155
points
15
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A collection of reusable CustomClipper<Path> shapes for Flutter including waves, polygons, tickets, coupons, borders, and creative UI clipping effects.

Repository (GitHub)
View/report issues

Topics

#flutter #custom-clipper #clip-path #ui #shapes

License

MIT (license)

Dependencies

flutter

More

Packages that depend on nn_clippers