feat: кастомные обои чата (SVG-паттерны, темы, глобальный фон, прозрачные панели)
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user