diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 6dc8581..005197f 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -28,6 +28,21 @@ extension ClockFormatValue on ClockFormat { } } +enum ChannelNotifyMode { all, mentionsOnly, off } + +extension ChannelNotifyModeValue on ChannelNotifyMode { + String get value { + switch (this) { + case ChannelNotifyMode.all: + return 'all'; + case ChannelNotifyMode.mentionsOnly: + return 'mentions'; + case ChannelNotifyMode.off: + return 'off'; + } + } +} + const Map defaultCyr2LatCharMap = { 'А': 'A', 'В': 'B', @@ -123,7 +138,17 @@ class AppSettings { final Map batteryChemistryByDeviceId; final Map batteryChemistryByRepeaterId; final UnitSystem unitSystem; + + /// Legacy channel-name-keyed mute set. Superseded by [channelNotifyModes], + /// which is keyed by PSK identity. Still read as a fallback and still + /// written, so an older build can be rolled back to without losing mutes. final Set mutedChannels; + + /// Per-channel notification level, keyed by [channelNotifyKey] — the PSK + /// identity when known, else the slot index. Keying by PSK (not by display + /// name, and not by slot) means the setting survives a rename and does not + /// bleed across a slot reassignment (#193/#194). + final Map channelNotifyModes; final bool mapShowDiscoveryContacts; final String tcpServerAddress; final int tcpServerPort; @@ -146,6 +171,17 @@ class AppSettings { return profile.charMap; } + /// Storage key for a channel's notify mode. + /// + /// Mirrors the identity scheme [ChannelMessageStore] uses for history (#194): + /// the PSK hex when the channel is synced and its key is known, else the slot + /// index as a fallback. The `psk_` marker keeps the two spaces from colliding + /// (a PSK is 32 hex chars; an index is a small integer). + static String channelNotifyKey({required int channelIndex, String? pskHex}) { + if (pskHex != null && pskHex.isNotEmpty) return 'psk_$pskHex'; + return '$channelIndex'; + } + AppSettings({ this.clearPathOnMaxRetry = false, this.mapShowRepeaters = true, @@ -179,6 +215,7 @@ class AppSettings { Map? batteryChemistryByRepeaterId, this.unitSystem = UnitSystem.metric, Set? mutedChannels, + Map? channelNotifyModes, this.mapShowDiscoveryContacts = true, this.tcpServerAddress = '', this.tcpServerPort = 0, @@ -195,6 +232,7 @@ class AppSettings { }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}, batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {}, mutedChannels = mutedChannels ?? {}, + channelNotifyModes = channelNotifyModes ?? {}, translationDownloadedModels = translationDownloadedModels ?? const [], cyr2latProfiles = cyr2latProfiles ?? @@ -241,6 +279,9 @@ class AppSettings { 'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId, 'unit_system': unitSystem.value, 'muted_channels': mutedChannels.toList(), + 'channel_notify_modes': channelNotifyModes.map( + (key, mode) => MapEntry(key, mode.value), + ), 'map_show_discovery_contacts': mapShowDiscoveryContacts, 'tcp_server_address': tcpServerAddress, 'tcp_server_port': tcpServerPort, @@ -280,6 +321,17 @@ class AppSettings { } } + ChannelNotifyMode parseChannelNotifyMode(dynamic value) { + switch (value) { + case 'mentions': + return ChannelNotifyMode.mentionsOnly; + case 'off': + return ChannelNotifyMode.off; + default: + return ChannelNotifyMode.all; + } + } + return AppSettings( clearPathOnMaxRetry: json['clear_path_on_max_retry'] as bool? ?? false, mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true, @@ -334,6 +386,16 @@ class AppSettings { ?.map((e) => e.toString()) .toSet()) ?? {}, + // Legacy `muted_channels` is not folded in here: it is name-keyed and a + // name cannot be resolved to a PSK at the data layer. AppSettingsService + // reads it as a fallback instead, so a pre-existing mute still reports + // `off` until the user sets a mode explicitly. + channelNotifyModes: + (json['channel_notify_modes'] as Map?)?.map( + (key, value) => + MapEntry(key.toString(), parseChannelNotifyMode(value)), + ) ?? + {}, mapShowDiscoveryContacts: json['map_show_discovery_contacts'] as bool? ?? true, tcpServerAddress: json['tcp_server_address'] as String? ?? '', @@ -427,6 +489,7 @@ class AppSettings { Map? batteryChemistryByRepeaterId, UnitSystem? unitSystem, Set? mutedChannels, + Map? channelNotifyModes, bool? mapShowDiscoveryContacts, String? tcpServerAddress, int? tcpServerPort, @@ -485,6 +548,7 @@ class AppSettings { batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId, unitSystem: unitSystem ?? this.unitSystem, mutedChannels: mutedChannels ?? this.mutedChannels, + channelNotifyModes: channelNotifyModes ?? this.channelNotifyModes, mapShowDiscoveryContacts: mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts, tcpServerAddress: tcpServerAddress ?? this.tcpServerAddress, diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index 7fc09ca..5e3d8c3 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -208,6 +208,54 @@ class AppSettingsService extends ChangeNotifier { await updateSettings(_settings.copyWith(unitSystem: value)); } + /// Per-channel notification level. + /// + /// Resolution order: the PSK-keyed mode when one is set, else the legacy + /// name-keyed mute (so a channel muted before this feature existed still + /// reports [ChannelNotifyMode.off]), else [ChannelNotifyMode.all]. + /// + /// [identityKey] comes from [AppSettings.channelNotifyKey]; the caller + /// resolves the slot index to a PSK. + ChannelNotifyMode channelNotifyMode({ + required String identityKey, + required String channelName, + }) { + final mode = _settings.channelNotifyModes[identityKey]; + if (mode != null) return mode; + if (_settings.mutedChannels.contains(channelName)) { + return ChannelNotifyMode.off; + } + return ChannelNotifyMode.all; + } + + Future setChannelNotifyMode({ + required String identityKey, + required String channelName, + required ChannelNotifyMode mode, + }) async { + final modes = Map.from( + _settings.channelNotifyModes, + ); + if (mode == ChannelNotifyMode.all) { + modes.remove(identityKey); // `all` is the default; don't store it. + } else { + modes[identityKey] = mode; + } + + // Keep the legacy name-keyed set in sync so rolling back to a build without + // notify modes still honours an Off channel. Remove after one release. + final muted = Set.from(_settings.mutedChannels); + if (mode == ChannelNotifyMode.off) { + muted.add(channelName); + } else { + muted.remove(channelName); + } + + await updateSettings( + _settings.copyWith(channelNotifyModes: modes, mutedChannels: muted), + ); + } + bool isChannelMuted(String channelName) { return _settings.mutedChannels.contains(channelName); }