diff --git a/app/src/main/java/com/capyreader/app/common/ContextShareLinkExt.kt b/app/src/main/java/com/capyreader/app/common/ContextShareLinkExt.kt index 3dba7fb1d..0e4355327 100644 --- a/app/src/main/java/com/capyreader/app/common/ContextShareLinkExt.kt +++ b/app/src/main/java/com/capyreader/app/common/ContextShareLinkExt.kt @@ -2,6 +2,7 @@ package com.capyreader.app.common import android.content.Context import android.content.Intent +import android.net.Uri fun Context.shareLink(url: String, title: String) { val share = Intent.createChooser(Intent().apply { @@ -12,3 +13,12 @@ fun Context.shareLink(url: String, title: String) { }, null) startActivity(share) } + +fun Context.shareImage(uri: Uri) { + val shareIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + setDataAndType(uri, "image/jpeg") + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + } + startActivity(Intent.createChooser(shareIntent, null)) +} diff --git a/app/src/main/java/com/capyreader/app/common/WebViewInterface.kt b/app/src/main/java/com/capyreader/app/common/WebViewInterface.kt index 2244b69fb..9c2356628 100644 --- a/app/src/main/java/com/capyreader/app/common/WebViewInterface.kt +++ b/app/src/main/java/com/capyreader/app/common/WebViewInterface.kt @@ -9,6 +9,7 @@ import kotlinx.serialization.json.Json class WebViewInterface( private val navigateToMedia: (media: Media) -> Unit, private val onRequestLinkDialog: (link: ShareLink) -> Unit, + private val onRequestImageDialog: (imageUrl: String) -> Unit = {}, private val onOpenAudioPlayer: (audio: AudioEnclosure) -> Unit = {}, private val onPauseAudio: () -> Unit = {}, ) { @@ -36,6 +37,13 @@ class WebViewInterface( } } + @JavascriptInterface + fun showImageDialog(imageUrl: String) { + optionalURL(imageUrl)?.let { + onRequestImageDialog(it.toString()) + } + } + @JavascriptInterface fun openAudioPlayer(audioJson: String) { try { diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreen.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreen.kt index 5275ac1f9..c12d4dbab 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreen.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreen.kt @@ -76,6 +76,7 @@ import com.capyreader.app.ui.components.SearchState import com.capyreader.app.ui.provideLinkOpener import com.capyreader.app.ui.rememberLazyListState import com.capyreader.app.ui.rememberLocalConnectivity +import com.capyreader.app.ui.settings.LocalSnackbarHost import com.jocmp.capy.Article import com.jocmp.capy.ArticleFilter import com.jocmp.capy.ArticleStatus @@ -160,6 +161,8 @@ fun ArticleScreen( state = searchState, ) + val snackbarHostState = remember { SnackbarHostState() } + CompositionLocalProvider( LocalFullContent provides fullContent, LocalArticleActions provides articleActions, @@ -170,6 +173,7 @@ fun ArticleScreen( LocalLinkOpener provides provideLinkOpener(context), LocalMarkAllReadButtonPosition provides markAllReadButtonPosition, LocalUnreadCount provides unreadCount, + LocalSnackbarHost provides snackbarHostState, ) { val openNextFeedOnReadAll = afterReadAll == AfterReadAllBehavior.OPEN_NEXT_FEED @@ -187,8 +191,6 @@ fun ArticleScreen( val scaffoldNavigator = rememberArticleScaffoldNavigator() val showMultipleColumns = scaffoldNavigator.scaffoldDirective.maxHorizontalPartitions > 1 var isPullToRefreshing by remember { mutableStateOf(false) } - - val snackbarHostState = remember { SnackbarHostState() } val addFeedSuccessMessage = stringResource(R.string.add_feed_success) val currentFeed by viewModel.currentFeed.collectAsStateWithLifecycle(null) val scrollBehavior = pinnedScrollBehavior() diff --git a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleReader.kt b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleReader.kt index 2bd31e70f..62df10aeb 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleReader.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleReader.kt @@ -11,25 +11,36 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.capyreader.app.R import com.capyreader.app.common.AudioEnclosure import com.capyreader.app.common.Media import com.capyreader.app.common.rememberTalkbackPreference +import com.capyreader.app.common.shareImage import com.capyreader.app.preferences.AppPreferences import com.capyreader.app.preferences.ReaderImageVisibility import com.capyreader.app.ui.ConnectivityType import com.capyreader.app.ui.LocalConnectivity import com.capyreader.app.ui.LocalLinkOpener import com.capyreader.app.ui.articles.ColumnScrollbar +import com.capyreader.app.ui.articles.media.ImageSaver import com.capyreader.app.ui.components.WebView import com.capyreader.app.ui.components.WebViewState import com.capyreader.app.ui.components.rememberSaveableShareLink import com.capyreader.app.ui.components.rememberWebViewState +import com.capyreader.app.ui.settings.LocalSnackbarHost import com.jocmp.capy.Article +import com.jocmp.capy.common.launchIO +import com.jocmp.capy.common.launchUI +import com.jocmp.capy.common.withUIContext import org.koin.compose.koinInject import kotlin.math.roundToInt @@ -43,12 +54,62 @@ fun ArticleReader( isAudioPlaying: Boolean = false, ) { val (shareLink, setShareLink) = rememberSaveableShareLink() + val (shareImageUrl, setImageUrl) = rememberSaveable { mutableStateOf(null) } val linkOpener = LocalLinkOpener.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + val snackbar = LocalSnackbarHost.current + val successMessage = stringResource(R.string.media_save_success) + val failureMessage = stringResource(R.string.media_save_failure) + val shareFailureMessage = stringResource(R.string.media_share_failure) + + fun showSnackbar(message: String) { + scope.launchUI { + snackbar.showSnackbar(message) + } + } + + fun saveImage(imageUrl: String) { + scope.launchIO { + val result = ImageSaver.saveImage(imageUrl, context = context) + + withUIContext { + result.fold( + onSuccess = { + showSnackbar(successMessage) + }, + onFailure = { + showSnackbar(failureMessage) + } + ) + + } + } + + setImageUrl(null) + } + + fun shareImage(imageUrl: String) { + scope.launchIO { + ImageSaver.shareImage(imageUrl, context = context) + .fold( + onSuccess = { uri -> + context.shareImage(uri) + }, + onFailure = { + showSnackbar(shareFailureMessage) + } + ) + } + + setImageUrl(null) + } val webViewState = rememberWebViewState( key = article.id, onNavigateToMedia = onSelectMedia, onRequestLinkDialog = { setShareLink(it) }, + onRequestImageDialog = { setImageUrl(it) }, onOpenLink = { linkOpener.open(it) }, onOpenAudioPlayer = onSelectAudio, onPauseAudio = onPauseAudio, @@ -88,6 +149,17 @@ fun ArticleReader( link = shareLink, ) } + + if (shareImageUrl != null) { + ShareImageDialog( + onClose = { + setImageUrl(null) + }, + imageUrl = shareImageUrl, + onSave = { saveImage(shareImageUrl) }, + onShare = { shareImage(shareImageUrl) }, + ) + } } @Composable diff --git a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleView.kt b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleView.kt index 630bc5659..b504b2907 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleView.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/detail/ArticleView.kt @@ -19,8 +19,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FlexibleBottomAppBar import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable @@ -51,6 +54,7 @@ import com.capyreader.app.ui.LocalLinkOpener import com.capyreader.app.ui.articles.LocalFullContent import com.capyreader.app.ui.collectChangesWithDefault import com.capyreader.app.ui.components.pullrefresh.SwipeRefresh +import com.capyreader.app.ui.settings.LocalSnackbarHost import com.jocmp.capy.Article import org.koin.compose.koinInject @@ -215,6 +219,8 @@ private fun ArticleViewScaffold( bottomScrollBehavior: BottomAppBarScrollBehavior, topToolbarPreference: ToolbarPreferences, ) { + val snackbarHostState = remember { SnackbarHostState() } + Scaffold( modifier = Modifier .nestedScroll(bottomScrollBehavior.nestedScrollConnection) @@ -223,33 +229,40 @@ private fun ArticleViewScaffold( if (topToolbarPreference.pinned) { topBar() } + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) } ) { innerPadding -> - Box( - Modifier - .fillMaxSize() + CompositionLocalProvider( + LocalSnackbarHost provides snackbarHostState, ) { Box( - modifier = Modifier - .padding(innerPadding) + Modifier .fillMaxSize() ) { - Column { - reader() + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + ) { + Column { + reader() + } } - } - if (!topToolbarPreference.pinned) { - topBar() - } + if (!topToolbarPreference.pinned) { + topBar() + } - if (enableBottomBar) { - Box( - Modifier - .align(Alignment.BottomStart) - .fillMaxWidth() - ) { - bottomBar() + if (enableBottomBar) { + Box( + Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + ) { + bottomBar() + } } } } diff --git a/app/src/main/java/com/capyreader/app/ui/articles/detail/ShareImageDialog.kt b/app/src/main/java/com/capyreader/app/ui/articles/detail/ShareImageDialog.kt new file mode 100644 index 000000000..f48c7fce6 --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/articles/detail/ShareImageDialog.kt @@ -0,0 +1,131 @@ +package com.capyreader.app.ui.articles.detail + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Save +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.core.net.toUri +import coil.compose.AsyncImage +import com.capyreader.app.R +import com.capyreader.app.ui.components.DialogCard +import com.capyreader.app.ui.theme.CapyTheme + +@Composable +fun ShareImageDialog( + onClose: () -> Unit, + imageUrl: String, + onSave: () -> Unit, + onShare: () -> Unit, +) { + val listItemColors = + ListItemDefaults.colors(containerColor = CardDefaults.cardColors().containerColor) + + Dialog(onDismissRequest = onClose) { + DialogCard { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(16.dp) + ) { + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(4.dp)) + .background(colorScheme.surfaceContainer) + ) + Text( + extractFilename(imageUrl), + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 12.dp) + ) + } + HorizontalDivider() + Column( + Modifier.padding(vertical = 8.dp) + ) { + ListItem( + modifier = Modifier.clickable { onSave() }, + colors = listItemColors, + leadingContent = { + Icon( + Icons.Rounded.Save, + contentDescription = null + ) + }, + headlineContent = { Text(stringResource(R.string.media_save)) } + ) + + ListItem( + modifier = Modifier.clickable { onShare() }, + colors = listItemColors, + leadingContent = { + Icon( + Icons.Rounded.Share, + contentDescription = null + ) + }, + headlineContent = { Text(stringResource(R.string.media_share)) } + ) + } + } + } + } +} + +private fun extractFilename(url: String): String { + return try { + url.toUri().lastPathSegment ?: url + } catch (_: Exception) { + url + } +} + +@Preview +@Composable +private fun ShareImageDialogPreview() { + CapyTheme { + ShareImageDialog( + onClose = {}, + imageUrl = "https://asteriastudio.com/images/photo.jpg", + onSave = {}, + onShare = {}, + ) + } +} diff --git a/app/src/main/java/com/capyreader/app/ui/articles/media/MediaShareButton.kt b/app/src/main/java/com/capyreader/app/ui/articles/media/MediaShareButton.kt index b31d392b7..8d67f1511 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/media/MediaShareButton.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/media/MediaShareButton.kt @@ -1,8 +1,5 @@ package com.capyreader.app.ui.articles.media -import android.content.Context -import android.content.Intent -import android.net.Uri import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Share import androidx.compose.runtime.Composable @@ -10,6 +7,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.capyreader.app.R +import com.capyreader.app.common.shareImage import com.capyreader.app.ui.settings.LocalSnackbarHost import com.jocmp.capy.common.launchIO import com.jocmp.capy.common.launchUI @@ -35,7 +33,7 @@ fun MediaShareButton(imageUrl: String) { withUIContext { result.fold( onSuccess = { uri -> - openShareSheet(uri, context = context) + context.shareImage(uri) }, onFailure = { showFailureMessage() @@ -53,13 +51,3 @@ fun MediaShareButton(imageUrl: String) { icon = Icons.Rounded.Share, ) } - -private fun openShareSheet(uri: Uri, context: Context) { - val shareIntent = Intent(Intent.ACTION_SEND).apply { - putExtra(Intent.EXTRA_STREAM, uri) - setDataAndType(uri, "image/jpeg") - flags = Intent.FLAG_GRANT_READ_URI_PERMISSION - } - - context.startActivity(Intent.createChooser(shareIntent, null)) -} diff --git a/app/src/main/java/com/capyreader/app/ui/components/WebView.kt b/app/src/main/java/com/capyreader/app/ui/components/WebView.kt index 9d7336d1c..9ed35f7c0 100644 --- a/app/src/main/java/com/capyreader/app/ui/components/WebView.kt +++ b/app/src/main/java/com/capyreader/app/ui/components/WebView.kt @@ -207,6 +207,7 @@ fun rememberWebViewState( renderer: ArticleRenderer = koinInject(), onNavigateToMedia: (media: Media) -> Unit, onRequestLinkDialog: (link: ShareLink) -> Unit, + onRequestImageDialog: (imageUrl: String) -> Unit = {}, onOpenLink: (url: Uri) -> Unit, onOpenAudioPlayer: (audio: AudioEnclosure) -> Unit = {}, onPauseAudio: () -> Unit = {}, @@ -240,6 +241,7 @@ fun rememberWebViewState( val webViewInterface = WebViewInterface( navigateToMedia = { onNavigateToMedia(it) }, onRequestLinkDialog = onRequestLinkDialog, + onRequestImageDialog = onRequestImageDialog, onOpenAudioPlayer = onOpenAudioPlayer, onPauseAudio = onPauseAudio, ) diff --git a/capy/src/main/assets/media.js b/capy/src/main/assets/media.js index 8494b793a..19289f180 100644 --- a/capy/src/main/assets/media.js +++ b/capy/src/main/assets/media.js @@ -28,6 +28,11 @@ function addImageClickListeners() { e.preventDefault(); Android.openImageGallery(JSON.stringify(galleryImages), index); }); + + longPress(img, (e) => { + e.preventDefault(); + Android.showImageDialog(img.src); + }); }); } diff --git a/types.d.ts b/types.d.ts index ad64cf763..cea582424 100644 --- a/types.d.ts +++ b/types.d.ts @@ -6,6 +6,7 @@ interface MediaItem { declare const Android: { openImageGallery(imagesJson: string, clickedIndex: number): void; showLinkDialog(href: string, text: string): void; + showImageDialog(imageUrl: string): void; openAudioPlayer(audioJson: string): void; pauseAudio(): void; requestAudioState(): void;