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;
}