import 'dart:async'; import 'dart:ui' as ui; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:kanimaji/animator.dart'; import 'package:kanimaji/kanjivg_parser.dart'; Future _defaultKanjiDataProvider(String kanji) async { final hex = kanji.runes.isEmpty ? '00000' : kanji.runes.first.toRadixString(16).padLeft(5, '0'); final assetPath = 'assets/kanjivg/kanji/$hex.svg'; final svgString = await rootBundle.loadString(assetPath); return KanjiVGItem.parseFromXml(svgString); } /// A widget that animates the stroke order of a given kanji character using KanjiVG data. class Kanimaji extends StatefulWidget { final String kanji; final FutureOr Function(String kanji) kanjiDataProvider; // Animation parameters final bool loop; // final Duration delayBetweenStrokes; // final Duration delayBetweenLoops; // TODO: add support for specifying animation bezier curve // final Cubic animationCurve; // final double speed; // Brush parameters final bool showBrush; final Color brushColor; final double brushRadius; // Stroke parameters final Color strokeColor; final Color strokeUnfilledColor; final double strokeWidth; final Color backgroundColor; // Stroke number parameters final bool showStrokeNumbers; final Color strokeNumberColor; final Color currentStrokeNumberColor; final double strokeNumberFontSize; final String? strokeNumberFontFamily; // Cross parameters final bool showCross; final Color crossColor; final double crossStrokeWidth; final double crossStipleLength; final double crossStipleGap; const Kanimaji({ super.key, required this.kanji, this.kanjiDataProvider = _defaultKanjiDataProvider, this.loop = true, this.strokeColor = Colors.black, this.strokeUnfilledColor = const Color(0xA0DDDDDD), this.strokeWidth = 3.0, this.backgroundColor = Colors.transparent, this.showBrush = true, this.brushColor = Colors.red, this.brushRadius = 4.0, this.showStrokeNumbers = true, this.strokeNumberColor = Colors.grey, this.currentStrokeNumberColor = Colors.red, this.strokeNumberFontSize = 4.0, this.strokeNumberFontFamily, this.showCross = true, this.crossColor = const Color(0x40AAAAAA), this.crossStrokeWidth = 0.8, this.crossStipleLength = 5.0, this.crossStipleGap = 3.0, }); @override State createState() => _KanimajiState(); } class _KanimajiState extends State with SingleTickerProviderStateMixin { KanjiVGItem? _kanjiData; String? _error; late AnimationController _controller; List get _pathLengths => _kanjiData?.paths .map((p) => p.svgPath.size(error: 1e-8).toDouble()) .toList() ?? []; List get _pathDurations => _pathLengths.map((len) => strokeLengthToDuration(len)).toList(); static const double _viewBoxWidth = 109.0; static const double _viewBoxHeight = 109.0; @override void initState() { super.initState(); _controller = AnimationController(vsync: this); _controller.addListener(_onTick); _loadAndParseSvg().then((_) => _configureController()); } @override void didUpdateWidget(covariant Kanimaji oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.kanji != widget.kanji) { _loadAndParseSvg().then((_) => _configureController()); } else if (oldWidget.loop != widget.loop || oldWidget.strokeColor != widget.strokeColor || oldWidget.strokeUnfilledColor != widget.strokeUnfilledColor || oldWidget.strokeWidth != widget.strokeWidth || oldWidget.backgroundColor != widget.backgroundColor || oldWidget.showBrush != widget.showBrush || oldWidget.brushColor != widget.brushColor || oldWidget.brushRadius != widget.brushRadius || oldWidget.showStrokeNumbers != widget.showStrokeNumbers || oldWidget.strokeNumberColor != widget.strokeNumberColor || oldWidget.currentStrokeNumberColor != widget.currentStrokeNumberColor || oldWidget.strokeNumberFontSize != widget.strokeNumberFontSize || oldWidget.strokeNumberFontFamily != widget.strokeNumberFontFamily || oldWidget.showCross != widget.showCross || oldWidget.crossColor != widget.crossColor || oldWidget.crossStrokeWidth != widget.crossStrokeWidth || oldWidget.crossStipleLength != widget.crossStipleLength || oldWidget.crossStipleGap != widget.crossStipleGap) { setState(() {}); } } void _onTick() { if (mounted) setState(() {}); } @override void dispose() { _controller.removeListener(_onTick); _controller.dispose(); super.dispose(); } Future _loadAndParseSvg() async { try { final data = await widget.kanjiDataProvider(widget.kanji); setState(() { _kanjiData = data; _error = null; }); } catch (e) { setState(() { _kanjiData = null; _error = 'Error loading/parsing kanji data: $e'; }); } } void _configureController() { if (_kanjiData == null) return; final double totalSec = _pathDurations.fold(0.0, (a, b) => a + b); _controller.stop(); _controller.duration = Duration(milliseconds: (totalSec * 1000.0).round()); if (widget.loop) { _controller.repeat(); } else { _controller.forward(from: 0); } } @override Widget build(BuildContext context) { if (_kanjiData == null && _error == null) { return SizedBox.expand(child: Center(child: CircularProgressIndicator())); } if (_error != null) { return SizedBox.expand(child: ErrorWidget(_error!)); } return FittedBox( child: SizedBox( width: _viewBoxWidth, height: _viewBoxHeight, child: CustomPaint( painter: _KanimajiPainter( paths: _kanjiData!.paths.map((p) => p.svgPath.toUiPath()).toList(), pathLengths: _pathLengths, pathDurations: _pathDurations, strokeNumbers: _kanjiData!.strokeNumbers, progress: _controller.value, strokeColor: widget.strokeColor, strokeUnfilledColor: widget.strokeUnfilledColor, strokeWidth: widget.strokeWidth, backgroundColor: widget.backgroundColor, viewBoxWidth: _viewBoxWidth, viewBoxHeight: _viewBoxHeight, showBrush: widget.showBrush, brushColor: widget.brushColor, brushRadius: widget.brushRadius, showStrokeNumbers: widget.showStrokeNumbers, strokeNumberColor: widget.strokeNumberColor, currentStrokeNumberColor: widget.currentStrokeNumberColor, strokeNumberFontSize: widget.strokeNumberFontSize, strokeNumberFontFamily: widget.strokeNumberFontFamily, showCross: widget.showCross, crossColor: widget.crossColor, crossStrokeWidth: widget.crossStrokeWidth, crossStipleLength: widget.crossStipleLength, crossStipleGap: widget.crossStipleGap, ), ), ), ); } } class _KanimajiPainter extends CustomPainter { final double progress; // 0..1 final List paths; final List pathLengths; final List pathDurations; // TODO: don't recalculate these all the time, compute once and cache List get absolutePathDurations { final List absolute = []; double sum = 0.0; for (final dur in pathDurations) { absolute.add(sum); sum += dur; } return absolute; } final List strokeNumbers; final double viewBoxWidth; final double viewBoxHeight; final Color strokeColor; final Color strokeUnfilledColor; final double strokeWidth; final Color backgroundColor; final bool showBrush; final Color brushColor; final double brushRadius; final bool showStrokeNumbers; final Color strokeNumberColor; final Color currentStrokeNumberColor; final double strokeNumberFontSize; final String? strokeNumberFontFamily; final bool showCross; final Color crossColor; final double crossStrokeWidth; final double crossStipleLength; final double crossStipleGap; _KanimajiPainter({ required this.progress, required this.paths, required this.pathLengths, required this.pathDurations, required this.strokeNumbers, required this.viewBoxWidth, required this.viewBoxHeight, required this.strokeColor, required this.strokeUnfilledColor, required this.strokeWidth, required this.backgroundColor, required this.showBrush, required this.brushColor, required this.brushRadius, required this.showStrokeNumbers, required this.strokeNumberColor, required this.currentStrokeNumberColor, required this.strokeNumberFontSize, required this.strokeNumberFontFamily, required this.showCross, required this.crossColor, required this.crossStrokeWidth, required this.crossStipleLength, required this.crossStipleGap, }); @override void paint(Canvas canvas, Size size) { final bgPaint = Paint()..color = backgroundColor; canvas.drawRect(Offset.zero & size, bgPaint); if (paths.isEmpty) return; final double sx = size.width / viewBoxWidth; final double sy = size.height / viewBoxHeight; final double scale = math.min(sx, sy); final double dx = (size.width - viewBoxWidth * scale) / 2.0; final double dy = (size.height - viewBoxHeight * scale) / 2.0; final Paint unfilledPaint = Paint() ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round ..strokeWidth = strokeWidth / scale ..color = strokeUnfilledColor; final Paint filledPaint = Paint() ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round ..strokeWidth = strokeWidth / scale ..color = strokeColor; final Paint brushPaint = Paint() ..style = PaintingStyle.fill ..color = brushColor; final Paint crossPaint = Paint() ..style = PaintingStyle.stroke ..color = crossColor ..strokeWidth = crossStrokeWidth; // Draw cross if enabled if (showCross) { // Draw vertical stipled line for ( double y = 0; y < size.height; y += crossStipleLength + crossStipleGap ) { canvas.drawLine( Offset(size.width / 2, y), Offset(size.width / 2, math.min(y + crossStipleLength, size.height)), crossPaint, ); } // Draw horizontal stipled line for ( double x = 0; x < size.width; x += crossStipleLength + crossStipleGap ) { canvas.drawLine( Offset(x, size.height / 2), Offset(math.min(x + crossStipleLength, size.width), size.height / 2), crossPaint, ); } } // Draw all unfilled paths for (final path in paths) { canvas.drawPath(path, unfilledPaint); } canvas.save(); canvas.translate(dx, dy); canvas.scale(scale, scale); // 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); final int currentlyDrawingIndex = absolutePathDurations.lastIndexWhere( (t) => t <= p * totalTime, ); if (currentlyDrawingIndex == -1) { for (final path in paths) { canvas.drawPath(path, filledPaint); } canvas.restore(); return; } // Draw all completed strokes fully filled for (int i = 0; i < currentlyDrawingIndex; i++) { canvas.drawPath(paths[i], filledPaint); } // Draw the currently drawing stroke with partial coverage if (currentlyDrawingIndex >= 0 && currentlyDrawingIndex < paths.length) { final ui.Path path = paths[currentlyDrawingIndex]; final double len = pathLengths[currentlyDrawingIndex]; final double dur = pathDurations[currentlyDrawingIndex]; final relativeElapsedTime = p * totalTime - (currentlyDrawingIndex > 0 ? absolutePathDurations[currentlyDrawingIndex] : 0.0); final double strokeProgress = (relativeElapsedTime / dur).clamp(0.0, 1.0); final ui.PathMetrics metrics = path.computeMetrics(); final ui.PathMetric metric = metrics.first; final double drawLength = len * strokeProgress; final ui.Path partialPath = metric.extractPath(0, drawLength); canvas.drawPath(partialPath, filledPaint); if (showBrush) { final ui.Tangent? tangent = metric.getTangentForOffset(drawLength); if (tangent != null) { canvas.drawCircle(tangent.position, brushRadius / scale, brushPaint); } } } // Draw stroke numbers if (showStrokeNumbers) { final textPainter = TextPainter( textAlign: TextAlign.center, textDirection: TextDirection.ltr, ); for (final sn in strokeNumbers) { final bool isCurrent = sn.num == (currentlyDrawingIndex + 1).clamp(1, strokeNumbers.length); final textSpan = TextSpan( text: sn.num.toString(), style: TextStyle( color: isCurrent ? currentStrokeNumberColor : strokeNumberColor, fontSize: strokeNumberFontSize / scale, fontFamily: strokeNumberFontFamily, ), ); textPainter.text = textSpan; textPainter.layout(); final Offset pos = Offset( sn.position.x.toDouble(), sn.position.y.toDouble(), ); final Offset centeredPos = pos - Offset(textPainter.width / 2, textPainter.height / 2); textPainter.paint(canvas, centeredPos); } } canvas.restore(); } @override bool shouldRepaint(covariant _KanimajiPainter oldDelegate) { return oldDelegate.progress != progress || oldDelegate.paths != paths || oldDelegate.strokeNumbers != strokeNumbers || oldDelegate.strokeColor != strokeColor || oldDelegate.strokeUnfilledColor != strokeUnfilledColor || oldDelegate.strokeWidth != strokeWidth || oldDelegate.backgroundColor != backgroundColor || oldDelegate.showBrush != showBrush || oldDelegate.brushColor != brushColor || oldDelegate.brushRadius != brushRadius || oldDelegate.showStrokeNumbers != showStrokeNumbers || oldDelegate.strokeNumberColor != strokeNumberColor || oldDelegate.currentStrokeNumberColor != currentStrokeNumberColor || oldDelegate.strokeNumberFontSize != strokeNumberFontSize || oldDelegate.strokeNumberFontFamily != strokeNumberFontFamily || oldDelegate.showCross != showCross || oldDelegate.crossColor != crossColor || oldDelegate.crossStrokeWidth != crossStrokeWidth || oldDelegate.crossStipleLength != crossStipleLength || oldDelegate.crossStipleGap != crossStipleGap; } }