feat: кастомные обои чата (SVG-паттерны, темы, глобальный фон, прозрачные панели)

This commit is contained in:
klockky
2026-07-10 21:18:38 +03:00
parent 8ab74739b5
commit e16c51d2f0
27 changed files with 1266 additions and 138 deletions
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none">
<g fill="#ffffff">
<path d="M18 20c0-4 3-7 7-7h20c4 0 7 3 7 7v8c0 4-3 7-7 7H33l-8 6 1-6h-1c-4 0-7-3-7-7z"/>
<path d="M74 58c0-3.5 3-6.5 6.5-6.5H98c3.5 0 6.5 3 6.5 6.5v7c0 3.5-3 6.5-6.5 6.5H86l-7 5 1-5c-3.5 0-6-3-6-6.5z"/>
<path d="M22 82c0-3.5 3-6.5 6.5-6.5H46c3.5 0 6.5 3 6.5 6.5v7c0 3.5-3 6.5-6.5 6.5H34l-7 5 1-5c-3.5 0-6-3-6-6.5z"/>
<circle cx="92" cy="24" r="4"/>
<circle cx="104" cy="34" r="2.5"/>
<circle cx="10" cy="56" r="2.5"/>
<circle cx="64" cy="104" r="3.5"/>
<circle cx="80" cy="110" r="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 653 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none">
<g fill="#ffffff">
<path d="M30 18c-3-5-11-5-13 1-2 5 2 10 13 18 11-8 15-13 13-18-2-6-10-6-13-1z"/>
<path d="M92 46c-2.4-4-8.8-4-10.4.8-1.6 4 1.6 8 10.4 14.4 8.8-6.4 12-10.4 10.4-14.4-1.6-4.8-8-4.8-10.4-.8z"/>
<path d="M34 78c-2.4-4-8.8-4-10.4.8-1.6 4 1.6 8 10.4 14.4 8.8-6.4 12-10.4 10.4-14.4-1.6-4.8-8-4.8-10.4-.8z"/>
<circle cx="70" cy="96" r="3"/>
<circle cx="102" cy="94" r="2.5"/>
<circle cx="58" cy="52" r="2.5"/>
<circle cx="10" cy="44" r="2.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 599 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none">
<g fill="#ffffff">
<path d="M14 34 46 20 34 52l-6-9-8 6z"/>
<path d="M78 78 106 66 96 92l-5-7-6 5z"/>
<path d="M30 92 52 82 44 102l-4-6-5 4z"/>
<circle cx="88" cy="30" r="3"/>
<circle cx="104" cy="42" r="2"/>
<circle cx="60" cy="58" r="2.5"/>
<circle cx="12" cy="70" r="2.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 421 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 60 60" fill="none">
<g fill="#ffffff">
<path d="M28 12h4v7h7v4h-7v7h-4v-7h-7v-4h7z"/>
<circle cx="0" cy="0" r="2"/>
<circle cx="60" cy="0" r="2"/>
<circle cx="0" cy="60" r="2"/>
<circle cx="60" cy="60" r="2"/>
<circle cx="30" cy="0" r="2"/>
<circle cx="0" cy="30" r="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 392 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none">
<g fill="none" stroke="#ffffff" stroke-width="3">
<circle cx="30" cy="30" r="16"/>
<circle cx="90" cy="66" r="12"/>
<circle cx="24" cy="88" r="10"/>
<circle cx="96" cy="16" r="7"/>
<circle cx="60" cy="60" r="4"/>
</g>
<g fill="#ffffff">
<circle cx="66" cy="102" r="3"/>
<circle cx="6" cy="54" r="2.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 451 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none">
<g fill="#ffffff">
<path d="M28 14c1.5 8 3 9.5 11 11-8 1.5-9.5 3-11 11-1.5-8-3-9.5-11-11 8-1.5 9.5-3 11-11z"/>
<path d="M90 50c1.2 6.4 2.4 7.6 8.8 8.8-6.4 1.2-7.6 2.4-8.8 8.8-1.2-6.4-2.4-7.6-8.8-8.8 6.4-1.2 7.6-2.4 8.8-8.8z"/>
<path d="M40 82c1 5.6 2 6.6 7.6 7.6-5.6 1-6.6 2-7.6 7.6-1-5.6-2-6.6-7.6-7.6 5.6-1 6.6-2 7.6-7.6z"/>
<circle cx="70" cy="22" r="2.5"/>
<circle cx="104" cy="30" r="2"/>
<circle cx="12" cy="60" r="2"/>
<circle cx="66" cy="104" r="2.5"/>
<circle cx="100" cy="98" r="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 642 B

+1 -1
View File
@@ -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';
+22
View File
@@ -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<bool>(
prefKey: prefKey,
defaultValue: false,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
},
);
static ValueNotifier<bool> get current => _setting.current;
static Future<bool> load() => _setting.load();
static Future<void> save(bool value) => _setting.save(value);
}
+146 -13
View File
@@ -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<Color> 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<ChatWallpaperTheme> kChatWallpaperThemes = <ChatWallpaperTheme>[];
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<ChatWallpaperTheme> kChatWallpaperThemes = <ChatWallpaperTheme>[
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;
@@ -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
+134
View File
@@ -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<TiledSvgPattern> createState() => _TiledSvgPatternState();
}
class _TiledSvgPatternState extends State<TiledSvgPattern> {
static final Map<String, ui.Image> _cache = {};
static final Map<String, Future<ui.Image>> _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<void> _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<ui.Image> _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;
}
+49
View File
@@ -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<Color?> 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<Color> 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;
}
+54 -10
View File
@@ -445,6 +445,15 @@ class _ChatScreenState extends State<ChatScreen>
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<double> _composerHeight = ValueNotifier(96);
final ValueNotifier<double> _pinnedBannerHeight = ValueNotifier(0);
@@ -1341,6 +1350,10 @@ class _ChatScreenState extends State<ChatScreen>
.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<ChatScreen>
],
);
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<ChatScreen>
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<ChatScreen>
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<ChatScreen>
);
}
bool _wallpaperListening = false;
Future<void> _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<void> _openWallpaperSheet() async {
@@ -2489,13 +2533,13 @@ class _ChatScreenState extends State<ChatScreen>
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<ChatScreen>
showCustomNotification(context, 'Не удалось сохранить обои');
return;
}
setState(() => _wallpaper = wp);
_applyEffectiveWallpaper();
}
Future<void> _clearHistory() async {
@@ -3896,7 +3940,7 @@ class _ChatScreenState extends State<ChatScreen>
? _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<ChatScreen>
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();
@@ -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) {
@@ -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<ChatBackgroundScreen> createState() => _ChatBackgroundScreenState();
}
class _ChatBackgroundScreenState extends State<ChatBackgroundScreen> {
int _accountId = 0;
ChatWallpaper? _wallpaper;
bool _ready = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<void> _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<void> _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<WallpaperImageSettings>(
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<bool>(
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',
),
),
),
),
);
}
}
@@ -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();
+348 -108
View File
@@ -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<WallpaperPick?> showChatWallpaperSheet(
BuildContext context, {
required ChatWallpaper? current,
}) {
return showModalBottomSheet<WallpaperPick>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
barrierColor: Colors.black.withValues(alpha: 0.45),
builder: (_) => _ChatWallpaperSheet(current: current),
return Navigator.of(context).push<WallpaperPick>(
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<ChatWallpaperGalleryScreen> createState() =>
_ChatWallpaperGalleryScreenState();
}
class _ChatWallpaperGalleryScreenState
extends State<ChatWallpaperGalleryScreen> {
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',
),
),
),
),
),
);
}
}
+20 -3
View File
@@ -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<StickerPanel>
@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<StickerPanel>
},
),
),
),
),
),
);
}
+1
View File
@@ -291,6 +291,7 @@
"appearanceChatChromeColor": "Color",
"appearanceChatChromeBlur": "Blur",
"appearanceChatChromeNone": "None",
"appearanceChatChromeTransparent": "Clear",
"appearanceGradientTitle": "Gradient",
"appearanceGradientSubtitle": "Depth and highlights in Glossy capsules",
"appearanceAccentColorTitle": "Accent color",
+6
View File
@@ -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:
+3
View File
@@ -762,6 +762,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get appearanceChatChromeNone => 'None';
@override
String get appearanceChatChromeTransparent => 'Clear';
@override
String get appearanceGradientTitle => 'Gradient';
+3
View File
@@ -765,6 +765,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get appearanceChatChromeNone => 'Нет';
@override
String get appearanceChatChromeTransparent => 'Прозр.';
@override
String get appearanceGradientTitle => 'Градиент';
+1
View File
@@ -256,6 +256,7 @@
"appearanceChatChromeColor": "Цвет",
"appearanceChatChromeBlur": "Блюр",
"appearanceChatChromeNone": "Нет",
"appearanceChatChromeTransparent": "Прозр.",
"appearanceGradientTitle": "Градиент",
"appearanceGradientSubtitle": "Объём и блики в Glossy-капсулах",
"appearanceAccentColorTitle": "Акцентный цвет",
+45 -3
View File
@@ -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<String> 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<String> args) async {
pillGradientFuture,
visualStyleFuture,
chatChromeFuture,
wallpaperTintFuture,
themeScheduleFuture,
messageActionsFuture,
swipeBackFuture,
@@ -310,6 +315,7 @@ class KometAppState extends State<KometApp>
late final ValueNotifier<Color?> accentSeed = ValueNotifier(
widget.initialAccentSeed,
);
final ValueNotifier<Color?> wallpaperSeed = ValueNotifier(null);
StreamSubscription<SessionExpiredException>? _sessionExpiredSub;
StreamSubscription<LoginStatus>? _loginStatusSub;
StreamSubscription<VpnBypassResult>? _vpnBypassSub;
@@ -345,8 +351,11 @@ class KometAppState extends State<KometApp>
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<KometApp>
_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<KometApp>
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<KometApp>
tlsInsecureEnabled.dispose();
fontScale.dispose();
accentSeed.dispose();
wallpaperSeed.dispose();
super.dispose();
}
@@ -719,6 +732,27 @@ class KometAppState extends State<KometApp>
accentSeed.value = seed;
}
void _onWallpaperTintChanged() => unawaited(_refreshWallpaperSeed());
Future<void> _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<void> applyAppFont(String fontId) async {
if (_fontId == fontId) return;
final prefs = await SharedPreferences.getInstance();
@@ -850,9 +884,17 @@ class KometAppState extends State<KometApp>
Widget build(BuildContext context) {
return DynamicColorBuilder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
return ValueListenableBuilder<Color?>(
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) {
+6
View File
@@ -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
+40
View File
@@ -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:
+2
View File
@@ -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