No boilerplate. No repeated strings. No setup. Define your variables once, then get()
and set()
them anywhere with zero friction. prf
makes local persistence faster, simpler, and easier to scale. Includes 10+ built-in types and utilities like persistent cooldowns and rate limiters. Designed to fully replace raw use of SharedPreferences
.
Way more types than SharedPreferences โ including
enums
DateTime
JSON models
+10 types and also special servicesPrfCooldown
PrfRateLimiter
for production ready persistent cooldowns and rate limiters.
- Introduction
- Why Use
prf
? - SharedPreferences vs
prf
- Setup & Basic Usage (Step-by-Step)
- Available Methods for All
prf
Types - Supported
prf
Types - Migrating from SharedPreferences to
prf
- Persistent Services & Utilities
- Roadmap & Future Plans
- Why
prf
Wins in Real Apps
Just define your variable once โ no strings, no boilerplate:
final username = PrfString('username');
Then get it:
final value = await username.get();
Or set it:
await username.set('Joey');
Thatโs it. You're done. Works with all prf
Types!
Working with SharedPreferences
often leads to:
- Repeated string keys
- Manual casting and null handling
- Verbose async boilerplate
- Scattered, hard-to-maintain logic
prf
solves all of that with a one-line variable definition thatโs type-safe, cached, and instantly usable throughout your app. No key management, no setup, no boilerplate, no .getString(...)
everywhere.
- โ Single definition โ just one line to define, then reuse anywhere
- โ Type-safe โ no casting, no runtime surprises
- โ Automatic caching โ values are stored in memory after the first read
- โ
Lazy initialization โ no need to manually call
SharedPreferences.getInstance()
- โ Supports more than just primitives โ 10+ types without counting utilities.
- โ Built for testing โ easily reset or mock storage in tests
- โ
Cleaner codebase โ no more scattered
prefs.get...()
or typo-prone string keys - โ
Isolate-safe โ built on
SharedPreferencesAsync
for full isolate compatibility, with caching on top, making it faster and more ergonomic than working with rawSharedPreferencesAsync
directly - โ
Persistent utilities included โ
PrfCooldown
โ for managing cooldown windows (e.g. daily rewards)PrfRateLimiter
โ token-bucket limiter for X actions per time window (e.g. 1000 messages per 15 minutes)
Feature | SharedPreferences (raw) |
prf |
---|---|---|
Define Once, Reuse Anywhere | โ Manual strings everywhere | โ One-line variable definition |
Type Safety | โ Requires manual casting | โ Fully typed, no casting needed |
Readability | โ Repetitive and verbose | โ Clear, concise, expressive |
Centralized Keys | โ You manage key strings | โ Keys are defined as variables |
Caching | โ No built-in caching | โ Automatic in-memory caching |
Lazy Initialization | โ Must await getInstance() manually |
โ Internally managed |
Supports Primitives | โ Yes | โ Yes |
Supports Advanced Types | โ No (DateTime , enum , etc. must be encoded manually) |
โ
Built-in support for DateTime , Uint8List , enum , JSON |
Special Persistent Services | โ None | โ
PrfCooldown , PrfRateLimiter , and more in the future |
Isolate Support | SharedPreferencesAsync , but still inherits all limitations |
โ Full isolate-safe support with async backen and built-in caching |
Using SharedPreferences
:
final prefs = await SharedPreferences.getInstance();
await prefs.setString('username', 'Joey');
final username = prefs.getString('username') ?? '';
Using prf
:
final username = PrfString('username');
await username.set('Joey');
final name = await username.get();
If you're tired of:
- Duplicated string keys
- Manual casting and null handling
- Scattered boilerplate
Then prf
is your drop-in solution for fast, safe, scalable, and elegant local persistence.
dependencies:
prf: ^latest
Then run:
flutter pub get
You only need one line to create a saved variable.
For example, to save how many coins a player has:
final playerCoins = PrfInt('player_coins', defaultValue: 0);
This means:
- You're saving an
int
(number)- The key is
'player_coins'
- If it's empty, it starts at
0
To give the player 100 coins:
await playerCoins.set(100);
To read how many coins the player has:
final coins = await playerCoins.get();
print('Coins: $coins'); // 100
Thatโs it! ๐ You donโt need to manage string keys or setup anything. Just define once, then use anywhere in your app.
Method | Description |
---|---|
get() |
Returns the current value (cached or from disk). |
set(value) |
Saves the value and updates the cache. |
remove() |
Deletes the value from storage and memory. |
isNull() |
Returns true if the value is null . |
getOrFallback(fallback) |
Returns the value or a fallback if null . |
existsOnPrefs() |
Checks if the key exists in SharedPreferences. |
Available on all
prf
types โ consistent, type-safe, and ready anywhere in your app.
Define your variable once with a type that fits your use case. Every type supports .get()
, .set()
, .remove()
, and more โ all cached, type-safe, and ready to use.
Basic Types | Class | Common Use Cases |
---|---|---|
bool |
PrfBool |
Feature flags, settings toggles |
int |
PrfInt |
Counters, scores, timestamps |
double |
PrfDouble |
Ratings, sliders, precise values |
String |
PrfString |
Usernames, tokens, IDs |
List<String> |
PrfStringList |
Tags, recent items, multi-select options |
enum |
PrfEnum<T> |
Typed modes, states, user roles |
T (via JSON) |
PrfJson<T> |
Full model objects with toJson / fromJson |
Uint8List |
PrfBytes |
Binary data (images, keys, QR codes) |
DateTime |
PrfDateTime |
Timestamps, cooldowns, scheduled actions |
Duration |
PrfDuration |
Intervals, delays, expiry timers |
BigInt |
PrfBigInt |
Cryptographic data, large counters, blockchain tokens |
ThemeMode |
PrfThemeMode |
Light/dark/system theme settings |
get()
โ read the current value (cached or from disk)set(value)
โ write and cache the valueremove()
โ delete from disk and cacheisNull()
โ check if nullgetOrFallback(default)
โ safely access with fallbackexistsOnPrefs()
โ check if a key is stored
Want to persist something more complex? Use PrfJson<T>
with any model that supports toJson
and fromJson
.
final userData = PrfJson<User>(
'user',
fromJson: (json) => User.fromJson(json),
toJson: (user) => user.toJson(),
);
Or use PrfEncoded<TSource, TStore>
to define your own encoding logic (e.g., compress/encrypt/etc).
Also See Persistent Services & Utilities:
PrfCooldown
โ for managing cooldown periods (e.g. daily rewards, retry delays)PrfRateLimiter
โ token-bucket limiter for rate control (e.g. 1000 actions per 15 minutes)
Whether you're using the modern SharedPreferencesAsync
or the legacy SharedPreferences
, migrating to prf
is simple and gives you cleaner, type-safe, and scalable persistence โ without losing any existing data.
In fact, you can use prf
with your current keys and values out of the box, preserving all previously stored data. But while backwards compatibility is supported, we recommend reviewing all built-in types and utilities that prf
provides โ such as PrfDuration
, PrfCooldown
, and PrfRateLimiter
โ which may offer a cleaner, more powerful way to structure your logic going forward, without relying on legacy patterns or custom code.
You can switch to prf
with zero configuration โ just use the same keys.
final prefs = SharedPreferencesAsync();
await prefs.setBool('dark_mode', true);
final isDark = await prefs.getBool('dark_mode');
final darkMode = PrfBool('dark_mode');
await darkMode.set(true);
final isDark = await darkMode.get();
- โ As long as you're using the same keys and types, your data will still be there. No migration needed.
- ๐งผ Or โ if you don't care about previously stored values, you can start fresh and use
prf
types right away. Theyโre ready to go with clean APIs and built-in caching for all variable types (bool
,int
,DateTime
,Uint8List
, enums, and more).
You can still switch to prf
using the same keys:
final prefs = await SharedPreferences.getInstance();
await prefs.setString('username', 'Joey');
final name = prefs.getString('username');
final username = PrfString('username');
await username.set('Joey');
final name = await username.get();
โ ๏ธ prf
uses SharedPreferencesAsync, which is isolate-safe, more robust โ and does not share data with the legacySharedPreferences
API. The legacy API is already planned for deprecation, so migrating away from it is strongly recommended.- โ
If you're still in development, you can safely switch to
prf
now โ saved values from before will not be accessible, but that's usually fine while iterating.
The migration bellow automatically migrates old values into the new backend if needed.
Safe to call multiple times โ it only runs once.
If your app previously used SharedPreferences
(the legacy API), and you're now using prf
(which defaults to SharedPreferencesAsync
):
- You must run a one-time migration to move your data into the new backend (especially on Android, where the storage backend switches to DataStore).
Run this before any reads or writes, ideally at app startup:
await Prf.migrateFromLegacyPrefsIfNeeded();
This ensures your old values are migrated into the new system.
It is safe to call multiple times โ migration will only occur once.
Case | Do you need to migrate? | Do your keys stay the same? |
---|---|---|
Using SharedPreferencesAsync |
โ No migration needed | โ Yes |
Using SharedPreferences (dev only) |
โ No migration needed | โ Yes |
Using SharedPreferences (production) |
โ Yes โ run migration once | โ Yes |
Starting fresh | โ No migration, no legacy | ๐ You can pick new keys |
With prf
, you get:
- ๐ Type-safe, reusable variables
- ๐ง Cleaner architecture
- ๐ Built-in in-memory caching
- ๐ Isolate-safe behavior with
SharedPreferencesAsync
- ๐ฆ Out-of-the-box support for
DateTime
,Uint8List
, enums, full models (PrfJson<T>
), and more
In addition to typed variables, prf
includes ready-to-use persistent utilities for common real-world use cases โ built on top of the same caching and async-safe architecture.
These utilities handle state automatically across sessions and isolates, with no manual logic or timers.
Theyโre fully integrated into prf
, use built-in types under the hood, and require no extra setup. Just define and use.
- ๐ PrfCooldown โ for managing cooldown periods (e.g. daily rewards, retry delays)
- ๐ PrfRateLimiter โ token-bucket limiter for rate control (e.g. 1000 actions per 15 minutes)
PrfCooldown
is a plug-and-play utility for managing cooldown windows (e.g. daily rewards, button lockouts, retry delays) that persist across sessions and isolates โ no timers, no manual bookkeeping, no re-implementation every time.
It handles:
- Cooldown timing (
DateTime.now()
+ duration) - Persistent storage via
prf
(with caching and async-safety) - Activation tracking and expiration logic
- Usage statistics (activation count, expiry progress, etc.)
Instantiate it with a unique prefix and a duration:
final cooldown = PrfCooldown('daily_reward', duration: Duration(hours: 24));
You can then use:
isCooldownActive()
โ Returnstrue
if the cooldown is still activeisExpired()
โ Returnstrue
if the cooldown has expired or was never startedactivateCooldown()
โ Starts the cooldown using the configured durationtryActivate()
โ Starts cooldown only if it's not active โ returns whether it was triggeredreset()
โ Clears the cooldown timer, but keeps the activation countcompleteReset()
โ Fully resets both the cooldown and its usage countertimeRemaining()
โ Returns remaining time as aDuration
secondsRemaining()
โ Same as above, in secondspercentRemaining()
โ Progress indicator between0.0
and1.0
getLastActivationTime()
โ ReturnsDateTime?
of last activationgetEndTime()
โ Returns when the cooldown will endwhenExpires()
โ Returns aFuture
that completes when the cooldown endsgetActivationCount()
โ Returns the total number of activationsremoveAll()
โ Deletes all stored values (for testing/debugging)anyStateExists()
โ Returnstrue
if any cooldown data exists in storage
Hereโs the tutorial section for PrfCooldown
, production-grade, clear, and aligned with your README style:
final cooldown = PrfCooldown('daily_reward', duration: Duration(hours: 24));
This creates a persistent cooldown that lasts 24 hours. It uses the prefix 'daily_reward'
to store:
- Last activation timestamp
- Activation count
if (await cooldown.isCooldownActive()) {
print('Wait before trying again!');
}
await cooldown.activateCooldown();
This sets the cooldown to now and begins the countdown. The activation count is automatically incremented.
if (await cooldown.tryActivate()) {
print('Action allowed and cooldown started');
} else {
print('Still cooling down...');
}
Use this for one-line cooldown triggers (e.g. claiming a daily gift or retrying a network call).
await cooldown.reset(); // Clears only the time
await cooldown.completeReset(); // Clears time and resets usage counter
final remaining = await cooldown.timeRemaining();
print('Still ${remaining.inMinutes} minutes left');
You can also use:
await cooldown.secondsRemaining(); // int
await cooldown.percentRemaining(); // double between 0.0โ1.0
final lastUsed = await cooldown.getLastActivationTime();
final endsAt = await cooldown.getEndTime();
await cooldown.whenExpires(); // Completes only when cooldown is over
final count = await cooldown.getActivationCount();
print('Used $count times');
await cooldown.removeAll(); // Clears all stored cooldown state
final exists = await cooldown.anyStateExists(); // Returns true if anything is stored
You can create as many cooldowns as you need โ each with a unique prefix.
All state is persisted, isolate-safe, and instantly reusable.
PrfRateLimiter
is a high-performance, plug-and-play utility that implements a token bucket algorithm to enforce rate limits โ like โ100 actions per 15 minutesโ โ across sessions, isolates, and app restarts.
It handles:
- Token-based rate limiting
- Automatic time-based token refill
- Persistent state using
prf
types (PrfDouble
,PrfDateTime
) - Async-safe, isolate-compatible behavior with built-in caching
Perfect for chat limits, API quotas, retry windows, or any action frequency cap โ all stored locally.
Create a limiter with a unique key, a max token count, and a refill window:
final limiter = PrfRateLimiter('chat_send', maxTokens: 100, refillDuration: Duration(minutes: 15));
You can then use:
tryConsume()
โ Tries to use 1 token; returnstrue
if allowed, orfalse
if rate-limitedisLimitedNow()
โ Returnstrue
if no tokens are currently availableisReady()
โ Returnstrue
if at least one token is availablegetAvailableTokens()
โ Returns the current number of usable tokens (calculated live)timeUntilNextToken()
โ Returns aDuration
until at least one token will be availablenextAllowedTime()
โ Returns the exactDateTime
when a token will be availablereset()
โ Resets to full token count and updates last refill to nowremoveAll()
โ Deletes all limiter state (for testing/debugging)anyStateExists()
โ Returnstrue
if limiter data exists in storagerunIfAllowed(action)
โ Runs a callback if allowed, otherwise returnsnull
debugStats()
โ Returns detailed internal stats for logging and debugging
The limiter uses fractional tokens internally to maintain precise refill rates, even across app restarts. No timers or background services required โ it just works.
Create a limiter with a key, a maximum number of actions, and a refill duration:
final limiter = PrfRateLimiter(
'chat_send',
maxTokens: 100,
refillDuration: Duration(minutes: 15),
);
This example allows up to 100 actions per 15 minutes. The token count is automatically replenished over time โ even after app restarts.
To attempt an action:
final canSend = await limiter.tryConsume();
if (canSend) {
// Allowed โ proceed with the action
} else {
// Blocked โ too many actions, rate limit hit
}
Returns true
if a token was available and consumed, or false
if the limit was exceeded.
To check how many tokens are usable at the moment:
final tokens = await limiter.getAvailableTokens();
print('Tokens left: ${tokens.toStringAsFixed(2)}');
Useful for debugging, showing rate limit progress, or enabling/disabling UI actions.
To wait or show feedback until the next token becomes available:
final waitTime = await limiter.timeUntilNextToken();
print('Try again in: ${waitTime.inSeconds}s');
You can also get the actual time point:
final nextTime = await limiter.nextAllowedTime();
To fully refill the bucket and reset the refill clock:
await limiter.reset();
Use this after manual overrides, feature unlocks, or privileged user actions.
To wipe all saved token/refill data (for debugging or tests):
await limiter.removeAll();
To check if the limiter has any stored state:
final exists = await limiter.anyStateExists();
With PrfRateLimiter
, you get a production-grade rolling window limiter with zero boilerplate โ fully persistent and ready for real-world usage.
prf
is built for simplicity, performance, and scalability. Upcoming improvements focus on expanding flexibility while maintaining a zero-boilerplate experience.
-
Improved performance
Smarter caching and leaner async operations. -
Additional type support
Encrypted strings, and more. -
Custom storage (experimental)
Support for alternative adapters (Hive, Isar, file system). -
Testing & tooling
In-memory test adapter, debug inspection tools, and test utilities. -
Optional code generation
Annotations for auto-registering variables and reducing manual setup.
Working with SharedPreferences
directly can quickly become verbose, error-prone, and difficult to scale. Whether youโre building a simple prototype or a production-ready app, clean persistence matters.
Even in basic use cases, you're forced to:
- Reuse raw string keys (risk of typos and duplication)
- Manually cast and fallback every read
- Handle async boilerplate (
getInstance
) everywhere - Encode/decode complex types manually
- Spread key logic across multiple files
Letโs see how this unfolds in practice.
Goal: Save and retrieve a username
, isFirstLaunch
, and a signupDate
.
final prefs = await SharedPreferences.getInstance();
// Save values
await prefs.setString('username', 'Joey');
await prefs.setBool('is_first_launch', false);
await prefs.setString(
'signup_date',
DateTime.now().toIso8601String(),
);
// Read values
final username = prefs.getString('username') ?? '';
final isFirstLaunch = prefs.getBool('is_first_launch') ?? true;
final signupDateStr = prefs.getString('signup_date');
final signupDate = signupDateStr != null
? DateTime.tryParse(signupDateStr)
: null;
๐ป Issues:
- Repeated string keys โ no compile-time safety
- Manual fallback handling and parsing
- No caching โ every
.get
hits disk - Boilerplate increases exponentially with more values
final username = PrfString('username');
final isFirstLaunch = PrfBool('is_first_launch', defaultValue: true);
final signupDate = PrfDateTime('signup_date');
// Save
await username.set('Joey');
await isFirstLaunch.set(false);
await signupDate.set(DateTime.now());
// Read
final name = await username.get(); // 'Joey'
final first = await isFirstLaunch.get(); // false
final date = await signupDate.get(); // DateTime instance
๐ก Defined once, used anywhere โ fully typed, cached, and clean.
Storing a User
model in raw SharedPreferences
requires:
- Manual
jsonEncode
/jsonDecode
- Validation on read
- String-based key tracking
// Get SharedPreferences
final prefs = await SharedPreferences.getInstance();
// Encode to JSON
final json = jsonEncode(user.toJson());
// Set value
await prefs.setString('user_data', json);
// Read
final raw = prefs.getString('user_data');
User? user;
if (raw != null) {
try {
// Decode JSON
final decoded = jsonDecode(raw);
// Convert to User
user = User.fromJson(decoded);
} catch (_) {
// fallback or error
}
}
// Define once
final userData = PrfJson<User>(
'user_data',
fromJson: User.fromJson,
toJson: (u) => u.toJson(),
);
// Save
await userData.set(user);
// Read
final savedUser = await userData.get(); // User?
Fully typed. Automatically parsed. Fallback-safe. Reusable across your app.
prf
was built to eliminate the day-to-day pain of using SharedPreferences in production codebases:
- โ Define once โ reuse anywhere
- โ
Clean API โ
get()
,set()
,remove()
,isNull()
for all types - โ
Supports advanced types:
DateTime
,Uint8List
,enum
,JSON
- โ Automatic caching โ fast access after first read
- โ Test-friendly โ easily reset, mock, or inspect values