feat: UX polish, localization, advert relocation, and map filter fixes

- i18n: translate connection screen + chat tab keys into 13 locales;
  add "Send my contact" (advert) strings in all locales
- feat: move self-advert from home header into composer "+" action menu
  (new lib/utils/advert_helper.dart, always shows Flood/Direct sheet)
- fix: hide-repeaters map toggle was bypassed in simple mode
- fix: simple mode map now shows only favourite chat contacts
- fix: wrap ListTiles in Material (contact_tile secondary actions,
  inferred contact group card) to satisfy Flutter ink assertions
- device-authoritative contact/channel sync and UX review fixes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Janez T
2026-06-12 10:01:10 +02:00
parent 3e1113035d
commit ba27843166
62 changed files with 12546 additions and 2521 deletions

View File

@@ -148,7 +148,7 @@ class _CustomMapSarUpdateSheetState extends State<CustomMapSarUpdateSheet> {
child: Column(
children: [
Text(
'Send SAR marker',
AppLocalizations.of(context)!.sendSarMarker,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 18,
@@ -156,7 +156,7 @@ class _CustomMapSarUpdateSheetState extends State<CustomMapSarUpdateSheet> {
),
),
Text(
'Custom cave map point',
AppLocalizations.of(context)!.customCaveMapPoint,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 14,
@@ -231,7 +231,11 @@ class _CustomMapSarUpdateSheetState extends State<CustomMapSarUpdateSheet> {
color: Colors.red.withValues(alpha: 0.3),
),
),
child: Text(AppLocalizations.of(context)!.noDestinationsAvailableLabel),
child: Text(
AppLocalizations.of(
context,
)!.noDestinationsAvailableLabel,
),
);
}
@@ -322,7 +326,7 @@ class _CustomMapSarUpdateSheetState extends State<CustomMapSarUpdateSheet> {
),
const SizedBox(height: 24),
Text(
'Map point',
AppLocalizations.of(context)!.mapPoint,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 16,
@@ -394,7 +398,9 @@ class _CustomMapSarUpdateSheetState extends State<CustomMapSarUpdateSheet> {
controller: _notesController,
maxLines: 3,
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.addAdditionalDetails,
hintText: AppLocalizations.of(
context,
)!.addAdditionalDetails,
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
@@ -426,7 +432,11 @@ class _CustomMapSarUpdateSheetState extends State<CustomMapSarUpdateSheet> {
(!_sendToAllContacts && _selectedContact == null)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.selectMarkerTypeAndDestination),
content: Text(
AppLocalizations.of(
context,
)!.selectMarkerTypeAndDestination,
),
),
);
return;

View File

@@ -161,6 +161,14 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
eta: eta,
pathLen: effectivePathLen,
transferCount: transferCount,
fetchingMissingLabel: AppLocalizations.of(
context,
)!.fetchingMissingImageFragments,
loadingLabel: AppLocalizations.of(context)!.loadingImage,
receivingLabel: AppLocalizations.of(
context,
)!.receivingImage,
tapToLoadLabel: AppLocalizations.of(context)!.tapToLoad,
),
style: TextStyle(
fontSize: 11,
@@ -283,8 +291,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
),
color: Colors.white70,
tooltip: isReceivingData
? 'Image is already being received'
: 'Load image',
? AppLocalizations.of(context)!.imageAlreadyBeingReceived
: AppLocalizations.of(context)!.loadImage,
),
],
],
@@ -321,32 +329,34 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender contact is unknown. Sync contacts first.',
AppLocalizations.of(context)!.cannotFetchImage,
AppLocalizations.of(context)!.senderContactUnknown,
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender route is unknown. Sync contacts/path first.',
AppLocalizations.of(context)!.cannotFetchImage,
AppLocalizations.of(context)!.senderRouteUnknown,
);
return;
}
if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
AppLocalizations.of(context)!.cannotFetchImage,
AppLocalizations.of(
context,
)!.messageTooFar('${resolution.hops}', '${resolution.maxHops}'),
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unreachable) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender route did not respond to a path check. Sync contacts/path and try again.',
AppLocalizations.of(context)!.cannotFetchImage,
AppLocalizations.of(context)!.senderRouteNoPathResponse,
);
return;
}
@@ -370,24 +380,26 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender contact is unknown. Sync contacts first.',
AppLocalizations.of(context)!.cannotFetchImage,
AppLocalizations.of(context)!.senderContactUnknown,
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender route is unknown. Sync contacts/path first.',
AppLocalizations.of(context)!.cannotFetchImage,
AppLocalizations.of(context)!.senderRouteUnknown,
);
return;
}
if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
AppLocalizations.of(context)!.cannotFetchImage,
AppLocalizations.of(
context,
)!.messageTooFar('${resolution.hops}', '${resolution.maxHops}'),
);
return;
}
@@ -397,8 +409,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (!routeVerified) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender route did not respond on the raw transport path.',
AppLocalizations.of(context)!.cannotFetchImage,
AppLocalizations.of(context)!.senderRouteNoRawResponse,
);
return;
}
@@ -406,7 +418,9 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (sender.routeHopCount >= 2) {
_showToast(
'Image fetch over ${sender.routeHopCount} hops may take a while.',
AppLocalizations.of(
context,
)!.imageFetchOverHops('${sender.routeHopCount}'),
);
}
@@ -415,8 +429,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (deviceKey == null || deviceKey.length < 6) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Device key is unavailable.',
AppLocalizations.of(context)!.cannotFetchImage,
AppLocalizations.of(context)!.deviceKeyUnavailable,
);
return;
}
@@ -459,11 +473,11 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
);
} catch (_) {
if (mounted) {
_showToast('Image fetch failed to send request');
_showToast(AppLocalizations.of(context)!.imageFetchFailedToSendRequest);
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_errorText = 'Image unavailable right now';
_errorText = AppLocalizations.of(context)!.imageUnavailable;
});
}
return;
@@ -491,11 +505,11 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (mounted &&
_isRequesting &&
!imageProvider.isComplete(envelope.sessionId)) {
_showToast('Image fetch timed out');
_showToast(AppLocalizations.of(context)!.imageFetchTimedOut);
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_errorText = 'Image fetch timed out';
_errorText = AppLocalizations.of(context)!.imageFetchTimedOut;
});
}
},
@@ -513,11 +527,11 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (!mounted) return;
_requestTimeoutTimer?.cancel();
context.read<ip.ImageProvider>().cancelIncomingSession(sessionId);
_showToast('Image receive canceled');
_showToast(AppLocalizations.of(context)!.imageReceiveCanceled);
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_errorText = 'Image receive canceled';
_errorText = AppLocalizations.of(context)!.imageReceiveCanceled;
});
}
@@ -563,6 +577,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
required bool isSentByMe,
required Duration? eta,
required int transferCount,
required String fetchingMissingLabel,
required String loadingLabel,
required String receivingLabel,
required String tapToLoadLabel,
}) {
final txEstimate = estimateImageTransmitDuration(
fragmentCount: envelope.total,
@@ -578,13 +596,13 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (isRequesting) {
final etaLabel = _formatEta(eta);
final actionLabel = isPartialRequest
? '📥 Fetching missing fragments…'
: '📥 Loading';
? '📥 $fetchingMissingLabel'
: '📥 $loadingLabel';
return '$actionLabel $received/$total · $etaLabel · $txEstimateLabel';
}
if (isReceivingData) {
final etaLabel = _formatEta(eta);
return '📥 Receiving $received/$total · $etaLabel · $txEstimateLabel';
return '📥 $receivingLabel $received/$total · $etaLabel · $txEstimateLabel';
}
if (isComplete) {
final base =
@@ -595,7 +613,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
}
return isSentByMe
? '🖼️ ${envelope.width}×${envelope.height} · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
: '🖼️ Tap to load · ${envelope.width}×${envelope.height} · $txEstimateLabel';
: '🖼️ $tapToLoadLabel · ${envelope.width}×${envelope.height} · $txEstimateLabel';
}
static String _formatTransmitEstimate(Duration value) {
@@ -634,7 +652,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
context: context,
barrierColor: Colors.black,
barrierDismissible: true,
barrierLabel: 'Close image preview',
barrierLabel: AppLocalizations.of(context)!.closeImagePreview,
pageBuilder: (dialogContext, animation, secondaryAnimation) => Material(
color: Colors.black,
child: Stack(

View File

@@ -179,7 +179,7 @@ class _MessageBubbleState extends State<MessageBubble> {
Future<void> _openMessageLink(String rawUrl) async {
final uri = _parseMessageLink(rawUrl);
if (uri == null) {
ToastLogger.error(context, 'Invalid link');
ToastLogger.error(context, AppLocalizations.of(context)!.invalidLink);
return;
}
@@ -200,7 +200,10 @@ class _MessageBubbleState extends State<MessageBubble> {
if (!mounted) {
return;
}
ToastLogger.error(context, 'Cannot open link');
ToastLogger.error(
context,
AppLocalizations.of(context)!.cannotOpenLink,
);
return;
}
@@ -209,7 +212,10 @@ class _MessageBubbleState extends State<MessageBubble> {
if (!mounted) {
return;
}
ToastLogger.error(context, 'Failed to open link');
ToastLogger.error(
context,
AppLocalizations.of(context)!.failedToOpenLink,
);
}
}
@@ -328,7 +334,10 @@ class _MessageBubbleState extends State<MessageBubble> {
final messagesProvider = context.read<MessagesProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
ToastLogger.error(context, 'Not connected to device');
ToastLogger.error(
context,
AppLocalizations.of(context)!.notConnectedToDevice,
);
return;
}
@@ -380,7 +389,10 @@ class _MessageBubbleState extends State<MessageBubble> {
if (!sentSuccessfully) {
messagesProvider.markMessageFailed(failedMessage.id);
ToastLogger.error(context, 'Failed to resend message');
ToastLogger.error(
context,
AppLocalizations.of(context)!.failedToResendMessage,
);
}
} else if (failedMessage.messageType == MessageType.channel) {
final prepared = messagesProvider.prepareMessageForRetry(
@@ -401,7 +413,10 @@ class _MessageBubbleState extends State<MessageBubble> {
}
} catch (e) {
if (!context.mounted) return;
ToastLogger.error(context, 'Retry failed: $e');
ToastLogger.error(
context,
AppLocalizations.of(context)!.retryFailed('$e'),
);
}
}
@@ -441,10 +456,7 @@ class _MessageBubbleState extends State<MessageBubble> {
onTap: () {
Clipboard.setData(ClipboardData(text: widget.message.text));
Navigator.pop(sheetContext);
ToastLogger.success(
parentContext,
l10n.textCopiedToClipboard,
);
ToastLogger.success(parentContext, l10n.textCopiedToClipboard);
},
),
// Save as Template option (only for SAR markers without existing template)
@@ -508,14 +520,17 @@ class _MessageBubbleState extends State<MessageBubble> {
_copyDrawingCoordinates(parentContext);
},
),
// Hide from map option (only for drawing messages)
// Delete drawing & message option (only for drawing messages)
if (widget.message.isDrawing && widget.message.drawingId != null)
ListTile(
leading: Icon(Icons.visibility_off),
title: Text(l10n.hideFromMap),
leading: Icon(Icons.delete_forever, color: Colors.red),
title: Text(
l10n.deleteDrawingAndMessage,
style: const TextStyle(color: Colors.red),
),
onTap: () {
Navigator.pop(sheetContext);
_hideDrawingFromMap(parentContext);
_showDeleteDrawingConfirmation(parentContext);
},
),
// Details option
@@ -1013,7 +1028,7 @@ class _MessageBubbleState extends State<MessageBubble> {
if (lastEchoRelayHash != null)
_detailRow(
sheetContext,
label: 'Last echo relay',
label: l10n.lastEchoRelay,
value: lastEchoRelayHash,
onCopy: () =>
copyField(sheetContext, lastEchoRelayHash),
@@ -1022,15 +1037,14 @@ class _MessageBubbleState extends State<MessageBubble> {
case final echoPath?)
_detailRow(
sheetContext,
label: 'Last echo path',
label: l10n.lastEchoPath,
value: echoPath,
onCopy: () =>
copyField(sheetContext, echoPath),
onCopy: () => copyField(sheetContext, echoPath),
),
if (lastEchoBytesReport != null)
_detailRow(
sheetContext,
label: 'Last echo bytes report',
label: l10n.lastEchoBytesReport,
value: lastEchoBytesReport,
onCopy: () => copyField(
sheetContext,
@@ -1153,10 +1167,8 @@ class _MessageBubbleState extends State<MessageBubble> {
sheetContext,
label: l10n.recipientKey,
value: recipientPrefixHex,
onCopy: () => copyField(
sheetContext,
recipientPrefixHex,
),
onCopy: () =>
copyField(sheetContext, recipientPrefixHex),
),
],
),
@@ -1393,9 +1405,7 @@ class _MessageBubbleState extends State<MessageBubble> {
if (isOwnMessage) {
final advLat = connectionProvider.deviceInfo.advLat;
final advLon = connectionProvider.deviceInfo.advLon;
if (advLat != null &&
advLon != null &&
(advLat != 0 || advLon != 0)) {
if (advLat != null && advLon != null && (advLat != 0 || advLon != 0)) {
return MessageContactLocation(
location: LatLng(advLat / 1e6, advLon / 1e6),
source: 'shared',
@@ -1407,10 +1417,7 @@ class _MessageBubbleState extends State<MessageBubble> {
final currentPosition = LocationTrackingService().currentPosition;
if (currentPosition != null) {
return MessageContactLocation(
location: LatLng(
currentPosition.latitude,
currentPosition.longitude,
),
location: LatLng(currentPosition.latitude, currentPosition.longitude),
source: 'shared',
capturedAt: currentPosition.timestamp,
sourceTimestamp: currentPosition.timestamp,
@@ -1420,14 +1427,14 @@ class _MessageBubbleState extends State<MessageBubble> {
final contactLocation = senderContact?.displayLocation;
if (contactLocation != null) {
final source =
senderContact?.telemetry?.gpsLocation != null
? 'telemetry'
: 'advert';
final source = senderContact?.telemetry?.gpsLocation != null
? 'telemetry'
: 'advert';
return MessageContactLocation(
location: contactLocation,
source: source,
capturedAt: senderContact?.locationUpdateTime ?? widget.message.receivedAt,
capturedAt:
senderContact?.locationUpdateTime ?? widget.message.receivedAt,
sourceTimestamp: senderContact?.locationUpdateTime,
);
}
@@ -1656,7 +1663,11 @@ class _MessageBubbleState extends State<MessageBubble> {
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.deleteMessage),
content: Text(l10n.deleteMessageConfirmation),
content: Text(
widget.message.isDrawing && widget.message.drawingId != null
? '${l10n.deleteMessageConfirmation} This will also remove the linked drawing from the map.'
: l10n.deleteMessageConfirmation,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
@@ -1686,7 +1697,10 @@ class _MessageBubbleState extends State<MessageBubble> {
void _shareLocation(BuildContext context) {
if (widget.message.sarGpsCoordinates == null) {
ToastLogger.error(context, 'No GPS coordinates available');
ToastLogger.error(
context,
AppLocalizations.of(context)!.noGpsCoordinatesAvailable,
);
return;
}
@@ -1731,7 +1745,7 @@ class _MessageBubbleState extends State<MessageBubble> {
Future<void> _saveAsTemplate(BuildContext context) async {
if (!widget.message.isSarMarker) {
ToastLogger.error(context, 'Not a SAR marker');
ToastLogger.error(context, AppLocalizations.of(context)!.notASarMarker);
return;
}
@@ -1764,7 +1778,10 @@ class _MessageBubbleState extends State<MessageBubble> {
} catch (e) {
debugPrint('Error saving template: $e');
if (!context.mounted) return;
ToastLogger.error(context, 'Failed to save template: $e');
ToastLogger.error(
context,
AppLocalizations.of(context)!.failedToSaveTemplate('$e'),
);
}
}
@@ -1780,7 +1797,7 @@ class _MessageBubbleState extends State<MessageBubble> {
final drawing = drawingProvider.getDrawingById(widget.message.drawingId!);
if (drawing == null) {
ToastLogger.error(context, 'Drawing not found');
ToastLogger.error(context, AppLocalizations.of(context)!.drawingNotFound);
return;
}
@@ -1798,7 +1815,10 @@ class _MessageBubbleState extends State<MessageBubble> {
} else if (drawing is RectangleDrawing) {
coordinatesText = drawing.corners.map(formatPoint).join('\n');
} else {
ToastLogger.error(context, 'Unknown drawing type');
ToastLogger.error(
context,
AppLocalizations.of(context)!.unknownDrawingType,
);
return;
}
@@ -1809,19 +1829,41 @@ class _MessageBubbleState extends State<MessageBubble> {
);
}
void _hideDrawingFromMap(BuildContext context) {
void _showDeleteDrawingConfirmation(BuildContext context) {
if (widget.message.drawingId == null) return;
final drawingProvider = context.read<DrawingProvider>();
final messagesProvider = context.read<MessagesProvider>();
final l10n = AppLocalizations.of(context)!;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.deleteDrawing),
content: Text(
'${l10n.deleteMessageConfirmation} This will also remove the drawing from the map.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () {
final drawingProvider = context.read<DrawingProvider>();
final messagesProvider = context.read<MessagesProvider>();
// Remove the drawing from map and delete the message
drawingProvider.removeDrawingAndMessage(
widget.message.drawingId!,
messagesProvider,
// Remove the drawing from map and delete the message
drawingProvider.removeDrawingAndMessage(
widget.message.drawingId!,
messagesProvider,
);
Navigator.pop(context);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(l10n.delete),
),
],
),
);
ToastLogger.success(context, 'Drawing removed from map');
}
Color _getMessageBubbleColor(
@@ -1899,7 +1941,7 @@ class _MessageBubbleState extends State<MessageBubble> {
return 'Direct (raw: ${message.pathLen})';
}
if (message.pathLen >= 255) return 'Unknown (raw: ${message.pathLen})';
return hopDisplayLabel(message);
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
}
String? _retryCauseLabel(Message message) {
@@ -2600,7 +2642,9 @@ class _MessageBubbleState extends State<MessageBubble> {
const SizedBox(width: 6),
Expanded(
child: Text(
'Custom map marker',
AppLocalizations.of(
context,
)!.customMapMarker,
style: Theme.of(context)
.textTheme
.labelMedium
@@ -2613,7 +2657,11 @@ class _MessageBubbleState extends State<MessageBubble> {
),
const SizedBox(height: 6),
Text(
'Point: ${message.sarCustomMapPoint!.latitude.toStringAsFixed(0)}, ${message.sarCustomMapPoint!.longitude.toStringAsFixed(0)}',
AppLocalizations.of(
context,
)!.customMapPointLabel(
'${message.sarCustomMapPoint!.latitude.toStringAsFixed(0)}, ${message.sarCustomMapPoint!.longitude.toStringAsFixed(0)}',
),
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
fontFamily: 'monospace',
@@ -2625,7 +2673,9 @@ class _MessageBubbleState extends State<MessageBubble> {
message.sarCustomMapId!.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
'Map ID: ${message.sarCustomMapId}',
AppLocalizations.of(
context,
)!.mapIdLabel(message.sarCustomMapId!),
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
fontFamily: 'monospace',
@@ -2932,7 +2982,10 @@ class _MessageBubbleState extends State<MessageBubble> {
),
]
// Show single message delivery status
// Channel messages only show transient states (sending/failed);
// delivered/ACK states don't exist for channels
else if (!message.isChannelMessage ||
message.deliveryStatus == MessageDeliveryStatus.sending ||
message.deliveryStatus == MessageDeliveryStatus.failed)
Row(
mainAxisSize: MainAxisSize.max,
@@ -2943,9 +2996,7 @@ class _MessageBubbleState extends State<MessageBubble> {
Icon(
getDeliveryStatusIcon(message.deliveryStatus),
size: 12,
color: getDeliveryStatusColor(
message.deliveryStatus,
),
color: getDeliveryStatusColor(message.deliveryStatus),
),
const SizedBox(width: 3),
Expanded(

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../models/message.dart';
import '../../models/message_route_metadata.dart';
import '../../models/path_selection.dart';
@@ -112,7 +113,7 @@ Widget buildReceivedSignalStatus(
required int? rssiDbm,
required double? snrDb,
}) {
final hopLabel = hopDisplayLabel(message);
final hopLabel = hopDisplayLabel(context, message);
return Wrap(
spacing: 4,
@@ -208,7 +209,7 @@ Widget buildSentDirectSignalStatus(
_techChip(
context,
icon: Icons.alt_route,
label: hopDisplayLabelForMessage(message, routeMetadata),
label: hopDisplayLabelForMessage(context, message, routeMetadata),
color: Colors.indigo,
),
_techChip(
@@ -286,22 +287,25 @@ String _formatMs(int value) {
return '${value}ms';
}
String hopDisplayLabel(Message message) {
if (message.pathLen == 0) return 'Direct';
if (message.pathLen >= 255 && message.isContactMessage) return 'Direct';
if (message.pathLen >= 255) return 'Unknown';
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
String hopDisplayLabel(BuildContext context, Message message) {
final l10n = AppLocalizations.of(context)!;
if (message.pathLen == 0) return l10n.direct;
if (message.pathLen >= 255 && message.isContactMessage) return l10n.direct;
if (message.pathLen >= 255) return l10n.unknown;
return l10n.hopCount(message.pathLen);
}
String hopDisplayLabelForMessage(
BuildContext context,
Message message,
MessageRouteMetadata? routeMetadata,
) {
final l10n = AppLocalizations.of(context)!;
final effectivePathLen = routeMetadata?.hopCount ?? message.pathLen;
if (effectivePathLen == 0) return 'Direct';
if (effectivePathLen >= 255 && message.isContactMessage) return 'Direct';
if (effectivePathLen >= 255) return 'Unknown';
return '$effectivePathLen hop${effectivePathLen == 1 ? '' : 's'}';
if (effectivePathLen == 0) return l10n.direct;
if (effectivePathLen >= 255 && message.isContactMessage) return l10n.direct;
if (effectivePathLen >= 255) return l10n.unknown;
return l10n.hopCount(effectivePathLen);
}
Widget _techChip(

View File

@@ -27,6 +27,7 @@ class MessagesComposer extends StatelessWidget {
final VoidCallback onShowRecipientSelector;
final Future<void> Function() onStartVoiceRecording;
final Future<void> Function() onStopAndSendVoice;
final Future<void> Function() onCancelVoiceRecording;
final Future<void> Function() onSendMessage;
final VoidCallback? onLongPressSend;
final String? regionScopeName;
@@ -53,6 +54,7 @@ class MessagesComposer extends StatelessWidget {
required this.onShowRecipientSelector,
required this.onStartVoiceRecording,
required this.onStopAndSendVoice,
required this.onCancelVoiceRecording,
required this.onSendMessage,
this.onLongPressSend,
this.regionScopeName,
@@ -110,6 +112,12 @@ class MessagesComposer extends StatelessWidget {
? onStopAndSendVoice
: onShowComposerActions,
),
if (isRecording) ...[
const SizedBox(width: 6),
_CancelRecordingButton(
onPressed: onCancelVoiceRecording,
),
],
if (regionScopeName != null &&
onRegionScopeTap != null) ...[
const SizedBox(width: 6),
@@ -142,13 +150,14 @@ class MessagesComposer extends StatelessWidget {
!isRecording &&
!isSendingVoice &&
textController.text.trim().isNotEmpty;
final l10n = AppLocalizations.of(context)!;
final semanticsLabel = isRecording
? 'Recording... release to send voice'
? l10n.recordingReleaseToSend
: (isSendingVoice
? 'Sending voice...'
? l10n.sendingVoice
: voiceSupported
? 'Send (long press to record voice)'
: 'Send');
? l10n.sendLongPressToRecordVoice
: l10n.send);
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
@@ -173,6 +182,7 @@ class MessagesComposer extends StatelessWidget {
onLongPressSend: onLongPressSend,
onStartVoiceRecording: onStartVoiceRecording,
onStopAndSendVoice: onStopAndSendVoice,
onCancelVoiceRecording: onCancelVoiceRecording,
),
],
);
@@ -294,7 +304,9 @@ class _ComposerActionButton extends StatelessWidget {
),
child: IconButton(
icon: Icon(isRecording ? Icons.stop : Icons.add, size: 20),
tooltip: isRecording ? 'Stop recording' : 'More actions',
tooltip: isRecording
? AppLocalizations.of(context)!.stopRecording
: AppLocalizations.of(context)!.moreActions,
onPressed: onPressed,
color: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
),
@@ -302,6 +314,33 @@ class _ComposerActionButton extends StatelessWidget {
}
}
class _CancelRecordingButton extends StatelessWidget {
final Future<void> Function() onPressed;
const _CancelRecordingButton({required this.onPressed});
@override
Widget build(BuildContext context) {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
),
),
child: IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
tooltip: AppLocalizations.of(context)!.discardRecording,
onPressed: onPressed,
color: Theme.of(context).colorScheme.error,
),
);
}
}
class _DestinationPill extends StatelessWidget {
final String destinationLabel;
final Widget destinationAvatar;
@@ -458,6 +497,7 @@ class _SendButton extends StatelessWidget {
final VoidCallback? onLongPressSend;
final Future<void> Function() onStartVoiceRecording;
final Future<void> Function() onStopAndSendVoice;
final Future<void> Function() onCancelVoiceRecording;
const _SendButton({
required this.canSendText,
@@ -471,6 +511,7 @@ class _SendButton extends StatelessWidget {
this.onLongPressSend,
required this.onStartVoiceRecording,
required this.onStopAndSendVoice,
required this.onCancelVoiceRecording,
});
@override
@@ -483,14 +524,14 @@ class _SendButton extends StatelessWidget {
onLongPress: canSendText && onLongPressSend != null
? onLongPressSend
: (voiceSupported && !isSendingVoice)
? () {
if (isRecording) {
onStopAndSendVoice();
return;
}
onStartVoiceRecording();
}
: null,
? () {
if (isRecording) {
onStopAndSendVoice();
return;
}
onStartVoiceRecording();
}
: null,
child: Tooltip(
message: semanticsLabel,
excludeFromSemantics: true,
@@ -500,13 +541,15 @@ class _SendButton extends StatelessWidget {
onLongPressStart: canSendText && onLongPressSend != null
? (_) => onLongPressSend!()
: (voiceSupported && !isSendingVoice)
? (_) => onStartVoiceRecording()
: null,
? (_) => onStartVoiceRecording()
: null,
onLongPressEnd: (!canSendText && voiceSupported && isRecording)
? (_) => onStopAndSendVoice()
: null,
// Finger slid off the button: discard the recording instead of
// sending it.
onLongPressCancel: (!canSendText && voiceSupported && isRecording)
? onStopAndSendVoice
? onCancelVoiceRecording
: null,
child: Column(
mainAxisSize: MainAxisSize.min,

View File

@@ -163,11 +163,11 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
String _sectionDescription(AppLocalizations l10n, String type) {
switch (type) {
case 'channel':
return 'Broadcast lanes for nearby mesh traffic';
return l10n.channelsSectionDescription;
case 'room':
return 'Shared spaces for ongoing team coordination';
return l10n.roomsSectionDescription;
case 'contact':
return 'Direct people and devices you can reach';
return l10n.contactsSectionDescription;
default:
return '';
}
@@ -302,7 +302,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
if (previewData.participantNames.isEmpty) {
return Text(
'No recent chatters',
AppLocalizations.of(context)!.noRecentChatters,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
@@ -972,14 +972,14 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
borderRadius: BorderRadius.circular(999),
),
child: Text(
'Public',
AppLocalizations.of(context)!.public,
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: accentColor,
fontWeight: FontWeight.w800,
),
),
),
),
],

View File

@@ -234,7 +234,9 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
if (!mounted) return;
if (!serviceEnabled) {
setState(() {
_locationError = 'Location services are disabled';
_locationError = AppLocalizations.of(
context,
)!.locationServicesDisabled;
_loadingLocation = false;
});
return;
@@ -248,7 +250,9 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
if (!mounted) return;
if (permission == LocationPermission.denied) {
setState(() {
_locationError = 'Location permission denied';
_locationError = AppLocalizations.of(
context,
)!.locationPermissionDenied;
_loadingLocation = false;
});
return;
@@ -257,7 +261,9 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
if (permission == LocationPermission.deniedForever) {
setState(() {
_locationError = 'Location permission permanently denied';
_locationError = AppLocalizations.of(
context,
)!.locationPermissionPermanentlyDenied;
_loadingLocation = false;
});
return;
@@ -399,7 +405,8 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
final teamContacts = contactsProvider.chatContacts;
// Get rooms and channels
final roomsAndChannels = contactsProvider.roomsAndChannels;
final roomsAndChannels =
contactsProvider.roomsAndChannels;
// Build destinations list with priority:
// 1. Team contacts first (most reliable for SAR)
@@ -463,7 +470,9 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _sendToAllContacts ? 'all_contacts' : _selectedContact?.publicKeyHex,
value: _sendToAllContacts
? 'all_contacts'
: _selectedContact?.publicKeyHex,
hint: Row(
children: [
Icon(
@@ -508,7 +517,9 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
SizedBox(width: 12),
Expanded(
child: Text(
AppLocalizations.of(context)!.allTeamContacts,
AppLocalizations.of(
context,
)!.allTeamContacts,
overflow: TextOverflow.ellipsis,
),
),
@@ -522,9 +533,11 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
if (contact.isChat) {
iconData = Icons.person; // Team member
} else if (contact.isRoom) {
iconData = Icons.storage; // Room (persistent)
iconData =
Icons.storage; // Room (persistent)
} else {
iconData = Icons.public; // Channel (ephemeral)
iconData =
Icons.public; // Channel (ephemeral)
}
return DropdownMenuItem<String>(
@@ -574,7 +587,8 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
if (_sendToAllContacts) {
final chatContactsCount = contactsProvider.chatContacts.length;
final chatContactsCount =
contactsProvider.chatContacts.length;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
@@ -596,7 +610,9 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
SizedBox(width: 8),
Expanded(
child: Text(
AppLocalizations.of(context)!.directMessagesInfo(chatContactsCount),
AppLocalizations.of(
context,
)!.directMessagesInfo(chatContactsCount),
style: TextStyle(
color: Colors.green.shade900,
fontSize: 11,
@@ -878,7 +894,9 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.manualCoordinates,
AppLocalizations.of(
context,
)!.manualCoordinates,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
@@ -886,7 +904,9 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
),
),
Text(
AppLocalizations.of(context)!.enterCoordinatesManually,
AppLocalizations.of(
context,
)!.enterCoordinatesManually,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
@@ -925,7 +945,9 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
color: colorScheme.onSurface,
),
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.latitudeLabel,
labelText: AppLocalizations.of(
context,
)!.latitudeLabel,
hintText: '46.0569',
errorText: _latitudeError,
filled: true,
@@ -951,7 +973,9 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
color: colorScheme.onSurface,
),
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.longitudeLabel,
labelText: AppLocalizations.of(
context,
)!.longitudeLabel,
hintText: '14.5058',
errorText: _longitudeError,
filled: true,
@@ -982,7 +1006,9 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
SizedBox(width: 8),
Expanded(
child: Text(
AppLocalizations.of(context)!.exampleCoordinates,
AppLocalizations.of(
context,
)!.exampleCoordinates,
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
@@ -1089,8 +1115,12 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
SnackBar(
content: Text(
_useManualCoordinates
? 'Please enter valid coordinates'
: 'Location not available',
? AppLocalizations.of(
context,
)!.pleaseEnterValidCoordinates
: AppLocalizations.of(
context,
)!.locationNotAvailable,
),
backgroundColor: Colors.red,
),
@@ -1117,7 +1147,8 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
}
// Validate location accuracy (warn if >50m) - only for GPS
if (!_useManualCoordinates && position.accuracy > 50.0) {
if (!_useManualCoordinates &&
position.accuracy > 50.0) {
final shouldContinue = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
@@ -1173,13 +1204,14 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
_sendToAllContacts
? null
: (_selectedContact!.isChannel
? null
: _selectedContact!.publicKey),
? null
: _selectedContact!.publicKey),
_sendToAllContacts
? false
: _selectedContact!.isChannel,
_sendToAllContacts,
_selectedTemplate!.getColorIndex(), // Include color index
_selectedTemplate!
.getColorIndex(), // Include color index
);
if (context.mounted) {
Navigator.pop(context);

View File

@@ -119,7 +119,11 @@ class TicTacToeMessageBubble extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
_statusText(state: state, mySymbol: mySymbol),
_statusText(
l10n: AppLocalizations.of(context)!,
state: state,
mySymbol: mySymbol,
),
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: statusColor),
@@ -139,7 +143,10 @@ class TicTacToeMessageBubble extends StatelessWidget {
}) async {
if (idx < 0 || idx > 8 || state.board[idx] != null) return;
if (!connectionProvider.deviceInfo.isConnected) {
ToastLogger.error(context, 'Not connected to device');
ToastLogger.error(
context,
AppLocalizations.of(context)!.notConnectedToDevice,
);
return;
}
@@ -177,19 +184,23 @@ class TicTacToeMessageBubble extends StatelessWidget {
if (!sent) {
messagesProvider.markMessageFailed(messageId);
if (!context.mounted) return;
ToastLogger.error(context, 'Failed to send Tic-Tac-Toe move');
ToastLogger.error(
context,
AppLocalizations.of(context)!.failedToSendTicTacToeMove,
);
}
}
static String _statusText({
required AppLocalizations l10n,
required TicTacToeGameState state,
required String mySymbol,
}) {
if (state.winnerSymbol != null) {
return state.winnerSymbol == mySymbol ? 'You won' : 'Opponent won';
return state.winnerSymbol == mySymbol ? l10n.youWon : l10n.opponentWon;
}
if (state.isDraw) return 'Draw';
return state.nextSymbol == mySymbol ? 'Your turn' : 'Opponent turn';
if (state.isDraw) return l10n.gameDraw;
return state.nextSymbol == mySymbol ? l10n.yourTurn : l10n.opponentTurn;
}
static String _key6Hex(Uint8List key) => key

View File

@@ -229,6 +229,12 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
requestingLabel: AppLocalizations.of(
context,
)!.requestingVoice,
fetchingMissingLabel: AppLocalizations.of(
context,
)!.fetchingMissingVoiceFragments,
receivingVoiceLabel: AppLocalizations.of(
context,
)!.receivingVoice,
eta: eta,
isSentByMe: widget.isSentByMe,
transferCount: transferCount,
@@ -283,32 +289,34 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender contact is unknown. Sync contacts first.',
AppLocalizations.of(context)!.cannotFetchVoice,
AppLocalizations.of(context)!.senderContactUnknown,
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route is unknown. Sync contacts/path first.',
AppLocalizations.of(context)!.cannotFetchVoice,
AppLocalizations.of(context)!.senderRouteUnknown,
);
return;
}
if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
AppLocalizations.of(context)!.cannotFetchVoice,
AppLocalizations.of(
context,
)!.messageTooFar('${resolution.hops}', '${resolution.maxHops}'),
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unreachable) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route did not respond to a path check. Sync contacts/path and try again.',
AppLocalizations.of(context)!.cannotFetchVoice,
AppLocalizations.of(context)!.senderRouteNoPathResponse,
);
return;
}
@@ -332,24 +340,26 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender contact is unknown. Sync contacts first.',
AppLocalizations.of(context)!.cannotFetchVoice,
AppLocalizations.of(context)!.senderContactUnknown,
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route is unknown. Sync contacts/path first.',
AppLocalizations.of(context)!.cannotFetchVoice,
AppLocalizations.of(context)!.senderRouteUnknown,
);
return;
}
if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
AppLocalizations.of(context)!.cannotFetchVoice,
AppLocalizations.of(
context,
)!.messageTooFar('${resolution.hops}', '${resolution.maxHops}'),
);
return;
}
@@ -359,8 +369,8 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
if (!routeVerified) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route did not respond on the raw transport path.',
AppLocalizations.of(context)!.cannotFetchVoice,
AppLocalizations.of(context)!.senderRouteNoRawResponse,
);
return;
}
@@ -368,7 +378,9 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
if (sender.routeHopCount >= 2) {
_showToast(
'Voice fetch over ${sender.routeHopCount} hops may take a while.',
AppLocalizations.of(
context,
)!.voiceFetchOverHops('${sender.routeHopCount}'),
);
}
@@ -376,8 +388,8 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
if (deviceKey == null || deviceKey.length < 6) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Device key is unavailable.',
AppLocalizations.of(context)!.cannotFetchVoice,
AppLocalizations.of(context)!.deviceKeyUnavailable,
);
return;
}
@@ -487,12 +499,12 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
if (!mounted) return;
_requestTimeoutTimer?.cancel();
context.read<VoiceProvider>().cancelIncomingSession(sessionId);
_showToast('Voice receive canceled');
_showToast(AppLocalizations.of(context)!.voiceReceiveCanceled);
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_autoPlayWhenReady = false;
_errorText = 'Voice receive canceled';
_errorText = AppLocalizations.of(context)!.voiceReceiveCanceled;
});
}
@@ -539,6 +551,8 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
required bool isPartialRequest,
required String? errorText,
required String requestingLabel,
required String fetchingMissingLabel,
required String receivingVoiceLabel,
required Duration? eta,
required bool isSentByMe,
required int transferCount,
@@ -547,12 +561,12 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
final progress = total > 0 ? ' ($received/$total)' : '';
if (isRequesting) {
final actionLabel = isPartialRequest
? 'Fetching missing voice fragments'
? fetchingMissingLabel
: requestingLabel;
return '$actionLabel$progress · ${_formatEta(eta)} · $txEstimateLabel';
}
if (isReceivingData) {
return 'Receiving voice$progress · ${_formatEta(eta)} · $txEstimateLabel';
return '$receivingVoiceLabel$progress · ${_formatEta(eta)} · $txEstimateLabel';
}
if (!isComplete && total > 0) {
return isSentByMe