fix: post creation, reaction like

This commit is contained in:
mike doornenbal 2024-07-31 16:40:12 +02:00
parent eb953ede0d
commit a8897242e7
11 changed files with 505 additions and 280 deletions

View file

@ -405,4 +405,81 @@ class FirebaseTimelinePostService
notifyListeners(); notifyListeners();
return categories; return categories;
} }
@override
Future<TimelinePost> 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<TimelinePost> 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;
}
} }

View file

@ -16,6 +16,7 @@ class TimelinePostReaction {
this.imageUrl, this.imageUrl,
this.creator, this.creator,
this.createdAtString, this.createdAtString,
this.likedBy,
}); });
factory TimelinePostReaction.fromJson( factory TimelinePostReaction.fromJson(
@ -31,6 +32,7 @@ class TimelinePostReaction {
imageUrl: json['image_url'] as String?, imageUrl: json['image_url'] as String?,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
createdAtString: json['created_at'] as String, createdAtString: json['created_at'] as String,
likedBy: (json['liked_by'] as List<dynamic>?)?.cast<String>() ?? [],
); );
/// The unique identifier of the reaction. /// The unique identifier of the reaction.
@ -57,6 +59,8 @@ class TimelinePostReaction {
/// Reaction creation date as String with microseconds. /// Reaction creation date as String with microseconds.
final String? createdAtString; final String? createdAtString;
final List<String>? likedBy;
TimelinePostReaction copyWith({ TimelinePostReaction copyWith({
String? id, String? id,
String? postId, String? postId,
@ -65,6 +69,7 @@ class TimelinePostReaction {
String? reaction, String? reaction,
String? imageUrl, String? imageUrl,
DateTime? createdAt, DateTime? createdAt,
List<String>? likedBy,
}) => }) =>
TimelinePostReaction( TimelinePostReaction(
id: id ?? this.id, id: id ?? this.id,
@ -74,6 +79,7 @@ class TimelinePostReaction {
reaction: reaction ?? this.reaction, reaction: reaction ?? this.reaction,
imageUrl: imageUrl ?? this.imageUrl, imageUrl: imageUrl ?? this.imageUrl,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
likedBy: likedBy ?? this.likedBy,
); );
Map<String, dynamic> toJson() => <String, dynamic>{ Map<String, dynamic> toJson() => <String, dynamic>{
@ -82,6 +88,7 @@ class TimelinePostReaction {
'reaction': reaction, 'reaction': reaction,
'image_url': imageUrl, 'image_url': imageUrl,
'created_at': createdAt.toIso8601String(), 'created_at': createdAt.toIso8601String(),
'liked_by': likedBy,
}, },
}; };
@ -91,6 +98,7 @@ class TimelinePostReaction {
'reaction': reaction, 'reaction': reaction,
'image_url': imageUrl, 'image_url': imageUrl,
'created_at': createdAtString, 'created_at': createdAtString,
'liked_by': likedBy,
}, },
}; };
} }

View file

@ -32,4 +32,14 @@ abstract class TimelinePostService with ChangeNotifier {
Future<List<TimelineCategory>> fetchCategories(); Future<List<TimelineCategory>> fetchCategories();
Future<bool> addCategory(TimelineCategory category); Future<bool> addCategory(TimelineCategory category);
Future<TimelinePost> likeReaction(
String userId,
TimelinePost post,
String reactionId,
);
Future<TimelinePost> unlikeReaction(
String userId,
TimelinePost post,
String reactionId,
);
} }

View file

@ -54,6 +54,9 @@ class TimelineTranslations {
required this.addCategorySubmitButton, required this.addCategorySubmitButton,
required this.addCategoryCancelButtton, required this.addCategoryCancelButtton,
required this.addCategoryHintText, required this.addCategoryHintText,
required this.addCategoryErrorText,
required this.titleErrorText,
required this.contentErrorText,
}); });
/// Default translations for the timeline component view /// Default translations for the timeline component view
@ -100,6 +103,9 @@ class TimelineTranslations {
this.addCategorySubmitButton = 'Add category', this.addCategorySubmitButton = 'Add category',
this.addCategoryCancelButtton = 'Cancel', this.addCategoryCancelButtton = 'Cancel',
this.addCategoryHintText = 'Category name...', 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; final String noPosts;
@ -117,6 +123,8 @@ class TimelineTranslations {
final String titleHintText; final String titleHintText;
final String contentHintText; final String contentHintText;
final String titleErrorText;
final String contentErrorText;
final String deletePost; final String deletePost;
final String deleteConfirmationTitle; final String deleteConfirmationTitle;
@ -147,6 +155,7 @@ class TimelineTranslations {
final String addCategorySubmitButton; final String addCategorySubmitButton;
final String addCategoryCancelButtton; final String addCategoryCancelButtton;
final String addCategoryHintText; final String addCategoryHintText;
final String addCategoryErrorText;
final String yes; final String yes;
final String no; final String no;
@ -194,6 +203,9 @@ class TimelineTranslations {
String? addCategorySubmitButton, String? addCategorySubmitButton,
String? addCategoryCancelButtton, String? addCategoryCancelButtton,
String? addCategoryHintText, String? addCategoryHintText,
String? addCategoryErrorText,
String? titleErrorText,
String? contentErrorText,
}) => }) =>
TimelineTranslations( TimelineTranslations(
noPosts: noPosts ?? this.noPosts, noPosts: noPosts ?? this.noPosts,
@ -244,5 +256,8 @@ class TimelineTranslations {
addCategoryHintText: addCategoryHintText ?? this.addCategoryHintText, addCategoryHintText: addCategoryHintText ?? this.addCategoryHintText,
createCategoryPopuptitle: createCategoryPopuptitle:
createCategoryPopuptitle ?? this.createCategoryPopuptitle, createCategoryPopuptitle ?? this.createCategoryPopuptitle,
addCategoryErrorText: addCategoryErrorText ?? this.addCategoryErrorText,
titleErrorText: titleErrorText ?? this.titleErrorText,
contentErrorText: contentErrorText ?? this.contentErrorText,
); );
} }

View file

@ -53,48 +53,23 @@ class _TimelinePostCreationScreenState
TextEditingController titleController = TextEditingController(); TextEditingController titleController = TextEditingController();
TextEditingController contentController = TextEditingController(); TextEditingController contentController = TextEditingController();
Uint8List? image; Uint8List? image;
bool editingDone = false;
bool allowComments = false; bool allowComments = false;
bool titleIsValid = false;
bool contentIsValid = false;
@override @override
void initState() { void initState() {
titleController.addListener(_listenForInputs);
contentController.addListener(_listenForInputs);
super.initState(); super.initState();
titleController.addListener(checkIfEditingDone);
contentController.addListener(checkIfEditingDone);
} }
@override void _listenForInputs() {
void dispose() { titleIsValid = titleController.text.isNotEmpty;
titleController.dispose(); contentIsValid = contentController.text.isNotEmpty;
contentController.dispose();
super.dispose();
} }
void checkIfEditingDone() { var formkey = GlobalKey<FormState>();
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!;
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -127,6 +102,8 @@ class _TimelinePostCreationScreenState
child: SingleChildScrollView( child: SingleChildScrollView(
child: Padding( child: Padding(
padding: widget.options.paddings.mainPadding, padding: widget.options.paddings.mainPadding,
child: Form(
key: formkey,
child: Column( child: Column(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -152,6 +129,15 @@ class _TimelinePostCreationScreenState
expands: null, expands: null,
minLines: null, minLines: null,
maxLines: 1, 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), const SizedBox(height: 16),
Text( Text(
@ -174,6 +160,15 @@ class _TimelinePostCreationScreenState
expands: false, expands: false,
minLines: null, minLines: null,
maxLines: 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( const SizedBox(
height: 16, height: 16,
@ -199,16 +194,18 @@ class _TimelinePostCreationScreenState
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
color: theme.colorScheme.surface, color: theme.colorScheme.surface,
child: ImagePicker( child: ImagePicker(
imagePickerConfig: widget.options.imagePickerConfig, imagePickerConfig:
imagePickerTheme: widget.options.imagePickerTheme ?? widget.options.imagePickerConfig,
imagePickerTheme: widget
.options.imagePickerTheme ??
ImagePickerTheme( ImagePickerTheme(
titleAlignment: TextAlign.center, titleAlignment: TextAlign.center,
title: ' Do you want to upload a file' title: ' Do you want to upload a file'
' or take a picture? ', ' or take a picture? ',
titleTextSize: titleTextSize:
theme.textTheme.titleMedium!.fontSize!, theme.textTheme.titleMedium!.fontSize!,
font: font: theme
theme.textTheme.titleMedium!.fontFamily!, .textTheme.titleMedium!.fontFamily!,
iconSize: 40, iconSize: 40,
selectImageText: 'UPLOAD FILE', selectImageText: 'UPLOAD FILE',
makePhotoText: 'TAKE PICTURE', makePhotoText: 'TAKE PICTURE',
@ -236,7 +233,6 @@ class _TimelinePostCreationScreenState
image = result; image = result;
}); });
} }
checkIfEditingDone();
}, },
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(8.0), borderRadius: BorderRadius.circular(8.0),
@ -274,7 +270,6 @@ class _TimelinePostCreationScreenState
setState(() { setState(() {
image = null; image = null;
}); });
checkIfEditingDone();
}, },
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@ -352,17 +347,19 @@ class _TimelinePostCreationScreenState
context, context,
onPostCreated, onPostCreated,
widget.options.translations.checkPost, widget.options.translations.checkPost,
enabled: editingDone, enabled: formkey.currentState!.validate(),
) ?? ) ??
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 48), padding: const EdgeInsets.symmetric(horizontal: 48),
child: DefaultFilledButton( child: DefaultFilledButton(
onPressed: editingDone onPressed: titleIsValid && contentIsValid
? () async { ? () async {
if (formkey.currentState!.validate()) {
await onPostCreated(); await onPostCreated();
await widget.service.postService await widget.service.postService
.fetchPosts(null); .fetchPosts(null);
} }
}
: null, : null,
buttonText: widget.enablePostOverviewScreen buttonText: widget.enablePostOverviewScreen
? widget.options.translations.checkPost ? widget.options.translations.checkPost
@ -375,6 +372,7 @@ class _TimelinePostCreationScreenState
), ),
), ),
), ),
),
); );
} }
} }

View file

@ -344,10 +344,16 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
setState(() {}); setState(() {});
} }
}, },
icon: Icon( icon: isLikedByUser
isLikedByUser ? widget.options.theme.likedIcon ??
? Icons.favorite_rounded Icon(
: Icons.favorite_outline_outlined, 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, color: widget.options.theme.iconColor,
size: widget.options.iconSize, size: widget.options.iconSize,
), ),
@ -410,7 +416,7 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// ignore: avoid_bool_literals_in_conditional_expressions // ignore: avoid_bool_literals_in_conditional_expressions
if (post.reactionEnabled || widget.isOverviewScreen != null if (post.reactionEnabled && widget.isOverviewScreen != null
? !widget.isOverviewScreen! ? !widget.isOverviewScreen!
: false) ...[ : false) ...[
Text( Text(
@ -544,6 +550,55 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
), ),
), ),
], ],
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,
),
);
},
),
], ],
), ),
), ),

View file

@ -163,6 +163,9 @@ class _TimelineSelectionScreenState extends State<TimelineSelectionScreen> {
PostCreationTextfield( PostCreationTextfield(
controller: controller, controller: controller,
hintText: widget.options.translations.addCategoryHintText, hintText: widget.options.translations.addCategoryHintText,
validator: (p0) => p0!.isEmpty
? widget.options.translations.addCategoryErrorText
: null,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
@ -180,7 +183,7 @@ class _TimelineSelectionScreenState extends State<TimelineSelectionScreen> {
), ),
); );
setState(() {}); setState(() {});
if (mounted) Navigator.pop(context); if (context.mounted) Navigator.pop(context);
}, },
buttonText: buttonText:
widget.options.translations.addCategorySubmitButton, widget.options.translations.addCategorySubmitButton,

View file

@ -272,4 +272,60 @@ class LocalTimelinePostService
return categories; return categories;
} }
@override
Future<TimelinePost> 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<TimelinePost> 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;
}
} }

View file

@ -14,11 +14,13 @@ class DefaultFilledButton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
var theme = Theme.of(context); var theme = Theme.of(context);
return FilledButton( return FilledButton(
style: ButtonStyle( style: onPressed != null
? ButtonStyle(
backgroundColor: WidgetStatePropertyAll( backgroundColor: WidgetStatePropertyAll(
theme.colorScheme.primary, theme.colorScheme.primary,
), ),
), )
: null,
onPressed: onPressed, onPressed: onPressed,
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),

View file

@ -4,6 +4,7 @@ class PostCreationTextfield extends StatelessWidget {
const PostCreationTextfield({ const PostCreationTextfield({
required this.controller, required this.controller,
required this.hintText, required this.hintText,
required this.validator,
super.key, super.key,
this.textMaxLength, this.textMaxLength,
this.decoration, this.decoration,
@ -22,10 +23,12 @@ class PostCreationTextfield extends StatelessWidget {
final bool? expands; final bool? expands;
final int? minLines; final int? minLines;
final int? maxLines; final int? maxLines;
final String? Function(String?)? validator;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var theme = Theme.of(context); var theme = Theme.of(context);
return TextField( return TextFormField(
validator: validator,
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
controller: controller, controller: controller,
maxLength: textMaxLength, maxLength: textMaxLength,

View file

@ -315,9 +315,7 @@ class _TimelinePostWidgetState extends State<TimelinePostWidget> {
] else ...[ ] else ...[
Text( Text(
'${widget.post.likes} ' '${widget.post.likes} '
'${widget.post.likes > 1 ? '${widget.post.likes > 1 ? widget.options.translations.multipleLikesTitle : widget.options.translations.oneLikeTitle}',
widget.options.translations.multipleLikesTitle :
widget.options.translations.oneLikeTitle}',
style: style:
widget.options.theme.textStyles.listPostLikeTitleAndAmount ?? widget.options.theme.textStyles.listPostLikeTitleAndAmount ??
theme.textTheme.titleSmall!.copyWith( theme.textTheme.titleSmall!.copyWith(