From 69d511e20967315961f7ab13bf4ffd30d440d432 Mon Sep 17 00:00:00 2001 From: Komposten Date: Thu, 7 May 2020 18:40:47 +0200 Subject: [PATCH 01/12] :recycle: Rewrite OBSavePostModal to use a single media object Previously we had to keep track of separate fields for image and video files or PostImage/PostVideo objects, a link url, removers, and boolean flags for whether we had an image or video. Now all this data is kept inside a single class. --- .../home/modals/save_post/create_post.dart | 211 ++++++++---------- 1 file changed, 98 insertions(+), 113 deletions(-) diff --git a/lib/pages/home/modals/save_post/create_post.dart b/lib/pages/home/modals/save_post/create_post.dart index 76f4a9d55..83c137c3b 100644 --- a/lib/pages/home/modals/save_post/create_post.dart +++ b/lib/pages/home/modals/save_post/create_post.dart @@ -71,26 +71,13 @@ class OBSavePostModalState extends OBContextualSearchBoxState { TextEditingController _textController; FocusNode _focusNode; int _charactersCount; - String _linkPreviewUrl; bool _isPostTextAllowedLength; bool _isPostTextContainingValidHashtags; bool _hasFocus; - bool _hasImage; - bool _hasVideo; - // When creating a post - File _postImageFile; - File _postVideoFile; - - // When editing a post - PostImage _postImage; - PostVideo _postVideo; - - VoidCallback _postImageWidgetRemover; - VoidCallback _postVideoWidgetRemover; - VoidCallback _linkPreviewWidgetRemover; + _MediaPreview _mediaPreview; List _postItemsWidgets; @@ -102,6 +89,10 @@ class OBSavePostModalState extends OBContextualSearchBoxState { bool _saveInProgress; CancelableOperation _saveOperation; + bool get _hasMedia => _mediaPreview != null; + bool get _hasNonLinkMedia => + _hasMedia && _mediaPreview.type != _MediaType.link; + @override void initState() { super.initState(); @@ -109,7 +100,6 @@ class OBSavePostModalState extends OBContextualSearchBoxState { _focusNode = FocusNode(); _hasFocus = false; - _linkPreviewUrl = ''; _isPostTextAllowedLength = false; _isPostTextContainingValidHashtags = false; _isCreateCommunityPostInProgress = false; @@ -132,14 +122,9 @@ class OBSavePostModalState extends OBContextualSearchBoxState { PostMedia postMedia = widget.post.getFirstMedia(); if (postMedia.type == PostMediaType.video) { _setPostVideo(postMedia.contentObject as PostVideo); - _hasImage = false; } else { _setPostImage(postMedia.contentObject as PostImage); - _hasVideo = false; } - } else { - _hasVideo = false; - _hasImage = false; } } else { _textController = DraftTextEditingController.post( @@ -149,8 +134,6 @@ class OBSavePostModalState extends OBContextualSearchBoxState { _postItemsWidgets = [ OBCreatePostText(controller: _textController, focusNode: _focusNode) ]; - _hasImage = false; - _hasVideo = false; if (widget.image != null) { _setPostImageFile(widget.image); } @@ -221,7 +204,7 @@ class OBSavePostModalState extends OBContextualSearchBoxState { child: buildSearchBox(), ) : const SizedBox(), - isAutocompleting || (_hasImage || _hasVideo) + isAutocompleting || _hasNonLinkMedia ? const SizedBox() : Container( height: _hasFocus == true ? 51 : 67, @@ -236,19 +219,14 @@ class OBSavePostModalState extends OBContextualSearchBoxState { Widget _buildNavigationBar(LocalizationService _localizationService) { bool isPrimaryActionButtonIsEnabled = - (_isPostTextAllowedLength && _charactersCount > 0) || - _hasImage || - _hasVideo; + (_isPostTextAllowedLength && _charactersCount > 0) || _hasNonLinkMedia; return OBThemedNavigationBar( leading: GestureDetector( child: OBIcon(OBIcons.close, semanticLabel: _localizationService.post__close_create_post_label), onTap: () { - if (this._postImageFile != null) this._postImageFile.delete(); - if (this._postVideoFile != null) - _mediaService.clearThumbnailForFile(this._postVideoFile); - if (this._postVideoFile != null) this._postVideoFile.delete(); + _removePostMedia(); Navigator.pop(context); }, ), @@ -323,8 +301,8 @@ class OBSavePostModalState extends OBContextualSearchBoxState { if (createPostData != null) { // Remove modal - if (this._postVideoFile != null) - _mediaService.clearThumbnailForFile(this._postVideoFile); + if (_mediaPreview.type == _MediaType.video && _mediaPreview?.file != null) + _mediaService.clearThumbnailForFile(_mediaPreview.file); Navigator.pop(context, createPostData); _clearDraft(); } @@ -367,7 +345,7 @@ class OBSavePostModalState extends OBContextualSearchBoxState { Widget _buildPostActions() { List postActions = []; - if (!_hasImage && !_hasVideo && !_isEditingPost) { + if (!_hasNonLinkMedia && !_isEditingPost) { postActions.addAll(_getImagePostActions()); } @@ -470,29 +448,25 @@ class OBSavePostModalState extends OBContextualSearchBoxState { return; } - setState(() { - this._postImageFile = image; - _hasImage = true; + _removePostMedia(); + setState(() { var postImageWidget = OBPostImagePreviewer( - key: Key(_postImageFile.path), - postImageFile: _postImageFile, - onRemove: () { - _removePostImageFile(); - }, - onWillEditImage: () { - _unfocusTextField(); - }, - onPostImageEdited: (File editedImage) { - _removePostImageFile(); - _setPostImageFile(editedImage); - }, + key: Key(image.path), + postImageFile: image, + onRemove: _removePostMedia, + onWillEditImage: _unfocusTextField, + onPostImageEdited: _setPostImageFile, ); - _postImageWidgetRemover = _addPostItemWidget(postImageWidget); + var remover = _addPostItemWidget(postImageWidget); + _mediaPreview = _MediaPreview( + type: _MediaType.image, + preview: postImageWidget, + file: image, + remover: remover, + ); }); - - _clearLinkPreviewUrl(); } void _setPostVideoFile(File video) { @@ -500,56 +474,53 @@ class OBSavePostModalState extends OBContextualSearchBoxState { return; } - setState(() { - this._postVideoFile = video; - _hasVideo = true; + _removePostMedia(); + setState(() { var postVideoWidget = OBPostVideoPreview( - key: Key(_postVideoFile.path), - postVideoFile: _postVideoFile, - onRemove: () { - _removePostVideoFile(); - }, + key: Key(video.path), + postVideoFile: video, + onRemove: _removePostMedia, ); - _postVideoWidgetRemover = _addPostItemWidget(postVideoWidget); + var remover = _addPostItemWidget(postVideoWidget); + _mediaPreview = _MediaPreview( + type: _MediaType.video, + preview: postVideoWidget, + file: video, + remover: remover, + ); }); - - _clearLinkPreviewUrl(); } void _setPostVideo(PostVideo postVideo) { // To be called on init only, therefore no setState - _hasVideo = true; - _postVideo = postVideo; var postVideoWidget = OBPostVideoPreview( - postVideo: _postVideo, + postVideo: postVideo, ); _addPostItemWidget(postVideoWidget); + + _mediaPreview = _MediaPreview( + type: _MediaType.video, preview: postVideoWidget, video: postVideo); } void _setPostImage(PostImage postImage) { // To be called on init only, therefore no setState - _hasImage = true; - _postImage = postImage; var postImageWidget = OBPostImagePreviewer( - postImage: _postImage, + postImage: postImage, ); _addPostItemWidget(postImageWidget); + + _mediaPreview = _MediaPreview( + type: _MediaType.image, preview: postImageWidget, image: postImage); } Future _onShare({String text, File image, File video}) async { - if (image != null || video != null) { - if (_hasImage) { - _removePostImageFile(); - } else if (_hasVideo) { - _removePostVideoFile(); - } - } + _removePostMedia(); if (text != null) { _textController.value = TextEditingValue( @@ -571,8 +542,8 @@ class OBSavePostModalState extends OBContextualSearchBoxState { Future _createCommunityPost() async { OBNewPostData newPostData = _makeNewPostData(); - if (this._postVideoFile != null) - _mediaService.clearThumbnailForFile(this._postVideoFile); + if (_mediaPreview.type == _MediaType.video && _mediaPreview?.file != null) + _mediaService.clearThumbnailForFile(_mediaPreview.file); Navigator.pop(context, newPostData); _clearDraft(); } @@ -601,36 +572,31 @@ class OBSavePostModalState extends OBContextualSearchBoxState { } void _checkForLinkPreview() async { - if (_hasImage || _hasVideo) return; + if (_hasNonLinkMedia) return; String text = _textController.text; String linkPreviewUrl = _linkPreviewService.checkForLinkPreviewUrl(text); if (linkPreviewUrl == null) { - _clearLinkPreviewUrl(); - return; - } - - if (linkPreviewUrl != null && linkPreviewUrl != _linkPreviewUrl) { + _removePostMedia(); + } else if (linkPreviewUrl != _mediaPreview?.link) { _setLinkPreviewUrl(linkPreviewUrl); } } void _setLinkPreviewUrl(String url) { - if (_linkPreviewWidgetRemover != null) _linkPreviewWidgetRemover(); + _removePostMedia(); setState(() { - _linkPreviewUrl = url; - _linkPreviewWidgetRemover = _addPostItemWidget(OBLinkPreview( - link: _linkPreviewUrl, - )); - }); - } - - void _clearLinkPreviewUrl() { - setState(() { - _linkPreviewUrl = null; - if (_linkPreviewWidgetRemover != null) _linkPreviewWidgetRemover(); + var linkPreview = OBLinkPreview(link: url); + var remover = _addPostItemWidget(linkPreview); + + _mediaPreview = _MediaPreview( + type: _MediaType.link, + preview: linkPreview, + link: url, + remover: remover, + ); }); } @@ -654,29 +620,27 @@ class OBSavePostModalState extends OBContextualSearchBoxState { } } - void _removePostImageFile() { - setState(() { - if (this._postImageFile != null) this._postImageFile.deleteSync(); - this._postImage = null; - _hasImage = false; - _postImageWidgetRemover(); - }); - } + void _removePostMedia() { + if (!_hasMedia) return; - void _removePostVideoFile() { - setState(() { - _mediaService.clearThumbnailForFile(this._postVideoFile); - if (this._postVideoFile != null) this._postVideoFile.deleteSync(); - this._postVideoFile = null; - _hasVideo = false; - _postVideoWidgetRemover(); - }); + if (_mediaPreview.file != null) { + if (_mediaPreview.type == _MediaType.video) { + _mediaService.clearThumbnailForFile(_mediaPreview.file); + } + _mediaPreview.file.deleteSync(); + } + _mediaPreview.remover?.call(); + + if (mounted) { + setState(() { + _mediaPreview = null; + }); + } } OBNewPostData _makeNewPostData() { List media = []; - if (_postImageFile != null) media.add(_postImageFile); - if (_postVideoFile != null) media.add(_postVideoFile); + if (_mediaPreview?.file != null) media.add(_mediaPreview.file); return OBNewPostData( text: _textController.text, media: media, community: widget.community); @@ -721,3 +685,24 @@ class OBSavePostModalState extends OBContextualSearchBoxState { debugPrint('CreatePostModal:$log'); } } + +class _MediaPreview { + final _MediaType type; + final Widget preview; + final File file; + final String link; + final PostVideo video; + final PostImage image; + final void Function() remover; + + _MediaPreview( + {@required this.type, + @required this.preview, + this.file, + this.link, + this.image, + this.video, + this.remover}); +} + +enum _MediaType { image, video, link } From a6de1aebeb5227d943dceea717690519139dc3a2 Mon Sep 17 00:00:00 2001 From: Komposten Date: Thu, 7 May 2020 19:40:58 +0200 Subject: [PATCH 02/12] :construction: Add progress indicator when adding media to a post WIP since shared media does not use this system yet. --- .../home/modals/save_post/create_post.dart | 85 +++++++++++---- .../widgets/post_media_previewer.dart | 100 ++++++++++++++++++ lib/services/media/media.dart | 47 ++++++-- 3 files changed, 199 insertions(+), 33 deletions(-) create mode 100644 lib/pages/home/modals/save_post/widgets/post_media_previewer.dart diff --git a/lib/pages/home/modals/save_post/create_post.dart b/lib/pages/home/modals/save_post/create_post.dart index 83c137c3b..afb820d2e 100644 --- a/lib/pages/home/modals/save_post/create_post.dart +++ b/lib/pages/home/modals/save_post/create_post.dart @@ -9,6 +9,7 @@ import 'package:Okuna/pages/home/lib/draft_editing_controller.dart'; import 'package:Okuna/pages/home/modals/save_post/widgets/create_post_text.dart'; import 'package:Okuna/pages/home/modals/save_post/widgets/post_community_previewer.dart'; import 'package:Okuna/pages/home/modals/save_post/widgets/post_image_previewer.dart'; +import 'package:Okuna/pages/home/modals/save_post/widgets/post_media_previewer.dart'; import 'package:Okuna/pages/home/modals/save_post/widgets/post_video_previewer.dart'; import 'package:Okuna/pages/home/modals/save_post/widgets/remaining_post_characters.dart'; import 'package:Okuna/provider.dart'; @@ -17,6 +18,7 @@ import 'package:Okuna/services/httpie.dart'; import 'package:Okuna/services/link_preview.dart'; import 'package:Okuna/services/localization.dart'; import 'package:Okuna/services/media/media.dart'; +import 'package:Okuna/services/media/models/media_file.dart'; import 'package:Okuna/services/navigation_service.dart'; import 'package:Okuna/services/share.dart'; import 'package:Okuna/services/toast.dart'; @@ -79,6 +81,8 @@ class OBSavePostModalState extends OBContextualSearchBoxState { _MediaPreview _mediaPreview; + bool _isProcessingMedia; + List _postItemsWidgets; bool _needsBootstrap; @@ -100,6 +104,7 @@ class OBSavePostModalState extends OBContextualSearchBoxState { _focusNode = FocusNode(); _hasFocus = false; + _isProcessingMedia = false; _isPostTextAllowedLength = false; _isPostTextContainingValidHashtags = false; _isCreateCommunityPostInProgress = false; @@ -204,7 +209,7 @@ class OBSavePostModalState extends OBContextualSearchBoxState { child: buildSearchBox(), ) : const SizedBox(), - isAutocompleting || _hasNonLinkMedia + isAutocompleting || _hasNonLinkMedia || _isProcessingMedia ? const SizedBox() : Container( height: _hasFocus == true ? 51 : 67, @@ -386,18 +391,11 @@ class OBSavePostModalState extends OBContextualSearchBoxState { onPressed: () async { _unfocusTextField(); try { - var pickedMedia = await _mediaService.pickMedia( - context: context, - source: ImageSource.gallery, - flattenGifs: false, - ); - if (pickedMedia != null) { - if (pickedMedia.type == FileType.image) { - _setPostImageFile(pickedMedia.file); - } else { - _setPostVideoFile(pickedMedia.file); - } - } + await _mediaService.pickMedia( + context: context, + source: ImageSource.gallery, + flattenGifs: false, + onProgress: _onMediaProgress); } catch (error) { _onError(error); } @@ -410,15 +408,10 @@ class OBSavePostModalState extends OBContextualSearchBoxState { onPressed: () async { _unfocusTextField(); try { - var pickedMedia = await _mediaService.pickMedia( - context: context, source: ImageSource.camera); - if (pickedMedia != null) { - if (pickedMedia.type == FileType.image) { - _setPostImageFile(pickedMedia.file); - } else { - _setPostVideoFile(pickedMedia.file); - } - } + await _mediaService.pickMedia( + context: context, + source: ImageSource.camera, + onProgress: _onMediaProgress); } catch (error) { _onError(error); } @@ -443,6 +436,52 @@ class OBSavePostModalState extends OBContextualSearchBoxState { _hasFocus = _focusNode.hasFocus; } + void _onMediaProgress(MediaProcessingState state, {dynamic data}) { + if (!mounted) { + return; + } + + if (state == MediaProcessingState.processing) { + /* TODO(komposten): Improve this part + It would be preferable if the previous media was restored + if this new media was cancelled before finishing processing. + */ + _removePostMedia(); + _isProcessingMedia = true; + // TODO(komposten): onRemove should cancel the whole operation. + var postMediaWidget = OBPostMediaPreview(onRemove: _removePostMedia); + var remover = _addPostItemWidget(postMediaWidget); + + _mediaPreview = _MediaPreview( + type: _MediaType.pending, preview: postMediaWidget, remover: remover); + } else if (state == MediaProcessingState.cancelled) { + /* TODO(komposten): Ideally, calling pickMedia should create an operation + which we can then cancel to end up here. That way starting a new media + process can cause the previous one to cancel and this elif to remove data + associated with it. + */ + } else if (state == MediaProcessingState.error) { + _toastService.error(message: data, context: context); + } else if (state == MediaProcessingState.finished) { + var pickedMedia = data as MediaFile; + + if (pickedMedia.type == FileType.image) { + _isProcessingMedia = false; + + _setPostImageFile(pickedMedia.file); + } else if (pickedMedia.type == FileType.video) { + _isProcessingMedia = false; + + _setPostVideoFile(pickedMedia.file); + } else { + _isProcessingMedia = false; + _removePostMedia(); + _toastService.error( + message: 'An unsupported media type was picked!', context: context); + } + } + } + void _setPostImageFile(File image) { if (!mounted) { return; @@ -705,4 +744,4 @@ class _MediaPreview { this.remover}); } -enum _MediaType { image, video, link } +enum _MediaType { image, video, link, pending } diff --git a/lib/pages/home/modals/save_post/widgets/post_media_previewer.dart b/lib/pages/home/modals/save_post/widgets/post_media_previewer.dart new file mode 100644 index 000000000..caa465e70 --- /dev/null +++ b/lib/pages/home/modals/save_post/widgets/post_media_previewer.dart @@ -0,0 +1,100 @@ +import 'dart:io'; + +import 'package:Okuna/models/post_image.dart'; +import 'package:Okuna/models/post_video.dart'; +import 'package:Okuna/models/theme.dart'; +import 'package:Okuna/pages/home/modals/save_post/widgets/post_image_previewer.dart'; +import 'package:Okuna/pages/home/modals/save_post/widgets/post_video_previewer.dart'; +import 'package:Okuna/provider.dart'; +import 'package:Okuna/services/theme_value_parser.dart'; +import 'package:Okuna/widgets/progress_indicator.dart'; +import 'package:flutter/material.dart'; + +class OBPostMediaPreview extends StatefulWidget { + final VoidCallback onRemove; + + OBPostMediaPreview({Key key, this.onRemove}) + : super(key: key); + + @override + _OBPostMediaPreviewState createState() => + _OBPostMediaPreviewState(); +} + +class _OBPostMediaPreviewState extends State { + final double buttonSize = 30.0; + + _OBPostMediaPreviewState(); + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return _buildProgressIndicator(); + } + + Widget _buildProgressIndicator() { + var provider = OpenbookProvider.of(context); + ThemeValueParserService themeParserService = + provider.themeValueParserService; + OBTheme theme = provider.themeService.getActiveTheme(); + + double avatarBorderRadius = 10.0; + + var background = SizedBox( + height: 200.0, + width: 200, + child: ClipRRect( + borderRadius: new BorderRadius.circular(avatarBorderRadius), + child: Container( + color: Colors.grey, + child: Center( + child: OBProgressIndicator( + size: 40, + color: themeParserService + .parseGradient(theme.primaryTextColor) + .colors + .first, + ), + ), + ), + ), + ); + + if (widget.onRemove == null) return background; + + return Stack( + children: [ + background, + Positioned( + top: 10, + right: 10, + child: _buildRemoveButton(), + ), + ], + ); + } + + Widget _buildRemoveButton() { + return GestureDetector( + onTap: widget.onRemove, + child: SizedBox( + width: buttonSize, + height: buttonSize, + child: FloatingActionButton( + heroTag: Key('postMediaPreviewerRemoveButton'), + onPressed: widget.onRemove, + backgroundColor: Colors.black54, + child: Icon( + Icons.clear, + color: Colors.white, + size: 20.0, + ), + ), + ), + ); + } +} diff --git a/lib/services/media/media.dart b/lib/services/media/media.dart index 77b2f4e81..addba3678 100644 --- a/lib/services/media/media.dart +++ b/lib/services/media/media.dart @@ -75,9 +75,11 @@ class MediaService { Future pickMedia( {@required BuildContext context, @required ImageSource source, - bool flattenGifs}) async { + bool flattenGifs, + void Function(MediaProcessingState, {dynamic data}) onProgress}) async { MediaFile media; + onProgress?.call(MediaProcessingState.picking); if (source == ImageSource.gallery) { bool permissionGranted = await _permissionsService.requestStoragePermissions(context: context); @@ -92,10 +94,12 @@ class MediaService { } else if (source == ImageSource.camera) { media = await _bottomSheetService.showCameraPicker(context: context); } else { - throw 'Unsupported media source: $source'; + onProgress?.call(MediaProcessingState.error, + data: 'Unsupported media source: $source'); } if (media == null) { + onProgress?.call(MediaProcessingState.cancelled); return null; } @@ -103,6 +107,7 @@ class MediaService { media: media, context: context, flattenGifs: flattenGifs, + onProgress: onProgress, ); return media; @@ -114,17 +119,25 @@ class MediaService { /// The returned file should always point to an image. If a GIF is picked it will /// be flattened. Future pickImage( - {@required OBImageType imageType, @required BuildContext context}) async { + {@required OBImageType imageType, + @required BuildContext context, + void Function(MediaProcessingState, {dynamic data}) onProgress}) async { + onProgress?.call(MediaProcessingState.picking); + File pickedImage = await _bottomSheetService.showImagePicker(context: context); - if (pickedImage == null) return null; + if (pickedImage == null) { + onProgress?.call(MediaProcessingState.cancelled); + return null; + } var media = await processMedia( media: MediaFile(pickedImage, FileType.image), context: context, flattenGifs: true, imageType: imageType, + onProgress: onProgress, ); return media.file; @@ -134,15 +147,23 @@ class MediaService { /// a new one with the camera. /// /// The returned file should always point to a video. - Future pickVideo({@required BuildContext context}) async { + Future pickVideo( + {@required BuildContext context, + void Function(MediaProcessingState, {dynamic data}) onProgress}) async { + onProgress?.call(MediaProcessingState.picking); + File pickedVideo = await _bottomSheetService.showVideoPicker(context: context); - if (pickedVideo == null) return null; + if (pickedVideo == null) { + onProgress?.call(MediaProcessingState.cancelled); + return null; + } var media = await processMedia( media: MediaFile(pickedVideo, FileType.video), context: context, + onProgress: onProgress, ); return media.file; @@ -152,10 +173,13 @@ class MediaService { {@required MediaFile media, @required BuildContext context, bool flattenGifs = false, - OBImageType imageType = OBImageType.post}) async { + OBImageType imageType = OBImageType.post, + void Function(MediaProcessingState, {dynamic data}) onProgress}) async { var mediaType = media.type; MediaFile result; + onProgress?.call(MediaProcessingState.processing); + // Copy the media to a temporary location. final tempPath = await _getTempPath(); final String mediaUuid = _uuid.v4(); @@ -182,9 +206,11 @@ class MediaService { } else if (mediaType == FileType.video) { result = await _processVideo(copiedMedia); } else { - throw 'Unsupported media type: ${media.type}'; + onProgress?.call(MediaProcessingState.error, + data: 'Unsupported media type: ${media.type}'); } + onProgress?.call(MediaProcessingState.finished, data: result); return result; } @@ -205,7 +231,7 @@ class MediaService { throw FileTooLargeException( _validationService.getAllowedImageSize(imageType)); } - + MediaFile result; if (imageType == OBImageType.post) { result = MediaFile(processedImage, media.type); @@ -230,7 +256,6 @@ class MediaService { return media; } - Future fixExifRotation(File image, {deleteOriginal: false}) async { List imageBytes = await image.readAsBytes(); @@ -432,3 +457,5 @@ class FileTooLargeException implements Exception { } enum OBImageType { avatar, cover, post } + +enum MediaProcessingState { picking, processing, finished, cancelled, error } From a1f84e4932093079b52d0dc48e6fb1f2bd5dca1b Mon Sep 17 00:00:00 2001 From: Komposten Date: Sat, 16 May 2020 17:41:37 +0200 Subject: [PATCH 03/12] :recycle: Rewrite share subscriptions to provide more information --- lib/pages/home/home.dart | 6 +- .../home/modals/save_post/create_post.dart | 23 +++-- lib/services/share.dart | 93 ++++++++++++++----- 3 files changed, 85 insertions(+), 37 deletions(-) diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index cbde4395a..8eb8491ed 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -331,7 +331,7 @@ class OBHomePageState extends State } } - _shareService.subscribe(_onShare); + _shareService.subscribe(onShare: _onShare); } Future _logout({unsubscribePushNotifications = false}) async { @@ -433,9 +433,9 @@ class OBHomePageState extends State //_navigateToTab(OBHomePageTabs.notifications); } - Future _onShare({String text, File image, File video}) async { + Future _onShare(Share share) async { bool postCreated = await _timelinePageController.createPost( - text: text, image: image, video: video); + text: share.text, image: share.image, video: share.video); if (postCreated) { _timelinePageController.popUntilFirstRoute(); diff --git a/lib/pages/home/modals/save_post/create_post.dart b/lib/pages/home/modals/save_post/create_post.dart index afb820d2e..14fcc846c 100644 --- a/lib/pages/home/modals/save_post/create_post.dart +++ b/lib/pages/home/modals/save_post/create_post.dart @@ -70,6 +70,8 @@ class OBSavePostModalState extends OBContextualSearchBoxState { DraftService _draftService; ShareService _shareService; + ShareSubscription _shareSubscription; + TextEditingController _textController; FocusNode _focusNode; int _charactersCount; @@ -161,7 +163,8 @@ class OBSavePostModalState extends OBContextualSearchBoxState { _isPostTextContainingValidHashtags = _validationService .isPostTextContainingValidHashtags(_textController.text); if (!_isEditingPost) { - _shareService.subscribe(_onShare); + _shareSubscription = _shareService.subscribe( + onShare: _onShare, onMediaProgress: _onMediaProgress); } } @@ -171,7 +174,7 @@ class OBSavePostModalState extends OBContextualSearchBoxState { _textController.removeListener(_onPostTextChanged); _focusNode.removeListener(_onFocusNodeChanged); _saveOperation?.cancel(); - _shareService.unsubscribe(_onShare); + _shareSubscription.cancel(); } @override @@ -558,22 +561,22 @@ class OBSavePostModalState extends OBContextualSearchBoxState { type: _MediaType.image, preview: postImageWidget, image: postImage); } - Future _onShare({String text, File image, File video}) async { + Future _onShare(Share share) async { _removePostMedia(); - if (text != null) { + if (share.text != null) { _textController.value = TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: text.length), + text: share.text, + selection: TextSelection.collapsed(offset: share.text.length), ); } - if (image != null) { - _setPostImageFile(image); + if (share.image != null) { + _setPostImageFile(share.image); } - if (video != null) { - _setPostVideoFile(video); + if (share.video != null) { + _setPostVideoFile(share.video); } return true; diff --git a/lib/services/share.dart b/lib/services/share.dart index c41a20c30..84ea92653 100644 --- a/lib/services/share.dart +++ b/lib/services/share.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:io'; -import 'package:Okuna/plugins/share/share.dart'; +import 'package:Okuna/plugins/share/share.dart' as SharePlugin; import 'package:Okuna/services/localization.dart'; import 'package:Okuna/services/media/media.dart'; import 'package:Okuna/services/media/models/media_file.dart'; @@ -21,12 +21,11 @@ class ShareService { LocalizationService _localizationService; StreamSubscription _shareReceiveSubscription; - List Function({String text, File image, File video})> - _subscribers; + List _subscribers; - Share _queuedShare; + SharePlugin.Share _queuedShare; bool _isProcessingShare = false; - Map _activeShares; + Map _activeShares; BuildContext _context; @@ -64,30 +63,32 @@ class ShareService { /// Subscribe to share events. /// - /// [onShare] should return [true] if it consumes the share immediately, - /// a [CancelableOperation] if some amount of work has to be done first (like - /// gif to video conversion in OBSavePostModal), or [false] if the subscriber - /// did _not_ consume the share (the share will be passed on to the next subscriber). + /// [onShare] should a [CancelableOperation] if some amount of work has to be + /// done first (like gif to video conversion in OBSavePostModal) to process + /// the share, otherwise [null] is expected. /// /// If a [CancelableOperation] is returned, it _must_ handle cancellation /// properly. - void subscribe( - Future Function({String text, File image, File video}) onShare) { - _subscribers.add(onShare); + ShareSubscription subscribe({ + bool acceptsText = true, + bool acceptsImages = true, + bool acceptsVideos = true, + @required Future Function(Share) onShare, + void Function(MediaProcessingState state, {dynamic data}) onMediaProgress, + }) { + var subscriber = ShareSubscriber( + acceptsText, acceptsImages, acceptsVideos, onShare, onMediaProgress); + _subscribers.add(subscriber); if (_subscribers.length == 1) { _processQueuedShare(); } - } - void unsubscribe( - Future Function({String text, File image, File video}) - subscriber) { - _subscribers.remove(subscriber); + return ShareSubscription(() => _subscribers.remove(subscriber)); } void _onReceiveShare(dynamic shared) async { - _queuedShare = Share.fromReceived(shared); + _queuedShare = SharePlugin.Share.fromReceived(shared); if (_subscribers.isNotEmpty && !_isProcessingShare) { _processQueuedShare(); @@ -115,11 +116,17 @@ class ShareService { } } - Future _onShare(Share share) async { + Future _onShare(SharePlugin.Share share) async { String text; File image; File video; + // TODO(komposten) + // 1) Find first sub who can handle the share. + // 2) Ask for a media progress listener. + // 3) Run normal operations down to the sub loop. + // 4) Use the sub directly instead of the sub loop. + if (share.error != null) { _toastService.error( message: _localizationService.trans(share.error), context: _context); @@ -142,7 +149,7 @@ class ShareService { video = File.fromUri(Uri.parse(share.video)); var processedFile = await _mediaService.processMedia( - media: MediaFile(image, FileType.video), + media: MediaFile(video, FileType.video), context: _context, ); @@ -160,12 +167,14 @@ class ShareService { } } + var newShare = Share(text: text, image: image, video: video); + for (var sub in _subscribers.reversed) { if (_activeShares[share].isCancelled) { break; } - var subResult = await sub(text: text, image: image, video: video); + var subResult = await sub.onShare(newShare); // Stop event propagation if we have a sub-result that is either true or // a CancelableOperation. @@ -180,9 +189,9 @@ class ShareService { } class ShareOperation { - final Future Function(Share) _shareFunction; + final Future Function(SharePlugin.Share) _shareFunction; - Share share; + SharePlugin.Share share; CancelableOperation shareOperation; CancelableOperation subOperation; bool isCancelled = false; @@ -191,7 +200,7 @@ class ShareOperation { bool _subComplete = false; FutureOr Function() _callback; - ShareOperation(this.share, Future Function(Share) shareFunction) + ShareOperation(this.share, Future Function(SharePlugin.Share) shareFunction) : _shareFunction = shareFunction; void start() { @@ -233,3 +242,39 @@ class ShareOperation { } } } + +class ShareSubscriber { + final bool acceptsText; + final bool acceptsImages; + final bool acceptsVideos; + final Future Function(Share) onShare; + final void Function(MediaProcessingState, {dynamic data}) + mediaProgressCallback; + + const ShareSubscriber(this.acceptsText, this.acceptsImages, + this.acceptsVideos, this.onShare, this.mediaProgressCallback); + + bool acceptsShare(SharePlugin.Share share) { + return ((share.text == null || acceptsText) && + (share.image == null || acceptsImages) && + (share.video == null || acceptsVideos)); + } +} + +class ShareSubscription { + final VoidCallback _cancel; + + ShareSubscription(this._cancel); + + void cancel() { + _cancel(); + } +} + +class Share { + final String text; + final File image; + final File video; + + const Share({this.text, this.image, this.video}); +} \ No newline at end of file From 5dfe98a511750054de2517c902effbd37dd86e4c Mon Sep 17 00:00:00 2001 From: Komposten Date: Sat, 16 May 2020 17:56:52 +0200 Subject: [PATCH 04/12] :recycle: Move data models out of ShareService --- lib/pages/home/home.dart | 4 +- .../home/modals/save_post/create_post.dart | 4 +- lib/provider.dart | 2 +- lib/services/share/models/share.dart | 9 +++++ lib/services/share/models/subscriber.dart | 21 ++++++++++ lib/services/share/models/subscription.dart | 11 ++++++ lib/services/{ => share}/share.dart | 39 ++----------------- 7 files changed, 50 insertions(+), 40 deletions(-) create mode 100644 lib/services/share/models/share.dart create mode 100644 lib/services/share/models/subscriber.dart create mode 100644 lib/services/share/models/subscription.dart rename lib/services/{ => share}/share.dart (89%) diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 8eb8491ed..87a158c2b 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'package:Okuna/models/push_notification.dart'; import 'package:Okuna/pages/home/lib/poppable_page_controller.dart'; @@ -19,7 +18,8 @@ import 'package:Okuna/pages/home/widgets/tab-scaffold.dart'; import 'package:Okuna/provider.dart'; import 'package:Okuna/services/httpie.dart'; import 'package:Okuna/services/modal_service.dart'; -import 'package:Okuna/services/share.dart'; +import 'package:Okuna/services/share/models/share.dart'; +import 'package:Okuna/services/share/share.dart'; import 'package:Okuna/services/toast.dart'; import 'package:Okuna/services/user.dart'; import 'package:Okuna/services/user_preferences.dart'; diff --git a/lib/pages/home/modals/save_post/create_post.dart b/lib/pages/home/modals/save_post/create_post.dart index 14fcc846c..22f93265d 100644 --- a/lib/pages/home/modals/save_post/create_post.dart +++ b/lib/pages/home/modals/save_post/create_post.dart @@ -20,7 +20,9 @@ import 'package:Okuna/services/localization.dart'; import 'package:Okuna/services/media/media.dart'; import 'package:Okuna/services/media/models/media_file.dart'; import 'package:Okuna/services/navigation_service.dart'; -import 'package:Okuna/services/share.dart'; +import 'package:Okuna/services/share/models/share.dart'; +import 'package:Okuna/services/share/models/subscription.dart'; +import 'package:Okuna/services/share/share.dart'; import 'package:Okuna/services/toast.dart'; import 'package:Okuna/services/user.dart'; import 'package:Okuna/services/validation.dart'; diff --git a/lib/provider.dart b/lib/provider.dart index a9c265467..37ae8d301 100644 --- a/lib/provider.dart +++ b/lib/provider.dart @@ -21,7 +21,7 @@ import 'package:Okuna/services/media/media.dart'; import 'package:Okuna/services/notifications_api.dart'; import 'package:Okuna/services/permissions.dart'; import 'package:Okuna/services/push_notifications/push_notifications.dart'; -import 'package:Okuna/services/share.dart'; +import 'package:Okuna/services/share/share.dart'; import 'package:Okuna/services/text_autocompletion.dart'; import 'package:Okuna/services/universal_links/universal_links.dart'; import 'package:Okuna/services/emoji_picker.dart'; diff --git a/lib/services/share/models/share.dart b/lib/services/share/models/share.dart new file mode 100644 index 000000000..0226f18f3 --- /dev/null +++ b/lib/services/share/models/share.dart @@ -0,0 +1,9 @@ +import 'dart:io'; + +class Share { + final String text; + final File image; + final File video; + + const Share({this.text, this.image, this.video}); +} diff --git a/lib/services/share/models/subscriber.dart b/lib/services/share/models/subscriber.dart new file mode 100644 index 000000000..6addef9fc --- /dev/null +++ b/lib/services/share/models/subscriber.dart @@ -0,0 +1,21 @@ +import 'package:Okuna/plugins/share/share.dart' as SharePlugin; +import 'package:Okuna/services/media/media.dart'; +import 'package:Okuna/services/share/models/share.dart'; + +class ShareSubscriber { + final bool acceptsText; + final bool acceptsImages; + final bool acceptsVideos; + final Future Function(Share) onShare; + final void Function(MediaProcessingState, {dynamic data}) + mediaProgressCallback; + + const ShareSubscriber(this.acceptsText, this.acceptsImages, + this.acceptsVideos, this.onShare, this.mediaProgressCallback); + + bool acceptsShare(SharePlugin.Share share) { + return ((share.text == null || acceptsText) && + (share.image == null || acceptsImages) && + (share.video == null || acceptsVideos)); + } +} diff --git a/lib/services/share/models/subscription.dart b/lib/services/share/models/subscription.dart new file mode 100644 index 000000000..c4a924323 --- /dev/null +++ b/lib/services/share/models/subscription.dart @@ -0,0 +1,11 @@ +import 'package:flutter/cupertino.dart'; + +class ShareSubscription { + final VoidCallback _cancel; + + ShareSubscription(this._cancel); + + void cancel() { + _cancel(); + } +} diff --git a/lib/services/share.dart b/lib/services/share/share.dart similarity index 89% rename from lib/services/share.dart rename to lib/services/share/share.dart index 84ea92653..750dd379f 100644 --- a/lib/services/share.dart +++ b/lib/services/share/share.dart @@ -5,6 +5,9 @@ import 'package:Okuna/plugins/share/share.dart' as SharePlugin; import 'package:Okuna/services/localization.dart'; import 'package:Okuna/services/media/media.dart'; import 'package:Okuna/services/media/models/media_file.dart'; +import 'package:Okuna/services/share/models/share.dart'; +import 'package:Okuna/services/share/models/subscriber.dart'; +import 'package:Okuna/services/share/models/subscription.dart'; import 'package:Okuna/services/toast.dart'; import 'package:Okuna/services/validation.dart'; import 'package:async/async.dart'; @@ -242,39 +245,3 @@ class ShareOperation { } } } - -class ShareSubscriber { - final bool acceptsText; - final bool acceptsImages; - final bool acceptsVideos; - final Future Function(Share) onShare; - final void Function(MediaProcessingState, {dynamic data}) - mediaProgressCallback; - - const ShareSubscriber(this.acceptsText, this.acceptsImages, - this.acceptsVideos, this.onShare, this.mediaProgressCallback); - - bool acceptsShare(SharePlugin.Share share) { - return ((share.text == null || acceptsText) && - (share.image == null || acceptsImages) && - (share.video == null || acceptsVideos)); - } -} - -class ShareSubscription { - final VoidCallback _cancel; - - ShareSubscription(this._cancel); - - void cancel() { - _cancel(); - } -} - -class Share { - final String text; - final File image; - final File video; - - const Share({this.text, this.image, this.video}); -} \ No newline at end of file From 21b380443b7275b3fec7d34a8ad083a5b9017258 Mon Sep 17 00:00:00 2001 From: Komposten Date: Sat, 16 May 2020 18:27:01 +0200 Subject: [PATCH 05/12] :sparkles: Display progress indicator when media is shared to a post modal --- .../home/modals/save_post/create_post.dart | 8 ++-- lib/services/share/share.dart | 40 ++++++++----------- 2 files changed, 19 insertions(+), 29 deletions(-) diff --git a/lib/pages/home/modals/save_post/create_post.dart b/lib/pages/home/modals/save_post/create_post.dart index 22f93265d..7b6e56284 100644 --- a/lib/pages/home/modals/save_post/create_post.dart +++ b/lib/pages/home/modals/save_post/create_post.dart @@ -176,7 +176,7 @@ class OBSavePostModalState extends OBContextualSearchBoxState { _textController.removeListener(_onPostTextChanged); _focusNode.removeListener(_onFocusNodeChanged); _saveOperation?.cancel(); - _shareSubscription.cancel(); + _shareSubscription?.cancel(); } @override @@ -564,8 +564,6 @@ class OBSavePostModalState extends OBContextualSearchBoxState { } Future _onShare(Share share) async { - _removePostMedia(); - if (share.text != null) { _textController.value = TextEditingValue( text: share.text, @@ -573,11 +571,11 @@ class OBSavePostModalState extends OBContextualSearchBoxState { ); } - if (share.image != null) { + if (share.image != null && share.image.path != _mediaPreview.file?.path) { _setPostImageFile(share.image); } - if (share.video != null) { + if (share.video != null && share.video.path != _mediaPreview.file?.path) { _setPostVideoFile(share.video); } diff --git a/lib/services/share/share.dart b/lib/services/share/share.dart index 750dd379f..57478b648 100644 --- a/lib/services/share/share.dart +++ b/lib/services/share/share.dart @@ -66,12 +66,11 @@ class ShareService { /// Subscribe to share events. /// - /// [onShare] should a [CancelableOperation] if some amount of work has to be - /// done first (like gif to video conversion in OBSavePostModal) to process - /// the share, otherwise [null] is expected. + /// [onShare] should return a [CancelableOperation] if some amount of work has + /// to be done to process the share to process the share. /// - /// If a [CancelableOperation] is returned, it _must_ handle cancellation - /// properly. + /// If a [CancelableOperation] is returned, it is expected to handle + /// cancellation properly. ShareSubscription subscribe({ bool acceptsText = true, bool acceptsImages = true, @@ -124,11 +123,10 @@ class ShareService { File image; File video; - // TODO(komposten) - // 1) Find first sub who can handle the share. - // 2) Ask for a media progress listener. - // 3) Run normal operations down to the sub loop. - // 4) Use the sub directly instead of the sub loop. + //TODO(komposten): Cancelling the share op should cancel processMedia! + + ShareSubscriber subscriber = + _subscribers.lastWhere((sub) => sub.acceptsShare(share)); if (share.error != null) { _toastService.error( @@ -144,6 +142,7 @@ class ShareService { var processedFile = await _mediaService.processMedia( media: MediaFile(image, FileType.image), context: _context, + onProgress: subscriber.mediaProgressCallback, ); image = processedFile.file; } @@ -154,6 +153,7 @@ class ShareService { var processedFile = await _mediaService.processMedia( media: MediaFile(video, FileType.video), context: _context, + onProgress: subscriber.mediaProgressCallback, ); video = processedFile.file; @@ -172,21 +172,13 @@ class ShareService { var newShare = Share(text: text, image: image, video: video); - for (var sub in _subscribers.reversed) { - if (_activeShares[share].isCancelled) { - break; - } - - var subResult = await sub.onShare(newShare); + if (_activeShares[share].isCancelled) { + return; + } - // Stop event propagation if we have a sub-result that is either true or - // a CancelableOperation. - if (subResult is CancelableOperation) { - _activeShares[share].setSubOperation(subResult); - break; - } else if (subResult == true) { - break; - } + var subResult = await subscriber.onShare(newShare); + if (subResult is CancelableOperation) { + _activeShares[share].setSubOperation(subResult); } } } From 224900c8bb91de1ee2b80ea9138cdb1b6bee71ed Mon Sep 17 00:00:00 2001 From: Komposten Date: Mon, 18 May 2020 17:56:11 +0200 Subject: [PATCH 06/12] :sparkles: Add EventService --- lib/provider.dart | 2 ++ lib/services/event/event.dart | 35 +++++++++++++++++++++ lib/services/event/models/event.dart | 8 +++++ lib/services/event/models/listener.dart | 3 ++ lib/services/event/models/subscription.dart | 9 ++++++ 5 files changed, 57 insertions(+) create mode 100644 lib/services/event/event.dart create mode 100644 lib/services/event/models/event.dart create mode 100644 lib/services/event/models/listener.dart create mode 100644 lib/services/event/models/subscription.dart diff --git a/lib/provider.dart b/lib/provider.dart index 37ae8d301..6a0f2808c 100644 --- a/lib/provider.dart +++ b/lib/provider.dart @@ -12,6 +12,7 @@ import 'package:Okuna/services/devices_api.dart'; import 'package:Okuna/services/dialog.dart'; import 'package:Okuna/services/documents.dart'; import 'package:Okuna/services/draft.dart'; +import 'package:Okuna/services/event/event.dart'; import 'package:Okuna/services/explore_timeline_preferences.dart'; import 'package:Okuna/services/hashtags_api.dart'; import 'package:Okuna/services/intercom.dart'; @@ -121,6 +122,7 @@ class OpenbookProviderState extends State { ConnectivityService connectivityService = ConnectivityService(); LinkPreviewService linkPreviewService = LinkPreviewService(); DraftService draftService = DraftService(); + EventService eventService = EventService(); SentryClient sentryClient; diff --git a/lib/services/event/event.dart b/lib/services/event/event.dart new file mode 100644 index 000000000..a2b938524 --- /dev/null +++ b/lib/services/event/event.dart @@ -0,0 +1,35 @@ +import 'models/event.dart'; +import 'models/listener.dart'; +import 'models/subscription.dart'; + +class EventService { + Map> subscribers; + + /// Register a subscriber for a specific event type. + /// By default the subscriber will be added to the front of the subscriber list + /// (and thus receive new events first). Use [append] if the subscriber should be + /// added to the end of the list instead. + EventSubscription subscribe(EventListener listener, + {bool append = false}) { + var subList = subscribers.putIfAbsent(T, () => []); + + if (!append) { + subList.insert(0, listener); + } else { + subList.add(listener); + } + + return EventSubscription(() => subList.remove(listener)); + } + + Future post(Event event) async { + var subList = subscribers[event.runtimeType] ?? []; + for (var sub in subList) { + await sub(event); + + if (event.consumed) { + break; + } + } + } +} \ No newline at end of file diff --git a/lib/services/event/models/event.dart b/lib/services/event/models/event.dart new file mode 100644 index 000000000..32e906f48 --- /dev/null +++ b/lib/services/event/models/event.dart @@ -0,0 +1,8 @@ +abstract class Event { + bool _consumed = false; + + bool get consumed => _consumed; + + /// Consumes the event so that it will not be sent to any further subscribers. + void consume() => _consumed = true; +} \ No newline at end of file diff --git a/lib/services/event/models/listener.dart b/lib/services/event/models/listener.dart new file mode 100644 index 000000000..b5d55c871 --- /dev/null +++ b/lib/services/event/models/listener.dart @@ -0,0 +1,3 @@ +import 'event.dart'; + +typedef Future EventListener(T event); \ No newline at end of file diff --git a/lib/services/event/models/subscription.dart b/lib/services/event/models/subscription.dart new file mode 100644 index 000000000..b3691fcfb --- /dev/null +++ b/lib/services/event/models/subscription.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; + +class EventSubscription { + /// Cancels this subscription so that it will no longer receive events. + /// A cancelled subscription cannot be resumed. + final VoidCallback cancel; + + const EventSubscription(this.cancel); +} \ No newline at end of file From 789cf7e42a781954784134317d0823f4ba55cbcc Mon Sep 17 00:00:00 2001 From: Komposten Date: Mon, 18 May 2020 21:19:51 +0200 Subject: [PATCH 07/12] :recycle: Re-write ShareService and MediaService to use EventService --- lib/pages/home/home.dart | 40 +++++---- .../home/modals/save_post/create_post.dart | 82 ++++++++++--------- lib/provider.dart | 2 + lib/services/event/event.dart | 33 +++++++- lib/services/media/media.dart | 49 ++++++----- lib/services/media/models/media_event.dart | 10 +++ lib/services/share/models/share_event.dart | 11 +++ lib/services/share/models/subscriber.dart | 21 ----- lib/services/share/models/subscription.dart | 11 --- lib/services/share/share.dart | 72 ++++------------ 10 files changed, 158 insertions(+), 173 deletions(-) create mode 100644 lib/services/media/models/media_event.dart create mode 100644 lib/services/share/models/share_event.dart delete mode 100644 lib/services/share/models/subscriber.dart delete mode 100644 lib/services/share/models/subscription.dart diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 87a158c2b..200b209fc 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -1,25 +1,24 @@ import 'dart:async'; import 'package:Okuna/models/push_notification.dart'; -import 'package:Okuna/pages/home/lib/poppable_page_controller.dart'; -import 'package:Okuna/services/intercom.dart'; -import 'package:Okuna/services/media/media.dart'; -import 'package:Okuna/services/push_notifications/push_notifications.dart'; import 'package:Okuna/models/user.dart'; +import 'package:Okuna/pages/home/lib/poppable_page_controller.dart'; import 'package:Okuna/pages/home/pages/communities/communities.dart'; +import 'package:Okuna/pages/home/pages/menu/menu.dart'; import 'package:Okuna/pages/home/pages/notifications/notifications.dart'; import 'package:Okuna/pages/home/pages/own_profile.dart'; -import 'package:Okuna/pages/home/pages/timeline/timeline.dart'; -import 'package:Okuna/pages/home/pages/menu/menu.dart'; import 'package:Okuna/pages/home/pages/search/search.dart'; +import 'package:Okuna/pages/home/pages/timeline/timeline.dart'; import 'package:Okuna/pages/home/widgets/bottom-tab-bar.dart'; import 'package:Okuna/pages/home/widgets/own_profile_active_icon.dart'; import 'package:Okuna/pages/home/widgets/tab-scaffold.dart'; import 'package:Okuna/provider.dart'; +import 'package:Okuna/services/event/event.dart'; import 'package:Okuna/services/httpie.dart'; +import 'package:Okuna/services/intercom.dart'; import 'package:Okuna/services/modal_service.dart'; -import 'package:Okuna/services/share/models/share.dart'; -import 'package:Okuna/services/share/share.dart'; +import 'package:Okuna/services/push_notifications/push_notifications.dart'; +import 'package:Okuna/services/share/models/share_event.dart'; import 'package:Okuna/services/toast.dart'; import 'package:Okuna/services/user.dart'; import 'package:Okuna/services/user_preferences.dart'; @@ -47,8 +46,7 @@ class OBHomePageState extends State IntercomService _intercomService; ModalService _modalService; UserPreferencesService _userPreferencesService; - ShareService _shareService; - MediaService _mediaService; + EventService _eventService; int _currentIndex; int _lastIndex; @@ -112,8 +110,7 @@ class OBHomePageState extends State _toastService = openbookProvider.toastService; _modalService = openbookProvider.modalService; _userPreferencesService = openbookProvider.userPreferencesService; - _shareService = openbookProvider.shareService; - _mediaService = openbookProvider.mediaService; + _eventService = openbookProvider.eventService; _bootstrap(); _needsBootstrap = false; } @@ -331,7 +328,7 @@ class OBHomePageState extends State } } - _shareService.subscribe(onShare: _onShare); + _eventService.subscribe(_onShareEvent); } Future _logout({unsubscribePushNotifications = false}) async { @@ -433,16 +430,17 @@ class OBHomePageState extends State //_navigateToTab(OBHomePageTabs.notifications); } - Future _onShare(Share share) async { - bool postCreated = await _timelinePageController.createPost( - text: share.text, image: share.image, video: share.video); + Future _onShareEvent(ShareEvent event) async { + if (event.status == ShareStatus.received) { + bool postCreated = await _timelinePageController.createPost(); - if (postCreated) { - _timelinePageController.popUntilFirstRoute(); - _navigateToTab(OBHomePageTabs.timeline); - } + if (postCreated) { + _timelinePageController.popUntilFirstRoute(); + _navigateToTab(OBHomePageTabs.timeline); + } - return true; + event.consume(); + } } void _navigateToTab(OBHomePageTabs tab) { diff --git a/lib/pages/home/modals/save_post/create_post.dart b/lib/pages/home/modals/save_post/create_post.dart index 7b6e56284..03cadf6f0 100644 --- a/lib/pages/home/modals/save_post/create_post.dart +++ b/lib/pages/home/modals/save_post/create_post.dart @@ -14,15 +14,17 @@ import 'package:Okuna/pages/home/modals/save_post/widgets/post_video_previewer.d import 'package:Okuna/pages/home/modals/save_post/widgets/remaining_post_characters.dart'; import 'package:Okuna/provider.dart'; import 'package:Okuna/services/draft.dart'; +import 'package:Okuna/services/event/event.dart'; +import 'package:Okuna/services/event/models/subscription.dart'; import 'package:Okuna/services/httpie.dart'; import 'package:Okuna/services/link_preview.dart'; import 'package:Okuna/services/localization.dart'; import 'package:Okuna/services/media/media.dart'; +import 'package:Okuna/services/media/models/media_event.dart'; import 'package:Okuna/services/media/models/media_file.dart'; import 'package:Okuna/services/navigation_service.dart'; import 'package:Okuna/services/share/models/share.dart'; -import 'package:Okuna/services/share/models/subscription.dart'; -import 'package:Okuna/services/share/share.dart'; +import 'package:Okuna/services/share/models/share_event.dart'; import 'package:Okuna/services/toast.dart'; import 'package:Okuna/services/user.dart'; import 'package:Okuna/services/validation.dart'; @@ -70,9 +72,10 @@ class OBSavePostModalState extends OBContextualSearchBoxState { LocalizationService _localizationService; LinkPreviewService _linkPreviewService; DraftService _draftService; - ShareService _shareService; + EventService _eventService; - ShareSubscription _shareSubscription; + EventSubscription _shareSubscription; + EventSubscription _mediaSubscription; TextEditingController _textController; FocusNode _focusNode; @@ -165,8 +168,8 @@ class OBSavePostModalState extends OBContextualSearchBoxState { _isPostTextContainingValidHashtags = _validationService .isPostTextContainingValidHashtags(_textController.text); if (!_isEditingPost) { - _shareSubscription = _shareService.subscribe( - onShare: _onShare, onMediaProgress: _onMediaProgress); + _shareSubscription = _eventService.subscribe(_onShareEvent); + _mediaSubscription = _eventService.subscribe(_onMediaEvent); } } @@ -177,6 +180,7 @@ class OBSavePostModalState extends OBContextualSearchBoxState { _focusNode.removeListener(_onFocusNodeChanged); _saveOperation?.cancel(); _shareSubscription?.cancel(); + _mediaSubscription?.cancel(); } @override @@ -191,7 +195,7 @@ class OBSavePostModalState extends OBContextualSearchBoxState { _toastService = openbookProvider.toastService; _userService = openbookProvider.userService; _draftService = openbookProvider.draftService; - _shareService = openbookProvider.shareService; + _eventService = openbookProvider.eventService; bootstrap(); _needsBootstrap = false; } @@ -399,8 +403,7 @@ class OBSavePostModalState extends OBContextualSearchBoxState { await _mediaService.pickMedia( context: context, source: ImageSource.gallery, - flattenGifs: false, - onProgress: _onMediaProgress); + flattenGifs: false); } catch (error) { _onError(error); } @@ -415,8 +418,7 @@ class OBSavePostModalState extends OBContextualSearchBoxState { try { await _mediaService.pickMedia( context: context, - source: ImageSource.camera, - onProgress: _onMediaProgress); + source: ImageSource.camera); } catch (error) { _onError(error); } @@ -441,12 +443,12 @@ class OBSavePostModalState extends OBContextualSearchBoxState { _hasFocus = _focusNode.hasFocus; } - void _onMediaProgress(MediaProcessingState state, {dynamic data}) { + Future _onMediaEvent(MediaProcessEvent event) async { if (!mounted) { return; } - if (state == MediaProcessingState.processing) { + if (event.state == MediaProcessingState.processing) { /* TODO(komposten): Improve this part It would be preferable if the previous media was restored if this new media was cancelled before finishing processing. @@ -459,31 +461,31 @@ class OBSavePostModalState extends OBContextualSearchBoxState { _mediaPreview = _MediaPreview( type: _MediaType.pending, preview: postMediaWidget, remover: remover); - } else if (state == MediaProcessingState.cancelled) { + } else if (event.state == MediaProcessingState.cancelled) { /* TODO(komposten): Ideally, calling pickMedia should create an operation which we can then cancel to end up here. That way starting a new media process can cause the previous one to cancel and this elif to remove data associated with it. */ - } else if (state == MediaProcessingState.error) { - _toastService.error(message: data, context: context); - } else if (state == MediaProcessingState.finished) { - var pickedMedia = data as MediaFile; + _removePostMedia(); + _isProcessingMedia = false; + } else if (event.state == MediaProcessingState.error) { + _toastService.error(message: event.data, context: context); + _isProcessingMedia = false; + } else if (event.state == MediaProcessingState.finished) { + var pickedMedia = event.data as MediaFile; if (pickedMedia.type == FileType.image) { - _isProcessingMedia = false; - _setPostImageFile(pickedMedia.file); } else if (pickedMedia.type == FileType.video) { - _isProcessingMedia = false; - _setPostVideoFile(pickedMedia.file); } else { - _isProcessingMedia = false; _removePostMedia(); _toastService.error( message: 'An unsupported media type was picked!', context: context); } + + _isProcessingMedia = false; } } @@ -563,23 +565,29 @@ class OBSavePostModalState extends OBContextualSearchBoxState { type: _MediaType.image, preview: postImageWidget, image: postImage); } - Future _onShare(Share share) async { - if (share.text != null) { - _textController.value = TextEditingValue( - text: share.text, - selection: TextSelection.collapsed(offset: share.text.length), - ); - } + Future _onShareEvent(ShareEvent event) async { + if (event.status == ShareStatus.received) { + event.consume(); + } else if (event.status == ShareStatus.processed) { + Share share = event.data; - if (share.image != null && share.image.path != _mediaPreview.file?.path) { - _setPostImageFile(share.image); - } + if (share.text != null) { + _textController.value = TextEditingValue( + text: share.text, + selection: TextSelection.collapsed(offset: share.text.length), + ); + } - if (share.video != null && share.video.path != _mediaPreview.file?.path) { - _setPostVideoFile(share.video); - } + if (share.image != null && share.image.path != _mediaPreview.file?.path) { + _setPostImageFile(share.image); + } - return true; + if (share.video != null && share.video.path != _mediaPreview.file?.path) { + _setPostVideoFile(share.video); + } + + event.consume(); + } } Future _createCommunityPost() async { diff --git a/lib/provider.dart b/lib/provider.dart index 6a0f2808c..5d98bef6c 100644 --- a/lib/provider.dart +++ b/lib/provider.dart @@ -197,6 +197,7 @@ class OpenbookProviderState extends State { mediaService.setBottomSheetService(bottomSheetService); mediaService.setPermissionsService(permissionService); mediaService.setUtilsService(utilsService); + mediaService.setEventService(eventService); documentsService.setHttpService(httpService); moderationApiService.setStringTemplateService(stringTemplateService); moderationApiService.setHttpieService(httpService); @@ -206,6 +207,7 @@ class OpenbookProviderState extends State { shareService.setMediaService(mediaService); shareService.setToastService(toastService); shareService.setValidationService(validationService); + shareService.setEventService(eventService); permissionService.setToastService(toastService); hashtagsApiService.setHttpieService(httpService); hashtagsApiService.setStringTemplateService(stringTemplateService); diff --git a/lib/services/event/event.dart b/lib/services/event/event.dart index a2b938524..ec745abc5 100644 --- a/lib/services/event/event.dart +++ b/lib/services/event/event.dart @@ -3,7 +3,7 @@ import 'models/listener.dart'; import 'models/subscription.dart'; class EventService { - Map> subscribers; + Map> subscribers = {}; /// Register a subscriber for a specific event type. /// By default the subscriber will be added to the front of the subscriber list @@ -11,7 +11,7 @@ class EventService { /// added to the end of the list instead. EventSubscription subscribe(EventListener listener, {bool append = false}) { - var subList = subscribers.putIfAbsent(T, () => []); + var subList = subscribers.putIfAbsent(T, () => >[]); if (!append) { subList.insert(0, listener); @@ -19,7 +19,17 @@ class EventService { subList.add(listener); } - return EventSubscription(() => subList.remove(listener)); + post(SubscriptionEvent(T, subList.length, subList.length - 1)); + + return EventSubscription(() => _unsubscribe(T, listener)); + } + + void _unsubscribe(Type eventType, dynamic listener) { + var subList = subscribers[eventType]; + + if (subList != null && subList.remove(listener)) { + post(SubscriptionEvent(eventType, subList.length, subList.length + 1)); + } } Future post(Event event) async { @@ -32,4 +42,19 @@ class EventService { } } } -} \ No newline at end of file + + /// Returns the number of subscribers for the event type [T]. + int subscriberCount(Type eventType) { + return subscribers[eventType]?.length ?? 0; + } +} + +/// An event which is send out every time a subscriber is added or removed. +class SubscriptionEvent extends Event { + final Type eventType; + final int newSubscriberCount; + final int oldSubscriberCount; + + SubscriptionEvent( + this.eventType, this.newSubscriberCount, this.oldSubscriberCount); +} diff --git a/lib/services/media/media.dart b/lib/services/media/media.dart index addba3678..18fe3dc31 100644 --- a/lib/services/media/media.dart +++ b/lib/services/media/media.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:Okuna/plugins/image_converter/image_converter.dart'; import 'package:Okuna/services/bottom_sheet.dart'; +import 'package:Okuna/services/event/event.dart'; import 'package:Okuna/services/localization.dart'; import 'package:Okuna/services/media/models/media_file.dart'; import 'package:Okuna/services/permissions.dart'; @@ -22,6 +23,8 @@ import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; import 'package:video_thumbnail/video_thumbnail.dart'; +import 'models/media_event.dart'; + export 'package:image_picker/image_picker.dart'; class MediaService { @@ -40,6 +43,7 @@ class MediaService { ToastService _toastService; PermissionsService _permissionsService; UtilsService _utilsService; + EventService _eventService; void setLocalizationService(LocalizationService localizationService) { _localizationService = localizationService; @@ -65,6 +69,10 @@ class MediaService { _permissionsService = permissionsService; } + void setEventService(EventService eventService) { + _eventService = eventService; + } + /// Opens a bottom sheet with the options to pick either an image or a video. /// The media is picked from the gallery or camera (determined by [source]). /// @@ -75,11 +83,10 @@ class MediaService { Future pickMedia( {@required BuildContext context, @required ImageSource source, - bool flattenGifs, - void Function(MediaProcessingState, {dynamic data}) onProgress}) async { + bool flattenGifs}) async { MediaFile media; - onProgress?.call(MediaProcessingState.picking); + _eventService.post(MediaProcessEvent(MediaProcessingState.picking)); if (source == ImageSource.gallery) { bool permissionGranted = await _permissionsService.requestStoragePermissions(context: context); @@ -94,12 +101,12 @@ class MediaService { } else if (source == ImageSource.camera) { media = await _bottomSheetService.showCameraPicker(context: context); } else { - onProgress?.call(MediaProcessingState.error, - data: 'Unsupported media source: $source'); + _eventService.post(MediaProcessEvent(MediaProcessingState.error, + data: 'Unsupported media source: $source')); } if (media == null) { - onProgress?.call(MediaProcessingState.cancelled); + _eventService.post(MediaProcessEvent(MediaProcessingState.cancelled)); return null; } @@ -107,7 +114,6 @@ class MediaService { media: media, context: context, flattenGifs: flattenGifs, - onProgress: onProgress, ); return media; @@ -120,15 +126,14 @@ class MediaService { /// be flattened. Future pickImage( {@required OBImageType imageType, - @required BuildContext context, - void Function(MediaProcessingState, {dynamic data}) onProgress}) async { - onProgress?.call(MediaProcessingState.picking); + @required BuildContext context}) async { + _eventService.post(MediaProcessEvent(MediaProcessingState.picking)); File pickedImage = await _bottomSheetService.showImagePicker(context: context); if (pickedImage == null) { - onProgress?.call(MediaProcessingState.cancelled); + _eventService.post(MediaProcessEvent(MediaProcessingState.cancelled)); return null; } @@ -137,7 +142,6 @@ class MediaService { context: context, flattenGifs: true, imageType: imageType, - onProgress: onProgress, ); return media.file; @@ -148,22 +152,20 @@ class MediaService { /// /// The returned file should always point to a video. Future pickVideo( - {@required BuildContext context, - void Function(MediaProcessingState, {dynamic data}) onProgress}) async { - onProgress?.call(MediaProcessingState.picking); + {@required BuildContext context}) async { + _eventService.post(MediaProcessEvent(MediaProcessingState.picking)); File pickedVideo = await _bottomSheetService.showVideoPicker(context: context); if (pickedVideo == null) { - onProgress?.call(MediaProcessingState.cancelled); + _eventService.post(MediaProcessEvent(MediaProcessingState.cancelled)); return null; } var media = await processMedia( media: MediaFile(pickedVideo, FileType.video), context: context, - onProgress: onProgress, ); return media.file; @@ -173,12 +175,11 @@ class MediaService { {@required MediaFile media, @required BuildContext context, bool flattenGifs = false, - OBImageType imageType = OBImageType.post, - void Function(MediaProcessingState, {dynamic data}) onProgress}) async { + OBImageType imageType = OBImageType.post}) async { var mediaType = media.type; MediaFile result; - onProgress?.call(MediaProcessingState.processing); + _eventService.post(MediaProcessEvent(MediaProcessingState.processing)); // Copy the media to a temporary location. final tempPath = await _getTempPath(); @@ -206,11 +207,11 @@ class MediaService { } else if (mediaType == FileType.video) { result = await _processVideo(copiedMedia); } else { - onProgress?.call(MediaProcessingState.error, - data: 'Unsupported media type: ${media.type}'); + _eventService.post(MediaProcessEvent(MediaProcessingState.error, + data: 'Unsupported media type: ${media.type}')); } - onProgress?.call(MediaProcessingState.finished, data: result); + _eventService.post(MediaProcessEvent(MediaProcessingState.finished, data: result)); return result; } @@ -457,5 +458,3 @@ class FileTooLargeException implements Exception { } enum OBImageType { avatar, cover, post } - -enum MediaProcessingState { picking, processing, finished, cancelled, error } diff --git a/lib/services/media/models/media_event.dart b/lib/services/media/models/media_event.dart new file mode 100644 index 000000000..118f919b0 --- /dev/null +++ b/lib/services/media/models/media_event.dart @@ -0,0 +1,10 @@ +import 'package:Okuna/services/event/models/event.dart'; + +class MediaProcessEvent extends Event { + final MediaProcessingState state; + final dynamic data; + + MediaProcessEvent(this.state, {this.data}); +} + +enum MediaProcessingState { picking, processing, finished, cancelled, error } \ No newline at end of file diff --git a/lib/services/share/models/share_event.dart b/lib/services/share/models/share_event.dart new file mode 100644 index 000000000..23442a38f --- /dev/null +++ b/lib/services/share/models/share_event.dart @@ -0,0 +1,11 @@ +import 'package:Okuna/services/event/models/event.dart'; +import 'package:Okuna/services/share/models/share.dart'; + +class ShareEvent extends Event { + final ShareStatus status; + final Share data; + + ShareEvent(this.status, {this.data}); +} + +enum ShareStatus { received, processed, cancelled } \ No newline at end of file diff --git a/lib/services/share/models/subscriber.dart b/lib/services/share/models/subscriber.dart deleted file mode 100644 index 6addef9fc..000000000 --- a/lib/services/share/models/subscriber.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:Okuna/plugins/share/share.dart' as SharePlugin; -import 'package:Okuna/services/media/media.dart'; -import 'package:Okuna/services/share/models/share.dart'; - -class ShareSubscriber { - final bool acceptsText; - final bool acceptsImages; - final bool acceptsVideos; - final Future Function(Share) onShare; - final void Function(MediaProcessingState, {dynamic data}) - mediaProgressCallback; - - const ShareSubscriber(this.acceptsText, this.acceptsImages, - this.acceptsVideos, this.onShare, this.mediaProgressCallback); - - bool acceptsShare(SharePlugin.Share share) { - return ((share.text == null || acceptsText) && - (share.image == null || acceptsImages) && - (share.video == null || acceptsVideos)); - } -} diff --git a/lib/services/share/models/subscription.dart b/lib/services/share/models/subscription.dart deleted file mode 100644 index c4a924323..000000000 --- a/lib/services/share/models/subscription.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:flutter/cupertino.dart'; - -class ShareSubscription { - final VoidCallback _cancel; - - ShareSubscription(this._cancel); - - void cancel() { - _cancel(); - } -} diff --git a/lib/services/share/share.dart b/lib/services/share/share.dart index 57478b648..61cee6ed6 100644 --- a/lib/services/share/share.dart +++ b/lib/services/share/share.dart @@ -2,12 +2,12 @@ import 'dart:async'; import 'dart:io'; import 'package:Okuna/plugins/share/share.dart' as SharePlugin; +import 'package:Okuna/services/event/event.dart'; import 'package:Okuna/services/localization.dart'; import 'package:Okuna/services/media/media.dart'; import 'package:Okuna/services/media/models/media_file.dart'; import 'package:Okuna/services/share/models/share.dart'; -import 'package:Okuna/services/share/models/subscriber.dart'; -import 'package:Okuna/services/share/models/subscription.dart'; +import 'package:Okuna/services/share/models/share_event.dart'; import 'package:Okuna/services/toast.dart'; import 'package:Okuna/services/validation.dart'; import 'package:async/async.dart'; @@ -22,9 +22,9 @@ class ShareService { MediaService _mediaService; ValidationService _validationService; LocalizationService _localizationService; + EventService _eventService; StreamSubscription _shareReceiveSubscription; - List _subscribers; SharePlugin.Share _queuedShare; bool _isProcessingShare = false; @@ -33,7 +33,6 @@ class ShareService { BuildContext _context; ShareService() { - _subscribers = []; _activeShares = {}; if (Platform.isAndroid) { @@ -60,39 +59,25 @@ class ShareService { _mediaService = mediaService; } + void setEventService(EventService eventService) { + _eventService = eventService; + _eventService.subscribe(_onSubscriberEvent); + } + void setContext(BuildContext context) { _context = context; } - /// Subscribe to share events. - /// - /// [onShare] should return a [CancelableOperation] if some amount of work has - /// to be done to process the share to process the share. - /// - /// If a [CancelableOperation] is returned, it is expected to handle - /// cancellation properly. - ShareSubscription subscribe({ - bool acceptsText = true, - bool acceptsImages = true, - bool acceptsVideos = true, - @required Future Function(Share) onShare, - void Function(MediaProcessingState state, {dynamic data}) onMediaProgress, - }) { - var subscriber = ShareSubscriber( - acceptsText, acceptsImages, acceptsVideos, onShare, onMediaProgress); - _subscribers.add(subscriber); - - if (_subscribers.length == 1) { + Future _onSubscriberEvent(SubscriptionEvent event) async { + if (event.oldSubscriberCount == 0 && event.newSubscriberCount > 0) { _processQueuedShare(); } - - return ShareSubscription(() => _subscribers.remove(subscriber)); } void _onReceiveShare(dynamic shared) async { _queuedShare = SharePlugin.Share.fromReceived(shared); - if (_subscribers.isNotEmpty && !_isProcessingShare) { + if (_eventService.subscriberCount(SubscriptionEvent) > 0 && !_isProcessingShare) { _processQueuedShare(); } } @@ -123,11 +108,13 @@ class ShareService { File image; File video; - //TODO(komposten): Cancelling the share op should cancel processMedia! + await _eventService.post(ShareEvent(ShareStatus.received)); - ShareSubscriber subscriber = - _subscribers.lastWhere((sub) => sub.acceptsShare(share)); + //TODO(komposten): Cancelling the share op should cancel processMedia! + /*TODO(komposten): Send a cancel or failure event if an exception is thrown + at any point (i.e. share.error != null, image/video too big, or text too long. + */ if (share.error != null) { _toastService.error( message: _localizationService.trans(share.error), context: _context); @@ -142,7 +129,6 @@ class ShareService { var processedFile = await _mediaService.processMedia( media: MediaFile(image, FileType.image), context: _context, - onProgress: subscriber.mediaProgressCallback, ); image = processedFile.file; } @@ -153,7 +139,6 @@ class ShareService { var processedFile = await _mediaService.processMedia( media: MediaFile(video, FileType.video), context: _context, - onProgress: subscriber.mediaProgressCallback, ); video = processedFile.file; @@ -176,10 +161,7 @@ class ShareService { return; } - var subResult = await subscriber.onShare(newShare); - if (subResult is CancelableOperation) { - _activeShares[share].setSubOperation(subResult); - } + _eventService.post(ShareEvent(ShareStatus.processed, data: newShare)); } } @@ -188,11 +170,9 @@ class ShareOperation { SharePlugin.Share share; CancelableOperation shareOperation; - CancelableOperation subOperation; bool isCancelled = false; bool _shareComplete = false; - bool _subComplete = false; FutureOr Function() _callback; ShareOperation(this.share, Future Function(SharePlugin.Share) shareFunction) @@ -206,24 +186,9 @@ class ShareOperation { }); } - void setSubOperation(CancelableOperation operation) { - subOperation = operation; - subOperation.then((_) { - _subComplete = true; - _complete(); - }); - - shareOperation.then((_) { - if (shareOperation.isCanceled) { - subOperation.cancel(); - } - }); - } - void cancel() { isCancelled = true; shareOperation?.cancel(); - subOperation?.cancel(); } void then(FutureOr Function() callback) { @@ -231,8 +196,7 @@ class ShareOperation { } void _complete() { - if ((subOperation == null || _subComplete) && - (shareOperation == null || _shareComplete)) { + if (shareOperation == null || _shareComplete) { _callback(); } } From d307680ef825828b0242f3c18502810e289bc0de Mon Sep 17 00:00:00 2001 From: Komposten Date: Mon, 18 May 2020 21:57:04 +0200 Subject: [PATCH 08/12] :bug: Fix shares to timeline not showing the media progress indicator in the post modal --- lib/pages/home/home.dart | 36 +++++++++++++++---- .../home/modals/save_post/create_post.dart | 15 ++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 200b209fc..67f529f79 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:Okuna/models/push_notification.dart'; import 'package:Okuna/models/user.dart'; import 'package:Okuna/pages/home/lib/poppable_page_controller.dart'; +import 'package:Okuna/pages/home/modals/save_post/create_post.dart'; import 'package:Okuna/pages/home/pages/communities/communities.dart'; import 'package:Okuna/pages/home/pages/menu/menu.dart'; import 'package:Okuna/pages/home/pages/notifications/notifications.dart'; @@ -14,6 +15,7 @@ import 'package:Okuna/pages/home/widgets/own_profile_active_icon.dart'; import 'package:Okuna/pages/home/widgets/tab-scaffold.dart'; import 'package:Okuna/provider.dart'; import 'package:Okuna/services/event/event.dart'; +import 'package:Okuna/services/event/models/subscription.dart'; import 'package:Okuna/services/httpie.dart'; import 'package:Okuna/services/intercom.dart'; import 'package:Okuna/services/modal_service.dart'; @@ -64,6 +66,9 @@ class OBHomePageState extends State OBCommunitiesPageController _communitiesPageController; OBNotificationsPageController _notificationsPageController; + Completer _postModalCompleter; + EventSubscription _postModalEventSubscription; + int _loggedInUserUnreadNotifications; String _loggedInUserAvatarUrl; @@ -432,14 +437,33 @@ class OBHomePageState extends State Future _onShareEvent(ShareEvent event) async { if (event.status == ShareStatus.received) { - bool postCreated = await _timelinePageController.createPost(); + event.consume(); - if (postCreated) { - _timelinePageController.popUntilFirstRoute(); - _navigateToTab(OBHomePageTabs.timeline); - } + _postModalCompleter = new Completer(); - event.consume(); + // Subscribe to post modal events so we know when the post modal is opened + // and closed. + _postModalEventSubscription = _eventService.subscribe(_onPostModalEvent); + _timelinePageController.createPost(); + + // Wait for the post modal to open before returning to ensure it has + // registered its event listeners before the share is processed further. + await _postModalCompleter.future; + _postModalCompleter = null; + } + } + + Future _onPostModalEvent(SavePostModalEvent event) async { + if (event.state == PostModalState.opened) { + if (!_postModalCompleter.isCompleted) { + _postModalCompleter?.complete(); + } + } else if (event.state == PostModalState.published) { + _timelinePageController.popUntilFirstRoute(); + _navigateToTab(OBHomePageTabs.timeline); + _postModalEventSubscription.cancel(); + } else if (event.state == PostModalState.cancelled) { + _postModalEventSubscription.cancel(); } } diff --git a/lib/pages/home/modals/save_post/create_post.dart b/lib/pages/home/modals/save_post/create_post.dart index 03cadf6f0..204afdb2d 100644 --- a/lib/pages/home/modals/save_post/create_post.dart +++ b/lib/pages/home/modals/save_post/create_post.dart @@ -15,6 +15,7 @@ import 'package:Okuna/pages/home/modals/save_post/widgets/remaining_post_charact import 'package:Okuna/provider.dart'; import 'package:Okuna/services/draft.dart'; import 'package:Okuna/services/event/event.dart'; +import 'package:Okuna/services/event/models/event.dart'; import 'package:Okuna/services/event/models/subscription.dart'; import 'package:Okuna/services/httpie.dart'; import 'package:Okuna/services/link_preview.dart'; @@ -171,6 +172,8 @@ class OBSavePostModalState extends OBContextualSearchBoxState { _shareSubscription = _eventService.subscribe(_onShareEvent); _mediaSubscription = _eventService.subscribe(_onMediaEvent); } + + _eventService.post(SavePostModalEvent(PostModalState.opened)); } @override @@ -242,6 +245,7 @@ class OBSavePostModalState extends OBContextualSearchBoxState { onTap: () { _removePostMedia(); Navigator.pop(context); + _eventService.post(SavePostModalEvent(PostModalState.cancelled)); }, ), title: _isEditingPost @@ -319,6 +323,7 @@ class OBSavePostModalState extends OBContextualSearchBoxState { _mediaService.clearThumbnailForFile(_mediaPreview.file); Navigator.pop(context, createPostData); _clearDraft(); + _eventService.post(SavePostModalEvent(PostModalState.published)); } } @@ -596,6 +601,7 @@ class OBSavePostModalState extends OBContextualSearchBoxState { _mediaService.clearThumbnailForFile(_mediaPreview.file); Navigator.pop(context, newPostData); _clearDraft(); + _eventService.post(SavePostModalEvent(PostModalState.published)); } void _savePost() async { @@ -607,6 +613,7 @@ class OBSavePostModalState extends OBContextualSearchBoxState { editedPost = await _saveOperation.value; Navigator.pop(context, editedPost); + _eventService.post(SavePostModalEvent(PostModalState.published)); } catch (error) { _onError(error); } finally { @@ -756,3 +763,11 @@ class _MediaPreview { } enum _MediaType { image, video, link, pending } + +class SavePostModalEvent extends Event { + final PostModalState state; + + SavePostModalEvent(this.state); +} + +enum PostModalState { opened, cancelled, published } \ No newline at end of file From 2766b52a3dd5e5075e9a7bcb52d5612927c7eb05 Mon Sep 17 00:00:00 2001 From: Komposten Date: Tue, 19 May 2020 19:57:30 +0200 Subject: [PATCH 09/12] :sparkles: Make it possible to cancel media processing Cancellation can happen if the user presses the 'x' on the media progress indicator or a two shares are received closely together (second one cancels the first). --- .../home/modals/save_post/create_post.dart | 8 +- lib/services/media/media.dart | 120 ++++++++++++------ lib/services/media/models/media_event.dart | 4 +- lib/services/share/share.dart | 47 +++++-- 4 files changed, 118 insertions(+), 61 deletions(-) diff --git a/lib/pages/home/modals/save_post/create_post.dart b/lib/pages/home/modals/save_post/create_post.dart index 204afdb2d..40355802c 100644 --- a/lib/pages/home/modals/save_post/create_post.dart +++ b/lib/pages/home/modals/save_post/create_post.dart @@ -460,18 +460,12 @@ class OBSavePostModalState extends OBContextualSearchBoxState { */ _removePostMedia(); _isProcessingMedia = true; - // TODO(komposten): onRemove should cancel the whole operation. - var postMediaWidget = OBPostMediaPreview(onRemove: _removePostMedia); + var postMediaWidget = OBPostMediaPreview(onRemove: event.operation.cancel); var remover = _addPostItemWidget(postMediaWidget); _mediaPreview = _MediaPreview( type: _MediaType.pending, preview: postMediaWidget, remover: remover); } else if (event.state == MediaProcessingState.cancelled) { - /* TODO(komposten): Ideally, calling pickMedia should create an operation - which we can then cancel to end up here. That way starting a new media - process can cause the previous one to cancel and this elif to remove data - associated with it. - */ _removePostMedia(); _isProcessingMedia = false; } else if (event.state == MediaProcessingState.error) { diff --git a/lib/services/media/media.dart b/lib/services/media/media.dart index 18fe3dc31..cdc0df262 100644 --- a/lib/services/media/media.dart +++ b/lib/services/media/media.dart @@ -80,7 +80,7 @@ class MediaService { /// /// The returned file may be either an image or a video. Use [MediaFile.type] /// to determine which one it is. - Future pickMedia( + Future> pickMedia( {@required BuildContext context, @required ImageSource source, bool flattenGifs}) async { @@ -103,6 +103,7 @@ class MediaService { } else { _eventService.post(MediaProcessEvent(MediaProcessingState.error, data: 'Unsupported media source: $source')); + return null; } if (media == null) { @@ -110,13 +111,13 @@ class MediaService { return null; } - media = await processMedia( + var processOp = processMedia( media: media, context: context, flattenGifs: flattenGifs, ); - return media; + return processOp; } /// Opens a bottom sheet with the option to pick an image from gallery or snap @@ -137,14 +138,17 @@ class MediaService { return null; } - var media = await processMedia( + var media = processMedia( media: MediaFile(pickedImage, FileType.image), context: context, flattenGifs: true, imageType: imageType, ); - return media.file; + // Awaiting CancelableOperation.value should be safe here since the operation + // is never cancelled (processMedia does not cancel it, and the outside never + // sees it). + return (await media.value).file; } /// Opens a bottom sheet with the option to pick a video from gallery or take @@ -163,56 +167,88 @@ class MediaService { return null; } - var media = await processMedia( + var media = processMedia( media: MediaFile(pickedVideo, FileType.video), context: context, ); - return media.file; + // Awaiting CancelableOperation.value should be safe here since the operation + // is never cancelled (processMedia does not cancel it, and the outside never + // sees it). + return (await media.value).file; } - Future processMedia( + CancelableOperation processMedia( {@required MediaFile media, @required BuildContext context, bool flattenGifs = false, - OBImageType imageType = OBImageType.post}) async { - var mediaType = media.type; - MediaFile result; + OBImageType imageType = OBImageType.post}) { + CancelableOperation gifOperation; + + // Create a cancelable completer to use for our processing operation. + var completer = + CancelableCompleter(onCancel: () async { + gifOperation?.cancel(); + await _eventService.post(MediaProcessEvent(MediaProcessingState.cancelled)); + }); + + // Start the processing work in a new Future. + var operationFuture = Future.sync(() async { + var mediaType = media.type; + MediaFile result; + + if (!completer.isCanceled) { + _eventService.post(MediaProcessEvent(MediaProcessingState.processing, + operation: completer.operation)); + } - _eventService.post(MediaProcessEvent(MediaProcessingState.processing)); + // Copy the media to a temporary location. + final tempPath = await _getTempPath(); + final String mediaUuid = _uuid.v4(); + String mediaExtension = basename(media.file.path); + var copiedFile = + media.file.copySync('$tempPath/$mediaUuid$mediaExtension'); + + if (await isGif(media.file) && !flattenGifs) { + mediaType = FileType.video; + + Completer completer = Completer(); + gifOperation = convertGifToVideo(copiedFile) + .then((file) => completer.complete(file), onError: (error, trace) { + print(error); + _toastService.error( + message: _localizationService.error__unknown_error, + context: context); + }); + copiedFile = await completer.future; + } - // Copy the media to a temporary location. - final tempPath = await _getTempPath(); - final String mediaUuid = _uuid.v4(); - String mediaExtension = basename(media.file.path); - var copiedFile = media.file.copySync('$tempPath/$mediaUuid$mediaExtension'); - - if (await isGif(media.file) && !flattenGifs) { - mediaType = FileType.video; - - Completer completer = Completer(); - convertGifToVideo(copiedFile).then((file) => completer.complete(file), - onError: (error, trace) { - print(error); - _toastService.error( - message: _localizationService.error__unknown_error, - context: context); - }); - copiedFile = await completer.future; - } + if (!completer.isCanceled) { + MediaFile copiedMedia = MediaFile(copiedFile, mediaType); + if (mediaType == FileType.image) { + result = + await _processImage(copiedMedia, tempPath, mediaUuid, imageType); + } else if (mediaType == FileType.video) { + result = await _processVideo(copiedMedia); + } else { + _eventService.post(MediaProcessEvent(MediaProcessingState.error, + data: 'Unsupported media type: ${media.type}')); + } + } - MediaFile copiedMedia = MediaFile(copiedFile, mediaType); - if (mediaType == FileType.image) { - result = await _processImage(copiedMedia, tempPath, mediaUuid, imageType); - } else if (mediaType == FileType.video) { - result = await _processVideo(copiedMedia); - } else { - _eventService.post(MediaProcessEvent(MediaProcessingState.error, - data: 'Unsupported media type: ${media.type}')); - } + if (!completer.isCanceled) { + _eventService.post( + MediaProcessEvent(MediaProcessingState.finished, data: result)); + return result; + } else { + return null; + } + }); - _eventService.post(MediaProcessEvent(MediaProcessingState.finished, data: result)); - return result; + // When the work has completed, complete our CancelableCompleter. + operationFuture.then(completer.complete); + + return completer.operation; } Future _processImage(MediaFile media, String tempPath, diff --git a/lib/services/media/models/media_event.dart b/lib/services/media/models/media_event.dart index 118f919b0..be6acae98 100644 --- a/lib/services/media/models/media_event.dart +++ b/lib/services/media/models/media_event.dart @@ -1,10 +1,12 @@ +import 'package:async/async.dart'; import 'package:Okuna/services/event/models/event.dart'; class MediaProcessEvent extends Event { final MediaProcessingState state; + final CancelableOperation operation; final dynamic data; - MediaProcessEvent(this.state, {this.data}); + MediaProcessEvent(this.state, {this.operation, this.data}); } enum MediaProcessingState { picking, processing, finished, cancelled, error } \ No newline at end of file diff --git a/lib/services/share/share.dart b/lib/services/share/share.dart index 61cee6ed6..e68cedef6 100644 --- a/lib/services/share/share.dart +++ b/lib/services/share/share.dart @@ -86,15 +86,15 @@ class ShareService { if (_queuedShare != null) { // Schedule cancellation of existing share operations. We don't cancel // immediately since that can cause concurrent modification of _activeShares. - _activeShares - .forEach((key, value) => Future.delayed(Duration(), value.cancel)); + _activeShares.forEach((key, value) async => await value.cancel()); var share = _queuedShare; _queuedShare = null; _isProcessingShare = true; _activeShares[share] = ShareOperation(share, _onShare); - _activeShares[share].then(() => _activeShares.remove(share)); + _activeShares[share].then( + () => Future.delayed(Duration(), () => _activeShares.remove(share))); _activeShares[share].start(); _isProcessingShare = false; @@ -110,8 +110,6 @@ class ShareService { await _eventService.post(ShareEvent(ShareStatus.received)); - //TODO(komposten): Cancelling the share op should cancel processMedia! - /*TODO(komposten): Send a cancel or failure event if an exception is thrown at any point (i.e. share.error != null, image/video too big, or text too long. */ @@ -126,22 +124,25 @@ class ShareService { if (share.image != null) { image = File.fromUri(Uri.parse(share.image)); - var processedFile = await _mediaService.processMedia( + var imageProcessOp = _mediaService.processMedia( media: MediaFile(image, FileType.image), context: _context, ); - image = processedFile.file; + + imageProcessOp.then((media) => image = media.file); + _activeShares[share].addMediaOperation(imageProcessOp); } if (share.video != null) { video = File.fromUri(Uri.parse(share.video)); - var processedFile = await _mediaService.processMedia( + var videoProcessOp = _mediaService.processMedia( media: MediaFile(video, FileType.video), context: _context, ); - video = processedFile.file; + videoProcessOp.then((media) => video = media.file); + _activeShares[share].addMediaOperation(videoProcessOp); } if (share.text != null) { @@ -155,6 +156,8 @@ class ShareService { } } + await _activeShares[share].getMediaOperationFuture(); + var newShare = Share(text: text, image: image, video: video); if (_activeShares[share].isCancelled) { @@ -170,6 +173,8 @@ class ShareOperation { SharePlugin.Share share; CancelableOperation shareOperation; + List _mediaOperations = []; + List _mediaOperationFutures = []; bool isCancelled = false; bool _shareComplete = false; @@ -186,15 +191,35 @@ class ShareOperation { }); } - void cancel() { + Future cancel() async { isCancelled = true; - shareOperation?.cancel(); + await shareOperation?.cancel(); + _mediaOperations.forEach((operation) async => await operation.cancel()); } void then(FutureOr Function() callback) { _callback = callback; } + void addMediaOperation(CancelableOperation operation) { + _mediaOperations.add(operation); + + // Create a completer for this operation and add its future + // to the media operation future list. + var completer = Completer(); + _mediaOperationFutures.add(completer.future); + + if (operation.isCompleted || operation.isCanceled) { + completer.complete(); + } else { + operation.then((_) => completer.complete()); + } + } + + Future getMediaOperationFuture() { + return Future.wait(_mediaOperationFutures); + } + void _complete() { if (shareOperation == null || _shareComplete) { _callback(); From 4e696c1e811ca78e676bdf80c8d53ba733e104a2 Mon Sep 17 00:00:00 2001 From: Komposten Date: Wed, 20 May 2020 16:06:15 +0200 Subject: [PATCH 10/12] :bug: Fix sharing media while a picked media is being processed not cancelling the active process --- lib/pages/home/modals/save_post/create_post.dart | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/pages/home/modals/save_post/create_post.dart b/lib/pages/home/modals/save_post/create_post.dart index 40355802c..55d2444bf 100644 --- a/lib/pages/home/modals/save_post/create_post.dart +++ b/lib/pages/home/modals/save_post/create_post.dart @@ -454,10 +454,6 @@ class OBSavePostModalState extends OBContextualSearchBoxState { } if (event.state == MediaProcessingState.processing) { - /* TODO(komposten): Improve this part - It would be preferable if the previous media was restored - if this new media was cancelled before finishing processing. - */ _removePostMedia(); _isProcessingMedia = true; var postMediaWidget = OBPostMediaPreview(onRemove: event.operation.cancel); @@ -486,6 +482,8 @@ class OBSavePostModalState extends OBContextualSearchBoxState { _isProcessingMedia = false; } + + event.consume(); } void _setPostImageFile(File image) { @@ -566,6 +564,13 @@ class OBSavePostModalState extends OBContextualSearchBoxState { Future _onShareEvent(ShareEvent event) async { if (event.status == ShareStatus.received) { + // Cancel any existing media processing operation. + if (_isProcessingMedia) { + if (_mediaPreview.preview is OBPostMediaPreview) { + (_mediaPreview.preview as OBPostMediaPreview).onRemove(); + } + } + event.consume(); } else if (event.status == ShareStatus.processed) { Share share = event.data; From 9ea4be504668c50e3772ef28e751bcb2d160eae4 Mon Sep 17 00:00:00 2001 From: Komposten Date: Wed, 20 May 2020 16:56:47 +0200 Subject: [PATCH 11/12] :bug: Fix sharing to Okuna not working if Okuna is closed --- lib/services/share/share.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/services/share/share.dart b/lib/services/share/share.dart index e68cedef6..9a0dbd929 100644 --- a/lib/services/share/share.dart +++ b/lib/services/share/share.dart @@ -69,7 +69,9 @@ class ShareService { } Future _onSubscriberEvent(SubscriptionEvent event) async { - if (event.oldSubscriberCount == 0 && event.newSubscriberCount > 0) { + if (event.eventType == ShareEvent && + event.oldSubscriberCount == 0 && + event.newSubscriberCount > 0) { _processQueuedShare(); } } @@ -77,7 +79,7 @@ class ShareService { void _onReceiveShare(dynamic shared) async { _queuedShare = SharePlugin.Share.fromReceived(shared); - if (_eventService.subscriberCount(SubscriptionEvent) > 0 && !_isProcessingShare) { + if (_eventService.subscriberCount(ShareEvent) > 0 && !_isProcessingShare) { _processQueuedShare(); } } From d61f9fb31234c0bfaee628fbd5fe69edb4e7aa84 Mon Sep 17 00:00:00 2001 From: Komposten Date: Wed, 20 May 2020 23:03:35 +0200 Subject: [PATCH 12/12] :bug: Fix ffmpeg not being cancelled when cancelling processMedia --- lib/services/media/media.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/services/media/media.dart b/lib/services/media/media.dart index cdc0df262..fa640035a 100644 --- a/lib/services/media/media.dart +++ b/lib/services/media/media.dart @@ -213,8 +213,8 @@ class MediaService { mediaType = FileType.video; Completer completer = Completer(); - gifOperation = convertGifToVideo(copiedFile) - .then((file) => completer.complete(file), onError: (error, trace) { + gifOperation = convertGifToVideo(copiedFile); + gifOperation.then((file) => completer.complete(file), onError: (error, trace) { print(error); _toastService.error( message: _localizationService.error__unknown_error,