1+ import 'dart:async' show unawaited;
12import 'dart:math' show max;
23
34import 'package:async/async.dart' ;
@@ -8,6 +9,7 @@ import 'package:lichess_mobile/src/model/common/id.dart';
89import 'package:lichess_mobile/src/model/puzzle/puzzle.dart' ;
910import 'package:lichess_mobile/src/model/puzzle/puzzle_angle.dart' ;
1011import 'package:lichess_mobile/src/model/puzzle/puzzle_batch_storage.dart' ;
12+ import 'package:lichess_mobile/src/model/puzzle/puzzle_difficulty.dart' ;
1113import 'package:lichess_mobile/src/model/puzzle/puzzle_preferences.dart' ;
1214import 'package:lichess_mobile/src/model/puzzle/puzzle_repository.dart' ;
1315import 'package:lichess_mobile/src/model/puzzle/puzzle_storage.dart' ;
@@ -82,6 +84,10 @@ class PuzzleService {
8284 final PuzzleStorage puzzleStorage;
8385 final Logger _log = Logger ('PuzzleService' );
8486
87+ /// Guards [_backgroundFill] against overlapping runs issuing duplicate
88+ /// requests.
89+ bool _isFilling = false ;
90+
8591 /// Loads the next puzzle from database and the glicko rating if available.
8692 ///
8793 /// Will sync with server if necessary.
@@ -145,9 +151,10 @@ class PuzzleService {
145151
146152 /// Synchronize offline puzzle queue with server and gets latest data.
147153 ///
148- /// This task will fetch missing puzzles so the queue length is always equal to
149- /// `queueLength` .
150- /// It will call [PuzzleRepository.solveBatch] if necessary.
154+ /// Does a single request so the next puzzle is available without blocking.
155+ /// A large offline queue can't be filled in one call (the server caps each
156+ /// batch at 50), so any remaining deficit is filled by [_backgroundFill]
157+ /// rather than making the user wait.
151158 ///
152159 /// This method should never fail, as if the network is down it will fallback
153160 /// to the local database.
@@ -162,48 +169,114 @@ class PuzzleService {
162169
163170 final deficit = max (0 , queueLength - unsolved.length);
164171
165- if (deficit > 0 || solved.isNotEmpty) {
166- _log.fine ('Will sync puzzles with lichess (deficit: $deficit , solved: ${solved .length })' );
167-
168- final difficulty = _ref.read (puzzlePreferencesProvider).difficulty;
169-
170- // anonymous users can't solve puzzles so we just download the deficit
171- final batchResponse = _ref.withClient (
172- (client) => Result .capture (
173- solved.isNotEmpty && userId != null
174- ? PuzzleRepository (
175- client,
176- ).solveBatch (nb: deficit, solved: solved, angle: angle, difficulty: difficulty)
177- : PuzzleRepository (
178- client,
179- ).selectBatch (nb: deficit, angle: angle, difficulty: difficulty),
180- ),
181- );
182-
183- return batchResponse
184- .fold (
185- (value) => Result .value ((
186- PuzzleBatch (
187- solved: IList (const []),
188- unsolved: IList ([...unsolved, ...value.puzzles]),
189- ),
190- value.glicko,
191- value.rounds,
192- true , // should save the batch
193- )),
194-
195- // we don't need to save the batch if the request failed
196- (_, _) => Result .value ((data, null , null , false )),
197- )
198- .flatMap ((tuple) async {
199- final (newBatch, glicko, rounds, shouldSave) = tuple;
200- if (newBatch != null && shouldSave) {
201- await batchStorage.save (userId: userId, angle: angle, data: newBatch);
202- }
203- return Result .value ((newBatch, glicko, rounds));
204- });
172+ if (deficit <= 0 && solved.isEmpty) {
173+ return Result .value ((data, null , null ));
174+ }
175+
176+ final difficulty = _ref.read (puzzlePreferencesProvider).difficulty;
177+
178+ // One request keeps the next puzzle responsive; the rest fills in the
179+ // background.
180+ final batchResult = await _fetchBatch (
181+ userId: userId,
182+ angle: angle,
183+ difficulty: difficulty,
184+ nb: deficit,
185+ solved: solved,
186+ );
187+
188+ if (batchResult.isError) {
189+ // Offline: fall back to the local database.
190+ return Result .value ((data, null , null ));
205191 }
206192
207- return Result .value ((data, null , null ));
193+ final value = batchResult.asValue! .value;
194+ final newBatch = PuzzleBatch (
195+ solved: IList (const []),
196+ unsolved: IList ([...unsolved, ...value.puzzles]),
197+ );
198+ await batchStorage.save (userId: userId, angle: angle, data: newBatch);
199+
200+ // Fill the rest without blocking so a large queue is cached before the
201+ // user goes offline.
202+ if (value.puzzles.isNotEmpty && newBatch.unsolved.length < queueLength) {
203+ unawaited (_backgroundFill (userId, angle, difficulty));
204+ }
205+
206+ return Result .value ((newBatch, value.glicko, value.rounds));
207+ }
208+
209+ /// Downloads a single batch, submitting [solved] if there is anything to
210+ /// report (and the user is logged in), otherwise just fetching new puzzles.
211+ FutureResult <PuzzleBatchResponse > _fetchBatch ({
212+ required UserId ? userId,
213+ required PuzzleAngle angle,
214+ required PuzzleDifficulty difficulty,
215+ required int nb,
216+ IList <PuzzleSolution > solved = const IListConst ([]),
217+ }) {
218+ _log.fine ('Will sync puzzles with lichess (nb: $nb , solved: ${solved .length })' );
219+ // anonymous users can't solve puzzles so we just download the deficit.
220+ return _ref.withClient (
221+ (client) => Result .capture (
222+ solved.isNotEmpty && userId != null
223+ ? PuzzleRepository (
224+ client,
225+ ).solveBatch (nb: nb, solved: solved, angle: angle, difficulty: difficulty)
226+ : PuzzleRepository (client).selectBatch (nb: nb, angle: angle, difficulty: difficulty),
227+ ),
228+ );
229+ }
230+
231+ /// Tops up the offline queue to `queueLength` in the background.
232+ ///
233+ /// Re-reads storage each pass and merges by puzzle id so a solve made while
234+ /// the fill runs is never clobbered. Guarded by [_isFilling] so overlapping
235+ /// calls can't double-fetch.
236+ Future <void > _backgroundFill (
237+ UserId ? userId,
238+ PuzzleAngle angle,
239+ PuzzleDifficulty difficulty,
240+ ) async {
241+ if (_isFilling) return ;
242+ _isFilling = true ;
243+ try {
244+ while (true ) {
245+ final current = await batchStorage.fetch (userId: userId, angle: angle);
246+ final unsolved = current? .unsolved ?? IList (const []);
247+ final deficit = max (0 , queueLength - unsolved.length);
248+ if (deficit <= 0 ) break ;
249+
250+ final batchResult = await _fetchBatch (
251+ userId: userId,
252+ angle: angle,
253+ difficulty: difficulty,
254+ nb: deficit,
255+ );
256+ if (batchResult.isError) break ;
257+
258+ final value = batchResult.asValue! .value;
259+ // Server has no more puzzles: stop rather than spin forever below
260+ // an unreachable `queueLength`.
261+ if (value.puzzles.isEmpty) break ;
262+
263+ // Skip ids already stored so a re-read after a mid-fill solve can't
264+ // reintroduce a duplicate.
265+ final existingIds = unsolved.map ((p) => p.puzzle.id).toISet ();
266+ final additions = value.puzzles.where ((p) => ! existingIds.contains (p.puzzle.id));
267+ if (additions.isEmpty) break ;
268+
269+ await batchStorage.save (
270+ userId: userId,
271+ angle: angle,
272+ data: PuzzleBatch (
273+ solved: current? .solved ?? IList (const []),
274+ unsolved: IList ([...unsolved, ...additions]),
275+ ),
276+ );
277+ }
278+ } finally {
279+ _isFilling = false ;
280+ }
208281 }
209282}
0 commit comments