From 8703287d909e0ca60395af354f2e822a5940651d Mon Sep 17 00:00:00 2001 From: Matthieu <24-artectrex@users.noreply.shinice.net> Date: Thu, 21 Dec 2023 14:06:20 +0100 Subject: [PATCH] Story creation integration --- .../app/postCreation/PostCreationFragment.kt | 51 ++++++-- .../app/postCreation/PostCreationViewModel.kt | 118 +++++++++++++----- .../postCreation/PostSubmissionFragment.kt | 28 ++++- .../app/postCreation/camera/CameraFragment.kt | 3 +- app/src/main/res/drawable/arrow_forward.xml | 5 + .../res/layout/fragment_post_creation.xml | 87 +++++++++---- .../res/layout/fragment_post_submission.xml | 83 +++++++++++- 7 files changed, 301 insertions(+), 74 deletions(-) create mode 100644 app/src/main/res/drawable/arrow_forward.xml diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationFragment.kt b/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationFragment.kt index c6a6b3d9..44d6da62 100644 --- a/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationFragment.kt +++ b/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationFragment.kt @@ -65,6 +65,7 @@ class PostCreationFragment : BaseFragment() { // Inflate the layout for this fragment binding = FragmentPostCreationBinding.inflate(layoutInflater) + return binding.root } @@ -91,11 +92,6 @@ class PostCreationFragment : BaseFragment() { } model = _model - if(model.storyCreation){ - binding.carousel.showCaption = false - //TODO hide grid button, hide dot (indicator), hide arrows, limit photos to 1 - } - model.getPhotoData().observe(viewLifecycleOwner) { newPhotoData -> // update UI binding.carousel.addData( @@ -107,6 +103,7 @@ class PostCreationFragment : BaseFragment() { ) } ) + binding.postCreationNextButton.isEnabled = newPhotoData.isNotEmpty() } lifecycleScope.launch { @@ -127,13 +124,26 @@ class PostCreationFragment : BaseFragment() { binding.toolbarPostCreation.visibility = if (uiState.isCarousel) View.VISIBLE else View.INVISIBLE binding.carousel.layoutCarousel = uiState.isCarousel + + if(uiState.storyCreation){ + binding.toggleStoryPost.check(binding.buttonStory.id) + binding.buttonStory.isPressed = true + binding.carousel.showLayoutSwitchButton = false + binding.carousel.showIndicator = false + } else { + binding.toggleStoryPost.check(binding.buttonPost.id) + binding.carousel.showLayoutSwitchButton = true + binding.carousel.showIndicator = true + } + binding.carousel.maxEntries = uiState.maxEntries + } } } binding.carousel.apply { layoutCarouselCallback = { model.becameCarousel(it)} - maxEntries = instance.albumLimit + maxEntries = if(model.uiState.value.storyCreation) 1 else instance.albumLimit addPhotoButtonCallback = { addPhoto() } @@ -141,9 +151,10 @@ class PostCreationFragment : BaseFragment() { model.updateDescription(position, description) } } - // get the description and send the post - binding.postCreationSendButton.setOnClickListener { - if (validatePost() && model.isNotEmpty()) { + + // Validate the post and go to the next step of the post creation process + binding.postCreationNextButton.setOnClickListener { + if (validatePost()) { findNavController().navigate(R.id.action_postCreationFragment_to_postSubmissionFragment) } } @@ -171,6 +182,23 @@ class PostCreationFragment : BaseFragment() { } } + binding.toggleStoryPost.addOnButtonCheckedListener { _, checkedId, isChecked -> + // Only handle checked events + if (!isChecked) return@addOnButtonCheckedListener + + when (checkedId) { + R.id.buttonStory -> { + model.storyMode(true) + } + R.id.buttonPost -> { + model.storyMode(false) + } + } + + } + + binding.backbutton.setOnClickListener{requireActivity().onBackPressedDispatcher.onBackPressed()} + // Clean up temporary files, if any val tempFiles = requireActivity().intent.getStringArrayExtra(PostCreationActivity.TEMP_FILES) tempFiles?.asList()?.forEach { @@ -284,8 +312,9 @@ class PostCreationFragment : BaseFragment() { private fun validatePost(): Boolean { if (model.getPhotoData().value?.none { it.video && it.videoEncodeComplete == false } == true) { - // Encoding is done, i.e. none of the items are both a video and not done encoding - return true + // Encoding is done, i.e. none of the items are both a video and not done encoding. + // We return true if the post is not empty, false otherwise. + return model.getPhotoData().value?.isNotEmpty() == true } // Encoding is not done, show a dialog and return false to indicate validation failed MaterialAlertDialogBuilder(requireActivity()).apply { diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationViewModel.kt b/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationViewModel.kt index 532f118e..e6f0cc54 100644 --- a/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationViewModel.kt +++ b/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationViewModel.kt @@ -55,7 +55,6 @@ import kotlin.collections.forEach import kotlin.collections.get import kotlin.collections.getOrNull import kotlin.collections.indexOfFirst -import kotlin.collections.isNotEmpty import kotlin.collections.mutableListOf import kotlin.collections.mutableMapOf import kotlin.collections.plus @@ -71,6 +70,7 @@ data class PostCreationActivityUiState( val addPhotoButtonEnabled: Boolean = true, val editPhotoButtonEnabled: Boolean = true, val removePhotoButtonEnabled: Boolean = true, + val maxEntries: Int?, val isCarousel: Boolean = true, @@ -87,6 +87,11 @@ data class PostCreationActivityUiState( val uploadErrorVisible: Boolean = false, val uploadErrorExplanationText: String = "", val uploadErrorExplanationVisible: Boolean = false, + + val storyCreation: Boolean, + val storyDuration: Int = 10, + val storyReplies: Boolean = true, + val storyReactions: Boolean = true, ) @Parcelize @@ -109,8 +114,9 @@ class PostCreationViewModel( val instance: InstanceDatabaseEntity? = null, existingDescription: String? = null, existingNSFW: Boolean = false, - val storyCreation: Boolean = false, + storyCreation: Boolean = false, ) : AndroidViewModel(application) { + private var storyPhotoDataBackup: MutableList? = null private val photoData: MutableLiveData> by lazy { MutableLiveData>().also { it.value = clipdata?.let { it1 -> addPossibleImages(it1, mutableListOf()) } @@ -130,7 +136,9 @@ class PostCreationViewModel( _uiState = MutableStateFlow(PostCreationActivityUiState( newPostDescriptionText = existingDescription ?: templateDescription, - nsfw = existingNSFW + nsfw = existingNSFW, + maxEntries = if(storyCreation) 1 else instance?.albumLimit, + storyCreation = storyCreation )) } @@ -147,35 +155,41 @@ class PostCreationViewModel( } } + /** + * Read-only public view on [photoData] + */ fun getPhotoData(): LiveData> = photoData /** * Will add as many images as possible to [photoData], from the [clipData], and if - * ([photoData].size + [clipData].itemCount) > [InstanceDatabaseEntity.albumLimit] then it will only add as many images + * ([photoData].size + [clipData].itemCount) > uiState.value.maxEntries then it will only add as many images * as are legal (if any) and a dialog will be shown to the user alerting them of this fact. */ fun addPossibleImages(clipData: ClipData, previousList: MutableList? = photoData.value): MutableList { val dataToAdd: ArrayList = arrayListOf() var count = clipData.itemCount - if(count + (previousList?.size ?: 0) > instance!!.albumLimit){ - _uiState.update { currentUiState -> - currentUiState.copy(userMessage = getApplication().getString(R.string.total_exceeds_album_limit).format(instance.albumLimit)) + uiState.value.maxEntries?.let { + if(count + (previousList?.size ?: 0) > it){ + _uiState.update { currentUiState -> + currentUiState.copy(userMessage = getApplication().getString(R.string.total_exceeds_album_limit).format(it)) + } + count = count.coerceAtMost(it - (previousList?.size ?: 0)) } - count = count.coerceAtMost(instance.albumLimit - (previousList?.size ?: 0)) - } - if (count + (previousList?.size ?: 0) >= instance.albumLimit) { - // Disable buttons to add more images - _uiState.update { currentUiState -> - currentUiState.copy(addPhotoButtonEnabled = false) - } - } - for (i in 0 until count) { - clipData.getItemAt(i).let { - val sizeAndVideoPair: Pair = - getSizeAndVideoValidate(it.uri, (previousList?.size ?: 0) + dataToAdd.size + 1) - dataToAdd.add(PhotoData(imageUri = it.uri, size = sizeAndVideoPair.first, video = sizeAndVideoPair.second, imageDescription = it.text?.toString())) + if (count + (previousList?.size ?: 0) >= it) { + // Disable buttons to add more images + _uiState.update { currentUiState -> + currentUiState.copy(addPhotoButtonEnabled = false) + } + } + for (i in 0 until count) { + clipData.getItemAt(i).let { + val sizeAndVideoPair: Pair = + getSizeAndVideoValidate(it.uri, (previousList?.size ?: 0) + dataToAdd.size + 1) + dataToAdd.add(PhotoData(imageUri = it.uri, size = sizeAndVideoPair.first, video = sizeAndVideoPair.second, imageDescription = it.text?.toString())) + } } } + return previousList?.plus(dataToAdd)?.toMutableList() ?: mutableListOf() } @@ -187,15 +201,15 @@ class PostCreationViewModel( * Returns the size of the file of the Uri, and whether it is a video, * and opens a dialog in case it is too big or in case the file is unsupported. */ - fun getSizeAndVideoValidate(uri: Uri, editPosition: Int): Pair { + private fun getSizeAndVideoValidate(uri: Uri, editPosition: Int): Pair { val size: Long = if (uri.scheme =="content") { getApplication().contentResolver.query(uri, null, null, null, null) ?.use { cursor -> /* Get the column indexes of the data in the Cursor, - * move to the first row in the Cursor, get the data, - * and display it. - */ + * move to the first row in the Cursor, get the data, + * and display it. + */ val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) if(sizeIndex >= 0) { cursor.moveToFirst() @@ -217,6 +231,7 @@ class PostCreationViewModel( } if ((!isVideo && sizeInkBytes > instance!!.maxPhotoSize) || (isVideo && sizeInkBytes > instance!!.maxVideoSize)) { + //TODO Offer remedy for too big file: re-compress it val maxSize = if (isVideo) instance.maxVideoSize else instance.maxPhotoSize _uiState.update { currentUiState -> currentUiState.copy( @@ -227,8 +242,6 @@ class PostCreationViewModel( return Pair(size, isVideo) } - fun isNotEmpty(): Boolean = photoData.value?.isNotEmpty() ?: false - fun updateDescription(position: Int, description: String) { photoData.value?.getOrNull(position)?.imageDescription = description photoData.value = photoData.value @@ -238,8 +251,8 @@ class PostCreationViewModel( photoData.value?.removeAt(currentPosition) _uiState.update { it.copy( - addPhotoButtonEnabled = true - ) + addPhotoButtonEnabled = (photoData.value?.size ?: 0) < (uiState.value.maxEntries ?: 0), + ) } photoData.value = photoData.value } @@ -258,7 +271,7 @@ class PostCreationViewModel( videoEncodeProgress = 0 videoEncodeComplete = false - VideoEditActivity.startEncoding(imageUri, it, + VideoEditActivity.startEncoding(imageUri, null, it, context = getApplication(), registerNewFFmpegSession = ::registerNewFFmpegSession, trackTempFile = ::trackTempFile, @@ -447,9 +460,8 @@ class PostCreationViewModel( } ?: apiHolder.api ?: apiHolder.setToCurrentUser() val inter: Observable = - //TODO specify story duration //TODO validate that image is correct (?) aspect ratio - if (storyCreation) api.storyUpload(requestBody.parts[0]) + if (uiState.value.storyCreation) api.storyUpload(requestBody.parts[0]) else api.mediaUpload(description, requestBody.parts[0]) apiHolder.api = null @@ -459,7 +471,7 @@ class PostCreationViewModel( .subscribe( { attachment: Attachment -> data.progress = 0 - data.uploadId = if(storyCreation){ + data.uploadId = if(uiState.value.storyCreation){ attachment.media_id!! } else { attachment.id!! @@ -519,11 +531,11 @@ class PostCreationViewModel( apiHolder.setToCurrentUser(it) } ?: apiHolder.api ?: apiHolder.setToCurrentUser() - if(storyCreation){ + if(uiState.value.storyCreation){ api.storyPublish( media_id = getPhotoData().value!!.firstNotNullOf { it.uploadId }, can_react = "1", can_reply = "1", - duration = 10 + duration = uiState.value.storyDuration ) } else{ api.postStatus( @@ -571,6 +583,44 @@ class PostCreationViewModel( fun chooseAccount(which: UserDatabaseEntity) { _uiState.update { it.copy(chosenAccount = which) } } + + fun storyMode(storyMode: Boolean) { + //TODO check ratio of files in story mode? What is acceptable? + + val newMaxEntries = if (storyMode) 1 else instance?.albumLimit + var newUiState = _uiState.value.copy( + storyCreation = storyMode, + maxEntries = newMaxEntries, + addPhotoButtonEnabled = (photoData.value?.size ?: 0) < (newMaxEntries ?: 0), + ) + // If switching to story, and there are too many pictures, keep the first and backup the rest + if (storyMode && (photoData.value?.size ?: 0) > 1){ + storyPhotoDataBackup = photoData.value + + photoData.value = photoData.value?.let { mutableListOf(it.firstOrNull()).filterNotNull().toMutableList() } + + //Show message saying extraneous pictures were removed but can be restored + newUiState = newUiState.copy( + userMessage = getApplication().getString(R.string.extraneous_pictures_stories) + ) + } + // Restore if backup not null and first value is unchanged + else if (storyPhotoDataBackup != null && storyPhotoDataBackup?.firstOrNull() == photoData.value?.firstOrNull()){ + photoData.value = storyPhotoDataBackup + storyPhotoDataBackup = null + } + _uiState.update { newUiState } + } + + fun storyDuration(value: Int) { + _uiState.update { + it.copy(storyDuration = value) + } + } + + fun updateStoryReactions(checked: Boolean) { _uiState.update { it.copy(storyReactions = checked) } } + + fun updateStoryReplies(checked: Boolean) { _uiState.update { it.copy(storyReplies = checked) } } } class PostCreationViewModelFactory(val application: Application, val clipdata: ClipData, val instance: InstanceDatabaseEntity, val existingDescription: String?, val existingNSFW: Boolean, val storyCreation: Boolean) : ViewModelProvider.Factory { diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/PostSubmissionFragment.kt b/app/src/main/java/org/pixeldroid/app/postCreation/PostSubmissionFragment.kt index 0aa1821f..3fadc832 100644 --- a/app/src/main/java/org/pixeldroid/app/postCreation/PostSubmissionFragment.kt +++ b/app/src/main/java/org/pixeldroid/app/postCreation/PostSubmissionFragment.kt @@ -26,6 +26,7 @@ import org.pixeldroid.app.utils.bindingLifecycleAware import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity import org.pixeldroid.app.utils.setSquareImageFromURL +import kotlin.math.roundToInt class PostSubmissionFragment : BaseFragment() { @@ -80,12 +81,16 @@ class PostSubmissionFragment : BaseFragment() { binding.nsfwSwitch.isChecked = model.uiState.value.nsfw binding.newPostDescriptionInputField.setText(model.uiState.value.newPostDescriptionText) - if(model.storyCreation){ + if(model.uiState.value.storyCreation){ binding.nsfwSwitch.visibility = View.GONE binding.postTextInputLayout.visibility = View.GONE binding.privateTitle.visibility = View.GONE binding.postPreview.visibility = View.GONE - //TODO show story specific stuff here + + binding.storyOptions.visibility = View.VISIBLE + binding.storyDurationSlider.value = model.uiState.value.storyDuration.toFloat() + binding.storyRepliesSwitch.isChecked = model.uiState.value.storyReplies + binding.storyReactionsSwitch.isChecked = model.uiState.value.storyReactions } lifecycleScope.launch { @@ -125,13 +130,24 @@ class PostSubmissionFragment : BaseFragment() { binding.nsfwSwitch.setOnCheckedChangeListener { _, isChecked -> model.updateNSFW(isChecked) } + binding.storyRepliesSwitch.setOnCheckedChangeListener { _, isChecked -> + model.updateStoryReplies(isChecked) + } + binding.storyReactionsSwitch.setOnCheckedChangeListener { _, isChecked -> + model.updateStoryReactions(isChecked) + } binding.postTextInputLayout.counterMaxLength = instance.maxStatusChars + binding.storyDurationSlider.addOnChangeListener { _, value, _ -> + // Responds to when slider's value is changed + model.storyDuration(value.roundToInt()) + } + setSquareImageFromURL(View(requireActivity()), model.getPhotoData().value?.get(0)?.imageUri.toString(), binding.postPreview) // Get the description and send the post - binding.postCreationSendButton.setOnClickListener { + binding.postSubmissionSendButton.setOnClickListener { if (validatePost()) model.upload() } @@ -190,13 +206,13 @@ class PostSubmissionFragment : BaseFragment() { } private fun enableButton(enable: Boolean = true){ - binding.postCreationSendButton.isEnabled = enable + binding.postSubmissionSendButton.isEnabled = enable if(enable){ binding.postingProgressBar.visibility = View.GONE - binding.postCreationSendButton.visibility = View.VISIBLE + binding.postSubmissionSendButton.visibility = View.VISIBLE } else { binding.postingProgressBar.visibility = View.VISIBLE - binding.postCreationSendButton.visibility = View.GONE + binding.postSubmissionSendButton.visibility = View.GONE } } diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/camera/CameraFragment.kt b/app/src/main/java/org/pixeldroid/app/postCreation/camera/CameraFragment.kt index 4677d814..e7cb6ba4 100644 --- a/app/src/main/java/org/pixeldroid/app/postCreation/camera/CameraFragment.kt +++ b/app/src/main/java/org/pixeldroid/app/postCreation/camera/CameraFragment.kt @@ -340,7 +340,8 @@ class CameraFragment : BaseFragment() { putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes) action = Intent.ACTION_GET_CONTENT addCategory(Intent.CATEGORY_OPENABLE) - putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + // Don't allow multiple for story + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, !addToStory) uploadImageResultContract.launch( Intent.createChooser(this, null) ) diff --git a/app/src/main/res/drawable/arrow_forward.xml b/app/src/main/res/drawable/arrow_forward.xml new file mode 100644 index 00000000..23072282 --- /dev/null +++ b/app/src/main/res/drawable/arrow_forward.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/fragment_post_creation.xml b/app/src/main/res/layout/fragment_post_creation.xml index 94317989..13e11db2 100644 --- a/app/src/main/res/layout/fragment_post_creation.xml +++ b/app/src/main/res/layout/fragment_post_creation.xml @@ -11,29 +11,74 @@ android:id="@+id/carousel" android:layout_width="match_parent" android:layout_height="0dp" - app:showCaption="true" - app:layout_constraintBottom_toTopOf="@+id/buttonConstraints" - app:layout_constraintTop_toTopOf="parent"/> + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintTop_toBottomOf="@+id/top_bar" + app:showCaption="true" /> + android:minHeight="?attr/actionBarSize" + android:theme="?attr/actionBarTheme" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> -