mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
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:
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user