mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
893 lines
30 KiB
Dart
893 lines
30 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../connector/meshcore_connector.dart';
|
|
import '../connector/meshcore_protocol.dart';
|
|
import '../models/contact.dart';
|
|
import '../models/contact_group.dart';
|
|
import '../storage/contact_group_store.dart';
|
|
import '../utils/contact_search.dart';
|
|
import '../utils/dialog_utils.dart';
|
|
import '../utils/disconnect_navigation_mixin.dart';
|
|
import '../utils/emoji_utils.dart';
|
|
import '../utils/route_transitions.dart';
|
|
import '../widgets/empty_state.dart';
|
|
import '../widgets/quick_switch_bar.dart';
|
|
import '../widgets/repeater_login_dialog.dart';
|
|
import '../widgets/unread_badge.dart';
|
|
import 'channels_screen.dart';
|
|
import 'chat_screen.dart';
|
|
import 'map_screen.dart';
|
|
import 'repeater_hub_screen.dart';
|
|
import 'settings_screen.dart';
|
|
|
|
enum ContactSortOption {
|
|
lastSeen,
|
|
recentMessages,
|
|
name,
|
|
type,
|
|
}
|
|
|
|
enum _ContactMenuAction {
|
|
sortRecentMessages,
|
|
sortName,
|
|
sortType,
|
|
toggleLastSeenFilter,
|
|
toggleUnreadOnly,
|
|
newGroup,
|
|
}
|
|
|
|
class ContactsScreen extends StatefulWidget {
|
|
final bool hideBackButton;
|
|
|
|
const ContactsScreen({
|
|
super.key,
|
|
this.hideBackButton = false,
|
|
});
|
|
|
|
@override
|
|
State<ContactsScreen> createState() => _ContactsScreenState();
|
|
}
|
|
|
|
class _ContactsScreenState extends State<ContactsScreen>
|
|
with DisconnectNavigationMixin {
|
|
final TextEditingController _searchController = TextEditingController();
|
|
String _searchQuery = '';
|
|
ContactSortOption _sortOption = ContactSortOption.lastSeen;
|
|
bool _forceLastSeenSort = true;
|
|
bool _showUnreadOnly = false;
|
|
final ContactGroupStore _groupStore = ContactGroupStore();
|
|
List<ContactGroup> _groups = [];
|
|
Timer? _searchDebounce;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadGroups();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchDebounce?.cancel();
|
|
_searchController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadGroups() async {
|
|
final groups = await _groupStore.loadGroups();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_groups = groups;
|
|
});
|
|
}
|
|
|
|
Future<void> _saveGroups() async {
|
|
await _groupStore.saveGroups(_groups);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final connector = context.watch<MeshCoreConnector>();
|
|
|
|
// Auto-navigate back to scanner if disconnected
|
|
if (!checkConnectionAndNavigate(connector)) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
final allowBack = !connector.isConnected;
|
|
final theme = Theme.of(context);
|
|
|
|
return PopScope(
|
|
canPop: allowBack,
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
titleSpacing: 16,
|
|
centerTitle: false,
|
|
automaticallyImplyLeading: !widget.hideBackButton && allowBack,
|
|
title: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text('Contacts'),
|
|
Text(
|
|
'${connector.contacts.length} contacts',
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: connector.isLoadingContacts
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.refresh),
|
|
tooltip: 'Refresh',
|
|
onPressed: connector.isLoadingContacts ? null : () => connector.getContacts(),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.bluetooth_disabled),
|
|
tooltip: 'Disconnect',
|
|
onPressed: () => _disconnect(context, connector),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.tune),
|
|
tooltip: 'Settings',
|
|
onPressed: () => Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
|
),
|
|
),
|
|
PopupMenuButton<_ContactMenuAction>(
|
|
tooltip: 'Contacts options',
|
|
onSelected: (action) {
|
|
switch (action) {
|
|
case _ContactMenuAction.sortRecentMessages:
|
|
setState(() {
|
|
_sortOption = ContactSortOption.recentMessages;
|
|
_forceLastSeenSort = false;
|
|
});
|
|
break;
|
|
case _ContactMenuAction.sortName:
|
|
setState(() {
|
|
_sortOption = ContactSortOption.name;
|
|
_forceLastSeenSort = false;
|
|
});
|
|
break;
|
|
case _ContactMenuAction.sortType:
|
|
setState(() {
|
|
_sortOption = ContactSortOption.type;
|
|
_forceLastSeenSort = false;
|
|
});
|
|
break;
|
|
case _ContactMenuAction.toggleLastSeenFilter:
|
|
setState(() {
|
|
_forceLastSeenSort = !_forceLastSeenSort;
|
|
if (_forceLastSeenSort) {
|
|
_sortOption = ContactSortOption.lastSeen;
|
|
}
|
|
});
|
|
break;
|
|
case _ContactMenuAction.toggleUnreadOnly:
|
|
setState(() {
|
|
_showUnreadOnly = !_showUnreadOnly;
|
|
});
|
|
break;
|
|
case _ContactMenuAction.newGroup:
|
|
_showGroupEditor(context, connector.contacts);
|
|
break;
|
|
}
|
|
},
|
|
itemBuilder: (context) {
|
|
final labelStyle = theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
fontWeight: FontWeight.w600,
|
|
);
|
|
return [
|
|
PopupMenuItem<_ContactMenuAction>(
|
|
enabled: false,
|
|
child: Text('Sort by', style: labelStyle),
|
|
),
|
|
CheckedPopupMenuItem<_ContactMenuAction>(
|
|
value: _ContactMenuAction.sortRecentMessages,
|
|
checked: _sortOption == ContactSortOption.recentMessages,
|
|
child: const Text('Recent messages'),
|
|
),
|
|
CheckedPopupMenuItem<_ContactMenuAction>(
|
|
value: _ContactMenuAction.sortName,
|
|
checked: _sortOption == ContactSortOption.name,
|
|
child: const Text('Name'),
|
|
),
|
|
CheckedPopupMenuItem<_ContactMenuAction>(
|
|
value: _ContactMenuAction.sortType,
|
|
checked: _sortOption == ContactSortOption.type,
|
|
child: const Text('Type'),
|
|
),
|
|
const PopupMenuDivider(),
|
|
PopupMenuItem<_ContactMenuAction>(
|
|
enabled: false,
|
|
child: Text('Filters', style: labelStyle),
|
|
),
|
|
CheckedPopupMenuItem<_ContactMenuAction>(
|
|
value: _ContactMenuAction.toggleLastSeenFilter,
|
|
checked: _forceLastSeenSort,
|
|
child: const Text('Last seen'),
|
|
),
|
|
CheckedPopupMenuItem<_ContactMenuAction>(
|
|
value: _ContactMenuAction.toggleUnreadOnly,
|
|
checked: _showUnreadOnly,
|
|
child: const Text('Unread only'),
|
|
),
|
|
PopupMenuItem<_ContactMenuAction>(
|
|
value: _ContactMenuAction.newGroup,
|
|
child: const Text('New group'),
|
|
),
|
|
];
|
|
},
|
|
),
|
|
],
|
|
),
|
|
body: _buildContactsBody(context, connector),
|
|
bottomNavigationBar: SafeArea(
|
|
top: false,
|
|
child: QuickSwitchBar(
|
|
selectedIndex: 0,
|
|
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _disconnect(
|
|
BuildContext context,
|
|
MeshCoreConnector connector,
|
|
) async {
|
|
await showDisconnectDialog(context, connector);
|
|
}
|
|
|
|
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
|
|
final contacts = connector.contacts;
|
|
|
|
if (contacts.isEmpty && connector.isLoadingContacts && _groups.isEmpty) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (contacts.isEmpty && _groups.isEmpty) {
|
|
return const EmptyState(
|
|
icon: Icons.people_outline,
|
|
title: 'No contacts yet',
|
|
subtitle: 'Contacts will appear when devices advertise',
|
|
);
|
|
}
|
|
|
|
final filteredAndSorted = _filterAndSortContacts(contacts, connector);
|
|
final filteredGroups =
|
|
_showUnreadOnly ? const <ContactGroup>[] : _filterAndSortGroups(_groups, contacts);
|
|
|
|
return Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: TextField(
|
|
controller: _searchController,
|
|
decoration: InputDecoration(
|
|
hintText: 'Search contacts...',
|
|
prefixIcon: const Icon(Icons.search),
|
|
suffixIcon: _searchQuery.isNotEmpty
|
|
? IconButton(
|
|
icon: const Icon(Icons.clear),
|
|
onPressed: () {
|
|
_searchController.clear();
|
|
setState(() {
|
|
_searchQuery = '';
|
|
});
|
|
},
|
|
)
|
|
: null,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
),
|
|
onChanged: (value) {
|
|
_searchDebounce?.cancel();
|
|
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_searchQuery = value.toLowerCase();
|
|
});
|
|
});
|
|
},
|
|
),
|
|
),
|
|
Expanded(
|
|
child: filteredAndSorted.isEmpty && filteredGroups.isEmpty
|
|
? Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
_showUnreadOnly
|
|
? 'No unread contacts'
|
|
: 'No contacts or groups found',
|
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: RefreshIndicator(
|
|
onRefresh: () => connector.getContacts(),
|
|
child: ListView.builder(
|
|
itemCount: filteredGroups.length + filteredAndSorted.length,
|
|
itemBuilder: (context, index) {
|
|
if (index < filteredGroups.length) {
|
|
final group = filteredGroups[index];
|
|
return _buildGroupTile(context, group, contacts);
|
|
}
|
|
final contact = filteredAndSorted[index - filteredGroups.length];
|
|
final unreadCount = connector.getUnreadCountForContact(contact);
|
|
return _ContactTile(
|
|
contact: contact,
|
|
lastSeen: _resolveLastSeen(contact),
|
|
unreadCount: unreadCount,
|
|
onTap: () => _openChat(context, contact),
|
|
onLongPress: () => _showContactOptions(context, connector, contact),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
List<ContactGroup> _filterAndSortGroups(List<ContactGroup> groups, List<Contact> contacts) {
|
|
final query = _searchQuery.trim().toLowerCase();
|
|
final contactsByKey = <String, Contact>{};
|
|
for (final contact in contacts) {
|
|
contactsByKey[contact.publicKeyHex] = contact;
|
|
}
|
|
|
|
final filtered = groups.where((group) {
|
|
if (query.isEmpty) return true;
|
|
if (group.name.toLowerCase().contains(query)) return true;
|
|
for (final key in group.memberKeys) {
|
|
final contact = contactsByKey[key];
|
|
if (contact != null && matchesContactQuery(contact, query)) return true;
|
|
}
|
|
return false;
|
|
}).toList();
|
|
|
|
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
|
return filtered;
|
|
}
|
|
|
|
List<Contact> _filterAndSortContacts(List<Contact> contacts, MeshCoreConnector connector) {
|
|
var filtered = contacts.where((contact) {
|
|
if (_searchQuery.isEmpty) return true;
|
|
return matchesContactQuery(contact, _searchQuery);
|
|
}).toList();
|
|
|
|
if (_showUnreadOnly) {
|
|
filtered = filtered.where((contact) {
|
|
return connector.getUnreadCountForContact(contact) > 0;
|
|
}).toList();
|
|
}
|
|
|
|
final sortOption = _forceLastSeenSort ? ContactSortOption.lastSeen : _sortOption;
|
|
switch (sortOption) {
|
|
case ContactSortOption.lastSeen:
|
|
filtered.sort((a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)));
|
|
break;
|
|
case ContactSortOption.recentMessages:
|
|
filtered.sort((a, b) {
|
|
final aMessages = connector.getMessages(a);
|
|
final bMessages = connector.getMessages(b);
|
|
final aLastMsg = aMessages.isEmpty ? DateTime(1970) : aMessages.last.timestamp;
|
|
final bLastMsg = bMessages.isEmpty ? DateTime(1970) : bMessages.last.timestamp;
|
|
return bLastMsg.compareTo(aLastMsg);
|
|
});
|
|
break;
|
|
case ContactSortOption.name:
|
|
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
|
break;
|
|
case ContactSortOption.type:
|
|
filtered.sort((a, b) {
|
|
final typeCompare = a.type.compareTo(b.type);
|
|
if (typeCompare != 0) return typeCompare;
|
|
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
|
});
|
|
break;
|
|
}
|
|
|
|
return filtered;
|
|
}
|
|
|
|
DateTime _resolveLastSeen(Contact contact) {
|
|
if (contact.type != advTypeChat) return contact.lastSeen;
|
|
return contact.lastMessageAt.isAfter(contact.lastSeen)
|
|
? contact.lastMessageAt
|
|
: contact.lastSeen;
|
|
}
|
|
|
|
Widget _buildGroupTile(BuildContext context, ContactGroup group, List<Contact> contacts) {
|
|
final memberContacts = _resolveGroupContacts(group, contacts);
|
|
final subtitle = _formatGroupMembers(memberContacts);
|
|
return ListTile(
|
|
leading: const CircleAvatar(
|
|
backgroundColor: Colors.teal,
|
|
child: Icon(Icons.group, color: Colors.white, size: 20),
|
|
),
|
|
title: Text(group.name),
|
|
subtitle: Text(subtitle),
|
|
trailing: Text(
|
|
memberContacts.length.toString(),
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
|
),
|
|
onTap: () => _showGroupOptions(context, group, contacts),
|
|
onLongPress: () => _showGroupOptions(context, group, contacts),
|
|
);
|
|
}
|
|
|
|
List<Contact> _resolveGroupContacts(ContactGroup group, List<Contact> contacts) {
|
|
final byKey = <String, Contact>{};
|
|
for (final contact in contacts) {
|
|
byKey[contact.publicKeyHex] = contact;
|
|
}
|
|
final resolved = <Contact>[];
|
|
for (final key in group.memberKeys) {
|
|
final contact = byKey[key];
|
|
if (contact != null) {
|
|
resolved.add(contact);
|
|
}
|
|
}
|
|
resolved.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
|
return resolved;
|
|
}
|
|
|
|
String _formatGroupMembers(List<Contact> members) {
|
|
if (members.isEmpty) return 'No members';
|
|
final names = members.map((c) => c.name).toList();
|
|
if (names.length <= 2) return names.join(', ');
|
|
return '${names.take(2).join(', ')} +${names.length - 2}';
|
|
}
|
|
|
|
void _openChat(BuildContext context, Contact contact) {
|
|
// Check if this is a repeater
|
|
if (contact.type == advTypeRepeater) {
|
|
_showRepeaterLogin(context, contact);
|
|
} else {
|
|
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _handleQuickSwitch(int index, BuildContext context) {
|
|
if (index == 0) return;
|
|
switch (index) {
|
|
case 1:
|
|
Navigator.pushReplacement(
|
|
context,
|
|
buildQuickSwitchRoute(
|
|
const ChannelsScreen(hideBackButton: true),
|
|
),
|
|
);
|
|
break;
|
|
case 2:
|
|
Navigator.pushReplacement(
|
|
context,
|
|
buildQuickSwitchRoute(
|
|
const MapScreen(hideBackButton: true),
|
|
),
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
void _showRepeaterLogin(BuildContext context, Contact repeater) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => RepeaterLoginDialog(
|
|
repeater: repeater,
|
|
onLogin: (password) {
|
|
// Navigate to repeater hub screen after successful login
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => RepeaterHubScreen(
|
|
repeater: repeater,
|
|
password: password,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showGroupOptions(BuildContext context, ContactGroup group, List<Contact> contacts) {
|
|
final members = _resolveGroupContacts(group, contacts);
|
|
showModalBottomSheet(
|
|
context: context,
|
|
builder: (sheetContext) => SafeArea(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.edit),
|
|
title: const Text('Edit Group'),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
_showGroupEditor(context, contacts, group: group);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.delete, color: Colors.red),
|
|
title: const Text('Delete Group', style: TextStyle(color: Colors.red)),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
_confirmDeleteGroup(context, group);
|
|
},
|
|
),
|
|
if (members.isNotEmpty) const Divider(),
|
|
...members.map((member) {
|
|
return ListTile(
|
|
leading: const Icon(Icons.person),
|
|
title: Text(member.name),
|
|
subtitle: Text(member.typeLabel),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
_openChat(context, member);
|
|
},
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _confirmDeleteGroup(BuildContext context, ContactGroup group) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: const Text('Delete Group'),
|
|
content: Text('Remove "${group.name}"?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
Navigator.pop(dialogContext);
|
|
setState(() {
|
|
_groups.removeWhere((g) => g.name == group.name);
|
|
});
|
|
await _saveGroups();
|
|
},
|
|
child: const Text('Delete', style: TextStyle(color: Colors.red)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showGroupEditor(
|
|
BuildContext context,
|
|
List<Contact> contacts, {
|
|
ContactGroup? group,
|
|
}) {
|
|
final isEditing = group != null;
|
|
final nameController = TextEditingController(text: group?.name ?? '');
|
|
final selectedKeys = <String>{...group?.memberKeys ?? []};
|
|
String filterQuery = '';
|
|
final sortedContacts = List<Contact>.from(contacts)
|
|
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (dialogContext) => StatefulBuilder(
|
|
builder: (builderContext, setDialogState) {
|
|
final filteredContacts = filterQuery.isEmpty
|
|
? sortedContacts
|
|
: sortedContacts
|
|
.where((contact) => matchesContactQuery(contact, filterQuery))
|
|
.toList();
|
|
return AlertDialog(
|
|
title: Text(isEditing ? 'Edit Group' : 'New Group'),
|
|
content: SizedBox(
|
|
width: double.maxFinite,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextField(
|
|
controller: nameController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Group name',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
decoration: const InputDecoration(
|
|
hintText: 'Filter contacts...',
|
|
prefixIcon: Icon(Icons.search),
|
|
border: OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
onChanged: (value) {
|
|
setDialogState(() {
|
|
filterQuery = value.toLowerCase();
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
SizedBox(
|
|
height: 240,
|
|
child: filteredContacts.isEmpty
|
|
? const Center(child: Text('No contacts match your filter'))
|
|
: ListView.builder(
|
|
itemCount: filteredContacts.length,
|
|
itemBuilder: (context, index) {
|
|
final contact = filteredContacts[index];
|
|
final isSelected = selectedKeys.contains(contact.publicKeyHex);
|
|
return CheckboxListTile(
|
|
value: isSelected,
|
|
title: Text(contact.name),
|
|
subtitle: Text(contact.typeLabel),
|
|
onChanged: (value) {
|
|
setDialogState(() {
|
|
if (value == true) {
|
|
selectedKeys.add(contact.publicKeyHex);
|
|
} else {
|
|
selectedKeys.remove(contact.publicKeyHex);
|
|
}
|
|
});
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
final name = nameController.text.trim();
|
|
if (name.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Group name is required')),
|
|
);
|
|
return;
|
|
}
|
|
final exists = _groups.any((g) {
|
|
if (isEditing && g.name == group!.name) return false;
|
|
return g.name.toLowerCase() == name.toLowerCase();
|
|
});
|
|
if (exists) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Group "$name" already exists')),
|
|
);
|
|
return;
|
|
}
|
|
setState(() {
|
|
if (isEditing) {
|
|
final index = _groups.indexWhere((g) => g.name == group!.name);
|
|
if (index != -1) {
|
|
_groups[index] = ContactGroup(
|
|
name: name,
|
|
memberKeys: selectedKeys.toList(),
|
|
);
|
|
}
|
|
} else {
|
|
_groups.add(ContactGroup(name: name, memberKeys: selectedKeys.toList()));
|
|
}
|
|
});
|
|
await _saveGroups();
|
|
if (dialogContext.mounted) {
|
|
Navigator.pop(dialogContext);
|
|
}
|
|
},
|
|
child: Text(isEditing ? 'Save' : 'Create'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showContactOptions(
|
|
BuildContext context,
|
|
MeshCoreConnector connector,
|
|
Contact contact,
|
|
) {
|
|
final isRepeater = contact.type == advTypeRepeater;
|
|
|
|
showModalBottomSheet(
|
|
context: context,
|
|
builder: (context) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (isRepeater)
|
|
ListTile(
|
|
leading: const Icon(Icons.cell_tower, color: Colors.orange),
|
|
title: const Text('Manage Repeater'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_showRepeaterLogin(context, contact);
|
|
},
|
|
)
|
|
else
|
|
ListTile(
|
|
leading: const Icon(Icons.chat),
|
|
title: const Text('Open Chat'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_openChat(context, contact);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.delete, color: Colors.red),
|
|
title: const Text('Delete Contact', style: TextStyle(color: Colors.red)),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_confirmDelete(context, connector, contact);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _confirmDelete(
|
|
BuildContext context,
|
|
MeshCoreConnector connector,
|
|
Contact contact,
|
|
) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Delete Contact'),
|
|
content: Text('Remove ${contact.name} from contacts?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
connector.removeContact(contact);
|
|
},
|
|
child: const Text('Delete', style: TextStyle(color: Colors.red)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ContactTile extends StatelessWidget {
|
|
final Contact contact;
|
|
final DateTime lastSeen;
|
|
final int unreadCount;
|
|
final VoidCallback onTap;
|
|
final VoidCallback onLongPress;
|
|
|
|
const _ContactTile({
|
|
required this.contact,
|
|
required this.lastSeen,
|
|
required this.unreadCount,
|
|
required this.onTap,
|
|
required this.onLongPress,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListTile(
|
|
leading: CircleAvatar(
|
|
backgroundColor: _getTypeColor(contact.type),
|
|
child: _buildContactAvatar(contact),
|
|
),
|
|
title: Text(contact.name),
|
|
subtitle: Text('${contact.typeLabel} • ${contact.pathLabel}'),
|
|
trailing: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
if (unreadCount > 0) ...[
|
|
UnreadBadge(count: unreadCount),
|
|
const SizedBox(height: 4),
|
|
],
|
|
Text(
|
|
_formatLastSeen(lastSeen),
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
|
),
|
|
if (contact.hasLocation)
|
|
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
|
|
],
|
|
),
|
|
onTap: onTap,
|
|
onLongPress: onLongPress,
|
|
);
|
|
}
|
|
|
|
Widget _buildContactAvatar(Contact contact) {
|
|
final emoji = firstEmoji(contact.name);
|
|
if (emoji != null) {
|
|
return Text(
|
|
emoji,
|
|
style: const TextStyle(fontSize: 18),
|
|
);
|
|
}
|
|
return Icon(_getTypeIcon(contact.type), color: Colors.white, size: 20);
|
|
}
|
|
|
|
IconData _getTypeIcon(int type) {
|
|
switch (type) {
|
|
case advTypeChat:
|
|
return Icons.chat;
|
|
case advTypeRepeater:
|
|
return Icons.cell_tower;
|
|
case advTypeRoom:
|
|
return Icons.group;
|
|
case advTypeSensor:
|
|
return Icons.sensors;
|
|
default:
|
|
return Icons.device_unknown;
|
|
}
|
|
}
|
|
|
|
Color _getTypeColor(int type) {
|
|
switch (type) {
|
|
case advTypeChat:
|
|
return Colors.blue;
|
|
case advTypeRepeater:
|
|
return Colors.orange;
|
|
case advTypeRoom:
|
|
return Colors.purple;
|
|
case advTypeSensor:
|
|
return Colors.green;
|
|
default:
|
|
return Colors.grey;
|
|
}
|
|
}
|
|
|
|
String _formatLastSeen(DateTime lastSeen) {
|
|
final now = DateTime.now();
|
|
final diff = now.difference(lastSeen);
|
|
|
|
if (diff.isNegative || diff.inMinutes < 5) return 'Last seen now';
|
|
if (diff.inMinutes < 60) return 'Last seen ${diff.inMinutes} mins ago';
|
|
if (diff.inHours < 24) {
|
|
final hours = diff.inHours;
|
|
return hours == 1 ? 'Last seen 1 hour ago' : 'Last seen $hours hours ago';
|
|
}
|
|
final days = diff.inDays;
|
|
return days == 1 ? 'Last seen 1 day ago' : 'Last seen $days days ago';
|
|
}
|
|
}
|