Skip to main content

sketchify 1.0.0 copy "sketchify: ^1.0.0" to clipboard
sketchify: ^1.0.0 copied to clipboard

Turn any image into a "draw itself" animation. Traces PNG, JPEG, WebP, SVG and more into smooth outlines that sketch in, then ends on the exact original.

example/lib/main.dart

import 'dart:async';

import 'package:file_selector/file_selector.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:sketchify/sketchify.dart';

// Traced images are cached on disk (IndexedDB on the web) out of the box,
// so they open instantly on the next launch too. No setup needed.
void main() => runApp(const SketchifyShowcase());

/// A playground for the sketchify package: pick an image, pick a look, and
/// watch it draw itself in.
class SketchifyShowcase extends StatefulWidget {
  const SketchifyShowcase({super.key});

  @override
  State<SketchifyShowcase> createState() => _SketchifyShowcaseState();
}

class _SketchifyShowcaseState extends State<SketchifyShowcase> {
  ThemeMode _themeMode = ThemeMode.system;

  ThemeData _theme(Brightness brightness) {
    final scheme = ColorScheme.fromSeed(seedColor: const Color(0xFF5B5BD6), brightness: brightness);
    return ThemeData(
      colorScheme: scheme,
      scaffoldBackgroundColor: scheme.surfaceContainerLowest,
      cardTheme: CardThemeData(
        elevation: 0,
        color: scheme.surfaceContainerLow,
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
        margin: EdgeInsets.zero,
      ),
      chipTheme: const ChipThemeData(showCheckmark: false),
      segmentedButtonTheme: SegmentedButtonThemeData(
        style: ButtonStyle(
          visualDensity: VisualDensity.compact,
          padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 6)),
          textStyle: WidgetStatePropertyAll(const TextStyle(fontSize: 13)),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sketchify',
      debugShowCheckedModeBanner: false,
      theme: _theme(Brightness.light),
      darkTheme: _theme(Brightness.dark),
      themeMode: _themeMode,
      home: ShowcasePage(
        onToggleTheme: () => setState(() {
          final bool dark =
              _themeMode == ThemeMode.dark ||
              (_themeMode == ThemeMode.system && MediaQuery.platformBrightnessOf(context) == Brightness.dark);
          _themeMode = dark ? ThemeMode.light : ThemeMode.dark;
        }),
      ),
    );
  }
}

// ── Samples and looks ───────────────────────────────────────────────────────

class _Sample {
  final String name;
  final String asset;
  final IconData icon;

  const _Sample(this.name, this.asset, this.icon);
}

const List<_Sample> _samples = [
  _Sample('Cat', 'assets/cat.png', Icons.pets_rounded),
  _Sample('Rocket', 'assets/rocket.svg', Icons.rocket_launch_rounded),
  _Sample('Owl', 'assets/owl.svg', Icons.nightlight_round),
  _Sample('Sunset', 'assets/sunset.svg', Icons.landscape_rounded),
  _Sample('Coffee', 'assets/coffee.svg', Icons.coffee_rounded),
  _Sample('Lettering', 'assets/lettering.svg', Icons.text_fields_rounded),
];

class _Look {
  final String name;
  final IconData icon;

  /// Paper colour behind the drawing; null uses the theme's.
  final Color? paper;
  final SketchStyle Function(ColorScheme scheme) style;

  const _Look(this.name, this.icon, this.style, {this.paper});
}

final List<_Look> _looks = [
  _Look(
    'Pencil',
    Icons.edit_rounded,
    (s) => SketchStyle(lineColor: s.onSurface.withValues(alpha: 0.85), lineWidth: 1.3, showPen: true, penGlow: false),
  ),
  _Look(
    'Neon',
    Icons.auto_awesome_rounded,
    (s) => const SketchStyle(
      lineGradient: LinearGradient(colors: [Color(0xFFFF3D9A), Color(0xFF8B5CF6), Color(0xFF22D3EE)]),
      lineWidth: 2.6,
      showPen: true,
      order: SketchOrder.staggered,
      reveal: SketchReveal.circle,
    ),
  ),
  _Look(
    'Ink',
    Icons.palette_rounded,
    (s) => const SketchStyle(
      useShapeColors: true,
      lineWidth: 2,
      order: SketchOrder.sequential,
      reveal: SketchReveal.wipeDown,
    ),
  ),
  _Look(
    'Blueprint',
    Icons.architecture_rounded,
    (s) => const SketchStyle(
      lineColor: Color(0xFFE3F0FF),
      lineWidth: 1.6,
      showPen: true,
      penColor: Color(0xFFFFFFFF),
      fill: SketchFill.none,
      order: SketchOrder.staggered,
    ),
    paper: const Color(0xFF1C4E89),
  ),
  _Look(
    'Vector',
    Icons.format_shapes_rounded,
    (s) => SketchStyle(lineColor: s.primary, lineWidth: 2, fill: SketchFill.vector, reveal: SketchReveal.wipeRight),
  ),
];

// ── Page ────────────────────────────────────────────────────────────────────

class ShowcasePage extends StatefulWidget {
  const ShowcasePage({super.key, required this.onToggleTheme});

  final VoidCallback onToggleTheme;

  @override
  State<ShowcasePage> createState() => _ShowcasePageState();
}

class _ShowcasePageState extends State<ShowcasePage> {
  final GlobalKey<SketchAnimationState> _animation = GlobalKey();
  final ValueNotifier<double> _progress = ValueNotifier(0);

  String _current = _samples.first.asset;
  String? _customName;
  Sketch? _sketch; // what's on stage; kept while the next image traces
  bool _loading = true;
  Timer? _showLoading; // cache hits are quick, so the spinner waits a moment
  String? _error;

  int _look = 0;
  SketchStyle? _style; // the look plus the user's tweaks
  double _speed = 1;
  SketchPlayMode _playMode = SketchPlayMode.once;
  bool _paused = false;
  bool _finished = false;

  @override
  void initState() {
    super.initState();
    _load(_current, () => Sketchify.traceAsset(_current));
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // Theme-dependent looks follow light/dark mode.
    _style ??= _looks[_look].style(Theme.of(context).colorScheme);
  }

  @override
  void dispose() {
    _showLoading?.cancel();
    _sketch?.dispose();
    _progress.dispose();
    super.dispose();
  }

  /// Shows the image [id]. Tracing goes through [Sketchify.cache], so an
  /// image seen before (even in an earlier session) is back almost at once.
  Future<void> _open(String id, Future<Sketch> Function() trace) async {
    setState(() {
      _current = id;
      _error = null;
      _paused = false;
      _finished = false;
    });
    await _load(id, trace);
  }

  Future<void> _load(String id, Future<Sketch> Function() trace) async {
    _showLoading?.cancel();
    _showLoading = Timer(const Duration(milliseconds: 120), () {
      if (mounted && _current == id) setState(() => _loading = true);
    });
    try {
      final sketch = await trace();
      if (!mounted || _current != id) return sketch.dispose(); // superseded
      final Sketch? old = _sketch;
      setState(() {
        _sketch = sketch;
        _loading = false;
      });
      // Free the previous image once the new one is on screen.
      if (old != null) WidgetsBinding.instance.addPostFrameCallback((_) => old.dispose());
    } catch (e) {
      if (mounted && _current == id) {
        setState(() {
          _error = e is UnsupportedImageException ? e.message : 'Could not trace this image: $e';
          _loading = false;
        });
      }
    } finally {
      if (_current == id) _showLoading?.cancel();
    }
  }

  Future<void> _clearCache() async {
    await Sketchify.cache?.clear();
    if (!mounted) return;
    ScaffoldMessenger.of(
      context,
    ).showSnackBar(const SnackBar(content: Text('Trace cache cleared. Images are traced afresh next time.')));
  }

  Future<void> _pickImage() async {
    final file = await openFile(
      acceptedTypeGroups: [
        XTypeGroup(
          label: 'Images',
          extensions: SketchImageFormat.allExtensions,
          mimeTypes: SketchImageFormat.allMimeTypes,
          uniformTypeIdentifiers: const ['public.image', 'public.svg-image'],
        ),
      ],
    );
    if (file == null) return;
    final bytes = await file.readAsBytes();
    // A fresh id each time, so picking a new image never shows a stale one.
    // (Picking the same file again is still a cache hit: the cache goes by
    // the file's contents.)
    final id = 'custom:${DateTime.now().microsecondsSinceEpoch}';
    setState(() => _customName = file.name);
    await _open(id, () => Sketchify.trace(bytes));
  }

  void _chooseLook(int index) {
    setState(() {
      _look = index;
      _style = _looks[index].style(Theme.of(context).colorScheme);
    });
    _replay();
  }

  void _tweak(SketchStyle Function(SketchStyle s) change) => setState(() => _style = change(_style!));

  void _replay() {
    setState(() {
      _paused = false;
      _finished = false;
    });
    _animation.currentState?.replay();
  }

  void _togglePlay() {
    final state = _animation.currentState;
    if (state == null) return;
    if (_finished && !_paused && _playMode == SketchPlayMode.once) return _replay();
    setState(() => _paused = !_paused);
    if (_paused) {
      state.pause();
    } else {
      if (state.progress >= 1 && _playMode == SketchPlayMode.once) return _replay();
      _finished = false;
      state.resume();
    }
  }

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        titleSpacing: 20,
        title: Row(
          children: [
            Container(
              padding: const EdgeInsets.all(8),
              decoration: BoxDecoration(
                gradient: const LinearGradient(colors: [Color(0xFFFF3D9A), Color(0xFF5B5BD6)]),
                borderRadius: BorderRadius.circular(12),
              ),
              child: const Icon(Icons.draw_rounded, color: Colors.white, size: 20),
            ),
            const SizedBox(width: 12),
            Flexible(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text(
                    'Sketchify',
                    overflow: TextOverflow.ellipsis,
                    style: TextStyle(fontWeight: FontWeight.w700),
                  ),
                  Text(
                    'Any image, drawn by hand',
                    overflow: TextOverflow.ellipsis,
                    style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant),
                  ),
                ],
              ),
            ),
          ],
        ),
        actions: [
          IconButton(
            tooltip: 'Clear trace cache',
            onPressed: _clearCache,
            icon: const Icon(Icons.cleaning_services_rounded),
          ),
          IconButton(
            tooltip: 'Light / dark',
            onPressed: () {
              widget.onToggleTheme();
              // Re-derive theme-dependent looks after the theme flips.
              WidgetsBinding.instance.addPostFrameCallback((_) {
                if (mounted) setState(() => _style = _looks[_look].style(Theme.of(context).colorScheme));
              });
            },
            icon: Icon(
              Theme.of(context).brightness == Brightness.dark ? Icons.light_mode_rounded : Icons.dark_mode_rounded,
            ),
          ),
          const SizedBox(width: 8),
        ],
      ),
      body: SafeArea(
        child: LayoutBuilder(
          builder: (context, constraints) {
            final bool wide = constraints.maxWidth >= 960;
            final stage = _Stage(
              sketch: _sketch,
              error: _error,
              loading: _loading,
              paper: _looks[_look].paper,
              child: _sketch == null
                  ? null
                  : SketchAnimation(
                      key: _animation,
                      sketch: _sketch!,
                      style: _style!,
                      speed: _speed,
                      playMode: _playMode,
                      delay: const Duration(milliseconds: 250),
                      onProgress: (v) => _progress.value = v,
                      onCompleted: () {
                        if (_playMode == SketchPlayMode.once) setState(() => _finished = true);
                      },
                    ),
            );
            final transport = _Transport(
              progress: _progress,
              playing: !_paused && !(_finished && _playMode == SketchPlayMode.once),
              enabled: _sketch != null && _error == null,
              onReplay: _replay,
              onTogglePlay: _togglePlay,
              onSeekStart: () => setState(() => _paused = true),
              onSeek: (v) => _animation.currentState?.seek(v),
            );
            final controls = _Controls(
              sampleId: _current,
              customName: _customName,
              onSample: (s) => _open(s.asset, () => Sketchify.traceAsset(s.asset)),
              onPick: _pickImage,
              look: _look,
              onLook: _chooseLook,
              style: _style!,
              onTweak: _tweak,
              speed: _speed,
              onSpeed: (v) => setState(() => _speed = v),
              playMode: _playMode,
              onPlayMode: (m) => setState(() {
                _playMode = m;
                _finished = false;
              }),
              sketch: _sketch,
            );

            if (wide) {
              return Padding(
                padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
                child: Row(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    Expanded(
                      child: Column(
                        children: [
                          Expanded(child: stage),
                          const SizedBox(height: 12),
                          transport,
                        ],
                      ),
                    ),
                    const SizedBox(width: 20),
                    SizedBox(width: 380, child: SingleChildScrollView(child: controls)),
                  ],
                ),
              );
            }
            return ListView(
              padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
              children: [
                AspectRatio(aspectRatio: 1.15, child: stage),
                const SizedBox(height: 12),
                transport,
                const SizedBox(height: 20),
                controls,
              ],
            );
          },
        ),
      ),
    );
  }
}

// ── Stage: the drawing on dotted paper ──────────────────────────────────────

class _Stage extends StatelessWidget {
  const _Stage({
    required this.sketch,
    required this.error,
    required this.loading,
    required this.paper,
    required this.child,
  });

  final Sketch? sketch;
  final String? error;
  final bool loading;
  final Color? paper;

  /// The animation. Always the same single widget (it holds a GlobalKey), so
  /// it is dimmed while loading rather than swapped out.
  final Widget? child;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    final Color background = paper ?? scheme.surfaceContainer;
    final Color dots = paper != null
        ? Colors.white.withValues(alpha: 0.16)
        : scheme.outlineVariant.withValues(alpha: 0.6);
    final double inset = MediaQuery.sizeOf(context).width < 600 ? 20 : 36;

    return AnimatedContainer(
      duration: const Duration(milliseconds: 400),
      curve: Curves.easeOut,
      decoration: BoxDecoration(
        color: background,
        borderRadius: BorderRadius.circular(28),
        border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.5)),
      ),
      clipBehavior: Clip.antiAlias,
      child: Stack(
        fit: StackFit.expand,
        children: [
          CustomPaint(painter: _DotGridPainter(dots)),
          if (error != null)
            Center(
              child: Padding(
                padding: const EdgeInsets.all(32),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Icon(Icons.broken_image_rounded, size: 48, color: scheme.error),
                    const SizedBox(height: 12),
                    Text(error!, textAlign: TextAlign.center),
                  ],
                ),
              ),
            )
          else if (child != null)
            AnimatedOpacity(
              duration: const Duration(milliseconds: 250),
              opacity: loading ? 0.2 : 1,
              child: Padding(padding: EdgeInsets.all(inset), child: child),
            ),
          if (loading)
            const Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [CircularProgressIndicator(), SizedBox(height: 16), Text('Tracing…')],
              ),
            ),
          if (sketch != null && error == null && !loading)
            Positioned(
              left: 16,
              top: 16,
              child: _Pill(
                icon: sketch!.fromCache ? Icons.bolt_rounded : null,
                text:
                    '${sketch!.format.name.toUpperCase()} · '
                    '${sketch!.fromCache ? 'from cache' : 'traced'} in ${sketch!.elapsed.inMilliseconds} ms',
              ),
            ),
        ],
      ),
    );
  }
}

class _DotGridPainter extends CustomPainter {
  _DotGridPainter(this.color);

  final Color color;

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()..color = color;
    const double gap = 20;
    for (double y = gap / 2; y < size.height; y += gap) {
      for (double x = gap / 2; x < size.width; x += gap) {
        canvas.drawCircle(Offset(x, y), 1.1, paint);
      }
    }
  }

  @override
  bool shouldRepaint(_DotGridPainter old) => old.color != color;
}

class _Pill extends StatelessWidget {
  const _Pill({required this.text, this.icon});

  final String text;
  final IconData? icon;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
      decoration: BoxDecoration(color: scheme.surface.withValues(alpha: 0.85), borderRadius: BorderRadius.circular(99)),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          if (icon != null) ...[Icon(icon, size: 14, color: scheme.primary), const SizedBox(width: 4)],
          Text(
            text,
            style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: scheme.onSurfaceVariant),
          ),
        ],
      ),
    );
  }
}

// ── Transport: replay, play/pause and the timeline ─────────────────────────

class _Transport extends StatelessWidget {
  const _Transport({
    required this.progress,
    required this.playing,
    required this.enabled,
    required this.onReplay,
    required this.onTogglePlay,
    required this.onSeekStart,
    required this.onSeek,
  });

  final ValueListenable<double> progress;
  final bool playing;
  final bool enabled;
  final VoidCallback onReplay;
  final VoidCallback onTogglePlay;
  final VoidCallback onSeekStart;
  final ValueChanged<double> onSeek;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
        child: Row(
          children: [
            IconButton(tooltip: 'Replay', onPressed: enabled ? onReplay : null, icon: const Icon(Icons.replay_rounded)),
            IconButton.filled(
              tooltip: playing ? 'Pause' : 'Play',
              onPressed: enabled ? onTogglePlay : null,
              icon: Icon(playing ? Icons.pause_rounded : Icons.play_arrow_rounded),
            ),
            Expanded(
              child: ValueListenableBuilder<double>(
                valueListenable: progress,
                builder: (context, value, _) => Slider(
                  value: value.clamp(0.0, 1.0),
                  onChangeStart: enabled ? (_) => onSeekStart() : null,
                  onChanged: enabled ? onSeek : null,
                ),
              ),
            ),
            ValueListenableBuilder<double>(
              valueListenable: progress,
              builder: (context, value, _) => SizedBox(
                width: 44,
                child: Text(
                  '${(value * 100).round()}%',
                  textAlign: TextAlign.end,
                  style: const TextStyle(fontFeatures: [FontFeature.tabularFigures()], fontWeight: FontWeight.w600),
                ),
              ),
            ),
            const SizedBox(width: 8),
          ],
        ),
      ),
    );
  }
}

// ── Controls ────────────────────────────────────────────────────────────────

class _Controls extends StatelessWidget {
  const _Controls({
    required this.sampleId,
    required this.customName,
    required this.onSample,
    required this.onPick,
    required this.look,
    required this.onLook,
    required this.style,
    required this.onTweak,
    required this.speed,
    required this.onSpeed,
    required this.playMode,
    required this.onPlayMode,
    required this.sketch,
  });

  final String sampleId;
  final String? customName;
  final ValueChanged<_Sample> onSample;
  final VoidCallback onPick;
  final int look;
  final ValueChanged<int> onLook;
  final SketchStyle style;
  final void Function(SketchStyle Function(SketchStyle s)) onTweak;
  final double speed;
  final ValueChanged<double> onSpeed;
  final SketchPlayMode playMode;
  final ValueChanged<SketchPlayMode> onPlayMode;
  final Sketch? sketch;

  @override
  Widget build(BuildContext context) {
    final bool custom = sampleId.startsWith('custom:');
    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        _Section(
          title: 'Image',
          child: Wrap(
            spacing: 8,
            runSpacing: 8,
            children: [
              for (final s in _samples)
                ChoiceChip(
                  avatar: Icon(s.icon, size: 18),
                  label: Text(s.name),
                  selected: sampleId == s.asset,
                  onSelected: (_) => onSample(s),
                ),
              ChoiceChip(
                avatar: const Icon(Icons.upload_rounded, size: 18),
                label: Text(custom && customName != null ? _shorten(customName!) : 'Your image'),
                selected: custom,
                onSelected: (_) => onPick(),
              ),
            ],
          ),
        ),
        _Section(
          title: 'Look',
          child: Wrap(
            spacing: 8,
            runSpacing: 8,
            children: [
              for (int i = 0; i < _looks.length; i++)
                ChoiceChip(
                  avatar: Icon(_looks[i].icon, size: 18),
                  label: Text(_looks[i].name),
                  selected: look == i,
                  onSelected: (_) => onLook(i),
                ),
            ],
          ),
        ),
        _Section(
          title: 'Playback',
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              SegmentedButton<double>(
                showSelectedIcon: false,
                segments: const [
                  ButtonSegment(value: 0.5, label: Text('0.5×')),
                  ButtonSegment(value: 1, label: Text('1×')),
                  ButtonSegment(value: 2, label: Text('2×')),
                  ButtonSegment(value: 4, label: Text('4×')),
                ],
                selected: {speed},
                onSelectionChanged: (s) => onSpeed(s.first),
              ),
              const SizedBox(height: 10),
              SegmentedButton<SketchPlayMode>(
                showSelectedIcon: false,
                segments: const [
                  ButtonSegment(value: SketchPlayMode.once, label: Text('Once')),
                  ButtonSegment(value: SketchPlayMode.loop, label: Text('Loop')),
                  ButtonSegment(value: SketchPlayMode.pingPong, label: Text('Ping-pong')),
                ],
                selected: {playMode},
                onSelectionChanged: (s) => onPlayMode(s.first),
              ),
            ],
          ),
        ),
        _Section(
          title: 'Fine-tune',
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              _Labeled(
                label: 'Order',
                child: SegmentedButton<SketchOrder>(
                  showSelectedIcon: false,
                  segments: const [
                    ButtonSegment(value: SketchOrder.together, label: Text('All')),
                    ButtonSegment(value: SketchOrder.staggered, label: Text('Stagger')),
                    ButtonSegment(value: SketchOrder.sequential, label: Text('In turn')),
                  ],
                  selected: {style.order},
                  onSelectionChanged: (s) => onTweak((st) => st.copyWith(order: s.first)),
                ),
              ),
              _Labeled(
                label: 'Fill',
                child: SegmentedButton<SketchFill>(
                  showSelectedIcon: false,
                  segments: const [
                    ButtonSegment(value: SketchFill.image, label: Text('Image')),
                    ButtonSegment(value: SketchFill.vector, label: Text('Vector')),
                    ButtonSegment(value: SketchFill.none, label: Text('Lines')),
                  ],
                  selected: {style.fill},
                  onSelectionChanged: (s) => onTweak((st) => st.copyWith(fill: s.first)),
                ),
              ),
              _Labeled(
                label: 'Reveal',
                child: Wrap(
                  spacing: 6,
                  runSpacing: 6,
                  children: [
                    for (final r in SketchReveal.values)
                      ChoiceChip(
                        label: Text(_revealName(r)),
                        selected: style.reveal == r,
                        onSelected: style.fill == SketchFill.none
                            ? null
                            : (_) => onTweak((st) => st.copyWith(reveal: r)),
                      ),
                  ],
                ),
              ),
              _Labeled(
                label: 'Line width  ${style.lineWidth.toStringAsFixed(1)}',
                child: Slider(
                  min: 0.5,
                  max: 6,
                  value: style.lineWidth.clamp(0.5, 6),
                  onChanged: (v) => onTweak((st) => st.copyWith(lineWidth: v)),
                ),
              ),
              SwitchListTile(
                contentPadding: EdgeInsets.zero,
                title: const Text('Pen dot'),
                value: style.showPen,
                onChanged: (v) => onTweak((st) => st.copyWith(showPen: v)),
              ),
              SwitchListTile(
                contentPadding: EdgeInsets.zero,
                title: const Text('Keep lines on top'),
                value: style.keepLines,
                onChanged: (v) => onTweak((st) => st.copyWith(keepLines: v)),
              ),
              SwitchListTile(
                contentPadding: EdgeInsets.zero,
                title: const Text('Lines in shape colours'),
                value: style.useShapeColors,
                onChanged: (v) => onTweak((st) => st.copyWith(useShapeColors: v)),
              ),
            ],
          ),
        ),
        if (sketch != null)
          _Section(
            title: 'What was found',
            child: _Findings(sketch: sketch!),
          ),
      ],
    );
  }

  static String _shorten(String name) => name.length <= 18 ? name : '${name.substring(0, 15)}…';

  static String _revealName(SketchReveal r) => switch (r) {
    SketchReveal.fade => 'Fade',
    SketchReveal.wipeDown => 'Wipe ↓',
    SketchReveal.wipeUp => 'Wipe ↑',
    SketchReveal.wipeRight => 'Wipe →',
    SketchReveal.wipeLeft => 'Wipe ←',
    SketchReveal.circle => 'Circle',
  };
}

class _Findings extends StatelessWidget {
  const _Findings({required this.sketch});

  final Sketch sketch;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    Widget stat(String value, String label) => Expanded(
      child: Column(
        children: [
          Text(value, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700)),
          Text(label, style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant)),
        ],
      ),
    );

    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        Row(
          children: [
            stat('${sketch.shapes.length}', 'shapes'),
            stat('${sketch.palette.length}', 'colours'),
            stat('${sketch.image.width}×${sketch.image.height}', 'final frame'),
          ],
        ),
        const SizedBox(height: 14),
        Wrap(
          spacing: 6,
          runSpacing: 6,
          children: [
            for (final c in sketch.palette)
              Tooltip(
                message: '#${c.toARGB32().toRadixString(16).substring(2).toUpperCase()}',
                child: Container(
                  width: 28,
                  height: 28,
                  decoration: BoxDecoration(
                    color: c,
                    shape: BoxShape.circle,
                    border: Border.all(color: scheme.outlineVariant),
                  ),
                ),
              ),
          ],
        ),
        if (sketch.removedBackground != null) ...[
          const SizedBox(height: 12),
          Row(
            children: [
              Icon(Icons.auto_fix_high_rounded, size: 16, color: scheme.primary),
              const SizedBox(width: 6),
              const Flexible(child: Text('Background removed: ', overflow: TextOverflow.ellipsis)),
              Container(
                width: 14,
                height: 14,
                decoration: BoxDecoration(
                  color: sketch.removedBackground,
                  borderRadius: BorderRadius.circular(4),
                  border: Border.all(color: scheme.outlineVariant),
                ),
              ),
            ],
          ),
        ],
      ],
    );
  }
}

class _Section extends StatelessWidget {
  const _Section({required this.title, required this.child});

  final String title;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 12),
      child: Card(
        child: Padding(
          padding: const EdgeInsets.fromLTRB(18, 16, 18, 18),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700)),
              const SizedBox(height: 12),
              child,
            ],
          ),
        ),
      ),
    );
  }
}

class _Labeled extends StatelessWidget {
  const _Labeled({required this.label, required this.child});

  final String label;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 12),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Text(label, style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant)),
          const SizedBox(height: 6),
          child,
        ],
      ),
    );
  }
}
0
likes
160
points
370
downloads
screenshot

Documentation

API reference

Publisher

verified publisherniteshmandal1.com.np

Weekly Downloads

Turn any image into a "draw itself" animation. Traces PNG, JPEG, WebP, SVG and more into smooth outlines that sketch in, then ends on the exact original.

Repository (GitHub)
View/report issues

Topics

#animation #svg #drawing #vector #image

License

MIT (license)

Dependencies

flutter, flutter_svg, path_provider, web

More

Packages that depend on sketchify