liquid_analytics
Vendor-neutral analytics for Flutter. Emit one typed event and fan it out to any set of providers through a middleware pipeline and a built-in consent gate — GDPR-style opt-in by default.
final liquid = Liquid(
sinks: [
FirebaseSink(),
PostHogSink(apiKey: '…'),
MixpanelSink(token: '…'),
],
middleware: [
GlobalContext({'env': 'prod', 'app_version': '2.1.0'}),
PiiRedactor(fields: {'email', 'phone'}),
Sampler(rate: 0.25, names: {'scroll_depth'}),
],
consent: ConsentPolicy(
requireOptIn: true, // nothing leaves the device until the user agrees
map: {
ConsentCategory.analytics: ['firebase', 'posthog'],
ConsentCategory.marketing: ['mixpanel'],
},
),
);
await liquid.init();
Three ways to record events
// 1. Quick string API
liquid.track('checkout_started', {'cart_value': 42.0});
// 2. Typed events — no code generation, just a class
class CheckoutStarted extends LiquidEvent {
const CheckoutStarted({required this.cartValue});
final double cartValue;
@override String get name => 'checkout_started';
@override Map<String, Object?> get properties => {'cart_value': cartValue};
}
liquid.log(const CheckoutStarted(cartValue: 42));
// 3. Identify / screen / group / alias / reset
liquid.identify('user_123', {'plan': 'pro'});
liquid.screen('Cart');
liquid.reset(); // clears the user across all sinks
Consent is first-class
Wire your consent banner straight to the controller. Events emitted before the user decides are buffered and replayed the moment they opt in; if they opt out, the buffer is discarded.
liquid.consent.grant(ConsentCategory.analytics);
liquid.consent.deny(ConsentCategory.marketing);
// ConsentController is a ChangeNotifier — drive UI from it directly.
ListenableBuilder(
listenable: liquid.consent,
builder: (_, __) => Switch(
value: liquid.consent.statusOf(ConsentCategory.analytics) ==
ConsentStatus.granted,
onChanged: (on) => on
? liquid.consent.grant(ConsentCategory.analytics)
: liquid.consent.deny(ConsentCategory.analytics),
),
);
Routing categories to sinks
ConsentPolicy.map decides which sinks may receive which category. A
non-empty map is an explicit allowlist — a category with no entry reaches no
sink at all:
ConsentPolicy(
map: {
ConsentCategory.analytics: ['firebase', 'posthog'],
ConsentCategory.marketing: ['mixpanel'],
// `personalization` is unmapped -> those events go nowhere.
},
);
This fails closed on purpose: forgetting a category withholds data instead of broadcasting it everywhere. Debug builds log a one-time warning naming any category that was dropped this way.
Leave the map empty to turn category routing off entirely, and every capable sink receives everything the consent gate lets through.
Persist consent across launches
liquid keeps consent in memory only. Seed it from storage with
ConsentPolicy.defaults, and write back whenever the user changes a toggle.
// Load before constructing Liquid:
final defaults = <ConsentCategory, ConsentStatus>{
// e.g. from SharedPreferences: ConsentStatus.values.byName(raw)
ConsentCategory.analytics: ConsentStatus.granted,
};
final liquid = Liquid(
sinks: [/* ... */],
consent: ConsentPolicy(
requireOptIn: true,
defaults: defaults,
),
);
await liquid.init();
// Save after each decision:
liquid.consent.addListener(() async {
final prefs = await SharedPreferences.getInstance();
for (final c in ConsentCategory.values) {
if (c == ConsentCategory.necessary) continue;
final s = liquid.consent.statusOf(c);
if (s == ConsentStatus.unknown) {
await prefs.remove('consent_${c.name}');
} else {
await prefs.setString('consent_${c.name}', s.name);
}
}
});
Ready-made UI: liquid_analytics_ui
(LiquidConsentBanner, showLiquidConsentSheet).
Full app bootstrap: repository Getting started.
Automatic screen tracking
MaterialApp(
navigatorObservers: [LiquidNavigatorObserver(liquid)],
);
Reliability (offline queue + flush)
By default liquid delivers immediately after consent. If a sink throws, the message is queued with exponential backoff and retried on Liquid.flush, a timer, or when the app is backgrounded.
final liquid = Liquid(
sinks: [PostHogSink(apiKey: '…')],
consent: ConsentPolicy.allowAll,
delivery: DeliveryOptions(
// Optional durable store (file / shared_preferences / your backend):
store: JsonDeliveryStore(
read: () async => prefs.getString('liquid_queue'),
write: (json) async => prefs.setString('liquid_queue', json),
),
maxAttempts: 5,
flushOnBackground: true,
),
);
Batch mode
Buffer consented events and drain them in bulk — useful when many HTTP sinks share a device radio:
delivery: DeliveryOptions(
batchMode: true,
flushPolicies: [
CountFlushPolicy(20),
TimerFlushPolicy(Duration(seconds: 30)),
],
),
Call await liquid.flush() anytime (also runs each sink's own flush).
DX packages
| Package | Purpose |
|---|---|
liquid_analytics_ui |
Consent banner/sheet, event inspector, GoRouter helpers |
liquid_codegen |
Optional YAML → typed LiquidEvent CLI |
Testing
FakeSink records everything in memory:
final fake = FakeSink();
final liquid = Liquid(sinks: [fake], consent: ConsentPolicy.allowAll);
await liquid.init();
liquid.track('tapped_buy');
await Future<void>.delayed(Duration.zero);
expect(fake.tracks.single.name, 'tapped_buy');
Writing a provider adapter
Implement LiquidSink. DebugSink and FakeSink are the reference
implementations. A real one looks like:
class MixpanelSink extends LiquidSink {
MixpanelSink({required this.token});
final String token;
late final Mixpanel _mp;
@override
String get id => 'mixpanel';
@override
Future<void> init() async {
_mp = await Mixpanel.init(token, trackAutomaticEvents: false);
}
@override
Future<void> deliver(LiquidMessage m) async {
switch (m.type) {
case LiquidMessageType.track:
_mp.track(m.name!, properties: Map.of(m.properties));
case LiquidMessageType.identify:
_mp.identify(m.name!);
case LiquidMessageType.reset:
_mp.reset();
default:
break; // ignore what this provider doesn't model
}
}
@override
Future<void> flush() async => _mp.flush();
}
Status
0.2.0 is published on pub.dev.
Adapters, offline delivery, UI helpers, GoRouter tracking, and the example app
ship in the same monorepo.
Upgrading from 0.1.x? Two behaviour changes need a look: ConsentPolicy.map
is now an explicit allowlist, and PiiRedactor recurses into nested data. See
the changelog.
Libraries
- liquid_analytics
- Vendor-neutral analytics for Flutter: one typed event, many destinations, with a built-in consent gate.