Mejorar la valoración de tu app Flutter en el App Store con un buen timing

Mejorar la valoración de tu app Flutter en el App Store con un buen timing

Una nota de 4,9 ⭐ sobre 5, no es solo una métrica para fardar. Es una palanca de ASO, una señal de confianza y... a menudo, el resultado de un momento inteligente.

En este artículo, te muestro cómo pido reseñas en una app Flutter de forma nativa, respetuosa y efectiva, con rate_my_app + in_app_review — y sobre todo cuándo hacerlo para maximizar las 5⭐️, sin molestar a tus usuarios.

1) Por qué las calificaciones importan de verdad (más allá de la nota)

  • ASO y visibilidad. Los algoritmos del App Store y del Play Store se apoyan en la cantidad/calidad de las reseñas. Una buena nota mejora la descubribilidad y la tasa de conversión en tu ficha.
  • Prueba social. Antes de instalar, el usuario revisa la valoración global y los últimos comentarios. Una media alta + respuestas profesionales → confianza inmediata.
  • Bucle de producto. Las reseñas (y sobre todo los comentarios privados) señalan dónde falla la experiencia. Atenderlos rápido evita un 1⭐ público y alimenta tu roadmap.

El error más común: pedir demasiado pronto. Interrumpes al usuario antes de que haya experimentado valor... y obtienes el efecto contrario. El "cuándo" es más difícil que el "cómo".

2) Mi enfoque Flutter: nativo, controlado... y respetuoso

Objetivo de producto

Disparar el popup nativo (iOS/Android) en el momento adecuado, luego recordar con moderación, y redirigir a los insatisfechos a un canal privado (feedback).

Los bloques que uso

  • in_app_review : dispara la ventana nativa de reseñas (StoreKit en iOS/macOS, In-App Review API en Android). Es rápida, conforme a la UX, y el usuario permanece en la app.
  • rate_my_app : gestiona las condiciones de muestra (D+N, X lanzamientos, recordatorios espaciados, etc.). Ideal para orquestar el timing y evitar el spam.
  • flutter_riverpod (opcional) : solo para centralizar la lógica en un servicio inyectable/testeable. Si no usas Riverpod, mantén una clase servicio clásica y llámala desde tus widgets.

Lo que respeta (y no hace) este enfoque

  • No hay acoso. No interrumpimos en frío. Elegimos un momento de engagement (éxito de una acción, fin de un "capítulo", micro-momento "wow").

  • Dejamos que la plataforma decida la exhibición final.

    • iOS : StoreKit puede limitar la aparición del prompt a 3 veces máximo en 365 días por usuario/dispositivo, incluso si pides más. Controlamos el timing, no la garantía de aparición.
    • Android : el Play Core puede ignorar nuestra petición si abusamos. Aquí también priman la moderación y la pertinencia del momento.

🔎 Nota "analytics" (opcional). Si tienes una pila de eventos (Firebase/Amplitude), puedes afinar el disparador: "Pedir tras N acciones exitosas". Útil, pero no indispensable para una primera iteración.

Esquema mental de la UX objetivo

  1. Momento positivo detectado → pedimos una reseña (popup nativo si está disponible).
  2. Si lo rechaza → lo aplazamos varios días/lanzamientos (no recordatorio inmediato).
  3. Si está tibio → lo redirigimos a un formulario interno ("Reportar un bug", "Dar una opinión detallada"), en lugar de una reseña pública.
  4. Tras la publicaciónrespondemos a las reseñas y cerramos el ciclo con mejoras de producto (changelog, parches rápidos).

feedback_start feedback_dont_like feedback_form feedback_rate_in_app

3) El timing: reglas simples que evitan el spam

El "cuándo" importa más que el "cómo". El objetivo: maximizar las 5⭐️ sin romper el flujo.

3.1 Reglas básicas (días, lanzamientos, recordatorios)

Configuras 4 salvaguardas:

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

👉 Estos umbrales dan tiempo al usuario para experimentar valor antes de ser solicitado. 👉 Los recordatorios espaciados evitan el efecto de "acoso".

3.2 Dos recorridos: calificación pública vs feedback privado

Distingimos dos casos de uso, cada uno con su propio 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
  • "Calificación pública" (popup nativo) → espaciamos mucho (30 d) y dejamos que la plataforma decida mostrarlo o no.
  • "Feedback privado" (formulario interno) → podemos volver antes (3 d): es una escucha, no una imposición.

El resultado:

  • Los descontentos se desvían hacia un canal privado.
  • Los satisfechos se orientan hacia la calificación pública.

3.3 Estados y salvaguardas técnicas

  • Inicialización única : llamamos a init() una sola vez y memorizamos _isInitialized.
  • Protección web : kIsWeb evita cualquier llamada no soportada por las stores.
  • Condiciones de apertura : shouldShowDialog y shouldShowDialogForFeedback encapsulan la decisión.
  • Persistencia : registramos la última vez que se hizo una solicitud (ISO-8601 en storage) para respetar los tiempos.
  • Fallback limpio : si el popup nativo no está disponible, abrir la store (launchStore()) en lugar de quedarse en silencio.

4) Implementación Flutter (servicio + diálogo UI)

4.1 Servicio RateAppService (Riverpod opcional)

Centralizamos toda la lógica aquí: inicialización, condiciones, tracking de eventos, petición de reseña nativa, redirección a la store, etc.

⚙️ Antes de usar :

  • Rellenar ANDROID_APP_ID / IOS_APP_ID vía dotenv.

  • Lanzar la generación de Riverpod si usas la anotación :

      dart run build_runner build -d
    
  • Adaptar los imports de Storage a nuestro proyecto.

  • Las claves i18n (FlutterI18n.translate) deben existir en tus archivos de traducción.

Código 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,
          ),
        ),
      ],
    );
  }
}


Puntos clave a recordar en el lado del servicio

  • init() : llamar pronto (splash/home) para que shouldOpenDialog esté listo cuando lo necesitemos.

  • shouldShowDialog : se apoya en la lógica de rate_my_app (días/lanzamientos/recordatorios).

  • requestAppReview() :

    • verifica si ya se pidió recientemente,
    • intenta el popup nativo si está disponible,
    • si no, fallback hacia la store (app store o play store).
  • Eventos callEvent(...) : útiles para seguir el recorrido (o conectar analytics).

  • Feedback privado : shouldShowDialogForFeedback espacía las solicitudes y registra la marca temporal para no pedir de nuevo demasiado pronto.

5) Ejemplos de uso en la app

La meta no es "mostrar un popup", sino elegir un momento en el que el usuario está de nuestro lado.

5.1 En la pantalla principal (después del render)

Esperar unos segundos después del primer render evita el efecto "pop-in agresivo".

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);
    }
  });
});

Por qué funciona:

  • no hay interrupción en frío,
  • la lógica (días/lanzamientos/recordatorios) se respeta,
  • dejamos al usuario respirar antes de actuar.

5.2 Tras una acción exitosa (el mejor momento)

Disparamos la petición cuando el usuario acaba de lograr un "pequeño éxito" (ej.: capítulo terminado, exportación exitosa, configuración OK).

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

Ideas de "momentos positivos" :

  • objetivo alcanzado (insignia, nivel, sesión completada),
  • función utilizada con éxito,

5.3 Redirigir a los insatisfechos a un canal privado

RateAppDialog maneja una rama "feedback". Asegúrate de que la ruta exista y sea simple y tranquilizadora como en el ejemplo a continuación.

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) Buenas prácticas y puntos a tener en cuenta

6.1 Respetar las cuotas nativas

  • iOS (StoreKit) limita fuertemente la visualización de la ventana del sistema (≈ 3 veces/usuario/año). Controlamos el timing, no la aparición garantizada.
  • Android (In-App Review API) puede ignorar nuestra petición si abusamos.

Consecuencia : Espaciar las solicitudes, variar los momentos, no insistir si el usuario dijo que no.

6.2 Micro-copy: el texto que tranquiliza al usuario

  • Claro y honesto : "¿Un minuto para valorarnos? Nos ayuda a mejorar la app."
  • Opciones explícitas : "Más tarde" / "Dar mi opinión" (sin dark patterns).
  • Agradecimiento : siempre agradecer, incluso si rechaza.

6.3 Accesibilidad y detalles de UX

  • Gestión del foco : tras showDialog, el foco del teclado/lector de pantalla debe ir al título/texto del diálogo.
  • Tamaño de objetivo : botones cómodos (≥ 44×44 pt).
  • Colores/contrastes : respetar WCAG (≥ 4.5:1 para texto normal).
  • Alt / semántica : si muestras una imagen en el diálogo, proporciona un alt pertinente en la versión accesible.

6.4 No romper el flujo crítico

  • Nunca durante una acción sensible (pago, entrada larga, grabación).
  • Evitar las pantallas iniciales (splash, login).
  • Sin repetición : si el usuario retrocede, no relances inmediatamente.

6.5 Canal de feedback in-app (parachoques anti-1⭐)

  • Enlace visible "Reportar un bug / Dar tu opinión" en Ajustes o Ayuda.
  • Formulario muy corto (mensaje).
  • Seguimiento por parte del soporte de producto (incluso un Notion/Email al principio).
  • Recuerda en el diálogo que respondemos realmente a los mensajes.

6.6 Feature flag y rollback

  • Rodea la petición de reseña con un flag remoto (Remote Config, Firestore, etc.).
  • Puedes desactivar la funcionalidad sin publicar una release si detectas un efecto indeseado.

6.7 Analytics (opcional, pero útil)

  • Mide la tasa de exposición (diálogo visto), la tasa de clic ("Dar mi opinión") y la tasa de apertura de la store.
  • No siempre sabremos si la calificación se dejó (limitación de la plataforma), pero podemos observar la tendencia en la página de la store.

6.8 Tests y QA

  • iOS : la ventana nativa puede no aparecer en desarrollo. Verifica vía TestFlight y cuentas de prueba.
  • Android : igual, varía timing y frecuencia, prueba en "Internal Testing".
  • Modo debug : si hace falta, prepara una variante de configuración de prueba (minDays=0, minLaunches=0) que no subas a producción.

Ejemplo de config de prueba (usar solo en debug) :

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

6.9 Casos límite técnicos

  • mounted : comprobar siempre antes de abrir un showDialog.
  • Re-entrant : proteger la llamada a showRateDialog para evitar duplicados si varios triggers se disparan.
  • Web : mantener kIsWeb para no hacer nada en plataformas no soportadas.
  • Navegación : si lanzamos la store, gestionar un posible estado de retorno (o no — a menudo innecesario).

Conclusión y próximos pasos

La fórmula que funciona:

momento positivo → petición respetuosa → feedback privado si hace falta → respuesta sistemática → mejora continua.

Con in_app_review para la visualización nativa, rate_my_app para el tempo, y un diálogo de feedback bien situado, creamos un sistema que protege la UX y aumenta la nota de forma natural.

Te toca a ti :

  • Conecta el servicio, elige un solo momento positivo y despliega a un pequeño porcentaje de usuarios.
  • Observa 2 semanas, ajusta los umbrales (días/lanzamientos), luego amplía.
  • Escribe una respuesta personalizada a cada nueva reseña.

💬 Agradezco tus comentarios:

  • ¿Has encontrado otros timings efectivos?
  • ¿Qué textos de diálogo convierten mejor para ti?
  • ¿Usas otros paquetes o patrones para gestionar esto correctamente?

👉 Si esta guía te ayudó, compártela con un·a dev móvil a tu alrededor.

Enlaces útiles

Etiquetas

  • flutter

  • ios

  • android

  • AppStore Optimization

  • ASO

  • reseña_en_la_aplicación

  • califica_mi_app

Este artículo fue publicado el

Comentarios

Cargando...

Mejorar la valoración de tu app Flutter en el App Store con un buen timing | DEMILY Clément