@tduncan / Cortana / commits / f3ba7fb

feat: implement review prompt service to request app reviews based on usage thresholds and model interaction signals

abdulmominsakib committed Jun 19, 2026 at 06:01 UTC f3ba7fb86acb8ae70d40b3a25cbdb4300c33beb2
8 files changed +555 -7
lib/core/providers/review_prompt_providers.dart new
+32
@@ -0,0 +1,32 @@
1 +import 'package:flutter_riverpod/flutter_riverpod.dart';
2 +
3 +import '../constants/app_constants.dart';
4 +import '../services/review_prompt_service.dart';
5 +import 'app_providers.dart';
6 +import 'storage_providers.dart';
7 +
8 +final reviewPromptClientProvider = Provider<ReviewPromptClient>((ref) {
9 + return InAppReviewPromptClient();
10 +});
11 +
12 +final reviewPromptServiceProvider = Provider<ReviewPromptService>((ref) {
13 + final prefs = ref.watch(sharedPreferencesProvider);
14 + final client = ref.watch(reviewPromptClientProvider);
15 +
16 + return ReviewPromptService(
17 + prefs: prefs,
18 + client: client,
19 + appVersionLoader: () async {
20 + try {
21 + final packageInfo = await ref.read(packageInfoProvider.future);
22 + final buildNumber = packageInfo.buildNumber;
23 + if (buildNumber.isEmpty) {
24 + return packageInfo.version;
25 + }
26 + return '${packageInfo.version}+$buildNumber';
27 + } catch (_) {
28 + return AppConstants.version;
29 + }
30 + },
31 + );
32 +});
lib/core/services/review_prompt_service.dart new
+184
@@ -0,0 +1,184 @@
1 +import 'package:in_app_review/in_app_review.dart';
2 +import 'package:shared_preferences/shared_preferences.dart';
3 +
4 +import '../models/enums.dart';
5 +
6 +abstract class ReviewPromptClient {
7 + Future<bool> isAvailable();
8 + Future<void> requestReview();
9 +}
10 +
11 +class InAppReviewPromptClient implements ReviewPromptClient {
12 + InAppReviewPromptClient({InAppReview? inAppReview})
13 + : _inAppReview = inAppReview ?? InAppReview.instance;
14 +
15 + final InAppReview _inAppReview;
16 +
17 + @override
18 + Future<bool> isAvailable() => _inAppReview.isAvailable();
19 +
20 + @override
21 + Future<void> requestReview() => _inAppReview.requestReview();
22 +}
23 +
24 +class ReviewPromptService {
25 + ReviewPromptService({
26 + required this.prefs,
27 + required this.client,
28 + required this.appVersionLoader,
29 + DateTime Function()? now,
30 + this.cooldown = const Duration(days: 30),
31 + this.successfulChatThreshold = 3,
32 + this.minSubstantialResponseLength = 80,
33 + }) : clock = now ?? DateTime.now;
34 +
35 + static const successfulChatCountKey = 'reviewPrompt.successfulChatCount';
36 + static const lastRequestMillisKey = 'reviewPrompt.lastRequestMillis';
37 + static const lastRequestedVersionKey = 'reviewPrompt.lastRequestedVersion';
38 + static const pendingLoadedOnDeviceModelIdKey =
39 + 'reviewPrompt.pendingLoadedOnDeviceModelId';
40 + static const downloadedModelIdsKey = 'reviewPrompt.downloadedModelIds';
41 + static const pendingDownloadedAndLoadedModelIdKey =
42 + 'reviewPrompt.pendingDownloadedAndLoadedModelId';
43 +
44 + final SharedPreferences prefs;
45 + final ReviewPromptClient client;
46 + final Future<String> Function() appVersionLoader;
47 + final DateTime Function() clock;
48 + final Duration cooldown;
49 + final int successfulChatThreshold;
50 + final int minSubstantialResponseLength;
51 +
52 + bool _requestInFlight = false;
53 +
54 + Future<void> markModelDownloadCompleted(String modelId) async {
55 + final downloadedIds = prefs.getStringList(downloadedModelIdsKey) ?? [];
56 + if (!downloadedIds.contains(modelId)) {
57 + await prefs.setStringList(downloadedModelIdsKey, [
58 + ...downloadedIds,
59 + modelId,
60 + ]);
61 + }
62 + }
63 +
64 + Future<void> markOnDeviceModelLoaded(String modelId) async {
65 + await prefs.setString(pendingLoadedOnDeviceModelIdKey, modelId);
66 +
67 + final downloadedIds = prefs.getStringList(downloadedModelIdsKey) ?? [];
68 + if (downloadedIds.contains(modelId)) {
69 + await prefs.setString(pendingDownloadedAndLoadedModelIdKey, modelId);
70 + }
71 + }
72 +
73 + Future<bool> maybeRequestReviewAfterSuccessfulChat({
74 + required String assistantContent,
75 + required ServerType serverType,
76 + String? modelId,
77 + bool usedCustomPersona = false,
78 + }) async {
79 + if (assistantContent.trim().length < minSubstantialResponseLength) {
80 + return false;
81 + }
82 +
83 + final successfulChatCount = (prefs.getInt(successfulChatCountKey) ?? 0) + 1;
84 + await prefs.setInt(successfulChatCountKey, successfulChatCount);
85 +
86 + final matchedLoadedOnDeviceModel =
87 + serverType == ServerType.onDevice &&
88 + modelId != null &&
89 + prefs.getString(pendingLoadedOnDeviceModelIdKey) == modelId;
90 +
91 + final matchedDownloadedAndLoadedModel =
92 + serverType == ServerType.onDevice &&
93 + modelId != null &&
94 + prefs.getString(pendingDownloadedAndLoadedModelIdKey) == modelId;
95 +
96 + final hasSpecialPositiveSignal =
97 + matchedLoadedOnDeviceModel ||
98 + matchedDownloadedAndLoadedModel ||
99 + usedCustomPersona;
100 +
101 + if (successfulChatCount < successfulChatThreshold &&
102 + !hasSpecialPositiveSignal) {
103 + return false;
104 + }
105 +
106 + if (!await _canRequestReview()) {
107 + return false;
108 + }
109 +
110 + if (_requestInFlight) {
111 + return false;
112 + }
113 +
114 + _requestInFlight = true;
115 + try {
116 + final available = await client.isAvailable();
117 + if (!available) {
118 + return false;
119 + }
120 +
121 + await client.requestReview();
122 + await _recordReviewAttempt();
123 + await _consumeMatchedSignals(
124 + consumeLoadedOnDevice: matchedLoadedOnDeviceModel,
125 + consumeDownloadedAndLoaded: matchedDownloadedAndLoadedModel,
126 + );
127 + return true;
128 + } finally {
129 + _requestInFlight = false;
130 + }
131 + }
132 +
133 + Future<bool> _canRequestReview() async {
134 + final currentVersion = await _currentAppVersion();
135 + if (prefs.getString(lastRequestedVersionKey) == currentVersion) {
136 + return false;
137 + }
138 +
139 + final lastRequestMillis = prefs.getInt(lastRequestMillisKey);
140 + if (lastRequestMillis == null) {
141 + return true;
142 + }
143 +
144 + final lastRequestAt = DateTime.fromMillisecondsSinceEpoch(
145 + lastRequestMillis,
146 + );
147 + return clock().difference(lastRequestAt) >= cooldown;
148 + }
149 +
150 + Future<void> _recordReviewAttempt() async {
151 + await prefs.setString(lastRequestedVersionKey, await _currentAppVersion());
152 + await prefs.setInt(lastRequestMillisKey, clock().millisecondsSinceEpoch);
153 + }
154 +
155 + Future<void> _consumeMatchedSignals({
156 + required bool consumeLoadedOnDevice,
157 + required bool consumeDownloadedAndLoaded,
158 + }) async {
159 + if (consumeLoadedOnDevice) {
160 + await prefs.remove(pendingLoadedOnDeviceModelIdKey);
161 + }
162 +
163 + if (consumeDownloadedAndLoaded) {
164 + final modelId = prefs.getString(pendingDownloadedAndLoadedModelIdKey);
165 + await prefs.remove(pendingDownloadedAndLoadedModelIdKey);
166 +
167 + if (modelId != null) {
168 + final downloadedIds = prefs.getStringList(downloadedModelIdsKey) ?? [];
169 + await prefs.setStringList(
170 + downloadedModelIdsKey,
171 + downloadedIds.where((id) => id != modelId).toList(),
172 + );
173 + }
174 + }
175 + }
176 +
177 + Future<String> _currentAppVersion() async {
178 + try {
179 + return await appVersionLoader();
180 + } catch (_) {
181 + return 'unknown';
182 + }
183 + }
184 +}
lib/features/chat/providers/chat_providers.dart
+49 -4
@@ -11,10 +11,12 @@ import 'package:localmind/features/conversations/providers/conversation_provider
11 as conv;
12 import 'package:localmind/features/models/data/models/model_info.dart';
13 import 'package:localmind/features/personas/providers/personas_providers.dart';
14 +import 'package:localmind/features/servers/data/models/server.dart';
15 import 'package:localmind/features/servers/providers/server_providers.dart';
16 import './chat_mcp_providers.dart';
17
18 import '../../../core/models/enums.dart';
19 +import '../../../core/providers/review_prompt_providers.dart';
20 import '../../../core/providers/service_providers.dart';
21 import '../../../core/providers/storage_providers.dart';
22 import '../../on_device/providers/on_device_providers.dart';
@@ -881,7 +883,8 @@ class ChatNotifier extends Notifier<ChatState> {
883 // Check if content is empty - this happens when free models refuse or fail
884 final hasContent =
885 streamingMessage.content.isNotEmpty ||
884 - (streamingMessage.reasoningContent?.isNotEmpty ?? false) ||
886 + (streamingMessage.reasoningContent?.isNotEmpty ??
887 + false) ||
888 collectedToolCalls.isNotEmpty;
889
890 if (!hasContent) {
@@ -951,9 +954,7 @@ class ChatNotifier extends Notifier<ChatState> {
954
955 final result = await completer.future;
956
954 - state = state.copyWith(
955 - clearPendingApproval: true,
956 - );
957 + state = state.copyWith(clearPendingApproval: true);
958
959 return result;
960 },
@@ -1011,6 +1012,12 @@ class ChatNotifier extends Notifier<ChatState> {
1012 }
1013 }
1014
1015 + _maybeRequestReviewAfterSuccessfulCompletion(
1016 + finalMessage: finalMessage,
1017 + server: server,
1018 + selectedModel: selectedModel,
1019 + );
1020 +
1021 _chunkCount = 0;
1022 _lastCheckpointTime = null;
1023 _lastSavedContentLength = 0;
@@ -1179,6 +1186,44 @@ class ChatNotifier extends Notifier<ChatState> {
1186 .renameConversation(_currentConversationId!, title);
1187 }
1188
1189 + void _maybeRequestReviewAfterSuccessfulCompletion({
1190 + required Message finalMessage,
1191 + required Server server,
1192 + required ModelInfo? selectedModel,
1193 + }) {
1194 + final modelId =
1195 + selectedModel?.id ??
1196 + (server.type == ServerType.onDevice
1197 + ? ref.read(onDeviceEngineProvider).loadedModelId
1198 + : null);
1199 +
1200 + unawaited(
1201 + ref
1202 + .read(reviewPromptServiceProvider)
1203 + .maybeRequestReviewAfterSuccessfulChat(
1204 + assistantContent: finalMessage.content,
1205 + serverType: server.type,
1206 + modelId: modelId,
1207 + usedCustomPersona: _isUsingCustomPersona(),
1208 + )
1209 + .catchError((Object error, StackTrace stackTrace) {
1210 + Log.error('Review prompt request failed: $error');
1211 + return false;
1212 + }),
1213 + );
1214 + }
1215 +
1216 + bool _isUsingCustomPersona() {
1217 + final activeConversation = ref.read(conv.activeConversationProvider);
1218 + final personaId = activeConversation?.personaId;
1219 + if (personaId == null) return false;
1220 +
1221 + final personas = ref.read(personasNotifierProvider).value ?? [];
1222 + return personas.any(
1223 + (persona) => persona.id == personaId && !persona.isBuiltIn,
1224 + );
1225 + }
1226 +
1227 Future<void> cancelStream() async {
1228 _uiUpdateTimer?.cancel();
1229 _uiUpdateTimer = null;
lib/features/on_device/providers/foreground_download_providers.dart
+12 -2
@@ -4,6 +4,7 @@ import 'dart:convert';
4 import 'package:flutter_riverpod/flutter_riverpod.dart';
5
6 import '../../../core/logger/app_logger.dart';
7 +import '../../../core/providers/review_prompt_providers.dart';
8 import '../../../core/providers/storage_providers.dart';
9 import '../data/models/download_progress_info.dart';
10 import '../data/models/download_status.dart';
@@ -92,9 +93,11 @@ class ForegroundDownloadNotifier
93 final elapsed = DateTime.now().difference(startTime).inSeconds;
94 final total = model.fileSizeBytes;
95 final received = ((progress / 100.0) * total).round();
95 -
96 +
97 final bytesPerSecond = elapsed > 0 ? (received / elapsed).round() : 0;
97 - final etaSeconds = bytesPerSecond > 0 ? ((total - received) / bytesPerSecond).round() : null;
98 + final etaSeconds = bytesPerSecond > 0
99 + ? ((total - received) / bytesPerSecond).round()
100 + : null;
101
102 state = {
103 ...state,
@@ -127,6 +130,13 @@ class ForegroundDownloadNotifier
130 _saveState();
131
132 ref.invalidate(downloadedModelsProvider);
133 + try {
134 + await ref
135 + .read(reviewPromptServiceProvider)
136 + .markModelDownloadCompleted(modelId);
137 + } catch (e) {
138 + Log.error('Failed to record model download review signal: $e');
139 + }
140 _removeCompletedDownload(modelId);
141 }
142 } catch (e) {
lib/features/on_device/providers/on_device_providers.dart
+9
@@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
6 import '../../../core/logger/app_logger.dart';
7 import '../../../core/models/enums.dart';
8 import '../../../core/providers/chat_background_service_provider.dart';
9 +import '../../../core/providers/review_prompt_providers.dart';
10 import '../data/models/on_device_model.dart';
11 import '../data/notification_permission_service.dart';
12 import '../data/on_device_gemma_service.dart';
@@ -118,6 +119,14 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
119 Log.info(
120 'Model $modelId loaded successfully with ${effectiveBackend.name}',
121 );
122 +
123 + try {
124 + await ref
125 + .read(reviewPromptServiceProvider)
126 + .markOnDeviceModelLoaded(modelId);
127 + } catch (e) {
128 + Log.error('Failed to record model load review signal: $e');
129 + }
130 } catch (e) {
131 Log.error('Failed to load model $modelId: $e');
132 state = state.copyWith(
pubspec.lock
+16
@@ -741,6 +741,22 @@ packages:
741 url: "https://pub.dev"
742 source: hosted
743 version: "1.1.7"
744 + in_app_review:
745 + dependency: "direct main"
746 + description:
747 + name: in_app_review
748 + sha256: "364db0c160b37fe7fad9cdaa18f968473924b17368869010af902760421b6bf8"
749 + url: "https://pub.dev"
750 + source: hosted
751 + version: "2.0.12"
752 + in_app_review_platform_interface:
753 + dependency: transitive
754 + description:
755 + name: in_app_review_platform_interface
756 + sha256: fed2c755f2125caa9ae10495a3c163aa7fab5af3585a9c62ef4a6920c5b45f10
757 + url: "https://pub.dev"
758 + source: hosted
759 + version: "2.0.5"
760 intl:
761 dependency: "direct main"
762 description:
pubspec.yaml
+2 -1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17 # In Windows, build-name is used as the major, minor, and patch parts
18 # of the product and file versions while build-number is used as the build suffix.
19 -version: 1.4.0+44
19 +version: 1.4.1+45
20
21 environment:
22 sdk: ">=3.12.0 <4.0.0"
@@ -70,6 +70,7 @@ dependencies:
70 audio_session: ^0.2.3
71 device_info_plus: ^13.1.0
72 package_info_plus: ^10.1.0
73 + in_app_review: ^2.0.12
74 sherpa_onnx: ^1.13.2
75 archive: ^4.0.2
76 loading_animation_widget: ^1.3.0
test/core/services/review_prompt_service_test.dart new
+251
@@ -0,0 +1,251 @@
1 +import 'package:flutter_test/flutter_test.dart';
2 +import 'package:localmind/core/models/enums.dart';
3 +import 'package:localmind/core/services/review_prompt_service.dart';
4 +import 'package:shared_preferences/shared_preferences.dart';
5 +
6 +void main() {
7 + const version = '1.2.3+4';
8 + final now = DateTime(2026, 1, 1, 12);
9 +
10 + Future<_Fixture> createFixture({
11 + Map<String, Object> initialValues = const {},
12 + bool available = true,
13 + }) async {
14 + SharedPreferences.setMockInitialValues(initialValues);
15 + final prefs = await SharedPreferences.getInstance();
16 + final client = _FakeReviewPromptClient(available: available);
17 + final service = ReviewPromptService(
18 + prefs: prefs,
19 + client: client,
20 + appVersionLoader: () async => version,
21 + now: () => now,
22 + cooldown: const Duration(days: 30),
23 + );
24 + return _Fixture(service: service, client: client, prefs: prefs);
25 + }
26 +
27 + group('ReviewPromptService', () {
28 + test(
29 + 'requests review after the third substantial chat completion',
30 + () async {
31 + final fixture = await createFixture();
32 +
33 + expect(
34 + await fixture.service.maybeRequestReviewAfterSuccessfulChat(
35 + assistantContent: _content(),
36 + serverType: ServerType.ollama,
37 + ),
38 + false,
39 + );
40 + expect(
41 + await fixture.service.maybeRequestReviewAfterSuccessfulChat(
42 + assistantContent: _content(),
43 + serverType: ServerType.ollama,
44 + ),
45 + false,
46 + );
47 + expect(fixture.client.requestCount, 0);
48 +
49 + expect(
50 + await fixture.service.maybeRequestReviewAfterSuccessfulChat(
51 + assistantContent: _content(),
52 + serverType: ServerType.ollama,
53 + ),
54 + true,
55 + );
56 +
57 + expect(fixture.client.requestCount, 1);
58 + expect(
59 + fixture.prefs.getInt(ReviewPromptService.successfulChatCountKey),
60 + 3,
61 + );
62 + expect(
63 + fixture.prefs.getString(ReviewPromptService.lastRequestedVersionKey),
64 + version,
65 + );
66 + },
67 + );
68 +
69 + test('ignores short assistant responses', () async {
70 + final fixture = await createFixture();
71 +
72 + expect(
73 + await fixture.service.maybeRequestReviewAfterSuccessfulChat(
74 + assistantContent: 'Too short.',
75 + serverType: ServerType.ollama,
76 + ),
77 + false,
78 + );
79 +
80 + expect(fixture.client.requestCount, 0);
81 + expect(
82 + fixture.prefs.getInt(ReviewPromptService.successfulChatCountKey),
83 + null,
84 + );
85 + });
86 +
87 + test('does not request review during cooldown', () async {
88 + final fixture = await createFixture(
89 + initialValues: {
90 + ReviewPromptService.successfulChatCountKey: 2,
91 + ReviewPromptService.lastRequestedVersionKey: '1.2.2+3',
92 + ReviewPromptService.lastRequestMillisKey: now
93 + .subtract(const Duration(days: 1))
94 + .millisecondsSinceEpoch,
95 + },
96 + );
97 +
98 + expect(
99 + await fixture.service.maybeRequestReviewAfterSuccessfulChat(
100 + assistantContent: _content(),
101 + serverType: ServerType.ollama,
102 + ),
103 + false,
104 + );
105 +
106 + expect(fixture.client.requestCount, 0);
107 + });
108 +
109 + test('does not request more than once per app version', () async {
110 + final fixture = await createFixture(
111 + initialValues: {
112 + ReviewPromptService.successfulChatCountKey: 2,
113 + ReviewPromptService.lastRequestedVersionKey: version,
114 + ReviewPromptService.lastRequestMillisKey: now
115 + .subtract(const Duration(days: 60))
116 + .millisecondsSinceEpoch,
117 + },
118 + );
119 +
120 + expect(
121 + await fixture.service.maybeRequestReviewAfterSuccessfulChat(
122 + assistantContent: _content(),
123 + serverType: ServerType.ollama,
124 + ),
125 + false,
126 + );
127 +
128 + expect(fixture.client.requestCount, 0);
129 + });
130 +
131 + test(
132 + 'requests after a loaded on-device model produces a response',
133 + () async {
134 + final fixture = await createFixture();
135 +
136 + await fixture.service.markOnDeviceModelLoaded('gemma-3n');
137 +
138 + expect(
139 + await fixture.service.maybeRequestReviewAfterSuccessfulChat(
140 + assistantContent: _content(),
141 + serverType: ServerType.onDevice,
142 + modelId: 'other-model',
143 + ),
144 + false,
145 + );
146 +
147 + expect(
148 + await fixture.service.maybeRequestReviewAfterSuccessfulChat(
149 + assistantContent: _content(),
150 + serverType: ServerType.onDevice,
151 + modelId: 'gemma-3n',
152 + ),
153 + true,
154 + );
155 +
156 + expect(fixture.client.requestCount, 1);
157 + expect(
158 + fixture.prefs.getString(
159 + ReviewPromptService.pendingLoadedOnDeviceModelIdKey,
160 + ),
161 + null,
162 + );
163 + },
164 + );
165 +
166 + test(
167 + 'download completion waits until the model is loaded and used',
168 + () async {
169 + final fixture = await createFixture();
170 +
171 + await fixture.service.markModelDownloadCompleted('qwen-3');
172 + expect(
173 + await fixture.service.maybeRequestReviewAfterSuccessfulChat(
174 + assistantContent: _content(),
175 + serverType: ServerType.onDevice,
176 + modelId: 'qwen-3',
177 + ),
178 + false,
179 + );
180 +
181 + await fixture.service.markOnDeviceModelLoaded('qwen-3');
182 + expect(
183 + await fixture.service.maybeRequestReviewAfterSuccessfulChat(
184 + assistantContent: _content(),
185 + serverType: ServerType.onDevice,
186 + modelId: 'qwen-3',
187 + ),
188 + true,
189 + );
190 +
191 + expect(fixture.client.requestCount, 1);
192 + expect(
193 + fixture.prefs.getString(
194 + ReviewPromptService.pendingDownloadedAndLoadedModelIdKey,
195 + ),
196 + null,
197 + );
198 + expect(
199 + fixture.prefs.getStringList(
200 + ReviewPromptService.downloadedModelIdsKey,
201 + ),
202 + isEmpty,
203 + );
204 + },
205 + );
206 +
207 + test('requests after a custom persona is used successfully', () async {
208 + final fixture = await createFixture();
209 +
210 + expect(
211 + await fixture.service.maybeRequestReviewAfterSuccessfulChat(
212 + assistantContent: _content(),
213 + serverType: ServerType.ollama,
214 + usedCustomPersona: true,
215 + ),
216 + true,
217 + );
218 +
219 + expect(fixture.client.requestCount, 1);
220 + });
221 + });
222 +}
223 +
224 +String _content() => List.filled(100, 'a').join();
225 +
226 +class _Fixture {
227 + const _Fixture({
228 + required this.service,
229 + required this.client,
230 + required this.prefs,
231 + });
232 +
233 + final ReviewPromptService service;
234 + final _FakeReviewPromptClient client;
235 + final SharedPreferences prefs;
236 +}
237 +
238 +class _FakeReviewPromptClient implements ReviewPromptClient {
239 + _FakeReviewPromptClient({required this.available});
240 +
241 + final bool available;
242 + int requestCount = 0;
243 +
244 + @override
245 + Future<bool> isAvailable() async => available;
246 +
247 + @override
248 + Future<void> requestReview() async {
249 + requestCount++;
250 + }
251 +}