mirror of
https://github.com/accelforce/Yuito
synced 2025-02-15 19:10:44 +01:00
Add UI for image-attachment "focus" (#2620)
* 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>
This commit is contained in:
parent
5d09a67b52
commit
7684f06938
@ -69,6 +69,7 @@ import com.keylesspalace.tusky.adapter.EmojiAdapter
|
|||||||
import com.keylesspalace.tusky.adapter.LocaleAdapter
|
import com.keylesspalace.tusky.adapter.LocaleAdapter
|
||||||
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
|
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
|
||||||
import com.keylesspalace.tusky.components.compose.dialog.CaptionDialog
|
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.dialog.showAddPollDialog
|
||||||
import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener
|
import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener
|
||||||
import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView
|
import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView
|
||||||
@ -171,6 +172,7 @@ class ComposeActivity :
|
|||||||
uriNew,
|
uriNew,
|
||||||
size,
|
size,
|
||||||
itemOld.description,
|
itemOld.description,
|
||||||
|
null, // Intentionally reset focus when cropping
|
||||||
itemOld
|
itemOld
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -217,6 +219,11 @@ class ComposeActivity :
|
|||||||
CaptionDialog.newInstance(item.localId, item.description, item.uri)
|
CaptionDialog.newInstance(item.localId, item.description, item.uri)
|
||||||
.show(supportFragmentManager, "caption_dialog")
|
.show(supportFragmentManager, "caption_dialog")
|
||||||
},
|
},
|
||||||
|
onAddFocus = { item ->
|
||||||
|
makeFocusDialog(item.focus, item.uri) { newFocus ->
|
||||||
|
viewModel.updateFocus(item.localId, newFocus)
|
||||||
|
}
|
||||||
|
},
|
||||||
onEditImage = this::editImageInQueue,
|
onEditImage = this::editImageInQueue,
|
||||||
onRemove = this::removeMediaFromQueue
|
onRemove = this::removeMediaFromQueue
|
||||||
)
|
)
|
||||||
@ -1139,7 +1146,8 @@ class ComposeActivity :
|
|||||||
val mediaSize: Long,
|
val mediaSize: Long,
|
||||||
val uploadPercent: Int = 0,
|
val uploadPercent: Int = 0,
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
val description: String? = null
|
val description: String? = null,
|
||||||
|
val focus: Attachment.Focus? = null
|
||||||
) {
|
) {
|
||||||
enum class Type {
|
enum class Type {
|
||||||
IMAGE, VIDEO, AUDIO;
|
IMAGE, VIDEO, AUDIO;
|
||||||
|
@ -103,7 +103,7 @@ class ComposeViewModel @Inject constructor(
|
|||||||
|
|
||||||
private var setupComplete = false
|
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 {
|
try {
|
||||||
val (type, uri, size) = mediaUploader.prepareMedia(mediaUri, instanceInfo.first())
|
val (type, uri, size) = mediaUploader.prepareMedia(mediaUri, instanceInfo.first())
|
||||||
val mediaItems = media.value
|
val mediaItems = media.value
|
||||||
@ -113,7 +113,7 @@ class ComposeViewModel @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
Result.failure(VideoOrImageException())
|
Result.failure(VideoOrImageException())
|
||||||
} else {
|
} else {
|
||||||
val queuedMedia = addMediaToQueue(type, uri, size, description)
|
val queuedMedia = addMediaToQueue(type, uri, size, description, focus)
|
||||||
Result.success(queuedMedia)
|
Result.success(queuedMedia)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@ -126,6 +126,7 @@ class ComposeViewModel @Inject constructor(
|
|||||||
uri: Uri,
|
uri: Uri,
|
||||||
mediaSize: Long,
|
mediaSize: Long,
|
||||||
description: String? = null,
|
description: String? = null,
|
||||||
|
focus: Attachment.Focus? = null,
|
||||||
replaceItem: QueuedMedia? = null
|
replaceItem: QueuedMedia? = null
|
||||||
): QueuedMedia {
|
): QueuedMedia {
|
||||||
var stashMediaItem: QueuedMedia? = null
|
var stashMediaItem: QueuedMedia? = null
|
||||||
@ -136,7 +137,8 @@ class ComposeViewModel @Inject constructor(
|
|||||||
uri = uri,
|
uri = uri,
|
||||||
type = type,
|
type = type,
|
||||||
mediaSize = mediaSize,
|
mediaSize = mediaSize,
|
||||||
description = description
|
description = description,
|
||||||
|
focus = focus
|
||||||
)
|
)
|
||||||
stashMediaItem = mediaItem
|
stashMediaItem = mediaItem
|
||||||
|
|
||||||
@ -181,7 +183,7 @@ class ComposeViewModel @Inject constructor(
|
|||||||
return mediaItem
|
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 ->
|
media.update { mediaValue ->
|
||||||
val mediaItem = QueuedMedia(
|
val mediaItem = QueuedMedia(
|
||||||
localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1,
|
localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1,
|
||||||
@ -190,7 +192,8 @@ class ComposeViewModel @Inject constructor(
|
|||||||
mediaSize = 0,
|
mediaSize = 0,
|
||||||
uploadPercent = -1,
|
uploadPercent = -1,
|
||||||
id = id,
|
id = id,
|
||||||
description = description
|
description = description,
|
||||||
|
focus = focus
|
||||||
)
|
)
|
||||||
mediaValue + mediaItem
|
mediaValue + mediaItem
|
||||||
}
|
}
|
||||||
@ -245,9 +248,11 @@ class ComposeViewModel @Inject constructor(
|
|||||||
suspend fun saveDraft(content: String, contentWarning: String) {
|
suspend fun saveDraft(content: String, contentWarning: String) {
|
||||||
val mediaUris: MutableList<String> = mutableListOf()
|
val mediaUris: MutableList<String> = mutableListOf()
|
||||||
val mediaDescriptions: MutableList<String?> = mutableListOf()
|
val mediaDescriptions: MutableList<String?> = mutableListOf()
|
||||||
|
val mediaFocus: MutableList<Attachment.Focus?> = mutableListOf()
|
||||||
media.value.forEach { item ->
|
media.value.forEach { item ->
|
||||||
mediaUris.add(item.uri.toString())
|
mediaUris.add(item.uri.toString())
|
||||||
mediaDescriptions.add(item.description)
|
mediaDescriptions.add(item.description)
|
||||||
|
mediaFocus.add(item.focus)
|
||||||
}
|
}
|
||||||
|
|
||||||
draftHelper.saveDraft(
|
draftHelper.saveDraft(
|
||||||
@ -260,6 +265,7 @@ class ComposeViewModel @Inject constructor(
|
|||||||
visibility = statusVisibility.value,
|
visibility = statusVisibility.value,
|
||||||
mediaUris = mediaUris,
|
mediaUris = mediaUris,
|
||||||
mediaDescriptions = mediaDescriptions,
|
mediaDescriptions = mediaDescriptions,
|
||||||
|
mediaFocus = mediaFocus,
|
||||||
poll = poll.value,
|
poll = poll.value,
|
||||||
failedToSend = false,
|
failedToSend = false,
|
||||||
scheduledAt = scheduledAt.value,
|
scheduledAt = scheduledAt.value,
|
||||||
@ -286,11 +292,13 @@ class ComposeViewModel @Inject constructor(
|
|||||||
val mediaIds: MutableList<String> = mutableListOf()
|
val mediaIds: MutableList<String> = mutableListOf()
|
||||||
val mediaUris: MutableList<Uri> = mutableListOf()
|
val mediaUris: MutableList<Uri> = mutableListOf()
|
||||||
val mediaDescriptions: MutableList<String> = mutableListOf()
|
val mediaDescriptions: MutableList<String> = mutableListOf()
|
||||||
|
val mediaFocus: MutableList<Attachment.Focus?> = mutableListOf()
|
||||||
val mediaProcessed: MutableList<Boolean> = mutableListOf()
|
val mediaProcessed: MutableList<Boolean> = mutableListOf()
|
||||||
media.value.forEach { item ->
|
media.value.forEach { item ->
|
||||||
mediaIds.add(item.id!!)
|
mediaIds.add(item.id!!)
|
||||||
mediaUris.add(item.uri)
|
mediaUris.add(item.uri)
|
||||||
mediaDescriptions.add(item.description ?: "")
|
mediaDescriptions.add(item.description ?: "")
|
||||||
|
mediaFocus.add(item.focus)
|
||||||
mediaProcessed.add(false)
|
mediaProcessed.add(false)
|
||||||
}
|
}
|
||||||
val tootToSend = StatusToSend(
|
val tootToSend = StatusToSend(
|
||||||
@ -301,6 +309,7 @@ class ComposeViewModel @Inject constructor(
|
|||||||
mediaIds = mediaIds,
|
mediaIds = mediaIds,
|
||||||
mediaUris = mediaUris.map { it.toString() },
|
mediaUris = mediaUris.map { it.toString() },
|
||||||
mediaDescriptions = mediaDescriptions,
|
mediaDescriptions = mediaDescriptions,
|
||||||
|
mediaFocus = mediaFocus,
|
||||||
scheduledAt = scheduledAt.value,
|
scheduledAt = scheduledAt.value,
|
||||||
inReplyToId = inReplyToId,
|
inReplyToId = inReplyToId,
|
||||||
poll = poll.value,
|
poll = poll.value,
|
||||||
@ -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 ->
|
val newMediaList = media.updateAndGet { mediaValue ->
|
||||||
mediaValue.map { mediaItem ->
|
mediaValue.map { mediaItem ->
|
||||||
if (mediaItem.localId == localId) {
|
if (mediaItem.localId == localId) {
|
||||||
mediaItem.copy(description = description)
|
mutator(mediaItem)
|
||||||
} else {
|
} else {
|
||||||
mediaItem
|
mediaItem
|
||||||
}
|
}
|
||||||
@ -332,7 +342,9 @@ class ComposeViewModel @Inject constructor(
|
|||||||
|
|
||||||
val updatedItem = newMediaList.find { it.localId == localId }
|
val updatedItem = newMediaList.find { it.localId == localId }
|
||||||
if (updatedItem?.id != null) {
|
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({
|
.fold({
|
||||||
true
|
true
|
||||||
}, { throwable ->
|
}, { throwable ->
|
||||||
@ -343,6 +355,18 @@ class ComposeViewModel @Inject constructor(
|
|||||||
return true
|
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> {
|
fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
|
||||||
when (token[0]) {
|
when (token[0]) {
|
||||||
'@' -> {
|
'@' -> {
|
||||||
@ -413,7 +437,7 @@ class ComposeViewModel @Inject constructor(
|
|||||||
// when coming from DraftActivity
|
// when coming from DraftActivity
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
draftAttachments.forEach { attachment ->
|
draftAttachments.forEach { attachment ->
|
||||||
pickMedia(attachment.uri, attachment.description)
|
pickMedia(attachment.uri, attachment.description, attachment.focus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else composeOptions?.mediaAttachments?.forEach { a ->
|
} else composeOptions?.mediaAttachments?.forEach { a ->
|
||||||
@ -423,7 +447,7 @@ class ComposeViewModel @Inject constructor(
|
|||||||
Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE
|
Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE
|
||||||
Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO
|
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
|
draftId = composeOptions?.draftId ?: 0
|
||||||
|
@ -32,6 +32,7 @@ import com.keylesspalace.tusky.components.compose.view.ProgressImageView
|
|||||||
class MediaPreviewAdapter(
|
class MediaPreviewAdapter(
|
||||||
context: Context,
|
context: Context,
|
||||||
private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit,
|
private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit,
|
||||||
|
private val onAddFocus: (ComposeActivity.QueuedMedia) -> Unit,
|
||||||
private val onEditImage: (ComposeActivity.QueuedMedia) -> Unit,
|
private val onEditImage: (ComposeActivity.QueuedMedia) -> Unit,
|
||||||
private val onRemove: (ComposeActivity.QueuedMedia) -> Unit
|
private val onRemove: (ComposeActivity.QueuedMedia) -> Unit
|
||||||
) : RecyclerView.Adapter<MediaPreviewAdapter.PreviewViewHolder>() {
|
) : RecyclerView.Adapter<MediaPreviewAdapter.PreviewViewHolder>() {
|
||||||
@ -44,15 +45,19 @@ class MediaPreviewAdapter(
|
|||||||
val item = differ.currentList[position]
|
val item = differ.currentList[position]
|
||||||
val popup = PopupMenu(view.context, view)
|
val popup = PopupMenu(view.context, view)
|
||||||
val addCaptionId = 1
|
val addCaptionId = 1
|
||||||
val editImageId = 2
|
val addFocusId = 2
|
||||||
val removeId = 3
|
val editImageId = 3
|
||||||
|
val removeId = 4
|
||||||
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
|
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, editImageId, 0, R.string.action_edit_image)
|
||||||
|
}
|
||||||
popup.menu.add(0, removeId, 0, R.string.action_remove)
|
popup.menu.add(0, removeId, 0, R.string.action_remove)
|
||||||
popup.setOnMenuItemClickListener { menuItem ->
|
popup.setOnMenuItemClickListener { menuItem ->
|
||||||
when (menuItem.itemId) {
|
when (menuItem.itemId) {
|
||||||
addCaptionId -> onAddCaption(item)
|
addCaptionId -> onAddCaption(item)
|
||||||
|
addFocusId -> onAddFocus(item)
|
||||||
editImageId -> onEditImage(item)
|
editImageId -> onEditImage(item)
|
||||||
removeId -> onRemove(item)
|
removeId -> onRemove(item)
|
||||||
}
|
}
|
||||||
@ -78,11 +83,24 @@ class MediaPreviewAdapter(
|
|||||||
// TODO: Fancy waveform display?
|
// TODO: Fancy waveform display?
|
||||||
holder.progressImageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
|
holder.progressImageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
|
||||||
} else {
|
} 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)
|
.load(item.uri)
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
.dontAnimate()
|
.dontAnimate()
|
||||||
.into(holder.progressImageView)
|
.centerInside()
|
||||||
|
|
||||||
|
if (focus != null)
|
||||||
|
glide = glide.addListener(imageView)
|
||||||
|
|
||||||
|
glide.into(imageView)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -225,7 +225,13 @@ class MediaUploader @Inject constructor(
|
|||||||
null
|
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))
|
send(UploadEvent.FinishedEvent(result.id))
|
||||||
}, { throwable ->
|
}, { throwable ->
|
||||||
val errorMessage = throwable.getServerErrorMessage()
|
val errorMessage = throwable.getServerErrorMessage()
|
||||||
|
@ -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()
|
||||||
|
}
|
@ -0,0 +1,130 @@
|
|||||||
|
package com.keylesspalace.tusky.components.compose.view
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Path
|
||||||
|
import android.graphics.Point
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
|
import com.keylesspalace.tusky.entity.Attachment
|
||||||
|
import kotlin.math.ceil
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
class FocusIndicatorView
|
||||||
|
@JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
defStyleAttr: Int = 0
|
||||||
|
) : View(context, attrs, defStyleAttr) {
|
||||||
|
private var focus: Attachment.Focus? = null
|
||||||
|
private var imageSize: Point? = null
|
||||||
|
private var circleRadius: Float? = null
|
||||||
|
|
||||||
|
fun setImageSize(width: Int, height: Int) {
|
||||||
|
this.imageSize = Point(width, height)
|
||||||
|
if (focus != null)
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setFocus(focus: Attachment.Focus) {
|
||||||
|
this.focus = focus
|
||||||
|
if (imageSize != null)
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assumes setFocus called first
|
||||||
|
fun getFocus(): Attachment.Focus {
|
||||||
|
return focus!!
|
||||||
|
}
|
||||||
|
|
||||||
|
// This needs to be consistent every time it is consulted over the lifetime of the object,
|
||||||
|
// so base it on the view width/height whenever the first access occurs.
|
||||||
|
private fun getCircleRadius(): Float {
|
||||||
|
val circleRadius = this.circleRadius
|
||||||
|
if (circleRadius != null)
|
||||||
|
return circleRadius
|
||||||
|
val newCircleRadius = min(this.width, this.height).toFloat() / 4.0f
|
||||||
|
this.circleRadius = newCircleRadius
|
||||||
|
return newCircleRadius
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remember focus uses -1..1 y-down coordinates (so focus value should be negated for y)
|
||||||
|
private fun axisToFocus(value: Float, innerLimit: Int, outerLimit: Int): Float {
|
||||||
|
val offset = (outerLimit - innerLimit) / 2 // Assume image is centered in widget frame
|
||||||
|
val result = (value - offset) / innerLimit.toFloat() * 2.0f - 1.0f // To range -1..1
|
||||||
|
return min(1.0f, max(-1.0f, result)) // Clamp
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun axisFromFocus(value: Float, innerLimit: Int, outerLimit: Int): Float {
|
||||||
|
val offset = (outerLimit - innerLimit) / 2
|
||||||
|
return offset.toFloat() + ((value + 1.0f) / 2.0f) * innerLimit.toFloat() // From range -1..1
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("ClickableViewAccessibility") // Android Studio wants us to implement PerformClick for accessibility, but that unfortunately cannot be made meaningful for this widget.
|
||||||
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||||
|
if (event.actionMasked == MotionEvent.ACTION_CANCEL)
|
||||||
|
return false
|
||||||
|
|
||||||
|
val imageSize = this.imageSize
|
||||||
|
if (imageSize == null)
|
||||||
|
return false
|
||||||
|
|
||||||
|
// Convert touch xy to point inside image
|
||||||
|
focus = Attachment.Focus(axisToFocus(event.x, imageSize.x, this.width), -axisToFocus(event.y, imageSize.y, this.height))
|
||||||
|
invalidate()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private val transparentDarkGray = 0x40000000
|
||||||
|
private val strokeWidth = 4.0f * this.resources.displayMetrics.density
|
||||||
|
|
||||||
|
private val curtainPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
|
||||||
|
private val curtainPath = Path()
|
||||||
|
|
||||||
|
init {
|
||||||
|
curtainPaint.color = transparentDarkGray
|
||||||
|
curtainPaint.style = Paint.Style.FILL
|
||||||
|
|
||||||
|
strokePaint.style = Paint.Style.STROKE
|
||||||
|
strokePaint.strokeWidth = strokeWidth
|
||||||
|
strokePaint.color = Color.WHITE
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
super.onDraw(canvas)
|
||||||
|
|
||||||
|
val imageSize = this.imageSize
|
||||||
|
val focus = this.focus
|
||||||
|
|
||||||
|
if (imageSize != null && focus != null) {
|
||||||
|
val x = axisFromFocus(focus.x, imageSize.x, this.width)
|
||||||
|
val y = axisFromFocus(-focus.y, imageSize.y, this.height)
|
||||||
|
val circleRadius = getCircleRadius()
|
||||||
|
|
||||||
|
curtainPath.reset() // Draw a flood fill with a hole cut out of it
|
||||||
|
curtainPath.fillType = Path.FillType.WINDING
|
||||||
|
curtainPath.addRect(0.0f, 0.0f, this.width.toFloat(), this.height.toFloat(), Path.Direction.CW)
|
||||||
|
curtainPath.addCircle(x, y, circleRadius, Path.Direction.CCW)
|
||||||
|
canvas.drawPath(curtainPath, curtainPaint)
|
||||||
|
|
||||||
|
canvas.drawCircle(x, y, circleRadius, strokePaint) // Draw white circle
|
||||||
|
canvas.drawCircle(x, y, strokeWidth / 2.0f, strokePaint) // Draw white dot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give a "safe" height based on currently set image size. Assume imageSize is set and height>width already checked
|
||||||
|
fun maxAttractiveHeight(): Int {
|
||||||
|
val height = this.imageSize!!.y
|
||||||
|
val circleRadius = getCircleRadius()
|
||||||
|
|
||||||
|
// Give us enough space for the image, plus on each side half a focus indicator circle, plus a strokeWidth
|
||||||
|
return ceil(height.toFloat() + circleRadius * 2.0f + strokeWidth).toInt()
|
||||||
|
}
|
||||||
|
}
|
@ -30,9 +30,10 @@ import androidx.appcompat.widget.AppCompatImageView;
|
|||||||
import android.util.AttributeSet;
|
import android.util.AttributeSet;
|
||||||
|
|
||||||
import com.keylesspalace.tusky.R;
|
import com.keylesspalace.tusky.R;
|
||||||
|
import com.keylesspalace.tusky.view.MediaPreviewImageView;
|
||||||
import at.connyduck.sparkbutton.helpers.Utils;
|
import at.connyduck.sparkbutton.helpers.Utils;
|
||||||
|
|
||||||
public final class ProgressImageView extends AppCompatImageView {
|
public final class ProgressImageView extends MediaPreviewImageView {
|
||||||
|
|
||||||
private int progress = -1;
|
private int progress = -1;
|
||||||
private final RectF progressRect = new RectF();
|
private final RectF progressRect = new RectF();
|
||||||
|
@ -25,6 +25,7 @@ import com.keylesspalace.tusky.BuildConfig
|
|||||||
import com.keylesspalace.tusky.db.AppDatabase
|
import com.keylesspalace.tusky.db.AppDatabase
|
||||||
import com.keylesspalace.tusky.db.DraftAttachment
|
import com.keylesspalace.tusky.db.DraftAttachment
|
||||||
import com.keylesspalace.tusky.db.DraftEntity
|
import com.keylesspalace.tusky.db.DraftEntity
|
||||||
|
import com.keylesspalace.tusky.entity.Attachment
|
||||||
import com.keylesspalace.tusky.entity.NewPoll
|
import com.keylesspalace.tusky.entity.NewPoll
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
import com.keylesspalace.tusky.util.IOUtils
|
import com.keylesspalace.tusky.util.IOUtils
|
||||||
@ -59,6 +60,7 @@ class DraftHelper @Inject constructor(
|
|||||||
visibility: Status.Visibility,
|
visibility: Status.Visibility,
|
||||||
mediaUris: List<String>,
|
mediaUris: List<String>,
|
||||||
mediaDescriptions: List<String?>,
|
mediaDescriptions: List<String?>,
|
||||||
|
mediaFocus: List<Attachment.Focus?>,
|
||||||
poll: NewPoll?,
|
poll: NewPoll?,
|
||||||
failedToSend: Boolean,
|
failedToSend: Boolean,
|
||||||
scheduledAt: String?,
|
scheduledAt: String?,
|
||||||
@ -103,6 +105,7 @@ class DraftHelper @Inject constructor(
|
|||||||
DraftAttachment(
|
DraftAttachment(
|
||||||
uriString = uris[i].toString(),
|
uriString = uris[i].toString(),
|
||||||
description = mediaDescriptions[i],
|
description = mediaDescriptions[i],
|
||||||
|
focus = mediaFocus[i],
|
||||||
type = types[i]
|
type = types[i]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -17,7 +17,6 @@ package com.keylesspalace.tusky.components.drafts
|
|||||||
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import androidx.appcompat.widget.AppCompatImageView
|
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.ListAdapter
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
@ -26,6 +25,7 @@ import com.bumptech.glide.Glide
|
|||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.db.DraftAttachment
|
import com.keylesspalace.tusky.db.DraftAttachment
|
||||||
|
import com.keylesspalace.tusky.view.MediaPreviewImageView
|
||||||
|
|
||||||
class DraftMediaAdapter(
|
class DraftMediaAdapter(
|
||||||
private val attachmentClick: () -> Unit
|
private val attachmentClick: () -> Unit
|
||||||
@ -42,24 +42,34 @@ class DraftMediaAdapter(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DraftMediaViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DraftMediaViewHolder {
|
||||||
return DraftMediaViewHolder(AppCompatImageView(parent.context))
|
return DraftMediaViewHolder(MediaPreviewImageView(parent.context))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: DraftMediaViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: DraftMediaViewHolder, position: Int) {
|
||||||
getItem(position)?.let { attachment ->
|
getItem(position)?.let { attachment ->
|
||||||
if (attachment.type == DraftAttachment.Type.AUDIO) {
|
if (attachment.type == DraftAttachment.Type.AUDIO) {
|
||||||
|
holder.imageView.clearFocus()
|
||||||
holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
|
holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
|
||||||
} else {
|
} else {
|
||||||
Glide.with(holder.itemView.context)
|
if (attachment.focus != null)
|
||||||
|
holder.imageView.setFocalPoint(attachment.focus)
|
||||||
|
else
|
||||||
|
holder.imageView.clearFocus()
|
||||||
|
var glide = Glide.with(holder.itemView.context)
|
||||||
.load(attachment.uri)
|
.load(attachment.uri)
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
.dontAnimate()
|
.dontAnimate()
|
||||||
.into(holder.imageView)
|
.centerInside()
|
||||||
|
|
||||||
|
if (attachment.focus != null)
|
||||||
|
glide = glide.addListener(holder.imageView)
|
||||||
|
|
||||||
|
glide.into(holder.imageView)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class DraftMediaViewHolder(val imageView: ImageView) :
|
inner class DraftMediaViewHolder(val imageView: MediaPreviewImageView) :
|
||||||
RecyclerView.ViewHolder(imageView) {
|
RecyclerView.ViewHolder(imageView) {
|
||||||
init {
|
init {
|
||||||
val thumbnailViewSize =
|
val thumbnailViewSize =
|
||||||
|
@ -22,6 +22,7 @@ import androidx.room.Entity
|
|||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
|
import com.keylesspalace.tusky.entity.Attachment
|
||||||
import com.keylesspalace.tusky.entity.NewPoll
|
import com.keylesspalace.tusky.entity.NewPoll
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
@ -52,6 +53,7 @@ data class DraftEntity(
|
|||||||
data class DraftAttachment(
|
data class DraftAttachment(
|
||||||
@SerializedName(value = "uriString", alternate = ["e", "i"]) val uriString: String,
|
@SerializedName(value = "uriString", alternate = ["e", "i"]) val uriString: String,
|
||||||
@SerializedName(value = "description", alternate = ["f", "j"]) val description: String?,
|
@SerializedName(value = "description", alternate = ["f", "j"]) val description: String?,
|
||||||
|
@SerializedName(value = "focus") val focus: Attachment.Focus?,
|
||||||
@SerializedName(value = "type", alternate = ["g", "k"]) val type: Type
|
@SerializedName(value = "type", alternate = ["g", "k"]) val type: Type
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
val uri: Uri
|
val uri: Uri
|
||||||
|
@ -147,7 +147,8 @@ interface MastodonApi {
|
|||||||
@PUT("api/v1/media/{mediaId}")
|
@PUT("api/v1/media/{mediaId}")
|
||||||
suspend fun updateMedia(
|
suspend fun updateMedia(
|
||||||
@Path("mediaId") mediaId: String,
|
@Path("mediaId") mediaId: String,
|
||||||
@Field("description") description: String
|
@Field("description") description: String?,
|
||||||
|
@Field("focus") focus: String?
|
||||||
): NetworkResult<Attachment>
|
): NetworkResult<Attachment>
|
||||||
|
|
||||||
@GET("api/v1/media/{mediaId}")
|
@GET("api/v1/media/{mediaId}")
|
||||||
|
@ -15,6 +15,7 @@ interface MediaUploadApi {
|
|||||||
@POST("api/v2/media")
|
@POST("api/v2/media")
|
||||||
suspend fun uploadMedia(
|
suspend fun uploadMedia(
|
||||||
@Part file: MultipartBody.Part,
|
@Part file: MultipartBody.Part,
|
||||||
@Part description: MultipartBody.Part? = null
|
@Part description: MultipartBody.Part? = null,
|
||||||
|
@Part focus: MultipartBody.Part? = null
|
||||||
): NetworkResult<MediaUploadResult>
|
): NetworkResult<MediaUploadResult>
|
||||||
}
|
}
|
||||||
|
@ -89,6 +89,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
|
|||||||
mediaIds = emptyList(),
|
mediaIds = emptyList(),
|
||||||
mediaUris = emptyList(),
|
mediaUris = emptyList(),
|
||||||
mediaDescriptions = emptyList(),
|
mediaDescriptions = emptyList(),
|
||||||
|
mediaFocus = emptyList(),
|
||||||
scheduledAt = null,
|
scheduledAt = null,
|
||||||
inReplyToId = citedStatusId,
|
inReplyToId = citedStatusId,
|
||||||
poll = null,
|
poll = null,
|
||||||
|
@ -26,6 +26,7 @@ import com.keylesspalace.tusky.components.drafts.DraftHelper
|
|||||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.di.Injectable
|
import com.keylesspalace.tusky.di.Injectable
|
||||||
|
import com.keylesspalace.tusky.entity.Attachment
|
||||||
import com.keylesspalace.tusky.entity.NewPoll
|
import com.keylesspalace.tusky.entity.NewPoll
|
||||||
import com.keylesspalace.tusky.entity.NewStatus
|
import com.keylesspalace.tusky.entity.NewStatus
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
@ -258,6 +259,7 @@ class SendStatusService : Service(), Injectable {
|
|||||||
visibility = Status.Visibility.byString(status.visibility),
|
visibility = Status.Visibility.byString(status.visibility),
|
||||||
mediaUris = status.mediaUris,
|
mediaUris = status.mediaUris,
|
||||||
mediaDescriptions = status.mediaDescriptions,
|
mediaDescriptions = status.mediaDescriptions,
|
||||||
|
mediaFocus = status.mediaFocus,
|
||||||
poll = status.poll,
|
poll = status.poll,
|
||||||
failedToSend = true,
|
failedToSend = true,
|
||||||
scheduledAt = status.scheduledAt,
|
scheduledAt = status.scheduledAt,
|
||||||
@ -359,6 +361,7 @@ data class StatusToSend(
|
|||||||
val mediaIds: List<String>,
|
val mediaIds: List<String>,
|
||||||
val mediaUris: List<String>,
|
val mediaUris: List<String>,
|
||||||
val mediaDescriptions: List<String>,
|
val mediaDescriptions: List<String>,
|
||||||
|
val mediaFocus: List<Attachment.Focus?>,
|
||||||
val scheduledAt: String?,
|
val scheduledAt: String?,
|
||||||
val inReplyToId: String?,
|
val inReplyToId: String?,
|
||||||
val poll: NewPoll?,
|
val poll: NewPoll?,
|
||||||
|
@ -37,7 +37,7 @@ import com.keylesspalace.tusky.util.FocalPointUtil
|
|||||||
* However if there is no focal point set (e.g. it is null), then this view should simply
|
* However if there is no focal point set (e.g. it is null), then this view should simply
|
||||||
* act exactly the same as an ordinary android ImageView.
|
* act exactly the same as an ordinary android ImageView.
|
||||||
*/
|
*/
|
||||||
class MediaPreviewImageView
|
open class MediaPreviewImageView
|
||||||
@JvmOverloads constructor(
|
@JvmOverloads constructor(
|
||||||
context: Context,
|
context: Context,
|
||||||
attrs: AttributeSet? = null,
|
attrs: AttributeSet? = null,
|
||||||
|
17
app/src/main/res/layout/dialog_focus.xml
Normal file
17
app/src/main/res/layout/dialog_focus.xml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<!-- todo add padding -->
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/imageView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
|
<com.keylesspalace.tusky.components.compose.view.FocusIndicatorView
|
||||||
|
android:id="@+id/focusIndicator"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
@ -404,10 +404,12 @@
|
|||||||
<string name="compose_active_account_description">Posting as %1$s</string>
|
<string name="compose_active_account_description">Posting as %1$s</string>
|
||||||
|
|
||||||
<string name="error_failed_set_caption">Failed to set caption</string>
|
<string name="error_failed_set_caption">Failed to set caption</string>
|
||||||
|
<string name="error_failed_set_focus">Failed to set focus point</string>
|
||||||
<plurals name="hint_describe_for_visually_impaired">
|
<plurals name="hint_describe_for_visually_impaired">
|
||||||
<item quantity="other">Describe for visually impaired\n(%d character limit)</item>
|
<item quantity="other">Describe for visually impaired\n(%d character limit)</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="action_set_caption">Set caption</string>
|
<string name="action_set_caption">Set caption</string>
|
||||||
|
<string name="action_set_focus">Set focus point</string>
|
||||||
<string name="action_edit_image">Edit image</string>
|
<string name="action_edit_image">Edit image</string>
|
||||||
<string name="action_remove">Remove</string>
|
<string name="action_remove">Remove</string>
|
||||||
<string name="lock_account_label">Lock account</string>
|
<string name="lock_account_label">Lock account</string>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user