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
+}