mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
fix: Align contact sorting labels #0
This commit is contained in:
@@ -57,9 +57,7 @@ class Channel {
|
|||||||
} else {
|
} else {
|
||||||
// Normal channel: require explicit secret
|
// Normal channel: require explicit secret
|
||||||
if (explicitSecret == null || explicitSecret.length != 16) {
|
if (explicitSecret == null || explicitSecret.length != 16) {
|
||||||
throw ArgumentError(
|
throw ArgumentError('Normal channels require a 16-byte secret');
|
||||||
'Normal channels require a 16-byte secret',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return Channel(
|
return Channel(
|
||||||
index: index,
|
index: index,
|
||||||
@@ -78,6 +76,18 @@ class Channel {
|
|||||||
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool isHashChannelName(String channelName) {
|
||||||
|
return channelName.trim().startsWith('#');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String pskBase64ForHashChannelName(String channelName) {
|
||||||
|
final normalized = channelName.trim();
|
||||||
|
if (!isHashChannelName(normalized)) {
|
||||||
|
throw ArgumentError('Only #channels can export derived psk_base64');
|
||||||
|
}
|
||||||
|
return base64.encode(_generateHashChannelSecret(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
/// Create the default public channel (channel 0)
|
/// Create the default public channel (channel 0)
|
||||||
/// Uses the well-known pre-shared key from MeshCore
|
/// Uses the well-known pre-shared key from MeshCore
|
||||||
factory Channel.publicChannel() {
|
factory Channel.publicChannel() {
|
||||||
@@ -85,8 +95,22 @@ class Channel {
|
|||||||
index: 0,
|
index: 0,
|
||||||
name: 'Public Channel',
|
name: 'Public Channel',
|
||||||
secret: Uint8List.fromList([
|
secret: Uint8List.fromList([
|
||||||
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
|
0x8b,
|
||||||
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72,
|
0x33,
|
||||||
|
0x87,
|
||||||
|
0xe9,
|
||||||
|
0xc5,
|
||||||
|
0xcd,
|
||||||
|
0xea,
|
||||||
|
0x6a,
|
||||||
|
0xc9,
|
||||||
|
0xe5,
|
||||||
|
0xed,
|
||||||
|
0xba,
|
||||||
|
0xa1,
|
||||||
|
0x15,
|
||||||
|
0xcd,
|
||||||
|
0x72,
|
||||||
]),
|
]),
|
||||||
flags: null,
|
flags: null,
|
||||||
);
|
);
|
||||||
@@ -95,6 +119,9 @@ class Channel {
|
|||||||
/// Check if this is a hash-based channel (name starts with '#')
|
/// Check if this is a hash-based channel (name starts with '#')
|
||||||
bool get isHashChannel => name.startsWith('#');
|
bool get isHashChannel => name.startsWith('#');
|
||||||
|
|
||||||
|
/// Base64-encoded PSK for sharing with firmware CLI and related tooling.
|
||||||
|
String get pskBase64 => base64.encode(secret);
|
||||||
|
|
||||||
/// Display name for the channel
|
/// Display name for the channel
|
||||||
/// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N"
|
/// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N"
|
||||||
String get displayName {
|
String get displayName {
|
||||||
@@ -131,12 +158,7 @@ class Channel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a copy with modified fields
|
/// Create a copy with modified fields
|
||||||
Channel copyWith({
|
Channel copyWith({int? index, String? name, Uint8List? secret, int? flags}) {
|
||||||
int? index,
|
|
||||||
String? name,
|
|
||||||
Uint8List? secret,
|
|
||||||
int? flags,
|
|
||||||
}) {
|
|
||||||
return Channel(
|
return Channel(
|
||||||
index: index ?? this.index,
|
index: index ?? this.index,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
|
|||||||
@@ -107,6 +107,15 @@ class _PendingRepeaterOwnerRequest {
|
|||||||
const _PendingRepeaterOwnerRequest({required this.publicKey});
|
const _PendingRepeaterOwnerRequest({required this.publicKey});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ContactsTabSection {
|
||||||
|
favourites,
|
||||||
|
teamMembers,
|
||||||
|
repeaters,
|
||||||
|
sensors,
|
||||||
|
rooms,
|
||||||
|
channels,
|
||||||
|
}
|
||||||
|
|
||||||
/// Main App Provider - coordinates all other providers
|
/// Main App Provider - coordinates all other providers
|
||||||
class AppProvider with ChangeNotifier {
|
class AppProvider with ChangeNotifier {
|
||||||
static const int _maxDirectPayloadHops = 3;
|
static const int _maxDirectPayloadHops = 3;
|
||||||
@@ -162,6 +171,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
bool get isContactsEnabled => _isContactsEnabled;
|
bool get isContactsEnabled => _isContactsEnabled;
|
||||||
bool _isSensorsEnabled = true;
|
bool _isSensorsEnabled = true;
|
||||||
bool get isSensorsEnabled => _isSensorsEnabled;
|
bool get isSensorsEnabled => _isSensorsEnabled;
|
||||||
|
final Map<ContactsTabSection, bool> _contactsSectionVisibility = {
|
||||||
|
for (final section in ContactsTabSection.values) section: true,
|
||||||
|
};
|
||||||
|
|
||||||
bool _isVoiceSilenceTrimmingEnabled = true;
|
bool _isVoiceSilenceTrimmingEnabled = true;
|
||||||
bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled;
|
bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled;
|
||||||
@@ -238,6 +250,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
_initializeLocationTracking();
|
_initializeLocationTracking();
|
||||||
_loadMapEnabled();
|
_loadMapEnabled();
|
||||||
_loadContactsEnabled();
|
_loadContactsEnabled();
|
||||||
|
_loadContactsSectionVisibility();
|
||||||
_loadSensorsEnabled();
|
_loadSensorsEnabled();
|
||||||
_loadVoiceSilenceTrimmingEnabled();
|
_loadVoiceSilenceTrimmingEnabled();
|
||||||
_loadVoiceBandPassFilterEnabled();
|
_loadVoiceBandPassFilterEnabled();
|
||||||
@@ -259,6 +272,27 @@ class AppProvider with ChangeNotifier {
|
|||||||
return ProfileStorageScope.scopedKey(baseKey);
|
return ProfileStorageScope.scopedKey(baseKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool isContactsSectionEnabled(ContactsTabSection section) {
|
||||||
|
return _contactsSectionVisibility[section] ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _contactsSectionVisibilityKey(ContactsTabSection section) {
|
||||||
|
switch (section) {
|
||||||
|
case ContactsTabSection.favourites:
|
||||||
|
return 'contacts_section_favourites_enabled';
|
||||||
|
case ContactsTabSection.teamMembers:
|
||||||
|
return 'contacts_section_team_members_enabled';
|
||||||
|
case ContactsTabSection.repeaters:
|
||||||
|
return 'contacts_section_repeaters_enabled';
|
||||||
|
case ContactsTabSection.sensors:
|
||||||
|
return 'contacts_section_sensors_enabled';
|
||||||
|
case ContactsTabSection.rooms:
|
||||||
|
return 'contacts_section_rooms_enabled';
|
||||||
|
case ContactsTabSection.channels:
|
||||||
|
return 'contacts_section_channels_enabled';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _startPacketCapturePersistence() {
|
void _startPacketCapturePersistence() {
|
||||||
_packetCaptureFlushTimer?.cancel();
|
_packetCaptureFlushTimer?.cancel();
|
||||||
_packetCaptureFlushTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
_packetCaptureFlushTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
||||||
@@ -565,6 +599,37 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadContactsSectionVisibility() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
for (final section in ContactsTabSection.values) {
|
||||||
|
_contactsSectionVisibility[section] =
|
||||||
|
prefs.getBool(_scopedKey(_contactsSectionVisibilityKey(section))) ??
|
||||||
|
true;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error loading contacts section visibility settings: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setContactsSectionEnabled(
|
||||||
|
ContactsTabSection section,
|
||||||
|
bool enabled,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
_contactsSectionVisibility[section] = enabled;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(
|
||||||
|
_scopedKey(_contactsSectionVisibilityKey(section)),
|
||||||
|
enabled,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving contacts section visibility setting: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load sensors enabled setting from shared preferences
|
/// Load sensors enabled setting from shared preferences
|
||||||
Future<void> _loadSensorsEnabled() async {
|
Future<void> _loadSensorsEnabled() async {
|
||||||
try {
|
try {
|
||||||
@@ -933,9 +998,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
// we also import into firmware so subsequent getContact calls work.
|
// we also import into firmware so subsequent getContact calls work.
|
||||||
// Matches the official app which calls cmdGetAdvertPath for all adverts.
|
// Matches the official app which calls cmdGetAdvertPath for all adverts.
|
||||||
if (source == ContactReceiveSource.advert) {
|
if (source == ContactReceiveSource.advert) {
|
||||||
unawaited(
|
unawaited(connectionProvider.importReceivedAdvert(contact.publicKey));
|
||||||
connectionProvider.importReceivedAdvert(contact.publicKey),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
final isNewPendingAdvert = contactsProvider
|
final isNewPendingAdvert = contactsProvider
|
||||||
.addOrUpdatePendingAdvertContact(
|
.addOrUpdatePendingAdvertContact(
|
||||||
@@ -1352,9 +1415,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
if (_handlePendingRepeaterOwnerResponse(tag, responseData)) {
|
if (_handlePendingRepeaterOwnerResponse(tag, responseData)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
debugPrint(
|
debugPrint('📊 [AppProvider] Binary response (0x8C tag=$tag) received');
|
||||||
'📊 [AppProvider] Binary response (0x8C tag=$tag) received',
|
|
||||||
);
|
|
||||||
// Binary responses carry Cayenne LPP telemetry data.
|
// Binary responses carry Cayenne LPP telemetry data.
|
||||||
// The data starts with a channel byte — valid LPP always has at least
|
// The data starts with a channel byte — valid LPP always has at least
|
||||||
// 3 bytes (channel + type + value). Skip clearly non-telemetry payloads.
|
// 3 bytes (channel + type + value). Skip clearly non-telemetry payloads.
|
||||||
@@ -2159,9 +2220,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
// already added to pending adverts and showed notification.
|
// already added to pending adverts and showed notification.
|
||||||
// Nothing else to do — the callback pipeline handles everything.
|
// Nothing else to do — the callback pipeline handles everything.
|
||||||
if (wasKnown) {
|
if (wasKnown) {
|
||||||
debugPrint(
|
debugPrint(' [pushAdvert] Existing contact refreshed');
|
||||||
' [pushAdvert] Existing contact refreshed',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2721,6 +2780,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
await Future.wait([
|
await Future.wait([
|
||||||
_loadMapEnabled(),
|
_loadMapEnabled(),
|
||||||
_loadContactsEnabled(),
|
_loadContactsEnabled(),
|
||||||
|
_loadContactsSectionVisibility(),
|
||||||
_loadSensorsEnabled(),
|
_loadSensorsEnabled(),
|
||||||
_loadVoiceSilenceTrimmingEnabled(),
|
_loadVoiceSilenceTrimmingEnabled(),
|
||||||
_loadVoiceBandPassFilterEnabled(),
|
_loadVoiceBandPassFilterEnabled(),
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../models/channel.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/contact_group.dart';
|
import '../models/contact_group.dart';
|
||||||
import '../providers/contacts_provider.dart';
|
import '../providers/contacts_provider.dart';
|
||||||
@@ -48,6 +50,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
ContactSection.repeaters: ContactSortMode.lastSeen,
|
ContactSection.repeaters: ContactSortMode.lastSeen,
|
||||||
ContactSection.sensors: ContactSortMode.lastSeen,
|
ContactSection.sensors: ContactSortMode.lastSeen,
|
||||||
ContactSection.rooms: ContactSortMode.lastSeen,
|
ContactSection.rooms: ContactSortMode.lastSeen,
|
||||||
|
ContactSection.channels: ContactSortMode.alphabetical,
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -296,17 +299,19 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
|
|
||||||
List<Contact> _sortContacts(List<Contact> contacts, ContactSection section) {
|
List<Contact> _sortContacts(List<Contact> contacts, ContactSection section) {
|
||||||
final sorted = List<Contact>.from(contacts);
|
final sorted = List<Contact>.from(contacts);
|
||||||
if (section == ContactSection.channels) {
|
final sortMode =
|
||||||
sorted.sort(
|
_sortModes[section] ??
|
||||||
(a, b) =>
|
(section == ContactSection.channels
|
||||||
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
|
? ContactSortMode.alphabetical
|
||||||
);
|
: ContactSortMode.lastSeen);
|
||||||
return sorted;
|
|
||||||
}
|
|
||||||
|
|
||||||
final sortMode = _sortModes[section] ?? ContactSortMode.lastSeen;
|
|
||||||
|
|
||||||
sorted.sort((a, b) {
|
sorted.sort((a, b) {
|
||||||
|
if (sortMode == ContactSortMode.alphabetical) {
|
||||||
|
return a.displayName.toLowerCase().compareTo(
|
||||||
|
b.displayName.toLowerCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (sortMode == ContactSortMode.distance) {
|
if (sortMode == ContactSortMode.distance) {
|
||||||
final distanceA = _distanceFromCurrentPosition(a);
|
final distanceA = _distanceFromCurrentPosition(a);
|
||||||
final distanceB = _distanceFromCurrentPosition(b);
|
final distanceB = _distanceFromCurrentPosition(b);
|
||||||
@@ -321,7 +326,12 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return b.lastSeenTime.compareTo(a.lastSeenTime);
|
final lastSeenCompare = b.lastSeenTime.compareTo(a.lastSeenTime);
|
||||||
|
if (lastSeenCompare != 0) {
|
||||||
|
return lastSeenCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase());
|
||||||
});
|
});
|
||||||
|
|
||||||
return sorted;
|
return sorted;
|
||||||
@@ -480,8 +490,35 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
widget.onNavigateToMap?.call();
|
widget.onNavigateToMap?.call();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _exportHashChannelPskBase64(
|
||||||
|
BuildContext context,
|
||||||
|
Contact channel,
|
||||||
|
) async {
|
||||||
|
final channelName = channel.advName.trim();
|
||||||
|
if (!Channel.isHashChannelName(channelName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final copiedMessage = AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.copiedToClipboard('psk_base64');
|
||||||
|
|
||||||
|
await Clipboard.setData(
|
||||||
|
ClipboardData(text: Channel.pskBase64ForHashChannelName(channelName)),
|
||||||
|
);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(copiedMessage)));
|
||||||
|
}
|
||||||
|
|
||||||
void _showChannelActionSheet(BuildContext context, Contact channel) {
|
void _showChannelActionSheet(BuildContext context, Contact channel) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final canExportHashChannelPsk = Channel.isHashChannelName(
|
||||||
|
channel.advName.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -509,6 +546,15 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
_showChannelOnMap(context, channel);
|
_showChannelOnMap(context, channel);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (canExportHashChannelPsk)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.key_outlined),
|
||||||
|
title: Text('${l10n.exportToClipboard} psk_base64'),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
await _exportHashChannelPskBase64(context, channel);
|
||||||
|
},
|
||||||
|
),
|
||||||
if (!channel.isPublicChannel)
|
if (!channel.isPublicChannel)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.delete, color: Colors.red),
|
leading: Icon(Icons.delete, color: Colors.red),
|
||||||
@@ -532,6 +578,60 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Color _sectionAccentColor(BuildContext context, ContactSection section) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
switch (section) {
|
||||||
|
case ContactSection.teamMembers:
|
||||||
|
return colorScheme.primary;
|
||||||
|
case ContactSection.repeaters:
|
||||||
|
return colorScheme.tertiary;
|
||||||
|
case ContactSection.sensors:
|
||||||
|
return colorScheme.secondary;
|
||||||
|
case ContactSection.rooms:
|
||||||
|
return colorScheme.primary;
|
||||||
|
case ContactSection.channels:
|
||||||
|
return Color.alphaBlend(
|
||||||
|
colorScheme.tertiary.withValues(alpha: 0.65),
|
||||||
|
colorScheme.primary.withValues(alpha: 0.35),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _sortModeIcon(ContactSortMode mode) {
|
||||||
|
switch (mode) {
|
||||||
|
case ContactSortMode.lastSeen:
|
||||||
|
return Icons.schedule_rounded;
|
||||||
|
case ContactSortMode.distance:
|
||||||
|
return Icons.near_me_rounded;
|
||||||
|
case ContactSortMode.alphabetical:
|
||||||
|
return Icons.sort_by_alpha_rounded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _sortModeLabel(AppLocalizations l10n, ContactSortMode mode) {
|
||||||
|
switch (mode) {
|
||||||
|
case ContactSortMode.lastSeen:
|
||||||
|
return l10n.lastSeen;
|
||||||
|
case ContactSortMode.distance:
|
||||||
|
return l10n.distance;
|
||||||
|
case ContactSortMode.alphabetical:
|
||||||
|
return 'A-Z';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ContactSortMode> _availableSortModes(ContactSection section) {
|
||||||
|
switch (section) {
|
||||||
|
case ContactSection.channels:
|
||||||
|
return const [ContactSortMode.alphabetical, ContactSortMode.lastSeen];
|
||||||
|
default:
|
||||||
|
return const [
|
||||||
|
ContactSortMode.lastSeen,
|
||||||
|
ContactSortMode.distance,
|
||||||
|
ContactSortMode.alphabetical,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
@@ -539,7 +639,10 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Consumer<ContactsProvider>(
|
body: Consumer<ContactsProvider>(
|
||||||
builder: (context, contactsProvider, child) {
|
builder: (context, contactsProvider, child) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final appProvider = context.watch<AppProvider>();
|
||||||
final messagesProvider = context.watch<MessagesProvider>();
|
final messagesProvider = context.watch<MessagesProvider>();
|
||||||
|
final connectionProvider = context.watch<ConnectionProvider>();
|
||||||
final allChatContacts = _sortContacts(
|
final allChatContacts = _sortContacts(
|
||||||
contactsProvider.chatContacts,
|
contactsProvider.chatContacts,
|
||||||
ContactSection.teamMembers,
|
ContactSection.teamMembers,
|
||||||
@@ -632,20 +735,49 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
_showSavedGroupsForSection(ContactSection.channels)
|
_showSavedGroupsForSection(ContactSection.channels)
|
||||||
? savedChannelGroups
|
? savedChannelGroups
|
||||||
: const <_RenderedSavedGroup>[];
|
: const <_RenderedSavedGroup>[];
|
||||||
final showTeamMembersSection = allChatContacts.isNotEmpty;
|
final showFavouritesSection =
|
||||||
final showRepeatersSection = allRepeaters.isNotEmpty;
|
appProvider.isContactsSectionEnabled(
|
||||||
final showSensorsSection = allSensors.isNotEmpty;
|
ContactsTabSection.favourites,
|
||||||
final showRoomsSection = allRooms.isNotEmpty;
|
) &&
|
||||||
final showChannelsSection = allChannels.isNotEmpty;
|
contactsProvider.favouriteContacts.isNotEmpty;
|
||||||
// Check if there are any displayable contacts
|
final showTeamMembersSection =
|
||||||
final hasDisplayableContacts =
|
appProvider.isContactsSectionEnabled(
|
||||||
|
ContactsTabSection.teamMembers,
|
||||||
|
) &&
|
||||||
|
allChatContacts.isNotEmpty;
|
||||||
|
final showRepeatersSection =
|
||||||
|
appProvider.isContactsSectionEnabled(
|
||||||
|
ContactsTabSection.repeaters,
|
||||||
|
) &&
|
||||||
|
allRepeaters.isNotEmpty;
|
||||||
|
final showSensorsSection =
|
||||||
|
appProvider.isContactsSectionEnabled(
|
||||||
|
ContactsTabSection.sensors,
|
||||||
|
) &&
|
||||||
|
allSensors.isNotEmpty;
|
||||||
|
final showRoomsSection =
|
||||||
|
appProvider.isContactsSectionEnabled(ContactsTabSection.rooms) &&
|
||||||
|
allRooms.isNotEmpty;
|
||||||
|
final showChannelsSection =
|
||||||
|
appProvider.isContactsSectionEnabled(
|
||||||
|
ContactsTabSection.channels,
|
||||||
|
) &&
|
||||||
|
allChannels.isNotEmpty;
|
||||||
|
final hasAnyContactData =
|
||||||
allChatContacts.isNotEmpty ||
|
allChatContacts.isNotEmpty ||
|
||||||
allRepeaters.isNotEmpty ||
|
allRepeaters.isNotEmpty ||
|
||||||
allSensors.isNotEmpty ||
|
allSensors.isNotEmpty ||
|
||||||
allRooms.isNotEmpty ||
|
allRooms.isNotEmpty ||
|
||||||
allChannels.isNotEmpty;
|
allChannels.isNotEmpty;
|
||||||
|
final hasAnyVisibleSection =
|
||||||
|
showFavouritesSection ||
|
||||||
|
showTeamMembersSection ||
|
||||||
|
showRepeatersSection ||
|
||||||
|
showSensorsSection ||
|
||||||
|
showRoomsSection ||
|
||||||
|
showChannelsSection;
|
||||||
|
|
||||||
if (!hasDisplayableContacts) {
|
if (!hasAnyContactData) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -666,10 +798,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
if (context
|
if (connectionProvider.deviceInfo.isConnected)
|
||||||
.watch<ConnectionProvider>()
|
|
||||||
.deviceInfo
|
|
||||||
.isConnected)
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 16),
|
padding: const EdgeInsets.only(top: 16),
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
@@ -683,31 +812,74 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasAnyVisibleSection) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.tune_rounded,
|
||||||
|
size: 56,
|
||||||
|
color: Theme.of(context).disabledColor,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'All contacts sections are hidden',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Enable one or more sections in Settings to show contacts here.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: _handleRefresh,
|
onRefresh: _handleRefresh,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
|
||||||
|
children: [
|
||||||
|
if (showFavouritesSection)
|
||||||
|
_SectionCard(
|
||||||
|
accentColor: Colors.amber,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Favourites (contacts with firmware favourite flag set)
|
|
||||||
if (contactsProvider.favouriteContacts.isNotEmpty) ...[
|
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
title: l10n.favourites,
|
title: l10n.favourites,
|
||||||
count: contactsProvider.favouriteContacts.length,
|
count: contactsProvider.favouriteContacts.length,
|
||||||
icon: Icons.star,
|
icon: Icons.star_rounded,
|
||||||
|
accentColor: Colors.amber,
|
||||||
),
|
),
|
||||||
..._buildContactSectionItems(
|
..._buildContactSectionItems(
|
||||||
contactsProvider.favouriteContacts,
|
contactsProvider.favouriteContacts,
|
||||||
),
|
),
|
||||||
const Divider(height: 32),
|
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Team Members (Chat contacts)
|
if (showTeamMembersSection)
|
||||||
if (showTeamMembersSection) ...[
|
_SectionCard(
|
||||||
|
accentColor: _sectionAccentColor(
|
||||||
|
context,
|
||||||
|
ContactSection.teamMembers,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
title: l10n.teamMembers,
|
title: l10n.teamMembers,
|
||||||
count: chatContacts.length,
|
count: chatContacts.length,
|
||||||
icon: Icons.people,
|
icon: Icons.people_alt_rounded,
|
||||||
trailing: _buildSortMenu(
|
accentColor: _sectionAccentColor(
|
||||||
context,
|
context,
|
||||||
ContactSection.teamMembers,
|
ContactSection.teamMembers,
|
||||||
),
|
),
|
||||||
@@ -741,38 +913,58 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
visibleSavedTeamGroups,
|
visibleSavedTeamGroups,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 32),
|
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Repeaters
|
if (showRepeatersSection)
|
||||||
if (showRepeatersSection) ...[
|
_SectionCard(
|
||||||
|
accentColor: _sectionAccentColor(
|
||||||
|
context,
|
||||||
|
ContactSection.repeaters,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
title: l10n.repeaters,
|
title: l10n.repeaters,
|
||||||
count: repeaters.length,
|
count: repeaters.length,
|
||||||
icon: Icons.router,
|
icon: Icons.router_rounded,
|
||||||
|
accentColor: _sectionAccentColor(
|
||||||
|
context,
|
||||||
|
ContactSection.repeaters,
|
||||||
|
),
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (context
|
if (connectionProvider.deviceInfo.isConnected)
|
||||||
.watch<ConnectionProvider>()
|
|
||||||
.deviceInfo
|
|
||||||
.isConnected)
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.radar, size: 20),
|
icon: const Icon(Icons.radar, size: 20),
|
||||||
tooltip: 'Discover repeaters',
|
tooltip: 'Discover repeaters',
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
foregroundColor: _sectionAccentColor(
|
||||||
|
context,
|
||||||
|
ContactSection.repeaters,
|
||||||
|
),
|
||||||
|
backgroundColor: _sectionAccentColor(
|
||||||
|
context,
|
||||||
|
ContactSection.repeaters,
|
||||||
|
).withValues(alpha: 0.10),
|
||||||
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context
|
context
|
||||||
.read<ConnectionProvider>()
|
.read<ConnectionProvider>()
|
||||||
.discoverNodeType(advertType: 2);
|
.discoverNodeType(advertType: 2);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(l10n.repeaterDiscoverySent),
|
content: Text(
|
||||||
|
l10n.repeaterDiscoverySent,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_buildSortMenu(context, ContactSection.repeaters),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -816,26 +1008,45 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
ungroupedRepeaters,
|
ungroupedRepeaters,
|
||||||
compact: true,
|
compact: true,
|
||||||
),
|
),
|
||||||
const Divider(height: 32),
|
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Sensors
|
if (showSensorsSection)
|
||||||
if (showSensorsSection) ...[
|
_SectionCard(
|
||||||
|
accentColor: _sectionAccentColor(
|
||||||
|
context,
|
||||||
|
ContactSection.sensors,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
title: l10n.sensors,
|
title: l10n.sensors,
|
||||||
count: sensors.length,
|
count: sensors.length,
|
||||||
icon: Icons.sensors,
|
icon: Icons.sensors_rounded,
|
||||||
|
accentColor: _sectionAccentColor(
|
||||||
|
context,
|
||||||
|
ContactSection.sensors,
|
||||||
|
),
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (context
|
if (connectionProvider.deviceInfo.isConnected)
|
||||||
.watch<ConnectionProvider>()
|
|
||||||
.deviceInfo
|
|
||||||
.isConnected)
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.radar, size: 20),
|
icon: const Icon(Icons.radar, size: 20),
|
||||||
tooltip: 'Discover sensors',
|
tooltip: 'Discover sensors',
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
foregroundColor: _sectionAccentColor(
|
||||||
|
context,
|
||||||
|
ContactSection.sensors,
|
||||||
|
),
|
||||||
|
backgroundColor: _sectionAccentColor(
|
||||||
|
context,
|
||||||
|
ContactSection.sensors,
|
||||||
|
).withValues(alpha: 0.10),
|
||||||
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context
|
context
|
||||||
.read<ConnectionProvider>()
|
.read<ConnectionProvider>()
|
||||||
@@ -847,7 +1058,6 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_buildSortMenu(context, ContactSection.sensors),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -870,16 +1080,27 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
visibleSavedSensorGroups,
|
visibleSavedSensorGroups,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 32),
|
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Rooms
|
if (showRoomsSection)
|
||||||
if (showRoomsSection) ...[
|
_SectionCard(
|
||||||
|
accentColor: _sectionAccentColor(
|
||||||
|
context,
|
||||||
|
ContactSection.rooms,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
title: l10n.rooms,
|
title: l10n.rooms,
|
||||||
count: rooms.length,
|
count: rooms.length,
|
||||||
icon: Icons.tag,
|
icon: Icons.meeting_room_outlined,
|
||||||
trailing: _buildSortMenu(context, ContactSection.rooms),
|
accentColor: _sectionAccentColor(
|
||||||
|
context,
|
||||||
|
ContactSection.rooms,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_buildSectionFilterField(
|
_buildSectionFilterField(
|
||||||
context,
|
context,
|
||||||
@@ -895,17 +1116,32 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
_buildNoFilterResults(context)
|
_buildNoFilterResults(context)
|
||||||
else
|
else
|
||||||
..._buildContactSectionItems(
|
..._buildContactSectionItems(
|
||||||
_excludeGroupedContacts(rooms, visibleSavedRoomGroups),
|
_excludeGroupedContacts(
|
||||||
|
rooms,
|
||||||
|
visibleSavedRoomGroups,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 32),
|
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Channels (visible in both simple and advanced mode)
|
if (showChannelsSection)
|
||||||
if (showChannelsSection) ...[
|
_SectionCard(
|
||||||
|
accentColor: _sectionAccentColor(
|
||||||
|
context,
|
||||||
|
ContactSection.channels,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
title: l10n.channels,
|
title: l10n.channels,
|
||||||
count: filteredChannels.length,
|
count: filteredChannels.length,
|
||||||
icon: Icons.broadcast_on_personal,
|
icon: Icons.broadcast_on_personal_rounded,
|
||||||
|
accentColor: _sectionAccentColor(
|
||||||
|
context,
|
||||||
|
ContactSection.channels,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_buildSectionFilterField(
|
_buildSectionFilterField(
|
||||||
context,
|
context,
|
||||||
@@ -919,7 +1155,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
if (filteredChannels.isEmpty &&
|
if (filteredChannels.isEmpty &&
|
||||||
_sectionHasActiveFilter(ContactSection.channels))
|
_sectionHasActiveFilter(ContactSection.channels))
|
||||||
_buildNoFilterResults(context)
|
_buildNoFilterResults(context)
|
||||||
else ...[
|
else
|
||||||
..._excludeGroupedContacts(
|
..._excludeGroupedContacts(
|
||||||
filteredChannels,
|
filteredChannels,
|
||||||
visibleSavedChannelGroups,
|
visibleSavedChannelGroups,
|
||||||
@@ -928,44 +1164,53 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
channel: channel,
|
channel: channel,
|
||||||
messagesProvider: messagesProvider,
|
messagesProvider: messagesProvider,
|
||||||
contactsProvider: contactsProvider,
|
contactsProvider: contactsProvider,
|
||||||
onTap: () => _showChannelActionSheet(context, channel),
|
onTap: () =>
|
||||||
|
_showChannelActionSheet(context, channel),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Add Channel Button (visible in both simple and advanced mode, only show when connected)
|
if (connectionProvider.deviceInfo.isConnected)
|
||||||
if (context.watch<ConnectionProvider>().deviceInfo.isConnected)
|
Container(
|
||||||
Padding(
|
padding: const EdgeInsets.all(12),
|
||||||
padding: const EdgeInsets.symmetric(
|
decoration: BoxDecoration(
|
||||||
horizontal: 16,
|
color: colorScheme.surfaceContainerLow,
|
||||||
vertical: 8,
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(
|
||||||
|
alpha: 0.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: FilledButton.tonalIcon(
|
||||||
onPressed: () => _openAddContactScreen(context),
|
onPressed: () => _openAddContactScreen(context),
|
||||||
icon: Icon(Icons.person_add_alt_1_outlined),
|
icon: const Icon(Icons.person_add_alt_1_outlined),
|
||||||
label: Text(l10n.addContact),
|
label: Text(l10n.addContact),
|
||||||
style: OutlinedButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
horizontal: 24,
|
|
||||||
vertical: 12,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: FilledButton.tonalIcon(
|
||||||
onPressed: () => _showAddChannelDialog(context),
|
onPressed: () => _showAddChannelDialog(context),
|
||||||
icon: Icon(Icons.add_circle_outline),
|
icon: const Icon(Icons.add_circle_outline),
|
||||||
label: Text(l10n.addChannel),
|
label: Text(l10n.addChannel),
|
||||||
style: OutlinedButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
horizontal: 24,
|
backgroundColor: _sectionAccentColor(
|
||||||
vertical: 12,
|
context,
|
||||||
|
ContactSection.channels,
|
||||||
|
).withValues(alpha: 0.14),
|
||||||
|
foregroundColor: _sectionAccentColor(
|
||||||
|
context,
|
||||||
|
ContactSection.channels,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1139,6 +1384,10 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 4),
|
||||||
|
child: _buildSortMenu(context, section, compact: true),
|
||||||
|
),
|
||||||
if (hasFilter) ...[
|
if (hasFilter) ...[
|
||||||
if (onSecondaryAction != null &&
|
if (onSecondaryAction != null &&
|
||||||
secondaryActionIcon != null)
|
secondaryActionIcon != null)
|
||||||
@@ -1260,10 +1509,19 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSortMenu(BuildContext context, ContactSection section) {
|
Widget _buildSortMenu(
|
||||||
|
BuildContext context,
|
||||||
|
ContactSection section, {
|
||||||
|
bool compact = false,
|
||||||
|
}) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final selectedMode = _sortModes[section] ?? ContactSortMode.lastSeen;
|
final selectedMode =
|
||||||
|
_sortModes[section] ??
|
||||||
|
(section == ContactSection.channels
|
||||||
|
? ContactSortMode.alphabetical
|
||||||
|
: ContactSortMode.lastSeen);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final availableModes = _availableSortModes(section);
|
||||||
|
|
||||||
return PopupMenuButton<ContactSortMode>(
|
return PopupMenuButton<ContactSortMode>(
|
||||||
tooltip: 'Sort',
|
tooltip: 'Sort',
|
||||||
@@ -1273,53 +1531,75 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
_sortModes[section] = sortMode;
|
_sortModes[section] = sortMode;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => availableModes
|
||||||
PopupMenuItem<ContactSortMode>(
|
.map(
|
||||||
value: ContactSortMode.lastSeen,
|
(mode) => PopupMenuItem<ContactSortMode>(
|
||||||
|
value: mode,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.schedule,
|
_sortModeIcon(mode),
|
||||||
size: 18,
|
size: 18,
|
||||||
color: selectedMode == ContactSortMode.lastSeen
|
color: selectedMode == mode ? colorScheme.primary : null,
|
||||||
? colorScheme.primary
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(l10n.lastSeen),
|
Text(_sortModeLabel(l10n, mode)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PopupMenuItem<ContactSortMode>(
|
)
|
||||||
value: ContactSortMode.distance,
|
.toList(),
|
||||||
child: Row(
|
child: compact
|
||||||
children: [
|
? Container(
|
||||||
Icon(
|
padding: const EdgeInsets.all(6),
|
||||||
Icons.near_me,
|
decoration: BoxDecoration(
|
||||||
size: 18,
|
color: colorScheme.primary.withValues(alpha: 0.10),
|
||||||
color: selectedMode == ContactSortMode.distance
|
shape: BoxShape.circle,
|
||||||
? colorScheme.primary
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
|
||||||
Text(l10n.distance),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.more_horiz,
|
_sortModeIcon(selectedMode),
|
||||||
size: 18,
|
size: 18,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.38),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_sortModeIcon(selectedMode),
|
||||||
|
size: 16,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
_sortModeLabel(l10n, selectedMode),
|
||||||
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Icon(
|
||||||
|
Icons.expand_more_rounded,
|
||||||
|
size: 16,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ContactSortMode { lastSeen, distance }
|
enum ContactSortMode { lastSeen, distance, alphabetical }
|
||||||
|
|
||||||
enum ContactSection { teamMembers, repeaters, sensors, rooms, channels }
|
enum ContactSection { teamMembers, repeaters, sensors, rooms, channels }
|
||||||
|
|
||||||
@@ -1334,44 +1614,130 @@ class _SectionHeader extends StatelessWidget {
|
|||||||
final String title;
|
final String title;
|
||||||
final int count;
|
final int count;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
final Color accentColor;
|
||||||
final Widget? trailing;
|
final Widget? trailing;
|
||||||
|
|
||||||
const _SectionHeader({
|
const _SectionHeader({
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.count,
|
required this.count,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
|
required this.accentColor,
|
||||||
this.trailing,
|
this.trailing,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
final titleBlock = Row(
|
||||||
child: Row(
|
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 20),
|
Expanded(
|
||||||
const SizedBox(width: 8),
|
child: Text(
|
||||||
Text(
|
|
||||||
title,
|
title,
|
||||||
style: Theme.of(
|
maxLines: 1,
|
||||||
context,
|
overflow: TextOverflow.ellipsis,
|
||||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(color: accentColor.withValues(alpha: 0.18)),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
count.toString(),
|
count.toString(),
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
|
color: accentColor,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (trailing != null) ...[const Spacer(), trailing!],
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final useStackedLayout = trailing != null && constraints.maxWidth < 430;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accentColor.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: accentColor.withValues(alpha: 0.18),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(icon, size: 20, color: accentColor),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: titleBlock),
|
||||||
|
if (!useStackedLayout && trailing != null) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Flexible(child: trailing!),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (useStackedLayout) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Align(alignment: Alignment.centerRight, child: trailing!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SectionCard extends StatelessWidget {
|
||||||
|
final Color accentColor;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const _SectionCard({required this.accentColor, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
colorScheme.surface,
|
||||||
|
accentColor.withValues(alpha: 0.04),
|
||||||
|
colorScheme.surfaceContainerLow,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(26),
|
||||||
|
border: Border.all(color: accentColor.withValues(alpha: 0.14)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.shadow.withValues(alpha: 0.04),
|
||||||
|
blurRadius: 18,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import '../services/voice_recorder_service.dart';
|
|||||||
import '../services/voice_codec_service.dart';
|
import '../services/voice_codec_service.dart';
|
||||||
import '../utils/toast_logger.dart';
|
import '../utils/toast_logger.dart';
|
||||||
import '../utils/key_comparison.dart';
|
import '../utils/key_comparison.dart';
|
||||||
|
import '../utils/contact_sorting.dart';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
import '../utils/image_message_parser.dart';
|
import '../utils/image_message_parser.dart';
|
||||||
import '../utils/tictactoe_message_parser.dart';
|
import '../utils/tictactoe_message_parser.dart';
|
||||||
@@ -299,16 +300,15 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
final normalizedQuery = query.trim().toLowerCase();
|
final normalizedQuery = query.trim().toLowerCase();
|
||||||
final contacts =
|
final contacts =
|
||||||
contactsProvider.contacts
|
contactsProvider.chatContacts.where((contact) {
|
||||||
.where((contact) => contact.type == ContactType.chat)
|
|
||||||
.where((contact) {
|
|
||||||
if (normalizedQuery.isEmpty) return true;
|
if (normalizedQuery.isEmpty) return true;
|
||||||
return contact.displayName.toLowerCase().contains(
|
return contact.displayName.toLowerCase().contains(normalizedQuery);
|
||||||
normalizedQuery,
|
}).toList()..sort((a, b) {
|
||||||
);
|
final primary = compareContactsByFavouriteThenLastSeen(a, b);
|
||||||
})
|
if (primary != 0) {
|
||||||
.toList()
|
return primary;
|
||||||
..sort((a, b) {
|
}
|
||||||
|
|
||||||
final aName = a.displayName.toLowerCase();
|
final aName = a.displayName.toLowerCase();
|
||||||
final bName = b.displayName.toLowerCase();
|
final bName = b.displayName.toLowerCase();
|
||||||
final aStarts =
|
final aStarts =
|
||||||
@@ -318,7 +318,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
if (aStarts != bStarts) {
|
if (aStarts != bStarts) {
|
||||||
return aStarts ? -1 : 1;
|
return aStarts ? -1 : 1;
|
||||||
}
|
}
|
||||||
return aName.compareTo(bName);
|
|
||||||
|
return compareContactsByDisplayName(a, b);
|
||||||
});
|
});
|
||||||
|
|
||||||
return contacts.take(8).toList(growable: false);
|
return contacts.take(8).toList(growable: false);
|
||||||
@@ -408,16 +409,33 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
final messagesProvider = context.read<MessagesProvider>();
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
|
|
||||||
// Filter contacts by type
|
final contacts = List<Contact>.from(contactsProvider.chatContacts)
|
||||||
final contacts = contactsProvider.contacts
|
..sort((a, b) {
|
||||||
.where((c) => c.type == ContactType.chat)
|
final primary = compareContactsByFavouriteThenLastSeen(a, b);
|
||||||
.toList();
|
if (primary != 0) {
|
||||||
final rooms = contactsProvider.contacts
|
return primary;
|
||||||
.where((c) => c.type == ContactType.room)
|
}
|
||||||
.toList();
|
|
||||||
final channels = contactsProvider.contacts
|
return compareContactsByDisplayName(a, b);
|
||||||
.where((c) => c.type == ContactType.channel)
|
});
|
||||||
.toList();
|
final rooms = List<Contact>.from(contactsProvider.rooms)
|
||||||
|
..sort((a, b) {
|
||||||
|
final primary = compareContactsByLastSeen(a, b);
|
||||||
|
if (primary != 0) {
|
||||||
|
return primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return compareContactsByDisplayName(a, b);
|
||||||
|
});
|
||||||
|
final channels = List<Contact>.from(contactsProvider.channels)
|
||||||
|
..sort((a, b) {
|
||||||
|
final primary = compareContactsByLastSeen(a, b);
|
||||||
|
if (primary != 0) {
|
||||||
|
return primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return compareContactsByDisplayName(a, b);
|
||||||
|
});
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -534,7 +552,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}).firstOrNull;
|
}).firstOrNull;
|
||||||
|
|
||||||
if (recipient == null) {
|
if (recipient == null) {
|
||||||
ToastLogger.error(context, AppLocalizations.of(context)!.cannotReplyContactNotFound);
|
ToastLogger.error(
|
||||||
|
context,
|
||||||
|
AppLocalizations.of(context)!.cannotReplyContactNotFound,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -563,13 +584,19 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
} else {
|
} else {
|
||||||
final senderPrefix = message.senderPublicKeyPrefix;
|
final senderPrefix = message.senderPublicKeyPrefix;
|
||||||
if (senderPrefix == null || senderPrefix.length < 6) {
|
if (senderPrefix == null || senderPrefix.length < 6) {
|
||||||
ToastLogger.error(context, AppLocalizations.of(context)!.cannotReplySenderMissing);
|
ToastLogger.error(
|
||||||
|
context,
|
||||||
|
AppLocalizations.of(context)!.cannotReplySenderMissing,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
recipient = contactsProvider.findContactByPrefix(senderPrefix);
|
recipient = contactsProvider.findContactByPrefix(senderPrefix);
|
||||||
if (recipient == null) {
|
if (recipient == null) {
|
||||||
ToastLogger.error(context, AppLocalizations.of(context)!.cannotReplyContactNotFound);
|
ToastLogger.error(
|
||||||
|
context,
|
||||||
|
AppLocalizations.of(context)!.cannotReplyContactNotFound,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,9 +190,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_isDeveloperModeEnabled = false;
|
_isDeveloperModeEnabled = false;
|
||||||
_versionTapCount = 0;
|
_versionTapCount = 0;
|
||||||
});
|
});
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(
|
||||||
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.developerModeDisabled)));
|
content: Text(AppLocalizations.of(context)!.developerModeDisabled),
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,9 +206,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_isDeveloperModeEnabled = true;
|
_isDeveloperModeEnabled = true;
|
||||||
_versionTapCount = 0;
|
_versionTapCount = 0;
|
||||||
});
|
});
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(
|
||||||
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.developerModeEnabled)));
|
content: Text(AppLocalizations.of(context)!.developerModeEnabled),
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,7 +563,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(AppLocalizations.of(context)!.updateCheckIsOnlyAvailableOnAndroid),
|
content: Text(
|
||||||
|
AppLocalizations.of(context)!.updateCheckIsOnlyAvailableOnAndroid,
|
||||||
|
),
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: Colors.orange,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -584,7 +590,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
if (!updateInfo.isAvailable) {
|
if (!updateInfo.isAvailable) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(AppLocalizations.of(context)!.youAreRunningTheLatestVersion),
|
content: Text(
|
||||||
|
AppLocalizations.of(context)!.youAreRunningTheLatestVersion,
|
||||||
|
),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -594,7 +602,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
if (updateInfo.downloadUrl == null) {
|
if (updateInfo.downloadUrl == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(AppLocalizations.of(context)!.updateAvailableButDownloadUrlNotFound),
|
content: Text(
|
||||||
|
AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.updateAvailableButDownloadUrlNotFound,
|
||||||
|
),
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: Colors.orange,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1178,6 +1190,100 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
_buildSectionHeader(AppLocalizations.of(context)!.contacts),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => _buildSettingsCard([
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.star_outline_rounded),
|
||||||
|
title: Text(AppLocalizations.of(context)!.favourites),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Show the favourites section in the contacts tab',
|
||||||
|
),
|
||||||
|
value: appProvider.isContactsSectionEnabled(
|
||||||
|
ContactsTabSection.favourites,
|
||||||
|
),
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.setContactsSectionEnabled(
|
||||||
|
ContactsTabSection.favourites,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.people_alt_outlined),
|
||||||
|
title: Text(AppLocalizations.of(context)!.teamMembers),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Show direct team contacts in the contacts tab',
|
||||||
|
),
|
||||||
|
value: appProvider.isContactsSectionEnabled(
|
||||||
|
ContactsTabSection.teamMembers,
|
||||||
|
),
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.setContactsSectionEnabled(
|
||||||
|
ContactsTabSection.teamMembers,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.router_outlined),
|
||||||
|
title: Text(AppLocalizations.of(context)!.repeaters),
|
||||||
|
subtitle: const Text('Show repeater nodes in the contacts tab'),
|
||||||
|
value: appProvider.isContactsSectionEnabled(
|
||||||
|
ContactsTabSection.repeaters,
|
||||||
|
),
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.setContactsSectionEnabled(
|
||||||
|
ContactsTabSection.repeaters,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.sensors_outlined),
|
||||||
|
title: Text(AppLocalizations.of(context)!.sensors),
|
||||||
|
subtitle: const Text('Show sensor nodes in the contacts tab'),
|
||||||
|
value: appProvider.isContactsSectionEnabled(
|
||||||
|
ContactsTabSection.sensors,
|
||||||
|
),
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.setContactsSectionEnabled(
|
||||||
|
ContactsTabSection.sensors,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.meeting_room_outlined),
|
||||||
|
title: Text(AppLocalizations.of(context)!.rooms),
|
||||||
|
subtitle: const Text('Show rooms in the contacts tab'),
|
||||||
|
value: appProvider.isContactsSectionEnabled(
|
||||||
|
ContactsTabSection.rooms,
|
||||||
|
),
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.setContactsSectionEnabled(
|
||||||
|
ContactsTabSection.rooms,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.broadcast_on_personal_outlined),
|
||||||
|
title: Text(AppLocalizations.of(context)!.channels),
|
||||||
|
subtitle: const Text('Show channels in the contacts tab'),
|
||||||
|
value: appProvider.isContactsSectionEnabled(
|
||||||
|
ContactsTabSection.channels,
|
||||||
|
),
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.setContactsSectionEnabled(
|
||||||
|
ContactsTabSection.channels,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
|
||||||
_buildSectionHeader('Messaging'),
|
_buildSectionHeader('Messaging'),
|
||||||
_buildSettingsCard([
|
_buildSettingsCard([
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -1205,7 +1311,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
Consumer<AppProvider>(
|
Consumer<AppProvider>(
|
||||||
builder: (context, appProvider, child) => SwitchListTile(
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
secondary: Icon(Icons.route),
|
secondary: Icon(Icons.route),
|
||||||
title: Text(AppLocalizations.of(context)!.nearestRepeaterFallback),
|
title: Text(
|
||||||
|
AppLocalizations.of(context)!.nearestRepeaterFallback,
|
||||||
|
),
|
||||||
subtitle: const Text(
|
subtitle: const Text(
|
||||||
'After normal retries fail, try one final resend through the nearest repeater',
|
'After normal retries fail, try one final resend through the nearest repeater',
|
||||||
),
|
),
|
||||||
@@ -1234,7 +1342,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
'Clear Messages',
|
'Clear Messages',
|
||||||
style: TextStyle(color: Colors.red),
|
style: TextStyle(color: Colors.red),
|
||||||
),
|
),
|
||||||
subtitle: Text(AppLocalizations.of(context)!.deleteAllStoredMessageHistory),
|
subtitle: Text(
|
||||||
|
AppLocalizations.of(context)!.deleteAllStoredMessageHistory,
|
||||||
|
),
|
||||||
onTap: _clearMessages,
|
onTap: _clearMessages,
|
||||||
),
|
),
|
||||||
Consumer<AppProvider>(
|
Consumer<AppProvider>(
|
||||||
@@ -1344,7 +1454,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
builder: (context, drawingProvider, child) => SwitchListTile(
|
builder: (context, drawingProvider, child) => SwitchListTile(
|
||||||
secondary: Icon(Icons.fmd_good_outlined),
|
secondary: Icon(Icons.fmd_good_outlined),
|
||||||
title: Text(AppLocalizations.of(context)!.showSarMarkersLabel),
|
title: Text(AppLocalizations.of(context)!.showSarMarkersLabel),
|
||||||
subtitle: Text(AppLocalizations.of(context)!.displaySarMarkersOnTheMainMap),
|
subtitle: Text(
|
||||||
|
AppLocalizations.of(context)!.displaySarMarkersOnTheMainMap,
|
||||||
|
),
|
||||||
value: drawingProvider.showSarMarkers,
|
value: drawingProvider.showSarMarkers,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
drawingProvider.toggleSarMarkers();
|
drawingProvider.toggleSarMarkers();
|
||||||
@@ -1354,7 +1466,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
Consumer<MapProvider>(
|
Consumer<MapProvider>(
|
||||||
builder: (context, mapProvider, child) => SwitchListTile(
|
builder: (context, mapProvider, child) => SwitchListTile(
|
||||||
secondary: Icon(Icons.timeline),
|
secondary: Icon(Icons.timeline),
|
||||||
title: Text(AppLocalizations.of(context)!.showAllContactTrailsLabel),
|
title: Text(
|
||||||
|
AppLocalizations.of(context)!.showAllContactTrailsLabel,
|
||||||
|
),
|
||||||
subtitle: const Text(
|
subtitle: const Text(
|
||||||
'Display location trails for all contacts that have history',
|
'Display location trails for all contacts that have history',
|
||||||
),
|
),
|
||||||
@@ -1420,7 +1534,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
builder: (context, appProvider, child) => SwitchListTile(
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
secondary: Icon(Icons.compress),
|
secondary: Icon(Icons.compress),
|
||||||
title: Text(AppLocalizations.of(context)!.voiceCompressor),
|
title: Text(AppLocalizations.of(context)!.voiceCompressor),
|
||||||
subtitle: Text(AppLocalizations.of(context)!.balancesQuietAndLoudSpeechLevels),
|
subtitle: Text(
|
||||||
|
AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.balancesQuietAndLoudSpeechLevels,
|
||||||
|
),
|
||||||
value: appProvider.isVoiceCompressorEnabled,
|
value: appProvider.isVoiceCompressorEnabled,
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
await appProvider.toggleVoiceCompressorEnabled(value);
|
await appProvider.toggleVoiceCompressorEnabled(value);
|
||||||
@@ -1431,7 +1549,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
builder: (context, appProvider, child) => SwitchListTile(
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
secondary: Icon(Icons.speed),
|
secondary: Icon(Icons.speed),
|
||||||
title: Text(AppLocalizations.of(context)!.voiceLimiter),
|
title: Text(AppLocalizations.of(context)!.voiceLimiter),
|
||||||
subtitle: Text(AppLocalizations.of(context)!.preventsClippingPeaksBeforeEncoding),
|
subtitle: Text(
|
||||||
|
AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.preventsClippingPeaksBeforeEncoding,
|
||||||
|
),
|
||||||
value: appProvider.isVoiceLimiterEnabled,
|
value: appProvider.isVoiceLimiterEnabled,
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
await appProvider.toggleVoiceLimiterEnabled(value);
|
await appProvider.toggleVoiceLimiterEnabled(value);
|
||||||
@@ -1442,7 +1564,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
builder: (context, appProvider, child) => SwitchListTile(
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
secondary: Icon(Icons.auto_fix_high),
|
secondary: Icon(Icons.auto_fix_high),
|
||||||
title: Text(AppLocalizations.of(context)!.micAutoGain),
|
title: Text(AppLocalizations.of(context)!.micAutoGain),
|
||||||
subtitle: Text(AppLocalizations.of(context)!.letsTheRecorderAdjustInputLevel),
|
subtitle: Text(
|
||||||
|
AppLocalizations.of(context)!.letsTheRecorderAdjustInputLevel,
|
||||||
|
),
|
||||||
value: appProvider.isVoiceAutoGainEnabled,
|
value: appProvider.isVoiceAutoGainEnabled,
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
await appProvider.toggleVoiceAutoGainEnabled(value);
|
await appProvider.toggleVoiceAutoGainEnabled(value);
|
||||||
@@ -1478,7 +1602,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
Consumer<AppProvider>(
|
Consumer<AppProvider>(
|
||||||
builder: (context, appProvider, child) => SwitchListTile(
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
secondary: Icon(Icons.content_cut),
|
secondary: Icon(Icons.content_cut),
|
||||||
title: Text(AppLocalizations.of(context)!.trimSilenceInVoiceMessages),
|
title: Text(
|
||||||
|
AppLocalizations.of(context)!.trimSilenceInVoiceMessages,
|
||||||
|
),
|
||||||
subtitle: const Text(
|
subtitle: const Text(
|
||||||
'Removes long silent parts before sending voice',
|
'Removes long silent parts before sending voice',
|
||||||
),
|
),
|
||||||
@@ -1648,7 +1774,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.timer),
|
leading: Icon(Icons.timer),
|
||||||
title: Text(AppLocalizations.of(context)!.activeuseUpdateInterval),
|
title: Text(
|
||||||
|
AppLocalizations.of(context)!.activeuseUpdateInterval,
|
||||||
|
),
|
||||||
subtitle: Text('$_fastLocationActiveCadenceSeconds s'),
|
subtitle: Text('$_fastLocationActiveCadenceSeconds s'),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: _editFastLocationActiveCadence,
|
onTap: _editFastLocationActiveCadence,
|
||||||
|
|||||||
24
lib/utils/contact_sorting.dart
Normal file
24
lib/utils/contact_sorting.dart
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import '../models/contact.dart';
|
||||||
|
|
||||||
|
int compareContactsByLastSeen(Contact a, Contact b) {
|
||||||
|
return b.lastSeenTime.compareTo(a.lastSeenTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
int compareContactsByFavouriteThenLastSeen(Contact a, Contact b) {
|
||||||
|
if (a.isFavourite != b.isFavourite) {
|
||||||
|
return a.isFavourite ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return compareContactsByLastSeen(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
int compareContactsByDisplayName(Contact a, Contact b) {
|
||||||
|
final nameCompare = a.displayName.toLowerCase().compareTo(
|
||||||
|
b.displayName.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (nameCompare != 0) {
|
||||||
|
return nameCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.publicKeyHex.compareTo(b.publicKeyHex);
|
||||||
|
}
|
||||||
@@ -54,6 +54,14 @@ class ContactTile extends StatelessWidget {
|
|||||||
return l10n.daysAgo(diff.inDays);
|
return l10n.daysAgo(diff.inDays);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _threeBytePrefix() {
|
||||||
|
final bytes = contact.publicKey.take(3);
|
||||||
|
return bytes
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join()
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isChannel = contact.type == ContactType.channel;
|
final isChannel = contact.type == ContactType.channel;
|
||||||
@@ -205,7 +213,12 @@ class ContactTile extends StatelessWidget {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 58,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Stack(
|
Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
@@ -221,7 +234,10 @@ class ContactTile extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.blue,
|
color: Colors.blue,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: Colors.white, width: 2),
|
border: Border.all(
|
||||||
|
color: Colors.white,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -235,7 +251,10 @@ class ContactTile extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _getRoomStatusColor(roomLoginState),
|
color: _getRoomStatusColor(roomLoginState),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: Colors.white, width: 2),
|
border: Border.all(
|
||||||
|
color: Colors.white,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
_getRoomStatusIcon(roomLoginState),
|
_getRoomStatusIcon(roomLoginState),
|
||||||
@@ -246,6 +265,22 @@ class ContactTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_threeBytePrefix(),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
35
test/models/channel_test.dart
Normal file
35
test/models/channel_test.dart
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/channel.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('hash channels expose deterministic psk_base64', () {
|
||||||
|
final channel = Channel.create(index: 3, name: '#ops');
|
||||||
|
|
||||||
|
expect(channel.isHashChannel, isTrue);
|
||||||
|
expect(channel.pskBase64, 'O2RN43fDLHh5NgWiWqkVvw==');
|
||||||
|
expect(
|
||||||
|
Channel.pskBase64ForHashChannelName('#ops'),
|
||||||
|
'O2RN43fDLHh5NgWiWqkVvw==',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normal channels reject derived hashtag psk export helper', () {
|
||||||
|
expect(
|
||||||
|
() => Channel.pskBase64ForHashChannelName('ops'),
|
||||||
|
throwsArgumentError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normal channels still expose their stored psk_base64', () {
|
||||||
|
final channel = Channel.create(
|
||||||
|
index: 4,
|
||||||
|
name: 'ops',
|
||||||
|
explicitSecret: Uint8List.fromList(List<int>.generate(16, (i) => i)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(channel.isHashChannel, isFalse);
|
||||||
|
expect(channel.pskBase64, 'AAECAwQFBgcICQoLDA0ODw==');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/channel.dart';
|
||||||
import 'package:meshcore_sar_app/models/contact.dart';
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
import 'package:meshcore_sar_app/models/contact_group.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/connection_provider.dart';
|
||||||
@@ -15,8 +17,28 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
String? clipboardText;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
clipboardText = null;
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||||
|
switch (call.method) {
|
||||||
|
case 'Clipboard.setData':
|
||||||
|
clipboardText =
|
||||||
|
(call.arguments as Map<dynamic, dynamic>)['text'] as String?;
|
||||||
|
return null;
|
||||||
|
case 'Clipboard.getData':
|
||||||
|
return <String, dynamic>{'text': clipboardText};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(SystemChannels.platform, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
Contact buildChannel({required String name, required int channelIndex}) {
|
Contact buildChannel({required String name, required int channelIndex}) {
|
||||||
@@ -123,6 +145,7 @@ void main() {
|
|||||||
await tester.tap(find.text('Ops'));
|
await tester.tap(find.text('Ops'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Export psk_base64'), findsNothing);
|
||||||
expect(find.text('Delete Channel'), findsOneWidget);
|
expect(find.text('Delete Channel'), findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(find.text('Delete Channel'));
|
await tester.tap(find.text('Delete Channel'));
|
||||||
@@ -137,6 +160,26 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('hash channel activity card exports psk_base64', (tester) async {
|
||||||
|
await pumpContactsTab(
|
||||||
|
tester,
|
||||||
|
contacts: [buildChannel(name: '#ops', channelIndex: 3)],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('#ops'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('#ops'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Export psk_base64'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Export psk_base64'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
expect(clipboardText, Channel.pskBase64ForHashChannelName('#ops'));
|
||||||
|
expect(find.text('psk_base64 copied to clipboard'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('repeaters show Others group when multiple groups exist', (
|
testWidgets('repeaters show Others group when multiple groups exist', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -218,7 +261,7 @@ void main() {
|
|||||||
contacts: [buildSensor(seed: 60, name: 'WX Station')],
|
contacts: [buildSensor(seed: 60, name: 'WX Station')],
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(find.text('Sensors'), findsOneWidget);
|
expect(find.text('Sensors'), findsWidgets);
|
||||||
expect(find.text('WX Station'), findsOneWidget);
|
expect(find.text('WX Station'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ void main() {
|
|||||||
required String name,
|
required String name,
|
||||||
required ContactType type,
|
required ContactType type,
|
||||||
int secondByte = 0,
|
int secondByte = 0,
|
||||||
|
int flags = 0,
|
||||||
|
int lastAdvert = 0,
|
||||||
}) {
|
}) {
|
||||||
final publicKey = Uint8List(32);
|
final publicKey = Uint8List(32);
|
||||||
publicKey[1] = secondByte;
|
publicKey[1] = secondByte;
|
||||||
@@ -18,11 +20,11 @@ void main() {
|
|||||||
return Contact(
|
return Contact(
|
||||||
publicKey: publicKey,
|
publicKey: publicKey,
|
||||||
type: type,
|
type: type,
|
||||||
flags: 0,
|
flags: flags,
|
||||||
outPathLen: 0,
|
outPathLen: 0,
|
||||||
outPath: Uint8List(0),
|
outPath: Uint8List(0),
|
||||||
advName: name,
|
advName: name,
|
||||||
lastAdvert: 0,
|
lastAdvert: lastAdvert,
|
||||||
advLat: 0,
|
advLat: 0,
|
||||||
advLon: 0,
|
advLon: 0,
|
||||||
lastMod: 0,
|
lastMod: 0,
|
||||||
@@ -98,4 +100,54 @@ void main() {
|
|||||||
expect(find.text('Show all'), findsNothing);
|
expect(find.text('Show all'), findsNothing);
|
||||||
expect(find.text('John Smith'), findsOneWidget);
|
expect(find.text('John Smith'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('sorts contacts with favourites first, then last seen', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final recentNonFavourite = buildContact(
|
||||||
|
name: 'Alpha',
|
||||||
|
type: ContactType.chat,
|
||||||
|
secondByte: 1,
|
||||||
|
lastAdvert: 300,
|
||||||
|
);
|
||||||
|
final olderFavourite = buildContact(
|
||||||
|
name: 'Bravo',
|
||||||
|
type: ContactType.chat,
|
||||||
|
secondByte: 2,
|
||||||
|
flags: 0x01,
|
||||||
|
lastAdvert: 100,
|
||||||
|
);
|
||||||
|
final newerFavourite = buildContact(
|
||||||
|
name: 'Charlie',
|
||||||
|
type: ContactType.chat,
|
||||||
|
secondByte: 3,
|
||||||
|
flags: 0x01,
|
||||||
|
lastAdvert: 200,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: RecipientSelectorSheet(
|
||||||
|
contacts: [recentNonFavourite, olderFavourite, newerFavourite],
|
||||||
|
rooms: const [],
|
||||||
|
channels: const [],
|
||||||
|
unreadCount: 0,
|
||||||
|
unreadCountsByPublicKey: const {},
|
||||||
|
showAllOption: false,
|
||||||
|
onSelect: (selectedRecipient, draftMessage) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final charlieY = tester.getTopLeft(find.text('Charlie')).dy;
|
||||||
|
final bravoY = tester.getTopLeft(find.text('Bravo')).dy;
|
||||||
|
final alphaY = tester.getTopLeft(find.text('Alpha')).dy;
|
||||||
|
|
||||||
|
expect(charlieY, lessThan(bravoY));
|
||||||
|
expect(bravoY, lessThan(alphaY));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user