diff --git a/README.md b/README.md index f7bd7eb..c06b64d 100644 --- a/README.md +++ b/README.md @@ -10,4 +10,6 @@ and from [here](https://nightly.link/aeoliux/Violet/workflows/build/main/release - Lucky number - Timetable - Attendance -- Agenda \ No newline at end of file +- Agenda +- School notices +- Basic message receiving (links and other HTML stuff are not parsed **for now**) \ No newline at end of file diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 2697e97..5186ce4 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -63,6 +63,7 @@ kotlin { implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.kotlinx.serialization.json) implementation(libs.ktor.client.content.negotiation) + implementation(libs.ksoup) // both implementation(libs.kotlinx.datetime) @@ -81,7 +82,7 @@ android { defaultConfig { applicationId = "com.github.aeoliux.violet" minSdk = 32 - targetSdk = 34 + targetSdk = 35 versionCode = 1 versionName = "1.0" } diff --git a/composeApp/src/androidMain/kotlin/com/github/aeoliux/violet/Keychain.android.kt b/composeApp/src/androidMain/kotlin/com/github/aeoliux/violet/Keychain.android.kt index 7d2298e..bd977d1 100644 --- a/composeApp/src/androidMain/kotlin/com/github/aeoliux/violet/Keychain.android.kt +++ b/composeApp/src/androidMain/kotlin/com/github/aeoliux/violet/Keychain.android.kt @@ -41,7 +41,6 @@ actual class Keychain(private val context: Context) { @OptIn(ExperimentalEncodingApi::class) actual fun savePass(password: String) { - println(password) val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.ENCRYPT_MODE, key) val iv = cipher.iv @@ -73,7 +72,6 @@ actual class Keychain(private val context: Context) { cipher.init(Cipher.DECRYPT_MODE, key, gcmParameterSpec) val final = String(cipher.doFinal(cipherText), Charsets.UTF_8) - println(final) return final } diff --git a/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/api/ApiClient.kt b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/api/ApiClient.kt index 0450572..b3dad4c 100644 --- a/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/api/ApiClient.kt +++ b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/api/ApiClient.kt @@ -33,6 +33,7 @@ import io.ktor.http.parameters import io.ktor.serialization.kotlinx.json.json import kotlinx.datetime.Clock import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.isoDayNumber @@ -45,7 +46,7 @@ class ApiClient { var colors = LinkedHashMap() var classrooms = LinkedHashMap() - private val client = HttpClient { + val client = HttpClient { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true diff --git a/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/api/scraping/messages/AllMessages.kt b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/api/scraping/messages/AllMessages.kt new file mode 100644 index 0000000..e50179d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/api/scraping/messages/AllMessages.kt @@ -0,0 +1,66 @@ +package com.github.aeoliux.violet.api.scraping.messages + +import com.fleeksoft.ksoup.Ksoup +import com.github.aeoliux.violet.api.ApiClient +import com.github.aeoliux.violet.api.localDateTimeFormat +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import kotlinx.datetime.LocalDateTime + +data class MessageLabel( + val url: String, + val sender: String, + val topic: String, + val sentAt: LocalDateTime?, + val hasAttachment: Boolean, +) + +typealias MessagesList = LinkedHashMap> +//fun MessagesList.init() { +// this[MessageCategories.Received] = emptyList() +// this[MessageCategories.Sent] = emptyList() +// this[MessageCategories.Bin] = emptyList() +//} + +suspend fun ApiClient.getMessages(): MessagesList { + val categories = listOf( + MessageCategories.Received, + MessageCategories.Sent, + MessageCategories.Bin + ) + + return categories.fold(MessagesList()) { acc, category -> + val url = "https://synergia.librus.pl/wiadomosci/${category.categoryId}" + val body = client.get(url).bodyAsText() + val soup = Ksoup.parse(body) + + val labels = soup.select( + "#formWiadomosci > div > div > table > tbody > tr > td:nth-child(2) > table:nth-child(2) > tbody > tr" + ).fold(emptyList()) { list, label -> + if (label.getAllElements().size > 2) { + val hasAttachment = label.select("td:nth-child(2)").html().startsWith(" a") + val messageUrl = senderData.attr("href") + val sender = senderData.html() + + val topic = label.select("td:nth-child(4) > a").html() + + val messageLabel = MessageLabel( + url = messageUrl, + sender = sender, + topic = topic, + sentAt = LocalDateTime.parse(date, localDateTimeFormat), + hasAttachment = hasAttachment + ) + + list.plus(messageLabel) + } else + list + } + + acc[category] = labels + acc + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/api/scraping/messages/GetMessage.kt b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/api/scraping/messages/GetMessage.kt new file mode 100644 index 0000000..f49bd3c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/api/scraping/messages/GetMessage.kt @@ -0,0 +1,40 @@ +package com.github.aeoliux.violet.api.scraping.messages + +import com.fleeksoft.ksoup.Ksoup +import com.github.aeoliux.violet.api.ApiClient +import com.github.aeoliux.violet.api.localDateTimeFormat +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import kotlinx.datetime.LocalDateTime + + +suspend fun ApiClient.getMessage(url: String): Message { + val url = "https://synergia.librus.pl$url" + val soup = Ksoup.parse( + client.get(url).bodyAsText() + ) + + val messageData = soup.select("#formWiadomosci > div > div > table > tbody > tr > td:nth-child(2)") + val meta = messageData.select("table:nth-child(2) > tbody > tr > td:nth-child(2)") + val content = messageData.select("div").html().replace("
", "") + + var topic = "" + var date = "" + var sender: String? = null + if (meta.size == 2) { + topic = meta[0].html() + date = meta[1].html() + } else { + sender = meta[0].html() + topic = meta[1].html() + date = meta[2].html() + } + + return Message( + sender = sender, + date = LocalDateTime.parse(date, localDateTimeFormat), + topic = topic, + content = content, + attachments = emptyList() + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/api/scraping/messages/Message.kt b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/api/scraping/messages/Message.kt new file mode 100644 index 0000000..290629c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/api/scraping/messages/Message.kt @@ -0,0 +1,11 @@ +package com.github.aeoliux.violet.api.scraping.messages + +import kotlinx.datetime.LocalDateTime + +data class Message( + val sender: String?, + val date: LocalDateTime, + val topic: String, + val content: String, + val attachments: List +) diff --git a/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/api/scraping/messages/MessageCategories.kt b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/api/scraping/messages/MessageCategories.kt new file mode 100644 index 0000000..ec72b2c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/api/scraping/messages/MessageCategories.kt @@ -0,0 +1,15 @@ +package com.github.aeoliux.violet.api.scraping.messages + +import kotlin.enums.EnumEntries + +enum class MessageCategories(val categoryId: Int) { + Received(5), + Sent(6), + Bin(7); + + companion object { + fun fromInt(n: Int): MessageCategories { + return MessageCategories.entries.first { n == it.categoryId } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/appState/FetchData.kt b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/appState/FetchData.kt index 18c98e6..02bb2c4 100644 --- a/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/appState/FetchData.kt +++ b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/appState/FetchData.kt @@ -1,10 +1,12 @@ package com.github.aeoliux.violet.app.appState +import com.github.aeoliux.violet.api.scraping.messages.getMessages import com.github.aeoliux.violet.app.storage.Database import com.github.aeoliux.violet.app.storage.insertAgenda import com.github.aeoliux.violet.app.storage.insertAttendances import com.github.aeoliux.violet.app.storage.insertGrades import com.github.aeoliux.violet.app.storage.insertLessons +import com.github.aeoliux.violet.app.storage.insertMessageIds import com.github.aeoliux.violet.app.storage.insertSchoolNotices import com.github.aeoliux.violet.app.storage.selectClassInfo import com.github.aeoliux.violet.app.storage.setAboutMe @@ -68,6 +70,12 @@ suspend fun AppState.fetchData(login: String? = null, password: String? = null) ) } + setFetchStatus("Did you get a message?") { + Database.insertMessageIds( + client.value.getMessages() + ) + } + statusMessage.value = "Saving some data and refreshing the view..." semester.value = classInfo.semester databaseUpdated.value = !databaseUpdated.value diff --git a/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/appState/FetchMessage.kt b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/appState/FetchMessage.kt new file mode 100644 index 0000000..b4ce4b2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/appState/FetchMessage.kt @@ -0,0 +1,17 @@ +package com.github.aeoliux.violet.app.appState + +import com.github.aeoliux.violet.api.scraping.messages.Message +import com.github.aeoliux.violet.api.scraping.messages.getMessage + +suspend fun AppState.fetchMessage(url: String): Message? { + this.logIn() + return try { + this.client.value.getMessage(url) + } catch (e: Exception) { + this.alertTitle.value = "Fetching message error" + this.alertMessage.value = e.toString() + this.showAlert.value = true + + null + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/appState/LogIn.kt b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/appState/LogIn.kt index 978b5a8..8e5dece 100644 --- a/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/appState/LogIn.kt +++ b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/appState/LogIn.kt @@ -9,7 +9,6 @@ suspend fun AppState.logIn(login: String? = null, password: String? = null) { if (login == null || password == null) { val usernameAndPass = keychain.getPass() ?: throw IllegalStateException("Username and password not found in keychain") - println(usernameAndPass) val indexOfSpace = usernameAndPass.indexOfFirst { it == ' ' } login = usernameAndPass.slice(0.. ComboBox( + options: List, + values: List, + selectedIndex: Int = 0, + onChange: (opt: T) -> Unit = {} +) { + var selectedIndex by remember { mutableStateOf(selectedIndex) } + + Card( + Modifier.fillMaxWidth() + .wrapContentHeight() + .padding(start = 10.dp, end = 10.dp, top = 2.dp, bottom = 2.dp) + ) { + ExpandableList( + header = { Text( + text = options[selectedIndex], + modifier = Modifier.padding(15.dp) + ) }, + ) { + options.forEachIndexed { index, it -> + Row( + Modifier + .fillMaxSize() + .clickable { + selectedIndex = index + onChange(values[selectedIndex]) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier + .padding(15.dp), + text = it + ) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/main/MainView.kt b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/main/MainView.kt index 1618ebe..4d1d82f 100644 --- a/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/main/MainView.kt +++ b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/main/MainView.kt @@ -114,6 +114,7 @@ fun MainView() { IconButton({ vm.showOrHideSettings() }) { Icon( imageVector = Icons.Filled.Settings, + tint = MaterialTheme.colorScheme.onBackground, contentDescription = "Show/hide settings" ) } diff --git a/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/main/MainViewModel.kt b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/main/MainViewModel.kt index ca3d120..caa3c33 100644 --- a/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/main/MainViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/main/MainViewModel.kt @@ -3,6 +3,7 @@ package com.github.aeoliux.violet.app.main import androidx.compose.runtime.Composable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.aeoliux.violet.api.scraping.messages.MessageLabel import com.github.aeoliux.violet.app.agenda.AgendaView import com.github.aeoliux.violet.app.appState.AppState import com.github.aeoliux.violet.app.appState.fetchData @@ -10,6 +11,7 @@ import com.github.aeoliux.violet.app.appState.runBackgroundTask import com.github.aeoliux.violet.app.attendance.AttendanceView import com.github.aeoliux.violet.app.grades.GradesView import com.github.aeoliux.violet.app.home.HomeView +import com.github.aeoliux.violet.app.messages.MessagesView import com.github.aeoliux.violet.app.schoolNotices.SchoolNoticesView import com.github.aeoliux.violet.app.timetable.TimetableView import kotlinx.coroutines.flow.MutableSharedFlow @@ -29,7 +31,8 @@ class MainViewModel( TabItem("Timetable") { TimetableView() }, TabItem("Attendance") { AttendanceView() }, TabItem("Agenda") { AgendaView() }, - TabItem("School notices") { SchoolNoticesView() } + TabItem("School notices") { SchoolNoticesView() }, + TabItem("Messages") { MessagesView() } ) private var _isRefreshing = MutableStateFlow(false) diff --git a/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/messages/MessageView.kt b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/messages/MessageView.kt new file mode 100644 index 0000000..ca109e8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/messages/MessageView.kt @@ -0,0 +1,106 @@ +package com.github.aeoliux.violet.app.messages + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.github.aeoliux.violet.app.appState.AppState +import com.github.aeoliux.violet.app.appState.LocalAppState +import com.github.aeoliux.violet.app.appState.fetchMessage + +@Composable +fun MessageView( + url: String, + appState: AppState = LocalAppState.current, + vm: MessageViewModel = viewModel { + MessageViewModel({ + appState.fetchMessage(it) + }) + }, + onClose: () -> Unit +) { + val message by vm.message.collectAsState() + + LaunchedEffect(Unit) { + vm.fetchMessage(url) + } + + Row(Modifier.fillMaxWidth()) { + IconButton( + onClick = { + vm.closeMessage() + onClose() + }, + modifier = Modifier.padding(start = 15.dp) + ) { + Icon( + imageVector = Icons.Default.ArrowBack, + tint = MaterialTheme.colorScheme.onBackground, + contentDescription = "Go back" + ) + } + } + + Divider(Modifier.padding(15.dp)) + + Column(Modifier.padding(start = 15.dp, end = 15.dp)) { + message?.let { + val labels = listOf("From", "Topic", "Sent at") + val vals = listOf(it.sender, it.topic, it.date) + + labels.forEachIndexed { index, label -> + val value = vals[index] + value?.let { + Row { + Text( + fontWeight = FontWeight.SemiBold, + text = label, + color = MaterialTheme.colorScheme.onBackground + ) + } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + Text( + text = "$it", + textAlign = TextAlign.End, + color = MaterialTheme.colorScheme.onBackground + ) + } + + Divider(Modifier.padding(top = 15.dp, bottom = 15.dp)) + } + } + + Card { + Column(Modifier.fillMaxWidth().padding(20.dp)) { + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Justify, + text = it.content + ) + } + } + }?: CircularProgressIndicator() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/messages/MessageViewModel.kt b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/messages/MessageViewModel.kt new file mode 100644 index 0000000..11de0a1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/messages/MessageViewModel.kt @@ -0,0 +1,30 @@ +package com.github.aeoliux.violet.app.messages + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.aeoliux.violet.api.scraping.messages.Message +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class MessageViewModel( + private val fetch: suspend (url: String) -> Message? +): ViewModel() { + private var _message = MutableStateFlow(null) + val message get() = _message.asStateFlow() + + fun fetchMessage(url: String) { + viewModelScope.launch { + _message.update { + fetch(url) + } + } + } + + fun closeMessage() { + viewModelScope.launch { + _message.update { null } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/messages/MessagesView.kt b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/messages/MessagesView.kt new file mode 100644 index 0000000..9ea8e69 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/messages/MessagesView.kt @@ -0,0 +1,86 @@ +package com.github.aeoliux.violet.app.messages + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.github.aeoliux.violet.api.scraping.messages.Message +import com.github.aeoliux.violet.api.scraping.messages.MessageCategories +import com.github.aeoliux.violet.api.scraping.messages.getMessage +import com.github.aeoliux.violet.app.appState.AppState +import com.github.aeoliux.violet.app.appState.LocalAppState +import com.github.aeoliux.violet.app.appState.logIn +import com.github.aeoliux.violet.app.components.ComboBox + +@Composable +fun MessagesView( + vm: MessagesViewModel = viewModel { MessagesViewModel() } +) { + val appState: AppState = LocalAppState.current + + val messages by vm.messages.collectAsState() + val selectedMessage by vm.selectedMessage.collectAsState() + val selectedCategory by vm.messagesCategory.collectAsState() + val categories = listOf(MessageCategories.Received, MessageCategories.Sent, MessageCategories.Bin) + + LaunchedEffect(appState.databaseUpdated.value) { + vm.selectMessages() + } + + if (selectedMessage != null) { + selectedMessage?.let { + MessageView( + url = it, + onClose = { vm.selectMessage(null) } + ) + } + } else { + ComboBox( + options = listOf("Received", "Sent", "Bin"), + values = listOf(MessageCategories.Received, MessageCategories.Sent, MessageCategories.Bin), + selectedIndex = categories.indexOf(selectedCategory) + ) { + vm.selectCategory(it) + } + + Divider(Modifier.padding(10.dp).fillMaxWidth()) + + messages[selectedCategory]?.forEach { + Card( + Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(start = 10.dp, end = 10.dp, top = 2.dp, bottom = 2.dp) + .clickable { + vm.selectMessage(it.url) + } + ) { + Column( + Modifier.padding(10.dp), + verticalArrangement = Arrangement.Center + ) { + Text(it.sender) + Text(it.topic) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/messages/MessagesViewModel.kt b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/messages/MessagesViewModel.kt new file mode 100644 index 0000000..112dc23 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/messages/MessagesViewModel.kt @@ -0,0 +1,47 @@ +package com.github.aeoliux.violet.app.messages + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.aeoliux.violet.api.scraping.messages.Message +import com.github.aeoliux.violet.api.scraping.messages.MessageCategories +import com.github.aeoliux.violet.api.scraping.messages.MessagesList +import com.github.aeoliux.violet.app.storage.Database +import com.github.aeoliux.violet.app.storage.selectMessageIds +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class MessagesViewModel(): ViewModel() { + private var _messagesCategory = MutableStateFlow(MessageCategories.Received) + val messagesCategory get() = _messagesCategory.asStateFlow() + + private var _messages = MutableStateFlow(MessagesList()) + val messages get() = _messages.asStateFlow() + + private var _selectedMessage = MutableStateFlow(null) + val selectedMessage get() = _selectedMessage.asStateFlow() + + fun selectCategory(category: MessageCategories) { + viewModelScope.launch { + _messagesCategory.update { + category + } + } + } + + fun selectMessages() { + viewModelScope.launch { + _messages.update { + Database.selectMessageIds()?: MessagesList() + } + } + } + + fun selectMessage(url: String?) { + viewModelScope.launch { + _selectedMessage.update { url } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/storage/Messages.kt b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/storage/Messages.kt new file mode 100644 index 0000000..2eaf159 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/github/aeoliux/violet/app/storage/Messages.kt @@ -0,0 +1,48 @@ +package com.github.aeoliux.violet.app.storage + +import com.github.aeoliux.violet.api.scraping.messages.MessageCategories +import com.github.aeoliux.violet.api.scraping.messages.MessageLabel +import com.github.aeoliux.violet.api.scraping.messages.MessagesList +import kotlinx.datetime.LocalDateTime + +fun Database.selectMessageIds(): MessagesList? { + try { + val result = dbQuery.selectMessagesIds().executeAsList() + + return result.fold(MessagesList()) { acc, msgId -> + val msgLabel = MessageLabel( + url = msgId.url, + sender = msgId.sender, + sentAt = LocalDateTime.parse(msgId.sentAt), + topic = msgId.topic, + hasAttachment = msgId.hasAttachment, + ) + + val category: MessageCategories = MessageCategories.fromInt(msgId.category.toInt()) + acc[category] = acc[category]?.plus(msgLabel)?: listOf(msgLabel) + + acc + } + } catch (_: NullPointerException) { + return null + } +} + +fun Database.insertMessageIds(ids: MessagesList) { + dbQuery.transaction { + dbQuery.clearMessagesIds() + + ids.forEach { (category, ids) -> + ids.forEach { id -> + dbQuery.insertMessagesIds( + category = category.categoryId.toLong(), + url = id.url, + sender = id.sender, + topic = id.topic, + sentAt = id.sentAt.toString(), + hasAttachment = id.hasAttachment + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/sqldelight/com.github.aeoliux.violet.storage/AppDatabase.sq b/composeApp/src/commonMain/sqldelight/com.github.aeoliux.violet.storage/AppDatabase.sq index 08b99c6..df76fe5 100644 --- a/composeApp/src/commonMain/sqldelight/com.github.aeoliux.violet.storage/AppDatabase.sq +++ b/composeApp/src/commonMain/sqldelight/com.github.aeoliux.violet.storage/AppDatabase.sq @@ -178,4 +178,22 @@ selectSchoolNotice: SELECT * FROM SchoolNotices WHERE id = ? LIMIT 1; clearSchoolNotices: -DELETE FROM SchoolNotices; \ No newline at end of file +DELETE FROM SchoolNotices; + +CREATE TABLE MessagesIds( + category INTEGER NOT NULL, + url TEXT UNIQUE NOT NULL, + sender TEXT NOT NULL, + topic TEXT NOT NULL, + sentAt TEXT NOT NULL, + hasAttachment INTEGER AS Boolean NOT NULL +); + +insertMessagesIds: +INSERT INTO MessagesIds VALUES (?, ?, ?, ?, ?, ?); + +selectMessagesIds: +SELECT * FROM MessagesIds ORDER BY sentAt DESC; + +clearMessagesIds: +DELETE FROM MessagesIds; \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b2b3b54..512ca56 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,75 +1,40 @@ [versions] -accompanistSwiperefresh = "0.28.0" -agp = "8.5.0" +agp = "8.5.2" android-compileSdk = "34" -android-minSdk = "24" -android-targetSdk = "34" -androidx-activityCompose = "1.9.1" -androidx-appcompat = "1.7.0" -androidx-constraintlayout = "2.1.4" -androidx-core-ktx = "1.13.1" -androidx-espresso-core = "3.6.1" +androidx-activityCompose = "1.9.3" androidx-lifecycle = "2.8.0" -androidx-material = "1.12.0" -androidx-test-junit = "1.2.1" compose-plugin = "1.6.11" -foundation = "1.6.11" -junit = "4.13.2" kotlin = "2.0.20" +ksoup = "0.2.1" material3 = "1.5.1" -materialIconsCore = "1.7.0" -materialIconsExtended = "1.7.0" -navigationCompose = "2.5.3" -navigationComposeVersion = "2.7.0-alpha07" -runtime = "1.6.11" -securityCryptoKtx = "1.0.0" +materialIconsCore = "1.7.5" +materialIconsExtended = "1.7.5" kotlinxDatetime = "0.6.1" kotlinxSerializationJson = "1.7.2" ktor = "2.3.12" -logback = "1.5.6" sqlDelight = "2.0.1" [libraries] -accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref = "accompanistSwiperefresh" } androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "materialIconsCore" } androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } -androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } -foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "foundation" } -kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } -kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } -junit = { group = "junit", name = "junit", version.ref = "junit" } -androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } -androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" } -androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } -androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidx-material" } -androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" } + androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } -androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } -ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-ios = { module = "io.ktor:ktor-client-ios", version.ref = "ktor" } ktor-client-json = { module = "io.ktor:ktor-client-json", version.ref = "ktor" } -ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp" } -ktor-client-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } -logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } -ktor-server-core = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" } -ktor-server-netty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" } -ktor-server-tests = { module = "io.ktor:ktor-server-tests-jvm", version.ref = "ktor" } material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } -navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" } -navigation-compose-v270alpha07 = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationComposeVersion" } -runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "runtime" } + sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqlDelight" } sqldelight-native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqlDelight" }