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