feat: Add compass and region support

This commit is contained in:
Janez T
2026-03-22 19:04:43 +01:00
parent 7ea6334f02
commit 2b42e7dd15
31 changed files with 1530 additions and 137 deletions

View File

@@ -2778,5 +2778,24 @@
"captured": "Captured",
"size": "Size",
"noCustomChannelsToClear": "No custom channels to clear.",
"noDeviceContactsToClear": "No device contacts to clear."
"noDeviceContactsToClear": "No device contacts to clear.",
"setRegionScope": "Set region scope",
"regionScope": "Region scope",
"regionScopeNone": "None (global)",
"clearRegionScope": "Clear scope",
"regionScopeWarning": "Only repeaters allowing this region will forward.",
"discoverRegions": "Discover from repeaters",
"discoveringRegions": "Discovering regions...",
"enterRegionName": "Region name (e.g. auckland)",
"noRegionsFound": "No regions found on this repeater.",
"regionScopeSet": "Region scope set to {name}",
"@regionScopeSet": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"regionScopeCleared": "Region scope cleared"
}

View File

@@ -5009,6 +5009,72 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'No device contacts to clear.'**
String get noDeviceContactsToClear;
/// No description provided for @setRegionScope.
///
/// In en, this message translates to:
/// **'Set region scope'**
String get setRegionScope;
/// No description provided for @regionScope.
///
/// In en, this message translates to:
/// **'Region scope'**
String get regionScope;
/// No description provided for @regionScopeNone.
///
/// In en, this message translates to:
/// **'None (global)'**
String get regionScopeNone;
/// No description provided for @clearRegionScope.
///
/// In en, this message translates to:
/// **'Clear scope'**
String get clearRegionScope;
/// No description provided for @regionScopeWarning.
///
/// In en, this message translates to:
/// **'Only repeaters allowing this region will forward.'**
String get regionScopeWarning;
/// No description provided for @discoverRegions.
///
/// In en, this message translates to:
/// **'Discover from repeaters'**
String get discoverRegions;
/// No description provided for @discoveringRegions.
///
/// In en, this message translates to:
/// **'Discovering regions...'**
String get discoveringRegions;
/// No description provided for @enterRegionName.
///
/// In en, this message translates to:
/// **'Region name (e.g. auckland)'**
String get enterRegionName;
/// No description provided for @noRegionsFound.
///
/// In en, this message translates to:
/// **'No regions found on this repeater.'**
String get noRegionsFound;
/// No description provided for @regionScopeSet.
///
/// In en, this message translates to:
/// **'Region scope set to {name}'**
String regionScopeSet(String name);
/// No description provided for @regionScopeCleared.
///
/// In en, this message translates to:
/// **'Region scope cleared'**
String get regionScopeCleared;
}
class _AppLocalizationsDelegate

View File

@@ -2705,4 +2705,40 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get noDeviceContactsToClear => 'Keine Gerätekontakte zum Löschen.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -2707,4 +2707,40 @@ class AppLocalizationsEl extends AppLocalizations {
@override
String get noDeviceContactsToClear => 'Δεν υπάρχουν επαφές συσκευής.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -2669,4 +2669,40 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get noDeviceContactsToClear => 'No device contacts to clear.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -2709,4 +2709,40 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get noDeviceContactsToClear =>
'No hay contactos del dispositivo que borrar.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -2713,4 +2713,40 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get noDeviceContactsToClear =>
'Aucun contact de l\'appareil à effacer.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -2682,4 +2682,40 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get noDeviceContactsToClear => 'Nema kontakata uređaja za brisanje.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -2699,4 +2699,40 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get noDeviceContactsToClear =>
'Nessun contatto dispositivo da cancellare.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -2696,4 +2696,40 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get noDeviceContactsToClear =>
'Brak kontaktów urządzenia do wyczyszczenia.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -2707,4 +2707,40 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get noDeviceContactsToClear =>
'Sem contactos do dispositivo para limpar.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -2692,4 +2692,40 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get noDeviceContactsToClear => 'Нет контактов устройства для очистки.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -2682,4 +2682,40 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get noDeviceContactsToClear => 'Ni stikov naprave za brisanje.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -2680,4 +2680,40 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get noDeviceContactsToClear => 'Temizlenecek cihaz kişisi yok.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -2703,4 +2703,40 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get noDeviceContactsToClear =>
'Немає контактів пристрою для очищення.';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -2575,4 +2575,40 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get noDeviceContactsToClear => '没有设备联系人可清除。';
@override
String get setRegionScope => 'Set region scope';
@override
String get regionScope => 'Region scope';
@override
String get regionScopeNone => 'None (global)';
@override
String get clearRegionScope => 'Clear scope';
@override
String get regionScopeWarning =>
'Only repeaters allowing this region will forward.';
@override
String get discoverRegions => 'Discover from repeaters';
@override
String get discoveringRegions => 'Discovering regions...';
@override
String get enterRegionName => 'Region name (e.g. auckland)';
@override
String get noRegionsFound => 'No regions found on this repeater.';
@override
String regionScopeSet(String name) {
return 'Region scope set to $name';
}
@override
String get regionScopeCleared => 'Region scope cleared';
}

View File

@@ -1,66 +1,209 @@
{
"de": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription"
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"el": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription"
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"es": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription"
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"fr": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription"
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"hr": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription"
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"it": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription"
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"pl": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription"
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"pt": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription"
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"ru": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription"
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"sl": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription"
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"tr": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription"
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"uk": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription"
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"zh": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription"
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
]
}

View File

@@ -562,6 +562,13 @@ class ConnectionProvider with ChangeNotifier {
clientRepeat: deviceInfo['clientRepeat'] as bool?,
pathHashMode: deviceInfo['pathHashMode'] as int?,
);
// Clear any stale flood scope on connect (firmware v8+)
final fwVer = deviceInfo['firmwareVersion'] as int?;
if (fwVer != null && fwVer >= 8) {
clearFloodScope();
}
notifyListeners();
};
@@ -1734,6 +1741,7 @@ class ConnectionProvider with ChangeNotifier {
required int channelIdx,
required String text,
String? messageId,
Uint8List? floodScopeKey,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
@@ -1746,10 +1754,12 @@ class ConnectionProvider with ChangeNotifier {
debugPrint(' Channel: $channelIdx');
debugPrint(' Text: $text');
debugPrint(' MessageID: $messageId');
debugPrint(' FloodScope: ${floodScopeKey != null ? "set (${floodScopeKey.length}B)" : "none"}');
await _activeService.sendChannelMessage(
channelIdx: channelIdx,
text: text,
floodScopeKey: floodScopeKey,
);
debugPrint('✅ [ConnectionProvider] BLE send completed');
@@ -1814,10 +1824,21 @@ class ConnectionProvider with ChangeNotifier {
);
}
/// Clear the flood scope on the device (firmware v8+).
Future<void> clearFloodScope() async {
if (!_activeService.isConnected) return;
try {
await _activeService.clearFloodScope();
} catch (e) {
debugPrint('⚠️ [ConnectionProvider] Failed to clear flood scope: $e');
}
}
Future<void> sendChannelData({
required int channelIdx,
required int dataType,
required Uint8List payload,
Uint8List? floodScopeKey,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
@@ -1830,6 +1851,7 @@ class ConnectionProvider with ChangeNotifier {
channelIdx: channelIdx,
dataType: dataType,
payload: payload,
floodScopeKey: floodScopeKey,
);
} catch (e) {
_error = 'Failed to send channel data: $e';

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
@@ -10,6 +11,7 @@ import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_compass/flutter_compass.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../services/compass_support.dart';
import '../utils/slovenian_crs.dart';
import '../providers/contacts_provider.dart';
import '../providers/messages_provider.dart';
@@ -200,46 +202,64 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
}
void _startCompassTracking() {
if (!CompassSupport.isAvailable) {
return;
}
final compassStream = FlutterCompass.events;
if (compassStream == null) {
return;
}
// Start listening to compass events
_compassStreamSubscription = compassStream.listen((CompassEvent event) {
// Check if widget is disposing, mounted, and event has valid heading
if (_isDisposing || !mounted || event.heading == null) return;
_compassStreamSubscription = compassStream.listen(
(CompassEvent event) {
// Check if widget is disposing, mounted, and event has valid heading
if (_isDisposing || !mounted || event.heading == null) return;
try {
setState(() {
_compassHeading = event.heading;
});
try {
setState(() {
_compassHeading = event.heading;
});
// Rotate map if rotation mode is enabled and we have compass heading
// Only rotate if map is ready
if (_rotateMarkerWithHeading &&
event.heading != null &&
_isMapReady &&
!_isDisposing) {
try {
// Use moveAndRotate to set absolute rotation
final camera = _mapController.camera;
_mapController.moveAndRotate(
camera.center,
camera.zoom,
-event.heading!,
);
} catch (e) {
// Map not ready yet, ignore
// Rotate map if rotation mode is enabled and we have compass heading
// Only rotate if map is ready
if (_rotateMarkerWithHeading &&
event.heading != null &&
_isMapReady &&
!_isDisposing) {
try {
// Use moveAndRotate to set absolute rotation
final camera = _mapController.camera;
_mapController.moveAndRotate(
camera.center,
camera.zoom,
-event.heading!,
);
} catch (e) {
// Map not ready yet, ignore
}
}
} catch (e) {
// Widget disposed during setState, ignore
if (!_isDisposing) {
debugPrint('Compass tracking error: $e');
}
}
} catch (e) {
// Widget disposed during setState, ignore
if (!_isDisposing) {
debugPrint('Compass tracking error: $e');
},
onError: (Object error, StackTrace stackTrace) {
if (_isDisposing) {
return;
}
}
});
if (error is MissingPluginException) {
debugPrint('Compass plugin is unavailable on this platform');
return;
}
debugPrint('Compass tracking stream error: $error');
},
);
}
Future<void> _loadSettings() async {
@@ -1004,7 +1024,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
),
child: DetailedCompassDialog(
initialPosition: _locationService.currentPosition,
initialHeading: _currentHeading,
initialHeading: _compassHeading,
contacts: contacts,
sarMarkers: sarMarkers,
),
@@ -1030,7 +1050,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
),
child: DetailedCompassDialog(
initialPosition: _locationService.currentPosition,
initialHeading: _currentHeading,
initialHeading: _compassHeading,
contacts: contacts,
sarMarkers: sarMarkers,
preSelectedContact: selectedContact,

View File

@@ -36,6 +36,8 @@ import '../utils/tictactoe_message_parser.dart';
import '../providers/image_provider.dart' as ip;
import '../services/image_codec_service.dart';
import '../services/image_preferences.dart';
import '../services/region_scope_preferences.dart';
import '../services/region_discovery_service.dart';
import 'package:image_picker/image_picker.dart';
import '../l10n/app_localizations.dart';
@@ -153,6 +155,11 @@ class _MessagesTabState extends State<MessagesTab> {
MessageDestinationPreferences.destinationTypeChannel;
Contact? _selectedRecipient;
// Region scope state
String? _channelRegionScopeName;
Uint8List? _channelRegionScopeKey;
Map<int, String> _channelRegionScopes = {};
// Image sending state
bool _isSendingImage = false;
final ImagePicker _imagePicker = ImagePicker();
@@ -180,6 +187,7 @@ class _MessagesTabState extends State<MessagesTab> {
// Load saved message destination
_loadSavedDestination();
_loadVoiceSettings();
_loadAllChannelRegionScopes();
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForNavigationRequest();
});
@@ -483,6 +491,9 @@ class _MessagesTabState extends State<MessagesTab> {
}
_enforceMessageByteLimit();
// Load region scope for channel destinations
await _loadRegionScope();
}
/// Show recipient selector bottom sheet
@@ -536,6 +547,7 @@ class _MessagesTabState extends State<MessagesTab> {
currentDestinationType: _destinationType,
currentRecipientPublicKey: _selectedRecipient?.publicKeyHex,
onSelect: _onRecipientSelected,
channelRegionScopes: _channelRegionScopes,
),
);
}
@@ -551,6 +563,9 @@ class _MessagesTabState extends State<MessagesTab> {
_enforceMessageByteLimit();
// Load region scope for channel destinations
await _loadRegionScope();
// Save to preferences
await MessageDestinationPreferences.setDestination(
type,
@@ -561,6 +576,51 @@ class _MessagesTabState extends State<MessagesTab> {
if (!mounted) return;
}
Future<void> _loadRegionScope() async {
if (_destinationType !=
MessageDestinationPreferences.destinationTypeChannel) {
if (_channelRegionScopeName != null) {
setState(() {
_channelRegionScopeName = null;
_channelRegionScopeKey = null;
});
}
return;
}
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
final scope = await RegionScopePreferences.getScope(channelIdx);
if (!mounted) return;
setState(() {
_channelRegionScopeName = scope?.name;
_channelRegionScopeKey = scope?.key;
if (scope != null) {
_channelRegionScopes[channelIdx] = scope.name;
} else {
_channelRegionScopes.remove(channelIdx);
}
});
}
Future<void> _loadAllChannelRegionScopes() async {
final contactsProvider = context.read<ContactsProvider>();
final channels = contactsProvider.chatContacts
.where((c) => c.publicKey.length > 1 && c.publicKey[0] == 0)
.toList();
final scopes = <int, String>{};
// Always check channel 0 (public)
final scope0 = await RegionScopePreferences.getScope(0);
if (scope0 != null) scopes[0] = scope0.name;
for (final ch in channels) {
final idx = ch.publicKey[1];
final scope = await RegionScopePreferences.getScope(idx);
if (scope != null) scopes[idx] = scope.name;
}
if (!mounted) return;
setState(() {
_channelRegionScopes = scopes;
});
}
Future<void> _applyPendingDestination({
required String type,
String? recipientPublicKeyHex,
@@ -827,11 +887,12 @@ class _MessagesTabState extends State<MessagesTab> {
// Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
// Send to selected channel
// Send to selected channel (with region scope if set)
await connectionProvider.sendChannelMessage(
channelIdx: channelIdx,
text: text,
messageId: messageId,
floodScopeKey: _channelRegionScopeKey,
);
}
@@ -1063,6 +1124,7 @@ class _MessagesTabState extends State<MessagesTab> {
channelIdx: channelIdx ?? 0,
text: envelopeText,
messageId: msgId,
floodScopeKey: _channelRegionScopeKey,
);
} else if (recipient != null) {
final sent = await connectionProvider.sendTextMessage(
@@ -1096,6 +1158,7 @@ class _MessagesTabState extends State<MessagesTab> {
channelIdx: channelIdx ?? 0,
dataType: MeshCoreConstants.dataTypeDev,
payload: fragment.encodeBinary(),
floodScopeKey: _channelRegionScopeKey,
);
}
}
@@ -1390,6 +1453,7 @@ class _MessagesTabState extends State<MessagesTab> {
channelIdx: channelIdx ?? 0,
text: envelopeText,
messageId: msgId,
floodScopeKey: _channelRegionScopeKey,
);
} else if (recipient != null) {
final sentSuccessfully = await connectionProvider.sendTextMessage(
@@ -1421,6 +1485,7 @@ class _MessagesTabState extends State<MessagesTab> {
channelIdx: channelIdx ?? 0,
dataType: MeshCoreConstants.dataTypeDev,
payload: packet.encodeBinary(),
floodScopeKey: _channelRegionScopeKey,
);
}
}
@@ -1649,6 +1714,20 @@ class _MessagesTabState extends State<MessagesTab> {
await _startTicTacToeGame();
}),
),
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel)
_ComposerActionTile(
icon: _channelRegionScopeName != null
? Icons.language_rounded
: Icons.public_rounded,
title: _channelRegionScopeName != null
? '${l10n.regionScope}: $_channelRegionScopeName'
: l10n.setRegionScope,
color: const Color(0xFF7C3AED),
onTap: () => runAction(() async {
_showRegionScopeSheet();
}),
),
];
return SafeArea(
@@ -1682,6 +1761,55 @@ class _MessagesTabState extends State<MessagesTab> {
);
}
void _showRegionScopeSheet() {
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
final contactsProvider = context.read<ContactsProvider>();
final connectionProvider = context.read<ConnectionProvider>();
final l10n = AppLocalizations.of(context)!;
final repeaters = contactsProvider.contacts
.where((c) => c.isRepeater)
.toList();
showModalBottomSheet(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (sheetContext) {
return _RegionScopeSheet(
currentScopeName: _channelRegionScopeName,
repeaters: repeaters,
connectionProvider: connectionProvider,
l10n: l10n,
onScopeSelected: (String? name) async {
Navigator.of(sheetContext).pop();
if (name == null) {
// Clear scope
await RegionScopePreferences.clearScope(channelIdx);
if (!mounted) return;
setState(() {
_channelRegionScopeName = null;
_channelRegionScopeKey = null;
_channelRegionScopes.remove(channelIdx);
});
ToastLogger.success(context, l10n.regionScopeCleared);
} else {
// Set scope
final key = RegionScopePreferences.deriveRegionKey(name);
await RegionScopePreferences.setScope(channelIdx, name, key);
if (!mounted) return;
setState(() {
_channelRegionScopeName = name;
_channelRegionScopeKey = key;
_channelRegionScopes[channelIdx] = name;
});
ToastLogger.success(context, l10n.regionScopeSet(name));
}
},
);
},
);
}
void _showFilteredMessageSearch() {
final messagesProvider = context.read<MessagesProvider>();
final scopedMessages = _getFilteredMessages(messagesProvider);
@@ -1968,6 +2096,7 @@ class _MessagesTabState extends State<MessagesTab> {
channelIdx: 0,
text: sarMessage,
messageId: messageId,
floodScopeKey: _channelRegionScopeKey,
);
if (!mounted) return;
@@ -2266,6 +2395,10 @@ class _MessagesTabState extends State<MessagesTab> {
onStartVoiceRecording: _startVoiceRecording,
onStopAndSendVoice: _stopAndSendVoice,
onSendMessage: _sendMessage,
regionScopeName: _channelRegionScopeName,
onRegionScopeTap: _channelRegionScopeName != null
? _showRegionScopeSheet
: null,
),
),
],
@@ -2275,3 +2408,229 @@ class _MessagesTabState extends State<MessagesTab> {
);
}
}
/// Bottom sheet for selecting a region scope for the current channel.
class _RegionScopeSheet extends StatefulWidget {
final String? currentScopeName;
final List<Contact> repeaters;
final ConnectionProvider connectionProvider;
final AppLocalizations l10n;
final ValueChanged<String?> onScopeSelected;
const _RegionScopeSheet({
required this.currentScopeName,
required this.repeaters,
required this.connectionProvider,
required this.l10n,
required this.onScopeSelected,
});
@override
State<_RegionScopeSheet> createState() => _RegionScopeSheetState();
}
class _RegionScopeSheetState extends State<_RegionScopeSheet> {
final TextEditingController _nameController = TextEditingController();
List<String> _discoveredRegions = [];
bool _isDiscovering = false;
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
Future<void> _discoverRegions() async {
if (widget.repeaters.isEmpty) return;
setState(() => _isDiscovering = true);
final allRegions = <String>{};
for (final repeater in widget.repeaters) {
final regions = await RegionDiscoveryService.discoverFromRepeater(
repeaterPublicKey: repeater.publicKey,
connectionProvider: widget.connectionProvider,
);
allRegions.addAll(regions);
}
if (!mounted) return;
setState(() {
_discoveredRegions = allRegions.toList()..sort();
_isDiscovering = false;
});
if (_discoveredRegions.isEmpty && mounted) {
ToastLogger.info(context, widget.l10n.noRegionsFound);
}
}
void _submitManualName() {
var name = _nameController.text.trim();
if (name.isEmpty) return;
if (!name.startsWith('#')) name = '#$name';
if (utf8.encode(name).length > 30) return;
widget.onScopeSelected(name);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final l10n = widget.l10n;
return SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.72,
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.regionScope,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
l10n.regionScopeWarning,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
// "None" option
_RegionOptionTile(
label: l10n.regionScopeNone,
isSelected: widget.currentScopeName == null,
onTap: () => widget.onScopeSelected(null),
),
const SizedBox(height: 8),
// Manual entry
Row(
children: [
Expanded(
child: TextField(
controller: _nameController,
decoration: InputDecoration(
hintText: l10n.enterRegionName,
isDense: true,
prefixText: '#',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
onSubmitted: (_) => _submitManualName(),
),
),
const SizedBox(width: 8),
FilledButton.tonal(
onPressed: _submitManualName,
child: const Icon(Icons.check_rounded, size: 20),
),
],
),
const SizedBox(height: 16),
// Discover button
if (widget.repeaters.isNotEmpty)
FilledButton.tonalIcon(
onPressed: _isDiscovering ? null : _discoverRegions,
icon: _isDiscovering
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.search_rounded, size: 18),
label: Text(
_isDiscovering
? l10n.discoveringRegions
: l10n.discoverRegions,
),
),
// Discovered regions
if (_discoveredRegions.isNotEmpty) ...[
const SizedBox(height: 12),
for (final region in _discoveredRegions) ...[
_RegionOptionTile(
label: region,
isSelected: widget.currentScopeName == region,
onTap: () => widget.onScopeSelected(region),
),
const SizedBox(height: 4),
],
],
],
),
),
),
);
}
}
class _RegionOptionTile extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _RegionOptionTile({
required this.label,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
isSelected
? Icons.radio_button_checked_rounded
: Icons.radio_button_off_rounded,
size: 20,
color: isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurface,
),
),
),
],
),
),
),
);
}
}

View File

@@ -523,6 +523,7 @@ class _AddSensorSheetState extends State<AddSensorSheet> {
class SensorCustomizeView extends StatelessWidget {
final String publicKeyHex;
final Contact? initialContact;
final bool showLivePreview;
final Future<void> Function({
required BuildContext context,
required String publicKeyHex,
@@ -532,9 +533,11 @@ class SensorCustomizeView extends StatelessWidget {
onRenameMetric;
const SensorCustomizeView({
super.key,
required this.publicKeyHex,
required this.initialContact,
required this.onRenameMetric,
this.showLivePreview = true,
});
@override
@@ -579,35 +582,39 @@ class SensorCustomizeView extends StatelessWidget {
body: ListView(
padding: EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_SensorCustomizeSectionCard(
title: AppLocalizations.of(context)!.livePreview,
subtitle:
'Changes apply immediately. This card matches the current dashboard layout for this sensor.',
child: SensorTelemetryCard(
contact: contact,
state: sensorsProvider.stateFor(publicKeyHex),
visibleFields: visibleFields,
fieldOrder: sensorsProvider.metricOrderFor(
publicKeyHex,
visibleFields,
),
labelOverrides: sensorsProvider.labelOverridesFor(
publicKeyHex,
),
onShowMetHistory: contact == null
? null
: (contact) => showBTHomeMetHistorySheet(
context,
contact: contact,
if (showLivePreview)
_SensorCustomizeSectionCard(
title: AppLocalizations.of(context)!.livePreview,
subtitle:
'Changes apply immediately. This card matches the current dashboard layout for this sensor.',
child: SensorTelemetryCard(
contact: contact,
state: sensorsProvider.stateFor(publicKeyHex),
visibleFields: visibleFields,
fieldOrder: sensorsProvider.metricOrderFor(
publicKeyHex,
visibleFields,
),
labelOverrides: sensorsProvider.labelOverridesFor(
publicKeyHex,
),
onShowMetHistory: contact == null
? null
: (contact) => showBTHomeMetHistorySheet(
context,
contact: contact,
),
fieldSpans: {
for (final field in visibleFields)
field: sensorsProvider.fieldSpanFor(
publicKeyHex,
field,
),
fieldSpans: {
for (final field in visibleFields)
field: sensorsProvider.fieldSpanFor(publicKeyHex, field),
},
margin: EdgeInsets.zero,
emptyMetricsMessage: 'No telemetry fields available yet.',
},
margin: EdgeInsets.zero,
emptyMetricsMessage: 'No telemetry fields available yet.',
),
),
),
_SensorCustomizeSectionCard(
title: AppLocalizations.of(context)!.refreshSchedule,
subtitle:

View File

@@ -0,0 +1,11 @@
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
class CompassSupport {
const CompassSupport._();
static bool get isAvailable =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS);
}

View File

@@ -0,0 +1,72 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../providers/connection_provider.dart';
/// Discovers available regions from repeater contacts via anonymous requests.
///
/// The firmware repeater responds to ANON_REQ_TYPE_REGIONS (0x01) with a
/// comma-separated list of region names that have flood allowed.
class RegionDiscoveryService {
static const int _anonReqTypeRegions = 0x01;
/// Discover regions from a single repeater.
///
/// Sends an anonymous request to the repeater and waits for the response.
/// Returns a list of region names (with `#` prefix).
/// Returns empty list on timeout or error.
static Future<List<String>> discoverFromRepeater({
required Uint8List repeaterPublicKey,
required ConnectionProvider connectionProvider,
Duration timeout = const Duration(seconds: 10),
}) async {
final result = await connectionProvider.sendAnonRequest(
contactPublicKey: repeaterPublicKey,
requestData: Uint8List.fromList([_anonReqTypeRegions]),
);
if (result == null) return [];
final tag = result.tag;
final completer = Completer<List<String>>();
void onResponse(Uint8List publicKeyPrefix, int responseTag, Uint8List data) {
if (responseTag != tag || completer.isCompleted) return;
completer.complete(_parseRegionResponse(data));
}
connectionProvider.onBinaryResponse = onResponse;
try {
return await completer.future.timeout(
timeout,
onTimeout: () => <String>[],
);
} catch (e) {
debugPrint('⚠️ [RegionDiscovery] Error discovering regions: $e');
return [];
} finally {
// Restore previous handler — callers should re-set if needed
if (connectionProvider.onBinaryResponse == onResponse) {
connectionProvider.onBinaryResponse = null;
}
}
}
/// Parse the region response payload.
///
/// Format: [4B sender_timestamp][4B repeater_clock][comma-separated names]
/// Names are returned without `#` prefix from firmware; we add it back.
static List<String> _parseRegionResponse(Uint8List data) {
if (data.length <= 8) return [];
final namesStr = utf8.decode(data.sublist(8), allowMalformed: true).trim();
if (namesStr.isEmpty || namesStr == '-none-') return [];
return namesStr
.split(',')
.map((name) => name.trim())
.where((name) => name.isNotEmpty && name != '*' && !name.startsWith('\$'))
.map((name) => name.startsWith('#') ? name : '#$name')
.toList();
}
}

View File

@@ -0,0 +1,86 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'profiles_feature_service.dart';
/// Stores per-channel region scope settings.
///
/// Each channel can optionally have a region scope assigned. When set, the app
/// will set the flood scope on the device before sending channel messages so
/// that repeaters outside the region won't forward them.
class RegionScopePreferences {
static const String _namePrefix = 'region_scope_name_';
static const String _keyPrefix = 'region_scope_key_';
/// Get the saved scope for a channel.
/// Returns null if no scope is set.
static Future<({String name, Uint8List key})?> getScope(
int channelIdx,
) async {
final prefs = await SharedPreferences.getInstance();
final name = prefs.getString(
ProfileStorageScope.scopedKey('$_namePrefix$channelIdx'),
);
final keyBase64 = prefs.getString(
ProfileStorageScope.scopedKey('$_keyPrefix$channelIdx'),
);
if (name == null || keyBase64 == null) return null;
try {
return (name: name, key: Uint8List.fromList(base64.decode(keyBase64)));
} catch (_) {
return null;
}
}
/// Save a region scope for a channel.
static Future<void> setScope(
int channelIdx,
String name,
Uint8List key,
) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
ProfileStorageScope.scopedKey('$_namePrefix$channelIdx'),
name,
);
await prefs.setString(
ProfileStorageScope.scopedKey('$_keyPrefix$channelIdx'),
base64.encode(key),
);
}
/// Clear the region scope for a channel.
static Future<void> clearScope(int channelIdx) async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(
ProfileStorageScope.scopedKey('$_namePrefix$channelIdx'),
);
await prefs.remove(
ProfileStorageScope.scopedKey('$_keyPrefix$channelIdx'),
);
}
/// Clear all region scopes (all channels).
static Future<void> clearAllScopes() async {
final prefs = await SharedPreferences.getInstance();
final keys = prefs.getKeys();
for (final key in keys) {
if (key.contains(_namePrefix) || key.contains(_keyPrefix)) {
await prefs.remove(key);
}
}
}
/// Derive the 16-byte transport key for a region name.
///
/// Matches firmware `TransportKeyStore::getAutoKeyFor`:
/// `SHA256("#regionname")` → first 16 bytes.
/// The name must include the leading `#` (auto-prepended if missing).
static Uint8List deriveRegionKey(String regionName) {
final normalized =
regionName.startsWith('#') ? regionName : '#$regionName';
final digest = sha256.convert(utf8.encode(normalized));
return Uint8List.fromList(digest.bytes.sublist(0, 16));
}
}

View File

@@ -1,9 +1,11 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:geolocator/geolocator.dart';
import 'package:flutter_compass/flutter_compass.dart';
import 'package:latlong2/latlong.dart';
import '../../services/compass_support.dart';
import '../../models/contact.dart';
import '../../models/sar_marker.dart';
import '../common/location_display.dart';
@@ -90,17 +92,33 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
});
}
// Subscribe to compass updates
final compassStream = FlutterCompass.events;
// Subscribe to compass updates on supported targets only.
final compassStream = CompassSupport.isAvailable
? FlutterCompass.events
: null;
if (compassStream != null) {
_compassSubscription = compassStream.listen((event) {
if (mounted && event.heading != null) {
setState(() {
_currentHeading = event.heading;
_compassAccuracy = event.accuracy;
});
}
});
_compassSubscription = compassStream.listen(
(event) {
if (mounted && event.heading != null) {
setState(() {
_currentHeading = event.heading;
_compassAccuracy = event.accuracy;
});
}
},
onError: (Object error, StackTrace stackTrace) {
if (!mounted) {
return;
}
if (error is MissingPluginException) {
debugPrint('Compass plugin is unavailable on this platform');
return;
}
debugPrint('Compass dialog stream error: $error');
},
);
}
// Subscribe to position updates

View File

@@ -27,6 +27,8 @@ class MessagesComposer extends StatelessWidget {
final Future<void> Function() onStartVoiceRecording;
final Future<void> Function() onStopAndSendVoice;
final Future<void> Function() onSendMessage;
final String? regionScopeName;
final VoidCallback? onRegionScopeTap;
const MessagesComposer({
super.key,
@@ -49,6 +51,8 @@ class MessagesComposer extends StatelessWidget {
required this.onStartVoiceRecording,
required this.onStopAndSendVoice,
required this.onSendMessage,
this.regionScopeName,
this.onRegionScopeTap,
});
@override
@@ -102,6 +106,14 @@ class MessagesComposer extends StatelessWidget {
? onStopAndSendVoice
: onShowComposerActions,
),
if (regionScopeName != null &&
onRegionScopeTap != null) ...[
const SizedBox(width: 6),
_RegionScopeChip(
name: regionScopeName!,
onTap: onRegionScopeTap!,
),
],
const SizedBox(width: 8),
Expanded(
child: _DestinationSelector(
@@ -534,3 +546,52 @@ class _SendButton extends StatelessWidget {
);
}
}
class _RegionScopeChip extends StatelessWidget {
final String name;
final VoidCallback onTap;
const _RegionScopeChip({required this.name, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.tertiary.withValues(alpha: 0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.language_rounded,
size: 16,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 80),
child: Text(
name,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: colorScheme.onTertiaryContainer,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}

View File

@@ -18,6 +18,9 @@ class RecipientSelectorSheet extends StatefulWidget {
final bool showAllOption;
final Function(String type, Contact? recipient) onSelect;
/// Region scope names per channel index (e.g. {0: "#auckland"}).
final Map<int, String> channelRegionScopes;
const RecipientSelectorSheet({
super.key,
required this.contacts,
@@ -29,6 +32,7 @@ class RecipientSelectorSheet extends StatefulWidget {
this.currentRecipientPublicKey,
this.showAllOption = true,
required this.onSelect,
this.channelRegionScopes = const {},
});
@override
@@ -165,13 +169,18 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
String _channelSubtitle(BuildContext context, Contact channel) {
final l10n = AppLocalizations.of(context)!;
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
final scopeName = widget.channelRegionScopes[channelIdx];
if (channel.isPublicChannel) {
return l10n.broadcastToAllNearby;
return scopeName != null
? '${l10n.broadcastToAllNearby}$scopeName'
: l10n.broadcastToAllNearby;
}
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
final shortKey = channel.publicKeyShort.toUpperCase();
return '${l10n.channel} $channelIdx$shortKey';
final base = '${l10n.channel} $channelIdx$shortKey';
return scopeName != null ? '$base$scopeName' : base;
}
bool _isDenseSection(String type) => type == 'channel' || type == 'contact';