fix(mobile): preserve untouched filters during sort edits

Adopt late preference loads until Show or duration is edited, and publish filters only for filter edits. Extend the existing section sheet journey through a signed late load and sort-only Apply.

Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-10-20 15:06:08 -07:00
parent 33bf8c19d9
commit f38bf9b273
2 changed files with 68 additions and 10 deletions

View File

@@ -58,12 +58,12 @@ class _SectionViewSheet extends HookConsumerWidget {
final days = useState(
(manager?.store.windowFor(group) ?? defaultActiveWindowSeconds) ~/ 86400,
);
final dirty = useState(false);
final filterDirty = useState(false);
final saving = useState(false);
final failed = useState(false);
// A confirmed initial load may arrive while the sheet is open. Preserve edits.
useEffect(() {
if (!dirty.value && !saving.value) {
if (!filterDirty.value && !saving.value) {
mode.value = manager?.store.modeFor(group) ?? ChannelFilterMode.all;
days.value =
(manager?.store.windowFor(group) ?? defaultActiveWindowSeconds) ~/
@@ -105,7 +105,7 @@ class _SectionViewSheet extends HookConsumerWidget {
(manager?.store.modeFor(group) ?? ChannelFilterMode.all) ||
(mode.value == ChannelFilterMode.active &&
days.value * 86400 != manager?.store.windowFor(group));
if (filterChanged) {
if (filterDirty.value && filterChanged) {
if (manager == null || !manager.ready || manager.busy) {
throw StateError('Preferences are read-only');
}
@@ -157,7 +157,6 @@ class _SectionViewSheet extends HookConsumerWidget {
onTap: change == null
? null
: () {
dirty.value = true;
change(entry.key);
},
),
@@ -208,7 +207,12 @@ class _SectionViewSheet extends HookConsumerWidget {
ChannelFilterMode.active: 'Active',
},
mode.value,
writable ? (value) => mode.value = value : null,
writable
? (value) {
filterDirty.value = true;
mode.value = value;
}
: null,
),
if (mode.value == ChannelFilterMode.active)
choices<int>(
@@ -218,7 +222,12 @@ class _SectionViewSheet extends HookConsumerWidget {
day: '$day ${day == 1 ? 'day' : 'days'}',
},
days.value,
writable ? (value) => days.value = value : null,
writable
? (value) {
filterDirty.value = true;
days.value = value;
}
: null,
),
if (manager?.ready != true)
const Text(

View File

@@ -10,6 +10,7 @@ import 'package:buzz/features/pairing/pairing_provider.dart';
import 'package:buzz/shared/community/paired_community_landing.dart';
import 'dart:async';
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
@@ -253,7 +254,32 @@ void main() {
testWidgets(
'section view stages choices until Apply and reopens saved duration',
(tester) async {
final manager = _filterTestManager()..ready = true;
var publications = 0;
final keys = nostr.Keys.generate();
final crypto = ChannelSortCrypto(keys.nsec, keys.public);
final remote = NostrEvent.fromJson(
nostr.Event.from(
kind: EventKind.readState,
tags: [
['d', 'channel-filter'],
],
content: crypto.encrypt(
jsonEncode(
ChannelFilterStore.empty()
.edit('channels', ChannelFilterMode.unreads)
.toJson(),
),
),
secretKey: nostr.Nip19.decode(payload: keys.nsec).data,
createdAt: 10,
verify: false,
).toMap(),
);
final manager = _filterTestManager(
fetch: () async => [remote],
testKeys: keys,
onPublish: () => publications++,
);
await tester.pumpWidget(
buildTestable(
overrides: [
@@ -265,12 +291,32 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Channels options'));
await tester.pumpAndSettle();
// Sort is available while preferences load. A late verified load must
// update the untouched filter draft and never cause an All publication.
await tester.tap(find.text('Recent'));
await tester.pumpAndSettle();
await manager.refresh();
await tester.pumpAndSettle();
expect(manager.store.modeFor('channels'), ChannelFilterMode.unreads);
final unreads = tester.widget<AppListRow>(
find.ancestor(
of: find.text('Unreads'),
matching: find.byType(AppListRow),
),
);
expect(unreads.trailing, isA<Icon>());
await tester.tap(find.text('Apply'));
await tester.pumpAndSettle();
expect(publications, 0);
expect(manager.store.modeFor('channels'), ChannelFilterMode.unreads);
await tester.tap(find.byTooltip('Channels options'));
await tester.pumpAndSettle();
await tester.tap(find.text('Active'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('7 days'));
await tester.tap(find.text('7 days'));
await tester.pumpAndSettle();
expect(manager.store.modeFor('channels'), ChannelFilterMode.all);
expect(manager.store.modeFor('channels'), ChannelFilterMode.unreads);
await tester.tap(find.text('Apply'));
await tester.pumpAndSettle();
expect(manager.store.modeFor('channels'), ChannelFilterMode.active);
@@ -4414,11 +4460,13 @@ class _CompletedPairingNotifier extends PairingNotifier {
}
ChannelFilterManager _filterTestManager({
Future<List<NostrEvent>> Function()? fetch,
nostr.Keys? testKeys,
ChannelFilterStore? cached,
bool failPublish = false,
VoidCallback? onPublish,
}) {
final keys = nostr.Keys.generate();
final keys = testKeys ?? nostr.Keys.generate();
final crypto = ChannelSortCrypto(keys.nsec, keys.public);
var history = <NostrEvent>[];
return ChannelFilterManager(
@@ -4426,7 +4474,8 @@ ChannelFilterManager _filterTestManager({
crypto: crypto,
cached: cached,
cache: (_) {},
fetch: () async => history,
fetch: () async =>
history.isEmpty && fetch != null ? await fetch() : history,
publish: (content, at) async {
onPublish?.call();
if (failPublish) throw StateError('rejected');