Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved Message thread design #346

Merged
merged 4 commits into from
Dec 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 10 additions & 84 deletions app/src/main/java/com/bnyro/contacts/db/obj/SmsData.kt
Original file line number Diff line number Diff line change
@@ -1,94 +1,20 @@
package com.bnyro.contacts.db.obj

import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
import com.bnyro.contacts.util.Format
import com.bnyro.contacts.util.addKeywords
import com.bnyro.contacts.util.generateAnnotations

@Entity(tableName = "localSms")
data class SmsData(
@PrimaryKey(autoGenerate = true) var id: Long = 0,
@ColumnInfo val address: String = "",
@ColumnInfo val body: String = "",
@ColumnInfo val timestamp: Long = 0,
@ColumnInfo val threadId: Long = 0,
@ColumnInfo val type: Int = 0,
@ColumnInfo(defaultValue = "NULL") var simNumber: Int? = null
) {
val formatted: AnnotatedString
get() {
val text = body
val keywords = mutableListOf<Pair<Format, String>>().apply {
addKeywords(
text,
linkRegex,
Format.LINK
)
addKeywords(
text,
emailRegex,
Format.EMAIL
)
addKeywords(
text,
phoneRegex,
Format.PHONE
)
}

return buildAnnotatedString {
append(text)
keywords.forEach { kw ->
val (format, keyword) = kw
val indexOf = text.indexOf(keyword)
addStyle(
style = SpanStyle(
color = Color.Blue,
textDecoration = when (format) {
Format.LINK -> TextDecoration.Underline
else -> TextDecoration.None
}
),
start = indexOf,
end = indexOf + keyword.length
)
val link = when (format) {
Format.LINK -> if (keyword.startsWith("http")) {
keyword
} else {
"http://$keyword"
}

Format.PHONE ->
"tel:$keyword"

Format.EMAIL -> "mailto:$keyword"
}
addStringAnnotation(
tag = format.name,
annotation = link,
start = indexOf,
end = indexOf + keyword.length
)
}
}
}

companion object {
private val linkRegex = Regex(
"(?<!@)(?<!\\S)((https?://)?[a-zA-Z0-9\\-]{2,}(\\.[a-zA-Z0-9]{2,})+)"
)
private val emailRegex = Regex(
"([A-Za-z0-9+_.-]+@(.+))"
)
private val phoneRegex = Regex(
"((\\+\\d{1,3}(\\s)?)?((\\(\\d{3}\\))|(\\d{3}))[-\\s]?\\d{3}[-\\s]?\\d{4})"
)
}
}
@ColumnInfo var address: String = "",
@ColumnInfo var body: String = "",
@ColumnInfo var timestamp: Long = 0,
@ColumnInfo var threadId: Long = 0,
@ColumnInfo var type: Int = 0,
@ColumnInfo(defaultValue = "NULL") var simNumber: Int? = null,
@Ignore val formatted: AnnotatedString = generateAnnotations(body)
)
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ fun ElevatedTextInputField(
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
colors: TextFieldColors = SearchBarDefaults.inputFieldColors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
) {
val focusRequester = remember { FocusRequester() }

Expand Down Expand Up @@ -98,9 +98,9 @@ fun ElevatedTextInputField(
shape = SearchBarDefaults.inputFieldShape,
colors = colors,
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(),
container = {},
container = {}
)
}
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
package com.bnyro.contacts.ui.components.conversation

import android.provider.Telephony
import android.text.format.DateUtils
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.DismissValue
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.SwipeToDismiss
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDismissState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.bnyro.contacts.R
import com.bnyro.contacts.db.obj.SmsData
import com.bnyro.contacts.ui.components.dialogs.ConfirmationDialog
import com.bnyro.contacts.ui.models.SmsModel

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ColumnScope.Messages(
messages: List<SmsData>,
scrollState: LazyListState,
smsModel: SmsModel
) {
val timestamped = messages.groupBy {
DateUtils.getRelativeDateTimeString(
LocalContext.current,
it.timestamp,
DateUtils.MINUTE_IN_MILLIS,
DateUtils.WEEK_IN_MILLIS,
DateUtils.FORMAT_ABBREV_ALL
).split(", ").first()
}
LazyColumn(
state = scrollState,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
timestamped.forEach { timestamp ->
item {
DayHeader(
timestamp.key
)
}
items(items = timestamp.value) { smsData ->
val isUserMe = smsData.type in listOf(
Telephony.Sms.MESSAGE_TYPE_DRAFT,
Telephony.Sms.MESSAGE_TYPE_SENT,
Telephony.Sms.MESSAGE_TYPE_OUTBOX
)
var showDeleteSmsDialog by remember {
mutableStateOf(false)
}

val state = rememberDismissState(
confirmValueChange = {
if (it == DismissValue.DismissedToEnd) {
showDeleteSmsDialog = true
}
return@rememberDismissState false
}
)
SwipeToDismiss(
state = state,
background = {},
dismissContent = {
Message(
msg = smsData,
isUserMe = isUserMe
)
}
)
if (showDeleteSmsDialog) {
val context = LocalContext.current
ConfirmationDialog(
onDismissRequest = { showDeleteSmsDialog = false },
title = stringResource(R.string.delete_message),
text = stringResource(R.string.irreversible)
) {
smsModel.deleteSms(context, smsData.id, smsData.threadId)
}
}
}
}
}
}

@Composable
fun Message(
msg: SmsData,
isUserMe: Boolean
) {
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
ChatItemBubble(msg, isUserMe)
Spacer(modifier = Modifier.height(4.dp))
}
}

private val leftChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp)
private val rightChatBubbleShape = RoundedCornerShape(20.dp, 4.dp, 20.dp, 20.dp)

@Composable
fun DayHeader(dayString: String) {
Row(
modifier = Modifier
.padding(vertical = 8.dp, horizontal = 16.dp)
.height(16.dp)
) {
DayHeaderLine()
Text(
text = dayString,
modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
DayHeaderLine()
}
}

@Composable
private fun RowScope.DayHeaderLine() {
Divider(
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
}

@Composable
fun ChatItemBubble(
message: SmsData,
isUserMe: Boolean
) {
val backgroundBubbleColor = if (isUserMe) {
MaterialTheme.colorScheme.surfaceColorAtElevation(100.dp)
} else {
MaterialTheme.colorScheme.surfaceColorAtElevation(10.dp)
}

val textColor =
MaterialTheme.colorScheme.primary

Row(
Modifier.fillMaxWidth(),
horizontalArrangement = if (isUserMe) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom
) {
Surface(
modifier = if (isUserMe) {
Modifier.padding(start = 40.dp)
} else {
Modifier.padding(
end = 40.dp
)
},
color = backgroundBubbleColor,
shape = if (isUserMe) rightChatBubbleShape else leftChatBubbleShape
) {
Column(modifier = Modifier.padding(16.dp)) {
ClickableMessage(
smsData = message
)
Spacer(modifier = Modifier.height(8.dp))
Row(Modifier.align(if (isUserMe) Alignment.Start else Alignment.End)) {
Spacer(modifier = Modifier.width(8.dp))
Text(
style = MaterialTheme.typography.bodyMedium,
color = textColor,
text = DateUtils.getRelativeDateTimeString(
LocalContext.current,
message.timestamp,
DateUtils.MINUTE_IN_MILLIS,
DateUtils.WEEK_IN_MILLIS,
DateUtils.FORMAT_ABBREV_ALL
).split(", ")[1]
)
message.simNumber?.let {
Spacer(modifier = Modifier.width(8.dp))
Text(
style = MaterialTheme.typography.bodyMedium,
color = textColor,
text = "SIM $it"
)
}
}
}
}
}
}

@Composable
fun ClickableMessage(
smsData: SmsData
) {
SelectionContainer {
val uriHandler = LocalUriHandler.current
ClickableText(
text = smsData.formatted,
style = MaterialTheme.typography.bodyLarge.copy(color = LocalContentColor.current),
onClick = { offset ->
val annotation =
smsData.formatted.getStringAnnotations(
offset,
offset
).firstOrNull()
annotation?.let {
uriHandler.openUri(it.item)
}
}
)
}
}
Loading
Loading