Update contacts tab widgets

This commit is contained in:
Janez T
2026-03-13 10:25:23 +01:00
parent 1e70f52069
commit 77e43843bf
5 changed files with 525 additions and 246 deletions

View File

@@ -401,13 +401,20 @@ class _ContactsTabState extends State<ContactsTab> {
);
}
/// Show the add channel dialog
/// Show the add channel sheet
Future<void> _showAddChannelDialog(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
await showDialog(
await showModalBottomSheet(
context: context,
builder: (context) => AddChannelDialog(
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<ConnectionProvider>();
try {
@@ -437,6 +444,8 @@ class _ContactsTabState extends State<ContactsTab> {
}
},
),
),
),
);
}
@@ -627,6 +636,13 @@ class _ContactsTabState extends State<ContactsTab> {
_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,11 +799,22 @@ class _ContactsTabState extends State<ContactsTab> {
visibleSavedRepeaterGroups,
ContactSection.repeaters,
),
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(
_excludeGroupedContacts(
repeaters,
visibleSavedRepeaterGroups,
),
ungroupedRepeaters,
compact: true,
),
const Divider(height: 32),
],
@@ -894,11 +921,15 @@ class _ContactsTabState extends State<ContactsTab> {
);
}
List<Widget> _buildContactSectionItems(List<Contact> contacts) {
List<Widget> _buildContactSectionItems(
List<Contact> 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<ContactsTab> {
(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<Contact> 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,

View File

@@ -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<void> Function(String name, String secret) onCreateChannel;
const AddChannelDialog({
super.key,
required this.onCreateChannel,
});
const AddChannelSheet({super.key, required this.onCreateChannel});
@override
State<AddChannelDialog> createState() => _AddChannelDialogState();
State<AddChannelSheet> createState() => _AddChannelSheetState();
}
class _AddChannelDialogState extends State<AddChannelDialog> {
class _AddChannelSheetState extends State<AddChannelSheet> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _secretController = TextEditingController();
@@ -109,21 +106,46 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
final normalizedName = _nameController.text.trimLeft();
final isHashChannel = normalizedName.startsWith('#');
return AlertDialog(
title: Text(l10n.addChannel),
content: SingleChildScrollView(
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: [
// Info banner explaining channel types
Row(
children: [
Expanded(
child: Text(
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: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3),
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),
),
@@ -149,8 +171,6 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
),
),
const SizedBox(height: 16),
// Channel Name Field
TextFormField(
controller: _nameController,
decoration: InputDecoration(
@@ -168,14 +188,13 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
textInputAction: isHashChannel
? TextInputAction.done
: TextInputAction.next,
onChanged: (_) => setState(() {}), // Rebuild to update icon
onChanged: (_) => setState(() {}),
onFieldSubmitted: (_) {
if (isHashChannel) {
_handleCreate();
}
},
),
// Channel Secret Field (only show for private channels)
if (!isHashChannel) ...[
const SizedBox(height: 16),
TextFormField(
@@ -193,7 +212,6 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
onFieldSubmitted: (_) => _handleCreate(),
),
const SizedBox(height: 8),
// Help Text for private channels
Text(
l10n.channelSecretHelp,
style: theme.textTheme.bodySmall?.copyWith(
@@ -201,15 +219,15 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
),
),
],
// 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.5,
),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
@@ -232,29 +250,39 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
),
),
],
],
),
),
),
actions: [
// Cancel Button
TextButton(
onPressed: _isCreating ? null : () => Navigator.of(context).pop(),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _isCreating
? null
: () => Navigator.of(context).pop(),
child: Text(l10n.cancel),
),
// Create Button
FilledButton(
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: _isCreating ? null : _handleCreate,
child: _isCreating
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Text(l10n.createChannel),
),
),
],
),
],
),
),
),
),
);
}
}

View File

@@ -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,7 +129,9 @@ class ContactTile extends StatelessWidget {
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
);
final Widget subtitleWidget = Column(
final Widget subtitleWidget = compact
? _buildCompactSubtitle(context, distanceText)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
@@ -294,6 +298,34 @@ class ContactTile extends StatelessWidget {
);
}
Widget _buildCompactSubtitle(BuildContext context, String? distanceText) {
final location = contact.displayLocation;
final compactPills = <Widget>[
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<String?>(
final result = await showModalBottomSheet<String?>(
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.',
isScrollControlled: true,
showDragHandle: true,
builder: (dialogContext) => SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(dialogContext).viewInsets.bottom,
),
onSubmitted: (value) {
Navigator.of(dialogContext).pop(value);
},
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),
),
),
],
),
],
),
),
);
}
}

View File

@@ -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<void> pumpContactsTab(
WidgetTester tester, {
required List<Contact> channels,
List<Contact> contacts = const [],
List<SavedContactGroup> 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);
});
}

View File

@@ -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<void> pumpDialog(
Future<void> pumpSheet(
WidgetTester tester, {
required Future<void> 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;