diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 63ebfefa..a6993724 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -62,7 +62,8 @@ jobs: packages/*/lib/src/app/*.gm.dart packages/*/lib/src/l10n/app_localizations.dart packages/*/lib/src/l10n/app_localizations_*.dart - key: ${{ runner.os }}-${{ steps.flutter.outputs.CHANNEL }}-dart-${{ hashFiles('**/build.yaml') }} + key: generated-${{ steps.flutter.outputs.CHANNEL }}-${{ runner.os }}-${{ hashFiles('**/build.yaml') }}-${{ hashFiles('packages/*/lib/**.dart') }} + restore-keys: generated-${{ steps.flutter.outputs.CHANNEL }}-${{ runner.os }}- - name: 📦 Install dependencies uses: bluefireteam/melos-action@c7dcb921b23cc520cace360b95d02b37bf09cdaa # v3 with: @@ -143,7 +144,8 @@ jobs: packages/*/lib/src/app/*.gm.dart packages/*/lib/src/l10n/app_localizations.dart packages/*/lib/src/l10n/app_localizations_*.dart - key: ${{ runner.os }}-${{ steps.flutter.outputs.CHANNEL }}-dart-${{ hashFiles('**/build.yaml') }} + key: generated-${{ steps.flutter.outputs.CHANNEL }}-${{ runner.os }}-${{ hashFiles('**/build.yaml') }}-${{ hashFiles('packages/*/lib/**.dart') }} + restore-keys: generated-${{ steps.flutter.outputs.CHANNEL }}-${{ runner.os }}- - name: 📦 Install dependencies run: dart pub get --enforce-lockfile - name: 🔧 Build @@ -195,7 +197,8 @@ jobs: packages/*/lib/src/app/*.gm.dart packages/*/lib/src/l10n/app_localizations.dart packages/*/lib/src/l10n/app_localizations_*.dart - key: ${{ runner.os }}-${{ steps.flutter.outputs.CHANNEL }}-dart-${{ hashFiles('**/build.yaml') }} + key: generated-${{ steps.flutter.outputs.CHANNEL }}-${{ runner.os }}-${{ hashFiles('**/build.yaml') }}-${{ hashFiles('packages/*/lib/**.dart') }} + restore-keys: generated-${{ steps.flutter.outputs.CHANNEL }}-${{ runner.os }}- - name: 🌋 Install Melos uses: bluefireteam/melos-action@c7dcb921b23cc520cace360b95d02b37bf09cdaa # v3 with: @@ -244,10 +247,8 @@ jobs: packages/*/lib/src/app/*.gm.dart packages/*/lib/src/l10n/app_localizations.dart packages/*/lib/src/l10n/app_localizations_*.dart - key: ${{ runner.os }}-${{ steps.flutter.outputs.CHANNEL }}-dart-${{ hashFiles('**/build.yaml') }} - restore-keys: | - ${{ runner.os }}-${{ steps.flutter.outputs.CHANNEL }}-dart- - ${{ runner.os }}- + key: generated-${{ steps.flutter.outputs.CHANNEL }}-${{ runner.os }}-${{ hashFiles('**/build.yaml') }}-${{ hashFiles('packages/*/lib/**.dart') }} + restore-keys: generated-${{ steps.flutter.outputs.CHANNEL }}-${{ runner.os }}- - name: 🌋 Install Melos uses: bluefireteam/melos-action@c7dcb921b23cc520cace360b95d02b37bf09cdaa # v3 with: diff --git a/packages/app/lib/src/app/bootstrap.dart b/packages/app/lib/src/app/bootstrap.dart index 6de45e45..1d930dd8 100644 --- a/packages/app/lib/src/app/bootstrap.dart +++ b/packages/app/lib/src/app/bootstrap.dart @@ -6,6 +6,7 @@ library; import 'dart:developer'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -127,6 +128,7 @@ String _normalizedValue(Object? value) { Uint8List _ => 'Uint8List(${value.length})', AsyncData(:final value) => 'AsyncData(value: ${_normalizedValue(value)})', + final IList list => '[${list.map(_normalizedValue).join(', ')}]', _ => value.toString(), }; } diff --git a/packages/app/lib/src/app/create_post.dart b/packages/app/lib/src/app/create_post.dart index cd9f2483..3b4eb282 100644 --- a/packages/app/lib/src/app/create_post.dart +++ b/packages/app/lib/src/app/create_post.dart @@ -11,10 +11,13 @@ import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; import '../features/auth/application/auth_service.dart'; +import '../features/home/application/feed_service.dart'; import '../features/home/application/location_service.dart'; import '../features/home/application/uploaded_image_service.dart'; import '../features/home/data/post_repository.dart'; +import '../features/home/domain/feed_entity.dart'; import '../features/home/domain/post_entity.dart'; +import '../features/home/domain/post_id.dart'; import '../features/home/domain/uploaded_image_entity.dart'; import '../utils/hooks.dart'; import '../utils/responsive.dart'; @@ -33,11 +36,11 @@ class CreatePost extends HookConsumerWidget { final formKey = useGlobalKey(); final title = useState(''); final description = useState(''); - final userId = ref.watch(idProvider); - final userName = ref.watch(userNameProvider); final handleSubmit = useCallback(() async { - final uploadedImages = ref.watch(uploadedImagesServiceProvider); + final userId = ref.read(idProvider); + final userName = ref.read(userNameProvider); + final uploadedImages = ref.read(uploadedImagesServiceProvider); final location = await ref.read(locationServiceProvider.future); var lat = location.latitude.roundToDouble(); var lng = location.longitude.roundToDouble(); @@ -51,7 +54,7 @@ class CreatePost extends HookConsumerWidget { formKey.currentState?.save(); - // Create a list off all uploaded images ids + // Create a list of all uploaded images ids await ref .read(postRepositoryProvider) @@ -69,10 +72,16 @@ class CreatePost extends HookConsumerWidget { imageIds: // Read in the list of uploaded images ids. uploadedImages.map((image) => image.imageId).toIList(), + comments: const IList.empty(), ), uploadedImages, ); + // Clear the uploaded images list. + ref + ..invalidate(feedServiceProvider(FeedEntity.local(lat, lng))) + ..invalidate(feedServiceProvider(const FeedEntity.world())); + if (!context.mounted) return; await context.router.maybePop(); diff --git a/packages/app/lib/src/app/router.dart b/packages/app/lib/src/app/router.dart index ba269fd5..d586cbbf 100644 --- a/packages/app/lib/src/app/router.dart +++ b/packages/app/lib/src/app/router.dart @@ -68,7 +68,7 @@ class AppRouter extends RootStackRouter { ), AutoRoute( page: PostViewRoute.page, - path: '/post', + path: '/post/:id', title: (context, data) => 'Post', ), AutoRoute( diff --git a/packages/app/lib/src/features/home/application/feed_service.dart b/packages/app/lib/src/features/home/application/feed_service.dart index 0c7670bc..e20af368 100644 --- a/packages/app/lib/src/features/home/application/feed_service.dart +++ b/packages/app/lib/src/features/home/application/feed_service.dart @@ -13,6 +13,7 @@ import '../data/post_repository.dart'; import '../domain/feed_entity.dart'; import '../domain/feed_model.dart'; import '../domain/post_entity.dart'; +import '../domain/post_id.dart'; part 'feed_service.g.dart'; @@ -42,8 +43,12 @@ base class FeedService extends _$FeedService { // Store the post in the provider ref.watch(singlePostProvider(post.id).notifier).setPost(post); - // Collect the post ID - newPostIds.add(post.id); + // Collect the post ID if it's not already in the state + if (!state.ids.contains(post.id)) { + newPostIds.add(post.id); + } else { + throw Exception('Post ${post.id} already exists in the feed state.'); + } } // Update the state with the new batch of post IDs and cursor @@ -68,16 +73,20 @@ FutureOr feedPost(Ref ref, FeedEntity feed, int postIndex) async { ref.watch(feedPostProvider(feed, postIndex - 1)); } - var next = ref - .read(feedServiceProvider(feed).select((s) => s.ids)) - .elementAtOrNull(postIndex); - var moreToGet = true; + var next = ref.watch( + feedServiceProvider(feed).select((s) => s.ids.elementAtOrNull(postIndex)), + ); + + if (next == null) { + await ref.watch(feedServiceProvider(feed).notifier).fetchMore(); - while (moreToGet && next == null) { - moreToGet = await ref.watch(feedServiceProvider(feed).notifier).fetchMore(); next = ref - .read(feedServiceProvider(feed).select((s) => s.ids)) + .watch(feedServiceProvider(feed).select((s) => s.ids)) .elementAtOrNull(postIndex); + + if (next == null) { + return null; + } } return next; @@ -139,6 +148,24 @@ base class SinglePost extends _$SinglePost { } } +/// Fetch a single post from the database. +@Riverpod(keepAlive: true) +Future getPost(Ref ref, PostId postId) async { + var post = ref.watch(singlePostProvider(postId)); + + if (post == null) { + final postRepo = ref.read(postRepositoryProvider); + + post = await postRepo.readPost(postId); + + if (post == null) return null; + + ref.read(singlePostProvider(postId).notifier).setPost(post); + } + + return post; +} + /// Image provider for posts @Riverpod(keepAlive: true) Future image(Ref ref, String id) async { diff --git a/packages/app/lib/src/features/home/application/post_service.dart b/packages/app/lib/src/features/home/application/post_service.dart index d4feafe2..dbef25ef 100644 --- a/packages/app/lib/src/features/home/application/post_service.dart +++ b/packages/app/lib/src/features/home/application/post_service.dart @@ -7,7 +7,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../auth/domain/user.dart'; -import '../domain/post_entity.dart'; +import '../domain/comment_entity.dart'; +import '../domain/post_id.dart'; import '../domain/post_model_entity.dart'; import 'avatar_service.dart'; import 'feed_service.dart'; @@ -20,19 +21,42 @@ part 'post_service.g.dart'; /// This lets us emulate a "suspense"-like UI, where the UI doesn’t show until all data is loaded. @Riverpod(keepAlive: true) Future postService(Ref ref, PostId postId) async { - final post = ref.watch(singlePostProvider(postId)); + final post = await ref.watch(getPostProvider(postId).future); if (post == null) return null; - final (avatar, images) = + final (avatar, images, commentsAvatars) = await ( ref.watch(avatarServiceProvider(post.authorName).future), Future.wait( // TODO(MattsAttack): Could we grab all images with a single call? post.imageIds.map((image) => ref.watch(imageProvider(image).future)), ), + Future.wait( + post.comments.map( + (comment) => + ref.watch(avatarServiceProvider(comment.authorName).future), + ), + ), ).wait; + if (post.comments.length != commentsAvatars.length) { + throw Exception('The number of comments and comment avatars do not match.'); + } + + final commentsWithAvatars = post.comments.zip(commentsAvatars); + final comments = + [ + for (final (comment, commentAvatar) in commentsWithAvatars) + CommentEntity( + author: comment.author, + comment: comment.comment, + avatar: commentAvatar, + authorName: comment.authorName, + timestamp: comment.timestamp, + ), + ].lockUnsafe; + return PostModelEntity( id: post.id, authorName: post.authorName, @@ -42,6 +66,7 @@ Future postService(Ref ref, PostId postId) async { description: post.description, images: images.lockUnsafe, likes: post.likes, + comments: comments, ); } @@ -100,3 +125,11 @@ IList currentPostImages(Ref ref) { IList currentPostLikes(Ref ref) { return ref.watch(currentPostProvider.select((value) => value.likes)); } + +/// Provide the number comments of the [currentPost]. +@Riverpod(dependencies: [currentPost]) +int currentPostCommentsCount(Ref ref) { + return ref.watch( + currentPostProvider.select((value) => value.comments.length), + ); +} diff --git a/packages/app/lib/src/features/home/data/post_repository.dart b/packages/app/lib/src/features/home/data/post_repository.dart index 6ecc8689..f8b69b76 100644 --- a/packages/app/lib/src/features/home/data/post_repository.dart +++ b/packages/app/lib/src/features/home/data/post_repository.dart @@ -1,9 +1,10 @@ /// This library contains post fetchers. library; +import 'dart:typed_data'; + import 'package:appwrite/appwrite.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/foundation.dart' show Uint8List; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -12,6 +13,7 @@ import '../../../utils/api.dart'; import '../../auth/domain/user.dart'; import '../domain/feed_entity.dart'; import '../domain/post_entity.dart'; +import '../domain/post_id.dart'; import '../domain/uploaded_image_entity.dart'; part 'post_repository.g.dart'; @@ -24,6 +26,9 @@ abstract interface class PostRepository { /// Read all the posts. Future> readPosts(FeedEntity feed, PostId? cursor); + /// Read a single post. + Future readPost(PostId postId); + /// Create a new post. /// /// Returns the created post. @@ -40,6 +45,9 @@ abstract interface class PostRepository { /// Fetch images from Appwrite. Future getImage(String id); + + /// Post a comment. + Future updatePost(PostId postId, Map updatedData); } final class _AppwritePostRepository implements PostRepository { @@ -86,6 +94,32 @@ final class _AppwritePostRepository implements PostRepository { }).toIList(); } + @override + Future readPost(PostId postId) async { + try { + final document = await database.getDocument( + databaseId: databaseId, + collectionId: collectionId, + documentId: postId.id, + ); + + assert( + !document.data.containsKey('id'), + 'ID should not have been redundantly stored.', + ); + + document.data['id'] = document.$id; + + return PostEntity.fromJson(document.data); + } on AppwriteException catch (e) { + if (e.code == 404) { + return null; + } + + rethrow; + } + } + @override Future getImage(String id) async { return await storage.getFileView(bucketId: 'post-media', fileId: id); @@ -133,6 +167,16 @@ final class _AppwritePostRepository implements PostRepository { file: InputFile.fromBytes(bytes: bytes, filename: fileName), ); } + + @override + Future updatePost(PostId id, Map updatedData) async { + await database.updateDocument( + databaseId: databaseId, + collectionId: collectionId, + documentId: id.id, + data: updatedData, + ); + } } /// Get a [PostRepository] for a specific author and feed. diff --git a/packages/app/lib/src/features/home/domain/comment_dto_entity.dart b/packages/app/lib/src/features/home/domain/comment_dto_entity.dart new file mode 100644 index 00000000..2e750ad2 --- /dev/null +++ b/packages/app/lib/src/features/home/domain/comment_dto_entity.dart @@ -0,0 +1,35 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../utils/json.dart'; +import '../../auth/domain/user.dart'; + +part 'comment_dto_entity.freezed.dart'; +part 'comment_dto_entity.g.dart'; + +/// {@template nexus.features.home.domain.comment_dto_entity} +/// Represent a comment on a post. +/// {@endtemplate} +@immutable +@freezed +sealed class CommentDtoEntity with _$CommentDtoEntity { + /// {@macro nexus.features.home.domain.comment_dto_entity} + /// + /// Create a new, immutable instance of [CommentDtoEntity]. + const factory CommentDtoEntity({ + /// The textual content of the comment. + required String comment, + + /// The [UserId] of the author of the comment. + required UserId author, + + /// The author of the comment’s display name. + required String authorName, + + /// When the comment was created. + @DataTimeJsonConverter() required DateTime timestamp, + }) = _CommentDtoEntity; + + /// Deserialize a JSON [Map] into a new, immutable instance of [CommentDtoEntity]. + factory CommentDtoEntity.fromJson(Map json) => + _$CommentDtoEntityFromJson(json); +} diff --git a/packages/app/lib/src/features/home/domain/comment_entity.dart b/packages/app/lib/src/features/home/domain/comment_entity.dart new file mode 100644 index 00000000..4ef2456e --- /dev/null +++ b/packages/app/lib/src/features/home/domain/comment_entity.dart @@ -0,0 +1,43 @@ +import 'dart:typed_data'; + +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../auth/domain/user.dart'; + +part 'comment_entity.freezed.dart'; + +/// {@template nexus.features.home.domain.comment_entity} +/// Represent a comment on a post. +/// +/// +/// {@endtemplate} +@immutable +@freezed +sealed class CommentEntity with _$CommentEntity { + /// {@macro nexus.features.home.domain.comment_entity} + /// + /// Create a new, immutable instance of [CommentEntity]. + const factory CommentEntity({ + /// The textual content of the comment. + required String comment, + + /// The author of the comment’s “initials picture”. + required Uint8List avatar, + + /// The author of the comment. + required UserId author, + + /// The author of the comment’s display name. + required String authorName, + + /// When the comment was created. + required DateTime timestamp, + }) = _CommentEntity; + + const CommentEntity._(); + + @override + String toString() { + return 'CommentEntity{comment: $comment, avatar: Uint8List(${avatar.length}), authorName: $authorName, timestamp: $timestamp}'; + } +} diff --git a/packages/app/lib/src/features/home/domain/feed_model.dart b/packages/app/lib/src/features/home/domain/feed_model.dart index aed97e55..0ffba771 100644 --- a/packages/app/lib/src/features/home/domain/feed_model.dart +++ b/packages/app/lib/src/features/home/domain/feed_model.dart @@ -5,7 +5,7 @@ import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; -import 'post_entity.dart'; +import 'post_id.dart'; part 'feed_model.freezed.dart'; diff --git a/packages/app/lib/src/features/home/domain/post_entity.dart b/packages/app/lib/src/features/home/domain/post_entity.dart index efb7f928..83d9ee15 100644 --- a/packages/app/lib/src/features/home/domain/post_entity.dart +++ b/packages/app/lib/src/features/home/domain/post_entity.dart @@ -6,6 +6,8 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import '../../../utils/json.dart'; import '../../auth/domain/user.dart'; +import 'comment_dto_entity.dart'; +import 'post_id.dart'; part 'post_entity.freezed.dart'; part 'post_entity.g.dart'; @@ -49,21 +51,14 @@ sealed class PostEntity with _$PostEntity { /// Contains ID of image in bucket @JsonKey(name: 'imageID') required IList imageIds, + + /// Contains comments. + required IList comments, }) = _PostEntity; factory PostEntity.fromJson(Map json) => _$PostEntityFromJson(json); } -/// Represent the unique id of a post. -@immutable -extension type const PostId(String id) { - /// Convert a JSON [String] to a [PostId]. - factory PostId.fromJson(String json) => PostId(json); - - /// Convert a [PostId] to a JSON [String]. - String toJson() => id; -} - /// A list of users who like a post. typedef Likes = IList; diff --git a/packages/app/lib/src/features/home/domain/post_id.dart b/packages/app/lib/src/features/home/domain/post_id.dart new file mode 100644 index 00000000..9fe7e9f0 --- /dev/null +++ b/packages/app/lib/src/features/home/domain/post_id.dart @@ -0,0 +1,11 @@ +import 'package:meta/meta.dart'; + +/// Represent the unique id of a post. +@immutable +extension type const PostId(String id) { + /// Convert a JSON [String] to a [PostId]. + factory PostId.fromJson(String json) => PostId(json); + + /// Convert a [PostId] to a JSON [String]. + String toJson() => id; +} diff --git a/packages/app/lib/src/features/home/domain/post_model_entity.dart b/packages/app/lib/src/features/home/domain/post_model_entity.dart index a4cea44b..4c702c4c 100644 --- a/packages/app/lib/src/features/home/domain/post_model_entity.dart +++ b/packages/app/lib/src/features/home/domain/post_model_entity.dart @@ -4,7 +4,9 @@ import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import '../../auth/domain/user.dart'; +import 'comment_entity.dart'; import 'post_entity.dart'; +import 'post_id.dart'; part 'post_model_entity.freezed.dart'; @@ -49,12 +51,15 @@ sealed class PostModelEntity with _$PostModelEntity { /// /// This is a list of the [UserId]s of users who liked the post. required IList likes, + + /// Commentary on the current post. + required IList comments, }) = _PostModelEntity; const PostModelEntity._(); @override String toString() { - return 'PostModelEntity(id: $id, authorName: $authorName, timestamp: $timestamp, headline: $headline, description: $description, likes: $likes)'; + return 'PostModelEntity(id: $id, authorName: $authorName, avatar: Uint8List(${avatar.length}), timestamp: $timestamp, headline: $headline, description: $description, images: ${images.map((image) => 'Uint8List(${image.length})')}, likes: $likes, comments: $comments)'; } } diff --git a/packages/app/lib/src/features/home/presentation/home/comment.dart b/packages/app/lib/src/features/home/presentation/home/comment.dart new file mode 100644 index 00000000..898fc493 --- /dev/null +++ b/packages/app/lib/src/features/home/presentation/home/comment.dart @@ -0,0 +1,70 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:timeago_flutter/timeago_flutter.dart'; + +import '../../domain/comment_entity.dart'; + +/// {@template nexus.features.home.presentation.home.comment} +/// View a comment. +/// {@endtemplate} +class Comment extends StatelessWidget { + /// {@macro nexus.features.home.presentation.home.comment} + /// + /// Construct a new [Comment] widget for a [CommentEntity]. + const Comment({required this.comment, super.key}); + + /// The [CommentEntity] to display. + final CommentEntity comment; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar(backgroundImage: MemoryImage(comment.avatar)), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + comment.authorName, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Timeago( + date: comment.timestamp, + builder: + (context, value) => Text( + value, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + comment.comment, + style: Theme.of(context).textTheme.bodyMedium, + softWrap: true, + ), + ], + ), + ), + ], + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('comment', comment)); + } +} diff --git a/packages/app/lib/src/features/home/presentation/home/create_comment.dart b/packages/app/lib/src/features/home/presentation/home/create_comment.dart new file mode 100644 index 00000000..2acdd6a2 --- /dev/null +++ b/packages/app/lib/src/features/home/presentation/home/create_comment.dart @@ -0,0 +1,141 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../../utils/hooks.dart'; +import '../../../../utils/responsive.dart'; +import '../../../auth/application/auth_service.dart'; +import '../../data/post_repository.dart'; +import '../../domain/comment_dto_entity.dart'; +import '../../domain/post_model_entity.dart'; + +/// {@template nexus.app.create_comment} +/// A dialog that allows for a user to create a new comment. +/// {@endtemplate} +class CreateComment extends HookConsumerWidget { + /// {@macro nexus.app.create_comment} + /// + /// Construct a new [CreateComment] widget. + const CreateComment({required this.post, super.key}); + + /// The post to be updating. + final PostModelEntity post; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formKey = useGlobalKey(); + final commentContent = useState(''); + + final handleSubmit = useCallback(() async { + final userId = ref.read(idProvider)!; + final userName = ref.read(userNameProvider)!; + + if (!(formKey.currentState?.validate() ?? false)) return; + + formKey.currentState?.save(); + + // Create a list of all uploaded images ids + + await ref.read(postRepositoryProvider).updatePost(post.id, { + 'comments': [ + CommentDtoEntity( + comment: commentContent.value, + author: userId, + authorName: userName, + timestamp: DateTime.timestamp(), + ).toJson(), + ], + }); + + if (!context.mounted) return; + await context.router.maybePop(); + + if (!context.mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Comment Created!'))); + }, [formKey]); + + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + context.sizeClass == MaterialWindowSizeClass.compact ? 0.0 : 16.0, + ), + ), + insetPadding: EdgeInsets.symmetric( + horizontal: + context.sizeClass == MaterialWindowSizeClass.compact ? 0.0 : 64.0, + vertical: + context.sizeClass == MaterialWindowSizeClass.compact ? 0.0 : 48.0, + ), + child: Padding( + padding: EdgeInsets.all( + context.sizeClass == MaterialWindowSizeClass.compact ? 0.0 : 16.0, + ), + child: Form( + key: formKey, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + if (context.sizeClass == MaterialWindowSizeClass.compact) + Row( + children: [ + IconButton( + onPressed: () async { + await context.router.maybePop(); + }, + icon: const Icon(Icons.close), + ), + Text( + 'Write a comment', + style: Theme.of(context).textTheme.headlineSmall, + ), + ], + ) + else + Text( + 'Write a comment', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + + // TODO(MattsAttack): guard against creating empty comments. + TextFormField( + initialValue: commentContent.value, + maxLength: 200, + maxLengthEnforcement: + MaxLengthEnforcement.truncateAfterCompositionEnds, + onSaved: (value) { + if (value == null) return; + + commentContent.value = value; + }, + decoration: const InputDecoration( + label: Text('Comment content'), + ), + ), + + const Spacer(), + + ElevatedButton( + onPressed: handleSubmit, + child: const Text('Post Comment'), + ), + ], + ), + ), + ), + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('post', post)); + } +} diff --git a/packages/app/lib/src/features/home/presentation/home/feed.dart b/packages/app/lib/src/features/home/presentation/home/feed.dart index 38870757..635bb12c 100644 --- a/packages/app/lib/src/features/home/presentation/home/feed.dart +++ b/packages/app/lib/src/features/home/presentation/home/feed.dart @@ -1,13 +1,17 @@ /// This library contains a widget that displays a feed of posts. library; +import 'package:auto_route/auto_route.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../../../app/router.gr.dart'; import '../../application/feed_service.dart'; +import '../../application/post_service.dart'; import '../../domain/feed_entity.dart'; +import '../../domain/post_model_entity.dart'; import 'post.dart'; /// {@template nexus.features.home.presentation.home.feed} @@ -40,44 +44,82 @@ class Feed extends ConsumerWidget { child: ListView.builder( physics: const AlwaysScrollableScrollPhysics(), itemBuilder: (context, index) { - final response = ref.watch(feedPostProvider(feed, index)); + final id = ref.watch(feedPostProvider(feed, index)); + final lastIsLoading = + index > 0 && + ref.watch(feedPostProvider(feed, index - 1)).isLoading; - return switch (response) { - AsyncData(:final value) when value != null => Post( - postId: value, + final AsyncValue response = switch (id) { + AsyncData(:final value?) => ref.watch( + postServiceProvider(value), ), - - // If we have none, return a placeholder. - AsyncData() when index == 0 => const Expanded( - child: Center(child: Text('No posts yet. Make the first!')), + AsyncData() => const AsyncData(null), + AsyncError(:final error, :final stackTrace) => AsyncError( + error, + stackTrace, ), - // If we run out of items, return null. - AsyncData() => null, + _ => const AsyncLoading(), + }; + return switch (response) { // If there's an error, display it as another post. - AsyncError(:final error, :final stackTrace) => Card( - margin: const EdgeInsets.all(4), - child: Container( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - const Text( - 'Error', - textAlign: TextAlign.left, - style: TextStyle( - fontSize: 24, - ), // TODO(MattsAttack): Need better text styling. - ), - Text('$error\n$stackTrace', textAlign: TextAlign.left), - ], + AsyncValue(:final error, :final stackTrace, hasError: true) => + Card( + margin: const EdgeInsets.all(4), + child: Container( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + const Text( + 'Error', + textAlign: TextAlign.left, + style: TextStyle( + fontSize: 24, + ), // TODO(MattsAttack): Need better text styling. + ), + Text( + '$error\n$stackTrace', + textAlign: TextAlign.left, + ), + ], + ), + ), + ), + + AsyncValue(:final value, hasValue: true) when value != null => + GestureDetector( + onTap: () async { + await context.router.push(PostViewRoute(id: value.id.id)); + }, + child: Card( + margin: const EdgeInsets.all(4), + child: ProviderScope( + overrides: [ + currentPostProvider.overrideWithValue(value), + ], + child: Post(key: ValueKey(value)), + ), ), ), + + // If we have none, return a placeholder. + AsyncValue(hasValue: true) when index == 0 => const Center( + child: Text('No posts yet. Make the first!'), ), + // If we run out of items, return null. + AsyncValue(hasValue: true) => null, // If we're loading, display a loading indicator. + AsyncLoading() when !lastIsLoading => const Center( + child: SizedBox( + width: 400, + height: 400, + child: CircularProgressIndicator(), + ), + ), _ => null, }; }, diff --git a/packages/app/lib/src/features/home/presentation/home/post.dart b/packages/app/lib/src/features/home/presentation/home/post.dart index b0d30cb4..0541d083 100644 --- a/packages/app/lib/src/features/home/presentation/home/post.dart +++ b/packages/app/lib/src/features/home/presentation/home/post.dart @@ -1,82 +1,42 @@ /// This library contains the UI for viewing a post. library; -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:timeago_flutter/timeago_flutter.dart'; -import '../../../../app/router.gr.dart'; import '../../../../utils/toast.dart'; import '../../../auth/application/auth_service.dart'; import '../../application/feed_service.dart'; import '../../application/post_service.dart'; -import '../../domain/post_entity.dart'; /// {@template nexus.features.home.presentation.home.post} /// View a post. /// {@endtemplate} class Post extends StatelessWidget { /// {@macro nexus.features.home.presentation.home.post} - - /// Construct a new [Post] widget for a [PostId]. - const Post({required this.postId, super.key}); - - /// [PostId] for this post. - final PostId postId; + /// + /// Construct a new [Post] widget for a []. + const Post({super.key}); @override Widget build(BuildContext context) { // TODO(MattsAttack): implement hero widget. - return GestureDetector( - onTap: () async { - if (context.router.current.name != PostViewRoute.name) { - // Prevents user from clicking on post in post view. - await context.router.push(PostViewRoute(postId: postId)); - } - }, - key: ValueKey(postId), - child: Card( - margin: const EdgeInsets.all(4), - child: Container( - padding: const EdgeInsets.all(16), - child: Consumer( - builder: (context, ref, child) { - return switch (ref.watch(postServiceProvider(postId))) { - AsyncData(:final value?) => ProviderScope( - overrides: [currentPostProvider.overrideWithValue(value)], - child: const Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // The post sections are in here, like poster info and post content. - _PosterInfo(), - Divider(), - _PostBody(), - _PostInteractables(), - ], - ), - ), - - AsyncError(:final error) => Text('Error loading post: $error'), - _ => const CircularProgressIndicator(), - }; - }, - ), - ), + return Container( + padding: const EdgeInsets.all(16), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // The post sections are in here, like poster info and post content. + _PosterInfo(), + Divider(), + _PostBody(), + _PostInteractables(), + ], ), ); } - - // coverage:ignore-start - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(StringProperty('postId', postId.id)); - } - - // coverage:ignore-end } class _PosterInfo extends ConsumerWidget { @@ -84,20 +44,45 @@ class _PosterInfo extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // TODO(lishaduck): Better support async loading, to remove the need for non-null assertion. final authorName = ref.watch(currentPostAuthorNameProvider); final timestamp = ref.watch(currentPostTimestampProvider); - // TODO(MattsAttack): Show actual date and time of post when you click on it. - - return Row( - spacing: 8, - children: [ - const _PostAvatar(), - Text(authorName), - Timeago(date: timestamp, builder: (context, value) => Text(value)), - // TODO(MattsAttack): Could put flairs here. - ], + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _PostAvatar(), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + authorName, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Timeago( + date: timestamp, + builder: + (context, value) => Text( + value, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + // TODO(MattsAttack): Add additional elements like flairs if needed. + ], + ), + ), + ], + ), ); } } @@ -186,29 +171,43 @@ class _PostInteractables extends HookConsumerWidget { final userId = ref.watch(idProvider); final postId = ref.watch(currentPostIdProvider); final likes = ref.watch(currentPostLikesProvider); + final numComments = ref.watch(currentPostCommentsCountProvider); return Row( + spacing: 4, children: [ - Text('${likes.length}'), - IconButton( - onPressed: () async { - if (userId == null) { - throw Exception('Null user ID detected'); - // TODO(MattsAttack): Send user back to login page, perhaps? - } - - final liked = await ref - .read(singlePostProvider(postId).notifier) - .toggleLike(userId); - - if (!liked || !context.mounted) return; - context.showSnackBar(content: const Text('Failed to like post')); - }, - icon: Icon( - likes.contains(userId) - ? Icons.thumb_up_sharp - : Icons.thumb_up_outlined, - ), + Row( + spacing: 2, + children: [ + Text('${likes.length}'), + IconButton( + onPressed: () async { + if (userId == null) { + throw Exception('Null user ID detected'); + // TODO(MattsAttack): Send user back to login page, perhaps? + } + + final liked = await ref + .read(singlePostProvider(postId).notifier) + .toggleLike(userId); + + if (!liked || !context.mounted) return; + context.showSnackBar( + content: const Text('Failed to like post'), + ); + }, + icon: Icon( + likes.contains(userId) + ? Icons.thumb_up_sharp + : Icons.thumb_up_outlined, + ), + ), + ], + ), + + Row( + spacing: 2, + children: [Text('$numComments'), const Icon(Icons.comment)], ), ], ); diff --git a/packages/app/lib/src/features/home/presentation/home/post_view_page.dart b/packages/app/lib/src/features/home/presentation/home/post_view_page.dart index e372f742..1b6466f2 100644 --- a/packages/app/lib/src/features/home/presentation/home/post_view_page.dart +++ b/packages/app/lib/src/features/home/presentation/home/post_view_page.dart @@ -2,43 +2,105 @@ library; import 'package:auto_route/auto_route.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../domain/post_entity.dart'; +import '../../../../app/router.gr.dart'; +import '../../application/feed_service.dart'; +import '../../application/post_service.dart'; +import '../../domain/comment_entity.dart'; +import '../../domain/post_id.dart'; +import 'comment.dart'; +import 'create_comment.dart'; import 'post.dart'; /// {@template nexus.features.home.presentation.home.post_view_page} /// A page that contains full post information. /// {@endtemplate} @RoutePage(deferredLoading: true) -class PostViewPage extends ConsumerWidget { +class PostViewPage extends StatelessWidget { /// {@macro nexus.features.home.presentation.home.post_view_page} /// /// Construct a new [PostViewPage] widget. - const PostViewPage({required this.postId, super.key}); + const PostViewPage({@PathParam('id') required this.id, super.key}); - /// ID for this post. - final PostId postId; + /// [PostId] for this post. + final String id; + + PostId get _postId => PostId(id); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { + final scope = RouterScope.of(context, watch: true); + return Scaffold( appBar: AppBar( - // TODO(MattsAttack): Could update this to be more interesting. - title: const Text('Post View'), + leading: CloseButton( + onPressed: () async { + if (scope.controller.canPop()) { + await scope.controller.maybePopTop(); + } else { + await context.router.replace(const FeedRoutingRoute()); + } + }, + ), + ), + floatingActionButton: Consumer( + builder: + (context, ref, _) => FloatingActionButton( + onPressed: () async { + final post = ref.read(postServiceProvider(_postId)).valueOrNull; + if (post == null) { + return; + } + + await showDialog( + context: context, + builder: (context) => CreateComment(post: post), + ); + + ref.invalidate(singlePostProvider(_postId)); + }, + child: const Icon(Icons.add_comment), + ), ), body: ListView( children: [ Center( child: Container( constraints: const BoxConstraints(maxWidth: 1000), - child: Column( - children: [ - Post(postId: postId), - // TODO(MattsAttack): Comments will go here. - ], + child: Consumer( + builder: (context, ref, _) { + final response = ref.watch(postServiceProvider(_postId)); + + return switch (response) { + AsyncData(:final value?) => Column( + children: [ + ProviderScope( + overrides: [ + currentPostProvider.overrideWithValue(value), + ], + child: const Post(), + ), + + const Divider(), + + if (value.comments.isNotEmpty) + _Comments(comments: value.comments), + ], + ), + AsyncData() => const Center(child: Text('Post not found')), + AsyncError(:final error, :final stackTrace) => Center( + child: Text( + 'Error: $error\n$stackTrace', + style: const TextStyle(color: Colors.red), + ), + ), + _ => const Center(child: CircularProgressIndicator()), + }; + }, ), ), ), @@ -51,8 +113,27 @@ class PostViewPage extends ConsumerWidget { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(StringProperty('postId', postId.id)); + properties.add(StringProperty('postId', _postId.id)); } // coverage:ignore-end } + +class _Comments extends StatelessWidget { + const _Comments({required this.comments, super.key}); + + final IList comments; + + @override + Widget build(BuildContext context) { + return Column( + children: [for (final comment in comments) Comment(comment: comment)], + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(IterableProperty('comments', comments)); + } +}