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