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

@@ -536,11 +536,13 @@ class _ContactsTabState extends State<ContactsTab> {
) async {
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
try {
final result = await context.read<AppProvider>().setChannelLocationSharingEnabled(
channelIdx,
enabled,
l10n: AppLocalizations.of(context),
);
final result = await context
.read<AppProvider>()
.setChannelLocationSharingEnabled(
channelIdx,
enabled,
l10n: AppLocalizations.of(context),
);
if (!context.mounted) return;
ToastLogger.success(context, result.message);
} catch (error) {
@@ -577,7 +579,11 @@ class _ContactsTabState extends State<ContactsTab> {
.read<AppProvider>()
.getChannelLocationSharingState(channelIdx);
if (!context.mounted) return;
await _setChannelLocationSharing(context, channel, !sharingState.isSharing);
await _setChannelLocationSharing(
context,
channel,
!sharingState.isSharing,
);
} catch (error) {
if (!context.mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
@@ -591,9 +597,9 @@ class _ContactsTabState extends State<ContactsTab> {
);
final channelLocationSharingFuture =
!channel.isPublicChannel && channel.publicKey.length > 1
? context
.read<AppProvider>()
.getChannelLocationSharingState(channel.publicKey[1])
? context.read<AppProvider>().getChannelLocationSharingState(
channel.publicKey[1],
)
: null;
showModalBottomSheet<void>(
@@ -1448,7 +1454,9 @@ class _ContactsTabState extends State<ContactsTab> {
height: 1.1,
),
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.searchThisSection,
hintText: AppLocalizations.of(
context,
)!.searchThisSection,
hintStyle: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.85,
@@ -1823,81 +1831,90 @@ class _InferredContactGroupCard extends StatelessWidget {
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
child: Material(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
),
),
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
childrenPadding: const EdgeInsets.fromLTRB(10, 0, 10, 6),
minTileHeight: 44,
initiallyExpanded: false,
leading: Icon(
Icons.folder_copy_outlined,
size: 16,
color: colorScheme.primary,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
),
title: Row(
children: [
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
fontSize: 13,
),
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 0,
),
childrenPadding: const EdgeInsets.fromLTRB(10, 0, 10, 6),
minTileHeight: 44,
initiallyExpanded: false,
leading: Icon(
Icons.folder_copy_outlined,
size: 16,
color: colorScheme.primary,
),
title: Row(
children: [
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
fontSize: 13,
),
),
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text(
contacts.length.toString(),
style: Theme.of(context).textTheme.labelSmall,
),
),
if (onDelete != null) ...[
const SizedBox(width: 2),
IconButton(
tooltip: AppLocalizations.of(context)!.deleteGroup,
onPressed: onDelete,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints.tightFor(
width: 32,
height: 32,
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 1,
),
padding: EdgeInsets.zero,
icon: Icon(
Icons.delete_outline_rounded,
size: 16,
color: colorScheme.error,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text(
contacts.length.toString(),
style: Theme.of(context).textTheme.labelSmall,
),
),
if (onDelete != null) ...[
const SizedBox(width: 2),
IconButton(
tooltip: AppLocalizations.of(context)!.deleteGroup,
onPressed: onDelete,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints.tightFor(
width: 32,
height: 32,
),
padding: EdgeInsets.zero,
icon: Icon(
Icons.delete_outline_rounded,
size: 16,
color: colorScheme.error,
),
),
],
],
),
children: [
...contacts.map(
(contact) => ContactTile(
contact: contact,
compact: compactContacts,
currentPosition: currentPosition,
calculateDistance: calculateDistance,
formatDistance: formatDistance,
onNavigateToMap: onNavigateToMap,
onNavigateToMessages: onNavigateToMessages,
),
),
],
),
children: [
...contacts.map(
(contact) => ContactTile(
contact: contact,
compact: compactContacts,
currentPosition: currentPosition,
calculateDistance: calculateDistance,
formatDistance: formatDistance,
onNavigateToMap: onNavigateToMap,
onNavigateToMessages: onNavigateToMessages,
),
),
],
),
),
);
@@ -1940,11 +1957,7 @@ class _ChannelActivityCard extends StatelessWidget {
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: Icon(
Icons.location_pin,
size: 11,
color: Colors.white,
),
child: Icon(Icons.location_pin, size: 11, color: Colors.white),
);
}
@@ -2599,8 +2612,7 @@ class _ContactsRegionScopeSheet extends StatefulWidget {
_ContactsRegionScopeSheetState();
}
class _ContactsRegionScopeSheetState
extends State<_ContactsRegionScopeSheet> {
class _ContactsRegionScopeSheetState extends State<_ContactsRegionScopeSheet> {
final TextEditingController _nameController = TextEditingController();
@override

View File

@@ -550,6 +550,37 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}
}
bool get _hasUnsavedChanges =>
_radioSettingsDirty || _autoDiscoverySettingsDirty;
Future<void> _handlePopInvoked(bool didPop, Object? result) async {
if (didPop) return;
final discard = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Discard unsaved changes?'),
content: const Text(
'You have changes that have not been saved to the device.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Keep editing'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Discard'),
),
],
),
);
if (discard == true && mounted) {
Navigator.of(context).pop();
}
}
String _autoDiscoverySignature(DeviceInfo deviceInfo) {
return [
deviceInfo.manualAddContacts,
@@ -766,6 +797,78 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}
}
Future<bool> _confirmApplyRadioSettings({
required int freqKhz,
required int bandwidth,
required int spreadingFactor,
required int codingRate,
required int txPower,
}) async {
final deviceInfo = context.read<ConnectionProvider>().deviceInfo;
final oldFreqKhz = deviceInfo.radioFreq;
final oldBandwidth = _normalizeBandwidthValue(deviceInfo.radioBw);
final oldSpreadingFactor = _normalizeSpreadingFactor(deviceInfo.radioSf);
final oldCodingRate = _normalizeCodingRate(deviceInfo.radioCr);
final oldTxPower = deviceInfo.txPower;
String formatFreq(int? khz) =>
khz != null ? (khz / 1000).toStringAsFixed(3) : '';
final changes = <String>[
if (oldFreqKhz != freqKhz)
'${AppLocalizations.of(context)!.frequencyMHz}: ${formatFreq(oldFreqKhz)}${formatFreq(freqKhz)}',
if (oldBandwidth != bandwidth)
'${AppLocalizations.of(context)!.bandwidth}: ${oldBandwidth != null ? _bandwidthFromValue(oldBandwidth) : ''}${_bandwidthFromValue(bandwidth)}',
if (oldSpreadingFactor != spreadingFactor)
'${AppLocalizations.of(context)!.spreadingFactor}: ${oldSpreadingFactor ?? ''}$spreadingFactor',
if (oldCodingRate != codingRate)
'${AppLocalizations.of(context)!.codingRate}: ${oldCodingRate ?? ''}$codingRate',
if (oldTxPower != txPower)
'${AppLocalizations.of(context)!.txPowerDbm}: ${oldTxPower ?? ''}$txPower',
];
if (changes.isEmpty) {
return true;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Apply radio settings?'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final change in changes)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(change),
),
const SizedBox(height: 8),
Text(
'Changing these settings will disconnect you from any mesh nodes not using the same settings.',
style: TextStyle(
color: Theme.of(dialogContext).colorScheme.error,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(AppLocalizations.of(context)!.cancel),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Apply'),
),
],
),
);
return confirmed == true;
}
Future<void> _saveRadioSettings() async {
final connectionProvider = context.read<ConnectionProvider>();
final validator = ValidationService();
@@ -808,6 +911,24 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
// Convert from MHz to kHz for protocol
final freqKhz = (freqResult.value! * 1000).round();
// Confirm before applying — wrong radio params knock this node off
// the mesh shared with the rest of the team.
final confirmed = await _confirmApplyRadioSettings(
freqKhz: freqKhz,
bandwidth: _bandwidthToValue(_selectedBandwidth),
spreadingFactor: _selectedSpreadingFactor,
codingRate: _selectedCodingRate,
txPower: txPowerResult.value!,
);
if (!confirmed || !mounted) {
if (mounted) {
setState(() {
_isSavingRadioSettings = false;
});
}
return;
}
await connectionProvider.setRadioParams(
frequency: freqKhz,
bandwidth: _bandwidthToValue(_selectedBandwidth),
@@ -1096,29 +1217,57 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}
Future<void> _confirmFactoryReset() async {
final deviceInfo = context.read<ConnectionProvider>().deviceInfo;
final deviceName = (deviceInfo.selfName ?? deviceInfo.deviceName)?.trim();
final confirmationWord = (deviceName == null || deviceName.isEmpty)
? 'WIPE'
: deviceName;
final confirmationController = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(AppLocalizations.of(context)!.wipeDeviceData),
content: const Text(
'This will erase all data on the connected device, including contacts, keys, and saved settings. This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(AppLocalizations.of(context)!.cancel),
builder: (dialogContext) => StatefulBuilder(
builder: (dialogContext, setDialogState) => AlertDialog(
title: Text(AppLocalizations.of(context)!.wipeDeviceData),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'This will erase all data on the connected device, including contacts, keys, and saved settings. This cannot be undone.',
),
const SizedBox(height: 12),
Text('Type "$confirmationWord" to confirm:'),
const SizedBox(height: 8),
TextField(
controller: confirmationController,
autofocus: true,
decoration: InputDecoration(hintText: confirmationWord),
onChanged: (_) => setDialogState(() {}),
),
],
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(AppLocalizations.of(context)!.cancel),
),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(AppLocalizations.of(context)!.wipeDevice),
),
],
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
onPressed:
confirmationController.text.trim() == confirmationWord
? () => Navigator.of(dialogContext).pop(true)
: null,
child: Text(AppLocalizations.of(context)!.wipeDevice),
),
],
),
),
);
confirmationController.dispose();
if (confirmed != true || !mounted) return;
@@ -1367,7 +1516,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
final colorScheme = theme.colorScheme;
final locationSet = _advertLocationPolicy != 0;
return Scaffold(
final scaffold = Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context)!.deviceSettings)),
body: ColoredBox(
color: colorScheme.surface,
@@ -2123,9 +2272,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
onChanged: (value) {
setState(() {
_repeatEnabled = value;
_radioSettingsSaved = false;
_radioSettingsError = null;
});
_markRadioSettingsDirty();
},
),
),
@@ -2375,6 +2523,12 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
),
),
);
return PopScope(
canPop: !_hasUnsavedChanges,
onPopInvokedWithResult: _handlePopInvoked,
child: scaffold,
);
}
String _getDeviceTypeString(BuildContext context, int? deviceType) {

View File

@@ -100,10 +100,21 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
});
try {
await context.read<ConnectionProvider>().discoverNodeType(
advertType: advertType,
);
final connectionProvider = context.read<ConnectionProvider>();
connectionProvider.clearError();
await connectionProvider.discoverNodeType(advertType: advertType);
if (!mounted) return;
final discoveryError = connectionProvider.error;
if (discoveryError != null) {
connectionProvider.clearError();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(discoveryError),
backgroundColor: Colors.red,
),
);
return;
}
final l10n = AppLocalizations.of(context)!;
final label = switch (advertType) {
_repeaterAdvertType => l10n.repeaterDiscoverySent,
@@ -153,8 +164,10 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
return;
}
} else {
connectionProvider.clearError();
await connectionProvider.getContact(advert.publicKey);
if (connectionProvider.error == 'Not found') {
final resolveError = connectionProvider.error;
if (resolveError == 'Not found') {
connectionProvider.clearError();
final added = await _addPendingAdvertToRadio(
advert,
@@ -164,6 +177,23 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
if (!added) {
return;
}
} else if (resolveError != null) {
// Other failures (timeout, disconnect, ...): surface the error and
// leave the advert pending so the user can retry.
connectionProvider.clearError();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(
context,
)!.failedToAddContact(resolveError),
),
backgroundColor: Colors.red,
),
);
}
return;
}
}
if ((advert.typeValue ?? 0) == _sensorAdvertType) {
@@ -248,10 +278,12 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
required ContactsProvider contactsProvider,
}) async {
final contact = _contactFromPendingAdvert(advert);
connectionProvider.clearError();
await connectionProvider.addOrUpdateContact(contact);
final addError = connectionProvider.error;
if (addError != null) {
connectionProvider.clearError();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.failedToAddContact(addError.toString()))),

View File

@@ -1,11 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:vibration/vibration.dart';
import '../providers/connection_provider.dart';
import '../providers/app_provider.dart';
import '../models/device_info.dart' show ConnectionMode, DeviceInfo;
@@ -24,7 +21,6 @@ import 'device_config_screen.dart';
import 'packet_log_screen.dart';
import 'live_traffic_screen.dart';
import 'profiles_screen.dart';
import '../utils/toast_logger.dart';
import '../l10n/app_localizations.dart';
import '../widgets/permission_request_dialog.dart';
import '../widgets/connection_dialog.dart';
@@ -37,8 +33,6 @@ import '../services/profiles_feature_service.dart';
enum _HomeTab { messages, contacts, sensors, map }
enum _AdvertMode { flood, direct }
class HomeScreen extends StatefulWidget {
final Function(AppThemeMode) onThemeChanged;
final Function(Locale?) onLocaleChanged;
@@ -70,11 +64,15 @@ class _HomeScreenState extends State<HomeScreen>
bool _isMapEnabled = true;
bool _isContactsEnabled = true;
bool _isSensorsEnabled = false;
bool _isSimpleMode = false;
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
String? _lastProfileDeviceKey;
Timer? _sensorAutoRefreshTicker;
List<_HomeTab> get _enabledTabs {
if (_isSimpleMode) {
return const [_HomeTab.messages, _HomeTab.map];
}
return [
_HomeTab.messages,
if (_isContactsEnabled) _HomeTab.contacts,
@@ -101,6 +99,7 @@ class _HomeScreenState extends State<HomeScreen>
_isMapEnabled = _appProvider.isMapEnabled;
_isContactsEnabled = _appProvider.isContactsEnabled;
_isSensorsEnabled = _appProvider.isSensorsEnabled;
_isSimpleMode = _appProvider.isSimpleMode;
_appProvider.addListener(_handleAppProviderChanged);
// Initialize synchronously so first build always has a valid controller.
@@ -137,6 +136,7 @@ class _HomeScreenState extends State<HomeScreen>
mapEnabled: _appProvider.isMapEnabled,
contactsEnabled: _appProvider.isContactsEnabled,
sensorsEnabled: _appProvider.isSensorsEnabled,
simpleMode: _appProvider.isSimpleMode,
);
}
@@ -161,10 +161,12 @@ class _HomeScreenState extends State<HomeScreen>
required bool mapEnabled,
required bool contactsEnabled,
required bool sensorsEnabled,
required bool simpleMode,
}) {
if (_isMapEnabled == mapEnabled &&
_isContactsEnabled == contactsEnabled &&
_isSensorsEnabled == sensorsEnabled) {
_isSensorsEnabled == sensorsEnabled &&
_isSimpleMode == simpleMode) {
return;
}
@@ -182,6 +184,7 @@ class _HomeScreenState extends State<HomeScreen>
_isMapEnabled = mapEnabled;
_isContactsEnabled = contactsEnabled;
_isSensorsEnabled = sensorsEnabled;
_isSimpleMode = simpleMode;
if (!_isMapEnabled) {
_isMapFullscreen = false;
}
@@ -359,113 +362,6 @@ class _HomeScreenState extends State<HomeScreen>
);
}
Future<void> _triggerAdvertFeedback() async {
final platform = Theme.of(context).platform;
try {
if (platform == TargetPlatform.iOS) {
await HapticFeedback.lightImpact();
await Future.delayed(const Duration(milliseconds: 50));
await HapticFeedback.lightImpact();
} else {
if (await Vibration.hasVibrator()) {
await Vibration.vibrate(duration: 50);
} else {
await HapticFeedback.mediumImpact();
}
}
} catch (e) {
debugPrint('Haptic feedback error: $e');
await HapticFeedback.vibrate();
}
}
Future<_AdvertMode?> _showAdvertModeSheet(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return showModalBottomSheet<_AdvertMode>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Advert mode',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
'Choose how far this announcement should travel.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 4,
),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(14),
),
child: Icon(
Icons.hub_rounded,
color: theme.colorScheme.onPrimaryContainer,
),
),
title: Text(l10n.flood),
subtitle: Text(l10n.relayThroughRepeatersAcrossTheMesh),
trailing: const Icon(Icons.chevron_right_rounded),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
onTap: () => Navigator.of(context).pop(_AdvertMode.flood),
),
const SizedBox(height: 8),
ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 4,
),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(14),
),
child: Icon(
Icons.near_me_rounded,
color: theme.colorScheme.onSecondaryContainer,
),
),
title: Text(l10n.direct),
subtitle: Text(l10n.nearbyOnlyWithoutRepeaterFlooding),
trailing: const Icon(Icons.chevron_right_rounded),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
onTap: () => Navigator.of(context).pop(_AdvertMode.direct),
),
],
),
),
),
);
}
void _showDeviceInfoSheet(BuildContext context, DeviceInfo deviceInfo) {
// Request self telemetry so it's fresh
context.read<ConnectionProvider>().requestSelfTelemetry();
@@ -679,108 +575,6 @@ class _HomeScreenState extends State<HomeScreen>
);
}
Future<void> _advertiseDevice(
BuildContext context, {
bool floodMode = true,
}) async {
final connectionProvider = context.read<ConnectionProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
if (context.mounted) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.deviceNotConnected,
);
}
return;
}
try {
// Check if location services are enabled
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
if (context.mounted) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.locationServicesDisabled,
);
}
return;
}
// Check location permissions
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
if (context.mounted) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.locationPermissionDenied,
);
}
return;
}
}
if (permission == LocationPermission.deniedForever) {
if (context.mounted) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.locationPermissionPermanentlyDenied,
);
}
return;
}
// Get current GPS position
Position? position;
try {
position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: 0,
),
).timeout(const Duration(seconds: 5));
} catch (e) {
debugPrint('❌ Failed to get GPS position: $e');
if (context.mounted) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.failedToGetGpsLocation,
);
}
return;
}
// Update lat/lon on device
await connectionProvider.setAdvertLatLon(
latitude: position.latitude,
longitude: position.longitude,
);
// Small delay to ensure the lat/lon is set
await Future.delayed(const Duration(milliseconds: 100));
await connectionProvider.sendSelfAdvert(floodMode: floodMode);
if (context.mounted) {
ToastLogger.success(
context,
floodMode ? 'Flood advert sent' : 'Direct advert sent',
);
}
} catch (e) {
debugPrint('❌ Failed to advertise device: $e');
if (context.mounted) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.failedToAdvertise(e.toString()),
);
}
}
}
Future<void> _showConnectionDialog(BuildContext context) async {
await showConnectionDialogFlow(
context,
@@ -837,102 +631,105 @@ class _HomeScreenState extends State<HomeScreen>
icon: const Icon(Icons.more_vert),
itemBuilder: (context) {
final items = <PopupMenuEntry<void>>[];
final profilesEnabled = context
.read<ProfileManager>()
.profilesEnabled;
final profilesEnabled =
!_isSimpleMode &&
context.read<ProfileManager>().profilesEnabled;
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.radar_outlined),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.liveTraffic),
],
),
onTap: () {
final navigator = Navigator.of(context);
final provider = context.read<ConnectionProvider>();
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (_) => LiveTrafficScreen.fromProvider(
provider,
openPacketLogs: () {
navigator.push(
MaterialPageRoute(
builder: (_) => PacketLogScreen(
bleService: provider.bleService,
),
if (!_isSimpleMode) {
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.radar_outlined),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.liveTraffic),
],
),
onTap: () {
final navigator = Navigator.of(context);
final provider = context.read<ConnectionProvider>();
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (_) =>
LiveTrafficScreen.fromProvider(
provider,
openPacketLogs: () {
navigator.push(
MaterialPageRoute(
builder: (_) => PacketLogScreen(
bleService: provider.bleService,
),
),
);
},
),
);
},
),
),
);
});
},
),
);
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.router_outlined),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.repeatersMap),
],
);
});
},
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) =>
const RepeatersMapScreen(),
fullscreenDialog: true,
),
);
});
},
),
);
);
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.person_search),
const SizedBox(width: 8),
Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final pendingCount =
contactsProvider.pendingAdverts.length;
return Text(
pendingCount > 0
? 'Discovery ($pendingCount)'
: 'Discovery',
);
},
),
],
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.router_outlined),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.repeatersMap),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) =>
const RepeatersMapScreen(),
fullscreenDialog: true,
),
);
});
},
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) => const DiscoveryScreen(),
);
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.person_search),
const SizedBox(width: 8),
Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final pendingCount =
contactsProvider.pendingAdverts.length;
return Text(
pendingCount > 0
? 'Discovery ($pendingCount)'
: 'Discovery',
);
},
),
);
});
},
),
);
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) => const DiscoveryScreen(),
),
);
});
},
),
);
}
items.add(
PopupMenuItem(
@@ -1066,7 +863,7 @@ class _HomeScreenState extends State<HomeScreen>
Icons.message,
unreadCount,
),
text: 'Chat',
text: AppLocalizations.of(context)!.chat,
);
case _HomeTab.contacts:
return Tab(
@@ -1082,9 +879,9 @@ class _HomeScreenState extends State<HomeScreen>
text: AppLocalizations.of(context)!.map,
);
case _HomeTab.sensors:
return const Tab(
icon: Icon(Icons.sensors),
text: 'Sensors',
return Tab(
icon: const Icon(Icons.sensors),
text: AppLocalizations.of(context)!.sensors,
);
}
}).toList(),
@@ -1122,8 +919,8 @@ class _HomeScreenState extends State<HomeScreen>
),
Text(
provider.isReconnecting
? 'Restoring previous link'
: 'No device connected',
? AppLocalizations.of(context)!.restoringPreviousLink
: AppLocalizations.of(context)!.noDeviceConnected,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
@@ -1307,50 +1104,6 @@ class _HomeScreenState extends State<HomeScreen>
padding: EdgeInsets.zero,
),
),
const SizedBox(width: 4),
GestureDetector(
onTap: () async {
await _triggerAdvertFeedback();
if (!mounted || !context.mounted) return;
await _advertiseDevice(context);
},
onLongPress: () async {
await _triggerAdvertFeedback();
if (!mounted || !context.mounted) return;
final mode = await _showAdvertModeSheet(context);
if (!mounted || !context.mounted || mode == null) {
return;
}
await _advertiseDevice(
context,
floodMode: mode == _AdvertMode.flood,
);
},
child: Container(
width: isTight ? 38 : 40,
height: isTight ? 38 : 40,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
theme.colorScheme.primary,
theme.colorScheme.primary.withValues(
alpha: 0.78,
),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.campaign_rounded,
color: Colors.white,
size: isTight ? 18 : 20,
),
),
),
],
),
),

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@ import '../services/message_destination_preferences.dart';
import '../services/voice_bitrate_preferences.dart';
import '../services/voice_recorder_service.dart';
import '../services/voice_codec_service.dart';
import '../utils/advert_helper.dart';
import '../utils/toast_logger.dart';
import '../utils/key_comparison.dart';
import '../utils/contact_sorting.dart';
@@ -149,6 +150,8 @@ class _MessagesTabState extends State<MessagesTab> {
String _mentionQuery = '';
List<Contact> _mentionSuggestions = const [];
ContactsProvider? _contactsProvider;
AppProvider? _appProvider;
bool? _lastKnownSimpleMode;
// Message destination state
String _destinationType =
@@ -207,6 +210,13 @@ class _MessagesTabState extends State<MessagesTab> {
_contactsProvider = contactsProvider;
_contactsProvider?.addListener(_handleContactsChanged);
}
final appProvider = context.read<AppProvider>();
if (!identical(_appProvider, appProvider)) {
_appProvider?.removeListener(_handleSimpleModeChanged);
_appProvider = appProvider;
_lastKnownSimpleMode = appProvider.isSimpleMode;
_appProvider?.addListener(_handleSimpleModeChanged);
}
_scheduleDestinationSync();
}
@@ -217,6 +227,7 @@ class _MessagesTabState extends State<MessagesTab> {
_voiceStreamSub?.cancel();
_voiceRecorder.dispose();
_contactsProvider?.removeListener(_handleContactsChanged);
_appProvider?.removeListener(_handleSimpleModeChanged);
_focusNode.removeListener(_handleFocusChanged);
_textController.dispose();
_focusNode.dispose();
@@ -246,6 +257,14 @@ class _MessagesTabState extends State<MessagesTab> {
_scheduleDestinationSync();
}
void _handleSimpleModeChanged() {
if (!mounted) return;
final simpleMode = _appProvider?.isSimpleMode;
if (simpleMode == _lastKnownSimpleMode) return;
_lastKnownSimpleMode = simpleMode;
_scheduleDestinationSync();
}
Future<void> _synchronizeDestinationState() async {
if (!mounted) return;
final messagesProvider = context.read<MessagesProvider>();
@@ -409,8 +428,11 @@ class _MessagesTabState extends State<MessagesTab> {
List<Contact> _buildMentionSuggestions(String query) {
final contactsProvider = context.read<ContactsProvider>();
final normalizedQuery = query.trim().toLowerCase();
final candidates = context.read<AppProvider>().isSimpleMode
? contactsProvider.favouriteChatContacts
: contactsProvider.chatContacts;
final contacts =
contactsProvider.chatContacts.where((contact) {
candidates.where((contact) {
if (normalizedQuery.isEmpty) return true;
return contact.displayName.toLowerCase().contains(normalizedQuery);
}).toList()..sort((a, b) {
@@ -488,6 +510,57 @@ class _MessagesTabState extends State<MessagesTab> {
savedDestination?['publicKey'];
if (!mounted) return;
final contactsProvider = context.read<ContactsProvider>();
if (context.read<AppProvider>().isSimpleMode) {
// Simple mode allows the locked channel/room plus direct messages to
// favourite contacts. A favourite chosen by the user (override or
// saved selection) wins over the locked anchor; anything else falls
// back to the locked destination, then to the first favourite. The
// fallback is never persisted over the normal-mode preference.
final favourites = contactsProvider.favouriteChatContacts;
final candidateType = overrideType ?? savedDestination?['type'];
final candidateKey =
overrideRecipientPublicKeyHex ?? savedDestination?['publicKey'];
Contact? favourite;
if (candidateType ==
MessageDestinationPreferences.destinationTypeContact) {
favourite = favourites
.where((c) => c.publicKeyHex == candidateKey)
.firstOrNull;
}
String destinationType;
Contact? recipient;
if (favourite != null) {
destinationType = MessageDestinationPreferences.destinationTypeContact;
recipient = favourite;
} else if (lockedDestination != null) {
destinationType =
lockedDestination['type'] ??
MessageDestinationPreferences.destinationTypeChannel;
recipient = _resolveDestinationRecipient(
contactsProvider,
destinationType,
lockedDestination['publicKey'],
);
} else {
destinationType = MessageDestinationPreferences.destinationTypeContact;
recipient = favourites.firstOrNull;
}
setState(() {
// Keep the selector usable so favourites stay reachable.
_isDestinationLocked = false;
_destinationType = destinationType;
_selectedRecipient = recipient;
});
_enforceMessageByteLimit();
await _loadRegionScope();
return;
}
final recipient = _resolveDestinationRecipient(
contactsProvider,
effectiveType,
@@ -495,7 +568,8 @@ class _MessagesTabState extends State<MessagesTab> {
);
final allowsEmptyRecipient =
effectiveType == MessageDestinationPreferences.destinationTypeAll ||
(effectiveType == MessageDestinationPreferences.destinationTypeChannel &&
(effectiveType ==
MessageDestinationPreferences.destinationTypeChannel &&
effectivePublicKeyHex == null);
final shouldFallbackToPublicChannel =
recipient == null && !allowsEmptyRecipient;
@@ -537,7 +611,8 @@ class _MessagesTabState extends State<MessagesTab> {
final candidates = switch (type) {
MessageDestinationPreferences.destinationTypeChannel =>
contactsProvider.channels,
MessageDestinationPreferences.destinationTypeRoom => contactsProvider.rooms,
MessageDestinationPreferences.destinationTypeRoom =>
contactsProvider.rooms,
MessageDestinationPreferences.destinationTypeContact =>
contactsProvider.chatContacts,
_ => contactsProvider.contacts,
@@ -549,41 +624,82 @@ class _MessagesTabState extends State<MessagesTab> {
}
/// Show recipient selector bottom sheet
void _showRecipientSelector() {
Future<void> _showRecipientSelector() async {
if (_isDestinationLocked) {
return;
}
final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>();
final isSimpleMode = context.read<AppProvider>().isSimpleMode;
final contacts = List<Contact>.from(contactsProvider.chatContacts)
..sort((a, b) {
final primary = compareContactsByFavouriteThenLastSeen(a, b);
if (primary != 0) {
return primary;
}
// In simple mode the only channel/room offered is the locked anchor, so
// the user can return to the team channel after messaging a favourite.
Contact? lockedRecipient;
String? lockedType;
if (isSimpleMode) {
final lockedDestination =
await MessageDestinationPreferences.getLockedDestination();
if (lockedDestination != null) {
lockedType =
lockedDestination['type'] ??
MessageDestinationPreferences.destinationTypeChannel;
lockedRecipient = _resolveDestinationRecipient(
contactsProvider,
lockedType,
lockedDestination['publicKey'],
);
}
}
if (!mounted) return;
return compareContactsByDisplayName(a, b);
});
final rooms = List<Contact>.from(contactsProvider.rooms)
..sort((a, b) {
final primary = compareContactsByLastSeen(a, b);
if (primary != 0) {
return primary;
}
final contacts =
List<Contact>.from(
isSimpleMode
? contactsProvider.favouriteChatContacts
: contactsProvider.chatContacts,
)..sort((a, b) {
final primary = compareContactsByFavouriteThenLastSeen(a, b);
if (primary != 0) {
return primary;
}
return compareContactsByDisplayName(a, b);
});
final channels = List<Contact>.from(contactsProvider.channels)
..sort((a, b) {
final primary = compareContactsByLastSeen(a, b);
if (primary != 0) {
return primary;
}
return compareContactsByDisplayName(a, b);
});
final rooms =
isSimpleMode
? <Contact>[
if (lockedRecipient != null &&
lockedType ==
MessageDestinationPreferences.destinationTypeRoom)
lockedRecipient,
]
: List<Contact>.from(contactsProvider.rooms)
..sort((a, b) {
final primary = compareContactsByLastSeen(a, b);
if (primary != 0) {
return primary;
}
return compareContactsByDisplayName(a, b);
});
return compareContactsByDisplayName(a, b);
});
final channels =
isSimpleMode
? <Contact>[
if (lockedRecipient != null &&
lockedType ==
MessageDestinationPreferences.destinationTypeChannel)
lockedRecipient,
]
: List<Contact>.from(contactsProvider.channels)
..sort((a, b) {
final primary = compareContactsByLastSeen(a, b);
if (primary != 0) {
return primary;
}
return compareContactsByDisplayName(a, b);
});
showModalBottomSheet(
context: context,
@@ -832,7 +948,10 @@ class _MessagesTabState extends State<MessagesTab> {
if (_selectedRecipient != null) {
return _selectedRecipient!.displayName;
}
return 'Public Channel';
if (context.read<AppProvider>().isSimpleMode) {
return AppLocalizations.of(context)!.noFavouriteContacts;
}
return AppLocalizations.of(context)!.publicChannel;
}
Future<void> _sendMessage() async {
@@ -845,13 +964,32 @@ class _MessagesTabState extends State<MessagesTab> {
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
ToastLogger.error(context, 'Not connected to device');
ToastLogger.error(
context,
AppLocalizations.of(context)!.notConnectedToDevice,
);
return;
}
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
if (!mounted) return;
ToastLogger.error(context, 'Select a channel, contact, or room first');
ToastLogger.error(
context,
AppLocalizations.of(context)!.selectDestinationFirst,
);
return;
}
// Refuse to send if the contact/room destination no longer resolves to a
// recipient - never silently redirect private traffic to the public channel.
if (_destinationType !=
MessageDestinationPreferences.destinationTypeChannel &&
_selectedRecipient == null) {
if (!mounted) return;
ToastLogger.error(
context,
AppLocalizations.of(context)!.recipientNoLongerAvailable,
);
return;
}
@@ -871,35 +1009,27 @@ class _MessagesTabState extends State<MessagesTab> {
messagesProvider,
channelIdx,
);
} else if (_selectedRecipient != null) {
// Send to contact or room
} else {
// Send to contact or room (recipient presence already validated above)
await _sendToRecipient(
text,
connectionProvider,
messagesProvider,
contactsProvider,
);
} else {
// Fallback to public channel if no recipient selected
debugPrint(
'⚠️ [MessagesTab] No recipient selected, falling back to public channel',
);
await _sendToChannel(text, connectionProvider, messagesProvider, 0);
}
_markCurrentDestinationAsRead();
if (!mounted) return;
} catch (e) {
// The placeholder bubble is marked failed by the send helpers, so the
// inline retry chip is the recovery path - don't restore the composer.
if (!mounted) return;
if (_textController.text.isEmpty) {
_textController.text = text;
_textController.selection = TextSelection.collapsed(
offset: _textController.text.length,
);
_focusNode.requestFocus();
}
ToastLogger.error(context, 'Failed to send: $e');
ToastLogger.error(
context,
AppLocalizations.of(context)!.failedToSendMessage('$e'),
);
}
}
@@ -936,12 +1066,18 @@ class _MessagesTabState extends State<MessagesTab> {
messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
// Send to selected channel (with region scope if set)
await connectionProvider.sendChannelMessage(
channelIdx: channelIdx,
text: text,
messageId: messageId,
floodScopeKey: _channelRegionScopeKey,
);
try {
await connectionProvider.sendChannelMessage(
channelIdx: channelIdx,
text: text,
messageId: messageId,
floodScopeKey: _channelRegionScopeKey,
);
} catch (e) {
// Mark the placeholder as failed so the retry chip appears
messagesProvider.markMessageFailed(messageId);
rethrow;
}
}
/// Send message to contact or room
@@ -979,12 +1115,19 @@ class _MessagesTabState extends State<MessagesTab> {
messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
// Send message to selected recipient
final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: _selectedRecipient!.publicKey,
text: text,
messageId: messageId,
contact: _selectedRecipient,
);
final bool sentSuccessfully;
try {
sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: _selectedRecipient!.publicKey,
text: text,
messageId: messageId,
contact: _selectedRecipient,
);
} catch (e) {
// Mark the placeholder as failed so the retry chip appears
messagesProvider.markMessageFailed(messageId);
rethrow;
}
if (!sentSuccessfully) {
// Mark message as failed if sending failed
@@ -1027,8 +1170,9 @@ class _MessagesTabState extends State<MessagesTab> {
final connectionProvider = context.read<ConnectionProvider>();
if (_selectedRecipient != null &&
connectionProvider.deviceInfo.isConnected) {
await connectionProvider
.setContactDirect(_selectedRecipient!);
await connectionProvider.setContactDirect(
_selectedRecipient!,
);
}
await _sendMessage();
},
@@ -1042,8 +1186,9 @@ class _MessagesTabState extends State<MessagesTab> {
final connectionProvider = context.read<ConnectionProvider>();
if (_selectedRecipient != null &&
connectionProvider.deviceInfo.isConnected) {
await connectionProvider
.resetPath(_selectedRecipient!.publicKey);
await connectionProvider.resetPath(
_selectedRecipient!.publicKey,
);
}
await _sendMessage();
},
@@ -1062,7 +1207,7 @@ class _MessagesTabState extends State<MessagesTab> {
_selectedRecipient == null) {
ToastLogger.warning(
context,
'Tic-Tac-Toe works only in direct messages. Choose a contact first.',
AppLocalizations.of(context)!.tictactoeDirectOnly,
);
return;
}
@@ -1071,13 +1216,19 @@ class _MessagesTabState extends State<MessagesTab> {
final messagesProvider = context.read<MessagesProvider>();
final contactsProvider = context.read<ContactsProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
ToastLogger.error(context, 'Not connected to device');
ToastLogger.error(
context,
AppLocalizations.of(context)!.notConnectedToDevice,
);
return;
}
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
if (devicePublicKey == null || devicePublicKey.length < 6) {
ToastLogger.error(context, 'Device key unavailable');
ToastLogger.error(
context,
AppLocalizations.of(context)!.deviceKeyUnavailable,
);
return;
}
@@ -1115,7 +1266,10 @@ class _MessagesTabState extends State<MessagesTab> {
final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
ToastLogger.error(context, 'Not connected to device');
ToastLogger.error(
context,
AppLocalizations.of(context)!.notConnectedToDevice,
);
return;
}
@@ -1145,7 +1299,10 @@ class _MessagesTabState extends State<MessagesTab> {
);
if (result == null) {
if (!mounted) return;
ToastLogger.error(context, 'Image compression failed');
ToastLogger.error(
context,
AppLocalizations.of(context)!.imageCompressionFailed,
);
return;
}
final compressed = result.bytes;
@@ -1176,7 +1333,10 @@ class _MessagesTabState extends State<MessagesTab> {
if (fragments.isEmpty) {
if (!mounted) return;
ToastLogger.error(context, 'Image fragmentation failed');
ToastLogger.error(
context,
AppLocalizations.of(context)!.imageFragmentationFailed,
);
return;
}
@@ -1184,7 +1344,10 @@ class _MessagesTabState extends State<MessagesTab> {
final deviceKey = connectionProvider.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
if (!mounted) return;
ToastLogger.error(context, 'Device key unavailable');
ToastLogger.error(
context,
AppLocalizations.of(context)!.deviceKeyUnavailable,
);
return;
}
final envelope = ImageEnvelope(
@@ -1247,13 +1410,19 @@ class _MessagesTabState extends State<MessagesTab> {
if (!sent) {
messagesProvider.markMessageFailed(msgId);
if (!mounted) return;
ToastLogger.error(context, 'Failed to announce image');
ToastLogger.error(
context,
AppLocalizations.of(context)!.failedToAnnounceImage,
);
return;
}
} else {
messagesProvider.markMessageFailed(msgId);
if (!mounted) return;
ToastLogger.error(context, 'No recipient selected');
ToastLogger.error(
context,
AppLocalizations.of(context)!.noRecipientSelected,
);
return;
}
@@ -1282,7 +1451,7 @@ class _MessagesTabState extends State<MessagesTab> {
messagesProvider.markMessageFailed(failedMessageId);
}
if (!mounted) return;
ToastLogger.error(context, 'Image send failed');
ToastLogger.error(context, AppLocalizations.of(context)!.imageSendFailed);
} finally {
if (mounted) setState(() => _isSendingImage = false);
}
@@ -1306,7 +1475,10 @@ class _MessagesTabState extends State<MessagesTab> {
debugPrint('🎙️ [Voice] hasPermission=$hasPermission');
if (!hasPermission) {
if (!mounted) return;
ToastLogger.error(context, 'Microphone permission required for voice');
ToastLogger.error(
context,
AppLocalizations.of(context)!.microphonePermissionRequiredForVoice,
);
return;
}
@@ -1317,7 +1489,10 @@ class _MessagesTabState extends State<MessagesTab> {
'🎙️ [Voice] isConnected=${connectionProvider.deviceInfo.isConnected}',
);
if (!connectionProvider.deviceInfo.isConnected) {
ToastLogger.error(context, 'Not connected to device');
ToastLogger.error(
context,
AppLocalizations.of(context)!.notConnectedToDevice,
);
return;
}
@@ -1463,6 +1638,27 @@ class _MessagesTabState extends State<MessagesTab> {
}
}
/// Stop the recorder and discard the captured audio without sending.
Future<void> _cancelVoiceRecording() async {
if (!_isRecording) return;
debugPrint(
'🎙️ [Voice] _cancelVoiceRecording: discarding ${_recordedChunks.length} chunks',
);
await _voiceStreamSub?.cancel();
_voiceStreamSub = null;
await _voiceRecorder.stopCapture();
_recordedChunks.clear();
_currentVoiceSessionId = null;
if (!mounted) return;
setState(() {
_isRecording = false;
_isSendingVoice = false;
});
ToastLogger.info(context, AppLocalizations.of(context)!.recordingDiscarded);
}
Future<void> _encodeAndSendAllPackets({
required List<Int16List> chunks,
required String sessionId,
@@ -1580,7 +1776,10 @@ class _MessagesTabState extends State<MessagesTab> {
} else {
messagesProvider.markMessageFailed(msgId);
if (!mounted) return;
ToastLogger.error(context, 'No recipient selected');
ToastLogger.error(
context,
AppLocalizations.of(context)!.noRecipientSelected,
);
return;
}
} catch (e, st) {
@@ -1684,9 +1883,11 @@ class _MessagesTabState extends State<MessagesTab> {
builder: (dialogContext) => AlertDialog(
title: Text(AppLocalizations.of(context)!.sendToPublicChannel),
content: Text(
'You are about to send $mediaType to the Public Channel. '
'This is not advised because everyone on the mesh may receive it. '
'Choose a private or tagged channel unless this is what you want.',
AppLocalizations.of(context)!.publicChannelMediaWarning(
mediaType == 'image'
? AppLocalizations.of(context)!.image
: AppLocalizations.of(context)!.voice,
),
),
actions: [
TextButton(
@@ -1773,11 +1974,13 @@ class _MessagesTabState extends State<MessagesTab> {
bool enabled,
) async {
try {
final result = await context.read<AppProvider>().setChannelLocationSharingEnabled(
channelIdx,
enabled,
l10n: AppLocalizations.of(context),
);
final result = await context
.read<AppProvider>()
.setChannelLocationSharingEnabled(
channelIdx,
enabled,
l10n: AppLocalizations.of(context),
);
if (!mounted) return;
ToastLogger.success(context, result.message);
} catch (error) {
@@ -1792,17 +1995,19 @@ class _MessagesTabState extends State<MessagesTab> {
if (!mounted) return;
ToastLogger.error(
context,
'Select a private channel to share your location.',
AppLocalizations.of(context)!.selectPrivateChannelToShareLocation,
);
return;
}
final channelIdx = recipient.publicKey.length > 1 ? recipient.publicKey[1] : 0;
final channelIdx = recipient.publicKey.length > 1
? recipient.publicKey[1]
: 0;
if (channelIdx <= 0) {
if (!mounted) return;
ToastLogger.error(
context,
'Select a private channel to share your location.',
AppLocalizations.of(context)!.selectPrivateChannelToShareLocation,
);
return;
}
@@ -1812,7 +2017,10 @@ class _MessagesTabState extends State<MessagesTab> {
.read<AppProvider>()
.getChannelLocationSharingState(channelIdx);
if (!mounted) return;
await _setSelectedChannelLocationSharing(channelIdx, !sharingState.isSharing);
await _setSelectedChannelLocationSharing(
channelIdx,
!sharingState.isSharing,
);
} catch (error) {
if (!mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
@@ -1823,9 +2031,9 @@ class _MessagesTabState extends State<MessagesTab> {
final privateChannelIdx = _selectedPrivateChannelIdx();
final locationSharingStateFuture = privateChannelIdx == null
? null
: context
.read<AppProvider>()
.getChannelLocationSharingState(privateChannelIdx);
: context.read<AppProvider>().getChannelLocationSharingState(
privateChannelIdx,
);
showModalBottomSheet(
context: context,
showDragHandle: true,
@@ -1848,6 +2056,21 @@ class _MessagesTabState extends State<MessagesTab> {
_showFilteredMessageSearch();
}),
),
_ComposerActionTile(
icon: Icons.campaign_rounded,
title: l10n.sendAdvert,
color: theme.colorScheme.primary,
onTap: () => runAction(() async {
await triggerAdvertFeedback(context);
if (!mounted) return;
final mode = await showAdvertModeSheet(context);
if (!mounted || mode == null) return;
await advertiseDevice(
context,
floodMode: mode == AdvertMode.flood,
);
}),
),
_ComposerActionTile(
icon: Icons.add_location_alt_rounded,
title: l10n.sendSarMarker,
@@ -1863,8 +2086,8 @@ class _MessagesTabState extends State<MessagesTab> {
? Icons.location_off_rounded
: Icons.share_location_rounded,
title: sharingState?.isSharing == true
? 'Stop sharing my location'
: 'Share my location',
? l10n.stopSharingMyLocation
: l10n.shareMyLocation,
color: const Color(0xFF0F766E),
onTap: () => runAction(() async {
await _handleChannelLocationSharingAction();
@@ -1873,7 +2096,7 @@ class _MessagesTabState extends State<MessagesTab> {
if (_voiceSupported)
_ComposerActionTile(
icon: _isRecording ? Icons.stop_rounded : Icons.mic_rounded,
title: _isRecording ? 'Stop recording' : 'Record voice',
title: _isRecording ? l10n.stopRecording : l10n.recordVoice,
color: const Color(0xFF7C3AED),
enabled: !_isSendingVoice,
busy: _isSendingVoice,
@@ -1947,7 +2170,7 @@ class _MessagesTabState extends State<MessagesTab> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'More actions',
l10n.moreActions,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
@@ -2060,7 +2283,9 @@ class _MessagesTabState extends State<MessagesTab> {
controller: searchController,
autofocus: true,
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.searchInCurrentFilter,
hintText: AppLocalizations.of(
context,
)!.searchInCurrentFilter,
prefixIcon: const Icon(Icons.search),
suffixIcon: searchController.text.isEmpty
? null
@@ -2083,8 +2308,12 @@ class _MessagesTabState extends State<MessagesTab> {
? Center(
child: Text(
query.isEmpty
? 'No messages in this filter'
: 'No matches in this filter',
? AppLocalizations.of(
context,
)!.noMessagesInFilter
: AppLocalizations.of(
context,
)!.noMatchesInFilter,
),
)
: ListView.separated(
@@ -2098,7 +2327,7 @@ class _MessagesTabState extends State<MessagesTab> {
true
? message.senderName!
: message.isSentMessage
? 'You'
? AppLocalizations.of(context)!.you
: _getDestinationLabel();
return ListTile(
@@ -2163,11 +2392,7 @@ class _MessagesTabState extends State<MessagesTab> {
border: Border.all(color: Colors.white, width: 1.5),
),
alignment: Alignment.center,
child: Icon(
Icons.location_pin,
size: 9,
color: Colors.white,
),
child: Icon(Icons.location_pin, size: 9, color: Colors.white),
),
),
],
@@ -2195,7 +2420,10 @@ class _MessagesTabState extends State<MessagesTab> {
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
ToastLogger.error(context, 'Not connected to device');
ToastLogger.error(
context,
AppLocalizations.of(context)!.notConnectedToDevice,
);
return;
}
@@ -2203,7 +2431,7 @@ class _MessagesTabState extends State<MessagesTab> {
if (!mounted) return;
ToastLogger.error(
context,
'Please select a destination to send SAR marker',
AppLocalizations.of(context)!.pleaseSelectADestinationToSendSarMarker,
);
return;
}
@@ -2215,9 +2443,12 @@ class _MessagesTabState extends State<MessagesTab> {
'S:$emoji:${colorIndex.toString()}:${position.latitude.toStringAsFixed(5)},${position.longitude.toStringAsFixed(5)}:$name';
if (sendToAllContacts) {
// Send to all chat contacts (ContactType.chat)
// Send to all chat contacts (ContactType.chat); in simple mode only
// favourite contacts are reachable.
final contactsProvider = context.read<ContactsProvider>();
final chatContacts = contactsProvider.chatContacts;
final chatContacts = context.read<AppProvider>().isSimpleMode
? contactsProvider.favouriteChatContacts
: contactsProvider.chatContacts;
if (chatContacts.isEmpty) {
if (!mounted) return;
@@ -2392,11 +2623,17 @@ class _MessagesTabState extends State<MessagesTab> {
}
if (!mounted) return;
ToastLogger.success(context, 'SAR marker sent to room');
ToastLogger.success(
context,
AppLocalizations.of(context)!.sarMarkerSentToRoom,
);
}
} catch (e) {
if (!mounted) return;
ToastLogger.error(context, 'Failed to send SAR marker: $e');
ToastLogger.error(
context,
AppLocalizations.of(context)!.failedToSendSarMarker('$e'),
);
}
}
@@ -2407,7 +2644,10 @@ class _MessagesTabState extends State<MessagesTab> {
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
ToastLogger.warning(context, 'Not connected - cannot sync messages');
ToastLogger.warning(
context,
AppLocalizations.of(context)!.notConnectedToDevice,
);
return;
}
@@ -2415,11 +2655,15 @@ class _MessagesTabState extends State<MessagesTab> {
debugPrint(
'🔄 [MessagesTab] Manual refresh triggered - syncing messages',
);
await connectionProvider.syncAllMessages(force: true);
if (!mounted) return;
} catch (e) {
debugPrint('❌ [MessagesTab] Sync error: $e');
if (!mounted) return;
ToastLogger.error(context, 'Sync failed: $e');
ToastLogger.error(
context,
AppLocalizations.of(context)!.syncFailed('$e'),
);
}
}
@@ -2639,6 +2883,7 @@ class _MessagesTabState extends State<MessagesTab> {
onShowRecipientSelector: _showRecipientSelector,
onStartVoiceRecording: _startVoiceRecording,
onStopAndSendVoice: _stopAndSendVoice,
onCancelVoiceRecording: _cancelVoiceRecording,
onSendMessage: _sendMessage,
onLongPressSend: _isContactDestination()
? _showSendModeSheet

View File

@@ -19,11 +19,7 @@ class ProfilesScreen extends StatelessWidget {
title: Text(AppLocalizations.of(context)!.profiles),
actions: [
IconButton(
onPressed: () async {
await context
.read<ProfileWorkspaceCoordinator>()
.importProfileFromFile();
},
onPressed: () => _importProfile(context),
icon: const Icon(Icons.file_open),
tooltip: AppLocalizations.of(context)!.importProfile,
),
@@ -126,9 +122,11 @@ class ProfilesScreen extends StatelessWidget {
await _renameProfile(context, profile);
break;
case 'delete':
await context
.read<ProfileWorkspaceCoordinator>()
.deleteProfile(profile);
await _deleteProfile(
context,
profile,
isActive: isActive,
);
break;
}
},
@@ -183,6 +181,67 @@ class ProfilesScreen extends StatelessWidget {
return sections.isEmpty ? 'Empty profile' : sections.join(' | ');
}
Future<void> _importProfile(BuildContext context) async {
final messenger = ScaffoldMessenger.of(context);
final errorColor = Theme.of(context).colorScheme.error;
final coordinator = context.read<ProfileWorkspaceCoordinator>();
try {
final imported = await coordinator.importProfileFromFile();
if (imported == null) {
messenger.showSnackBar(
SnackBar(
content: const Text('Could not import profile — invalid file'),
backgroundColor: errorColor,
),
);
return;
}
messenger.showSnackBar(
SnackBar(content: Text('Imported profile "${imported.name}"')),
);
} catch (_) {
messenger.showSnackBar(
SnackBar(
content: const Text('Could not import profile — invalid file'),
backgroundColor: errorColor,
),
);
}
}
Future<void> _deleteProfile(
BuildContext context,
ConfigProfile profile, {
required bool isActive,
}) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Profile'),
content: Text(
'Delete "${profile.name}"? Its messages and contacts will be '
'permanently erased.'
'${isActive ? '\n\nThis profile is currently active. The app will switch to the Default profile.' : ''}',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(AppLocalizations.of(context)!.delete),
),
],
),
);
if (confirmed != true || !context.mounted) {
return;
}
await context.read<ProfileWorkspaceCoordinator>().deleteProfile(profile);
}
Future<void> _createProfile(BuildContext context) async {
final controller = TextEditingController();
final name = await showDialog<String>(

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../models/device_info.dart';
import '../providers/connection_provider.dart';
import '../services/wizard_preferences.dart';
import '../widgets/connection_dialog.dart';
@@ -168,7 +169,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
codingRate: 5,
),
];
_RadioPreset _selectedPreset = _radioPresets[4];
/// Null means "keep the device's current radio settings" (no preset write).
_RadioPreset? _selectedPreset;
bool _userSelectedPreset = false;
@override
void initState() {
@@ -176,6 +179,77 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
final deviceInfo = context.read<ConnectionProvider>().deviceInfo;
_deviceNameController.text =
deviceInfo.selfName ?? deviceInfo.deviceName ?? '';
_selectedPreset = _matchRadioPreset(deviceInfo);
}
static const Map<int, int> _bandwidthHzToValue = {
7800: 0,
10400: 1,
15600: 2,
20800: 3,
31250: 4,
41700: 5,
62500: 6,
125000: 7,
250000: 8,
500000: 9,
};
static int? _normalizeBandwidthValue(int? bw) {
if (bw == null) {
return null;
}
if (bw >= 0 && bw <= 9) {
return bw;
}
return _bandwidthHzToValue[bw];
}
static int? _normalizeSpreadingFactor(int? spreadingFactor) {
if (spreadingFactor == null ||
spreadingFactor < 7 ||
spreadingFactor > 12) {
return null;
}
return spreadingFactor;
}
static int? _normalizeCodingRate(int? codingRate) {
if (codingRate == null) {
return null;
}
if (codingRate >= 5 && codingRate <= 8) {
return codingRate;
}
if (codingRate >= 1 && codingRate <= 4) {
return codingRate + 4;
}
return null;
}
/// Find the preset matching the device's current radio settings, if any.
static _RadioPreset? _matchRadioPreset(DeviceInfo deviceInfo) {
final frequencyKhz = deviceInfo.radioFreq;
final bandwidth = _normalizeBandwidthValue(deviceInfo.radioBw);
final spreadingFactor = _normalizeSpreadingFactor(deviceInfo.radioSf);
final codingRate = _normalizeCodingRate(deviceInfo.radioCr);
if (frequencyKhz == null ||
bandwidth == null ||
spreadingFactor == null ||
codingRate == null) {
return null;
}
for (final preset in _radioPresets) {
if (preset.frequencyKhz == frequencyKhz &&
preset.bandwidth == bandwidth &&
preset.spreadingFactor == spreadingFactor &&
preset.codingRate == codingRate) {
return preset;
}
}
return null;
}
@override
@@ -233,13 +307,14 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
if (!mounted) return;
final deviceInfo = connectionProvider.deviceInfo;
final fetchedName = deviceInfo.selfName ?? deviceInfo.deviceName ?? '';
if (fetchedName.isNotEmpty) {
setState(() {
setState(() {
if (fetchedName.isNotEmpty) {
_deviceNameController.text = fetchedName;
});
} else {
setState(() {});
}
}
if (!_userSelectedPreset) {
_selectedPreset = _matchRadioPreset(deviceInfo);
}
});
}
Future<void> _handleDeviceSetupAction() async {
@@ -263,21 +338,49 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
_isApplyingDeviceSetup = true;
});
final selectedPreset = _selectedPreset;
try {
connectionProvider.clearError();
await connectionProvider.setAdvertName(trimmedName);
await connectionProvider.setRadioParams(
frequency: _selectedPreset.frequencyKhz,
bandwidth: _selectedPreset.bandwidth,
spreadingFactor: _selectedPreset.spreadingFactor,
codingRate: _selectedPreset.codingRate,
);
var saveError = connectionProvider.error;
// Only write radio params when the user picked a preset that differs
// from the device's current settings.
if (saveError == null &&
selectedPreset != null &&
selectedPreset != _matchRadioPreset(deviceInfo)) {
await connectionProvider.setRadioParams(
frequency: selectedPreset.frequencyKhz,
bandwidth: selectedPreset.bandwidth,
spreadingFactor: selectedPreset.spreadingFactor,
codingRate: selectedPreset.codingRate,
);
saveError = connectionProvider.error;
}
if (saveError != null) {
connectionProvider.clearError();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.failedToSave(saveError)),
backgroundColor: Colors.red,
),
);
return;
}
await connectionProvider.refreshDeviceInfo();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
l10n.wizardDeviceSetupSaved(trimmedName, _selectedPreset.label),
l10n.wizardDeviceSetupSaved(
trimmedName,
selectedPreset?.label ?? 'current radio settings',
),
),
backgroundColor: Colors.green,
),
@@ -555,7 +658,8 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
),
),
const SizedBox(height: 12),
DropdownButtonFormField<_RadioPreset>(
DropdownButtonFormField<_RadioPreset?>(
key: ValueKey(_selectedPreset?.id ?? 'keep_current'),
initialValue: _selectedPreset,
isExpanded: true,
decoration: InputDecoration(
@@ -563,21 +667,28 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
border: OutlineInputBorder(),
helperText: l10n.wizardConfigRegionHelp,
),
items: _radioPresets
.map(
(preset) => DropdownMenuItem<_RadioPreset>(
value: preset,
child: Text(
preset.label,
overflow: TextOverflow.ellipsis,
),
items: [
const DropdownMenuItem<_RadioPreset?>(
value: null,
child: Text(
'Keep current settings',
overflow: TextOverflow.ellipsis,
),
),
..._radioPresets.map(
(preset) => DropdownMenuItem<_RadioPreset?>(
value: preset,
child: Text(
preset.label,
overflow: TextOverflow.ellipsis,
),
)
.toList(),
),
),
],
onChanged: (preset) {
if (preset == null) return;
setState(() {
_selectedPreset = preset;
_userSelectedPreset = true;
});
},
),
@@ -591,13 +702,14 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_selectedPreset.label,
_selectedPreset?.label ?? 'Keep current settings',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
Text(
_selectedPreset.summary,
_selectedPreset?.summary ??
'Saving will not change the radio settings on your device.',
style: Theme.of(context).textTheme.bodyMedium,
),
SizedBox(height: 14),