Display and send messages

This commit is contained in:
2026-04-10 14:26:33 +03:00
parent 902af5e7b5
commit d7d14389fc
5 changed files with 189 additions and 231 deletions
+1 -1
View File
@@ -98,5 +98,5 @@ dependencies {
// Others // Others
implementation(libs.splitties.base) // Syntax sugar implementation(libs.splitties.base) // Syntax sugar
implementation(libs.jsoup) // HTML parser implementation(libs.jsoup) // HTML parser
implementation(libs.iconsax.compose) // Material icons
} }
@@ -40,6 +40,7 @@ import de.connect2x.trixnity.client.cryptodriver.vodozemac.vodozemac
import de.connect2x.trixnity.clientserverapi.client.SyncState import de.connect2x.trixnity.clientserverapi.client.SyncState
import ru.risdeveau.pixeldragon.R import ru.risdeveau.pixeldragon.R
import ru.risdeveau.pixeldragon.client import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.ui.layout.Room
import ru.risdeveau.pixeldragon.ui.layout.RoomList import ru.risdeveau.pixeldragon.ui.layout.RoomList
import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme
import ru.risdeveau.pixeldragon.util.getMediaStore import ru.risdeveau.pixeldragon.util.getMediaStore
@@ -94,9 +95,9 @@ class MainActivity : ComponentActivity() {
"room/{rid}", "room/{rid}",
arguments = listOf(navArgument("rid") { type = NavType.StringType }) arguments = listOf(navArgument("rid") { type = NavType.StringType })
) { navBackStackEntry -> ) { navBackStackEntry ->
// Room(Modifier Room(Modifier
// .padding(innerPadding) .padding(innerPadding)
// .fillMaxSize(), navBackStackEntry.arguments!!.getString("rid")!!) .fillMaxSize(), navBackStackEntry.arguments!!.getString("rid")!!)
} }
composable( composable(
"space/{rid}", "space/{rid}",
@@ -8,8 +8,7 @@ package ru.risdeveau.pixeldragon.ui.item
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons import io.github.rabehx.iconsax.Iconsax
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -28,6 +27,7 @@ import coil3.request.ImageRequest
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import de.connect2x.trixnity.client.media import de.connect2x.trixnity.client.media
import de.connect2x.trixnity.clientserverapi.model.media.FileTransferProgress import de.connect2x.trixnity.clientserverapi.model.media.FileTransferProgress
import io.github.rabehx.iconsax.outline.Warning2
import ru.risdeveau.pixeldragon.client import ru.risdeveau.pixeldragon.client
enum class ImageLoadState { enum class ImageLoadState {
@@ -119,7 +119,7 @@ fun MXCImage(
} }
imageLoadState == ImageLoadState.Error -> { imageLoadState == ImageLoadState.Error -> {
Icon( Icon(
Icons.Outlined.Warning, Iconsax.Outline.Warning2,
contentDescription = "Error", contentDescription = "Error",
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)
) )
@@ -9,145 +9,190 @@ import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.view.MotionEvent
import android.view.ViewGroup
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import de.connect2x.trixnity.client.room import de.connect2x.trixnity.client.room
import de.connect2x.trixnity.client.room.message.MessageBuilder
import de.connect2x.trixnity.client.room.message.text
import de.connect2x.trixnity.client.room.toFlowList import de.connect2x.trixnity.client.room.toFlowList
import de.connect2x.trixnity.client.store.TimelineEvent import de.connect2x.trixnity.client.store.TimelineEvent
import de.connect2x.trixnity.client.user import de.connect2x.trixnity.client.store.eventId
import de.connect2x.trixnity.client.store.unsigned
import de.connect2x.trixnity.core.model.RoomId import de.connect2x.trixnity.core.model.RoomId
import de.connect2x.trixnity.core.model.events.ClientEvent import de.connect2x.trixnity.core.model.events.ClientEvent
import de.connect2x.trixnity.core.model.events.m.room.AvatarEventContent
import de.connect2x.trixnity.core.model.events.m.room.CanonicalAliasEventContent
import de.connect2x.trixnity.core.model.events.m.room.HistoryVisibilityEventContent
import de.connect2x.trixnity.core.model.events.m.room.MemberEventContent
import de.connect2x.trixnity.core.model.events.m.room.NameEventContent
import de.connect2x.trixnity.core.model.events.m.room.PowerLevelsEventContent
import de.connect2x.trixnity.core.model.events.m.room.RoomMessageEventContent import de.connect2x.trixnity.core.model.events.m.room.RoomMessageEventContent
import io.ktor.util.reflect.instanceOf import de.connect2x.trixnity.core.model.events.m.room.TopicEventContent
import io.github.rabehx.iconsax.Iconsax
import io.github.rabehx.iconsax.filled.ArrowRight
import io.github.rabehx.iconsax.filled.Send
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.safety.Safelist import org.jsoup.safety.Safelist
import ru.risdeveau.pixeldragon.client import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.ui.item.MXCImage
@OptIn(ExperimentalCoroutinesApi::class)
@Composable @Composable
fun Room(modifier: Modifier = Modifier, rid: String) { fun Room(modifier: Modifier = Modifier, rid: String) {
val roomId = remember(rid) { RoomId(rid) } val roomId = remember(rid) { RoomId(rid) }
val limit = remember { MutableStateFlow(50) } val limit = remember { MutableStateFlow(50) }
val eventsFlow = remember(roomId) { val eventsFlow = remember(roomId) {
client!!.room client!!.room
.getLastTimelineEvents(roomId) .getLastTimelineEvents(roomId)
.toFlowList(limit) .toFlowList(limit)
.flatMapLatest { flows ->
if (flows.isEmpty()) flowOf(emptyList())
else combine(flows) { it.toList() }
}
} }
val eventFlows by eventsFlow.collectAsState(initial = emptyList()) val events by eventsFlow.collectAsState(initial = emptyList())
val listState = rememberLazyListState() val listState = rememberLazyListState()
LazyColumn(modifier = modifier, state = listState, reverseLayout = true) { Column(modifier) {
items(eventFlows, key = { it.hashCode() }) { eventFlow -> var message by remember { mutableStateOf("") }
EventItem(eventFlow)
LazyColumn(Modifier.weight(1f), state = listState, reverseLayout = true) {
items(events, key = { it.eventId.full }) { event ->
EventItem(event)
}
if (events.isEmpty()) {
item {
Text("Empty room", modifier = Modifier.padding(16.dp))
}
}
} }
if (eventFlows.isEmpty()) { Row (Modifier.fillMaxWidth()) {
item { OutlinedTextField(
Text("Empty room", modifier = Modifier.padding(16.dp)) modifier = Modifier
} .padding(4.dp)
.weight(1f),
value = message,
onValueChange = { message = it.trim() },
)
IconButton(
enabled = message.isNotBlank(),
content = { Icon(Iconsax.Filled.ArrowRight, contentDescription = "Send") },
onClick = {
CoroutineScope(Dispatchers.IO).launch {
client!!.room.sendMessage(RoomId(rid)) {
text(message)
}
}
message = ""
}
)
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun EventItem(eventFlow: Flow<TimelineEvent>) { fun EventItem(event: TimelineEvent) {
val event by eventFlow.collectAsState(null) val content = event.content?.getOrNull()
event?.let { event -> Box(Modifier
Box(Modifier.fillMaxWidth()) { .fillMaxWidth()
/*when (event.type) { .heightIn(min = 24.dp)) {
"m.room.message" -> Column( when {
Modifier content == null -> Text("Not decrypted",
.fillMaxSize()
.then(
if (event.sender != ME!!.userId)
Modifier.padding(end = 16.dp)
else
Modifier.padding(start = 16.dp)
)
.padding(4.dp)
.background(
if (event.sender != ME?.userId)
MaterialTheme.colorScheme.surfaceContainer
else
MaterialTheme.colorScheme.primaryContainer
)
.clip(RoundedCornerShape(16.dp))
.padding(4.dp)
) {
Text(event.sender, maxLines = 1, fontWeight = FontWeight.Bold)
when (val msgtype = event.content.optString("msgtype", null)) {
"m.text" -> when (event.content.optString("format")) {
"org.matrix.custom.html" -> {
if (event.content.getString("body") == event.content.getString("formatted_body"))
Text(event.content.getString("body"))
HtmlRenderer(event.content.getString("formatted_body"))
}
else -> Text(event.content.getString("body"))
}
"m.image" ->
MXCImage(event.content.getString("url"), Modifier.fillMaxWidth(.9f))
null -> Text(event.content.toString(2))
else -> Text("Unknown type: $msgtype", color = MaterialTheme.colorScheme.error)
}
}
else -> Text(event.content?.getOrNull().toString(),
Modifier Modifier
.fillMaxHeight() .fillMaxHeight()
.padding(4.dp) .padding(4.dp)
.background(MaterialTheme.colorScheme.errorContainer) .background(MaterialTheme.colorScheme.errorContainer)
.padding(4.dp) .padding(4.dp)
)*/ )
Text(event.content?.getOrNull().toString())
content is RoomMessageEventContent -> {
val formatted = content.formattedBody
if (formatted != null) {
HtmlRenderer(
htmlContent = formatted,
plainText = content.body
)
} else {
Text(
text = content.body,
modifier = Modifier.padding(4.dp)
)
}
}
event.event is ClientEvent.RoomEvent.StateEvent -> Text(
when (content) {
is AvatarEventContent -> "Avatar changed"
is PowerLevelsEventContent -> "Permissions changed"
is MemberEventContent -> "Membership changed"
is CanonicalAliasEventContent -> "Canonical alias changed"
is TopicEventContent -> "Topic changed"
is NameEventContent -> "Name changed"
is HistoryVisibilityEventContent -> "History visibility changed"
else -> content.toString()
},
Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
else -> Text(content.toString(),
Modifier
.fillMaxHeight()
.padding(4.dp)
.background(MaterialTheme.colorScheme.errorContainer)
.padding(4.dp)
)
} }
} }
} }
@@ -163,205 +208,115 @@ private fun String.sanitizeHTML(): String {
"blockquote", "details", "summary", "blockquote", "details", "summary",
"em", "code", "div", "pre", "span", "img" "em", "code", "div", "pre", "span", "img"
) )
// .addAttributes("span", .addAttributes("a", "target", "href")
// "data-mx-bg-color", "data-mx-color", .addAttributes("img", "width", "height", "alt", "title", "src")
// "data-mx-spoiler", "data-mx-maths"
// )
.addAttributes("a",
"target", "href"
)
.addAttributes("img",
"width", "height", "alt", "title", "src"
)
.addAttributes("ol", "start") .addAttributes("ol", "start")
.addAttributes("code", "class") .addAttributes("code", "class")
.addAttributes("div", "data-mx-maths") .addAttributes("div", "data-mx-maths")
val doc = Jsoup.parse(this) val doc = Jsoup.parse(this)
doc.select("mx-reply").remove() doc.select("mx-reply").remove()
return Jsoup.clean(doc.body().toString(), matrixSafelist)
val out = Jsoup.clean(doc.body().toString(), matrixSafelist)
return out
} }
@SuppressLint("ClickableViewAccessibility")
@Composable @Composable
fun HtmlRenderer( fun HtmlRenderer(
htmlContent: String, htmlContent: String,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
plainText: String = ""
) { ) {
val context = LocalContext.current val context = LocalContext.current
var isLoaded by remember(htmlContent) { mutableStateOf(false) }
val webView = remember { WebView(context).apply { val webView = remember { WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
settings.apply { settings.apply {
javaScriptEnabled = false javaScriptEnabled = false
loadWithOverviewMode = true loadWithOverviewMode = true
useWideViewPort = true useWideViewPort = true
layoutAlgorithm = WebSettings.LayoutAlgorithm.TEXT_AUTOSIZING
} }
isVerticalScrollBarEnabled = false isVerticalScrollBarEnabled = false
setBackgroundColor(Color.Transparent.toArgb()) setBackgroundColor(Color.Transparent.toArgb())
setOnTouchListener { _, e -> e.action == MotionEvent.ACTION_MOVE }
} } } }
val css = """ val css = """
body { body {
font-family: -apple-system, sans-serif; font-family: -apple-system, sans-serif;
font-size: 16px; font-size: 16px;
line-height: 1.6; line-height: 1.5;
color: ${colorToCss(MaterialTheme.colorScheme.onBackground)}; color: ${colorToCss(MaterialTheme.colorScheme.onBackground)};
margin: 0; margin: 0; padding: 2px 0;
padding: 0; word-wrap: break-word;
}
h1, h2, h3, h4, h5, h6 {
font-weight: bold;
padding: 0;
}
h1 { font-size: 24px; }
h2 { font-size: 22px; }
h3 { font-size: 20px; }
h4 { font-size: 18px; }
h5 { font-size: 16px; }
h6 { font-size: 14px; }
a {
color: ${colorToCss(MaterialTheme.colorScheme.primary)};
text-decoration: none;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 12px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
th, td {
border: 1px solid ${colorToCss(MaterialTheme.colorScheme.outline)};
padding: 12px;
text-align: left;
}
th {
background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)};
font-weight: bold;
}
blockquote {
border-left: 4px solid ${colorToCss(MaterialTheme.colorScheme.primary)};
padding-left: 16px;
margin-left: 0;
color: ${colorToCss(MaterialTheme.colorScheme.onSurfaceVariant)};
}
pre {
background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)};
padding: 16px;
overflow: auto;
border-radius: 4px;
}
code {
font-family: monospace;
background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)};
padding: 2px 4px;
border-radius: 4px;
}
hr {
border: 0;
height: 1px;
background-color: ${colorToCss(MaterialTheme.colorScheme.outline)};
margin: 24px 0;
}
ul, ol {
padding-left: 24px;
margin: 12px 0;
}
li {
margin-bottom: 8px;
}
details {
margin: 12px 0;
}
summary {
font-weight: bold;
cursor: pointer;
}
@media (prefers-color-scheme: dark) {
:root {
--border-color: ${colorToCss(MaterialTheme.colorScheme.outline)};
}
} }
a { color: ${colorToCss(MaterialTheme.colorScheme.primary)}; text-decoration: none; }
img { max-width: 100%; height: auto; display: block; margin: 8px 0; }
table { width: 100%; border-collapse: collapse; margin: 8px 0; }
th, td { border: 1px solid ${colorToCss(MaterialTheme.colorScheme.outline)}; padding: 8px; text-align: left; }
blockquote { border-left: 4px solid ${colorToCss(MaterialTheme.colorScheme.primary)}; padding-left: 12px; margin: 8px 0; }
pre, code { background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)}; padding: 2px 4px; border-radius: 4px; }
""".trimIndent() """.trimIndent()
LaunchedEffect(htmlContent) { LaunchedEffect(htmlContent) {
webView.loadDataWithBaseURL( isLoaded = false
null, webView.webViewClient = object : WebViewClient() {
wrapHtml(htmlContent, css), override fun onPageFinished(view: WebView?, url: String?) {
"text/html", isLoaded = true
"UTF-8", view?.requestLayout()
null }
) override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
return try {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(request.url.toString())))
true
} catch (e: Exception) { false }
}
}
webView.loadDataWithBaseURL(null, wrapHtml(htmlContent, css), "text/html", "UTF-8", null)
} }
DisposableEffect(webView) { DisposableEffect(webView) {
onDispose { onDispose { /* webView.destroy() */ }
webView.destroy()
}
} }
AndroidView( Box(
factory = { webView }, modifier = modifier
modifier = modifier, .fillMaxWidth()
update = { view -> .heightIn(min = 24.dp)
view.webViewClient = SafeWebViewClient(context) .animateContentSize()
} ) {
) if (!isLoaded) {
} Text(
text = plainText.ifBlank { " " },
private class SafeWebViewClient( modifier = Modifier
private val context: Context .padding(vertical = 2.dp)
) : WebViewClient() { .alpha(0.6f),
override fun shouldOverrideUrlLoading( style = MaterialTheme.typography.bodyMedium
view: WebView,
request: WebResourceRequest
): Boolean {
val url = request.url.toString()
try {
// Открываем ссылки во внешнем браузере
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(url))
) )
return true
} catch (e: Exception) {
// Обработка ошибок открытия ссылки
return false
} }
AndroidView(
factory = { webView },
modifier = Modifier
.fillMaxWidth()
.alpha(if (isLoaded) 1f else 0f)
)
} }
} }
private fun wrapHtml(content: String, css: String): String { private fun wrapHtml(content: String, css: String): String {
return """ return """
<!DOCTYPE html> <!DOCTYPE html><html><head>
<html> <meta name="viewport" content="width=device-width, initial-scale=1">
<head> <style>$css</style></head><body>${content.sanitizeHTML()}</body></html>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
$css
</style>
</head>
<body>
${content.sanitizeHTML()}
</body>
</html>
""".trimIndent() """.trimIndent()
} }
private fun colorToCss(color: Color): String { private fun colorToCss(color: Color): String {
val argb = color.toArgb() return String.format("#%06X", 0xFFFFFF and color.toArgb())
return String.format("#%06X", 0xFFFFFF and argb)
} }
@OptIn(ExperimentalCoroutinesApi::class)
fun Flow<Flow<Flow<TimelineEvent>>?>.flattenTimelineEvents(): Flow<TimelineEvent> = this
.filterNotNull()
.flatMapConcat { middleFlow ->
middleFlow.flatMapConcat { innerFlow ->
innerFlow
}
}
+2
View File
@@ -1,6 +1,7 @@
[versions] [versions]
agp = "9.1.0" agp = "9.1.0"
coil = "3.4.0" coil = "3.4.0"
iconsaxCompose = "0.0.5"
jsoup = "1.22.1" jsoup = "1.22.1"
kotlin = "2.2.21" kotlin = "2.2.21"
coreKtx = "1.15.0" coreKtx = "1.15.0"
@@ -29,6 +30,7 @@ androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
iconsax-compose = { module = "io.github.rabehx:iconsax-compose", version.ref = "iconsaxCompose" }
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }