diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..3dd856d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,47 @@ + + +## Описание + + + +**Объём:** N файлов, +X / −Y. + +## Что вошло + + + +### 💬 Чаты и сообщения +- + +### 👤 Профиль и контакты +- + +### 🔒 Безопасность + +- + +### 🛠 Прочее +- + +## Коммиты + + +- `xxxxxxx` + +## Проверка + +- [ ] `flutter analyze` +- [ ] Сборка `komet` flavor +- [ ] Ручная проверка затронутых сценариев +- [ ] Регресс по безопасности (если затронута) diff --git a/lib/core/config/app_message_actions_style.dart b/lib/core/config/app_message_actions_style.dart new file mode 100644 index 0000000..2b8c5ef --- /dev/null +++ b/lib/core/config/app_message_actions_style.dart @@ -0,0 +1,36 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum MessageActionsStyle { radial, list } + +class AppMessageActionsStyle { + static const prefKey = 'app_message_actions_style'; + static final ValueNotifier current = ValueNotifier( + MessageActionsStyle.radial, + ); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return _parse(prefs.getString(prefKey)); + } + + static Future save(MessageActionsStyle style) async { + current.value = style; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(prefKey, style.name); + } + + static MessageActionsStyle _parse(String? val) { + if (val == MessageActionsStyle.list.name) return MessageActionsStyle.list; + return MessageActionsStyle.radial; + } + + static String label(MessageActionsStyle style) { + switch (style) { + case MessageActionsStyle.radial: + return 'Радиальное'; + case MessageActionsStyle.list: + return 'Список'; + } + } +} diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index c45bf37..96884ff 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1,8 +1,10 @@ import 'dart:async'; import 'dart:io' show File; +import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:komet/backend/modules/chats.dart'; import 'package:komet/backend/modules/file_uploader.dart'; @@ -17,8 +19,10 @@ import '../../../core/protocol/packet.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/config/app_cache_extent.dart'; +import '../../../core/config/app_message_actions_style.dart'; import '../../../models/attachment.dart'; import '../../widgets/message_bubble.dart'; +import '../../widgets/message_actions_overlay.dart'; import '../../widgets/attachment_panel.dart'; class _UploadStatus { @@ -878,16 +882,22 @@ class _ChatScreenState extends State overrideStatus: _effectiveStatus(message), ); + final pressable = _LongPressBubble( + message: message, + isMe: isMe, + child: bubble, + ); + if (message.id == _lastSentId) { return _SentMessageAnimation( key: ValueKey('anim_${message.id}'), onComplete: () { if (mounted) setState(() => _lastSentId = null); }, - child: bubble, + child: pressable, ); } - return bubble; + return pressable; }, ), ), @@ -1678,6 +1688,96 @@ IconData _iconForFilename(String? name) { } } +class _LongPressBubble extends StatefulWidget { + final Widget child; + final CachedMessage message; + final bool isMe; + + const _LongPressBubble({ + required this.child, + required this.message, + required this.isMe, + }); + + @override + State<_LongPressBubble> createState() => _LongPressBubbleState(); +} + +class _LongPressBubbleState extends State<_LongPressBubble> { + final GlobalKey _boundaryKey = GlobalKey(); + MessageActionsController? _controller; + + @override + void dispose() { + _controller?.commit(); + _controller = null; + super.dispose(); + } + + void _onLongPressStart(LongPressStartDetails details) { + final ctx = _boundaryKey.currentContext; + if (ctx == null) return; + final renderObject = ctx.findRenderObject(); + if (renderObject is! RenderRepaintBoundary) return; + + final origin = renderObject.localToGlobal(Offset.zero); + final rect = origin & renderObject.size; + final rawDpr = MediaQuery.of(ctx).devicePixelRatio; + final dpr = rawDpr > 2.0 ? 2.0 : rawDpr; + + final ui.Image snapshot; + try { + snapshot = renderObject.toImageSync(pixelRatio: dpr); + } catch (_) { + return; + } + + Haptics.medium(); + + final controller = MessageActionsController(); + controller.attach(details.globalPosition); + _controller = controller; + + showMessageActions( + context: ctx, + snapshot: snapshot, + originRect: rect, + tapPoint: details.globalPosition, + isMe: widget.isMe, + messageText: widget.message.text, + controller: controller, + style: AppMessageActionsStyle.current.value, + onDispose: () { + if (identical(_controller, controller)) { + _controller = null; + } + controller.dispose(); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Listener( + behavior: HitTestBehavior.deferToChild, + onPointerMove: (event) => _controller?.updatePointer(event.position), + onPointerUp: (event) => _controller?.commit(), + onPointerCancel: (event) => _controller?.commit(), + child: GestureDetector( + behavior: HitTestBehavior.deferToChild, + onLongPressStart: _onLongPressStart, + onLongPressMoveUpdate: (d) => + _controller?.updatePointer(d.globalPosition), + onLongPressEnd: (_) => _controller?.commit(), + child: RepaintBoundary( + key: _boundaryKey, + child: widget.child, + ), + ), + ); + } +} + class _SentMessageAnimation extends StatefulWidget { final Widget child; final VoidCallback onComplete; diff --git a/lib/frontend/screens/profile/customization_screen.dart b/lib/frontend/screens/profile/customization_screen.dart index ff447a7..aca2378 100644 --- a/lib/frontend/screens/profile/customization_screen.dart +++ b/lib/frontend/screens/profile/customization_screen.dart @@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../core/utils/haptics.dart'; import 'appearance_screen.dart'; import 'font_settings_screen.dart'; +import 'message_actions_screen.dart'; import 'theme_settings_screen.dart'; class _CustomizationCategory { @@ -43,6 +44,12 @@ class CustomizationScreen extends StatelessWidget { subtitle: 'Шрифт приложения, свои шрифты, размер текста', builder: _buildFontSettings, ), + _CustomizationCategory( + icon: Symbols.touch_app, + title: 'Меню действий', + subtitle: 'Радиальное или список — для долгого нажатия на сообщение', + builder: _buildMessageActions, + ), ]; static Widget _buildAppearance(BuildContext context) => @@ -54,6 +61,9 @@ class CustomizationScreen extends StatelessWidget { static Widget _buildThemeSettings(BuildContext context) => const ThemeSettingsScreen(); + static Widget _buildMessageActions(BuildContext context) => + const MessageActionsScreen(); + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; diff --git a/lib/frontend/screens/profile/message_actions_screen.dart b/lib/frontend/screens/profile/message_actions_screen.dart new file mode 100644 index 0000000..c0abf82 --- /dev/null +++ b/lib/frontend/screens/profile/message_actions_screen.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/config/app_message_actions_style.dart'; +import '../../../core/utils/haptics.dart'; + +class MessageActionsScreen extends StatelessWidget { + const MessageActionsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBarM3E( + titleText: 'Меню действий', + backgroundColor: cs.surface, + ), + body: SafeArea( + top: false, + child: ListView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + children: const [ + _StyleCard(), + ], + ), + ), + ); + } +} + +class _StyleCard extends StatelessWidget { + const _StyleCard(); + + static const _items = [ + ( + style: MessageActionsStyle.radial, + icon: Symbols.bubble_chart, + label: 'Радиальное', + description: 'Дуга кнопок вокруг точки нажатия', + ), + ( + style: MessageActionsStyle.list, + icon: Symbols.menu, + label: 'Список', + description: 'Вертикальное меню рядом с сообщением', + ), + ]; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(28), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Стиль', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Как показывается меню при долгом нажатии на сообщение', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: AppMessageActionsStyle.current, + builder: (context, current, _) { + return Column( + children: [ + for (final item in _items) + _StyleTile( + icon: item.icon, + label: item.label, + description: item.description, + selected: current == item.style, + onTap: () { + if (current == item.style) return; + Haptics.selection(); + AppMessageActionsStyle.save(item.style); + }, + ), + ], + ); + }, + ), + ], + ), + ), + ); + } +} + +class _StyleTile extends StatelessWidget { + final IconData icon; + final String label; + final String description; + final bool selected; + final VoidCallback onTap; + + const _StyleTile({ + required this.icon, + required this.label, + required this.description, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Row( + children: [ + Icon(icon, color: cs.onSurface, size: 22, weight: 500), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + description, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12.5, + height: 1.3, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Icon( + selected + ? Symbols.radio_button_checked + : Symbols.radio_button_unchecked, + color: selected ? cs.primary : cs.outline, + size: 22, + fill: selected ? 1 : 0, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/theme_settings_screen.dart b/lib/frontend/screens/profile/theme_settings_screen.dart index 9e08c3d..2332fe5 100644 --- a/lib/frontend/screens/profile/theme_settings_screen.dart +++ b/lib/frontend/screens/profile/theme_settings_screen.dart @@ -81,10 +81,11 @@ class _ThemeModeCard extends StatelessWidget { icon: item.icon, label: item.label, selected: current == item.mode, - onTap: () { + onTap: (position) { if (current == item.mode) return; Haptics.selection(); - KometApp.stateOf(context)?.applyThemeMode(item.mode); + KometApp.stateOf(context) + ?.applyThemeModeWithReveal(item.mode, position); }, ), ], @@ -98,11 +99,11 @@ class _ThemeModeCard extends StatelessWidget { } } -class _ModeTile extends StatelessWidget { +class _ModeTile extends StatefulWidget { final IconData icon; final String label; final bool selected; - final VoidCallback onTap; + final ValueChanged onTap; const _ModeTile({ required this.icon, @@ -111,23 +112,31 @@ class _ModeTile extends StatelessWidget { required this.onTap, }); + @override + State<_ModeTile> createState() => _ModeTileState(); +} + +class _ModeTileState extends State<_ModeTile> { + Offset _lastTapPosition = Offset.zero; + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Material( color: Colors.transparent, child: InkWell( - onTap: onTap, + onTapDown: (d) => _lastTapPosition = d.globalPosition, + onTap: () => widget.onTap(_lastTapPosition), borderRadius: BorderRadius.circular(16), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), child: Row( children: [ - Icon(icon, color: cs.onSurface, size: 22, weight: 500), + Icon(widget.icon, color: cs.onSurface, size: 22, weight: 500), const SizedBox(width: 14), Expanded( child: Text( - label, + widget.label, style: TextStyle( color: cs.onSurface, fontSize: 15, @@ -136,10 +145,12 @@ class _ModeTile extends StatelessWidget { ), ), Icon( - selected ? Symbols.radio_button_checked : Symbols.radio_button_unchecked, - color: selected ? cs.primary : cs.outline, + widget.selected + ? Symbols.radio_button_checked + : Symbols.radio_button_unchecked, + color: widget.selected ? cs.primary : cs.outline, size: 22, - fill: selected ? 1 : 0, + fill: widget.selected ? 1 : 0, ), ], ), @@ -149,54 +160,76 @@ class _ModeTile extends StatelessWidget { } } -class _AmoledCard extends StatelessWidget { +class _AmoledCard extends StatefulWidget { const _AmoledCard(); + @override + State<_AmoledCard> createState() => _AmoledCardState(); +} + +class _AmoledCardState extends State<_AmoledCard> { + Offset _lastPointerPosition = Offset.zero; + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return Material( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(28), - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 14, 12, 14), - child: Row( - children: [ - Icon(Symbols.contrast, color: cs.onSurface, size: 24, weight: 500), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'AMOLED-чёрный', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 2), - Text( - 'Чистый чёрный фон для OLED-экранов', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), - ), - ], + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (e) => _lastPointerPosition = e.position, + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(28), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 12, 14), + child: Row( + children: [ + Icon( + Symbols.contrast, + color: cs.onSurface, + size: 24, + weight: 500, ), - ), - ValueListenableBuilder( - valueListenable: AppAmoled.current, - builder: (context, value, _) { - return Switch( - value: value, - onChanged: (v) { - Haptics.selection(); - KometApp.stateOf(context)?.applyAmoled(v); - }, - ); - }, - ), - ], + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'AMOLED-чёрный', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'Чистый чёрный фон для OLED-экранов', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + ValueListenableBuilder( + valueListenable: AppAmoled.current, + builder: (context, value, _) { + return Switch( + value: value, + onChanged: (v) { + Haptics.selection(); + KometApp.stateOf(context)?.applyAmoledWithReveal( + v, + _lastPointerPosition, + ); + }, + ); + }, + ), + ], + ), ), ), ); diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart new file mode 100644 index 0000000..67d2a07 --- /dev/null +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -0,0 +1,614 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../core/config/app_message_actions_style.dart'; +import '../../core/utils/haptics.dart'; +import 'custom_notification.dart'; + +class MessageActionsController extends ChangeNotifier { + Offset? pointer; + Offset? initialPointer; + bool committed = false; + bool movedSignificantly = false; + bool _attached = false; + + void attach(Offset initial) { + if (_attached) return; + _attached = true; + initialPointer = initial; + pointer = initial; + GestureBinding.instance.pointerRouter.addGlobalRoute(_onPointerEvent); + } + + void updatePointer(Offset p) { + if (committed) return; + pointer = p; + if (initialPointer != null && + !movedSignificantly && + (p - initialPointer!).distance > 18) { + movedSignificantly = true; + } + notifyListeners(); + } + + void _onPointerEvent(PointerEvent event) { + if (committed) return; + if (event is PointerMoveEvent) { + updatePointer(event.position); + } else if (event is PointerUpEvent || event is PointerCancelEvent) { + commit(); + } + } + + void commit() { + if (committed) return; + committed = true; + notifyListeners(); + } + + @override + void dispose() { + if (_attached) { + GestureBinding.instance.pointerRouter.removeGlobalRoute(_onPointerEvent); + _attached = false; + } + super.dispose(); + } +} + +void showMessageActions({ + required BuildContext context, + required ui.Image snapshot, + required Rect originRect, + required Offset tapPoint, + required bool isMe, + required String? messageText, + required MessageActionsController controller, + required MessageActionsStyle style, + required VoidCallback onDispose, +}) { + final overlay = Overlay.of(context, rootOverlay: true); + late OverlayEntry entry; + entry = OverlayEntry( + builder: (ctx) => _MessageActionsLayer( + snapshot: snapshot, + originRect: originRect, + tapPoint: tapPoint, + isMe: isMe, + messageText: messageText, + controller: controller, + style: style, + onDismiss: () { + if (entry.mounted) entry.remove(); + onDispose(); + }, + ), + ); + overlay.insert(entry); +} + +class _MessageActionsLayer extends StatefulWidget { + final ui.Image snapshot; + final Rect originRect; + final Offset tapPoint; + final bool isMe; + final String? messageText; + final MessageActionsController controller; + final MessageActionsStyle style; + final VoidCallback onDismiss; + + const _MessageActionsLayer({ + required this.snapshot, + required this.originRect, + required this.tapPoint, + required this.isMe, + required this.messageText, + required this.controller, + required this.style, + required this.onDismiss, + }); + + @override + State<_MessageActionsLayer> createState() => _MessageActionsLayerState(); +} + +class _MessageActionsLayerState extends State<_MessageActionsLayer> + with SingleTickerProviderStateMixin { + late final AnimationController _animController; + late final Animation _animation; + bool _closing = false; + static const double _radius = 92.0; + static const double _arcSpan = math.pi * 0.62; + static const double _btnSize = 52.0; + static const double _hitRadius = 40.0; + static const double _hMargin = 8.0; + + late List<_Action> _actions; + bool _showBelow = true; + Offset _anchor = Offset.zero; + List _buttonCenters = const []; + List _buttonHitRects = const []; + Rect _menuRect = Rect.zero; + bool _initialized = false; + + int _hoveredIndex = -1; + bool _committedFired = false; + + @override + void initState() { + super.initState(); + _animController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 320), + reverseDuration: const Duration(milliseconds: 220), + ); + _animation = CurvedAnimation( + parent: _animController, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + _animController.forward(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_initialized) return; + _initialized = true; + _actions = _buildActions(); + final screenSize = MediaQuery.sizeOf(context); + if (widget.style == MessageActionsStyle.radial) { + _computeRadialGeometry(screenSize); + } else { + _computeListGeometry(screenSize); + } + widget.controller.addListener(_onControllerUpdate); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _onControllerUpdate(); + }); + } + + void _computeRadialGeometry(Size screenSize) { + _showBelow = widget.tapPoint.dy < screenSize.height * 0.55; + _anchor = _showBelow + ? Offset(widget.tapPoint.dx, widget.originRect.bottom + 16) + : Offset(widget.tapPoint.dx, widget.originRect.top - 16); + _buttonCenters = _computeButtonCenters(screenSize); + _buttonHitRects = [ + for (final c in _buttonCenters) + Rect.fromCenter(center: c, width: _hitRadius * 2, height: _hitRadius * 2), + ]; + } + + void _computeListGeometry(Size screenSize) { + final n = _actions.length; + const menuWidth = 244.0; + const itemHeight = 48.0; + const vPad = 8.0; + final menuHeight = n * itemHeight + vPad * 2; + final spaceBelow = screenSize.height - widget.originRect.bottom - 24; + final spaceAbove = widget.originRect.top - 24; + _showBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove; + final menuY = _showBelow + ? widget.originRect.bottom + 10 + : widget.originRect.top - 10 - menuHeight; + final rawX = widget.isMe + ? widget.originRect.right - menuWidth + : widget.originRect.left; + final menuX = rawX.clamp(8.0, screenSize.width - menuWidth - 8.0).toDouble(); + _menuRect = Rect.fromLTWH(menuX, menuY, menuWidth, menuHeight); + _buttonHitRects = [ + for (int i = 0; i < n; i++) + Rect.fromLTWH( + menuX, + menuY + vPad + i * itemHeight, + menuWidth, + itemHeight, + ), + ]; + } + + @override + void dispose() { + _animController.dispose(); + widget.controller.removeListener(_onControllerUpdate); + widget.snapshot.dispose(); + super.dispose(); + } + + List<_Action> _buildActions() { + final hasText = widget.messageText != null && widget.messageText!.isNotEmpty; + return <_Action>[ + if (hasText) _Action(Symbols.content_copy, 'Копировать', _copy), + _Action(Symbols.reply, 'Ответить', () => _stub('Ответ')), + _Action(Symbols.forward, 'Переслать', () => _stub('Пересылка')), + _Action( + Symbols.delete, + 'Удалить', + () => _stub('Удаление'), + destructive: true, + ), + ]; + } + + List _computeButtonCenters(Size screenSize) { + final n = _actions.length; + if (n == 0) return const []; + final base = _showBelow ? math.pi * 0.5 : -math.pi * 0.5; + final start = base - _arcSpan / 2; + final step = n == 1 ? 0.0 : _arcSpan / (n - 1); + final minX = _hMargin + _btnSize / 2; + final maxX = screenSize.width - _hMargin - _btnSize / 2; + return [ + for (int i = 0; i < n; i++) + () { + final angle = start + step * i; + var p = _anchor + Offset(math.cos(angle), math.sin(angle)) * _radius; + if (p.dx < minX) p = Offset(minX, p.dy); + if (p.dx > maxX) p = Offset(maxX, p.dy); + return p; + }(), + ]; + } + + void _onControllerUpdate() { + if (!mounted) return; + + final p = widget.controller.pointer; + if (p != null) { + final newHovered = _findButtonAt(p); + if (newHovered != _hoveredIndex) { + if (newHovered != -1) Haptics.selection(); + setState(() => _hoveredIndex = newHovered); + } + } + + if (widget.controller.committed && !_committedFired) { + _committedFired = true; + _onCommit(); + } + } + + int _findButtonAt(Offset p) { + for (int i = 0; i < _buttonHitRects.length; i++) { + if (_buttonHitRects[i].contains(p)) return i; + } + return -1; + } + + void _onCommit() { + if (_hoveredIndex != -1 && widget.controller.movedSignificantly) { + Haptics.medium(); + _actions[_hoveredIndex].onTap(); + } else if (widget.controller.movedSignificantly) { + _close(); + } + } + + Future _close() async { + if (!mounted || _closing) return; + _closing = true; + try { + await _animController.reverse(); + } catch (_) {} + if (!mounted) return; + widget.onDismiss(); + } + + Future _copy() async { + final text = widget.messageText; + if (text != null && text.isNotEmpty) { + await Clipboard.setData(ClipboardData(text: text)); + if (!mounted) return; + showCustomNotification(context, 'Скопировано'); + } + await _close(); + } + + Future _stub(String name) async { + if (!mounted) return; + showCustomNotification(context, '$name — пока в разработке'); + await _close(); + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + return AnimatedBuilder( + animation: _animation, + builder: (ctx, _) { + final t = _animation.value.clamp(0.0, 1.0); + final blurSigma = 14.0 * t; + final bubbleScale = 1.0 + 0.05 * t; + + return GestureDetector( + onTap: _close, + behavior: HitTestBehavior.opaque, + child: Stack( + children: [ + Positioned.fill( + child: BackdropFilter( + filter: ui.ImageFilter.blur( + sigmaX: blurSigma, + sigmaY: blurSigma, + ), + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.22 * t), + ), + ), + ), + Positioned( + left: widget.originRect.left, + top: widget.originRect.top, + width: widget.originRect.width, + height: widget.originRect.height, + child: Transform.scale( + scale: bubbleScale, + child: RawImage( + image: widget.snapshot, + width: widget.originRect.width, + height: widget.originRect.height, + fit: BoxFit.fill, + ), + ), + ), + if (widget.style == MessageActionsStyle.radial) ...[ + ..._buildButtons(t), + _buildLabelBanner(size, t), + ] else + _buildListMenu(t), + ], + ), + ); + }, + ); + } + + Widget _buildListMenu(double t) { + final cs = Theme.of(context).colorScheme; + final eased = Curves.easeOutCubic.transform(t); + final scale = 0.88 + 0.12 * eased; + return Positioned( + left: _menuRect.left, + top: _menuRect.top, + width: _menuRect.width, + height: _menuRect.height, + child: Opacity( + opacity: eased, + child: Transform.scale( + scale: scale, + alignment: Alignment( + widget.isMe ? 1.0 : -1.0, + _showBelow ? -1.0 : 1.0, + ), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(18), + clipBehavior: Clip.antiAlias, + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + for (int i = 0; i < _actions.length; i++) + _ListMenuItem( + action: _actions[i], + highlighted: _hoveredIndex == i, + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ), + ); + } + + List _buildButtons(double t) { + final n = _actions.length; + return [ + for (int i = 0; i < n; i++) + Builder( + builder: (_) { + final delay = (i / n) * 0.25; + final localT = ((t - delay) / (1.0 - delay)).clamp(0.0, 1.0); + final eased = Curves.easeOutBack.transform(localT); + final isHovered = _hoveredIndex == i; + final hoverScale = isHovered ? 1.18 : 1.0; + final entryScale = 0.4 + 0.6 * eased; + final centerAtFull = _buttonCenters[i]; + final centerAtT = _anchor + + (centerAtFull - _anchor) * eased; + + return Positioned( + left: centerAtT.dx - _btnSize / 2, + top: centerAtT.dy - _btnSize / 2, + width: _btnSize, + height: _btnSize, + child: Opacity( + opacity: localT, + child: Transform.scale( + scale: entryScale, + child: AnimatedScale( + scale: hoverScale, + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + child: _ActionButton( + action: _actions[i], + highlighted: isHovered, + ), + ), + ), + ), + ); + }, + ), + ]; + } + + Widget _buildLabelBanner(Size size, double t) { + final label = + _hoveredIndex == -1 ? null : _actions[_hoveredIndex].label; + final bottomInset = MediaQuery.paddingOf(context).bottom; + return Positioned( + left: 0, + right: 0, + bottom: bottomInset + 36, + child: IgnorePointer( + child: Center( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 140), + transitionBuilder: (child, anim) => FadeTransition( + opacity: anim, + child: ScaleTransition( + scale: Tween(begin: 0.85, end: 1.0).animate(anim), + child: child, + ), + ), + child: label == null + ? const SizedBox(key: ValueKey('empty'), height: 0) + : Container( + key: ValueKey('label_$label'), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 9, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.72 * t), + borderRadius: BorderRadius.circular(24), + ), + child: Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), + ); + } +} + +class _Action { + final IconData icon; + final String label; + final VoidCallback onTap; + final bool destructive; + const _Action( + this.icon, + this.label, + this.onTap, { + this.destructive = false, + }); +} + +class _ListMenuItem extends StatelessWidget { + final _Action action; + final bool highlighted; + const _ListMenuItem({required this.action, required this.highlighted}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final pillBg = action.destructive ? cs.error : cs.primary; + final onPill = action.destructive ? cs.onError : cs.onPrimary; + final restFg = action.destructive ? cs.error : cs.onSurface; + final fg = highlighted ? onPill : restFg; + return SizedBox( + height: 48, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Haptics.tap(); + action.onTap(); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + decoration: BoxDecoration( + color: highlighted ? pillBg : Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + Icon(action.icon, color: fg, size: 22), + const SizedBox(width: 14), + Expanded( + child: Text( + action.label, + style: TextStyle( + color: fg, + fontSize: 15, + fontWeight: + highlighted ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _ActionButton extends StatelessWidget { + final _Action action; + final bool highlighted; + const _ActionButton({ + required this.action, + required this.highlighted, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final bgColor = highlighted ? cs.primary : cs.surfaceContainerHighest; + final iconColor = highlighted ? cs.onPrimary : cs.onSurface; + return AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + decoration: BoxDecoration( + color: bgColor, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.32), + blurRadius: highlighted ? 14 : 8, + offset: const Offset(0, 3), + ), + ], + ), + child: Material( + color: Colors.transparent, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: () { + Haptics.tap(); + action.onTap(); + }, + child: Center( + child: Icon(action.icon, color: iconColor, size: 24), + ), + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/theme_reveal.dart b/lib/frontend/widgets/theme_reveal.dart new file mode 100644 index 0000000..3db78d0 --- /dev/null +++ b/lib/frontend/widgets/theme_reveal.dart @@ -0,0 +1,70 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; + +class ThemeRevealOverlay { + static OverlayEntry build({ + required ui.Image snapshot, + required Offset center, + required Animation animation, + }) { + return OverlayEntry( + builder: (ctx) { + final size = MediaQuery.sizeOf(ctx); + final maxRadius = _maxRadius(center, size); + return IgnorePointer( + child: AnimatedBuilder( + animation: animation, + builder: (_, __) { + final t = Curves.easeInOutCubic.transform( + animation.value.clamp(0.0, 1.0), + ); + return ClipPath( + clipper: _RevealClipper( + center: center, + radius: maxRadius * t, + ), + child: Opacity( + opacity: 1.0 - (t * t * t * t), + child: RawImage( + image: snapshot, + width: size.width, + height: size.height, + fit: BoxFit.fill, + ), + ), + ); + }, + ), + ); + }, + ); + } + + static double _maxRadius(Offset center, Size size) { + final dx = math.max(center.dx, size.width - center.dx); + final dy = math.max(center.dy, size.height - center.dy); + return math.sqrt(dx * dx + dy * dy); + } +} + +class _RevealClipper extends CustomClipper { + final Offset center; + final double radius; + + _RevealClipper({required this.center, required this.radius}); + + @override + Path getClip(Size size) { + final full = Path()..addRect(Offset.zero & size); + if (radius <= 0) return full; + final hole = Path() + ..addOval(Rect.fromCircle(center: center, radius: radius)); + return Path.combine(PathOperation.difference, full, hole); + } + + @override + bool shouldReclip(covariant _RevealClipper old) => + old.center != center || old.radius != radius; +} diff --git a/lib/main.dart b/lib/main.dart index 0e86ce3..cbf9d76 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,10 @@ import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui' as ui; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:komet/l10n/app_localizations.dart'; import 'package:m3e_collection/m3e_collection.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -13,6 +16,7 @@ import 'core/config/app_bubble_behavior.dart'; import 'core/config/app_bubble_shape.dart'; import 'core/config/app_cache_extent.dart'; import 'core/config/app_fonts.dart'; +import 'core/config/app_message_actions_style.dart'; import 'core/config/app_theme_mode.dart'; import 'core/config/app_theme_schedule.dart'; import 'backend/modules/account.dart'; @@ -31,6 +35,7 @@ import 'frontend/debug/fps_overlay_layer.dart'; import 'frontend/screens/auth/login_screen.dart'; import 'frontend/screens/chats/chat_list_screen.dart'; import 'frontend/widgets/custom_notification.dart'; +import 'frontend/widgets/theme_reveal.dart'; final api = Api(); final accountModule = AccountModule(api); @@ -86,6 +91,7 @@ void main() async { AppThemeModeConfig.current.value = await AppThemeModeConfig.load(); AppAmoled.current.value = await AppAmoled.load(); AppThemeSchedule.current.value = await AppThemeSchedule.load(); + AppMessageActionsStyle.current.value = await AppMessageActionsStyle.load(); runApp( KometApp( initialLocale: initialLocale, @@ -128,9 +134,15 @@ class KometApp extends StatefulWidget { State createState() => KometAppState(); } -class KometAppState extends State with WidgetsBindingObserver { +class KometAppState extends State + with WidgetsBindingObserver, TickerProviderStateMixin { static const _fallbackSeed = Color(0xFFC1C4FF); + final GlobalKey _captureBoundaryKey = GlobalKey(); + OverlayEntry? _revealEntry; + AnimationController? _revealController; + ui.Image? _revealImage; + late Locale _locale; late String _fontId; bool _isLoggingOut = false; @@ -239,6 +251,7 @@ class KometAppState extends State with WidgetsBindingObserver { @override void dispose() { + _finishReveal(); _sessionExpiredSub?.cancel(); _loginStatusSub?.cancel(); _vpnBypassSub?.cancel(); @@ -321,10 +334,89 @@ class KometAppState extends State with WidgetsBindingObserver { await AppThemeModeConfig.save(mode); } + void applyThemeModeWithReveal(AppThemeMode mode, Offset center) { + if (AppThemeModeConfig.current.value == mode) return; + _runThemeReveal(center, () => AppThemeModeConfig.save(mode)); + } + Future applyAmoled(bool value) async { await AppAmoled.save(value); } + void applyAmoledWithReveal(bool value, Offset center) { + if (AppAmoled.current.value == value) return; + _runThemeReveal(center, () => AppAmoled.save(value)); + } + + void _runThemeReveal(Offset center, Future Function() apply) { + final overlay = KometApp.navigatorKey.currentState?.overlay; + final ctx = _captureBoundaryKey.currentContext; + if (overlay == null || ctx == null) { + apply(); + return; + } + if (MediaQuery.disableAnimationsOf(ctx)) { + apply(); + return; + } + final renderObject = ctx.findRenderObject(); + if (renderObject is! RenderRepaintBoundary) { + apply(); + return; + } + + final ui.Image snapshot; + try { + final dpr = math.min(MediaQuery.of(ctx).devicePixelRatio, 2.0); + snapshot = renderObject.toImageSync(pixelRatio: dpr); + } catch (_) { + apply(); + return; + } + + _finishReveal(); + + final controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 650), + ); + final entry = ThemeRevealOverlay.build( + snapshot: snapshot, + center: center, + animation: controller, + ); + + _revealController = controller; + _revealEntry = entry; + _revealImage = snapshot; + + overlay.insert(entry); + apply(); + + WidgetsBinding.instance.endOfFrame.then((_) { + if (_revealController != controller) return; + controller.forward().then( + (_) { + if (_revealController != controller) return; + _finishReveal(); + }, + onError: (_) {}, + ); + }); + } + + void _finishReveal() { + _revealEntry?.remove(); + _revealEntry = null; + _revealController?.dispose(); + _revealController = null; + final img = _revealImage; + _revealImage = null; + if (img != null) { + WidgetsBinding.instance.addPostFrameCallback((_) => img.dispose()); + } + } + Future applyThemeSchedule(ThemeSchedule schedule) async { await AppThemeSchedule.save(schedule); } @@ -553,7 +645,10 @@ class KometAppState extends State with WidgetsBindingObserver { fit: StackFit.expand, clipBehavior: Clip.none, children: [ - sChild!, + RepaintBoundary( + key: _captureBoundaryKey, + child: sChild!, + ), if (fpsOn) const FpsOverlayLayer(), ], );