diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 9125090..f27e66e 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -489,7 +489,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
- CURRENT_PROJECT_VERSION = 124;
+ CURRENT_PROJECT_VERSION = 125;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 124;
+ CURRENT_PROJECT_VERSION = 125;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -530,7 +530,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 124;
+ CURRENT_PROJECT_VERSION = 125;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 124;
+ CURRENT_PROJECT_VERSION = 125;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -679,7 +679,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
- CURRENT_PROJECT_VERSION = 124;
+ CURRENT_PROJECT_VERSION = 125;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +702,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
- CURRENT_PROJECT_VERSION = 124;
+ CURRENT_PROJECT_VERSION = 125;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index bcbd7b6..503a8ff 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -43,7 +43,7 @@
CFBundleSignature
????
CFBundleVersion
- 124
+ 125
LSRequiresIPhoneOS
ITSAppUsesNonExemptEncryption
diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml
index f7349f1..e0139aa 100644
--- a/ios/fastlane/report.xml
+++ b/ios/fastlane/report.xml
@@ -5,22 +5,22 @@
-
+
-
+
-
+
-
+
diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart
index acbe59a..60afcdc 100644
--- a/lib/screens/contacts_tab.dart
+++ b/lib/screens/contacts_tab.dart
@@ -19,6 +19,8 @@ import '../utils/avatar_label_helper.dart';
import '../widgets/common/contact_avatar.dart';
import '../widgets/contacts/contact_tile.dart';
import '../widgets/contacts/add_channel_dialog.dart';
+import '../services/region_scope_preferences.dart';
+import '../utils/toast_logger.dart';
import 'add_contact_screen.dart';
class ContactsTab extends StatefulWidget {
@@ -546,6 +548,15 @@ class _ContactsTabState extends State {
await _exportHashChannelPskBase64(context, channel);
},
),
+ _ChannelSheetAction(
+ icon: Icons.language_rounded,
+ label: l10n.setRegionScope,
+ onTap: () async {
+ Navigator.pop(context);
+ if (!context.mounted) return;
+ _showRegionScopeForChannel(context, channel);
+ },
+ ),
if (!channel.isPublicChannel)
_ChannelSheetAction(
icon: Icons.delete_outline_rounded,
@@ -575,6 +586,38 @@ class _ContactsTabState extends State {
);
}
+ void _showRegionScopeForChannel(BuildContext context, Contact channel) async {
+ final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
+ final l10n = AppLocalizations.of(context)!;
+ final currentScope = await RegionScopePreferences.getScope(channelIdx);
+ if (!context.mounted) return;
+
+ showModalBottomSheet(
+ context: context,
+ showDragHandle: true,
+ isScrollControlled: true,
+ builder: (sheetContext) {
+ return _ContactsRegionScopeSheet(
+ currentScopeName: currentScope?.name,
+ l10n: l10n,
+ onScopeSelected: (String? name) async {
+ Navigator.of(sheetContext).pop();
+ if (name == null) {
+ await RegionScopePreferences.clearScope(channelIdx);
+ if (!context.mounted) return;
+ ToastLogger.success(context, l10n.regionScopeCleared);
+ } else {
+ final key = RegionScopePreferences.deriveRegionKey(name);
+ await RegionScopePreferences.setScope(channelIdx, name, key);
+ if (!context.mounted) return;
+ ToastLogger.success(context, l10n.regionScopeSet(name));
+ }
+ },
+ );
+ },
+ );
+ }
+
Color _sectionAccentColor(BuildContext context, ContactSection section) {
final colorScheme = Theme.of(context).colorScheme;
switch (section) {
@@ -2394,3 +2437,165 @@ class _MetricChip extends StatelessWidget {
);
}
}
+
+class _ContactsRegionScopeSheet extends StatefulWidget {
+ final String? currentScopeName;
+ final AppLocalizations l10n;
+ final ValueChanged onScopeSelected;
+
+ const _ContactsRegionScopeSheet({
+ required this.currentScopeName,
+ required this.l10n,
+ required this.onScopeSelected,
+ });
+
+ @override
+ State<_ContactsRegionScopeSheet> createState() =>
+ _ContactsRegionScopeSheetState();
+}
+
+class _ContactsRegionScopeSheetState
+ extends State<_ContactsRegionScopeSheet> {
+ final TextEditingController _nameController = TextEditingController();
+
+ @override
+ void dispose() {
+ _nameController.dispose();
+ super.dispose();
+ }
+
+ void _submitManualName() {
+ var name = _nameController.text.trim();
+ if (name.isEmpty) return;
+ if (!name.startsWith('#')) name = '#$name';
+ 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),
+
+ _ScopeOption(
+ label: l10n.regionScopeNone,
+ isSelected: widget.currentScopeName == null,
+ onTap: () => widget.onScopeSelected(null),
+ ),
+ const SizedBox(height: 8),
+
+ 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),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _ScopeOption extends StatelessWidget {
+ final String label;
+ final bool isSelected;
+ final VoidCallback onTap;
+
+ const _ScopeOption({
+ 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,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart
index 9d75b53..bdfc478 100644
--- a/lib/screens/messages_tab.dart
+++ b/lib/screens/messages_tab.dart
@@ -37,7 +37,6 @@ 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';
@@ -1763,12 +1762,7 @@ class _MessagesTabState extends State {
void _showRegionScopeSheet() {
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
- final contactsProvider = context.read();
- final connectionProvider = context.read();
final l10n = AppLocalizations.of(context)!;
- final repeaters = contactsProvider.contacts
- .where((c) => c.isRepeater)
- .toList();
showModalBottomSheet(
context: context,
@@ -1777,8 +1771,6 @@ class _MessagesTabState extends State {
builder: (sheetContext) {
return _RegionScopeSheet(
currentScopeName: _channelRegionScopeName,
- repeaters: repeaters,
- connectionProvider: connectionProvider,
l10n: l10n,
onScopeSelected: (String? name) async {
Navigator.of(sheetContext).pop();
@@ -2412,15 +2404,11 @@ class _MessagesTabState extends State {
/// Bottom sheet for selecting a region scope for the current channel.
class _RegionScopeSheet extends StatefulWidget {
final String? currentScopeName;
- final List repeaters;
- final ConnectionProvider connectionProvider;
final AppLocalizations l10n;
final ValueChanged onScopeSelected;
const _RegionScopeSheet({
required this.currentScopeName,
- required this.repeaters,
- required this.connectionProvider,
required this.l10n,
required this.onScopeSelected,
});
@@ -2431,8 +2419,6 @@ class _RegionScopeSheet extends StatefulWidget {
class _RegionScopeSheetState extends State<_RegionScopeSheet> {
final TextEditingController _nameController = TextEditingController();
- List _discoveredRegions = [];
- bool _isDiscovering = false;
@override
void dispose() {
@@ -2440,30 +2426,6 @@ class _RegionScopeSheetState extends State<_RegionScopeSheet> {
super.dispose();
}
- Future _discoverRegions() async {
- if (widget.repeaters.isEmpty) return;
- setState(() => _isDiscovering = true);
-
- final allRegions = {};
- 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;
@@ -2504,7 +2466,6 @@ class _RegionScopeSheetState extends State<_RegionScopeSheet> {
),
const SizedBox(height: 16),
- // "None" option
_RegionOptionTile(
label: l10n.regionScopeNone,
isSelected: widget.currentScopeName == null,
@@ -2512,7 +2473,6 @@ class _RegionScopeSheetState extends State<_RegionScopeSheet> {
),
const SizedBox(height: 8),
- // Manual entry
Row(
children: [
Expanded(
@@ -2540,38 +2500,6 @@ class _RegionScopeSheetState extends State<_RegionScopeSheet> {
),
],
),
- 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),
- ],
- ],
],
),
),
diff --git a/lib/services/region_discovery_service.dart b/lib/services/region_discovery_service.dart
deleted file mode 100644
index 44497e5..0000000
--- a/lib/services/region_discovery_service.dart
+++ /dev/null
@@ -1,72 +0,0 @@
-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> 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>();
-
- 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: () => [],
- );
- } 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 _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();
- }
-}
diff --git a/lib/widgets/connection_dialog.dart b/lib/widgets/connection_dialog.dart
index 8218a07..e460627 100644
--- a/lib/widgets/connection_dialog.dart
+++ b/lib/widgets/connection_dialog.dart
@@ -20,6 +20,30 @@ Future _initializeConnectedWorkspace({
await appProvider.initialize();
}
+String _normalizeConnectionError(Object error) {
+ var message = error.toString();
+ if (message.startsWith('Exception: ')) {
+ message = message.substring('Exception: '.length);
+ }
+ if (message.startsWith('Connection failed: Exception: ')) {
+ return message.substring('Connection failed: Exception: '.length);
+ }
+ if (message.startsWith('Connection failed: ')) {
+ return message.substring('Connection failed: '.length);
+ }
+ return message;
+}
+
+void _showConnectionErrorSnackBar(BuildContext context, Object error) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(_normalizeConnectionError(error)),
+ backgroundColor: Colors.red,
+ duration: const Duration(seconds: 5),
+ ),
+ );
+}
+
Future showConnectionDialogFlow(
BuildContext context, {
Color? backgroundColor,
@@ -36,6 +60,21 @@ Future showConnectionDialogFlow(
return result == _ConnectionDialogResult.connected;
}
+ try {
+ await _initializeConnectedWorkspace(
+ profileWorkspaceCoordinator: context.read(),
+ appProvider: context.read(),
+ );
+ } catch (error) {
+ if (context.mounted) {
+ _showConnectionErrorSnackBar(context, error);
+ }
+ }
+
+ if (!context.mounted) {
+ return true;
+ }
+
if (!offerPostConnectRepeaterDiscovery) {
return true;
}
@@ -182,43 +221,14 @@ class _ConnectionDialogState extends State
return Colors.red;
}
- Future _handleSuccessfulConnection() async {
- final appProvider = context.read();
- final profileWorkspaceCoordinator = context
- .read();
-
- await _initializeConnectedWorkspace(
- profileWorkspaceCoordinator: profileWorkspaceCoordinator,
- appProvider: appProvider,
- );
-
+ void _closeOnSuccessfulConnection() {
if (!mounted) return;
Navigator.of(context).pop(_ConnectionDialogResult.connected);
}
- String _normalizeConnectionError(Object error) {
- var message = error.toString();
- if (message.startsWith('Exception: ')) {
- message = message.substring('Exception: '.length);
- }
- if (message.startsWith('Connection failed: Exception: ')) {
- return message.substring('Connection failed: Exception: '.length);
- }
- if (message.startsWith('Connection failed: ')) {
- return message.substring('Connection failed: '.length);
- }
- return message;
- }
-
void _showConnectionError(Object error) {
if (!mounted) return;
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text(_normalizeConnectionError(error)),
- backgroundColor: Colors.red,
- duration: const Duration(seconds: 5),
- ),
- );
+ _showConnectionErrorSnackBar(context, error);
}
@override
@@ -554,7 +564,7 @@ class _ConnectionDialogState extends State
'Failed to connect to $name',
);
}
- await _handleSuccessfulConnection();
+ _closeOnSuccessfulConnection();
} catch (error) {
_showConnectionError(error);
} finally {
@@ -676,7 +686,7 @@ class _ConnectionDialogState extends State
'Failed to connect to ${server.ipAddress}:${server.port}',
);
}
- await _handleSuccessfulConnection();
+ _closeOnSuccessfulConnection();
} catch (e) {
if (!mounted) return;
setState(() {
@@ -883,11 +893,6 @@ class _SerialDeviceListState extends State<_SerialDeviceList> {
if (!mounted) return;
if (success) {
- await _initializeConnectedWorkspace(
- profileWorkspaceCoordinator: context
- .read(),
- appProvider: context.read(),
- );
widget.onConnected(_ConnectionDialogResult.connected);
} else {
await connection.disconnect();
diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart
index 480cc36..8dccaf0 100644
--- a/lib/widgets/messages/message_bubble.dart
+++ b/lib/widgets/messages/message_bubble.dart
@@ -12,6 +12,7 @@ import '../../models/sar_template.dart';
import '../../models/map_drawing.dart';
import '../../models/map_coordinate_space.dart';
import '../../providers/messages_provider.dart';
+import '../../providers/channels_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/connection_provider.dart';
import '../../providers/drawing_provider.dart';
@@ -1955,10 +1956,12 @@ class _MessageBubbleState extends State {
final isSarMarker = message.isSarMarker;
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final messageFontScale = context.watch().messageFontScale;
+ final l10n = AppLocalizations.of(context)!;
// Determine if this is own message
final connectionProvider = context.read();
final messagesProvider = context.read();
+ final channelsProvider = context.watch();
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
final isOwnMessage =
message.isSentMessage || message.isFromSelf(selfPublicKey);
@@ -2004,7 +2007,7 @@ class _MessageBubbleState extends State {
// Get rich display name (with emoji if available)
final displayName = isOwnMessage
- ? AppLocalizations.of(context)!.you
+ ? l10n.you
: message.getRichDisplayName(senderContact);
// Look up destination/source display labels for direct/channel messages
@@ -2039,15 +2042,30 @@ class _MessageBubbleState extends State {
}
}
} else if (message.isChannelMessage) {
- if (message.channelIdx == 0) {
- channelDisplayName = AppLocalizations.of(context)!.publicChannel;
+ final channelIdx = message.channelIdx ?? 0;
+ if (channelIdx == 0) {
+ channelDisplayName = l10n.publicChannel;
} else {
final channelContact = contactsProvider.channels.where((c) {
- return c.publicKey.length > 1 && c.publicKey[1] == message.channelIdx;
+ return c.publicKey.length > 1 && c.publicKey[1] == channelIdx;
}).firstOrNull;
+ final syncedChannel = channelsProvider.getChannel(channelIdx);
+ final syncedChannelDisplayName =
+ syncedChannel != null && syncedChannel.hasCustomName
+ ? syncedChannel.displayName
+ : null;
+ final contactChannelDisplayName = channelContact
+ ?.getLocalizedDisplayName(context)
+ .trim();
+
channelDisplayName =
- channelContact?.getLocalizedDisplayName(context) ??
- '${AppLocalizations.of(context)!.channel} ${message.channelIdx}';
+ syncedChannelDisplayName ??
+ (contactChannelDisplayName != null &&
+ contactChannelDisplayName.isNotEmpty
+ ? contactChannelDisplayName
+ : null) ??
+ syncedChannel?.displayName ??
+ '${l10n.channel} $channelIdx';
}
if (isOwnMessage) {
@@ -2057,14 +2075,14 @@ class _MessageBubbleState extends State {
final recipientSubtitle =
isOwnMessage && message.isChannelMessage && recipientDisplayName != null
- ? '${AppLocalizations.of(context)!.channel}: $recipientDisplayName'
+ ? '${l10n.channel}: $recipientDisplayName'
: recipientDisplayName;
final directCounterpartLabel = !message.isChannelMessage
- ? (isOwnMessage ? recipientSubtitle : AppLocalizations.of(context)!.you)
+ ? (isOwnMessage ? recipientSubtitle : l10n.you)
: null;
final receivedChannelSubtitle =
!isOwnMessage && message.isChannelMessage && channelDisplayName != null
- ? '${AppLocalizations.of(context)!.channel}: $channelDisplayName'
+ ? '${l10n.channel}: $channelDisplayName'
: null;
final shouldFloatBubble = widget.isCompact;
diff --git a/lib/widgets/messages/recipient_selector_sheet.dart b/lib/widgets/messages/recipient_selector_sheet.dart
index df85696..92d943a 100644
--- a/lib/widgets/messages/recipient_selector_sheet.dart
+++ b/lib/widgets/messages/recipient_selector_sheet.dart
@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../models/contact.dart';
+import '../../providers/messages_provider.dart';
+import '../../utils/avatar_label_helper.dart';
import '../common/contact_avatar.dart';
enum _RecipientSortMode { activity, favorites, alphabetical }
@@ -17,6 +20,7 @@ class RecipientSelectorSheet extends StatefulWidget {
final String? currentRecipientPublicKey;
final bool showAllOption;
final Function(String type, Contact? recipient) onSelect;
+ final MessagesProvider? messagesProvider;
/// Region scope names per channel index (e.g. {0: "#auckland"}).
final Map channelRegionScopes;
@@ -32,6 +36,7 @@ class RecipientSelectorSheet extends StatefulWidget {
this.currentRecipientPublicKey,
this.showAllOption = true,
required this.onSelect,
+ this.messagesProvider,
this.channelRegionScopes = const {},
});
@@ -167,20 +172,162 @@ class _RecipientSelectorSheetState extends State {
}
}
- 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 scopeName != null
- ? '${l10n.broadcastToAllNearby} • $scopeName'
- : l10n.broadcastToAllNearby;
+ MessagesProvider? _resolveMessagesProvider(BuildContext context) {
+ if (widget.messagesProvider != null) {
+ return widget.messagesProvider;
}
- final shortKey = channel.publicKeyShort.toUpperCase();
- final base = '${l10n.channel} $channelIdx • $shortKey';
- return scopeName != null ? '$base • $scopeName' : base;
+ try {
+ return Provider.of(context);
+ } on ProviderNotFoundException {
+ return null;
+ }
+ }
+
+ String _formatRelativeTime(BuildContext context, DateTime when) {
+ final l10n = AppLocalizations.of(context)!;
+ final diff = DateTime.now().difference(when);
+
+ if (diff.inMinutes < 1) return l10n.justNow;
+ if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
+ if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
+ return l10n.daysAgo(diff.inDays);
+ }
+
+ _ChannelPreviewData _channelPreviewData(
+ BuildContext context,
+ Contact channel,
+ MessagesProvider? messagesProvider,
+ ) {
+ if (messagesProvider == null) {
+ return const _ChannelPreviewData();
+ }
+
+ final lastActivityAt = messagesProvider.getLastActivityForDestination(
+ channel,
+ );
+ final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
+ final channelMessages = messagesProvider.getMessagesForChannel(channelIdx)
+ ..sort((a, b) => b.sentAt.compareTo(a.sentAt));
+ final participantNames = [];
+
+ for (final message in channelMessages) {
+ final senderName = message.senderName?.trim();
+ if (senderName == null || senderName.isEmpty) {
+ continue;
+ }
+ if (!participantNames.contains(senderName)) {
+ participantNames.add(senderName);
+ }
+ }
+
+ return _ChannelPreviewData(
+ activityLabel: lastActivityAt == null
+ ? null
+ : _formatRelativeTime(context, lastActivityAt),
+ participantNames: participantNames,
+ );
+ }
+
+ Contact? _findParticipantContact(String name) {
+ final normalizedName = name.trim();
+
+ for (final contact in widget.contacts) {
+ if (!contact.isChannel && contact.advName.trim() == normalizedName) {
+ return contact;
+ }
+ }
+
+ for (final contact in widget.contacts) {
+ if (!contact.isChannel && contact.displayName.trim() == normalizedName) {
+ return contact;
+ }
+ }
+
+ return null;
+ }
+
+ String _contactActivityLabel(
+ BuildContext context,
+ Contact contact,
+ MessagesProvider? messagesProvider,
+ ) {
+ final lastActivityAt =
+ messagesProvider?.getLastActivityForDestination(contact) ??
+ contact.lastSeenTime;
+
+ return _formatRelativeTime(context, lastActivityAt);
+ }
+
+ Widget _buildTextSubtitle(
+ BuildContext context,
+ Contact contact,
+ String subtitle,
+ ) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return Text(
+ subtitle,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ fontFamily: contact.isChannel ? null : 'monospace',
+ ),
+ );
+ }
+
+ Widget _buildChannelSubtitle(
+ BuildContext context,
+ Contact channel,
+ _ChannelPreviewData previewData,
+ ) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ if (previewData.participantNames.isEmpty) {
+ return Text(
+ 'No recent chatters',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(
+ context,
+ ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
+ );
+ }
+
+ return Row(
+ children: [
+ _ParticipantAvatarStack(
+ key: Key('channel-participants-${channel.publicKeyHex}'),
+ names: previewData.participantNames,
+ contactForName: _findParticipantContact,
+ ),
+ ],
+ );
+ }
+
+ Widget _buildChannelRecipientCard(
+ BuildContext context,
+ Contact channel,
+ MessagesProvider? messagesProvider,
+ ) {
+ final previewData = _channelPreviewData(context, channel, messagesProvider);
+
+ return _buildRecipientCard(
+ context: context,
+ type: 'channel',
+ contact: channel,
+ title: channel.getLocalizedDisplayName(context),
+ subtitle: _buildChannelSubtitle(context, channel, previewData),
+ unreadCount: _unreadFor(channel),
+ isSelected: _isSelected('channel', channel),
+ compact: true,
+ activityLabel: previewData.activityLabel,
+ onTap: () {
+ widget.onSelect('channel', channel);
+ Navigator.pop(context);
+ },
+ );
}
bool _isDenseSection(String type) => type == 'channel' || type == 'contact';
@@ -189,6 +336,7 @@ class _RecipientSelectorSheetState extends State {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colorScheme = Theme.of(context).colorScheme;
+ final messagesProvider = _resolveMessagesProvider(context);
final filteredContacts = _filterAndSortContacts(widget.contacts);
final filteredRooms = _filterAndSortContacts(widget.rooms);
final filteredChannels = _filterAndSortContacts(
@@ -335,19 +483,10 @@ class _RecipientSelectorSheetState extends State {
emptyLabel: l10n.noChannelsFound,
children: [
for (final channel in filteredChannels)
- _buildRecipientCard(
- context: context,
- type: 'channel',
- contact: channel,
- title: channel.getLocalizedDisplayName(context),
- subtitle: _channelSubtitle(context, channel),
- unreadCount: _unreadFor(channel),
- isSelected: _isSelected('channel', channel),
- compact: true,
- onTap: () {
- widget.onSelect('channel', channel);
- Navigator.pop(context);
- },
+ _buildChannelRecipientCard(
+ context,
+ channel,
+ messagesProvider,
),
],
),
@@ -366,7 +505,11 @@ class _RecipientSelectorSheetState extends State {
type: 'contact',
contact: contact,
title: contact.displayName,
- subtitle: contact.publicKeyShort,
+ activityLabel: _contactActivityLabel(
+ context,
+ contact,
+ messagesProvider,
+ ),
unreadCount: _unreadFor(contact),
isSelected: _isSelected('contact', contact),
compact: true,
@@ -392,7 +535,11 @@ class _RecipientSelectorSheetState extends State {
type: 'room',
contact: room,
title: room.displayName,
- subtitle: room.publicKeyShort,
+ subtitle: _buildTextSubtitle(
+ context,
+ room,
+ room.publicKeyShort,
+ ),
unreadCount: _unreadFor(room),
isSelected: _isSelected('room', room),
onTap: () {
@@ -690,10 +837,11 @@ class _RecipientSelectorSheetState extends State {
required String type,
required Contact contact,
required String title,
- required String subtitle,
+ Widget? subtitle,
required int unreadCount,
required bool isSelected,
bool compact = false,
+ String? activityLabel,
required VoidCallback onTap,
}) {
final colorScheme = Theme.of(context).colorScheme;
@@ -757,52 +905,70 @@ class _RecipientSelectorSheetState extends State {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Flexible(
- child: Text(
- title,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: Theme.of(context).textTheme.titleSmall
- ?.copyWith(
- fontWeight: FontWeight.w800,
- letterSpacing: -0.2,
+ Expanded(
+ child: Row(
+ children: [
+ Flexible(
+ child: Text(
+ title,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context)
+ .textTheme
+ .titleSmall
+ ?.copyWith(
+ fontWeight: FontWeight.w800,
+ letterSpacing: -0.2,
+ ),
),
+ ),
+ if (contact.isPublicChannel) ...[
+ SizedBox(width: compact ? 6 : 8),
+ Container(
+ padding: EdgeInsets.symmetric(
+ horizontal: compact ? 7 : 8,
+ vertical: compact ? 2 : 3,
+ ),
+ decoration: BoxDecoration(
+ color: accentColor.withValues(
+ alpha: 0.10,
+ ),
+ borderRadius: BorderRadius.circular(999),
+ ),
+ child: Text(
+ 'Public',
+ style: Theme.of(context)
+ .textTheme
+ .labelSmall
+ ?.copyWith(
+ color: accentColor,
+ fontWeight: FontWeight.w800,
+ ),
+ ),
+ ),
+ ],
+ ],
),
),
- if (contact.isPublicChannel) ...[
- SizedBox(width: compact ? 6 : 8),
- Container(
- padding: EdgeInsets.symmetric(
- horizontal: compact ? 7 : 8,
- vertical: compact ? 2 : 3,
- ),
- decoration: BoxDecoration(
- color: accentColor.withValues(alpha: 0.10),
- borderRadius: BorderRadius.circular(999),
- ),
- child: Text(
- 'Public',
- style: Theme.of(context).textTheme.labelSmall
- ?.copyWith(
- color: accentColor,
- fontWeight: FontWeight.w800,
- ),
- ),
+ if (activityLabel != null) ...[
+ SizedBox(width: compact ? 8 : 10),
+ Text(
+ activityLabel,
+ style: Theme.of(context).textTheme.labelSmall
+ ?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ fontWeight: FontWeight.w700,
+ ),
),
],
],
),
- SizedBox(height: compact ? 2 : 4),
- Text(
+ if (subtitle != null) ...[
+ SizedBox(height: compact ? 2 : 4),
subtitle,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: Theme.of(context).textTheme.bodySmall?.copyWith(
- color: colorScheme.onSurfaceVariant,
- fontFamily: contact.isChannel ? null : 'monospace',
- ),
- ),
+ ],
],
),
),
@@ -858,3 +1024,140 @@ class _RecipientSelectorSheetState extends State {
);
}
}
+
+class _ChannelPreviewData {
+ final String? activityLabel;
+ final List participantNames;
+
+ const _ChannelPreviewData({
+ this.activityLabel,
+ this.participantNames = const [],
+ });
+}
+
+class _ParticipantAvatarStack extends StatelessWidget {
+ final List names;
+ final Contact? Function(String name) contactForName;
+ static const int _visibleCount = 4;
+
+ const _ParticipantAvatarStack({
+ super.key,
+ required this.names,
+ required this.contactForName,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final visibleNames = names.take(_visibleCount).toList();
+ final overflowCount = names.length - visibleNames.length;
+ const avatarSize = 20.0;
+ const spacing = 14.0;
+ final itemCount = visibleNames.length + (overflowCount > 0 ? 1 : 0);
+ final width = itemCount == 0 ? 0.0 : avatarSize + (itemCount - 1) * spacing;
+
+ return SizedBox(
+ width: width,
+ height: avatarSize,
+ child: Stack(
+ clipBehavior: Clip.none,
+ children: [
+ for (var i = 0; i < visibleNames.length; i++)
+ Positioned(
+ left: i * spacing,
+ top: 0,
+ child: _ParticipantAvatar(
+ name: visibleNames[i],
+ contact: contactForName(visibleNames[i]),
+ ),
+ ),
+ if (overflowCount > 0)
+ Positioned(
+ left: visibleNames.length * spacing,
+ top: 0,
+ child: _ParticipantOverflowAvatar(count: overflowCount),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _ParticipantOverflowAvatar extends StatelessWidget {
+ final int count;
+
+ const _ParticipantOverflowAvatar({required this.count});
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return Container(
+ width: 20,
+ height: 20,
+ decoration: BoxDecoration(
+ color: colorScheme.surfaceContainerHighest,
+ shape: BoxShape.circle,
+ border: Border.all(color: colorScheme.surface, width: 2),
+ ),
+ alignment: Alignment.center,
+ child: Text(
+ '+$count',
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ color: colorScheme.onSurface,
+ fontSize: 8,
+ ),
+ ),
+ );
+ }
+}
+
+class _ParticipantAvatar extends StatelessWidget {
+ final String name;
+ final Contact? contact;
+
+ const _ParticipantAvatar({required this.name, required this.contact});
+
+ @override
+ Widget build(BuildContext context) {
+ if (contact != null) {
+ final surfaceColor = Theme.of(context).colorScheme.surface;
+
+ return SizedBox(
+ width: 20,
+ height: 20,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ border: Border.all(color: surfaceColor, width: 2),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(2),
+ child: ClipOval(child: ContactAvatar(contact: contact!, radius: 6)),
+ ),
+ ),
+ );
+ }
+
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return Container(
+ width: 20,
+ height: 20,
+ decoration: BoxDecoration(
+ color: colorScheme.tertiaryContainer,
+ shape: BoxShape.circle,
+ border: Border.all(color: colorScheme.surface, width: 2),
+ ),
+ alignment: Alignment.center,
+ child: Text(
+ AvatarLabelHelper.buildLabel(name),
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ color: colorScheme.onTertiaryContainer,
+ fontSize: 8,
+ ),
+ ),
+ );
+ }
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 9059be3..2f1cef1 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
-version: 2026.0322.1+38
+version: 2026.0322.2+39
environment:
sdk: ^3.9.2
diff --git a/test/widgets/connection_dialog_test.dart b/test/widgets/connection_dialog_test.dart
index 55a9662..23f1fcd 100644
--- a/test/widgets/connection_dialog_test.dart
+++ b/test/widgets/connection_dialog_test.dart
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
+import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart';
@@ -34,6 +35,24 @@ class _FakeConnectionProvider extends ConnectionProvider {
}
}
+class _ConnectableFakeConnectionProvider extends ConnectionProvider {
+ int connectCalls = 0;
+
+ @override
+ List get scannedDevices => [
+ ScannedDevice(device: BluetoothDevice.fromId('test-device'), rssi: -55),
+ ];
+
+ @override
+ String? get error => null;
+
+ @override
+ Future connect(BluetoothDevice device) async {
+ connectCalls += 1;
+ return true;
+ }
+}
+
void main() {
testWidgets('BLE scan waits for explicit user action', (tester) async {
final connectionProvider = _FakeConnectionProvider();
@@ -64,4 +83,47 @@ void main() {
expect(connectionProvider.stopScanCalls, 1);
expect(connectionProvider.startScanCalls, 1);
});
+
+ testWidgets('successful BLE connect closes the dialog immediately', (
+ tester,
+ ) async {
+ final connectionProvider = _ConnectableFakeConnectionProvider();
+
+ await tester.pumpWidget(
+ ChangeNotifierProvider.value(
+ value: connectionProvider,
+ child: MaterialApp(
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ home: Builder(
+ builder: (context) => Scaffold(
+ body: Center(
+ child: FilledButton(
+ onPressed: () {
+ showModalBottomSheet