mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-20 07:01:40 +00:00
android wip
This commit is contained in:
@@ -517,7 +517,8 @@ data class ChatItem (
|
||||
val meta: CIMeta,
|
||||
val content: CIContent,
|
||||
val formattedText: List<FormattedText>? = null,
|
||||
val quotedItem: CIQuote? = null
|
||||
val quotedItem: CIQuote? = null,
|
||||
val file: CIFile? = null
|
||||
) {
|
||||
val id: Long get() = meta.itemId
|
||||
val timestampText: String get() = meta.timestampText
|
||||
@@ -549,6 +550,7 @@ data class ChatItem (
|
||||
text: String = "hello\nthere",
|
||||
status: CIStatus = CIStatus.SndNew(),
|
||||
quotedItem: CIQuote? = null,
|
||||
file: CIFile? = null,
|
||||
itemDeleted: Boolean = false,
|
||||
itemEdited: Boolean = false,
|
||||
editable: Boolean = true
|
||||
@@ -557,7 +559,8 @@ data class ChatItem (
|
||||
chatDir = dir,
|
||||
meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable),
|
||||
content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)),
|
||||
quotedItem = quotedItem
|
||||
quotedItem = quotedItem,
|
||||
file = file
|
||||
)
|
||||
|
||||
fun getDeletedContentSampleData(
|
||||
@@ -569,9 +572,10 @@ data class ChatItem (
|
||||
) =
|
||||
ChatItem(
|
||||
chatDir = dir,
|
||||
meta = CIMeta.getSample(id, ts, text, status, false, false, false),
|
||||
meta = CIMeta.getSample(id, ts, text, status, itemDeleted = false, itemEdited = false, editable = false),
|
||||
content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast),
|
||||
quotedItem = null
|
||||
quotedItem = null,
|
||||
file = null
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -699,18 +703,6 @@ sealed class CIContent: ItemContent {
|
||||
override val text get() = "deleted"
|
||||
override val msgContent get() = null
|
||||
}
|
||||
|
||||
@Serializable @SerialName("sndFileInvitation")
|
||||
class SndFileInvitation(val fileId: Long, val filePath: String): CIContent() {
|
||||
override val text get() = "sending files is not supported yet"
|
||||
override val msgContent get() = null
|
||||
}
|
||||
|
||||
@Serializable @SerialName("rcvFileInvitation")
|
||||
class RcvFileInvitation(val rcvFileTransfer: RcvFileTransfer): CIContent() {
|
||||
override val text get() = "receiving files is not supported yet"
|
||||
override val msgContent get() = null
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -738,6 +730,37 @@ class CIQuote (
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class CIFile(
|
||||
val fileId: Long,
|
||||
val fileName: String,
|
||||
val fileSize: Long,
|
||||
val filePath: String? = null,
|
||||
val fileStatus: CIFileStatus
|
||||
) {
|
||||
val stored: Boolean = when (fileStatus) {
|
||||
CIFileStatus.SndStored -> true
|
||||
CIFileStatus.SndCancelled -> true
|
||||
CIFileStatus.RcvComplete -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getSample(fileId: Long, fileName: String, fileSize: Long, filePath: String?, fileStatus: CIFileStatus = CIFileStatus.SndStored): CIFile =
|
||||
CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class CIFileStatus {
|
||||
@SerialName("snd_stored") SndStored,
|
||||
@SerialName("snd_cancelled") SndCancelled,
|
||||
@SerialName("rcv_invitation") RcvInvitation,
|
||||
@SerialName("rcv_transfer") RcvTransfer,
|
||||
@SerialName("rcv_complete") RcvComplete,
|
||||
@SerialName("rcv_cancelled") RcvCancelled;
|
||||
}
|
||||
|
||||
@Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
|
||||
@Serializable(with = MsgContentSerializer::class)
|
||||
sealed class MsgContent {
|
||||
@@ -749,12 +772,16 @@ sealed class MsgContent {
|
||||
@Serializable(with = MsgContentSerializer::class)
|
||||
class MCLink(override val text: String, val preview: LinkPreview): MsgContent()
|
||||
|
||||
@Serializable(with = MsgContentSerializer::class)
|
||||
class MCImage(override val text: String, val image: String): MsgContent()
|
||||
|
||||
@Serializable(with = MsgContentSerializer::class)
|
||||
class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
|
||||
|
||||
val cmdString: String get() = when (this) {
|
||||
is MCText -> "text $text"
|
||||
is MCLink -> "json ${json.encodeToString(this)}"
|
||||
is MCImage -> "json ${json.encodeToString(this)}"
|
||||
is MCUnknown -> "json $json"
|
||||
}
|
||||
}
|
||||
@@ -769,6 +796,10 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
||||
element<String>("text")
|
||||
element<String>("preview")
|
||||
})
|
||||
element("MCImage", buildClassSerialDescriptor("MCImage") {
|
||||
element<String>("text")
|
||||
element<String>("image")
|
||||
})
|
||||
element("MCUnknown", buildClassSerialDescriptor("MCUnknown"))
|
||||
}
|
||||
|
||||
@@ -785,6 +816,10 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
||||
val preview = Json.decodeFromString<LinkPreview>(json["preview"].toString())
|
||||
MsgContent.MCLink(text, preview)
|
||||
}
|
||||
"image" -> {
|
||||
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
|
||||
MsgContent.MCImage(text, image)
|
||||
}
|
||||
else -> MsgContent.MCUnknown(t, text, json)
|
||||
}
|
||||
} else {
|
||||
@@ -809,6 +844,12 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
||||
put("text", value.text)
|
||||
put("preview", json.encodeToJsonElement(value.preview))
|
||||
}
|
||||
is MsgContent.MCImage ->
|
||||
buildJsonObject {
|
||||
put("type", "image")
|
||||
put("text", value.text)
|
||||
put("image", value.image)
|
||||
}
|
||||
is MsgContent.MCUnknown -> value.json
|
||||
}
|
||||
encoder.encodeJsonElement(json)
|
||||
@@ -876,6 +917,3 @@ enum class FormatColor(val color: String) {
|
||||
white -> MaterialTheme.colors.onBackground
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class RcvFileTransfer
|
||||
|
||||
@@ -141,9 +141,8 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiSendMessage(type: ChatType, id: Long, quotedItemId: Long? = null, mc: MsgContent): AChatItem? {
|
||||
val cmd = if (quotedItemId == null) CC.ApiSendMessage(type, id, mc)
|
||||
else CC.ApiSendMessageQuote(type, id, quotedItemId, mc)
|
||||
suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent): AChatItem? {
|
||||
val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc)
|
||||
val r = sendCmd(cmd)
|
||||
if (r is CR.NewChatItem ) return r.chatItem
|
||||
Log.e(TAG, "apiSendMessage bad response: ${r.responseType} ${r.details}")
|
||||
@@ -479,8 +478,7 @@ sealed class CC {
|
||||
class StartChat: CC()
|
||||
class ApiGetChats: CC()
|
||||
class ApiGetChat(val type: ChatType, val id: Long): CC()
|
||||
class ApiSendMessage(val type: ChatType, val id: Long, val mc: MsgContent): CC()
|
||||
class ApiSendMessageQuote(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC()
|
||||
class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent): CC()
|
||||
class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC()
|
||||
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC()
|
||||
class GetUserSMPServers(): CC()
|
||||
@@ -504,8 +502,13 @@ sealed class CC {
|
||||
is StartChat -> "/_start"
|
||||
is ApiGetChats -> "/_get chats"
|
||||
is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100"
|
||||
is ApiSendMessage -> "/_send ${chatRef(type, id)} ${mc.cmdString}"
|
||||
is ApiSendMessageQuote -> "/_send_quote ${chatRef(type, id)} $itemId ${mc.cmdString}"
|
||||
is ApiSendMessage -> when {
|
||||
file == null && quotedItemId == null -> "/_send ${chatRef(type, id)} ${mc.cmdString}"
|
||||
file != null && quotedItemId == null -> "/_send ${chatRef(type, id)} file $file ${mc.cmdString}"
|
||||
file == null && quotedItemId != null -> "/_send ${chatRef(type, id)} quoted $quotedItemId ${mc.cmdString}"
|
||||
file != null && quotedItemId != null -> "/_send ${chatRef(type, id)} file $file quoted $quotedItemId ${mc.cmdString}"
|
||||
else -> throw Exception()
|
||||
}
|
||||
is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}"
|
||||
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
|
||||
is GetUserSMPServers -> "/smp_servers"
|
||||
@@ -531,7 +534,6 @@ sealed class CC {
|
||||
is ApiGetChats -> "apiGetChats"
|
||||
is ApiGetChat -> "apiGetChat"
|
||||
is ApiSendMessage -> "apiSendMessage"
|
||||
is ApiSendMessageQuote -> "apiSendMessageQuote"
|
||||
is ApiUpdateChatItem -> "apiUpdateChatItem"
|
||||
is ApiDeleteChatItem -> "apiDeleteChatItem"
|
||||
is GetUserSMPServers -> "getUserSMPServers"
|
||||
|
||||
@@ -3,8 +3,7 @@ package chat.simplex.app.views
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.*
|
||||
@@ -44,13 +43,15 @@ fun TerminalLayout(terminalItems: List<TerminalItem>, close: () -> Unit, sendCom
|
||||
Scaffold(
|
||||
topBar = { CloseSheetBar(close) },
|
||||
bottomBar = {
|
||||
SendMsgView(
|
||||
msg = remember { mutableStateOf("") },
|
||||
linkPreview = remember { mutableStateOf(null) },
|
||||
cancelledLinks = remember { mutableSetOf() },
|
||||
parseMarkdown = { null },
|
||||
sendMessage = sendCommand
|
||||
)
|
||||
Box(Modifier.padding(horizontal = 8.dp)) {
|
||||
SendMsgView(
|
||||
msg = remember { mutableStateOf("") },
|
||||
linkPreview = remember { mutableStateOf(null) },
|
||||
cancelledLinks = remember { mutableSetOf() },
|
||||
parseMarkdown = { null },
|
||||
sendMessage = sendCommand
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.navigationBarsWithImePadding()
|
||||
) { contentPadding ->
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowBackIos
|
||||
@@ -16,6 +19,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -33,6 +37,8 @@ import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.Clock
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
@Composable
|
||||
fun ChatView(chatModel: ChatModel) {
|
||||
@@ -41,9 +47,12 @@ fun ChatView(chatModel: ChatModel) {
|
||||
if (chat == null || user == null) {
|
||||
chatModel.chatId.value = null
|
||||
} else {
|
||||
val context = LocalContext.current
|
||||
val quotedItem = remember { mutableStateOf<ChatItem?>(null) }
|
||||
val editingItem = remember { mutableStateOf<ChatItem?>(null) }
|
||||
val linkPreview = remember { mutableStateOf<LinkPreview?>(null) }
|
||||
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
|
||||
val imagePreview = remember { mutableStateOf<String?>(null) }
|
||||
var msg = remember { mutableStateOf("") }
|
||||
|
||||
BackHandler { chatModel.chatId.value = null }
|
||||
@@ -62,7 +71,7 @@ fun ChatView(chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
}
|
||||
ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem, linkPreview,
|
||||
ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem, linkPreview, chosenImage, imagePreview,
|
||||
back = { chatModel.chatId.value = null },
|
||||
info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } },
|
||||
openDirectChat = { contactId ->
|
||||
@@ -85,12 +94,28 @@ fun ChatView(chatModel: ChatModel) {
|
||||
)
|
||||
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
|
||||
} else {
|
||||
var file: String? = null
|
||||
val imagePreviewData = imagePreview.value
|
||||
val chosenImageData = chosenImage.value
|
||||
val linkPreviewData = linkPreview.value
|
||||
val mc = when {
|
||||
imagePreviewData != null && chosenImageData != null -> {
|
||||
file = saveImage(context, chosenImageData)
|
||||
MsgContent.MCImage(msg, imagePreviewData)
|
||||
}
|
||||
linkPreviewData != null -> {
|
||||
MsgContent.MCLink(msg, linkPreviewData)
|
||||
}
|
||||
else -> {
|
||||
MsgContent.MCText(msg)
|
||||
}
|
||||
}
|
||||
val newItem = chatModel.controller.apiSendMessage(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
file = file,
|
||||
quotedItemId = quotedItem.value?.meta?.itemId,
|
||||
mc = if (linkPreviewData != null) MsgContent.MCLink(msg, linkPreviewData) else MsgContent.MCText(msg)
|
||||
mc = mc
|
||||
)
|
||||
if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem)
|
||||
}
|
||||
@@ -113,11 +138,23 @@ fun ChatView(chatModel: ChatModel) {
|
||||
if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem)
|
||||
}
|
||||
},
|
||||
parseMarkdown = { text -> runBlocking { chatModel.controller.apiParseMarkdown(text) } }
|
||||
parseMarkdown = { text -> runBlocking { chatModel.controller.apiParseMarkdown(text) } },
|
||||
onImageChange = { bitmap -> imagePreview.value = resizeImageToDataSize(bitmap, maxDataSize = 12500) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveImage(context: Context, image: Bitmap): String {
|
||||
val imageResized = base64ToBitmap(resizeImageToDataSize(image, 160000))
|
||||
val imageName = "image_${System.currentTimeMillis()}.jpg"
|
||||
val file = File(context.filesDir, imageName)
|
||||
val output = FileOutputStream(file)
|
||||
imageResized.compress(Bitmap.CompressFormat.JPEG, 100, output)
|
||||
output.flush()
|
||||
output.close()
|
||||
return file.absolutePath
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatLayout(
|
||||
user: User,
|
||||
@@ -127,27 +164,53 @@ fun ChatLayout(
|
||||
quotedItem: MutableState<ChatItem?>,
|
||||
editingItem: MutableState<ChatItem?>,
|
||||
linkPreview: MutableState<LinkPreview?>,
|
||||
chosenImage: MutableState<Bitmap?>,
|
||||
imagePreview: MutableState<String?>,
|
||||
back: () -> Unit,
|
||||
info: () -> Unit,
|
||||
openDirectChat: (Long) -> Unit,
|
||||
sendMessage: (String) -> Unit,
|
||||
resetMessage: () -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
parseMarkdown: (String) -> List<FormattedText>?
|
||||
parseMarkdown: (String) -> List<FormattedText>?,
|
||||
onImageChange: (Bitmap) -> Unit
|
||||
) {
|
||||
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Surface(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
Scaffold(
|
||||
topBar = { ChatInfoToolbar(chat, back, info) },
|
||||
bottomBar = { ComposeView(msg, quotedItem, editingItem, linkPreview, sendMessage, resetMessage, parseMarkdown) },
|
||||
modifier = Modifier.navigationBarsWithImePadding()
|
||||
) { contentPadding ->
|
||||
Box(Modifier.padding(contentPadding)) {
|
||||
ChatItemsList(user, chat, chatItems, msg, quotedItem, editingItem, openDirectChat, deleteMessage)
|
||||
ModalBottomSheetLayout(
|
||||
scrimColor = Color.Black.copy(alpha = 0.12F),
|
||||
modifier = Modifier.navigationBarsWithImePadding(),
|
||||
sheetContent = {
|
||||
GetImageBottomSheet(
|
||||
chosenImage,
|
||||
onImageChange = onImageChange,
|
||||
hideBottomSheet = {
|
||||
scope.launch { bottomSheetModalState.hide() }
|
||||
})
|
||||
},
|
||||
sheetState = bottomSheetModalState,
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { ChatInfoToolbar(chat, back, info) },
|
||||
bottomBar = {
|
||||
ComposeView(
|
||||
msg, quotedItem, editingItem, linkPreview, imagePreview, sendMessage, resetMessage, parseMarkdown,
|
||||
showBottomSheet = { scope.launch { bottomSheetModalState.show() } }
|
||||
)
|
||||
},
|
||||
modifier = Modifier.navigationBarsWithImePadding()
|
||||
) { contentPadding ->
|
||||
Box(Modifier.padding(contentPadding)) {
|
||||
ChatItemsList(user, chat, chatItems, msg, quotedItem, editingItem, openDirectChat, deleteMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,13 +404,16 @@ fun PreviewChatLayout() {
|
||||
quotedItem = remember { mutableStateOf(null) },
|
||||
editingItem = remember { mutableStateOf(null) },
|
||||
linkPreview = remember { mutableStateOf(null) },
|
||||
chosenImage = remember { mutableStateOf(null) },
|
||||
imagePreview = remember { mutableStateOf(null) },
|
||||
back = {},
|
||||
info = {},
|
||||
openDirectChat = {},
|
||||
sendMessage = {},
|
||||
resetMessage = {},
|
||||
deleteMessage = { _, _ -> },
|
||||
parseMarkdown = { null }
|
||||
parseMarkdown = { null },
|
||||
onImageChange = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -386,13 +452,16 @@ fun PreviewGroupChatLayout() {
|
||||
quotedItem = remember { mutableStateOf(null) },
|
||||
editingItem = remember { mutableStateOf(null) },
|
||||
linkPreview = remember { mutableStateOf(null) },
|
||||
chosenImage = remember { mutableStateOf(null) },
|
||||
imagePreview = remember { mutableStateOf(null) },
|
||||
back = {},
|
||||
info = {},
|
||||
openDirectChat = {},
|
||||
sendMessage = {},
|
||||
resetMessage = {},
|
||||
deleteMessage = { _, _ -> },
|
||||
parseMarkdown = { null }
|
||||
parseMarkdown = { null },
|
||||
onImageChange = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.views.chat.item.SentColorLight
|
||||
import chat.simplex.app.views.helpers.base64ToBitmap
|
||||
|
||||
@Composable
|
||||
fun ComposeImageView(image: String) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
.background(SentColorLight),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val imageBitmap = base64ToBitmap(image).asImageBitmap()
|
||||
Image(
|
||||
imageBitmap,
|
||||
"preview image",
|
||||
modifier = Modifier
|
||||
.width(80.dp)
|
||||
.height(60.dp)
|
||||
.padding(end = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import ComposeImageView
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AddCircleOutline
|
||||
import androidx.compose.material.icons.outlined.AttachFile
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.helpers.ComposeLinkView
|
||||
|
||||
@@ -13,9 +26,11 @@ fun ComposeView(
|
||||
quotedItem: MutableState<ChatItem?>,
|
||||
editingItem: MutableState<ChatItem?>,
|
||||
linkPreview: MutableState<LinkPreview?>,
|
||||
imagePreview: MutableState<String?>,
|
||||
sendMessage: (String) -> Unit,
|
||||
resetMessage: () -> Unit,
|
||||
parseMarkdown: (String) -> List<FormattedText>?
|
||||
parseMarkdown: (String) -> List<FormattedText>?,
|
||||
showBottomSheet: () -> Unit
|
||||
) {
|
||||
val cancelledLinks = remember { mutableSetOf<String>() }
|
||||
|
||||
@@ -28,8 +43,13 @@ fun ComposeView(
|
||||
}
|
||||
|
||||
Column {
|
||||
val lp = linkPreview.value
|
||||
if (lp != null) ComposeLinkView(lp, ::cancelPreview)
|
||||
val ip = imagePreview.value
|
||||
if (ip != null) {
|
||||
ComposeImageView(ip)
|
||||
} else {
|
||||
val lp = linkPreview.value
|
||||
if (lp != null) ComposeLinkView(lp, ::cancelPreview)
|
||||
}
|
||||
when {
|
||||
quotedItem.value != null -> {
|
||||
ContextItemView(quotedItem)
|
||||
@@ -39,6 +59,26 @@ fun ComposeView(
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
SendMsgView(msg, linkPreview, cancelledLinks, parseMarkdown, sendMessage, editing = editingItem.value != null)
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 2.dp, end = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.AddCircleOutline,
|
||||
contentDescription = "Add image",
|
||||
tint = if (editingItem.value == null) MaterialTheme.colors.primary else Color.Gray,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.padding(vertical = 4.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
if (editingItem.value == null) {
|
||||
showBottomSheet()
|
||||
}
|
||||
}
|
||||
)
|
||||
SendMsgView(msg, linkPreview, cancelledLinks, parseMarkdown, sendMessage, editing = editingItem.value != null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ fun SendMsgView(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
autoCorrect = true
|
||||
),
|
||||
modifier = Modifier.padding(8.dp),
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
cursorBrush = SolidColor(HighOrLowlight),
|
||||
decorationBox = { innerTextField ->
|
||||
Surface(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.FileProvider
|
||||
import chat.simplex.app.BuildConfig
|
||||
import chat.simplex.app.model.CIFile
|
||||
import chat.simplex.app.views.helpers.base64ToBitmap
|
||||
import java.io.*
|
||||
|
||||
@Composable
|
||||
fun ChatItemImageView(image: String, file: CIFile?) {
|
||||
Column {
|
||||
val imageBitmap = if (
|
||||
file?.filePath != null &&
|
||||
File(file.filePath).exists() &&
|
||||
file.stored // TODO more advanced approach would be to send progressive jpeg and only check for filepath
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
try {
|
||||
getBitmapFromUri(context, file.filePath)
|
||||
} catch (e: Exception) {
|
||||
base64ToBitmap(image)
|
||||
}
|
||||
} else {
|
||||
base64ToBitmap(image)
|
||||
}
|
||||
Image(
|
||||
imageBitmap.asImageBitmap(),
|
||||
contentDescription = "image",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// https://developer.android.com/training/data-storage/shared/documents-files#bitmap
|
||||
@Throws(IOException::class)
|
||||
private fun getBitmapFromUri(context: Context, file: String): Bitmap {
|
||||
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(file))
|
||||
val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, "r")
|
||||
val fileDescriptor = parcelFileDescriptor?.fileDescriptor
|
||||
val image = BitmapFactory.decodeFileDescriptor(fileDescriptor)
|
||||
parcelFileDescriptor?.close()
|
||||
return image
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import ChatItemImageView
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -61,7 +62,9 @@ fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null, sho
|
||||
} else {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
val mc = ci.content.msgContent
|
||||
if (mc is MsgContent.MCLink) {
|
||||
if (mc is MsgContent.MCImage) {
|
||||
ChatItemImageView(image = mc.image, file = ci.file)
|
||||
} else if (mc is MsgContent.MCLink) {
|
||||
ChatItemLinkView(mc.preview)
|
||||
}
|
||||
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
||||
|
||||
@@ -37,7 +37,7 @@ import kotlin.math.sqrt
|
||||
|
||||
// Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery
|
||||
|
||||
private fun cropToSquare(image: Bitmap): Bitmap {
|
||||
fun cropToSquare(image: Bitmap): Bitmap {
|
||||
var xOffset = 0
|
||||
var yOffset = 0
|
||||
val side = min(image.height, image.width)
|
||||
@@ -124,7 +124,8 @@ fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLaun
|
||||
|
||||
@Composable
|
||||
fun GetImageBottomSheet(
|
||||
profileImageStr: MutableState<String?>,
|
||||
imageBitmap: MutableState<Bitmap?>,
|
||||
onImageChange: (Bitmap) -> Unit,
|
||||
hideBottomSheet: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -134,12 +135,16 @@ fun GetImageBottomSheet(
|
||||
if (uri != null) {
|
||||
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
profileImageStr.value = resizeImageToDataSize(cropToSquare(bitmap), maxDataSize = 12500)
|
||||
imageBitmap.value = bitmap
|
||||
onImageChange(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? ->
|
||||
if (bitmap != null) profileImageStr.value = resizeImageToDataSize(cropToSquare(bitmap), maxDataSize = 12500)
|
||||
if (bitmap != null) {
|
||||
imageBitmap.value = bitmap
|
||||
onImageChange(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||
|
||||
+8
-3
@@ -1,6 +1,7 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -63,6 +64,7 @@ fun UserProfileLayout(
|
||||
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val displayName = remember { mutableStateOf(profile.displayName) }
|
||||
val fullName = remember { mutableStateOf(profile.fullName) }
|
||||
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
|
||||
val profileImage = remember { mutableStateOf(profile.image) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
@@ -74,9 +76,12 @@ fun UserProfileLayout(
|
||||
scrimColor = Color.Black.copy(alpha = 0.12F),
|
||||
modifier = Modifier.navigationBarsWithImePadding(),
|
||||
sheetContent = {
|
||||
GetImageBottomSheet(profileImage, hideBottomSheet = {
|
||||
scope.launch { bottomSheetModalState.hide() }
|
||||
})
|
||||
GetImageBottomSheet(
|
||||
chosenImage,
|
||||
onImageChange = { bitmap -> profileImage.value = resizeImageToDataSize(cropToSquare(bitmap), maxDataSize = 12500) },
|
||||
hideBottomSheet = {
|
||||
scope.launch { bottomSheetModalState.hide() }
|
||||
})
|
||||
},
|
||||
sheetState = bottomSheetModalState,
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||
|
||||
@@ -773,6 +773,17 @@ struct CIFile: Decodable {
|
||||
static func getSample(_ fileId: Int64, _ fileName: String, _ fileSize: Int64, filePath: String?, fileStatus: CIFileStatus = .sndStored) -> CIFile {
|
||||
CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus)
|
||||
}
|
||||
|
||||
var stored: Bool {
|
||||
get {
|
||||
switch self.fileStatus {
|
||||
case .sndStored: return true
|
||||
case .sndCancelled: return true
|
||||
case .rcvComplete: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CIFileStatus: String, Decodable {
|
||||
|
||||
@@ -52,7 +52,7 @@ struct FramedItemView: View {
|
||||
.padding(.bottom, 2)
|
||||
} else {
|
||||
if case let .image(_, image) = chatItem.content.msgContent {
|
||||
ChatItemImageView(image: image, file: chatItem.file?.filePath)
|
||||
ChatItemImageView(image: image, file: chatItem.file)
|
||||
} else if case let .link(_, preview) = chatItem.content.msgContent {
|
||||
ChatItemLinkView(linkPreview: preview)
|
||||
}
|
||||
|
||||
@@ -260,8 +260,9 @@ struct ChatView: View {
|
||||
if let imageResized = resizeImageToDataSize(uiImage, maxDataSize: 160000),
|
||||
let dataResized = Data(base64Encoded: dropImagePrefix(imageResized)),
|
||||
let jpegData = UIImage(data: dataResized)?.jpegData(compressionQuality: 1) {
|
||||
let imgName = "IMG_\(Date().timeIntervalSince1970).jpg"
|
||||
let filename = getDocumentsDirectory().appendingPathComponent(imgName)
|
||||
let millisecondsSince1970 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded())
|
||||
let imageName = "image_\(millisecondsSince1970).jpg"
|
||||
let filename = getDocumentsDirectory().appendingPathComponent(imageName)
|
||||
do {
|
||||
try jpegData.write(to: filename)
|
||||
return filename.path
|
||||
|
||||
@@ -124,6 +124,8 @@ struct ComposeView: View {
|
||||
}
|
||||
.onChange(of: chosenImage) { image in
|
||||
if let image = image {
|
||||
// TODO
|
||||
// imagePreview = resizeImageToDataSize(image, maxDataSize: 12500)
|
||||
imagePreview = resizeImageToDataSize(cropToSquare(image), maxDataSize: 12500)
|
||||
} else {
|
||||
imagePreview = nil
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user