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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -291,6 +291,7 @@
|
||||
"appearanceChatChromeColor": "Color",
|
||||
"appearanceChatChromeBlur": "Blur",
|
||||
"appearanceChatChromeNone": "None",
|
||||
"appearanceChatChromeTransparent": "Clear",
|
||||
"appearanceGradientTitle": "Gradient",
|
||||
"appearanceGradientSubtitle": "Depth and highlights in Glossy capsules",
|
||||
"appearanceAccentColorTitle": "Accent color",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -762,6 +762,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get appearanceChatChromeNone => 'None';
|
||||
|
||||
@override
|
||||
String get appearanceChatChromeTransparent => 'Clear';
|
||||
|
||||
@override
|
||||
String get appearanceGradientTitle => 'Gradient';
|
||||
|
||||
|
||||
@@ -765,6 +765,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get appearanceChatChromeNone => 'Нет';
|
||||
|
||||
@override
|
||||
String get appearanceChatChromeTransparent => 'Прозр.';
|
||||
|
||||
@override
|
||||
String get appearanceGradientTitle => 'Градиент';
|
||||
|
||||
|
||||
@@ -256,6 +256,7 @@
|
||||
"appearanceChatChromeColor": "Цвет",
|
||||
"appearanceChatChromeBlur": "Блюр",
|
||||
"appearanceChatChromeNone": "Нет",
|
||||
"appearanceChatChromeTransparent": "Прозр.",
|
||||
"appearanceGradientTitle": "Градиент",
|
||||
"appearanceGradientSubtitle": "Объём и блики в Glossy-капсулах",
|
||||
"appearanceAccentColorTitle": "Акцентный цвет",
|
||||
|
||||
+45
-3
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user