forked from melod1n/fast-messenger
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:
@@ -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
|
||||
|
||||
+6
-1
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+2
-1
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+5
-3
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+77
-12
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+68
-4
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user