import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/material.dart'; /// Deterministic irregular polygon + palette derived from a question UUID. /// /// Parses the canonical 16-byte UUID (hyphens optional) and maps byte pairs to /// polar vertices so the same id always produces the same glyph. class GuidGlyphShape { GuidGlyphShape._(this.bytes); final Uint8List bytes; /// Builds a shape from [guid]. Non-UUID strings fall back to a stable hash. factory GuidGlyphShape.fromGuid(String guid) { final Uint8List? parsed = tryParseUuidBytes(guid); if (parsed != null) { return GuidGlyphShape._(parsed); } return GuidGlyphShape._(_hashTo16Bytes(guid)); } /// Vertex count in [5, 10], from the first UUID byte. int get vertexCount => 5 + (bytes[0] % 6); /// Unit-circle offsets (rough polygon); multiply by size when painting. List unitVertices() { final int n = vertexCount; final List points = []; double angle = (bytes[1] / 255) * math.pi * 2; for (int i = 0; i < n; i++) { final int rIndex = (i * 2) % 16; final int aIndex = (i * 2 + 1) % 16; final double radius = 0.42 + (bytes[rIndex] / 255) * 0.52; angle += (math.pi * 2 / n) + ((bytes[aIndex] / 255) - 0.5) * 0.95; points.add( Offset(math.cos(angle) * radius, math.sin(angle) * radius), ); } return points; } /// Fill color: warm accent band so glyphs stay on-brand but distinct. Color fillColor() { final double hue = 8 + ((bytes[2] << 8 | bytes[3]) / 65535) * 42; return HSLColor.fromAHSL(1, hue, 0.72, 0.52).toColor(); } Color strokeColor() => HSLColor.fromColor(fillColor()).withLightness(0.38).toColor(); /// Normalized slider position in [-1, 1] for [displayValue] in [-10, 10]. static double displayT(num displayValue) => (displayValue / 10).clamp(-1.0, 1.0).toDouble(); /// Display-only vertex warp; same [guid] + [displayValue] always matches. List displayUnitVertices(num displayValue) { final double t = displayT(displayValue); final List base = unitVertices(); final int n = base.length; final double spinSign = (bytes[9] & 1) == 0 ? 1.0 : -1.0; final double spin = t * math.pi * (0.28 + (bytes[4] / 255) * 0.22) * spinSign; final double cosR = math.cos(spin); final double sinR = math.sin(spin); final double stretchY = 1 + t * (0.38 + (bytes[5] / 255) * 0.2); final double stretchX = 1 - t * 0.14 * (bytes[6] / 255); final List out = []; for (int i = 0; i < n; i++) { final Offset p = base[i]; final double bulge = 1 + t * ((bytes[(i * 3 + 7) % 16] / 255) - 0.5) * 0.55; final double x = p.dx * stretchX * bulge; final double y = p.dy * stretchY * bulge; out.add(Offset(x * cosR - y * sinR, x * sinR + y * cosR)); } return out; } /// Display-only fill; base identity color shifts with slider value. Color displayFillColor(num displayValue) { final double t = displayT(displayValue); final HSLColor hsl = HSLColor.fromColor(fillColor()); final double hueShift = t * (12 + (bytes[7] % 20)); final double lightness = (hsl.lightness + t * 0.12).clamp(0.35, 0.68); final double saturation = (hsl.saturation + t.abs() * 0.1).clamp(0.5, 0.85); return hsl .withHue((hsl.hue + hueShift) % 360) .withLightness(lightness) .withSaturation(saturation) .toColor(); } Color displayStrokeColor(num displayValue) => HSLColor.fromColor(displayFillColor(displayValue)) .withLightness(0.38) .toColor(); double displayStrokeWidth(num displayValue) { final double t = displayT(displayValue); return (2.5 + t.abs() * 1.2 + (bytes[8] / 255) * 0.4).clamp(2.0, 4.0); } static Uint8List? tryParseUuidBytes(String guid) { final String hex = guid.replaceAll('-', '').trim().toLowerCase(); if (hex.length != 32 || !RegExp(r'^[0-9a-f]{32}$').hasMatch(hex)) { return null; } final Uint8List out = Uint8List(16); for (int i = 0; i < 16; i++) { out[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); } return out; } static Uint8List _hashTo16Bytes(String input) { var h1 = 0x811c9dc5; var h2 = 0x01000193; for (final int codeUnit in input.codeUnits) { h1 = (h1 ^ codeUnit) * 0x01000193; h2 = (h2 + codeUnit) * 0x85ebca6b; } final Uint8List out = Uint8List(16); for (int i = 0; i < 16; i++) { final int mix = (h1 >> (i % 16)) ^ (h2 >> ((i + 3) % 16)) ^ (i * 31); out[i] = mix & 0xff; } return out; } } /// Painted glyph for a question id (replaces the fixed red dot). class QuestionGuidGlyph extends StatelessWidget { const QuestionGuidGlyph({ super.key, required this.guid, this.size = 80, this.displayValue = 0, }); final String guid; final double size; /// Slider value in [-10, 10]; warps the painted glyph for display only. final num displayValue; @override Widget build(BuildContext context) { final GuidGlyphShape shape = GuidGlyphShape.fromGuid(guid); final Color glow = shape.displayFillColor(displayValue); return SizedBox( width: size, height: size, child: DecoratedBox( decoration: BoxDecoration( shape: BoxShape.circle, boxShadow: [ BoxShadow( color: glow.withValues( alpha: 0.32 + GuidGlyphShape.displayT(displayValue).abs() * 0.18, ), blurRadius: 16 + GuidGlyphShape.displayT(displayValue).abs() * 6, spreadRadius: 2, ), ], ), child: CustomPaint( painter: _GuidGlyphPainter( shape: shape, displayValue: displayValue, ), ), ), ); } } class _GuidGlyphPainter extends CustomPainter { _GuidGlyphPainter({ required this.shape, required this.displayValue, }); final GuidGlyphShape shape; final num displayValue; @override void paint(Canvas canvas, Size size) { final Offset center = Offset(size.width / 2, size.height / 2); final double scale = size.shortestSide * 0.42; final List unit = shape.displayUnitVertices(displayValue); final Path path = Path(); for (int i = 0; i < unit.length; i++) { final Offset p = center + Offset(unit[i].dx * scale, unit[i].dy * scale); if (i == 0) { path.moveTo(p.dx, p.dy); } else { path.lineTo(p.dx, p.dy); } } path.close(); final Color fill = shape.displayFillColor(displayValue); final Color stroke = shape.displayStrokeColor(displayValue); final double strokeWidth = shape.displayStrokeWidth(displayValue); canvas.drawPath( path, Paint() ..color = fill ..style = PaintingStyle.fill, ); canvas.drawPath( path, Paint() ..color = stroke ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..strokeJoin = StrokeJoin.round, ); } @override bool shouldRepaint(covariant _GuidGlyphPainter oldDelegate) => oldDelegate.shape.bytes != shape.bytes || oldDelegate.displayValue != displayValue; }