From 6791a30150108becc2ca36bc6e9a73b5fff35b3d Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Mon, 3 Aug 2026 11:40:09 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=B8=D1=81=D1=8E=D0=BB=D1=8C?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D1=80=D1=8B=D0=B3=D0=B0=D1=8E=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/app_spectrum_background.dart | 25 + lib/core/media/dominant_color.dart | 142 +++++ lib/frontend/screens/calls/calls_tab.dart | 6 +- .../screens/chats/chat_list_screen.dart | 28 +- .../screens/contacts/contacts_tab.dart | 5 +- .../screens/profile/appearance_screen.dart | 55 ++ .../screens/profile/settings_tab.dart | 5 +- lib/frontend/widgets/komet_avatar.dart | 76 ++- lib/frontend/widgets/spectrum_background.dart | 497 ++++++++++++++++++ lib/frontend/widgets/spectrum_tint.dart | 99 ++++ lib/l10n/app_en.arb | 2 + lib/l10n/app_localizations.dart | 12 + lib/l10n/app_localizations_en.dart | 7 + lib/l10n/app_localizations_ru.dart | 7 + lib/l10n/app_ru.arb | 2 + lib/main.dart | 3 + test/spectrum_background_test.dart | 199 +++++++ 17 files changed, 1152 insertions(+), 18 deletions(-) create mode 100644 lib/core/config/app_spectrum_background.dart create mode 100644 lib/core/media/dominant_color.dart create mode 100644 lib/frontend/widgets/spectrum_background.dart create mode 100644 lib/frontend/widgets/spectrum_tint.dart create mode 100644 test/spectrum_background_test.dart diff --git a/lib/core/config/app_spectrum_background.dart b/lib/core/config/app_spectrum_background.dart new file mode 100644 index 0000000..b1b47be --- /dev/null +++ b/lib/core/config/app_spectrum_background.dart @@ -0,0 +1,25 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppSpectrumBackground { + static const prefKey = 'app_spectrum_background'; + static const bool defaultValue = false; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static bool get isEnabled => _setting.current.value; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); +} diff --git a/lib/core/media/dominant_color.dart b/lib/core/media/dominant_color.dart new file mode 100644 index 0000000..d17a1a0 --- /dev/null +++ b/lib/core/media/dominant_color.dart @@ -0,0 +1,142 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +class DominantColorCache { + DominantColorCache._(); + + static final DominantColorCache instance = DominantColorCache._(); + + static const int _sampleExtent = 8; + static const int _maxEntries = 256; + static const int _minAlpha = 8; + static const double _achromaticFloor = 0.15; + static const Duration _missingFileCooldown = Duration(seconds: 3); + + final Map _resolved = {}; + final Set _inFlight = {}; + final Set _rejected = {}; + final Map _retryAfter = {}; + + Color? lookup(String url) => _resolved[url]; + + void request(String url, VoidCallback onResolved) { + if (_resolved.containsKey(url) || + _inFlight.contains(url) || + _rejected.contains(url)) { + return; + } + final retryAt = _retryAfter[url]; + if (retryAt != null && DateTime.now().isBefore(retryAt)) return; + + _inFlight.add(url); + _extract(url).then((outcome) { + _inFlight.remove(url); + switch (outcome) { + case _ExtractionMissing(): + _retryAfter[url] = DateTime.now().add(_missingFileCooldown); + case _ExtractionRejected(): + _rejected.add(url); + _retryAfter.remove(url); + case _ExtractionResolved(color: final color): + _retryAfter.remove(url); + _store(url, color); + onResolved(); + } + }); + } + + void _store(String url, Color color) { + if (_resolved.length >= _maxEntries) { + _resolved.remove(_resolved.keys.first); + } + _resolved[url] = color; + } + + Future<_ExtractionOutcome> _extract(String url) async { + ui.Codec? codec; + ui.Image? image; + try { + final cached = await DefaultCacheManager().getFileFromCache(url); + if (cached == null) return const _ExtractionMissing(); + + final bytes = await cached.file.readAsBytes(); + codec = await ui.instantiateImageCodec( + bytes, + targetWidth: _sampleExtent, + targetHeight: _sampleExtent, + ); + final frame = await codec.getNextFrame(); + image = frame.image; + final raw = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + if (raw == null) return const _ExtractionRejected(); + + final color = _average(raw.buffer.asUint8List()); + if (color == null) return const _ExtractionRejected(); + return _ExtractionResolved(color); + } catch (_) { + return const _ExtractionRejected(); + } finally { + image?.dispose(); + codec?.dispose(); + } + } + + Color? _average(Uint8List pixels) { + var accumulatedRed = 0.0; + var accumulatedGreen = 0.0; + var accumulatedBlue = 0.0; + var accumulatedWeight = 0.0; + + for (var offset = 0; offset + 3 < pixels.length; offset += 4) { + final alpha = pixels[offset + 3]; + if (alpha < _minAlpha) continue; + + final red = pixels[offset].toDouble(); + final green = pixels[offset + 1].toDouble(); + final blue = pixels[offset + 2].toDouble(); + + final brightest = red > green + ? (red > blue ? red : blue) + : (green > blue ? green : blue); + final darkest = red < green + ? (red < blue ? red : blue) + : (green < blue ? green : blue); + final saturation = brightest <= 0 ? 0.0 : (brightest - darkest) / brightest; + + final weight = (alpha / 255) * (_achromaticFloor + saturation); + accumulatedRed += red * weight; + accumulatedGreen += green * weight; + accumulatedBlue += blue * weight; + accumulatedWeight += weight; + } + + if (accumulatedWeight <= 0) return null; + return Color.fromARGB( + 255, + (accumulatedRed / accumulatedWeight).round().clamp(0, 255), + (accumulatedGreen / accumulatedWeight).round().clamp(0, 255), + (accumulatedBlue / accumulatedWeight).round().clamp(0, 255), + ); + } +} + +sealed class _ExtractionOutcome { + const _ExtractionOutcome(); +} + +class _ExtractionMissing extends _ExtractionOutcome { + const _ExtractionMissing(); +} + +class _ExtractionRejected extends _ExtractionOutcome { + const _ExtractionRejected(); +} + +class _ExtractionResolved extends _ExtractionOutcome { + const _ExtractionResolved(this.color); + + final Color color; +} diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index e841c2c..20ad589 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -18,6 +18,7 @@ import '../../widgets/chat_menu_overlay.dart'; import '../../widgets/small_spinner.dart'; import '../../widgets/prompt_dialog.dart'; import '../../widgets/call_link_handler.dart'; +import '../../widgets/spectrum_tint.dart'; import 'call_screen.dart'; class CallsTab extends StatefulWidget { @@ -27,7 +28,8 @@ class CallsTab extends StatefulWidget { State createState() => _CallsTabState(); } -class _CallsTabState extends State with ReloadOnReconnect { +class _CallsTabState extends State + with ReloadOnReconnect, SpectrumSurface { List _calls = []; final Set _removing = {}; bool _isLoading = true; @@ -469,7 +471,7 @@ class _CallsTabState extends State with ReloadOnReconnect { : _calls; return Scaffold( - backgroundColor: cs.surface, + backgroundColor: spectrumSurfaceColor(cs), body: SafeArea( bottom: false, child: Column( diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index b10008e..d558572 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -47,6 +47,7 @@ import '../../../core/protocol/packet.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/config/app_animations.dart'; import '../../../core/config/app_frost.dart'; +import '../../../core/config/app_spectrum_background.dart'; import '../../../core/config/app_nav_pill_style.dart'; import '../../../core/config/app_visual_style.dart'; import '../../../core/config/app_stories.dart'; @@ -74,6 +75,8 @@ import '../../../main.dart' messagesModule, storiesModule; import '../../widgets/attachment/attachment_sheet.dart'; +import '../../widgets/spectrum_background.dart'; +import '../../widgets/spectrum_tint.dart'; import '../../widgets/update_dialog.dart'; import '../stories/story_composer_screen.dart'; import '../stories/story_owner_info.dart'; @@ -189,7 +192,7 @@ class ChatListScreen extends StatefulWidget { enum _DeleteKind { personalLike, ownerGroup, blocked } class _ChatListScreenState extends State - with TickerProviderStateMixin, RouteAware { + with TickerProviderStateMixin, RouteAware, SpectrumSurface { String? _selectedFolderId; List _folders = []; @@ -2126,6 +2129,29 @@ class _ChatListScreenState extends State return Stack( children: [ + if (AppSpectrumBackground.isEnabled) + Positioned.fill( + child: AnimatedBuilder( + animation: Listenable.merge([ + _navPageAnimController, + _navDragDx, + ]), + child: const RepaintBoundary(child: SpectrumBackground()), + builder: (context, child) { + final pageDisplayT = _effectivePageNavRowT( + inactiveWidth: inactiveWidth, + bubbleLeftForIndex: bubbleLeftForPageT, + ); + return Transform.translate( + offset: Offset( + -pageDisplayT * pageW * SpectrumTuning.parallax, + 0, + ), + child: child, + ); + }, + ), + ), ClipRect( child: SizedBox( width: pageW, diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index 530a853..1515165 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -14,6 +14,7 @@ import '../../widgets/komet_avatar.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/sheet_helpers.dart'; import '../../widgets/small_spinner.dart'; +import '../../widgets/spectrum_tint.dart'; import '../../widgets/springy_tap.dart'; import '../chats/chat_info_screen.dart'; import 'nfc_exchange_sheet.dart'; @@ -28,7 +29,7 @@ class ContactsTab extends StatefulWidget { State createState() => _ContactsTabState(); } -class _ContactsTabState extends State { +class _ContactsTabState extends State with SpectrumSurface { List _contacts = []; bool _isLoading = true; @@ -219,7 +220,7 @@ class _ContactsTabState extends State { final cs = Theme.of(context).colorScheme; return Scaffold( - backgroundColor: cs.surface, + backgroundColor: spectrumSurfaceColor(cs), body: SafeArea( bottom: false, child: Column( diff --git a/lib/frontend/screens/profile/appearance_screen.dart b/lib/frontend/screens/profile/appearance_screen.dart index 7e904b5..5d4497d 100644 --- a/lib/frontend/screens/profile/appearance_screen.dart +++ b/lib/frontend/screens/profile/appearance_screen.dart @@ -13,6 +13,7 @@ import '../../../core/config/app_chat_chrome.dart'; import '../../../core/config/app_composer_background.dart'; import '../../../core/config/app_composer_style.dart'; import '../../../core/config/app_nav_pill_style.dart'; +import '../../../core/config/app_spectrum_background.dart'; import '../../../core/utils/bubble_radius.dart'; import '../../../core/utils/debouncer.dart'; import '../../../core/utils/haptics.dart'; @@ -128,6 +129,8 @@ class _AppearanceScreenState extends State { const _NavPillStyleCard(), const SizedBox(height: 12), const _GradientToggleCard(), + const SizedBox(height: 12), + const _SpectrumToggleCard(), ], ), ), @@ -521,6 +524,58 @@ class _GradientToggleCard extends StatelessWidget { } } +class _SpectrumToggleCard extends StatelessWidget { + const _SpectrumToggleCard(); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + return GlossyPill( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(28), + padding: const EdgeInsets.fromLTRB(20, 14, 12, 14), + depth: 6, + child: Row( + children: [ + Icon(Symbols.graphic_eq, color: cs.onSurface, size: 24, weight: 500), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.appearanceSpectrumTitle, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + l10n.appearanceSpectrumSubtitle, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ), + ValueListenableBuilder( + valueListenable: AppSpectrumBackground.current, + builder: (context, value, _) => Switch( + value: value, + onChanged: (v) { + Haptics.selection(); + AppSpectrumBackground.save(v); + }, + ), + ), + ], + ), + ); + } +} + class _PreviewSection extends StatefulWidget { final ValueNotifier color; final ValueNotifier isSystem; diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 0fb5da2..3982312 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -38,6 +38,7 @@ import 'cloud_storage_screen.dart'; import 'customization_section.dart'; import 'debug_menu_screen.dart'; import 'devices_screen.dart'; +import '../../widgets/spectrum_tint.dart'; import 'edit_profile_screen.dart'; import 'info_screen.dart'; import 'komet_settings_screen.dart'; @@ -52,7 +53,7 @@ class SettingsTab extends StatefulWidget { State createState() => _SettingsTabState(); } -class _SettingsTabState extends State { +class _SettingsTabState extends State with SpectrumSurface { ProfileData? _profile; bool _isPhoneVisible = false; ScrollController? _scrollController; @@ -358,7 +359,7 @@ class _SettingsTabState extends State { final delta = expandedH - collapsedH; _syncHeaderDelta(delta); return Scaffold( - backgroundColor: cs.surface, + backgroundColor: spectrumSurfaceColor(cs), body: NotificationListener( onNotification: (n) => _handleScrollNotification(n, delta), child: CustomScrollView( diff --git a/lib/frontend/widgets/komet_avatar.dart b/lib/frontend/widgets/komet_avatar.dart index 404f671..447b7cf 100644 --- a/lib/frontend/widgets/komet_avatar.dart +++ b/lib/frontend/widgets/komet_avatar.dart @@ -1,9 +1,12 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import '../../core/config/app_spectrum_background.dart'; +import 'spectrum_tint.dart'; + /// Circular avatar: shows [imageUrl] when available, otherwise the first letter /// of [name] on a colored background. Falls back to the letter on image error. -class KometAvatar extends StatelessWidget { +class KometAvatar extends StatefulWidget { final String name; final String? imageUrl; final double size; @@ -26,27 +29,74 @@ class KometAvatar extends StatelessWidget { static const _fadeInDuration = Duration(milliseconds: 500); static const _fadeOutDuration = Duration(milliseconds: 1000); + @override + State createState() => _KometAvatarState(); +} + +class _KometAvatarState extends State + implements SpectrumTintSource { + Color _background = const Color(0xFF000000); + bool _registered = false; + + @override + void initState() { + super.initState(); + AppSpectrumBackground.current.addListener(_syncRegistration); + _syncRegistration(); + } + + @override + void dispose() { + AppSpectrumBackground.current.removeListener(_syncRegistration); + if (_registered) SpectrumTintRegistry.instance.unregister(this); + super.dispose(); + } + + void _syncRegistration() { + final shouldRegister = AppSpectrumBackground.isEnabled; + if (shouldRegister == _registered) return; + _registered = shouldRegister; + if (shouldRegister) { + SpectrumTintRegistry.instance.register(this); + } else { + SpectrumTintRegistry.instance.unregister(this); + } + } + + @override + BuildContext? get tintContext => mounted ? context : null; + + @override + String? get tintImageUrl => widget.imageUrl; + + @override + Color get tintFallbackColor => _background; + + @override + double get tintWeight => widget.size; + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - final bg = backgroundColor ?? cs.primaryContainer; - final fg = foregroundColor ?? cs.onPrimaryContainer; - final letter = name.isNotEmpty ? name[0].toUpperCase() : '?'; + final bg = widget.backgroundColor ?? cs.primaryContainer; + final fg = widget.foregroundColor ?? cs.onPrimaryContainer; + _background = bg; + final letter = widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?'; final placeholder = Center( child: Text( letter, style: TextStyle( color: fg, - fontSize: fontSize ?? size * 0.4, + fontSize: widget.fontSize ?? widget.size * 0.4, fontWeight: FontWeight.bold, ), ), ); - final url = imageUrl; - final cache = (size * 3).round(); + final url = widget.imageUrl; + final cache = (widget.size * 3).round(); return Container( - width: size, - height: size, + width: widget.size, + height: widget.size, clipBehavior: Clip.antiAlias, decoration: BoxDecoration(shape: BoxShape.circle, color: bg), child: (url != null && url.isNotEmpty) @@ -55,8 +105,12 @@ class KometAvatar extends StatelessWidget { fit: BoxFit.cover, memCacheWidth: cache, memCacheHeight: cache, - fadeInDuration: fadeIn ? _fadeInDuration : Duration.zero, - fadeOutDuration: fadeIn ? _fadeOutDuration : Duration.zero, + fadeInDuration: widget.fadeIn + ? KometAvatar._fadeInDuration + : Duration.zero, + fadeOutDuration: widget.fadeIn + ? KometAvatar._fadeOutDuration + : Duration.zero, errorWidget: (_, _, _) => placeholder, ) : placeholder, diff --git a/lib/frontend/widgets/spectrum_background.dart b/lib/frontend/widgets/spectrum_background.dart new file mode 100644 index 0000000..1e7fe80 --- /dev/null +++ b/lib/frontend/widgets/spectrum_background.dart @@ -0,0 +1,497 @@ +import 'dart:math' as math; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +import 'spectrum_tint.dart'; + +class SpectrumTuning { + const SpectrumTuning._(); + + static const double barWidth = 1; + static const double barGap = 0.6; + static const double heightFraction = 0.75; + static const double surfaceLift = 0.06; + static const double tintStrength = 0.3; + static const double tintRadius = 190; + static const double tintFollowRate = 3.5; + static const double frameInterval = 1 / 60; + static const double tintInterval = 0.2; + static const double parallax = 0.06; + static const int minBars = 4; + static const int maxBars = 1024; + + static Color baseColor(ColorScheme cs) { + final surface = cs.surface; + final lift = surface.computeLuminance() < 0.5 ? Colors.white : Colors.black; + return Color.alphaBlend(lift.withValues(alpha: surfaceLift), surface); + } +} + +class SpectrumBackground extends StatefulWidget { + const SpectrumBackground({super.key}); + + @override + State createState() => _SpectrumBackgroundState(); +} + +class _SpectrumBackgroundState extends State + with SingleTickerProviderStateMixin { + static const double _maxStep = 0.25; + + final List _samples = []; + + late final Ticker _ticker; + _SpectrumField? _field; + _SpectrumPalette? _palette; + Duration _lastElapsed = Duration.zero; + double _frameAccumulator = 0; + double _tintAccumulator = SpectrumTuning.tintInterval; + double _pitch = SpectrumTuning.barWidth + SpectrumTuning.barGap; + double _leftInset = 0; + Color _baseColor = const Color(0xFF000000); + + @override + void initState() { + super.initState(); + SpectrumTintRegistry.instance.listenForResolvedColors(_onColorResolved); + _ticker = createTicker(_onTick)..start(); + } + + @override + void dispose() { + SpectrumTintRegistry.instance.listenForResolvedColors(null); + _ticker.dispose(); + _field?.dispose(); + super.dispose(); + } + + void _onColorResolved() => _tintAccumulator = SpectrumTuning.tintInterval; + + void _onTick(Duration elapsed) { + final delta = + (elapsed - _lastElapsed).inMicroseconds / + Duration.microsecondsPerSecond; + _lastElapsed = elapsed; + if (delta <= 0) return; + + final step = delta > _maxStep ? _maxStep : delta; + + _tintAccumulator += step; + if (_tintAccumulator >= SpectrumTuning.tintInterval) { + _tintAccumulator = 0; + _refreshTints(); + } + + _frameAccumulator += step; + if (_frameAccumulator < SpectrumTuning.frameInterval) return; + + final frameStep = _frameAccumulator; + _frameAccumulator = 0; + _palette?.advance(frameStep); + _field?.update(frameStep); + } + + void _refreshTints() { + final palette = _palette; + if (palette == null) return; + + final box = context.findRenderObject(); + if (box is! RenderBox || !box.attached || !box.hasSize) return; + + final origin = box.localToGlobal(Offset.zero); + final viewport = (origin & box.size).inflate(SpectrumTuning.tintRadius); + SpectrumTintRegistry.instance.collect(_samples, viewport); + + palette.retarget( + base: _baseColor, + samples: _samples, + zoneLeft: origin.dx + _zoneLeft, + zoneRight: origin.dx + _zoneRight, + baselineY: origin.dy + box.size.height, + ); + } + + double get _zoneLeft => _leftInset; + + double get _zoneRight => + _leftInset + + (_field == null ? 0 : (_field!.barCount - 1) * _pitch) + + SpectrumTuning.barWidth; + + void _syncMetrics(Size size, Color base) { + _baseColor = base; + if (size.width <= 0 || size.height <= 0) return; + + final pitch = SpectrumTuning.barWidth + SpectrumTuning.barGap; + final count = ((size.width + SpectrumTuning.barGap) / pitch).floor().clamp( + SpectrumTuning.minBars, + SpectrumTuning.maxBars, + ); + final occupied = count * pitch - SpectrumTuning.barGap; + + _pitch = pitch; + _leftInset = (size.width - occupied) / 2; + + if (_field?.barCount == count) return; + + _field?.dispose(); + _field = _SpectrumField(count); + _palette = _SpectrumPalette(base); + } + + @override + Widget build(BuildContext context) { + final base = SpectrumTuning.baseColor(Theme.of(context).colorScheme); + + return LayoutBuilder( + builder: (context, constraints) { + _syncMetrics(constraints.biggest, base); + + final field = _field; + final palette = _palette; + if (field == null || palette == null) return const SizedBox.expand(); + + return CustomPaint( + size: Size.infinite, + isComplex: false, + willChange: true, + painter: _SpectrumPainter( + field: field, + palette: palette, + zoneLeft: _zoneLeft, + zoneRight: _zoneRight, + pitch: _pitch, + leftInset: _leftInset, + ), + ); + }, + ); + } +} + +class _SpectrumField extends ChangeNotifier { + _SpectrumField(this.barCount) + : heights = Float32List(barCount), + _targets = Float32List(barCount), + _spread = Float32List(barCount), + _sparks = Float32List(barCount), + _velocities = Float32List(barCount), + _phases = Float32List(barCount), + _rates = Float32List(barCount) { + final random = math.Random(barCount * 7919 + 13); + for (var i = 0; i < barCount; i++) { + _phases[i] = random.nextDouble() * math.pi * 2; + _rates[i] = 0.6 + random.nextDouble() * 1.5; + } + final reach = (barCount * 0.03).clamp(3.0, 40.0); + _spreadDecay = math.pow(_spreadEdge, 1 / reach).toDouble(); + } + + static const double _speed = 2.8; + static const double _ambient = 0.22; + static const double _reach = 1; + static const double _sparkReach = 0.45; + static const double _sparkDecay = 7; + static const double _riseRate = 26; + static const double _gravity = 5.5; + static const double _spreadEdge = 0.1; + + final int barCount; + final Float32List heights; + final Float32List _targets; + final Float32List _spread; + final Float32List _sparks; + final Float32List _velocities; + final Float32List _phases; + final Float32List _rates; + final math.Random _random = math.Random(4409); + + late final double _spreadDecay; + double _elapsed = 0; + double _sparkCountdown = 0.15; + + void update(double dt) { + _elapsed += dt; + _driveTargets(dt); + _spreadToNeighbours(); + _applyGravity(dt); + notifyListeners(); + } + + void _driveTargets(double dt) { + _sparkCountdown -= dt; + if (_sparkCountdown <= 0) { + _sparkCountdown = 0.08 + _random.nextDouble() * 0.3; + _sparks[_random.nextInt(barCount)] = 0.5 + _random.nextDouble() * 0.5; + } + final sparkDecay = math.exp(-dt * _sparkDecay); + + final time = _elapsed * _speed; + final peak = + 0.5 + 0.24 * math.sin(time * 0.11) + 0.09 * math.sin(time * 0.37 + 1.3); + final width = 0.19 + 0.05 * math.sin(time * 0.23); + final last = barCount - 1; + + for (var i = 0; i < barCount; i++) { + final position = last == 0 ? 0.5 : i / last; + final distance = (position - peak) / width; + final envelope = math.exp(-distance * distance); + + final slow = 0.5 + 0.5 * math.sin(time * _rates[i] * 0.55 + _phases[i]); + final fast = + 0.5 + 0.5 * math.sin(time * _rates[i] * 2.3 + _phases[i] * 1.7); + final wobble = slow * fast; + + _sparks[i] *= sparkDecay; + final driven = + envelope * (0.3 + 0.7 * wobble) * _reach + + _ambient * wobble + + _sparks[i] * _sparkReach; + _targets[i] = driven > 1 ? 1 : driven; + } + } + + void _spreadToNeighbours() { + var running = 0.0; + for (var i = 0; i < barCount; i++) { + running *= _spreadDecay; + final value = _targets[i]; + if (value > running) running = value; + _spread[i] = running; + } + running = 0.0; + for (var i = barCount - 1; i >= 0; i--) { + running *= _spreadDecay; + final value = _targets[i]; + if (value > running) running = value; + if (running > _spread[i]) _spread[i] = running; + } + } + + void _applyGravity(double dt) { + final riseFactor = 1 - math.exp(-dt * _riseRate); + for (var i = 0; i < barCount; i++) { + final target = _spread[i]; + final current = heights[i]; + if (target >= current) { + heights[i] = current + (target - current) * riseFactor; + _velocities[i] = 0; + continue; + } + _velocities[i] += _gravity * dt; + final next = current - _velocities[i] * dt; + if (next <= target) { + heights[i] = target; + _velocities[i] = 0; + } else { + heights[i] = next; + } + } + } +} + +class _SpectrumPalette { + _SpectrumPalette(Color base) { + _writeUniform(_current, base); + _writeUniform(_target, base); + for (var i = 0; i < stopCount; i++) { + colors[i] = base; + } + } + + static const int stopCount = 16; + static const double _epsilon = 0.0008; + static final double _radiusSquared = + SpectrumTuning.tintRadius * SpectrumTuning.tintRadius; + static final List _stops = List.generate( + stopCount, + (i) => i / (stopCount - 1), + ); + + final List colors = List.filled( + stopCount, + const Color(0xFF000000), + ); + final Float32List _current = Float32List(stopCount * 3); + final Float32List _target = Float32List(stopCount * 3); + + bool uniform = true; + bool _targetUniform = true; + ui.Shader? _shader; + double _shaderLeft = double.nan; + double _shaderRight = double.nan; + + static void _writeUniform(Float32List channels, Color color) { + for (var i = 0; i < channels.length; i += 3) { + channels[i] = color.r; + channels[i + 1] = color.g; + channels[i + 2] = color.b; + } + } + + void retarget({ + required Color base, + required List samples, + required double zoneLeft, + required double zoneRight, + required double baselineY, + }) { + final baseRed = base.r; + final baseGreen = base.g; + final baseBlue = base.b; + final span = zoneRight - zoneLeft; + var anyTinted = false; + + for (var i = 0; i < stopCount; i++) { + final x = zoneLeft + span * _stops[i]; + var sumRed = 0.0; + var sumGreen = 0.0; + var sumBlue = 0.0; + var sumWeight = 0.0; + + for (var s = 0; s < samples.length; s++) { + final sample = samples[s]; + final dx = sample.center.dx - x; + final dy = sample.center.dy - baselineY; + final weight = + sample.weight / (1 + (dx * dx + dy * dy) / _radiusSquared); + if (weight < 0.015) continue; + sumRed += sample.color.r * weight; + sumGreen += sample.color.g * weight; + sumBlue += sample.color.b * weight; + sumWeight += weight; + } + + final index = i * 3; + if (sumWeight <= 0) { + _target[index] = baseRed; + _target[index + 1] = baseGreen; + _target[index + 2] = baseBlue; + continue; + } + + final influence = + (sumWeight > 1 ? 1.0 : sumWeight) * SpectrumTuning.tintStrength; + _target[index] = baseRed + (sumRed / sumWeight - baseRed) * influence; + _target[index + 1] = + baseGreen + (sumGreen / sumWeight - baseGreen) * influence; + _target[index + 2] = + baseBlue + (sumBlue / sumWeight - baseBlue) * influence; + anyTinted = true; + } + + _targetUniform = !anyTinted; + } + + void advance(double dt) { + final factor = 1 - math.exp(-dt * SpectrumTuning.tintFollowRate); + var changed = false; + + for (var i = 0; i < _current.length; i++) { + final delta = _target[i] - _current[i]; + if (delta < _epsilon && delta > -_epsilon) { + if (_current[i] != _target[i]) { + _current[i] = _target[i]; + changed = true; + } + continue; + } + _current[i] += delta * factor; + changed = true; + } + + if (!changed) { + uniform = _targetUniform; + return; + } + + for (var i = 0; i < stopCount; i++) { + final index = i * 3; + colors[i] = Color.from( + alpha: 1, + red: _current[index], + green: _current[index + 1], + blue: _current[index + 2], + ); + } + uniform = false; + _shader = null; + } + + ui.Shader shaderFor(double left, double right) { + final cached = _shader; + if (cached != null && _shaderLeft == left && _shaderRight == right) { + return cached; + } + _shaderLeft = left; + _shaderRight = right; + return _shader = ui.Gradient.linear( + Offset(left, 0), + Offset(right, 0), + colors, + _stops, + ); + } +} + +class _SpectrumPainter extends CustomPainter { + _SpectrumPainter({ + required this.field, + required this.palette, + required this.zoneLeft, + required this.zoneRight, + required this.pitch, + required this.leftInset, + }) : super(repaint: field); + + static const double _minVisibleHeight = 0.6; + + final _SpectrumField field; + final _SpectrumPalette palette; + final double zoneLeft; + final double zoneRight; + final double pitch; + final double leftInset; + + @override + void paint(Canvas canvas, Size size) { + final heights = field.heights; + if (heights.isEmpty) return; + + final maxHeight = size.height * SpectrumTuning.heightFraction; + final baseline = size.height; + final paint = Paint(); + if (palette.uniform) { + paint.color = palette.colors.first; + } else { + paint.shader = palette.shaderFor(zoneLeft, zoneRight); + } + + for (var i = 0; i < heights.length; i++) { + final height = heights[i] * maxHeight; + if (height < _minVisibleHeight) continue; + final left = leftInset + pitch * i; + canvas.drawRect( + Rect.fromLTRB( + left, + baseline - height, + left + SpectrumTuning.barWidth, + baseline, + ), + paint, + ); + } + } + + @override + bool shouldRepaint(_SpectrumPainter old) => + old.field != field || + old.palette != palette || + old.pitch != pitch || + old.leftInset != leftInset || + old.zoneLeft != zoneLeft || + old.zoneRight != zoneRight; +} diff --git a/lib/frontend/widgets/spectrum_tint.dart b/lib/frontend/widgets/spectrum_tint.dart new file mode 100644 index 0000000..a78350d --- /dev/null +++ b/lib/frontend/widgets/spectrum_tint.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +import '../../core/config/app_spectrum_background.dart'; +import '../../core/media/dominant_color.dart'; + +class SpectrumTintSample { + const SpectrumTintSample({ + required this.center, + required this.color, + required this.weight, + }); + + final Offset center; + final Color color; + final double weight; +} + +abstract class SpectrumTintSource { + BuildContext? get tintContext; + + String? get tintImageUrl; + + Color get tintFallbackColor; + + double get tintWeight; +} + +class SpectrumTintRegistry { + SpectrumTintRegistry._(); + + static final SpectrumTintRegistry instance = SpectrumTintRegistry._(); + + static const double _referenceWeight = 48; + + final Set _sources = {}; + VoidCallback? _resolutionListener; + + void register(SpectrumTintSource source) => _sources.add(source); + + void unregister(SpectrumTintSource source) => _sources.remove(source); + + void listenForResolvedColors(VoidCallback? listener) => + _resolutionListener = listener; + + void collect(List out, Rect viewport) { + out.clear(); + for (final source in _sources) { + final sourceContext = source.tintContext; + if (sourceContext == null) continue; + + final box = sourceContext.findRenderObject(); + if (box is! RenderBox || !box.attached || !box.hasSize) continue; + + final center = box.localToGlobal(box.size.center(Offset.zero)); + if (!viewport.contains(center)) continue; + + out.add( + SpectrumTintSample( + center: center, + color: _colorFor(source), + weight: source.tintWeight / _referenceWeight, + ), + ); + } + } + + Color _colorFor(SpectrumTintSource source) { + final url = source.tintImageUrl; + if (url == null || url.isEmpty) return source.tintFallbackColor; + + final resolved = DominantColorCache.instance.lookup(url); + if (resolved != null) return resolved; + + final listener = _resolutionListener; + if (listener != null) DominantColorCache.instance.request(url, listener); + return source.tintFallbackColor; + } +} + +mixin SpectrumSurface on State { + @override + void initState() { + super.initState(); + AppSpectrumBackground.current.addListener(_onSpectrumBackgroundChanged); + } + + @override + void dispose() { + AppSpectrumBackground.current.removeListener(_onSpectrumBackgroundChanged); + super.dispose(); + } + + void _onSpectrumBackgroundChanged() { + if (mounted) setState(() {}); + } + + Color spectrumSurfaceColor(ColorScheme cs) => + AppSpectrumBackground.isEnabled ? Colors.transparent : cs.surface; +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bc8f730..f80c00e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -318,6 +318,8 @@ "appearanceNavPillFrost": "G-FrostBlur", "appearanceGradientTitle": "Gradient", "appearanceGradientSubtitle": "Depth and highlights in Glossy capsules", + "appearanceSpectrumTitle": "Spectrum background", + "appearanceSpectrumSubtitle": "Experimental — living bars beneath the interface", "appearanceAccentColorTitle": "Accent color", "appearanceAccentColorSystem": "System", "appearanceAccentColorSubtitle": "Main color of the interface and bubbles", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 69b64b3..1d20c34 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1652,6 +1652,18 @@ abstract class AppLocalizations { /// **'Depth and highlights in Glossy capsules'** String get appearanceGradientSubtitle; + /// No description provided for @appearanceSpectrumTitle. + /// + /// In en, this message translates to: + /// **'Spectrum background'** + String get appearanceSpectrumTitle; + + /// No description provided for @appearanceSpectrumSubtitle. + /// + /// In en, this message translates to: + /// **'Experimental — living bars beneath the interface'** + String get appearanceSpectrumSubtitle; + /// No description provided for @appearanceAccentColorTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 84dbc40..777047c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -833,6 +833,13 @@ class AppLocalizationsEn extends AppLocalizations { String get appearanceGradientSubtitle => 'Depth and highlights in Glossy capsules'; + @override + String get appearanceSpectrumTitle => 'Spectrum background'; + + @override + String get appearanceSpectrumSubtitle => + 'Experimental — living bars beneath the interface'; + @override String get appearanceAccentColorTitle => 'Accent color'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 98f3fab..870a3ef 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -836,6 +836,13 @@ class AppLocalizationsRu extends AppLocalizations { @override String get appearanceGradientSubtitle => 'Объём и блики в Glossy-капсулах'; + @override + String get appearanceSpectrumTitle => 'Спектр на фоне'; + + @override + String get appearanceSpectrumSubtitle => + 'Экспериментально — живые полосы под интерфейсом'; + @override String get appearanceAccentColorTitle => 'Акцентный цвет'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index d9a4590..100549c 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -276,6 +276,8 @@ "appearanceNavPillFrost": "G-FrostBlur", "appearanceGradientTitle": "Градиент", "appearanceGradientSubtitle": "Объём и блики в Glossy-капсулах", + "appearanceSpectrumTitle": "Спектр на фоне", + "appearanceSpectrumSubtitle": "Экспериментально — живые полосы под интерфейсом", "appearanceAccentColorTitle": "Акцентный цвет", "appearanceAccentColorSystem": "Системный", "appearanceAccentColorSubtitle": "Основной цвет интерфейса и пузырей", diff --git a/lib/main.dart b/lib/main.dart index fceafe3..1490a09 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,6 +23,7 @@ import 'core/storage/chat_encryption_store.dart'; import 'core/config/app_accent.dart'; import 'core/config/app_amoled.dart'; import 'core/config/app_show_extra_info.dart'; +import 'core/config/app_spectrum_background.dart'; import 'core/config/app_bubble_behavior.dart'; import 'core/config/komet_settings.dart'; import 'core/config/debug_test.dart'; @@ -227,6 +228,7 @@ void main(List args) async { final videoNoteRearCameraFuture = AppVideoNoteRearCamera.load(); final digitalIdNativeFuture = AppDigitalIdNative.load(); final showExtraInfoFuture = AppShowExtraInfo.load(); + final spectrumBackgroundFuture = AppSpectrumBackground.load(); final trafficCaptureFuture = TrafficMonitor.instance.load(); final debugLogFuture = DebugSessionLog.instance.init(); @@ -287,6 +289,7 @@ void main(List args) async { videoNoteRearCameraFuture, digitalIdNativeFuture, showExtraInfoFuture, + spectrumBackgroundFuture, ]); await DeviceContactsService.loadFromStartup(); await trafficCaptureFuture; diff --git a/test/spectrum_background_test.dart b/test/spectrum_background_test.dart new file mode 100644 index 0000000..73afb06 --- /dev/null +++ b/test/spectrum_background_test.dart @@ -0,0 +1,199 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:komet/core/config/app_spectrum_background.dart'; +import 'package:komet/frontend/widgets/komet_avatar.dart'; +import 'package:komet/frontend/widgets/spectrum_background.dart'; + +class _CountingCanvas implements Canvas { + final List rects = []; + final List colors = []; + + @override + void drawRect(Rect rect, Paint paint) { + rects.add(rect); + colors.add(paint.color); + } + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +const Size _viewport = Size(800, 600); + +Widget _host({Widget? overlay, required Brightness brightness, Color? seed}) { + return MaterialApp( + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: seed ?? const Color(0xFF6750A4), + brightness: brightness, + ), + ), + home: Scaffold( + body: Stack( + children: [ + const Positioned.fill(child: SpectrumBackground()), + ?overlay, + ], + ), + ), + ); +} + +_CountingCanvas _paintOnce(WidgetTester tester) { + final paint = tester.widget( + find.descendant( + of: find.byType(SpectrumBackground), + matching: find.byType(CustomPaint), + ), + ); + final canvas = _CountingCanvas(); + paint.painter!.paint(canvas, _viewport); + return canvas; +} + +Future _renderedPixels(WidgetTester tester) async { + final paint = tester.widget( + find.descendant( + of: find.byType(SpectrumBackground), + matching: find.byType(CustomPaint), + ), + ); + final recorder = ui.PictureRecorder(); + paint.painter!.paint(Canvas(recorder), _viewport); + final picture = recorder.endRecording(); + + ByteData? data; + await tester.runAsync(() async { + final image = await picture.toImage( + _viewport.width.round(), + _viewport.height.round(), + ); + data = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + image.dispose(); + }); + return data!; +} + +double _averageRed(ByteData pixels, int row, int fromX, int toX) { + var total = 0.0; + var counted = 0; + for (var x = fromX; x < toX; x++) { + final offset = (row * _viewport.width.round() + x) * 4; + if (pixels.getUint8(offset + 3) == 0) continue; + total += pixels.getUint8(offset); + counted++; + } + return counted == 0 ? 0 : total / counted; +} + +Future _settleBars(WidgetTester tester) async { + for (var i = 0; i < 12; i++) { + await tester.pump(const Duration(milliseconds: 60)); + } +} + +void main() { + setUp(() async { + SharedPreferences.setMockInitialValues({ + AppSpectrumBackground.prefKey: true, + }); + await AppSpectrumBackground.load(); + }); + + testWidgets('bars grow from the bottom and stay inside the lower zone', ( + tester, + ) async { + await tester.pumpWidget(_host(brightness: Brightness.dark)); + await _settleBars(tester); + + final canvas = _paintOnce(tester); + expect(canvas.rects, isNotEmpty); + + final zoneTop = _viewport.height * (1 - SpectrumTuning.heightFraction); + for (final rect in canvas.rects) { + expect(rect.bottom, _viewport.height); + expect(rect.top, greaterThanOrEqualTo(zoneTop - 0.01)); + expect(rect.width, SpectrumTuning.barWidth); + } + + final heights = canvas.rects.map((r) => r.height).toSet(); + expect(heights.length, greaterThan(1)); + }); + + testWidgets('bar color is a neutral lift of the surface, not the accent', ( + tester, + ) async { + await tester.pumpWidget( + _host(brightness: Brightness.dark, seed: const Color(0xFFFF4FA3)), + ); + await _settleBars(tester); + + final context = tester.element(find.byType(SpectrumBackground)); + final cs = Theme.of(context).colorScheme; + final bar = _paintOnce(tester).colors.first; + + expect(bar.computeLuminance(), greaterThan(cs.surface.computeLuminance())); + expect( + bar.computeLuminance(), + lessThan(cs.surfaceContainerHighest.computeLuminance()), + ); + + final barHsl = HSLColor.fromColor(bar); + final surfaceHsl = HSLColor.fromColor(cs.surface); + expect(barHsl.saturation, lessThanOrEqualTo(surfaceHsl.saturation + 0.01)); + expect((barHsl.hue - surfaceHsl.hue).abs(), lessThan(1)); + expect(barHsl.lightness, greaterThan(surfaceHsl.lightness)); + }); + + testWidgets('bar count scales to thin lines across the width', ( + tester, + ) async { + await tester.pumpWidget(_host(brightness: Brightness.dark)); + await _settleBars(tester); + + final expected = + (_viewport.width + SpectrumTuning.barGap) / + (SpectrumTuning.barWidth + SpectrumTuning.barGap); + expect(expected, greaterThan(400)); + expect(_paintOnce(tester).rects.length, greaterThan(200)); + }); + + testWidgets('a nearby avatar tints the bars closest to it', (tester) async { + await tester.pumpWidget( + _host( + brightness: Brightness.dark, + overlay: const Align( + alignment: Alignment.bottomLeft, + child: KometAvatar( + name: 'Nova', + size: 48, + backgroundColor: Color(0xFFFF0000), + fadeIn: false, + ), + ), + ), + ); + await _settleBars(tester); + + final pixels = await _renderedPixels(tester); + final row = _viewport.height.round() - 2; + final nearAvatar = _averageRed(pixels, row, 0, 200); + final farSide = _averageRed(pixels, row, 600, 800); + + expect(nearAvatar, greaterThan(farSide)); + }); + + testWidgets('no tint sources leave every bar on the base color', ( + tester, + ) async { + await tester.pumpWidget(_host(brightness: Brightness.dark)); + await _settleBars(tester); + + expect(_paintOnce(tester).colors.toSet().length, 1); + }); +}