// // A Flutter widget that renders an animated single-kanji stroke drawing using // // the project's SVG path parser. This widget loads the Kanji SVG from // // `assets/kanjivg/kanji/.svg` (the same assets used by the exporter), // // parses the path `d` attributes, converts them into `ui.Path` objects and // // animates the stroke draw using an AnimationController. // // // // Notes: // // - This implementation uses the repository's SVG path parser (`parsePath`) // // and the custom `Path` representation in `lib/svg/path.dart`. We convert // // those into Flutter `ui.Path` by sampling points along the sampled SVG path. // // - The SVGs in KanjiVG typically use a 109x109 viewBox. We detect the // // `viewBox` attribute (if present) and scale paths to fit the widget size. // // // // Public API (Kanimaji widget parameters): // // - `kanji` (required): the single character Kanji to display. // // - `size`: target width/height of the widget (square). // // - `strokeColor`, `strokeUnfilledColor`, `strokeWidth`. // // - `backgroundColor`. // // - `speed`: multiplier to speed up (>1) or slow down (<1) the animation. // // - `loop`: whether to repeat the animation. // // - `showBrush`: whether to show a small circular "brush" tip following the stroke. // // - `durationOverride`: optionally override the computed animation duration. // // // // Example: // // Kanimaji( // // kanji: '水', // // size: 240, // // strokeColor: Colors.black, // // strokeUnfilledColor: Colors.grey.shade300, // // strokeWidth: 3.0, // // backgroundColor: Colors.white, // // speed: 1.0, // // loop: true, // // ); // import 'dart:math' as math; // import 'dart:ui' as ui; // import 'package:flutter/material.dart'; // import 'package:flutter/services.dart' show rootBundle; // import 'package:xml/xml.dart'; // import 'svg/parser.dart' show parsePath; // import 'svg/path.dart' as svgpath; // import 'point.dart' as kpt; // import 'animator.dart' show strokeLengthToDuration; // class Kanimaji extends StatefulWidget { // final String kanji; // final double size; // final Color strokeColor; // final Color strokeUnfilledColor; // final double strokeWidth; // final Color backgroundColor; // final double speed; // final bool loop; // final bool showBrush; // final Duration? durationOverride; // const Kanimaji({ // super.key, // required this.kanji, // this.size = 150, // this.strokeColor = Colors.black, // this.strokeUnfilledColor = const Color(0xFFEEEEEE), // this.strokeWidth = 3.0, // this.backgroundColor = Colors.transparent, // this.speed = 1.0, // this.loop = true, // this.showBrush = true, // this.durationOverride, // }); // @override // State createState() => _KanimajiState(); // } // class _KanimajiState extends State with SingleTickerProviderStateMixin { // bool _loaded = false; // String? _error; // late AnimationController _controller; // // Converted UI paths and per-path lengths // List _paths = []; // List _pathLengths = []; // // per-stroke durations (in seconds) computed using animator.strokeLengthToDuration // List _pathDurations = []; // // viewBox width/height from svg for scaling // double _viewBoxWidth = 109.0; // double _viewBoxHeight = 109.0; // // total length summation (for drawing metrics) // double _totalLength = 0.0; // @override // void initState() { // super.initState(); // // Dummy controller; real duration will be set after loading // _controller = AnimationController(vsync: this); // // Add a single listener once to avoid duplicates when reconfiguring the controller. // _controller.addListener(_onTick); // _loadAndParseSvg(); // } // @override // void didUpdateWidget(covariant Kanimaji oldWidget) { // super.didUpdateWidget(oldWidget); // // If kanji changed, re-load // if (oldWidget.kanji != widget.kanji) { // _loadAndParseSvg(); // } else if (oldWidget.speed != widget.speed || // oldWidget.durationOverride != widget.durationOverride || // oldWidget.loop != widget.loop) { // // Update controller if animation parameters changed // _configureController(); // } // } // void _onTick() { // if (mounted) setState(() {}); // } // @override // void dispose() { // _controller.removeListener(_onTick); // _controller.dispose(); // super.dispose(); // } // String _codepointHex(String s) { // // Support multi-code-unit characters by taking the first Unicode rune // final runes = s.runes.toList(); // if (runes.isEmpty) return '00000'; // return runes[0].toRadixString(16).padLeft(5, '0'); // } // Future _loadAndParseSvg() async { // setState(() { // _loaded = false; // _error = null; // _paths = []; // _pathLengths = []; // _pathDurations = []; // _totalLength = 0.0; // }); // final hex = _codepointHex(widget.kanji); // final assetPath = 'assets/kanjivg/kanji/$hex.svg'; // try { // final svgString = await rootBundle.loadString(assetPath); // final doc = XmlDocument.parse(svgString); // // parse viewBox: "minX minY width height" // final viewBoxAttr = doc.rootElement.getAttribute('viewBox'); // if (viewBoxAttr != null) { // final parts = viewBoxAttr.split(RegExp(r'[\s,]+')).where((p) => p.isNotEmpty).toList(); // if (parts.length >= 4) { // final w = double.tryParse(parts[2]); // final h = double.tryParse(parts[3]); // if (w != null && h != null && w > 0 && h > 0) { // _viewBoxWidth = w; // _viewBoxHeight = h; // } // } // } else { // // fallback to width/height attributes if present (may include "px") // final widthAttr = doc.rootElement.getAttribute('width'); // final heightAttr = doc.rootElement.getAttribute('height'); // if (widthAttr != null && heightAttr != null) { // final w = double.tryParse(widthAttr.replaceAll('px', '').trim()); // final h = double.tryParse(heightAttr.replaceAll('px', '').trim()); // if (w != null && h != null && w > 0 && h > 0) { // _viewBoxWidth = w; // _viewBoxHeight = h; // } // } // } // // Find all elements inside the SVG. KanjiVG groups strokes in . // final pathElements = doc.findAllElements('path', namespace: doc.rootElement.name.namespaceUri).toList(); // if (pathElements.isEmpty) { // throw Exception('No elements found in $assetPath'); // } // final uiPaths = []; // final lengths = []; // for (final p in pathElements) { // final d = p.getAttribute('d'); // if (d == null || d.trim().isEmpty) continue; // // Parse the SVG path using existing parser // final svgParsed = parsePath(d); // returns svgpath.Path (project type) // // Convert to ui.Path using direct segment conversion (no sampling) // final uiPath = _svgPathToUiPath(svgParsed); // if (uiPath == null) continue; // // Compute its drawing length using PathMetrics (for extractPath) // final pm = uiPath.computeMetrics(); // double pathLen = 0.0; // for (final metric in pm) { // pathLen += metric.length; // } // // If the path metric yields zero (rare), skip // if (pathLen <= 0) continue; // // Compute logical stroke length using the parser (precise geometry) and convert to duration // final double logicalLen = svgParsed.size(error: 1e-8).toDouble(); // final double durationSec = strokeLengthToDuration(logicalLen); // uiPaths.add(uiPath); // lengths.add(pathLen); // _pathDurations.add(durationSec); // _totalLength += pathLen; // } // if (uiPaths.isEmpty) { // throw Exception('No drawable paths found in $assetPath'); // } // setState(() { // _paths = uiPaths; // _pathLengths = lengths; // }); // _configureController(); // setState(() { // _loaded = true; // }); // } catch (e) { // setState(() { // _error = 'Error loading/ parsing $assetPath: $e'; // _loaded = true; // mark loaded so we can show error // }); // } // } // // Convert a parsed SVG path (project Path) into a Flutter ui.Path without sampling. // // Uses the concrete segment types (Move, Line, CubicBezier, QuadraticBezier, Arc, Close) // // and maps them to the corresponding ui.Path methods for native, accurate drawing. // ui.Path? _svgPathToUiPath(svgpath.Path svgP) { // try { // final ui.Path p = ui.Path(); // bool started = false; // for (final seg in svgP) { // if (seg is svgpath.Move) { // p.moveTo(seg.start.x.toDouble(), seg.start.y.toDouble()); // started = true; // } else if (seg is svgpath.Line) { // if (!started) { // p.moveTo(seg.start.x.toDouble(), seg.start.y.toDouble()); // started = true; // } // p.lineTo(seg.end.x.toDouble(), seg.end.y.toDouble()); // } else if (seg is svgpath.Close) { // p.close(); // } else if (seg is svgpath.CubicBezier) { // if (!started) { // p.moveTo(seg.start.x.toDouble(), seg.start.y.toDouble()); // started = true; // } // p.cubicTo( // seg.control1.x.toDouble(), // seg.control1.y.toDouble(), // seg.control2.x.toDouble(), // seg.control2.y.toDouble(), // seg.end.x.toDouble(), // seg.end.y.toDouble(), // ); // } else if (seg is svgpath.QuadraticBezier) { // if (!started) { // p.moveTo(seg.start.x.toDouble(), seg.start.y.toDouble()); // started = true; // } // p.quadraticBezierTo( // seg.control.x.toDouble(), // seg.control.y.toDouble(), // seg.end.x.toDouble(), // seg.end.y.toDouble(), // ); // } else if (seg is svgpath.Arc) { // if (!started) { // p.moveTo(seg.start.x.toDouble(), seg.start.y.toDouble()); // started = true; // } // // Map SVG Arc to Flutter arcToPoint. rotation is in degrees in svg; arcToPoint expects radians. // final radius = Radius.elliptical(seg.radius.x.toDouble(), seg.radius.y.toDouble()); // p.arcToPoint( // Offset(seg.end.x.toDouble(), seg.end.y.toDouble()), // radius: radius, // rotation: seg.rotation.toDouble() * (math.pi / 180.0), // largeArc: seg.arc, // clockwise: seg.sweep, // ); // } else { // // Unknown segment fallback: try to move to end // try { // p.moveTo(seg.end.x.toDouble(), seg.end.y.toDouble()); // started = true; // } catch (_) {} // } // } // return p; // } catch (e) { // return null; // } // } // void _configureController() { // // Use per-stroke durations computed from the parsed geometry (strokeLengthToDuration) // // Sum durations and scale by widget.speed. If durationOverride is provided use it. // final double totalSec = _pathDurations.fold(0.0, (a, b) => a + b); // final double scaledTotalSec = (widget.speed <= 0) ? totalSec : (totalSec / widget.speed); // final Duration duration = widget.durationOverride ?? // Duration(milliseconds: (math.max(0.001, scaledTotalSec) * 1000.0).round()); // final bool shouldRepeat = widget.loop; // _controller.stop(); // _controller.duration = duration; // if (shouldRepeat) { // _controller.repeat(); // } else { // _controller.forward(from: 0); // } // // Repaint handled by the single listener added in initState. // } // @override // Widget build(BuildContext context) { // if (!_loaded) { // return SizedBox( // width: widget.size, // height: widget.size, // child: Center(child: CircularProgressIndicator()), // ); // } // if (_error != null) { // return SizedBox( // width: widget.size, // height: widget.size, // child: Container( // color: widget.backgroundColor, // alignment: Alignment.center, // child: Text( // 'Error', // style: TextStyle(color: Colors.red, fontSize: math.max(10, widget.size * 0.08)), // ), // ), // ); // } // return SizedBox( // width: widget.size, // height: widget.size, // child: CustomPaint( // painter: _KanimajiPainter( // paths: _paths, // pathLengths: _pathLengths, // pathDurations: _pathDurations, // progress: _controller.value, // strokeColor: widget.strokeColor, // strokeUnfilledColor: widget.strokeUnfilledColor, // strokeWidth: widget.strokeWidth, // backgroundColor: widget.backgroundColor, // viewBoxWidth: _viewBoxWidth, // viewBoxHeight: _viewBoxHeight, // showBrush: widget.showBrush, // ), // ), // ); // } // } // class _KanimajiPainter extends CustomPainter { // final List paths; // final List pathLengths; // final List pathDurations; // per-stroke durations in seconds // final double progress; // 0..1 // final Color strokeColor; // final Color strokeUnfilledColor; // final double strokeWidth; // final Color backgroundColor; // final double viewBoxWidth; // final double viewBoxHeight; // final bool showBrush; // _KanimajiPainter({ // required this.paths, // required this.pathLengths, // required this.pathDurations, // required this.progress, // required this.strokeColor, // required this.strokeUnfilledColor, // required this.strokeWidth, // required this.backgroundColor, // required this.viewBoxWidth, // required this.viewBoxHeight, // required this.showBrush, // }); // @override // void paint(Canvas canvas, Size size) { // // Paint background // final bgPaint = Paint()..color = backgroundColor; // canvas.drawRect(Offset.zero & size, bgPaint); // if (paths.isEmpty) return; // // compute scale to fit viewBox into size, preserving aspect ratio // final double sx = size.width / viewBoxWidth; // final double sy = size.height / viewBoxHeight; // final double scale = math.min(sx, sy); // // compute translation to center the drawing // final double dx = (size.width - viewBoxWidth * scale) / 2.0; // final double dy = (size.height - viewBoxHeight * scale) / 2.0; // canvas.save(); // canvas.translate(dx, dy); // canvas.scale(scale, scale); // // paints // final Paint unfilledPaint = Paint() // ..style = PaintingStyle.stroke // ..strokeCap = StrokeCap.round // ..strokeJoin = StrokeJoin.round // ..strokeWidth = strokeWidth / scale // scale stroke width so it looks consistent // ..color = strokeUnfilledColor; // final Paint filledPaint = Paint() // ..style = PaintingStyle.stroke // ..strokeCap = StrokeCap.round // ..strokeJoin = StrokeJoin.round // ..strokeWidth = strokeWidth / scale // ..color = strokeColor; // // total animation time in seconds computed from durations // final double totalTime = pathDurations.isEmpty ? 1.0 : pathDurations.fold(0.0, (a, b) => a + b); // final double p = progress.clamp(0.0, 1.0); // double elapsedTime = 0.0; // seconds accumulated so far // for (int i = 0; i < paths.length; i++) { // final ui.Path path = paths[i]; // final double len = pathLengths[i]; // final double dur = (i < pathDurations.length) ? pathDurations[i] : 0.0; // final double startFraction = totalTime == 0 ? 0.0 : (elapsedTime / totalTime); // final double endFraction = totalTime == 0 ? 1.0 : ((elapsedTime + dur) / totalTime); // // Draw background (unfilled) fully for this stroke // canvas.drawPath(path, unfilledPaint); // // Determine coverage for this stroke based on global progress (time-based) // double coverage = 0.0; // if (p >= endFraction) { // coverage = 1.0; // } else if (p <= startFraction || dur <= 0.0) { // coverage = 0.0; // } else { // coverage = (p - startFraction) / (endFraction - startFraction); // } // if (coverage > 0.0) { // // extract subpath up to coverage * len (length-based extraction for drawing) // final metrics = path.computeMetrics().toList(); // final ui.Path drawn = ui.Path(); // double remain = coverage * len; // for (final metric in metrics) { // if (remain <= 0) break; // final take = math.min(metric.length, remain); // final sub = metric.extractPath(0.0, take); // drawn.addPath(sub, Offset.zero); // remain -= take; // } // // draw filled portion // canvas.drawPath(drawn, filledPaint); // // brush tip // if (showBrush && coverage > 0.0 && coverage < 1.0) { // // find the tangent at the very end of the drawn part // final metrics2 = path.computeMetrics().toList(); // double rem = coverage * len; // for (final metric in metrics2) { // if (rem <= 0) break; // if (rem <= metric.length + 1e-9) { // final tangent = metric.getTangentForOffset(rem.clamp(0.0, metric.length)); // if (tangent != null) { // final Offset pos = tangent.position; // final double brushRadius = (strokeWidth * 0.9) / scale; // final Paint brushPaint = Paint()..color = strokeColor; // canvas.drawCircle(pos, brushRadius, brushPaint); // } // break; // } else { // rem -= metric.length; // } // } // } // } // elapsedTime += dur; // } // canvas.restore(); // } // @override // bool shouldRepaint(covariant _KanimajiPainter oldDelegate) { // return oldDelegate.progress != progress || // oldDelegate.paths != paths || // oldDelegate.strokeColor != strokeColor || // oldDelegate.strokeUnfilledColor != strokeUnfilledColor || // oldDelegate.strokeWidth != strokeWidth || // oldDelegate.backgroundColor != backgroundColor; // } // }