mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: UX polish, localization, advert relocation, and map filter fixes
- i18n: translate connection screen + chat tab keys into 13 locales; add "Send my contact" (advert) strings in all locales - feat: move self-advert from home header into composer "+" action menu (new lib/utils/advert_helper.dart, always shows Flood/Direct sheet) - fix: hide-repeaters map toggle was bypassed in simple mode - fix: simple mode map now shows only favourite chat contacts - fix: wrap ListTiles in Material (contact_tile secondary actions, inferred contact group card) to satisfy Flutter ink assertions - device-authoritative contact/channel sync and UX review fixes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -196,7 +196,8 @@ class AppProvider with ChangeNotifier {
|
||||
bool _isInitialized = false;
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
bool get isSimpleMode => true;
|
||||
bool _isSimpleMode = false;
|
||||
bool get isSimpleMode => _isSimpleMode;
|
||||
|
||||
bool _isMapEnabled = true;
|
||||
bool get isMapEnabled => _isMapEnabled;
|
||||
@@ -283,6 +284,7 @@ class AppProvider with ChangeNotifier {
|
||||
_canStartAutomaticMessageSync;
|
||||
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
|
||||
_initializeLocationTracking();
|
||||
_loadSimpleMode();
|
||||
_loadMapEnabled();
|
||||
_loadContactsEnabled();
|
||||
_loadContactsSectionVisibility();
|
||||
@@ -593,6 +595,29 @@ class AppProvider with ChangeNotifier {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Load simple mode setting from shared preferences
|
||||
Future<void> _loadSimpleMode() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_isSimpleMode = prefs.getBool(_scopedKey('simple_mode_enabled')) ?? false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error loading simple mode setting: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle simple mode on/off
|
||||
Future<void> toggleSimpleMode(bool enabled) async {
|
||||
try {
|
||||
_isSimpleMode = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_scopedKey('simple_mode_enabled'), enabled);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error saving simple mode setting: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Load map enabled setting from shared preferences
|
||||
Future<void> _loadMapEnabled() async {
|
||||
try {
|
||||
@@ -1141,9 +1166,22 @@ class AppProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
if (isEmptyChannelInfo) {
|
||||
// The device channel list is authoritative: an empty slot means
|
||||
// any locally retained channel in that slot was deleted on the
|
||||
// device, so drop it from the channel list and the contacts UI.
|
||||
// Message history is kept (only app-initiated deletes clear it).
|
||||
debugPrint(
|
||||
' ⏭️ Ignoring empty channel slot $channelIdx during sync',
|
||||
' 🧹 Empty channel slot $channelIdx - dropping stale local channel if present',
|
||||
);
|
||||
channelsProvider.removeChannel(channelIdx);
|
||||
|
||||
final publicKeyBytes = Uint8List(32);
|
||||
publicKeyBytes[0] = 0xFF; // Special marker for channels
|
||||
publicKeyBytes[1] = channelIdx; // Channel index
|
||||
final publicKeyHex = publicKeyBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
contactsProvider.removeContact(publicKeyHex);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2018,9 +2056,8 @@ class AppProvider with ChangeNotifier {
|
||||
|
||||
final latestContact =
|
||||
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
|
||||
final manualSelection = await _pathHistoryService.getManualSelectionForContact(
|
||||
latestContact,
|
||||
);
|
||||
final manualSelection = await _pathHistoryService
|
||||
.getManualSelectionForContact(latestContact);
|
||||
final session =
|
||||
_directMessageRouteSessions[messageId] ??
|
||||
_DirectMessageRouteSession(
|
||||
@@ -2548,8 +2585,13 @@ class AppProvider with ChangeNotifier {
|
||||
// Get battery and storage information
|
||||
await connectionProvider.getBatteryAndStorage();
|
||||
|
||||
// Load contacts (waits for device to finish sending all contacts)
|
||||
await connectionProvider.getContacts();
|
||||
// Load contacts (waits for device to finish sending all contacts).
|
||||
// The device list is authoritative: contacts it no longer has are
|
||||
// deleted locally once a complete list was received.
|
||||
final contactListComplete = await connectionProvider.getContacts();
|
||||
contactsProvider.finalizeDeviceContactSync(
|
||||
deviceListComplete: contactListComplete,
|
||||
);
|
||||
|
||||
// Sync all channels so slot assignment and channel state mirror the device.
|
||||
final channelsToSync = connectionProvider.deviceInfo.maxChannels;
|
||||
@@ -2567,12 +2609,10 @@ class AppProvider with ChangeNotifier {
|
||||
debugPrint(
|
||||
'🔄 [AppProvider] Performing initial message sync (fallback for missed pushes)',
|
||||
);
|
||||
final initialMessageCount =
|
||||
await messagesProvider.withReceivedNotificationsSuppressed(
|
||||
() => connectionProvider.syncAllMessages(
|
||||
force: true,
|
||||
),
|
||||
);
|
||||
final initialMessageCount = await messagesProvider
|
||||
.withReceivedNotificationsSuppressed(
|
||||
() => connectionProvider.syncAllMessages(force: true),
|
||||
);
|
||||
debugPrint(
|
||||
'📥 [AppProvider] Initial sync retrieved $initialMessageCount message(s)',
|
||||
);
|
||||
@@ -2624,18 +2664,19 @@ class AppProvider with ChangeNotifier {
|
||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||
);
|
||||
channelsProvider.prepareForDeviceSync();
|
||||
await connectionProvider.getContacts();
|
||||
final contactListComplete = await connectionProvider.getContacts();
|
||||
contactsProvider.finalizeDeviceContactSync(
|
||||
deviceListComplete: contactListComplete,
|
||||
);
|
||||
await connectionProvider.syncChannels(
|
||||
maxChannels: connectionProvider.deviceInfo.maxChannels,
|
||||
);
|
||||
await refreshChannelLocationSharingState();
|
||||
|
||||
final messageCount =
|
||||
await messagesProvider.withReceivedNotificationsSuppressed(
|
||||
() => connectionProvider.syncAllMessages(
|
||||
force: true,
|
||||
),
|
||||
);
|
||||
final messageCount = await messagesProvider
|
||||
.withReceivedNotificationsSuppressed(
|
||||
() => connectionProvider.syncAllMessages(force: true),
|
||||
);
|
||||
debugPrint(
|
||||
'📥 [AppProvider] Reconnect sync retrieved $messageCount message(s)',
|
||||
);
|
||||
@@ -2881,7 +2922,8 @@ class AppProvider with ChangeNotifier {
|
||||
final sharingMode = channelLocationSharingModeForChannel(channelIdx);
|
||||
|
||||
return ChannelLocationSharingState(
|
||||
mode: sharingMode ??
|
||||
mode:
|
||||
sharingMode ??
|
||||
(_hardwareChannelLocationSharingSupported
|
||||
? ChannelLocationSharingMode.hardware
|
||||
: ChannelLocationSharingMode.appFallback),
|
||||
@@ -2923,7 +2965,9 @@ class AppProvider with ChangeNotifier {
|
||||
await locationTrackingService.setFastLocationUpdatesEnabled(false);
|
||||
await refreshChannelLocationSharingState();
|
||||
if (_hardwareChannelLocationSharingChannelIdx != channelIdx) {
|
||||
throw StateError('Radio location sharing did not enable for this channel');
|
||||
throw StateError(
|
||||
'Radio location sharing did not enable for this channel',
|
||||
);
|
||||
}
|
||||
notifyListeners();
|
||||
return ChannelLocationSharingResult(
|
||||
@@ -2933,13 +2977,16 @@ class AppProvider with ChangeNotifier {
|
||||
hardwareSupported: true,
|
||||
isConnected: true,
|
||||
),
|
||||
message: l10n?.sharingLocationFromRadio ?? 'Sharing location on this channel from the radio.',
|
||||
message:
|
||||
l10n?.sharingLocationFromRadio ??
|
||||
'Sharing location on this channel from the radio.',
|
||||
);
|
||||
}
|
||||
|
||||
_hardwareChannelLocationSharingSupported = false;
|
||||
_hardwareChannelLocationSharingChannelIdx = null;
|
||||
final previousEnabled = locationTrackingService.fastLocationUpdatesEnabled;
|
||||
final previousEnabled =
|
||||
locationTrackingService.fastLocationUpdatesEnabled;
|
||||
final previousChannelIdx = locationTrackingService.fastLocationChannelIdx;
|
||||
try {
|
||||
await locationTrackingService.updateFastLocationChannelIdx(channelIdx);
|
||||
@@ -2964,12 +3011,14 @@ class AppProvider with ChangeNotifier {
|
||||
state: const ChannelLocationSharingState(
|
||||
mode: ChannelLocationSharingMode.appFallback,
|
||||
isSharing: true,
|
||||
hardwareSupported: false,
|
||||
isConnected: true,
|
||||
),
|
||||
message: l10n?.sharingLocationFromPhone ?? 'Sharing location on this channel from the phone.',
|
||||
);
|
||||
}
|
||||
hardwareSupported: false,
|
||||
isConnected: true,
|
||||
),
|
||||
message:
|
||||
l10n?.sharingLocationFromPhone ??
|
||||
'Sharing location on this channel from the phone.',
|
||||
);
|
||||
}
|
||||
|
||||
final activeHardwareChannelIdx = _hardwareChannelLocationSharingIdxFromVars(
|
||||
vars,
|
||||
@@ -2991,7 +3040,9 @@ class AppProvider with ChangeNotifier {
|
||||
_hardwareChannelLocationSharingChannelIdx = null;
|
||||
await refreshChannelLocationSharingState();
|
||||
if (_hardwareChannelLocationSharingChannelIdx == channelIdx) {
|
||||
throw StateError('Radio location sharing is still enabled for this channel');
|
||||
throw StateError(
|
||||
'Radio location sharing is still enabled for this channel',
|
||||
);
|
||||
}
|
||||
notifyListeners();
|
||||
return ChannelLocationSharingResult(
|
||||
@@ -3001,7 +3052,9 @@ class AppProvider with ChangeNotifier {
|
||||
hardwareSupported: true,
|
||||
isConnected: true,
|
||||
),
|
||||
message: l10n?.stoppedSharingLocation ?? 'Stopped sharing location on this channel.',
|
||||
message:
|
||||
l10n?.stoppedSharingLocation ??
|
||||
'Stopped sharing location on this channel.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3015,7 +3068,9 @@ class AppProvider with ChangeNotifier {
|
||||
hardwareSupported: false,
|
||||
isConnected: true,
|
||||
),
|
||||
message: l10n?.stoppedSharingLocation ?? 'Stopped sharing location on this channel.',
|
||||
message:
|
||||
l10n?.stoppedSharingLocation ??
|
||||
'Stopped sharing location on this channel.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4093,8 +4148,15 @@ class AppProvider with ChangeNotifier {
|
||||
if (!connectionProvider.deviceInfo.isConnected) return;
|
||||
|
||||
try {
|
||||
// Sync contacts
|
||||
await connectionProvider.getContacts();
|
||||
// Sync contacts. The device list is authoritative: contacts it no
|
||||
// longer has are deleted locally once a complete list was received.
|
||||
await contactsProvider.prepareForDeviceContactSync(
|
||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||
);
|
||||
final contactListComplete = await connectionProvider.getContacts();
|
||||
contactsProvider.finalizeDeviceContactSync(
|
||||
deviceListComplete: contactListComplete,
|
||||
);
|
||||
|
||||
// Sync all channels so refresh reflects the full device state.
|
||||
channelsProvider.prepareForDeviceSync();
|
||||
|
||||
@@ -163,6 +163,10 @@ class ConnectionProvider with ChangeNotifier {
|
||||
// Completer to wait for contacts sync to finish
|
||||
Completer<void>? _contactsSyncCompleter;
|
||||
|
||||
// True only when the device signaled end-of-contacts for the last sync
|
||||
// (false on timeout or disconnect, i.e. a possibly partial list).
|
||||
bool _contactsSyncEndedNormally = false;
|
||||
|
||||
// Lightweight guards for other commands that can be double-tapped
|
||||
bool _isLoginInProgress = false;
|
||||
DateTime? _lastLoginRequestedAt;
|
||||
@@ -444,6 +448,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
service.onContactsComplete = (contacts) {
|
||||
debugPrint('📥 [Provider] Contacts sync complete: ${contacts.length}');
|
||||
_isContactsSyncInProgress = false;
|
||||
_contactsSyncEndedNormally = true;
|
||||
if (_contactsSyncCompleter != null &&
|
||||
!_contactsSyncCompleter!.isCompleted) {
|
||||
_contactsSyncCompleter!.complete();
|
||||
@@ -992,15 +997,20 @@ class ConnectionProvider with ChangeNotifier {
|
||||
///
|
||||
/// Waits for the device to finish sending all contacts (up to 5 s timeout)
|
||||
/// so callers don't need an arbitrary delay.
|
||||
Future<void> getContacts() async {
|
||||
///
|
||||
/// Returns true only when the device signaled end-of-contacts, i.e. the
|
||||
/// received list is complete and can be treated as authoritative. Returns
|
||||
/// false on timeout, disconnect, or error (possibly partial list).
|
||||
Future<bool> getContacts() async {
|
||||
if (!_activeService.isConnected) {
|
||||
_error = 'Not connected to device';
|
||||
notifyListeners();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
_isContactsSyncInProgress = true;
|
||||
_contactsSyncEndedNormally = false;
|
||||
_contactsSyncCompleter = Completer<void>();
|
||||
await _activeService.getContacts();
|
||||
await _contactsSyncCompleter!.future.timeout(
|
||||
@@ -1011,10 +1021,12 @@ class ConnectionProvider with ChangeNotifier {
|
||||
);
|
||||
},
|
||||
);
|
||||
return _contactsSyncEndedNormally;
|
||||
} catch (e) {
|
||||
_isContactsSyncInProgress = false;
|
||||
_error = 'Failed to get contacts: $e';
|
||||
notifyListeners();
|
||||
return false;
|
||||
} finally {
|
||||
_isContactsSyncInProgress = false;
|
||||
_contactsSyncCompleter = null;
|
||||
|
||||
@@ -303,6 +303,45 @@ class ContactsProvider with ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
/// Finish a device contact sync started with [prepareForDeviceContactSync].
|
||||
///
|
||||
/// The device contact list is authoritative: when [deviceListComplete] is
|
||||
/// true, any contact still left in the retained snapshot (i.e. not sent by
|
||||
/// the device during this sync) is deleted locally. Channel pseudo-contacts
|
||||
/// are excluded — they mirror device channel slots and are reconciled by the
|
||||
/// channel sync instead. When the sync was partial (timeout/disconnect),
|
||||
/// nothing is deleted so a flaky link can't wipe valid contacts.
|
||||
void finalizeDeviceContactSync({required bool deviceListComplete}) {
|
||||
if (!deviceListComplete) {
|
||||
if (_retainedContactsForSync.isNotEmpty) {
|
||||
debugPrint(
|
||||
'ℹ️ [ContactsProvider] Partial contact sync - keeping '
|
||||
'${_retainedContactsForSync.length} unconfirmed contact(s)',
|
||||
);
|
||||
}
|
||||
_retainedContactsForSync.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
final staleKeys = _retainedContactsForSync.keys
|
||||
.where((key) => _contacts[key]?.type != ContactType.channel)
|
||||
.where(_contacts.containsKey)
|
||||
.toList();
|
||||
for (final key in staleKeys) {
|
||||
final removed = _contacts.remove(key);
|
||||
debugPrint(
|
||||
'🗑️ [ContactsProvider] Removing contact no longer on device: '
|
||||
'${removed?.advName} (${key.substring(0, 8)}...)',
|
||||
);
|
||||
}
|
||||
_retainedContactsForSync.clear();
|
||||
|
||||
if (staleKeys.isNotEmpty) {
|
||||
_persistContacts();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove self-contact from loaded contacts (called after BLE connection established)
|
||||
void _removeSelfContact(Uint8List devicePublicKey) {
|
||||
final selfKeyHex = devicePublicKey
|
||||
@@ -386,6 +425,10 @@ class ContactsProvider with ChangeNotifier {
|
||||
ContactTelemetry? get selfTelemetry => _selfTelemetry;
|
||||
List<Contact> get favouriteContacts =>
|
||||
_contacts.values.where((c) => c.isFavourite).toList();
|
||||
List<Contact> get favouriteChatContacts =>
|
||||
chatContacts.where((c) => c.isFavourite).toList();
|
||||
List<Contact> get favouriteContactsWithLocation =>
|
||||
favouriteContacts.where((c) => effectiveLocationFor(c) != null).toList();
|
||||
List<SavedContactGroup> get savedContactGroups =>
|
||||
List<SavedContactGroup>.from(_savedContactGroups)
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
|
||||
Reference in New Issue
Block a user