import 'dart:math' as math; import 'package:flutter/material.dart'; /// OkLCH color math. Lightness and chroma are perceptual here, so a palette /// can be moved to another hue without the per-hue lightness corrections that /// rotating an HSL hue would need. /// Builds a color from lightness (0..1), chroma and hue (degrees). Chroma is /// reduced until the color fits in sRGB, so a hue that can't hold the asked /// chroma comes back muted rather than clipped to a wrong hue. Color oklch(double lightness, double chroma, double hue) { var low = 0.0; var high = chroma; if (_fitsSrgb(_linearSrgb(lightness, chroma, hue))) { return _color(_linearSrgb(lightness, chroma, hue)); } for (var i = 0; i < 16; i++) { final mid = (low + high) / 2; if (_fitsSrgb(_linearSrgb(lightness, mid, hue))) { low = mid; } else { high = mid; } } return _color(_linearSrgb(lightness, low, hue)); } /// The WCAG contrast ratio, from 1 (identical) to 21 (black on white). double contrastRatio(Color a, Color b) { final la = _relativeLuminance(a); final lb = _relativeLuminance(b); final lighter = math.max(la, lb); final darker = math.min(la, lb); return (lighter + 0.05) / (darker + 0.05); } /// How far a color sits from gray, in the same units [oklch] takes: the /// bigger it is, the more vivid the color looks. double chromaOf(Color color) { double linear(double value) => value <= 0.04045 ? value / 12.92 : math.pow((value + 0.055) / 1.055, 2.4) as double; final r = linear(color.r); final g = linear(color.g); final b = linear(color.b); final l = math.pow(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b, 1 / 3) as double; final m = math.pow(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b, 1 / 3) as double; final s = math.pow(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b, 1 / 3) as double; final a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s; final bb = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s; return math.sqrt(a * a + bb * bb); } List _linearSrgb(double lightness, double chroma, double hue) { final radians = hue * math.pi / 180; final a = chroma * math.cos(radians); final b = chroma * math.sin(radians); final l = math.pow(lightness + 0.3963377774 * a + 0.2158037573 * b, 3) as double; final m = math.pow(lightness - 0.1055613458 * a - 0.0638541728 * b, 3) as double; final s = math.pow(lightness - 0.0894841775 * a - 1.2914855480 * b, 3) as double; return [ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s, ]; } bool _fitsSrgb(List rgb) => rgb.every((channel) => channel >= -0.0001 && channel <= 1.0001); Color _color(List rgb) => Color.fromARGB( 255, _encode(rgb[0]), _encode(rgb[1]), _encode(rgb[2]), ); int _encode(double channel) { final clamped = channel.clamp(0.0, 1.0); final encoded = clamped <= 0.0031308 ? 12.92 * clamped : 1.055 * math.pow(clamped, 1 / 2.4) - 0.055; return (encoded * 255).round().clamp(0, 255); } double _relativeLuminance(Color color) { double channel(double value) => value <= 0.04045 ? value / 12.92 : math.pow((value + 0.055) / 1.055, 2.4) as double; return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b); }