pnlight_sdk 0.11.0
pnlight_sdk: ^0.11.0 copied to clipboard
Flutter wrapper for PNLight iOS SDK.
PNLight SDK - Flutter Plugin #
A Flutter plugin that provides iOS integration with the PNLight SDK.
Installation #
Add this to your package's pubspec.yaml file:
dependencies:
pnlight_sdk: ^0.1.0
Then run:
flutter pub get
Requirements #
- Flutter: >=3.3.0
- Dart: >=2.17.0 <4.0.0
- iOS: 15.0+
This package is iOS-only. Android is not supported.
iOS Setup #
pnlight_sdk declares DivKit, the DivKit extension runtime, Lottie, and Core
Haptics transitively. After adding the Flutter package, no separate pod,
renderer registration, navigation controller, or safe-area integration is
required for RemoteUiView.
The pod also links PNLight's required Apple frameworks automatically. Apps that
need IDFA tracking must still provide the user-facing
NSUserTrackingUsageDescription text in Info.plist:
<key>NSUserTrackingUsageDescription</key>
<string>This app uses device tracking to provide analytics and improve user experience.</string>
Usage #
Initialization #
Initialize PNLight before using analytics, attribution, Remote UI, or Remote Config. You can provide local Remote Config defaults at startup: they work offline, are never sent to PNLight, and are used if a remote value is missing or has a different type.
import 'package:flutter/widgets.dart';
import 'package:pnlight_sdk/pnlight_sdk.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await PNLightSDK.initialize(
'your-api-key',
remoteConfigDefaults: {
'paywall_enabled': true,
'paywall_title': 'Try Premium',
'trial_days': 7,
'enabled_plans': ['monthly', 'yearly'],
'paywall_style': {'accent': '#6750A4', 'compact': false},
},
);
runApp(MyApp());
}
Event Logging #
import 'package:pnlight_sdk/pnlight_sdk.dart';
await PNLightSDK.logEvent('purchase_completed', {
'product_id': 'premium_subscription',
'amount': 9.99,
'currency': 'USD',
});
Attribution #
Send attribution data from external providers before requesting UI config.
import 'package:pnlight_sdk/pnlight_sdk.dart';
final success = await PNLightSDK.addAttribution(
provider: 'appsFlyer',
data: {'af_status': 'Non-organic'},
identifier: 'your-appsflyer-id',
);
AppsFlyer Integration Example
PNLight does not depend on the AppsFlyer initialization order — it only needs the conversion data, delivered via addAttribution. Make sure the conversion ("attribution success") callback is not processed before PNLight is initialized: if it can fire earlier, store the conversion data in memory and call addAttribution once initialize completes.
AppsFlyer requires the ATT prompt to complete before it starts. If PNLight is initialized before ATT authorization, call updateIdfa() after the prompt completes so PNLight receives the granted IDFA.
import 'package:app_tracking_transparency/app_tracking_transparency.dart';
import 'package:appsflyer_sdk/appsflyer_sdk.dart';
import 'package:pnlight_sdk/pnlight_sdk.dart';
Future<void> startSdks() async {
await PNLightSDK.initialize('your-api-key');
// Request ATT, then pass the granted IDFA to PNLight.
await AppTrackingTransparency.requestTrackingAuthorization();
await PNLightSDK.updateIdfa();
// Start AppsFlyer after ATT completes (an AppsFlyer requirement).
final appsFlyerSdk = AppsflyerSdk(AppsFlyerOptions(
afDevKey: 'your-appsflyer-dev-key',
appId: 'your-ios-app-id',
showDebug: false,
));
appsFlyerSdk.onInstallConversionData((conversionData) async {
final appsFlyerId = await appsFlyerSdk.getAppsFlyerUID();
final payload = conversionData['payload'] ?? conversionData;
await PNLightSDK.addAttribution(
provider: 'appsFlyer',
data: Map<String, dynamic>.from(payload as Map),
identifier: appsFlyerId,
);
});
await appsFlyerSdk.initSdk(
registerConversionDataCallback: true,
registerOnAppOpenAttributionCallback: false,
registerOnDeepLinkingCallback: false,
);
}
Supported providers:
appsFlyerfirebasefacebook
User Identity #
import 'package:pnlight_sdk/pnlight_sdk.dart';
final userId = await PNLightSDK.getUserId();
In-App Purchases #
PNLight wraps StoreKit 2 for fetching products (price, offers, trial info),
purchasing, restoring, and checking entitlements. The product ids are configured
on the backend — fetchProducts resolves them against the App Store.
import 'package:pnlight_sdk/pnlight_sdk.dart';
// Load the configured products with their App Store price/offer info.
final products = await PNLightSDK.fetchProducts();
for (final product in products) {
print('${product.displayName}: ${product.displayPrice}');
final offer = product.subscription?.introductoryOffer;
if (offer != null && product.subscription!.isEligibleForIntroOffer) {
// e.g. pay-as-you-go: "$0.99/month for 6 months"
print('Offer: ${offer.displayPrice} (${offer.paymentMode.name}, '
'${offer.periodCount} × ${offer.period.value} ${offer.period.unit.name})');
}
}
// Purchase.
final result = await PNLightSDK.purchase('your.product.id');
if (result == PNLightPurchaseResult.success) {
// Unlock content.
}
// Entitlement checks (local StoreKit entitlements, work offline).
final premium = await PNLightSDK.isPremium();
final eligible = await PNLightSDK.isEligibleForTrial('your.product.id');
// Restore previous purchases.
await PNLightSDK.restorePurchases();
RemoteUiView - Server-driven UI #
RemoteUiView fetches and renders a server-driven layout from PNLight for a given placement. It calls getUIConfig(placement) internally, renders the native view, and emits action events to Dart.
DivKit markup can also select PNLight's custom native iOS components:
pnlight.circular_loader, pnlight.cta_button, pnlight.icon_button,
pnlight.animated_prepend_list, pnlight.progress_bar, and
pnlight.animated_number. They are UIKit views rendered inside the iOS platform
view; Dart does not recreate them. Their layout, styling, state, and actions are
still controlled by the server-delivered DivKit JSON. These custom components
are unavailable on non-iOS platforms.
When using external attribution providers such as AppsFlyer, send attribution as early as possible (see the AppsFlyer example above). getUIConfig waits for attribution data internally when attributionRequired is true (the default), so no manual delay is needed.
import 'package:flutter/widgets.dart';
import 'package:pnlight_sdk/pnlight_sdk.dart';
class PaywallScreen extends StatelessWidget {
const PaywallScreen({super.key});
void _handleAction(RemoteUiActionEvent event) {
if (event.logId == 'close_button') {
// Close the screen
}
}
void _handlePurchased(String productId) {
print('Purchased $productId');
}
@override
Widget build(BuildContext context) {
return RemoteUiView(
placement: 'paywall',
onAction: _handleAction,
onPurchased: _handlePurchased,
);
}
}
System purchase action #
Remote UI can start a StoreKit purchase inside PNLight with a DivKit typed custom action, without a purchase handler in Dart:
{
"log_id": "purchase_button",
"typed": { "type": "custom" },
"payload": {
"id": "pnlight.purchase",
"params": {
"product_id": "{{product_1}}",
"on_success": [{
"log_id": "purchase_success",
"url": "pnlight://navigation/replace?route=protected"
}]
}
}
}
payload.id must be pnlight.purchase, and params.product_id is required.
params.on_success is optional and accepts ordinary DivKit action dictionaries.
After success, PNLight runs all of them with the original DivKit action context,
so navigation, set_variable, haptics, dialogs, and URL actions retain their
normal behavior. With no on_success, the purchase has no follow-up.
The follow-up runs only for PNLightPurchaseResult.success. Cancellation, a
pending StoreKit purchase, and errors leave the current UI in place. Repeated
purchase taps are ignored while one SDK-owned purchase is in progress.
The view's optional onPurchased callback receives the productId after this
SDK-owned purchase is verified successfully. It is not emitted for manual
PNLightSDK.purchase(productId) calls or purchases started by another Remote UI
view.
This action is a schema v3 feature. The document must declare
"schemaVersion": 3; schema v1/v2 documents do not execute
pnlight.purchase.
Remote UI schema version #
PNLight versions the Remote UI envelope independently from the pub package.
Schema v3 adds the SDK-owned pnlight.purchase action with its onPurchased
event, and the pnlight.progress_bar and pnlight.animated_number native
components. Schema v2 remains supported for existing documents and continues to
provide safe-area handling, scaling variables, flows, haptics, and dialogs.
Declare "schemaVersion": 2 at the document root to enable PNLight safe-area
handling, linear-scaling variables, server-driven flows, native haptics, and
native dialogs:
{
"schemaVersion": 2,
"card": {}
}
A missing schemaVersion (or an explicit value of 1) is legacy v1. Existing
backend configs therefore keep their previous edge-to-edge DivKit behavior
when an app upgrades the plugin. They do not receive v2 scaling variables or
opt into PNLight-owned navigation, haptic, and dialog action handling. New
configs that use any feature below should declare "schemaVersion": 2.
Safe areas #
Remote UI stays clear of the Dynamic Island, status bar, home indicator, and
landscape sensor housing without Flutter-side padding in schema v2. A v2
document with no safe_area field defaults to inset on all four edges:
{
"schemaVersion": 2,
"safe_area": {
"mode": "inset",
"edges": ["top", "bottom", "left", "right"]
},
"card": {}
}
| Mode | Behavior |
|---|---|
inset |
Default. Places the complete DivKit document inside the selected safe-area edges. |
content |
Keeps the document edge-to-edge and exposes selected insets as DivKit variables. |
edge_to_edge |
Keeps the document edge-to-edge and resolves safe-area variables to zero. |
In content mode, use safe_area_top, safe_area_bottom,
safe_area_left, and safe_area_right in the content container while its
background remains full bleed:
{
"schemaVersion": 2,
"safe_area": {
"mode": "content",
"edges": ["top", "bottom"]
},
"card": {
"states": [{
"state_id": 0,
"div": {
"type": "container",
"width": { "type": "match_parent" },
"height": { "type": "match_parent" },
"paddings": {
"top": "@{safe_area_top + 24}",
"bottom": "@{safe_area_bottom + 24}",
"left": 24,
"right": 24
},
"items": []
}
}]
}
}
PNLight updates these variables after rotation, window resizing, and sheet
presentation. A flow can declare a default safe_area; each route can override
it beside divkit and presentation.
Linear scaling variables #
Remote UI JSON may declare a logical design viewport:
{
"schemaVersion": 2,
"referenceSize": { "width": 390, "height": 844 },
"card": {}
}
PNLight exposes scaleX = availableWidth / referenceSize.width and
scaleY = availableHeight / referenceSize.height as live numeric DivKit
variables. Use them directly in expressions, for example
"left": "@{24 * scaleX}". They update for rotation, host-view resize, and
native sheet-size changes. In inset safe-area mode, the available viewport is
measured after the selected system insets are removed.
The ratios are raw, not automatically clamped, and PNLight does not scale the
layout unless markup references them. For a uniform mobile design scale,
prefer scaleX and apply your chosen bounds in markup. Never multiply system
safe-area or keyboard insets by these values. Without referenceSize, both
variables equal 1 in schema v2.
Flows may define referenceSize beside routes; individual routes may
override it beside divkit or inside their complete divkit document. No
Flutter configuration is required.
Server-driven native flows #
The same RemoteUiView also accepts a PNLight flow envelope. Each route contains
a complete DivKit document. PNLight owns a private native navigation stack and
consumes navigation URLs before they reach Dart:
{
"schemaVersion": 2,
"type": "flow",
"safe_area": { "mode": "inset" },
"initial_route": "welcome",
"routes": {
"welcome": {
"divkit": {
"templates": {},
"card": {
"log_id": "welcome",
"states": [{
"state_id": 0,
"div": {
"type": "custom",
"custom_type": "pnlight.cta_button",
"width": { "type": "match_parent" },
"height": { "type": "fixed", "value": 56 },
"custom_props": {
"title": "Open offer",
"url": "pnlight://navigation/present?route=offer"
}
}
}]
}
}
},
"offer": {
"safe_area": {
"mode": "content",
"edges": ["top", "bottom"]
},
"presentation": {
"style": "sheet",
"detent": "large",
"grabber": true,
"dismissible": true,
"corner_radius": 28
},
"divkit": {
"templates": {},
"card": {
"log_id": "offer",
"states": [{
"state_id": 0,
"div": {
"type": "custom",
"custom_type": "pnlight.cta_button",
"width": { "type": "match_parent" },
"height": { "type": "fixed", "value": 56 },
"custom_props": {
"title": "Dismiss",
"url": "pnlight://navigation/dismiss"
}
}
}]
}
}
}
}
}
| URL | Native iOS behavior |
|---|---|
pnlight://navigation/push?route=details |
Pushes a route on the active PNLight stack. |
pnlight://navigation/pop |
Pops the active stack. |
pnlight://navigation/replace?route=details |
Replaces the active route. |
pnlight://navigation/pop_to_root |
Pops to the first route. |
pnlight://navigation/present?route=offer |
Presents a route in a new native modal navigation context. |
pnlight://navigation/dismiss |
Dismisses the current PNLight modal context. |
Routes are preloaded when the flow is received. A presented route can push other routes inside its own stack, while dismissing it preserves the embedded stack's controllers, DivKit variables, and scroll state.
presentation.style accepts sheet (default) or full_screen. A sheet uses
the native iOS page-sheet presentation:
| Field | Default | Description |
|---|---|---|
detent |
large |
large, or medium with expansion to large. |
grabber |
true |
Shows the native sheet grabber. |
dismissible |
true |
When false, disables swipe-to-dismiss. |
corner_radius |
system | Preferred native sheet corner radius. |
The present URL can override those fields for one transition, for example
pnlight://navigation/present?route=offer&detent=medium&grabber=false.
Unknown routes are ignored. In a legacy single-card document, navigation URLs
continue through onAction; they are consumed only inside a valid flow.
Native alerts and action sheets #
Put dialogs at the Remote UI document root (beside card, or beside routes
in a flow) and trigger one with
pnlight://dialog/show?id=<dialog-id>. The plugin presents a native iOS
UIAlertController; Flutter needs no navigator, dialog state, or additional
configuration.
{
"schemaVersion": 2,
"dialogs": {
"delete_confirmation": {
"style": "alert",
"title": "Delete item?",
"message": "This cannot be undone.",
"buttons": [
{ "title": "Cancel", "style": "cancel", "actions": [] },
{
"title": "Delete",
"style": "destructive",
"actions": [
{
"log_id": "delete_confirmed",
"url": "my-app://delete?id=42"
}
]
}
]
}
},
"card": {}
}
Dialog style is alert or action_sheet; button style is default,
cancel, or destructive. Every button owns an ordered array of normal
DivKit actions. They run through the current card's DivKit handler, including
typed variable/state actions, PNLight navigation and haptics, analytics, and
custom URLs delivered through onAction. Empty arrays are dismiss-only.
Native haptics #
PNLight consumes pnlight://haptic/... actions before onAction. Simple UIKit
feedback works in both schema-v2 single-card documents and flow routes:
| URL | Native behavior |
|---|---|
pnlight://haptic/impact?style=light |
Impact feedback. style: light, medium, heavy, soft, or rigid; optional intensity is clamped to 0...1. |
pnlight://haptic/selection |
Selection feedback. |
pnlight://haptic/notification?type=success |
Notification feedback. type: success, warning, or error. |
Flows can also declare named Core Haptics patterns:
{
"schemaVersion": 2,
"type": "flow",
"initial_route": "main",
"haptics": {
"ambient_pulse": {
"events": [
{
"type": "continuous",
"time": 0,
"duration": 0.8,
"intensity": 0.25,
"sharpness": 0.35
},
{
"type": "transient",
"time": 0.4,
"intensity": 0.65,
"sharpness": 0.55
}
],
"loop": true,
"max_duration": 120
}
},
"routes": {
"main": {
"divkit": {
"templates": {},
"card": {
"log_id": "main",
"states": [{
"state_id": 0,
"div": {
"type": "text",
"text": "Haptic demo",
"width": { "type": "match_parent" },
"height": { "type": "wrap_content" }
}
}]
}
}
}
}
}
Use pnlight://haptic/start?pattern=ambient_pulse to start a named pattern and
pnlight://haptic/stop?pattern=ambient_pulse to stop it. A stop URL without
pattern stops every active player owned by the route.
Patterns accept 1–128 transient or continuous events. Timing is in seconds;
time_ms, duration_ms, and max_duration_ms are also accepted. Intensity
and sharpness are clamped to 0...1. A pattern can run for at most 600 seconds
and defaults to a five-minute safety limit. Core Haptics is a no-op on
unsupported hardware. Active players stop when their route leaves the window,
the configuration changes, the app enters the background, or the renderer is
destroyed.
Lottie animations #
pnlight_sdk registers DivKit's standard lottie extension automatically.
The Flutter app does not install Lottie or register a renderer.
Attach the extension to a fixed-size DivKit element and provide a remote JSON URL:
{
"type": "container",
"width": { "type": "fixed", "value": 200 },
"height": { "type": "fixed", "value": 200 },
"items": [],
"extensions": [{
"id": "lottie",
"params": {
"lottie_url": "https://cdn.example.com/animation.json",
"repeat_count": 0,
"repeat_mode": "restart",
"is_playing": true
}
}]
}
Alternatively, put the decoded Lottie document directly in lottie_json:
{
"id": "lottie",
"params": {
"lottie_json": {
"v": "5.7.4",
"fr": 60,
"ip": 0,
"op": 120,
"w": 200,
"h": 200,
"assets": [],
"layers": []
}
}
}
repeat_mode accepts restart (default) or reverse. A repeat_count of 0
repeats indefinitely, and is_playing defaults to true. Remote URLs use the
DivKit resource pipeline; use HTTPS in production. The integration supports
inline and downloaded uncompressed Lottie JSON, but not .lottie ZIP archives.
Native iOS circular loader #
Use DivKit's custom element to render a native UIActivityIndicatorView inside Remote UI markup:
{
"type": "custom",
"custom_type": "pnlight.circular_loader",
"width": { "type": "fixed", "value": 48 },
"height": { "type": "fixed", "value": 48 },
"custom_props": {
"style": "large",
"color": "#FF007AFF",
"accessibility_label": "Loading"
}
}
style accepts "medium" (the default) or "large". color accepts #RRGGBB or DivKit-style #AARRGGBB and defaults to the adaptive iOS label color. accessibility_label defaults to "Loading". Standard DivKit width and height fields control the element's layout; set a dimension to { "type": "wrap_content" } to use the native indicator's intrinsic size for that dimension.
Native iOS CTA button #
Render a native, animated call-to-action button with a repeating shimmer streak,
an idle attention pulse, and a spring press bounce. Taps use the same action
pipeline as DivKit buttons, so a custom-scheme url fires
RemoteUiView.onAction with a RemoteUiActionEvent.
{
"type": "custom",
"custom_type": "pnlight.cta_button",
"width": { "type": "match_parent" },
"height": { "type": "fixed", "value": 58 },
"custom_props": {
"title": "Continue",
"background_color": "#FF007AFF",
"title_color": "#FFFFFFFF",
"corner_radius": 16,
"font_size": 19,
"font_weight": "bold",
"shimmer": true,
"bounce": true,
"url": "pnlight://cta?id=continue"
}
}
All props are optional; only title and url are usually needed. Colors accept
#RRGGBB or DivKit-style #AARRGGBB.
| Prop | Default | Description |
|---|---|---|
title |
"" |
Button label. |
background_color |
#FF007AFF |
Fill color (also the gradient start). |
background_color_end |
— | When set, the fill is a horizontal gradient to this color. |
glass |
off (on for icon buttons) | Native Liquid Glass fill on iOS 26+. See below. |
title_color |
#FFFFFFFF |
Label color. |
corner_radius |
14 |
Corner radius in points (continuous curve). |
font_size |
18 |
Label point size. |
font_weight |
semibold |
regular/medium/semibold/bold/heavy/black. |
horizontal_padding / vertical_padding |
24 / 16 |
Used to size the button when width/height is wrap_content. |
icon |
— | SF Symbol name, e.g. "shield.lefthalf.filled". Renders before the title. |
icon_size |
20 |
Symbol point size. |
icon_weight |
semibold |
ultralight … black. |
icon_color |
title_color |
Symbol tint. |
icon_spacing |
8 |
Gap between icon and title. |
loading |
false |
Swaps the title for a native spinner and ignores taps. |
disabled |
false |
Dims the button and ignores taps. |
disabled_alpha |
0.45 |
Opacity used while disabled. |
disabled_background_color |
— | Replaces the fill (and any gradient) while disabled. |
disabled_title_color |
— | Replaces the label color while disabled. |
loading_indicator_color |
title_color |
Spinner color. |
loading_indicator_style |
medium |
medium or large. |
url |
— | Action fired on tap (custom scheme → onAction; http(s) → opened externally). |
log_id |
— | Emitted as the action's logId when there is no url. |
accessibility_label |
title |
VoiceOver label (defaults to "Loading" while loading). |
Both loading and disabled make the button inert: taps are ignored and the
shimmer and bounce animations stop.
shimmer and bounce accept a boolean shorthand (true/false) or an object
for fine control:
{
"shimmer": {
"enabled": true,
"color": "#80FFFFFF",
"duration": 1.4,
"pause": 1.1,
"band_width": 0.3,
"angle": 16
},
"bounce": {
"idle": { "enabled": true, "scale": 1.03, "period": 2.6 },
"press": { "enabled": true, "scale": 0.96 }
}
}
{ "bounce": true } enables both the idle pulse and press feedback.
{ "bounce": { "idle": false, "press": true } } keeps only press feedback.
Liquid Glass (iOS 26+)
Buttons can use the native Liquid Glass material. Icon buttons opt in
automatically when markup does not set background_color; they fall back to
secondarySystemFill on older systems. An explicit background_color disables
the automatic glass default. Set glass explicitly to combine a chosen color
with the material:
{
"glass": {
"enabled": true,
"style": "regular",
"tint": "#99FF375F",
"prominent": true
}
}
style accepts "regular" or "clear". { "glass": true } is the shorthand
for opting a CTA in. prominent selects the filled primary-action treatment.
When glass is enabled, title_color defaults to the adaptive label color and
shimmer defaults to off unless markup overrides them. Glass falls back to the
solid background_color on iOS 25 and earlier, when Reduce Transparency is
enabled, and while disabled_background_color applies.
Native CTA and icon buttons also support the standard DivKit actions array.
Actions execute in declaration order through DivKit's normal action handler,
including typed variable/state actions, PNLight haptics, dialogs, navigation,
analytics, and custom URLs. A non-empty actions array takes precedence over
the custom_props.url / log_id single-action shorthand.
Driving loading and disabled at runtime
Any custom prop can contain a DivKit expression. For example, bind loading to
a card variable:
{
"card": {
"variables": [{ "type": "boolean", "name": "is_busy", "value": false }],
"states": [{ "state_id": 0, "div": {
"type": "custom",
"custom_type": "pnlight.cta_button",
"custom_props": {
"title": "Submit",
"loading": "@{is_busy}",
"url": "pnlight://cta?id=submit"
}
}}]
}
}
Toggle the variable from markup with a standard set_variable action:
{
"log_id": "begin",
"typed": {
"type": "set_variable",
"variable_name": "is_busy",
"value": { "type": "boolean", "value": true }
}
}
The Flutter callback receives the URL query parameters:
void handleAction(RemoteUiActionEvent event) {
if (event.params['id'] == 'continue') {
// Continue the flow.
}
}
RemoteUiView(
placement: 'paywall',
onAction: handleAction,
);
Native iOS icon button #
pnlight.icon_button uses the same native button renderer, tuned for a small
circular SF Symbol control such as close, settings, or favorite:
{
"type": "custom",
"custom_type": "pnlight.icon_button",
"width": { "type": "fixed", "value": 44 },
"height": { "type": "fixed", "value": 44 },
"custom_props": {
"icon": "xmark",
"accessibility_label": "Close",
"url": "pnlight://cta?id=close"
}
}
It accepts every pnlight.cta_button prop above, including loading,
disabled, shimmer, bounce, and expression binding. Only its defaults
differ:
| Prop | CTA default | Icon default |
|---|---|---|
| shape | corner_radius: 14 |
fully circular |
background_color |
#FF007AFF |
secondarySystemFill (adaptive) |
glass |
off | on when no background_color is set |
| icon/title color | white | label (adaptive) |
horizontal_padding / vertical_padding |
24 / 16 |
12 / 12 |
shimmer |
on | off |
bounce.idle |
on | off |
bounce.press |
on | on |
Setting corner_radius opts out of the circular shape. With wrap_content on
both axes, the button sizes itself from the symbol plus padding and remains
square.
For an icon-only button, set accessibility_label; otherwise VoiceOver falls
back to the SF Symbol name. An unknown symbol name is reported in the card's
DivKit errors instead of silently rendering an empty button.
Native iOS progress bar #
pnlight.progress_bar is a native linear progress bar. Give it a new
progress (or move the variable behind progress_variable) and it animates
from whatever is currently on screen to the new value, so markup no longer has
to fake a bar with a stack of DivKit animations.
{
"type": "custom",
"custom_type": "pnlight.progress_bar",
"width": { "type": "match_parent" },
"height": { "type": "fixed", "value": 10 },
"custom_props": {
"instance_id": "scan",
"progress": 0,
"progress_variable": "scan_progress",
"animation_duration": 0.45
}
}
progress is clamped to 0...1. The bar's thickness is the standard DivKit
height; with wrap_content it falls back to track_height. corner_radius
defaults to a pill. Colors accept #RRGGBB or DivKit-style #AARRGGBB.
| Prop | Default | Description |
|---|---|---|
instance_id |
fallback shared ID | Stable identity for retaining fill state; set this explicitly. |
progress |
0 |
Target fill, clamped to 0...1. |
progress_variable |
"" |
DivKit variable name that drives progress reactively across native and web renderers. |
initial_progress |
— | Fill the first render starts from, so the bar can animate in on appear. Without it the first value is applied immediately. |
indeterminate |
false |
Loops a sliding band and ignores progress, for work of unknown length. |
track_color |
secondarySystemFill |
Track fill. |
track_height |
8 |
Thickness used only when the DivKit height is wrap_content. |
fill_color |
#FF007AFF |
Fill color (also the gradient start). |
fill_color_end |
— | When set, the fill is a horizontal gradient to this color. |
corner_radius |
pill | Continuous corner radius in points, applied to the track and the fill. |
fill_inset |
0 |
Inset between the track's bounds and the fill on every edge. |
animation_duration |
0.3 |
Seconds spent travelling to a new value. |
indeterminate_duration |
1.1 |
Seconds for one band pass. |
indeterminate_band_width |
0.3 |
Band thickness as a fraction of the track width. |
reduced_motion |
false |
Applies values immediately and holds the indeterminate band still. |
accessibility_label |
"Progress" |
VoiceOver label; the value is announced as a percentage. |
A long animation_duration is also how a bar fills by itself: set progress
to 1 with "animation_duration": 8 and the bar takes eight seconds to get
there, with no timer in the markup.
Native iOS animated number #
pnlight.animated_number is a native label that counts between values. Pass a
number variable and it renders every value in between — 0 to 14 counts up
rather than snapping.
{
"type": "custom",
"custom_type": "pnlight.animated_number",
"width": { "type": "match_parent" },
"height": { "type": "wrap_content" },
"custom_props": {
"instance_id": "issues",
"value": 0,
"value_variable": "issues_found",
"suffix": " issues found",
"font_size": 28,
"font_weight": "bold"
}
}
| Prop | Default | Description |
|---|---|---|
instance_id |
fallback shared ID | Stable identity for retaining the displayed value; set this explicitly. |
value |
0 |
Target number. |
value_variable |
"" |
DivKit variable name that drives value reactively across native and web renderers. |
initial_value |
— | Value the first render counts from, e.g. 0 for a count-up on appear. Without it the first value is displayed immediately. |
decimals |
0 |
Fraction digits, applied as both the minimum and the maximum so the label never changes length mid-count. |
min_integer_digits |
1 |
Zero-pads shorter numbers, e.g. 2 renders 07. |
grouping |
true |
Thousands separators. |
monospaced_digits |
true |
Tabular figures, so the label does not jitter while counting. |
prefix / suffix |
"" |
Text placed before/after the number, e.g. "$" or " GB". |
font_size |
34 |
Point size. |
font_weight |
bold |
ultralight/thin/light/regular/medium/semibold/bold/heavy/black. |
text_color |
label |
Adaptive by default. |
text_alignment |
center |
left/center/right/natural. |
animation_duration |
0.6 |
Seconds spent counting to a new value. |
curve |
ease_out |
linear/ease_in/ease_out/ease_in_out. |
reduced_motion |
false |
Applies values immediately, without counting. |
accessibility_label |
— | VoiceOver label; the target value is announced as the element's value. |
Numbers are formatted for the device locale, so separators follow the user's region. A value that changes mid-count is picked up from the value currently on screen, and an unrelated variable change never restarts the count.
Both components are schema v3 features. The document must declare
"schemaVersion": 3; in a v1/v2 document they report a DivKit error instead of
rendering. Give every simultaneously rendered instance a stable, unique
instance_id — that identity is what lets the native view keep its animation
state across DivKit variable updates.
Use progress_variable / value_variable for variable-driven values so the
same JSON is reactive in both the native and the web renderer; keep progress
/ value as the literal initial value. Every other prop supports DivKit
expressions. Both components are implemented by PNLightSDK on iOS and by
@pnlight/sdk-react on the web.
Manual Config Fetching #
Use getUIConfig if you need to fetch the placement configuration yourself. When attributionRequired is true (the default), the SDK waits for attribution data internally before returning:
import 'package:pnlight_sdk/pnlight_sdk.dart';
final config = await PNLightSDK.getUIConfig('paywall');
final configWithoutAttributionWait = await PNLightSDK.getUIConfig(
'paywall',
attributionRequired: false,
);
Remote Config #
Remote Config delivers typed, flat configuration values from the PNLight dashboard. It is for non-secret product configuration, not API keys, credentials, or other sensitive data. Values are persisted per SDK token and installation user, so local defaults are available immediately and the last activated configuration remains available offline.
Fetch after initializing PNLight. fetchAndActivate waits for AppsFlyer
attribution by default so server-side attribution overrides can apply. Set
waitAttribution: false when a base-only response is needed immediately.
final outcome = await PNLightSDK.fetchAndActivate();
switch (outcome) {
case RemoteConfigFetchResult.activated:
case RemoteConfigFetchResult.notModified:
// A current server config is active (or its ETag matched).
break;
case RemoteConfigFetchResult.throttled:
// A successful fetch occurred within the minimum interval.
break;
case RemoteConfigFetchResult.failed:
// Previously active values and local defaults are still safe to use.
break;
}
final paywallEnabled = await PNLightSDK.remoteConfigBoolean(
'paywall_enabled',
fallback: true,
);
final title = await PNLightSDK.remoteConfigString(
'paywall_title',
fallback: 'Try Premium',
);
final trialDays = await PNLightSDK.remoteConfigNumber('trial_days', fallback: 7);
final plans = await PNLightSDK.remoteConfigStringArray(
'enabled_plans',
fallback: ['monthly'],
);
final style = await PNLightSDK.remoteConfigJSONObject(
'paywall_style',
fallback: {'accent': '#6750A4'},
);
Use minimumFetchInterval: Duration.zero for development and tests; production
uses a 15-minute minimum by default. Remote Config keys are flat. JSON-object
values are supported for structured values, but an override replaces the entire
object rather than deep-merging it.
API Reference #
PNLightSDK #
| Method | Description |
|---|---|
initialize(apiKey, baseDomain?, remoteConfigDefaults?) |
Initialize the SDK and optional local Remote Config defaults |
fetchAndActivate(minimumFetchInterval?, waitAttribution?) |
Fetch and atomically activate Remote Config; waits for attribution by default |
remoteConfigBoolean/String/Number/StringArray/JSONObject(key, fallback:) |
Read a typed Remote Config value with a local fallback |
logEvent(eventName, eventArgs?) |
Log a custom event with optional arguments |
addAttribution(provider, data?, identifier?) |
Send attribution data from AppsFlyer, Firebase, or Facebook |
getUserId() |
Get or create a stable user identifier |
updateIdfa() |
Send the current IDFA to PNLight after the ATT prompt completes |
prefetchUIConfig(placement) |
Prefetch a UI config into the in-memory cache |
getUIConfig(placement, attributionRequired?) |
Fetch a UI config; waits for attribution by default |
fetchProducts() |
Load configured products with App Store price/offer/trial info |
purchase(productId) |
Purchase a product; returns a PNLightPurchaseResult |
restorePurchases() |
Restore previous purchases by syncing with the App Store |
isPremium() |
Whether the user has an active entitlement to any configured product |
isPurchased(productId) |
Whether the user has an active entitlement to a specific product |
isEligibleForTrial(productId) |
Whether the user is eligible for a product's introductory offer |
getAppleReceipt() |
Base64 App Store receipt for server-side validation, or null if absent |
RemoteUiView #
| Parameter | Type | Description |
|---|---|---|
placement |
String |
PNLight placement identifier |
cardId |
String? |
Card identifier; defaults to pnlight_<placement> |
secure |
bool |
Deprecated. Secure rendering is controlled by the backend response. |
preventRecording |
bool |
Deprecated. Capture blocking is controlled by the backend. |
onAction |
void Function(RemoteUiActionEvent)? |
Called when a custom action is triggered |
onPurchased |
void Function(String productId)? |
Called after this view verifies a Remote UI purchase |
RemoteUiActionEvent #
| Property | Type | Description |
|---|---|---|
url |
String |
Full URL string of the triggered action |
scheme |
String |
URL scheme |
path |
String |
URL path component |
params |
Map<String, String> |
Query parameters extracted from the URL |
logId |
String? |
Log ID for the triggered action |
action |
String? |
Raw action value when provided by the native view |
UIConfig #
| Property | Type | Description |
|---|---|---|
config |
String? |
Remote UI JSON config |
parameters |
Map<String, dynamic>? |
Placement parameters returned by PNLight |
debug |
bool? |
Debug flag returned by PNLight |
Support #
For support and questions, visit docs.pnlight.app.