feat: писюльки прыгают

This commit is contained in:
Jganenokk
2026-08-03 11:40:09 +07:00
parent d83056b8e3
commit 6791a30150
17 changed files with 1152 additions and 18 deletions
@@ -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<bool>(
prefKey: prefKey,
defaultValue: defaultValue,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
},
);
static ValueNotifier<bool> get current => _setting.current;
static bool get isEnabled => _setting.current.value;
static Future<bool> load() => _setting.load();
static Future<void> save(bool value) => _setting.save(value);
}
+142
View File
@@ -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<String, Color> _resolved = <String, Color>{};
final Set<String> _inFlight = <String>{};
final Set<String> _rejected = <String>{};
final Map<String, DateTime> _retryAfter = <String, DateTime>{};
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;
}
+4 -2
View File
@@ -18,6 +18,7 @@ import '../../widgets/chat_menu_overlay.dart';
import '../../widgets/small_spinner.dart'; import '../../widgets/small_spinner.dart';
import '../../widgets/prompt_dialog.dart'; import '../../widgets/prompt_dialog.dart';
import '../../widgets/call_link_handler.dart'; import '../../widgets/call_link_handler.dart';
import '../../widgets/spectrum_tint.dart';
import 'call_screen.dart'; import 'call_screen.dart';
class CallsTab extends StatefulWidget { class CallsTab extends StatefulWidget {
@@ -27,7 +28,8 @@ class CallsTab extends StatefulWidget {
State<CallsTab> createState() => _CallsTabState(); State<CallsTab> createState() => _CallsTabState();
} }
class _CallsTabState extends State<CallsTab> with ReloadOnReconnect { class _CallsTabState extends State<CallsTab>
with ReloadOnReconnect, SpectrumSurface {
List<CallLogEntry> _calls = []; List<CallLogEntry> _calls = [];
final Set<String> _removing = {}; final Set<String> _removing = {};
bool _isLoading = true; bool _isLoading = true;
@@ -469,7 +471,7 @@ class _CallsTabState extends State<CallsTab> with ReloadOnReconnect {
: _calls; : _calls;
return Scaffold( return Scaffold(
backgroundColor: cs.surface, backgroundColor: spectrumSurfaceColor(cs),
body: SafeArea( body: SafeArea(
bottom: false, bottom: false,
child: Column( child: Column(
@@ -47,6 +47,7 @@ import '../../../core/protocol/packet.dart';
import '../../../core/utils/haptics.dart'; import '../../../core/utils/haptics.dart';
import '../../../core/config/app_animations.dart'; import '../../../core/config/app_animations.dart';
import '../../../core/config/app_frost.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_nav_pill_style.dart';
import '../../../core/config/app_visual_style.dart'; import '../../../core/config/app_visual_style.dart';
import '../../../core/config/app_stories.dart'; import '../../../core/config/app_stories.dart';
@@ -74,6 +75,8 @@ import '../../../main.dart'
messagesModule, messagesModule,
storiesModule; storiesModule;
import '../../widgets/attachment/attachment_sheet.dart'; import '../../widgets/attachment/attachment_sheet.dart';
import '../../widgets/spectrum_background.dart';
import '../../widgets/spectrum_tint.dart';
import '../../widgets/update_dialog.dart'; import '../../widgets/update_dialog.dart';
import '../stories/story_composer_screen.dart'; import '../stories/story_composer_screen.dart';
import '../stories/story_owner_info.dart'; import '../stories/story_owner_info.dart';
@@ -189,7 +192,7 @@ class ChatListScreen extends StatefulWidget {
enum _DeleteKind { personalLike, ownerGroup, blocked } enum _DeleteKind { personalLike, ownerGroup, blocked }
class _ChatListScreenState extends State<ChatListScreen> class _ChatListScreenState extends State<ChatListScreen>
with TickerProviderStateMixin, RouteAware { with TickerProviderStateMixin, RouteAware, SpectrumSurface {
String? _selectedFolderId; String? _selectedFolderId;
List<ChatFolder> _folders = []; List<ChatFolder> _folders = [];
@@ -2126,6 +2129,29 @@ class _ChatListScreenState extends State<ChatListScreen>
return Stack( return Stack(
children: [ 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( ClipRect(
child: SizedBox( child: SizedBox(
width: pageW, width: pageW,
@@ -14,6 +14,7 @@ import '../../widgets/komet_avatar.dart';
import '../../widgets/connection_status.dart'; import '../../widgets/connection_status.dart';
import '../../widgets/sheet_helpers.dart'; import '../../widgets/sheet_helpers.dart';
import '../../widgets/small_spinner.dart'; import '../../widgets/small_spinner.dart';
import '../../widgets/spectrum_tint.dart';
import '../../widgets/springy_tap.dart'; import '../../widgets/springy_tap.dart';
import '../chats/chat_info_screen.dart'; import '../chats/chat_info_screen.dart';
import 'nfc_exchange_sheet.dart'; import 'nfc_exchange_sheet.dart';
@@ -28,7 +29,7 @@ class ContactsTab extends StatefulWidget {
State<ContactsTab> createState() => _ContactsTabState(); State<ContactsTab> createState() => _ContactsTabState();
} }
class _ContactsTabState extends State<ContactsTab> { class _ContactsTabState extends State<ContactsTab> with SpectrumSurface {
List<CachedContact> _contacts = []; List<CachedContact> _contacts = [];
bool _isLoading = true; bool _isLoading = true;
@@ -219,7 +220,7 @@ class _ContactsTabState extends State<ContactsTab> {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
return Scaffold( return Scaffold(
backgroundColor: cs.surface, backgroundColor: spectrumSurfaceColor(cs),
body: SafeArea( body: SafeArea(
bottom: false, bottom: false,
child: Column( child: Column(
@@ -13,6 +13,7 @@ import '../../../core/config/app_chat_chrome.dart';
import '../../../core/config/app_composer_background.dart'; import '../../../core/config/app_composer_background.dart';
import '../../../core/config/app_composer_style.dart'; import '../../../core/config/app_composer_style.dart';
import '../../../core/config/app_nav_pill_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/bubble_radius.dart';
import '../../../core/utils/debouncer.dart'; import '../../../core/utils/debouncer.dart';
import '../../../core/utils/haptics.dart'; import '../../../core/utils/haptics.dart';
@@ -128,6 +129,8 @@ class _AppearanceScreenState extends State<AppearanceScreen> {
const _NavPillStyleCard(), const _NavPillStyleCard(),
const SizedBox(height: 12), const SizedBox(height: 12),
const _GradientToggleCard(), 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<bool>(
valueListenable: AppSpectrumBackground.current,
builder: (context, value, _) => Switch(
value: value,
onChanged: (v) {
Haptics.selection();
AppSpectrumBackground.save(v);
},
),
),
],
),
);
}
}
class _PreviewSection extends StatefulWidget { class _PreviewSection extends StatefulWidget {
final ValueNotifier<Color> color; final ValueNotifier<Color> color;
final ValueNotifier<bool> isSystem; final ValueNotifier<bool> isSystem;
@@ -38,6 +38,7 @@ import 'cloud_storage_screen.dart';
import 'customization_section.dart'; import 'customization_section.dart';
import 'debug_menu_screen.dart'; import 'debug_menu_screen.dart';
import 'devices_screen.dart'; import 'devices_screen.dart';
import '../../widgets/spectrum_tint.dart';
import 'edit_profile_screen.dart'; import 'edit_profile_screen.dart';
import 'info_screen.dart'; import 'info_screen.dart';
import 'komet_settings_screen.dart'; import 'komet_settings_screen.dart';
@@ -52,7 +53,7 @@ class SettingsTab extends StatefulWidget {
State<SettingsTab> createState() => _SettingsTabState(); State<SettingsTab> createState() => _SettingsTabState();
} }
class _SettingsTabState extends State<SettingsTab> { class _SettingsTabState extends State<SettingsTab> with SpectrumSurface {
ProfileData? _profile; ProfileData? _profile;
bool _isPhoneVisible = false; bool _isPhoneVisible = false;
ScrollController? _scrollController; ScrollController? _scrollController;
@@ -358,7 +359,7 @@ class _SettingsTabState extends State<SettingsTab> {
final delta = expandedH - collapsedH; final delta = expandedH - collapsedH;
_syncHeaderDelta(delta); _syncHeaderDelta(delta);
return Scaffold( return Scaffold(
backgroundColor: cs.surface, backgroundColor: spectrumSurfaceColor(cs),
body: NotificationListener<ScrollNotification>( body: NotificationListener<ScrollNotification>(
onNotification: (n) => _handleScrollNotification(n, delta), onNotification: (n) => _handleScrollNotification(n, delta),
child: CustomScrollView( child: CustomScrollView(
+65 -11
View File
@@ -1,9 +1,12 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.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 /// Circular avatar: shows [imageUrl] when available, otherwise the first letter
/// of [name] on a colored background. Falls back to the letter on image error. /// 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 name;
final String? imageUrl; final String? imageUrl;
final double size; final double size;
@@ -26,27 +29,74 @@ class KometAvatar extends StatelessWidget {
static const _fadeInDuration = Duration(milliseconds: 500); static const _fadeInDuration = Duration(milliseconds: 500);
static const _fadeOutDuration = Duration(milliseconds: 1000); static const _fadeOutDuration = Duration(milliseconds: 1000);
@override
State<KometAvatar> createState() => _KometAvatarState();
}
class _KometAvatarState extends State<KometAvatar>
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final bg = backgroundColor ?? cs.primaryContainer; final bg = widget.backgroundColor ?? cs.primaryContainer;
final fg = foregroundColor ?? cs.onPrimaryContainer; final fg = widget.foregroundColor ?? cs.onPrimaryContainer;
final letter = name.isNotEmpty ? name[0].toUpperCase() : '?'; _background = bg;
final letter = widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?';
final placeholder = Center( final placeholder = Center(
child: Text( child: Text(
letter, letter,
style: TextStyle( style: TextStyle(
color: fg, color: fg,
fontSize: fontSize ?? size * 0.4, fontSize: widget.fontSize ?? widget.size * 0.4,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
); );
final url = imageUrl; final url = widget.imageUrl;
final cache = (size * 3).round(); final cache = (widget.size * 3).round();
return Container( return Container(
width: size, width: widget.size,
height: size, height: widget.size,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(shape: BoxShape.circle, color: bg), decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
child: (url != null && url.isNotEmpty) child: (url != null && url.isNotEmpty)
@@ -55,8 +105,12 @@ class KometAvatar extends StatelessWidget {
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: cache, memCacheWidth: cache,
memCacheHeight: cache, memCacheHeight: cache,
fadeInDuration: fadeIn ? _fadeInDuration : Duration.zero, fadeInDuration: widget.fadeIn
fadeOutDuration: fadeIn ? _fadeOutDuration : Duration.zero, ? KometAvatar._fadeInDuration
: Duration.zero,
fadeOutDuration: widget.fadeIn
? KometAvatar._fadeOutDuration
: Duration.zero,
errorWidget: (_, _, _) => placeholder, errorWidget: (_, _, _) => placeholder,
) )
: placeholder, : placeholder,
@@ -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<SpectrumBackground> createState() => _SpectrumBackgroundState();
}
class _SpectrumBackgroundState extends State<SpectrumBackground>
with SingleTickerProviderStateMixin {
static const double _maxStep = 0.25;
final List<SpectrumTintSample> _samples = <SpectrumTintSample>[];
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<double> _stops = List<double>.generate(
stopCount,
(i) => i / (stopCount - 1),
);
final List<Color> colors = List<Color>.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<SpectrumTintSample> 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;
}
+99
View File
@@ -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<SpectrumTintSource> _sources = <SpectrumTintSource>{};
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<SpectrumTintSample> 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<T extends StatefulWidget> on State<T> {
@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;
}
+2
View File
@@ -318,6 +318,8 @@
"appearanceNavPillFrost": "G-FrostBlur", "appearanceNavPillFrost": "G-FrostBlur",
"appearanceGradientTitle": "Gradient", "appearanceGradientTitle": "Gradient",
"appearanceGradientSubtitle": "Depth and highlights in Glossy capsules", "appearanceGradientSubtitle": "Depth and highlights in Glossy capsules",
"appearanceSpectrumTitle": "Spectrum background",
"appearanceSpectrumSubtitle": "Experimental — living bars beneath the interface",
"appearanceAccentColorTitle": "Accent color", "appearanceAccentColorTitle": "Accent color",
"appearanceAccentColorSystem": "System", "appearanceAccentColorSystem": "System",
"appearanceAccentColorSubtitle": "Main color of the interface and bubbles", "appearanceAccentColorSubtitle": "Main color of the interface and bubbles",
+12
View File
@@ -1652,6 +1652,18 @@ abstract class AppLocalizations {
/// **'Depth and highlights in Glossy capsules'** /// **'Depth and highlights in Glossy capsules'**
String get appearanceGradientSubtitle; 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. /// No description provided for @appearanceAccentColorTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+7
View File
@@ -833,6 +833,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get appearanceGradientSubtitle => String get appearanceGradientSubtitle =>
'Depth and highlights in Glossy capsules'; 'Depth and highlights in Glossy capsules';
@override
String get appearanceSpectrumTitle => 'Spectrum background';
@override
String get appearanceSpectrumSubtitle =>
'Experimental — living bars beneath the interface';
@override @override
String get appearanceAccentColorTitle => 'Accent color'; String get appearanceAccentColorTitle => 'Accent color';
+7
View File
@@ -836,6 +836,13 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get appearanceGradientSubtitle => 'Объём и блики в Glossy-капсулах'; String get appearanceGradientSubtitle => 'Объём и блики в Glossy-капсулах';
@override
String get appearanceSpectrumTitle => 'Спектр на фоне';
@override
String get appearanceSpectrumSubtitle =>
'Экспериментально — живые полосы под интерфейсом';
@override @override
String get appearanceAccentColorTitle => 'Акцентный цвет'; String get appearanceAccentColorTitle => 'Акцентный цвет';
+2
View File
@@ -276,6 +276,8 @@
"appearanceNavPillFrost": "G-FrostBlur", "appearanceNavPillFrost": "G-FrostBlur",
"appearanceGradientTitle": "Градиент", "appearanceGradientTitle": "Градиент",
"appearanceGradientSubtitle": "Объём и блики в Glossy-капсулах", "appearanceGradientSubtitle": "Объём и блики в Glossy-капсулах",
"appearanceSpectrumTitle": "Спектр на фоне",
"appearanceSpectrumSubtitle": "Экспериментально — живые полосы под интерфейсом",
"appearanceAccentColorTitle": "Акцентный цвет", "appearanceAccentColorTitle": "Акцентный цвет",
"appearanceAccentColorSystem": "Системный", "appearanceAccentColorSystem": "Системный",
"appearanceAccentColorSubtitle": "Основной цвет интерфейса и пузырей", "appearanceAccentColorSubtitle": "Основной цвет интерфейса и пузырей",
+3
View File
@@ -23,6 +23,7 @@ import 'core/storage/chat_encryption_store.dart';
import 'core/config/app_accent.dart'; import 'core/config/app_accent.dart';
import 'core/config/app_amoled.dart'; import 'core/config/app_amoled.dart';
import 'core/config/app_show_extra_info.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/app_bubble_behavior.dart';
import 'core/config/komet_settings.dart'; import 'core/config/komet_settings.dart';
import 'core/config/debug_test.dart'; import 'core/config/debug_test.dart';
@@ -227,6 +228,7 @@ void main(List<String> args) async {
final videoNoteRearCameraFuture = AppVideoNoteRearCamera.load(); final videoNoteRearCameraFuture = AppVideoNoteRearCamera.load();
final digitalIdNativeFuture = AppDigitalIdNative.load(); final digitalIdNativeFuture = AppDigitalIdNative.load();
final showExtraInfoFuture = AppShowExtraInfo.load(); final showExtraInfoFuture = AppShowExtraInfo.load();
final spectrumBackgroundFuture = AppSpectrumBackground.load();
final trafficCaptureFuture = TrafficMonitor.instance.load(); final trafficCaptureFuture = TrafficMonitor.instance.load();
final debugLogFuture = DebugSessionLog.instance.init(); final debugLogFuture = DebugSessionLog.instance.init();
@@ -287,6 +289,7 @@ void main(List<String> args) async {
videoNoteRearCameraFuture, videoNoteRearCameraFuture,
digitalIdNativeFuture, digitalIdNativeFuture,
showExtraInfoFuture, showExtraInfoFuture,
spectrumBackgroundFuture,
]); ]);
await DeviceContactsService.loadFromStartup(); await DeviceContactsService.loadFromStartup();
await trafficCaptureFuture; await trafficCaptureFuture;
+199
View File
@@ -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<Rect> rects = <Rect>[];
final List<Color> colors = <Color>[];
@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<CustomPaint>(
find.descendant(
of: find.byType(SpectrumBackground),
matching: find.byType(CustomPaint),
),
);
final canvas = _CountingCanvas();
paint.painter!.paint(canvas, _viewport);
return canvas;
}
Future<ByteData> _renderedPixels(WidgetTester tester) async {
final paint = tester.widget<CustomPaint>(
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<void> _settleBars(WidgetTester tester) async {
for (var i = 0; i < 12; i++) {
await tester.pump(const Duration(milliseconds: 60));
}
}
void main() {
setUp(() async {
SharedPreferences.setMockInitialValues(<String, Object>{
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);
});
}