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

@@ -282,6 +282,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
required String serverKey,
}) async {
final connectionProvider = context.read<ConnectionProvider>();
final l10n = AppLocalizations.of(context)!;
setState(() {
_connectingToServerKey = serverKey;
@@ -291,7 +292,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
final success = await connectionProvider.connectTcp(host, port);
if (!success) {
throw Exception(
connectionProvider.error ?? 'Failed to connect to $host:$port',
connectionProvider.error ?? l10n.failedToConnectToHost(host, port),
);
}
await _rememberRecentServer(name: name, host: host, port: port);
@@ -311,6 +312,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
Widget build(BuildContext context) {
final connectionProvider = context.watch<ConnectionProvider>();
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
return Container(
height: MediaQuery.of(context).size.height * 0.9,
@@ -351,7 +353,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
child: Column(
children: [
Text(
'Connect Device',
l10n.connectDevice,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
@@ -359,7 +361,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
),
const SizedBox(height: 2),
Text(
'Choose Bluetooth, WiFi, or Serial transport',
l10n.chooseTransportSubtitle,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
@@ -382,10 +384,16 @@ class _ConnectionDialogState extends State<ConnectionDialog>
indicatorSize: TabBarIndicatorSize.tab,
labelColor: theme.colorScheme.onPrimaryContainer,
unselectedLabelColor: theme.colorScheme.onSurfaceVariant,
tabs: const [
Tab(text: 'BLE', icon: Icon(Icons.bluetooth_rounded)),
Tab(text: 'Network', icon: Icon(Icons.wifi_rounded)),
Tab(text: 'Serial', icon: Icon(Icons.usb_rounded)),
tabs: [
Tab(
text: l10n.ble,
icon: const Icon(Icons.bluetooth_rounded),
),
Tab(
text: l10n.network,
icon: const Icon(Icons.wifi_rounded),
),
Tab(text: l10n.serial, icon: const Icon(Icons.usb_rounded)),
],
),
],
@@ -678,8 +686,10 @@ class _ConnectionDialogState extends State<ConnectionDialog>
icon: Icons.bluetooth_searching_rounded,
title: _hasRequestedBleScan
? l10n.noDevicesFound
: 'Press scan to search for nearby devices',
actionLabel: _hasRequestedBleScan ? l10n.scanAgain : 'Scan',
: l10n.pressScanToSearchForDevices,
actionLabel: _hasRequestedBleScan
? l10n.scanAgain
: l10n.scan,
onAction: _refreshBleDevices,
)
: ListView.builder(
@@ -704,10 +714,10 @@ class _ConnectionDialogState extends State<ConnectionDialog>
if (!success) {
final name = device.platformName.isNotEmpty
? device.platformName
: 'device';
: l10n.device;
throw Exception(
connectionProvider.error ??
'Failed to connect to $name',
l10n.failedToConnectToDevice(name),
);
}
_closeOnSuccessfulConnection();
@@ -727,7 +737,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
iconColor: signalColor,
title: device.platformName.isNotEmpty
? device.platformName
: 'Unknown Device',
: l10n.unknownDevice,
subtitle: AppLocalizations.of(context)!.signalDbm(rssi.toString()),
trailing: isConnecting
? const SizedBox(
@@ -752,6 +762,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
}
Widget _buildNetworkServersTab() {
final l10n = AppLocalizations.of(context)!;
final bool showingCachedResults =
!_networkScanner.isScanning &&
_networkScanner.hasCachedResults &&
@@ -771,12 +782,14 @@ class _ConnectionDialogState extends State<ConnectionDialog>
? Icons.cached_rounded
: Icons.wifi_find_rounded,
message: showingCachedResults
? 'Showing cached results. Tap refresh to rescan.'
: 'Scanning local network for MeshCore WiFi devices on port 5000',
? l10n.showingCachedResultsTapRefresh
: l10n.scanningLocalNetworkOnPort(
NetworkScannerService.defaultPort,
),
secondaryActionIcon: Icons.add_rounded,
secondaryActionTooltip: _networkScanner.isScanning
? 'Cancel scan and add server'
: 'Add server',
? l10n.cancelScanAndAddServer
: l10n.addServer,
onSecondaryAction: isAnyConnectionInProgress
? null
: _connectManualTcpHost,
@@ -792,7 +805,10 @@ class _ConnectionDialogState extends State<ConnectionDialog>
),
const SizedBox(height: 8),
Text(
'Scanning... $_scannedCount/${_totalToScan > 0 ? _totalToScan : "?"} IPs',
l10n.scanningProgressIps(
_scannedCount,
_totalToScan > 0 ? '$_totalToScan' : '?',
),
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
@@ -801,7 +817,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
child: TextButton.icon(
onPressed: _connectManualTcpHost,
icon: const Icon(Icons.close_rounded),
label: const Text('Cancel and enter manually'),
label: Text(l10n.cancelAndEnterManually),
),
),
],
@@ -811,14 +827,14 @@ class _ConnectionDialogState extends State<ConnectionDialog>
child: showEmptyState
? _buildEmptyState(
icon: Icons.wifi_off_rounded,
title: 'No recent or discovered servers yet',
actionLabel: 'Scan Again',
title: l10n.noRecentOrDiscoveredServers,
actionLabel: l10n.scanAgain,
onAction: _startNetworkScan,
)
: ListView(
children: [
if (hasRecentServers)
_buildNetworkSectionHeader('Recently used'),
_buildNetworkSectionHeader(l10n.recentlyUsed),
for (final server in _recentServers)
_buildTransportCard(
icon: Icons.history_rounded,
@@ -826,7 +842,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
title: server.name,
subtitle:
_connectingToServerKey == '${server.host}:${server.port}'
? 'Connecting...'
? l10n.connecting
: '${server.host}:${server.port}',
trailing:
_connectingToServerKey == '${server.host}:${server.port}'
@@ -859,7 +875,9 @@ class _ConnectionDialogState extends State<ConnectionDialog>
),
),
if (hasDiscoveredServers)
_buildNetworkSectionHeader('Discovered on this network'),
_buildNetworkSectionHeader(
l10n.discoveredOnThisNetwork,
),
for (final server in _discoveredServers)
Builder(
builder: (context) {
@@ -874,7 +892,10 @@ class _ConnectionDialogState extends State<ConnectionDialog>
);
if (!isAvailable) {
throw Exception(
'Server at ${server.ipAddress}:${server.port} is no longer available. Please scan again to find active servers.',
l10n.serverNoLongerAvailable(
server.ipAddress,
server.port,
),
);
}
@@ -894,7 +915,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
iconColor: Colors.green,
title: server.displayName,
subtitle: isConnectingToThisServer
? 'Connecting...'
? l10n.connecting
: '${server.ipAddress}:${server.port}${server.responseTime}ms',
trailing: isConnectingToThisServer
? const SizedBox(
@@ -1002,6 +1023,7 @@ class _ManualTcpHostDialogState extends State<_ManualTcpHostDialog> {
}
void _submit() {
final l10n = AppLocalizations.of(context)!;
final host = _hostController.text.trim();
final portText = _portController.text.trim();
final parsedAddress = InternetAddress.tryParse(host);
@@ -1010,10 +1032,10 @@ class _ManualTcpHostDialogState extends State<_ManualTcpHostDialog> {
String? portErrorText;
if (parsedAddress == null) {
hostErrorText = 'Enter a valid IP address';
hostErrorText = l10n.enterValidIpAddress;
}
if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) {
portErrorText = 'Enter a valid TCP port';
portErrorText = l10n.enterValidTcpPort;
}
if (hostErrorText != null || portErrorText != null) {
setState(() {
@@ -1039,7 +1061,7 @@ class _ManualTcpHostDialogState extends State<_ManualTcpHostDialog> {
autofocus: true,
keyboardType: TextInputType.url,
decoration: InputDecoration(
labelText: 'IP address',
labelText: AppLocalizations.of(context)!.ipAddress,
hintText: '192.168.1.42',
border: const OutlineInputBorder(),
errorText: _hostErrorText,
@@ -1059,9 +1081,9 @@ class _ManualTcpHostDialogState extends State<_ManualTcpHostDialog> {
controller: _portController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'TCP port',
labelText: AppLocalizations.of(context)!.tcpPort,
hintText: NetworkScannerService.defaultPort.toString(),
helperText: 'Custom server port',
helperText: AppLocalizations.of(context)!.customServerPort,
border: const OutlineInputBorder(),
errorText: _portErrorText,
),

View File

@@ -16,6 +16,7 @@ class _AddChannelSheetState extends State<AddChannelSheet> {
final _nameController = TextEditingController();
final _secretController = TextEditingController();
bool _isCreating = false;
bool _obscureSecret = true;
@override
void dispose() {
@@ -203,8 +204,23 @@ class _AddChannelSheetState extends State<AddChannelSheet> {
labelText: l10n.channelSecret,
hintText: l10n.channelSecretHint,
border: const OutlineInputBorder(),
suffixIcon: IconButton(
onPressed: _isCreating
? null
: () => setState(() {
_obscureSecret = !_obscureSecret;
}),
icon: Icon(
_obscureSecret
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
tooltip: _obscureSecret
? 'Show secret'
: 'Hide secret',
),
),
obscureText: true,
obscureText: _obscureSecret,
enabled: !_isCreating,
maxLength: 32,
validator: _validateSecret,

View File

@@ -120,34 +120,6 @@ class ContactTile extends StatelessWidget {
: null;
void handleTap() => _handlePrimaryTap(context, contact);
final onLongPress = isPingInProgress
? null
: () async {
final connectionProvider = context.read<ConnectionProvider>();
final hasPath = contact.routeHasPath;
final result = await connectionProvider.smartPing(
contactPublicKey: contact.publicKey,
hasPath: hasPath,
onRetryWithFlooding: () {
if (context.mounted) {
ToastLogger.warning(
context,
AppLocalizations.of(
context,
)!.directPingTimeout(contact.displayName),
);
}
},
);
if (context.mounted && !result.success) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.pingFailed(contact.displayName),
);
}
};
final colorScheme = Theme.of(context).colorScheme;
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
@@ -233,7 +205,7 @@ class ContactTile extends StatelessWidget {
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: handleTap,
onLongPress: onLongPress,
onLongPress: handleTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
@@ -418,6 +390,36 @@ class ContactTile extends StatelessWidget {
_showContactActionSheet(context, contact);
}
Future<void> _smartPingContact(BuildContext context, Contact contact) async {
final connectionProvider = context.read<ConnectionProvider>();
final hasPath = contact.routeHasPath;
final result = await connectionProvider.smartPing(
contactPublicKey: contact.publicKey,
hasPath: hasPath,
onRetryWithFlooding: () {
if (context.mounted) {
ToastLogger.warning(
context,
AppLocalizations.of(
context,
)!.directPingTimeout(contact.displayName),
);
}
},
);
if (!context.mounted) return;
if (result.success) {
ToastLogger.success(context, 'Ping reply from ${contact.displayName}');
} else {
ToastLogger.error(
context,
AppLocalizations.of(context)!.pingFailed(contact.displayName),
);
}
}
String _locationSharingErrorMessage(Object error) {
final message = error.toString();
if (message.startsWith('Bad state: ')) {
@@ -498,6 +500,11 @@ class ContactTile extends StatelessWidget {
contact.type == ContactType.repeater ||
contact.type == ContactType.sensor;
final canPreviewSensor = contact.isSensor;
final canSmartPing =
contact.type == ContactType.chat || contact.type == ContactType.sensor;
final isPingInProgress = context.read<ConnectionProvider>().isPingInProgress(
contact.publicKey,
);
final sensorsProvider = context.read<SensorsProvider>();
final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex);
final channelLocationSharingFuture =
@@ -662,6 +669,16 @@ class ContactTile extends StatelessWidget {
_pingRelay(context, contact);
},
),
if (canSmartPing)
_ContactSheetAction(
icon: Icons.network_ping,
label: l10n.ping,
enabled: !isPingInProgress,
onTap: () async {
Navigator.pop(context);
await _smartPingContact(context, contact);
},
),
if (!contact.isPublicChannel)
_ContactSheetAction(
icon: Icons.edit_outlined,
@@ -1596,11 +1613,12 @@ class _ContactActionSheetState extends State<_ContactActionSheet> {
),
),
),
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
Material(
color: colorScheme.surfaceContainerLow,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
border: Border.all(
side: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.28),
),
),

View File

@@ -21,7 +21,6 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
final FocusNode _focusNode = FocusNode();
bool _isLoggingIn = false;
bool _obscurePassword = true;
bool _isDisposed = false; // Track disposal state for async callbacks
@override
void initState() {
@@ -31,7 +30,6 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
@override
void dispose() {
_isDisposed = true;
_passwordController.dispose();
_focusNode.dispose();
super.dispose();
@@ -228,6 +226,15 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
// Save password before sending
await _savePassword(password);
if (!mounted) return;
// Capture references before the sheet pops: the login result arrives
// after Navigator.pop, when this State is disposed, so the callbacks
// below must not depend on this widget's context.
final messenger = ScaffoldMessenger.of(context);
final colorScheme = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
// Set up login callbacks
Function(Uint8List, int, bool, int)? originalOnSuccess;
Function(Uint8List)? originalOnFail;
@@ -251,12 +258,12 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
' Messages will be fetched when onMessageWaiting callback is triggered',
);
// Check both _isDisposed flag and mounted to handle race conditions
if (_isDisposed || !mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
// Use the captured messenger: the sheet has already been popped by the
// time this callback fires, so this State's context is gone.
messenger.showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.loggedInSuccessfully),
backgroundColor: Theme.of(context).colorScheme.primary,
content: Text(l10n.loggedInSuccessfully),
backgroundColor: colorScheme.primary,
duration: const Duration(seconds: 3),
),
);
@@ -269,12 +276,12 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
debugPrint('❌ [RoomLogin] Login failed - incorrect password');
// Check both _isDisposed flag and mounted to handle race conditions
if (_isDisposed || !mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
// Use the captured messenger: the sheet has already been popped by the
// time this callback fires, so this State's context is gone.
messenger.showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.loginFailed),
backgroundColor: Theme.of(context).colorScheme.error,
content: Text(l10n.loginFailed),
backgroundColor: colorScheme.error,
duration: const Duration(seconds: 3),
),
);
@@ -289,15 +296,14 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
_focusNode.unfocus();
if (!mounted) return;
Navigator.pop(context); // Close the dialog
if (mounted) {
Navigator.pop(context); // Close the dialog
}
ScaffoldMessenger.of(context).showSnackBar(
messenger.showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.loggingIn(widget.contact.displayName),
),
backgroundColor: Theme.of(context).colorScheme.primary,
content: Text(l10n.loggingIn(widget.contact.displayName)),
backgroundColor: colorScheme.primary,
duration: const Duration(seconds: 2),
),
);
@@ -306,13 +312,10 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
connectionProvider.onLoginSuccess = originalOnSuccess;
connectionProvider.onLoginFail = originalOnFail;
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
messenger.showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.failedToSendLogin(e.toString()),
),
backgroundColor: Theme.of(context).colorScheme.error,
content: Text(l10n.failedToSendLogin(e.toString())),
backgroundColor: colorScheme.error,
),
);
} finally {

View File

@@ -66,20 +66,27 @@ class DrawingToolbar extends StatelessWidget {
icon: const Icon(Icons.close, size: 20),
onPressed: () => drawingProvider.exitDrawingMode(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
constraints: const BoxConstraints(
minWidth: 44,
minHeight: 44,
),
),
],
),
const SizedBox(height: 8),
// Color picker - more compact
Wrap(
spacing: 4,
runSpacing: 4,
spacing: 0,
runSpacing: 0,
children: DrawingColors.palette.map((color) {
final isSelected = drawingProvider.selectedColor == color;
// Padding expands the touch target to 40x40 while keeping
// the swatch visually compact (glove-friendly hit area)
return GestureDetector(
onTap: () => drawingProvider.setColor(color),
behavior: HitTestBehavior.opaque,
child: Container(
margin: const EdgeInsets.all(8),
width: 24,
height: 24,
decoration: BoxDecoration(
@@ -124,8 +131,11 @@ class DrawingToolbar extends StatelessWidget {
onPressed: () => drawingProvider.cancelCurrentDrawing(),
tooltip: AppLocalizations.of(context)!.cancel,
iconSize: 20,
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 44,
minHeight: 44,
),
),
// Clear measurement
if (drawingProvider.drawingMode == DrawingMode.measure &&
@@ -136,8 +146,11 @@ class DrawingToolbar extends StatelessWidget {
tooltip: AppLocalizations.of(context)!.clearMeasurement,
color: Colors.orange,
iconSize: 20,
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 44,
minHeight: 44,
),
),
// Complete line drawing
if (drawingProvider.drawingMode == DrawingMode.line &&
@@ -148,8 +161,11 @@ class DrawingToolbar extends StatelessWidget {
tooltip: AppLocalizations.of(context)!.completeLine,
color: Colors.green,
iconSize: 20,
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 44,
minHeight: 44,
),
),
// Clear all drawings
if (drawingProvider.drawings.isNotEmpty)
@@ -160,8 +176,11 @@ class DrawingToolbar extends StatelessWidget {
tooltip: AppLocalizations.of(context)!.clearAll,
color: Colors.red,
iconSize: 20,
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 44,
minHeight: 44,
),
),
],
),
@@ -702,6 +721,7 @@ class DrawingToolbar extends StatelessWidget {
);
int successCount = 0;
int alreadyShared = 0;
int failedCount = 0;
for (final drawing in drawings) {
// Skip if already shared
@@ -763,12 +783,26 @@ class DrawingToolbar extends StatelessWidget {
} catch (e, stackTrace) {
debugPrint('❌ Failed to share drawing ${drawing.id}: $e');
debugPrint(' Stack trace: $stackTrace');
failedCount++;
}
}
debugPrint(
' Share complete: $successCount/${drawings.length} sent, $alreadyShared already shared',
);
if (!context.mounted) return;
if (failedCount > 0) {
ToastLogger.error(
context,
'Failed to share $failedCount drawing${failedCount == 1 ? '' : 's'}',
);
} else if (successCount > 0) {
ToastLogger.success(
context,
'Shared $successCount drawing${successCount == 1 ? '' : 's'}',
);
}
}
/// Share drawings to a specific room
@@ -800,6 +834,7 @@ class DrawingToolbar extends StatelessWidget {
);
int successCount = 0;
int alreadyShared = 0;
int failedCount = 0;
for (final drawing in drawings) {
// Skip if already shared
@@ -864,12 +899,26 @@ class DrawingToolbar extends StatelessWidget {
'❌ Failed to share drawing ${drawing.id} to ${room.advName}: $e',
);
debugPrint(' Stack trace: $stackTrace');
failedCount++;
}
}
debugPrint(
' Share complete: $successCount/${drawings.length} sent, $alreadyShared already shared',
);
if (!context.mounted) return;
if (failedCount > 0) {
ToastLogger.error(
context,
'Failed to share $failedCount drawing${failedCount == 1 ? '' : 's'}',
);
} else if (successCount > 0) {
ToastLogger.success(
context,
'Shared $successCount drawing${successCount == 1 ? '' : 's'}',
);
}
}
/// Show share dialog for a single drawing

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