feat(#260): ChannelNotifyMode model + PSK-keyed storage

Adds ChannelNotifyMode { all, mentionsOnly, off } and a channelNotifyModes
map keyed by PSK identity via AppSettings.channelNotifyKey, mirroring the
psk_ marker scheme ChannelMessageStore uses for history (#194). Keying by
PSK rather than display name means the setting survives a channel rename
and does not bleed across a slot reassignment (#193).

Legacy muted_channels stays readable and keeps being written:
AppSettingsService.channelNotifyMode falls back to it by name so a channel
muted before this feature still reports off, and setChannelNotifyMode keeps
it in sync so rolling back to a build without notify modes still honours an
Off channel. Name cannot be resolved to a PSK at the data layer, so the
migration is a read-through fallback rather than an upfront pass.

The notification gate and Channels UI still use the legacy isChannelMuted
path; #261 and #262 switch them over.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pull/266/head
Strycher 5 days ago
parent 919f77e67e
commit b2f7551c83

@ -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<String, String> defaultCyr2LatCharMap = {
'А': 'A',
'В': 'B',
@ -123,7 +138,17 @@ class AppSettings {
final Map<String, String> batteryChemistryByDeviceId;
final Map<String, String> 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<String> 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<String, ChannelNotifyMode> 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<String, String>? batteryChemistryByRepeaterId,
this.unitSystem = UnitSystem.metric,
Set<String>? mutedChannels,
Map<String, ChannelNotifyMode>? 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<String, String>? batteryChemistryByRepeaterId,
UnitSystem? unitSystem,
Set<String>? mutedChannels,
Map<String, ChannelNotifyMode>? 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,

@ -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<void> setChannelNotifyMode({
required String identityKey,
required String channelName,
required ChannelNotifyMode mode,
}) async {
final modes = Map<String, ChannelNotifyMode>.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<String>.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);
}

Loading…
Cancel
Save

Powered by TurnKey Linux.