diff --git a/assets/wallpapers/patterns/bubbles.svg b/assets/wallpapers/patterns/bubbles.svg new file mode 100644 index 0000000..590fc10 --- /dev/null +++ b/assets/wallpapers/patterns/bubbles.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/wallpapers/patterns/hearts.svg b/assets/wallpapers/patterns/hearts.svg new file mode 100644 index 0000000..ac5f0d8 --- /dev/null +++ b/assets/wallpapers/patterns/hearts.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/wallpapers/patterns/planes.svg b/assets/wallpapers/patterns/planes.svg new file mode 100644 index 0000000..525b9dd --- /dev/null +++ b/assets/wallpapers/patterns/planes.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/wallpapers/patterns/plus.svg b/assets/wallpapers/patterns/plus.svg new file mode 100644 index 0000000..fb558b6 --- /dev/null +++ b/assets/wallpapers/patterns/plus.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/wallpapers/patterns/rings.svg b/assets/wallpapers/patterns/rings.svg new file mode 100644 index 0000000..cbd62a4 --- /dev/null +++ b/assets/wallpapers/patterns/rings.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/wallpapers/patterns/stars.svg b/assets/wallpapers/patterns/stars.svg new file mode 100644 index 0000000..88c305a --- /dev/null +++ b/assets/wallpapers/patterns/stars.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/lib/core/config/app_chat_chrome.dart b/lib/core/config/app_chat_chrome.dart index 661ee95..85f8b53 100644 --- a/lib/core/config/app_chat_chrome.dart +++ b/lib/core/config/app_chat_chrome.dart @@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart'; import 'persisted_setting.dart'; -enum ChatChromeStyle { color, blur, none } +enum ChatChromeStyle { color, blur, none, transparent } class AppChatChrome { static const prefKey = 'app_chat_chrome'; diff --git a/lib/core/config/app_wallpaper_tint.dart b/lib/core/config/app_wallpaper_tint.dart new file mode 100644 index 0000000..23c5ed5 --- /dev/null +++ b/lib/core/config/app_wallpaper_tint.dart @@ -0,0 +1,22 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppWallpaperTint { + static const prefKey = 'app_wallpaper_tint'; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: false, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); +} diff --git a/lib/core/config/chat_wallpaper_themes.dart b/lib/core/config/chat_wallpaper_themes.dart index 3a4dcca..ea637f7 100644 --- a/lib/core/config/chat_wallpaper_themes.dart +++ b/lib/core/config/chat_wallpaper_themes.dart @@ -1,31 +1,164 @@ import 'package:flutter/material.dart'; +import '../utils/tiled_svg.dart'; + @immutable class ChatWallpaperTheme { final String id; final String name; - final Gradient gradient; - final Color bubbleTint; + final List colors; + final AlignmentGeometry begin; + final AlignmentGeometry end; + final String? pattern; + final Color patternColor; + final double patternOpacity; + final double tileSize; + final bool dark; const ChatWallpaperTheme({ required this.id, required this.name, - required this.gradient, - this.bubbleTint = Colors.transparent, + required this.colors, + this.begin = Alignment.topLeft, + this.end = Alignment.bottomRight, + this.pattern, + this.patternColor = Colors.white, + this.patternOpacity = 0.1, + this.tileSize = 130, + this.dark = true, }); - Widget buildBackground() => DecoratedBox( - decoration: BoxDecoration(gradient: gradient), - child: const SizedBox.expand(), - ); + Gradient get gradient => + LinearGradient(colors: colors, begin: begin, end: end); - Widget buildPreview() => DecoratedBox( - decoration: BoxDecoration(gradient: gradient), - child: const SizedBox.expand(), - ); + Color get bubbleTint => colors.first; + + Widget buildBackground() => _ChatWallpaperThemeView(theme: this); + + Widget buildPreview() => + _ChatWallpaperThemeView(theme: this, tileScale: 0.42); } -const List kChatWallpaperThemes = []; +class _ChatWallpaperThemeView extends StatelessWidget { + final ChatWallpaperTheme theme; + final double tileScale; + + const _ChatWallpaperThemeView({required this.theme, this.tileScale = 1}); + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + DecoratedBox(decoration: BoxDecoration(gradient: theme.gradient)), + if (theme.pattern != null) + TiledSvgPattern( + asset: theme.pattern!, + color: theme.patternColor, + opacity: theme.patternOpacity, + tileSize: theme.tileSize * tileScale, + ), + ], + ); + } +} + +const String _kPatternDir = 'assets/wallpapers/patterns'; + +const List kChatWallpaperThemes = [ + ChatWallpaperTheme( + id: 'ocean', + name: 'Океан', + colors: [Color(0xFF2A7B9B), Color(0xFF57C1EB), Color(0xFF246FA8)], + pattern: '$_kPatternDir/bubbles.svg', + patternOpacity: 0.1, + ), + ChatWallpaperTheme( + id: 'sunset', + name: 'Закат', + colors: [Color(0xFFFF7E5F), Color(0xFFFEB47B)], + pattern: '$_kPatternDir/hearts.svg', + patternOpacity: 0.12, + ), + ChatWallpaperTheme( + id: 'lavender', + name: 'Лаванда', + colors: [Color(0xFF9D50BB), Color(0xFF6E48AA)], + pattern: '$_kPatternDir/stars.svg', + patternOpacity: 0.11, + ), + ChatWallpaperTheme( + id: 'mint', + name: 'Мята', + colors: [Color(0xFF43E97B), Color(0xFF38F9D7)], + pattern: '$_kPatternDir/plus.svg', + patternColor: Colors.black, + patternOpacity: 0.06, + tileSize: 66, + dark: false, + ), + ChatWallpaperTheme( + id: 'graphite', + name: 'Графит', + colors: [Color(0xFF232526), Color(0xFF414345)], + pattern: '$_kPatternDir/plus.svg', + patternOpacity: 0.06, + tileSize: 66, + ), + ChatWallpaperTheme( + id: 'sky', + name: 'Небо', + colors: [Color(0xFF2193B0), Color(0xFF6DD5ED)], + pattern: '$_kPatternDir/planes.svg', + patternOpacity: 0.11, + ), + ChatWallpaperTheme( + id: 'peach', + name: 'Персик', + colors: [Color(0xFFFFD3A5), Color(0xFFFD6585)], + pattern: '$_kPatternDir/rings.svg', + patternColor: Colors.black, + patternOpacity: 0.05, + dark: false, + ), + ChatWallpaperTheme( + id: 'forest', + name: 'Лес', + colors: [Color(0xFF134E5E), Color(0xFF71B280)], + pattern: '$_kPatternDir/rings.svg', + patternOpacity: 0.09, + ), + ChatWallpaperTheme( + id: 'grape', + name: 'Виноград', + colors: [Color(0xFF4776E6), Color(0xFF8E54E9)], + pattern: '$_kPatternDir/stars.svg', + patternOpacity: 0.11, + ), + ChatWallpaperTheme( + id: 'night', + name: 'Ночь', + colors: [Color(0xFF0F2027), Color(0xFF203A43), Color(0xFF2C5364)], + pattern: '$_kPatternDir/stars.svg', + patternOpacity: 0.08, + ), + ChatWallpaperTheme( + id: 'rose', + name: 'Роза', + colors: [Color(0xFFF4C4F3), Color(0xFFFC67FA)], + pattern: '$_kPatternDir/hearts.svg', + patternOpacity: 0.14, + ), + ChatWallpaperTheme( + id: 'amber', + name: 'Янтарь', + colors: [Color(0xFFF7971E), Color(0xFFFFD200)], + pattern: '$_kPatternDir/bubbles.svg', + patternColor: Colors.black, + patternOpacity: 0.05, + dark: false, + ), +]; ChatWallpaperTheme? chatWallpaperThemeById(String? id) { if (id == null) return null; diff --git a/lib/core/storage/chat_wallpaper_store.dart b/lib/core/storage/chat_wallpaper_store.dart index d3aaabc..ed6730e 100644 --- a/lib/core/storage/chat_wallpaper_store.dart +++ b/lib/core/storage/chat_wallpaper_store.dart @@ -7,6 +7,8 @@ import 'package:path_provider/path_provider.dart'; import '../utils/logger.dart'; import 'per_chat_json_store.dart'; +const int kGlobalWallpaperChatId = 0; + enum ChatWallpaperKind { image, theme } @immutable diff --git a/lib/core/utils/tiled_svg.dart b/lib/core/utils/tiled_svg.dart new file mode 100644 index 0000000..674e02e --- /dev/null +++ b/lib/core/utils/tiled_svg.dart @@ -0,0 +1,134 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class TiledSvgPattern extends StatefulWidget { + final String asset; + final Color color; + final double opacity; + final double tileSize; + + const TiledSvgPattern({ + super.key, + required this.asset, + required this.color, + this.opacity = 0.12, + this.tileSize = 120, + }); + + @override + State createState() => _TiledSvgPatternState(); +} + +class _TiledSvgPatternState extends State { + static final Map _cache = {}; + static final Map> _pending = {}; + + ui.Image? _image; + double _dpr = 1; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final dpr = MediaQuery.maybeOf(context)?.devicePixelRatio ?? 1; + if (dpr != _dpr || _image == null) { + _dpr = dpr; + _resolve(); + } + } + + @override + void didUpdateWidget(TiledSvgPattern old) { + super.didUpdateWidget(old); + if (old.asset != widget.asset || old.tileSize != widget.tileSize) { + _resolve(); + } + } + + Future _resolve() async { + final px = (widget.tileSize * _dpr).clamp(1, 4096).round(); + final key = '${widget.asset}@$px'; + final cached = _cache[key]; + if (cached != null) { + if (_image != cached) setState(() => _image = cached); + return; + } + final future = _pending.putIfAbsent(key, () => _rasterize(widget.asset, px)); + try { + final image = await future; + _cache[key] = image; + _pending.remove(key); + if (mounted) setState(() => _image = image); + } catch (_) { + _pending.remove(key); + } + } + + static Future _rasterize(String asset, int px) async { + final info = await vg.loadPicture(SvgAssetLoader(asset), null); + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + final size = info.size; + if (size.width > 0 && size.height > 0) { + canvas.scale(px / size.width, px / size.height); + } + canvas.drawPicture(info.picture); + final picture = recorder.endRecording(); + final image = await picture.toImage(px, px); + info.picture.dispose(); + picture.dispose(); + return image; + } + + @override + Widget build(BuildContext context) { + final image = _image; + if (image == null) return const SizedBox.expand(); + return CustomPaint( + size: Size.infinite, + painter: _PatternPainter( + image: image, + color: widget.color.withValues(alpha: widget.opacity), + tileSize: widget.tileSize, + dpr: _dpr, + ), + ); + } +} + +class _PatternPainter extends CustomPainter { + final ui.Image image; + final Color color; + final double tileSize; + final double dpr; + + const _PatternPainter({ + required this.image, + required this.color, + required this.tileSize, + required this.dpr, + }); + + @override + void paint(Canvas canvas, Size size) { + final s = 1 / dpr; + final matrix = Matrix4.identity()..scaleByDouble(s, s, 1, 1); + final paint = Paint() + ..shader = ImageShader( + image, + TileMode.repeated, + TileMode.repeated, + matrix.storage, + ) + ..colorFilter = ColorFilter.mode(color, BlendMode.srcIn); + canvas.drawRect(Offset.zero & size, paint); + } + + @override + bool shouldRepaint(_PatternPainter old) => + old.image != image || + old.color != color || + old.tileSize != tileSize || + old.dpr != dpr; +} diff --git a/lib/core/utils/wallpaper_seed.dart b/lib/core/utils/wallpaper_seed.dart new file mode 100644 index 0000000..b55c8b5 --- /dev/null +++ b/lib/core/utils/wallpaper_seed.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:image/image.dart' as img; + +import '../config/chat_wallpaper_themes.dart'; +import '../storage/chat_wallpaper_store.dart'; + +Future computeWallpaperSeed(ChatWallpaper? wallpaper) async { + if (wallpaper == null) return null; + if (!wallpaper.isImage) { + final theme = chatWallpaperThemeById(wallpaper.themeId); + if (theme == null) return null; + return _mostVivid(theme.colors); + } + final path = wallpaper.imagePath; + if (path == null) return null; + try { + final bytes = await File(path).readAsBytes(); + final decoded = img.decodeImage(bytes); + if (decoded == null) return null; + final small = img.copyResize(decoded, width: 8, height: 8); + var r = 0, g = 0, b = 0, n = 0; + for (final pixel in small) { + r += pixel.r.toInt(); + g += pixel.g.toInt(); + b += pixel.b.toInt(); + n++; + } + if (n == 0) return null; + return Color.fromARGB(255, r ~/ n, g ~/ n, b ~/ n); + } catch (_) { + return null; + } +} + +Color _mostVivid(List colors) { + var best = colors.first; + var bestScore = -1.0; + for (final color in colors) { + final hsl = HSLColor.fromColor(color); + final score = hsl.saturation * (1 - (hsl.lightness - 0.5).abs()); + if (score > bestScore) { + bestScore = score; + best = color; + } + } + return best; +} diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index e65168b..278d178 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -445,6 +445,15 @@ class _ChatScreenState extends State CachedChat? chat; bool _peerIsBot = false; ChatWallpaper? _wallpaper; + + ChatChromeStyle get _effectiveChrome { + final chrome = AppChatChrome.current.value; + if (_wallpaper != null && chrome == ChatChromeStyle.none) { + return ChatChromeStyle.blur; + } + return chrome; + } + final ValueNotifier _composerHeight = ValueNotifier(96); final ValueNotifier _pinnedBannerHeight = ValueNotifier(0); @@ -1341,6 +1350,10 @@ class _ChatScreenState extends State .listenable(widget.chatId) .removeListener(_recomputeHeaderStatus); PresenceFetch.revision.removeListener(_onPresenceChanged); + if (_wallpaperListening) { + ChatWallpaperStore.instance.revision + .removeListener(_applyEffectiveWallpaper); + } _headerStatusNotifier.dispose(); _otherReadTime.dispose(); _chatController.dispose(); @@ -1893,7 +1906,7 @@ class _ChatScreenState extends State ], ); Widget wrapChrome(Widget child) { - if (AppChatChrome.current.value != ChatChromeStyle.blur) return child; + if (_effectiveChrome != ChatChromeStyle.blur) return child; return _FrostedPanel( tint: cs.surfaceContainerHigh.withValues(alpha: 0.55), border: Border( @@ -2255,7 +2268,7 @@ class _ChatScreenState extends State final height = glossy ? ui.lerpDouble(_glossyHeaderHeight, _glossySearchHeight, searchT)! : kToolbarHeight; - final chrome = AppChatChrome.current.value; + final chrome = _effectiveChrome; return AppBar( backgroundColor: chrome == ChatChromeStyle.color ? (glossy ? Colors.transparent : cs.surfaceContainerHigh) @@ -2289,6 +2302,24 @@ class _ChatScreenState extends State child: const SizedBox.expand(), ), ) + : (chrome == ChatChromeStyle.transparent && !glossy) + ? IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + cs.surface.withValues(alpha: 0.72), + cs.surface.withValues(alpha: 0.45), + cs.surface.withValues(alpha: 0.0), + ], + stops: const [0.0, 0.62, 1.0], + ), + ), + child: const SizedBox.expand(), + ), + ) : null, foregroundColor: cs.onSurface, surfaceTintColor: Colors.transparent, @@ -2474,11 +2505,24 @@ class _ChatScreenState extends State ); } + bool _wallpaperListening = false; + Future _loadWallpaper() async { await ChatWallpaperStore.instance.load(); if (!mounted) return; - final wp = ChatWallpaperStore.instance.get(_myId, widget.chatId); - if (wp != _wallpaper) setState(() => _wallpaper = wp); + if (!_wallpaperListening) { + _wallpaperListening = true; + ChatWallpaperStore.instance.revision.addListener(_applyEffectiveWallpaper); + } + _applyEffectiveWallpaper(); + } + + void _applyEffectiveWallpaper() { + if (!mounted) return; + final store = ChatWallpaperStore.instance; + final wp = store.get(_myId, widget.chatId) ?? + store.get(_myId, kGlobalWallpaperChatId); + if (!identical(wp, _wallpaper)) setState(() => _wallpaper = wp); } Future _openWallpaperSheet() async { @@ -2489,13 +2533,13 @@ class _ChatScreenState extends State switch (pick.type) { case WallpaperPickType.none: await store.clear(_myId, widget.chatId); - if (mounted) setState(() => _wallpaper = null); + _applyEffectiveWallpaper(); break; case WallpaperPickType.theme: final theme = pick.theme; if (theme == null) break; - final wp = await store.setTheme(_myId, widget.chatId, theme.id); - if (mounted) setState(() => _wallpaper = wp); + await store.setTheme(_myId, widget.chatId, theme.id); + _applyEffectiveWallpaper(); break; case WallpaperPickType.gallery: await _pickWallpaperFromGallery(); @@ -2532,7 +2576,7 @@ class _ChatScreenState extends State showCustomNotification(context, 'Не удалось сохранить обои'); return; } - setState(() => _wallpaper = wp); + _applyEffectiveWallpaper(); } Future _clearHistory() async { @@ -3896,7 +3940,7 @@ class _ChatScreenState extends State ? _prank.pinkTheme(Theme.of(context)) : Theme.of(context); final cs = theme.colorScheme; - final underlap = AppChatChrome.current.value != ChatChromeStyle.color; + final underlap = _effectiveChrome != ChatChromeStyle.color; // TODO: Локализация // TODO: Cклонения @@ -4018,7 +4062,7 @@ class _ChatScreenState extends State Widget _buildUnderlapBody() { final cs = Theme.of(context).colorScheme; - final vignette = AppChatChrome.current.value == ChatChromeStyle.none; + final vignette = _effectiveChrome == ChatChromeStyle.none; final bannerTop = _pinnedBannerTop(); final banner = _buildPinnedBanner(floating: true); if (banner == null) _resetPinnedBannerHeight(); diff --git a/lib/frontend/screens/profile/appearance_screen.dart b/lib/frontend/screens/profile/appearance_screen.dart index 43f8a6b..3c19088 100644 --- a/lib/frontend/screens/profile/appearance_screen.dart +++ b/lib/frontend/screens/profile/appearance_screen.dart @@ -232,6 +232,10 @@ class _ChatChromeCard extends StatelessWidget { value: ChatChromeStyle.none, label: Text(l10n.appearanceChatChromeNone), ), + ButtonSegment( + value: ChatChromeStyle.transparent, + label: Text(l10n.appearanceChatChromeTransparent), + ), ], selected: {current}, onSelectionChanged: (set) { diff --git a/lib/frontend/screens/profile/chat_background_screen.dart b/lib/frontend/screens/profile/chat_background_screen.dart new file mode 100644 index 0000000..0a10d4a --- /dev/null +++ b/lib/frontend/screens/profile/chat_background_screen.dart @@ -0,0 +1,299 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; + +import '../../../core/config/app_wallpaper_tint.dart'; +import '../../../core/storage/app_database.dart'; +import '../../../core/storage/chat_wallpaper_store.dart'; +import '../../widgets/chat_wallpaper_sheet.dart'; +import '../../widgets/chat_wallpaper_view.dart'; +import '../../widgets/custom_notification.dart'; +import '../chats/chat_wallpaper_preview_screen.dart'; + +class ChatBackgroundScreen extends StatefulWidget { + const ChatBackgroundScreen({super.key}); + + @override + State createState() => _ChatBackgroundScreenState(); +} + +class _ChatBackgroundScreenState extends State { + int _accountId = 0; + ChatWallpaper? _wallpaper; + bool _ready = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + await ChatWallpaperStore.instance.load(); + final profile = await AppDatabase.loadActiveProfile(); + if (!mounted) return; + setState(() { + _accountId = profile?.id ?? 0; + _wallpaper = ChatWallpaperStore.instance + .get(_accountId, kGlobalWallpaperChatId); + _ready = true; + }); + } + + void _refresh() { + if (!mounted) return; + setState(() { + _wallpaper = ChatWallpaperStore.instance + .get(_accountId, kGlobalWallpaperChatId); + }); + } + + Future _openPicker() async { + if (_accountId == 0) return; + final pick = await showChatWallpaperSheet(context, current: _wallpaper); + if (pick == null || !mounted) return; + final store = ChatWallpaperStore.instance; + switch (pick.type) { + case WallpaperPickType.none: + await store.clear(_accountId, kGlobalWallpaperChatId); + _refresh(); + break; + case WallpaperPickType.theme: + final theme = pick.theme; + if (theme == null) break; + await store.setTheme(_accountId, kGlobalWallpaperChatId, theme.id); + _refresh(); + break; + case WallpaperPickType.gallery: + await _pickFromGallery(); + break; + } + } + + Future _pickFromGallery() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + withData: true, + ); + if (result == null || result.files.isEmpty) return; + final bytes = result.files.first.bytes; + if (bytes == null) { + if (mounted) showCustomNotification(context, 'Не удалось прочитать файл'); + return; + } + if (!mounted) return; + final settings = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChatWallpaperPreviewScreen(imageBytes: bytes), + ), + ); + if (settings == null || !mounted) return; + final wp = await ChatWallpaperStore.instance.setImage( + _accountId, + kGlobalWallpaperChatId, + bytes, + settings: settings, + ); + if (!mounted) return; + if (wp == null) { + showCustomNotification(context, 'Не удалось сохранить обои'); + return; + } + _refresh(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + surfaceTintColor: Colors.transparent, + title: const Text( + 'Фон чатов', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ), + body: SafeArea( + top: false, + child: Column( + children: [ + Expanded(child: _preview(cs)), + _panel(cs), + ], + ), + ), + ); + } + + Widget _preview(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: ClipRRect( + borderRadius: BorderRadius.circular(28), + child: Stack( + fit: StackFit.expand, + children: [ + if (_wallpaper != null) + ChatWallpaperView(wallpaper: _wallpaper!) + else + ColoredBox(color: cs.surfaceContainerHighest), + _SampleBubbles(cs: cs), + ], + ), + ), + ); + } + + Widget _panel(ColorScheme cs) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Эти обои применяются ко всем чатам, где не выбран свой фон.', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + height: 1.35, + ), + ), + const SizedBox(height: 12), + ValueListenableBuilder( + valueListenable: AppWallpaperTint.current, + builder: (context, enabled, _) => Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Подстраивать интерфейс под обои', + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + 'Акцентный цвет приложения возьмётся из фона', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12.5, + height: 1.3, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Switch( + value: enabled, + onChanged: (v) => AppWallpaperTint.save(v), + ), + ], + ), + ), + const SizedBox(height: 12), + GestureDetector( + onTap: _ready ? _openPicker : null, + child: Container( + height: 52, + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(16), + ), + child: Center( + child: Text( + 'Выбрать обои', + style: TextStyle( + color: cs.onPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _SampleBubbles extends StatelessWidget { + final ColorScheme cs; + + const _SampleBubbles({required this.cs}); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _bubble( + text: 'Единый фон для всех чатов', + color: cs.surfaceContainerHighest.withValues(alpha: 0.94), + textColor: cs.onSurface, + alignment: Alignment.centerLeft, + ), + const SizedBox(height: 8), + _bubble( + text: 'Красиво ✨', + color: cs.primary, + textColor: cs.onPrimary, + alignment: Alignment.centerRight, + ), + ], + ), + ), + ); + } + + Widget _bubble({ + required String text, + required Color color, + required Color textColor, + required Alignment alignment, + }) { + return Align( + alignment: alignment, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 260), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(18), + ), + child: Text( + text, + style: TextStyle( + color: textColor, + fontSize: 15, + fontFamily: 'Outfit', + ), + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/customization_screen.dart b/lib/frontend/screens/profile/customization_screen.dart index 76e2f66..5a794d5 100644 --- a/lib/frontend/screens/profile/customization_screen.dart +++ b/lib/frontend/screens/profile/customization_screen.dart @@ -7,6 +7,7 @@ import '../../../core/utils/haptics.dart'; import '../../widgets/glossy_pill.dart'; import 'app_icon_screen.dart'; import 'appearance_screen.dart'; +import 'chat_background_screen.dart'; import 'font_settings_screen.dart'; import 'message_actions_screen.dart'; import 'theme_settings_screen.dart'; @@ -41,6 +42,12 @@ class CustomizationScreen extends StatelessWidget { subtitle: 'Акцентный цвет интерфейса', builder: _buildAppearance, ), + _CustomizationCategory( + icon: Symbols.wallpaper, + title: 'Фон чатов', + subtitle: 'Общие обои и темы для всех чатов', + builder: _buildChatBackground, + ), _CustomizationCategory( icon: Symbols.text_fields, title: 'Шрифты', @@ -64,6 +71,9 @@ class CustomizationScreen extends StatelessWidget { static Widget _buildAppearance(BuildContext context) => const AppearanceScreen(); + static Widget _buildChatBackground(BuildContext context) => + const ChatBackgroundScreen(); + static Widget _buildFontSettings(BuildContext context) => const FontSettingsScreen(); diff --git a/lib/frontend/widgets/chat_wallpaper_sheet.dart b/lib/frontend/widgets/chat_wallpaper_sheet.dart index 1131ed0..aa69820 100644 --- a/lib/frontend/widgets/chat_wallpaper_sheet.dart +++ b/lib/frontend/widgets/chat_wallpaper_sheet.dart @@ -3,7 +3,6 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/core/config/chat_wallpaper_themes.dart'; import 'package:komet/core/storage/chat_wallpaper_store.dart'; -import 'package:komet/frontend/widgets/sheet_helpers.dart'; enum WallpaperPickType { none, theme, gallery } @@ -24,105 +23,241 @@ Future showChatWallpaperSheet( BuildContext context, { required ChatWallpaper? current, }) { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - barrierColor: Colors.black.withValues(alpha: 0.45), - builder: (_) => _ChatWallpaperSheet(current: current), + return Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => ChatWallpaperGalleryScreen(current: current), + ), ); } -class _ChatWallpaperSheet extends StatelessWidget { +class ChatWallpaperGalleryScreen extends StatefulWidget { final ChatWallpaper? current; - const _ChatWallpaperSheet({required this.current}); + const ChatWallpaperGalleryScreen({super.key, required this.current}); - bool get _isNoneSelected => current == null; + @override + State createState() => + _ChatWallpaperGalleryScreenState(); +} + +class _ChatWallpaperGalleryScreenState + extends State { + ChatWallpaperTheme? _selected; + bool _isImage = false; + + @override + void initState() { + super.initState(); + final current = widget.current; + _isImage = current?.isImage ?? false; + _selected = current == null || current.isImage + ? null + : chatWallpaperThemeById(current.themeId); + } + + bool get _changed { + if (_isImage) return _selected != null; + return _selected?.id != chatWallpaperThemeById(widget.current?.themeId)?.id; + } + + void _apply() { + if (_selected == null) { + Navigator.pop(context, const WallpaperPick.none()); + } else { + Navigator.pop(context, WallpaperPick.theme(_selected)); + } + } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return SafeArea( - top: false, - child: Container( - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Symbols.arrow_back), + onPressed: () => Navigator.pop(context), ), - child: Column( - mainAxisSize: MainAxisSize.min, + title: const Text( + 'Обои', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ), + body: Column( + children: [ + Expanded(child: _preview(cs)), + _panel(cs), + ], + ), + ); + } + + Widget _preview(ColorScheme cs) { + final theme = _selected; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: ClipRRect( + borderRadius: BorderRadius.circular(28), + child: Stack( + fit: StackFit.expand, children: [ - const SheetGrabber(), - _header(context, cs), - const SizedBox(height: 12), - _themeRow(context, cs), - const SizedBox(height: 20), - _galleryButton(context, cs), - const SizedBox(height: 12), + if (theme != null) + theme.buildBackground() + else + ColoredBox(color: cs.surfaceContainerHighest), + const IgnorePointer(child: _PreviewScrim()), + _SampleBubbles(theme: theme), ], ), ), ); } - Widget _header(BuildContext context, ColorScheme cs) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - children: [ - IconButton( - icon: Icon(Symbols.close, color: cs.onSurface), - onPressed: () => Navigator.pop(context), + Widget _panel(ColorScheme cs) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 14), + SizedBox( + height: 150, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + _NoneTile( + selected: _selected == null && !_isImage, + onTap: () => setState(() { + _selected = null; + _isImage = false; + }), + ), + for (final theme in kChatWallpaperThemes) + _ThemeTile( + theme: theme, + selected: _selected?.id == theme.id, + onTap: () => setState(() { + _selected = theme; + _isImage = false; + }), + ), + ], + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Row( + children: [ + Expanded( + child: _GalleryButton( + onTap: () => + Navigator.pop(context, const WallpaperPick.gallery()), + ), + ), + const SizedBox(width: 12), + Expanded(child: _ApplyButton(enabled: _changed, onTap: _apply)), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _PreviewScrim extends StatelessWidget { + const _PreviewScrim(); + + @override + Widget build(BuildContext context) { + return const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x14000000), Color(0x00000000), Color(0x1F000000)], + stops: [0.0, 0.5, 1.0], + ), + ), + child: SizedBox.expand(), + ); + } +} + +class _SampleBubbles extends StatelessWidget { + final ChatWallpaperTheme? theme; + + const _SampleBubbles({required this.theme}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _bubble( + text: 'Как насчёт новых обоев для этого чата?', + color: cs.surfaceContainerHighest.withValues(alpha: 0.94), + textColor: cs.onSurface, + alignment: Alignment.centerLeft, + ), + const SizedBox(height: 8), + _bubble( + text: 'Выглядит отлично 🔥', + color: cs.primary, + textColor: cs.onPrimary, + alignment: Alignment.centerRight, + ), + ], + ), + ), + ); + } + + Widget _bubble({ + required String text, + required Color color, + required Color textColor, + required Alignment alignment, + }) { + return Align( + alignment: alignment, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 260), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(18), ), - const SizedBox(width: 4), - Text( - 'Выбрать тему', + child: Text( + text, style: TextStyle( - color: cs.onSurface, - fontSize: 22, - fontWeight: FontWeight.w700, + color: textColor, + fontSize: 15, fontFamily: 'Outfit', ), ), - ], - ), - ); - } - - Widget _themeRow(BuildContext context, ColorScheme cs) { - return SizedBox( - height: 172, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - children: [ - _NoneTile( - selected: _isNoneSelected, - onTap: () => Navigator.pop(context, const WallpaperPick.none()), - ), - for (final theme in kChatWallpaperThemes) - _ThemeTile( - theme: theme, - selected: current?.themeId == theme.id, - onTap: () => - Navigator.pop(context, WallpaperPick.theme(theme)), - ), - ], - ), - ); - } - - Widget _galleryButton(BuildContext context, ColorScheme cs) { - return TextButton( - onPressed: () => Navigator.pop(context, const WallpaperPick.gallery()), - child: Text( - 'Выбрать обои из галереи', - style: TextStyle( - color: cs.primary, - fontSize: 17, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', ), ), ); @@ -133,11 +268,13 @@ class _TileFrame extends StatelessWidget { final bool selected; final VoidCallback onTap; final Widget child; + final String label; const _TileFrame({ required this.selected, required this.onTap, required this.child, + required this.label, }); @override @@ -147,20 +284,62 @@ class _TileFrame extends StatelessWidget { padding: const EdgeInsets.only(right: 12), child: GestureDetector( onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 160), - width: 112, - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: selected ? cs.primary : Colors.transparent, - width: 2.5, - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(15), - child: child, + child: SizedBox( + width: 96, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 160), + height: 116, + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: selected ? cs.primary : Colors.transparent, + width: 2.5, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(15), + child: Stack( + fit: StackFit.expand, + children: [ + child, + if (selected) + Align( + alignment: Alignment.bottomRight, + child: Container( + margin: const EdgeInsets.all(6), + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + ), + child: Icon( + Symbols.check, + size: 16, + color: cs.onPrimary, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 6), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: selected ? cs.primary : cs.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ], ), ), ), @@ -180,25 +359,11 @@ class _NoneTile extends StatelessWidget { return _TileFrame( selected: selected, onTap: onTap, + label: 'Без обоев', child: ColoredBox( color: cs.surfaceContainerHighest, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Без\nтемы', - textAlign: TextAlign.center, - style: TextStyle( - color: cs.onSurface, - fontSize: 18, - height: 1.1, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - const SizedBox(height: 14), - const Icon(Symbols.close, color: Color(0xFFFF3B30), size: 40), - ], + child: const Center( + child: Icon(Symbols.block, color: Color(0xFFFF3B30), size: 34), ), ), ); @@ -221,7 +386,82 @@ class _ThemeTile extends StatelessWidget { return _TileFrame( selected: selected, onTap: onTap, + label: theme.name, child: theme.buildPreview(), ); } } + +class _GalleryButton extends StatelessWidget { + final VoidCallback onTap; + + const _GalleryButton({required this.onTap}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return GestureDetector( + onTap: onTap, + child: Container( + height: 52, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Symbols.image, color: cs.onSurface, size: 22), + const SizedBox(width: 8), + Text( + 'Из галереи', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ], + ), + ), + ); + } +} + +class _ApplyButton extends StatelessWidget { + final bool enabled; + final VoidCallback onTap; + + const _ApplyButton({required this.enabled, required this.onTap}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return GestureDetector( + onTap: enabled ? onTap : null, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 140), + opacity: enabled ? 1 : 0.4, + child: Container( + height: 52, + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(16), + ), + child: Center( + child: Text( + 'Применить', + style: TextStyle( + color: cs.onPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/sticker_panel.dart b/lib/frontend/widgets/sticker_panel.dart index 4671727..27130ff 100644 --- a/lib/frontend/widgets/sticker_panel.dart +++ b/lib/frontend/widgets/sticker_panel.dart @@ -1,3 +1,5 @@ +import 'dart:ui' as ui; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -227,10 +229,22 @@ class _StickerPanelState extends State @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return Container( + return SizedBox( height: widget.height, - color: cs.surface, - child: _loading + child: ClipRect( + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: 34, sigmaY: 34), + child: DecoratedBox( + decoration: BoxDecoration( + color: cs.surface.withValues(alpha: 0.38), + border: Border( + top: BorderSide( + color: cs.outlineVariant.withValues(alpha: 0.4), + width: 0.5, + ), + ), + ), + child: _loading ? Center(child: SmallSpinner()) : _error != null || _sections.isEmpty ? Center( @@ -276,6 +290,9 @@ class _StickerPanelState extends State }, ), ), + ), + ), + ), ); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b0b4cb9..cc4c732 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -291,6 +291,7 @@ "appearanceChatChromeColor": "Color", "appearanceChatChromeBlur": "Blur", "appearanceChatChromeNone": "None", + "appearanceChatChromeTransparent": "Clear", "appearanceGradientTitle": "Gradient", "appearanceGradientSubtitle": "Depth and highlights in Glossy capsules", "appearanceAccentColorTitle": "Accent color", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 29b083f..683fd31 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1532,6 +1532,12 @@ abstract class AppLocalizations { /// **'None'** String get appearanceChatChromeNone; + /// No description provided for @appearanceChatChromeTransparent. + /// + /// In en, this message translates to: + /// **'Clear'** + String get appearanceChatChromeTransparent; + /// No description provided for @appearanceGradientTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 928aa85..d575ca9 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -762,6 +762,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appearanceChatChromeNone => 'None'; + @override + String get appearanceChatChromeTransparent => 'Clear'; + @override String get appearanceGradientTitle => 'Gradient'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 110748a..2b72e9a 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -765,6 +765,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get appearanceChatChromeNone => 'Нет'; + @override + String get appearanceChatChromeTransparent => 'Прозр.'; + @override String get appearanceGradientTitle => 'Градиент'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index b703a03..2769a06 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -256,6 +256,7 @@ "appearanceChatChromeColor": "Цвет", "appearanceChatChromeBlur": "Блюр", "appearanceChatChromeNone": "Нет", + "appearanceChatChromeTransparent": "Прозр.", "appearanceGradientTitle": "Градиент", "appearanceGradientSubtitle": "Объём и блики в Glossy-капсулах", "appearanceAccentColorTitle": "Акцентный цвет", diff --git a/lib/main.dart b/lib/main.dart index 6c96024..48f39e0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -38,6 +38,9 @@ import 'core/config/app_media_cache.dart'; import 'core/config/app_pill_gradient.dart'; import 'core/config/app_visual_style.dart'; import 'core/config/app_chat_chrome.dart'; +import 'core/config/app_wallpaper_tint.dart'; +import 'core/storage/chat_wallpaper_store.dart'; +import 'core/utils/wallpaper_seed.dart'; import 'core/config/app_theme_mode.dart'; import 'core/config/app_theme_schedule.dart'; import 'core/config/app_digital_id_mode.dart'; @@ -187,6 +190,7 @@ void main(List args) async { final pillGradientFuture = AppPillGradient.load(); final visualStyleFuture = AppVisualStyle.load(); final chatChromeFuture = AppChatChrome.load(); + final wallpaperTintFuture = AppWallpaperTint.load(); final themeScheduleFuture = AppThemeSchedule.load(); final messageActionsFuture = AppMessageActionsStyle.load(); final swipeBackFuture = AppSwipeBackDesktop.load(); @@ -237,6 +241,7 @@ void main(List args) async { pillGradientFuture, visualStyleFuture, chatChromeFuture, + wallpaperTintFuture, themeScheduleFuture, messageActionsFuture, swipeBackFuture, @@ -310,6 +315,7 @@ class KometAppState extends State late final ValueNotifier accentSeed = ValueNotifier( widget.initialAccentSeed, ); + final ValueNotifier wallpaperSeed = ValueNotifier(null); StreamSubscription? _sessionExpiredSub; StreamSubscription? _loginStatusSub; StreamSubscription? _vpnBypassSub; @@ -345,8 +351,11 @@ class KometAppState extends State AppThemeModeConfig.current.addListener(_onThemeModeChanged); AppAmoled.current.addListener(_onAmoledChanged); AppThemeSchedule.current.addListener(_onScheduleChanged); + AppWallpaperTint.current.addListener(_onWallpaperTintChanged); + ChatWallpaperStore.instance.revision.addListener(_onWallpaperTintChanged); _lastAppliedThemeMode = _effectiveThemeMode; _rescheduleSwitch(); + unawaited(_refreshWallpaperSeed()); api.setReconnectCallback(() async { try { @@ -365,6 +374,7 @@ class KometAppState extends State _loginStatusSub = accountModule.loginStatusStream.listen((status) async { if (status == LoginStatus.success) { DeepLinkService.instance.markReady(); + unawaited(_refreshWallpaperSeed()); CallController.instance.init(api); OutboxService.instance.init(api, messagesModule); SelfCheckService.instance.init(api); @@ -510,6 +520,8 @@ class KometAppState extends State AppThemeModeConfig.current.removeListener(_onThemeModeChanged); AppAmoled.current.removeListener(_onAmoledChanged); AppThemeSchedule.current.removeListener(_onScheduleChanged); + AppWallpaperTint.current.removeListener(_onWallpaperTintChanged); + ChatWallpaperStore.instance.revision.removeListener(_onWallpaperTintChanged); WidgetsBinding.instance.removeObserver(this); _profileUpdateController.close(); fpsOverlayEnabled.dispose(); @@ -517,6 +529,7 @@ class KometAppState extends State tlsInsecureEnabled.dispose(); fontScale.dispose(); accentSeed.dispose(); + wallpaperSeed.dispose(); super.dispose(); } @@ -719,6 +732,27 @@ class KometAppState extends State accentSeed.value = seed; } + void _onWallpaperTintChanged() => unawaited(_refreshWallpaperSeed()); + + Future _refreshWallpaperSeed() async { + if (!AppWallpaperTint.current.value) { + wallpaperSeed.value = null; + return; + } + final profile = await AppDatabase.loadActiveProfile(); + final accountId = profile?.id ?? 0; + if (accountId == 0) { + wallpaperSeed.value = null; + return; + } + await ChatWallpaperStore.instance.load(); + final wallpaper = + ChatWallpaperStore.instance.get(accountId, kGlobalWallpaperChatId); + final seed = await computeWallpaperSeed(wallpaper); + if (!mounted) return; + wallpaperSeed.value = seed; + } + Future applyAppFont(String fontId) async { if (_fontId == fontId) return; final prefs = await SharedPreferences.getInstance(); @@ -850,9 +884,17 @@ class KometAppState extends State Widget build(BuildContext context) { return DynamicColorBuilder( builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { - return ValueListenableBuilder( - valueListenable: accentSeed, - builder: (context, seed, _) { + return ListenableBuilder( + listenable: Listenable.merge([ + accentSeed, + wallpaperSeed, + AppWallpaperTint.current, + ]), + builder: (context, _) { + final seed = AppWallpaperTint.current.value && + wallpaperSeed.value != null + ? wallpaperSeed.value + : accentSeed.value; final ColorScheme lightBase; final ColorScheme darkBase; if (seed != null) { diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 5b03800..60a84cb 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -108,6 +108,8 @@ PODS: - PromisesObjC (2.4.1) - record_macos (1.2.1): - FlutterMacOS + - share_plus (0.0.1): + - FlutterMacOS - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -144,6 +146,7 @@ DEPENDENCIES: - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - photo_manager (from `Flutter/ephemeral/.symlinks/plugins/photo_manager/darwin`) - record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`) + - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) @@ -205,6 +208,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/photo_manager/darwin record_macos: :path: Flutter/ephemeral/.symlinks/plugins/record_macos/macos + share_plus: + :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin sqflite_darwin: @@ -247,6 +252,7 @@ SPEC CHECKSUMS: photo_manager: 25fd77df14f4f0ba5ef99e2c61814dde77e2bceb PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273 record_macos: 5d55909f9650314be6424ffd6b123ac75a08c3c1 + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd diff --git a/pubspec.lock b/pubspec.lock index 733c3db..94c9e4f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -547,6 +547,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -1029,6 +1037,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -1570,6 +1586,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "142a9146f447d15b10bdc00e21d5f4d83e5b32bb5f8f8f5a04c75311344923a3" + url: "https://pub.dev" + source: hosted + version: "1.2.6" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 747af50..97dc056 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -83,6 +83,7 @@ dependencies: media_kit_libs_macos_video: ^1.1.4 sensors_plus: ^7.1.0 smart_auth: ^3.2.0 + flutter_svg: ^2.0.10 dev_dependencies: flutter_test: @@ -131,6 +132,7 @@ flutter: - assets/meteor_icon.png - assets/emoji_keywords.json - assets/lottie/ + - assets/wallpapers/patterns/ fonts: - family: Inter