"Has this already happened this round?" — a one-time tip, a weekly nudge, a
changelog sheet that only opens on a new version. One key, one typed policy, one
record in SharedPreferences.
import 'package:once_new/once_new.dart';
if (await Once.claim('tip.longPress', const OncePolicy.every(Duration(days: 7)))) {
showTip();
}claim is ask-and-it-counts: the same call that answers true also writes the
mark. There is no separate "now remember it" step to forget on an early return.
This is a rewrite of once, keeping the idea
and fixing what the API shape made impossible to fix:
- The policy is a type, not a magic number. The original packs period,
sentinel and milliseconds into one
int:-1means monthly,-2means forever,-3means daily, and0 < n < 12is read as a month index — so the December "once a month" case,n == 12, falls through to the generic branch and becomes "run if more than 12 milliseconds have passed", i.e. every single call, all December.OncePolicyis a sealed class; that state isn't expressible. - Calendar policies store a full date. Daily stores
yyyy-MM-dd, not a 1–7 weekday, so someone who opens the app exactly every 7 days still fires. Monthly storesyyyy-MM, not a bare month number, so a year-long gap isn't skipped. - Token policies compare "changed", not "increased". A rollback (a pulled
TestFlight build, a halted Play staged rollout) should fire too. The original
strips non-digits and compares the result as an integer, which makes
1.0.10look newer than1.1.0. - A backwards clock heals itself. If the stored anchor sits in the future — the user moved the system clock, or NTP stepped back — the rolling window treats it as expired instead of staying shut until real time catches up.
- Only one dependency:
shared_preferences. Nopackage_info_plus. You pass the version string you already have, from wherever you already get it. - Store and clock are injectable.
Once.debugOverrideswaps in aMemoryOnceStoreand a fake clock, so the decision logic runs in a plain unit test that time-travels instead of waiting.
dependencies:
once_new: ^0.0.1Or run:
flutter pub add once_new| Policy | Fires when | Stored mark |
|---|---|---|
OncePolicy.once() |
Never again until the key is cleared. | '' (presence is the record) |
OncePolicy.every(Duration) |
period has elapsed since the last run, or the anchor is in the future. |
epoch milliseconds |
OncePolicy.everyDay() |
The local calendar day changed. | yyyy-MM-dd |
OncePolicy.everyMonth() |
The local calendar month changed. | yyyy-MM |
OncePolicy.everyYear() |
The local calendar year changed. | yyyy |
OncePolicy.onChange(token, runOnFirstSeen: false) |
token differs from the stored one. First sight of a token fires only if runOnFirstSeen is true. |
the token itself |
A first sight records the token even when it doesn't fire, so a fresh install stays quiet now and still fires on the next version — rather than being stuck in "never seen before" forever.
every is a rolling window anchored on the last actual run, so it drifts: fire
at 09:00 and the next one lands a little after 09:00 tomorrow. Want "once every
morning"? That's everyDay().
onChange is the changelog case:
final shouldShow = await Once.claim(
'whatsNew',
OncePolicy.onChange(packageInfo.version), // fresh installs stay quiet
);Each record is tagged with the policy it was written under, so changing the policy on a key starts a new record rather than reading a date string as a timestamp.
Once.claim is async and works at any time — it loads the records on first use.
Use it in callbacks, initState, route handlers, anywhere an await is fine.
When the answer has to be right in the very first frame, warm the cache once
before runApp and then decide synchronously in build:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Once.warmUp();
runApp(const MyApp());
}if (Once.claimSync('onboarding', const OncePolicy.once())) {
return const OnboardingPage();
}claimSync throws a StateError if warmUp never ran. Silently answering "not
done yet" would make the tip reappear on every cold start — a bug nobody enjoys
tracking down.
Writing the mark is still asynchronous (claimSync doesn't hand you the
future), but the in-memory record updates immediately, so a second ask in the
same run always answers false. All writes go through one queue, so a clear
issued right after a claimSync can't be undone by the late write landing
behind it. Once.flush() awaits the queue when you need the disk to be settled.
Need to display "next one is due" without consuming it? isDueSync asks without
recording. Don't use it as the gate — there's a window between asking and acting.
| Member | Signature | What it does |
|---|---|---|
claim |
Future<bool> claim(String key, OncePolicy policy) |
Asks and records in one step. Concurrent calls on the same key are serialized, so only one gets true. |
claimSync |
bool claimSync(String key, OncePolicy policy) |
Same decision, synchronously. Requires warmUp; throws StateError otherwise. |
isDueSync |
bool isDueSync(String key, OncePolicy policy) |
Asks without recording. Returns true when the cache isn't warm. |
run |
Future<OnceResult<T>> run<T>(String key, OncePolicy policy, {required FutureOr<T> Function() callback, FutureOr<T> Function()? fallback}) |
Runs callback when due, else fallback. OnceResult.ran says which, OnceResult.value carries the return. |
markDone |
Future<void> markDone(String key, OncePolicy policy) |
Marks this round as done without running anything — "don't show me again". Takes the policy because the policy defines how long a round is. |
clear |
Future<void> clear(String key) |
Forgets one key. |
clearAll |
Future<void> clearAll() |
Forgets every record this package wrote. |
warmUp |
Future<void> warmUp() |
Loads the records into memory. Await once before runApp; repeat calls are free. |
flush |
Future<void> flush() |
Awaits the pending writes queued by claimSync. Useful before asserting on storage, or before shutting down. |
debugOverride |
void debugOverride({OnceStore? store, DateTime Function()? clock}) |
Swaps the store and the clock. Tests only; Once.debugReset() puts the defaults back. |
run keeps "did it run" and "what did it return" apart:
final result = await Once.run<String>(
'daily.sync',
const OncePolicy.everyDay(),
callback: () => syncFromServer(),
fallback: () => readCachedLabel(),
);
if (result.ran) log('synced: ${result.value}');Inject a MemoryOnceStore and a clock you control, then move time forward by
assignment instead of waiting:
import 'package:flutter_test/flutter_test.dart';
import 'package:once_new/once_new.dart';
void main() {
test('weekly tip fires again after seven days', () async {
var fakeNow = DateTime(2026, 1, 1, 9);
Once.debugOverride(store: MemoryOnceStore(), clock: () => fakeNow);
addTearDown(Once.debugReset);
const weekly = OncePolicy.every(Duration(days: 7));
expect(await Once.claim('tip', weekly), isTrue);
fakeNow = DateTime(2026, 1, 6, 9);
expect(await Once.claim('tip', weekly), isFalse);
fakeNow = DateTime(2026, 1, 8, 9);
expect(await Once.claim('tip', weekly), isTrue);
});
}MemoryOnceStore.values exposes the raw map if a test needs to assert what
landed on "disk" — await Once.flush() first, since claimSync queues its
write instead of awaiting it.
The package's own suite lives in test/, covering the policy decisions
(once_policy_test.dart) and the bookkeeping around them
(once_new_test.dart):
flutter testclaimSyncreturns before the write completes. The in-memory record is updated synchronously, so the same run can't fire twice; if the process dies before the write lands, the worst case is one extra fire. Writes are serialized, so laterclear/clearAllcalls always win over an in-flight write.- Every record carries its policy tag. Switching a key from
everyDay()toevery(Duration(...))is a new record, not a parse error on the old one — but it does mean the key fires once more right after the switch. - Anything unparseable under the key (hand-edited, left by another library) is treated as "no record" rather than an exception. Worst case, one extra fire.
clearAllonly removes keys under theonce_new.prefix, matched withstartsWith. The host app's own preferences are never touched.- The default store goes through
SharedPreferences.getInstance(), soSharedPreferences.setMockInitialValuesworks in widget tests without any override.
MIT License © 2026 Hugo