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( + context: context, + isScrollControlled: true, + builder: (_) => const ConnectionDialog(), + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.byType(ConnectionDialog), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Connect')); + await tester.pumpAndSettle(); + + expect(connectionProvider.connectCalls, 1); + expect(find.byType(ConnectionDialog), findsNothing); + }); } diff --git a/test/widgets/message_bubble_test.dart b/test/widgets/message_bubble_test.dart index 7ba99a2..84c0727 100644 --- a/test/widgets/message_bubble_test.dart +++ b/test/widgets/message_bubble_test.dart @@ -181,6 +181,44 @@ void main() { } }); + testWidgets('channel bubbles refresh to synced channel names', ( + tester, + ) async { + final harness = await _TestHarness.create(); + try { + final message = Message( + id: 'channel-name-refresh', + messageType: MessageType.channel, + senderPublicKeyPrefix: _prefix(61), + channelIdx: 3, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'Team update', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + deliveryStatus: MessageDeliveryStatus.sent, + ); + + await tester.pumpWidget(_buildApp(harness, message)); + await tester.pumpAndSettle(); + + expect(find.text('Channel 3'), findsOneWidget); + expect(find.text('#slovenija'), findsNothing); + + harness.channelsProvider.addOrUpdateChannel( + index: 3, + name: '#slovenija', + secret: Uint8List(16), + ); + await tester.pumpAndSettle(); + + expect(find.text('#slovenija'), findsOneWidget); + expect(find.text('Channel 3'), findsNothing); + } finally { + await _disposeHarness(tester, harness); + } + }); + testWidgets('message bubble detects and opens links', (tester) async { final harness = await _TestHarness.create(); try { diff --git a/test/widgets/recipient_selector_sheet_test.dart b/test/widgets/recipient_selector_sheet_test.dart index 1d222c8..817ef1c 100644 --- a/test/widgets/recipient_selector_sheet_test.dart +++ b/test/widgets/recipient_selector_sheet_test.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/models/message.dart'; +import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/widgets/messages/recipient_selector_sheet.dart'; void main() { @@ -31,13 +33,18 @@ void main() { ); } - Future pumpSheet(WidgetTester tester) async { - final channel = buildContact( - name: 'Ops', - type: ContactType.channel, - secondByte: 3, - ); - final contact = buildContact(name: 'John Smith', type: ContactType.chat); + Future pumpSheet( + WidgetTester tester, { + List? contacts, + List? channels, + MessagesProvider? messagesProvider, + bool showAllOption = true, + }) async { + final resolvedChannels = + channels ?? + [buildContact(name: 'Ops', type: ContactType.channel, secondByte: 3)]; + final resolvedContacts = + contacts ?? [buildContact(name: 'John Smith', type: ContactType.chat)]; await tester.pumpWidget( MaterialApp( @@ -45,15 +52,17 @@ void main() { supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: RecipientSelectorSheet( - contacts: [contact], + contacts: resolvedContacts, rooms: const [], - channels: [channel], + channels: resolvedChannels, unreadCount: 11, unreadCountsByPublicKey: { - channel.publicKeyHex: 7, - contact.publicKeyHex: 3, + for (final channel in resolvedChannels) channel.publicKeyHex: 7, + for (final contact in resolvedContacts) contact.publicKeyHex: 3, }, currentDestinationType: 'all', + showAllOption: showAllOption, + messagesProvider: messagesProvider, onSelect: (selectedContact, destinationType) {}, ), ), @@ -150,4 +159,49 @@ void main() { expect(charlieY, lessThan(bravoY)); expect(bravoY, lessThan(alphaY)); }); + + testWidgets('shows channel activity and participants instead of raw ids', ( + tester, + ) async { + final channel = buildContact( + name: 'Ops', + type: ContactType.channel, + secondByte: 3, + ); + final messagesProvider = MessagesProvider() + ..addMessage( + Message( + id: 'channel-activity', + messageType: MessageType.channel, + senderName: 'Radio Alpha', + channelIdx: 3, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: + DateTime.now() + .subtract(const Duration(minutes: 5)) + .millisecondsSinceEpoch ~/ + 1000, + text: 'status update', + receivedAt: DateTime.now().subtract(const Duration(minutes: 5)), + ), + ); + + await pumpSheet( + tester, + contacts: const [], + channels: [channel], + messagesProvider: messagesProvider, + showAllOption: false, + ); + + expect( + find.byKey(Key('channel-participants-${channel.publicKeyHex}')), + findsOneWidget, + ); + expect(find.text('Radio Alpha'), findsNothing); + expect(find.text('5m ago'), findsOneWidget); + expect(find.textContaining('Channel 3'), findsNothing); + expect(find.text(channel.publicKeyShort.toUpperCase()), findsNothing); + }); }