Skip to main content

once_new 0.0.1 copy "once_new: ^0.0.1" to clipboard
once_new: ^0.0.1 copied to clipboard

Run a block once, or at most once per hour / day / week / month / year / app version. The bookkeeping lives in SharedPreferences; there is nothing to initialize.

once_new #

pub package pub points License: MIT Platform

"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.

Why #

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: -1 means monthly, -2 means forever, -3 means daily, and 0 < n < 12 is 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. OncePolicy is 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 stores yyyy-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.10 look newer than 1.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. No package_info_plus. You pass the version string you already have, from wherever you already get it.
  • Store and clock are injectable. Once.debugOverride swaps in a MemoryOnceStore and a fake clock, so the decision logic runs in a plain unit test that time-travels instead of waiting.

Install #

dependencies:
  once_new: ^0.0.1

Or run:

flutter pub add once_new

Policies #

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.

Sync or async #

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.

API #

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}');

Testing #

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 test

Notes #

  • claimSync returns 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 later clear / clearAll calls always win over an in-flight write.
  • Every record carries its policy tag. Switching a key from everyDay() to every(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.
  • clearAll only removes keys under the once_new. prefix, matched with startsWith. The host app's own preferences are never touched.
  • The default store goes through SharedPreferences.getInstance(), so SharedPreferences.setMockInitialValues works in widget tests without any override.

License #

MIT License © 2026 Hugo

0
likes
160
points
258
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Run a block once, or at most once per hour / day / week / month / year / app version. The bookkeeping lives in SharedPreferences; there is nothing to initialize.

Repository (GitHub)
View/report issues

Topics

#once #throttle #scheduling #onboarding #shared-preferences

License

MIT (license)

Dependencies

flutter, shared_preferences

More

Packages that depend on once_new