Skip to content

Commit b196083

Browse files
authored
Blog home carousel (#2336)
1 parent c9837ea commit b196083

10 files changed

Lines changed: 700 additions & 285 deletions

File tree

lib/src/model/account/home_widgets.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ enum HomeEditableWidget {
55
hello(false),
66
perfCards(false),
77
ongoingGames(true),
8+
blogCarousel(false),
89
quickPairing(false),
910
featuredTournaments(false),
1011
recentGames(false);
@@ -16,6 +17,7 @@ enum HomeEditableWidget {
1617
HomeEditableWidget.quickPairing => l10n.quickPairing,
1718
HomeEditableWidget.featuredTournaments => l10n.openTournaments,
1819
HomeEditableWidget.recentGames => l10n.recentGames,
20+
HomeEditableWidget.blogCarousel => l10n.blog,
1921
};
2022

2123
const HomeEditableWidget(this.alwaysEnabled);

lib/src/model/blog/blog.dart

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import 'package:deep_pick/deep_pick.dart';
2+
import 'package:freezed_annotation/freezed_annotation.dart';
3+
import 'package:lichess_mobile/src/model/common/id.dart';
4+
import 'package:lichess_mobile/src/model/user/user.dart';
5+
import 'package:lichess_mobile/src/utils/json.dart';
6+
7+
part 'blog.freezed.dart';
8+
9+
@freezed
10+
sealed class BlogPost with _$BlogPost {
11+
const factory BlogPost({
12+
required StringId id,
13+
required String title,
14+
required String slug,
15+
required DateTime createdAt,
16+
required Uri url,
17+
LightUser? author,
18+
Uri? imageUrl,
19+
}) = _BlogPost;
20+
21+
factory BlogPost.fromServerJson(Map<String, dynamic> json) {
22+
return _blogPostFromPick(pick(json).required());
23+
}
24+
}
25+
26+
BlogPost _blogPostFromPick(RequiredPick pick) {
27+
return BlogPost(
28+
id: pick('id').asStringIdOrThrow(),
29+
title: pick('title').asStringOrThrow(),
30+
slug: pick('slug').asStringOrThrow(),
31+
createdAt: pick('createdAt').asDateTimeFromMillisecondsOrThrow(),
32+
url: pick('url').letOrThrow((p) => Uri.parse(p.asStringOrThrow())),
33+
imageUrl: pick('image').letOrNull((p) => Uri.tryParse(p.asStringOrThrow())),
34+
author: pick('author').asLightUserOrNull(),
35+
);
36+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
2+
import 'package:flutter_riverpod/flutter_riverpod.dart';
3+
import 'package:lichess_mobile/src/model/blog/blog.dart';
4+
import 'package:lichess_mobile/src/network/aggregator.dart';
5+
import 'package:lichess_mobile/src/network/http.dart';
6+
7+
final blogCarouselProvider = FutureProvider.autoDispose<IList<BlogPost>>((ref) {
8+
return ref.read(blogRepositoryProvider).getCarousel();
9+
}, name: 'BlogCarouselProvider');
10+
11+
final blogRepositoryProvider = Provider<BlogRepository>((ref) {
12+
final client = ref.read(lichessClientProvider);
13+
final aggregator = ref.read(aggregatorProvider);
14+
return BlogRepository(client, aggregator);
15+
}, name: 'BlogRepositoryProvider');
16+
17+
class BlogRepository {
18+
BlogRepository(this.client, this.aggregator);
19+
20+
final LichessClient client;
21+
final Aggregator aggregator;
22+
23+
Future<IList<BlogPost>> getCarousel() {
24+
return client.readJsonList(Uri(path: '/api/blog/carousel'), mapper: BlogPost.fromServerJson);
25+
}
26+
}

lib/src/model/broadcast/broadcast_providers.dart

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
33
import 'package:lichess_mobile/src/model/broadcast/broadcast.dart';
44
import 'package:lichess_mobile/src/model/broadcast/broadcast_repository.dart';
55
import 'package:lichess_mobile/src/model/common/id.dart';
6-
import 'package:lichess_mobile/src/utils/image.dart';
76
import 'package:riverpod_annotation/riverpod_annotation.dart';
87

98
part 'broadcast_providers.g.dart';
@@ -95,16 +94,3 @@ Future<BroadcastPlayerWithGameResults> broadcastPlayer(
9594
Future<IList<BroadcastTeamMatch>> broadcastTeamMatches(Ref ref, BroadcastRoundId roundId) {
9695
return ref.read(broadcastRepositoryProvider).getTeamMatches(roundId);
9796
}
98-
99-
@Riverpod(keepAlive: true)
100-
BroadcastImageWorkerFactory broadcastImageWorkerFactory(Ref ref) {
101-
return const BroadcastImageWorkerFactory();
102-
}
103-
104-
class BroadcastImageWorkerFactory {
105-
const BroadcastImageWorkerFactory();
106-
107-
Future<ImageColorWorker> spawn() {
108-
return ImageColorWorker.spawn();
109-
}
110-
}

lib/src/model/common/id.dart

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,23 @@ extension type const StudyChapterId(String value) implements StringId {
7474
extension type const FideId(int value) implements IntId {}
7575

7676
extension IDPick on Pick {
77+
StringId asStringIdOrThrow() {
78+
final value = required().value;
79+
if (value is String) {
80+
return StringId(value);
81+
}
82+
throw PickException("value $value at $debugParsingExit can't be casted to StringId");
83+
}
84+
85+
StringId? asStringIdOrNull() {
86+
if (value == null) return null;
87+
try {
88+
return asStringIdOrThrow();
89+
} catch (_) {
90+
return null;
91+
}
92+
}
93+
7794
UserId asUserIdOrThrow() {
7895
final value = required().value;
7996
if (value is String) {

lib/src/utils/image.dart

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,25 @@ import 'dart:typed_data';
44
import 'dart:ui' as ui;
55

66
import 'package:flutter/material.dart';
7+
import 'package:flutter_riverpod/flutter_riverpod.dart';
78
import 'package:material_color_utilities/material_color_utilities.dart';
89

910
typedef ImageColors = ({int primaryContainer, int onPrimaryContainer});
1011

12+
/// A provider that supplies an [ImageWorkerFactory] to spawn image color workers.
13+
final imageWorkerFactoryProvider = Provider<ImageWorkerFactory>((ref) {
14+
return const ImageWorkerFactory();
15+
}, name: 'ImageWorkerFactoryProvider');
16+
17+
/// A factory that spawns [ImageColorWorker] instances.
18+
class ImageWorkerFactory {
19+
const ImageWorkerFactory();
20+
21+
Future<ImageColorWorker> spawn() {
22+
return ImageColorWorker.spawn();
23+
}
24+
}
25+
1126
/// A worker that quantizes an image and returns a minimal color scheme associated
1227
/// with the image.
1328
///
@@ -143,7 +158,7 @@ class ImageColorWorker {
143158
// code below taken from: https://github.com/flutter/flutter/blob/74669e4bf1352a5134ad68398a6bf7fac0a6473b/packages/flutter/lib/src/material/color_scheme.dart
144159

145160
Future<QuantizerResult> extractColorsFromImageProvider(ImageProvider imageProvider) async {
146-
final ui.Image scaledImage = await _imageProviderToScaled(imageProvider);
161+
final ui.Image scaledImage = await imageProviderToScaled(imageProvider);
147162
final ByteData? imageBytes = await scaledImage.toByteData();
148163

149164
final QuantizerResult quantizerResult = await QuantizerCelebi().quantize(
@@ -165,8 +180,8 @@ int getArgbFromAbgr(int abgr) {
165180
return (abgr & exceptRMask & exceptBMask) | (b << 16) | r;
166181
}
167182

168-
// Scale image size down to reduce computation time of color extraction.
169-
Future<ui.Image> _imageProviderToScaled(ImageProvider imageProvider) async {
183+
/// Scale image size down to reduce computation time of color extraction.
184+
Future<ui.Image> imageProviderToScaled(ImageProvider imageProvider) async {
170185
const double maxDimension = 112.0;
171186
final ImageStream stream = imageProvider.resolve(
172187
const ImageConfiguration(size: Size(maxDimension, maxDimension)),

lib/src/view/broadcast/broadcast_carousel.dart

Lines changed: 2 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ class _BroadcastCarouselItemState extends State<BroadcastCarouselItem> {
207207
});
208208
} else if (widget.worker.closed == false) {
209209
await precacheImage(provider, context);
210-
final ui.Image scaledImage = await _imageProviderToScaled(provider);
210+
final ui.Image scaledImage = await imageProviderToScaled(provider);
211211
final imageBytes = await scaledImage.toByteData();
212212
final response = await _computeImageColors(widget.worker, provider.url, imageBytes!);
213213
if (response != null) {
@@ -502,65 +502,9 @@ Future<void> preCacheBroadcastImages(
502502
if (imageUrl != null) {
503503
final provider = NetworkImage(imageUrl);
504504
await precacheImage(provider, context);
505-
final ui.Image scaledImage = await _imageProviderToScaled(provider);
505+
final ui.Image scaledImage = await imageProviderToScaled(provider);
506506
final imageBytes = await scaledImage.toByteData();
507507
await _computeImageColors(worker, imageUrl, imageBytes!);
508508
}
509509
}
510510
}
511-
512-
// Scale image size down to reduce computation time of color extraction.
513-
Future<ui.Image> _imageProviderToScaled(ImageProvider imageProvider) async {
514-
const double maxDimension = 112.0;
515-
final ImageStream stream = imageProvider.resolve(
516-
const ImageConfiguration(size: Size(maxDimension, maxDimension)),
517-
);
518-
final Completer<ui.Image> imageCompleter = Completer<ui.Image>();
519-
late ImageStreamListener listener;
520-
late ui.Image scaledImage;
521-
Timer? loadFailureTimeout;
522-
523-
listener = ImageStreamListener(
524-
(ImageInfo info, bool sync) async {
525-
loadFailureTimeout?.cancel();
526-
stream.removeListener(listener);
527-
final ui.Image image = info.image;
528-
final int width = image.width;
529-
final int height = image.height;
530-
double paintWidth = width.toDouble();
531-
double paintHeight = height.toDouble();
532-
assert(width > 0 && height > 0);
533-
534-
final bool rescale = width > maxDimension || height > maxDimension;
535-
if (rescale) {
536-
paintWidth = (width > height) ? maxDimension : (maxDimension / height) * width;
537-
paintHeight = (height > width) ? maxDimension : (maxDimension / width) * height;
538-
}
539-
final ui.PictureRecorder pictureRecorder = ui.PictureRecorder();
540-
final Canvas canvas = Canvas(pictureRecorder);
541-
paintImage(
542-
canvas: canvas,
543-
rect: Rect.fromLTRB(0, 0, paintWidth, paintHeight),
544-
image: image,
545-
filterQuality: FilterQuality.none,
546-
);
547-
548-
final ui.Picture picture = pictureRecorder.endRecording();
549-
scaledImage = await picture.toImage(paintWidth.toInt(), paintHeight.toInt());
550-
imageCompleter.complete(info.image);
551-
},
552-
onError: (Object exception, StackTrace? stackTrace) {
553-
stream.removeListener(listener);
554-
throw Exception('Failed to render image: $exception');
555-
},
556-
);
557-
558-
loadFailureTimeout = Timer(const Duration(seconds: 5), () {
559-
stream.removeListener(listener);
560-
imageCompleter.completeError(TimeoutException('Timeout occurred trying to load image'));
561-
});
562-
563-
stream.addListener(listener);
564-
await imageCompleter.future;
565-
return scaledImage;
566-
}

0 commit comments

Comments
 (0)