From a8897242e79cf375327b320e230e6e0808fe3611 Mon Sep 17 00:00:00 2001 From: mike doornenbal Date: Wed, 31 Jul 2024 16:40:12 +0200 Subject: [PATCH] fix: post creation, reaction like --- .../src/service/firebase_post_service.dart | 77 +++ .../lib/src/model/timeline_reaction.dart | 8 + .../src/services/timeline_post_service.dart | 10 + .../lib/src/config/timeline_translations.dart | 15 + .../timeline_post_creation_screen.dart | 522 +++++++++--------- .../lib/src/screens/timeline_post_screen.dart | 71 ++- .../screens/timeline_selection_screen.dart | 5 +- .../lib/src/services/local_post_service.dart | 56 ++ .../src/widgets/default_filled_button.dart | 12 +- .../src/widgets/post_creation_textfield.dart | 5 +- .../lib/src/widgets/timeline_post_widget.dart | 4 +- 11 files changed, 505 insertions(+), 280 deletions(-) diff --git a/packages/flutter_timeline_firebase/lib/src/service/firebase_post_service.dart b/packages/flutter_timeline_firebase/lib/src/service/firebase_post_service.dart index 51edf98..270a209 100644 --- a/packages/flutter_timeline_firebase/lib/src/service/firebase_post_service.dart +++ b/packages/flutter_timeline_firebase/lib/src/service/firebase_post_service.dart @@ -405,4 +405,81 @@ class FirebaseTimelinePostService notifyListeners(); return categories; } + + @override + Future likeReaction( + String userId, + TimelinePost post, + String reactionId, + ) async { + // update the post with the new like + var updatedPost = post.copyWith( + reactions: post.reactions?.map( + (r) { + if (r.id == reactionId) { + return r.copyWith( + likedBy: (r.likedBy ?? [])..add(userId), + ); + } + return r; + }, + ).toList(), + ); + posts = posts + .map( + (p) => p.id == post.id ? updatedPost : p, + ) + .toList(); + var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); + await postRef.update({ + 'reactions': post.reactions + ?.map( + (r) => + r.id == reactionId ? r.copyWith(likedBy: r.likedBy ?? []) : r, + ) + .map((e) => e.toJson()) + .toList(), + }); + notifyListeners(); + return updatedPost; + } + + @override + Future unlikeReaction( + String userId, + TimelinePost post, + String reactionId, + ) async { + // update the post with the new like + var updatedPost = post.copyWith( + reactions: post.reactions?.map( + (r) { + if (r.id == reactionId) { + return r.copyWith( + likedBy: r.likedBy?..remove(userId), + ); + } + return r; + }, + ).toList(), + ); + posts = posts + .map( + (p) => p.id == post.id ? updatedPost : p, + ) + .toList(); + var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); + await postRef.update({ + 'reactions': post.reactions + ?.map( + (r) => r.id == reactionId + ? r.copyWith(likedBy: r.likedBy?..remove(userId)) + : r, + ) + .map((e) => e.toJson()) + .toList(), + }); + notifyListeners(); + return updatedPost; + } } diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart index f880362..4fa4c04 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart @@ -16,6 +16,7 @@ class TimelinePostReaction { this.imageUrl, this.creator, this.createdAtString, + this.likedBy, }); factory TimelinePostReaction.fromJson( @@ -31,6 +32,7 @@ class TimelinePostReaction { imageUrl: json['image_url'] as String?, createdAt: DateTime.parse(json['created_at'] as String), createdAtString: json['created_at'] as String, + likedBy: (json['liked_by'] as List?)?.cast() ?? [], ); /// The unique identifier of the reaction. @@ -57,6 +59,8 @@ class TimelinePostReaction { /// Reaction creation date as String with microseconds. final String? createdAtString; + final List? likedBy; + TimelinePostReaction copyWith({ String? id, String? postId, @@ -65,6 +69,7 @@ class TimelinePostReaction { String? reaction, String? imageUrl, DateTime? createdAt, + List? likedBy, }) => TimelinePostReaction( id: id ?? this.id, @@ -74,6 +79,7 @@ class TimelinePostReaction { reaction: reaction ?? this.reaction, imageUrl: imageUrl ?? this.imageUrl, createdAt: createdAt ?? this.createdAt, + likedBy: likedBy ?? this.likedBy, ); Map toJson() => { @@ -82,6 +88,7 @@ class TimelinePostReaction { 'reaction': reaction, 'image_url': imageUrl, 'created_at': createdAt.toIso8601String(), + 'liked_by': likedBy, }, }; @@ -91,6 +98,7 @@ class TimelinePostReaction { 'reaction': reaction, 'image_url': imageUrl, 'created_at': createdAtString, + 'liked_by': likedBy, }, }; } diff --git a/packages/flutter_timeline_interface/lib/src/services/timeline_post_service.dart b/packages/flutter_timeline_interface/lib/src/services/timeline_post_service.dart index 957702a..17f5d38 100644 --- a/packages/flutter_timeline_interface/lib/src/services/timeline_post_service.dart +++ b/packages/flutter_timeline_interface/lib/src/services/timeline_post_service.dart @@ -32,4 +32,14 @@ abstract class TimelinePostService with ChangeNotifier { Future> fetchCategories(); Future addCategory(TimelineCategory category); + Future likeReaction( + String userId, + TimelinePost post, + String reactionId, + ); + Future unlikeReaction( + String userId, + TimelinePost post, + String reactionId, + ); } diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart b/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart index 0bb78f5..41e7a91 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart @@ -54,6 +54,9 @@ class TimelineTranslations { required this.addCategorySubmitButton, required this.addCategoryCancelButtton, required this.addCategoryHintText, + required this.addCategoryErrorText, + required this.titleErrorText, + required this.contentErrorText, }); /// Default translations for the timeline component view @@ -100,6 +103,9 @@ class TimelineTranslations { this.addCategorySubmitButton = 'Add category', this.addCategoryCancelButtton = 'Cancel', this.addCategoryHintText = 'Category name...', + this.addCategoryErrorText = 'Please enter a category name', + this.titleErrorText = 'Please enter a title', + this.contentErrorText = 'Please enter content', }); final String noPosts; @@ -117,6 +123,8 @@ class TimelineTranslations { final String titleHintText; final String contentHintText; + final String titleErrorText; + final String contentErrorText; final String deletePost; final String deleteConfirmationTitle; @@ -147,6 +155,7 @@ class TimelineTranslations { final String addCategorySubmitButton; final String addCategoryCancelButtton; final String addCategoryHintText; + final String addCategoryErrorText; final String yes; final String no; @@ -194,6 +203,9 @@ class TimelineTranslations { String? addCategorySubmitButton, String? addCategoryCancelButtton, String? addCategoryHintText, + String? addCategoryErrorText, + String? titleErrorText, + String? contentErrorText, }) => TimelineTranslations( noPosts: noPosts ?? this.noPosts, @@ -244,5 +256,8 @@ class TimelineTranslations { addCategoryHintText: addCategoryHintText ?? this.addCategoryHintText, createCategoryPopuptitle: createCategoryPopuptitle ?? this.createCategoryPopuptitle, + addCategoryErrorText: addCategoryErrorText ?? this.addCategoryErrorText, + titleErrorText: titleErrorText ?? this.titleErrorText, + contentErrorText: contentErrorText ?? this.contentErrorText, ); } diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart index 36164b4..f2b6992 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart @@ -53,48 +53,23 @@ class _TimelinePostCreationScreenState TextEditingController titleController = TextEditingController(); TextEditingController contentController = TextEditingController(); Uint8List? image; - bool editingDone = false; bool allowComments = false; + bool titleIsValid = false; + bool contentIsValid = false; @override void initState() { + titleController.addListener(_listenForInputs); + contentController.addListener(_listenForInputs); super.initState(); - titleController.addListener(checkIfEditingDone); - contentController.addListener(checkIfEditingDone); } - @override - void dispose() { - titleController.dispose(); - contentController.dispose(); - super.dispose(); + void _listenForInputs() { + titleIsValid = titleController.text.isNotEmpty; + contentIsValid = contentController.text.isNotEmpty; } - void checkIfEditingDone() { - setState(() { - editingDone = - titleController.text.isNotEmpty && contentController.text.isNotEmpty; - if (widget.options.requireImageForPost) { - editingDone = editingDone && image != null; - } - if (widget.options.minTitleLength != null) { - editingDone = editingDone && - titleController.text.length >= widget.options.minTitleLength!; - } - if (widget.options.maxTitleLength != null) { - editingDone = editingDone && - titleController.text.length <= widget.options.maxTitleLength!; - } - if (widget.options.minContentLength != null) { - editingDone = editingDone && - contentController.text.length >= widget.options.minContentLength!; - } - if (widget.options.maxContentLength != null) { - editingDone = editingDone && - contentController.text.length <= widget.options.maxContentLength!; - } - }); - } + var formkey = GlobalKey(); @override Widget build(BuildContext context) { @@ -127,251 +102,274 @@ class _TimelinePostCreationScreenState child: SingleChildScrollView( child: Padding( padding: widget.options.paddings.mainPadding, - child: Column( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.options.translations.title, - style: theme.textTheme.titleMedium, - ), - const SizedBox( - height: 4, - ), - widget.options.textInputBuilder?.call( - titleController, - null, - '', - ) ?? - PostCreationTextfield( - controller: titleController, - hintText: widget.options.translations.titleHintText, - textMaxLength: widget.options.maxTitleLength, - decoration: widget.options.titleInputDecoration, - textCapitalization: TextCapitalization.sentences, - expands: null, - minLines: null, - maxLines: 1, - ), - const SizedBox(height: 16), - Text( - widget.options.translations.content, - style: theme.textTheme.titleMedium, - ), - Text( - widget.options.translations.contentDescription, - style: theme.textTheme.bodySmall, - ), - const SizedBox( - height: 4, - ), - PostCreationTextfield( - controller: contentController, - hintText: widget.options.translations.contentHintText, - textMaxLength: null, - decoration: widget.options.contentInputDecoration, - textCapitalization: TextCapitalization.sentences, - expands: false, - minLines: null, - maxLines: null, - ), - const SizedBox( - height: 16, - ), - Text( - widget.options.translations.uploadImage, - style: theme.textTheme.titleMedium, - ), - Text( - widget.options.translations.uploadImageDescription, - style: theme.textTheme.bodySmall, - ), - const SizedBox( - height: 8, - ), - Stack( - children: [ - GestureDetector( - onTap: () async { - var result = await showModalBottomSheet( - context: context, - builder: (context) => Container( - padding: const EdgeInsets.all(8.0), - color: theme.colorScheme.surface, - child: ImagePicker( - imagePickerConfig: widget.options.imagePickerConfig, - imagePickerTheme: widget.options.imagePickerTheme ?? - ImagePickerTheme( - titleAlignment: TextAlign.center, - title: ' Do you want to upload a file' - ' or take a picture? ', - titleTextSize: - theme.textTheme.titleMedium!.fontSize!, - font: - theme.textTheme.titleMedium!.fontFamily!, - iconSize: 40, - selectImageText: 'UPLOAD FILE', - makePhotoText: 'TAKE PICTURE', - selectImageIcon: const Icon( - size: 40, - Icons.insert_drive_file, + child: Form( + key: formkey, + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.options.translations.title, + style: theme.textTheme.titleMedium, + ), + const SizedBox( + height: 4, + ), + widget.options.textInputBuilder?.call( + titleController, + null, + '', + ) ?? + PostCreationTextfield( + controller: titleController, + hintText: widget.options.translations.titleHintText, + textMaxLength: widget.options.maxTitleLength, + decoration: widget.options.titleInputDecoration, + textCapitalization: TextCapitalization.sentences, + expands: null, + minLines: null, + maxLines: 1, + validator: (value) { + if (value == null || value.isEmpty) { + return widget.options.translations.titleErrorText; + } + if (value.trim().isEmpty) { + return widget.options.translations.titleErrorText; + } + return null; + }, + ), + const SizedBox(height: 16), + Text( + widget.options.translations.content, + style: theme.textTheme.titleMedium, + ), + Text( + widget.options.translations.contentDescription, + style: theme.textTheme.bodySmall, + ), + const SizedBox( + height: 4, + ), + PostCreationTextfield( + controller: contentController, + hintText: widget.options.translations.contentHintText, + textMaxLength: null, + decoration: widget.options.contentInputDecoration, + textCapitalization: TextCapitalization.sentences, + expands: false, + minLines: null, + maxLines: null, + validator: (value) { + if (value == null || value.isEmpty) { + return widget.options.translations.contentErrorText; + } + if (value.trim().isEmpty) { + return widget.options.translations.contentErrorText; + } + return null; + }, + ), + const SizedBox( + height: 16, + ), + Text( + widget.options.translations.uploadImage, + style: theme.textTheme.titleMedium, + ), + Text( + widget.options.translations.uploadImageDescription, + style: theme.textTheme.bodySmall, + ), + const SizedBox( + height: 8, + ), + Stack( + children: [ + GestureDetector( + onTap: () async { + var result = await showModalBottomSheet( + context: context, + builder: (context) => Container( + padding: const EdgeInsets.all(8.0), + color: theme.colorScheme.surface, + child: ImagePicker( + imagePickerConfig: + widget.options.imagePickerConfig, + imagePickerTheme: widget + .options.imagePickerTheme ?? + ImagePickerTheme( + titleAlignment: TextAlign.center, + title: ' Do you want to upload a file' + ' or take a picture? ', + titleTextSize: + theme.textTheme.titleMedium!.fontSize!, + font: theme + .textTheme.titleMedium!.fontFamily!, + iconSize: 40, + selectImageText: 'UPLOAD FILE', + makePhotoText: 'TAKE PICTURE', + selectImageIcon: const Icon( + size: 40, + Icons.insert_drive_file, + ), + ), + customButton: TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: Text( + 'Cancel', + style: theme.textTheme.bodyMedium!.copyWith( + decoration: TextDecoration.underline, ), ), - customButton: TextButton( - onPressed: () { - Navigator.pop(context); - }, - child: Text( - 'Cancel', - style: theme.textTheme.bodyMedium!.copyWith( - decoration: TextDecoration.underline, - ), ), ), ), - ), - ); - if (result != null) { - setState(() { - image = result; - }); - } - checkIfEditingDone(); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(8.0), - child: image != null - ? Image.memory( - image!, - width: double.infinity, - height: 150.0, - fit: BoxFit.cover, - // give it a rounded border - ) - : DottedBorder( - dashPattern: const [4, 4], - radius: const Radius.circular(8.0), - color: theme.textTheme.displayMedium?.color ?? - Colors.white, - child: const SizedBox( + ); + if (result != null) { + setState(() { + image = result; + }); + } + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: image != null + ? Image.memory( + image!, width: double.infinity, height: 150.0, - child: Icon( - Icons.image, - size: 50, + fit: BoxFit.cover, + // give it a rounded border + ) + : DottedBorder( + dashPattern: const [4, 4], + radius: const Radius.circular(8.0), + color: theme.textTheme.displayMedium?.color ?? + Colors.white, + child: const SizedBox( + width: double.infinity, + height: 150.0, + child: Icon( + Icons.image, + size: 50, + ), ), ), - ), + ), ), - ), - // if an image is selected, show a delete button - if (image != null) ...[ - Positioned( - top: 8, - right: 8, - child: GestureDetector( - onTap: () { - setState(() { - image = null; - }); - checkIfEditingDone(); - }, - child: Container( - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), - borderRadius: BorderRadius.circular(8.0), - ), - child: const Icon( - Icons.delete, - color: Colors.white, + // if an image is selected, show a delete button + if (image != null) ...[ + Positioned( + top: 8, + right: 8, + child: GestureDetector( + onTap: () { + setState(() { + image = null; + }); + }, + child: Container( + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + borderRadius: BorderRadius.circular(8.0), + ), + child: const Icon( + Icons.delete, + color: Colors.white, + ), ), ), ), + ], + ], + ), + const SizedBox(height: 16), + Text( + widget.options.translations.commentsTitle, + style: theme.textTheme.titleMedium, + ), + Text( + widget.options.translations.allowCommentsDescription, + style: theme.textTheme.bodySmall, + ), + const SizedBox( + height: 8, + ), + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Checkbox( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: + const VisualDensity(horizontal: -4, vertical: -4), + activeColor: theme.colorScheme.primary, + value: allowComments, + onChanged: (value) { + setState(() { + allowComments = true; + }); + }, + ), + Text( + widget.options.translations.yes, + style: theme.textTheme.bodyMedium, + ), + const SizedBox( + width: 32, + ), + Checkbox( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: + const VisualDensity(horizontal: -4, vertical: -4), + activeColor: theme.colorScheme.primary, + value: !allowComments, + onChanged: (value) { + setState(() { + allowComments = false; + }); + }, + ), + Text( + widget.options.translations.no, + style: theme.textTheme.bodyMedium, ), ], - ], - ), - const SizedBox(height: 16), - Text( - widget.options.translations.commentsTitle, - style: theme.textTheme.titleMedium, - ), - Text( - widget.options.translations.allowCommentsDescription, - style: theme.textTheme.bodySmall, - ), - const SizedBox( - height: 8, - ), - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Checkbox( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: - const VisualDensity(horizontal: -4, vertical: -4), - activeColor: theme.colorScheme.primary, - value: allowComments, - onChanged: (value) { - setState(() { - allowComments = true; - }); - }, - ), - Text( - widget.options.translations.yes, - style: theme.textTheme.bodyMedium, - ), - const SizedBox( - width: 32, - ), - Checkbox( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: - const VisualDensity(horizontal: -4, vertical: -4), - activeColor: theme.colorScheme.primary, - value: !allowComments, - onChanged: (value) { - setState(() { - allowComments = false; - }); - }, - ), - Text( - widget.options.translations.no, - style: theme.textTheme.bodyMedium, - ), - ], - ), - const SizedBox(height: 120), - SafeArea( - bottom: true, - child: Align( - alignment: Alignment.bottomCenter, - child: widget.options.buttonBuilder?.call( - context, - onPostCreated, - widget.options.translations.checkPost, - enabled: editingDone, - ) ?? - Padding( - padding: const EdgeInsets.symmetric(horizontal: 48), - child: DefaultFilledButton( - onPressed: editingDone - ? () async { - await onPostCreated(); - await widget.service.postService - .fetchPosts(null); - } - : null, - buttonText: widget.enablePostOverviewScreen - ? widget.options.translations.checkPost - : widget.options.translations.postCreation, - ), - ), ), - ), - ], + const SizedBox(height: 120), + SafeArea( + bottom: true, + child: Align( + alignment: Alignment.bottomCenter, + child: widget.options.buttonBuilder?.call( + context, + onPostCreated, + widget.options.translations.checkPost, + enabled: formkey.currentState!.validate(), + ) ?? + Padding( + padding: const EdgeInsets.symmetric(horizontal: 48), + child: DefaultFilledButton( + onPressed: titleIsValid && contentIsValid + ? () async { + if (formkey.currentState!.validate()) { + await onPostCreated(); + await widget.service.postService + .fetchPosts(null); + } + } + : null, + buttonText: widget.enablePostOverviewScreen + ? widget.options.translations.checkPost + : widget.options.translations.postCreation, + ), + ), + ), + ), + ], + ), ), ), ), diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart index 18f3e10..225449c 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart @@ -344,13 +344,19 @@ class _TimelinePostScreenState extends State { setState(() {}); } }, - icon: Icon( - isLikedByUser - ? Icons.favorite_rounded - : Icons.favorite_outline_outlined, - color: widget.options.theme.iconColor, - size: widget.options.iconSize, - ), + icon: isLikedByUser + ? widget.options.theme.likedIcon ?? + Icon( + Icons.favorite_rounded, + color: widget.options.theme.iconColor, + size: widget.options.iconSize, + ) + : widget.options.theme.likeIcon ?? + Icon( + Icons.favorite_outline_outlined, + color: widget.options.theme.iconColor, + size: widget.options.iconSize, + ), ), const SizedBox(width: 8), if (post.reactionEnabled) @@ -410,7 +416,7 @@ class _TimelinePostScreenState extends State { ), const SizedBox(height: 16), // ignore: avoid_bool_literals_in_conditional_expressions - if (post.reactionEnabled || widget.isOverviewScreen != null + if (post.reactionEnabled && widget.isOverviewScreen != null ? !widget.isOverviewScreen! : false) ...[ Text( @@ -544,6 +550,55 @@ class _TimelinePostScreenState extends State { ), ), ], + Builder( + builder: (context) { + var isLikedByUser = + reaction.likedBy?.contains(widget.userId) ?? + false; + return IconButton( + padding: const EdgeInsets.only(left: 12), + constraints: const BoxConstraints(), + onPressed: () async { + if (isLikedByUser) { + updatePost( + await widget.service.postService + .unlikeReaction( + widget.userId, + post, + reaction.id, + ), + ); + setState(() {}); + } else { + updatePost( + await widget.service.postService + .likeReaction( + widget.userId, + post, + reaction.id, + ), + ); + setState(() {}); + } + }, + icon: isLikedByUser + ? widget.options.theme.likedIcon ?? + Icon( + Icons.favorite_rounded, + color: + widget.options.theme.iconColor, + size: 14, + ) + : widget.options.theme.likeIcon ?? + Icon( + Icons.favorite_outline_outlined, + color: + widget.options.theme.iconColor, + size: 14, + ), + ); + }, + ), ], ), ), diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_selection_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_selection_screen.dart index b5bf4b3..2ed8b36 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_selection_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_selection_screen.dart @@ -163,6 +163,9 @@ class _TimelineSelectionScreenState extends State { PostCreationTextfield( controller: controller, hintText: widget.options.translations.addCategoryHintText, + validator: (p0) => p0!.isEmpty + ? widget.options.translations.addCategoryErrorText + : null, ), const SizedBox(height: 16), Row( @@ -180,7 +183,7 @@ class _TimelineSelectionScreenState extends State { ), ); setState(() {}); - if (mounted) Navigator.pop(context); + if (context.mounted) Navigator.pop(context); }, buttonText: widget.options.translations.addCategorySubmitButton, diff --git a/packages/flutter_timeline_view/lib/src/services/local_post_service.dart b/packages/flutter_timeline_view/lib/src/services/local_post_service.dart index fff97e8..f1ee4cf 100644 --- a/packages/flutter_timeline_view/lib/src/services/local_post_service.dart +++ b/packages/flutter_timeline_view/lib/src/services/local_post_service.dart @@ -272,4 +272,60 @@ class LocalTimelinePostService return categories; } + + @override + Future likeReaction( + String userId, + TimelinePost post, + String reactionId, + ) async { + var updatedPost = post.copyWith( + reactions: post.reactions?.map( + (r) { + if (r.id == reactionId) { + return r.copyWith( + likedBy: (r.likedBy ?? [])..add(userId), + ); + } + return r; + }, + ).toList(), + ); + posts = posts + .map( + (p) => p.id == post.id ? updatedPost : p, + ) + .toList(); + + notifyListeners(); + return updatedPost; + } + + @override + Future unlikeReaction( + String userId, + TimelinePost post, + String reactionId, + ) async { + var updatedPost = post.copyWith( + reactions: post.reactions?.map( + (r) { + if (r.id == reactionId) { + return r.copyWith( + likedBy: r.likedBy?..remove(userId), + ); + } + return r; + }, + ).toList(), + ); + posts = posts + .map( + (p) => p.id == post.id ? updatedPost : p, + ) + .toList(); + + notifyListeners(); + return updatedPost; + } } diff --git a/packages/flutter_timeline_view/lib/src/widgets/default_filled_button.dart b/packages/flutter_timeline_view/lib/src/widgets/default_filled_button.dart index 7581003..00ac78b 100644 --- a/packages/flutter_timeline_view/lib/src/widgets/default_filled_button.dart +++ b/packages/flutter_timeline_view/lib/src/widgets/default_filled_button.dart @@ -14,11 +14,13 @@ class DefaultFilledButton extends StatelessWidget { Widget build(BuildContext context) { var theme = Theme.of(context); return FilledButton( - style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll( - theme.colorScheme.primary, - ), - ), + style: onPressed != null + ? ButtonStyle( + backgroundColor: WidgetStatePropertyAll( + theme.colorScheme.primary, + ), + ) + : null, onPressed: onPressed, child: Padding( padding: const EdgeInsets.all(8), diff --git a/packages/flutter_timeline_view/lib/src/widgets/post_creation_textfield.dart b/packages/flutter_timeline_view/lib/src/widgets/post_creation_textfield.dart index 96a3263..381359c 100644 --- a/packages/flutter_timeline_view/lib/src/widgets/post_creation_textfield.dart +++ b/packages/flutter_timeline_view/lib/src/widgets/post_creation_textfield.dart @@ -4,6 +4,7 @@ class PostCreationTextfield extends StatelessWidget { const PostCreationTextfield({ required this.controller, required this.hintText, + required this.validator, super.key, this.textMaxLength, this.decoration, @@ -22,10 +23,12 @@ class PostCreationTextfield extends StatelessWidget { final bool? expands; final int? minLines; final int? maxLines; + final String? Function(String?)? validator; @override Widget build(BuildContext context) { var theme = Theme.of(context); - return TextField( + return TextFormField( + validator: validator, style: theme.textTheme.bodySmall, controller: controller, maxLength: textMaxLength, diff --git a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart index f6b4f3e..686da59 100644 --- a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart +++ b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart @@ -315,9 +315,7 @@ class _TimelinePostWidgetState extends State { ] else ...[ Text( '${widget.post.likes} ' - '${widget.post.likes > 1 ? - widget.options.translations.multipleLikesTitle : - widget.options.translations.oneLikeTitle}', + '${widget.post.likes > 1 ? widget.options.translations.multipleLikesTitle : widget.options.translations.oneLikeTitle}', style: widget.options.theme.textStyles.listPostLikeTitleAndAmount ?? theme.textTheme.titleSmall!.copyWith(