Add options to download/share image on long press in ItemScreen

This commit is contained in:
Shinokuni 2024-04-14 14:40:32 +02:00
parent eeb054f068
commit afbf8129ca
9 changed files with 198 additions and 25 deletions

View File

@ -9,10 +9,19 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Readrops"> android:theme="@style/Theme.Readrops">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true">
android:label="Articles">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@ -23,5 +32,4 @@
</application> </application>
</manifest> </manifest>

View File

@ -0,0 +1,54 @@
package com.readrops.app.compose.item
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.readrops.app.compose.R
import com.readrops.app.compose.util.components.BaseDialog
import com.readrops.app.compose.util.components.SelectableImageText
import com.readrops.app.compose.util.theme.spacing
enum class ItemImageChoice {
SHARE,
DOWNLOAD
}
@Composable
fun ItemImageDialog(
onChoice: (ItemImageChoice) -> Unit,
onDismiss: () -> Unit
) {
BaseDialog(
title = stringResource(id = R.string.image_options),
icon = painterResource(id = R.drawable.ic_image),
onDismiss = onDismiss
) {
Column {
SelectableImageText(
image = rememberVectorPainter(image = Icons.Default.Share),
text = stringResource(id = R.string.share_image),
style = MaterialTheme.typography.titleMedium,
spacing = MaterialTheme.spacing.shortSpacing,
padding = MaterialTheme.spacing.shortSpacing,
imageSize = 16.dp,
onClick = { onChoice(ItemImageChoice.SHARE) }
)
SelectableImageText(
image = painterResource(id = R.drawable.ic_download),
text = stringResource(id = R.string.download_image),
style = MaterialTheme.typography.titleMedium,
spacing = MaterialTheme.spacing.shortSpacing,
padding = MaterialTheme.spacing.shortSpacing,
imageSize = 16.dp,
onClick = { onChoice(ItemImageChoice.DOWNLOAD) }
)
}
}
}

View File

@ -15,9 +15,12 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -74,6 +77,7 @@ class ItemScreen(
val backgroundColor = MaterialTheme.colorScheme.background val backgroundColor = MaterialTheme.colorScheme.background
val onBackgroundColor = MaterialTheme.colorScheme.onBackground val onBackgroundColor = MaterialTheme.colorScheme.onBackground
val snackbarHostState = remember { SnackbarHostState() }
var isScrollable by remember { mutableStateOf(true) } var isScrollable by remember { mutableStateOf(true) }
// https://developer.android.com/develop/ui/compose/touch-input/pointer-input/scroll#parent-compose-child-view // https://developer.android.com/develop/ui/compose/touch-input/pointer-input/scroll#parent-compose-child-view
@ -93,6 +97,28 @@ class ItemScreen(
} }
} }
if (state.imageDialogUrl != null) {
ItemImageDialog(
onChoice = {
if (it == ItemImageChoice.SHARE) {
screenModel.shareImage(state.imageDialogUrl!!, context)
} else {
screenModel.downloadImage(state.imageDialogUrl!!, context)
}
screenModel.closeImageDialog()
},
onDismiss = { screenModel.closeImageDialog() }
)
}
LaunchedEffect(state.fileDownloadedEvent) {
if (state.fileDownloadedEvent) {
snackbarHostState.showSnackbar("Downloaded file!")
}
}
if (state.itemWithFeed != null) { if (state.itemWithFeed != null) {
val itemWithFeed = state.itemWithFeed!! val itemWithFeed = state.itemWithFeed!!
val item = itemWithFeed.item val item = itemWithFeed.item
@ -111,6 +137,7 @@ class ItemScreen(
Scaffold( Scaffold(
modifier = Modifier modifier = Modifier
.nestedScroll(nestedScrollConnection), .nestedScroll(nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = { bottomBar = {
ItemScreenBottomBar( ItemScreenBottomBar(
item = item, item = item,
@ -148,12 +175,11 @@ class ItemScreen(
onGlobalLayoutListener = { viewHeight, contentHeight -> onGlobalLayoutListener = { viewHeight, contentHeight ->
isScrollable = viewHeight - contentHeight < 0 isScrollable = viewHeight - contentHeight < 0
}, },
onUrlClick = { url -> openUrl(url) } onUrlClick = { url -> openUrl(url) },
onImageLongPress = { url -> screenModel.openImageDialog(url) }
) { ) {
if (item.imageLink != null) { if (item.imageLink != null) {
BackgroundTitle( BackgroundTitle(itemWithFeed = itemWithFeed)
itemWithFeed = itemWithFeed
)
} else { } else {
val tintColor = if (itemWithFeed.bgColor != 0) { val tintColor = if (itemWithFeed.bgColor != 0) {
Color(itemWithFeed.bgColor) Color(itemWithFeed.bgColor)

View File

@ -2,9 +2,16 @@ package com.readrops.app.compose.item
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.os.Environment
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.core.content.FileProvider
import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import coil.imageLoader
import coil.request.ImageRequest
import com.readrops.app.compose.repositories.BaseRepository import com.readrops.app.compose.repositories.BaseRepository
import com.readrops.db.Database import com.readrops.db.Database
import com.readrops.db.entities.Item import com.readrops.db.entities.Item
@ -15,11 +22,11 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jsoup.Jsoup
import org.jsoup.parser.Parser
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get import org.koin.core.component.get
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import java.io.File
import java.io.FileOutputStream
class ItemScreenModel( class ItemScreenModel(
private val database: Database, private val database: Database,
@ -53,19 +60,6 @@ class ItemScreenModel(
} }
} }
fun formatText(): String {
val itemWithFeed = state.value.itemWithFeed!!
val document = if (itemWithFeed.websiteUrl != null) Jsoup.parse(
Parser.unescapeEntities(itemWithFeed.item.text, false), itemWithFeed.websiteUrl
) else Jsoup.parse(
Parser.unescapeEntities(itemWithFeed.item.text, false)
)
document.select("div,span").forEach { it.clearAttributes() }
return document.body().html()
}
fun shareItem(item: Item, context: Context) { fun shareItem(item: Item, context: Context) {
Intent().apply { Intent().apply {
action = Intent.ACTION_SEND action = Intent.ACTION_SEND
@ -89,9 +83,73 @@ class ItemScreenModel(
repository.setItemStarState(item) repository.setItemStarState(item)
} }
} }
fun openImageDialog(url: String) = mutableState.update { it.copy(imageDialogUrl = url) }
fun closeImageDialog() = mutableState.update { it.copy(imageDialogUrl = null) }
fun downloadImage(url: String, context: Context) {
screenModelScope.launch(dispatcher) {
val bitmap = getImage(url, context)
val target = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
url.substringAfterLast('/')
)
FileOutputStream(target).apply {
bitmap.compress(Bitmap.CompressFormat.PNG, 90, this)
flush()
close()
}
mutableState.update { it.copy(fileDownloadedEvent = true) }
}
}
fun shareImage(url: String, context: Context) {
screenModelScope.launch(dispatcher) {
val bitmap = getImage(url, context)
val uri = saveImageInCache(bitmap, url, context)
Intent().apply {
action = Intent.ACTION_SEND
type = "image/*"
putExtra(Intent.EXTRA_STREAM, uri)
}.also {
context.startActivity(Intent.createChooser(it, null))
}
}
}
private suspend fun getImage(url: String, context: Context): Bitmap {
val downloader = context.imageLoader
return (downloader.execute(
ImageRequest.Builder(context)
.data(url)
.allowHardware(false)
.build()
).drawable as BitmapDrawable).bitmap
}
private fun saveImageInCache(bitmap: Bitmap, url: String, context: Context): Uri {
val imagesFolder = File(context.cacheDir.absolutePath, "images")
if (!imagesFolder.exists()) imagesFolder.mkdirs()
val image = File(imagesFolder, url.substringAfterLast('/'))
FileOutputStream(image).apply {
bitmap.compress(Bitmap.CompressFormat.PNG, 90, this)
flush()
close()
}
return FileProvider.getUriForFile(context, context.packageName, image)
}
} }
@Stable @Stable
data class ItemState( data class ItemState(
val itemWithFeed: ItemWithFeed? = null val itemWithFeed: ItemWithFeed? = null,
val imageDialogUrl: String? = null,
val fileDownloadedEvent: Boolean = false
) )

View File

@ -13,6 +13,7 @@ class ItemNestedScrollView(
context: Context, context: Context,
onGlobalLayoutListener: (viewHeight: Int, contentHeight: Int) -> Unit, onGlobalLayoutListener: (viewHeight: Int, contentHeight: Int) -> Unit,
onUrlClick: (String) -> Unit, onUrlClick: (String) -> Unit,
onImageLongPress: (String) -> Unit,
composeViewContent: @Composable () -> Unit composeViewContent: @Composable () -> Unit
) : NestedScrollView(context) { ) : NestedScrollView(context) {
@ -38,7 +39,8 @@ class ItemNestedScrollView(
val webView = ItemWebView( val webView = ItemWebView(
context = context, context = context,
onUrlClick = onUrlClick onUrlClick = onUrlClick,
onImageLongPress = onImageLongPress
).apply { ).apply {
id = 2 id = 2
ViewCompat.setNestedScrollingEnabled(this, true) ViewCompat.setNestedScrollingEnabled(this, true)

View File

@ -17,6 +17,7 @@ import org.jsoup.parser.Parser
class ItemWebView( class ItemWebView(
context: Context, context: Context,
onUrlClick: (String) -> Unit, onUrlClick: (String) -> Unit,
onImageLongPress: (String) -> Unit,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
) : WebView(context, attrs) { ) : WebView(context, attrs) {
@ -33,6 +34,15 @@ class ItemWebView(
return true return true
} }
} }
setOnLongClickListener {
val type = hitTestResult.type
if (type == HitTestResult.IMAGE_TYPE || type == HitTestResult.SRC_IMAGE_ANCHOR_TYPE) {
hitTestResult.extra?.let { onImageLongPress(it) }
}
false
}
} }
fun loadText( fun loadText(
@ -72,5 +82,4 @@ class ItemWebView(
"" ""
} }
} }
} }

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M5,20h14v-2H5V20zM19,9h-4V3H9v6H5l7,7L19,9z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z"/>
</vector>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<paths>
<cache-path name="images" path="images/" />
</paths>
</resources>