suggestly_flutter

Embeddable Material 3 forms and ready-made dialogs for Suggestly feedback: feature requests, bug reports (with screenshots and hidden version and device metadata), and star ratings. The forms inherit your app's theme, take translated strings, and can be placed in any page or shown through the bundled dialog wrappers.

Features

  • SuggestlyBugReportForm, SuggestlyFeatureRequestForm and SuggestlyRatingForm - fields-only widgets that you can embed in your own pages, driven by a SuggestlyFormController.
  • SuggestlyBugReportPopup, SuggestlyFeatureRequestPopup and SuggestlyRatingPopup - one-call wrappers that open the matching form in a centred dialog, or a full-height bottom sheet on phones, with Cancel and Submit buttons.
  • SuggestlyFormLabels - every user-visible string with an English default, so translated apps present the forms as their own.
  • Fields inherit the host's InputDecorationTheme, severity and priority use a SegmentedButton, and optional bug report fields sit behind an "Add more detail" expander.
  • Identity, eligibility, name capture, payload building and submission stay inside the package for both presentations.

Getting started

Add the dependency to your pubspec.yaml (path assumes the monorepo layout):

dependencies:
  suggestly_flutter:
    path: ../suggestly.packages/suggestly_flutter

Then import the package:

import 'package:suggestly_flutter/suggestly_flutter.dart';

Before showing any Suggestly dialogs, initialise the shared client with your API key:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Suggestly.initialize(apiKey: 'YOUR-API-KEY-HERE');
  runApp(const MyApp());
}

Initialisation now validates the API key by fetching your application details. If the key is invalid, a SuggestlyApiException is thrown.

If you do not already store a Suggestly identity for the signed-in customer, call Suggestly.ensureSubmissionIdentity to create or retrieve the user and mint a short-lived submission token:

final SubmissionIdentity identity = await Suggestly.ensureSubmissionIdentity(
  email: currentUser.email,
  name: currentUser.displayName, // Optional; stored only when provided.
);

Keep the returned SubmissionIdentity in memory and pass it to each popup. The SDK automatically refreshes tokens when they expire, so you can reuse the identity across multiple submissions during the same session.

The Flutter SDK no longer depends on legacy public profile endpoints (/public/users/:userId and /public/users/profile). User identity and token flows run through the submission-token endpoints only.

For private applications, users of your app do not need a Suggestly account or a reviewer invite before they can submit. Submissions signed with the SDK's submission token are accepted straight away. After the first submission the backend enrols the user as a pending reviewer and emails them a link to accept the invite, which lets them view the private application and replies inside Suggestly. Feedback submitted through the Suggestly web portal still requires collaborator or reviewer access.

Pass the signed-in user's name when you have it. The backend stores it on the Suggestly user the first time it is seen, and the dialogs reuse it instead of asking the user to type a name.

Usage

Feature requests

final SubmissionIdentity identity = await Suggestly.ensureSubmissionIdentity(
  email: currentUser.email,
  name: currentUser.displayName,
);

final SuggestlyPopupResult<FeedbackSubmissionResult> featureResult =
    await SuggestlyFeatureRequestPopup.show(
  context,
  title: 'Request a feature',
  message: 'Share what would make Suggestly more helpful.',
  identity: identity,
  submitLabel: 'Submit request',
);

if (featureResult.didSubmit) {
  debugPrint('We logged the feature request.');
} else if (featureResult.hasError) {
  debugPrint('Feature request failed: ${featureResult.errorMessage}');
}

Bug reports (with attachments)

final SuggestlyPopupResult<FeedbackSubmissionResult> bugResult =
    await SuggestlyBugReportPopup.show(
  context,
  title: 'Report a bug',
  message: 'Screenshots are optional but recommended.',
  submitLabel: 'Send report',
  identity: identity,
);

if (bugResult.didSubmit) {
  debugPrint('Thanks for the report!');
} else if (bugResult.hasError) {
  debugPrint('Bug report failed: ${bugResult.errorMessage}');
}

The popup guides users through adding up to five screenshots using the built-in file_selector integration. Attachments are validated for file type and size, renamed deterministically, and uploaded alongside the report.

Ratings

final SuggestlyPopupResult<FeedbackSubmissionResult> ratingResult =
    await SuggestlyRatingPopup.show(
  context,
  title: 'Rate your experience',
  message: 'Stars help us prioritise improvements.',
  identity: identity,
  ratingLabelBuilder: (value) => '$value / 5',
  submitLabel: 'Submit rating',
);

if (ratingResult.didSubmit) {
  debugPrint('Thanks for the feedback!');
} else if (ratingResult.hasError) {
  debugPrint('Rating failed: ${ratingResult.errorMessage}');
}

Collaborators can submit internal ratings while an application remains in Draft. Once it is live, the popups automatically prevent team members from rating their own product.

What the dialog shows

The dialog renders only the title, the optional message, the form and the Cancel and Submit buttons. There is no application header and no "processed via Suggestly" notice; the reviewer invite email sent after the first submission tells users where to manage their feedback. On screens narrower than 600dp the popup opens as a full-height bottom sheet that grows with the keyboard; wider screens get a centred dialog capped at 720dp. Pass TextInputPopupThemeData(presentation: SuggestlyPopupPresentation.dialog) through themeOverrides or a TextInputPopupTheme to keep the dialog everywhere.

Submit stays disabled until the required fields are valid. The bug report dialog hides the version and environment fields by default; pass showVersionField: true or showEnvironmentFields: true to show if you still want users to type them, or pre-fill them through initialData so they are sent without being shown.

Embedding the form in your own page

Each popup is a thin wrapper around a form widget. Use the form directly when you want your own app bar, footer buttons or navigation. The form renders only its fields plus the preparing, blocked and error states; it draws no buttons and never pops a route. You trigger submission through a SuggestlyFormController and receive the outcome once through onResult. The form is a Column sized to its content, so it can sit inside a ListView, SingleChildScrollView or Scaffold body.

class ReportProblemPage extends StatefulWidget {
  const ReportProblemPage({super.key, required this.identity});

  final SubmissionIdentity identity;

  @override
  State<ReportProblemPage> createState() => _ReportProblemPageState();
}

class _ReportProblemPageState extends State<ReportProblemPage> {
  final SuggestlyFormController _controller = SuggestlyFormController();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Report a problem')),
      // Keep the buttons inside the body so they stay above the keyboard.
      // A Scaffold.bottomNavigationBar would be covered by it.
      body: Column(
        children: <Widget>[
          Expanded(
            child: SingleChildScrollView(
              // The form pads itself 16px above and below its fields.
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: SuggestlyBugReportForm(
                identity: widget.identity,
                controller: _controller,
                onResult: (SuggestlyPopupResult<FeedbackSubmissionResult> result) {
                  Navigator.of(context).pop(result);
                },
                // Sent in the payload without being shown to the user.
                initialData: const BugReportData(
                  title: '',
                  description: '',
                  severity: BugReportSeverity.medium,
                  reportedVersion: '2.3.1+45',
                  environment: BugReportEnvironment(
                    model: 'Pixel 8',
                    os: 'Android',
                    osVersion: '15',
                  ),
                ),
              ),
            ),
          ),
          SafeArea(
            top: false,
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: ListenableBuilder(
                listenable: _controller,
                builder: (BuildContext context, Widget? child) {
                  return Row(
                    children: <Widget>[
                      Expanded(
                        child: OutlinedButton(
                          onPressed: _controller.isSubmitting
                              ? null
                              : () => Navigator.of(context).pop(),
                          child: const Text('Cancel'),
                        ),
                      ),
                      const SizedBox(width: 12),
                      Expanded(
                        child: FilledButton(
                          onPressed:
                              _controller.canSubmit ? _controller.submit : null,
                          child: Text(
                            _controller.isSubmitting ? 'Sending' : 'Send',
                          ),
                        ),
                      ),
                    ],
                  );
                },
              ),
            ),
          ),
        ],
      ),
    );
  }
}

The form pads itself 16px above and below its fields so the first floating label and the last control clear your header and footer; pass padding to override that. The controller exposes canSubmit, isSubmitting, isPreparing, isBlocked and errorMessage, and notifies listeners whenever any of them changes. canSubmit is false while preparing, blocked, submitting, after a successful submission, or while a required field is empty or invalid.

SuggestlyFeatureRequestForm and SuggestlyRatingForm take the same shape, with FeatureRequestData and RatingData as initialData. defaultSeverity and defaultPriority preselect the segmented control so an untouched form is valid. Pass pickAttachments to the bug report form to show an attach action backed by your own picker; the package adds no image picker dependency, and the dialog wrapper uses file_selector.

Translated apps

Every string the forms render has an English default. Hosts that translate their app should pass labels so the form reads as part of the host:

SuggestlyBugReportForm(
  identity: identity,
  onResult: handleResult,
  labels: SuggestlyFormLabels(
    common: SuggestlyCommonFormLabels(
      titleLabel: t.feedback.title,
      descriptionLabel: t.feedback.description,
      titleRequired: t.feedback.titleRequired,
    ),
    bugReport: SuggestlyBugReportFormLabels(
      severityLabel: t.feedback.severity,
      severityLow: t.feedback.severityLow,
      moreDetailLabel: t.feedback.moreDetail,
    ),
  ),
);

The same labels parameter is accepted by the show methods.

Field styling

Fields are built as InputDecoration(labelText:, hintText:) and nothing else, so your InputDecorationTheme supplies the border, radius, fill, padding and error style. When you pass TextInputPopupThemeData.inputDecoration, that override stays authoritative and the package fills the gaps it leaves with its own outline border and padding.

Example host app

An interactive host application lives under example/ so you can exercise the dialogs without wiring them into your product first. Launch it with:

cd suggestly.packages/suggestly_flutter/example
flutter run

Demo mode boots instantly using the bundled in-memory client, and you can toggle eligibility for each popup to validate error paths. Switch to Live mode, paste a real Suggestly API key, and press Initialise to talk to your own backend.

The host shows both presentations: the three buttons call the show methods, and Report a problem pushes a page with its own app bar and Cancel and Send buttons around SuggestlyBugReportForm, pre-filled with the app version from package_info_plus and the device details from device_info_plus. The host theme sets a filled, 12px-radius InputDecorationTheme that the forms inherit.

Publishing

Publish from the package root:

cd suggestly.packages/suggestly_flutter
flutter pub get
flutter analyze
flutter test
dart pub publish --dry-run
dart pub publish

Before running dart pub publish, make sure pubspec.yaml has the next semantic version, CHANGELOG.md documents that version, and the dry run lists only files that should be public on pub.flutter-io.cn. Pub.dev package uploads are permanent; publish a new version for fixes rather than relying on removal.

Theming

Wrap a subtree with TextInputPopupTheme to provide defaults that apply to every popup, or pass themeOverrides to individual dialogs:

TextInputPopupTheme(
  data: TextInputPopupThemeData(
    backgroundColor: Theme.of(context).colorScheme.surface,
    shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
    contentPadding: const EdgeInsets.fromLTRB(28, 28, 28, 20),
    loadingIndicatorColor: Colors.deepPurple,
    loadingTextStyle: Theme.of(context).textTheme.bodyMedium,
    errorTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
      color: Theme.of(context).colorScheme.error,
    ),
  ),
  child: Builder(
    builder: (context) => FilledButton(
      onPressed: () {
        SuggestlyFeatureRequestPopup.show(
          context,
          title: 'Request a feature',
          message: 'Tell us what would make Suggestly better.',
          identity: identity,
          themeOverrides: const TextInputPopupThemeData(
            primaryButtonStyle: FilledButton.styleFrom(
              minimumSize: Size(140, 40),
            ),
          ),
        );
      },
      child: const Text('Request feature'),
    ),
  ),
);

Additional information

  • flutter analyze and flutter test run clean.
  • SuggestlyBugReportPopup owns the attachment workflow with file_selector, enforcing the same limits as the Suggestly web app (image formats only, up to five files, 5 MB each). Embedded forms enforce the same limits on files returned by pickAttachments.
  • Forms prompt for a missing user name, save it before submission and include it in the payload, allowing the backend to persist that profile name as part of the secure submission flow.
  • Contributions, issues, and suggestions are welcome.

Direct API access

If you need to interact with the Suggestly service outside the prebuilt dialogs, the Suggestly class exposes the underlying client:

  • Suggestly.ensureSubmissionIdentity normalises email/name input, creates the user if necessary, stores the name when the user has none, and issues a signed submission token plus user ID for downstream calls. Reviewer enrolment for private applications is handled entirely by the backend after submissions, so no additional parameters or dialogs are needed on the client.
  • Suggestly.fetchFeedbackEligibility reads cached eligibility for one or more submission kinds, while Suggestly.ensureFeedbackEligibility refreshes it on demand. Both accept a SubmissionIdentity so the signed submission token (and embedded userId) accompanies every lookup.
  • Suggestly.getApplicationSummary returns the application metadata (name, logo, status). Suggestly.initialize uses it to validate the API key; the forms no longer request it.
  • Suggestly.submitBugReport, Suggestly.submitFeatureRequest, and Suggestly.submitRating let you send feedback from fully custom flows using the SubmissionIdentity you already issued.
  • Suggestly.listMySubmissions, Suggestly.getMySubmission and Suggestly.addSubmissionComment back an in-app "My feedback" screen: the user's own bug reports and suggestions with their status, the full detail with the team's comments, and a way to add more context. They use the same SubmissionIdentity, so no Suggestly account or portal sign-in is needed. Each FeedbackSubmission also carries a portalUrl for hosts that prefer to hand off to the portal.
  • Suggestly.dispose clears in-memory caches and closes the internal HTTP client; call it when tearing down long-lived isolates or cleaning up tests.
  • Suggestly.debugOverrideClient accepts a SuggestlyTestingClient (or your own fake) to intercept outbound traffic during automated testing.

User name capture is handled automatically within the forms and is not part of the public Flutter API surface.

Libraries

suggestly_flutter