feat: выбор обоев для чата
This commit is contained in:
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user