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
+7 -1
View File
@@ -38,6 +38,12 @@ Unofficial messenger for russian social network VKontakte
- [x] Audio
- [x] File
- [x] Link
- [x] Sticker
- [x] Reply
- [ ] Forwarded messages
- [ ] Wall post
- [ ] Comment in wall post
- [ ] Poll
- [ ] TODO
- [x] Send messages
- [x] Pinned message
@@ -57,7 +63,7 @@ Unofficial messenger for russian social network VKontakte
- [x] View attachments
- [x] Open photo
- [x] Internal viewer
- [ ] External viewer
- [x] External viewer
- [ ] Open video in external player
- [ ] TODO
- [ ] Caching
@@ -5,6 +5,7 @@ import android.content.res.Resources
import android.os.PowerManager
import androidx.preference.PreferenceManager
import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi
import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.auth.captcha.di.captchaModule
import dev.meloda.fast.auth.login.di.loginModule
@@ -33,6 +34,7 @@ import org.koin.core.qualifier.qualifier
import org.koin.dsl.bind
import org.koin.dsl.module
@OptIn(ExperimentalCoilApi::class)
val applicationModule = module {
includes(domainModule)
includes(
@@ -66,6 +68,7 @@ val applicationModule = module {
ImageLoader.Builder(get())
.crossfade(true)
.build()
.also { it.diskCache?.directory?.toFile()?.listFiles() }
}
singleOf(::LongPollControllerImpl) bind LongPollController::class
@@ -320,7 +320,12 @@ class MessagesRepositoryImpl(
messagesIds = messageIds.orEmpty(),
important = important
)
messagesService.markAsImportant(requestModel.map).mapApiDefault()
messagesService.markAsImportant(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
apiResponse.requireResponse().marked.map { it.cmId }
},
errorMapper = { error -> error?.toDomain() }
)
}
override suspend fun delete(
@@ -58,3 +58,15 @@ data class MessagesSendResponse(
@Json(name = "message_id") val messageId: Long,
@Json(name = "cmid") val cmId: Long
)
@JsonClass(generateAdapter = true)
data class MessagesMarkAsImportantResponse(
@Json(name = "marked") val marked: List<Mark>
) {
@JsonClass(generateAdapter = true)
data class Mark(
@Json(name = "cmid") val cmId: Long,
@Json(name = "message_id") val messageId: Long,
@Json(name = "peer_id") val peerId: Long
)
}
@@ -9,6 +9,7 @@ import dev.meloda.fast.model.api.responses.MessagesGetByIdResponse
import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryAttachmentsResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryResponse
import dev.meloda.fast.model.api.responses.MessagesMarkAsImportantResponse
import dev.meloda.fast.model.api.responses.MessagesSendResponse
import dev.meloda.fast.network.ApiResponse
import dev.meloda.fast.network.RestApiError
@@ -76,7 +77,7 @@ interface MessagesService {
@POST(MessagesUrls.MARK_AS_IMPORTANT)
suspend fun markAsImportant(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<List<Long>>, RestApiError>
): ApiResult<ApiResponse<MessagesMarkAsImportantResponse>, RestApiError>
@FormUrlEncoded
@POST(MessagesUrls.DELETE)
@@ -277,4 +277,6 @@
<string name="action_copy_link">Скопировать ссылку</string>
<string name="action_copy">Скопировать</string>
<string name="action_copy_image">Скопировать изображение</string>
<string name="action_open_in">Открыть в…</string>
<string name="action_share">Поделиться</string>
</resources>
+2
View File
@@ -354,4 +354,6 @@
<string name="action_copy_link">Copy link</string>
<string name="action_copy">Copy</string>
<string name="action_copy_image">Copy image</string>
<string name="action_open_in">Open in…</string>
<string name="action_share">Share</string>
</resources>
@@ -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()