frosty/lib/utils.dart
Tommy Chow 16f2722a5b perf: async startup cache sweep, memoize chat name color
the orphaned-cache-file sweep ran a synchronous recursive listSync on
the UI isolate at startup against a cache that can hold 10k files; use
the async listing instead. memoize adjustChatNameColor keyed by (color,
background, target contrast) since chat repeats the same handful of
palette colors thousands of times and low-contrast ones pay a 16-step
search per call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:45:38 -04:00

140 lines
4.4 KiB
Dart

import 'dart:io';
import 'dart:math' as math;
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:frosty/constants.dart';
// --- Contrast helpers ---
double _linearize(double c) =>
c <= 0.03928 ? c / 12.92 : math.pow((c + 0.055) / 1.055, 2.4).toDouble();
double _relativeLuminance(Color c) {
final r = _linearize(c.r);
final g = _linearize(c.g);
final b = _linearize(c.b);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
double _contrastRatio(Color a, Color b) {
final l1 = _relativeLuminance(a);
final l2 = _relativeLuminance(b);
final hi = l1 > l2 ? l1 : l2;
final lo = l1 > l2 ? l2 : l1;
return (hi + 0.05) / (lo + 0.05);
}
/// Memoized results for [adjustChatNameColor]. Chat renders the same handful
/// of Twitch palette colors thousands of times per session, and every call
/// costs luminance math (plus a 16-step search for low-contrast colors) — the
/// answer only depends on this key, so compute it once per combination.
final _adjustedNameColorCache = <(int, int, double), Color>{};
/// Adjusts [color] just enough to reach [targetContrast] on the app's background.
/// Defaults to Theme.of(context).scaffoldBackgroundColor; if transparent, falls
/// back to theme.colorScheme.surface.
///
/// For normal-size text use 4.5; for large/bold labels you can pass 3.0.
Color adjustChatNameColor(
BuildContext context,
Color color, {
Color? background,
double targetContrast = 4.5,
}) {
final theme = Theme.of(context);
// Pick background: scaffoldBackgroundColor -> surface (if scaffold is transparent).
Color bg = background ?? theme.scaffoldBackgroundColor;
if (bg.a == 0.0) {
// Some themes leave scaffold transparent and paint surfaces; use surface then.
bg = theme.colorScheme.surface;
}
final cacheKey = (color.toARGB32(), bg.toARGB32(), targetContrast);
final cached = _adjustedNameColorCache[cacheKey];
if (cached != null) return cached;
final adjusted = _adjustNameColorUncached(color, bg, targetContrast);
// Custom hex name colors are unbounded in theory; keep the cache small.
if (_adjustedNameColorCache.length >= 1024) _adjustedNameColorCache.clear();
_adjustedNameColorCache[cacheKey] = adjusted;
return adjusted;
}
Color _adjustNameColorUncached(Color color, Color bg, double targetContrast) {
if (_contrastRatio(color, bg) >= targetContrast) return color;
final hsl0 = HSLColor.fromColor(color);
final bool darkBg = _relativeLuminance(bg) < 0.5;
// Binary search lightness toward the needed extreme with gentle sat easing.
double lo = darkBg ? hsl0.lightness : 0.0;
double hi = darkBg ? 1.0 : hsl0.lightness;
double bestL = hsl0.lightness;
for (int i = 0; i < 16; i++) {
final mid = (lo + hi) / 2.0;
final t = (mid - 0.5).abs() * 2; // 0..1 near ends
final easedSat = (hsl0.saturation * (1 - 0.25 * t * t)).clamp(0.0, 1.0);
final candidate = hsl0
.withLightness(mid)
.withSaturation(easedSat)
.toColor();
if (_contrastRatio(candidate, bg) >= targetContrast) {
bestL = mid;
if (darkBg) {
hi = mid; // go brighter, but keep minimal change
} else {
lo = mid; // go darker
}
} else {
if (darkBg) {
lo = mid;
} else {
hi = mid;
}
}
}
final t = (bestL - 0.5).abs() * 2;
final easedSat = (hsl0.saturation * (1 - 0.25 * t * t)).clamp(0.0, 1.0);
return hsl0.withLightness(bestL).withSaturation(easedSat).toColor();
}
String getReadableName(String displayName, String username) {
// If display name equals username, don't add redundant parentheses
if (displayName == username) {
return displayName;
}
// If display name contains only digits, don't add username
if (regexNumbersOnly.hasMatch(displayName)) {
return displayName;
}
// If display name contains English letters, don't add username
if (regexEnglish.hasMatch(displayName)) {
return displayName;
}
// Otherwise (non-English characters, possibly with numbers), add username
return '$displayName ($username)';
}
var _isIPad = false;
bool isIPad() {
return _isIPad;
}
Future<void> initUtils() async {
// Determine whether the device is an iPad or not.
if (Platform.isIOS) {
final deviceInfo = DeviceInfoPlugin();
final info = await deviceInfo.iosInfo;
if (info.model.toLowerCase().contains('ipad')) {
_isIPad = true;
}
}
}