Enhance PhotoViewer with share and open-in actions, improve reply UI

This commit introduces "Share" and "Open in..." actions to the `PhotoViewScreen`, allowing users to share images via other apps or open them in external viewers.

**Key changes:**

- **PhotoViewer:**
    - Added "Share" and "Open in..." options to the `PhotoViewScreen` dropdown menu.
    - `PhotoViewViewModel`:
        - Implemented `onShareClicked()` and `onOpenInClicked()` to handle these new actions.
        - Added `shareRequest` StateFlow to manage image sharing intents.
        - Introduced `downloadAndStoreImageToCache()` to download and cache images for sharing.
        - `onImageShared()` resets `shareRequest` after sharing.
    - Updated `TopBar` to include the new menu items.
    - Added string resources for "Open in…" and "Share".
- **Reply UI:**
    - `Reply.kt`: Title and summary text now use `TextOverflow.Ellipsis` to prevent long text from breaking the layout.
- **API Model:**
    - `MessagesResponse.kt`: Added `MessagesMarkAsImportantResponse` data class to handle the response for marking messages as important.
- **Data Layer:**
    - `MessagesRepositoryImpl`: Updated `markAsImportant` to correctly map the API response using `MessagesMarkAsImportantResponse`.
- **Minor:**
    - `README.md`: Updated feature checklist for external viewer.
    - `ApplicationModule.kt`: Added experimental Coil API opt-in.
This commit is contained in:
2025-06-26 20:46:53 +03:00
parent a7307e7862
commit 70b552412c
10 changed files with 184 additions and 22 deletions
@@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
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.dp
@@ -50,7 +51,6 @@ fun Reply(
bottom = bottomPadding
)
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
@@ -74,14 +74,16 @@ fun Reply(
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = 1
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
summary?.let {
Text(
text = summary,
style = MaterialTheme.typography.labelSmall,
maxLines = 1
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@@ -3,10 +3,13 @@ package dev.meloda.fast.photoviewer
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.toBitmapOrNull
import androidx.core.net.toUri
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -26,13 +29,21 @@ import java.io.FileOutputStream
import java.net.URLDecoder
import java.util.UUID
import dev.meloda.fast.ui.R as UiR
interface PhotoViewViewModel {
val screenState: StateFlow<PhotoViewScreenState>
val shareRequest: StateFlow<Uri?>
fun onPageChanged(newPage: Int)
fun onShareClicked()
fun onOpenInClicked()
fun onCopyLinkClicked()
fun onCopyClicked()
fun onImageShared()
}
class PhotoViewViewModelImpl(
@@ -42,6 +53,8 @@ class PhotoViewViewModelImpl(
override val screenState = MutableStateFlow(PhotoViewScreenState.EMPTY)
override val shareRequest = MutableStateFlow<Uri?>(null)
init {
val arguments = PhotoView.from(savedStateHandle).arguments
@@ -59,6 +72,47 @@ class PhotoViewViewModelImpl(
screenState.setValue { old -> old.copy(selectedPage = newPage) }
}
override fun onShareClicked() {
val url = screenState.value.images
.getOrNull(screenState.value.selectedPage)
?.extractUrl() ?: return
viewModelScope.launch(Dispatchers.IO) {
val imageFile = downloadAndStoreImageToCache(url) ?: return@launch
val uri = FileProvider.getUriForFile(
applicationContext,
"${applicationContext.packageName}.provider",
imageFile
)
shareRequest.setValue { uri }
}
}
override fun onOpenInClicked() {
val url = screenState.value.images
.getOrNull(screenState.value.selectedPage)
?.extractUrl() ?: return
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
applicationContext.startActivity(intent)
} catch (e: Exception) {
e.printStackTrace()
viewModelScope.launch(Dispatchers.Main) {
Toast.makeText(
applicationContext,
UiR.string.error_occurred,
Toast.LENGTH_LONG
).show()
}
}
}
override fun onCopyLinkClicked() {
val url = screenState.value.images
.getOrNull(screenState.value.selectedPage)
@@ -85,18 +139,7 @@ class PhotoViewViewModelImpl(
?.extractUrl() ?: return
viewModelScope.launch(Dispatchers.IO) {
val drawable = applicationContext.imageLoader.execute(
ImageRequest.Builder(applicationContext)
.data(url)
.build()
).drawable ?: return@launch
val imagesDir = File(applicationContext.cacheDir, "images")
if (!imagesDir.exists()) imagesDir.mkdirs()
val imageFile = File(imagesDir, "shared_image_id${UUID.randomUUID()}.png")
FileOutputStream(imageFile).use {
drawable.toBitmapOrNull()?.compress(Bitmap.CompressFormat.PNG, 100, it)
}
val imageFile = downloadAndStoreImageToCache(url) ?: return@launch
val uri = FileProvider.getUriForFile(
applicationContext,
@@ -116,4 +159,26 @@ class PhotoViewViewModelImpl(
}
}
}
override fun onImageShared() {
shareRequest.setValue { null }
}
private suspend fun downloadAndStoreImageToCache(url: String): File? =
withContext(Dispatchers.IO) {
val drawable = applicationContext.imageLoader.execute(
ImageRequest.Builder(applicationContext)
.data(url)
.build()
).drawable ?: return@withContext null
val imagesDir = File(applicationContext.cacheDir, "images")
if (!imagesDir.exists()) imagesDir.mkdirs()
val imageFile = File(imagesDir, "shared_image_id${UUID.randomUUID()}.png")
FileOutputStream(imageFile).use {
drawable.toBitmapOrNull()?.compress(Bitmap.CompressFormat.PNG, 100, it)
}
imageFile
}
}
@@ -1,10 +1,13 @@
package dev.meloda.fast.photoviewer.presentation
import android.content.Intent
import android.widget.Toast
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize
@@ -33,6 +36,7 @@ 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.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
@@ -41,6 +45,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset
@@ -53,6 +58,8 @@ import dev.meloda.fast.photoviewer.PhotoViewViewModel
import dev.meloda.fast.photoviewer.PhotoViewViewModelImpl
import dev.meloda.fast.photoviewer.model.PhotoViewScreenState
import dev.meloda.fast.ui.util.getImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import kotlin.math.abs
import dev.meloda.fast.ui.R as UiR
@@ -63,11 +70,46 @@ fun PhotoViewRoute(
viewModel: PhotoViewViewModel = koinViewModel<PhotoViewViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val shareRequest by viewModel.shareRequest.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(shareRequest) {
if (shareRequest != null) {
viewModel.onImageShared()
val intent = Intent(Intent.ACTION_SEND).apply {
setType("image/png")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
putExtra(Intent.EXTRA_STREAM, shareRequest)
}
val chooserIntent = Intent.createChooser(intent, null)
chooserIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
try {
context.startActivity(chooserIntent)
} catch (e: Exception) {
e.printStackTrace()
scope.launch(Dispatchers.Main) {
Toast.makeText(
context,
UiR.string.error_occurred,
Toast.LENGTH_LONG
).show()
}
}
}
}
PhotoViewScreen(
screenState = screenState,
onBack = onBack,
onPageChanged = viewModel::onPageChanged,
onShareClicked = viewModel::onShareClicked,
onOpenInClicked = viewModel::onOpenInClicked,
onCopyLinkClicked = viewModel::onCopyLinkClicked,
onCopyClicked = viewModel::onCopyClicked
)
@@ -78,6 +120,8 @@ fun PhotoViewScreen(
screenState: PhotoViewScreenState = PhotoViewScreenState.EMPTY,
onBack: () -> Unit = {},
onPageChanged: (index: Int) -> Unit = {},
onShareClicked: () -> Unit = {},
onOpenInClicked: () -> Unit = {},
onCopyLinkClicked: () -> Unit = {},
onCopyClicked: () -> Unit = {}
) {
@@ -108,6 +152,8 @@ fun PhotoViewScreen(
topBar = {
TopBar(
onBack = onBack,
onShareClicked = onShareClicked,
onOpenInClicked = onOpenInClicked,
onCopyClicked = onCopyClicked,
onCopyLinkClicked = onCopyLinkClicked,
)
@@ -116,7 +162,7 @@ fun PhotoViewScreen(
alpha = calculatedAlpha
)
) { padding ->
Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize()) {
Pager(
pagerState = pagerState,
state = screenState,
@@ -134,6 +180,8 @@ fun PhotoViewScreen(
fun TopBar(
modifier: Modifier = Modifier,
onBack: () -> Unit,
onShareClicked: () -> Unit,
onOpenInClicked: () -> Unit,
onCopyClicked: () -> Unit,
onCopyLinkClicked: () -> Unit
) {
@@ -141,9 +189,7 @@ fun TopBar(
mutableStateOf(false)
}
val hideDropDownMenu by rememberUpdatedState(
{ dropdownMenuShown = false }
)
val hideDropDownMenu by rememberUpdatedState { dropdownMenuShown = false }
TopAppBar(
modifier = modifier,
@@ -172,6 +218,24 @@ fun TopBar(
onDismissRequest = { dropdownMenuShown = false },
offset = DpOffset(x = (10).dp, y = (-60).dp)
) {
DropdownMenuItem(
onClick = {
hideDropDownMenu()
onShareClicked()
},
text = {
Text(text = stringResource(UiR.string.action_share))
}
)
DropdownMenuItem(
onClick = {
hideDropDownMenu()
onOpenInClicked()
},
text = {
Text(text = stringResource(UiR.string.action_open_in))
}
)
DropdownMenuItem(
onClick = {
hideDropDownMenu()