main
dart 184 lines 5.47 KB
Raw
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 }