Add image viewer gallery actions
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
|
||||
@@ -78,6 +78,7 @@ import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.DoneAll
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Campaign
|
||||
@@ -376,9 +377,34 @@ private fun QMaxApp(
|
||||
val context = LocalContext.current
|
||||
var activeTab by rememberSaveable { mutableStateOf(MainTab.Chats) }
|
||||
var notificationPermissionRequested by rememberSaveable { mutableStateOf(false) }
|
||||
var pendingGalleryAttachment by remember { mutableStateOf<AttachmentDto?>(null) }
|
||||
val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
notificationPermissionRequested = true
|
||||
}
|
||||
val galleryPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
val attachment = pendingGalleryAttachment
|
||||
pendingGalleryAttachment = null
|
||||
if (granted && attachment != null) {
|
||||
vm.saveImageToGallery(attachment) {
|
||||
Toast.makeText(context, "\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u043e \u0432 Pictures/Qmax", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else if (!granted) {
|
||||
Toast.makeText(context, "\u041d\u0435\u0442 \u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0438\u044f \u043d\u0430 \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveImageToGallery(attachment: AttachmentDto) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
vm.saveImageToGallery(attachment) {
|
||||
Toast.makeText(context, "\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u043e \u0432 Pictures/Qmax", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
pendingGalleryAttachment = attachment
|
||||
galleryPermission.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.session?.serverUrl, state.session?.userName) {
|
||||
if (state.session == null) {
|
||||
@@ -428,8 +454,8 @@ private fun QMaxApp(
|
||||
total = state.previewImageGallery.size,
|
||||
onPrevious = { vm.moveImagePreview(-1) },
|
||||
onNext = { vm.moveImagePreview(1) },
|
||||
onSave = previewAttachment?.let { attachment -> { saveImageToGallery(attachment) } },
|
||||
onShare = previewAttachment?.let { attachment -> { vm.shareAttachment(attachment) } },
|
||||
onOpen = previewAttachment?.let { attachment -> { vm.openAttachment(attachment) } },
|
||||
onClose = vm::closeImage
|
||||
)
|
||||
}
|
||||
@@ -6165,8 +6191,8 @@ private fun ImagePreview(
|
||||
total: Int,
|
||||
onPrevious: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onSave: (() -> Unit)?,
|
||||
onShare: (() -> Unit)?,
|
||||
onOpen: (() -> Unit)?,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
BackHandler(onBack = onClose)
|
||||
@@ -6178,6 +6204,7 @@ private fun ImagePreview(
|
||||
var scale by remember(url) { mutableStateOf(1f) }
|
||||
var offset by remember(url) { mutableStateOf(Offset.Zero) }
|
||||
var horizontalDrag by remember(url) { mutableStateOf(0f) }
|
||||
var menuOpen by remember(url) { mutableStateOf(false) }
|
||||
val transformState = rememberTransformableState { zoomChange, panChange, _ ->
|
||||
val nextScale = (scale * zoomChange).coerceIn(1f, 5f)
|
||||
scale = nextScale
|
||||
@@ -6270,14 +6297,33 @@ private fun ImagePreview(
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
onShare?.let { share ->
|
||||
IconButton(onClick = share, modifier = Modifier.size(44.dp)) {
|
||||
Icon(Icons.Filled.Share, contentDescription = "Поделиться", tint = Color.White)
|
||||
}
|
||||
}
|
||||
onOpen?.let { open ->
|
||||
TextButton(onClick = open) {
|
||||
Text("Открыть", color = Color.White)
|
||||
if (onSave != null || onShare != null) {
|
||||
Box {
|
||||
IconButton(onClick = { menuOpen = true }, modifier = Modifier.size(44.dp)) {
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = "Меню", tint = Color.White)
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
onSave?.let { save ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("Сохранить в галерею") },
|
||||
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
save()
|
||||
}
|
||||
)
|
||||
}
|
||||
onShare?.let { share ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("Поделиться") },
|
||||
leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
share()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ClipData
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ContentValues
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
@@ -340,6 +345,51 @@ class QMaxRepository(
|
||||
sharePayload(session = session, text = null, attachments = listOf(attachment))
|
||||
}
|
||||
|
||||
suspend fun saveImageToGallery(session: QMaxSession, attachment: AttachmentDto): Uri {
|
||||
val source = cachedAttachmentFile(session, attachment)
|
||||
val mimeType = attachment.contentType.substringBefore(';').trim().takeIf { it.startsWith("image/") }
|
||||
?: "image/jpeg"
|
||||
val displayName = File(attachment.fileName.substringAfterLast('/')).name.trim().ifBlank {
|
||||
"qmax-${System.currentTimeMillis()}.${mimeType.substringAfter('/', "jpg")}"
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
val directory = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
|
||||
"Qmax"
|
||||
)
|
||||
if (!directory.exists() && !directory.mkdirs()) {
|
||||
error("Не удалось создать папку Pictures/Qmax")
|
||||
}
|
||||
val target = uniqueGalleryFile(directory, displayName)
|
||||
source.copyTo(target, overwrite = false)
|
||||
MediaScannerConnection.scanFile(context, arrayOf(target.absolutePath), arrayOf(mimeType), null)
|
||||
return Uri.fromFile(target)
|
||||
}
|
||||
|
||||
val resolver = context.contentResolver
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
|
||||
put(MediaStore.Images.Media.MIME_TYPE, mimeType)
|
||||
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/Qmax")
|
||||
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||
}
|
||||
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
|
||||
?: error("Не удалось создать изображение в галерее")
|
||||
try {
|
||||
resolver.openOutputStream(uri, "w")?.use { output ->
|
||||
source.inputStream().use { input -> input.copyTo(output) }
|
||||
} ?: error("Не удалось записать изображение в галерею")
|
||||
resolver.update(uri, ContentValues().apply {
|
||||
put(MediaStore.Images.Media.IS_PENDING, 0)
|
||||
}, null, null)
|
||||
return uri
|
||||
} catch (error: Throwable) {
|
||||
resolver.delete(uri, null, null)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun maxStatus(session: QMaxSession): MaxBridgeStatusDto {
|
||||
return withFreshSession(session) { api.maxStatus(it.serverUrl, it.accessToken) }
|
||||
}
|
||||
@@ -440,6 +490,21 @@ class QMaxRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private fun uniqueGalleryFile(directory: File, displayName: String): File {
|
||||
val initial = directory.resolve(displayName)
|
||||
if (!initial.exists()) return initial
|
||||
|
||||
val extension = displayName.substringAfterLast('.', missingDelimiterValue = "")
|
||||
val baseName = if (extension.isBlank()) displayName else displayName.removeSuffix(".$extension")
|
||||
var suffix = 2
|
||||
while (true) {
|
||||
val candidateName = if (extension.isBlank()) "$baseName ($suffix)" else "$baseName ($suffix).$extension"
|
||||
val candidate = directory.resolve(candidateName)
|
||||
if (!candidate.exists()) return candidate
|
||||
suffix += 1
|
||||
}
|
||||
}
|
||||
|
||||
private fun commonShareMime(types: List<String>): String {
|
||||
val normalized = types.map { it.takeIf(String::isNotBlank) ?: "application/octet-stream" }
|
||||
if (normalized.isEmpty()) return "*/*"
|
||||
|
||||
@@ -1016,6 +1016,12 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
||||
repository.shareAttachment(session, attachment)
|
||||
}
|
||||
|
||||
fun saveImageToGallery(attachment: AttachmentDto, onSuccess: () -> Unit = {}) = launchLoading(false) {
|
||||
val session = requireSession()
|
||||
repository.saveImageToGallery(session, attachment)
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
fun replyTo(message: MessageDto) {
|
||||
state.value = state.value.copy(replyTarget = message, editTarget = null)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user