Skip to main content

What is storage?

Storage is the persistence layer for tracking presentation events. It records:
  • Impressions - When presentations are shown
  • Dismissals - When users close presentations
  • Conversions - When users take action
Guards and the engine use storage to make decisions about what to show.
Storage is optional but highly recommended. If not provided, Presentum uses NoOpPresentumStorage which does nothing and logs warnings (only visible when presentum.logs is enabled via --dart-define=presentum.logs=true). Without storage, impression tracking, cooldowns, and dismissal states won’t persist.Always provide a storage implementation in production.

Storage interface

Implement PresentumStorage<S, V>:
abstract interface class PresentumStorage<
  S extends PresentumSurface,
  V extends PresentumVisualVariant
> {
  // Clears specific item by [itemId] on [surface] with [variant] style.
  FutureOr<void> clearItem(
    String itemId, {
    required S surface,
    required V variant,
  });

  // Shown tracking
  FutureOr<void> recordShown(
    String itemId, {
    required S surface,
    required V variant,
    required DateTime at,
  });

  FutureOr<DateTime?> getLastShown(
    String itemId, {
    required S surface,
    required V variant,
  });

  FutureOr<int> getShownCount(
    String itemId, {
    required Duration period,
    required S surface,
    required V variant,
  });

  // Dismissal tracking
  FutureOr<void> recordDismissed(
    String itemId, {
    required S surface,
    required V variant,
    required DateTime at,
  });

  FutureOr<DateTime?> getDismissedAt(
    String itemId, {
    required S surface,
    required V variant,
  });

  // Conversion tracking
  FutureOr<void> recordConverted(
    String itemId, {
    required S surface,
    required V variant,
    required DateTime at,
  });
}

SharedPreferences implementation

Generic production implementation using SharedPreferences, that can be used for any presentum:
import 'dart:async';

import 'package:presentum/presentum.dart';
import 'package:shared_preferences/shared_preferences.dart';

typedef PersistentPresentumStorageKey<
  S extends PresentumSurface,
  V extends PresentumVisualVariant
> = (String itemId, S surface, V variant);

extension type PersistentPresentumStorageKeys<
  S extends PresentumSurface,
  V extends PresentumVisualVariant
>(PersistentPresentumStorageKey<S, V> key) {
  String get shownCount =>
      '__shown_${key.$1}_${key.$2.name}_${key.$3.name}_count_key__';
  String get lastShown =>
      '__shown_${key.$1}_${key.$2.name}_${key.$3.name}_last_shown_key__';
  String get timestamps =>
      '__shown_${key.$1}_${key.$2.name}_${key.$3.name}_timestamps_key__';
  String get dismissedAt =>
      '__dismissed_${key.$1}_${key.$2.name}_${key.$3.name}_at_key__';
  String get convertedAt =>
      '__converted_${key.$1}_${key.$2.name}_${key.$3.name}_at_key__';

  List<String> get allKeys => [
    shownCount,
    lastShown,
    timestamps,
    dismissedAt,
    convertedAt,
  ];
}

class PersistentPresentumStorage<
  S extends PresentumSurface,
  V extends PresentumVisualVariant
>
    implements PresentumStorage<S, V> {
  PersistentPresentumStorage({required SharedPreferencesWithCache prefs})
    : _prefs = prefs;

  final SharedPreferencesWithCache _prefs;

  @override
  Future<void> clearItem(
    String itemId, {
    required S surface,
    required V variant,
  }) => Future.wait(
    PersistentPresentumStorageKeys((
      itemId,
      surface,
      variant,
    )).allKeys.map(_prefs.remove),
  );

  @override
  FutureOr<DateTime?> getLastShown(
    String itemId, {
    required S surface,
    required V variant,
  }) async {
    final key = PersistentPresentumStorageKeys((
      itemId,
      surface,
      variant,
    )).lastShown;
    final timestampStr = _prefs.getString(key);
    return timestampStr != null ? DateTime.parse(timestampStr) : null;
  }

  @override
  FutureOr<void> recordShown(
    String itemId, {
    required S surface,
    required V variant,
    required DateTime at,
  }) async {
    final keys = PersistentPresentumStorageKeys((itemId, surface, variant));
    final countKey = keys.shownCount;
    final lastShownKey = keys.lastShown;
    final timestampsKey = keys.timestamps;

    final currentCount = _prefs.getInt(countKey) ?? 0;
    final currentTimestamps = _prefs.getStringList(timestampsKey) ?? [];

    await _prefs.setInt(countKey, currentCount + 1);
    await _prefs.setString(lastShownKey, at.toIso8601String());
    await _prefs.setStringList(timestampsKey, [
      ...currentTimestamps,
      at.toIso8601String(),
    ]);
  }

  @override
  FutureOr<int> getShownCount(
    String itemId, {
    required Duration period,
    required S surface,
    required V variant,
  }) async {
    final keys = PersistentPresentumStorageKeys((itemId, surface, variant));
    final timestampsKey = keys.timestamps;
    final timestampStrings = _prefs.getStringList(timestampsKey) ?? [];
    final timestamps = timestampStrings.map(DateTime.parse).toList();
    final cutoff = DateTime.now().subtract(period);
    final count = timestamps.where((t) => t.isAfter(cutoff)).length;

    return count;
  }

  @override
  FutureOr<DateTime?> getDismissedAt(
    String itemId, {
    required S surface,
    required V variant,
  }) async {
    final keys = PersistentPresentumStorageKeys((itemId, surface, variant));
    final timestampStr = _prefs.getString(keys.dismissedAt);
    return timestampStr != null ? DateTime.parse(timestampStr) : null;
  }

  @override
  FutureOr<void> recordDismissed(
    String itemId, {
    required S surface,
    required V variant,
    required DateTime at,
  }) async {
    final keys = PersistentPresentumStorageKeys((itemId, surface, variant));
    await _prefs.setString(keys.dismissedAt, at.toIso8601String());
  }

  @override
  FutureOr<void> recordConverted(
    String itemId, {
    required S surface,
    required V variant,
    required DateTime at,
  }) async {
    final keys = PersistentPresentumStorageKeys((itemId, surface, variant));
    await _prefs.setString(keys.convertedAt, at.toIso8601String());
  }
}
See full production storage ->
Per-surface-variant tracking lets the same campaign appear differently on different surfaces with independent impression counts.

In-memory implementation

For testing or session-only tracking, use built-in InMemoryPresentumStorage

Backend API implementation

Sync events to a backend:
class ApiStorage implements PresentumStorage<AppSurface, CampaignVariant> {
  ApiStorage(this.apiClient);

  final ApiClient apiClient;

  @override
  Future<void> recordShown(
    String itemId, {
    required AppSurface surface,
    required CampaignVariant variant,
    required DateTime at,
  }) async {
    await apiClient.post('/impressions', {
      'item_id': itemId,
      'surface': surface.name,
      'variant': variant.name,
      'timestamp': at.toIso8601String(),
    });
  }

  @override
  Future<DateTime?> getLastShown(
    String itemId, {
    required AppSurface surface,
    required CampaignVariant variant,
  }) async {
    final response = await apiClient.get(
      '/impressions/last',
      params: {
        'item_id': itemId,
        'surface': surface.name,
        'variant': variant.name,
      },
    );

    final timestamp = response['timestamp'] as String?;
    return timestamp != null ? DateTime.parse(timestamp) : null;
  }

  // Implement other methods...
}

No-op storage (default)

If you don’t provide a storage implementation, Presentum uses NoOpPresentumStorage:
// ⚠️ Without storage - uses NoOpPresentumStorage internally
presentum = Presentum(
  guards: guards,
);
// Logs warnings when storage methods are called
// (only visible with --dart-define=presentum.logs=true)
NoOpPresentumStorage does nothing and returns default values:
  • getLastShown()null
  • getShownCount()0
  • getDismissedAt()null
  • All record*() methods → no-op with warning log
Don’t rely on NoOpPresentumStorage in production. Without storage, your guards can’t check impression counts, cooldowns, or dismissal states. Always provide a real storage implementation.

Storage event handler

Use PresentumStorageEventHandler to automatically record events:
presentum = Presentum(
  storage: storage, // ✅ Provide storage
  eventHandlers: [
    PresentumStorageEventHandler(storage: storage),
    // This handler calls storage.recordShown/Dismissed/Converted automatically
  ],
  guards: guards,
);
Learn more about events ->

Best practices

Don’t rely on NoOpPresentumStorage. Always pass a real storage implementation:
// ❌ Bad - uses NoOpPresentumStorage
presentum = Presentum(guards: guards);

// ✅ Good - explicit storage
presentum = Presentum(
  storage: PersistentPresentumStorage(prefs: prefs),
  guards: guards,
);
getShownCount receives a period parameter. Only count impressions within that timeframe: dart final cutoff = DateTime.now().subtract(period); final recentCount = timestamps.where((t) => t.isAfter(cutoff)).length;
Avoid async initialization in initState. Use SharedPreferencesWithCache for synchronous access:
@override
void initState() {
  super.initState();
  // ✅ Synchronous - no await needed
  _storage = PersistentPresentumStorage(prefs: deps.sharedPreferences);
  presentum = Presentum(storage: _storage);
}
Use --dart-define=presentum.logs=true to see NoOpPresentumStorage warnings and other logs:
flutter run --dart-define=presentum.logs=true
This helps catch missing storage implementations during development.

Next steps