From 939dabbd8694aa5046152bf243b82b11238609ef Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 22 May 2026 18:17:56 +0000 Subject: [PATCH 01/15] =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20circular=20reveal=20=D0=BF=D1=80=D0=B8=20=D1=81?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=20=D1=82=D0=B5=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../profile/theme_settings_screen.dart | 137 +++++++++++------- lib/frontend/widgets/theme_reveal.dart | 70 +++++++++ lib/main.dart | 96 +++++++++++- 3 files changed, 249 insertions(+), 54 deletions(-) create mode 100644 lib/frontend/widgets/theme_reveal.dart 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/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..2931a62 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,6 @@ 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'; @@ -31,6 +33,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); @@ -128,9 +131,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 +248,7 @@ class KometAppState extends State with WidgetsBindingObserver { @override void dispose() { + _finishReveal(); _sessionExpiredSub?.cancel(); _loginStatusSub?.cancel(); _vpnBypassSub?.cancel(); @@ -321,10 +331,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 +642,10 @@ class KometAppState extends State with WidgetsBindingObserver { fit: StackFit.expand, clipBehavior: Clip.none, children: [ - sChild!, + RepaintBoundary( + key: _captureBoundaryKey, + child: sChild!, + ), if (fpsOn) const FpsOverlayLayer(), ], ); From ad3885cfb3f5f96a5689be239ca320676fe8afc6 Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 22 May 2026 18:43:31 +0000 Subject: [PATCH 02/15] =?UTF-8?q?fix:=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=20package:flutter/rendering.dart=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?RenderRepaintBoundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/main.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/main.dart b/lib/main.dart index 2931a62..b871ce2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ 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'; From f85cad55ee32d26a6f377022232c04c53bde2a6e Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 23 May 2026 04:20:59 +0000 Subject: [PATCH 03/15] =?UTF-8?q?long-press=20=D0=BD=D0=B0=20=D1=81=D0=BE?= =?UTF-8?q?=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B8:=20scale-up=20=D1=81?= =?UTF-8?q?=20blur=20=D0=B8=20=D1=80=D0=B0=D0=B4=D0=B8=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D0=BC=D0=B5=D0=BD=D1=8E=20=D0=B4=D0=B5?= =?UTF-8?q?=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/chats/chat_screen.dart | 75 +++++- .../widgets/message_actions_overlay.dart | 241 ++++++++++++++++++ 2 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 lib/frontend/widgets/message_actions_overlay.dart diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index c45bf37..fa7cbcb 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'; @@ -19,6 +21,7 @@ import '../../../core/utils/haptics.dart'; import '../../../core/config/app_cache_extent.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 +881,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 +1687,68 @@ 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(); + Offset _tapPoint = Offset.zero; + + void _onLongPressStart(LongPressStartDetails details) { + _tapPoint = details.globalPosition; + 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 dpr = MediaQuery.of(ctx).devicePixelRatio.clamp(1.0, 2.0); + + final ui.Image snapshot; + try { + snapshot = renderObject.toImageSync(pixelRatio: dpr); + } catch (_) { + return; + } + + Haptics.medium(); + Navigator.of(ctx).push( + MessageActionsRoute( + snapshot: snapshot, + originRect: rect, + tapPoint: _tapPoint, + isMe: widget.isMe, + messageText: widget.message.text, + ), + ); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.deferToChild, + onLongPressStart: _onLongPressStart, + child: RepaintBoundary( + key: _boundaryKey, + child: widget.child, + ), + ); + } +} + class _SentMessageAnimation extends StatefulWidget { final Widget child; final VoidCallback onComplete; diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart new file mode 100644 index 0000000..ec0f08d --- /dev/null +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -0,0 +1,241 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../core/utils/haptics.dart'; +import 'custom_notification.dart'; + +class MessageActionsRoute extends PageRouteBuilder { + MessageActionsRoute({ + required ui.Image snapshot, + required Rect originRect, + required Offset tapPoint, + required bool isMe, + required String? messageText, + }) : super( + opaque: false, + barrierColor: Colors.transparent, + transitionDuration: const Duration(milliseconds: 320), + reverseTransitionDuration: const Duration(milliseconds: 220), + pageBuilder: (ctx, anim, secondaryAnim) { + return _MessageActionsLayer( + snapshot: snapshot, + originRect: originRect, + tapPoint: tapPoint, + isMe: isMe, + messageText: messageText, + animation: CurvedAnimation( + parent: anim, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ), + ); + }, + transitionsBuilder: (_, __, ___, child) => child, + ); +} + +class _MessageActionsLayer extends StatefulWidget { + final ui.Image snapshot; + final Rect originRect; + final Offset tapPoint; + final bool isMe; + final String? messageText; + final Animation animation; + + const _MessageActionsLayer({ + required this.snapshot, + required this.originRect, + required this.tapPoint, + required this.isMe, + required this.messageText, + required this.animation, + }); + + @override + State<_MessageActionsLayer> createState() => _MessageActionsLayerState(); +} + +class _MessageActionsLayerState extends State<_MessageActionsLayer> { + @override + void dispose() { + widget.snapshot.dispose(); + super.dispose(); + } + + Future _close() async { + if (!mounted) return; + Navigator.of(context).maybePop(); + } + + 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(); + } + + 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('Удаление')), + ]; + } + + @override + Widget build(BuildContext context) { + final actions = _buildActions(); + final size = MediaQuery.sizeOf(context); + final showBelow = widget.tapPoint.dy < size.height * 0.55; + final anchor = showBelow + ? Offset(widget.tapPoint.dx, widget.originRect.bottom + 16) + : Offset(widget.tapPoint.dx, widget.originRect.top - 16); + + return AnimatedBuilder( + animation: widget.animation, + builder: (ctx, _) { + final t = widget.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, + ), + ), + ), + ..._buildRadialMenu(actions, anchor, showBelow, t, size), + ], + ), + ); + }, + ); + } + + List _buildRadialMenu( + List<_Action> actions, + Offset anchor, + bool below, + double t, + Size screenSize, + ) { + final n = actions.length; + if (n == 0) return const []; + const radius = 92.0; + const arcSpan = math.pi * 0.62; + final baseAngle = below ? math.pi * 0.5 : -math.pi * 0.5; + final startAngle = baseAngle - arcSpan / 2; + final step = n == 1 ? 0.0 : arcSpan / (n - 1); + const btnSize = 52.0; + const margin = 8.0; + + 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 angle = startAngle + step * i; + final rOffset = + Offset(math.cos(angle), math.sin(angle)) * radius * eased; + var pos = anchor + rOffset; + final minX = margin + btnSize / 2; + final maxX = screenSize.width - margin - btnSize / 2; + if (pos.dx < minX) pos = Offset(minX, pos.dy); + if (pos.dx > maxX) pos = Offset(maxX, pos.dy); + return Positioned( + left: pos.dx - btnSize / 2, + top: pos.dy - btnSize / 2, + width: btnSize, + height: btnSize, + child: Opacity( + opacity: localT, + child: Transform.scale( + scale: 0.4 + 0.6 * eased, + child: _ActionButton(action: actions[i]), + ), + ), + ); + }, + ), + ]; + } +} + +class _Action { + final IconData icon; + final String label; + final VoidCallback onTap; + const _Action(this.icon, this.label, this.onTap); +} + +class _ActionButton extends StatelessWidget { + final _Action action; + const _ActionButton({required this.action}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: cs.surfaceContainerHighest, + shape: const CircleBorder(), + elevation: 6, + shadowColor: Colors.black.withValues(alpha: 0.4), + child: InkWell( + customBorder: const CircleBorder(), + onTap: () { + Haptics.tap(); + action.onTap(); + }, + child: Tooltip( + message: action.label, + child: Center( + child: Icon(action.icon, color: cs.onSurface, size: 24), + ), + ), + ), + ); + } +} From 0d2c9826be183a15b1431ba9cccf647da4451460 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 23 May 2026 04:21:54 +0000 Subject: [PATCH 04/15] =?UTF-8?q?fix:=20cap=20pixelRatio=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20=D1=81=D1=80=D0=B0=D0=B2=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20.clamp()?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D1=82=D0=B8=D0=BF=D0=B0=20double?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/chats/chat_screen.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index fa7cbcb..e9fccf5 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1715,7 +1715,8 @@ class _LongPressBubbleState extends State<_LongPressBubble> { final origin = renderObject.localToGlobal(Offset.zero); final rect = origin & renderObject.size; - final dpr = MediaQuery.of(ctx).devicePixelRatio.clamp(1.0, 2.0); + final rawDpr = MediaQuery.of(ctx).devicePixelRatio; + final dpr = rawDpr > 2.0 ? 2.0 : rawDpr; final ui.Image snapshot; try { From 5aad5b9e02260bdd43a4192d95fc4b69a53a3d89 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 23 May 2026 05:13:23 +0000 Subject: [PATCH 05/15] =?UTF-8?q?drag-to-select=20=D0=BD=D0=B0=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B4=D0=B8=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=BC=20=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=8E:=20=D0=BF=D0=BE=D0=B4=D1=81=D0=B2=D0=B5?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20=D0=BF=D0=BE=D0=B4=20=D0=BF=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D1=86=D0=B5=D0=BC,=20=D0=BB=D0=B5=D0=B9=D0=B1=D0=BB,=20?= =?UTF-8?q?=D1=82=D0=B0=D0=BA=D1=82=D0=B8=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B8=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/chats/chat_screen.dart | 38 ++- .../widgets/message_actions_overlay.dart | 301 ++++++++++++++---- 2 files changed, 268 insertions(+), 71 deletions(-) diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index e9fccf5..01be3b8 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1704,10 +1704,16 @@ class _LongPressBubble extends StatefulWidget { class _LongPressBubbleState extends State<_LongPressBubble> { final GlobalKey _boundaryKey = GlobalKey(); - Offset _tapPoint = Offset.zero; + MessageActionsController? _controller; + + @override + void dispose() { + _controller?.commit(); + _controller = null; + super.dispose(); + } void _onLongPressStart(LongPressStartDetails details) { - _tapPoint = details.globalPosition; final ctx = _boundaryKey.currentContext; if (ctx == null) return; final renderObject = ctx.findRenderObject(); @@ -1726,15 +1732,35 @@ class _LongPressBubbleState extends State<_LongPressBubble> { } Haptics.medium(); - Navigator.of(ctx).push( + + final controller = MessageActionsController(); + _controller = controller; + + Navigator.of(ctx) + .push( MessageActionsRoute( snapshot: snapshot, originRect: rect, - tapPoint: _tapPoint, + tapPoint: details.globalPosition, isMe: widget.isMe, messageText: widget.message.text, + controller: controller, ), - ); + ) + .whenComplete(() { + if (identical(_controller, controller)) { + _controller = null; + } + controller.dispose(); + }); + } + + void _onLongPressMoveUpdate(LongPressMoveUpdateDetails details) { + _controller?.updatePointer(details.globalPosition); + } + + void _onLongPressEnd(LongPressEndDetails details) { + _controller?.commit(); } @override @@ -1742,6 +1768,8 @@ class _LongPressBubbleState extends State<_LongPressBubble> { return GestureDetector( behavior: HitTestBehavior.deferToChild, onLongPressStart: _onLongPressStart, + onLongPressMoveUpdate: _onLongPressMoveUpdate, + onLongPressEnd: _onLongPressEnd, child: RepaintBoundary( key: _boundaryKey, child: widget.child, diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index ec0f08d..83df990 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -8,6 +8,28 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../core/utils/haptics.dart'; import 'custom_notification.dart'; +class MessageActionsController extends ChangeNotifier { + Offset? pointer; + Offset? initialPointer; + bool committed = false; + bool movedSignificantly = false; + + void updatePointer(Offset p) { + initialPointer ??= p; + pointer = p; + if (!movedSignificantly && (p - initialPointer!).distance > 18) { + movedSignificantly = true; + } + notifyListeners(); + } + + void commit() { + if (committed) return; + committed = true; + notifyListeners(); + } +} + class MessageActionsRoute extends PageRouteBuilder { MessageActionsRoute({ required ui.Image snapshot, @@ -15,6 +37,7 @@ class MessageActionsRoute extends PageRouteBuilder { required Offset tapPoint, required bool isMe, required String? messageText, + required MessageActionsController controller, }) : super( opaque: false, barrierColor: Colors.transparent, @@ -27,6 +50,7 @@ class MessageActionsRoute extends PageRouteBuilder { tapPoint: tapPoint, isMe: isMe, messageText: messageText, + controller: controller, animation: CurvedAnimation( parent: anim, curve: Curves.easeOutCubic, @@ -44,6 +68,7 @@ class _MessageActionsLayer extends StatefulWidget { final Offset tapPoint; final bool isMe; final String? messageText; + final MessageActionsController controller; final Animation animation; const _MessageActionsLayer({ @@ -52,6 +77,7 @@ class _MessageActionsLayer extends StatefulWidget { required this.tapPoint, required this.isMe, required this.messageText, + required this.controller, required this.animation, }); @@ -60,12 +86,112 @@ class _MessageActionsLayer extends StatefulWidget { } class _MessageActionsLayerState extends State<_MessageActionsLayer> { + 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 []; + bool _initialized = false; + + int _hoveredIndex = -1; + bool _tapMode = false; + bool _committedFired = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_initialized) return; + _initialized = true; + _actions = _buildActions(); + final screenSize = MediaQuery.sizeOf(context); + _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); + widget.controller.addListener(_onControllerUpdate); + } + @override void 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('Удаление')), + ]; + } + + 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 || _tapMode) 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 < _buttonCenters.length; i++) { + if ((_buttonCenters[i] - p).distance <= _hitRadius) { + return i; + } + } + return -1; + } + + void _onCommit() { + if (_hoveredIndex != -1) { + Haptics.medium(); + _actions[_hoveredIndex].onTap(); + } else if (widget.controller.movedSignificantly) { + _close(); + } else { + setState(() => _tapMode = true); + } + } + Future _close() async { if (!mounted) return; Navigator.of(context).maybePop(); @@ -87,25 +213,9 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> { await _close(); } - 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('Удаление')), - ]; - } - @override Widget build(BuildContext context) { - final actions = _buildActions(); final size = MediaQuery.sizeOf(context); - final showBelow = widget.tapPoint.dy < size.height * 0.55; - final anchor = showBelow - ? Offset(widget.tapPoint.dx, widget.originRect.bottom + 16) - : Offset(widget.tapPoint.dx, widget.originRect.top - 16); - return AnimatedBuilder( animation: widget.animation, builder: (ctx, _) { @@ -114,7 +224,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> { final bubbleScale = 1.0 + 0.05 * t; return GestureDetector( - onTap: _close, + onTap: _tapMode ? _close : null, behavior: HitTestBehavior.opaque, child: Stack( children: [ @@ -144,7 +254,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> { ), ), ), - ..._buildRadialMenu(actions, anchor, showBelow, t, size), + ..._buildButtons(t), + _buildLabelBanner(size, t), ], ), ); @@ -152,49 +263,41 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> { ); } - List _buildRadialMenu( - List<_Action> actions, - Offset anchor, - bool below, - double t, - Size screenSize, - ) { - final n = actions.length; - if (n == 0) return const []; - const radius = 92.0; - const arcSpan = math.pi * 0.62; - final baseAngle = below ? math.pi * 0.5 : -math.pi * 0.5; - final startAngle = baseAngle - arcSpan / 2; - final step = n == 1 ? 0.0 : arcSpan / (n - 1); - const btnSize = 52.0; - const margin = 8.0; - + 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 localT = ((t - delay) / (1.0 - delay)).clamp(0.0, 1.0); final eased = Curves.easeOutBack.transform(localT); - final angle = startAngle + step * i; - final rOffset = - Offset(math.cos(angle), math.sin(angle)) * radius * eased; - var pos = anchor + rOffset; - final minX = margin + btnSize / 2; - final maxX = screenSize.width - margin - btnSize / 2; - if (pos.dx < minX) pos = Offset(minX, pos.dy); - if (pos.dx > maxX) pos = Offset(maxX, pos.dy); + 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: pos.dx - btnSize / 2, - top: pos.dy - btnSize / 2, - width: btnSize, - height: btnSize, + left: centerAtT.dx - _btnSize / 2, + top: centerAtT.dy - _btnSize / 2, + width: _btnSize, + height: _btnSize, child: Opacity( opacity: localT, child: Transform.scale( - scale: 0.4 + 0.6 * eased, - child: _ActionButton(action: actions[i]), + scale: entryScale, + child: AnimatedScale( + scale: hoverScale, + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + child: _ActionButton( + action: _actions[i], + highlighted: isHovered, + tapEnabled: _tapMode, + ), + ), ), ), ); @@ -202,6 +305,52 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> { ), ]; } + + 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 { @@ -213,26 +362,46 @@ class _Action { class _ActionButton extends StatelessWidget { final _Action action; - const _ActionButton({required this.action}); + final bool highlighted; + final bool tapEnabled; + const _ActionButton({ + required this.action, + required this.highlighted, + required this.tapEnabled, + }); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return Material( - color: cs.surfaceContainerHighest, - shape: const CircleBorder(), - elevation: 6, - shadowColor: Colors.black.withValues(alpha: 0.4), - child: InkWell( - customBorder: const CircleBorder(), - onTap: () { - Haptics.tap(); - action.onTap(); - }, - child: Tooltip( - message: action.label, + 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: tapEnabled + ? () { + Haptics.tap(); + action.onTap(); + } + : null, child: Center( - child: Icon(action.icon, color: cs.onSurface, size: 24), + child: Icon(action.icon, color: iconColor, size: 24), ), ), ), From eb77ad791d042af2dd6729eb8b15d2feff1d2308 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 23 May 2026 05:32:38 +0000 Subject: [PATCH 06/15] =?UTF-8?q?fix:=20=D0=BB=D0=BE=D0=B2=D0=B8=D0=BC=20p?= =?UTF-8?q?ointer=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D1=8F=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20Listener=20=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=20onLongPressMoveUpdate=20(route=20=D0=B3=D0=BB?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D0=BB=20events)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/chats/chat_screen.dart | 33 ++++++++++--------- .../widgets/message_actions_overlay.dart | 3 ++ 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 01be3b8..216c7c9 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1734,6 +1734,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> { Haptics.medium(); final controller = MessageActionsController(); + controller.updatePointer(details.globalPosition); _controller = controller; Navigator.of(ctx) @@ -1755,24 +1756,26 @@ class _LongPressBubbleState extends State<_LongPressBubble> { }); } - void _onLongPressMoveUpdate(LongPressMoveUpdateDetails details) { - _controller?.updatePointer(details.globalPosition); - } - - void _onLongPressEnd(LongPressEndDetails details) { - _controller?.commit(); - } - @override Widget build(BuildContext context) { - return GestureDetector( + return Listener( behavior: HitTestBehavior.deferToChild, - onLongPressStart: _onLongPressStart, - onLongPressMoveUpdate: _onLongPressMoveUpdate, - onLongPressEnd: _onLongPressEnd, - child: RepaintBoundary( - key: _boundaryKey, - child: widget.child, + onPointerMove: (event) { + _controller?.updatePointer(event.position); + }, + onPointerUp: (event) { + _controller?.commit(); + }, + onPointerCancel: (event) { + _controller?.commit(); + }, + child: GestureDetector( + behavior: HitTestBehavior.deferToChild, + onLongPressStart: _onLongPressStart, + child: RepaintBoundary( + key: _boundaryKey, + child: widget.child, + ), ), ); } diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 83df990..c3e7b66 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -115,6 +115,9 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> { : Offset(widget.tapPoint.dx, widget.originRect.top - 16); _buttonCenters = _computeButtonCenters(screenSize); widget.controller.addListener(_onControllerUpdate); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _onControllerUpdate(); + }); } @override From 0791acc44987eb85691dd5d088c83c440d668a39 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 23 May 2026 05:49:21 +0000 Subject: [PATCH 07/15] =?UTF-8?q?fix:=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=20=D0=BD=D0=B0=20pointer=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20GestureBinding.pointerRouter=20=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20Listener=20(=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20route=20=D0=BE=D1=82?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D0=B0=D0=BB=20hit-=D1=86=D0=B5=D0=BF=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D1=83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/chats/chat_screen.dart | 16 +++----- .../widgets/message_actions_overlay.dart | 39 ++++++++++++++++--- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 216c7c9..d82a6be 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1705,6 +1705,7 @@ class _LongPressBubble extends StatefulWidget { class _LongPressBubbleState extends State<_LongPressBubble> { final GlobalKey _boundaryKey = GlobalKey(); MessageActionsController? _controller; + int? _lastPointerId; @override void dispose() { @@ -1714,6 +1715,9 @@ class _LongPressBubbleState extends State<_LongPressBubble> { } void _onLongPressStart(LongPressStartDetails details) { + final pointerId = _lastPointerId; + if (pointerId == null) return; + final ctx = _boundaryKey.currentContext; if (ctx == null) return; final renderObject = ctx.findRenderObject(); @@ -1734,7 +1738,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> { Haptics.medium(); final controller = MessageActionsController(); - controller.updatePointer(details.globalPosition); + controller.attach(pointerId, details.globalPosition); _controller = controller; Navigator.of(ctx) @@ -1760,15 +1764,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> { Widget build(BuildContext context) { return Listener( behavior: HitTestBehavior.deferToChild, - onPointerMove: (event) { - _controller?.updatePointer(event.position); - }, - onPointerUp: (event) { - _controller?.commit(); - }, - onPointerCancel: (event) { - _controller?.commit(); - }, + onPointerDown: (event) => _lastPointerId = event.pointer, child: GestureDetector( behavior: HitTestBehavior.deferToChild, onLongPressStart: _onLongPressStart, diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index c3e7b66..39c4d5d 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -9,18 +9,34 @@ import '../../core/utils/haptics.dart'; import 'custom_notification.dart'; class MessageActionsController extends ChangeNotifier { + int? _pointerId; Offset? pointer; Offset? initialPointer; bool committed = false; bool movedSignificantly = false; - void updatePointer(Offset p) { - initialPointer ??= p; - pointer = p; - if (!movedSignificantly && (p - initialPointer!).distance > 18) { - movedSignificantly = true; + void attach(int pointerId, Offset initial) { + if (_pointerId != null) return; + _pointerId = pointerId; + initialPointer = initial; + pointer = initial; + GestureBinding.instance.pointerRouter + .addRoute(pointerId, _onPointerEvent); + } + + void _onPointerEvent(PointerEvent event) { + if (committed) return; + if (event is PointerMoveEvent) { + pointer = event.position; + if (initialPointer != null && + !movedSignificantly && + (event.position - initialPointer!).distance > 18) { + movedSignificantly = true; + } + notifyListeners(); + } else if (event is PointerUpEvent || event is PointerCancelEvent) { + commit(); } - notifyListeners(); } void commit() { @@ -28,6 +44,17 @@ class MessageActionsController extends ChangeNotifier { committed = true; notifyListeners(); } + + @override + void dispose() { + final id = _pointerId; + if (id != null) { + GestureBinding.instance.pointerRouter + .removeRoute(id, _onPointerEvent); + _pointerId = null; + } + super.dispose(); + } } class MessageActionsRoute extends PageRouteBuilder { From 3e3ff6d07bb72341f7e5a5df3433580456b23bc1 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 23 May 2026 05:54:00 +0000 Subject: [PATCH 08/15] =?UTF-8?q?fix:=20=D1=8F=D0=B2=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=20package:flutter/gestures?= =?UTF-8?q?.dart=20=D0=B4=D0=BB=D1=8F=20GestureBinding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/widgets/message_actions_overlay.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 39c4d5d..554544d 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -1,6 +1,7 @@ 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'; From 561cea38cbe3326667e13a53a7686338e53135c2 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 23 May 2026 06:20:36 +0000 Subject: [PATCH 09/15] =?UTF-8?q?fix:=20addGlobalRoute=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20addRoute=20(=D0=BD=D0=B0=20iOS=20?= =?UTF-8?q?=D0=B2=D0=B8=D0=B4=D0=B8=D0=BC=D0=BE=20=D0=B0=D0=B4=D1=80=D0=B5?= =?UTF-8?q?=D1=81=D0=BD=D0=B0=D1=8F=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BD=D0=B5=20=D0=B4=D0=BE=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D1=8F=D0=BB=D0=B0=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82?= =?UTF-8?q?=D0=B8=D1=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/chats/chat_screen.dart | 2 +- .../widgets/message_actions_overlay.dart | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index d82a6be..f2937d2 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1738,7 +1738,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> { Haptics.medium(); final controller = MessageActionsController(); - controller.attach(pointerId, details.globalPosition); + controller.attach(details.globalPosition, pointerId: pointerId); _controller = controller; Navigator.of(ctx) diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 554544d..90bd96d 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -15,18 +15,20 @@ class MessageActionsController extends ChangeNotifier { Offset? initialPointer; bool committed = false; bool movedSignificantly = false; + bool _attached = false; - void attach(int pointerId, Offset initial) { - if (_pointerId != null) return; + void attach(Offset initial, {int? pointerId}) { + if (_attached) return; + _attached = true; _pointerId = pointerId; initialPointer = initial; pointer = initial; - GestureBinding.instance.pointerRouter - .addRoute(pointerId, _onPointerEvent); + GestureBinding.instance.pointerRouter.addGlobalRoute(_onPointerEvent); } void _onPointerEvent(PointerEvent event) { if (committed) return; + if (_pointerId != null && event.pointer != _pointerId) return; if (event is PointerMoveEvent) { pointer = event.position; if (initialPointer != null && @@ -48,11 +50,9 @@ class MessageActionsController extends ChangeNotifier { @override void dispose() { - final id = _pointerId; - if (id != null) { - GestureBinding.instance.pointerRouter - .removeRoute(id, _onPointerEvent); - _pointerId = null; + if (_attached) { + GestureBinding.instance.pointerRouter.removeGlobalRoute(_onPointerEvent); + _attached = false; } super.dispose(); } From bf7eb04e7f943a39b4eb5e5506c29e52218e4af6 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 23 May 2026 06:44:18 +0000 Subject: [PATCH 10/15] =?UTF-8?q?=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B2=D1=81=D0=B5=D0=B3=D0=B4=D0=B0=20=D1=82=D0=B0=D0=BF=D0=B0?= =?UTF-8?q?=D0=B1=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=20+=20=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BD=D0=BE=D0=B9=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20drag-to-select?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/chats/chat_screen.dart | 13 +++--- .../widgets/message_actions_overlay.dart | 46 ++++++++----------- 2 files changed, 27 insertions(+), 32 deletions(-) diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index f2937d2..dd44b50 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1705,7 +1705,6 @@ class _LongPressBubble extends StatefulWidget { class _LongPressBubbleState extends State<_LongPressBubble> { final GlobalKey _boundaryKey = GlobalKey(); MessageActionsController? _controller; - int? _lastPointerId; @override void dispose() { @@ -1715,9 +1714,6 @@ class _LongPressBubbleState extends State<_LongPressBubble> { } void _onLongPressStart(LongPressStartDetails details) { - final pointerId = _lastPointerId; - if (pointerId == null) return; - final ctx = _boundaryKey.currentContext; if (ctx == null) return; final renderObject = ctx.findRenderObject(); @@ -1738,7 +1734,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> { Haptics.medium(); final controller = MessageActionsController(); - controller.attach(details.globalPosition, pointerId: pointerId); + controller.attach(details.globalPosition); _controller = controller; Navigator.of(ctx) @@ -1764,10 +1760,15 @@ class _LongPressBubbleState extends State<_LongPressBubble> { Widget build(BuildContext context) { return Listener( behavior: HitTestBehavior.deferToChild, - onPointerDown: (event) => _lastPointerId = event.pointer, + 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, diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 90bd96d..38801db 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -10,33 +10,35 @@ import '../../core/utils/haptics.dart'; import 'custom_notification.dart'; class MessageActionsController extends ChangeNotifier { - int? _pointerId; Offset? pointer; Offset? initialPointer; bool committed = false; bool movedSignificantly = false; bool _attached = false; - void attach(Offset initial, {int? pointerId}) { + void attach(Offset initial) { if (_attached) return; _attached = true; - _pointerId = pointerId; 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 (_pointerId != null && event.pointer != _pointerId) return; if (event is PointerMoveEvent) { - pointer = event.position; - if (initialPointer != null && - !movedSignificantly && - (event.position - initialPointer!).distance > 18) { - movedSignificantly = true; - } - notifyListeners(); + updatePointer(event.position); } else if (event is PointerUpEvent || event is PointerCancelEvent) { commit(); } @@ -127,7 +129,6 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> { bool _initialized = false; int _hoveredIndex = -1; - bool _tapMode = false; bool _committedFired = false; @override @@ -186,7 +187,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> { } void _onControllerUpdate() { - if (!mounted || _tapMode) return; + if (!mounted) return; final p = widget.controller.pointer; if (p != null) { @@ -213,13 +214,11 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> { } void _onCommit() { - if (_hoveredIndex != -1) { + if (_hoveredIndex != -1 && widget.controller.movedSignificantly) { Haptics.medium(); _actions[_hoveredIndex].onTap(); } else if (widget.controller.movedSignificantly) { _close(); - } else { - setState(() => _tapMode = true); } } @@ -255,7 +254,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> { final bubbleScale = 1.0 + 0.05 * t; return GestureDetector( - onTap: _tapMode ? _close : null, + onTap: _close, behavior: HitTestBehavior.opaque, child: Stack( children: [ @@ -326,7 +325,6 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> { child: _ActionButton( action: _actions[i], highlighted: isHovered, - tapEnabled: _tapMode, ), ), ), @@ -394,11 +392,9 @@ class _Action { class _ActionButton extends StatelessWidget { final _Action action; final bool highlighted; - final bool tapEnabled; const _ActionButton({ required this.action, required this.highlighted, - required this.tapEnabled, }); @override @@ -425,12 +421,10 @@ class _ActionButton extends StatelessWidget { shape: const CircleBorder(), child: InkWell( customBorder: const CircleBorder(), - onTap: tapEnabled - ? () { - Haptics.tap(); - action.onTap(); - } - : null, + onTap: () { + Haptics.tap(); + action.onTap(); + }, child: Center( child: Icon(action.icon, color: iconColor, size: 24), ), From ff7bc8c1f0a535dffd9f72795fc625a5f58da52b Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 23 May 2026 07:04:04 +0000 Subject: [PATCH 11/15] =?UTF-8?q?debug:=20=D0=B2=D0=B8=D0=B7=D1=83=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=B7?= =?UTF-8?q?=D0=B8=D1=86=D0=B8=D0=B8=20=D0=BF=D0=B0=D0=BB=D1=8C=D1=86=D0=B0?= =?UTF-8?q?,=20=D1=86=D0=B5=D0=BD=D1=82=D1=80=D0=BE=D0=B2=20=D0=BA=D0=BD?= =?UTF-8?q?=D0=BE=D0=BF=D0=BE=D0=BA=20=D0=B8=20=D1=81=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=8F=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BB=D0=BB=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/message_actions_overlay.dart | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 38801db..80e6ae2 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -286,6 +286,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> { ), ..._buildButtons(t), _buildLabelBanner(size, t), + _buildDebugOverlay(), ], ), ); @@ -293,6 +294,23 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> { ); } + Widget _buildDebugOverlay() { + return Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: _DebugPainter( + pointerPosition: widget.controller.pointer, + initialPointer: widget.controller.initialPointer, + buttonCenters: _buttonCenters, + hoveredIndex: _hoveredIndex, + committed: widget.controller.committed, + moved: widget.controller.movedSignificantly, + ), + ), + ), + ); + } + List _buildButtons(double t) { final n = _actions.length; return [ @@ -382,6 +400,79 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> { } } +class _DebugPainter extends CustomPainter { + final Offset? pointerPosition; + final Offset? initialPointer; + final List buttonCenters; + final int hoveredIndex; + final bool committed; + final bool moved; + + _DebugPainter({ + required this.pointerPosition, + required this.initialPointer, + required this.buttonCenters, + required this.hoveredIndex, + required this.committed, + required this.moved, + }); + + @override + void paint(Canvas canvas, Size size) { + final centerPaint = Paint()..color = const Color(0xFFFFFFFF); + final centerBorder = Paint() + ..color = const Color(0xFF000000) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + for (var i = 0; i < buttonCenters.length; i++) { + canvas.drawCircle(buttonCenters[i], 5, centerPaint); + canvas.drawCircle(buttonCenters[i], 5, centerBorder); + } + + final initial = initialPointer; + if (initial != null) { + final paint = Paint()..color = const Color(0xFFFFEB3B); + canvas.drawCircle(initial, 8, paint); + } + + final p = pointerPosition; + if (p != null) { + final paint = Paint()..color = const Color(0xFFFF1744); + canvas.drawCircle(p, 14, paint); + final border = Paint() + ..color = const Color(0xFFFFFFFF) + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + canvas.drawCircle(p, 14, border); + } + + final textPainter = TextPainter( + text: TextSpan( + text: + 'h=$hoveredIndex c=$committed mov=$moved\n' + 'p=${pointerPosition?.dx.toStringAsFixed(0)},${pointerPosition?.dy.toStringAsFixed(0)}', + style: const TextStyle( + color: Color(0xFFFFFFFF), + fontSize: 14, + fontWeight: FontWeight.w700, + backgroundColor: Color(0xCC000000), + ), + ), + textDirection: TextDirection.ltr, + ); + textPainter.layout(); + textPainter.paint(canvas, const Offset(12, 60)); + } + + @override + bool shouldRepaint(covariant _DebugPainter old) { + return old.pointerPosition != pointerPosition || + old.hoveredIndex != hoveredIndex || + old.committed != committed || + old.moved != moved; + } +} + class _Action { final IconData icon; final String label; From b3e30217ddec984415f54d03b6beca30176aa9b8 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 23 May 2026 07:22:53 +0000 Subject: [PATCH 12/15] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=20=D1=81=20Navigator.push=20=D0=BD=D0=B0=20OverlayEntry:?= =?UTF-8?q?=20route=20=D0=BC=D0=BE=D0=B4=D0=B0=D0=BB=20=D0=B3=D0=BB=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=20pointer=20events=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=20cancel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/chats/chat_screen.dart | 32 +++---- .../widgets/message_actions_overlay.dart | 95 ++++++++++++------- 2 files changed, 74 insertions(+), 53 deletions(-) diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index dd44b50..3fb8bdb 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1737,23 +1737,21 @@ class _LongPressBubbleState extends State<_LongPressBubble> { controller.attach(details.globalPosition); _controller = controller; - Navigator.of(ctx) - .push( - MessageActionsRoute( - snapshot: snapshot, - originRect: rect, - tapPoint: details.globalPosition, - isMe: widget.isMe, - messageText: widget.message.text, - controller: controller, - ), - ) - .whenComplete(() { - if (identical(_controller, controller)) { - _controller = null; - } - controller.dispose(); - }); + showMessageActions( + context: ctx, + snapshot: snapshot, + originRect: rect, + tapPoint: details.globalPosition, + isMe: widget.isMe, + messageText: widget.message.text, + controller: controller, + onDispose: () { + if (identical(_controller, controller)) { + _controller = null; + } + controller.dispose(); + }, + ); } @override diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 80e6ae2..dc6b3b6 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -60,36 +60,33 @@ class MessageActionsController extends ChangeNotifier { } } -class MessageActionsRoute extends PageRouteBuilder { - MessageActionsRoute({ - required ui.Image snapshot, - required Rect originRect, - required Offset tapPoint, - required bool isMe, - required String? messageText, - required MessageActionsController controller, - }) : super( - opaque: false, - barrierColor: Colors.transparent, - transitionDuration: const Duration(milliseconds: 320), - reverseTransitionDuration: const Duration(milliseconds: 220), - pageBuilder: (ctx, anim, secondaryAnim) { - return _MessageActionsLayer( - snapshot: snapshot, - originRect: originRect, - tapPoint: tapPoint, - isMe: isMe, - messageText: messageText, - controller: controller, - animation: CurvedAnimation( - parent: anim, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ), - ); - }, - transitionsBuilder: (_, __, ___, child) => child, - ); +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 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, + onDismiss: () { + if (entry.mounted) entry.remove(); + onDispose(); + }, + ), + ); + overlay.insert(entry); } class _MessageActionsLayer extends StatefulWidget { @@ -99,7 +96,7 @@ class _MessageActionsLayer extends StatefulWidget { final bool isMe; final String? messageText; final MessageActionsController controller; - final Animation animation; + final VoidCallback onDismiss; const _MessageActionsLayer({ required this.snapshot, @@ -108,14 +105,18 @@ class _MessageActionsLayer extends StatefulWidget { required this.isMe, required this.messageText, required this.controller, - required this.animation, + required this.onDismiss, }); @override State<_MessageActionsLayer> createState() => _MessageActionsLayerState(); } -class _MessageActionsLayerState extends State<_MessageActionsLayer> { +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; @@ -131,6 +132,22 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> { 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(); @@ -151,6 +168,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> { @override void dispose() { + _animController.dispose(); widget.controller.removeListener(_onControllerUpdate); widget.snapshot.dispose(); super.dispose(); @@ -223,8 +241,13 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> { } Future _close() async { + if (!mounted || _closing) return; + _closing = true; + try { + await _animController.reverse(); + } catch (_) {} if (!mounted) return; - Navigator.of(context).maybePop(); + widget.onDismiss(); } Future _copy() async { @@ -247,9 +270,9 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> { Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); return AnimatedBuilder( - animation: widget.animation, + animation: _animation, builder: (ctx, _) { - final t = widget.animation.value.clamp(0.0, 1.0); + final t = _animation.value.clamp(0.0, 1.0); final blurSigma = 14.0 * t; final bubbleScale = 1.0 + 0.05 * t; From efd15898ffb0ba0697078f2d8e87f1c41c724d7d Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 23 May 2026 07:37:14 +0000 Subject: [PATCH 13/15] =?UTF-8?q?=D1=83=D0=B1=D0=B8=D1=80=D0=B0=D1=8E=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BB=D0=B0=D0=B4=D0=BE=D1=87=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?painter=20=D1=81=20=D0=BF=D0=BE=D0=B7=D0=B8=D1=86=D0=B8=D0=B5?= =?UTF-8?q?=D0=B9=20=D0=BF=D0=B0=D0=BB=D1=8C=D1=86=D0=B0=20=D0=B8=20=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D1=80=D0=B0=D0=BC=D0=B8=20=D0=BA=D0=BD=D0=BE?= =?UTF-8?q?=D0=BF=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/message_actions_overlay.dart | 91 ------------------- 1 file changed, 91 deletions(-) diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index dc6b3b6..3335240 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -309,7 +309,6 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ), ..._buildButtons(t), _buildLabelBanner(size, t), - _buildDebugOverlay(), ], ), ); @@ -317,23 +316,6 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ); } - Widget _buildDebugOverlay() { - return Positioned.fill( - child: IgnorePointer( - child: CustomPaint( - painter: _DebugPainter( - pointerPosition: widget.controller.pointer, - initialPointer: widget.controller.initialPointer, - buttonCenters: _buttonCenters, - hoveredIndex: _hoveredIndex, - committed: widget.controller.committed, - moved: widget.controller.movedSignificantly, - ), - ), - ), - ); - } - List _buildButtons(double t) { final n = _actions.length; return [ @@ -423,79 +405,6 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> } } -class _DebugPainter extends CustomPainter { - final Offset? pointerPosition; - final Offset? initialPointer; - final List buttonCenters; - final int hoveredIndex; - final bool committed; - final bool moved; - - _DebugPainter({ - required this.pointerPosition, - required this.initialPointer, - required this.buttonCenters, - required this.hoveredIndex, - required this.committed, - required this.moved, - }); - - @override - void paint(Canvas canvas, Size size) { - final centerPaint = Paint()..color = const Color(0xFFFFFFFF); - final centerBorder = Paint() - ..color = const Color(0xFF000000) - ..style = PaintingStyle.stroke - ..strokeWidth = 1.5; - for (var i = 0; i < buttonCenters.length; i++) { - canvas.drawCircle(buttonCenters[i], 5, centerPaint); - canvas.drawCircle(buttonCenters[i], 5, centerBorder); - } - - final initial = initialPointer; - if (initial != null) { - final paint = Paint()..color = const Color(0xFFFFEB3B); - canvas.drawCircle(initial, 8, paint); - } - - final p = pointerPosition; - if (p != null) { - final paint = Paint()..color = const Color(0xFFFF1744); - canvas.drawCircle(p, 14, paint); - final border = Paint() - ..color = const Color(0xFFFFFFFF) - ..style = PaintingStyle.stroke - ..strokeWidth = 2; - canvas.drawCircle(p, 14, border); - } - - final textPainter = TextPainter( - text: TextSpan( - text: - 'h=$hoveredIndex c=$committed mov=$moved\n' - 'p=${pointerPosition?.dx.toStringAsFixed(0)},${pointerPosition?.dy.toStringAsFixed(0)}', - style: const TextStyle( - color: Color(0xFFFFFFFF), - fontSize: 14, - fontWeight: FontWeight.w700, - backgroundColor: Color(0xCC000000), - ), - ), - textDirection: TextDirection.ltr, - ); - textPainter.layout(); - textPainter.paint(canvas, const Offset(12, 60)); - } - - @override - bool shouldRepaint(covariant _DebugPainter old) { - return old.pointerPosition != pointerPosition || - old.hoveredIndex != hoveredIndex || - old.committed != committed || - old.moved != moved; - } -} - class _Action { final IconData icon; final String label; From d963af407d11d35fa1d95438a3b7b12a861652ce Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 23 May 2026 08:22:05 +0000 Subject: [PATCH 14/15] =?UTF-8?q?=D0=B2=D1=82=D0=BE=D1=80=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D0=BB=D1=8C=20=D0=BC=D0=B5=D0=BD=D1=8E=20?= =?UTF-8?q?=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D0=B9:=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20(=D0=BA=D0=B0=D0=BA=20=D0=B2?= =?UTF-8?q?=20telegram);=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=B2=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=81=D1=82=D0=BE=D0=BC=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE.md | 47 +++++ .../config/app_message_actions_style.dart | 36 ++++ lib/frontend/screens/chats/chat_screen.dart | 2 + .../screens/profile/customization_screen.dart | 10 + .../profile/message_actions_screen.dart | 173 +++++++++++++++++ .../widgets/message_actions_overlay.dart | 174 ++++++++++++++++-- lib/main.dart | 2 + 7 files changed, 432 insertions(+), 12 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 lib/core/config/app_message_actions_style.dart create mode 100644 lib/frontend/screens/profile/message_actions_screen.dart 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 3fb8bdb..96884ff 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -19,6 +19,7 @@ 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'; @@ -1745,6 +1746,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> { isMe: widget.isMe, messageText: widget.message.text, controller: controller, + style: AppMessageActionsStyle.current.value, onDispose: () { if (identical(_controller, controller)) { _controller = null; 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/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 3335240..3d87892 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -6,6 +6,7 @@ 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'; @@ -68,6 +69,7 @@ void showMessageActions({ required bool isMe, required String? messageText, required MessageActionsController controller, + required MessageActionsStyle style, required VoidCallback onDispose, }) { final overlay = Overlay.of(context, rootOverlay: true); @@ -80,6 +82,7 @@ void showMessageActions({ isMe: isMe, messageText: messageText, controller: controller, + style: style, onDismiss: () { if (entry.mounted) entry.remove(); onDispose(); @@ -96,6 +99,7 @@ class _MessageActionsLayer extends StatefulWidget { final bool isMe; final String? messageText; final MessageActionsController controller; + final MessageActionsStyle style; final VoidCallback onDismiss; const _MessageActionsLayer({ @@ -105,6 +109,7 @@ class _MessageActionsLayer extends StatefulWidget { required this.isMe, required this.messageText, required this.controller, + required this.style, required this.onDismiss, }); @@ -127,6 +132,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> bool _showBelow = true; Offset _anchor = Offset.zero; List _buttonCenters = const []; + List _buttonHitRects = const []; + Rect _menuRect = Rect.zero; bool _initialized = false; int _hoveredIndex = -1; @@ -155,15 +162,55 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> _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); - widget.controller.addListener(_onControllerUpdate); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _onControllerUpdate(); - }); + _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 @@ -180,7 +227,12 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> if (hasText) _Action(Symbols.content_copy, 'Копировать', _copy), _Action(Symbols.reply, 'Ответить', () => _stub('Ответ')), _Action(Symbols.forward, 'Переслать', () => _stub('Пересылка')), - _Action(Symbols.delete, 'Удалить', () => _stub('Удаление')), + _Action( + Symbols.delete, + 'Удалить', + () => _stub('Удаление'), + destructive: true, + ), ]; } @@ -223,10 +275,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> } int _findButtonAt(Offset p) { - for (int i = 0; i < _buttonCenters.length; i++) { - if ((_buttonCenters[i] - p).distance <= _hitRadius) { - return i; - } + for (int i = 0; i < _buttonHitRects.length; i++) { + if (_buttonHitRects[i].contains(p)) return i; } return -1; } @@ -307,8 +357,11 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ), ), ), - ..._buildButtons(t), - _buildLabelBanner(size, t), + if (widget.style == MessageActionsStyle.radial) ...[ + ..._buildButtons(t), + _buildLabelBanner(size, t), + ] else + _buildListMenu(t), ], ), ); @@ -316,6 +369,47 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ); } + 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 [ @@ -409,7 +503,63 @@ class _Action { final IconData icon; final String label; final VoidCallback onTap; - const _Action(this.icon, this.label, this.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 fg = action.destructive ? cs.error : cs.onSurface; + final hoverBg = action.destructive + ? cs.error.withValues(alpha: 0.14) + : cs.primary.withValues(alpha: 0.16); + return AnimatedContainer( + duration: const Duration(milliseconds: 120), + curve: Curves.easeOutCubic, + height: 48, + color: highlighted ? hoverBg : Colors.transparent, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Haptics.tap(); + action.onTap(); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + 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 { diff --git a/lib/main.dart b/lib/main.dart index b871ce2..cbf9d76 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,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'; @@ -90,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, From 96922e1f9ac35f3362ab97561fc35b376a65973a Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 23 May 2026 08:36:53 +0000 Subject: [PATCH 15/15] =?UTF-8?q?=D0=BF=D1=83=D0=BD=D0=BA=D1=82=D1=8B=20?= =?UTF-8?q?=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=20=D0=BF=D0=BE=D0=B4=D1=81?= =?UTF-8?q?=D0=B2=D0=B5=D1=87=D0=B8=D0=B2=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BA=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B5=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20primary-=D1=86=D0=B2=D0=B5=D1=82=D0=BE=D0=BC=20(destru?= =?UTF-8?q?ctive=20=E2=80=94=20error)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/message_actions_overlay.dart | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 3d87892..67d2a07 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -520,15 +520,12 @@ class _ListMenuItem extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - final fg = action.destructive ? cs.error : cs.onSurface; - final hoverBg = action.destructive - ? cs.error.withValues(alpha: 0.14) - : cs.primary.withValues(alpha: 0.16); - return AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: Curves.easeOutCubic, + 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, - color: highlighted ? hoverBg : Colors.transparent, child: Material( color: Colors.transparent, child: InkWell( @@ -537,23 +534,32 @@ class _ListMenuItem extends StatelessWidget { action.onTap(); }, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - 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, + 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, + ), ), ), - ), - ], + ], + ), ), ), ),