featurely

In-app feedback for Flutter apps, backed by a self-hosted Featurely instance: end users browse feature requests and issue reports, vote, comment, and submit new feedback (with an optional screenshot and email), and message your team privately through In-App Chat — your team triages and replies from the Featurely web dashboard.

  • Two-line integration — init once, show anywhere.
  • In-App Chat — a private one-to-one thread with your team, as a standalone sheet (showChat) or from the feedback sheet's "Message us" action.
  • Push notifications — replies, "shipped" updates and your own broadcasts, with no google-services.json needed on Android.
  • Native-feeling — inherits your accent color, corner radius, font, and light/dark mode; iOS and Android adaptive details.
  • 34 languages including RTL (ar, he), resolved independently of the host app's locale.
  • Automatic sandbox/live separation — one API key; debug builds report to Sandbox (with an unmistakable amber SANDBOX strip), release builds to Live. Test data can never pollute Live.
  • Android and iOS only.

Getting started

dependencies:
  featurely: ^0.7.0

Initialize once at startup (idempotent — call it on every launch), then present the sheet from any trigger:

import 'package:featurely/featurely.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Featurely.init(
    baseUrl: 'https://feedback.example.com', // your instance, no /api/v1
    apiKey: const String.fromEnvironment('FEATURELY_API_KEY'),
  );
  runApp(const MyApp());
}

// From any button:
await Featurely.show(context);

Environments

Each project has a single API key (always available in Project Settings); the SDK declares the environment on every request. By default it follows the build type — debug builds report to Sandbox, release builds to Live — so there is nothing to configure. For special flavors (e.g. a staging release build that should stay in Sandbox), override it at init:

await Featurely.init(
  baseUrl: …,
  apiKey: …,
  environment: FeaturelyEnvironment.sandbox,
);

Sandbox sessions render an amber SANDBOX strip across the top of the sheet so QA always knows which mode they're in. It is never a user-facing runtime toggle.

In-App Chat

End users can message your team privately; the team answers from the dashboard Inbox. Replies show up in the open chat within a few seconds (the SDK polls every 5 s while the chat is on screen and the app is in the foreground).

Two entry points:

// 1. A standalone chat sheet, from your own "Contact us" button:
await Featurely.showChat(context);

// 2. Automatically: the feedback sheet opened by Featurely.show shows a
//    "Message us" action that pushes the chat inside the same sheet.

To start the user off, prefill the composer — for example from an order screen:

await Featurely.showChat(
  context,
  initialMessage: 'I have a question about order #1234',
);

The text is only placed in the composer (cursor at the end, field focused); it is never sent automatically, and the user can edit or delete it. Blank text is ignored, it is capped to the 4 000-character message limit, and it is applied once per showChat call — it doesn't come back after the user sends or clears it. The "Message us" chat opens with an empty composer.

Chat metadata

Give your team context with each message — the current screen, the plan, an order id. Set an app-wide map once (it applies to every message sent from then on; null clears it), and/or pass a map when you open the chat:

Featurely.setChatMetadata({'plan': 'pro', 'appVersion': '2.4.1'});

await Featurely.showChat(
  context,
  metadata: {'screen': 'Checkout', 'orderId': '1234'}, // wins on collisions
);

Metadata is shown to your team only — next to the message in the Inbox and in the support alert email. It is never shown to the user and never returned by the API, but don't put secrets in it. Values are strings; keys are trimmed and at most 64 characters, values are truncated to 500 characters, and at most 20 entries are sent (the SDK drops or trims anything else rather than failing the send). A retried message keeps the metadata it was composed with. The "Message us" chat inside the feedback sheet uses the app-wide map only.

Show an unread badge on your own button with unreadMessageCount() — it never throws and returns 0 before init, when the device has no conversation, when the server doesn't support chat, or on any error:

final unread = await Featurely.unreadMessageCount(); // e.g. on app resume

For a plain dot badge, hasUnreadMessages() returns unreadMessageCount() > 0 with the same never-throws semantics (false wherever the count is 0). Each call is a network request, so refresh it on demand — for example on app resume and after the chat closes:

Future<bool> _unread = Featurely.hasUnreadMessages();

// In build():
FutureBuilder<bool>(
  future: _unread,
  builder: (context, snapshot) => Badge(
    isLabelVisible: snapshot.data ?? false,
    child: IconButton(
      icon: const Icon(Icons.chat_bubble_outline),
      onPressed: () async {
        await Featurely.showChat(context);
        setState(() => _unread = Featurely.hasUnreadMessages());
      },
    ),
  ),
)

Things to know:

  • One thread per device. The conversation belongs to the SDK's device ID, not to login(userId), so it never follows a user across devices. logout() (and logging in as a different user) rotates the device ID and starts a fresh, empty chat; the old thread stays in your Inbox.
  • Don't collect secrets in chat. Anyone holding the device ID can read that device's thread, the same trust level as votes.
  • Messages are plain text, up to 4 000 characters. Messages that fail to send show "Not sent — Tap to retry" and are kept only while the chat is open. A retry never double-posts.
  • Chat needs a Featurely server that reports chatEnabled in GET /api/v1/config. Against older servers the "Message us" action is hidden and unreadMessageCount() returns 0; gate your own showChat button accordingly, since on those servers the chat screen can only show its failed-load state.
  • The chat uses the same theming, localization (including RTL) and SANDBOX strip as the feedback sheet.

AI Support Assistant

A Featurely project can turn on an AI support assistant (powered by Claude) that answers chat messages within seconds, from a knowledge document your team maintains in the dashboard plus a live diagnostics snapshot your app attaches to each message. When it can't help, or the user asks for a person, it hands the conversation to your team, who get the usual alert email. A team reply always takes over from the assistant.

Setup

  1. Server (self-hosted): set ANTHROPIC_API_KEY and ASSISTANT_ENABLED=true. Optional: ASSISTANT_MODEL (default claude-opus-5), ASSISTANT_EFFORT (default low) and ASSISTANT_CONCURRENCY (default 4). See the server README.
  2. Dashboard: an admin enables the assistant per environment under Settings → Assistant (Sandbox and Live separately), and sets its name, instructions and knowledge document.
  3. App: update to this SDK (0.6.0+). Nothing else is required: every chat send declares that this SDK can show assistant replies, and the assistant only answers conversations whose SDK does. The two hooks below make its answers much better.

Assistant replies show under the assistant's name (default "Assistant"). They are plain text; step-by-step help comes as numbered lines. While the assistant is writing, the chat shows "{name} is typing…" and checks for the reply every 1.5 s (for up to 60 s, then back to every 5 s). There is no "talk to a person" button: users just ask, and the assistant escalates.

Diagnostics provider

Give the assistant the app state it needs to diagnose a problem, so it doesn't have to ask:

Featurely.setChatDiagnosticsProvider(() async => {
      'bluetooth': bluetooth.isOn ? 'on' : 'off',
      'watchPaired': pairing.isPaired,
      'lastSyncMinutesAgo': sync.minutesSinceLast,
      'permissions': {'notifications': await notificationsGranted()},
    });

The provider runs on every send (including a manual retry) and gets 1 second. Its result must be a JSON object: null, bool, finite numbers, strings of at most 200 characters, lists of at most 30 items, and maps with String keys, nested at most 3 levels (the top-level map counts), and at most 4 096 bytes as UTF-8 JSON. If the provider times out, throws, returns null, or breaks a limit, the message goes without diagnostics (a debug build logs why); a send never fails because of them. If the server still rejects the snapshot, the SDK re-sends the message once without it. Pass null to remove the provider.

Diagnostics are shown to your team in the Inbox and sent to the assistant; they are never shown to the user or returned by the API. Don't include secrets, identifiers, or personal data — report states and counts, not names, emails, or tokens.

Actions

Let the assistant suggest one-tap actions in your app. Register the actions with localized titles, and handle taps:

Featurely.registerChatActions([
  FeaturelyChatAction(id: 'open_settings', title: l10n.openSettings),
  FeaturelyChatAction(id: 'retry_pairing', title: l10n.tryAgain),
]);

Featurely.setChatActionHandler((id) {
  switch (id) {
    case 'retry_pairing':
      pairing.retry();
      return FeaturelyChatActionResult.stay; // keep the chat open
    case 'open_settings':
      // Runs after the chat sheet has closed.
      WidgetsBinding.instance.addPostFrameCallback(
          (_) => navigatorKey.currentState?.pushNamed('/settings'));
      return FeaturelyChatActionResult.dismiss; // close the chat sheet
    default:
      return FeaturelyChatActionResult.stay;
  }
});
  • Each send lists the registered ids (as long as a handler is set), and the assistant can suggest up to 3 of them per reply. They appear as buttons below the reply, labelled with your title. Ids you haven't registered are never shown.
  • A tapped button shows as used for the rest of the app session.
  • stay keeps the chat open. dismiss closes the sheet that showChat (or show) presented right after the handler returns. To navigate once it is gone, schedule the work as above (or navigate after await Featurely.showChat(context) completes).
  • Ids must match ^[a-z][a-z0-9_]{0,39}$; at most 12 are kept. Invalid, duplicate, or blank-titled entries are dropped with a debug-build log. registerChatActions replaces the previous list; pass null to setChatActionHandler to remove the buttons.

All three hooks are app-wide, need init first, and survive a later init.

Data sent to Anthropic

When a project enables the assistant, Anthropic becomes a subprocessor for that project. For each reply, the Featurely server sends Anthropic only:

  • the conversation's message bodies, who wrote each (user, team, or assistant) and relative timestamps
  • the diagnostics snapshot
  • the conversation locale
  • the chat metadata (setChatMetadata / showChat(metadata:)), minus the keys your admin hides (by default support_id, email, and user_id)
  • the registered action ids
  • your project's assistant instructions and knowledge document

It never sends the contact email, the device ID, your login user ID, IP addresses, user agents, or your team members' emails. API data is not used for model training by default.

Privacy policy wording

Disclose the assistant in your privacy policy. For example:

In-app support messages, together with technical information about the app's state on your device (for example, connection and permission status), may be processed by an AI assistant provided by Anthropic, PBC, to answer your question. Your email address and device identifiers are not shared with Anthropic. You can always ask to reach a person on our team.

Push notifications

The SDK registers the device for push notifications your team sends from the dashboard or your backend, plus Featurely's own: "the team replied" in the chat, a request that shipped, and a public team reply. Registration is automatic once init has run and the project has push credentials (Settings → Push in the dashboard): the SDK keeps it current on every app foreground, permission, time zone and opt-out change, and moves it across logout and account switches. It never shows the permission prompt on its own.

Since 0.7.0 the package contains native code, so after upgrading do a full rebuild (flutter run), not a hot restart. Android apps need minSdk 23.

1. iOS setup

  • In Xcode, add the Push Notifications capability to the Runner target (it adds the aps-environment entitlement), and upload your APNs .p8 key in Featurely (Settings → Push).

  • Let Flutter forward notification callbacks to plugins by making your app delegate the notification-center delegate, as FlutterFire does, in ios/Runner/AppDelegate.swift:

    import Flutter
    import UIKit
    import UserNotifications
    
    @main
    @objc class AppDelegate: FlutterAppDelegate {
      override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
      ) -> Bool {
        UNUserNotificationCenter.current().delegate = self
        GeneratedPluginRegistrant.register(with: self)
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
      }
    }
    

    (Keep your existing plugin registration; only the delegate line is new.) The SDK receives the APNs token itself and registers for remote notifications once push is available — no other code. It handles only Featurely pushes and leaves every other push to your own code and other plugins.

The iOS plugin supports both Swift Package Manager (Flutter's default integration) and CocoaPods, so there is nothing to choose: it follows whatever your app already uses. Its deployment target is iOS 15.0.

2. Android setup

Nothing to add to the app: no google-services.json and no Google Services Gradle plugin. Upload the app's google-services.json in Featurely (Settings → Push); the SDK fetches the Firebase settings for your applicationId and starts its own Firebase app. If you'd rather bundle the file and initialize Firebase yourself, the SDK falls back to your default Firebase app when Featurely has no config for the package.

Featurely pushes use their own notification channel, "Notifications" (localized), unless the push names an existing channel of yours.

Using firebase_messaging too? Android delivers each message to only one FirebaseMessagingService, so forward foreground messages from yours; background notifications and taps keep working without it:

FirebaseMessaging.onMessage.listen((message) {
  Featurely.push.handleForegroundMessage(
    message.data,
    title: message.notification?.title,
    body: message.notification?.body,
  ); // false: not a Featurely push; handle it yourself.
});

3. Taps

Pass the navigator key you give your MaterialApp, so a tap can open the chat (for a chat reply) or the feedback list ("shipped", "team replied"):

final navigatorKey = GlobalKey<NavigatorState>();

await Featurely.init(…, navigatorKey: navigatorKey);
runApp(MaterialApp(navigatorKey: navigatorKey, …));

A push with a url opens it with the OS instead (only http, https and your own app schemes). To route taps yourself, set a handler right after init — return true when you handled the tap, false for the default:

Featurely.push.setOpenHandler((open) {
  if (open.data['offerId'] case final offerId?) {
    navigatorKey.currentState?.pushNamed('/offers/$offerId');
    return true;
  }
  return false;
});

A tap that launches the app is held until init has finished; your handler (set it in the same synchronous stretch as await Featurely.init(…)) and URL opening then run right away, with or without a navigatorKey. Only opening the chat or the feedback list waits for the navigator to be mounted, for up to 30 s. Every tap is saved on the device first and reported to Featurely once push is available, so opens aren't lost offline.

4. Permission, opt-out and tags

// At a moment of your choosing (e.g. after onboarding):
final permission = await Featurely.push.requestPermission();
// iOS: alert, badge and sound (pass provisional: true for quiet delivery).
// Android 13+: the POST_NOTIFICATIONS prompt; older versions don't prompt.

await Featurely.push.permission(); // The current state, without prompting.

// An in-app switch for Featurely pushes (per install; survives logout):
await Featurely.push.optOut();
await Featurely.push.optIn();
await Featurely.push.isOptedOut();

// Tags for targeting broadcasts (e.g. "plan = pro", "level >= 5"):
await Featurely.push.setTags({'plan': 'pro', 'level': '7', 'beta': null});
final tags = await Featurely.push.getTags();

Tags work before permission is granted: 1–50 per call, keys of 1–64 characters (A-Z a-z 0-9 _ . -, not starting with featurely), values of 1–255 characters, null to delete. Invalid tags throw an ArgumentError without a request. They go to the linked user when login was called, otherwise to the device. Tags are not trusted data — the app can overwrite any of them — so never use them to grant access or decide billing.

Push failures never throw; registration retries at the next trigger and reports errors to onError.

Theming

await Featurely.init(
  baseUrl: …,
  apiKey: …,
  theme: const FeaturelyTheme(
    accentColor: Color(0xFFD9572B), // default: your Theme's colorScheme.primary
    cornerRadius: 16,               // default: 12
    brightness: Brightness.dark,    // default: follows the host theme
    fontFamily: 'Inter',            // default: host font
  ),
);

Status pill colors and the sandbox strip are fixed by design and are not themed. On-accent text color is computed by contrast, so any accent hue stays legible in light and dark.

Localization

The sheet ships all 34 Featurely locales and resolves its language from the device locale (or the locale: override passed to init), independent of your MaterialApp's locale — fallback chain: language + script → language + region → base language → English. Chinese uses Traditional (zh-Hant) for a Hant script or, without a script, for the regions TW, HK and MO (Locale('zh', 'TW') → zh-Hant); everything else gets Simplified (zh). ar and he render fully right-to-left.

Identity: login / logout

By default users are pseudonymous per install. If your app has its own accounts, link them so votes follow the user across devices and reinstalls:

await Featurely.init(…, userId: currentUser.id); // or:
await Featurely.login(currentUser.id);           // on sign-in
await Featurely.logout();                        // on sign-out
  • Pass an opaque internal id, never an email or other PII.
  • login is idempotent and safe on every launch; switching accounts is handled automatically (the SDK rotates its device identity between users).
  • logout starts the next user of the device with a clean slate.

Featurely.setPlan('Pro Monthly') updates the plan label attached to submissions (drives the PAYING badge in your dashboard).

Observability

The sheet handles every failure with its own localized UI, so errors are invisible to the host by default. To log or report them (a rotated key, an unreachable instance), pass onError — it receives each API operation that ultimately fails, after retries:

await Featurely.init(
  …,
  onError: (operation, error) => log.warning('featurely $operation: $error'),
);

error is a FeaturelyApiException (branch on its code) or a FeaturelyNetworkException; neither ever contains the API key. Exceptions thrown by the listener are swallowed — they never break the SDK's own handling.

Screenshots & permissions

The submit form offers one optional screenshot from the photo library (no camera). No Info.plist entry is needed on iOS 14+ (PHPicker), and no runtime permission on Android (Photo Picker on API 33+; older APIs are handled by image_picker's legacy path). HEIC images are transcoded to PNG automatically before upload.

Example app

example/ is a runnable host app for manual QA against a local featurely-app docker instance:

cd example
flutter run \
  --dart-define=FEATURELY_BASE_URL=http://localhost:3000 \
  --dart-define=FEATURELY_API_KEY=fk_…

(Use http://10.0.2.2:3000 on the Android emulator.) It exposes theming knobs, a locale override, login/logout buttons, a chat button wired to an AI assistant diagnostics provider and one registered action, and a "Request notifications" button. Its iOS project uses Swift Package Manager only (no Podfile). It has the iOS push entitlement and deliberately no google-services.json, so Android push runs on the config uploaded in Featurely.

Requirements

  • Flutter >=3.38.1, Dart ^3.10.0
  • Android & iOS (no web/desktop); Android minSdk 23 and compileSdk 36 (Android Gradle Plugin 8.9.1+), iOS 15.0
  • A Featurely instance serving the frozen /api/v1 contract (any server version — the SDK decodes leniently and never breaks on additive changes). In-App Chat needs a server that reports chatEnabled; the AI Support Assistant needs one with the assistant enabled for the environment; push needs one that reports pushEnabled.

Libraries

featurely
Featurely Flutter SDK — an in-app feedback sheet for the self-hosted Featurely platform.