Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add UI for image-attachment "focus" #2620

Merged
merged 28 commits into from
Sep 21, 2022
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f3bde24
Attempt-zero implementation of a "focus" feature for image attachment…
mcclure Jul 16, 2022
613358e
Remove code duplication between 'update description' and 'update focus'
mcclure Jul 17, 2022
7d86015
Fix ktlint/bitrise failures
mcclure Jul 18, 2022
c283a4c
Make updateMediaItem private
mcclure Jul 22, 2022
7b3fd74
When focus is set on a post attachment the preview focuses correctly.…
mcclure Jul 23, 2022
02b5e68
Replace use of PointF for Focus where focus is represented, fix ktlint
mcclure Jul 23, 2022
84926d9
Substitute 'focus' for 'focus point' in strings
mcclure Jul 23, 2022
a439ec5
First attempt draw focus point. Only updates on initial load. Modeled…
mcclure Sep 3, 2022
5e920da
Redraw focus after each tap
mcclure Sep 3, 2022
09ab141
Dark curtain where focus isn't (now looks like mastosoc)
mcclure Sep 3, 2022
8f08b1b
Correct ktlint for FocusDialog
mcclure Sep 3, 2022
d345e62
draft: switch to overlay for focus indicator
connyduck Sep 5, 2022
c89cdcf
Draw focus circle, but ImageView and FocusIndicatorView seem to share…
mcclure Sep 5, 2022
3437fa8
Switch focus circle to path approach
mcclure Sep 5, 2022
480b07b
Correctly scale, save and load focuses. Clamp to visible area. Focus …
mcclure Sep 5, 2022
0e6c942
ktlint fixes and comments
mcclure Sep 5, 2022
f4e4f8b
Focus indicator drawing should use device-independent pixels
mcclure Sep 5, 2022
d929b98
Shrink focus window when it gets unattractively tall (no linting, mis…
mcclure Sep 18, 2022
dab6381
Correct max-height behavior for screens in landscape mode
mcclure Sep 18, 2022
af5c995
Focus attachment result is are flipped on x axis; fix this
mcclure Sep 19, 2022
829c151
Correctly thread focus through on scheduled posts, redrafted posts, a…
mcclure Sep 19, 2022
cde5d5e
More focus ktlint fixes
mcclure Sep 19, 2022
a93369b
Fix specific case where a draft is given a focus, then deleted, then …
mcclure Sep 19, 2022
400683d
Fix accidental file change in focus PR
mcclure Sep 19, 2022
a81ad1c
Remerge develop with focus PR
mcclure Sep 19, 2022
829a0ed
ktLint fix
mcclure Sep 19, 2022
fc771f3
Fix property style warnings in focus
mcclure Sep 20, 2022
33d5745
Fix remaining style warnings from focus PR
mcclure Sep 20, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.EmojiAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog
import com.keylesspalace.tusky.components.compose.dialog.makeFocusDialog
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener
import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView
Expand Down Expand Up @@ -169,6 +170,7 @@ class ComposeActivity :
uriNew,
size,
itemOld.description,
null, // Intentionally reset focus when cropping
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the best approach? We could do something weird where we try to calculate where/whether the focal point is within the cropped area, but this feels like second guessing the user so I just reset.

itemOld
)
}
Expand Down Expand Up @@ -216,6 +218,11 @@ class ComposeActivity :
viewModel.updateDescription(item.localId, newDescription)
}
},
onAddFocus = { item ->
makeFocusDialog(item.focus, item.uri) { newFocus ->
viewModel.updateFocus(item.localId, newFocus)
}
},
onEditImage = this::editImageInQueue,
onRemove = this::removeMediaFromQueue
)
Expand Down Expand Up @@ -1065,7 +1072,8 @@ class ComposeActivity :
val mediaSize: Long,
val uploadPercent: Int = 0,
val id: String? = null,
val description: String? = null
val description: String? = null,
val focus: Attachment.Focus? = null
) {
enum class Type {
IMAGE, VIDEO, AUDIO;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ class ComposeViewModel @Inject constructor(
}
}

suspend fun pickMedia(mediaUri: Uri, description: String? = null): Result<QueuedMedia> = withContext(Dispatchers.IO) {
suspend fun pickMedia(mediaUri: Uri, description: String? = null, focus: Attachment.Focus? = null): Result<QueuedMedia> = withContext(Dispatchers.IO) {
try {
val (type, uri, size) = mediaUploader.prepareMedia(mediaUri)
val mediaItems = media.value
Expand All @@ -117,7 +117,7 @@ class ComposeViewModel @Inject constructor(
) {
Result.failure(VideoOrImageException())
} else {
val queuedMedia = addMediaToQueue(type, uri, size, description)
val queuedMedia = addMediaToQueue(type, uri, size, description, focus)
Result.success(queuedMedia)
}
} catch (e: Exception) {
Expand All @@ -130,6 +130,7 @@ class ComposeViewModel @Inject constructor(
uri: Uri,
mediaSize: Long,
description: String? = null,
focus: Attachment.Focus? = null,
replaceItem: QueuedMedia? = null
): QueuedMedia {
var stashMediaItem: QueuedMedia? = null
Expand All @@ -140,7 +141,8 @@ class ComposeViewModel @Inject constructor(
uri = uri,
type = type,
mediaSize = mediaSize,
description = description
description = description,
focus = focus
)
stashMediaItem = mediaItem

Expand Down Expand Up @@ -185,7 +187,7 @@ class ComposeViewModel @Inject constructor(
return mediaItem
}

private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?) {
private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Long term observation: Adding focus to "travel along with" description when attachments hibernate/resurrect required adding plumbing in a LOT of places. It might make more sense to make a "Metadata" struct like the one already in Attachment and pass that around instead of description (although not Attachment.Meta itself, as that includes "duration", a metadata returned from the server but not tracked by us when composing attachments), in case later there is some third thing that we track per attachment (a low res version, I don't know)

media.update { mediaValue ->
val mediaItem = QueuedMedia(
localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1,
Expand All @@ -194,7 +196,8 @@ class ComposeViewModel @Inject constructor(
mediaSize = 0,
uploadPercent = -1,
id = id,
description = description
description = description,
focus = focus
)
mediaValue + mediaItem
}
Expand Down Expand Up @@ -248,9 +251,11 @@ class ComposeViewModel @Inject constructor(
suspend fun saveDraft(content: String, contentWarning: String) {
val mediaUris: MutableList<String> = mutableListOf()
val mediaDescriptions: MutableList<String?> = mutableListOf()
val mediaFocus: MutableList<Attachment.Focus?> = mutableListOf()
media.value.forEach { item ->
mediaUris.add(item.uri.toString())
mediaDescriptions.add(item.description)
mediaFocus.add(item.focus)
}

draftHelper.saveDraft(
Expand All @@ -263,6 +268,7 @@ class ComposeViewModel @Inject constructor(
visibility = statusVisibility.value!!,
mediaUris = mediaUris,
mediaDescriptions = mediaDescriptions,
mediaFocus = mediaFocus,
poll = poll.value,
failedToSend = false
)
Expand Down Expand Up @@ -290,11 +296,13 @@ class ComposeViewModel @Inject constructor(
val mediaIds: MutableList<String> = mutableListOf()
val mediaUris: MutableList<Uri> = mutableListOf()
val mediaDescriptions: MutableList<String> = mutableListOf()
val mediaFocus: MutableList<Attachment.Focus?> = mutableListOf()
val mediaProcessed: MutableList<Boolean> = mutableListOf()
for (item in media.value) {
mediaIds.add(item.id!!)
mediaUris.add(item.uri)
mediaDescriptions.add(item.description ?: "")
mediaFocus.add(item.focus)
mediaProcessed.add(false)
}

Expand All @@ -306,6 +314,7 @@ class ComposeViewModel @Inject constructor(
mediaIds = mediaIds,
mediaUris = mediaUris.map { it.toString() },
mediaDescriptions = mediaDescriptions,
mediaFocus = mediaFocus,
scheduledAt = scheduledAt.value,
inReplyToId = inReplyToId,
poll = poll.value,
Expand All @@ -324,11 +333,12 @@ class ComposeViewModel @Inject constructor(
return combineLiveData(deletionObservable, sendFlow.asLiveData()) { _, _ -> }
}

suspend fun updateDescription(localId: Int, description: String): Boolean {
// Updates a QueuedMedia item arbitrarily, then sends description and focus to server
private suspend fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia): Boolean {
val newMediaList = media.updateAndGet { mediaValue ->
mediaValue.map { mediaItem ->
if (mediaItem.localId == localId) {
mediaItem.copy(description = description)
mutator(mediaItem)
} else {
mediaItem
}
Expand All @@ -337,7 +347,9 @@ class ComposeViewModel @Inject constructor(

val updatedItem = newMediaList.find { it.localId == localId }
if (updatedItem?.id != null) {
return api.updateMedia(updatedItem.id, description)
val focus = updatedItem.focus
val focusString = if (focus != null) "${focus.x},${focus.y}" else null
return api.updateMedia(updatedItem.id, updatedItem.description, focusString)
.fold({
true
}, { throwable ->
Expand All @@ -348,6 +360,18 @@ class ComposeViewModel @Inject constructor(
return true
}

suspend fun updateDescription(localId: Int, description: String): Boolean {
return updateMediaItem(localId, { mediaItem ->
mediaItem.copy(description = description)
})
}

suspend fun updateFocus(localId: Int, focus: Attachment.Focus): Boolean {
return updateMediaItem(localId, { mediaItem ->
mediaItem.copy(focus = focus)
})
}

fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
when (token[0]) {
'@' -> {
Expand Down Expand Up @@ -418,7 +442,7 @@ class ComposeViewModel @Inject constructor(
// when coming from DraftActivity
viewModelScope.launch {
draftAttachments.forEach { attachment ->
pickMedia(attachment.uri, attachment.description)
pickMedia(attachment.uri, attachment.description, attachment.focus)
}
}
} else composeOptions?.mediaAttachments?.forEach { a ->
Expand All @@ -428,7 +452,7 @@ class ComposeViewModel @Inject constructor(
Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE
Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO
}
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description)
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus)
}

draftId = composeOptions?.draftId ?: 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import com.keylesspalace.tusky.components.compose.view.ProgressImageView
class MediaPreviewAdapter(
context: Context,
private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit,
private val onAddFocus: (ComposeActivity.QueuedMedia) -> Unit,
private val onEditImage: (ComposeActivity.QueuedMedia) -> Unit,
private val onRemove: (ComposeActivity.QueuedMedia) -> Unit
) : RecyclerView.Adapter<MediaPreviewAdapter.PreviewViewHolder>() {
Expand All @@ -44,15 +45,19 @@ class MediaPreviewAdapter(
val item = differ.currentList[position]
val popup = PopupMenu(view.context, view)
val addCaptionId = 1
val editImageId = 2
val removeId = 3
val addFocusId = 2
val editImageId = 3
val removeId = 4
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE)
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) {
popup.menu.add(0, addFocusId, 0, R.string.action_set_focus)
popup.menu.add(0, editImageId, 0, R.string.action_edit_image)
}
popup.menu.add(0, removeId, 0, R.string.action_remove)
popup.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
addCaptionId -> onAddCaption(item)
addFocusId -> onAddFocus(item)
editImageId -> onEditImage(item)
removeId -> onRemove(item)
}
Expand All @@ -78,11 +83,24 @@ class MediaPreviewAdapter(
// TODO: Fancy waveform display?
holder.progressImageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
} else {
Glide.with(holder.itemView.context)
val imageView = holder.progressImageView
val focus = item.focus

if (focus != null)
imageView.setFocalPoint(focus)
else
imageView.removeFocalPoint(); // Probably unnecessary since we have no UI for removal once added.

var glide = Glide.with(holder.itemView.context)
.load(item.uri)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate()
.into(holder.progressImageView)
.centerInside()

if (focus != null)
glide = glide.addListener(imageView)

glide.into(imageView)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,13 @@ class MediaUploader @Inject constructor(
null
}

mediaUploadApi.uploadMedia(body, description).fold({ result ->
val focus = if (media.focus != null) {
MultipartBody.Part.createFormData("focus", "${media.focus.x},${media.focus.y}")
} else {
null
}

mediaUploadApi.uploadMedia(body, description, focus).fold({ result ->
send(UploadEvent.FinishedEvent(result.id))
}, { throwable ->
val errorMessage = throwable.getServerErrorMessage()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/* Copyright 2019 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */

package com.keylesspalace.tusky.components.compose.dialog

import android.app.Activity
import android.content.DialogInterface
import android.graphics.drawable.Drawable
import android.net.Uri
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.DialogFocusBinding
import com.keylesspalace.tusky.entity.Attachment.Focus
import kotlinx.coroutines.launch

fun <T> T.makeFocusDialog(
existingFocus: Focus?,
previewUri: Uri,
onUpdateFocus: suspend (Focus) -> Boolean
) where T : Activity, T : LifecycleOwner {
val focus = existingFocus ?: Focus(0.0f, 0.0f) // Default to center

val dialogBinding = DialogFocusBinding.inflate(layoutInflater)

dialogBinding.focusIndicator.setFocus(focus)

Glide.with(this)
.load(previewUri)
.downsample(DownsampleStrategy.CENTER_INSIDE)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(p0: GlideException?, p1: Any?, p2: Target<Drawable?>?, p3: Boolean): Boolean {
return false
}

override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable?>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
val width = resource!!.getIntrinsicWidth()
val height = resource.getIntrinsicHeight()

dialogBinding.focusIndicator.setImageSize(width, height)

// We want the dialog to be a little taller than the image, so you can slide your thumb past the image border,
// but if it's *too* much taller that looks weird. See if a threshold has been crossed:
if (width > height) {
val maxHeight = dialogBinding.focusIndicator.maxAttractiveHeight()

if (dialogBinding.imageView.getHeight() > maxHeight) {
val verticalShrinkLayout = FrameLayout.LayoutParams(width, maxHeight)
dialogBinding.imageView.setLayoutParams(verticalShrinkLayout)
dialogBinding.focusIndicator.setLayoutParams(verticalShrinkLayout)
}
}
return false // Pass through
}
})
.into(dialogBinding.imageView)

val okListener = { dialog: DialogInterface, _: Int ->
lifecycleScope.launch {
if (!onUpdateFocus(dialogBinding.focusIndicator.getFocus())) {
showFailedFocusMessage()
}
}
dialog.dismiss()
}

val dialog = AlertDialog.Builder(this)
.setView(dialogBinding.root)
.setPositiveButton(android.R.string.ok, okListener)
.setNegativeButton(android.R.string.cancel, null)
.create()

val window = dialog.window
window?.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
)

dialog.show()
}

private fun Activity.showFailedFocusMessage() {
Toast.makeText(this, R.string.error_failed_set_focus, Toast.LENGTH_SHORT).show()
}
Loading