streak_plus
A backend-agnostic, offline-first streak tracking engine for Flutter.
Track daily habits, weekly goals, monthly targets, or any custom frequency — with pluggable storage and optional remote sync.
Features
- Flexible streak types — daily, weekly, monthly, yearly, or a custom N-day window
- Freeze days — protect a gap from breaking the streak (rest day, vacation, illness)
- Pluggable storage — bring your own backend: SharedPreferences, Hive, SQLite, secure storage, etc.
- Optional remote sync — bidirectional merge with conflict resolution (offline-first)
- Pure calculation logic — stateless, deterministic, easy to unit-test in isolation
- Idempotent events — logging the same date twice is always safe
Installation
# pubspec.yaml
dependencies:
streak_plus: ^0.0.1
Quick start
import 'package:streak_plus/streak_plus.dart';
// 1. Create — MemoryStorage for tests, bring your own for production.
final streak = StreakPlus(storage: MemoryStorage());
// 2. Always await init() before reading or writing any state.
await streak.init();
// 3. Log activity.
await streak.logEvent(DateTime.now());
// 4. Read state — all getters are synchronous after init().
print(streak.currentStreak); // 1
print(streak.longestStreak); // 1
print(streak.isActive); // true
print(streak.lastActivityDate); // today (UTC midnight)
Streak types
Pass a StreakConfig to change how streaks are measured.
The streak count is expressed in the matching unit (days / weeks / months / etc.).
// Daily (default) — streak in days
StreakPlus(storage: storage, config: StreakConfig.daily);
// Weekly — streak in weeks; a week qualifies when it has ≥ 3 activity days (Mon–Sun)
StreakPlus(storage: storage, config: const StreakConfig.weekly(requiredDays: 3));
// Monthly — streak in months; a month qualifies when it has ≥ 20 activity days
StreakPlus(storage: storage, config: const StreakConfig.monthly(requiredDays: 20));
// Yearly — streak in years; a year qualifies when it has ≥ 100 activity days
StreakPlus(storage: storage, config: const StreakConfig.yearly(requiredDays: 100));
// Custom rolling window — streak in 5-day buckets; a bucket qualifies at ≥ 2 days
StreakPlus(
storage: storage,
config: const StreakConfig.custom(requiredDays: 2, everyDays: 5),
);
StreakConfigis a runtime parameter — it is not persisted. Raw activity dates are always stored, so you can change the config without losing history.
Freeze days
A freeze day bridges a single missing day so the streak does not break.
Mon Tue Wed Thu Fri
✓ ✓ – ✓ ✓ ← without freeze: streak resets on the Wed gap
✓ ✓ ❄️ ✓ ✓ ← with freeze on Wed: streak continues unbroken
// Protect yesterday from breaking the streak
await streak.addFreezeDay(
DateTime.now().subtract(const Duration(days: 1)),
);
Freeze days cover a gap of exactly one missing day. Larger gaps always reset the streak. Freeze days apply to
StreakConfig.dailyonly.
Pluggable storage
Implement StreakStorage (three methods) to persist with any backend.
abstract class StreakStorage {
Future<void> save(StreakModel model);
Future<StreakModel?> load();
Future<void> clear();
}
StreakModel.toMap() / StreakModel.fromMap() handle serialization — you only need to store and retrieve a Map<String, dynamic>.
SharedPreferences example
class SharedPreferencesStorage implements StreakStorage {
@override
Future<void> save(StreakModel model) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('streak', jsonEncode(model.toMap()));
}
@override
Future<StreakModel?> load() async {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString('streak');
if (json == null) return null;
return StreakModel.fromMap(jsonDecode(json));
}
@override
Future<void> clear() async =>
(await SharedPreferences.getInstance()).remove('streak');
}
Remote sync
Implement StreakSyncAdapter (two methods) to sync with any backend.
abstract class StreakSyncAdapter {
Future<void> push(StreakModel model); // upload local state
Future<StreakModel?> pull(); // fetch remote state
}
Firebase Firestore example
class FirebaseStreakAdapter implements StreakSyncAdapter {
final String userId;
FirebaseStreakAdapter(this.userId);
@override
Future<void> push(StreakModel model) =>
FirebaseFirestore.instance
.collection('streaks')
.doc(userId)
.set(model.toMap());
@override
Future<StreakModel?> pull() async {
final doc = await FirebaseFirestore.instance
.collection('streaks')
.doc(userId)
.get();
if (!doc.exists) return null;
return StreakModel.fromMap(doc.data()!);
}
}
Wiring it in
final streak = StreakPlus(
storage: SharedPreferencesStorage(),
sync: FirebaseStreakAdapter(currentUser.uid),
);
// init() automatically pulls from remote on startup
await streak.init();
// Manual sync
await streak.pushToRemote(); // local → remote (one-way)
await streak.syncWithRemote(); // pull · merge · push (bidirectional)
Conflict resolution
| Condition | Winner | Notes |
|---|---|---|
remote.longestStreak > local.longestStreak |
Remote | Remote has richer history |
remote.longestStreak ≤ local.longestStreak |
Local | Offline-first principle |
| Either side has unique activity dates | Both | Dates are always unioned — no events are lost |
API reference
StreakPlus
| Member | Kind | Description |
|---|---|---|
StreakPlus({storage, sync?, config?}) |
constructor | Inject storage, optional sync adapter, optional config |
init() |
Future<void> |
Load persisted state. Must be awaited before anything else. |
logEvent(date) |
Future<void> |
Record activity on date. Idempotent. |
addFreezeDay(date) |
Future<void> |
Mark date as a freeze day. |
currentStreak |
int |
Running streak count (in the config's unit). |
longestStreak |
int |
All-time best streak count. |
isActive |
bool |
true when the streak is still alive and extendable today. |
lastActivityDate |
DateTime? |
Most recent activity (UTC midnight), or null. |
snapshot |
StreakModel |
Full immutable state snapshot. |
pushToRemote() |
Future<void> |
Upload local state to remote. No-op without a sync adapter. |
syncWithRemote() |
Future<void> |
Bidirectional sync: pull → merge → push. No-op without a sync adapter. |
StreakConfig
| Constructor | Unit | Period qualifies when… |
|---|---|---|
StreakConfig.daily |
days | activity logged that day |
StreakConfig.weekly(requiredDays: n) |
weeks | ≥ n activity days in the Mon–Sun week |
StreakConfig.monthly(requiredDays: n) |
months | ≥ n activity days in the calendar month |
StreakConfig.yearly(requiredDays: n) |
years | ≥ n activity days in the calendar year |
StreakConfig.custom(requiredDays: n, everyDays: m) |
m-day windows | ≥ n activity days in the fixed m-day bucket |
StreakModel
| Field | Type | Description |
|---|---|---|
activityDates |
List<DateTime> |
All logged activity dates (UTC, sorted ascending) |
freezeDates |
List<DateTime> |
All freeze dates (UTC, sorted ascending) |
currentStreak |
int |
Cached current streak count |
longestStreak |
int |
Cached all-time best |
lastActivityDate |
DateTime? |
Most recent activity date |
toMap() |
Map<String, dynamic> |
Serialize to a JSON-safe map |
StreakModel.fromMap(map) |
factory | Deserialize from a map |
Design principles
| Principle | What it means |
|---|---|
| Offline-first | All reads/writes are local; sync is optional and always explicit |
| No lock-in | Storage and sync are interfaces — swap backends without touching library code |
| Idempotent | logEvent and addFreezeDay are safe to repeat with the same date |
| Deterministic | StreakCalculator is pure (no I/O); same inputs always produce the same output |
| UTC dates | All dates normalised to UTC midnight to avoid timezone drift |
| Config is runtime | StreakConfig is not persisted — change frequency goals without losing history |
Running the example app
cd streak_plus_example
flutter pub get
flutter run
| Screen | What it demonstrates |
|---|---|
| 01 · Daily streak | init, logEvent, addFreezeDay, reading all state properties |
| 02 · Streak types | All StreakConfig variants displayed side by side on the same activity data |
| 03 · Sync adapter | pushToRemote, syncWithRemote, and an adapter implementation skeleton |
Architecture
graph TD
A([Your App]) -->|logEvent / addFreezeDay| B[StreakPlus]
A -->|pushToRemote / syncWithRemote| B
B --> C[StreakEngine]
C -->|calculateCurrentStreak\ncalculateLongestStreak\nisStreakActive| D["StreakCalculator\npure logic · no I/O"]
C -->|save / load| E["StreakStorage\ninterface"]
C -->|push / pull / sync| F[SyncManager]
E -->|implement| E1["MemoryStorage\nbuilt-in"]
E -->|implement| E2["SharedPreferencesStorage\nyour app"]
E -->|implement| E3["HiveStorage · SQLite\n· any backend"]
F -->|push / pull| G["StreakSyncAdapter\ninterface"]
G -->|implement| G1["MockSyncAdapter\nexample"]
G -->|implement| G2["FirebaseAdapter\nSupabaseAdapter · any backend"]
C --- H[("StreakModel\nactivityDates\nfreezeDates\ncurrentStreak\nlongestStreak")]
style D fill:#e65100,stroke:#ff8f00,color:#fff
style E fill:#0277bd,stroke:#29b6f6,color:#fff
style G fill:#2e7d32,stroke:#66bb6a,color:#fff
style H fill:#6a1b9a,stroke:#ce93d8,color:#fff
style E1 fill:#0277bd,stroke:#29b6f6,color:#fff
style E2 fill:#0277bd,stroke:#29b6f6,color:#fff
style E3 fill:#0277bd,stroke:#29b6f6,color:#fff
style G1 fill:#2e7d32,stroke:#66bb6a,color:#fff
style G2 fill:#2e7d32,stroke:#66bb6a,color:#fff
Event flow
sequenceDiagram
participant App
participant StreakPlus
participant StreakEngine
participant StreakCalculator
participant StreakStorage
App->>StreakPlus: await init()
StreakPlus->>StreakEngine: init()
StreakEngine->>StreakStorage: load()
StreakStorage-->>StreakEngine: StreakModel (or null)
StreakEngine-->>App: ready
App->>StreakPlus: await logEvent(date)
StreakPlus->>StreakEngine: logEvent(date)
StreakEngine->>StreakCalculator: calculateCurrentStreak(...)
StreakCalculator-->>StreakEngine: int
StreakEngine->>StreakCalculator: calculateLongestStreak(...)
StreakCalculator-->>StreakEngine: int
StreakEngine->>StreakStorage: save(updatedModel)
StreakEngine-->>App: done
App->>StreakPlus: streak.currentStreak
StreakPlus-->>App: int (synchronous)
Streak type decision
flowchart TD
Q{What is your\nfrequency goal?} --> D[Every day]
Q --> W[N days per week]
Q --> M[N days per month]
Q --> Y[N days per year]
Q --> C["N days per\ncustom window"]
D --> CD["StreakConfig.daily"]
W --> CW["StreakConfig.weekly(requiredDays: 3)"]
M --> CM["StreakConfig.monthly(requiredDays: 20)"]
Y --> CY["StreakConfig.yearly(requiredDays: 100)"]
C --> CC["StreakConfig.custom(requiredDays: 2, everyDays: 5)"]
CD --> R[streak count in days]
CW --> RW[streak count in weeks]
CM --> RM[streak count in months]
CY --> RY[streak count in years]
CC --> RC[streak count in windows]
style Q fill:#37474f,stroke:#90a4ae,color:#fff
style CD fill:#e65100,stroke:#ff8f00,color:#fff
style CW fill:#e65100,stroke:#ff8f00,color:#fff
style CM fill:#e65100,stroke:#ff8f00,color:#fff
style CY fill:#e65100,stroke:#ff8f00,color:#fff
style CC fill:#e65100,stroke:#ff8f00,color:#fff
style R fill:#37474f,stroke:#90a4ae,color:#fff
style RW fill:#37474f,stroke:#90a4ae,color:#fff
style RM fill:#37474f,stroke:#90a4ae,color:#fff
style RY fill:#37474f,stroke:#90a4ae,color:#fff
style RC fill:#37474f,stroke:#90a4ae,color:#fff
Sync conflict resolution
flowchart TD
Start([syncWithRemote called]) --> Pull[Pull remote model]
Pull --> NoRemote{Remote\nhas data?}
NoRemote -- No --> KeepLocal[Keep local · done]
NoRemote -- Yes --> Compare{"remote.longestStreak\nvs local.longestStreak"}
Compare -- "remote > local" --> WinR[Remote wins]
Compare -- "remote ≤ local" --> WinL["Local wins\noffline-first"]
WinR --> Union["Union activity dates\nfrom both sides"]
WinL --> Union
Union --> Save[Save merged model\nto local storage]
Save --> Push[Push merged model\nback to remote]
Push --> Done([done])
style Start fill:#37474f,stroke:#90a4ae,color:#fff
style Done fill:#37474f,stroke:#90a4ae,color:#fff
style NoRemote fill:#37474f,stroke:#90a4ae,color:#fff
style Compare fill:#37474f,stroke:#90a4ae,color:#fff
style WinR fill:#0277bd,stroke:#29b6f6,color:#fff
style WinL fill:#2e7d32,stroke:#66bb6a,color:#fff
style Union fill:#6a1b9a,stroke:#ce93d8,color:#fff
style KeepLocal fill:#37474f,stroke:#90a4ae,color:#fff
Libraries
- streak_plus
- streak_plus — Backend-agnostic, offline-first streak tracking engine.