favorites handling only

This commit is contained in:
ericz 2026-02-24 23:56:30 +01:00
parent a42cf77a70
commit 5a70ed48cf
27 changed files with 233 additions and 8 deletions

View file

@ -30,6 +30,11 @@ jobs:
${{ runner.os }}-gradle-
- run: flutter pub get
- run: flutter build apk --release --no-pub
- name: Upload Debug APK
uses: actions/upload-artifact@v4
with:
name: app-debug
path: build/app/outputs/flutter-apk/app-release.apk
ios:
runs-on: macos-latest

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

@ -1343,6 +1343,7 @@
"listFilter_az": "A-Z",
"listFilter_filters": "Filtere",
"listFilter_all": "Alle",
"listFilter_favorites": "Favoriten",
"listFilter_users": "Benutzer",
"listFilter_repeaters": "Repeater",
"listFilter_roomServers": "Raumserver",

View file

@ -1555,6 +1555,7 @@
"listFilter_az": "A-Z",
"listFilter_filters": "Filters",
"listFilter_all": "All",
"listFilter_favorites": "Favorites",
"listFilter_users": "Users",
"listFilter_repeaters": "Repeaters",
"listFilter_roomServers": "Room servers",
@ -1779,4 +1780,4 @@
"settings_gpxExportShareSubject": "meshcore-open GPX map data export",
"snrIndicator_nearByRepeaters": "Nearby Repeaters",
"snrIndicator_lastSeen": "Last seen"
}
}

View file

@ -4772,6 +4772,12 @@ 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_users.
///
/// In en, this message translates to:

View file

@ -2728,6 +2728,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get listFilter_all => 'Всички';
@override
String get listFilter_favorites => 'Favorites';
@override
String get listFilter_users => 'Потребители';

View file

@ -2733,6 +2733,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get listFilter_all => 'Alle';
@override
String get listFilter_favorites => 'Favoriten';
@override
String get listFilter_users => 'Benutzer';

View file

@ -2686,6 +2686,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get listFilter_all => 'All';
@override
String get listFilter_favorites => 'Favorites';
@override
String get listFilter_users => 'Users';

View file

@ -2726,6 +2726,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get listFilter_all => 'Todas';
@override
String get listFilter_favorites => 'Favorites';
@override
String get listFilter_users => 'Usuarios';

View file

@ -2742,6 +2742,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get listFilter_all => 'Tout';
@override
String get listFilter_favorites => 'Favorites';
@override
String get listFilter_users => 'Utilisateurs';

View file

@ -2726,6 +2726,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get listFilter_all => 'Tutti';
@override
String get listFilter_favorites => 'Favorites';
@override
String get listFilter_users => 'Utenti';

View file

@ -2717,6 +2717,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get listFilter_all => 'Alles';
@override
String get listFilter_favorites => 'Favorites';
@override
String get listFilter_users => 'Gebruikers';

View file

@ -2724,6 +2724,9 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get listFilter_all => 'Wszystko';
@override
String get listFilter_favorites => 'Favorites';
@override
String get listFilter_users => 'Użytkownicy';

View file

@ -2727,6 +2727,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get listFilter_all => 'Tudo';
@override
String get listFilter_favorites => 'Favorites';
@override
String get listFilter_users => 'Usuários';

View file

@ -2730,6 +2730,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get listFilter_all => 'Все';
@override
String get listFilter_favorites => 'Favorites';
@override
String get listFilter_users => 'Пользователи';

View file

@ -2712,6 +2712,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get listFilter_all => 'Všetko';
@override
String get listFilter_favorites => 'Favorites';
@override
String get listFilter_users => 'Používatelia';

View file

@ -2715,6 +2715,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get listFilter_all => 'Vse';
@override
String get listFilter_favorites => 'Favorites';
@override
String get listFilter_users => 'Uporabniki';

View file

@ -2700,6 +2700,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get listFilter_all => 'Alla';
@override
String get listFilter_favorites => 'Favorites';
@override
String get listFilter_users => 'Användare';

View file

@ -2737,6 +2737,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get listFilter_all => 'Все';
@override
String get listFilter_favorites => 'Favorites';
@override
String get listFilter_users => 'Користувачі';

View file

@ -2582,6 +2582,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get listFilter_all => '全部';
@override
String get listFilter_favorites => 'Favorites';
@override
String get listFilter_users => '用户';

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

@ -13,6 +13,7 @@ import '../connector/meshcore_protocol.dart';
import '../models/contact.dart';
import '../models/contact_group.dart';
import '../storage/contact_group_store.dart';
import '../storage/contact_settings_store.dart';
import '../utils/contact_search.dart';
import '../utils/dialog_utils.dart';
import '../utils/disconnect_navigation_mixin.dart';
@ -481,6 +482,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 +519,7 @@ class _ContactsScreenState extends State<ContactsScreen>
})
.where((group) {
if (_typeFilter == ContactTypeFilter.all) return true;
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.common_remove} ${context.l10n.listFilter_favorites}'
: '${context.l10n.common_add} ${context.l10n.listFilter_favorites}',
),
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,9 @@ 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

@ -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;

View file

@ -9,6 +9,7 @@ import flutter_blue_plus_darwin
import flutter_local_notifications
import mobile_scanner
import package_info_plus
import path_provider_foundation
import share_plus
import shared_preferences_foundation
import sqflite_darwin
@ -20,6 +21,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))

View file

@ -1 +1,53 @@
{}
{
"bg": [
"listFilter_favorites"
],
"es": [
"listFilter_favorites"
],
"fr": [
"listFilter_favorites"
],
"it": [
"listFilter_favorites"
],
"nl": [
"listFilter_favorites"
],
"pl": [
"listFilter_favorites"
],
"pt": [
"listFilter_favorites"
],
"ru": [
"listFilter_favorites"
],
"sk": [
"listFilter_favorites"
],
"sl": [
"listFilter_favorites"
],
"sv": [
"listFilter_favorites"
],
"uk": [
"listFilter_favorites"
],
"zh": [
"listFilter_favorites"
]
}