Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,9 @@ option via `optionSinceAppVersion`, e.g. `"1.2.0"`): tags
when a filter or parameter shipped, purely so `WhatsNewService` can draw a
"NEW" badge next to it — persisting across every launch of the current app
version, and only advancing on the next real update, not clearing after one
run. **Stamp it in the same change that adds or modifies the property, using
run. A patch-only update (1.5.0 → 1.5.1) is not one: the baseline moves only
when major or minor changes, so a bugfix release keeps the feature release's
badges. **Stamp it in the same change that adds or modifies the property, using
the version that change will actually ship in** (a minor bump on the latest
public release, or whatever's already in `app/pubspec.yaml` if that's ahead)
— not retroactively at release time, which is exactly how properties get
Expand Down
24 changes: 24 additions & 0 deletions app/lib/services/whats_new_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ import '../models/filter_schema.dart';
/// is otherwise left untouched — which is what makes badges survive many
/// launches of the same version instead of clearing after one.
///
/// A patch release (1.5.0 -> 1.5.1) is not an update for this purpose: the
/// baseline only moves when the major or minor version changes. A bugfix
/// release shipped days after 1.5.0 would otherwise clear 1.5.0's badges
/// before most users had seen them; instead 1.5.1 keeps showing everything
/// since the version before 1.5.0, plus anything stamped 1.5.1.
///
/// A fresh install (or an upgrade from a build that predates this tracking,
/// which looks the same — no `lastRunAppVersion` stored yet) flags nothing as
/// new: both markers are seeded to the current version, so there's no earlier
Expand Down Expand Up @@ -54,6 +60,12 @@ class WhatsNewService {
// Nothing to diff against yet — seed both markers to now.
_lastSeenVersion = currentVersion;
await prefs.setString(_baselineKey, currentVersion);
} else if (lastRunVersion != currentVersion &&
_isPatchOnly(lastRunVersion, currentVersion)) {
// A bugfix release: keep the baseline the last feature release set,
// so its badges outlive the fix that followed it.
_lastSeenVersion = storedBaseline ?? lastRunVersion;
await prefs.setString(_baselineKey, _lastSeenVersion!);
} else if (lastRunVersion != currentVersion) {
// The version actually changed since the last launch: this is the
// one moment the baseline moves, to whatever was running just
Expand Down Expand Up @@ -149,6 +161,18 @@ class WhatsNewService {
}
}

/// Whether [from] -> [to] changes only the patch component (same major and
/// minor), the kind of update that leaves the badge baseline alone.
bool _isPatchOnly(String from, String to) {
List<int> majorMinor(String v) {
final parts = v.split('.').map((p) => int.tryParse(p.trim()) ?? 0).toList();
return [parts.isNotEmpty ? parts[0] : 0, parts.length > 1 ? parts[1] : 0];
}

final a = majorMinor(from), b = majorMinor(to);
return a[0] == b[0] && a[1] == b[1];
}

/// Compare two dotted version strings numerically ("1.10.0" > "1.9.0").
/// Returns positive if [a] > [b], negative if [a] < [b], zero if equal.
/// Non-numeric or differing-length components degrade to a component-wise
Expand Down
75 changes: 75 additions & 0 deletions app/test/whats_new_service_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,81 @@ void main() {
expect(prefs.getString('lastRunAppVersion'), '1.3.0');
});

test('a patch release keeps the previous feature release\'s badges',
() async {
// 1.4.2 -> 1.5.0 set the baseline to 1.4.2; now 1.5.1 ships a fix.
SharedPreferences.setMockInitialValues({
'lastRunAppVersion': '1.5.0',
'lastSeenAppVersion': '1.4.2',
});
setCurrentVersion('1.5.1');
await service.initialize();

expect(service.isNew('1.5.0'), true,
reason: 'a bugfix release must not clear what 1.5.0 added');
expect(service.isNew('1.5.1'), true, reason: 'anything the fix itself added');
expect(service.isNew('1.4.2'), false);

final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('lastSeenAppVersion'), '1.4.2');
expect(prefs.getString('lastRunAppVersion'), '1.5.1');
});

test('several patch releases in a row keep the same baseline', () async {
SharedPreferences.setMockInitialValues({
'lastRunAppVersion': '1.5.0',
'lastSeenAppVersion': '1.4.2',
});
for (final version in ['1.5.1', '1.5.2', '1.5.10']) {
service.resetForTesting();
setCurrentVersion(version);
await service.initialize();
expect(service.isNew('1.5.0'), true, reason: 'still new on $version');
}
});

test('the next feature release after patches advances past all of them',
() async {
SharedPreferences.setMockInitialValues({
'lastRunAppVersion': '1.5.2',
'lastSeenAppVersion': '1.4.2',
});
setCurrentVersion('1.6.0');
await service.initialize();

expect(service.isNew('1.5.0'), false);
expect(service.isNew('1.5.2'), false);
expect(service.isNew('1.6.0'), true);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('lastSeenAppVersion'), '1.5.2');
});

test('a major release with the same minor number still advances', () async {
SharedPreferences.setMockInitialValues({
'lastRunAppVersion': '1.5.1',
'lastSeenAppVersion': '1.4.0',
});
setCurrentVersion('2.5.0');
await service.initialize();

expect(service.isNew('1.5.0'), false);
expect(service.isNew('2.5.0'), true);
});

test('jumping straight to a patch of a new feature release still badges it',
() async {
// Skipped 1.5.0 entirely: 1.4.2 -> 1.5.1 crosses a minor boundary.
SharedPreferences.setMockInitialValues({
'lastRunAppVersion': '1.4.2',
'lastSeenAppVersion': '1.3.0',
});
setCurrentVersion('1.5.1');
await service.initialize();

expect(service.isNew('1.5.0'), true);
expect(service.isNew('1.4.2'), false);
});

test('untagged (null) is never new', () async {
SharedPreferences.setMockInitialValues({
'lastRunAppVersion': '1.1.0',
Expand Down
5 changes: 5 additions & 0 deletions docs/FILTER_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,11 @@ schemas; extend it when stamping a release's additions.
just happened), then holds steady through as many future launches as the
user stays on that version. There's no "mark as seen" action to wire up, and
no badge that silently disappears after someone glances at the app once.
- **A patch release doesn't count as an update.** The baseline moves only
when the major or minor version changes, so 1.5.0 → 1.5.1 keeps every badge
1.5.0 showed (and adds anything stamped `1.5.1`); the next feature release,
1.6.0, then clears them all. A bugfix shipped days after a feature release
would otherwise wipe its badges before most users saw them.
- **A fresh install sees no badges at all.** There's no "since you last
updated" for someone who has never had a previous version, so a first
launch would otherwise badge the entire app.
Expand Down
Loading