Skip to content

Commit a3faf2d

Browse files
committed
feat: persist ongoing OTB games
Closes #2466
1 parent 23a71c7 commit a3faf2d

File tree

6 files changed

+338
-70
lines changed

6 files changed

+338
-70
lines changed

lib/src/model/game/over_the_board_game.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ part 'over_the_board_game.g.dart';
1919
abstract class OverTheBoardGame with _$OverTheBoardGame, BaseGame, IndexableSteps {
2020
const OverTheBoardGame._();
2121

22+
factory OverTheBoardGame.fromJson(Map<String, dynamic> json) => _$OverTheBoardGameFromJson(json);
23+
2224
@override
2325
Player get white => Player(
2426
onGame: true,

lib/src/model/over_the_board/over_the_board_clock.dart

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,15 @@ class OverTheBoardClock extends Notifier<OverTheBoardClockState> {
4848
);
4949
}
5050

51-
void setupClock(TimeIncrement timeIncrement) {
51+
void setupClock(TimeIncrement timeIncrement, {Duration? whiteTimeLeft, Duration? blackTimeLeft}) {
5252
_stopwatch.stop();
5353
_stopwatch.reset();
5454

55-
state = OverTheBoardClockState.fromTimeIncrement(timeIncrement);
55+
state = OverTheBoardClockState.fromTimeIncrement(
56+
timeIncrement,
57+
whiteTimeLeft: whiteTimeLeft,
58+
blackTimeLeft: blackTimeLeft,
59+
);
5660
}
5761

5862
void restart() {
@@ -111,15 +115,19 @@ sealed class OverTheBoardClockState with _$OverTheBoardClockState {
111115
required Side? flagSide,
112116
}) = _OverTheBoardClockState;
113117

114-
factory OverTheBoardClockState.fromTimeIncrement(TimeIncrement timeIncrement) {
118+
factory OverTheBoardClockState.fromTimeIncrement(
119+
TimeIncrement timeIncrement, {
120+
Duration? whiteTimeLeft,
121+
Duration? blackTimeLeft,
122+
}) {
115123
final initialTime = timeIncrement.isInfinite
116124
? null
117125
: Duration(seconds: max(timeIncrement.time, timeIncrement.increment));
118126

119127
return OverTheBoardClockState(
120128
timeIncrement: timeIncrement,
121-
whiteTimeLeft: initialTime,
122-
blackTimeLeft: initialTime,
129+
whiteTimeLeft: whiteTimeLeft ?? initialTime,
130+
blackTimeLeft: blackTimeLeft ?? initialTime,
123131
activeClock: null,
124132
flagSide: null,
125133
);

lib/src/model/over_the_board/over_the_board_game_controller.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ class OverTheBoardGameController extends Notifier<OverTheBoardGameState> {
3232
state = OverTheBoardGameState.fromVariant(variant, Speed.fromTimeIncrement(timeIncrement));
3333
}
3434

35+
void loadOngoingGame(OverTheBoardGame game) {
36+
state = OverTheBoardGameState(game: game, stepCursor: game.steps.length - 1);
37+
}
38+
3539
void rematch() {
3640
state = OverTheBoardGameState.fromVariant(state.game.meta.variant, state.game.meta.speed);
3741
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import 'dart:convert';
2+
import 'dart:io';
3+
4+
import 'package:flutter/foundation.dart';
5+
import 'package:flutter_riverpod/flutter_riverpod.dart';
6+
import 'package:freezed_annotation/freezed_annotation.dart';
7+
import 'package:lichess_mobile/src/model/common/time_increment.dart';
8+
import 'package:lichess_mobile/src/model/game/over_the_board_game.dart';
9+
import 'package:logging/logging.dart';
10+
import 'package:path_provider/path_provider.dart';
11+
12+
part 'over_the_board_game_storage.freezed.dart';
13+
part 'over_the_board_game_storage.g.dart';
14+
15+
final _logger = Logger('OverTheBoardGameStorage');
16+
17+
@Freezed(fromJson: true, toJson: true)
18+
sealed class SavedOtbGame with _$SavedOtbGame {
19+
const SavedOtbGame._();
20+
21+
factory SavedOtbGame({
22+
required OverTheBoardGame game,
23+
required TimeIncrement timeIncrement,
24+
Duration? whiteTimeLeft,
25+
Duration? blackTimeLeft,
26+
}) = _SavedOtbGame;
27+
28+
factory SavedOtbGame.fromJson(Map<String, dynamic> json) => _$SavedOtbGameFromJson(json);
29+
}
30+
31+
/// A provider for [OverTheBoardGameStorage].
32+
final overTheBoardGameStorageProvider = Provider<OverTheBoardGameStorage>((Ref ref) {
33+
return OverTheBoardGameStorage(ref);
34+
}, name: 'OverTheBoardGameStorageProvider');
35+
36+
const kOtbGameFileName = 'otb_game.json';
37+
38+
class OverTheBoardGameStorage {
39+
const OverTheBoardGameStorage(this.ref);
40+
final Ref ref;
41+
42+
Future<File> _getFile() async {
43+
final dir = await getApplicationCacheDirectory();
44+
return File('${dir.path}/$kOtbGameFileName');
45+
}
46+
47+
/// Loads an ongoing OTB game from storage. Returns null if there's no ongoing game or if an error occurs.
48+
Future<SavedOtbGame?> fetchOngoingGame() async {
49+
try {
50+
final file = await _getFile();
51+
if (!await file.exists()) {
52+
return null;
53+
}
54+
55+
final contents = await file.readAsString();
56+
final json = jsonDecode(contents);
57+
58+
if (json is! Map<String, dynamic>) {
59+
throw const FormatException('[OtbGameStorage] cannot fetch game: expected an object');
60+
}
61+
62+
return SavedOtbGame.fromJson(json);
63+
} catch (e) {
64+
_logger.warning('[OtbGameStorage] failed to fetch game: $e');
65+
return null;
66+
}
67+
}
68+
69+
/// Persist the ongoing OTB game to storage. Use [fetchOngoingGame] to retrieve it later.
70+
Future<void> save(
71+
OverTheBoardGame game, {
72+
required TimeIncrement timeIncrement,
73+
required Duration? whiteTimeLeft,
74+
required Duration? blackTimeLeft,
75+
}) async {
76+
try {
77+
final file = await _getFile();
78+
await file.writeAsString(
79+
jsonEncode(
80+
SavedOtbGame(
81+
game: game,
82+
timeIncrement: timeIncrement,
83+
whiteTimeLeft: whiteTimeLeft,
84+
blackTimeLeft: blackTimeLeft,
85+
).toJson(),
86+
),
87+
);
88+
} catch (e) {
89+
_logger.warning('[OtbGameStorage] failed to save game: $e');
90+
}
91+
}
92+
}

lib/src/view/over_the_board/over_the_board_screen.dart

Lines changed: 94 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
99
import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart';
1010
import 'package:lichess_mobile/src/model/over_the_board/over_the_board_clock.dart';
1111
import 'package:lichess_mobile/src/model/over_the_board/over_the_board_game_controller.dart';
12+
import 'package:lichess_mobile/src/model/over_the_board/over_the_board_game_storage.dart';
1213
import 'package:lichess_mobile/src/model/settings/board_preferences.dart';
1314
import 'package:lichess_mobile/src/model/settings/over_the_board_preferences.dart';
15+
import 'package:lichess_mobile/src/utils/focus_detector.dart';
1416
import 'package:lichess_mobile/src/utils/immersive_mode.dart';
1517
import 'package:lichess_mobile/src/utils/l10n_context.dart';
1618
import 'package:lichess_mobile/src/utils/navigation.dart';
@@ -66,8 +68,23 @@ class _BodyState extends ConsumerState<_Body> {
6668
@override
6769
void initState() {
6870
super.initState();
69-
WidgetsBinding.instance.addPostFrameCallback((_) {
70-
showConfigureGameSheet(context, isDismissible: true);
71+
72+
WidgetsBinding.instance.addPostFrameCallback((_) async {
73+
final ongoingGame = await ref.read(overTheBoardGameStorageProvider).fetchOngoingGame();
74+
if (ongoingGame != null && ongoingGame.game.steps.length > 1 && !ongoingGame.game.finished) {
75+
ref.read(overTheBoardGameControllerProvider.notifier).loadOngoingGame(ongoingGame.game);
76+
77+
ref
78+
.read(overTheBoardClockProvider.notifier)
79+
.setupClock(
80+
ongoingGame.timeIncrement,
81+
whiteTimeLeft: ongoingGame.whiteTimeLeft,
82+
blackTimeLeft: ongoingGame.blackTimeLeft,
83+
);
84+
} else {
85+
if (!mounted) return;
86+
showConfigureGameSheet(context, isDismissible: true);
87+
}
7188
});
7289
}
7390

@@ -131,6 +148,9 @@ class _BodyState extends ConsumerState<_Body> {
131148
}
132149
});
133150

151+
final clockState = ref.read(overTheBoardClockProvider);
152+
final otbGameStorage = ref.read(overTheBoardGameStorageProvider);
153+
134154
return WakelockWidget(
135155
child: PopScope(
136156
canPop: false,
@@ -141,7 +161,7 @@ class _BodyState extends ConsumerState<_Body> {
141161

142162
final navigator = Navigator.of(context);
143163
final game = gameState.game;
144-
if (game.abortable) {
164+
if (game.abortable || game.finished) {
145165
return navigator.pop();
146166
}
147167

@@ -154,7 +174,7 @@ class _BodyState extends ConsumerState<_Body> {
154174
builder: (context) {
155175
return YesNoDialog(
156176
title: Text(context.l10n.mobileAreYouSure),
157-
content: const Text('Your game will be lost.'),
177+
content: const Text('No worries, your game will be saved.'),
158178
onNo: () => Navigator.of(context).pop(false),
159179
onYes: () => Navigator.of(context).pop(true),
160180
);
@@ -166,69 +186,79 @@ class _BodyState extends ConsumerState<_Body> {
166186
ref.read(overTheBoardClockProvider.notifier).resume(gameState.turn);
167187
}
168188
},
169-
child: Column(
170-
children: [
171-
Expanded(
172-
child: SafeArea(
173-
child: GameLayout(
174-
key: _boardKey,
175-
topTable: _Player(
176-
side: orientation.opposite,
177-
upsideDown:
178-
!overTheBoardPrefs.flipPiecesAfterMove || orientation != gameState.turn,
179-
clockKey: const ValueKey('topClock'),
180-
),
181-
bottomTable: _Player(
182-
side: orientation,
183-
upsideDown:
184-
overTheBoardPrefs.flipPiecesAfterMove && orientation != gameState.turn,
185-
clockKey: const ValueKey('bottomClock'),
186-
),
187-
orientation: orientation,
188-
fen: gameState.currentPosition.fen,
189-
lastMove: gameState.lastMove,
190-
interactiveBoardParams: (
191-
variant: gameState.game.meta.variant,
192-
position: gameState.currentPosition,
193-
playerSide: gameState.game.finished
194-
? PlayerSide.none
195-
: gameState.turn == Side.white
196-
? PlayerSide.white
197-
: PlayerSide.black,
198-
onPromotionSelection: ref
199-
.read(overTheBoardGameControllerProvider.notifier)
200-
.onPromotionSelection,
201-
promotionMove: gameState.promotionMove,
202-
onMove: (move, {isDrop}) {
203-
ref
204-
.read(overTheBoardClockProvider.notifier)
205-
.onMove(newSideToMove: gameState.turn.opposite);
206-
ref.read(overTheBoardGameControllerProvider.notifier).makeMove(move);
207-
},
208-
premovable: null,
209-
),
210-
moves: gameState.moves,
211-
currentMoveIndex: gameState.stepCursor,
212-
boardSettingsOverrides: BoardSettingsOverrides(
213-
drawShape: const DrawShapeOptions(enable: false),
214-
pieceOrientationBehavior: overTheBoardPrefs.flipPiecesAfterMove
215-
? PieceOrientationBehavior.sideToPlay
216-
: PieceOrientationBehavior.opponentUpsideDown,
217-
pieceAssets: overTheBoardPrefs.symmetricPieces
218-
? PieceSet.symmetric.assets
219-
: null,
220-
),
221-
userActionsBar: _BottomBar(
222-
onFlipBoard: () {
223-
setState(() {
224-
orientation = orientation.opposite;
225-
});
226-
},
189+
child: FocusDetector(
190+
onFocusLost: () {
191+
otbGameStorage.save(
192+
gameState.game,
193+
timeIncrement: clockState.timeIncrement,
194+
whiteTimeLeft: clockState.whiteTimeLeft,
195+
blackTimeLeft: clockState.blackTimeLeft,
196+
);
197+
},
198+
child: Column(
199+
children: [
200+
Expanded(
201+
child: SafeArea(
202+
child: GameLayout(
203+
key: _boardKey,
204+
topTable: _Player(
205+
side: orientation.opposite,
206+
upsideDown:
207+
!overTheBoardPrefs.flipPiecesAfterMove || orientation != gameState.turn,
208+
clockKey: const ValueKey('topClock'),
209+
),
210+
bottomTable: _Player(
211+
side: orientation,
212+
upsideDown:
213+
overTheBoardPrefs.flipPiecesAfterMove && orientation != gameState.turn,
214+
clockKey: const ValueKey('bottomClock'),
215+
),
216+
orientation: orientation,
217+
fen: gameState.currentPosition.fen,
218+
lastMove: gameState.lastMove,
219+
interactiveBoardParams: (
220+
variant: gameState.game.meta.variant,
221+
position: gameState.currentPosition,
222+
playerSide: gameState.game.finished
223+
? PlayerSide.none
224+
: gameState.turn == Side.white
225+
? PlayerSide.white
226+
: PlayerSide.black,
227+
onPromotionSelection: ref
228+
.read(overTheBoardGameControllerProvider.notifier)
229+
.onPromotionSelection,
230+
promotionMove: gameState.promotionMove,
231+
onMove: (move, {isDrop}) {
232+
ref
233+
.read(overTheBoardClockProvider.notifier)
234+
.onMove(newSideToMove: gameState.turn.opposite);
235+
ref.read(overTheBoardGameControllerProvider.notifier).makeMove(move);
236+
},
237+
premovable: null,
238+
),
239+
moves: gameState.moves,
240+
currentMoveIndex: gameState.stepCursor,
241+
boardSettingsOverrides: BoardSettingsOverrides(
242+
drawShape: const DrawShapeOptions(enable: false),
243+
pieceOrientationBehavior: overTheBoardPrefs.flipPiecesAfterMove
244+
? PieceOrientationBehavior.sideToPlay
245+
: PieceOrientationBehavior.opponentUpsideDown,
246+
pieceAssets: overTheBoardPrefs.symmetricPieces
247+
? PieceSet.symmetric.assets
248+
: null,
249+
),
250+
userActionsBar: _BottomBar(
251+
onFlipBoard: () {
252+
setState(() {
253+
orientation = orientation.opposite;
254+
});
255+
},
256+
),
227257
),
228258
),
229259
),
230-
),
231-
],
260+
],
261+
),
232262
),
233263
),
234264
);

0 commit comments

Comments
 (0)