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
+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) {