Améliorer la note de son app Flutter sur l'App Store avec un bon timing

Une note de 4,9 ⭐ sur 5, ce n'est pas qu'une métrique de flex. C'est un levier d'ASO, un signal de confiance et... souvent, le résultat d'un timing intelligent.

Dans cet article, je te montre comment je demande des avis dans une app Flutter de façon native, respectueuse et efficace, avec rate_my_app + in_app_review — et surtout quand le faire pour maximiser les 5⭐️, sans agacer tes utilisateurs.


1) Pourquoi les notes comptent vraiment (au-delà du score)

  • ASO & visibilité. Les algorithmes de l'App Store et du Play Store s'appuient sur la quantité/qualité des avis. Une bonne note améliore la découvrabilité et le taux de conversion sur ta fiche.
  • Preuve sociale. Avant d'installer, l'utilisateur scanne la note globale et les derniers commentaires. Une moyenne haute + des réponses pro → confiance immédiate.
  • Boucle produit. Les avis (et surtout les retours privés) pointent où l'expérience casse. Les traiter vite, c'est éviter un 1⭐️ public et alimenter ta roadmap.

L'erreur la plus courante : demander trop tôt. Tu interromps l'utilisateur avant qu'il ait vécu de la valeur... et tu récoltes l'effet inverse. Le "quand" est plus dur que le "comment".


2) Mon approche Flutter : native, contrôlée... et respectueuse

Objectif produit

Déclencher la popup native (iOS/Android) au bon moment, puis relancer avec modération, et rediriger les insatisfaits vers un canal privé (feedback).

Les briques que j'utilise

  • in_app_review : déclenche la fenêtre native d'avis (StoreKit sur iOS/macOS, In-App Review API sur Android). C'est rapide, conforme UX, et l'utilisateur reste dans l'app.
  • rate_my_app : gère les conditions d'affichage (J+N, X lancements, rappels espacés, etc.). Idéal pour orchestrer le timing et éviter le spam.
  • flutter_riverpod (optionnel) : juste pour centraliser la logique dans un service injectable/testable. Si tu n'utilises pas Riverpod, garde une classe service classique et appelle-la depuis tes widgets.

Ce que respecte (et ne fait pas) cette approche

  • Pas de harcèlement. On n'interrompt pas à froid. On choisit un moment d'engagement (succès d'une action, fin d'un "chapitre", micro-moment "wow").

  • On laisse la plateforme décider de l'affichage final.

    • iOS : StoreKit peut limiter l'apparition du prompt à 3 fois max sur 365 jours par utilisateur/appareil, même si tu demandes plus. On maîtrise le timing, pas la garantie d'affichage.
    • Android : le Play Core peut ignorer notre demande si on abuses. Là aussi, modération et pertinence du moment priment.

🔎 Note "analytics" (facultatif). Si tu as une stack d'events (Firebase/Amplitude), tu peux affiner le déclencheur : "Demander après N actions réussies". Utile, mais pas indispensable pour une première itération.

Schéma mental de l'UX cible

  1. Moment positif détecté → on demande un avis (popup native si disponible).
  2. S'il refuse → on repousse de plusieurs jours/lancements (pas de relance immédiate).
  3. S'il est mitigé → on bascule vers un formulaire interne ("Signaler un bug", "Donner un avis détaillé"), plutôt qu'un avis public.
  4. Après publicationon répond aux avis et on boucle vers des améliorations produit (changelog, patchs rapides).

feedback_start feedback_dont_like feedback_form feedback_rate_in_app

3) Le timing : des règles simples qui évitent le spam

Le "quand" compte plus que le "comment". L'objectif : maximiser les 5⭐️ sans jamais casser le flow.

3.1 Règles de base (jours, lancements, relances)

Tu configures 4 garde-fous :

const MIN_DAYS = 1;        // attendre au moins N jours après install
const MIN_LAUNCHES = 3;    // attendre N lancements de l'app
const REMIND_DAYS = 5;     // laisser N jours entre deux demandes
const REMIND_LAUNCHES = 5; // ou N lancements entre deux demandes

👉 Ces seuils donnent le temps à l'utilisateur de vivre de la valeur avant d'être sollicité. 👉 Les relances espacées évitent l'effet "harcèlement".

3.2 Deux parcours : notation publique vs feedback privé

On distingue deux cas d'usage, chacun avec son propre tempo :

const DAYS_BEFORE_ASK_IN_APP_REVIEW = 30;                 // popup native de note
const DAYS_BEFORE_ASK_IN_APP_REVIEW_FOR_FEEDBACK = 3;     // demande de feedback interne
  • "Note publique" (popup native) → on espaces beaucoup (30 j) et on laisses la plateforme décider d'afficher ou non.
  • "Feedback privé" (formulaire interne) → on peut revenir plus tôt (3 j) : c'est une écoute, pas une injonction.

Le résultat :

  • Les mécontents sont déviés vers un canal privé.
  • Les satisfaits sont guidés vers la note publique.

3.3 États & garde-fous techniques

  • Initialisation unique : on appelle init() une seule fois et on mémorise _isInitialized.
  • Web guard : kIsWeb protège de tout appel non supporté par les stores.
  • Conditions d'ouverture : shouldShowDialog et shouldShowDialogForFeedback encapsulent la décision.
  • Persistance : on enregistres la dernière fois qu'une demande a été faite (ISO-8601 en storage) pour respecter les délais.
  • Fallback propre : si la popup native n'est pas dispo, ouverture du store (launchStore()) plutôt que silence radio.

4) Implémentation Flutter (service + UI dialog)

4.1 Service RateAppService (Riverpod optionnel)

On centralise toute la logique ici : initialisation, conditions, tracking d'événements, demande de review native, redirection store, etc.

⚙️ Avant d'utiliser :

  • Renseigner ANDROID_APP_ID / IOS_APP_ID via dotenv.

  • Lancer la génération Riverpod si tu utilises l'annotation :

      dart run build_runner build -d
    
  • Adapter les imports de Storage à notre projet.

  • Les clés i18n (FlutterI18n.translate) doivent exister dans tes fichiers de traduction.

Code RateAppService

import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_i18n/flutter_i18n.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import 'package:in_app_review/in_app_review.dart';
import 'package:rate_my_app/rate_my_app.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:flutter/foundation.dart' show kIsWeb;

part 'rate_app_provider.g.dart';

const MIN_DAYS = 1;
const MIN_LAUNCHES = 3;
const REMIND_DAYS = 5;
const REMIND_LAUNCHES = 5;

const DAYS_BEFORE_ASK_IN_APP_REVIEW = 30;
const DAYS_BEFORE_ASK_IN_APP_REVIEW_FOR_FEEDBACK = 3;

/// Provider for the RateMyApp instance
@Riverpod(keepAlive: true)
RateAppService rateAppService(Ref ref) {
  return RateAppService();
}

/// Service class to handle RateMyApp functionality
class RateAppService {
  late final RateMyApp _rateMyApp;
  late final InAppReview _inAppReview;
  final Storage _storage = SharedPreferencesStorage();

  bool _isInitialized = false;

  RateAppService() {
    if (kIsWeb) return;

    _rateMyApp = RateMyApp(
      preferencesPrefix: 'rate_my_app_',
      minDays: MIN_DAYS,
      minLaunches: MIN_LAUNCHES,
      remindDays: REMIND_DAYS,
      remindLaunches: REMIND_LAUNCHES,
      googlePlayIdentifier:
          dotenv.env['ANDROID_APP_ID'],
      appStoreIdentifier: dotenv.env['IOS_APP_ID'],
    );

    _inAppReview = InAppReview.instance;
  }

  /// Initialize the RateMyApp instance
  Future<void> init() async {
    if (kIsWeb || _isInitialized) return;

    await _rateMyApp.init();
    _isInitialized = true;
  }

  /// Check if the rate dialog should be shown
  bool get shouldShowDialog =>
      !kIsWeb && _isInitialized && _rateMyApp.shouldOpenDialog;

  Future<bool> get shouldShowDialogForFeedback async {
    final lastTimeAsked =
        await _storage.read(key: "in_app_review_last_time_asked_for_feedback");

    if (lastTimeAsked != null) {
      final now = DateTime.now();
      final lastTimeAskedInDate = DateTime.parse(lastTimeAsked);
      final difference = now.difference(lastTimeAskedInDate);
      if (difference.inDays < DAYS_BEFORE_ASK_IN_APP_REVIEW_FOR_FEEDBACK) {
        return false;
      }
    }

    await _storage.write(
        key: "in_app_review_last_time_asked_for_feedback",
        value: DateTime.now().toIso8601String());

    return !kIsWeb && _isInitialized;
  }

  /// Show the rate dialog
  Future<void> showRateDialog(BuildContext context) async {
    if (kIsWeb || !_isInitialized) return;

    _rateMyApp.callEvent(RateMyAppEventType.dialogOpen);

    showDialog(
      context: context,
      builder: (context) => RateAppDialog(),
    );
  }

  /// Call a RateMyApp event
  void callEvent(RateMyAppEventType eventType) {
    if (kIsWeb || !_isInitialized) return;
    _rateMyApp.callEvent(eventType);
  }

  // InAppReview
  Future<bool> requestAppReview() async {
    if (kIsWeb || !_isInitialized) return false;

    final lastTimeAsked =
        await _storage.read(key: "in_app_review_last_time_asked");

    if (lastTimeAsked != null) {
      final now = DateTime.now();
      final lastTimeAskedInDate = DateTime.parse(lastTimeAsked);

      final difference = now.difference(lastTimeAskedInDate);
      if (difference.inDays < DAYS_BEFORE_ASK_IN_APP_REVIEW) {
        await launchStore();
        return false;
      }
    }

    if (await _inAppReview.isAvailable()) {
      _inAppReview.requestReview();
      await _storage.write(
          key: "in_app_review_last_time_asked",
          value: DateTime.now().toIso8601String());
      return true;
    }

    await launchStore();
    return false;
  }

  Future<LaunchStoreResult> launchStore() async {
    if (kIsWeb || !_isInitialized) return LaunchStoreResult.errorOccurred;

    return await _rateMyApp.launchStore();
  }
}

Enum RateAppStep

enum RateAppStep {
  initial,
  rateApp,
  askForFeedback,
}

Widget RateAppDialog

import 'package:flutter/material.dart';
import 'package:flutter_i18n/flutter_i18n.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';

class RateAppDialog extends ConsumerStatefulWidget {
  final RateAppStep step;
  const RateAppDialog({
    super.key,
    this.step = RateAppStep.initial,
  });

  @override
  ConsumerState<RateAppDialog> createState() => _RateAppDialogState();
}

class _RateAppDialogState extends ConsumerState<RateAppDialog> {
  RateAppStep _step = RateAppStep.initial;

  @override
  void initState() {
    super.initState();

    _step = widget.step;
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    final title = switch (_step) {
      RateAppStep.initial =>
        FlutterI18n.translate(context, "rate_app_dialog.title.initial"),
      RateAppStep.rateApp => FlutterI18n.translate(
          context, "rate_app_dialog.title.rate_app"),
      RateAppStep.askForFeedback => FlutterI18n.translate(
          context, "rate_app_dialog.title.ask_for_feedback"),
    };

    final message = switch (_step) {
      RateAppStep.initial =>
        FlutterI18n.translate(context, "rate_app_dialog.message.initial"),
      RateAppStep.rateApp => FlutterI18n.translate(
          context, "rate_app_dialog.message.rate_app"),
      RateAppStep.askForFeedback => FlutterI18n.translate(
          context, "rate_app_dialog.message.ask_for_feedback"),
    };

    final image = switch (_step) {
      RateAppStep.initial => 'assets/images/dialog/rate-dialog-positive.png',
      RateAppStep.rateApp => 'assets/images/dialog/rate-dialog-positive.png',
      RateAppStep.askForFeedback =>
        'assets/images/dialog/rate-dialog-negative.png',
    };

    final forceOverflow = switch (_step) {
      RateAppStep.initial => false,
      RateAppStep.rateApp => true,
      RateAppStep.askForFeedback => true,
    };

    final buttonYes = switch (_step) {
      RateAppStep.initial => FlutterI18n.translate(
          context, "rate_app_dialog.button_yes.initial"),
      RateAppStep.rateApp => FlutterI18n.translate(
          context, "rate_app_dialog.button_yes.rate_app"),
      RateAppStep.askForFeedback => FlutterI18n.translate(
          context, "rate_app_dialog.button_yes.ask_for_feedback"),
    };

    final buttonNo = switch (_step) {
      RateAppStep.initial => FlutterI18n.translate(
          context, "rate_app_dialog.button_no.initial"),
      RateAppStep.rateApp => FlutterI18n.translate(
          context, "rate_app_dialog.button_no.rate_app"),
      RateAppStep.askForFeedback => FlutterI18n.translate(
          context, "rate_app_dialog.button_no.ask_for_feedback"),
    };

    return AlertDialog(
      contentPadding: EdgeInsets.zero,
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Padding(
            padding: const EdgeInsets.fromLTRB(32, 32, 32, 0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.center,
              children: [
                Image.asset(
                  image,
                  width: 200.w,
                  height: 200.h,
                ),
                const SizedBox(height: 16),
                Text(title,
                    style: theme.textTheme.headlineSmall,
                    textAlign: TextAlign.center),
                const SizedBox(height: 8),
                Text(message,
                    style: theme.textTheme.bodyMedium?.copyWith(
                          color: theme.colorScheme.onSurface.withOpacity(0.6),
                          fontWeight: FontWeight.w300,
                        ),
                    textAlign: TextAlign.center),
              ],
            ),
          ),
          const SizedBox(height: 24),
          Divider(
            color: theme.dividerColor,
            height: 1,
          ),
        ],
      ),
      actionsAlignment: MainAxisAlignment.spaceBetween,
      actionsPadding: const EdgeInsets.symmetric(
          horizontal: 32, vertical: 24),
      actionsOverflowAlignment: OverflowBarAlignment.center,
      actionsOverflowDirection: VerticalDirection.up,
      actions: [
        TextButton(
          onPressed: () {
            switch (_step) {
              case RateAppStep.initial:
                setState(() {
                  _step = RateAppStep.askForFeedback;
                });
                ref
                    .read(rateAppServiceProvider)
                    .callEvent(RateMyAppEventType.noButtonPressed);
                break;
              case RateAppStep.rateApp:
                ref
                    .read(rateAppServiceProvider)
                    .callEvent(RateMyAppEventType.laterButtonPressed);
                Navigator.of(context).pop(false);
                break;
              case RateAppStep.askForFeedback:
                ref
                    .read(rateAppServiceProvider)
                    .callEvent(RateMyAppEventType.laterButtonPressed);
                Navigator.of(context).pop(false);
                break;
            }
          },
          child: Text(
            buttonNo,
          ),
        ),
        if (forceOverflow)
          const SizedBox(width: 48, height: 8),
        ElevatedButton(
          onPressed: () async {
            switch (_step) {
              case RateAppStep.initial:
                setState(() {
                  _step = RateAppStep.rateApp;
                });

                ref
                    .read(rateAppServiceProvider)
                    .callEvent(RateMyAppEventType.rateButtonPressed);
                await ref.read(rateAppServiceProvider).requestAppReview();
                break;
              case RateAppStep.rateApp:
                ref.read(rateAppServiceProvider);
                Navigator.of(context).pop(false);
                break;
              case RateAppStep.askForFeedback:
                Navigator.of(context).pop(false);
                context.go('/settings/contact/feedback_on_app');
                break;
            }
          },
          child: Text(
            buttonYes,
          ),
        ),
      ],
    );
  }
}


Points clés à retenir côté service

  • init() : à appeler tôt (splash/home) pour que shouldOpenDialog soit prêt quand on en a besoin.

  • shouldShowDialog : s'appuie sur la logique de rate_my_app (jours/lancements/relances).

  • requestAppReview() :

    • vérifie si on a déjà demandé récemment,
    • tente la popup native si dispo,
    • sinon fallback vers le store (app store ou play store).
  • Événements callEvent(...) : utiles pour suivre le parcours (ou brancher des analytics).

  • Feedback privé : shouldShowDialogForFeedback espace les demandes et enregistre l'horodatage pour ne pas re-demander trop vite.

5) Exemples d'utilisation dans l'app

Le but n'est pas "d'afficher une popup", mais de choisir un moment où l'utilisateur est de notre côté.

5.1 Sur l'écran principal (après rendu)

Attendre quelques secondes après le premier rendu évite l'effet "pop-in agressif".

WidgetsBinding.instance.addPostFrameCallback((_) {
  Future.delayed(const Duration(seconds: 5), () async {
    if (!mounted) return;
    final service = ref.read(rateAppServiceProvider);
    if (service.shouldShowDialog) {
      await service.showRateDialog(context);
    }
  });
});

Pourquoi ça marche :

  • pas d'interruption à froid,
  • la logique (jours/lancements/relances) est respectée,
  • on laisse l'utilisateur respirer avant d'agir.

5.2 Après une action réussie (le meilleur moment)

On déclenche la demande quand l'utilisateur vient d'atteindre un "petit succès" (ex. : chapitre terminé, export réussi, configuration OK).

Future<void> onStoryCompleted(BuildContext context, WidgetRef ref) async {
  final service = ref.read(rateAppServiceProvider);
  if (service.shouldShowDialog) {
    await service.showRateDialog(context);
  }
}

Idées de "moments positifs" :

  • objectif atteint (badge, niveau, session terminée),
  • feature testée avec succès,

5.3 Rediriger les insatisfaits vers un canal privé

RateAppDialog gère une branche "feedback". Assure-toi que la route existe et soit simple et rassurante comme dans l'exemple ci-dessous.

context.go('/settings/contact/feedback_on_app');
// dans cet écran : formulaire court, captures optionnelles, consentement, envoi asynchrone.
// Si offline, stocke en local et sync plus tard.

6) Bonnes pratiques & points d'attention

6.1 Respecter les quotas natifs

  • iOS (StoreKit) limite fortement l'affichage de la fenêtre système (≈ 3 fois/utilisateur/an). On maîtrise le timing, pas l'apparition garantie.
  • Android (In-App Review API) peut ignorer notre demande si on abuses.

Conséquence : Espacer les demandes, varier les moments, ne pas insister si l'utilisateur a dit non.

6.2 Micro-copy : le texte qui met l'utilisateur à l'aise

  • Clair et honnête : "Une minute pour nous noter ? Ça nous aide à améliorer l'app."
  • Choix explicites : "Plus tard" / "Donner mon avis" (pas de dark patterns).
  • Remerciement : toujours remercier, même s'il refuse.

6.3 Accessibilité & détails UX

  • Focus management : après showDialog, le focus clavier/lecteur d'écran doit aller sur le titre/texte du dialogue.
  • Taille de cible : boutons confortables (≥ 44×44 pt).
  • Couleurs/contrastes : respecte WCAG (≥ 4.5:1 pour le texte normal).
  • Alt / sémantique : si tu affiches une image dans le dialogue, fournis une alt pertinente dans la version accessible.

6.4 Ne casse pas le flow critique

  • Jamais pendant une action sensible (paiement, saisie longue, enregistrement).
  • Évite les tout premiers écrans (splash, login).
  • Pas de répétition : si l'utilisateur revient en arrière, ne relance pas immédiatement.

6.5 Canal de feedback in-app (pare-chocs anti-1⭐️)

  • Lien visible "Signaler un bug / Donner votre avis" dans Paramètres ou Aide.
  • Formulaire très court (message).
  • Suivi côté support produit (même un Notion/Email au début).
  • Rappelle dans le dialogue que on répond réellement aux messages.

6.6 Feature flag & rollback

  • Entoure la demande d'avis d'un flag distant (Remote Config, Firestore, etc.).
  • On peut désactiver la feature sans publier une release si on voit un effet indésirable.

6.7 Analytics (facultatif, mais utile)

  • Mesure le taux d'exposition (dialogue vu), le taux de clic ("Donner mon avis") et le taux d'ouverture du store.
  • On ne sauras pas toujours si la note a été déposée (limitation plateforme), mais on peux observer la tendance sur la page store.

6.8 Tests & QA

  • iOS : la fenêtre native peut ne pas apparaître en dev. Vérifie via TestFlight et comptes de test.
  • Android : idem, fais varier timing & fréquence, teste en "Internal Testing".
  • Mode debug : si besoin, prépare une variante de config de test (minDays=0, minLaunches=0) que on n'embarques pas en prod.

Exemple de config de test (à n'utiliser qu'en debug) :

RateMyApp(
  preferencesPrefix: 'rateMyApp_dev_',
  minDays: 0,
  minLaunches: 0,
  remindDays: 1,
  remindLaunches: 1,
  // ...
);

6.9 Edge cases techniques

  • mounted : toujours vérifier avant d'ouvrir un showDialog.
  • Re-entrant : protège l'appel de showRateDialog pour éviter les doublons si plusieurs triggers se déclenchent.
  • Web : garde kIsWeb pour ne rien faire sur les plateformes non supportées.
  • Navigation : si on lances le store, gère un éventuel retour d'état (ou pas — souvent inutile).

Conclusion & prochaines étapes

La formule qui marche :

moment positif → demande respectueuse → feedback privé si besoin → réponse systématique → amélioration continue.

Avec in_app_review pour l'affichage natif, rate_my_app pour le tempo, et un dialogue de feedback bien placé, on crées un système qui protège l'UX et booste la note naturellement.

À toi de jouer :

  • Branche le service, choisis un seul moment positif et déploie sur un petit pourcentage d'utilisateurs.
  • Observe 2 semaines, ajuste les seuils (jours/lancements), puis élargis.
  • Écris une réponse personnalisée à chaque nouvel avis.

💬 Je suis preneur de tes retours :

  • As-tu trouvé d'autres timings efficaces ?
  • Quels textes de dialogue convertissent le mieux chez toi ?
  • Tu utilises d'autres packages ou patterns pour gérer ça proprement ?

👉 Si ce guide t'a aidé, pense à le partager à un·e dev mobile autour de toi.

Liens utiles

Tags

  • flutter
  • mobile
  • android
  • ios
  • AppStore Optimization

Cet article à été posté le