Release 0.2.0 (#150)

Release Notes

* Bumped haze, agp, and guava dependencies
* Implemented ordering functionality for friends list
* Added scroll to top feature in friends and conversations screens
* Improved messages handling
* Fixed coloring issues
* Cache improvements
* Implemented logout functionality
* Implemented new authorization flow (no auto-token re-request)
* Added support for sticker pack preview attachments
* Bump LongPoll to version 19
* Markdown support for messages bubbles
* Adjust app name font size based on screen width

---------

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
2025-04-04 21:47:05 +03:00
committed by GitHub
parent 0eb3146428
commit 82fb78e9ea
279 changed files with 9171 additions and 4517 deletions
@@ -3,12 +3,35 @@ package dev.meloda.fast.conversations.di
import dev.meloda.fast.conversations.ConversationsViewModelImpl
import dev.meloda.fast.domain.ConversationsUseCase
import dev.meloda.fast.domain.ConversationsUseCaseImpl
import dev.meloda.fast.model.ConversationsFilter
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.core.module.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.core.scope.Scope
import org.koin.dsl.bind
import org.koin.dsl.module
val conversationsModule = module {
viewModel(named(ConversationsFilter.ALL)) {
createConversationsViewModel(ConversationsFilter.ALL)
}
viewModel(named(ConversationsFilter.ARCHIVE)) {
createConversationsViewModel(ConversationsFilter.ARCHIVE)
}
singleOf(::ConversationsUseCaseImpl) bind ConversationsUseCase::class
viewModelOf(::ConversationsViewModelImpl)
}
private fun Scope.createConversationsViewModel(filter: ConversationsFilter): ConversationsViewModelImpl {
return ConversationsViewModelImpl(
filter = filter,
updatesParser = get(),
conversationsUseCase = get(),
messagesUseCase = get(),
resources = get(),
userSettings = get(),
imageLoader = get(),
applicationContext = get(),
loadConversationsByIdUseCase = get()
)
}
@@ -0,0 +1,12 @@
package dev.meloda.fast.conversations.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class ConversationDialog {
data class ConversationPin(val conversationId: Long) : ConversationDialog()
data class ConversationUnpin(val conversationId: Long) : ConversationDialog()
data class ConversationDelete(val conversationId: Long) : ConversationDialog()
data class ConversationArchive(val conversationId: Long) : ConversationDialog()
data class ConversationUnarchive(val conversationId: Long) : ConversationDialog()
}
@@ -0,0 +1,11 @@
package dev.meloda.fast.conversations.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class ConversationNavigation {
data class MessagesHistory(val peerId: Long) : ConversationNavigation()
data object CreateChat : ConversationNavigation()
}
@@ -1,31 +1,28 @@
package dev.meloda.fast.conversations.model
import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.model.api.ConversationsShowOptions
import dev.meloda.fast.ui.model.api.UiConversation
@Immutable
data class ConversationsScreenState(
val showOptions: ConversationsShowOptions,
val conversations: List<UiConversation>,
val isLoading: Boolean,
val isPaginating: Boolean,
val isPaginationExhausted: Boolean,
val profileImageUrl: String?,
val scrollIndex: Int,
val scrollOffset: Int
val scrollOffset: Int,
val isArchive: Boolean
) {
companion object {
val EMPTY: ConversationsScreenState = ConversationsScreenState(
showOptions = ConversationsShowOptions.EMPTY,
conversations = emptyList(),
isLoading = true,
isPaginating = false,
isPaginationExhausted = false,
profileImageUrl = null,
scrollIndex = 0,
scrollOffset = 0,
isArchive = false
)
}
}
@@ -0,0 +1,9 @@
package dev.meloda.fast.conversations.model
import dev.meloda.fast.model.InteractionType
import kotlinx.coroutines.Job
data class InteractionJob(
val interactionType: InteractionType,
val timerJob: Job
)
@@ -0,0 +1,5 @@
package dev.meloda.fast.conversations.model
import kotlinx.coroutines.CancellationException
class NewInteractionException : CancellationException()
@@ -1,35 +1,73 @@
package dev.meloda.fast.conversations.navigation
import androidx.navigation.NavController
import androidx.activity.compose.LocalActivity
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import dev.meloda.fast.conversations.ConversationsViewModel
import androidx.navigation.navigation
import dev.meloda.fast.conversations.ConversationsViewModelImpl
import dev.meloda.fast.conversations.presentation.ConversationsRoute
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.extensions.sharedViewModel
import dev.meloda.fast.model.ConversationsFilter
import dev.meloda.fast.ui.theme.LocalNavController
import dev.meloda.fast.ui.theme.getOrThrow
import kotlinx.serialization.Serializable
import org.koin.androidx.compose.koinViewModel
import org.koin.core.qualifier.named
@Serializable
object ConversationsGraph
@Serializable
object Conversations
fun NavGraphBuilder.conversationsScreen(
onError: (BaseError) -> Unit,
onConversationItemClicked: (id: Int) -> Unit,
onPhotoClicked: (url: String) -> Unit,
onCreateChatClicked: () -> Unit,
navController: NavController,
) {
composable<Conversations> {
val viewModel: ConversationsViewModel =
it.sharedViewModel<ConversationsViewModelImpl>(navController = navController)
@Serializable
object Archive
ConversationsRoute(
onError = onError,
onConversationItemClicked = onConversationItemClicked,
onConversationPhotoClicked = onPhotoClicked,
onCreateChatButtonClicked = onCreateChatClicked,
viewModel = viewModel
)
fun NavGraphBuilder.conversationsGraph(
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (id: Long) -> Unit,
onNavigateToCreateChat: () -> Unit,
onScrolledToTop: () -> Unit
) {
navigation<ConversationsGraph>(
startDestination = Conversations
) {
composable<Conversations> {
val context = LocalContext.current
val navController = LocalNavController.getOrThrow()
val viewModel: ConversationsViewModelImpl = koinViewModel(
qualifier = named(ConversationsFilter.ALL),
viewModelStoreOwner = context as AppCompatActivity
)
ConversationsRoute(
viewModel = viewModel,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat,
onNavigateToArchive = { navController.navigate(Archive) },
onScrolledToTop = onScrolledToTop
)
}
composable<Archive> {
val context = LocalContext.current
val navController = LocalNavController.getOrThrow()
val viewModel: ConversationsViewModelImpl = koinViewModel(
qualifier = named(ConversationsFilter.ARCHIVE),
viewModelStoreOwner = context as AppCompatActivity
)
ConversationsRoute(
viewModel = viewModel,
onBack = navController::navigateUp,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onScrolledToTop = onScrolledToTop
)
}
}
}
@@ -0,0 +1,74 @@
package dev.meloda.fast.conversations.presentation
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.core.os.bundleOf
import dev.meloda.fast.conversations.model.ConversationDialog
import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.R as UiR
@Composable
fun HandleDialogs(
screenState: ConversationsScreenState,
dialog: ConversationDialog?,
onConfirmed: (ConversationDialog, Bundle) -> Unit = { _, _ -> },
onDismissed: (ConversationDialog) -> Unit = {},
onItemPicked: (ConversationDialog, Bundle) -> Unit = { _, _ -> }
) {
when (dialog) {
null -> Unit
is ConversationDialog.ConversationArchive -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = UiR.string.confirm_archive_conversation),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = UiR.string.action_archive),
cancelText = stringResource(id = UiR.string.cancel)
)
}
is ConversationDialog.ConversationUnarchive -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = UiR.string.confirm_unarchive_conversation),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = UiR.string.action_unarchive),
cancelText = stringResource(id = UiR.string.cancel)
)
}
is ConversationDialog.ConversationDelete -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = UiR.string.confirm_delete_conversation),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = UiR.string.action_delete),
cancelText = stringResource(id = UiR.string.cancel)
)
}
is ConversationDialog.ConversationPin -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = UiR.string.confirm_pin_conversation),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = UiR.string.action_pin),
cancelText = stringResource(id = UiR.string.cancel)
)
}
is ConversationDialog.ConversationUnpin -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = UiR.string.confirm_unpin_conversation),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = UiR.string.action_unpin),
cancelText = stringResource(id = UiR.string.cancel)
)
}
}
}
@@ -6,10 +6,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -21,7 +18,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ElevatedAssistChip
@@ -40,7 +38,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
@@ -63,16 +60,14 @@ val BirthdayColor = Color(0xffb00b69)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ConversationItem(
onItemClick: (Int) -> Unit,
onItemClick: (UiConversation) -> Unit,
onItemLongClick: (conversation: UiConversation) -> Unit,
onOptionClicked: (UiConversation, ConversationOption) -> Unit,
maxLines: Int,
isUserAccount: Boolean,
conversation: UiConversation,
modifier: Modifier = Modifier,
onPhotoClicked: (url: String) -> Unit
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val hapticFeedback = LocalHapticFeedback.current
val bottomStartCornerRadius by animateDpAsState(
@@ -84,7 +79,7 @@ fun ConversationItem(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onClick = { onItemClick(conversation.id) },
onClick = { onItemClick(conversation) },
onLongClick = {
onItemLongClick(conversation)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
@@ -154,12 +149,7 @@ fun ConversationItem(
contentDescription = "Avatar",
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.clickable {
if (avatarImage is String) {
onPhotoClicked(avatarImage)
}
},
.clip(CircleShape),
placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut)
)
}
@@ -250,7 +240,7 @@ fun ConversationItem(
text = conversation.title,
minLines = 1,
maxLines = maxLines,
style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp)
style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp),
)
Row {
@@ -338,9 +328,13 @@ fun ConversationItem(
Box(
modifier = Modifier
.clip(CircleShape)
.defaultMinSize(minWidth = 20.dp, minHeight = 20.dp)
.defaultMinSize(
minWidth = 20.dp,
minHeight = 20.dp
)
.background(MaterialTheme.colorScheme.primary)
.align(Alignment.CenterHorizontally)
.padding(horizontal = if (count.length > 1) 2.dp else 0.dp)
) {
Text(
modifier = Modifier
@@ -361,18 +355,19 @@ fun ConversationItem(
Column(
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
.padding(start = 8.dp)
) {
Spacer(modifier = Modifier.height(12.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(4.dp))
Row(
LazyRow(
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp)
.fillMaxWidth()
.padding(horizontal = 10.dp)
) {
conversation.options.forEach { option ->
items(conversation.options.toList()) { option ->
ElevatedAssistChip(
onClick = { onOptionClicked(conversation, option) },
leadingIcon = {
@@ -388,6 +383,7 @@ fun ConversationItem(
Text(text = option.title.getString().orEmpty())
}
)
Spacer(Modifier.width(8.dp))
}
}
}
@@ -402,5 +398,3 @@ fun ConversationItem(
}
}
}
@@ -27,25 +27,26 @@ import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun ConversationsList(
onConversationsClick: (Int) -> Unit,
modifier: Modifier = Modifier,
conversations: ImmutableList<UiConversation>,
onConversationsClick: (UiConversation) -> Unit,
onConversationsLongClick: (UiConversation) -> Unit,
screenState: ConversationsScreenState,
state: LazyListState,
maxLines: Int,
modifier: Modifier,
onOptionClicked: (UiConversation, ConversationOption) -> Unit,
padding: PaddingValues,
onPhotoClicked: (url: String) -> Unit
padding: PaddingValues
) {
val theme = LocalThemeConfig.current
val coroutineScope = rememberCoroutineScope()
val bottomPadding = LocalBottomPadding.current
LazyColumn(
modifier = modifier,
state = state
@@ -55,7 +56,7 @@ fun ConversationsList(
Spacer(modifier = Modifier.height(8.dp))
}
items(
items = screenState.conversations,
items = conversations.values,
key = UiConversation::id,
) { conversation ->
val isUserAccount by remember(conversation) {
@@ -71,8 +72,12 @@ fun ConversationsList(
maxLines = maxLines,
isUserAccount = isUserAccount,
conversation = conversation,
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null),
onPhotoClicked = onPhotoClicked
modifier =
if (theme.enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
)
Spacer(modifier = Modifier.height(8.dp))
@@ -82,7 +87,14 @@ fun ConversationsList(
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null),
.then(
if (theme.enableAnimations)
Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (screenState.isPaginating) {
@@ -107,6 +119,7 @@ fun ConversationsList(
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(LocalBottomPadding.current))
}
}
}
@@ -0,0 +1,80 @@
package dev.meloda.fast.conversations.presentation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.conversations.ConversationsViewModel
import dev.meloda.fast.conversations.model.ConversationNavigation
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@Composable
fun ConversationsRoute(
viewModel: ConversationsViewModel,
onBack: (() -> Unit)? = null,
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (conversationId: Long) -> Unit,
onNavigateToCreateChat: (() -> Unit)? = null,
onNavigateToArchive: (() -> Unit)? = null,
onScrolledToTop: () -> Unit,
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val navigationEvent by viewModel.navigation.collectAsStateWithLifecycle()
val conversations by viewModel.uiConversations.collectAsStateWithLifecycle()
val dialog by viewModel.dialog.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
LaunchedEffect(navigationEvent) {
val shouldBeConsumed: Boolean = when (val navigation = navigationEvent) {
null -> false
is ConversationNavigation.CreateChat -> {
onNavigateToCreateChat?.invoke()
true
}
is ConversationNavigation.MessagesHistory -> {
onNavigateToMessagesHistory(navigation.peerId)
true
}
}
if (shouldBeConsumed) viewModel.onNavigationConsumed()
}
ConversationsScreen(
onBack = { onBack?.invoke() },
screenState = screenState,
conversations = conversations.toImmutableList(),
baseError = baseError,
canPaginate = canPaginate,
onConversationItemClicked = viewModel::onConversationItemClick,
onConversationItemLongClicked = viewModel::onConversationItemLongClick,
onOptionClicked = viewModel::onOptionClicked,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefreshDropdownItemClicked = viewModel::onRefresh,
onRefresh = viewModel::onRefresh,
onCreateChatButtonClicked = viewModel::onCreateChatButtonClicked,
onArchiveActionClicked = { onNavigateToArchive?.invoke() },
setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset,
onConsumeReselection = onScrolledToTop,
onErrorViewButtonClicked = {
if (baseError in listOf(BaseError.AccountBlocked, BaseError.SessionExpired)) {
onError(requireNotNull(baseError))
} else {
viewModel.onErrorButtonClicked()
}
}
)
HandleDialogs(
screenState = screenState,
dialog = dialog,
onConfirmed = viewModel::onDialogConfirmed,
onDismissed = viewModel::onDialogDismissed,
onItemPicked = viewModel::onDialogItemPicked
)
}
@@ -3,11 +3,8 @@ package dev.meloda.fast.conversations.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideIn
import androidx.compose.animation.slideOut
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
@@ -17,11 +14,13 @@ import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -51,7 +50,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -59,66 +57,30 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.conversations.ConversationsViewModel
import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.navigation.Conversations
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.components.ErrorView
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalReselectedTab
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
import dev.meloda.fast.ui.util.isScrollingUp
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import dev.meloda.fast.ui.R as UiR
@Composable
fun ConversationsRoute(
onError: (BaseError) -> Unit,
onConversationItemClicked: (conversationId: Int) -> Unit,
onConversationPhotoClicked: (url: String) -> Unit,
onCreateChatButtonClicked: () -> Unit,
viewModel: ConversationsViewModel
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
ConversationsScreen(
screenState = screenState,
baseError = baseError,
canPaginate = canPaginate,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onConversationItemClicked = { id ->
onConversationItemClicked(id)
viewModel.onConversationItemClick()
},
onConversationItemLongClicked = viewModel::onConversationItemLongClick,
onOptionClicked = viewModel::onOptionClicked,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefreshDropdownItemClicked = viewModel::onRefresh,
onRefresh = viewModel::onRefresh,
onConversationPhotoClicked = onConversationPhotoClicked,
onCreateChatButtonClicked = onCreateChatButtonClicked,
setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset
)
HandleDialogs(
screenState = screenState,
viewModel = viewModel
)
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class,
@@ -126,21 +88,23 @@ fun ConversationsRoute(
@Composable
fun ConversationsScreen(
screenState: ConversationsScreenState = ConversationsScreenState.EMPTY,
conversations: ImmutableList<UiConversation> = emptyImmutableList(),
baseError: BaseError? = null,
canPaginate: Boolean = false,
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onConversationItemClicked: (conversationId: Int) -> Unit = {},
onBack: () -> Unit = {},
onConversationItemClicked: (conversation: UiConversation) -> Unit = {},
onConversationItemLongClicked: (conversation: UiConversation) -> Unit = {},
onOptionClicked: (UiConversation, ConversationOption) -> Unit = { _, _ -> },
onPaginationConditionsMet: () -> Unit = {},
onRefreshDropdownItemClicked: () -> Unit = {},
onRefresh: () -> Unit = {},
onConversationPhotoClicked: (url: String) -> Unit = {},
onCreateChatButtonClicked: () -> Unit = {},
onArchiveActionClicked: () -> Unit = {},
setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {}
setScrollOffset: (Int) -> Unit = {},
onConsumeReselection: () -> Unit = {},
onErrorViewButtonClicked: () -> Unit = {}
) {
val view = LocalView.current
val currentTheme = LocalThemeConfig.current
val maxLines by remember(currentTheme) {
@@ -152,6 +116,21 @@ fun ConversationsScreen(
initialFirstVisibleItemScrollOffset = screenState.scrollOffset
)
val currentTabReselected = LocalReselectedTab.current[Conversations] ?: false
LaunchedEffect(currentTabReselected) {
if (currentTabReselected) {
if (screenState.isArchive) {
onBack.invoke()
} else {
if (listState.firstVisibleItemIndex > 14) {
listState.scrollToItem(14)
}
listState.animateScrollToItem(0)
onConsumeReselection()
}
}
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.debounce(500L)
@@ -209,22 +188,40 @@ fun ConversationsScreen(
title = {
Text(
text = stringResource(
id = if (screenState.isLoading) UiR.string.title_loading
else UiR.string.title_conversations
id = when {
screenState.isLoading -> UiR.string.title_loading
screenState.isArchive -> UiR.string.title_archive
else -> UiR.string.title_conversations
}
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.headlineSmall
)
},
actions = {
IconButton(
onClick = {
dropDownMenuExpanded = true
navigationIcon = {
if (screenState.isArchive) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = null
)
}
) {
}
},
actions = {
if (!screenState.isArchive) {
IconButton(onClick = onArchiveActionClicked) {
Icon(
painter = painterResource(UiR.drawable.outline_archive_24),
contentDescription = null
)
}
}
IconButton(onClick = { dropDownMenuExpanded = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
imageVector = Icons.Rounded.MoreVert,
contentDescription = null
)
}
@@ -270,7 +267,7 @@ fun ConversationsScreen(
)
val showHorizontalProgressBar by remember(screenState) {
derivedStateOf { screenState.isLoading && screenState.conversations.isNotEmpty() }
derivedStateOf { screenState.isLoading && conversations.isNotEmpty() }
}
AnimatedVisibility(showHorizontalProgressBar) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
@@ -281,46 +278,38 @@ fun ConversationsScreen(
}
},
floatingActionButton = {
Column {
AnimatedVisibility(
visible = listState.isScrollingUp(),
enter = slideIn { IntOffset(0, 600) } + fadeIn(tween(200)),
exit = slideOut { IntOffset(0, 600) } + fadeOut(tween(200))
) {
FloatingActionButton(onClick = onCreateChatButtonClicked) {
if (!screenState.isArchive) {
val offsetY by animateIntAsState(
targetValue = if (listState.isScrollingUp()) 0 else 600
)
Column {
FloatingActionButton(
onClick = onCreateChatButtonClicked,
modifier = Modifier.offset {
IntOffset(0, offsetY)
}
) {
Icon(
painter = painterResource(id = UiR.drawable.ic_baseline_create_24),
painter = painterResource(id = UiR.drawable.round_create_24),
contentDescription = "Add chat button"
)
}
}
Spacer(modifier = Modifier.height(LocalBottomPadding.current))
Spacer(modifier = Modifier.height(LocalBottomPadding.current))
}
}
}
) { padding ->
when {
baseError != null -> {
when (baseError) {
is BaseError.SessionExpired -> {
ErrorView(
text = stringResource(UiR.string.session_expired),
buttonText = stringResource(UiR.string.action_log_out),
onButtonClick = onSessionExpiredLogOutButtonClicked
)
}
is BaseError.SimpleError -> {
ErrorView(
text = baseError.message,
buttonText = stringResource(UiR.string.try_again),
onButtonClick = onRefresh
)
}
}
VkErrorView(
baseError = baseError,
onButtonClick = onErrorViewButtonClicked
)
}
screenState.isLoading && screenState.conversations.isEmpty() -> FullScreenLoader()
screenState.isLoading && conversations.isEmpty() -> FullScreenLoader()
else -> {
val pullToRefreshState = rememberPullToRefreshState()
@@ -345,6 +334,7 @@ fun ConversationsScreen(
}
) {
ConversationsList(
conversations = conversations,
onConversationsClick = onConversationItemClicked,
onConversationsLongClick = onConversationItemLongClicked,
screenState = screenState,
@@ -356,11 +346,10 @@ fun ConversationsScreen(
Modifier
}.fillMaxSize(),
onOptionClicked = onOptionClicked,
padding = padding,
onPhotoClicked = onConversationPhotoClicked
padding = padding
)
if (screenState.conversations.isEmpty()) {
if (conversations.isEmpty()) {
NoItemsView(
buttonText = stringResource(UiR.string.action_refresh),
onButtonClick = onRefresh
@@ -371,38 +360,3 @@ fun ConversationsScreen(
}
}
}
// TODO: 26.08.2023, Danil Nikolaev: remove usage of viewModel
@Composable
fun HandleDialogs(
screenState: ConversationsScreenState,
viewModel: ConversationsViewModel
) {
val showOptions = screenState.showOptions
if (showOptions.showDeleteDialog != null) {
MaterialDialog(
onDismissRequest = viewModel::onDeleteDialogDismissed,
title = stringResource(id = UiR.string.confirm_delete_conversation),
confirmAction = viewModel::onDeleteDialogPositiveClick,
confirmText = stringResource(id = UiR.string.action_delete),
cancelText = stringResource(id = UiR.string.cancel)
)
}
showOptions.showPinDialog?.let { conversation ->
MaterialDialog(
onDismissRequest = viewModel::onPinDialogDismissed,
title = stringResource(
id = if (conversation.isPinned) UiR.string.confirm_unpin_conversation
else UiR.string.confirm_pin_conversation
),
confirmAction = viewModel::onPinDialogPositiveClick,
confirmText = stringResource(
id = if (conversation.isPinned) UiR.string.action_unpin
else UiR.string.action_pin
),
cancelText = stringResource(id = UiR.string.cancel)
)
}
}
@@ -23,8 +23,10 @@ import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.model.api.ActionState
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
import java.util.Calendar
import java.util.Locale
import kotlin.math.ln
@@ -33,7 +35,9 @@ import dev.meloda.fast.ui.R as UiR
fun VkConversation.asPresentation(
resources: Resources,
useContactName: Boolean
useContactName: Boolean,
isExpanded: Boolean = false,
options: ImmutableList<ConversationOption> = emptyImmutableList()
): UiConversation = UiConversation(
id = id,
lastMessageId = lastMessageId,
@@ -47,14 +51,15 @@ fun VkConversation.asPresentation(
isPinned = majorId > 0,
actionImageId = ActionState.parse(isPhantom, isCallInProgress).getResourceId(),
isBirthday = extractBirthday(this),
isUnread = extractReadCondition(this, lastMessage),
isUnread = !isRead(),
isAccount = isAccount(id),
isOnline = !isAccount(id) && user?.onlineStatus?.isOnline() == true,
lastMessage = lastMessage,
peerType = peerType,
interactionText = extractInteractionText(resources, this),
isExpanded = false,
options = ImmutableList.empty()
isExpanded = isExpanded,
isArchived = isArchived,
options = options
)
fun VkConversation.extractAvatar() = when (peerType) {
@@ -101,7 +106,7 @@ private fun extractUnreadCount(
lastMessage: VkMessage?,
conversation: VkConversation
): String? = when {
lastMessage?.isOut == false && !conversation.isInUnread() -> null
lastMessage?.isOut == false && conversation.isInRead() -> null
conversation.unreadCount == 0 -> null
conversation.unreadCount < 1000 -> conversation.unreadCount.toString()
else -> {
@@ -121,7 +126,7 @@ private fun extractUnreadCount(
private fun extractMessage(
resources: Resources,
lastMessage: VkMessage?,
peerId: Int,
peerId: Long,
peerType: PeerType
): AnnotatedString {
val youPrefix = UiText.Resource(UiR.string.you_message_prefix)
@@ -210,7 +215,12 @@ private fun extractMessage(
.replace("<br/>", " ")
.replace("&ndash;", "-")
.trim()
.let { text -> getTextWithVisualizedMentions(text, Color.Red) }
.let { text ->
extractTextWithVisualizedMentions(
isOut = lastMessage?.isOut == true,
originalText = text
)
}
.let { text -> prefix + text }
}
@@ -612,6 +622,9 @@ private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? {
AttachmentType.PODCAST -> null
AttachmentType.NARRATIVE -> null
AttachmentType.ARTICLE -> null
AttachmentType.VIDEO_MESSAGE -> null
AttachmentType.GROUP_CHAT_STICKER -> UiR.drawable.ic_attachment_sticker
AttachmentType.STICKER_PACK_PREVIEW -> null
}?.let(UiImage::Resource)
}
@@ -649,10 +662,9 @@ private fun extractForwardsText(
else -> null
}
private fun getTextWithVisualizedMentions(
originalText: String,
mentionColor: Color,
fun extractTextWithVisualizedMentions(
isOut: Boolean,
originalText: String
): AnnotatedString = buildAnnotatedString {
val regex = """\[(id|club)(\d+)\|([^]]+)]""".toRegex()
@@ -676,7 +688,7 @@ private fun getTextWithVisualizedMentions(
replacements.add(indexRange to replaced)
mentions += MentionIndex(
id = id.toIntOrNull() ?: -1,
id = id.toLongOrNull() ?: -1,
idPrefix = idPrefix,
indexRange = indexRange
)
@@ -693,7 +705,7 @@ private fun getTextWithVisualizedMentions(
val endIndex = mention.indexRange.last
addStyle(
style = SpanStyle(color = mentionColor),
style = SpanStyle(color = Color.Red),
start = startIndex,
end = endIndex
)
@@ -707,7 +719,7 @@ private fun getTextWithVisualizedMentions(
}
data class MentionIndex(
val id: Int,
val id: Long,
val idPrefix: String,
val indexRange: IntRange
)
@@ -755,6 +767,9 @@ private fun getAttachmentUiText(
AttachmentType.PODCAST -> UiR.string.message_attachments_podcast
AttachmentType.NARRATIVE -> UiR.string.message_attachments_narrative
AttachmentType.ARTICLE -> UiR.string.message_attachments_article
AttachmentType.VIDEO_MESSAGE -> UiR.string.message_attachments_video_message
AttachmentType.GROUP_CHAT_STICKER -> UiR.string.message_attachments_group_sticker
AttachmentType.STICKER_PACK_PREVIEW -> UiR.string.message_attachments_sticker_pack_preview
}.let(UiText::Resource)
}
@@ -796,10 +811,9 @@ private fun extractBirthday(conversation: VkConversation): Boolean {
private fun extractReadCondition(
conversation: VkConversation,
lastMessage: VkMessage?
): Boolean = (lastMessage?.isOut == true && conversation.isOutUnread()) ||
(lastMessage?.isOut == false && conversation.isInUnread())
): Boolean = !conversation.isRead(lastMessage)
private fun isAccount(peerId: Int) = peerId == UserConfig.userId
private fun isAccount(peerId: Long) = peerId == UserConfig.userId
private fun extractInteractionText(
resources: Resources,