From bed2c35d97047edcf60244db5b967befbc0cf4be Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:10:11 +0100 Subject: [PATCH] feat: prompt for reviews --- android/fastlane/Fastfile | 4 +- ios/fastlane/Fastfile | 6 +- lib/main.dart | 20 ++- lib/services/in_app_review_service.dart | 161 ++++++++++++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.yaml | 1 + 6 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 lib/services/in_app_review_service.dart diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile index 40b895cf..35024f0b 100644 --- a/android/fastlane/Fastfile +++ b/android/fastlane/Fastfile @@ -32,13 +32,13 @@ platform :android do desc "Build release AAB" lane :build do # Navigate to Flutter project root and build - sh("cd #{ENV['PWD']}/.. && flutter build appbundle") + sh("cd #{ENV['PWD']}/.. && flutter build appbundle --dart-define=ENABLE_IN_APP_REVIEW=true") end desc "Build and deploy to Google Play Store" lane :release do # Build the Flutter app - sh("cd #{ENV['PWD']}/.. && flutter build appbundle") + sh("cd #{ENV['PWD']}/.. && flutter build appbundle --dart-define=ENABLE_IN_APP_REVIEW=true") # Get version code from pubspec.yaml pubspec_path = "#{ENV['PWD']}/../pubspec.yaml" diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 57829379..379fab29 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -19,13 +19,13 @@ platform :ios do desc "Build release IPA" lane :build do # Navigate to Flutter project root and build - sh("cd #{ENV['PWD']}/.. && flutter build ipa") + sh("cd #{ENV['PWD']}/.. && flutter build ipa --dart-define=ENABLE_IN_APP_REVIEW=true") end desc "Build and deploy to TestFlight" lane :deploy_testflight do # Build the Flutter app - sh("cd #{ENV['PWD']}/.. && flutter build ipa") + sh("cd #{ENV['PWD']}/.. && flutter build ipa --dart-define=ENABLE_IN_APP_REVIEW=true") # Upload to TestFlight upload_to_testflight( @@ -36,7 +36,7 @@ platform :ios do desc "Build and deploy to App Store" lane :deploy_appstore do # Build the Flutter app - sh("cd #{ENV['PWD']}/.. && flutter build ipa") + sh("cd #{ENV['PWD']}/.. && flutter build ipa --dart-define=ENABLE_IN_APP_REVIEW=true") # Upload to App Store upload_to_app_store( diff --git a/lib/main.dart b/lib/main.dart index a0e69b1b..e2f9dd8d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,6 +27,7 @@ import 'watch_together/watch_together.dart'; import 'services/multi_server_manager.dart'; import 'services/offline_watch_sync_service.dart'; import 'services/data_aggregation_service.dart'; +import 'services/in_app_review_service.dart'; import 'services/server_registry.dart'; import 'services/download_manager_service.dart'; import 'services/download_storage_service.dart'; @@ -129,6 +130,9 @@ class _MainAppState extends State with WidgetsBindingObserver { _downloadManager = DownloadManagerService(database: _appDatabase, storageService: DownloadStorageService.instance); _offlineWatchSyncService = OfflineWatchSyncService(database: _appDatabase, serverManager: _serverManager); + + // Start in-app review session tracking + InAppReviewService.instance.startSession(); } @override @@ -139,9 +143,19 @@ class _MainAppState extends State with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) { - // App came back to foreground - trigger sync check - _offlineWatchSyncService.onAppResumed(); + switch (state) { + case AppLifecycleState.resumed: + // App came back to foreground - trigger sync check and start new session + _offlineWatchSyncService.onAppResumed(); + InAppReviewService.instance.startSession(); + case AppLifecycleState.paused: + case AppLifecycleState.detached: + // App went to background or is closing - end session + InAppReviewService.instance.endSession(); + case AppLifecycleState.inactive: + case AppLifecycleState.hidden: + // Transitional states - don't trigger session events + break; } } diff --git a/lib/services/in_app_review_service.dart b/lib/services/in_app_review_service.dart new file mode 100644 index 00000000..ec837cfb --- /dev/null +++ b/lib/services/in_app_review_service.dart @@ -0,0 +1,161 @@ +import 'dart:io' show Platform; +import 'package:in_app_review/in_app_review.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../utils/app_logger.dart'; + +/// Service to manage in-app review prompts +/// Only enabled when ENABLE_IN_APP_REVIEW build flag is set +class InAppReviewService { + static final InAppReviewService _instance = InAppReviewService._(); + static InAppReviewService get instance => _instance; + + InAppReviewService._(); + + final InAppReview _inAppReview = InAppReview.instance; + + // SharedPreferences keys + static const String _keyQualifyingSessionsCount = 'review_qualifying_sessions_count'; + static const String _keyLastPromptTime = 'review_last_prompt_time'; + + // Configuration + static const int _requiredSessions = 6; + static const Duration _minimumSessionDuration = Duration(minutes: 5); + static const Duration _promptCooldown = Duration(days: 60); + + // Session tracking + DateTime? _sessionStartTime; + + /// Check if in-app review is enabled via build flag + /// Only enabled on mobile platforms (iOS and Android) + static bool get isEnabled { + if (!Platform.isIOS && !Platform.isAndroid) { + return false; + } + const enabled = bool.fromEnvironment('ENABLE_IN_APP_REVIEW', defaultValue: false); + return enabled; + } + + /// Start tracking a new session + void startSession() { + if (!isEnabled) return; + _sessionStartTime = DateTime.now(); + appLogger.d('In-app review: Session started'); + } + + /// End the current session and check if it qualifies + /// Call this when app goes to background or is closed + Future endSession() async { + if (!isEnabled || _sessionStartTime == null) return; + + final sessionDuration = DateTime.now().difference(_sessionStartTime!); + _sessionStartTime = null; + + if (sessionDuration >= _minimumSessionDuration) { + await _incrementQualifyingSessions(); + appLogger.d('In-app review: Qualifying session ended (${sessionDuration.inMinutes} minutes)'); + await maybeRequestReview(); + } else { + appLogger.d('In-app review: Session too short (${sessionDuration.inMinutes} minutes)'); + } + } + + /// Increment the qualifying sessions counter + Future _incrementQualifyingSessions() async { + final prefs = await SharedPreferences.getInstance(); + final currentCount = prefs.getInt(_keyQualifyingSessionsCount) ?? 0; + await prefs.setInt(_keyQualifyingSessionsCount, currentCount + 1); + } + + /// Get the current qualifying sessions count + Future _getQualifyingSessionsCount() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt(_keyQualifyingSessionsCount) ?? 0; + } + + /// Check if we should request a review based on session count and cooldown + Future _shouldRequestReview() async { + final prefs = await SharedPreferences.getInstance(); + + // Check session count + final sessionCount = await _getQualifyingSessionsCount(); + if (sessionCount < _requiredSessions) { + appLogger.d('In-app review: Not enough sessions ($sessionCount/$_requiredSessions)'); + return false; + } + + // Check cooldown + final lastPromptString = prefs.getString(_keyLastPromptTime); + if (lastPromptString != null) { + final lastPrompt = DateTime.parse(lastPromptString); + final timeSinceLastPrompt = DateTime.now().difference(lastPrompt); + if (timeSinceLastPrompt < _promptCooldown) { + final daysRemaining = (_promptCooldown - timeSinceLastPrompt).inDays; + appLogger.d('In-app review: Cooldown active ($daysRemaining days remaining)'); + return false; + } + } + + return true; + } + + /// Request a review if conditions are met + Future maybeRequestReview() async { + if (!isEnabled) return; + + final shouldRequest = await _shouldRequestReview(); + if (!shouldRequest) return; + + try { + // Check if in-app review is available on this device + final isAvailable = await _inAppReview.isAvailable(); + if (!isAvailable) { + appLogger.d('In-app review: Not available on this device'); + return; + } + + // Request the review + await _inAppReview.requestReview(); + appLogger.i('In-app review: Review prompt shown'); + + // Record that we showed the prompt and reset session count + await _recordPromptShown(); + } catch (e) { + appLogger.e('In-app review: Error requesting review', error: e); + } + } + + /// Record that the review prompt was shown + Future _recordPromptShown() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyLastPromptTime, DateTime.now().toIso8601String()); + // Reset session count so user needs to use app more before next prompt + await prefs.setInt(_keyQualifyingSessionsCount, 0); + } + + /// Get debug info about the current state (for development/testing) + Future> getDebugInfo() async { + final prefs = await SharedPreferences.getInstance(); + final sessionCount = prefs.getInt(_keyQualifyingSessionsCount) ?? 0; + final lastPromptString = prefs.getString(_keyLastPromptTime); + final isAvailable = await _inAppReview.isAvailable(); + + return { + 'isEnabled': isEnabled, + 'isAvailable': isAvailable, + 'qualifyingSessions': sessionCount, + 'requiredSessions': _requiredSessions, + 'lastPromptTime': lastPromptString, + 'cooldownDays': _promptCooldown.inDays, + 'currentSessionStartTime': _sessionStartTime?.toIso8601String(), + }; + } + + /// Reset all stored data (for testing purposes) + Future reset() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_keyQualifyingSessionsCount); + await prefs.remove(_keyLastPromptTime); + _sessionStartTime = null; + appLogger.d('In-app review: State reset'); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 56fa71cb..604097d9 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,6 +11,7 @@ import file_picker import flutter_webrtc import gamepads_darwin import hotkey_manager_macos +import in_app_review import macos_window_utils import os_media_controls import package_info_plus @@ -31,6 +32,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) GamepadsDarwinPlugin.register(with: registry.registrar(forPlugin: "GamepadsDarwinPlugin")) HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin")) + InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin")) MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin")) OsMediaControlsPlugin.register(with: registry.registrar(forPlugin: "OsMediaControlsPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) diff --git a/pubspec.yaml b/pubspec.yaml index 7d87d339..5aabd052 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,7 @@ dependencies: material_symbols_icons: ^4.2892.0 peerdart: ^0.5.6 share_plus: ^10.0.0 + in_app_review: ^2.0.11 dev_dependencies: flutter_test: