android: add chat info page, delete contacts, show network connection status for contacts, improve error handling

This commit is contained in:
Evgeny Poberezkin
2022-02-20 21:17:24 +00:00
parent b3153ae0fd
commit d37f493c6a
12 changed files with 558 additions and 184 deletions
@@ -16,6 +16,7 @@ import androidx.navigation.compose.*
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.*
import chat.simplex.app.views.chat.ChatInfoView
import chat.simplex.app.views.chat.ChatView
import chat.simplex.app.views.chatlist.ChatListView
import chat.simplex.app.views.helpers.withApi
@@ -50,14 +51,10 @@ class SimplexViewModel(application: Application) : AndroidViewModel(application)
@ExperimentalMaterialApi
@Composable
fun MainPage(chatModel: ChatModel, nav: NavController) {
Box {
if (chatModel.currentUser.value == null) WelcomeView(chatModel) {
nav.navigate(Pages.ChatList.route)
} else {
ChatListView(chatModel, nav)
}
val am = chatModel.alertManager
if (am.presentAlert.value) am.alertView.value?.invoke()
if (chatModel.currentUser.value == null) WelcomeView(chatModel) {
nav.navigate(Pages.ChatList.route)
} else {
ChatListView(chatModel, nav)
}
}
@@ -68,40 +65,47 @@ fun MainPage(chatModel: ChatModel, nav: NavController) {
fun Navigation(chatModel: ChatModel) {
val nav = rememberNavController()
NavHost(navController = nav, startDestination=Pages.Home.route){
composable(route=Pages.Home.route){
MainPage(chatModel, nav)
}
composable(route = Pages.Welcome.route) {
WelcomeView(chatModel) {
nav.navigate(Pages.Home.route) {
popUpTo(Pages.Home.route) { inclusive = true }
Box {
NavHost(navController = nav, startDestination = Pages.Home.route) {
composable(route = Pages.Home.route) {
MainPage(chatModel, nav)
}
composable(route = Pages.Welcome.route) {
WelcomeView(chatModel) {
nav.navigate(Pages.Home.route) {
popUpTo(Pages.Home.route) { inclusive = true }
}
}
}
composable(route = Pages.ChatList.route) {
ChatListView(chatModel, nav)
}
composable(route = Pages.Chat.route) {
ChatView(chatModel, nav)
}
composable(route = Pages.AddContact.route) {
AddContactView(chatModel, nav)
}
composable(route = Pages.Connect.route) {
ConnectContactView(chatModel, nav)
}
composable(route = Pages.ChatInfo.route) {
ChatInfoView(chatModel, nav)
}
composable(route = Pages.Terminal.route) {
TerminalView(chatModel, nav)
}
composable(
Pages.TerminalItemDetails.route + "/{identifier}",
arguments = listOf(
navArgument("identifier") {
type = NavType.LongType
}
)
) { entry -> DetailView(entry.arguments!!.getLong("identifier"), chatModel.terminalItems, nav) }
}
composable(route = Pages.ChatList.route) {
ChatListView(chatModel, nav)
}
composable(route = Pages.Chat.route) {
ChatView(chatModel, nav)
}
composable(route = Pages.AddContact.route) {
AddContactView(chatModel, nav)
}
composable(route = Pages.Connect.route) {
ConnectContactView(chatModel, nav)
}
composable(route = Pages.Terminal.route) {
TerminalView(chatModel, nav)
}
composable(
Pages.TerminalItemDetails.route + "/{identifier}",
arguments = listOf(
navArgument("identifier"){
type = NavType.LongType
}
)
) { entry -> DetailView( entry.arguments!!.getLong("identifier"), chatModel.terminalItems, nav) }
val am = chatModel.alertManager
if (am.presentAlert.value) am.alertView.value?.invoke()
}
}
@@ -114,6 +118,7 @@ sealed class Pages(val route: String) {
object Chat: Pages("chat")
object AddContact: Pages("add_contact")
object Connect: Pages("connect")
object ChatInfo: Pages("chat_info")
}
@DelicateCoroutinesApi
@@ -5,9 +5,12 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import chat.simplex.app.SimplexApp
import kotlinx.datetime.*
import kotlinx.datetime.TimeZone
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.Boolean
import kotlin.Int
import kotlin.Long
import kotlin.String
class ChatModel(val controller: ChatController, val alertManager: SimplexApp.AlertManager) {
var currentUser = mutableStateOf<User?>(null)
@@ -39,12 +42,14 @@ class ChatModel(val controller: ChatController, val alertManager: SimplexApp.Ale
}
}
// func updateNetworkStatus(_ contact: Contact, _ status: Chat.NetworkStatus) {
// if let ix = getChatIndex(contact.id) {
// chats[ix].serverInfo.networkStatus = status
// }
// }
//
fun updateNetworkStatus(contact: Contact, status: Chat.NetworkStatus) {
val i = getChatIndex(contact.id)
if (i >= 0) {
val chat = chats[i]
chats[i] = chat.copy(serverInfo = chat.serverInfo.copy(networkStatus = status))
}
}
// func replaceChat(_ id: String, _ chat: Chat) {
// if let i = getChatIndex(id) {
// chats[i] = chat
@@ -59,7 +64,7 @@ class ChatModel(val controller: ChatController, val alertManager: SimplexApp.Ale
val i = getChatIndex(cInfo.id)
if (i >= 0) {
val chat = chats[i]
val updatedChat = chat.copy(
chats[i] = chat.copy(
chatItems = arrayListOf(cItem),
chatStats =
if (cItem.meta.itemStatus is CIStatus.RcvNew)
@@ -67,7 +72,6 @@ class ChatModel(val controller: ChatController, val alertManager: SimplexApp.Ale
else
chat.chatStats
)
chats[i] = updatedChat
if (i > 0) {
popChat_(i)
}
@@ -154,18 +158,23 @@ class ChatModel(val controller: ChatController, val alertManager: SimplexApp.Ale
val chat = chats.removeAt(i)
chats.add(index = 0, chat)
}
//
// func removeChat(_ id: String) {
// withAnimation {
// chats.removeAll(where: { $0.id == id })
// }
// }
fun removeChat(id: String) {
chats.removeAll { it.id == id }
}
}
enum class ChatType(val type: String) {
Direct("@"),
Group("#"),
ContactRequest("<@")
ContactRequest("<@");
val chatTypeName: String get () =
when (this) {
Direct -> "contact"
Group -> "group"
ContactRequest -> "contact request"
}
}
@Serializable
@@ -221,20 +230,22 @@ data class Chat (
data class ChatStats(val unreadCount: Int = 0, val minUnreadItemId: Long = 0)
@Serializable
class ServerInfo(val networkStatus: NetworkStatus)
data class ServerInfo(val networkStatus: NetworkStatus)
@Serializable
sealed class NetworkStatus {
abstract val statusString: String
abstract val statusExplanation: String
abstract val imageName: String
val statusString: String get() = if (this is Connected) "Server connected" else "Connecting server…"
val statusExplanation: String get() =
when {
this is Connected -> "You are connected to the server you use to receve messages from this contact."
this is Error -> "Trying to connect to the server you use to receve messages from this contact (error: $error)."
else -> "Trying to connect to the server you use to receve messages from this contact."
}
@Serializable
class Unknown: NetworkStatus() {
override val statusString get() = "Server connected"
override val statusExplanation get() = "You are connected to the server you use to receve messages from this contact."
override val imageName get() = "circle.dotted" // ?
}
@Serializable @SerialName("unknown") class Unknown: NetworkStatus()
@Serializable @SerialName("connected") class Connected: NetworkStatus()
@Serializable @SerialName("disconnected") class Disconnected: NetworkStatus()
@Serializable @SerialName("error") class Error(val error: String): NetworkStatus()
}
}
@@ -249,7 +260,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val ready get() = contact.ready
override val createdAt get() = contact.createdAt
override val displayName get() = contact.displayName
override val fullName get() = contact.displayName
override val fullName get() = contact.fullName
companion object {
val sampleData = Direct(Contact.sampleData)
@@ -265,7 +276,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val ready get() = groupInfo.ready
override val createdAt get() = groupInfo.createdAt
override val displayName get() = groupInfo.displayName
override val fullName get() = groupInfo.displayName
override val fullName get() = groupInfo.fullName
companion object {
val sampleData = Group(GroupInfo.sampleData)
@@ -281,7 +292,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val ready get() = contactRequest.ready
override val createdAt get() = contactRequest.createdAt
override val displayName get() = contactRequest.displayName
override val fullName get() = contactRequest.displayName
override val fullName get() = contactRequest.fullName
companion object {
val sampleData = ContactRequest(UserContactRequest.sampleData)
@@ -565,8 +576,3 @@ sealed class MsgContent {
class RcvFileTransfer {
}
@Serializable
class AgentErrorType {
}
@@ -124,9 +124,7 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
return false
}
else -> {
val errMsg = "${r.responseType}: ${r.details}"
Log.e("SIMPLEX", "apiConnect bad response: $errMsg")
alertManager.showAlertMsg("Connection error", errMsg)
apiErrorAlert("apiConnect", "Connection error", r)
return false
}
}
@@ -134,8 +132,20 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
suspend fun apiDeleteChat(type: ChatType, id: Long): Boolean {
val r = sendCmd(CC.ApiDeleteChat(type, id))
if (r is CR.ContactDeleted) return true // TODO groups
Log.d("SIMPLEX", "apiDeleteChat bad response: ${r.responseType} ${r.details}")
when {
r is CR.ContactDeleted -> return true // TODO groups
r is CR.ChatCmdError -> {
val e = r.chatError
if (e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.ContactGroups) {
alertManager.showAlertMsg(
"Can't delete contact!",
"Contact ${e.errorType.contact.displayName} cannot be deleted, it is a member of the group(s) ${e.errorType.groupNames}"
)
return false
}
}
}
apiErrorAlert("apiDeleteChat", "Error deleting ${type.chatTypeName}", r)
return false
}
@@ -193,22 +203,53 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
return false
}
fun apiErrorAlert(method: String, title: String, r: CR) {
val errMsg = "${r.responseType}: ${r.details}"
Log.e("SIMPLEX", "$method bad response: $errMsg")
alertManager.showAlertMsg(title, errMsg)
}
fun processReceivedMsg(r: CR) {
chatModel.terminalItems.add(TerminalItem.resp(r))
when {
r is CR.ContactConnected -> chatModel.updateContact(r.contact)
// r is CR.UpdateNetworkStatus -> return
// r is CR.ReceivedContactRequest -> return
// r is CR.ContactUpdated -> return
// r is CR.ContactSubscribed -> return
// r is CR.ContactSubError -> return
// r is CR.UpdateContact -> return
r is CR.ContactUpdated -> {
val cInfo = ChatInfo.Direct(r.toContact)
if (chatModel.hasChat(r.toContact.id)) {
chatModel.updateChatInfo(cInfo)
}
}
r is CR.ContactSubscribed -> {
chatModel.updateContact(r.contact)
chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Connected())
}
r is CR.ContactDisconnected -> {
chatModel.updateContact(r.contact)
chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Disconnected())
}
r is CR.ContactSubError -> {
chatModel.updateContact(r.contact)
val e = r.chatError
val err: String =
if (e is ChatError.ChatErrorAgent) {
val a = e.agentError
when {
a is AgentErrorType.BROKER && a.brokerErr is BrokerErrorType.NETWORK -> "network"
a is AgentErrorType.SMP && a.smpErr is SMPErrorType.AUTH -> "contact deleted"
else -> e.string
}
}
else e.string
chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Error(err))
}
r is CR.NewChatItem -> {
val cInfo = r.chatItem.chatInfo
val cItem = r.chatItem.chatItem
chatModel.addChatItem(cInfo, cItem)
}
// NtfManager.shared.notifyMessageReceived(cInfo, cItem)
}
// switch res {
// chatModel.updateNetworkStatus(contact, .connected)
@@ -219,31 +260,7 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
// chatItems: []
// ))
// NtfManager.shared.notifyContactRequest(contactRequest)
// case let .contactUpdated(toContact):
// let cInfo = ChatInfo.direct(contact: toContact)
// if chatModel.hasChat(toContact.id) {
// chatModel.updateChatInfo(cInfo)
// }
// case let .contactSubscribed(contact):
// chatModel.updateContact(contact)
// chatModel.updateNetworkStatus(contact, .connected)
// case let .contactDisconnected(contact):
// chatModel.updateContact(contact)
// chatModel.updateNetworkStatus(contact, .disconnected)
// case let .contactSubError(contact, chatError):
// chatModel.updateContact(contact)
//// var err: String
//// switch chatError {
//// case .errorAgent(agentError: .BROKER(brokerErr: .NETWORK)): err = "network"
//// case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted"
//// default: err = String(describing: chatError)
//// }
//// chatModel.updateNetworkStatus(contact, .error(err))
// case let .newChatItem(aChatItem):
// let cInfo = aChatItem.chatInfo
// let cItem = aChatItem.chatItem
// chatModel.addChatItem(cInfo, cItem)
// NtfManager.shared.notifyMessageReceived(cInfo, cItem)
//
// case let .chatItemUpdated(aChatItem):
// let cInfo = aChatItem.chatInfo
// let cItem = aChatItem.chatItem
@@ -557,13 +574,13 @@ sealed class CR {
@Serializable @SerialName("chatCmdError")
class ChatCmdError(val chatError: ChatError): CR() {
override val responseType get() = "chatCmdError"
override val details get() = chatError.toString()
override val details get() = chatError.string
}
@Serializable @SerialName("chatError")
class ChatRespError(val chatError: ChatError): CR() {
override val responseType get() = "chatError"
override val details get() = chatError.toString()
override val details get() = chatError.string
}
@Serializable
@@ -610,21 +627,186 @@ abstract class TerminalItem {
@Serializable
sealed class ChatError {
@Serializable @SerialName("error")
class ChatErrorChat(val errorType: ChatErrorType): ChatError()
@Serializable @SerialName("errorStore")
class ChatErrorStore(val storeError: StoreError): ChatError()
val string: String get() = when {
this is ChatErrorChat -> "chat ${errorType.string}"
this is ChatErrorAgent -> "agent ${agentError.string}"
this is ChatErrorStore -> "store ${storeError.string}"
else -> "ChatError"
}
@Serializable @SerialName("error") class ChatErrorChat(val errorType: ChatErrorType): ChatError()
@Serializable @SerialName("errorAgent") class ChatErrorAgent(val agentError: AgentErrorType): ChatError()
@Serializable @SerialName("errorStore") class ChatErrorStore(val storeError: StoreError): ChatError()
}
@Serializable
sealed class ChatErrorType {
@Serializable @SerialName("invalidConnReq")
class InvalidConnReq: ChatErrorType()
val string: String get() = when {
this is InvalidConnReq -> "invalidConnReq"
this is ContactGroups -> "groupNames $groupNames"
else -> "ChatErrorType"
}
@Serializable @SerialName("invalidConnReq") class InvalidConnReq: ChatErrorType()
@Serializable @SerialName("contactGroups") class ContactGroups(val contact: Contact, val groupNames: List<String>): ChatErrorType()
}
@Serializable
sealed class StoreError {
@Serializable @SerialName("userContactLinkNotFound")
class UserContactLinkNotFound: StoreError()
}
val string: String get() = when {
this is UserContactLinkNotFound -> "userContactLinkNotFound"
else -> "StoreError"
}
@Serializable @SerialName("userContactLinkNotFound") class UserContactLinkNotFound: StoreError()
}
@Serializable
sealed class AgentErrorType {
val string: String get() = when {
this is CMD -> "CMD ${cmdErr.string}"
this is CONN -> "CONN ${connErr.string}"
this is SMP -> "SMP ${smpErr.string}"
this is BROKER -> "BROKER ${brokerErr.string}"
this is AGENT -> "AGENT ${agentErr.string}"
this is INTERNAL -> "INTERNAL $internalErr"
else -> "AgentErrorType"
}
@Serializable @SerialName("CMD") class CMD(val cmdErr: CommandErrorType): AgentErrorType()
@Serializable @SerialName("CONN") class CONN(val connErr: ConnectionErrorType): AgentErrorType()
@Serializable @SerialName("SMP") class SMP(val smpErr: SMPErrorType): AgentErrorType()
@Serializable @SerialName("BROKER") class BROKER(val brokerErr: BrokerErrorType): AgentErrorType()
@Serializable @SerialName("AGENT") class AGENT(val agentErr: SMPAgentError): AgentErrorType()
@Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType()
}
@Serializable
sealed class CommandErrorType {
val string: String get() = when {
this is PROHIBITED -> "PROHIBITED"
this is SYNTAX -> "SYNTAX"
this is NO_CONN -> "NO_CONN"
this is SIZE -> "SIZE"
this is LARGE -> "LARGE"
else -> "CommandErrorType"
}
@Serializable @SerialName("PROHIBITED") class PROHIBITED: CommandErrorType()
@Serializable @SerialName("SYNTAX") class SYNTAX: CommandErrorType()
@Serializable @SerialName("NO_CONN") class NO_CONN: CommandErrorType()
@Serializable @SerialName("SIZE") class SIZE: CommandErrorType()
@Serializable @SerialName("LARGE") class LARGE: CommandErrorType()
}
@Serializable
sealed class ConnectionErrorType {
val string: String get() = when {
this is NOT_FOUND -> "NOT_FOUND"
this is DUPLICATE -> "DUPLICATE"
this is SIMPLEX -> "SIMPLEX"
this is NOT_ACCEPTED -> "NOT_ACCEPTED"
this is NOT_AVAILABLE -> "NOT_AVAILABLE"
else -> "ConnectionErrorType"
}
@Serializable @SerialName("NOT_FOUND") class NOT_FOUND: ConnectionErrorType()
@Serializable @SerialName("DUPLICATE") class DUPLICATE: ConnectionErrorType()
@Serializable @SerialName("SIMPLEX") class SIMPLEX: ConnectionErrorType()
@Serializable @SerialName("NOT_ACCEPTED") class NOT_ACCEPTED: ConnectionErrorType()
@Serializable @SerialName("NOT_AVAILABLE") class NOT_AVAILABLE: ConnectionErrorType()
}
@Serializable
sealed class BrokerErrorType {
val string: String get() = when {
this is RESPONSE -> "RESPONSE ${smpErr.string}"
this is UNEXPECTED -> "UNEXPECTED"
this is NETWORK -> "NETWORK"
this is TRANSPORT -> "TRANSPORT ${transportErr.string}"
this is TIMEOUT -> "TIMEOUT"
else -> "BrokerErrorType"
}
@Serializable @SerialName("RESPONSE") class RESPONSE(val smpErr: SMPErrorType): BrokerErrorType()
@Serializable @SerialName("UNEXPECTED") class UNEXPECTED: BrokerErrorType()
@Serializable @SerialName("NETWORK") class NETWORK: BrokerErrorType()
@Serializable @SerialName("TRANSPORT") class TRANSPORT(val transportErr: SMPTransportError): BrokerErrorType()
@Serializable @SerialName("TIMEOUT") class TIMEOUT: BrokerErrorType()
}
@Serializable
sealed class SMPErrorType {
val string: String get() = when {
this is BLOCK -> "BLOCK"
this is SESSION -> "SESSION"
this is CMD -> "CMD ${cmdErr.string}"
this is AUTH -> "AUTH"
this is QUOTA -> "QUOTA"
this is NO_MSG -> "NO_MSG"
this is LARGE_MSG -> "LARGE_MSG"
this is INTERNAL -> "INTERNAL"
else -> "SMPErrorType"
}
@Serializable @SerialName("BLOCK") class BLOCK: SMPErrorType()
@Serializable @SerialName("SESSION") class SESSION: SMPErrorType()
@Serializable @SerialName("CMD") class CMD(val cmdErr: SMPCommandError): SMPErrorType()
@Serializable @SerialName("AUTH") class AUTH: SMPErrorType()
@Serializable @SerialName("QUOTA") class QUOTA: SMPErrorType()
@Serializable @SerialName("NO_MSG") class NO_MSG: SMPErrorType()
@Serializable @SerialName("LARGE_MSG") class LARGE_MSG: SMPErrorType()
@Serializable @SerialName("INTERNAL") class INTERNAL: SMPErrorType()
}
@Serializable
sealed class SMPCommandError {
val string: String get() = when {
this is UNKNOWN -> "UNKNOWN"
this is SYNTAX -> "SYNTAX"
this is NO_AUTH -> "NO_AUTH"
this is HAS_AUTH -> "HAS_AUTH"
this is NO_QUEUE -> "NO_QUEUE"
else -> "SMPCommandError"
}
@Serializable @SerialName("UNKNOWN") class UNKNOWN: SMPCommandError()
@Serializable @SerialName("SYNTAX") class SYNTAX: SMPCommandError()
@Serializable @SerialName("NO_AUTH") class NO_AUTH: SMPCommandError()
@Serializable @SerialName("HAS_AUTH") class HAS_AUTH: SMPCommandError()
@Serializable @SerialName("NO_QUEUE") class NO_QUEUE: SMPCommandError()
}
@Serializable
sealed class SMPTransportError {
val string: String get() = when {
this is BadBlock -> "badBlock"
this is LargeMsg -> "largeMsg"
this is BadSession -> "badSession"
this is Handshake -> "handshake ${handshakeErr.string}"
else -> "SMPTransportError"
}
@Serializable @SerialName("badBlock") class BadBlock: SMPTransportError()
@Serializable @SerialName("largeMsg") class LargeMsg: SMPTransportError()
@Serializable @SerialName("badSession") class BadSession: SMPTransportError()
@Serializable @SerialName("handshake") class Handshake(val handshakeErr: SMPHandshakeError): SMPTransportError()
}
@Serializable
sealed class SMPHandshakeError {
val string: String get() = when {
this is PARSE -> "PARSE"
this is VERSION -> "VERSION"
this is IDENTITY -> "IDENTITY"
else -> "SMPHandshakeError"
}
@Serializable @SerialName("PARSE") class PARSE: SMPHandshakeError()
@Serializable @SerialName("VERSION") class VERSION: SMPHandshakeError()
@Serializable @SerialName("IDENTITY") class IDENTITY: SMPHandshakeError()
}
@Serializable
sealed class SMPAgentError {
val string: String get() = when {
this is A_MESSAGE -> "A_MESSAGE"
this is A_PROHIBITED -> "A_PROHIBITED"
this is A_VERSION -> "A_VERSION"
this is A_ENCRYPTION -> "A_ENCRYPTION"
else -> "SMPAgentError"
}
@Serializable @SerialName("A_MESSAGE") class A_MESSAGE: SMPAgentError()
@Serializable @SerialName("A_PROHIBITED") class A_PROHIBITED: SMPAgentError()
@Serializable @SerialName("A_VERSION") class A_VERSION: SMPAgentError()
@Serializable @SerialName("A_ENCRYPTION") class A_ENCRYPTION: SMPAgentError()
}
@@ -57,7 +57,7 @@ fun DetailView(identifier: Long, terminalItems: List<TerminalItem>, navControlle
Text("Back")
}
SelectionContainer {
Text((terminalItems.filter { it.id == identifier }).first().details)
Text((terminalItems.firstOrNull { it.id == identifier })?.details ?: "")
}
}
}
@@ -0,0 +1,130 @@
package chat.simplex.app.views.chat
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.Pages
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.DelicateCoroutinesApi
@DelicateCoroutinesApi
@Composable
fun ChatInfoView(chatModel: ChatModel, nav: NavController) {
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
if (chat != null) {
ChatInfoLayout(chat,
close = { nav.popBackStack() },
deleteContact = {
chatModel.alertManager.showAlertMsg(
title = "Delete contact?",
text = "Contact and all messages will be deleted - this cannot be undone!",
confirmText = "Delete",
onConfirm = {
val cInfo = chat.chatInfo
withApi {
val r = chatModel.controller.apiDeleteChat(cInfo.chatType, cInfo.apiId)
if (r) {
chatModel.removeChat(cInfo.id)
nav.navigate(Pages.ChatList.route)
}
}
}
)
}
)
}
}
@Composable
fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) {
Column(Modifier
.fillMaxSize()
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
CloseSheetBar(close)
Spacer(Modifier.size(48.dp))
ChatInfoImage(chat, size = 192.dp)
val cInfo = chat.chatInfo
Text(
cInfo.displayName, style = MaterialTheme.typography.h1,
modifier = Modifier.padding(top = 32.dp).padding(bottom = 8.dp)
)
Text(
cInfo.fullName, style = MaterialTheme.typography.h2,
modifier = Modifier.padding(bottom = 16.dp)
)
if (cInfo is ChatInfo.Direct) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row(Modifier.padding(horizontal = 32.dp)) {
ServerImage(chat)
Text(
chat.serverInfo.networkStatus.statusString,
textAlign = TextAlign.Center,
modifier = Modifier.padding(start = 8.dp)
)
}
Text(
chat.serverInfo.networkStatus.statusExplanation,
style = MaterialTheme.typography.body2,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp).padding(horizontal = 16.dp)
)
}
Spacer(Modifier.weight(1F))
Box(Modifier.padding(24.dp)) {
SimpleButton(
"Delete contact", icon = Icons.Outlined.Delete,
color = Color.Red,
click = deleteContact
)
}
}
}
}
@Composable
fun ServerImage(chat: Chat) {
val status = chat.serverInfo.networkStatus
when {
status is Chat.NetworkStatus.Connected ->
Icon(Icons.Filled.Circle, "Connected", tint = MaterialTheme.colors.primaryVariant)
status is Chat.NetworkStatus.Disconnected ->
Icon(Icons.Filled.Pending, "Disconnected", tint = HighOrLowlight)
status is Chat.NetworkStatus.Error ->
Icon(Icons.Filled.Error, "Error", tint = HighOrLowlight)
else ->
Icon(Icons.Outlined.Circle, "Pending", tint = HighOrLowlight)
}
}
@Preview
@Composable
fun PreviewChatInfoLayout() {
SimpleXTheme {
ChatInfoLayout(
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = arrayListOf(),
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
),
close = {}, deleteContact = {}
)
}
}
@@ -11,12 +11,15 @@ import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.Pages
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.ChatItemView
import chat.simplex.app.views.helpers.ChatInfoImage
import chat.simplex.app.views.helpers.withApi
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.datetime.Clock
@@ -25,20 +28,26 @@ import kotlinx.datetime.Clock
@Composable
fun ChatView(chatModel: ChatModel, nav: NavController) {
if (chatModel.chatId.value != null && chatModel.chats.count() > 0) {
val chat: Chat = chatModel.chats.first { chat -> chat.chatInfo.id == chatModel.chatId.value }
ChatLayout(chat, chatModel.chatItems, back = { nav.popBackStack() }, sendMessage = { msg ->
withApi {
// show "in progress"
val cInfo = chat.chatInfo
val newItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
mc = MsgContent.MCText(msg)
)
// hide "in progress"
if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem)
}
})
val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
if (chat != null) {
ChatLayout(chat, chatModel.chatItems,
back = { nav.popBackStack() },
info = { nav.navigate(Pages.ChatInfo.route) },
sendMessage = { msg ->
withApi {
// show "in progress"
val cInfo = chat.chatInfo
val newItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
mc = MsgContent.MCText(msg)
)
// hide "in progress"
if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem)
}
}
)
}
}
}
@@ -46,10 +55,11 @@ fun ChatView(chatModel: ChatModel, nav: NavController) {
fun ChatLayout(
chat: Chat, chatItems: List<ChatItem>,
back: () -> Unit,
info: () -> Unit,
sendMessage: (String) -> Unit
) {
Scaffold(
topBar = { ChatInfoToolbar(chat, back) },
topBar = { ChatInfoToolbar(chat, back, info) },
bottomBar = { SendMsgView(sendMessage) }
) { contentPadding ->
Box(
@@ -64,9 +74,10 @@ fun ChatLayout(
}
@Composable
fun ChatInfoToolbar(chat: Chat, back: () -> Unit) {
Box(
modifier = Modifier.fillMaxWidth(),
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
Box(Modifier
.fillMaxWidth()
.height(60.dp),
contentAlignment = Alignment.CenterStart
) {
Icon(
@@ -77,14 +88,23 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit) {
.clickable(onClick = back)
.padding(start = 16.dp)
)
Column(
Modifier
.padding(horizontal = 40.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
Row(Modifier
.padding(horizontal = 40.dp)
.fillMaxWidth()
.clickable(onClick = info),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(chat.chatInfo.displayName)
Text(chat.chatInfo.fullName)
val cInfo = chat.chatInfo
ChatInfoImage(chat, size = 40.dp)
Column(Modifier.padding(start = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(cInfo.displayName, fontWeight = FontWeight.Bold)
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
Text(cInfo.fullName)
}
}
}
}
}
@@ -127,6 +147,7 @@ fun PreviewChatViewLayout() {
),
chatItems = chatItems,
back = {},
info = {},
sendMessage = {}
)
}
@@ -3,18 +3,18 @@ package chat.simplex.app.views.chat.item
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import chat.simplex.app.model.CIDirection
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import kotlinx.datetime.Clock
@Composable
fun CIMetaView(chatItem: ChatItem) {
Text(
chatItem.timestampText,
color = Color.Gray,
style = MaterialTheme.typography.caption
color = HighOrLowlight,
style = MaterialTheme.typography.body2
)
}
@@ -64,13 +64,13 @@ fun ChatListView(chatModel: ChatModel, nav: NavController) {
.background(MaterialTheme.colors.background)
) {
ChatListToolbar(newChatCtrl)
ChatList(chatModel, nav)
Button(
onClick = { nav.navigate(Pages.Terminal.route) },
modifier = Modifier.padding(14.dp)
) {
Text("Terminal")
}
ChatList(chatModel, nav)
}
if (newChatCtrl.state.bottomSheetState.isExpanded) {
Surface(Modifier
@@ -1,14 +1,11 @@
package chat.simplex.app.views.chatlist
import androidx.compose.foundation.*
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@@ -16,6 +13,7 @@ import androidx.compose.ui.unit.dp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.ChatInfoImage
@Composable
fun ChatPreviewView(chat: Chat, goToChat: () -> Unit) {
@@ -27,30 +25,19 @@ fun ChatPreviewView(chat: Chat, goToChat: () -> Unit) {
.height(80.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp),
) {
Column(verticalArrangement = Arrangement.Center, modifier = Modifier.fillMaxHeight()) {
Icon(
Icons.Filled.Person,
contentDescription = "Avatar Placeholder",
tint = MaterialTheme.colors.background,
modifier = Modifier
.size(55.dp)
.clip(CircleShape)
.border(1.5.dp, MaterialTheme.colors.secondary, CircleShape)
.background(MaterialTheme.colors.secondary)
)
ChatInfoImage(chat, size = 60.dp)
}
Spacer(modifier = Modifier.width(6.dp))
Column(modifier = Modifier.padding(all = 8.dp)) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(chat.chatInfo.chatViewName, fontWeight = FontWeight.Bold)
(
if (chat.chatItems.count() > 0) {
Text(getTimestampText(chat.chatItems.last().meta.itemTs), color = HighOrLowlight)
}
else Text(getTimestampText(chat.chatInfo.createdAt), color = HighOrLowlight)
)
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.createdAt)
Text(ts, color = HighOrLowlight, style = MaterialTheme.typography.body2)
}
if (chat.chatItems.count() > 0) {
Text(
@@ -0,0 +1,44 @@
package chat.simplex.app.views.helpers
import androidx.compose.foundation.layout.*
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.SupervisedUserCircle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.Chat
import chat.simplex.app.model.ChatInfo
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun ChatInfoImage(chat: Chat, size: Dp) {
val icon =
if (chat.chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle
else Icons.Filled.AccountCircle
Box(Modifier.size(size)) {
Icon(icon,
contentDescription = "Avatar Placeholder",
tint = MaterialTheme.colors.secondary,
modifier = Modifier.fillMaxSize(),
// .clip(CircleShape)
// .border(1.5.dp, MaterialTheme.colors.secondary, CircleShape)
// .background(MaterialTheme.colors.secondary)
)
}
}
@Preview
@Composable
fun PreviewChatInfoImage() {
SimpleXTheme {
ChatInfoImage(
chat = Chat(chatInfo = ChatInfo.Direct.sampleData, chatItems = arrayListOf()),
size = 55.dp
)
}
}
@@ -9,24 +9,23 @@ import androidx.compose.material.icons.outlined.Share
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun SimpleButton(text: String, icon: ImageVector, click: () -> Unit) {
fun SimpleButton(text: String, icon: ImageVector,
color: Color = MaterialTheme.colors.primary,
click: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { click() }
) {
Icon(icon, text,
tint = MaterialTheme.colors.primary,
Icon(icon, text, tint = color,
modifier = Modifier.padding(horizontal = 10.dp)
)
Text(text,
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.primary
)
Text(text, style = MaterialTheme.typography.caption, color = color)
}
}
+4 -4
View File
@@ -685,10 +685,10 @@ enum SMPCommandError: Decodable {
}
enum SMPTransportError: Decodable {
case TEBadBlock
case TELargeMsg
case TEBadSession
case TEHandshake(handshakeErr: SMPHandshakeError)
case badBlock
case largeMsg
case badSession
case handshake(handshakeErr: SMPHandshakeError)
}
enum SMPHandshakeError: Decodable {