Release 0.1.8 (#139)
* pagination in chat fixed * other fixes and improvements * fixed visual bug in progress bar in chat history * Refactor: Enhance conversations and friends features - In `ConversationsScreen`, removed `isNeedToScrollToTop` and `onScrolledToTop`, and refactored toolbar container color logic. Added `NoItemsView` for empty conversation lists. - In `MainGraph`, added `onMessageClicked` for navigation to message history. - In `ApiEvent`, introduced `parseOrNull` for handling unknown event types. - In `ConversationsViewModel`, removed `scrollToTop` logic and refactored error handling. - In `FriendsViewModel`, refactored error handling and introduced `onErrorConsumed` and `handleError`. - In `FriendItem`, added an icon button to initiate sending a message to a friend. - In `strings.xml`, added or updated strings for session expiration, log out, refreshing, and empty friend lists. - In `RootScreen`, added `onMessageClicked` for navigating to messages. - In `FriendsList`, added `onMessageClicked` for handling message clicks. - In `MainScreen`, removed unused `MutableSharedFlow`. - In `FriendsScreen`, added support for showing errors, added `onMessageClicked`, and replaced `hazeChild` with `hazeEffect` and `hazeSource`. - In `FriendsNavigation`, added `onMessageClicked` for handling message clicks. - In `ConversationsNavigation`, removed the unused `scrollToTopFlow` parameter. - In `ErrorView`, added text alignment. - In `NoItemsView`, added support for a button and custom text. - In `LongPollUpdatesParser`, replaced try-catch with `parseOrNull`. * Chat creation feature (#138) * - read indicator, edit status and time for message in messages history * message sending status
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,34 @@
|
||||
plugins {
|
||||
alias(libs.plugins.fast.android.feature)
|
||||
alias(libs.plugins.fast.android.library.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "dev.meloda.fast.createchat"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.domain)
|
||||
implementation(projects.core.model)
|
||||
implementation(projects.core.ui)
|
||||
|
||||
implementation(libs.bundles.nanokt)
|
||||
|
||||
implementation(libs.koin.android)
|
||||
implementation(libs.koin.androidx.compose)
|
||||
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.bundles.compose)
|
||||
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
implementation(libs.haze)
|
||||
implementation(libs.haze.materials)
|
||||
|
||||
implementation(libs.eithernet)
|
||||
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
implementation(libs.kotlin.serialization)
|
||||
}
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
package dev.meloda.fast.conversations
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import dev.meloda.fast.common.extensions.listenValue
|
||||
import dev.meloda.fast.common.extensions.setValue
|
||||
import dev.meloda.fast.conversations.model.CreateChatScreenState
|
||||
import dev.meloda.fast.data.State
|
||||
import dev.meloda.fast.data.UserConfig
|
||||
import dev.meloda.fast.data.processState
|
||||
import dev.meloda.fast.datastore.UserSettings
|
||||
import dev.meloda.fast.domain.FriendsUseCase
|
||||
import dev.meloda.fast.domain.GetLocalUserByIdUseCase
|
||||
import dev.meloda.fast.domain.MessagesUseCase
|
||||
import dev.meloda.fast.domain.util.asPresentation
|
||||
import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.network.VkErrorCode
|
||||
import dev.meloda.fast.ui.model.api.UiFriend
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
interface CreateChatViewModel {
|
||||
|
||||
val screenState: StateFlow<CreateChatScreenState>
|
||||
val baseError: StateFlow<BaseError?>
|
||||
val currentOffset: StateFlow<Int>
|
||||
val canPaginate: StateFlow<Boolean>
|
||||
|
||||
val isChatCreated: StateFlow<Int?>
|
||||
|
||||
fun onPaginationConditionsMet()
|
||||
fun onRefresh()
|
||||
fun onErrorConsumed()
|
||||
|
||||
fun toggleFriendSelection(userId: Int)
|
||||
|
||||
fun onTitleTextInputChanged(newTitle: String)
|
||||
|
||||
fun onCreateChatButtonClicked()
|
||||
|
||||
fun onNavigatedBack()
|
||||
}
|
||||
|
||||
class CreateChatViewModelImpl(
|
||||
private val friendsUseCase: FriendsUseCase,
|
||||
private val messagesUseCase: MessagesUseCase,
|
||||
private val imageLoader: ImageLoader,
|
||||
private val applicationContext: Context,
|
||||
private val getLocalUserByIdUseCase: GetLocalUserByIdUseCase,
|
||||
private val userSettings: UserSettings
|
||||
) : CreateChatViewModel, ViewModel() {
|
||||
|
||||
override val screenState = MutableStateFlow(CreateChatScreenState.EMPTY)
|
||||
override val baseError = MutableStateFlow<BaseError?>(null)
|
||||
override val currentOffset = MutableStateFlow(0)
|
||||
override val canPaginate = MutableStateFlow(false)
|
||||
|
||||
override val isChatCreated = MutableStateFlow<Int?>(null)
|
||||
|
||||
private val useContactNames: Boolean = userSettings.useContactNames.value
|
||||
|
||||
init {
|
||||
loadFriends()
|
||||
}
|
||||
|
||||
override fun onPaginationConditionsMet() {
|
||||
currentOffset.update { screenState.value.friends.size }
|
||||
loadFriends()
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
onErrorConsumed()
|
||||
loadFriends(offset = 0)
|
||||
}
|
||||
|
||||
override fun onErrorConsumed() {
|
||||
baseError.setValue { null }
|
||||
}
|
||||
|
||||
override fun toggleFriendSelection(userId: Int) {
|
||||
val newSelectionList = screenState.value.selectedFriendsIds.toMutableList()
|
||||
|
||||
if (newSelectionList.contains(userId)) {
|
||||
newSelectionList.remove(userId)
|
||||
} else {
|
||||
newSelectionList.add(userId)
|
||||
}
|
||||
|
||||
screenState.setValue { old ->
|
||||
old.copy(selectedFriendsIds = newSelectionList)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTitleTextInputChanged(newTitle: String) {
|
||||
screenState.setValue { old -> old.copy(chatTitle = newTitle) }
|
||||
}
|
||||
|
||||
override fun onCreateChatButtonClicked() {
|
||||
createChat()
|
||||
}
|
||||
|
||||
override fun onNavigatedBack() {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
isChatCreated.emit(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFriends(
|
||||
offset: Int = currentOffset.value
|
||||
) {
|
||||
friendsUseCase.getFriends(count = LOAD_COUNT, offset = offset)
|
||||
.listenValue(viewModelScope) { state ->
|
||||
state.processState(
|
||||
error = ::handleError,
|
||||
success = { response ->
|
||||
val itemsCountSufficient = response.size == LOAD_COUNT
|
||||
canPaginate.setValue { itemsCountSufficient }
|
||||
|
||||
val paginationExhausted = !itemsCountSufficient &&
|
||||
screenState.value.friends.isNotEmpty()
|
||||
|
||||
val imagesToPreload =
|
||||
response.mapNotNull { it.photo100.takeIf { !it.isNullOrEmpty() } }
|
||||
|
||||
imagesToPreload.forEach { url ->
|
||||
imageLoader.enqueue(
|
||||
ImageRequest.Builder(applicationContext)
|
||||
.data(url)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
friendsUseCase.storeUsers(response)
|
||||
|
||||
val loadedFriends = response.map {
|
||||
it.asPresentation(useContactNames)
|
||||
}
|
||||
|
||||
val newState = screenState.value.copy(
|
||||
isPaginationExhausted = paginationExhausted
|
||||
)
|
||||
if (offset == 0) {
|
||||
screenState.setValue {
|
||||
newState.copy(friends = loadedFriends)
|
||||
}
|
||||
} else {
|
||||
screenState.setValue {
|
||||
newState.copy(
|
||||
friends = newState.friends.plus(loadedFriends)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
screenState.setValue { old ->
|
||||
old.copy(
|
||||
isLoading = offset == 0 && state.isLoading(),
|
||||
isPaginating = offset > 0 && state.isLoading()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createChat() {
|
||||
viewModelScope.launch {
|
||||
val title = screenState.value.chatTitle.takeUnless(String::isBlank)
|
||||
|
||||
val accountAsFriend =
|
||||
getLocalUserByIdUseCase.proceed(UserConfig.userId)?.asPresentation(useContactNames)
|
||||
|
||||
val accountList = accountAsFriend?.let(::listOf) ?: emptyList()
|
||||
|
||||
val selectedFriends = screenState.value.selectedFriendsIds
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.mapNotNull { userId -> screenState.value.friends.find { it.userId == userId } }
|
||||
|
||||
messagesUseCase.createChat(
|
||||
userIds = selectedFriends?.map { it.userId },
|
||||
title = title
|
||||
?: (accountList + selectedFriends.orEmpty()).joinToString(transform = UiFriend::firstName)
|
||||
).listenValue(viewModelScope) { state ->
|
||||
state.processState(
|
||||
error = ::handleError,
|
||||
success = { response ->
|
||||
withContext(Dispatchers.Main) {
|
||||
isChatCreated.emit(2_000_000_000 + response)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleError(error: State.Error) {
|
||||
when (error) {
|
||||
is State.Error.ApiError -> {
|
||||
when (error.errorCode) {
|
||||
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
|
||||
baseError.setValue { BaseError.SessionExpired }
|
||||
}
|
||||
|
||||
else -> {
|
||||
baseError.setValue {
|
||||
BaseError.SimpleError(message = error.errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
State.Error.ConnectionError -> {
|
||||
baseError.setValue {
|
||||
BaseError.SimpleError(message = "Connection error")
|
||||
}
|
||||
}
|
||||
|
||||
State.Error.InternalError -> {
|
||||
baseError.setValue {
|
||||
BaseError.SimpleError(message = "Internal error")
|
||||
}
|
||||
}
|
||||
|
||||
State.Error.UnknownError -> {
|
||||
baseError.setValue {
|
||||
BaseError.SimpleError(message = "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LOAD_COUNT = 30
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package dev.meloda.fast.conversations.di
|
||||
|
||||
import dev.meloda.fast.conversations.CreateChatViewModelImpl
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val createChatModule = module {
|
||||
viewModelOf(::CreateChatViewModelImpl)
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.meloda.fast.conversations.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import dev.meloda.fast.ui.model.api.UiFriend
|
||||
|
||||
@Immutable
|
||||
data class CreateChatScreenState(
|
||||
val isLoading: Boolean,
|
||||
val isPaginating: Boolean,
|
||||
val isPaginationExhausted: Boolean,
|
||||
val friends: List<UiFriend>,
|
||||
val selectedFriendsIds: List<Int>,
|
||||
val chatTitle: String
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY: CreateChatScreenState = CreateChatScreenState(
|
||||
isLoading = true,
|
||||
isPaginating = false,
|
||||
isPaginationExhausted = false,
|
||||
friends = emptyList(),
|
||||
selectedFriendsIds = emptyList(),
|
||||
chatTitle = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.meloda.fast.conversations.navigation
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import dev.meloda.fast.conversations.CreateChatViewModel
|
||||
import dev.meloda.fast.conversations.CreateChatViewModelImpl
|
||||
import dev.meloda.fast.conversations.presentation.CreateChatRoute
|
||||
import dev.meloda.fast.ui.extensions.sharedViewModel
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
object CreateChat
|
||||
|
||||
fun NavGraphBuilder.createChatScreen(
|
||||
onChatCreated: (Int) -> Unit,
|
||||
navController: NavController,
|
||||
) {
|
||||
composable<CreateChat> {
|
||||
val viewModel: CreateChatViewModel =
|
||||
it.sharedViewModel<CreateChatViewModelImpl>(navController = navController)
|
||||
|
||||
CreateChatRoute(
|
||||
onError = {
|
||||
|
||||
},
|
||||
onBack = navController::popBackStack,
|
||||
onChatCreated = onChatCreated,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavController.navigateToCreateChat() {
|
||||
this.navigate(CreateChat)
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package dev.meloda.fast.conversations.presentation
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import dev.meloda.fast.ui.R
|
||||
import dev.meloda.fast.ui.model.api.UiFriend
|
||||
|
||||
|
||||
@Composable
|
||||
fun CreateChatItem(
|
||||
modifier: Modifier = Modifier,
|
||||
friend: UiFriend,
|
||||
maxLines: Int,
|
||||
isSelected: Boolean,
|
||||
onItemClicked: (Int) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onItemClicked(friend.userId) }
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
val friendAvatar = friend.avatar?.extractUrl()
|
||||
|
||||
Box(modifier = Modifier.size(56.dp)) {
|
||||
if (friendAvatar == null) {
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape),
|
||||
painter = painterResource(id = R.drawable.ic_account_circle_cut),
|
||||
contentDescription = "Avatar",
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground)
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = friendAvatar,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape),
|
||||
placeholder = painterResource(id = R.drawable.ic_account_circle_cut)
|
||||
)
|
||||
}
|
||||
|
||||
if (friend.onlineStatus.isOnline()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.size(18.dp)
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(2.dp)
|
||||
.align(Alignment.BottomEnd)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.matchParentSize()
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Text(
|
||||
text = friend.title,
|
||||
minLines = 1,
|
||||
maxLines = maxLines,
|
||||
style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { onItemClicked(friend.userId) },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package dev.meloda.fast.conversations.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowUp
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.meloda.fast.conversations.model.CreateChatScreenState
|
||||
import dev.meloda.fast.ui.R
|
||||
import dev.meloda.fast.ui.model.api.UiFriend
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun CreateChatList(
|
||||
screenState: CreateChatScreenState,
|
||||
state: LazyListState,
|
||||
maxLines: Int,
|
||||
modifier: Modifier,
|
||||
padding: PaddingValues,
|
||||
onItemClicked: (Int) -> Unit,
|
||||
onTitleTextInputChanged: (String) -> Unit
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
state = state
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
|
||||
|
||||
}
|
||||
items(
|
||||
items = screenState.friends,
|
||||
key = UiFriend::userId,
|
||||
) { friend ->
|
||||
CreateChatItem(
|
||||
maxLines = maxLines,
|
||||
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null),
|
||||
friend = friend,
|
||||
isSelected = screenState.selectedFriendsIds.contains(friend.userId),
|
||||
onItemClicked = onItemClicked
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateItem(fadeInSpec = null, fadeOutSpec = null),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (screenState.isPaginating) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
if (screenState.isPaginationExhausted) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
coroutineScope.launch(Dispatchers.Main) {
|
||||
state.scrollToItem(14)
|
||||
state.animateScrollToItem(0)
|
||||
}
|
||||
},
|
||||
colors = IconButtonDefaults.filledIconButtonColors()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.KeyboardArrowUp,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
package dev.meloda.fast.conversations.presentation
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.Done
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.CreateChatViewModel
|
||||
import dev.meloda.fast.conversations.model.CreateChatScreenState
|
||||
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.IconButton
|
||||
import dev.meloda.fast.ui.components.NoItemsView
|
||||
import dev.meloda.fast.ui.theme.LocalHazeState
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
import dev.meloda.fast.ui.util.isScrollingUp
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
@Composable
|
||||
fun CreateChatRoute(
|
||||
onError: (BaseError) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onChatCreated: (Int) -> Unit,
|
||||
viewModel: CreateChatViewModel
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
|
||||
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
|
||||
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
|
||||
val isChatCreated by viewModel.isChatCreated.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(isChatCreated) {
|
||||
if (isChatCreated != null) {
|
||||
onChatCreated(isChatCreated ?: -1)
|
||||
viewModel.onNavigatedBack()
|
||||
}
|
||||
}
|
||||
|
||||
CreateChatScreen(
|
||||
screenState = screenState,
|
||||
baseError = baseError,
|
||||
canPaginate = canPaginate,
|
||||
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
|
||||
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
|
||||
onBack = onBack,
|
||||
onRefresh = viewModel::onRefresh,
|
||||
onCreateChatButtonClicked = viewModel::onCreateChatButtonClicked,
|
||||
onItemClicked = viewModel::toggleFriendSelection,
|
||||
onTitleTextInputChanged = viewModel::onTitleTextInputChanged
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(
|
||||
ExperimentalMaterial3Api::class,
|
||||
ExperimentalHazeMaterialsApi::class,
|
||||
)
|
||||
@Composable
|
||||
fun CreateChatScreen(
|
||||
screenState: CreateChatScreenState = CreateChatScreenState.EMPTY,
|
||||
baseError: BaseError? = null,
|
||||
canPaginate: Boolean = false,
|
||||
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
|
||||
onPaginationConditionsMet: () -> Unit = {},
|
||||
onBack: () -> Unit = {},
|
||||
onRefresh: () -> Unit = {},
|
||||
onCreateChatButtonClicked: () -> Unit = {},
|
||||
onItemClicked: (Int) -> Unit = {},
|
||||
onTitleTextInputChanged: (String) -> Unit = {}
|
||||
) {
|
||||
val currentTheme = LocalThemeConfig.current
|
||||
|
||||
val maxLines by remember(currentTheme) {
|
||||
mutableIntStateOf(if (currentTheme.enableMultiline) 2 else 1)
|
||||
}
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val paginationConditionMet by remember(canPaginate, listState) {
|
||||
derivedStateOf {
|
||||
canPaginate &&
|
||||
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
|
||||
?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(paginationConditionMet) {
|
||||
if (paginationConditionMet && !screenState.isPaginating) {
|
||||
onPaginationConditionsMet()
|
||||
}
|
||||
}
|
||||
|
||||
val hazeState = LocalHazeState.current
|
||||
|
||||
val toolbarColorAlpha by animateFloatAsState(
|
||||
targetValue = if (!listState.canScrollBackward) 1f else 0f,
|
||||
label = "toolbarColorAlpha",
|
||||
animationSpec = tween(durationMillis = 50)
|
||||
)
|
||||
|
||||
val toolbarContainerColor by animateColorAsState(
|
||||
targetValue =
|
||||
if (currentTheme.enableBlur || !listState.canScrollBackward)
|
||||
MaterialTheme.colorScheme.surface
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
|
||||
label = "toolbarColorAlpha",
|
||||
animationSpec = tween(durationMillis = 50)
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentWindowInsets = WindowInsets.statusBars,
|
||||
topBar = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
toolbarContainerColor.copy(
|
||||
alpha = if (currentTheme.enableBlur) toolbarColorAlpha else 1f
|
||||
)
|
||||
)
|
||||
.then(
|
||||
if (currentTheme.enableBlur) {
|
||||
Modifier.hazeEffect(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.thick()
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
) {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = if (screenState.isLoading) UiR.string.title_loading
|
||||
else UiR.string.title_create_chat
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = toolbarContainerColor.copy(
|
||||
alpha = 0f
|
||||
)
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
var isTextFieldFocused by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val borderWidth by animateDpAsState(if (isTextFieldFocused) 1.5.dp else 0.dp)
|
||||
val borderColor by animateColorAsState(
|
||||
if (isTextFieldFocused) MaterialTheme.colorScheme.primary
|
||||
else Color.Transparent
|
||||
)
|
||||
|
||||
TextField(
|
||||
modifier = Modifier
|
||||
.height(58.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.border(
|
||||
borderWidth,
|
||||
borderColor,
|
||||
RoundedCornerShape(16.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.onFocusChanged { isTextFieldFocused = it.hasFocus },
|
||||
value = screenState.chatTitle,
|
||||
onValueChange = onTitleTextInputChanged,
|
||||
label = { Text(text = stringResource(UiR.string.create_chat_title)) },
|
||||
placeholder = { Text(text = stringResource(UiR.string.create_chat_title)) },
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (baseError == null) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.imePadding()
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onCreateChatButtonClicked,
|
||||
expanded = listState.isScrollingUp(),
|
||||
text = { Text(text = stringResource(UiR.string.action_create)) },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Done,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader()
|
||||
|
||||
else -> {
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
|
||||
PullToRefreshBox(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
|
||||
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr))
|
||||
.padding(bottom = padding.calculateBottomPadding()),
|
||||
state = pullToRefreshState,
|
||||
isRefreshing = screenState.isLoading,
|
||||
onRefresh = onRefresh,
|
||||
indicator = {
|
||||
PullToRefreshDefaults.Indicator(
|
||||
state = pullToRefreshState,
|
||||
isRefreshing = screenState.isLoading,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = padding.calculateTopPadding()),
|
||||
)
|
||||
}
|
||||
) {
|
||||
CreateChatList(
|
||||
screenState = screenState,
|
||||
state = listState,
|
||||
maxLines = maxLines,
|
||||
modifier = if (currentTheme.enableBlur) {
|
||||
Modifier.hazeSource(state = hazeState)
|
||||
} else {
|
||||
Modifier
|
||||
}.fillMaxSize(),
|
||||
padding = padding,
|
||||
onItemClicked = onItemClicked,
|
||||
onTitleTextInputChanged = onTitleTextInputChanged
|
||||
)
|
||||
|
||||
if (screenState.friends.isEmpty()) {
|
||||
NoItemsView(
|
||||
buttonText = stringResource(UiR.string.action_refresh),
|
||||
onButtonClick = onRefresh
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user