diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 05c82de..c376a4a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index afd1626..ef19f02 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -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 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 _fetchContactSnapshotFromDevice( + Uint8List pubKey, { + Duration timeout = const Duration(seconds: 3), + }) async { + if (!isConnected) return null; + final expectedKeyHex = pubKeyToHex(pubKey); + final completer = Completer(); + + 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 setPathOverride( diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 2933e80..d5ce9ee 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -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; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index a1647fa..f596ae4 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index eec03bc..5b15b65 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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" -} \ No newline at end of file +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 54e9cdc..5d6a6a2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 21a6e79..513cdc3 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -2728,6 +2728,9 @@ class AppLocalizationsBg extends AppLocalizations { @override String get listFilter_all => 'Всички'; + @override + String get listFilter_favorites => 'Favorites'; + @override String get listFilter_users => 'Потребители'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index e5a3a49..bada9f6 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a56e217..b090c45 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 98cd658..ac51330 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -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'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index a52ff00..3ca86e6 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index aebea2f..c9900c7 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -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'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 5460d29..8c537f2 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -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'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 7286033..2c11c82 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -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'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 025c81c..c2fac6c 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 399b158..9fde3b8 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2730,6 +2730,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get listFilter_all => 'Все'; + @override + String get listFilter_favorites => 'Favorites'; + @override String get listFilter_users => 'Пользователи'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 5138311..5d31462 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -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'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index f42e8e0..19934e8 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -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'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index ba99455..04ae835 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -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'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index e2bbbe8..1c3442d 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -2737,6 +2737,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get listFilter_all => 'Все'; + @override + String get listFilter_favorites => 'Favorites'; + @override String get listFilter_users => 'Користувачі'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 4da17ea..6a9881e 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2582,6 +2582,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get listFilter_all => '全部'; + @override + String get listFilter_favorites => 'Favorites'; + @override String get listFilter_users => '用户'; diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 143a62a..5e532e6 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -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, diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index c3f783c..08e3e14 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -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 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 }) .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 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 ) { 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 }, ), ], + 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]), ], diff --git a/lib/storage/contact_store.dart b/lib/storage/contact_store.dart index 08d158b..504ff16 100644 --- a/lib/storage/contact_store.dart +++ b/lib/storage/contact_store.dart @@ -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)) diff --git a/lib/widgets/list_filter_widget.dart b/lib/widgets/list_filter_widget.dart index e9c0d9e..473a3df 100644 --- a/lib/widgets/list_filter_widget.dart +++ b/lib/widgets/list_filter_widget.dart @@ -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; diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d2ea57e..4084d9b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) diff --git a/untranslated.json b/untranslated.json index 9e26dfe..a6d2937 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1 +1,53 @@ -{} \ No newline at end of file +{ + "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" + ] +}