feat: выбор обоев для чата

This commit is contained in:
Jganenokk
2026-07-03 17:51:20 +07:00
parent 7c18d0a71f
commit 473889186b
9 changed files with 1243 additions and 65 deletions
+72 -1
View File
@@ -37,6 +37,7 @@ import '../../../core/protocol/packet.dart';
import '../../../core/push/push_service.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/storage/chat_activity_store.dart';
import '../../../core/storage/chat_wallpaper_store.dart';
import '../../../core/storage/draft_store.dart';
import '../../../core/cache/info_cache.dart';
import '../../../core/cache/message_session_cache.dart';
@@ -67,7 +68,10 @@ import '../../widgets/sticker_panel.dart';
import '../../widgets/sticker_pack_sheet.dart';
import '../../widgets/swipe_to_pop.dart';
import '../../widgets/schedule_time_picker.dart';
import '../../widgets/chat_wallpaper_sheet.dart';
import '../../widgets/chat_wallpaper_view.dart';
import 'scheduled_messages_screen.dart';
import 'chat_wallpaper_preview_screen.dart';
class _UploadStatus {
final bool active;
@@ -325,6 +329,7 @@ class _ChatScreenState extends State<ChatScreen>
bool _floatingDateScheduled = false;
int _myId = 0;
CachedChat? chat;
ChatWallpaper? _wallpaper;
final ValueNotifier<DateTime?> _floatingDate = ValueNotifier(null);
Timer? _floatingDateTimer;
@@ -428,6 +433,7 @@ class _ChatScreenState extends State<ChatScreen>
if (!mounted) return;
_myId = p?.id ?? 0;
_restoreDraft();
unawaited(_loadWallpaper());
unawaited(_refreshBadge());
ChatsModule.getChat(_myId, widget.chatId)
@@ -2417,7 +2423,7 @@ class _ChatScreenState extends State<ChatScreen>
ChatMenuItem(
icon: Symbols.wallpaper,
label: 'Изменить обои',
onTap: () {},
onTap: _openWallpaperSheet,
),
ChatMenuItem(
icon: Symbols.mop,
@@ -2433,6 +2439,67 @@ class _ChatScreenState extends State<ChatScreen>
);
}
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);
}
Future<void> _openWallpaperSheet() async {
if (_myId == 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(_myId, widget.chatId);
if (mounted) setState(() => _wallpaper = null);
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);
break;
case WallpaperPickType.gallery:
await _pickWallpaperFromGallery();
break;
}
}
Future<void> _pickWallpaperFromGallery() 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(
_myId,
widget.chatId,
bytes,
settings: settings,
);
if (!mounted) return;
if (wp == null) {
showCustomNotification(context, 'Не удалось сохранить обои');
return;
}
setState(() => _wallpaper = wp);
}
Future<bool?> _showConfirmDialog({
required String title,
required String body,
@@ -3483,6 +3550,10 @@ class _ChatScreenState extends State<ChatScreen>
Expanded(
child: Stack(
children: [
if (_wallpaper != null)
Positioned.fill(
child: ChatWallpaperView(wallpaper: _wallpaper!),
),
Positioned.fill(
child: _isLoading && _messages.isEmpty
? _buildShimmerLoading()
@@ -0,0 +1,433 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:komet/core/storage/chat_wallpaper_store.dart';
import 'package:komet/frontend/widgets/chat_wallpaper_view.dart';
class ChatWallpaperPreviewScreen extends StatefulWidget {
final Uint8List imageBytes;
final WallpaperImageSettings initial;
const ChatWallpaperPreviewScreen({
super.key,
required this.imageBytes,
this.initial = const WallpaperImageSettings(dim: 0.2),
});
@override
State<ChatWallpaperPreviewScreen> createState() =>
_ChatWallpaperPreviewScreenState();
}
class _ChatWallpaperPreviewScreenState
extends State<ChatWallpaperPreviewScreen> {
late double _dim = widget.initial.dim;
late bool _blur = widget.initial.blur;
late bool _motion = widget.initial.motion;
late double _offsetX = widget.initial.offsetX;
late final MemoryImage _image = MemoryImage(widget.imageBytes);
void _pan(double dx, double width) {
if (width <= 0) return;
setState(() {
_offsetX = (_offsetX - dx / width * 2).clamp(-1.0, 1.0);
});
}
void _apply() {
Navigator.pop(
context,
WallpaperImageSettings(
dim: _dim,
blur: _blur,
motion: _motion,
offsetX: _offsetX,
),
);
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (d) => _pan(d.delta.dx, width),
child: WallpaperImageLayer(
image: _image,
dim: _dim,
blur: _blur,
motion: _motion,
offsetX: _offsetX,
),
),
const IgnorePointer(child: _EdgeScrim()),
SafeArea(
child: Column(
children: [
_appBar(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
child: _DimmingSlider(
value: _dim,
onChanged: (v) => setState(() => _dim = v),
),
),
const Expanded(child: IgnorePointer(child: _SamplePreview())),
_controls(),
],
),
),
],
),
);
}
Widget _appBar() {
return SizedBox(
height: 56,
child: Row(
children: [
IconButton(
icon: const Icon(Symbols.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
const Text(
'Обои',
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w700,
fontFamily: 'Outfit',
),
),
],
),
);
}
Widget _controls() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: _ToggleChip(
label: 'Размытие',
value: _blur,
onTap: () => setState(() => _blur = !_blur),
),
),
const SizedBox(width: 12),
Expanded(
child: _ToggleChip(
label: 'Движение',
value: _motion,
onTap: () => setState(() => _motion = !_motion),
),
),
],
),
const SizedBox(height: 12),
_ApplyButton(onTap: _apply),
],
),
);
}
}
class _Frosted extends StatelessWidget {
final double radius;
final Widget child;
const _Frosted({required this.radius, required this.child});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 24, sigmaY: 24),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(radius),
border: Border.all(color: Colors.white.withValues(alpha: 0.18)),
),
child: child,
),
),
);
}
}
class _EdgeScrim extends StatelessWidget {
const _EdgeScrim();
@override
Widget build(BuildContext context) {
return const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x59000000),
Color(0x00000000),
Color(0x00000000),
Color(0x66000000),
],
stops: [0.0, 0.16, 0.74, 1.0],
),
),
child: SizedBox.expand(),
);
}
}
class _DimmingSlider extends StatelessWidget {
final double value;
final ValueChanged<double> onChanged;
const _DimmingSlider({required this.value, required this.onChanged});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
void update(double dx) => onChanged((dx / width).clamp(0.0, 1.0));
final fraction = value.clamp(0.0, 1.0);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (d) => update(d.localPosition.dx),
onHorizontalDragUpdate: (d) => update(d.localPosition.dx),
child: _Frosted(
radius: 14,
child: SizedBox(
width: double.infinity,
height: 48,
child: Stack(
fit: StackFit.expand,
children: [
Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: fraction,
heightFactor: 1,
child: ColoredBox(
color: Colors.white.withValues(alpha: 0.85),
),
),
),
_DimLabel(value: fraction, color: Colors.white),
ClipRect(
clipper: _RevealClipper(fraction),
child: _DimLabel(value: fraction, color: Colors.black),
),
],
),
),
),
);
},
);
}
}
class _DimLabel extends StatelessWidget {
final double value;
final Color color;
const _DimLabel({required this.value, required this.color});
@override
Widget build(BuildContext context) {
final style = TextStyle(
color: color,
fontSize: 16,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Text('Затемнение', style: style),
const Spacer(),
Text('${(value * 100).round()}%', style: style),
],
),
);
}
}
class _RevealClipper extends CustomClipper<Rect> {
final double fraction;
const _RevealClipper(this.fraction);
@override
Rect getClip(Size size) => Rect.fromLTWH(0, 0, size.width * fraction, size.height);
@override
bool shouldReclip(_RevealClipper oldClipper) =>
oldClipper.fraction != fraction;
}
class _ToggleChip extends StatelessWidget {
final String label;
final bool value;
final VoidCallback onTap;
const _ToggleChip({
required this.label,
required this.value,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: _Frosted(
radius: 24,
child: SizedBox(
height: 48,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 140),
width: 24,
height: 24,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: value ? Colors.white : Colors.transparent,
border: Border.all(color: Colors.white, width: 2),
),
child: value
? const Icon(Symbols.check, size: 16, color: Colors.black)
: null,
),
const SizedBox(width: 10),
Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
),
],
),
),
),
);
}
}
class _ApplyButton extends StatelessWidget {
final VoidCallback onTap;
const _ApplyButton({required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: _Frosted(
radius: 26,
child: const SizedBox(
height: 52,
child: Center(
child: Text(
'Применить',
style: TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w700,
fontFamily: 'Outfit',
),
),
),
),
),
);
}
}
class _SamplePreview extends StatelessWidget {
const _SamplePreview();
@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, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_bubble(
text: 'Как насчёт новых обоев для этого чата?',
color: cs.surfaceContainerHighest.withValues(alpha: 0.92),
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',
),
),
),
),
);
}
}
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -500,7 +501,10 @@ class _ColorPickerCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_HueStripPicker(color: col, onChanged: onColorChanged),
_ColorWheelPicker(
color: col,
onChanged: onColorChanged,
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
@@ -660,79 +664,65 @@ class _BubbleBehaviorCard extends StatelessWidget {
}
}
class _HueStripPicker extends StatelessWidget {
class _ColorWheelPicker extends StatefulWidget {
final Color color;
final ValueChanged<Color> onChanged;
const _HueStripPicker({required this.color, required this.onChanged});
const _ColorWheelPicker({required this.color, required this.onChanged});
static const _gradient = LinearGradient(
colors: [
Color(0xFFFF0000),
Color(0xFFFFFF00),
Color(0xFF00FF00),
Color(0xFF00FFFF),
Color(0xFF0000FF),
Color(0xFFFF00FF),
Color(0xFFFF0000),
],
);
@override
State<_ColorWheelPicker> createState() => _ColorWheelPickerState();
}
class _ColorWheelPickerState extends State<_ColorWheelPicker> {
late HSVColor _hsv;
late Color _lastEmitted;
@override
void initState() {
super.initState();
_hsv = HSVColor.fromColor(widget.color).withValue(1);
_lastEmitted = widget.color;
}
@override
void didUpdateWidget(covariant _ColorWheelPicker oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.color != _lastEmitted) {
_hsv = HSVColor.fromColor(widget.color).withValue(1);
_lastEmitted = widget.color;
}
}
void _handleWheel(Offset local, double size) {
final radius = size / 2;
final dx = local.dx - radius;
final dy = local.dy - radius;
final sat = (math.sqrt(dx * dx + dy * dy) / radius).clamp(0.0, 1.0);
var hue = math.atan2(dy, dx) * 180 / math.pi;
if (hue < 0) hue += 360;
final hsv = _hsv.withHue(hue).withSaturation(sat);
setState(() => _hsv = hsv);
final color = hsv.toColor();
_lastEmitted = color;
widget.onChanged(color);
}
@override
Widget build(BuildContext context) {
final hue = HSVColor.fromColor(color).hue;
const trackHeight = 26.0;
const thumbDiameter = 30.0;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
void emit(double dx) {
final clamped = dx.clamp(0.0, width);
final newHue = (clamped / width) * 360;
onChanged(HSVColor.fromAHSV(1, newHue, 1, 1).toColor());
}
final wheelSize = math.min(260.0, constraints.maxWidth);
final thumbLeft = (hue / 360) * width - thumbDiameter / 2;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanDown: (d) => emit(d.localPosition.dx),
onPanUpdate: (d) => emit(d.localPosition.dx),
child: SizedBox(
height: thumbDiameter + 4,
child: Stack(
children: [
Center(
child: Container(
height: trackHeight,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(trackHeight / 2),
gradient: _gradient,
),
),
),
Positioned(
left: thumbLeft.clamp(0, width - thumbDiameter),
top: (thumbDiameter + 4 - thumbDiameter) / 2,
child: Container(
width: thumbDiameter,
height: thumbDiameter,
decoration: BoxDecoration(
color: HSVColor.fromAHSV(1, hue, 1, 1).toColor(),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
),
),
],
return Center(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanDown: (d) => _handleWheel(d.localPosition, wheelSize),
onPanUpdate: (d) => _handleWheel(d.localPosition, wheelSize),
child: SizedBox(
width: wheelSize,
height: wheelSize,
child: CustomPaint(painter: _WheelPainter(hsv: _hsv)),
),
),
);
@@ -740,3 +730,47 @@ class _HueStripPicker extends StatelessWidget {
);
}
}
class _WheelPainter extends CustomPainter {
final HSVColor hsv;
const _WheelPainter({required this.hsv});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2;
final rect = Rect.fromCircle(center: center, radius: radius);
final hueShader = SweepGradient(
colors: [
for (var i = 0; i <= 360; i += 30)
HSVColor.fromAHSV(1, (i % 360).toDouble(), 1, 1).toColor(),
],
stops: [for (var i = 0; i <= 360; i += 30) i / 360],
).createShader(rect);
canvas.drawCircle(center, radius, Paint()..shader = hueShader);
final satShader = RadialGradient(
colors: [Colors.white, Colors.white.withValues(alpha: 0)],
).createShader(rect);
canvas.drawCircle(center, radius, Paint()..shader = satShader);
final angle = hsv.hue * math.pi / 180;
final thumb = Offset(
center.dx + hsv.saturation * radius * math.cos(angle),
center.dy + hsv.saturation * radius * math.sin(angle),
);
canvas.drawShadow(
Path()..addOval(Rect.fromCircle(center: thumb, radius: 13)),
Colors.black,
2,
false,
);
canvas.drawCircle(thumb, 13, Paint()..color = Colors.white);
canvas.drawCircle(thumb, 10, Paint()..color = hsv.toColor());
}
@override
bool shouldRepaint(_WheelPainter oldDelegate) => oldDelegate.hsv != hsv;
}
@@ -0,0 +1,227 @@
import 'package:flutter/material.dart';
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 }
class WallpaperPick {
final WallpaperPickType type;
final ChatWallpaperTheme? theme;
const WallpaperPick.none()
: type = WallpaperPickType.none,
theme = null;
const WallpaperPick.gallery()
: type = WallpaperPickType.gallery,
theme = null;
const WallpaperPick.theme(this.theme) : type = WallpaperPickType.theme;
}
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),
);
}
class _ChatWallpaperSheet extends StatelessWidget {
final ChatWallpaper? current;
const _ChatWallpaperSheet({required this.current});
bool get _isNoneSelected => current == null;
@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)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SheetGrabber(),
_header(context, cs),
const SizedBox(height: 12),
_themeRow(context, cs),
const SizedBox(height: 20),
_galleryButton(context, cs),
const SizedBox(height: 12),
],
),
),
);
}
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),
),
const SizedBox(width: 4),
Text(
'Выбрать тему',
style: TextStyle(
color: cs.onSurface,
fontSize: 22,
fontWeight: FontWeight.w700,
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',
),
),
);
}
}
class _TileFrame extends StatelessWidget {
final bool selected;
final VoidCallback onTap;
final Widget child;
const _TileFrame({
required this.selected,
required this.onTap,
required this.child,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Padding(
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,
),
),
),
);
}
}
class _NoneTile extends StatelessWidget {
final bool selected;
final VoidCallback onTap;
const _NoneTile({required this.selected, required this.onTap});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return _TileFrame(
selected: selected,
onTap: onTap,
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),
],
),
),
);
}
}
class _ThemeTile extends StatelessWidget {
final ChatWallpaperTheme theme;
final bool selected;
final VoidCallback onTap;
const _ThemeTile({
required this.theme,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return _TileFrame(
selected: selected,
onTap: onTap,
child: theme.buildPreview(),
);
}
}
@@ -0,0 +1,169 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:sensors_plus/sensors_plus.dart';
import 'package:komet/core/config/chat_wallpaper_themes.dart';
import 'package:komet/core/storage/chat_wallpaper_store.dart';
class ChatWallpaperView extends StatelessWidget {
final ChatWallpaper wallpaper;
const ChatWallpaperView({super.key, required this.wallpaper});
@override
Widget build(BuildContext context) {
if (wallpaper.isImage) {
final path = wallpaper.imagePath;
if (path == null) return const SizedBox.shrink();
return WallpaperImageLayer(
image: FileImage(File(path)),
dim: wallpaper.dim,
blur: wallpaper.blur,
motion: wallpaper.motion,
offsetX: wallpaper.offsetX,
);
}
final theme = chatWallpaperThemeById(wallpaper.themeId);
if (theme == null) return const SizedBox.shrink();
return theme.buildBackground();
}
}
class WallpaperImageLayer extends StatefulWidget {
final ImageProvider image;
final double dim;
final bool blur;
final bool motion;
final double offsetX;
const WallpaperImageLayer({
super.key,
required this.image,
this.dim = 0,
this.blur = false,
this.motion = false,
this.offsetX = 0,
});
@override
State<WallpaperImageLayer> createState() => _WallpaperImageLayerState();
}
class _WallpaperImageLayerState extends State<WallpaperImageLayer> {
static const double _maxShift = 20;
static const double _motionScale = 1.16;
static const double _blurSigma = 22;
static const Duration _transition = Duration(milliseconds: 320);
final ValueNotifier<Offset> _offset = ValueNotifier(Offset.zero);
StreamSubscription<AccelerometerEvent>? _sub;
@override
void initState() {
super.initState();
if (widget.motion) _startMotion();
}
@override
void didUpdateWidget(WallpaperImageLayer old) {
super.didUpdateWidget(old);
if (widget.motion && !old.motion) _startMotion();
if (!widget.motion && old.motion) _stopMotion();
}
void _startMotion() {
_offset.value = Offset.zero;
_sub ??= accelerometerEventStream(samplingPeriod: SensorInterval.gameInterval)
.listen(_onAccelerometer, onError: (_) {}, cancelOnError: false);
}
void _stopMotion() {
_sub?.cancel();
_sub = null;
}
void _onAccelerometer(AccelerometerEvent event) {
final targetX = (-event.x / 9.8).clamp(-1.0, 1.0) * _maxShift;
final targetY = (event.y / 9.8).clamp(-1.0, 1.0) * _maxShift;
final prev = _offset.value;
_offset.value = Offset(
prev.dx + (targetX - prev.dx) * 0.12,
prev.dy + (targetY - prev.dy) * 0.12,
);
}
@override
void dispose() {
_sub?.cancel();
_offset.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final image = Image(
image: widget.image,
fit: BoxFit.cover,
alignment: Alignment(widget.offsetX.clamp(-1.0, 1.0), 0),
width: double.infinity,
height: double.infinity,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
);
return TweenAnimationBuilder<double>(
tween: Tween(end: widget.blur ? _blurSigma : 0.0),
duration: _transition,
curve: Curves.easeInOut,
child: image,
builder: (context, sigma, blurChild) {
final blurred = sigma > 0.05
? ImageFiltered(
imageFilter: ui.ImageFilter.blur(
sigmaX: sigma,
sigmaY: sigma,
tileMode: TileMode.clamp,
),
child: blurChild,
)
: blurChild!;
return TweenAnimationBuilder<double>(
tween: Tween(end: widget.motion ? 1.0 : 0.0),
duration: _transition,
curve: Curves.easeInOut,
child: blurred,
builder: (context, motionT, motionChild) {
final layer = motionT > 0.001
? ValueListenableBuilder<Offset>(
valueListenable: _offset,
builder: (context, offset, child) => Transform.translate(
offset: offset * motionT,
child: Transform.scale(
scale: 1 + (_motionScale - 1) * motionT,
child: child,
),
),
child: motionChild,
)
: motionChild!;
return ClipRect(
child: Stack(
fit: StackFit.expand,
children: [
layer,
if (widget.dim > 0)
ColoredBox(
color: Colors.black.withValues(alpha: widget.dim),
),
],
),
);
},
);
},
);
}
}