From 70b552412ca87e80adc3ebbe56f237866a2de9ac Mon Sep 17 00:00:00 2001 From: Danil Nikolaev Date: Thu, 26 Jun 2025 20:46:53 +0300 Subject: [PATCH] Enhance PhotoViewer with share and open-in actions, improve reply UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 8 +- .../fast/common/di/ApplicationModule.kt | 3 + .../api/messages/MessagesRepositoryImpl.kt | 7 +- .../model/api/responses/MessagesResponse.kt | 12 +++ .../service/messages/MessagesService.kt | 3 +- core/ui/src/main/res/values-ru/strings.xml | 2 + core/ui/src/main/res/values/strings.xml | 2 + .../presentation/attachments/Reply.kt | 8 +- .../fast/photoviewer/PhotoViewViewModel.kt | 89 ++++++++++++++++--- .../presentation/PhotoViewScreen.kt | 72 ++++++++++++++- 10 files changed, 184 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 8f459c99..aa77a600 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/src/main/kotlin/dev/meloda/fast/common/di/ApplicationModule.kt b/app/src/main/kotlin/dev/meloda/fast/common/di/ApplicationModule.kt index 04a219f3..eae975f4 100644 --- a/app/src/main/kotlin/dev/meloda/fast/common/di/ApplicationModule.kt +++ b/app/src/main/kotlin/dev/meloda/fast/common/di/ApplicationModule.kt @@ -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 diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepositoryImpl.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepositoryImpl.kt index 37e84d96..a161d9f3 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepositoryImpl.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepositoryImpl.kt @@ -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( diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/MessagesResponse.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/MessagesResponse.kt index 4af64159..884912dd 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/MessagesResponse.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/MessagesResponse.kt @@ -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 +) { + @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 + ) +} diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesService.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesService.kt index d528af64..580b3fef 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesService.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesService.kt @@ -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 - ): ApiResult>, RestApiError> + ): ApiResult, RestApiError> @FormUrlEncoded @POST(MessagesUrls.DELETE) diff --git a/core/ui/src/main/res/values-ru/strings.xml b/core/ui/src/main/res/values-ru/strings.xml index f091a6bb..046ed177 100644 --- a/core/ui/src/main/res/values-ru/strings.xml +++ b/core/ui/src/main/res/values-ru/strings.xml @@ -277,4 +277,6 @@ Скопировать ссылку Скопировать Скопировать изображение + Открыть в… + Поделиться diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 2403f0ac..6681cb9f 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -354,4 +354,6 @@ Copy link Copy Copy image + Open in… + Share diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Reply.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Reply.kt index b1cd61b3..f982dbc1 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Reply.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Reply.kt @@ -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 ) } } diff --git a/feature/photoviewer/src/main/kotlin/dev/meloda/fast/photoviewer/PhotoViewViewModel.kt b/feature/photoviewer/src/main/kotlin/dev/meloda/fast/photoviewer/PhotoViewViewModel.kt index a469d1ac..ecb6b005 100644 --- a/feature/photoviewer/src/main/kotlin/dev/meloda/fast/photoviewer/PhotoViewViewModel.kt +++ b/feature/photoviewer/src/main/kotlin/dev/meloda/fast/photoviewer/PhotoViewViewModel.kt @@ -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 + val shareRequest: StateFlow + 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(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 + } } diff --git a/feature/photoviewer/src/main/kotlin/dev/meloda/fast/photoviewer/presentation/PhotoViewScreen.kt b/feature/photoviewer/src/main/kotlin/dev/meloda/fast/photoviewer/presentation/PhotoViewScreen.kt index 6cb19d30..5f673d91 100644 --- a/feature/photoviewer/src/main/kotlin/dev/meloda/fast/photoviewer/presentation/PhotoViewScreen.kt +++ b/feature/photoviewer/src/main/kotlin/dev/meloda/fast/photoviewer/presentation/PhotoViewScreen.kt @@ -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() ) { 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()