From 2609f01d2e0cf688059c781f60e1bcfe673f8aef Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Thu, 13 Feb 2025 12:27:17 -0600 Subject: [PATCH] Start adding snackbar --- .../capyreader/app/common/ContextFileExt.kt | 22 ++++- .../com/capyreader/app/ui/CapySnackbar.kt | 5 + .../com/capyreader/app/ui/LocalSnackbar.kt | 13 +++ .../app/ui/articles/media/ArticleMediaView.kt | 84 ++++++++++------ .../app/ui/articles/media/ExternalImages.kt | 96 +++++++++++++++++++ .../app/ui/articles/media/ImageSaver.kt | 63 ------------ .../app/ui/articles/media/MediaSaveButton.kt | 17 +++- .../app/ui/articles/media/MediaShareButton.kt | 40 +++++++- app/src/main/res/xml/file_paths.xml | 5 + 9 files changed, 245 insertions(+), 100 deletions(-) create mode 100644 app/src/main/java/com/capyreader/app/ui/CapySnackbar.kt create mode 100644 app/src/main/java/com/capyreader/app/ui/LocalSnackbar.kt create mode 100644 app/src/main/java/com/capyreader/app/ui/articles/media/ExternalImages.kt delete mode 100644 app/src/main/java/com/capyreader/app/ui/articles/media/ImageSaver.kt diff --git a/app/src/main/java/com/capyreader/app/common/ContextFileExt.kt b/app/src/main/java/com/capyreader/app/common/ContextFileExt.kt index fad70e92a..f9be0003e 100644 --- a/app/src/main/java/com/capyreader/app/common/ContextFileExt.kt +++ b/app/src/main/java/com/capyreader/app/common/ContextFileExt.kt @@ -9,11 +9,23 @@ import java.io.File fun Context.fileURI(file: File): Uri = FileProvider.getUriForFile(this, "${BuildConfig.APPLICATION_ID}.fileprovider", file) +fun Context.externalImageCacheFile(name: String): File { + val imageCache = File(externalCacheDir, "images").apply { + if (!exists()) { + mkdir() + } + } + + return File(imageCache, name).apply { + createNewFile() + } +} + fun Context.createCacheFile(name: String): File { - val file = File(externalCacheDir, name) - if (file.exists()) { - file.delete() + return File(externalCacheDir, name).apply { + if (exists()) { + delete() + } + createNewFile() } - file.createNewFile() - return file } diff --git a/app/src/main/java/com/capyreader/app/ui/CapySnackbar.kt b/app/src/main/java/com/capyreader/app/ui/CapySnackbar.kt new file mode 100644 index 000000000..3bc9df0f1 --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/CapySnackbar.kt @@ -0,0 +1,5 @@ +package com.capyreader.app.ui + +interface CapySnackbar { + fun show(message: String) +} diff --git a/app/src/main/java/com/capyreader/app/ui/LocalSnackbar.kt b/app/src/main/java/com/capyreader/app/ui/LocalSnackbar.kt new file mode 100644 index 000000000..1282681e5 --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/LocalSnackbar.kt @@ -0,0 +1,13 @@ +package com.capyreader.app.ui + +import androidx.compose.runtime.compositionLocalOf + +val LocalSnackbar = compositionLocalOf { SnackbarState {} } + +data class SnackbarState( + val showMessage: (message: String) -> Unit, +) : CapySnackbar { + override fun show(message: String) { + showMessage(message) + } +} diff --git a/app/src/main/java/com/capyreader/app/ui/articles/media/ArticleMediaView.kt b/app/src/main/java/com/capyreader/app/ui/articles/media/ArticleMediaView.kt index a6f122a68..ae75a3cd9 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/media/ArticleMediaView.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/media/ArticleMediaView.kt @@ -17,12 +17,17 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue 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.Alignment @@ -36,11 +41,14 @@ import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat import coil.request.ImageRequest import com.capyreader.app.common.Media +import com.capyreader.app.ui.LocalSnackbar +import com.capyreader.app.ui.SnackbarState import com.capyreader.app.ui.components.LoadingView import com.capyreader.app.ui.components.Swiper import com.capyreader.app.ui.components.rememberSwiperState import com.capyreader.app.ui.isCompact import com.capyreader.app.ui.theme.CapyTheme +import com.jocmp.capy.common.launchUI import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage import me.saket.telephoto.zoomable.rememberZoomableImageState @@ -67,7 +75,10 @@ fun ArticleMediaView( onDismissRequest = onDismissRequest, showOverlay = showOverlay, footer = { - CaptionOverlay(caption) + CaptionOverlay( + caption = caption, + imageUrl = url + ) } ) { ZoomableAsyncImage( @@ -131,43 +142,55 @@ fun MediaScaffold( } ) + val scope = rememberCoroutineScope() val isOverlayVisible = showOverlay && swiperState.progress == 0f + val snackbarHostState = remember { SnackbarHostState() } + val snackbarState = SnackbarState { message -> + scope.launchUI { + snackbarHostState.showSnackbar(message) + } + } Scaffold( containerColor = Color.Black.copy(alpha = 1f - swiperState.progress), modifier = Modifier - .fillMaxSize() + .fillMaxSize(), + snackbarHost = { SnackbarHost(snackbarHostState) } ) { paddingValues -> - Box( - Modifier.padding(paddingValues) + CompositionLocalProvider( + LocalSnackbar provides snackbarState, ) { - Swiper( - state = swiperState, - modifier = Modifier.fillMaxSize() + Box( + Modifier.padding(paddingValues) ) { - content() - } - - Box(Modifier.align(Alignment.BottomStart)) { - AnimatedVisibility( - isOverlayVisible, - enter = fadeIn() + expandVertically(), - exit = shrinkVertically() + fadeOut(), + Swiper( + state = swiperState, + modifier = Modifier.fillMaxSize() ) { - footer() + content() } - } - CloseButton( - onClick = { onDismissRequest() }, - visible = isOverlayVisible - ) + Box(Modifier.align(Alignment.BottomStart)) { + AnimatedVisibility( + isOverlayVisible, + enter = fadeIn() + expandVertically(), + exit = shrinkVertically() + fadeOut(), + ) { + footer() + } + } + + CloseButton( + onClick = { onDismissRequest() }, + visible = isOverlayVisible + ) + } } } } @Composable -private fun CaptionOverlay(text: String?) { +private fun CaptionOverlay(caption: String?, imageUrl: String) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = if (isCompact()) { @@ -180,7 +203,7 @@ private fun CaptionOverlay(text: String?) { .background(Color.Black.copy(alpha = 0.8f)) .padding(vertical = 8.dp, horizontal = 16.dp) ) { - if (!text.isNullOrBlank()) { + if (!caption.isNullOrBlank()) { Box( Modifier .then( @@ -192,7 +215,7 @@ private fun CaptionOverlay(text: String?) { ) ) { Text( - text, + caption, color = MediaColors.textColor, modifier = Modifier .padding(top = 8.dp) @@ -202,8 +225,8 @@ private fun CaptionOverlay(text: String?) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - MediaSaveButton() - MediaShareButton() + MediaSaveButton(imageUrl) + MediaShareButton(imageUrl) } } } @@ -233,7 +256,8 @@ private fun ArticleMediaViewPreview_Foldable() { ) { Box(Modifier.align(Alignment.BottomStart)) { CaptionOverlay( - "A description of the picture you're taking a look at" + "A description of the picture you're taking a look at", + "http://example.com/test.jpg" ) } } @@ -251,7 +275,8 @@ private fun ArticleMediaViewPreview_Phone() { ) { Box(Modifier.align(Alignment.BottomStart)) { CaptionOverlay( - "A description" + "A description", + "http://example.com/test.jpg" ) } } @@ -270,7 +295,8 @@ private fun ArticleMediaViewPreview_Tablet() { ) { Box(Modifier.align(Alignment.BottomStart)) { CaptionOverlay( - "A description of the picture you're taking a look at" + "A description of the picture you're taking a look at", + "http://example.com/test.jpg" ) } } diff --git a/app/src/main/java/com/capyreader/app/ui/articles/media/ExternalImages.kt b/app/src/main/java/com/capyreader/app/ui/articles/media/ExternalImages.kt new file mode 100644 index 000000000..c6986ea49 --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/articles/media/ExternalImages.kt @@ -0,0 +1,96 @@ +package com.capyreader.app.ui.articles.media + +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.os.Environment +import android.provider.MediaStore +import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri +import coil.executeBlocking +import coil.imageLoader +import coil.request.ImageRequest +import com.capyreader.app.common.MD5 +import com.capyreader.app.common.externalImageCacheFile +import com.capyreader.app.common.fileURI +import com.jocmp.capy.logging.CapyLog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import java.io.FileOutputStream +import java.io.IOException + +object ExternalImages { + suspend fun shareImage(imageUrl: String, context: Context): Result { + return withContext(Dispatchers.IO) { + return@withContext try { + val bitmap = + createBitmap(imageUrl, context) ?: throw IOException("Failed to generate image") + + val target = context.externalImageCacheFile(fileName(imageUrl)) + + context.contentResolver.openFileDescriptor(target.toUri(), "w")?.use { descriptor -> + FileOutputStream(descriptor.fileDescriptor).use { + it.write(jpegStream(bitmap)) + } + } + + Result.success(context.fileURI(target)) + } catch (e: Exception) { + CapyLog.error("share_img", error = e) + Result.failure(e) + } + } + } + + suspend fun saveImage(imageUrl: String, context: Context): Result { + return withContext(Dispatchers.IO) { + return@withContext try { + val bitmap = + createBitmap(imageUrl, context) ?: throw IOException("Failed to generate image") + + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName(imageUrl)) + put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") + put( + MediaStore.MediaColumns.RELATIVE_PATH, + "${Environment.DIRECTORY_PICTURES}/Capy" + ) + } + + val uri = context.contentResolver.insert( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + contentValues + ) ?: throw IOException("Failed to create MediaStore entry") + + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(jpegStream(bitmap)) + } ?: throw IOException("Failed to open output stream") + + Result.success(uri) + } catch (e: Exception) { + CapyLog.error("save_img", error = e) + Result.failure(e) + } + } + } + + private fun jpegStream(bitmap: Bitmap): ByteArray { + val byteArrayOutputStream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutputStream) + return byteArrayOutputStream.toByteArray() + } + + private fun fileName(imageUrl: String): String { + return "${MD5.from(imageUrl)}.jpg" + } + + private fun createBitmap(imageUrl: String, context: Context): Bitmap? { + val imageRequest = ImageRequest.Builder(context) + .data(imageUrl) + .build() + + return context.imageLoader.executeBlocking(imageRequest).drawable?.toBitmap() + } +} diff --git a/app/src/main/java/com/capyreader/app/ui/articles/media/ImageSaver.kt b/app/src/main/java/com/capyreader/app/ui/articles/media/ImageSaver.kt deleted file mode 100644 index 7f8d8442b..000000000 --- a/app/src/main/java/com/capyreader/app/ui/articles/media/ImageSaver.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.capyreader.app.ui.articles.media - -import android.content.ContentValues -import android.content.Context -import android.graphics.Bitmap -import android.net.Uri -import android.os.Environment -import android.provider.MediaStore -import androidx.core.graphics.drawable.toBitmap -import coil.executeBlocking -import coil.imageLoader -import coil.request.ImageRequest -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.koin.core.component.KoinComponent -import java.io.ByteArrayOutputStream -import java.io.IOException - -class ImageSaver( - private val context: Context, -) : KoinComponent { - suspend fun saveImage(imageUrl: String, filename: String): Result { - - return withContext(Dispatchers.IO) { - try { - val imageRequest = ImageRequest.Builder(context) - .data(imageUrl) - .build() - val bitmap = - context.imageLoader.executeBlocking(imageRequest).drawable?.toBitmap() - ?: return@withContext Result.failure(Error()) - - val contentValues = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, filename) - put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") - put( - MediaStore.MediaColumns.RELATIVE_PATH, - "${Environment.DIRECTORY_PICTURES}/capyreader" - ) - } - - val uri = context.contentResolver.insert( - MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - contentValues - ) ?: throw IOException("Failed to create MediaStore entry") - - context.contentResolver.openOutputStream(uri)?.use { outputStream -> - outputStream.write(jpegStream(bitmap)) - } ?: throw IOException("Failed to open output stream") - - return@withContext Result.success(uri) - } catch (e: Exception) { - return@withContext Result.failure(e) - } - } - } - - private fun jpegStream(bitmap: Bitmap): ByteArray { - val byteArrayOutputStream = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutputStream) - return byteArrayOutputStream.toByteArray() - } -} diff --git a/app/src/main/java/com/capyreader/app/ui/articles/media/MediaSaveButton.kt b/app/src/main/java/com/capyreader/app/ui/articles/media/MediaSaveButton.kt index de2b1eb04..2f58e9133 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/media/MediaSaveButton.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/media/MediaSaveButton.kt @@ -5,15 +5,28 @@ import androidx.compose.foundation.layout.Box import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Save import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import com.capyreader.app.R +import com.jocmp.capy.common.launchIO @Composable -fun MediaSaveButton() { +fun MediaSaveButton(imageUrl: String) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + fun saveImage() { + scope.launchIO { + ExternalImages.saveImage(imageUrl, context = context) + } + } + MediaActionButton( onClick = { + saveImage() }, text = R.string.media_save, icon = Icons.Rounded.Save, @@ -30,7 +43,7 @@ fun SaveButtonPreview() { Box( Modifier.background(Color.Black.copy(alpha = 0.8f)), ) { - MediaSaveButton() + MediaSaveButton(imageUrl = "https://example.com/jpeg.jpeg") } } } 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 4f011d8b2..df74b9c27 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,16 +1,54 @@ 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 +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext import com.capyreader.app.R +import com.jocmp.capy.common.launchIO +import com.jocmp.capy.common.withUIContext @Composable -fun MediaShareButton() { +fun MediaShareButton(imageUrl: String) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + fun shareImage() { + scope.launchIO { + val result = ExternalImages.shareImage(imageUrl, context = context) + + withUIContext { + result.fold( + onSuccess = { uri -> + openShareSheet(uri, context = context) + }, + onFailure = { + // Error saving image + } + ) + } + } + } + MediaActionButton( onClick = { + shareImage() }, text = R.string.media_share, 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/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index eb116f7c9..a79fc023b 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -3,7 +3,12 @@ + + +