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] Audio
- [x] File - [x] File
- [x] Link - [x] Link
- [x] Sticker
- [x] Reply
- [ ] Forwarded messages
- [ ] Wall post
- [ ] Comment in wall post
- [ ] Poll
- [ ] TODO - [ ] TODO
- [x] Send messages - [x] Send messages
- [x] Pinned message - [x] Pinned message
@@ -57,7 +63,7 @@ Unofficial messenger for russian social network VKontakte
- [x] View attachments - [x] View attachments
- [x] Open photo - [x] Open photo
- [x] Internal viewer - [x] Internal viewer
- [ ] External viewer - [x] External viewer
- [ ] Open video in external player - [ ] Open video in external player
- [ ] TODO - [ ] TODO
- [ ] Caching - [ ] Caching
@@ -5,6 +5,7 @@ import android.content.res.Resources
import android.os.PowerManager import android.os.PowerManager
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import coil.ImageLoader import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi
import dev.meloda.fast.MainViewModelImpl import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.auth.captcha.di.captchaModule import dev.meloda.fast.auth.captcha.di.captchaModule
import dev.meloda.fast.auth.login.di.loginModule 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.bind
import org.koin.dsl.module import org.koin.dsl.module
@OptIn(ExperimentalCoilApi::class)
val applicationModule = module { val applicationModule = module {
includes(domainModule) includes(domainModule)
includes( includes(
@@ -66,6 +68,7 @@ val applicationModule = module {
ImageLoader.Builder(get()) ImageLoader.Builder(get())
.crossfade(true) .crossfade(true)
.build() .build()
.also { it.diskCache?.directory?.toFile()?.listFiles() }
} }
singleOf(::LongPollControllerImpl) bind LongPollController::class singleOf(::LongPollControllerImpl) bind LongPollController::class
@@ -320,7 +320,12 @@ class MessagesRepositoryImpl(
messagesIds = messageIds.orEmpty(), messagesIds = messageIds.orEmpty(),
important = important 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( override suspend fun delete(
@@ -58,3 +58,15 @@ data class MessagesSendResponse(
@Json(name = "message_id") val messageId: Long, @Json(name = "message_id") val messageId: Long,
@Json(name = "cmid") val cmId: 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.MessagesGetConversationMembersResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryAttachmentsResponse import dev.meloda.fast.model.api.responses.MessagesGetHistoryAttachmentsResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryResponse 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.model.api.responses.MessagesSendResponse
import dev.meloda.fast.network.ApiResponse import dev.meloda.fast.network.ApiResponse
import dev.meloda.fast.network.RestApiError import dev.meloda.fast.network.RestApiError
@@ -76,7 +77,7 @@ interface MessagesService {
@POST(MessagesUrls.MARK_AS_IMPORTANT) @POST(MessagesUrls.MARK_AS_IMPORTANT)
suspend fun markAsImportant( suspend fun markAsImportant(
@FieldMap params: Map<String, String> @FieldMap params: Map<String, String>
): ApiResult<ApiResponse<List<Long>>, RestApiError> ): ApiResult<ApiResponse<MessagesMarkAsImportantResponse>, RestApiError>
@FormUrlEncoded @FormUrlEncoded
@POST(MessagesUrls.DELETE) @POST(MessagesUrls.DELETE)
@@ -277,4 +277,6 @@
<string name="action_copy_link">Скопировать ссылку</string> <string name="action_copy_link">Скопировать ссылку</string>
<string name="action_copy">Скопировать</string> <string name="action_copy">Скопировать</string>
<string name="action_copy_image">Скопировать изображение</string> <string name="action_copy_image">Скопировать изображение</string>
<string name="action_open_in">Открыть в…</string>
<string name="action_share">Поделиться</string>
</resources> </resources>
+2
View File
@@ -354,4 +354,6 @@
<string name="action_copy_link">Copy link</string> <string name="action_copy_link">Copy link</string>
<string name="action_copy">Copy</string> <string name="action_copy">Copy</string>
<string name="action_copy_image">Copy image</string> <string name="action_copy_image">Copy image</string>
<string name="action_open_in">Open in…</string>
<string name="action_share">Share</string>
</resources> </resources>
@@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -50,7 +51,6 @@ fun Reply(
bottom = bottomPadding bottom = bottomPadding
) )
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
@@ -74,14 +74,16 @@ fun Reply(
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
maxLines = 1 maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
summary?.let { summary?.let {
Text( Text(
text = summary, text = summary,
style = MaterialTheme.typography.labelSmall, 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.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri
import android.widget.Toast import android.widget.Toast
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.toBitmapOrNull import androidx.core.graphics.drawable.toBitmapOrNull
import androidx.core.net.toUri
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -26,13 +29,21 @@ import java.io.FileOutputStream
import java.net.URLDecoder import java.net.URLDecoder
import java.util.UUID import java.util.UUID
import dev.meloda.fast.ui.R as UiR
interface PhotoViewViewModel { interface PhotoViewViewModel {
val screenState: StateFlow<PhotoViewScreenState> val screenState: StateFlow<PhotoViewScreenState>
val shareRequest: StateFlow<Uri?>
fun onPageChanged(newPage: Int) fun onPageChanged(newPage: Int)
fun onShareClicked()
fun onOpenInClicked()
fun onCopyLinkClicked() fun onCopyLinkClicked()
fun onCopyClicked() fun onCopyClicked()
fun onImageShared()
} }
class PhotoViewViewModelImpl( class PhotoViewViewModelImpl(
@@ -42,6 +53,8 @@ class PhotoViewViewModelImpl(
override val screenState = MutableStateFlow(PhotoViewScreenState.EMPTY) override val screenState = MutableStateFlow(PhotoViewScreenState.EMPTY)
override val shareRequest = MutableStateFlow<Uri?>(null)
init { init {
val arguments = PhotoView.from(savedStateHandle).arguments val arguments = PhotoView.from(savedStateHandle).arguments
@@ -59,6 +72,47 @@ class PhotoViewViewModelImpl(
screenState.setValue { old -> old.copy(selectedPage = newPage) } 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() { override fun onCopyLinkClicked() {
val url = screenState.value.images val url = screenState.value.images
.getOrNull(screenState.value.selectedPage) .getOrNull(screenState.value.selectedPage)
@@ -85,18 +139,7 @@ class PhotoViewViewModelImpl(
?.extractUrl() ?: return ?.extractUrl() ?: return
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val drawable = applicationContext.imageLoader.execute( val imageFile = downloadAndStoreImageToCache(url) ?: return@launch
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 uri = FileProvider.getUriForFile( val uri = FileProvider.getUriForFile(
applicationContext, 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 package dev.meloda.fast.photoviewer.presentation
import android.content.Intent
import android.widget.Toast
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
@@ -33,6 +36,7 @@ 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
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow 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.graphicsLayer
import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset 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.PhotoViewViewModelImpl
import dev.meloda.fast.photoviewer.model.PhotoViewScreenState import dev.meloda.fast.photoviewer.model.PhotoViewScreenState
import dev.meloda.fast.ui.util.getImage import dev.meloda.fast.ui.util.getImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import kotlin.math.abs import kotlin.math.abs
import dev.meloda.fast.ui.R as UiR import dev.meloda.fast.ui.R as UiR
@@ -63,11 +70,46 @@ fun PhotoViewRoute(
viewModel: PhotoViewViewModel = koinViewModel<PhotoViewViewModelImpl>() viewModel: PhotoViewViewModel = koinViewModel<PhotoViewViewModelImpl>()
) { ) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle() 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( PhotoViewScreen(
screenState = screenState, screenState = screenState,
onBack = onBack, onBack = onBack,
onPageChanged = viewModel::onPageChanged, onPageChanged = viewModel::onPageChanged,
onShareClicked = viewModel::onShareClicked,
onOpenInClicked = viewModel::onOpenInClicked,
onCopyLinkClicked = viewModel::onCopyLinkClicked, onCopyLinkClicked = viewModel::onCopyLinkClicked,
onCopyClicked = viewModel::onCopyClicked onCopyClicked = viewModel::onCopyClicked
) )
@@ -78,6 +120,8 @@ fun PhotoViewScreen(
screenState: PhotoViewScreenState = PhotoViewScreenState.EMPTY, screenState: PhotoViewScreenState = PhotoViewScreenState.EMPTY,
onBack: () -> Unit = {}, onBack: () -> Unit = {},
onPageChanged: (index: Int) -> Unit = {}, onPageChanged: (index: Int) -> Unit = {},
onShareClicked: () -> Unit = {},
onOpenInClicked: () -> Unit = {},
onCopyLinkClicked: () -> Unit = {}, onCopyLinkClicked: () -> Unit = {},
onCopyClicked: () -> Unit = {} onCopyClicked: () -> Unit = {}
) { ) {
@@ -108,6 +152,8 @@ fun PhotoViewScreen(
topBar = { topBar = {
TopBar( TopBar(
onBack = onBack, onBack = onBack,
onShareClicked = onShareClicked,
onOpenInClicked = onOpenInClicked,
onCopyClicked = onCopyClicked, onCopyClicked = onCopyClicked,
onCopyLinkClicked = onCopyLinkClicked, onCopyLinkClicked = onCopyLinkClicked,
) )
@@ -116,7 +162,7 @@ fun PhotoViewScreen(
alpha = calculatedAlpha alpha = calculatedAlpha
) )
) { padding -> ) { padding ->
Column(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Pager( Pager(
pagerState = pagerState, pagerState = pagerState,
state = screenState, state = screenState,
@@ -134,6 +180,8 @@ fun PhotoViewScreen(
fun TopBar( fun TopBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBack: () -> Unit, onBack: () -> Unit,
onShareClicked: () -> Unit,
onOpenInClicked: () -> Unit,
onCopyClicked: () -> Unit, onCopyClicked: () -> Unit,
onCopyLinkClicked: () -> Unit onCopyLinkClicked: () -> Unit
) { ) {
@@ -141,9 +189,7 @@ fun TopBar(
mutableStateOf(false) mutableStateOf(false)
} }
val hideDropDownMenu by rememberUpdatedState( val hideDropDownMenu by rememberUpdatedState { dropdownMenuShown = false }
{ dropdownMenuShown = false }
)
TopAppBar( TopAppBar(
modifier = modifier, modifier = modifier,
@@ -172,6 +218,24 @@ fun TopBar(
onDismissRequest = { dropdownMenuShown = false }, onDismissRequest = { dropdownMenuShown = false },
offset = DpOffset(x = (10).dp, y = (-60).dp) 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( DropdownMenuItem(
onClick = { onClick = {
hideDropDownMenu() hideDropDownMenu()