Back to Knowledge Base How to Collect User Feedback in Your Flutter App (Complete Guide)
February 04, 2026 · Feedback Pulse Team

How to Collect User Feedback in Your Flutter App (Complete Guide)

Flutter gives you one codebase for iOS, Android, web, and desktop. But collecting user feedback across all these platforms usually means juggling multiple tools or SDKs — one for iOS, one for Android, maybe a JavaScript widget for web.

It doesn't have to be that complicated. Since Flutter apps run everywhere, you can use a single REST API to collect feedback from all platforms with the same Dart code.

This guide walks you through building a complete feedback system in Flutter — from the UI to the API integration to analyzing what users tell you.

Step 1: Create a Feedback Service

First, let's create a service class that handles the API communication. This keeps your feedback logic separate from your UI code.

// lib/services/feedback_service.dart
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';

class FeedbackService {
  static const String _baseUrl = 'https://fpulse.app/api/v1';
  static const String _apiKey = String.fromEnvironment(
    'FPULSE_API_KEY',
    defaultValue: '',
  );

  static Future<bool> submit({
    required String sentiment,
    String? comment,
    String? screenId,
  }) async {
    try {
      final packageInfo = await PackageInfo.fromPlatform();

      final body = {
        'sentiment': sentiment,
        'app_version': packageInfo.version,
        'app_type': _getPlatform(),
        if (comment != null && comment.isNotEmpty) 'comment': comment,
        if (screenId != null) 'screen_id': screenId,
      };

      final response = await http.post(
        Uri.parse('$_baseUrl/feedback'),
        headers: {
          'X-API-Key': _apiKey,
          'Content-Type': 'application/json',
        },
        body: jsonEncode(body),
      );

      return response.statusCode == 201;
    } catch (e) {
      debugPrint('Feedback submission failed: $e');
      return false;
    }
  }

  static String _getPlatform() {
    if (kIsWeb) return 'web';
    if (Platform.isIOS) return 'ios';
    if (Platform.isAndroid) return 'android';
    if (Platform.isMacOS) return 'macos';
    if (Platform.isWindows) return 'windows';
    if (Platform.isLinux) return 'linux';
    return 'unknown';
  }
}

Add the dependency:

# pubspec.yaml
dependencies:
  http: ^1.2.0
  package_info_plus: ^8.0.0

Pass your API key at build time so it's not hardcoded in source:

flutter run --dart-define=FPULSE_API_KEY=fp_your_key_here

Step 2: Build the Feedback Widget

Now let's build a feedback bottom sheet that slides up when the user taps a button. This feels native on both iOS and Android.

// lib/widgets/feedback_sheet.dart
import 'package:flutter/material.dart';
import '../services/feedback_service.dart';

class FeedbackSheet extends StatefulWidget {
  final String? screenId;
  const FeedbackSheet({super.key, this.screenId});

  @override
  State<FeedbackSheet> createState() => _FeedbackSheetState();
}

class _FeedbackSheetState extends State<FeedbackSheet> {
  String? _sentiment;
  final _commentController = TextEditingController();
  bool _loading = false;
  bool _submitted = false;

  Future<void> _submit() async {
    if (_sentiment == null || _loading) return;
    setState(() => _loading = true);

    final success = await FeedbackService.submit(
      sentiment: _sentiment!,
      comment: _commentController.text,
      screenId: widget.screenId,
    );

    if (mounted) {
      if (success) {
        setState(() => _submitted = true);
        await Future.delayed(const Duration(seconds: 2));
        if (mounted) Navigator.of(context).pop();
      } else {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('Failed to send. Try again.')),
        );
        setState(() => _loading = false);
      }
    }
  }

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

  @override
  Widget build(BuildContext context) {
    if (_submitted) {
      return Container(
        padding: const EdgeInsets.all(32),
        child: const Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(Icons.check_circle, color: Colors.green, size: 48),
            SizedBox(height: 12),
            Text('Thanks for your feedback!',
                style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
          ],
        ),
      );
    }

    return Padding(
      padding: EdgeInsets.only(
        left: 24, right: 24, top: 24,
        bottom: MediaQuery.of(context).viewInsets.bottom + 24,
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text("How's your experience?",
              style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
          const SizedBox(height: 20),

          // Sentiment buttons
          Row(
            children: [
              Expanded(
                child: _SentimentButton(
                  emoji: '👍',
                  label: 'Good',
                  selected: _sentiment == 'positive',
                  onTap: () => setState(() => _sentiment = 'positive'),
                ),
              ),
              const SizedBox(width: 12),
              Expanded(
                child: _SentimentButton(
                  emoji: '👎',
                  label: 'Bad',
                  selected: _sentiment == 'negative',
                  onTap: () => setState(() => _sentiment = 'negative'),
                ),
              ),
            ],
          ),
          const SizedBox(height: 16),

          // Comment field
          TextField(
            controller: _commentController,
            maxLines: 3,
            decoration: InputDecoration(
              hintText: 'Tell us more (optional)...',
              border: OutlineInputBorder(
                borderRadius: BorderRadius.circular(12),
              ),
              filled: true,
            ),
          ),
          const SizedBox(height: 16),

          // Submit button
          SizedBox(
            width: double.infinity,
            height: 48,
            child: FilledButton(
              onPressed: _sentiment != null && !_loading ? _submit : null,
              child: _loading
                  ? const SizedBox(
                      width: 20, height: 20,
                      child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
                  : const Text('Send Feedback', style: TextStyle(fontSize: 16)),
            ),
          ),
        ],
      ),
    );
  }
}

class _SentimentButton extends StatelessWidget {
  final String emoji;
  final String label;
  final bool selected;
  final VoidCallback onTap;

  const _SentimentButton({
    required this.emoji,
    required this.label,
    required this.selected,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 200),
        padding: const EdgeInsets.symmetric(vertical: 16),
        decoration: BoxDecoration(
          color: selected
              ? Theme.of(context).colorScheme.primaryContainer
              : Theme.of(context).colorScheme.surfaceContainerHighest,
          borderRadius: BorderRadius.circular(12),
          border: Border.all(
            color: selected
                ? Theme.of(context).colorScheme.primary
                : Colors.transparent,
            width: 2,
          ),
        ),
        child: Column(
          children: [
            Text(emoji, style: const TextStyle(fontSize: 32)),
            const SizedBox(height: 4),
            Text(label, style: TextStyle(
              fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
            )),
          ],
        ),
      ),
    );
  }
}

Step 3: Show It in Your App

Add a floating action button or trigger the sheet from anywhere:

// Show from any screen
void showFeedbackSheet(BuildContext context, {String? screenId}) {
  showModalBottomSheet(
    context: context,
    isScrollControlled: true,
    shape: const RoundedRectangleBorder(
      borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
    ),
    builder: (_) => FeedbackSheet(screenId: screenId),
  );
}

// Example: FAB on your home screen
FloatingActionButton(
  onPressed: () => showFeedbackSheet(context, screenId: 'home'),
  child: const Icon(Icons.feedback),
)

Step 4: Smart Timing

Don't show feedback prompts immediately. Here are patterns that work:

After a completed action

// After user finishes checkout
await processOrder();
if (mounted) {
  showFeedbackSheet(context, screenId: 'checkout_complete');
}

After X sessions

// In your app startup
final prefs = await SharedPreferences.getInstance();
final sessionCount = (prefs.getInt('session_count') ?? 0) + 1;
await prefs.setInt('session_count', sessionCount);

// Show on 3rd session, then every 10th session
if (sessionCount == 3 || sessionCount % 10 == 0) {
  final lastPrompt = prefs.getInt('last_feedback_prompt') ?? 0;
  if (sessionCount > lastPrompt) {
    await prefs.setInt('last_feedback_prompt', sessionCount);
    if (mounted) showFeedbackSheet(context);
  }
}

After an error

try {
  await someApiCall();
} catch (e) {
  // Show error to user, then ask for feedback
  showErrorDialog(context, e.toString());
  // Delayed feedback prompt
  Future.delayed(const Duration(seconds: 3), () {
    if (mounted) {
      showFeedbackSheet(context, screenId: 'error_recovery');
    }
  });
}

Step 5: Track Which Screen Feedback Comes From

The screen_id parameter is one of the most useful things you can send. It tells you exactly where users are when they submit feedback.

Use a consistent naming convention:

// Good screen IDs
'home'
'search_results'
'product_detail'
'checkout'
'settings_profile'
'onboarding_step_3'

// Avoid
'Screen1'
'MyHomePage'
'lib/screens/home.dart'

In your dashboard, you can filter feedback by screen to understand which parts of your app generate the most complaints or praise.

Step 6: Separate Dev and Production Feedback

During development, you don't want test feedback mixing with real user data. Use the environment parameter:

// In your feedback service
final body = {
  'sentiment': sentiment,
  'app_type': _getPlatform(),
  'environment': kDebugMode ? 'development' : 'production',
  // ... other fields
};

In your Feedback Pulse dashboard, you can toggle between Dev and Prod environments to see each separately.

What happens after collection

Collecting feedback is only useful if you act on it. Once feedback flows in, Feedback Pulse gives you:

  • Sentiment trends over time — Did your latest release improve things or make them worse?
  • Screen-level breakdown — Which screens generate the most negative feedback?
  • AI suggestions — Automated analysis that identifies patterns and recommends actions
  • Integrations — Push negative feedback to Slack, create tasks in ClickUp, or trigger webhooks

The whole point is to close the gap between "user has a problem" and "team knows about it" to as close to zero as possible.

The full picture

Here's what the complete setup looks like:

  1. User taps feedback button in your Flutter app
  2. Bottom sheet slides up — they pick sentiment, leave a comment
  3. One API call sends everything — sentiment, comment, platform, version, screen
  4. Dashboard shows the feedback with full context
  5. AI analyzes patterns and suggests what to fix
  6. Slack notification alerts your team to urgent issues

All from one codebase. Works the same on iOS, Android, web, and desktop.

Get started free — the free tier gives you 1,000 feedbacks per month across all platforms. No credit card, no SDK to install, just a REST API and your existing Flutter code.

Share:

How's your experience?

Thank you!

Your feedback helps us improve.

We value your privacy

We use cookies to enhance your browsing experience, analyze site traffic, and personalize content. By clicking "Accept All", you consent to our use of cookies. Contact us