Story creation integration

This commit is contained in:
Matthieu 2023-12-21 14:06:20 +01:00
parent bb3c9afb13
commit 8703287d90
7 changed files with 301 additions and 74 deletions

View File

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

View File

@ -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<PhotoData>? = null
private val photoData: MutableLiveData<MutableList<PhotoData>> by lazy {
MutableLiveData<MutableList<PhotoData>>().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,23 +155,27 @@ class PostCreationViewModel(
}
}
/**
* Read-only public view on [photoData]
*/
fun getPhotoData(): LiveData<MutableList<PhotoData>> = 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>? = photoData.value): MutableList<PhotoData> {
val dataToAdd: ArrayList<PhotoData> = arrayListOf()
var count = clipData.itemCount
if(count + (previousList?.size ?: 0) > instance!!.albumLimit){
uiState.value.maxEntries?.let {
if(count + (previousList?.size ?: 0) > it){
_uiState.update { currentUiState ->
currentUiState.copy(userMessage = getApplication<PixelDroidApplication>().getString(R.string.total_exceeds_album_limit).format(instance.albumLimit))
currentUiState.copy(userMessage = getApplication<PixelDroidApplication>().getString(R.string.total_exceeds_album_limit).format(it))
}
count = count.coerceAtMost(instance.albumLimit - (previousList?.size ?: 0))
count = count.coerceAtMost(it - (previousList?.size ?: 0))
}
if (count + (previousList?.size ?: 0) >= instance.albumLimit) {
if (count + (previousList?.size ?: 0) >= it) {
// Disable buttons to add more images
_uiState.update { currentUiState ->
currentUiState.copy(addPhotoButtonEnabled = false)
@ -176,6 +188,8 @@ class PostCreationViewModel(
dataToAdd.add(PhotoData(imageUri = it.uri, size = sizeAndVideoPair.first, video = sizeAndVideoPair.second, imageDescription = it.text?.toString()))
}
}
}
return previousList?.plus(dataToAdd)?.toMutableList() ?: mutableListOf()
}
@ -187,7 +201,7 @@ 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<Long, Boolean> {
private fun getSizeAndVideoValidate(uri: Uri, editPosition: Int): Pair<Long, Boolean> {
val size: Long =
if (uri.scheme =="content") {
getApplication<PixelDroidApplication>().contentResolver.query(uri, null, null, null, null)
@ -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,7 +251,7 @@ 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<PixelDroidApplication>(),
registerNewFFmpegSession = ::registerNewFFmpegSession,
trackTempFile = ::trackTempFile,
@ -447,9 +460,8 @@ class PostCreationViewModel(
} ?: apiHolder.api ?: apiHolder.setToCurrentUser()
val inter: Observable<Attachment> =
//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<PixelDroidApplication>().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 {

View File

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

View File

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

View File

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:tint="#FFFFFF" android:viewportHeight="24"
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,4l-1.41,1.41L16.17,11H4v2h12.17l-5.58,5.59L12,20l8,-8z"/>
</vector>

View File

@ -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" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/buttonConstraints"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<Button
android:id="@+id/post_creation_send_button"
android:id="@+id/top_bar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:enabled="true"
android:text="@string/upload_next_step"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_height="wrap_content"
android:minHeight="?attr/actionBarSize"
android:theme="?attr/actionBarTheme"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/backbutton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@android:string/cancel"
android:src="?attr/homeAsUpIndicator"
android:tooltipText='@android:string/cancel'
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/toggleStoryPost"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:checkedButton="@+id/buttonPost"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/post_creation_next_button"
app:layout_constraintStart_toEndOf="@id/backbutton"
app:layout_constraintTop_toTopOf="parent"
app:selectionRequired="true"
app:singleSelection="true">
<Button
android:id="@+id/buttonStory"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/type_story" />
<Button
android:id="@+id/buttonPost"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/type_post" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<Button
style="@style/Widget.Material3.Button.TextButton.Icon"
app:icon="@drawable/arrow_forward"
app:iconGravity="end"
android:id="@+id/post_creation_next_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:enabled="true"
android:text="@string/continue_post_creation"
android:visibility="visible"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
@ -44,7 +89,7 @@
android:minHeight="?attr/actionBarSize"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
app:layout_constraintTop_toBottomOf="@id/top_bar">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/savePhotoButton"
@ -53,8 +98,8 @@
android:layout_marginStart="30dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/save_to_gallery"
android:tooltipText='@string/save_to_gallery'
android:src="@drawable/download_file_30dp"
android:tooltipText='@string/save_to_gallery'
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
@ -66,8 +111,8 @@
android:layout_marginStart="30dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/delete"
android:tooltipText='@string/delete'
android:src="@drawable/delete_30dp"
android:tooltipText='@string/delete'
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/savePhotoButton"
app:layout_constraintTop_toTopOf="parent" />
@ -79,8 +124,8 @@
android:layout_marginStart="30dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/edit"
android:tooltipText='@string/edit'
android:src="@drawable/ic_baseline_edit_30"
android:tooltipText='@string/edit'
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/removePhotoButton"
app:layout_constraintTop_toTopOf="parent" />
@ -92,8 +137,8 @@
android:layout_marginEnd="30dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/add_photo"
android:tooltipText='@string/add_photo'
android:src="@drawable/add_photo_button"
android:tooltipText='@string/add_photo'
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />

View File

@ -76,6 +76,7 @@
app:layout_constraintHorizontal_bias="0.498"
app:layout_constraintStart_toStartOf="@id/upload_error_text_view"
app:layout_constraintTop_toBottomOf="@+id/upload_error_text_explanation" />
</androidx.constraintlayout.widget.ConstraintLayout>
@ -103,7 +104,7 @@
app:layout_constraintEnd_toEndOf="parent">
<Button
android:id="@+id/post_creation_send_button"
android:id="@+id/post_submission_send_button"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:enabled="true"
@ -182,4 +183,84 @@
app:layout_constraintStart_toEndOf="@+id/nsfwSwitch"
app:layout_constraintTop_toTopOf="@+id/nsfwSwitch" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/storyOptions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/top_bar"
android:visibility="gone"
tools:visibility="visible">
<TextView
android:id="@+id/story_duration_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="32dp"
android:text="@string/story_duration"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>
<com.google.android.material.slider.Slider
android:id="@+id/story_duration_slider"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:stepSize="1.0"
android:valueFrom="3.0"
android:valueTo="15.0"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/story_duration_title" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/storyRepliesSwitch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="@id/story_duration_slider"
app:layout_constraintTop_toBottomOf="@+id/story_duration_slider" />
<TextView
android:id="@+id/storyRepliesTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Allow replies"
android:textStyle="bold"
android:padding="8dp"
app:layout_constraintBottom_toBottomOf="@+id/storyRepliesSwitch"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@+id/storyRepliesSwitch"
app:layout_constraintTop_toTopOf="@+id/storyRepliesSwitch" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/storyReactionsSwitch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="@id/storyRepliesSwitch"
app:layout_constraintTop_toBottomOf="@+id/storyRepliesSwitch" />
<TextView
android:id="@+id/storyReactionsTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Allow reactions"
android:textStyle="bold"
android:padding="8dp"
app:layout_constraintBottom_toBottomOf="@+id/storyReactionsSwitch"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@+id/storyReactionsSwitch"
app:layout_constraintTop_toTopOf="@+id/storyReactionsSwitch"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>