From 77e43843bf534e5cf4f3f0b573b2bc22dc1bdb91 Mon Sep 17 00:00:00 2001 From: Janez T Date: Fri, 13 Mar 2026 10:25:23 +0100 Subject: [PATCH] Update contacts tab widgets --- lib/screens/contacts_tab.dart | 107 ++++--- lib/widgets/contacts/add_channel_dialog.dart | 276 ++++++++++--------- lib/widgets/contacts/contact_tile.dart | 269 ++++++++++++------ test/screens/contacts_tab_test.dart | 111 +++++++- test/widgets/add_channel_dialog_test.dart | 8 +- 5 files changed, 525 insertions(+), 246 deletions(-) diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index 433d9ce..ecd23cf 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -401,41 +401,50 @@ class _ContactsTabState extends State { ); } - /// Show the add channel dialog + /// Show the add channel sheet Future _showAddChannelDialog(BuildContext context) async { final l10n = AppLocalizations.of(context)!; - await showDialog( + await showModalBottomSheet( context: context, - builder: (context) => AddChannelDialog( - onCreateChannel: (name, secret) async { - final connectionProvider = context.read(); - try { - await connectionProvider.createChannel( - channelName: name, - channelSecret: secret, - ); + isScrollControlled: true, + showDragHandle: true, + builder: (context) => SafeArea( + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: AddChannelSheet( + onCreateChannel: (name, secret) async { + final connectionProvider = context.read(); + try { + await connectionProvider.createChannel( + channelName: name, + channelSecret: secret, + ); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.channelCreatedSuccessfully), - backgroundColor: Colors.green, - ), - ); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.channelCreationFailed(e.toString())), - backgroundColor: Colors.red, - ), - ); - } - rethrow; // Re-throw to let dialog handle the error state - } - }, + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.channelCreatedSuccessfully), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.channelCreationFailed(e.toString())), + backgroundColor: Colors.red, + ), + ); + } + rethrow; // Re-throw to let dialog handle the error state + } + }, + ), + ), ), ); } @@ -627,6 +636,13 @@ class _ContactsTabState extends State { _showSavedGroupsForSection(ContactSection.repeaters) ? savedRepeaterGroups : const <_RenderedSavedGroup>[]; + final ungroupedRepeaters = _excludeGroupedContacts( + repeaters, + visibleSavedRepeaterGroups, + ); + final showRepeatersOthersGroup = + visibleSavedRepeaterGroups.length > 1 && + ungroupedRepeaters.isNotEmpty; final rooms = _filterContactsForSection( allRooms, ContactSection.rooms, @@ -783,12 +799,23 @@ class _ContactsTabState extends State { visibleSavedRepeaterGroups, ContactSection.repeaters, ), - ..._buildContactSectionItems( - _excludeGroupedContacts( - repeaters, - visibleSavedRepeaterGroups, + if (showRepeatersOthersGroup) + _InferredContactGroupCard( + label: 'Others', + contacts: ungroupedRepeaters, + kindLabel: 'Auto group', + compactContacts: true, + currentPosition: _currentPosition, + calculateDistance: _calculateDistanceInMeters, + formatDistance: _formatDistance, + onNavigateToMap: widget.onNavigateToMap, + onNavigateToMessages: widget.onNavigateToMessages, + ) + else + ..._buildContactSectionItems( + ungroupedRepeaters, + compact: true, ), - ), const Divider(height: 32), ], @@ -894,11 +921,15 @@ class _ContactsTabState extends State { ); } - List _buildContactSectionItems(List contacts) { + List _buildContactSectionItems( + List contacts, { + bool compact = false, + }) { return contacts .map( (contact) => ContactTile( contact: contact, + compact: compact, currentPosition: _currentPosition, calculateDistance: _calculateDistanceInMeters, formatDistance: _formatDistance, @@ -932,6 +963,7 @@ class _ContactsTabState extends State { (group) => _InferredContactGroupCard( label: group.group.label, contacts: group.contacts, + compactContacts: section == ContactSection.repeaters, currentPosition: _currentPosition, calculateDistance: _calculateDistanceInMeters, formatDistance: _formatDistance, @@ -1317,6 +1349,7 @@ class _InferredContactGroupCard extends StatelessWidget { final String label; final List contacts; final String? kindLabel; + final bool compactContacts; final Position? currentPosition; final double Function(double, double, double, double) calculateDistance; final String Function(double) formatDistance; @@ -1328,6 +1361,7 @@ class _InferredContactGroupCard extends StatelessWidget { required this.label, required this.contacts, this.kindLabel, + this.compactContacts = false, required this.currentPosition, required this.calculateDistance, required this.formatDistance, @@ -1414,6 +1448,7 @@ class _InferredContactGroupCard extends StatelessWidget { (contact) => ContactTile( contact: contact, groupLabel: label, + compact: compactContacts, currentPosition: currentPosition, calculateDistance: calculateDistance, formatDistance: formatDistance, diff --git a/lib/widgets/contacts/add_channel_dialog.dart b/lib/widgets/contacts/add_channel_dialog.dart index d7c1145..cd60f7a 100644 --- a/lib/widgets/contacts/add_channel_dialog.dart +++ b/lib/widgets/contacts/add_channel_dialog.dart @@ -1,20 +1,17 @@ import 'package:flutter/material.dart'; import '../../l10n/app_localizations.dart'; -/// Dialog for adding a new channel -class AddChannelDialog extends StatefulWidget { +/// Bottom sheet for adding a new channel +class AddChannelSheet extends StatefulWidget { final Future Function(String name, String secret) onCreateChannel; - const AddChannelDialog({ - super.key, - required this.onCreateChannel, - }); + const AddChannelSheet({super.key, required this.onCreateChannel}); @override - State createState() => _AddChannelDialogState(); + State createState() => _AddChannelSheetState(); } -class _AddChannelDialogState extends State { +class _AddChannelSheetState extends State { final _formKey = GlobalKey(); final _nameController = TextEditingController(); final _secretController = TextEditingController(); @@ -84,7 +81,7 @@ class _AddChannelDialogState extends State { try { final channelName = _nameController.text.trim(); final isHashChannel = channelName.startsWith('#'); - + // For hash channels, pass empty secret (will be auto-generated) // For private channels, use the provided secret final secret = isHashChannel ? '' : _secretController.text; @@ -109,152 +106,183 @@ class _AddChannelDialogState extends State { final normalizedName = _nameController.text.trimLeft(); final isHashChannel = normalizedName.startsWith('#'); - return AlertDialog( - title: Text(l10n.addChannel), - content: SingleChildScrollView( - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Info banner explaining channel types - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: theme.colorScheme.primary.withValues(alpha: 0.3), - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - Icon( - Icons.info_outline, - size: 20, - color: theme.colorScheme.primary, - ), - const SizedBox(width: 8), Expanded( child: Text( - l10n.channelTypesInfo, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + l10n.addChannel, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, ), ), ), + IconButton( + onPressed: _isCreating + ? null + : () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + tooltip: l10n.close, + ), ], ), - ), - const SizedBox(height: 16), - - // Channel Name Field - TextFormField( - controller: _nameController, - decoration: InputDecoration( - labelText: l10n.channelName, - hintText: l10n.channelNameHint, - border: const OutlineInputBorder(), - prefixIcon: Icon( - isHashChannel ? Icons.tag : Icons.lock_outline, - color: isHashChannel ? Colors.blue : Colors.orange, - ), - ), - enabled: !_isCreating, - maxLength: 31, - validator: _validateName, - textInputAction: isHashChannel - ? TextInputAction.done - : TextInputAction.next, - onChanged: (_) => setState(() {}), // Rebuild to update icon - onFieldSubmitted: (_) { - if (isHashChannel) { - _handleCreate(); - } - }, - ), - // Channel Secret Field (only show for private channels) - if (!isHashChannel) ...[ - const SizedBox(height: 16), - TextFormField( - controller: _secretController, - decoration: InputDecoration( - labelText: l10n.channelSecret, - hintText: l10n.channelSecretHint, - border: const OutlineInputBorder(), - ), - obscureText: true, - enabled: !_isCreating, - maxLength: 32, - validator: _validateSecret, - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => _handleCreate(), - ), - const SizedBox(height: 8), - // Help Text for private channels - Text( - l10n.channelSecretHelp, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - - // Help Text for hash channels - if (isHashChannel) ...[ const SizedBox(height: 8), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(8), + color: theme.colorScheme.primaryContainer.withValues( + alpha: 0.3, + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.primary.withValues(alpha: 0.3), + ), ), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon( - Icons.auto_awesome, + Icon( + Icons.info_outline, size: 20, - color: Colors.blue, + color: theme.colorScheme.primary, ), const SizedBox(width: 8), Expanded( child: Text( - l10n.hashChannelInfo, + l10n.channelTypesInfo, style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurfaceVariant, ), ), ), ], ), ), + const SizedBox(height: 16), + TextFormField( + controller: _nameController, + decoration: InputDecoration( + labelText: l10n.channelName, + hintText: l10n.channelNameHint, + border: const OutlineInputBorder(), + prefixIcon: Icon( + isHashChannel ? Icons.tag : Icons.lock_outline, + color: isHashChannel ? Colors.blue : Colors.orange, + ), + ), + enabled: !_isCreating, + maxLength: 31, + validator: _validateName, + textInputAction: isHashChannel + ? TextInputAction.done + : TextInputAction.next, + onChanged: (_) => setState(() {}), + onFieldSubmitted: (_) { + if (isHashChannel) { + _handleCreate(); + } + }, + ), + if (!isHashChannel) ...[ + const SizedBox(height: 16), + TextFormField( + controller: _secretController, + decoration: InputDecoration( + labelText: l10n.channelSecret, + hintText: l10n.channelSecretHint, + border: const OutlineInputBorder(), + ), + obscureText: true, + enabled: !_isCreating, + maxLength: 32, + validator: _validateSecret, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _handleCreate(), + ), + const SizedBox(height: 8), + Text( + l10n.channelSecretHelp, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + if (isHashChannel) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer.withValues( + alpha: 0.5, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon( + Icons.auto_awesome, + size: 20, + color: Colors.blue, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + l10n.hashChannelInfo, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _isCreating + ? null + : () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: _isCreating ? null : _handleCreate, + child: _isCreating + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Text(l10n.createChannel), + ), + ), + ], + ), ], - ], + ), ), ), ), - actions: [ - // Cancel Button - TextButton( - onPressed: _isCreating ? null : () => Navigator.of(context).pop(), - child: Text(l10n.cancel), - ), - - // Create Button - FilledButton( - onPressed: _isCreating ? null : _handleCreate, - child: _isCreating - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(l10n.createChannel), - ), - ], ); } } diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 897d0df..aafd14a 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -22,6 +22,7 @@ import '../../l10n/app_localizations.dart'; class ContactTile extends StatelessWidget { final Contact contact; final String? groupLabel; + final bool compact; final Position? currentPosition; final double Function(double, double, double, double)? calculateDistance; final String Function(double)? formatDistance; @@ -32,6 +33,7 @@ class ContactTile extends StatelessWidget { super.key, required this.contact, this.groupLabel, + this.compact = false, this.currentPosition, this.calculateDistance, this.formatDistance, @@ -127,50 +129,52 @@ class ContactTile extends StatelessWidget { : colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, ); - final Widget subtitleWidget = Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - Wrap( - spacing: 6, - runSpacing: 6, - children: [ - if (groupLabel case final label?) - _buildMetaPill( - context, - icon: Icons.folder_copy_outlined, - label: label, + final Widget subtitleWidget = compact + ? _buildCompactSubtitle(context, distanceText) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + if (groupLabel case final label?) + _buildMetaPill( + context, + icon: Icons.folder_copy_outlined, + label: label, + ), + _buildMetaPill( + context, + icon: Icons.key_outlined, + label: contact.publicKeyShort, + monospace: true, + ), + ], ), - _buildMetaPill( - context, - icon: Icons.key_outlined, - label: contact.publicKeyShort, - monospace: true, - ), - ], - ), - if (location != null) ...[ - const SizedBox(height: 2), - _buildLocationLine( - context, - latitude: location.latitude, - longitude: location.longitude, - distanceText: distanceText, - ), - const SizedBox(height: 6), - Row(children: [_buildRoutePill(context, contact)]), - ] else - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - AppLocalizations.of(context)!.noGpsData, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: Colors.grey), - ), - ), - ], - ); + if (location != null) ...[ + const SizedBox(height: 2), + _buildLocationLine( + context, + latitude: location.latitude, + longitude: location.longitude, + distanceText: distanceText, + ), + const SizedBox(height: 6), + Row(children: [_buildRoutePill(context, contact)]), + ] else + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + AppLocalizations.of(context)!.noGpsData, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: Colors.grey), + ), + ), + ], + ); return Container( margin: const EdgeInsets.only(bottom: 8), @@ -294,6 +298,34 @@ class ContactTile extends StatelessWidget { ); } + Widget _buildCompactSubtitle(BuildContext context, String? distanceText) { + final location = contact.displayLocation; + final compactPills = [ + if (groupLabel case final label?) + _buildMetaPill(context, icon: Icons.folder_copy_outlined, label: label), + _buildMetaPill( + context, + icon: Icons.key_outlined, + label: contact.publicKeyShort, + monospace: true, + ), + if (distanceText != null) _buildDistancePill(context, distanceText), + if (contact.routeHasPath && contact.routeHopCount > 0) + _buildRoutePill(context, contact), + if (location == null) + _buildMetaPill( + context, + icon: Icons.location_disabled_outlined, + label: AppLocalizations.of(context)!.noGpsData, + ), + ]; + + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Wrap(spacing: 6, runSpacing: 6, children: compactPills), + ); + } + void _handlePrimaryTap(BuildContext context, Contact contact) { _showContactActionSheet(context, contact); } @@ -570,47 +602,26 @@ class ContactTile extends StatelessWidget { BuildContext context, Contact contact, ) async { - final controller = TextEditingController(text: contact.nameOverride ?? ''); final l10n = AppLocalizations.of(context)!; - - final result = await showDialog( + final result = await showModalBottomSheet( context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Edit name'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - controller: controller, - autofocus: true, - textInputAction: TextInputAction.done, - decoration: InputDecoration( - labelText: 'Custom name', - hintText: contact.advName, - helperText: 'Leave blank to use the advertised name.', - ), - onSubmitted: (value) { - Navigator.of(dialogContext).pop(value); - }, - ), - ], + isScrollControlled: true, + showDragHandle: true, + builder: (dialogContext) => SafeArea( + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(dialogContext).viewInsets.bottom, + ), + child: _ContactNameOverrideSheet( + initialValue: contact.nameOverride ?? '', + advertisedName: contact.advName, + cancelLabel: l10n.cancel, + saveLabel: l10n.save, + ), ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: Text(l10n.cancel), - ), - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(controller.text), - child: Text(l10n.save), - ), - ], ), ); - controller.dispose(); - if (result == null || !context.mounted) { return; } @@ -980,3 +991,105 @@ class ContactTile extends StatelessWidget { ); } } + +class _ContactNameOverrideSheet extends StatefulWidget { + final String initialValue; + final String advertisedName; + final String cancelLabel; + final String saveLabel; + + const _ContactNameOverrideSheet({ + required this.initialValue, + required this.advertisedName, + required this.cancelLabel, + required this.saveLabel, + }); + + @override + State<_ContactNameOverrideSheet> createState() => + _ContactNameOverrideSheetState(); +} + +class _ContactNameOverrideSheetState extends State<_ContactNameOverrideSheet> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + Navigator.of(context).pop(_controller.text); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Edit name', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 8), + TextField( + controller: _controller, + autofocus: true, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: 'Custom name', + hintText: widget.advertisedName, + helperText: 'Leave blank to use the advertised name.', + border: const OutlineInputBorder(), + ), + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(widget.cancelLabel), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: _submit, + child: Text(widget.saveLabel), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/test/screens/contacts_tab_test.dart b/test/screens/contacts_tab_test.dart index e16dd50..66e9033 100644 --- a/test/screens/contacts_tab_test.dart +++ b/test/screens/contacts_tab_test.dart @@ -4,11 +4,13 @@ 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/contact_group.dart'; import 'package:meshcore_sar_app/providers/connection_provider.dart'; import 'package:meshcore_sar_app/providers/contacts_provider.dart'; import 'package:meshcore_sar_app/providers/map_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/screens/contacts_tab.dart'; +import 'package:meshcore_sar_app/utils/contact_grouping.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -36,13 +38,39 @@ void main() { ); } + Contact buildRepeater({required int seed, required String name}) { + final publicKey = Uint8List(32); + publicKey[0] = seed; + publicKey[1] = seed + 1; + + return Contact( + publicKey: publicKey, + type: ContactType.repeater, + flags: 0, + outPathLen: -1, + outPath: Uint8List(0), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 46056000 + seed, + advLon: 14505000 + seed, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } + Future pumpContactsTab( WidgetTester tester, { - required List channels, + List contacts = const [], + List savedGroups = const [], }) async { final contactsProvider = ContactsProvider(); - for (final channel in channels) { - contactsProvider.addOrUpdateContact(channel); + for (final contact in contacts) { + contactsProvider.addOrUpdateContact(contact); + } + if (savedGroups.isNotEmpty) { + await contactsProvider.replaceAutoGroupsForSection( + 'repeaters', + savedGroups, + ); } await tester.pumpWidget( @@ -69,7 +97,7 @@ void main() { ) async { await pumpContactsTab( tester, - channels: [buildChannel(name: 'Ops', channelIndex: 3)], + contacts: [buildChannel(name: 'Ops', channelIndex: 3)], ); expect(find.text('Ops'), findsOneWidget); @@ -89,4 +117,79 @@ void main() { findsOneWidget, ); }); + + testWidgets('repeaters show Others group when multiple groups exist', ( + tester, + ) async { + final repeaters = [ + buildRepeater(seed: 10, name: 'AL-1'), + buildRepeater(seed: 11, name: 'AL-2'), + buildRepeater(seed: 12, name: 'AL-3'), + buildRepeater(seed: 13, name: 'AL-4'), + buildRepeater(seed: 20, name: 'BR-1'), + buildRepeater(seed: 21, name: 'BR-2'), + buildRepeater(seed: 22, name: 'BR-3'), + buildRepeater(seed: 23, name: 'BR-4'), + buildRepeater(seed: 30, name: 'Lone Relay'), + ]; + final inferredGroups = ContactGrouping.inferGroups(repeaters); + final savedGroups = inferredGroups + .map( + (group) => SavedContactGroup( + id: 'repeaters_${group.key}', + sectionKey: 'repeaters', + label: group.label, + query: group.label, + createdAt: DateTime(2026, 3, 13, 10), + matchPrefixes: group.matchPrefixes, + isAutoGroup: true, + ), + ) + .toList(); + + await pumpContactsTab( + tester, + contacts: repeaters, + savedGroups: savedGroups, + ); + + expect(find.text('AL-'), findsOneWidget); + expect(find.text('BR-'), findsOneWidget); + expect(find.text('Others'), findsOneWidget); + expect(find.text('Lone Relay'), findsNothing); + }); + + testWidgets('repeaters stay flat when only one group exists', (tester) async { + final repeaters = [ + buildRepeater(seed: 40, name: 'AL-1'), + buildRepeater(seed: 41, name: 'AL-2'), + buildRepeater(seed: 42, name: 'AL-3'), + buildRepeater(seed: 43, name: 'AL-4'), + buildRepeater(seed: 50, name: 'Lone Relay'), + ]; + final inferredGroups = ContactGrouping.inferGroups(repeaters); + final savedGroups = inferredGroups + .map( + (group) => SavedContactGroup( + id: 'repeaters_${group.key}', + sectionKey: 'repeaters', + label: group.label, + query: group.label, + createdAt: DateTime(2026, 3, 13, 10), + matchPrefixes: group.matchPrefixes, + isAutoGroup: true, + ), + ) + .toList(); + + await pumpContactsTab( + tester, + contacts: repeaters, + savedGroups: savedGroups, + ); + + expect(find.text('AL-'), findsOneWidget); + expect(find.text('Others'), findsNothing); + expect(find.text('Lone Relay'), findsOneWidget); + }); } diff --git a/test/widgets/add_channel_dialog_test.dart b/test/widgets/add_channel_dialog_test.dart index 078add8..cf4c135 100644 --- a/test/widgets/add_channel_dialog_test.dart +++ b/test/widgets/add_channel_dialog_test.dart @@ -4,7 +4,7 @@ import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/widgets/contacts/add_channel_dialog.dart'; void main() { - Future pumpDialog( + Future pumpSheet( WidgetTester tester, { required Future Function(String name, String secret) onCreateChannel, }) async { @@ -15,7 +15,7 @@ void main() { home: Scaffold( body: Builder( builder: (context) => Center( - child: AddChannelDialog(onCreateChannel: onCreateChannel), + child: AddChannelSheet(onCreateChannel: onCreateChannel), ), ), ), @@ -27,7 +27,7 @@ void main() { String? submittedName; String? submittedSecret; - await pumpDialog( + await pumpSheet( tester, onCreateChannel: (name, secret) async { submittedName = name; @@ -47,7 +47,7 @@ void main() { testWidgets('uses done action for hash channels', (tester) async { String? submittedName; - await pumpDialog( + await pumpSheet( tester, onCreateChannel: (name, secret) async { submittedName = name;