diff --git a/lib/helpers/link_handler.dart b/lib/helpers/link_handler.dart index c2eae294..980369da 100644 --- a/lib/helpers/link_handler.dart +++ b/lib/helpers/link_handler.dart @@ -1,3 +1,4 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -20,6 +21,7 @@ class LinkHandler { required String text, required TextStyle style, TextStyle? linkStyle, + VoidCallback? onSecondaryTap, }) { final effectiveLinkStyle = linkStyle ?? defaultLinkStyle(context, style); const options = LinkifyOptions(humanize: false, defaultToHttps: false); @@ -27,7 +29,7 @@ class LinkHandler { void onOpen(LinkableElement link) => handleLinkTap(context, link.url); if (PlatformInfo.isDesktop) { - return SelectableLinkify( + final linkify = SelectableLinkify( text: text, style: style, linkStyle: effectiveLinkStyle, @@ -35,6 +37,14 @@ class LinkHandler { linkifiers: linkifiers, onOpen: onOpen, ); + if (onSecondaryTap == null) return linkify; + return Listener( + onPointerDown: (event) { + if (event.buttons & kSecondaryMouseButton != 0) onSecondaryTap(); + }, + behavior: HitTestBehavior.translucent, + child: linkify, + ); } return Linkify( text: text, diff --git a/lib/screens/ble_debug_log_screen.dart b/lib/screens/ble_debug_log_screen.dart index c229c2e5..fc8fe2d0 100644 --- a/lib/screens/ble_debug_log_screen.dart +++ b/lib/screens/ble_debug_log_screen.dart @@ -110,20 +110,32 @@ class _BleDebugLogScreenState extends State { final entry = entries[index]; final time = '${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}'; - return GestureDetector( - onLongPress: () async { - await Clipboard.setData( - ClipboardData( - text: entry.payload - .map( - (b) => b - .toRadixString(16) - .padLeft(2, '0'), - ) - .join(''), + Future copyHex() async { + await Clipboard.setData( + ClipboardData( + text: entry.payload + .map( + (b) => b + .toRadixString(16) + .padLeft(2, '0'), + ) + .join(''), + ), + ); + if (context.mounted) { + showDismissibleSnackBar( + context, + content: Text( + context.l10n.debugLog_bleCopied, ), ); - }, + } + } + + return GestureDetector( + onTap: copyHex, + onLongPress: copyHex, + onSecondaryTap: copyHex, child: Container( color: MeshPalette.bg, padding: const EdgeInsets.symmetric( diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index bea4ac47..2af7e73f 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -646,6 +646,9 @@ class _ChannelChatScreenState extends State { fontStyle: FontStyle.italic, color: textColor.withValues(alpha: 0.72), ), + onSecondaryTap: PlatformInfo.isDesktop + ? () => _showMessageActions(message) + : null, ), ), ], diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 94bbd65e..745445ee 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -429,142 +429,140 @@ class _ChannelsScreenState extends State channelMessageStore, channel, ), - child: GestureDetector( - onSecondaryTapUp: PlatformInfo.isDesktop - ? (_) => _showChannelActions( - this.context, - connector, - channelMessageStore, - channel, - ) - : null, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Leading avatar with optional community badge - Stack( - clipBehavior: Clip.none, - children: [ - AvatarCircle( - name: channelLabel, - size: 42, - color: iconColor, - icon: icon, - ), - if (isCommunityChannel) - Positioned( - right: -2, - bottom: -2, - child: Container( - width: 16, - height: 16, - decoration: BoxDecoration( - color: MeshPalette.magenta, - shape: BoxShape.circle, - border: Border.all( - color: Theme.of( - context, - ).colorScheme.surfaceContainerLow, - width: 2, - ), - ), - child: const Icon( - Icons.people, - size: 8, - color: Colors.white, - ), - ), - ), - ], - ), - const SizedBox(width: 12), - // Title + subtitle + ch chip - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Text( - channelLabel, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(fontWeight: FontWeight.w500), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 6), - StatusChip( - label: 'CH ${channel.index}', - color: MeshPalette.blue, - fontSize: 10, - ), - ], - ), - if (lastPreview.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - lastPreview, - style: MeshTheme.mono( - fontSize: 11.5, - color: scheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ], + onSecondaryTap: PlatformInfo.isDesktop + ? () => _showChannelActions( + this.context, + connector, + channelMessageStore, + channel, + ) + : null, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Leading avatar with optional community badge + Stack( + clipBehavior: Clip.none, + children: [ + AvatarCircle( + name: channelLabel, + size: 42, + color: iconColor, + icon: icon, ), - ), - const SizedBox(width: 8), - // Right side: time + unread badge + muted + drag handle - Column( - crossAxisAlignment: CrossAxisAlignment.end, + if (isCommunityChannel) + Positioned( + right: -2, + bottom: -2, + child: Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: MeshPalette.magenta, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of( + context, + ).colorScheme.surfaceContainerLow, + width: 2, + ), + ), + child: const Icon( + Icons.people, + size: 8, + color: Colors.white, + ), + ), + ), + ], + ), + const SizedBox(width: 12), + // Title + subtitle + ch chip + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - if (lastTime != null) - Text( - _relativeTime(lastTime), - style: MeshTheme.mono( - fontSize: 11, - color: scheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 4), Row( - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - if (isMuted) ...[ - Icon( - Icons.notifications_off, - size: 14, - color: scheme.onSurfaceVariant, + Expanded( + child: Text( + channelLabel, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - const SizedBox(width: 4), - ], - if (unreadCount > 0) UnreadBadge(count: unreadCount), + ), + const SizedBox(width: 6), + StatusChip( + label: 'CH ${channel.index}', + color: MeshPalette.blue, + fontSize: 10, + ), ], ), + if (lastPreview.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + lastPreview, + style: MeshTheme.mono( + fontSize: 11.5, + color: scheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ], ), - if (showDragHandle && dragIndex != null) ...[ - const SizedBox(width: 4), - ReorderableDragStartListener( - index: dragIndex, - child: Padding( - padding: const EdgeInsets.all(8), - child: Icon( - Icons.drag_handle, + ), + const SizedBox(width: 8), + // Right side: time + unread badge + muted + drag handle + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + if (lastTime != null) + Text( + _relativeTime(lastTime), + style: MeshTheme.mono( + fontSize: 11, color: scheme.onSurfaceVariant, ), ), + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isMuted) ...[ + Icon( + Icons.notifications_off, + size: 14, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + ], + if (unreadCount > 0) UnreadBadge(count: unreadCount), + ], ), ], + ), + if (showDragHandle && dragIndex != null) ...[ + const SizedBox(width: 4), + ReorderableDragStartListener( + index: dragIndex, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + Icons.drag_handle, + color: scheme.onSurfaceVariant, + ), + ), + ), ], - ), + ], ), ), ); diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 5bdd0fdc..7eca042b 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -1435,6 +1435,9 @@ class _MessageBubble extends StatelessWidget { color: textColor.withValues(alpha: 0.72), fontSize: bodyFontSize * textScale, ), + onSecondaryTap: PlatformInfo.isDesktop + ? onLongPress + : null, ), ), ], diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart index 415562c3..4a782d6f 100644 --- a/lib/screens/discovery_screen.dart +++ b/lib/screens/discovery_screen.dart @@ -147,13 +147,6 @@ class _DiscoveryScreenState extends State { connector, index, ); - if (PlatformInfo.isDesktop) { - return GestureDetector( - onSecondaryTapUp: (_) => - _showContactContextMenu(contact, connector), - child: tile, - ); - } return tile; }, ), @@ -204,6 +197,9 @@ class _DiscoveryScreenState extends State { } }, onLongPress: () => _showContactContextMenu(contact, connector), + onSecondaryTap: PlatformInfo.isDesktop + ? () => _showContactContextMenu(contact, connector) + : null, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), child: Row( children: [ diff --git a/lib/screens/line_of_sight_map_screen.dart b/lib/screens/line_of_sight_map_screen.dart index 57d7603f..fe935e43 100644 --- a/lib/screens/line_of_sight_map_screen.dart +++ b/lib/screens/line_of_sight_map_screen.dart @@ -430,6 +430,7 @@ class _LineOfSightMapScreenState extends State { minZoom: _mapMinZoom, maxZoom: _mapMaxZoom, onLongPress: (_, point) => _addCustomPoint(point), + onSecondaryTap: (_, point) => _addCustomPoint(point), onPositionChanged: (camera, hasGesture) { final shouldShow = camera.zoom >= _labelZoomThreshold; if (!_didReceivePositionUpdate || diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 3f57dfac..18e3ba9d 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -285,6 +285,31 @@ class _MapScreenState extends State { ); } + void _handleMapContextPress( + BuildContext context, + MeshCoreConnector connector, + LatLng latLng, + ) { + if (_isSelectingPoi) { + setState(() { + _isSelectingPoi = false; + }); + _shareMarker( + context: context, + connector: connector, + position: latLng, + defaultLabel: context.l10n.map_pointOfInterest, + flags: 'poi', + ); + return; + } + _showShareMarkerAtPositionSheet( + context: context, + connector: connector, + position: latLng, + ); + } + @override Widget build(BuildContext context) { return Builder( @@ -708,24 +733,10 @@ class _MapScreenState extends State { } }, onLongPress: (_, latLng) { - if (_isSelectingPoi) { - setState(() { - _isSelectingPoi = false; - }); - _shareMarker( - context: context, - connector: connector, - position: latLng, - defaultLabel: context.l10n.map_pointOfInterest, - flags: 'poi', - ); - return; - } - _showShareMarkerAtPositionSheet( - context: context, - connector: connector, - position: latLng, - ); + _handleMapContextPress(context, connector, latLng); + }, + onSecondaryTap: (_, latLng) { + _handleMapContextPress(context, connector, latLng); }, onPositionChanged: (camera, hasGesture) { // Track zoom in half-step buckets so cluster/marker @@ -1181,9 +1192,12 @@ class _MapScreenState extends State { width: 48, height: 48, child: GestureDetector( - onLongPress: () => _isBuildingPathTrace - ? _showNodeInfo(context, guess.contact) - : null, + onLongPress: () { + if (_isBuildingPathTrace) _showNodeInfo(context, guess.contact); + }, + onSecondaryTap: () { + if (_isBuildingPathTrace) _showNodeInfo(context, guess.contact); + }, onTap: () => _isBuildingPathTrace ? _addToPath(context, guess.contact, position: guess.position) : _selectNode(guess.contact, guessedPosition: guess.position), @@ -1383,8 +1397,12 @@ class _MapScreenState extends State { height: size, child: GestureDetector( behavior: HitTestBehavior.opaque, - onLongPress: () => - _isBuildingPathTrace ? _showNodeInfo(context, contact) : null, + onLongPress: () { + if (_isBuildingPathTrace) _showNodeInfo(context, contact); + }, + onSecondaryTap: () { + if (_isBuildingPathTrace) _showNodeInfo(context, contact); + }, onTap: () => _isBuildingPathTrace ? _addToPath(context, contact) : _selectNode(contact), diff --git a/lib/widgets/mesh_ui.dart b/lib/widgets/mesh_ui.dart index 797af7a7..cb4698bb 100644 --- a/lib/widgets/mesh_ui.dart +++ b/lib/widgets/mesh_ui.dart @@ -50,6 +50,7 @@ class MeshCard extends StatelessWidget { final Widget child; final VoidCallback? onTap; final VoidCallback? onLongPress; + final VoidCallback? onSecondaryTap; final EdgeInsetsGeometry padding; final EdgeInsetsGeometry margin; final Color? color; @@ -61,6 +62,7 @@ class MeshCard extends StatelessWidget { required this.child, this.onTap, this.onLongPress, + this.onSecondaryTap, this.padding = const EdgeInsets.all(14), this.margin = const EdgeInsets.symmetric(horizontal: 16, vertical: 4), this.color, @@ -89,6 +91,7 @@ class MeshCard extends StatelessWidget { HapticFeedback.selectionClick(); onLongPress!(); }, + onSecondaryTap: onSecondaryTap, child: Padding(padding: padding, child: child), ), ), diff --git a/lib/widgets/routing_sheet.dart b/lib/widgets/routing_sheet.dart index 4c1a26f6..2cc55b8a 100644 --- a/lib/widgets/routing_sheet.dart +++ b/lib/widgets/routing_sheet.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; +import '../utils/platform_info.dart'; import '../helpers/path_helper.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; @@ -534,56 +535,67 @@ class _RoutingSheetBodyState extends State<_RoutingSheetBody> { l10n.routing_deliveryCounts(record.successCount, record.failureCount), ]; - return Card( - margin: const EdgeInsets.symmetric(vertical: 4), - child: ListTile( - enabled: hasBytes, - leading: CircleAvatar( - radius: 18, - backgroundColor: bg, - child: Icon( - _qualityIcon(quality), - size: 18, - color: fg, - semanticLabel: _qualityLabel(context, quality), + return GestureDetector( + behavior: HitTestBehavior.opaque, + onSecondaryTapUp: PlatformInfo.isDesktop && hasBytes + ? (_) => + _showPathDetail(context, connector, contact, record.pathBytes) + : null, + child: Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + enabled: hasBytes, + leading: CircleAvatar( + radius: 18, + backgroundColor: bg, + child: Icon( + _qualityIcon(quality), + size: 18, + color: fg, + semanticLabel: _qualityLabel(context, quality), + ), ), - ), - title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text( - '$line1\n${line2Parts.join(' • ')}', - style: const TextStyle(fontSize: 11), - ), - isThreeLine: true, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (inUse) - Tooltip( - message: l10n.routing_inUse, - child: Icon( - Icons.check_circle, - color: scheme.primary, - semanticLabel: l10n.routing_inUse, + title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text( + '$line1\n${line2Parts.join(' • ')}', + style: const TextStyle(fontSize: 11), + ), + isThreeLine: true, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (inUse) + Tooltip( + message: l10n.routing_inUse, + child: Icon( + Icons.check_circle, + color: scheme.primary, + semanticLabel: l10n.routing_inUse, + ), + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + tooltip: l10n.chat_removePath, + constraints: const BoxConstraints(minWidth: 44, minHeight: 44), + onPressed: () => pathService.removePathRecord( + contact.publicKeyHex, + record.pathBytes, ), ), - IconButton( - icon: const Icon(Icons.delete_outline, size: 20), - tooltip: l10n.chat_removePath, - constraints: const BoxConstraints(minWidth: 44, minHeight: 44), - onPressed: () => pathService.removePathRecord( - contact.publicKeyHex, - record.pathBytes, - ), - ), - ], + ], + ), + onTap: hasBytes && !inUse + ? () => _applyHistoryPath(connector, contact, record) + : null, + onLongPress: hasBytes + ? () => _showPathDetail( + context, + connector, + contact, + record.pathBytes, + ) + : null, ), - onTap: hasBytes && !inUse - ? () => _applyHistoryPath(connector, contact, record) - : null, - onLongPress: hasBytes - ? () => - _showPathDetail(context, connector, contact, record.pathBytes) - : null, ), ); } diff --git a/lib/widgets/translated_message_content.dart b/lib/widgets/translated_message_content.dart index 3495897d..a40bacf8 100644 --- a/lib/widgets/translated_message_content.dart +++ b/lib/widgets/translated_message_content.dart @@ -8,6 +8,7 @@ class TranslatedMessageContent extends StatelessWidget { final TextStyle style; final TextStyle? originalStyle; final bool showOriginalFirst; + final VoidCallback? onSecondaryTap; const TranslatedMessageContent({ super.key, @@ -16,6 +17,7 @@ class TranslatedMessageContent extends StatelessWidget { this.originalText, this.originalStyle, this.showOriginalFirst = true, + this.onSecondaryTap, }); @override @@ -36,12 +38,14 @@ class TranslatedMessageContent extends StatelessWidget { fontStyle: FontStyle.italic, fontSize: style.fontSize, ), + onSecondaryTap: onSecondaryTap, ) : null; final translatedWidget = LinkHandler.buildLinkifyText( context: context, text: trimmedDisplay, style: style, + onSecondaryTap: onSecondaryTap, ); if (!shouldShowOriginal) {