Skip to content

Commit f22daa5

Browse files
Commentary (#156)
* refactor: move PostId to a new file This prevents in import cycle. * feat: begin wiring up comments * refactor: set `postServiceProvider` by parent This lets us get comments. * feat: support rendering comments * feat: write comments * fix: image provider logging * fix: post view path param * feat: remove card from post view page * feat: switch to fab * fix: fetch posts 1-by-1 sometimes * fix: autoleadingbutton only works when scope is poppable * wip: pretty comments * feat: awesome comment stuff Co-authored-by: Matthew Wasser <[email protected]> * feat: more comment stuff Co-authored-by: Matthew Wasser <[email protected]> * feat: fix a ton of bugs Co-authored-by: Eli <[email protected]> * ci: correct generated restoration keys Co-authored-by: Matthew Wasser <[email protected]> * fix: comment duplication --------- Co-authored-by: Matthew Wasser <[email protected]>
1 parent 5aae8e2 commit f22daa5

File tree

18 files changed

+703
-165
lines changed

18 files changed

+703
-165
lines changed

.github/workflows/ci.yaml

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ jobs:
6262
packages/*/lib/src/app/*.gm.dart
6363
packages/*/lib/src/l10n/app_localizations.dart
6464
packages/*/lib/src/l10n/app_localizations_*.dart
65-
key: ${{ runner.os }}-${{ steps.flutter.outputs.CHANNEL }}-dart-${{ hashFiles('**/build.yaml') }}
65+
key: generated-${{ steps.flutter.outputs.CHANNEL }}-${{ runner.os }}-${{ hashFiles('**/build.yaml') }}-${{ hashFiles('packages/*/lib/**.dart') }}
66+
restore-keys: generated-${{ steps.flutter.outputs.CHANNEL }}-${{ runner.os }}-
6667
- name: 📦 Install dependencies
6768
uses: bluefireteam/melos-action@c7dcb921b23cc520cace360b95d02b37bf09cdaa # v3
6869
with:
@@ -143,7 +144,8 @@ jobs:
143144
packages/*/lib/src/app/*.gm.dart
144145
packages/*/lib/src/l10n/app_localizations.dart
145146
packages/*/lib/src/l10n/app_localizations_*.dart
146-
key: ${{ runner.os }}-${{ steps.flutter.outputs.CHANNEL }}-dart-${{ hashFiles('**/build.yaml') }}
147+
key: generated-${{ steps.flutter.outputs.CHANNEL }}-${{ runner.os }}-${{ hashFiles('**/build.yaml') }}-${{ hashFiles('packages/*/lib/**.dart') }}
148+
restore-keys: generated-${{ steps.flutter.outputs.CHANNEL }}-${{ runner.os }}-
147149
- name: 📦 Install dependencies
148150
run: dart pub get --enforce-lockfile
149151
- name: 🔧 Build
@@ -195,7 +197,8 @@ jobs:
195197
packages/*/lib/src/app/*.gm.dart
196198
packages/*/lib/src/l10n/app_localizations.dart
197199
packages/*/lib/src/l10n/app_localizations_*.dart
198-
key: ${{ runner.os }}-${{ steps.flutter.outputs.CHANNEL }}-dart-${{ hashFiles('**/build.yaml') }}
200+
key: generated-${{ steps.flutter.outputs.CHANNEL }}-${{ runner.os }}-${{ hashFiles('**/build.yaml') }}-${{ hashFiles('packages/*/lib/**.dart') }}
201+
restore-keys: generated-${{ steps.flutter.outputs.CHANNEL }}-${{ runner.os }}-
199202
- name: 🌋 Install Melos
200203
uses: bluefireteam/melos-action@c7dcb921b23cc520cace360b95d02b37bf09cdaa # v3
201204
with:
@@ -244,10 +247,8 @@ jobs:
244247
packages/*/lib/src/app/*.gm.dart
245248
packages/*/lib/src/l10n/app_localizations.dart
246249
packages/*/lib/src/l10n/app_localizations_*.dart
247-
key: ${{ runner.os }}-${{ steps.flutter.outputs.CHANNEL }}-dart-${{ hashFiles('**/build.yaml') }}
248-
restore-keys: |
249-
${{ runner.os }}-${{ steps.flutter.outputs.CHANNEL }}-dart-
250-
${{ runner.os }}-
250+
key: generated-${{ steps.flutter.outputs.CHANNEL }}-${{ runner.os }}-${{ hashFiles('**/build.yaml') }}-${{ hashFiles('packages/*/lib/**.dart') }}
251+
restore-keys: generated-${{ steps.flutter.outputs.CHANNEL }}-${{ runner.os }}-
251252
- name: 🌋 Install Melos
252253
uses: bluefireteam/melos-action@c7dcb921b23cc520cace360b95d02b37bf09cdaa # v3
253254
with:

packages/app/lib/src/app/bootstrap.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ library;
66

77
import 'dart:developer';
88

9+
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
910
import 'package:flutter/foundation.dart';
1011
import 'package:flutter/services.dart';
1112
import 'package:flutter/widgets.dart';
@@ -127,6 +128,7 @@ String _normalizedValue(Object? value) {
127128
Uint8List _ => 'Uint8List(${value.length})',
128129
AsyncData<Uint8List>(:final value) =>
129130
'AsyncData<Uint8List>(value: ${_normalizedValue(value)})',
131+
final IList<Uint8List> list => '[${list.map(_normalizedValue).join(', ')}]',
130132
_ => value.toString(),
131133
};
132134
}

packages/app/lib/src/app/create_post.dart

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@ import 'package:image_picker/image_picker.dart';
1111
import 'package:path_provider/path_provider.dart';
1212

1313
import '../features/auth/application/auth_service.dart';
14+
import '../features/home/application/feed_service.dart';
1415
import '../features/home/application/location_service.dart';
1516
import '../features/home/application/uploaded_image_service.dart';
1617
import '../features/home/data/post_repository.dart';
18+
import '../features/home/domain/feed_entity.dart';
1719
import '../features/home/domain/post_entity.dart';
20+
import '../features/home/domain/post_id.dart';
1821
import '../features/home/domain/uploaded_image_entity.dart';
1922
import '../utils/hooks.dart';
2023
import '../utils/responsive.dart';
@@ -33,11 +36,11 @@ class CreatePost extends HookConsumerWidget {
3336
final formKey = useGlobalKey<FormState>();
3437
final title = useState('');
3538
final description = useState('');
36-
final userId = ref.watch(idProvider);
37-
final userName = ref.watch(userNameProvider);
3839

3940
final handleSubmit = useCallback(() async {
40-
final uploadedImages = ref.watch(uploadedImagesServiceProvider);
41+
final userId = ref.read(idProvider);
42+
final userName = ref.read(userNameProvider);
43+
final uploadedImages = ref.read(uploadedImagesServiceProvider);
4144
final location = await ref.read(locationServiceProvider.future);
4245
var lat = location.latitude.roundToDouble();
4346
var lng = location.longitude.roundToDouble();
@@ -51,7 +54,7 @@ class CreatePost extends HookConsumerWidget {
5154

5255
formKey.currentState?.save();
5356

54-
// Create a list off all uploaded images ids
57+
// Create a list of all uploaded images ids
5558

5659
await ref
5760
.read(postRepositoryProvider)
@@ -69,10 +72,16 @@ class CreatePost extends HookConsumerWidget {
6972
imageIds:
7073
// Read in the list of uploaded images ids.
7174
uploadedImages.map((image) => image.imageId).toIList(),
75+
comments: const IList.empty(),
7276
),
7377
uploadedImages,
7478
);
7579

80+
// Clear the uploaded images list.
81+
ref
82+
..invalidate(feedServiceProvider(FeedEntity.local(lat, lng)))
83+
..invalidate(feedServiceProvider(const FeedEntity.world()));
84+
7685
if (!context.mounted) return;
7786
await context.router.maybePop();
7887

packages/app/lib/src/app/router.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ class AppRouter extends RootStackRouter {
6868
),
6969
AutoRoute(
7070
page: PostViewRoute.page,
71-
path: '/post',
71+
path: '/post/:id',
7272
title: (context, data) => 'Post',
7373
),
7474
AutoRoute(

packages/app/lib/src/features/home/application/feed_service.dart

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import '../data/post_repository.dart';
1313
import '../domain/feed_entity.dart';
1414
import '../domain/feed_model.dart';
1515
import '../domain/post_entity.dart';
16+
import '../domain/post_id.dart';
1617

1718
part 'feed_service.g.dart';
1819

@@ -42,8 +43,12 @@ base class FeedService extends _$FeedService {
4243
// Store the post in the provider
4344
ref.watch(singlePostProvider(post.id).notifier).setPost(post);
4445

45-
// Collect the post ID
46-
newPostIds.add(post.id);
46+
// Collect the post ID if it's not already in the state
47+
if (!state.ids.contains(post.id)) {
48+
newPostIds.add(post.id);
49+
} else {
50+
throw Exception('Post ${post.id} already exists in the feed state.');
51+
}
4752
}
4853

4954
// Update the state with the new batch of post IDs and cursor
@@ -68,16 +73,20 @@ FutureOr<PostId?> feedPost(Ref ref, FeedEntity feed, int postIndex) async {
6873
ref.watch(feedPostProvider(feed, postIndex - 1));
6974
}
7075

71-
var next = ref
72-
.read(feedServiceProvider(feed).select((s) => s.ids))
73-
.elementAtOrNull(postIndex);
74-
var moreToGet = true;
76+
var next = ref.watch(
77+
feedServiceProvider(feed).select((s) => s.ids.elementAtOrNull(postIndex)),
78+
);
79+
80+
if (next == null) {
81+
await ref.watch(feedServiceProvider(feed).notifier).fetchMore();
7582

76-
while (moreToGet && next == null) {
77-
moreToGet = await ref.watch(feedServiceProvider(feed).notifier).fetchMore();
7883
next = ref
79-
.read(feedServiceProvider(feed).select((s) => s.ids))
84+
.watch(feedServiceProvider(feed).select((s) => s.ids))
8085
.elementAtOrNull(postIndex);
86+
87+
if (next == null) {
88+
return null;
89+
}
8190
}
8291

8392
return next;
@@ -139,6 +148,24 @@ base class SinglePost extends _$SinglePost {
139148
}
140149
}
141150

151+
/// Fetch a single post from the database.
152+
@Riverpod(keepAlive: true)
153+
Future<PostEntity?> getPost(Ref ref, PostId postId) async {
154+
var post = ref.watch(singlePostProvider(postId));
155+
156+
if (post == null) {
157+
final postRepo = ref.read(postRepositoryProvider);
158+
159+
post = await postRepo.readPost(postId);
160+
161+
if (post == null) return null;
162+
163+
ref.read(singlePostProvider(postId).notifier).setPost(post);
164+
}
165+
166+
return post;
167+
}
168+
142169
/// Image provider for posts
143170
@Riverpod(keepAlive: true)
144171
Future<Uint8List> image(Ref ref, String id) async {

packages/app/lib/src/features/home/application/post_service.dart

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
77
import 'package:riverpod_annotation/riverpod_annotation.dart';
88

99
import '../../auth/domain/user.dart';
10-
import '../domain/post_entity.dart';
10+
import '../domain/comment_entity.dart';
11+
import '../domain/post_id.dart';
1112
import '../domain/post_model_entity.dart';
1213
import 'avatar_service.dart';
1314
import 'feed_service.dart';
@@ -20,19 +21,42 @@ part 'post_service.g.dart';
2021
/// This lets us emulate a "suspense"-like UI, where the UI doesn’t show until all data is loaded.
2122
@Riverpod(keepAlive: true)
2223
Future<PostModelEntity?> postService(Ref ref, PostId postId) async {
23-
final post = ref.watch(singlePostProvider(postId));
24+
final post = await ref.watch(getPostProvider(postId).future);
2425

2526
if (post == null) return null;
2627

27-
final (avatar, images) =
28+
final (avatar, images, commentsAvatars) =
2829
await (
2930
ref.watch(avatarServiceProvider(post.authorName).future),
3031
Future.wait(
3132
// TODO(MattsAttack): Could we grab all images with a single call?
3233
post.imageIds.map((image) => ref.watch(imageProvider(image).future)),
3334
),
35+
Future.wait(
36+
post.comments.map(
37+
(comment) =>
38+
ref.watch(avatarServiceProvider(comment.authorName).future),
39+
),
40+
),
3441
).wait;
3542

43+
if (post.comments.length != commentsAvatars.length) {
44+
throw Exception('The number of comments and comment avatars do not match.');
45+
}
46+
47+
final commentsWithAvatars = post.comments.zip(commentsAvatars);
48+
final comments =
49+
[
50+
for (final (comment, commentAvatar) in commentsWithAvatars)
51+
CommentEntity(
52+
author: comment.author,
53+
comment: comment.comment,
54+
avatar: commentAvatar,
55+
authorName: comment.authorName,
56+
timestamp: comment.timestamp,
57+
),
58+
].lockUnsafe;
59+
3660
return PostModelEntity(
3761
id: post.id,
3862
authorName: post.authorName,
@@ -42,6 +66,7 @@ Future<PostModelEntity?> postService(Ref ref, PostId postId) async {
4266
description: post.description,
4367
images: images.lockUnsafe,
4468
likes: post.likes,
69+
comments: comments,
4570
);
4671
}
4772

@@ -100,3 +125,11 @@ IList<Uint8List> currentPostImages(Ref ref) {
100125
IList<UserId> currentPostLikes(Ref ref) {
101126
return ref.watch(currentPostProvider.select((value) => value.likes));
102127
}
128+
129+
/// Provide the number comments of the [currentPost].
130+
@Riverpod(dependencies: [currentPost])
131+
int currentPostCommentsCount(Ref ref) {
132+
return ref.watch(
133+
currentPostProvider.select((value) => value.comments.length),
134+
);
135+
}

packages/app/lib/src/features/home/data/post_repository.dart

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
/// This library contains post fetchers.
22
library;
33

4+
import 'dart:typed_data';
5+
46
import 'package:appwrite/appwrite.dart';
57
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
6-
import 'package:flutter/foundation.dart' show Uint8List;
78
import 'package:hooks_riverpod/hooks_riverpod.dart';
89
import 'package:riverpod_annotation/riverpod_annotation.dart';
910

@@ -12,6 +13,7 @@ import '../../../utils/api.dart';
1213
import '../../auth/domain/user.dart';
1314
import '../domain/feed_entity.dart';
1415
import '../domain/post_entity.dart';
16+
import '../domain/post_id.dart';
1517
import '../domain/uploaded_image_entity.dart';
1618

1719
part 'post_repository.g.dart';
@@ -24,6 +26,9 @@ abstract interface class PostRepository {
2426
/// Read all the posts.
2527
Future<IList<PostEntity>> readPosts(FeedEntity feed, PostId? cursor);
2628

29+
/// Read a single post.
30+
Future<PostEntity?> readPost(PostId postId);
31+
2732
/// Create a new post.
2833
///
2934
/// Returns the created post.
@@ -40,6 +45,9 @@ abstract interface class PostRepository {
4045

4146
/// Fetch images from Appwrite.
4247
Future<Uint8List> getImage(String id);
48+
49+
/// Post a comment.
50+
Future<void> updatePost(PostId postId, Map<String, Object?> updatedData);
4351
}
4452

4553
final class _AppwritePostRepository implements PostRepository {
@@ -86,6 +94,32 @@ final class _AppwritePostRepository implements PostRepository {
8694
}).toIList();
8795
}
8896

97+
@override
98+
Future<PostEntity?> readPost(PostId postId) async {
99+
try {
100+
final document = await database.getDocument(
101+
databaseId: databaseId,
102+
collectionId: collectionId,
103+
documentId: postId.id,
104+
);
105+
106+
assert(
107+
!document.data.containsKey('id'),
108+
'ID should not have been redundantly stored.',
109+
);
110+
111+
document.data['id'] = document.$id;
112+
113+
return PostEntity.fromJson(document.data);
114+
} on AppwriteException catch (e) {
115+
if (e.code == 404) {
116+
return null;
117+
}
118+
119+
rethrow;
120+
}
121+
}
122+
89123
@override
90124
Future<Uint8List> getImage(String id) async {
91125
return await storage.getFileView(bucketId: 'post-media', fileId: id);
@@ -133,6 +167,16 @@ final class _AppwritePostRepository implements PostRepository {
133167
file: InputFile.fromBytes(bytes: bytes, filename: fileName),
134168
);
135169
}
170+
171+
@override
172+
Future<void> updatePost(PostId id, Map<String, Object?> updatedData) async {
173+
await database.updateDocument(
174+
databaseId: databaseId,
175+
collectionId: collectionId,
176+
documentId: id.id,
177+
data: updatedData,
178+
);
179+
}
136180
}
137181

138182
/// Get a [PostRepository] for a specific author and feed.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import 'package:freezed_annotation/freezed_annotation.dart';
2+
3+
import '../../../utils/json.dart';
4+
import '../../auth/domain/user.dart';
5+
6+
part 'comment_dto_entity.freezed.dart';
7+
part 'comment_dto_entity.g.dart';
8+
9+
/// {@template nexus.features.home.domain.comment_dto_entity}
10+
/// Represent a comment on a post.
11+
/// {@endtemplate}
12+
@immutable
13+
@freezed
14+
sealed class CommentDtoEntity with _$CommentDtoEntity {
15+
/// {@macro nexus.features.home.domain.comment_dto_entity}
16+
///
17+
/// Create a new, immutable instance of [CommentDtoEntity].
18+
const factory CommentDtoEntity({
19+
/// The textual content of the comment.
20+
required String comment,
21+
22+
/// The [UserId] of the author of the comment.
23+
required UserId author,
24+
25+
/// The author of the comment’s display name.
26+
required String authorName,
27+
28+
/// When the comment was created.
29+
@DataTimeJsonConverter() required DateTime timestamp,
30+
}) = _CommentDtoEntity;
31+
32+
/// Deserialize a JSON [Map] into a new, immutable instance of [CommentDtoEntity].
33+
factory CommentDtoEntity.fromJson(Map<String, dynamic> json) =>
34+
_$CommentDtoEntityFromJson(json);
35+
}

0 commit comments

Comments
 (0)