feat: писюльки прыгают
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 => 'Акцентный цвет';
|
||||||
|
|
||||||
|
|||||||
@@ -276,6 +276,8 @@
|
|||||||
"appearanceNavPillFrost": "G-FrostBlur",
|
"appearanceNavPillFrost": "G-FrostBlur",
|
||||||
"appearanceGradientTitle": "Градиент",
|
"appearanceGradientTitle": "Градиент",
|
||||||
"appearanceGradientSubtitle": "Объём и блики в Glossy-капсулах",
|
"appearanceGradientSubtitle": "Объём и блики в Glossy-капсулах",
|
||||||
|
"appearanceSpectrumTitle": "Спектр на фоне",
|
||||||
|
"appearanceSpectrumSubtitle": "Экспериментально — живые полосы под интерфейсом",
|
||||||
"appearanceAccentColorTitle": "Акцентный цвет",
|
"appearanceAccentColorTitle": "Акцентный цвет",
|
||||||
"appearanceAccentColorSystem": "Системный",
|
"appearanceAccentColorSystem": "Системный",
|
||||||
"appearanceAccentColorSubtitle": "Основной цвет интерфейса и пузырей",
|
"appearanceAccentColorSubtitle": "Основной цвет интерфейса и пузырей",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user