Skip to content

Commit

Permalink
Add UI for image-attachment "focus" (#2620)
Browse files Browse the repository at this point in the history
* Attempt-zero implementation of a "focus" feature for image attachments. Choose "Set focus" in the attachment menu, tap once to select focus point (no visual feedback currently), tap "OK". Works in tests.

* Remove code duplication between 'update description' and 'update focus'

* Fix ktlint/bitrise failures

* Make updateMediaItem private

* When focus is set on a post attachment the preview focuses correctly. ProgressImageView now inherits from MediaPreviewImageView.

* Replace use of PointF for Focus where focus is represented, fix ktlint

* Substitute 'focus' for 'focus point' in strings

* First attempt draw focus point. Only updates on initial load. Modeled on code from RoundedCorners builtin from Glide

* Redraw focus after each tap

* Dark curtain where focus isn't (now looks like mastosoc)

* Correct ktlint for FocusDialog

* draft: switch to overlay for focus indicator

* Draw focus circle, but ImageView and FocusIndicatorView seem to share a single canvas

* Switch focus circle to path approach

* Correctly scale, save and load focuses. Clamp to visible area. Focus editor looks and feels right

* ktlint fixes and comments

* Focus indicator drawing should use device-independent pixels

* Shrink focus window when it gets unattractively tall (no linting, misbehaves on wide aspect ratio screens)

* Correct max-height behavior for screens in landscape mode

* Focus attachment result is are flipped on x axis; fix this

* Correctly thread focus through on scheduled posts, redrafted posts, and drafts (but draft focus is lost on post)

* More focus ktlint fixes

* Fix specific case where a draft is given a focus, then deleted, then posted in that order

* Fix accidental file change in focus PR

* ktLint fix

* Fix property style warnings in focus

* Fix remaining style warnings from focus PR

Co-authored-by: Conny Duck <k.pozniak@gmx.at>
  • Loading branch information
mcclure and connyduck authored Sep 21, 2022
1 parent 5d09a67 commit 7684f06
Show file tree
Hide file tree
Showing 17 changed files with 358 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import com.keylesspalace.tusky.adapter.EmojiAdapter
import com.keylesspalace.tusky.adapter.LocaleAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.components.compose.dialog.CaptionDialog
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 @@ -171,6 +172,7 @@ class ComposeActivity :
uriNew,
size,
itemOld.description,
null, // Intentionally reset focus when cropping
itemOld
)
}
Expand Down Expand Up @@ -217,6 +219,11 @@ class ComposeActivity :
CaptionDialog.newInstance(item.localId, item.description, item.uri)
.show(supportFragmentManager, "caption_dialog")
},
onAddFocus = { item ->
makeFocusDialog(item.focus, item.uri) { newFocus ->
viewModel.updateFocus(item.localId, newFocus)
}
},
onEditImage = this::editImageInQueue,
onRemove = this::removeMediaFromQueue
)
Expand Down Expand Up @@ -1139,7 +1146,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 @@ -103,7 +103,7 @@ class ComposeViewModel @Inject constructor(

private var setupComplete = false

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, instanceInfo.first())
val mediaItems = media.value
Expand All @@ -113,7 +113,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 @@ -126,6 +126,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 @@ -136,7 +137,8 @@ class ComposeViewModel @Inject constructor(
uri = uri,
type = type,
mediaSize = mediaSize,
description = description
description = description,
focus = focus
)
stashMediaItem = mediaItem

Expand Down Expand Up @@ -181,7 +183,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?) {
media.update { mediaValue ->
val mediaItem = QueuedMedia(
localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1,
Expand All @@ -190,7 +192,8 @@ class ComposeViewModel @Inject constructor(
mediaSize = 0,
uploadPercent = -1,
id = id,
description = description
description = description,
focus = focus
)
mediaValue + mediaItem
}
Expand Down Expand Up @@ -245,9 +248,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 @@ -260,6 +265,7 @@ class ComposeViewModel @Inject constructor(
visibility = statusVisibility.value,
mediaUris = mediaUris,
mediaDescriptions = mediaDescriptions,
mediaFocus = mediaFocus,
poll = poll.value,
failedToSend = false,
scheduledAt = scheduledAt.value,
Expand All @@ -286,11 +292,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()
media.value.forEach { item ->
mediaIds.add(item.id!!)
mediaUris.add(item.uri)
mediaDescriptions.add(item.description ?: "")
mediaFocus.add(item.focus)
mediaProcessed.add(false)
}
val tootToSend = StatusToSend(
Expand All @@ -301,6 +309,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 @@ -319,11 +328,12 @@ class ComposeViewModel @Inject constructor(
}
}

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 @@ -332,7 +342,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 @@ -343,6 +355,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 @@ -413,7 +437,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 @@ -423,7 +447,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!!.intrinsicWidth
val height = resource.intrinsicHeight

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.height > maxHeight) {
val verticalShrinkLayout = FrameLayout.LayoutParams(width, maxHeight)
dialogBinding.imageView.layoutParams = verticalShrinkLayout
dialogBinding.focusIndicator.layoutParams = 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

1 comment on commit 7684f06

@Archenoth
Copy link

Choose a reason for hiding this comment

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

Oh my gosh, this is super late, but thank you for this!!

Having to switch to something else every time I wanted to upload media was a huge hassle, and I super-appreciate the effort that went into this!

Please sign in to comment.