piano_composer 1.0.4 copy "piano_composer: ^1.0.4" to clipboard
piano_composer: ^1.0.4 copied to clipboard

Piano Composer Flutter SDK.

Piano Composer SDK for Flutter #

Piano Composer Flutter SDK delivers personalized experiences, paywalls, and A/B tests from Piano's experience management platform, and reports user interactions back for attribution.

Installation #

Add the dependency to your pubspec.yaml:

dependencies:
  piano_composer: ^1.0.4

Getting Started #

piano_composer builds on piano_common, so you need a Piano instance first:

import 'package:piano_common/piano_common.dart';
import 'package:piano_composer/piano_composer.dart';

final piano = await Piano.init(
  endpoint: PianoEndpoint.production,
  aid: '<AID>',
);

Get a client via piano.composer(). Each call returns a new PianoComposerClient, so retain the reference when you need to share configuration or read back state (browserId, pageViewId) after a request:

final composer = piano.composer();

final request = PianoComposerRequest(
  url: 'https://piano.io/article',
  title: 'Article Title',
  tags: ['technology', 'news'],
);

final response = await composer.execute(request);

Configuration #

Configuration setters are chainable and return the same client:

final composer = piano.composer()
  ..userToken('<access_token>')        // token for personalized experiences
  ..gaClientId('<ga_client_id>')       // link experiences to Google Analytics
  ..browserIdProvider(() => '<browser_id>'); // supply a custom browser id

Experience Interceptors #

Extend PianoComposerInterceptor to hook into the request lifecycle. Both methods are optional:

class CustomInterceptor extends PianoComposerInterceptor {
  @override
  void beforeExecute(PianoComposerRequest request) {
    // Inspect the request before it is sent, or throw to refuse it.
  }

  @override
  void afterExecute(
    PianoComposerRequest request,
    PianoComposerResponse response,
  ) {
    // Handle the response. `response.pageViewId` is the page view Composer
    // reported this request under, so another SDK can be given the same one.
  }
}

final composer = piano.composer()
  ..addExperienceInterceptor(CustomInterceptor());

beforeExecute is the only place an interceptor can fail an experience: throwing there refuses the request before it is sent. The experience has been executed successfully by the time afterExecute runs, so throwing there does not fail it — the failure is logged and the remaining interceptors still run.

That applies to what afterExecute throws before it returns. It returns void, so an async implementation hands the failures that happen after its first await to the surrounding zone instead of to the client. Start the work that has to wait and handle it yourself:

@override
void afterExecute(
  PianoComposerRequest request,
  PianoComposerResponse response,
) {
  unawaited(
    report(response).catchError((Object error) => log(error)),
  );
}

response.pageViewId belongs to the response being handled rather than to the client, so it is the right page view even while clients of the same instance execute in parallel, and the work started above reads the same value whenever it runs.

An interceptor that joins Composer with another SDK can also name the browser id of the requests it intercepts. It is passed next to the interceptor, so that PianoComposerInterceptor stays the two methods above:

final composer = piano.composer()
  ..addExperienceInterceptor(
    interceptor,
    browserIdProvider: () => '<browser_id>', // null leaves the browser id alone
  );

Interceptors of the Instance #

piano.composer() returns a new client on every call, so an interceptor added to one client is unknown to the next one. Enable it for the instance instead when every experience of the application is meant to run it. This lives in its own library, since executing an experience needs none of it:

import 'package:piano_composer/interceptors.dart';

piano.composerInterceptors.enable(CustomInterceptor());

Every client of the instance runs it, including the clients created before it was enabled, because the interceptors are read for every request:

  • Interceptors of the client run before the enabled ones. Enabling the same interceptor twice does nothing, and disable takes one back, reporting whether it was enabled.
  • piano.composerInterceptors.enabled lists what is enabled for the instance, and one instance shares nothing with another.
  • enable takes a browserIdProvider as well, which is the browser id the requests of the instance report while that interceptor is enabled. The first interceptor that knows one names it, the interceptors of the client are asked before the enabled ones, and a browserIdProvider given to the client wins over all of them. piano.composerInterceptors.browserId() reports the one the enabled interceptors currently name.

This is also how a package built on Composer is turned on — see piano_c1x, which is enabled once for the instance rather than once per client.

Building a Request #

PianoComposerRequest carries the content metadata and targeting context. All fields are optional:

Field Type Description
url String? Page URL
referer String? Referring URL
title String? Content title
description String? Content description
tags List<String> Content tags
keywords List<String> Content keywords
zone String? Targeting zone
contentId String? Content identifier
contentType String? Content type
contentCreated String? ISO 8601 timestamp (use PianoComposerRequest.formatDate)
contentAuthor String? Content author
contentSection String? Content section
contentIsNative bool? Whether the content is native
customVariables Map<String, List<String>?> Custom targeting variables
isDebug bool Enable verbose API responses
final request = PianoComposerRequest(
  url: 'https://piano.io/article/123',
  title: 'Breaking News Article',
  description: 'Latest updates on technology',
  contentId: '123',
  contentType: 'article',
  contentCreated: PianoComposerRequest.formatDate(DateTime.now()),
  contentAuthor: 'John Doe',
  contentSection: 'Technology',
  zone: 'homepage',
  customVariables: {
    'user_type': ['premium'],
    'region': ['us-west'],
  },
);

final response = await composer.execute(request);

Handling Events #

execute returns a PianoComposerResponse whose result.events contains the experience events. Handle them with typed listeners:

final response = await composer.execute(
  request,
  listeners: [
    PianoComposerEventTypeListener<PianoComposerShowTemplate>(
      onEvent: (event) => print('Show template: ${event.eventData.url}'),
    ),
    PianoComposerEventTypeListener<PianoComposerMeter>(
      onEvent: (event) => print('Meter event received'),
    ),
  ],
);

Or receive every event with a single callback:

final response = await composer.execute(
  request,
  onEvents: (events) {
    for (final event in events) {
      print('Event: ${event.eventData.runtimeType}');
    }
  },
);

Event types include PianoComposerExperienceExecute, PianoComposerMeter, PianoComposerShowTemplate, PianoComposerShowForm, PianoComposerShowLogin, PianoComposerShowRecommendations, PianoComposerUserSegment, PianoComposerSetResponseVariable, and PianoComposerNonSite.

Tracking #

Report user interactions back to Piano through a PianoComposerTracking instance so they are attributed to the originating experience:

final tracking = piano.composer().tracking;

// User dismissed a template or experience.
await tracking.trackCloseEvent('<tracking_id>');

// A recommendations widget was shown, then clicked.
await tracking.trackRecommendationsDisplay('<tracking_id>');
await tracking.trackRecommendationsClick('<tracking_id>', '<url>');

// A custom form was shown, then submitted.
await tracking.trackCustomFormImpression('<form_name>', '<tracking_id>');
await tracking.trackCustomFormSubmission('<form_name>', '<tracking_id>');

Every event received from execute also carries its own tracking reference and tracking id, so there is no need to keep the client around:

await composer.execute(
  request,
  listeners: [
    PianoComposerEventTypeListener<PianoComposerShowTemplate>(
      onEvent: (event) => event.tracking?.trackCloseEvent(
        event.eventExecutionContext.trackingId,
      ),
    ),
  ],
);

tracking is null only for events you construct yourself (for example via PianoComposerEvent.fromJson), because the client attaches it while processing the response.

Reading Response Data #

// Retain the same instance to read back state after execute().
final composer = piano.composer();
final response = await composer.execute(request);

// Cookies
final tbCookie = response.tbCookie; // tracking cookie
final xbCookie = response.xbCookie; // experience cookie
final taCookie = response.taCookie; // analytics cookie

// Identifiers
final browserId = composer.browserId;
final pageViewId = response.pageViewId; // the page view of this very request
final userId = response.userId;

// Edge cookies (if enabled)
final edgeCookies = composer.edgeCookies;

Clearing Stored Data #

Clear all locally stored data and cookies:

piano.composer().clearStoredData();

Error Handling #

execute throws a PianoException when the API returns errors:

try {
  final response = await piano.composer().execute(request);
} on PianoException catch (e) {
  print('Piano error: ${e.message}');
} catch (e) {
  print('Unexpected error: $e');
}