From 473889186b34d60d75058cf6e9208199bfde26ca Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Fri, 3 Jul 2026 17:51:20 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BE=D0=B5=D0=B2=20=D0=B4=D0=BB=D1=8F=20=D1=87=D0=B0?= =?UTF-8?q?=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/chat_wallpaper_themes.dart | 36 ++ lib/core/storage/chat_wallpaper_store.dart | 191 ++++++++ lib/frontend/screens/chats/chat_screen.dart | 73 ++- .../chats/chat_wallpaper_preview_screen.dart | 433 ++++++++++++++++++ .../screens/profile/appearance_screen.dart | 162 ++++--- .../widgets/chat_wallpaper_sheet.dart | 227 +++++++++ lib/frontend/widgets/chat_wallpaper_view.dart | 169 +++++++ pubspec.lock | 16 + pubspec.yaml | 1 + 9 files changed, 1243 insertions(+), 65 deletions(-) create mode 100644 lib/core/config/chat_wallpaper_themes.dart create mode 100644 lib/core/storage/chat_wallpaper_store.dart create mode 100644 lib/frontend/screens/chats/chat_wallpaper_preview_screen.dart create mode 100644 lib/frontend/widgets/chat_wallpaper_sheet.dart create mode 100644 lib/frontend/widgets/chat_wallpaper_view.dart diff --git a/lib/core/config/chat_wallpaper_themes.dart b/lib/core/config/chat_wallpaper_themes.dart new file mode 100644 index 0000000..3a4dcca --- /dev/null +++ b/lib/core/config/chat_wallpaper_themes.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +@immutable +class ChatWallpaperTheme { + final String id; + final String name; + final Gradient gradient; + final Color bubbleTint; + + const ChatWallpaperTheme({ + required this.id, + required this.name, + required this.gradient, + this.bubbleTint = Colors.transparent, + }); + + Widget buildBackground() => DecoratedBox( + decoration: BoxDecoration(gradient: gradient), + child: const SizedBox.expand(), + ); + + Widget buildPreview() => DecoratedBox( + decoration: BoxDecoration(gradient: gradient), + child: const SizedBox.expand(), + ); +} + +const List kChatWallpaperThemes = []; + +ChatWallpaperTheme? chatWallpaperThemeById(String? id) { + if (id == null) return null; + for (final theme in kChatWallpaperThemes) { + if (theme.id == id) return theme; + } + return null; +} diff --git a/lib/core/storage/chat_wallpaper_store.dart b/lib/core/storage/chat_wallpaper_store.dart new file mode 100644 index 0000000..3dcf078 --- /dev/null +++ b/lib/core/storage/chat_wallpaper_store.dart @@ -0,0 +1,191 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum ChatWallpaperKind { image, theme } + +@immutable +class WallpaperImageSettings { + final double dim; + final bool blur; + final bool motion; + final double offsetX; + + const WallpaperImageSettings({ + this.dim = 0, + this.blur = false, + this.motion = false, + this.offsetX = 0, + }); +} + +@immutable +class ChatWallpaper { + final ChatWallpaperKind kind; + final String? imagePath; + final String? themeId; + final double dim; + final bool blur; + final bool motion; + final double offsetX; + + const ChatWallpaper.image( + String path, { + this.dim = 0, + this.blur = false, + this.motion = false, + this.offsetX = 0, + }) : kind = ChatWallpaperKind.image, + imagePath = path, + themeId = null; + + const ChatWallpaper.theme(String id) + : kind = ChatWallpaperKind.theme, + imagePath = null, + themeId = id, + dim = 0, + blur = false, + motion = false, + offsetX = 0; + + bool get isImage => kind == ChatWallpaperKind.image; + + Map _toJson() => isImage + ? { + 'path': imagePath, + 'dim': dim, + 'blur': blur, + 'motion': motion, + 'offsetX': offsetX, + } + : {'theme': themeId}; + + static ChatWallpaper? _fromJson(Object? raw) { + if (raw is! Map) return null; + final path = raw['path']; + if (path is String && path.isNotEmpty) { + return ChatWallpaper.image( + path, + dim: (raw['dim'] as num?)?.toDouble() ?? 0, + blur: raw['blur'] == true, + motion: raw['motion'] == true, + offsetX: (raw['offsetX'] as num?)?.toDouble() ?? 0, + ); + } + final theme = raw['theme']; + if (theme is String && theme.isNotEmpty) return ChatWallpaper.theme(theme); + return null; + } +} + +class ChatWallpaperStore { + ChatWallpaperStore._(); + + static final ChatWallpaperStore instance = ChatWallpaperStore._(); + + static const String _prefsKey = 'chat_wallpapers'; + static const String _dirName = 'chat_wallpapers'; + + final Map _wallpapers = {}; + final ValueNotifier revision = ValueNotifier(0); + bool _loaded = false; + + String _key(int accountId, int chatId) => '$accountId/$chatId'; + + Future load() async { + if (_loaded) return; + _loaded = true; + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_prefsKey); + if (raw == null) return; + try { + final map = jsonDecode(raw); + if (map is Map) { + map.forEach((k, v) { + if (k is! String) return; + final wp = ChatWallpaper._fromJson(v); + if (wp != null) _wallpapers[k] = wp; + }); + } + } catch (_) {} + } + + ChatWallpaper? get(int accountId, int chatId) { + if (accountId == 0) return null; + return _wallpapers[_key(accountId, chatId)]; + } + + Future setImage( + int accountId, + int chatId, + Uint8List bytes, { + WallpaperImageSettings settings = const WallpaperImageSettings(), + }) async { + if (accountId == 0) return null; + final dir = await getApplicationDocumentsDirectory(); + final wpDir = Directory('${dir.path}/$_dirName'); + if (!await wpDir.exists()) await wpDir.create(recursive: true); + final stamp = DateTime.now().millisecondsSinceEpoch; + final file = File('${wpDir.path}/${accountId}_${chatId}_$stamp.img'); + await file.writeAsBytes(bytes, flush: true); + final wallpaper = ChatWallpaper.image( + file.path, + dim: settings.dim, + blur: settings.blur, + motion: settings.motion, + offsetX: settings.offsetX, + ); + await _store(accountId, chatId, wallpaper); + return wallpaper; + } + + Future setTheme( + int accountId, + int chatId, + String themeId, + ) async { + final wallpaper = ChatWallpaper.theme(themeId); + await _store(accountId, chatId, wallpaper); + return wallpaper; + } + + Future clear(int accountId, int chatId) => _store(accountId, chatId, null); + + Future _store( + int accountId, + int chatId, + ChatWallpaper? wallpaper, + ) async { + if (accountId == 0) return; + final key = _key(accountId, chatId); + final previous = _wallpapers[key]; + if (previous != null && + previous.isImage && + previous.imagePath != wallpaper?.imagePath) { + unawaited(_deleteImage(previous.imagePath)); + } + if (wallpaper == null) { + if (previous == null) return; + _wallpapers.remove(key); + } else { + _wallpapers[key] = wallpaper; + } + revision.value++; + final prefs = await SharedPreferences.getInstance(); + final serializable = {}; + _wallpapers.forEach((k, v) => serializable[k] = v._toJson()); + await prefs.setString(_prefsKey, jsonEncode(serializable)); + } + + Future _deleteImage(String? path) async { + if (path == null) return; + try { + final file = File(path); + if (await file.exists()) await file.delete(); + } catch (_) {} + } +} diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 1eefae8..7d2809c 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -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 bool _floatingDateScheduled = false; int _myId = 0; CachedChat? chat; + ChatWallpaper? _wallpaper; final ValueNotifier _floatingDate = ValueNotifier(null); Timer? _floatingDateTimer; @@ -428,6 +433,7 @@ class _ChatScreenState extends State 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 ChatMenuItem( icon: Symbols.wallpaper, label: 'Изменить обои', - onTap: () {}, + onTap: _openWallpaperSheet, ), ChatMenuItem( icon: Symbols.mop, @@ -2433,6 +2439,67 @@ class _ChatScreenState extends State ); } + Future _loadWallpaper() async { + await ChatWallpaperStore.instance.load(); + if (!mounted) return; + final wp = ChatWallpaperStore.instance.get(_myId, widget.chatId); + if (wp != _wallpaper) setState(() => _wallpaper = wp); + } + + Future _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 _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( + 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 _showConfirmDialog({ required String title, required String body, @@ -3483,6 +3550,10 @@ class _ChatScreenState extends State Expanded( child: Stack( children: [ + if (_wallpaper != null) + Positioned.fill( + child: ChatWallpaperView(wallpaper: _wallpaper!), + ), Positioned.fill( child: _isLoading && _messages.isEmpty ? _buildShimmerLoading() diff --git a/lib/frontend/screens/chats/chat_wallpaper_preview_screen.dart b/lib/frontend/screens/chats/chat_wallpaper_preview_screen.dart new file mode 100644 index 0000000..ede2389 --- /dev/null +++ b/lib/frontend/screens/chats/chat_wallpaper_preview_screen.dart @@ -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 createState() => + _ChatWallpaperPreviewScreenState(); +} + +class _ChatWallpaperPreviewScreenState + extends State { + 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 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 { + 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', + ), + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/appearance_screen.dart b/lib/frontend/screens/profile/appearance_screen.dart index beff004..8812720 100644 --- a/lib/frontend/screens/profile/appearance_screen.dart +++ b/lib/frontend/screens/profile/appearance_screen.dart @@ -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 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; +} diff --git a/lib/frontend/widgets/chat_wallpaper_sheet.dart b/lib/frontend/widgets/chat_wallpaper_sheet.dart new file mode 100644 index 0000000..1131ed0 --- /dev/null +++ b/lib/frontend/widgets/chat_wallpaper_sheet.dart @@ -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 showChatWallpaperSheet( + BuildContext context, { + required ChatWallpaper? current, +}) { + return showModalBottomSheet( + 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(), + ); + } +} diff --git a/lib/frontend/widgets/chat_wallpaper_view.dart b/lib/frontend/widgets/chat_wallpaper_view.dart new file mode 100644 index 0000000..079a122 --- /dev/null +++ b/lib/frontend/widgets/chat_wallpaper_view.dart @@ -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 createState() => _WallpaperImageLayerState(); +} + +class _WallpaperImageLayerState extends State { + 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 = ValueNotifier(Offset.zero); + StreamSubscription? _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( + 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( + 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( + 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), + ), + ], + ), + ); + }, + ); + }, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index cd24665..c259997 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1221,6 +1221,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + sensors_plus: + dependency: "direct main" + description: + name: sensors_plus + sha256: "31182c626623de79d3f7e1f9b43028f940821ee012c092f68f165fe55b867822" + url: "https://pub.dev" + source: hosted + version: "7.1.0" + sensors_plus_platform_interface: + dependency: transitive + description: + name: sensors_plus_platform_interface + sha256: "9e12a92569bf14ae04b2f086c3e087177a47e527b7666ae1bbe5f70f8450ab19" + url: "https://pub.dev" + source: hosted + version: "2.0.2" share_plus: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 6f93305..d7c79f2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -79,6 +79,7 @@ dependencies: media_kit_libs_windows_video: ^1.0.11 media_kit_libs_linux: ^1.2.1 media_kit_libs_macos_video: ^1.1.4 + sensors_plus: ^7.1.0 dev_dependencies: flutter_test: