kitty_sdk 0.13.5
kitty_sdk: ^0.13.5 copied to clipboard
Kitty Flutter SDK — device fingerprint attribution (Flare) plus authentication, user management, licenses and subscriptions (Account) for the Kitty Launch platform.
Kitty Flutter SDK #
Unified Flutter SDK for the Kitty Launch platform — combines Flare (device fingerprint attribution) and Account (authentication, user management, licenses, subscriptions) in a single package.
Install #
dependencies:
kitty_sdk: ^0.13.5
Requires Flutter >=3.38.0 and Dart >=3.10.0. Coming from 0.12.x? Read
Upgrading to 0.13.0 below first.
Upgrading to 0.13.3 #
No new minimums and no new setup. What a host app can notice:
- With the Kitty backend's App Store attribution update (this reaches 0.13.2
too, once that backend update is deployed), a signed-out device is no longer
logged in to an account that has a real email. If the purchase already
belongs to such an account, the backend answers 409
(
appstore_purchase_claimed). The SDK finishes the transaction without signing in, andonAppStoreObserverErrorreceivesApiException(statusCode: 409)once per launch, and again after each paywall Restore, until the user signs in to that account the usual way; the SDK then claims the purchase for it. Treat this 409 as expected. An explicitKittyAccount.loginWithAppStorecall throws the same exception. The paywall's Restore reports success whenever StoreKit finds the purchase, without signing the user in, so ask a signed-out user to sign in after a restore. - An iOS purchase made while a login is still loading its account, or after a
login whose account fetch failed, is now claimed for that login, and only
onAppStoreAutoClaimfires. Before, the SDK logged in with the purchase's App Store account:onAppStoreAutoLoginfired, and that virtual account replaced the new session. - An automatic App Store login that a login, a logout, a legacy adoption or a
lost stored session overtakes — before or after it has stored its own
credentials — no longer replaces the newer session and no longer finishes its
purchase, and
onAppStoreAutoLogindoes not fire for it. The SDK claims the purchase under the newer session at once, or StoreKit redelivers it. - New
KittyAccount.appAccountTokenfor apps with their own StoreKit code; see "Account — App Store attribution for your own StoreKit code". - New analytics event
AppStoreAutoLoginSuperseded(replay). A superseded automatic login emits noLoginFailed,AppStoreAutoLoginFailedorAppStoreAutoLoginNoAccount, andonAppStoreObserverErrorreceives aSessionSupersededExceptionwhen its purchase is held instead of replayed.
Upgrading to 0.13.0 #
Use 0.13.2 or later rather than 0.13.0. (0.13.1 was not published to pub.dev; 0.13.2 contains its fixes.) It fixes two purchase defects present in 0.13.0: KittyBuyNow purchases now work on Android (broken since 0.10.0), and the paywall's pre-purchase cleanup no longer finishes a renewal whose own claim is still held. Everything below applies to 0.13.0 and later.
New minimums: Flutter >=3.38.0, Dart >=3.10.0 and
in_app_purchase_storekit ^0.4.10. The SDK depends on the plugin directly, so
if your app pins it, allow 0.4.10 or later.
On iOS the SDK finishes App Store transactions, and only after the backend
confirms them. While the App Store observer runs (enableAppStoreObserver: true, the default), the SDK finishes iOS transactions, not your app:
- A purchase is finished once the Kitty backend confirms it. Until then it stays unfinished, and StoreKit redelivers it on the next launch, where the SDK tries again. A purchase can therefore stay held across launches. Failures that may be temporary (offline, timeouts, 5xx, 429) just keep it held. A purchase the backend rejects or does not confirm is finished with an error on the fifth such launch.
- As before, a signed-out buyer is logged in automatically from the purchase. The purchase then goes through the same confirmation. It is finished without confirmation only when that login is refused outright (404, or another 4xx except 429). Since the Kitty backend's App Store attribution update, that includes a 409 for a purchase that already belongs to an account with a real email (see Upgrading to 0.13.3).
- If your app also listens to
InAppPurchase.instance.purchaseStream, do not callcompletePurchasefor iOS App Store purchases while the observer runs. Finishing early discards the receipt before the backend has recorded it. - Android purchase handling is unchanged: the paywalls still finish Google Play
purchases immediately. KittyBuyNow purchases (
InAppBuyNowBlocand the web paywall'sBuyNowMessageHandler) now work on Android in 0.13.1. From 0.10.0 through 0.13.0 they failed withPlatformException(channel-error)before reaching Google Play.
Other changes a host app can notice:
onAppStoreAutoClaimnow also fires after an anonymous auto-login, because the SDK claims the purchase right after logging in.onAppStoreObserverErrornow also receives aStateErrorwhen a purchase is given up. It also receives aTimeoutExceptionwhen a StoreKit finish (10 s), an auto-login or a claim (30 s) times out. Timed-out work is retried on the next launch.- New analytics event
AppStoreClaimGivenUp(productId,attempts). New SharedPreferences keykitty_purchase_claim_attempts(a per-transaction retry counter). ReauthRequiredExceptionnow means the session is really gone, and it has a new optionaloriginStatusCode. A temporary refresh failure now surfaces asApiException,TimeoutException, a transport error, or a parse error (FormatException/TypeError). Do not treat those as a logout. When a logout races a request, it surfaces withisCredentialsAbsent == trueand nooriginStatusCode.SessionSupersededExceptioncan reach calls made throughKittyAccount.authorizedHttpClientorKittySdk.tokens. This happens in a rare race where a login overtakes a token refresh. Catch it by type (on SessionSupersededException) and retry.- When the user id is a UUID, signed-in iOS purchases now carry it as
appAccountToken(StoreKit 2) orapplicationUserName(StoreKit 1). Since 0.13.3, apps with their own StoreKit code read the same value fromKittySdk.account.appAccountToken. - A login that replaces a signed-in user now drops the previous account at once
and emits
nullonaccountStreambefore the new account. If the new account then cannot be fetched (a 5xx or network error), the login throws and the app reads as signed out (isAuthenticatedfalse,currentAccountnull), but the new credentials are kept:refreshAccountInfo(), or the next launch, completes the session. Until then, purchases are handled as signed out up to 0.13.2; since 0.13.3 they are claimed for the new login.
Known limitations:
- While a purchase is held, buying the same product again hits StoreKit's
duplicate-purchase guard, and the SDK reports "purchase pending". The built-in
flows show no message:
InAppBuyNowBlocreturns to the product list as it does on a cancel, andBuyNowMessageHandlercloses the web paywall. - Only a running observer confirms transactions. In three setups the paywall's
pre-purchase cleanup no longer finishes leftover transactions:
enableAppStoreObserver: false, KittyBuyNow without KittyAccount, and macOS. There, a leftover transaction for the product being bought ends in the same silent "pending". - After a login, the SDK at once re-claims subscriptions and non-consumables bought before it. A held consumable, such as a coin pack, is claimed when StoreKit redelivers it on the next launch. Since 0.13.3, one case is not held: a purchase whose automatic App Store login a login or a legacy adoption overtook is claimed for that session at once, a single time, consumables included.
- Confirmation needs StoreKit 2, which is the plugin's default. If your app opts into StoreKit 1, signed-in purchases cannot be confirmed. They are finished with an error on the fifth launch.
Quick Start #
import 'package:kitty_sdk/kitty_sdk.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await KittySdk.initialize(
config: KittySdkConfig(
projectId: 'proj_xxx',
accountBaseUrl: '<your-account-api-url>',
flareBaseUrl: '<your-flare-api-url>',
),
);
runApp(const MyApp());
}
Usage #
Flare — fingerprint identification #
final match = await KittySdk.flare.identify();
if (match != null) {
print('Matched! uid=${match.uid} type=${match.matchType}');
}
Account — authentication #
final account = await KittySdk.account.loginWithPassword(
email: 'user@example.com',
password: 'secret',
);
KittySdk.account.accountStream.listen((account) {
// React to auth state changes
});
For an app that keeps a separate legacy login, check project membership before choosing the login flow:
final useKitty = await KittySdk.account.isUserInProject(
email: email,
);
if (useKitty) {
// Show the Kitty login flow.
} else {
// Show the legacy login flow.
}
This checks for a Kitty project relation or license, including an expired one.
It does not authenticate the email. Handle lookup errors separately; an API
failure must not be treated as false. For Apple sign-in, perform provider
routing after the Apple identity has been verified by the app's backend.
Account — App Store attribution for your own StoreKit code #
The SDK's paywalls attach the signed-in user's id to iOS purchases. If your app starts App Store purchases with its own StoreKit code, read the same value right before each purchase and pass it on:
final token = KittySdk.account.appAccountToken; // null: send nothing
// StoreKit 2 (in_app_purchase_storekit)
await SK2Product.purchase(
productId,
options: token == null ? null : SK2ProductPurchaseOptions(appAccountToken: token),
);
// StoreKit 1 (in_app_purchase)
await InAppPurchase.instance.buyNonConsumable(
purchaseParam: PurchaseParam(productDetails: product, applicationUserName: token),
);
It is the id of the signed-in, loaded account when that id is a UUID. It is null
while signed out, while a login is still loading its account, and for a non-UUID
id; then send no token. While the App Store observer runs, the SDK still claims
such a purchase for the signed-in user. Like every KittySdk.account member, it
throws before KittySdk.initialize.
Account — licenses #
final user = KittySdk.account.currentAccount;
final license = user?.bestLicense;
if (license != null && license.isActive) {
// User has active license
}
Account — feature access (entitlements) #
Gate features on the entitlement keys the billing plan declares
(premium_stories, hd_export, …) rather than on plan names. The account-level
list is the server's answer — the SDK reports it as given and never re-derives
it from the licenses.
App Store review accounts are an exception: when the loaded account's effective
email is test@<domain>, hasEntitlement(key) returns true for every key,
regardless of licenses, expiration, or the server's entitlement list. Email
matching is case-insensitive and ignores surrounding whitespace. The effective
email is primaryEmail, falling back to the first entry in emails; a secondary
test email does not override an ordinary primary email. This applies to the
entitlement helper only: the raw entitlements list and license state stay as
returned by the server.
The gate is tri-state:
null— no account is loaded: a fresh install before the first login, or afterlogout(). Fail closed and keep the feature locked until you know.true/false— the most recently loaded account. Startup awaits a refresh when a session is restored, so normally this is the server's answer. If that startup refresh fails — an offline launch — the SDK keeps the persisted cache, so afalsemay be a stale cached answer rather than the server's current one.
The SDK does not retry a failed startup refresh on its own. Call
refreshAccountInfo() yourself — on app resume, for instance — if the gate has
to recover from an offline launch. A cache written by an SDK version before
0.12.0 rehydrates with no entitlements, so a user who does hold a key reads
false until such a refresh lands. That is deliberate: it fails closed.
switch (KittySdk.account.hasEntitlement('premium_stories')) {
case true:
showPremiumStories();
case false:
showPaywall();
case null:
showLoading(); // not known yet — never treat this as granted
}
// All keys the account holds, or null when no account is loaded.
final keys = KittySdk.account.entitlements;
Rebuild on accountStream rather than reading this once at startup — that is
how the UI picks up a later refresh, whether it comes from a login, a purchase,
or your own refreshAccountInfo() call.
Account UI — theming #
Set the base SDK UI theme once:
await KittySdk.initialize(
config: KittySdkConfig(
projectId: 'proj_xxx',
accountBaseUrl: '...',
flareBaseUrl: '...',
theme: KittyTheme(
themeData: ThemeData(
useMaterial3: true,
fontFamily: 'DM Sans',
colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple),
),
),
),
);
Customize auth and bind-email screens separately:
final authTheme = KittyAuthTheme(
backgroundColor: Colors.white,
loginHeaderWidget: Image.asset('assets/logo.png', height: 64),
codeHeaderWidget: Image.asset('assets/logo.png', height: 48),
loginBodyPadding: EdgeInsets.symmetric(horizontal: 24),
primaryButtonStyle: ElevatedButton.styleFrom(
minimumSize: Size(double.infinity, 48),
shape: StadiumBorder(),
),
inputDecoration: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
),
);
KittyAuthFlow(
account: KittySdk.account,
theme: authTheme,
);
KittyBindEmailFlow(
account: KittySdk.account,
theme: authTheme,
);
Customize settings screens by logical blocks:
KittySettingsFlow(
account: KittySdk.account,
onLoginPressed: () {},
onRenewSubscription: () {},
theme: KittySettingsTheme(
page: KittySettingsPageTheme(
backgroundColor: Colors.white,
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 24),
),
menuItem: KittySettingsMenuItemTheme(
borderRadius: BorderRadius.all(Radius.circular(16)),
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 14),
iconColor: Colors.black54,
),
buttons: KittySettingsButtonsTheme(
primaryButtonSize: Size(240, 52),
primaryButtonStyle: ElevatedButton.styleFrom(
shape: StadiumBorder(),
),
),
dialog: KittySettingsDialogTheme(
destructiveActionStyle: TextStyle(color: Colors.red),
),
bindEmail: KittySettingsBindEmailTheme(
authTheme: authTheme,
),
),
);
All theme fields are optional. If a field is null, the SDK uses its default style from Theme.of(context) / the built-in Kitty UI.
page.actionIconTheme is opt-in and applies to every page inside
KittySettingsFlow (a standalone KittyManageDataPage does not apply it).
null keeps Flutter's back button. Use it to draw your own back button:
KittySettingsTheme(
page: KittySettingsPageTheme(
actionIconTheme: ActionIconThemeData(
backButtonIconBuilder: (context) => const CircleAvatar(
radius: 20,
backgroundColor: Colors.white,
child: Icon(Icons.arrow_back, color: Colors.black87),
),
),
),
);
AI data consent #
Opt-in. It asks the user before photos, videos or prompts go to a third-party
AI provider, and lets them withdraw later in Settings → Data (Apple App Review
Guidelines 5.1.2(i) and 5.1.1(ii)). Pass a KittyConsentConfig to initialize:
await KittySdk.initialize(
config: KittySdkConfig(
projectId: 'proj_xxx',
accountBaseUrl: '...',
flareBaseUrl: '...',
),
consentConfig: const KittyConsentConfig(
providerName: 'fal.ai',
appName: 'HoldStill',
privacyPolicyUrl: 'https://example.com/privacy',
termsOfUseUrl: 'https://example.com/terms',
// policyVersion: 'ai-v1', // change it to ask every user again
),
);
initialize waits for the first storage read, so KittySdk.consent.status is
ready when it returns, for the identity known at startup (usually guest, or
the cached account). Until the signed-in account has loaded, a signed-in user
reads as that startup identity, which is the fail-closed side. To avoid
prompting a signed-in user under the guest identity, call promptIfUnknown
after the account has loaded. Without a consentConfig nothing changes:
KittySdk.isConsentEnabled is false, settings shows no Data row, and
KittySdk.consent throws a StateError.
The SDK owns the consent record and its dialogs; the host blocks the AI work. The host makes three calls:
// 1. Home screen, once (from initState or a post-frame callback, never from
// build): the blocking first-visit dialog, shown only while the status
// is unknown.
await KittySdk.consent.promptIfUnknown(context);
// 2. Before every user-initiated AI action: true at once when granted,
// otherwise the "Enable AI to create your photo" dialog. True only after
// "Agree & Generate" has been saved.
if (!await KittySdk.consent.ensureConsent(context)) return;
// 3. In every repository that sends data to the AI provider: check again,
// right before the request.
class UploadRepository {
Future<void> upload(File photo) async {
if (!KittySdk.consent.isGranted) throw const AiConsentRequiredException();
// ... send to fal.ai
}
}
Concurrent ensureConsent calls are not deduplicated: two taps can open two
dialogs. Disable the Generate button while ensureConsent is pending, or guard
it with an in-flight flag.
The cubit that calls the repository catches AiConsentRequiredException,
calls ensureConsent, and retries only after a fresh user action. The check
in the repository matters: consent can be withdrawn while a generation is in
flight, and requests already sent cannot be recalled, so only a per-request
check stops the next upload or retry. To react to changes, listen to
KittySdk.consent.changes: it emits the current AiConsentStatus (unknown,
granted, declined) to each new listener, then every change.
Fail closed. Anything uncertain counts as not granted:
- no record, a corrupt record, a storage error (for example blocked
localStorageon web), or a record saved under anotherpolicyVersionall read asunknown; - the status is derived on every read from the live identity's stored
record, so a login, logout or account switch changes it at once, with no
loading gap; a
grantedsave counts only once the platform has confirmed it; - during a login, until the account fetch completes (the new session is
stored but
KittySdk.account.currentAccountis stillnull), the identity is stillguest, so the guest's answer applies, a guestgrantedincluded; the signed-in account's own record applies as soon as the account has loaded; - a failed save of "Agree" inside a dialog leaves the status unchanged, shows
saveErrorTextand keeps the dialog open so the user can retry; only a directKittySdk.consent.grant(...)call throwsKittyConsentWriteException; - a failed withdrawal is never rolled back: the status stays
declined, the stored key is removed so a restart cannot read a stalegranted, and the write is retried with the next one (in this session only; the retry is not persisted); - if the identity changes while a dialog is open, the answer is saved for the
identity that opened it, and
ensureConsentreturnsfalse.
Storage scope. Consent is stored on the device only, in SharedPreferences
(localStorage on web), under kitty_ai_consent_<projectId>_<accountId|guest>.
There is no backend call and no sync: each Kitty account has its own record
on each device, and withdrawing on one device does not affect another. Consent
never moves between identities, so a guest's answer does not carry over after
login. Logout keeps the record, so the same user signing back in keeps their
choice.
Account deletion. Deleting the account through KittySettingsFlow removes
that account's record automatically. Calling KittyAccount.deleteAccount() or
markForDeletion() directly, outside the settings flow, does not clear the
consent key. Deletion is a reversible mark (the account can be restored), so a
user who signs back in and restores would get their old choice back, a
granted included. Capture the account id, clear its record, then delete:
final id = KittySdk.account.currentAccount?.id;
if (id != null) await KittySdk.consent.clearForAccount(id);
await KittySdk.account.deleteAccount();
Clearing first fails closed: if deletion is refused, the only cost is a
re-prompt. Clearing after can leave a restorable account with granted if
logout throws after the deletion mark.
Settings. With consent enabled, KittySettingsFlow shows a Data row
after Legal Info, to guests too. It opens Manage Data: a switch that turns
consent on directly and, when turned off, asks "Turn off AI features?" in a
centred pop-up (Keep Enabled, Turn Off; top-anchored instead with
KittyConsentTheme.turnOffDialogTopOffset) before declining. A tap outside the
pop-up or the system back button keeps consent on. KittySettingsFlow(consent: ...) is optional and defaults to
KittySdk.consent when the SDK was initialized with a consentConfig. A
custom cabinet can push the page itself:
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => KittyManageDataPage(consent: KittySdk.consent),
));
Text and theme. Every string is a KittyConsentConfig field with an
English default (firstVisitTitle, generateBody, toggleSubtitle, ...);
strings may use the {appName} and {providerName} placeholders. Pass
translated strings for other languages. Visual tokens live in
KittyConsentTheme: give it to the settings flow through
KittySettingsTheme(consent: ...), and to the dialogs directly.
The SDK ships the layout only. With no theme, every colour and text style
comes from the host's Theme (ColorScheme, TextTheme, DialogTheme), so
the default look is neutral; the SDK hardcodes no brand colour. Pass your
brand's colours explicitly:
| Field | Applies to | Default |
|---|---|---|
cardColor |
Consent dialog and turn-off pop-up card | Dialog background |
barrierColor |
Scrim behind both dialogs | DialogTheme.barrierColor |
primaryGradient, primaryColor, primaryTextStyle |
Agree, Agree & Generate (gradient wins) | primary, onPrimary |
secondaryColor, secondaryTextStyle |
Decline, Keep Enabled | surface, onSurface |
outlineColor |
Decline / Keep Enabled border (buttons only) | outlineVariant |
destructiveColor, destructiveTextStyle |
Turn Off | error, onError |
toggleActiveGradient, toggleActiveColor |
Manage Data switch track when on (gradient wins) | primary |
toggleInactiveColor |
Manage Data switch track when off | surfaceContainerHighest |
toggleThumbColor |
Manage Data switch knob, on and off | onPrimary (on) / outline (off) |
toggleBorderColor |
Manage Data switch track border when off | none (no border unless set) |
titleStyle, bodyStyle, linkStyle |
Dialog text; Manage Data title and subtitle unless manageDataTitleStyle / manageDataSubtitleStyle are set (they win) |
TextTheme; the turn-off pop-up uses the Figma sizes (title 20 w700, body 16 w500 / 24, labels 16 w700) |
cardRadius, buttonHeight |
Card corners, button height | 24, 56 (at least 48) |
paragraphSpacing |
Gap between body paragraphs (split on a blank line); first-visit and generate dialogs only. Paragraphs still read as one text to screen readers | null: one text with blank lines |
titleBodySpacing, bodyLinksSpacing, contentButtonsSpacing |
Title → body, body → links, text → buttons gaps; first-visit and generate dialogs only. Links keep a 48 tap target, so their visible gaps are larger | 12, 8, 24 |
linkGradient |
Fill of the link text and underline (every stop must meet 4.5:1 against cardColor) |
none: linkStyle colour |
manageDataPadding |
Manage Data page padding around the card | page.contentPadding, else 16 |
manageDataCardColor |
Manage Data card fill | cardColor, else surface |
manageDataTitleStyle, manageDataSubtitleStyle |
Manage Data card title and subtitle | titleStyle / bodyStyle, else TextTheme |
turnOffDialogTopOffset |
Turn-off pop-up top, from the top of the safe area; never closer than 24 to either edge, moves up when it does not fit and scrolls when it fills the screen; ignored with turnOffDialogBuilder |
null: centred |
The Manage Data card always pads its content by 16 and rounds by 16; it
ignores the settings menuItem.padding and menuItem.borderRadius.
// Example values only: use your own brand colours.
const consentTheme = KittyConsentTheme(
cardColor: Color(0xFFFFFFFF),
barrierColor: Color(0x99000000),
primaryGradient: LinearGradient(
colors: [Color(0xFF6A5AE0), Color(0xFFB45AE0)],
),
outlineColor: Color(0xFFE0E0E6),
destructiveColor: Color(0xFFE5484D),
toggleActiveGradient: LinearGradient(
colors: [Color(0xFF6A5AE0), Color(0xFFB45AE0)],
),
toggleInactiveColor: Color(0xFFE9E9EF),
toggleThumbColor: Color(0xFFFFFFFF),
);
KittySettingsFlow(
account: KittySdk.account,
onLoginPressed: () {},
onRenewSubscription: () {},
theme: const KittySettingsTheme(consent: consentTheme),
);
await KittySdk.consent.promptIfUnknown(context, theme: consentTheme);
await KittySdk.consent.ensureConsent(context, theme: consentTheme);
Custom UI. Three optional builders on KittyConsentConfig replace the
default UI, while the SDK keeps the state, persistence, busy guard, error
handling, results and analytics. A builder gets an immutable controller that
the SDK rebuilds on every change; call its methods, never the store.
| Builder | Controller | Replaces |
|---|---|---|
dialogBuilder |
KittyConsentDialogController: kind, config, isBusy, errorText; agree(), decline(), openPrivacyPolicy(), openTermsOfUse() |
The content of both dialogs. The SDK keeps the route, the barrier rule per kind and the result. |
turnOffDialogBuilder |
KittyTurnOffDialogController: config; keepEnabled(), turnOff() |
The turn-off pop-up. Its output is the dialog route's child, so return a Dialog. The SDK keeps the route, the barrier (tap outside → false) and the result. |
manageDataBuilder |
KittyManageDataController: config, status, isGranted; enable(), requestDisable(), disableWithoutConfirmation() |
The body of KittyManageDataPage, below the SDK app bar |
KittyConsentConfig(
providerName: 'fal.ai',
appName: 'HoldStill',
dialogBuilder: (context, c) => MyConsentCard(
title: c.kind == KittyConsentDialogKind.firstVisit
? c.config.firstVisitTitle
: c.config.generateTitle,
error: c.errorText,
onAgree: c.isBusy ? null : c.agree,
onDecline: c.isBusy ? null : c.decline,
),
manageDataBuilder: (context, c) => SwitchListTile(
title: Text(c.config.toggleTitle),
value: c.isGranted,
onChanged: (on) => on ? c.enable() : c.requestDisable(),
),
);
requestDisable() shows the turn-off pop-up (from turnOffDialogBuilder or
the default) and declines only on "Turn Off".
Analytics. Events go through onAnalyticsEvent: AiConsentShown,
AiConsentGranted, AiConsentDeclined, AiConsentWithdrawn,
ManageDataOpened, SettingsDataClicked, AiConsentStorageError and
ConsentClearFailed. They carry the status, source and policy version, never
images, prompts or emails.
Buy Now — web paywall with mobile prices #
Requires Common Module v13.4.0+ on the web side.
final products = <ProductDetails>{...}; // from InAppPurchase
router.push(MaterialPageRoute(
builder: (_) => KittyWebBuyNowPage(
url: 'https://your-domain.com/buy-now', // Kitty Flow url
products: products,
onPaymentSuccess: () {
router.pop();
},
),
));
Modules #
| Module | Access | Description |
|---|---|---|
| Flare | KittySdk.flare |
Device fingerprint identification & purchase attribution |
| Account | KittySdk.account |
Auth, user management, licenses, subscriptions |
| Tokens | KittySdk.tokens |
Token balance and operations (requires Account) |
| Buy Now | KittySdk.buyNow |
Web paywall with mobile price bridging |
| AI data consent | KittySdk.consent |
Local, per-account AI data-processing consent, its dialogs and Manage Data (opt-in via consentConfig) |
Tokens — balance and operations #
// 1. Enable tokens in KittySdkConfig
await KittySdk.initialize(
config: KittySdkConfig(
projectId: 'proj_xxx',
accountBaseUrl: '...',
flareBaseUrl: '...',
tokens: const KittyTokensConfig(isDebug: true),
),
);
// 2. Use the module (user must be logged in via KittySdk.account)
// By default the SDK uses the first usable license of the current user.
// Pass `licenseId:` explicitly when you need to target a specific license.
final balance = await KittySdk.tokens.getBalance();
print('Tokens: ${balance.balance}');
// Get operations history
final ops = await KittySdk.tokens.getOperations(skip: 0, limit: 20);
// Token mutations (add/remove) are server-side only — call them from your
// backend with an internal API key or a per-project `kap_` bearer token
// against `POST /api/internal/tokens/add` and `/api/internal/tokens/remove`.
// 3. Use the Cubit in your UI
BlocProvider(
create: (_) => TokensCubit(tokens: KittySdk.tokens)..loadBalance(),
child: BlocBuilder<TokensCubit, TokensState>(
builder: (context, state) {
if (state.isLoading) return const CircularProgressIndicator();
return Text('Balance: ${state.tokenCount}');
},
),
)
Backend token mutations — important notes
add/remove are server-side only (call them from your backend with a per-project
kap_ bearer token). Before you build against them, mind these three behaviours
(full guide: kitty-launch-account/docs/backend-token-api.md):
/addand/removehit DIFFERENT buckets./addcredits the persistentbalancebucket;/removedebits the renewablesubscriptionBalancebucket (which has no floor and can go negative). A naive "grant then spend" will not net out — for a "tokens per operation" model, confirm with the platform team which bucket you should be debiting.- No idempotency on
/add//remove. Calling twice applies twice. Deduplicate on your side (fire exactly once per operation, e.g. guard by your own operation id). - A
kap_token cannot read balance.GET /balanceandGET /operationsrequire the internal API key — akap_bearer gets 401 there. Decide how your backend will verify results (e.g. read balance in the client via the SDK, or request read access from the platform team).