release/0.1.6 (#103)

* Bump com.google.guava:guava from 33.3.1-jre to 33.4.0-jre (#97)

* Bump coroutines from 1.9.0 to 1.10.1 (#100)

* some improvements + loading conversation on new message if it is not already in the list

* Bump koin from 4.0.0 to 4.0.1 (#101)

* minor update

---------

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
2025-01-11 06:38:08 +03:00
committed by GitHub
parent 7c14df1824
commit 7e5843759d
12 changed files with 310 additions and 194 deletions
@@ -26,8 +26,8 @@ import dev.meloda.fast.provider.ApiLanguageProvider
import dev.meloda.fast.service.longpolling.di.longPollModule import dev.meloda.fast.service.longpolling.di.longPollModule
import dev.meloda.fast.settings.di.settingsModule import dev.meloda.fast.settings.di.settingsModule
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModelOf
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.core.qualifier.qualifier import org.koin.core.qualifier.qualifier
import org.koin.dsl.bind import org.koin.dsl.bind
import org.koin.dsl.module import org.koin.dsl.module
@@ -61,7 +61,7 @@ val applicationModule = module {
qualifier = qualifier("main") qualifier = qualifier("main")
} }
single { single<ImageLoader> {
ImageLoader.Builder(get()) ImageLoader.Builder(get())
.crossfade(true) .crossfade(true)
.build() .build()
@@ -1,7 +1,5 @@
package dev.meloda.fast.common.extensions package dev.meloda.fast.common.extensions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -20,18 +20,20 @@ sealed class State<out T> {
data object ConnectionError : Error() data object ConnectionError : Error()
data object Unknown : Error() data object UnknownError : Error()
data object InternalError : Error() data object InternalError : Error()
data class OAuthError(val error: OAuthErrorDomain) : Error() data class OAuthError(val error: OAuthErrorDomain) : Error()
data class TestError(val message: String) : Error()
} }
fun isLoading(): Boolean = this is Loading fun isLoading(): Boolean = this is Loading
companion object { companion object {
val UNKNOWN_ERROR = Error.Unknown val UNKNOWN_ERROR = Error.UnknownError
} }
} }
@@ -73,11 +75,12 @@ fun <T : Any> ApiResult<T, RestApiErrorDomain>.mapToState() = when (this) {
is ApiResult.Failure.ApiFailure -> this.error.toStateApiError() is ApiResult.Failure.ApiFailure -> this.error.toStateApiError()
} }
fun <T : Any, N> ApiResult<T, RestApiErrorDomain>.mapToState(successMapper: (T) -> N) = when (this) { fun <T : Any, N> ApiResult<T, RestApiErrorDomain>.mapToState(successMapper: (T) -> N) =
when (this) {
is ApiResult.Success -> State.Success(successMapper(this.value)) is ApiResult.Success -> State.Success(successMapper(this.value))
is ApiResult.Failure.NetworkFailure -> State.Error.ConnectionError is ApiResult.Failure.NetworkFailure -> State.Error.ConnectionError
is ApiResult.Failure.UnknownFailure -> State.UNKNOWN_ERROR is ApiResult.Failure.UnknownFailure -> State.UNKNOWN_ERROR
is ApiResult.Failure.HttpFailure -> this.error.toStateApiError() is ApiResult.Failure.HttpFailure -> this.error.toStateApiError()
is ApiResult.Failure.ApiFailure -> this.error.toStateApiError() is ApiResult.Failure.ApiFailure -> this.error.toStateApiError()
} }
@@ -33,6 +33,7 @@ class OAuthUseCaseImpl(
forceSms = forceSms forceSms = forceSms
) )
kotlin.runCatching {
val error = response.error?.let(VkOAuthError::parse) val error = response.error?.let(VkOAuthError::parse)
val errorType = response.errorType?.let(VkOAuthErrorType::parse) val errorType = response.errorType?.let(VkOAuthErrorType::parse)
@@ -120,5 +121,12 @@ class OAuthUseCaseImpl(
} }
emit(newState) emit(newState)
}.fold(
onSuccess = {
},
onFailure = {
emit(State.Error.TestError(it.stackTraceToString()))
}
)
} }
} }
@@ -5,6 +5,8 @@ enum class ValidationType(val value: String) {
SMS("2fa_sms"); SMS("2fa_sms");
companion object { companion object {
fun parse(value: String): ValidationType = entries.first { it.value == value } fun parse(value: String): ValidationType =
entries.firstOrNull { it.value == value }
?: throw IllegalArgumentException("Unknown validation type $value")
} }
} }
@@ -345,6 +345,13 @@ class LoginViewModelImpl(
true true
} }
is State.Error.TestError -> {
val message = stateError.message
val error = LoginError.SimpleError(message = message)
loginError.update { error }
true
}
else -> false else -> false
} }
} }
@@ -9,4 +9,5 @@ sealed class LoginError {
data object TooManyTries : LoginError() data object TooManyTries : LoginError()
data object WrongValidationCode : LoginError() data object WrongValidationCode : LoginError()
data object WrongValidationCodeFormat : LoginError() data object WrongValidationCodeFormat : LoginError()
data class SimpleError(val message: String): LoginError()
} }
@@ -50,8 +50,6 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.auth.login.LoginViewModel
import dev.meloda.fast.auth.login.LoginViewModelImpl
import dev.meloda.fast.auth.login.model.CaptchaArguments import dev.meloda.fast.auth.login.model.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginError import dev.meloda.fast.auth.login.model.LoginError
import dev.meloda.fast.auth.login.model.LoginScreenState import dev.meloda.fast.auth.login.model.LoginScreenState
@@ -441,5 +439,14 @@ fun HandleError(
confirmText = stringResource(id = UiR.string.ok) confirmText = stringResource(id = UiR.string.ok)
) )
} }
is LoginError.SimpleError -> {
MaterialDialog(
onDismissRequest = onDismiss,
title = "Error",
text = error.message,
confirmText = stringResource(id = UiR.string.ok)
)
}
} }
} }
@@ -1,8 +1,11 @@
package dev.meloda.fast.conversations package dev.meloda.fast.conversations
import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import coil.ImageLoader
import coil.request.ImageRequest
import com.conena.nanokt.collections.indexOfFirstOrNull import com.conena.nanokt.collections.indexOfFirstOrNull
import dev.meloda.fast.common.extensions.createTimerFlow import dev.meloda.fast.common.extensions.createTimerFlow
import dev.meloda.fast.common.extensions.findWithIndex import dev.meloda.fast.common.extensions.findWithIndex
@@ -18,6 +21,7 @@ import dev.meloda.fast.data.State
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.ConversationsUseCase import dev.meloda.fast.domain.ConversationsUseCase
import dev.meloda.fast.domain.LoadConversationsByIdUseCase
import dev.meloda.fast.domain.LongPollUpdatesParser import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
@@ -43,7 +47,6 @@ interface ConversationsViewModel {
val screenState: StateFlow<ConversationsScreenState> val screenState: StateFlow<ConversationsScreenState>
val baseError: StateFlow<BaseError?> val baseError: StateFlow<BaseError?>
val imagesToPreload: StateFlow<List<String>>
val currentOffset: StateFlow<Int> val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean> val canPaginate: StateFlow<Boolean>
val scrollToTop: StateFlow<Boolean> val scrollToTop: StateFlow<Boolean>
@@ -78,16 +81,23 @@ class ConversationsViewModelImpl(
private val conversationsUseCase: ConversationsUseCase, private val conversationsUseCase: ConversationsUseCase,
private val messagesUseCase: MessagesUseCase, private val messagesUseCase: MessagesUseCase,
private val resources: Resources, private val resources: Resources,
private val userSettings: UserSettings private val userSettings: UserSettings,
private val imageLoader: ImageLoader,
private val applicationContext: Context,
private val loadConversationsByIdUseCase: LoadConversationsByIdUseCase
) : ConversationsViewModel, ViewModel() { ) : ConversationsViewModel, ViewModel() {
override val screenState = MutableStateFlow(ConversationsScreenState.EMPTY) override val screenState = MutableStateFlow(ConversationsScreenState.EMPTY)
override val baseError = MutableStateFlow<BaseError?>(null) override val baseError = MutableStateFlow<BaseError?>(null)
override val imagesToPreload = MutableStateFlow<List<String>>(emptyList())
override val currentOffset = MutableStateFlow(0) override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false) override val canPaginate = MutableStateFlow(false)
override val scrollToTop = MutableStateFlow(false) override val scrollToTop = MutableStateFlow(false)
// TODO: 22-Dec-24, Danil Nikolaev: rewrite
private val useContactNames = {
userSettings.useContactNames.value
}
override fun onPaginationConditionsMet() { override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.conversations.size } currentOffset.update { screenState.value.conversations.size }
loadConversations() loadConversations()
@@ -281,9 +291,17 @@ class ConversationsViewModelImpl(
val paginationExhausted = !itemsCountSufficient && val paginationExhausted = !itemsCountSufficient &&
screenState.value.conversations.isNotEmpty() screenState.value.conversations.isNotEmpty()
imagesToPreload.setValue { val imagesToPreload =
response.mapNotNull { it.extractAvatar().extractUrl() } response.mapNotNull { it.extractAvatar().extractUrl() }
imagesToPreload.forEach { url ->
imageLoader.enqueue(
ImageRequest.Builder(applicationContext)
.data(url)
.build()
)
} }
conversationsUseCase.storeConversations(response) conversationsUseCase.storeConversations(response)
val loadedConversations = response.map { val loadedConversations = response.map {
@@ -337,7 +355,12 @@ class ConversationsViewModelImpl(
conversations.update { newConversations } conversations.update { newConversations }
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
conversations = newConversations.map { it.asPresentation(resources) } conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
)
}
) )
} }
} }
@@ -373,13 +396,40 @@ class ConversationsViewModelImpl(
private fun handleNewMessage(event: LongPollEvent.VkMessageNewEvent) { private fun handleNewMessage(event: LongPollEvent.VkMessageNewEvent) {
val message = event.message val message = event.message
val newConversations = conversations.value.toMutableList() val newConversations = conversations.value.toMutableList()
val conversationIndex = val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == message.peerId } newConversations.indexOfFirstOrNull { it.id == message.peerId }
if (conversationIndex == null) { // диалога нет в списке if (conversationIndex == null) {
// pizdets loadConversationsByIdUseCase(peerIds = listOf(message.peerId))
// TODO: 04/07/2024, Danil Nikolaev: load conversation and store info .listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
},
success = { response ->
val conversation = (response.firstOrNull() ?: return@listenValue)
.copy(lastMessage = message)
// TODO: 22-Dec-24, Danil Nikolaev: handle interactions and pinned state
newConversations.add(pinnedConversationsCount.value, conversation)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
)
}
)
}
}
)
}
} else { } else {
val conversation = newConversations[conversationIndex] val conversation = newConversations[conversationIndex]
var newConversation = conversation.copy( var newConversation = conversation.copy(
@@ -420,7 +470,12 @@ class ConversationsViewModelImpl(
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
conversations = newConversations.map { it.asPresentation(resources) } conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
)
}
) )
} }
} }
@@ -444,7 +499,12 @@ class ConversationsViewModelImpl(
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
conversations = newConversations.map { it.asPresentation(resources) } conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
)
}
) )
} }
} }
@@ -454,8 +514,11 @@ class ConversationsViewModelImpl(
val newConversations = conversations.value.toMutableList() val newConversations = conversations.value.toMutableList()
val conversationIndex = val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId } ?: return newConversations.indexOfFirstOrNull { it.id == event.peerId }
if (conversationIndex == null) { // диалога нет в списке
// pizdets
} else {
newConversations[conversationIndex] = newConversations[conversationIndex] =
newConversations[conversationIndex].copy( newConversations[conversationIndex].copy(
inRead = event.messageId, inRead = event.messageId,
@@ -466,17 +529,26 @@ class ConversationsViewModelImpl(
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
conversations = newConversations.map { it.asPresentation(resources) } conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
) )
} }
)
}
}
} }
private fun handleReadOutgoingMessage(event: LongPollEvent.VkMessageReadOutgoingEvent) { private fun handleReadOutgoingMessage(event: LongPollEvent.VkMessageReadOutgoingEvent) {
val newConversations = conversations.value.toMutableList() val newConversations = conversations.value.toMutableList()
val conversationIndex = val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId } ?: return newConversations.indexOfFirstOrNull { it.id == event.peerId }
if (conversationIndex == null) { // диалога нет в списке
// pizdets
} else {
newConversations[conversationIndex] = newConversations[conversationIndex] =
newConversations[conversationIndex].copy( newConversations[conversationIndex].copy(
outRead = event.messageId, outRead = event.messageId,
@@ -486,9 +558,15 @@ class ConversationsViewModelImpl(
conversations.update { newConversations } conversations.update { newConversations }
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
conversations = newConversations.map { it.asPresentation(resources) } conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
) )
} }
)
}
}
} }
private fun handlePinStateChanged(event: LongPollEvent.VkConversationPinStateChangedEvent) { private fun handlePinStateChanged(event: LongPollEvent.VkConversationPinStateChangedEvent) {
@@ -496,8 +574,11 @@ class ConversationsViewModelImpl(
val newConversations = conversations.value.toMutableList() val newConversations = conversations.value.toMutableList()
val conversationIndex = val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId } ?: return newConversations.indexOfFirstOrNull { it.id == event.peerId }
if (conversationIndex == null) { // диалога нет в списке
// pizdets
} else {
val pin = event.majorId > 0 val pin = event.majorId > 0
val conversation = newConversations[conversationIndex].copy(majorId = event.majorId) val conversation = newConversations[conversationIndex].copy(majorId = event.majorId)
@@ -523,7 +604,13 @@ class ConversationsViewModelImpl(
conversations.update { newConversations } conversations.update { newConversations }
screenState.setValue { old -> screenState.setValue { old ->
old.copy(conversations = newConversations.map { it.asPresentation(resources) }) old.copy(conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
)
})
}
} }
} }
@@ -543,8 +630,11 @@ class ConversationsViewModelImpl(
val newConversations = conversations.value.toMutableList() val newConversations = conversations.value.toMutableList()
val conversationAndIndex = val conversationAndIndex =
newConversations.findWithIndex { it.id == peerId } ?: return newConversations.findWithIndex { it.id == peerId }
if (conversationAndIndex == null) { // диалога нет в списке
// pizdets
} else {
newConversations[conversationAndIndex.first] = newConversations[conversationAndIndex.first] =
conversationAndIndex.second.copy( conversationAndIndex.second.copy(
interactionType = interactionType.value, interactionType = interactionType.value,
@@ -555,7 +645,12 @@ class ConversationsViewModelImpl(
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
conversations = newConversations.map { it.asPresentation(resources) } conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
)
}
) )
} }
@@ -583,6 +678,7 @@ class ConversationsViewModelImpl(
stopInteraction(peerId, newInteractionJob) stopInteraction(peerId, newInteractionJob)
} }
} }
}
private fun stopInteraction(peerId: Int, interactionJob: InteractionJob) { private fun stopInteraction(peerId: Int, interactionJob: InteractionJob) {
interactionsTimers[peerId] ?: return interactionsTimers[peerId] ?: return
@@ -600,7 +696,12 @@ class ConversationsViewModelImpl(
conversations.update { newConversations } conversations.update { newConversations }
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
conversations = newConversations.map { it.asPresentation(resources) } conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
)
}
) )
} }
@@ -629,7 +730,12 @@ class ConversationsViewModelImpl(
conversations.update { newConversations } conversations.update { newConversations }
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
conversations = newConversations.map { it.asPresentation(resources) } conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
)
}
) )
} }
} }
@@ -54,7 +54,6 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -65,8 +64,6 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.imageLoader
import coil.request.ImageRequest
import dev.chrisbanes.haze.haze import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
@@ -96,24 +93,11 @@ fun ConversationsRoute(
onConversationPhotoClicked: (url: String) -> Unit, onConversationPhotoClicked: (url: String) -> Unit,
viewModel: ConversationsViewModel viewModel: ConversationsViewModel
) { ) {
val context = LocalContext.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle() val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle() val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val isNeedToScrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() val isNeedToScrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle()
val imagesToPreload by viewModel.imagesToPreload.collectAsStateWithLifecycle()
LaunchedEffect(imagesToPreload) {
imagesToPreload.forEach { url ->
context.imageLoader.enqueue(
ImageRequest.Builder(context)
.data(url)
.build()
)
}
}
ConversationsScreen( ConversationsScreen(
screenState = screenState, screenState = screenState,
baseError = baseError, baseError = baseError,
@@ -33,7 +33,7 @@ import dev.meloda.fast.ui.R as UiR
fun VkConversation.asPresentation( fun VkConversation.asPresentation(
resources: Resources, resources: Resources,
useContactName: Boolean = false useContactName: Boolean
): UiConversation = UiConversation( ): UiConversation = UiConversation(
id = id, id = id,
lastMessageId = lastMessageId, lastMessageId = lastMessageId,
+5 -5
View File
@@ -2,8 +2,8 @@
minSdk = "23" minSdk = "23"
targetSdk = "35" targetSdk = "35"
compileSdk = "35" compileSdk = "35"
versionCode = "8" versionCode = "9"
versionName = "0.1.5" versionName = "0.1.6"
agp = "8.7.3" agp = "8.7.3"
converterMoshi = "2.11.0" converterMoshi = "2.11.0"
@@ -13,14 +13,14 @@ kotlin = "2.1.0"
ksp = "2.1.0-1.0.29" ksp = "2.1.0-1.0.29"
compose-bom = "2024.12.01" compose-bom = "2024.12.01"
koin = "4.0.0" koin = "4.0.1"
accompanist = "0.37.0" accompanist = "0.37.0"
coil = "2.7.0" coil = "2.7.0"
coroutines = "1.9.0" coroutines = "1.10.1"
junit = "4.13.2" junit = "4.13.2"
chucker = "4.1.0" chucker = "4.1.0"
guava = "33.3.1-jre" guava = "33.4.0-jre"
lifecycle = "2.8.7" lifecycle = "2.8.7"
core-ktx = "1.15.0" core-ktx = "1.15.0"
material = "1.12.0" material = "1.12.0"