Merge branch 'main' of github.com:MeshEnvy/meshcore-open

This commit is contained in:
Ben Allfree 2026-02-24 22:50:20 -08:00
commit 2a3119544c
42 changed files with 1473 additions and 2223 deletions

1
.gitignore vendored
View file

@ -30,6 +30,7 @@ migrate_working_dir/
.flutter-plugins-dependencies
.pub-cache/
.pub/
pubspec.lock
/build/
/coverage/

View file

@ -669,6 +669,7 @@ class MeshCoreConnector extends ChangeNotifier {
publicKey: contact.publicKey,
name: contact.name,
type: contact.type,
flags: contact.flags,
pathLength: selection.hopCount >= 0
? selection.hopCount
: contact.pathLength,
@ -1185,11 +1186,78 @@ class MeshCoreConnector extends ChangeNotifier {
customPath,
pathLen,
type: contact.type,
flags: contact.flags,
name: contact.name,
),
);
}
Future<void> setContactFavorite(Contact contact, bool isFavorite) async {
if (!isConnected) return;
final latestContact =
await _fetchContactSnapshotFromDevice(contact.publicKey) ?? contact;
final updatedFlags = isFavorite
? (latestContact.flags | contactFlagFavorite)
: (latestContact.flags & ~contactFlagFavorite);
await sendFrame(
buildUpdateContactPathFrame(
latestContact.publicKey,
latestContact.path,
latestContact.pathLength,
type: latestContact.type,
flags: updatedFlags,
name: latestContact.name,
),
);
final index = _contacts.indexWhere(
(c) => c.publicKeyHex == contact.publicKeyHex,
);
if (index >= 0) {
_contacts[index] = _contacts[index].copyWith(
type: latestContact.type,
name: latestContact.name,
pathLength: latestContact.pathLength,
path: latestContact.path,
flags: updatedFlags,
);
notifyListeners();
unawaited(_persistContacts());
}
}
Future<Contact?> _fetchContactSnapshotFromDevice(
Uint8List pubKey, {
Duration timeout = const Duration(seconds: 3),
}) async {
if (!isConnected) return null;
final expectedKeyHex = pubKeyToHex(pubKey);
final completer = Completer<Contact?>();
void finish(Contact? result) {
if (!completer.isCompleted) {
completer.complete(result);
}
}
final subscription = receivedFrames.listen((frame) {
if (frame.isEmpty || frame[0] != respCodeContact) return;
final parsed = Contact.fromFrame(frame);
if (parsed == null || parsed.publicKeyHex != expectedKeyHex) return;
finish(parsed);
});
final timer = Timer(timeout, () => finish(null));
try {
await getContactByKey(pubKey);
return await completer.future;
} finally {
timer.cancel();
await subscription.cancel();
}
}
/// Set path override for a contact (persists across contact refreshes)
/// pathLen: -1 = force flood, null = auto (use device path), >= 0 = specific path
Future<void> setPathOverride(

View file

@ -290,6 +290,7 @@ int _minPositive(int a, int b) {
const int contactPubKeyOffset = 1;
const int contactTypeOffset = 33;
const int contactFlagsOffset = 34;
const int contactFlagFavorite = 0x01;
const int contactPathLenOffset = 35;
const int contactPathOffset = 36;
const int contactNameOffset = 100;

View file

@ -1,6 +1,12 @@
{
"channels_channelDeleteFailed": "Неуспешно изтриване на канала \"{name}\"",
"@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } },
"@channels_channelDeleteFailed": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"@@locale": "bg",
"appTitle": "MeshCore Open",
"nav_contacts": "Контакти",
@ -1744,5 +1750,8 @@
"type": "double"
}
}
}
},
"listFilter_removeFromFavorites": "Премахване от списъка с любими",
"listFilter_addToFavorites": "Добави към любими",
"listFilter_favorites": "Любими"
}

View file

@ -1343,6 +1343,9 @@
"listFilter_az": "A-Z",
"listFilter_filters": "Filtere",
"listFilter_all": "Alle",
"listFilter_favorites": "Favoriten",
"listFilter_addToFavorites": "Zu Favoriten hinzufügen",
"listFilter_removeFromFavorites": "Aus Favoriten entfernen",
"listFilter_users": "Benutzer",
"listFilter_repeaters": "Repeater",
"listFilter_roomServers": "Raumserver",

View file

@ -1555,6 +1555,9 @@
"listFilter_az": "A-Z",
"listFilter_filters": "Filters",
"listFilter_all": "All",
"listFilter_favorites": "Favorites",
"listFilter_addToFavorites": "Add to favorites",
"listFilter_removeFromFavorites": "Remove from favorites",
"listFilter_users": "Users",
"listFilter_repeaters": "Repeaters",
"listFilter_roomServers": "Room servers",
@ -1779,4 +1782,4 @@
"settings_gpxExportShareSubject": "meshcore-open GPX map data export",
"snrIndicator_nearByRepeaters": "Nearby Repeaters",
"snrIndicator_lastSeen": "Last seen"
}
}

View file

@ -1,6 +1,12 @@
{
"channels_channelDeleteFailed": "No se pudo eliminar el canal \"{name}\"",
"@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } },
"@channels_channelDeleteFailed": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"@@locale": "es",
"appTitle": "MeshCore Open",
"nav_contacts": "Contactos",
@ -1772,5 +1778,8 @@
"type": "double"
}
}
}
},
"listFilter_favorites": "Favoritos",
"listFilter_removeFromFavorites": "Eliminar de las favoritas",
"listFilter_addToFavorites": "Añadir a favoritos"
}

View file

@ -1,6 +1,12 @@
{
"channels_channelDeleteFailed": "Échec de la suppression de la chaîne \"{name}\"",
"@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } },
"@channels_channelDeleteFailed": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"@@locale": "fr",
"appTitle": "MeshCore Open",
"nav_contacts": "Contacts",
@ -1744,5 +1750,8 @@
"type": "double"
}
}
}
},
"listFilter_addToFavorites": "Ajouter à mes favoris",
"listFilter_removeFromFavorites": "Supprimer des favoris",
"listFilter_favorites": "Préférences"
}

View file

@ -1,6 +1,12 @@
{
"channels_channelDeleteFailed": "Impossibile eliminare il canale \"{name}\"",
"@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } },
"@channels_channelDeleteFailed": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"@@locale": "it",
"appTitle": "MeshCore Open",
"nav_contacts": "Contatti",
@ -1744,5 +1750,8 @@
"type": "double"
}
}
}
},
"listFilter_addToFavorites": "Aggiungi ai preferiti",
"listFilter_removeFromFavorites": "Rimuovi dai preferiti",
"listFilter_favorites": "Preferiti"
}

View file

@ -4772,6 +4772,24 @@ abstract class AppLocalizations {
/// **'All'**
String get listFilter_all;
/// No description provided for @listFilter_favorites.
///
/// In en, this message translates to:
/// **'Favorites'**
String get listFilter_favorites;
/// No description provided for @listFilter_addToFavorites.
///
/// In en, this message translates to:
/// **'Add to favorites'**
String get listFilter_addToFavorites;
/// No description provided for @listFilter_removeFromFavorites.
///
/// In en, this message translates to:
/// **'Remove from favorites'**
String get listFilter_removeFromFavorites;
/// No description provided for @listFilter_users.
///
/// In en, this message translates to:

View file

@ -2728,6 +2728,15 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get listFilter_all => 'Всички';
@override
String get listFilter_favorites => 'Любими';
@override
String get listFilter_addToFavorites => 'Добави към любими';
@override
String get listFilter_removeFromFavorites => 'Премахване от списъка с любими';
@override
String get listFilter_users => 'Потребители';

View file

@ -2733,6 +2733,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get listFilter_all => 'Alle';
@override
String get listFilter_favorites => 'Favoriten';
@override
String get listFilter_addToFavorites => 'Zu Favoriten hinzufügen';
@override
String get listFilter_removeFromFavorites => 'Aus Favoriten entfernen';
@override
String get listFilter_users => 'Benutzer';

View file

@ -2686,6 +2686,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get listFilter_all => 'All';
@override
String get listFilter_favorites => 'Favorites';
@override
String get listFilter_addToFavorites => 'Add to favorites';
@override
String get listFilter_removeFromFavorites => 'Remove from favorites';
@override
String get listFilter_users => 'Users';

View file

@ -2726,6 +2726,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get listFilter_all => 'Todas';
@override
String get listFilter_favorites => 'Favoritos';
@override
String get listFilter_addToFavorites => 'Añadir a favoritos';
@override
String get listFilter_removeFromFavorites => 'Eliminar de las favoritas';
@override
String get listFilter_users => 'Usuarios';

View file

@ -2742,6 +2742,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get listFilter_all => 'Tout';
@override
String get listFilter_favorites => 'Préférences';
@override
String get listFilter_addToFavorites => 'Ajouter à mes favoris';
@override
String get listFilter_removeFromFavorites => 'Supprimer des favoris';
@override
String get listFilter_users => 'Utilisateurs';

View file

@ -2726,6 +2726,15 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get listFilter_all => 'Tutti';
@override
String get listFilter_favorites => 'Preferiti';
@override
String get listFilter_addToFavorites => 'Aggiungi ai preferiti';
@override
String get listFilter_removeFromFavorites => 'Rimuovi dai preferiti';
@override
String get listFilter_users => 'Utenti';

View file

@ -2717,6 +2717,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get listFilter_all => 'Alles';
@override
String get listFilter_favorites => 'Favorieten';
@override
String get listFilter_addToFavorites => 'Toevoegen aan favorieten';
@override
String get listFilter_removeFromFavorites => 'Verwijderen uit favorieten';
@override
String get listFilter_users => 'Gebruikers';

View file

@ -2724,6 +2724,15 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get listFilter_all => 'Wszystko';
@override
String get listFilter_favorites => 'Ulubione';
@override
String get listFilter_addToFavorites => 'Dodaj do ulubionych';
@override
String get listFilter_removeFromFavorites => 'Usuń z ulubionych';
@override
String get listFilter_users => 'Użytkownicy';

View file

@ -2727,6 +2727,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get listFilter_all => 'Tudo';
@override
String get listFilter_favorites => 'Favoritos';
@override
String get listFilter_addToFavorites => 'Adicionar aos favoritos';
@override
String get listFilter_removeFromFavorites => 'Remover da lista de favoritos';
@override
String get listFilter_users => 'Usuários';

View file

@ -2730,6 +2730,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get listFilter_all => 'Все';
@override
String get listFilter_favorites => 'Избранное';
@override
String get listFilter_addToFavorites => 'Добавить в избранное';
@override
String get listFilter_removeFromFavorites => 'Удалить из избранного';
@override
String get listFilter_users => 'Пользователи';

View file

@ -2712,6 +2712,15 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get listFilter_all => 'Všetko';
@override
String get listFilter_favorites => 'Obľúbené';
@override
String get listFilter_addToFavorites => 'Pridaj do obľúbených';
@override
String get listFilter_removeFromFavorites => 'Odstrániť z označení';
@override
String get listFilter_users => 'Používatelia';

View file

@ -2715,6 +2715,15 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get listFilter_all => 'Vse';
@override
String get listFilter_favorites => 'Priljubljene';
@override
String get listFilter_addToFavorites => 'Dodaj v priljubljene';
@override
String get listFilter_removeFromFavorites => 'Odstrani iz priljubljenih';
@override
String get listFilter_users => 'Uporabniki';

View file

@ -2700,6 +2700,15 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get listFilter_all => 'Alla';
@override
String get listFilter_favorites => 'Favoriter';
@override
String get listFilter_addToFavorites => 'Lägg till i favoriter';
@override
String get listFilter_removeFromFavorites => 'Ta bort från favoriter';
@override
String get listFilter_users => 'Användare';

View file

@ -2737,6 +2737,15 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get listFilter_all => 'Все';
@override
String get listFilter_favorites => 'Улюблені';
@override
String get listFilter_addToFavorites => 'Додати до улюблених';
@override
String get listFilter_removeFromFavorites => 'Видалити зі списку улюблених';
@override
String get listFilter_users => 'Користувачі';

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,12 @@
{
"channels_channelDeleteFailed": "Kan kanaal {name} niet verwijderen",
"@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } },
"@channels_channelDeleteFailed": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"@@locale": "nl",
"appTitle": "MeshCore Open",
"nav_contacts": "Contacten",
@ -1744,5 +1750,8 @@
"type": "double"
}
}
}
},
"listFilter_removeFromFavorites": "Verwijderen uit favorieten",
"listFilter_favorites": "Favorieten",
"listFilter_addToFavorites": "Toevoegen aan favorieten"
}

View file

@ -1,6 +1,12 @@
{
"channels_channelDeleteFailed": "Nie udało się usunąć kanału \"{name}\"",
"@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } },
"@channels_channelDeleteFailed": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"@@locale": "pl",
"appTitle": "MeshCore Open",
"nav_contacts": "Kontakty",
@ -1744,5 +1750,8 @@
"type": "double"
}
}
}
},
"listFilter_removeFromFavorites": "Usuń z ulubionych",
"listFilter_addToFavorites": "Dodaj do ulubionych",
"listFilter_favorites": "Ulubione"
}

View file

@ -1,6 +1,12 @@
{
"channels_channelDeleteFailed": "Falha ao excluir o canal \"{name}\"",
"@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } },
"@channels_channelDeleteFailed": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"@@locale": "pt",
"appTitle": "MeshCore Open",
"nav_contacts": "Contactos",
@ -1744,5 +1750,8 @@
"type": "double"
}
}
}
},
"listFilter_addToFavorites": "Adicionar aos favoritos",
"listFilter_removeFromFavorites": "Remover da lista de favoritos",
"listFilter_favorites": "Favoritos"
}

View file

@ -1,6 +1,12 @@
{
"channels_channelDeleteFailed": "Не удалось удалить канал {name}.",
"@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } },
"@channels_channelDeleteFailed": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"@@locale": "ru",
"appTitle": "MeshCore Open",
"nav_contacts": "Контакты",
@ -984,5 +990,8 @@
"type": "double"
}
}
}
},
"listFilter_addToFavorites": "Добавить в избранное",
"listFilter_favorites": "Избранное",
"listFilter_removeFromFavorites": "Удалить из избранного"
}

View file

@ -1,6 +1,12 @@
{
"channels_channelDeleteFailed": "Kanál \"{name}\" sa nepodarilo odstrániť",
"@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } },
"@channels_channelDeleteFailed": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"@@locale": "sk",
"appTitle": "MeshCore Open",
"nav_contacts": "Kontakty",
@ -1744,5 +1750,8 @@
"type": "double"
}
}
}
},
"listFilter_removeFromFavorites": "Odstrániť z označení",
"listFilter_addToFavorites": "Pridaj do obľúbených",
"listFilter_favorites": "Obľúbené"
}

View file

@ -1,6 +1,12 @@
{
"channels_channelDeleteFailed": "Kanala {name} ni bilo mogoče izbrisati",
"@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } },
"@channels_channelDeleteFailed": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"@@locale": "sl",
"appTitle": "MeshCore Open",
"nav_contacts": "Stiki",
@ -1744,5 +1750,8 @@
"type": "double"
}
}
}
},
"listFilter_favorites": "Priljubljene",
"listFilter_removeFromFavorites": "Odstrani iz priljubljenih",
"listFilter_addToFavorites": "Dodaj v priljubljene"
}

View file

@ -1,6 +1,12 @@
{
"channels_channelDeleteFailed": "Det gick inte att ta bort kanalen \"{name}\"",
"@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } },
"@channels_channelDeleteFailed": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"@@locale": "sv",
"appTitle": "MeshCore Open",
"nav_contacts": "Kontakter",
@ -1744,5 +1750,8 @@
"type": "double"
}
}
}
},
"listFilter_removeFromFavorites": "Ta bort från favoriter",
"listFilter_addToFavorites": "Lägg till i favoriter",
"listFilter_favorites": "Favoriter"
}

View file

@ -1,6 +1,12 @@
{
"channels_channelDeleteFailed": "Не вдалося видалити канал \"{name}\"",
"@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } },
"@channels_channelDeleteFailed": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"@@locale": "uk",
"appTitle": "MeshCore Open",
"nav_contacts": "Контакти",
@ -1744,5 +1750,8 @@
"type": "double"
}
}
}
},
"listFilter_removeFromFavorites": "Видалити зі списку улюблених",
"listFilter_addToFavorites": "Додати до улюблених",
"listFilter_favorites": "Улюблені"
}

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,7 @@ class Contact {
final Uint8List publicKey;
final String name;
final int type;
final int flags;
final int pathLength; // -1 = flood, 0+ = direct hops (from device)
final Uint8List path; // Path bytes from device
final int?
@ -19,6 +20,7 @@ class Contact {
required this.publicKey,
required this.name,
required this.type,
this.flags = 0,
required this.pathLength,
required this.path,
this.pathOverride,
@ -58,11 +60,13 @@ class Contact {
}
bool get hasLocation => latitude != null && longitude != null;
bool get isFavorite => (flags & contactFlagFavorite) != 0;
Contact copyWith({
Uint8List? publicKey,
String? name,
int? type,
int? flags,
int? pathLength,
Uint8List? path,
int? pathOverride,
@ -77,6 +81,7 @@ class Contact {
publicKey: publicKey ?? this.publicKey,
name: name ?? this.name,
type: type ?? this.type,
flags: flags ?? this.flags,
pathLength: pathLength ?? this.pathLength,
path: path ?? this.path,
pathOverride: clearPathOverride
@ -167,6 +172,7 @@ class Contact {
data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize),
);
final type = data[contactTypeOffset];
final flags = data[contactFlagsOffset];
final pathLen = data[contactPathLenOffset].toSigned(8);
final safePathLen = pathLen > 0
? (pathLen > maxPathSize ? maxPathSize : pathLen)
@ -191,6 +197,7 @@ class Contact {
publicKey: pubKey,
name: name.isEmpty ? 'Unknown' : name,
type: type,
flags: flags,
pathLength: pathLen,
path: pathBytes,
latitude: lat,

View file

@ -970,30 +970,47 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
builder: (context, value, child) {
final gifId = _parseGifId(value.text);
if (gifId != null) {
return Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
fallbackTextColor: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
maxSize: 160,
return Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
(event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey ==
LogicalKeyboardKey.numpadEnter)) {
_sendMessage();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
fallbackTextColor: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
maxSize: 160,
),
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => _textController.clear(),
),
],
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_textController.clear();
_textFieldFocusNode.requestFocus();
},
),
],
),
);
}
@ -1056,6 +1073,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
connector.sendChannelMessage(widget.channel, messageText);
_textController.clear();
_cancelReply();
_textFieldFocusNode.requestFocus();
}
String _formatTime(DateTime time) {

View file

@ -354,28 +354,44 @@ class _ChatScreenState extends State<ChatScreen> {
builder: (context, value, child) {
final gifId = _parseGifId(value.text);
if (gifId != null) {
return Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor:
colorScheme.surfaceContainerHighest,
fallbackTextColor: colorScheme.onSurface
.withValues(alpha: 0.6),
maxSize: 160,
return Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
(event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey ==
LogicalKeyboardKey.numpadEnter)) {
_sendMessage(connector);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor:
colorScheme.surfaceContainerHighest,
fallbackTextColor: colorScheme.onSurface
.withValues(alpha: 0.6),
maxSize: 160,
),
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => _textController.clear(),
),
],
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_textController.clear();
_textFieldFocusNode.requestFocus();
},
),
],
),
);
}
@ -443,6 +459,7 @@ class _ChatScreenState extends State<ChatScreen> {
connector.sendMessage(widget.contact, text);
_textController.clear();
_textFieldFocusNode.requestFocus();
}
void _showPathHistory(BuildContext context) {

View file

@ -481,6 +481,7 @@ class _ContactsScreenState extends State<ContactsScreen>
contact: contact,
lastSeen: _resolveLastSeen(contact),
unreadCount: unreadCount,
isFavorite: contact.isFavorite,
onTap: () => _openChat(context, contact),
onLongPress: () =>
_showContactOptions(context, connector, contact),
@ -517,6 +518,8 @@ class _ContactsScreenState extends State<ContactsScreen>
})
.where((group) {
if (_typeFilter == ContactTypeFilter.all) return true;
// Groups don't have a favorite flag, so hide them under favorites filter
if (_typeFilter == ContactTypeFilter.favorites) return false;
for (final key in group.memberKeys) {
final contact = contactsByKey[key];
if (contact != null && _matchesTypeFilter(contact)) return true;
@ -591,6 +594,8 @@ class _ContactsScreenState extends State<ContactsScreen>
switch (_typeFilter) {
case ContactTypeFilter.all:
return true;
case ContactTypeFilter.favorites:
return contact.isFavorite;
case ContactTypeFilter.users:
return contact.type == advTypeChat;
case ContactTypeFilter.repeaters:
@ -981,6 +986,7 @@ class _ContactsScreenState extends State<ContactsScreen>
) {
final isRepeater = contact.type == advTypeRepeater;
final isRoom = contact.type == advTypeRoom;
final isFavorite = contact.isFavorite;
showModalBottomSheet(
context: context,
@ -1087,6 +1093,21 @@ class _ContactsScreenState extends State<ContactsScreen>
},
),
],
ListTile(
leading: Icon(
isFavorite ? Icons.star : Icons.star_border,
color: Colors.amber[700],
),
title: Text(
isFavorite
? context.l10n.listFilter_removeFromFavorites
: context.l10n.listFilter_addToFavorites,
),
onTap: () async {
Navigator.pop(sheetContext);
await connector.setContactFavorite(contact, !isFavorite);
},
),
ListTile(
leading: const Icon(Icons.copy),
title: Text(context.l10n.contacts_ShareContact),
@ -1155,6 +1176,7 @@ class _ContactTile extends StatelessWidget {
final Contact contact;
final DateTime lastSeen;
final int unreadCount;
final bool isFavorite;
final VoidCallback onTap;
final VoidCallback onLongPress;
@ -1162,6 +1184,7 @@ class _ContactTile extends StatelessWidget {
required this.contact,
required this.lastSeen,
required this.unreadCount,
required this.isFavorite,
required this.onTap,
required this.onLongPress,
});
@ -1214,6 +1237,10 @@ class _ContactTile extends StatelessWidget {
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isFavorite)
Icon(Icons.star, size: 14, color: Colors.amber[700]),
if (isFavorite && contact.hasLocation)
const SizedBox(width: 2),
if (contact.hasLocation)
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
],

View file

@ -168,6 +168,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
_commandController.clear();
_historyIndex = -1;
_commandFocusNode.requestFocus();
// Auto-scroll to bottom
Future.delayed(const Duration(milliseconds: 100), () {

View file

@ -33,6 +33,7 @@ class ContactStore {
'publicKey': base64Encode(contact.publicKey),
'name': contact.name,
'type': contact.type,
'flags': contact.flags,
'pathLength': contact.pathLength,
'path': base64Encode(contact.path),
'pathOverride': contact.pathOverride,
@ -53,6 +54,7 @@ class ContactStore {
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
name: json['name'] as String? ?? 'Unknown',
type: json['type'] as int? ?? 0,
flags: json['flags'] as int? ?? 0,
pathLength: json['pathLength'] as int? ?? -1,
path: json['path'] != null
? Uint8List.fromList(base64Decode(json['path'] as String))

View file

@ -3,7 +3,7 @@ import '../l10n/l10n.dart';
enum ContactSortOption { lastSeen, recentMessages, name }
enum ContactTypeFilter { all, users, repeaters, rooms }
enum ContactTypeFilter { all, favorites, users, repeaters, rooms }
class SortFilterMenuOption {
final int value;
@ -94,11 +94,12 @@ const int _actionSortRecentMessages = 1;
const int _actionSortName = 2;
const int _actionSortLastSeen = 3;
const int _actionFilterAll = 4;
const int _actionFilterUsers = 5;
const int _actionFilterRepeaters = 6;
const int _actionFilterRooms = 7;
const int _actionToggleUnreadOnly = 8;
const int _actionNewGroup = 9;
const int _actionFilterFavorites = 5;
const int _actionFilterUsers = 6;
const int _actionFilterRepeaters = 7;
const int _actionFilterRooms = 8;
const int _actionToggleUnreadOnly = 9;
const int _actionNewGroup = 10;
class ContactsFilterMenu extends StatelessWidget {
final ContactSortOption sortOption;
@ -154,6 +155,11 @@ class ContactsFilterMenu extends StatelessWidget {
label: l10n.listFilter_all,
checked: typeFilter == ContactTypeFilter.all,
),
SortFilterMenuOption(
value: _actionFilterFavorites,
label: l10n.listFilter_favorites,
checked: typeFilter == ContactTypeFilter.favorites,
),
SortFilterMenuOption(
value: _actionFilterUsers,
label: l10n.listFilter_users,
@ -198,6 +204,9 @@ class ContactsFilterMenu extends StatelessWidget {
case _actionFilterUsers:
onTypeFilterChanged(ContactTypeFilter.users);
break;
case _actionFilterFavorites:
onTypeFilterChanged(ContactTypeFilter.favorites);
break;
case _actionFilterRepeaters:
onTypeFilterChanged(ContactTypeFilter.repeaters);
break;

File diff suppressed because it is too large Load diff