import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/material.dart'; import '../theme/app_theme.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; } /// True when the slider is at neutral zero (no directional guess). static bool isNeutralZero(num displayValue) => displayValue == 0; /// 0 at zero, 1 at ±10. static double displayIntensity(num displayValue) => isNeutralZero(displayValue) ? 0 : (displayValue.abs() / 10).clamp(0.0, 1.0); static const Color _negativeAccent = Color(0xFFF87171); static const Color _neutralBlend = Color(0xFF64748B); /// Progressive green (+) / red (−) fill; crystal white at zero. Color displayFillColor(num displayValue) { if (isNeutralZero(displayValue)) { return const Color(0xFFF8FCFF); } final double intensity = displayIntensity(displayValue); final Color target = displayValue > 0 ? AppColors.success : _negativeAccent; return Color.lerp(fillColor(), target, 0.25 + intensity * 0.75)!; } Color displayStrokeColor(num displayValue) { if (isNeutralZero(displayValue)) { return const Color(0xFFE2E8F0); } final double intensity = displayIntensity(displayValue); final Color target = displayValue > 0 ? AppColors.success : _negativeAccent; return Color.lerp(_neutralBlend, target, 0.35 + intensity * 0.65)!; } /// Furthest unit-circle distance after [displayUnitVertices] warp (for glow sizing). double displayMaxUnitRadius(num displayValue) { double maxR = 0; for (final Offset p in displayUnitVertices(displayValue)) { maxR = math.max(maxR, p.distance); } return maxR > 0 ? maxR : 0.5; } /// Glow color for shadows (crystal white at zero, green +, red −). Color displayGlowColor(num displayValue) { if (isNeutralZero(displayValue)) { return const Color(0xFFF8FCFF); } return displayValue > 0 ? AppColors.success : _negativeAccent; } 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 double intensity = GuidGlyphShape.displayIntensity(displayValue); final Color glow = shape.displayGlowColor(displayValue); final double strokeWidth = shape.displayStrokeWidth(displayValue); final double baseRadius = (size / 2 - strokeWidth - 2).clamp(18.0, size / 2); // Slightly larger bounds when warped / off-zero so the halo follows the shape. final double paintedRadius = baseRadius * (1 + intensity * 0.1); // Shadow box matches painted extent; blur/spread grow with that diameter. final double bodyDiameter = paintedRadius * 2; final double blur = bodyDiameter * (0.2 + intensity * 0.16); final double spread = bodyDiameter * (0.03 + intensity * 0.06); final double glowAlpha = 0.38 + intensity * 0.42; return SizedBox( width: size, height: size, child: Center( child: Container( width: bodyDiameter, height: bodyDiameter, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.transparent, boxShadow: [ BoxShadow( color: glow.withValues(alpha: glowAlpha), blurRadius: blur, spreadRadius: spread * 0.35, ), BoxShadow( color: glow.withValues(alpha: glowAlpha * 0.45), blurRadius: blur * 1.75, spreadRadius: spread, ), ], ), child: CustomPaint( painter: _GuidGlyphPainter( shape: shape, displayValue: displayValue, paintedRadius: paintedRadius, ), ), ), ), ); } } class _GuidGlyphPainter extends CustomPainter { _GuidGlyphPainter({ required this.shape, required this.displayValue, required this.paintedRadius, }); final GuidGlyphShape shape; final num displayValue; final double paintedRadius; @override void paint(Canvas canvas, Size size) { final Offset center = Offset(size.width / 2, size.height / 2); final double maxUnitR = shape.displayMaxUnitRadius(displayValue); final double scale = paintedRadius / maxUnitR; final double strokeWidth = shape.displayStrokeWidth(displayValue); 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); 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 || oldDelegate.paintedRadius != paintedRadius; }