Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions lib/src/model/puzzle/puzzle_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ Future<PuzzleContext?> nextPuzzle(Ref ref, PuzzleAngle angle) async {
return puzzleService.nextPuzzle(userId: session?.user.id, angle: angle);
}

@riverpod
Future<PuzzleContext?> nextReplayPuzzle(Ref ref, int daysToReplay) async {
final session = ref.watch(authSessionProvider);
final puzzleService = await ref.read(puzzleServiceFactoryProvider)(
queueLength: kPuzzleLocalQueueLength,
);
// useful for for preview puzzle list in puzzle tab (providers in a list can
// be invalidated multiple times when the user scrolls the list)
// ref.cacheFor(const Duration(minutes: 1));

return puzzleService.nextReplayPuzzle(userId: session?.user.id, daysToReplay: daysToReplay);
}

typedef InitialStreak = ({PuzzleStreak streak, Puzzle puzzle});

/// Fetches the active streak from the local storage if available, otherwise fetches it from the server.
Expand Down
49 changes: 49 additions & 0 deletions lib/src/model/puzzle/puzzle_service.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:math' show max;

import 'package:async/async.dart';
import 'package:collection/collection.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
Expand Down Expand Up @@ -60,6 +61,8 @@ class PuzzleContext with _$PuzzleContext {

/// List of solved puzzle results if available.
IList<PuzzleRound>? rounds,

@Default(false) bool replaying,
}) = _PuzzleContext;
}

Expand Down Expand Up @@ -101,6 +104,52 @@ class PuzzleService {
);
}

/// Loads the next puzzle to replay from history
Future<PuzzleContext?> nextReplayPuzzle({
required UserId? userId,
required int daysToReplay,
}) async {
final checkPuzzlesAfter = DateTime.now().subtract(Duration(days: daysToReplay));
PuzzleId? puzzleIdToReplay;
var lastPuzzleCheckedTime = DateTime.now();

while (puzzleIdToReplay == null && lastPuzzleCheckedTime.isAfter(checkPuzzlesAfter)) {
final puzzleHistory = await _ref.withClientCacheFor(
(client) => PuzzleRepository(client).puzzleActivity(20, before: lastPuzzleCheckedTime),
const Duration(hours: 3),
);
if (puzzleHistory.isEmpty) {
break;
}
puzzleIdToReplay =
puzzleHistory
.firstWhereOrNull(
(entry) =>
!entry.win &&
entry.date.isAfter(DateTime.now().subtract(Duration(days: daysToReplay))),
)
?.id;
lastPuzzleCheckedTime = puzzleHistory.last.date;
}

if (puzzleIdToReplay != null) {
return _ref
.withClient((client) => PuzzleRepository(client).fetch(puzzleIdToReplay!))
.then(
(puzzle) => PuzzleContext(
puzzle: puzzle,
angle: const PuzzleTheme(PuzzleThemeKey.mix),
userId: userId,
glicko: null,
rounds: null,
replaying: true,
),
);
} else {
return null;
}
}

/// Update puzzle queue with the solved puzzle, sync with server and returns
/// the next puzzle with the glicko rating if available.
///
Expand Down
153 changes: 103 additions & 50 deletions lib/src/view/puzzle/dashboard_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' show ClientException;
import 'package:lichess_mobile/src/model/auth/auth_session.dart';
import 'package:lichess_mobile/src/model/puzzle/puzzle.dart';
import 'package:lichess_mobile/src/model/puzzle/puzzle_angle.dart';
import 'package:lichess_mobile/src/model/puzzle/puzzle_providers.dart';
import 'package:lichess_mobile/src/model/puzzle/puzzle_theme.dart';
import 'package:lichess_mobile/src/styles/styles.dart';
import 'package:lichess_mobile/src/utils/l10n_context.dart';
import 'package:lichess_mobile/src/utils/navigation.dart';
import 'package:lichess_mobile/src/utils/screen.dart';
import 'package:lichess_mobile/src/utils/string.dart';
import 'package:lichess_mobile/src/view/puzzle/puzzle_screen.dart';
import 'package:lichess_mobile/src/widgets/adaptive_choice_picker.dart';
import 'package:lichess_mobile/src/widgets/buttons.dart';
import 'package:lichess_mobile/src/widgets/list.dart';
Expand Down Expand Up @@ -46,62 +49,16 @@ class _Body extends ConsumerWidget {

@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView(children: [PuzzleDashboardWidget()]);
}
}

class PuzzleDashboardWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final puzzleDashboard = ref.watch(puzzleDashboardProvider(ref.watch(daysProvider).days));
final cardColor = Theme.of(context).platform == TargetPlatform.iOS ? Colors.transparent : null;
final days = ref.watch(daysProvider).days;
final puzzleDashboard = ref.watch(puzzleDashboardProvider(days));

return puzzleDashboard.when(
data: (dashboard) {
if (dashboard == null) {
return const SizedBox.shrink();
}
final chartData = dashboard.themes.take(9).sortedBy((e) => e.theme.name).toList();
return ListSection(
header: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.puzzlePuzzleDashboard),
Text(
context.l10n.puzzlePuzzleDashboardDescription,
style: Styles.subtitle.copyWith(color: textShade(context, Styles.subtitleOpacity)),
),
],
),
// hack to make the divider take full length or row
cupertinoAdditionalDividerMargin: -14,
children: [
StatCardRow([
StatCard(
context.l10n.performance,
value: dashboard.global.performance.toString(),
backgroundColor: cardColor,
elevation: 0,
),
StatCard(
context.l10n
.puzzleNbPlayed(dashboard.global.nb)
.replaceAll(RegExp(r'\d+'), '')
.trim()
.capitalize(),
value: dashboard.global.nb.toString().localizeNumbers(),
backgroundColor: cardColor,
elevation: 0,
),
StatCard(
context.l10n.puzzleSolved.capitalize(),
value: '${((dashboard.global.firstWins / dashboard.global.nb) * 100).round()}%',
backgroundColor: cardColor,
elevation: 0,
),
]),
if (chartData.length >= 3) PuzzleChart(chartData),
],
return ListView(
children: [PuzzleDashboardWidget(dashboard), ReplayButton(dashboard, days)],
);
},
error: (e, s) {
Expand Down Expand Up @@ -163,6 +120,59 @@ class PuzzleDashboardWidget extends ConsumerWidget {
}
}

class PuzzleDashboardWidget extends ConsumerWidget {
const PuzzleDashboardWidget(this.dashboard);
final PuzzleDashboard dashboard;

@override
Widget build(BuildContext context, WidgetRef ref) {
final cardColor = Theme.of(context).platform == TargetPlatform.iOS ? Colors.transparent : null;

final chartData = dashboard.themes.take(9).sortedBy((e) => e.theme.name).toList();
return ListSection(
header: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.puzzlePuzzleDashboard),
Text(
context.l10n.puzzlePuzzleDashboardDescription,
style: Styles.subtitle.copyWith(color: textShade(context, Styles.subtitleOpacity)),
),
],
),
// hack to make the divider take full length or row
cupertinoAdditionalDividerMargin: -14,
children: [
StatCardRow([
StatCard(
context.l10n.performance,
value: dashboard.global.performance.toString(),
backgroundColor: cardColor,
elevation: 0,
),
StatCard(
context.l10n
.puzzleNbPlayed(dashboard.global.nb)
.replaceAll(RegExp(r'\d+'), '')
.trim()
.capitalize(),
value: dashboard.global.nb.toString().localizeNumbers(),
backgroundColor: cardColor,
elevation: 0,
),
StatCard(
context.l10n.puzzleSolved.capitalize(),
value: '${((dashboard.global.firstWins / dashboard.global.nb) * 100).round()}%',
backgroundColor: cardColor,
elevation: 0,
),
]),
if (chartData.length >= 3) PuzzleChart(chartData),
],
);
}
}

class PuzzleChart extends StatelessWidget {
const PuzzleChart(this.puzzleData);

Expand Down Expand Up @@ -206,6 +216,49 @@ class PuzzleChart extends StatelessWidget {
}
}

class ReplayButton extends ConsumerWidget {
const ReplayButton(this.dashboard, this.daysToReplay);

final PuzzleDashboard dashboard;
final int daysToReplay;

@override
Widget build(BuildContext context, WidgetRef ref) {
final puzzlesToReplay =
dashboard.global.nb - dashboard.global.firstWins - dashboard.global.replayWins;
final onPressed =
(puzzlesToReplay <= 0)
? null
: () {
Navigator.of(context, rootNavigator: true).push(
PuzzleScreen.buildRoute(
context,
angle: const PuzzleTheme(PuzzleThemeKey.mix),
puzzleId: null,
daysToReplay: daysToReplay,
),
);
};

return Row(
children: [
const Spacer(),
FatButton(
semanticsLabel: context.l10n.puzzleNbToReplay(puzzlesToReplay),
onPressed: onPressed,
child: Row(
children: [
const Icon(Icons.play_arrow),
Text(context.l10n.puzzleNbToReplay(puzzlesToReplay)),
],
),
),
const Spacer(),
],
);
}
}

class DaysSelector extends ConsumerWidget {
const DaysSelector();

Expand Down
48 changes: 45 additions & 3 deletions lib/src/view/puzzle/puzzle_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,22 @@ class PuzzleScreen extends ConsumerStatefulWidget {
/// Creates a new puzzle screen.
///
/// If [puzzleId] is provided, the screen will load the puzzle with that id. Otherwise, it will load the next puzzle from the queue.
const PuzzleScreen({required this.angle, this.puzzleId, super.key});
const PuzzleScreen({required this.angle, this.puzzleId, this.daysToReplay = 0, super.key});

final PuzzleAngle angle;
final PuzzleId? puzzleId;
final int daysToReplay;

static Route<dynamic> buildRoute(
BuildContext context, {
required PuzzleAngle angle,
PuzzleId? puzzleId,
int daysToReplay = 0,
}) {
return buildScreenRoute(context, screen: PuzzleScreen(angle: angle, puzzleId: puzzleId));
return buildScreenRoute(
context,
screen: PuzzleScreen(angle: angle, puzzleId: puzzleId, daysToReplay: daysToReplay),
);
}

@override
Expand Down Expand Up @@ -100,6 +105,8 @@ class _PuzzleScreenState extends ConsumerState<PuzzleScreen> with RouteAware {
body:
widget.puzzleId != null
? _LoadPuzzleFromId(angle: widget.angle, id: widget.puzzleId!)
: widget.daysToReplay > 0
? _LoadNextReplayPuzzle(daysToReplay: widget.daysToReplay)
: _LoadNextPuzzle(angle: widget.angle),
),
);
Expand Down Expand Up @@ -163,6 +170,40 @@ class _LoadNextPuzzle extends ConsumerWidget {
}
}

class _LoadNextReplayPuzzle extends ConsumerWidget {
const _LoadNextReplayPuzzle({required this.daysToReplay});

final int daysToReplay;

@override
Widget build(BuildContext context, WidgetRef ref) {
final nextPuzzle = ref.watch(nextReplayPuzzleProvider(daysToReplay));

return nextPuzzle.when(
data: (data) {
if (data == null) {
return const Center(
child: BoardTable(
fen: kEmptyFen,
orientation: Side.white,
errorMessage: 'No more puzzles to replay.',
),
);
} else {
return _Body(initialPuzzleContext: data);
}
},
loading: () => const Center(child: CircularProgressIndicator.adaptive()),
error: (e, s) {
debugPrint('SEVERE: [PuzzleScreen] could not load next replay puzzle; $e\n$s');
return Center(
child: BoardTable(fen: kEmptyFen, orientation: Side.white, errorMessage: e.toString()),
);
},
);
}
}

class _LoadPuzzleFromId extends ConsumerWidget {
const _LoadPuzzleFromId({required this.angle, required this.id});

Expand Down Expand Up @@ -400,7 +441,8 @@ class _BottomBarState extends ConsumerState<_BottomBar> {
return PlatformBottomBar(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
if (widget.initialPuzzleContext.userId != null &&
if (!widget.initialPuzzleContext.replaying &&
widget.initialPuzzleContext.userId != null &&
!isDailyPuzzle &&
puzzleState.mode != PuzzleMode.view)
_DifficultySelector(initialPuzzleContext: widget.initialPuzzleContext),
Expand Down