diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b1ea9a9..28af480 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,7 @@ /* - * Created by sweetbread on 22.02.2025, 15:45 + * Created by sweetbread * Copyright (c) 2025. All rights reserved. - * Last modified 22.02.2025, 14:56 + * Last modified 03.03.2025, 16:46 */ plugins { @@ -77,7 +77,14 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.room.ktx) ksp(libs.androidx.room.compiler) - + + // Navigation Compose + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.navigation.fragment) + implementation(libs.androidx.navigation.ui) + implementation(libs.androidx.navigation.dynamic.features.fragment) + androidTestImplementation(libs.androidx.navigation.testing) + // Others implementation(libs.splitties.base) // Syntax sugar } \ No newline at end of file diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/api/Event.kt b/app/src/main/java/ru/risdeveau/pixeldragon/api/Event.kt new file mode 100755 index 0000000..441a01d --- /dev/null +++ b/app/src/main/java/ru/risdeveau/pixeldragon/api/Event.kt @@ -0,0 +1,56 @@ +/* + * Created by sweetbread + * Copyright (c) 2025. All rights reserved. + * Last modified 03.03.2025, 20:21 + */ + +package ru.risdeveau.pixeldragon.api + +import io.ktor.client.request.bearerAuth +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.statement.bodyAsText +import org.json.JSONObject +import ru.risdeveau.pixeldragon.baseUrl +import ru.risdeveau.pixeldragon.client +import ru.risdeveau.pixeldragon.token + +class Event ( + val id: String, + val rid: String, + val sender: String, + val type: String, + val content: JSONObject +) { + constructor(json: JSONObject) : this( + json.getString("event_id"), + json.getString("room_id"), + json.getString("sender"), + json.getString("type"), + json.getJSONObject("content") + ) +} + +data class EventsAround ( + val base: Event, + val before: List, + val after: List +) + +suspend fun getEventsAround(room: String, event: String): EventsAround { + val r = client.get("$baseUrl/rooms/$room/context/$event") { + bearerAuth(token) + parameter("limit", "50") + } + val json = JSONObject(r.bodyAsText()) + + return EventsAround( + Event(json.getJSONObject("event")), + if (json.has("events_before")) json.getJSONArray("events_before").let { + List(it.length()) { i -> Event(it.getJSONObject(i))}.reversed() + } else listOf(), + if (json.has("events_after")) json.getJSONArray("events_after").let { + List(it.length()) { i -> Event(it.getJSONObject(i))} + } else listOf() + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/api/Room.kt b/app/src/main/java/ru/risdeveau/pixeldragon/api/Room.kt index f778b7f..60bb0ca 100755 --- a/app/src/main/java/ru/risdeveau/pixeldragon/api/Room.kt +++ b/app/src/main/java/ru/risdeveau/pixeldragon/api/Room.kt @@ -1,7 +1,7 @@ /* * Created by sweetbread * Copyright (c) 2025. All rights reserved. - * Last modified 22.02.2025, 19:52 + * Last modified 03.03.2025, 18:28 */ package ru.risdeveau.pixeldragon.api @@ -54,4 +54,10 @@ private suspend fun getState(rid: String, state: String, key: String): String? { val json = JSONObject(r.bodyAsText()) if (!json.has(key)) return null return json.getString(key) -} \ No newline at end of file +} + +suspend fun getAccountData(user: String, room: String, state: String): JSONObject? { + val r = client.get("$baseUrl/user/$user/rooms/$room/account_data/$state") { bearerAuth(token) } + if (r.status != HttpStatusCode.OK) return null + return JSONObject(r.bodyAsText()) +} diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/ui/activity/MainActivity.kt b/app/src/main/java/ru/risdeveau/pixeldragon/ui/activity/MainActivity.kt index deeffab..4b50ac9 100755 --- a/app/src/main/java/ru/risdeveau/pixeldragon/ui/activity/MainActivity.kt +++ b/app/src/main/java/ru/risdeveau/pixeldragon/ui/activity/MainActivity.kt @@ -1,7 +1,7 @@ /* * Created by sweetbread * Copyright (c) 2025. All rights reserved. - * Last modified 03.03.2025, 15:53 + * Last modified 03.03.2025, 20:22 */ package ru.risdeveau.pixeldragon.ui.activity @@ -10,52 +10,23 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -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.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults.topAppBarColors -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.unit.dp -import coil3.compose.SubcomposeAsyncImage -import coil3.network.NetworkHeaders -import coil3.network.httpHeaders -import coil3.request.ImageRequest -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import ru.risdeveau.pixeldragon.api.getRoom -import ru.risdeveau.pixeldragon.api.getRooms -import ru.risdeveau.pixeldragon.api.mxcToUrl -import ru.risdeveau.pixeldragon.db.Room -import ru.risdeveau.pixeldragon.token +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import ru.risdeveau.pixeldragon.ui.layout.Room +import ru.risdeveau.pixeldragon.ui.layout.RoomList import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme -import splitties.init.appCtx class MainActivity : ComponentActivity() { @OptIn(ExperimentalMaterial3Api::class) @@ -79,123 +50,24 @@ class MainActivity : ComponentActivity() { ) }, ) { innerPadding -> - RoomList(Modifier.padding(innerPadding)) + val navController = rememberNavController() + + NavHost(navController = navController, startDestination = "rooms") { + composable("rooms") { RoomList(Modifier.padding(innerPadding), navController) } + composable( + "room/{rid}", + arguments = listOf(navArgument("rid") { type = NavType.StringType }) + ) { navBackStackEntry -> + Room(Modifier.padding(innerPadding).fillMaxSize(), navBackStackEntry.arguments!!.getString("rid")!!) + } + composable( + "space/{rid}", + arguments = listOf(navArgument("rid") { type = NavType.StringType }) + ) { navBackStackEntry -> + Text(modifier = Modifier.padding(innerPadding), text = "Not implemented") } + } } } } } } - -@Composable -fun RoomList(modifier: Modifier = Modifier) { - var list by remember { mutableStateOf(listOf()) } - val coroutineScope = rememberCoroutineScope() - val listState = rememberLazyListState() - -// if (itemState.scrollToTop) { -// LaunchedEffect(coroutineScope) { -// Log.e("TAG", "TopCoinsScreen: scrollToTop" ) -// listState.scrollToItem(0) -// } -// } - - LaunchedEffect(true) { - coroutineScope.launch { - withContext(Dispatchers.IO) { - list = getRooms() - } - } - } - - LazyColumn(modifier = modifier, state = listState) { - items(list) { rid -> - RoomItem(rid = rid) - } - - item { - if (list.isEmpty()) { - Text("You have no rooms") - } - } - } -} - -@Composable -fun RoomItem(modifier: Modifier = Modifier, rid: String) { - var room by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - - LaunchedEffect(true) { - scope.launch { - withContext(Dispatchers.IO) { - room = getRoom(rid) - } - } - } - - if (room != null) { - Row( - modifier - .padding(8.dp) - .height((52+8*2).dp) - .fillMaxWidth() - .background(MaterialTheme.colorScheme.background, RoundedCornerShape(16.dp)) - .padding(8.dp) - ) { - SubcomposeAsyncImage( - modifier = Modifier - .padding(end = 4.dp) - .height(52.dp) - .width(52.dp) - .let { - if (room!!.type == "m.space") - it.clip(RoundedCornerShape(12.dp)) - else - it.clip(CircleShape) - }, - model = ImageRequest.Builder(appCtx) - .data(mxcToUrl(room!!.avatarUrl ?: "")) - .httpHeaders( - NetworkHeaders.Builder() - .set("Authorization", "Bearer $token") - .set("Cache-Control", "max-age=86400") - .build() - ) - .build(), - contentDescription = room!!.roomId, - contentScale = ContentScale.Crop, - loading = { - CircularProgressIndicator() - } - ) - Column { - Text( - room!!.name ?: "Unnamed", - maxLines = 1, - color = MaterialTheme.colorScheme.primary, - fontSize = MaterialTheme.typography.titleLarge.fontSize - ) - } - } - } else { - Row( - modifier - .padding(8.dp) - .height(80.dp) - .fillMaxWidth() - .background(MaterialTheme.colorScheme.background, RoundedCornerShape(16.dp)) - .padding(8.dp) - ) { - LinearProgressIndicator(Modifier.fillMaxWidth()) - } - } -} - -//@Preview(showBackground = true) -//@Composable -//fun GreetingPreview() { -// PixelDragonTheme { -// RoomItem() -// } -//} - diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt b/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt new file mode 100755 index 0000000..263edd7 --- /dev/null +++ b/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt @@ -0,0 +1,90 @@ +/* + * Created by sweetbread + * Copyright (c) 2025. All rights reserved. + * Last modified 03.03.2025, 21:30 + */ + +package ru.risdeveau.pixeldragon.ui.layout + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import ru.risdeveau.pixeldragon.api.Event +import ru.risdeveau.pixeldragon.api.getAccountData +import ru.risdeveau.pixeldragon.api.getEventsAround +import ru.risdeveau.pixeldragon.api.getMe + +@Composable +fun Room(modifier: Modifier = Modifier, rid: String) { + var eventsId by remember { mutableStateOf(listOf()) } + val coroutineScope = rememberCoroutineScope() + val listState = rememberLazyListState() + + LaunchedEffect(true) { + coroutineScope.launch { + withContext(Dispatchers.IO) { + val readMark = getAccountData(getMe()!!.userId, rid, "m.fully_read") + val eventsAround = getEventsAround(rid, readMark!!.getString("event_id")) //FIXME: Null check + eventsId = eventsAround.let { + it.before + listOf(it.base) + it.after + } + } + } + } + + LazyColumn(modifier = modifier, state = listState, reverseLayout = true) { + items(eventsId.reversed()) { event -> + EventItem(event) + } + + item { + if (eventsId.isEmpty()) { + Text("Empty room") + } + } + } +} + +@Composable +fun EventItem(event: Event) { + Box (Modifier.fillMaxWidth()) { + when (event.type) { + "m.room.message" -> Column( + Modifier + .align(Alignment.CenterStart) + .padding(4.dp) + .background( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(16.dp) + ) + .padding(4.dp) + ) { + Text(event.sender, maxLines = 1, fontWeight = FontWeight.Bold) + Text(event.content.getString("body")) + } + else -> Text(event.type, Modifier.padding(4.dp).background(MaterialTheme.colorScheme.errorContainer).padding(4.dp)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Rooms.kt b/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Rooms.kt new file mode 100755 index 0000000..b4a6daa --- /dev/null +++ b/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Rooms.kt @@ -0,0 +1,161 @@ +/* + * Created by sweetbread + * Copyright (c) 2025. All rights reserved. + * Last modified 03.03.2025, 20:22 + */ + +package ru.risdeveau.pixeldragon.ui.layout + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import coil3.compose.SubcomposeAsyncImage +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.request.ImageRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import ru.risdeveau.pixeldragon.api.getRoom +import ru.risdeveau.pixeldragon.api.getRooms +import ru.risdeveau.pixeldragon.api.mxcToUrl +import ru.risdeveau.pixeldragon.db.Room +import ru.risdeveau.pixeldragon.token +import splitties.init.appCtx + +@Composable +fun RoomList(modifier: Modifier = Modifier, navController: NavController) { + var list by remember { mutableStateOf(listOf()) } + val coroutineScope = rememberCoroutineScope() + val listState = rememberLazyListState() + +// if (itemState.scrollToTop) { +// LaunchedEffect(coroutineScope) { +// Log.e("TAG", "TopCoinsScreen: scrollToTop" ) +// listState.scrollToItem(0) +// } +// } + + LaunchedEffect(true) { + coroutineScope.launch { + withContext(Dispatchers.IO) { + list = getRooms() + } + } + } + + LazyColumn(modifier = modifier, state = listState) { + items(list) { rid -> + RoomItem(rid = rid, navController = navController ) + } + + item { + if (list.isEmpty()) { + Text("You have no rooms") + } + } + } +} + +@Composable +fun RoomItem(modifier: Modifier = Modifier, rid: String, navController: NavController) { + var room by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + LaunchedEffect(true) { + scope.launch { + withContext(Dispatchers.IO) { + room = getRoom(rid) + } + } + } + + if (room != null) { + Row( + modifier + .padding(8.dp) + .height((52 + 8 * 2).dp) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background, RoundedCornerShape(16.dp)) + .padding(8.dp) + .clickable { + if (room!!.type == "m.space") + navController.navigate("space/$rid") + else + navController.navigate("room/$rid") + } + ) { + SubcomposeAsyncImage( + modifier = Modifier + .padding(end = 4.dp) + .height(52.dp) + .width(52.dp) + .let { + if (room!!.type == "m.space") + it.clip(RoundedCornerShape(12.dp)) + else + it.clip(CircleShape) + }, + model = ImageRequest.Builder(appCtx) + .data(mxcToUrl(room!!.avatarUrl ?: "")) + .httpHeaders( + NetworkHeaders.Builder() + .set("Authorization", "Bearer $token") + .set("Cache-Control", "max-age=86400") + .build() + ) + .build(), + contentDescription = room!!.roomId, + contentScale = ContentScale.Crop, + loading = { + CircularProgressIndicator() + } + ) + Column { + Text( + room!!.name ?: "Unnamed", + maxLines = 1, + color = MaterialTheme.colorScheme.primary, + fontSize = MaterialTheme.typography.titleLarge.fontSize + ) + } + } + } else { + Row( + modifier + .padding(8.dp) + .height(80.dp) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background, RoundedCornerShape(16.dp)) + .padding(8.dp) + ) { + LinearProgressIndicator(Modifier.fillMaxWidth()) + } + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4d46867..095d61e 100755 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,16 +6,23 @@ coreKtx = "1.15.0" junit = "4.13.2" junitVersion = "1.2.1" espressoCore = "3.6.1" +kotlinxSerializationJson = "1.7.3" ktor = "3.1.0" lifecycleRuntimeKtx = "2.8.7" activityCompose = "1.10.0" composeBom = "2025.02.00" +navigationCompose = "2.8.8" room = "2.6.1" splittiesFunPackAndroidBase = "3.0.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "room" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +androidx-navigation-dynamic-features-fragment = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "navigationCompose" } +androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment", version.ref = "navigationCompose" } +androidx-navigation-testing = { module = "androidx.navigation:navigation-testing", version.ref = "navigationCompose" } +androidx-navigation-ui = { module = "androidx.navigation:navigation-ui", version.ref = "navigationCompose" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } @@ -34,6 +41,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }