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();
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.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<dynamic>?)?.cast<String>() ?? [],
);
/// The unique identifier of the reaction.
@ -57,6 +59,8 @@ class TimelinePostReaction {
/// Reaction creation date as String with microseconds.
final String? createdAtString;
final List<String>? likedBy;
TimelinePostReaction copyWith({
String? id,
String? postId,
@ -65,6 +69,7 @@ class TimelinePostReaction {
String? reaction,
String? imageUrl,
DateTime? createdAt,
List<String>? 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<String, dynamic> toJson() => <String, dynamic>{
@ -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,
},
};
}

View file

@ -32,4 +32,14 @@ abstract class TimelinePostService with ChangeNotifier {
Future<List<TimelineCategory>> fetchCategories();
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.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,
);
}

View file

@ -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<FormState>();
@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<Uint8List?>(
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<Uint8List?>(
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: <Widget>[
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: <Widget>[
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,
),
),
),
),
],
),
),
),
),

View file

@ -344,13 +344,19 @@ class _TimelinePostScreenState extends State<TimelinePostScreen> {
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<TimelinePostScreen> {
),
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<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(
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<TimelineSelectionScreen> {
),
);
setState(() {});
if (mounted) Navigator.pop(context);
if (context.mounted) Navigator.pop(context);
},
buttonText:
widget.options.translations.addCategorySubmitButton,

View file

@ -272,4 +272,60 @@ class LocalTimelinePostService
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) {
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),

View file

@ -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,

View file

@ -315,9 +315,7 @@ class _TimelinePostWidgetState extends State<TimelinePostWidget> {
] 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(