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