Upstream changes (#23)

This commit is contained in:
2024-07-11 02:12:32 +03:00
committed by GitHub
parent 8a6378f509
commit 3503ecffab
906 changed files with 23577 additions and 24115 deletions
+1
View File
@@ -0,0 +1 @@
/build
+92
View File
@@ -0,0 +1,92 @@
import com.android.build.api.variant.BuildConfigField
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
val sdkPackage: String = getLocalProperty("sdkPackage", "\"\"")
val sdkFingerprint: String = getLocalProperty("sdkFingerprint", "\"\"")
fun getLocalProperty(key: String, defValue: String): String {
return gradleLocalProperties(rootDir, providers).getProperty(key, defValue)
}
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.com.google.devtools.ksp)
alias(libs.plugins.kotlin.compose.compiler)
alias(libs.plugins.org.jetbrains.kotlin.plugin.parcelize)
alias(libs.plugins.kotlin.serialization)
}
group = "com.meloda.app.fast.auth"
androidComponents {
onVariants { variant ->
variant.buildConfigFields.apply {
put(
"sdkPackage",
BuildConfigField(
type = "String",
value = sdkPackage,
comment = "sdkPackage for VK"
)
)
put(
"sdkFingerprint",
BuildConfigField(
type = "String",
value = sdkFingerprint,
comment = "sdkFingerprint for VK"
)
)
}
}
}
android {
namespace = "com.meloda.app.fast.auth"
compileSdk = Configs.compileSdk
defaultConfig {
minSdk = Configs.minSdk
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = Configs.java
targetCompatibility = Configs.java
}
kotlinOptions {
jvmTarget = Configs.java.toString()
freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-receivers")
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
useLiveLiterals = true
}
}
dependencies {
implementation(projects.core.data)
implementation(projects.core.ui)
implementation(projects.feature.conversations)
implementation(projects.feature.auth.login)
implementation(projects.feature.auth.captcha)
implementation(projects.feature.auth.twofa)
implementation(projects.feature.auth.userbanned)
implementation(libs.koin.androidx.compose)
implementation(libs.koin.android)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization)
}
+1
View File
@@ -0,0 +1 @@
/build
+59
View File
@@ -0,0 +1,59 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.kotlin.compose.compiler)
alias(libs.plugins.org.jetbrains.kotlin.plugin.parcelize)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.meloda.app.fast.captcha"
compileSdk = Configs.compileSdk
defaultConfig {
minSdk = Configs.minSdk
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = Configs.java
targetCompatibility = Configs.java
}
kotlinOptions {
jvmTarget = Configs.java.toString()
freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-receivers")
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
useLiveLiterals = true
}
}
dependencies {
implementation(projects.core.data)
implementation(projects.core.ui)
implementation(libs.nanokt.android)
implementation(libs.nanokt.jvm)
implementation(libs.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.eithernet)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization)
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,81 @@
package com.meloda.app.fast.auth.captcha
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import com.meloda.app.fast.auth.captcha.model.CaptchaArguments
import com.meloda.app.fast.auth.captcha.model.CaptchaScreenState
import com.meloda.app.fast.auth.captcha.navigation.Captcha
import com.meloda.app.fast.auth.captcha.validation.CaptchaValidator
import com.meloda.app.fast.common.extensions.setValue
import com.meloda.app.fast.common.extensions.updateValue
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
interface CaptchaViewModel {
val screenState: StateFlow<CaptchaScreenState>
fun onCodeInputChanged(newCode: String)
fun onTextFieldDoneClicked()
fun onDoneButtonClicked()
fun setArguments(arguments: CaptchaArguments)
fun onNavigatedToLogin()
}
class CaptchaViewModelImpl(
private val validator: CaptchaValidator,
savedStateHandle: SavedStateHandle
) : CaptchaViewModel, ViewModel() {
override val screenState = MutableStateFlow(CaptchaScreenState.EMPTY)
init {
val arguments = Captcha.from(savedStateHandle).arguments
screenState.setValue { old ->
old.copy(
captchaSid = arguments.captchaSid,
captchaImage = arguments.captchaImage
)
}
}
override fun onCodeInputChanged(newCode: String) {
val newState = screenState.value.copy(captchaCode = newCode.trim())
screenState.update { newState }
processValidation()
}
override fun onTextFieldDoneClicked() {
onDoneButtonClicked()
}
override fun onDoneButtonClicked() {
if (!processValidation()) return
screenState.updateValue(screenState.value.copy(isNeedToOpenLogin = true))
}
override fun setArguments(arguments: CaptchaArguments) {
// screenState.updateValue(
// screenState.value.copy(
// captchaSid = arguments.captchaSid,
// captchaImage = arguments.captchaImage
// )
// )
}
override fun onNavigatedToLogin() {
screenState.updateValue(CaptchaScreenState.EMPTY)
}
private fun processValidation(): Boolean {
val isValid = validator.validate(screenState.value).isValid()
screenState.updateValue(screenState.value.copy(codeError = !isValid))
return isValid
}
}
@@ -0,0 +1,14 @@
package com.meloda.app.fast.auth.captcha.di
import com.meloda.app.fast.auth.captcha.CaptchaViewModel
import com.meloda.app.fast.auth.captcha.CaptchaViewModelImpl
import com.meloda.app.fast.auth.captcha.validation.CaptchaValidator
import org.koin.androidx.viewmodel.dsl.viewModelOf
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
val captchaModule = module {
singleOf(::CaptchaValidator)
viewModelOf(::CaptchaViewModelImpl) bind CaptchaViewModel::class
}
@@ -0,0 +1,12 @@
package com.meloda.app.fast.auth.captcha.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class CaptchaArguments(
val captchaSid: String,
val captchaImage: String
) : Parcelable
@@ -0,0 +1,20 @@
package com.meloda.app.fast.auth.captcha.model
data class CaptchaScreenState(
val captchaSid: String,
val captchaImage: String,
val captchaCode: String,
val codeError: Boolean,
val isNeedToOpenLogin: Boolean
) {
companion object {
val EMPTY = CaptchaScreenState(
captchaSid = "",
captchaImage = "",
captchaCode = "",
codeError = false,
isNeedToOpenLogin = false
)
}
}
@@ -0,0 +1,8 @@
package com.meloda.app.fast.auth.captcha.model
sealed class CaptchaValidationResult {
data object Empty : CaptchaValidationResult()
data object Valid : CaptchaValidationResult()
fun isValid() = this == Valid
}
@@ -0,0 +1,48 @@
package com.meloda.app.fast.auth.captcha.navigation
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.meloda.app.fast.auth.captcha.model.CaptchaArguments
import com.meloda.app.fast.auth.captcha.presentation.CaptchaScreen
import com.meloda.app.fast.common.customNavType
import kotlinx.serialization.Serializable
import kotlin.reflect.typeOf
@Serializable
data class Captcha(val arguments: CaptchaArguments) {
companion object {
val typeMap = mapOf(typeOf<CaptchaArguments>() to customNavType<CaptchaArguments>())
fun from(savedStateHandle: SavedStateHandle) =
savedStateHandle.toRoute<Captcha>(typeMap)
}
}
fun NavGraphBuilder.captchaRoute(
onBack: () -> Unit,
onResult: (String) -> Unit
) {
composable<Captcha>(
typeMap = Captcha.typeMap
) {
CaptchaScreen(
onBack = onBack,
onResult = onResult
)
}
}
fun NavController.navigateToCaptcha(arguments: CaptchaArguments) {
this.navigate(Captcha(arguments))
}
fun NavController.setCaptchaResult(code: String) {
this.currentBackStackEntry
?.savedStateHandle
?.set("captchacode", code)
}
@@ -0,0 +1,234 @@
package com.meloda.app.fast.auth.captcha.presentation
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Done
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton
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.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.meloda.app.fast.auth.captcha.CaptchaViewModel
import com.meloda.app.fast.auth.captcha.CaptchaViewModelImpl
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.designsystem.MaterialDialog
import com.meloda.app.fast.designsystem.TextFieldErrorText
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@Composable
fun CaptchaScreen(
onBack: () -> Unit,
onResult: (String) -> Unit,
viewModel: CaptchaViewModel = koinViewModel<CaptchaViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
var confirmedExit by rememberSaveable {
mutableStateOf(false)
}
var showExitAlert by rememberSaveable {
mutableStateOf(false)
}
if (confirmedExit) {
onBack()
}
BackHandler(enabled = !confirmedExit) {
if (!confirmedExit) {
showExitAlert = true
}
}
if (showExitAlert) {
MaterialDialog(
onDismissAction = { showExitAlert = false },
title = UiText.Simple("Confirmation"),
text = UiText.Simple("Are you sure? Captcha process will be cancelled."),
confirmText = UiText.Resource(UiR.string.yes),
confirmAction = {
confirmedExit = true
},
cancelText = UiText.Resource(UiR.string.no)
)
}
if (screenState.isNeedToOpenLogin) {
viewModel.onNavigatedToLogin()
onResult(screenState.captchaCode)
}
val focusManager = LocalFocusManager.current
Scaffold { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(30.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
ExtendedFloatingActionButton(
onClick = onBack,
text = {
Text(
text = "Cancel",
color = MaterialTheme.colorScheme.primary
)
},
icon = {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = "Close icon",
tint = MaterialTheme.colorScheme.primary,
)
}
)
Column(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Captcha",
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(38.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "To proceed with your action, enter a code from the picture",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(0.5f)
)
Spacer(modifier = Modifier.width(24.dp))
val imageModifier = Modifier
.border(
2.dp,
MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(10.dp)
)
.clip(RoundedCornerShape(10.dp))
.height(48.dp)
.width(130.dp)
if (LocalView.current.isInEditMode) {
Image(
painter = painterResource(id = UiR.drawable.test_captcha),
contentDescription = "Captcha image",
modifier = imageModifier
)
} else {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(screenState.captchaImage)
.crossfade(true)
.build(),
contentDescription = "Captcha image",
contentScale = ContentScale.FillBounds,
modifier = imageModifier
)
}
}
Spacer(modifier = Modifier.height(30.dp))
var code by remember { mutableStateOf(TextFieldValue(screenState.captchaCode)) }
val showError = screenState.codeError
TextField(
value = code,
onValueChange = { newText ->
code = newText
viewModel.onCodeInputChanged(newText.text)
},
label = { Text(text = "Code") },
placeholder = { Text(text = "Code") },
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp)),
leadingIcon = {
Icon(
painter = painterResource(id = UiR.drawable.round_qr_code_24),
contentDescription = "QR code icon",
tint = if (showError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
}
)
},
shape = RoundedCornerShape(10.dp),
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
viewModel.onTextFieldDoneClicked()
}
),
isError = showError
)
AnimatedVisibility(visible = showError) {
TextFieldErrorText(text = "Field must not be empty")
}
}
FloatingActionButton(
onClick = viewModel::onDoneButtonClicked,
containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Icon(
imageVector = Icons.Rounded.Done,
contentDescription = "Done icon",
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
}
@@ -0,0 +1,14 @@
package com.meloda.app.fast.auth.captcha.validation
import com.meloda.app.fast.auth.captcha.model.CaptchaScreenState
import com.meloda.app.fast.auth.captcha.model.CaptchaValidationResult
class CaptchaValidator {
fun validate(screenState: CaptchaScreenState): CaptchaValidationResult {
return when {
screenState.captchaCode.isEmpty() -> CaptchaValidationResult.Empty
else -> CaptchaValidationResult.Valid
}
}
}
+1
View File
@@ -0,0 +1 @@
/build
+92
View File
@@ -0,0 +1,92 @@
import com.android.build.api.variant.BuildConfigField
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
val debugUserId: String = getLocalProperty("userId", "\"0\"")
val debugAccessToken: String = getLocalProperty("accessToken", "\"\"")
fun getLocalProperty(key: String, defValue: String): String {
return gradleLocalProperties(rootDir, providers).getProperty(key, defValue)
}
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.kotlin.compose.compiler)
alias(libs.plugins.org.jetbrains.kotlin.plugin.parcelize)
alias(libs.plugins.kotlin.serialization)
}
androidComponents {
onVariants { variant ->
variant.buildConfigFields.apply {
put(
"debugUserId",
BuildConfigField(
type = "String",
value = debugUserId,
comment = "user id for debugging purposes"
)
)
put(
"debugAccessToken",
BuildConfigField(
type = "String",
value = debugAccessToken,
comment = "access token for debugging purposes"
)
)
}
}
}
android {
namespace = "com.meloda.app.fast.auth.login"
compileSdk = Configs.compileSdk
defaultConfig {
minSdk = Configs.minSdk
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = Configs.java
targetCompatibility = Configs.java
}
kotlinOptions {
jvmTarget = Configs.java.toString()
freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-receivers")
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
useLiveLiterals = true
}
}
dependencies {
implementation(projects.core.data)
implementation(projects.core.ui)
implementation(libs.nanokt.android)
implementation(libs.nanokt.jvm)
implementation(libs.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.eithernet)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization)
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,376 @@
package com.meloda.fast.auth.login
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.meloda.app.fast.auth.login.BuildConfig
import com.meloda.app.fast.common.UserConfig
import com.meloda.app.fast.common.VkConstants
import com.meloda.app.fast.common.extensions.listenValue
import com.meloda.app.fast.common.extensions.setValue
import com.meloda.app.fast.common.extensions.updateValue
import com.meloda.app.fast.data.State
import com.meloda.app.fast.data.api.users.UsersUseCase
import com.meloda.app.fast.data.db.AccountsRepository
import com.meloda.app.fast.data.processState
import com.meloda.app.fast.model.database.AccountEntity
import com.meloda.app.fast.network.OAuthErrorDomain
import com.meloda.fast.auth.login.model.CaptchaArguments
import com.meloda.fast.auth.login.model.LoginScreenState
import com.meloda.fast.auth.login.model.LoginTwoFaArguments
import com.meloda.fast.auth.login.model.LoginValidationResult
import com.meloda.fast.auth.login.validation.LoginValidator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
interface LoginViewModel {
val screenState: StateFlow<LoginScreenState>
fun onPasswordVisibilityButtonClicked()
fun onLoginInputChanged(newLogin: String)
fun onPasswordInputChanged(newPassword: String)
fun onSignInButtonClicked()
fun onErrorDialogDismissed()
fun onNavigatedToMain()
fun onNavigatedToUserBanned()
fun onNavigatedToCaptcha()
fun onNavigatedToTwoFa()
fun onTwoFaCodeReceived(code: String)
fun onCaptchaCodeReceived(code: String)
fun onLogoLongClicked()
}
class LoginViewModelImpl(
private val oAuthUseCase: OAuthUseCase,
private val usersUseCase: UsersUseCase,
private val accountsRepository: AccountsRepository,
private val loginValidator: LoginValidator,
) : ViewModel(), LoginViewModel {
override val screenState = MutableStateFlow(LoginScreenState.EMPTY)
private val validationState: StateFlow<List<LoginValidationResult>> =
screenState.map(loginValidator::validate)
.stateIn(viewModelScope, SharingStarted.Eagerly, listOf(LoginValidationResult.Empty))
override fun onPasswordVisibilityButtonClicked() {
screenState.setValue { old -> old.copy(passwordVisible = !old.passwordVisible) }
}
override fun onLoginInputChanged(newLogin: String) {
val newState = screenState.value.copy(
login = newLogin.trim(),
loginError = false
)
screenState.updateValue(newState)
}
override fun onPasswordInputChanged(newPassword: String) {
val newState = screenState.value.copy(
password = newPassword.trim(),
passwordError = false
)
screenState.updateValue(newState)
}
override fun onSignInButtonClicked() {
login()
}
override fun onErrorDialogDismissed() {
screenState.setValue { old -> old.copy(error = null) }
}
override fun onNavigatedToMain() {
screenState.setValue { old -> old.copy(isNeedToNavigateToMain = false) }
}
override fun onNavigatedToUserBanned() {
screenState.setValue { old -> old.copy(userBannedArguments = null) }
}
override fun onNavigatedToCaptcha() {
screenState.setValue { old -> old.copy(captchaArguments = null) }
}
override fun onNavigatedToTwoFa() {
screenState.setValue { old -> old.copy(twoFaArguments = null) }
}
override fun onTwoFaCodeReceived(code: String) {
screenState.setValue { old -> old.copy(validationCode = code) }
login()
}
override fun onCaptchaCodeReceived(code: String) {
screenState.setValue { old -> old.copy(captchaCode = code) }
login()
}
override fun onLogoLongClicked() {
val currentAccount = AccountEntity(
userId = BuildConfig.debugUserId.toInt(),
accessToken = BuildConfig.debugAccessToken,
fastToken = null,
trustedHash = null
).also { account ->
UserConfig.currentUserId = account.userId
UserConfig.userId = account.userId
UserConfig.accessToken = account.accessToken
UserConfig.fastToken = account.fastToken
UserConfig.trustedHash = account.trustedHash
}
viewModelScope.launch(Dispatchers.IO) {
accountsRepository.storeAccounts(listOf(currentAccount))
delay(350)
screenState.setValue { old -> old.copy(isNeedToNavigateToMain = true) }
}
}
private fun login(forceSms: Boolean = false) {
val currentState = screenState.value.copy()
Log.d(
"LoginViewModel",
"auth: login: ${currentState.login}; password: ${currentState.password}; code: ${currentState.validationCode}"
)
processValidation()
if (!validationState.value.contains(LoginValidationResult.Valid)) return
oAuthUseCase.auth(
login = currentState.login,
password = currentState.password,
forceSms = forceSms,
twoFaCode = currentState.validationCode,
captchaSid = currentState.captchaArguments?.captchaSid,
captchaKey = currentState.captchaCode
).listenValue { state ->
state.processState(
error = { error ->
Log.d("LoginViewModelImpl", "login: error: $error")
parseError(error)
},
success = { response ->
val userId = response.userId
val accessToken = response.accessToken
if (userId == null || accessToken == null) {
// TODO: 11/04/2024, Danil Nikolaev: send unknown error event
return@processState
}
usersUseCase.getUserById(
userId = userId,
fields = VkConstants.USER_FIELDS,
nomCase = null
).listenValue { state ->
state.processState(
error = {},
success = { user -> user?.let { usersUseCase.storeUser(user) } }
)
}
val currentAccount = AccountEntity(
userId = userId,
accessToken = accessToken,
fastToken = null,
trustedHash = response.twoFaHash
).also { account ->
UserConfig.currentUserId = account.userId
UserConfig.userId = account.userId
UserConfig.accessToken = account.accessToken
UserConfig.fastToken = account.fastToken
UserConfig.trustedHash = account.trustedHash
}
accountsRepository.storeAccounts(listOf(currentAccount))
screenState.setValue { old ->
old.copy(
captchaArguments = null,
captchaCode = null,
validationSid = null,
validationCode = null,
twoFaArguments = null,
login = "",
password = "",
isNeedToNavigateToMain = true
)
}
}
)
screenState.emit(screenState.value.copy(isLoading = state.isLoading()))
}
}
private fun parseError(stateError: State.Error): Boolean {
return when (stateError) {
is State.Error.OAuthError -> {
when (val error = stateError.error) {
is OAuthErrorDomain.ValidationRequiredError -> {
val twoFaArguments = LoginTwoFaArguments(
validationSid = error.validationSid,
redirectUri = error.redirectUri,
phoneMask = error.phoneMask,
validationType = error.validationType.value,
canResendSms = error.validationResend == "sms",
wrongCodeError = null
)
screenState.setValue { old -> old.copy(twoFaArguments = twoFaArguments) }
true
}
is OAuthErrorDomain.CaptchaRequiredError -> {
val captchaArguments = CaptchaArguments(
captchaSid = error.captchaSid,
captchaImage = error.captchaImageUrl
)
screenState.setValue { old -> old.copy(captchaArguments = captchaArguments) }
true
}
OAuthErrorDomain.InvalidCredentialsError -> TODO()
is OAuthErrorDomain.UserBannedError -> TODO()
OAuthErrorDomain.WrongTwoFaCode -> TODO()
OAuthErrorDomain.WrongTwoFaCodeFormat -> TODO()
OAuthErrorDomain.UnknownError -> TODO()
}
}
else -> false
}
// return when (val error =
// (stateError as? State.Error.OAuthError<*>)?.error) {
// null -> false
// is CaptchaRequiredError -> {
// val captchaArguments = CaptchaArguments(
// captchaSid = error.captchaSid,
// captchaImage = error.captchaImage,
// )
//
// screenState.setValue { old ->
// old.copy(
// isNeedToOpenCaptcha = true,
// captchaArguments = captchaArguments
// )
// }
//
// true
// }
//
// is InvalidCredentialsError -> {
// screenState.setValue { old -> old.copy(error = LoginError.WrongCredentials) }
//
// true
// }
//
// is UserBannedError -> {
// val banInfo = error.banInfo
//
// val userBannedArguments = UserBannedArguments(
// name = banInfo.memberName,
// message = banInfo.message,
// restoreUrl = banInfo.restoreUrl,
// accessToken = banInfo.accessToken
// )
//
// screenState.setValue { old ->
// old.copy(
// isNeedToOpenUserBanned = true,
// userBannedArguments = userBannedArguments
// )
// }
//
// true
// }
//
// is ValidationRequiredError -> {
// val twoFaArguments = TwoFaArguments(
// validationSid = error.validationSid,
// redirectUri = error.redirectUri,
// phoneMask = error.phoneMask,
// validationType = error.validationType,
// canResendSms = error.validationResend == "sms",
// wrongCodeError = null
// )
//
// screenState.setValue { old ->
// old.copy(
// isNeedToOpenTwoFa = true,
// twoFaArguments = twoFaArguments
// )
// }
//
// true
// }
//
// is WrongTwoFaCode -> {
// screenState.setValue { old ->
// old.copy(
// isNeedToOpenTwoFa = true,
// twoFaArguments = old.twoFaArguments?.copy(
// wrongCodeError = UiText.Simple("Wrong code")
// )
// )
// }
//
// true
// }
//
// is WrongTwoFaCodeFormat -> {
// screenState.setValue { old ->
// old.copy(
// isNeedToOpenTwoFa = true,
// twoFaArguments = old.twoFaArguments?.copy(
// wrongCodeError = UiText.Simple("Wrong code format")
// )
// )
// }
//
// true
// }
// else -> false
// }
}
private fun processValidation() {
validationState.value.forEach { result ->
when (result) {
LoginValidationResult.LoginEmpty -> {
screenState.updateValue(screenState.value.copy(loginError = true))
}
LoginValidationResult.PasswordEmpty -> {
screenState.updateValue(screenState.value.copy(passwordError = true))
}
LoginValidationResult.Empty -> Unit
LoginValidationResult.Valid -> Unit
}
}
}
}
@@ -0,0 +1,17 @@
package com.meloda.fast.auth.login
import com.meloda.app.fast.data.State
import com.meloda.fast.auth.login.model.AuthInfo
import kotlinx.coroutines.flow.Flow
interface OAuthUseCase {
fun auth(
login: String,
password: String,
forceSms: Boolean,
twoFaCode: String?,
captchaSid: String?,
captchaKey: String?
): Flow<State<AuthInfo>>
}
@@ -0,0 +1,111 @@
package com.meloda.fast.auth.login
import com.meloda.app.fast.data.State
import com.meloda.app.fast.data.api.oauth.OAuthRepository
import com.meloda.app.fast.network.OAuthErrorDomain
import com.meloda.app.fast.network.ValidationType
import com.meloda.app.fast.network.VkErrorTypes
import com.meloda.app.fast.network.VkOAuthErrors
import com.meloda.fast.auth.login.model.AuthInfo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class OAuthUseCaseImpl(
private val oAuthRepository: OAuthRepository
) : OAuthUseCase {
override fun auth(
login: String,
password: String,
forceSms: Boolean,
twoFaCode: String?,
captchaSid: String?,
captchaKey: String?
): Flow<State<AuthInfo>> = flow {
emit(State.Loading)
val response = oAuthRepository.auth(
login = login,
password = password,
twoFaCode = twoFaCode,
captchaSid = captchaSid,
captchaKey = captchaKey,
forceSms = forceSms
)
val newState = when (response.error) {
null -> {
State.Success(
AuthInfo(
userId = response.userId,
accessToken = response.accessToken,
twoFaHash = response.twoFaHash
)
)
}
VkOAuthErrors.NEED_VALIDATION -> {
if (response.banInfo != null) {
val info = requireNotNull(response.banInfo)
State.Error.OAuthError(
OAuthErrorDomain.UserBannedError(
memberName = info.memberName,
message = info.message,
accessToken = info.accessToken,
restoreUrl = info.restoreUrl
)
)
} else {
State.Error.OAuthError(
OAuthErrorDomain.ValidationRequiredError(
description = response.errorDescription.orEmpty(),
validationType = response.validationType.orEmpty()
.let(ValidationType::parse),
validationSid = response.validationSid.orEmpty(),
phoneMask = response.phoneMask.orEmpty(),
redirectUri = response.redirectUri.orEmpty(),
validationResend = response.validationResend,
restoreIfCannotGetCode = response.restoreIfCannotGetCode
)
)
}
}
VkOAuthErrors.NEED_CAPTCHA -> {
State.Error.OAuthError(
OAuthErrorDomain.CaptchaRequiredError(
captchaSid = response.captchaSid.orEmpty(),
captchaImageUrl = response.captchaImage.orEmpty()
)
)
}
VkOAuthErrors.INVALID_CLIENT -> {
State.Error.OAuthError(OAuthErrorDomain.InvalidCredentialsError)
}
VkOAuthErrors.INVALID_REQUEST -> {
when (response.errorType) {
VkErrorTypes.WRONG_OTP -> {
State.Error.OAuthError(OAuthErrorDomain.WrongTwoFaCode)
}
VkErrorTypes.WRONG_OTP_FORMAT -> {
State.Error.OAuthError(OAuthErrorDomain.WrongTwoFaCodeFormat)
}
else -> {
State.Error.OAuthError(OAuthErrorDomain.UnknownError)
}
}
}
else -> {
State.Error.OAuthError(OAuthErrorDomain.UnknownError)
}
}
emit(newState)
}
}
@@ -0,0 +1,17 @@
package com.meloda.fast.auth.login.di
import com.meloda.fast.auth.login.LoginViewModel
import com.meloda.fast.auth.login.LoginViewModelImpl
import com.meloda.fast.auth.login.OAuthUseCase
import com.meloda.fast.auth.login.OAuthUseCaseImpl
import com.meloda.fast.auth.login.validation.LoginValidator
import org.koin.androidx.viewmodel.dsl.viewModelOf
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
val loginModule = module {
singleOf(::LoginValidator)
viewModelOf(::LoginViewModelImpl) bind LoginViewModel::class
singleOf(::OAuthUseCaseImpl) bind OAuthUseCase::class
}
@@ -0,0 +1,7 @@
package com.meloda.fast.auth.login.model
data class AuthInfo(
val userId: Int?,
val accessToken: String?,
val twoFaHash: String?
)
@@ -0,0 +1,12 @@
package com.meloda.fast.auth.login.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class CaptchaArguments(
val captchaSid: String,
val captchaImage: String
) : Parcelable
@@ -0,0 +1,8 @@
package com.meloda.fast.auth.login.model
data class LoginArguments(val code: String) {
companion object {
val EMPTY: LoginArguments = LoginArguments(code = "")
}
}
@@ -0,0 +1,6 @@
package com.meloda.fast.auth.login.model
sealed interface LoginError {
data object WrongCredentials : LoginError
}
@@ -0,0 +1,44 @@
package com.meloda.fast.auth.login.model
import androidx.compose.runtime.Immutable
// TODO: 04/05/2024, Danil Nikolaev: simplify
@Immutable
data class LoginScreenState(
val login: String,
val password: String,
val captchaCode: String?,
val validationSid: String?,
val validationCode: String?,
val isLoading: Boolean,
val loginError: Boolean,
val passwordError: Boolean,
val passwordVisible: Boolean,
val copiedCode: String?,
val isNeedToNavigateToMain: Boolean,
val twoFaArguments: LoginTwoFaArguments?,
val captchaArguments: CaptchaArguments?,
val userBannedArguments: UserBannedArguments?,
val error: LoginError?,
) {
companion object {
val EMPTY = LoginScreenState(
login = "",
password = "",
captchaCode = null,
validationSid = null,
validationCode = null,
isLoading = false,
loginError = false,
passwordError = false,
passwordVisible = false,
copiedCode = null,
isNeedToNavigateToMain = false,
twoFaArguments = null,
captchaArguments = null,
userBannedArguments = null,
error = null,
)
}
}
@@ -0,0 +1,16 @@
package com.meloda.fast.auth.login.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class LoginTwoFaArguments(
val validationSid: String,
val redirectUri: String,
val phoneMask: String,
val validationType: String,
val canResendSms: Boolean,
val wrongCodeError: String?,
) : Parcelable
@@ -0,0 +1,12 @@
package com.meloda.fast.auth.login.model
sealed class LoginValidationResult {
data object LoginEmpty : LoginValidationResult()
data object PasswordEmpty : LoginValidationResult()
data object Empty : LoginValidationResult()
data object Valid : LoginValidationResult()
}
@@ -0,0 +1,14 @@
package com.meloda.fast.auth.login.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class UserBannedArguments(
val name: String,
val message: String,
val restoreUrl: String,
val accessToken: String
) : Parcelable
@@ -0,0 +1,56 @@
package com.meloda.fast.auth.login.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.meloda.app.fast.common.extensions.navigation.sharedViewModel
import com.meloda.app.fast.model.BaseError
import com.meloda.fast.auth.login.LoginViewModel
import com.meloda.fast.auth.login.LoginViewModelImpl
import com.meloda.fast.auth.login.model.CaptchaArguments
import com.meloda.fast.auth.login.model.LoginTwoFaArguments
import com.meloda.fast.auth.login.model.UserBannedArguments
import com.meloda.fast.auth.login.presentation.LoginScreen
import com.meloda.fast.auth.login.presentation.LogoScreen
import kotlinx.serialization.Serializable
@Serializable
object Login
@Serializable
object Logo
fun NavGraphBuilder.loginRoute(
onError: (BaseError) -> Unit,
onNavigateToCaptcha: (CaptchaArguments) -> Unit,
onNavigateToTwoFa: (LoginTwoFaArguments) -> Unit,
onNavigateToMain: () -> Unit,
onNavigateToUserBanned: (UserBannedArguments) -> Unit,
onNavigateToCredentials: () -> Unit,
navController: NavController
) {
composable<Login> {
val viewModel: LoginViewModel =
it.sharedViewModel<LoginViewModelImpl>(navController = navController)
LoginScreen(
onError = onError,
onNavigateToUserBanned = onNavigateToUserBanned,
onNavigateToMain = onNavigateToMain,
onNavigateToCaptcha = onNavigateToCaptcha,
onNavigateToTwoFa = onNavigateToTwoFa,
viewModel = viewModel
)
}
composable<Logo> {
LogoScreen(
onNavigateToMain = onNavigateToMain,
onShowCredentials = onNavigateToCredentials
)
}
}
fun NavController.navigateToLogin() {
this.navigate(route = Login)
}
@@ -0,0 +1,330 @@
package com.meloda.fast.auth.login.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.designsystem.MaterialDialog
import com.meloda.app.fast.designsystem.TextFieldErrorText
import com.meloda.app.fast.designsystem.autoFillRequestHandler
import com.meloda.app.fast.designsystem.connectNode
import com.meloda.app.fast.designsystem.defaultFocusChangeAutoFill
import com.meloda.app.fast.designsystem.handleEnterKey
import com.meloda.app.fast.designsystem.handleTabKey
import com.meloda.app.fast.model.BaseError
import com.meloda.fast.auth.login.LoginViewModel
import com.meloda.fast.auth.login.LoginViewModelImpl
import com.meloda.fast.auth.login.model.CaptchaArguments
import com.meloda.fast.auth.login.model.LoginError
import com.meloda.fast.auth.login.model.LoginTwoFaArguments
import com.meloda.fast.auth.login.model.UserBannedArguments
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun LoginScreen(
onError: (BaseError) -> Unit,
onNavigateToUserBanned: (UserBannedArguments) -> Unit,
onNavigateToMain: () -> Unit,
onNavigateToCaptcha: (CaptchaArguments) -> Unit,
onNavigateToTwoFa: (LoginTwoFaArguments) -> Unit,
viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
if (screenState.isNeedToNavigateToMain) {
viewModel.onNavigatedToMain()
onNavigateToMain()
}
screenState.userBannedArguments?.let { arguments ->
viewModel.onNavigatedToUserBanned()
onNavigateToUserBanned(arguments)
}
screenState.captchaArguments?.let { arguments ->
viewModel.onNavigatedToCaptcha()
onNavigateToCaptcha(arguments)
}
screenState.twoFaArguments?.let { arguments ->
viewModel.onNavigatedToTwoFa()
onNavigateToTwoFa(arguments)
}
val focusManager = LocalFocusManager.current
val (loginFocusable, passwordFocusable) = FocusRequester.createRefs()
// TODO: 29/06/2024, Danil Nikolaev: remove lambda
val goButtonClickAction = {
if (!screenState.isLoading) {
focusManager.clearFocus()
viewModel.onSignInButtonClicked()
}
}
val loginFieldTabClick = {
passwordFocusable.requestFocus()
true
}
var loginText by remember { mutableStateOf(TextFieldValue(screenState.login)) }
val showLoginError = screenState.loginError
val autoFillEmailHandler = autoFillRequestHandler(
autofillTypes = listOf(AutofillType.EmailAddress),
onFill = { value ->
loginText =
TextFieldValue(text = value, selection = TextRange(value.length))
viewModel.onLoginInputChanged(value)
}
)
var passwordText by remember { mutableStateOf(TextFieldValue(screenState.password)) }
val showPasswordError = screenState.passwordError
val autoFillPasswordHandler = autoFillRequestHandler(
autofillTypes = listOf(AutofillType.Password),
onFill = { value ->
passwordText =
TextFieldValue(text = value, selection = TextRange(value.length))
viewModel.onPasswordInputChanged(value)
}
)
Scaffold { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(30.dp)
.imePadding()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center)
) {
Text(
text = stringResource(id = UiR.string.sign_in_to_vk),
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.displayMedium
)
Spacer(modifier = Modifier.height(58.dp))
TextField(
modifier = Modifier
.height(58.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.handleEnterKey(loginFieldTabClick::invoke)
.handleTabKey(loginFieldTabClick::invoke)
.focusRequester(loginFocusable)
.connectNode(handler = autoFillEmailHandler)
.defaultFocusChangeAutoFill(handler = autoFillEmailHandler),
value = loginText,
onValueChange = { newText ->
val text = newText.text
if (text.isEmpty()) {
autoFillEmailHandler.requestVerifyManual()
}
loginText = newText
viewModel.onLoginInputChanged(text)
},
label = { Text(text = stringResource(id = UiR.string.login_hint)) },
placeholder = { Text(text = stringResource(id = UiR.string.login_hint)) },
leadingIcon = {
Icon(
painter = painterResource(id = UiR.drawable.ic_round_person_24),
contentDescription = "Login icon",
tint = if (showLoginError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
}
)
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Email
),
keyboardActions = KeyboardActions(onNext = { passwordFocusable.requestFocus() }),
isError = showLoginError,
singleLine = true
)
AnimatedVisibility(visible = showLoginError) {
TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field))
}
Spacer(modifier = Modifier.height(16.dp))
TextField(
modifier = Modifier
.height(58.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.handleEnterKey {
goButtonClickAction.invoke()
true
}
.focusRequester(passwordFocusable)
.connectNode(handler = autoFillPasswordHandler)
.defaultFocusChangeAutoFill(handler = autoFillPasswordHandler),
value = passwordText,
onValueChange = { newText ->
val text = newText.text
if (text.isEmpty()) {
autoFillPasswordHandler.requestVerifyManual()
}
passwordText = newText
viewModel.onPasswordInputChanged(text)
},
label = { Text(text = stringResource(id = UiR.string.password_login_hint)) },
placeholder = { Text(text = stringResource(id = UiR.string.password_login_hint)) },
leadingIcon = {
Icon(
painter = painterResource(id = UiR.drawable.round_vpn_key_24),
contentDescription = "Password icon",
tint = if (showPasswordError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
}
)
},
trailingIcon = {
val imagePainter = painterResource(
id = if (screenState.passwordVisible) UiR.drawable.round_visibility_off_24
else UiR.drawable.round_visibility_24
)
IconButton(onClick = viewModel::onPasswordVisibilityButtonClicked) {
Icon(
painter = imagePainter,
contentDescription = if (screenState.passwordVisible) "Password visible icon"
else "Password invisible icon"
)
}
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Go,
keyboardType = KeyboardType.Password
),
keyboardActions = KeyboardActions(
onGo = { goButtonClickAction.invoke() }
),
isError = showPasswordError,
visualTransformation = if (screenState.passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
singleLine = true
)
AnimatedVisibility(visible = showPasswordError) {
TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field))
}
}
Box(
modifier = Modifier.align(Alignment.BottomCenter),
contentAlignment = Alignment.Center
) {
FloatingActionButton(
onClick = goButtonClickAction::invoke,
containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.testTag("Sign in button")
) {
Icon(
painter = painterResource(id = UiR.drawable.ic_arrow_end),
contentDescription = "Sign in icon",
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
AnimatedVisibility(
visible = screenState.isLoading,
enter = fadeIn(),
exit = fadeOut()
) {
CircularProgressIndicator()
}
}
}
}
}
HandleError(
onDismiss = viewModel::onErrorDialogDismissed,
error = screenState.error
)
}
@Composable
fun HandleError(
onDismiss: () -> Unit,
error: LoginError?,
) {
when (error) {
LoginError.WrongCredentials -> {
MaterialDialog(
onDismissAction = onDismiss,
title = UiText.Simple("Error"),
text = UiText.Simple("Wrong login or password"),
confirmText = UiText.Resource(UiR.string.ok)
)
}
null -> Unit
}
}
@@ -0,0 +1,110 @@
package com.meloda.fast.auth.login.presentation
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.padding
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meloda.fast.auth.login.LoginViewModel
import com.meloda.fast.auth.login.LoginViewModelImpl
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LogoScreen(
onNavigateToMain: () -> Unit,
onShowCredentials: () -> Unit,
viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
if (screenState.isNeedToNavigateToMain) {
viewModel.onNavigatedToMain()
onNavigateToMain()
}
Scaffold { padding ->
val topPadding by animateDpAsState(targetValue = padding.calculateTopPadding())
val bottomPadding by animateDpAsState(targetValue = padding.calculateBottomPadding())
val endPadding by animateDpAsState(
targetValue = padding.calculateEndPadding(LayoutDirection.Ltr)
)
val startPadding by animateDpAsState(
targetValue = padding.calculateStartPadding(LayoutDirection.Ltr)
)
Box(
modifier = Modifier
.fillMaxSize()
.padding(
start = startPadding,
top = topPadding,
end = endPadding,
bottom = bottomPadding
)
.padding(30.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(id = UiR.drawable.ic_logo_big),
contentDescription = "Application Logo",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onLongClick = viewModel::onLogoLongClicked,
onClick = {}
)
)
Spacer(modifier = Modifier.height(46.dp))
Text(
text = stringResource(id = UiR.string.fast_messenger),
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onBackground
)
}
FloatingActionButton(
onClick = onShowCredentials,
containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.align(Alignment.BottomCenter)
) {
Icon(
painter = painterResource(id = UiR.drawable.ic_arrow_end),
contentDescription = "Go button",
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
}
@@ -0,0 +1,27 @@
package com.meloda.fast.auth.login.validation
import com.meloda.app.fast.common.extensions.addIf
import com.meloda.fast.auth.login.model.LoginScreenState
import com.meloda.fast.auth.login.model.LoginValidationResult
class LoginValidator {
fun validate(screenState: LoginScreenState): List<LoginValidationResult> {
val resultList = mutableListOf<LoginValidationResult>()
resultList.addIf(LoginValidationResult.LoginEmpty) {
screenState.login.isBlank()
}
resultList.addIf(LoginValidationResult.PasswordEmpty) {
screenState.password.isBlank()
}
resultList.addIf(LoginValidationResult.Valid) {
resultList.isEmpty()
}
return resultList
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,102 @@
package com.meloda.app.fast.auth
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.navigation
import com.meloda.app.fast.auth.captcha.model.CaptchaArguments
import com.meloda.app.fast.auth.captcha.navigation.captchaRoute
import com.meloda.app.fast.auth.captcha.navigation.navigateToCaptcha
import com.meloda.app.fast.auth.captcha.navigation.setCaptchaResult
import com.meloda.app.fast.auth.twofa.model.TwoFaArguments
import com.meloda.app.fast.auth.twofa.navigation.navigateToTwoFa
import com.meloda.app.fast.auth.twofa.navigation.setTwoFaResult
import com.meloda.app.fast.auth.twofa.navigation.twoFaRoute
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.userbanned.model.UserBannedArguments
import com.meloda.app.fast.userbanned.navigation.navigateToUserBanned
import com.meloda.app.fast.userbanned.navigation.userBannedRoute
import com.meloda.fast.auth.login.navigation.Logo
import com.meloda.fast.auth.login.navigation.loginRoute
import com.meloda.fast.auth.login.navigation.navigateToLogin
import kotlinx.serialization.Serializable
@Serializable
object AuthGraph
fun NavGraphBuilder.authNavGraph(
onError: (BaseError) -> Unit,
onNavigateToMain: () -> Unit,
navController: NavHostController
) {
navigation<AuthGraph>(
startDestination = Logo
) {
loginRoute(
onError = onError,
onNavigateToCaptcha = { arguments ->
navController.navigateToCaptcha(
CaptchaArguments(
arguments.captchaSid,
arguments.captchaImage
)
)
},
onNavigateToTwoFa = { arguments ->
navController.navigateToTwoFa(
TwoFaArguments(
validationSid = arguments.validationSid,
redirectUri = arguments.redirectUri,
phoneMask = arguments.phoneMask,
validationType = arguments.validationType,
canResendSms = arguments.canResendSms,
wrongCodeError = arguments.wrongCodeError
)
)
},
onNavigateToMain = onNavigateToMain,
onNavigateToUserBanned = { arguments ->
navController.navigateToUserBanned(
UserBannedArguments(
name = arguments.name,
message = arguments.message,
restoreUrl = arguments.restoreUrl,
accessToken = arguments.accessToken
)
)
},
onNavigateToCredentials = navController::navigateToLogin,
navController = navController
)
twoFaRoute(
onBack = navController::navigateUp,
onResult = { code ->
navController.popBackStack()
navController.setTwoFaResult(code)
}
)
captchaRoute(
onBack = navController::navigateUp,
onResult = { code ->
navController.popBackStack()
navController.setCaptchaResult(code)
}
)
userBannedRoute(onBack = navController::navigateUp)
}
}
fun NavController.navigateToAuth(clearBackStack: Boolean = false) {
val navController = this
this.navigate(AuthGraph) {
if (clearBackStack) {
popUpTo(navController.graph.id) {
inclusive = true
}
}
}
}
@@ -0,0 +1,14 @@
package com.meloda.app.fast.auth
import com.meloda.app.fast.auth.captcha.di.captchaModule
import com.meloda.app.fast.auth.twofa.di.twoFaModule
import com.meloda.fast.auth.login.di.loginModule
import org.koin.dsl.module
val authModule = module {
includes(
loginModule,
twoFaModule,
captchaModule,
)
}
+1
View File
@@ -0,0 +1 @@
/build
+60
View File
@@ -0,0 +1,60 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.kotlin.compose.compiler)
alias(libs.plugins.org.jetbrains.kotlin.plugin.parcelize)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.meloda.app.fast.twofa"
compileSdk = Configs.compileSdk
defaultConfig {
minSdk = Configs.minSdk
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = Configs.java
targetCompatibility = Configs.java
}
kotlinOptions {
jvmTarget = Configs.java.toString()
freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-receivers")
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
useLiveLiterals = true
}
}
dependencies {
implementation(projects.core.common)
implementation(projects.core.data)
implementation(projects.core.ui)
implementation(libs.nanokt.android)
implementation(libs.nanokt.jvm)
implementation(libs.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.eithernet)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization)
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,12 @@
package com.meloda.app.fast.auth.twofa
import com.meloda.app.fast.data.State
import com.meloda.app.fast.model.api.responses.SendSmsResponse
import kotlinx.coroutines.flow.Flow
interface AuthUseCase {
fun sendSms(
validationSid: String
): Flow<State<SendSmsResponse>>
}
@@ -0,0 +1,27 @@
package com.meloda.app.fast.auth.twofa
import com.meloda.app.fast.data.State
import com.meloda.app.fast.data.api.auth.AuthRepository
import com.meloda.app.fast.model.api.responses.SendSmsResponse
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class AuthUseCaseImpl(
private val authRepository: AuthRepository
) : AuthUseCase {
// TODO: 05/05/2024, Danil Nikolaev: implement
override fun sendSms(validationSid: String): Flow<State<SendSmsResponse>> = flow {
// emit(State.Loading)
//
// val newState = authRepository.sendSms(validationSid)
// .fold(
// onSuccess = { response -> State.Success(response) },
// onNetworkFailure = { State.Error.ConnectionError },
// onUnknownFailure = { State.UNKNOWN_ERROR },
// onHttpFailure = { result -> result.error.toStateApiError() },
// onApiFailure = { result -> result.error.toStateApiError() }
// )
// emit(newState)
}
}
@@ -0,0 +1,212 @@
package com.meloda.app.fast.auth.twofa
import android.util.Log
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.meloda.app.fast.auth.twofa.model.TwoFaArguments
import com.meloda.app.fast.auth.twofa.model.TwoFaScreenState
import com.meloda.app.fast.auth.twofa.model.TwoFaValidationType
import com.meloda.app.fast.auth.twofa.navigation.TwoFa
import com.meloda.app.fast.auth.twofa.validation.TwoFaValidator
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.common.extensions.createTimerFlow
import com.meloda.app.fast.common.extensions.listenValue
import com.meloda.app.fast.common.extensions.setValue
import com.meloda.app.fast.common.extensions.updateValue
import com.meloda.app.fast.data.processState
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.launch
interface TwoFaViewModel {
val screenState: StateFlow<TwoFaScreenState>
fun onCodeInputChanged(newCode: String)
fun onBackButtonClicked()
fun onCancelButtonClicked()
fun onRequestSmsButtonClicked()
fun onTextFieldDoneClicked()
fun onDoneButtonClicked()
fun onNavigatedToLogin()
fun setArguments(arguments: TwoFaArguments)
}
class TwoFaViewModelImpl(
private val validator: TwoFaValidator,
private val authUseCase: AuthUseCase,
savedStateHandle: SavedStateHandle
) : TwoFaViewModel, ViewModel() {
override val screenState = MutableStateFlow(TwoFaScreenState.EMPTY)
private var delayJob: Job? = null
init {
// TODO: 08/07/2024, Danil Nikolaev: use when fixed
//savedStateHandle.toRoute<TwoFa>().arguments
val arguments = TwoFa.from(savedStateHandle).arguments
screenState.setValue { old ->
old.copy(
twoFaSid = arguments.validationSid,
canResendSms = arguments.canResendSms,
codeError = arguments.wrongCodeError,
twoFaText = getTwoFaText(TwoFaValidationType.parse(arguments.validationType)),
phoneMask = arguments.phoneMask
)
}
}
override fun onCodeInputChanged(newCode: String) {
screenState.updateValue(
screenState.value.copy(
twoFaCode = newCode.trim(),
codeError = null
)
)
if (newCode.length == 6) {
viewModelScope.launch {
delay(250)
onDoneButtonClicked()
}
}
}
override fun onBackButtonClicked() {
onCancelButtonClicked()
}
override fun onCancelButtonClicked() {
screenState.updateValue(
screenState.value.copy(
twoFaCode = null,
isNeedToOpenLogin = true
)
)
}
override fun onRequestSmsButtonClicked() {
sendValidationCode()
}
override fun onTextFieldDoneClicked() {
onDoneButtonClicked()
}
override fun onDoneButtonClicked() {
if (!processValidation()) return
screenState.updateValue(screenState.value.copy(isNeedToOpenLogin = true))
}
override fun onNavigatedToLogin() {
screenState.updateValue(TwoFaScreenState.EMPTY)
}
override fun setArguments(arguments: TwoFaArguments) {
Log.d("TwoFaViewModel", "TwoFaArguments: $arguments")
// screenState.updateValue(
// screenState.value.copy(
// twoFaSid = arguments.validationSid,
// canResendSms = arguments.canResendSms,
// codeError = arguments.wrongCodeError,
// twoFaText = getTwoFaText(TwoFaValidationType.parse(arguments.validationType)),
// phoneMask = arguments.phoneMask
// )
// )
}
private fun processValidation(): Boolean {
val isValid = validator.validate(screenState.value).isValid()
screenState.updateValue(
screenState.value.copy(
codeError = if (isValid) null
else "Field must not be empty"
)
)
return isValid
}
private fun sendValidationCode() {
val validationSid = screenState.value.twoFaSid
authUseCase.sendSms(validationSid)
.listenValue { state ->
state.processState(
error = { error -> },
success = { response ->
val newValidationType = response.validationType
val newCanResendSms = response.validationResend == "sms"
screenState.setValue { old ->
old.copy(
canResendSms = newCanResendSms,
twoFaText = getTwoFaText(
TwoFaValidationType.parse(newValidationType.orEmpty())
)
)
}
startTickTimer(response.delay)
}
)
if (state.isLoading()) {
screenState.emit(screenState.value.copy(canResendSms = false))
}
}
}
fun startTickTimer(delay: Int?) {
if (delay == null || delayJob?.isActive == true) return
delayJob = createTimerFlow(
time = delay,
onStartAction = {
screenState.updateValue(
screenState.value.copy(canResendSms = false)
)
},
onTickAction = { remainedTime ->
screenState.updateValue(
screenState.value.copy(delayTime = remainedTime)
)
},
onTimeoutAction = {
screenState.updateValue(
screenState.value.copy(
canResendSms = true
)
)
},
).launchIn(viewModelScope)
}
private fun getTwoFaText(validationType: TwoFaValidationType): UiText {
return when (validationType) {
TwoFaValidationType.Sms -> {
UiText.Simple("SMS with the code is sent to ${screenState.value.phoneMask}")
}
TwoFaValidationType.TwoFaApp -> {
UiText.Simple("Enter the code from the code generator application")
}
is TwoFaValidationType.Another -> UiText.Simple(validationType.type)
}
}
}
@@ -0,0 +1,17 @@
package com.meloda.app.fast.auth.twofa.di
import com.meloda.app.fast.auth.twofa.AuthUseCase
import com.meloda.app.fast.auth.twofa.AuthUseCaseImpl
import com.meloda.app.fast.auth.twofa.TwoFaViewModel
import com.meloda.app.fast.auth.twofa.TwoFaViewModelImpl
import com.meloda.app.fast.auth.twofa.validation.TwoFaValidator
import org.koin.androidx.viewmodel.dsl.viewModelOf
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
val twoFaModule = module {
singleOf(::TwoFaValidator)
viewModelOf(::TwoFaViewModelImpl) bind TwoFaViewModel::class
singleOf(::AuthUseCaseImpl) bind AuthUseCase::class
}
@@ -0,0 +1,16 @@
package com.meloda.app.fast.auth.twofa.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class TwoFaArguments(
val validationSid: String,
val redirectUri: String,
val phoneMask: String,
val validationType: String,
val canResendSms: Boolean,
val wrongCodeError: String?,
) : Parcelable
@@ -0,0 +1,28 @@
package com.meloda.app.fast.auth.twofa.model
import com.meloda.app.fast.common.UiText
data class TwoFaScreenState(
val twoFaSid: String,
val twoFaCode: String?,
val twoFaText: UiText,
val canResendSms: Boolean,
val codeError: String?,
val delayTime: Int,
val isNeedToOpenLogin: Boolean,
val phoneMask: String
) {
companion object {
val EMPTY = TwoFaScreenState(
twoFaSid = "",
twoFaCode = null,
twoFaText = UiText.Simple(""),
canResendSms = false,
codeError = null,
delayTime = 0,
isNeedToOpenLogin = false,
phoneMask = ""
)
}
}
@@ -0,0 +1,6 @@
package com.meloda.app.fast.auth.twofa.model
sealed class TwoFaUiAction {
data class CodeResult(val code: String) : TwoFaUiAction()
data object BackClicked : TwoFaUiAction()
}
@@ -0,0 +1,8 @@
package com.meloda.app.fast.auth.twofa.model
sealed class TwoFaValidationResult {
data object Empty : TwoFaValidationResult()
data object Valid : TwoFaValidationResult()
fun isValid() = this == Valid
}
@@ -0,0 +1,23 @@
package com.meloda.app.fast.auth.twofa.model
sealed class TwoFaValidationType(val value: String) {
data object Sms : TwoFaValidationType(TYPE_SMS)
data object TwoFaApp : TwoFaValidationType(TYPE_TWO_FA_APP)
data class Another(val type: String) : TwoFaValidationType(type)
companion object {
private const val TYPE_SMS = "sms"
private const val TYPE_TWO_FA_APP = "2fa_app"
fun parse(validationType: String): TwoFaValidationType {
return when (validationType) {
TYPE_SMS -> Sms
TYPE_TWO_FA_APP -> TwoFaApp
else -> Another(validationType)
}
}
}
}
@@ -0,0 +1,49 @@
package com.meloda.app.fast.auth.twofa.navigation
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.meloda.app.fast.auth.twofa.model.TwoFaArguments
import com.meloda.app.fast.auth.twofa.model.TwoFaUiAction
import com.meloda.app.fast.auth.twofa.presentation.TwoFaScreen
import com.meloda.app.fast.common.customNavType
import kotlinx.serialization.Serializable
import kotlin.reflect.typeOf
@Serializable
data class TwoFa(val arguments: TwoFaArguments) {
companion object {
val typeMap = mapOf(typeOf<TwoFaArguments>() to customNavType<TwoFaArguments>())
fun from(savedStateHandle: SavedStateHandle) =
savedStateHandle.toRoute<TwoFa>(typeMap)
}
}
fun NavGraphBuilder.twoFaRoute(
onBack: () -> Unit,
onResult: (String) -> Unit
) {
composable<TwoFa>(typeMap = TwoFa.typeMap) {
TwoFaScreen(
onAction = { action ->
when (action) {
TwoFaUiAction.BackClicked -> onBack()
is TwoFaUiAction.CodeResult -> onResult(action.code)
}
}
)
}
}
fun NavController.navigateToTwoFa(arguments: TwoFaArguments) {
this.navigate(TwoFa(arguments))
}
fun NavController.setTwoFaResult(code: String) {
this.currentBackStackEntry
?.savedStateHandle
?.set("twofacode", code)
}
@@ -0,0 +1,255 @@
package com.meloda.app.fast.auth.twofa.presentation
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Done
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton
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.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meloda.app.fast.auth.twofa.TwoFaViewModel
import com.meloda.app.fast.auth.twofa.TwoFaViewModelImpl
import com.meloda.app.fast.auth.twofa.model.TwoFaUiAction
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.designsystem.MaterialDialog
import com.meloda.app.fast.designsystem.TextFieldErrorText
import com.meloda.app.fast.designsystem.getString
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
private typealias OnAction = (TwoFaUiAction) -> Unit
@Composable
fun TwoFaScreen(
onAction: OnAction,
viewModel: TwoFaViewModel = koinViewModel<TwoFaViewModelImpl>(),
) {
val focusManager = LocalFocusManager.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
var confirmedExit by rememberSaveable {
mutableStateOf(false)
}
var showExitAlert by rememberSaveable {
mutableStateOf(false)
}
if (confirmedExit) {
onAction(TwoFaUiAction.BackClicked)
}
BackHandler(enabled = !confirmedExit) {
if (!confirmedExit) {
showExitAlert = true
}
}
if (showExitAlert) {
MaterialDialog(
onDismissAction = { showExitAlert = false },
title = UiText.Simple("Confirmation"),
text = UiText.Simple("Are you sure? Authorization process will be cancelled."),
confirmText = UiText.Resource(UiR.string.yes),
confirmAction = {
confirmedExit = true
},
cancelText = UiText.Resource(UiR.string.no)
)
}
if (screenState.isNeedToOpenLogin) {
viewModel.onNavigatedToLogin()
val code = screenState.twoFaCode
if (code == null) {
onAction(TwoFaUiAction.BackClicked)
} else {
onAction(TwoFaUiAction.CodeResult(code = code))
}
}
Scaffold { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(30.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
ExtendedFloatingActionButton(
onClick = { onAction(TwoFaUiAction.BackClicked) },
text = {
Text(
text = "Cancel",
color = MaterialTheme.colorScheme.primary
)
},
icon = {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = "Close icon",
tint = MaterialTheme.colorScheme.primary,
)
}
)
Column(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Two-Factor\nAuthentication",
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(38.dp))
Text(
text = screenState.twoFaText.getString().orEmpty(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(10.dp))
val delayRemainedTime = screenState.delayTime
AnimatedVisibility(visible = delayRemainedTime > 0) {
Text(
text = "Can resend after $delayRemainedTime seconds",
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(10.dp))
var code by remember { mutableStateOf(TextFieldValue(screenState.twoFaCode.orEmpty())) }
val codeError = screenState.codeError
TextField(
value = code,
onValueChange = { newText ->
if (newText.text.length > 6) return@TextField
code = newText
viewModel.onCodeInputChanged((newText.text))
},
label = { Text(text = "Code") },
placeholder = { Text(text = "Code") },
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp)),
leadingIcon = {
Icon(
painter = painterResource(id = UiR.drawable.round_qr_code_24),
contentDescription = "QR Code icon",
tint = if (codeError != null) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
}
)
},
shape = RoundedCornerShape(10.dp),
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Number
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
viewModel.onTextFieldDoneClicked()
}
),
isError = codeError != null
)
AnimatedVisibility(visible = codeError != null) {
TextFieldErrorText(text = codeError.orEmpty())
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
val canResendSms = screenState.canResendSms
AnimatedVisibility(
visible = canResendSms,
) {
ExtendedFloatingActionButton(
onClick = viewModel::onRequestSmsButtonClicked,
text = {
Text(
text = "Request SMS",
color = MaterialTheme.colorScheme.onPrimary
)
},
icon = {
Icon(
painter = painterResource(id = UiR.drawable.round_sms_24),
tint = MaterialTheme.colorScheme.onPrimary,
contentDescription = "SMS icon"
)
},
containerColor = MaterialTheme.colorScheme.primary,
)
}
Spacer(modifier = Modifier.width(16.dp))
FloatingActionButton(
onClick = viewModel::onDoneButtonClicked,
containerColor = MaterialTheme.colorScheme.secondaryContainer,
) {
Icon(
imageVector = Icons.Rounded.Done,
contentDescription = "Done icon",
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
AnimatedVisibility(
visible = !canResendSms,
exit = shrinkHorizontally()
) {
Spacer(modifier = Modifier.width(16.dp))
}
}
}
}
}
@@ -0,0 +1,14 @@
package com.meloda.app.fast.auth.twofa.validation
import com.meloda.app.fast.auth.twofa.model.TwoFaScreenState
import com.meloda.app.fast.auth.twofa.model.TwoFaValidationResult
class TwoFaValidator {
fun validate(screenState: TwoFaScreenState): TwoFaValidationResult {
return when {
screenState.twoFaCode.isNullOrEmpty() -> TwoFaValidationResult.Empty
else -> TwoFaValidationResult.Valid
}
}
}
+1
View File
@@ -0,0 +1 @@
/build
+60
View File
@@ -0,0 +1,60 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.com.google.devtools.ksp)
alias(libs.plugins.kotlin.compose.compiler)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.org.jetbrains.kotlin.plugin.parcelize)
}
group = "com.meloda.app.fast.userbanned"
android {
namespace = "com.meloda.app.fast.userbanned"
compileSdk = Configs.compileSdk
defaultConfig {
minSdk = Configs.minSdk
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = Configs.java
targetCompatibility = Configs.java
}
kotlinOptions {
jvmTarget = Configs.java.toString()
freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-receivers")
}
buildFeatures {
compose = true
}
composeOptions {
useLiveLiterals = true
}
}
dependencies {
implementation(projects.core.data)
implementation(projects.core.model)
implementation(projects.core.ui)
implementation(libs.nanokt.android)
implementation(libs.nanokt.jvm)
implementation(libs.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.androidx.navigation.compose)
implementation(libs.kotlin.serialization)
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,14 @@
package com.meloda.app.fast.userbanned.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class UserBannedArguments(
val name: String,
val message: String,
val restoreUrl: String,
val accessToken: String
) : Parcelable
@@ -0,0 +1,53 @@
package com.meloda.app.fast.userbanned.navigation
import android.os.Bundle
import androidx.core.os.BundleCompat
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.meloda.app.fast.userbanned.model.UserBannedArguments
import com.meloda.app.fast.userbanned.presentation.UserBannedScreen
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.reflect.typeOf
@Serializable
data class UserBanned(val arguments: UserBannedArguments)
val UserBannedNavType = object : NavType<UserBannedArguments>(isNullableAllowed = false) {
override fun get(bundle: Bundle, key: String): UserBannedArguments? =
BundleCompat.getParcelable(bundle, key, UserBannedArguments::class.java)
override fun parseValue(value: String): UserBannedArguments = Json.decodeFromString(value)
override fun serializeAsValue(value: UserBannedArguments): String = Json.encodeToString(value)
override fun put(bundle: Bundle, key: String, value: UserBannedArguments) {
bundle.putParcelable(key, value)
}
override val name: String = "UserBannedArguments"
}
fun NavGraphBuilder.userBannedRoute(
onBack: () -> Unit
) {
composable<UserBanned>(
typeMap = mapOf(typeOf<UserBannedArguments>() to UserBannedNavType)
) { backStackEntry ->
val arguments: UserBannedArguments = backStackEntry.toRoute()
UserBannedScreen(
onBack = onBack,
name = arguments.name,
message = arguments.message,
)
}
}
fun NavController.navigateToUserBanned(arguments: UserBannedArguments) {
this.navigate(UserBanned(arguments))
}
@@ -0,0 +1,97 @@
package com.meloda.app.fast.userbanned.presentation
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.meloda.app.fast.designsystem.AppTheme
import com.meloda.app.fast.designsystem.R as UiR
@Preview
@Composable
fun UserBannedScreenPreview() {
AppTheme {
UserBannedScreen(
onBack = {},
name = "Calvin Harris",
message = "Eto konets"
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UserBannedScreen(
onBack: () -> Unit,
name: String,
message: String,
) {
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = null
)
}
},
title = {
Text(text = stringResource(id = UiR.string.warning))
}
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.padding(horizontal = 16.dp)
.padding(vertical = 8.dp)
) {
Text(
text = stringResource(id = UiR.string.account_temporarily_blocked),
style = MaterialTheme.typography.titleLarge,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Medium)) {
append(stringResource(id = UiR.string.user_name))
append(": ")
}
append(name)
}
)
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Medium)) {
append(stringResource(id = UiR.string.blocking_reason_title))
append(": ")
}
append(message)
}
)
}
}
}
+1
View File
@@ -0,0 +1 @@
/build
+62
View File
@@ -0,0 +1,62 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.kotlin.compose.compiler)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.org.jetbrains.kotlin.plugin.parcelize)
}
group = "com.meloda.app.fast.chatmaterials"
android {
namespace = "com.meloda.app.fast.chatmaterials"
compileSdk = Configs.compileSdk
defaultConfig {
minSdk = Configs.minSdk
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = Configs.java
targetCompatibility = Configs.java
}
kotlinOptions {
jvmTarget = Configs.java.toString()
}
buildFeatures {
compose = true
}
composeOptions {
useLiveLiterals = true
}
}
dependencies {
implementation(projects.core.data)
implementation(projects.core.model)
implementation(projects.core.ui)
implementation(libs.nanokt.android)
implementation(libs.nanokt.jvm)
implementation(libs.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.kotlin.serialization)
implementation(libs.androidx.navigation.compose)
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,91 @@
package com.meloda.app.fast.chatmaterials
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class
)
@Composable
fun ChatMaterialsScreen(
onBack: () -> Unit
) {
val hazeState = remember { HazeState() }
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = "Chat Materials")
},
colors = TopAppBarDefaults.largeTopAppBarColors(Color.Transparent),
modifier = Modifier
.hazeChild(
state = hazeState,
style = HazeMaterials.ultraThin()
)
.fillMaxWidth(),
navigationIcon = {
IconButton(
onClick = onBack
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = null
)
}
}
)
}
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(200.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.haze(
state = hazeState,
style = HazeMaterials.ultraThin()
)
) {
items(100) { index ->
val link = "https://random.imagecdn.app/500/150"
AsyncImage(
model = link,
contentDescription = "Image",
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
)
}
}
}
}
@@ -0,0 +1,24 @@
package com.meloda.app.fast.chatmaterials.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.meloda.app.fast.chatmaterials.ChatMaterialsScreen
import kotlinx.serialization.Serializable
@Serializable
data class ChatMaterials(val a: String)
fun NavGraphBuilder.chatMaterialsRoute(
onBack: () -> Unit
) {
composable<ChatMaterials> {
ChatMaterialsScreen(
onBack = onBack
)
}
}
fun NavController.navigateToChatMaterials() {
this.navigate(ChatMaterials(""))
}
+1
View File
@@ -0,0 +1 @@
/build
+69
View File
@@ -0,0 +1,69 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.com.google.devtools.ksp)
alias(libs.plugins.kotlin.compose.compiler)
alias(libs.plugins.kotlin.serialization)
}
group = "com.meloda.app.fast.conversations"
android {
namespace = "com.meloda.app.fast.conversations"
compileSdk = Configs.compileSdk
defaultConfig {
minSdk = Configs.minSdk
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = Configs.java
targetCompatibility = Configs.java
}
kotlinOptions {
jvmTarget = Configs.java.toString()
freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-receivers")
}
buildFeatures {
compose = true
}
composeOptions {
useLiveLiterals = true
}
}
dependencies {
implementation(projects.core.common)
implementation(projects.core.data)
implementation(projects.core.model)
implementation(projects.core.ui)
implementation(libs.nanokt.android)
implementation(libs.nanokt.jvm)
implementation(libs.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)
// TODO: 03/07/2024, Danil Nikolaev: remove when stable release
implementation("androidx.compose.foundation:foundation:1.7.0-beta04")
implementation(libs.eithernet)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization)
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,625 @@
package com.meloda.app.fast.conversations
import android.content.res.Resources
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.conena.nanokt.collections.indexOfFirstOrNull
import com.meloda.app.fast.common.extensions.createTimerFlow
import com.meloda.app.fast.common.extensions.findWithIndex
import com.meloda.app.fast.common.extensions.listenValue
import com.meloda.app.fast.common.extensions.setValue
import com.meloda.app.fast.conversations.model.ConversationOption
import com.meloda.app.fast.conversations.model.ConversationsScreenState
import com.meloda.app.fast.conversations.model.ConversationsShowOptions
import com.meloda.app.fast.conversations.model.UiConversation
import com.meloda.app.fast.conversations.util.asPresentation
import com.meloda.app.fast.conversations.util.extractAvatar
import com.meloda.app.fast.data.LongPollUpdatesParser
import com.meloda.app.fast.data.State
import com.meloda.app.fast.data.api.conversations.ConversationsUseCase
import com.meloda.app.fast.data.api.messages.MessagesUseCase
import com.meloda.app.fast.data.processState
import com.meloda.app.fast.datastore.UserSettings
import com.meloda.app.fast.designsystem.ImmutableList
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.model.InteractionType
import com.meloda.app.fast.model.LongPollEvent
import com.meloda.app.fast.model.api.domain.VkConversation
import com.meloda.app.fast.network.VkErrorCodes
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlin.coroutines.cancellation.CancellationException
interface ConversationsViewModel {
val screenState: StateFlow<ConversationsScreenState>
val baseError: StateFlow<BaseError?>
val imagesToPreload: StateFlow<List<String>>
val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean>
fun onMetPaginationCondition()
fun onDeleteDialogDismissed()
fun onDeleteDialogPositiveClick(conversationId: Int)
fun onRefresh()
fun onConversationItemClick(conversationId: Int)
fun onConversationItemLongClick(conversation: UiConversation)
fun onPinDialogDismissed()
fun onPinDialogPositiveClick(conversation: UiConversation)
fun onOptionClicked(conversation: UiConversation, option: ConversationOption)
fun onErrorConsumed()
}
class ConversationsViewModelImpl(
updatesParser: LongPollUpdatesParser,
private val conversationsUseCase: ConversationsUseCase,
private val messagesUseCase: MessagesUseCase,
private val resources: Resources,
private val userSettings: UserSettings
) : ConversationsViewModel, ViewModel() {
override val screenState = MutableStateFlow(ConversationsScreenState.EMPTY)
override val baseError = MutableStateFlow<BaseError?>(null)
override val imagesToPreload = MutableStateFlow<List<String>>(emptyList())
override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false)
override fun onMetPaginationCondition() {
currentOffset.update { screenState.value.conversations.size }
loadConversations()
}
private val conversations = MutableStateFlow<List<VkConversation>>(emptyList())
private val pinnedConversationsCount = conversations.map { conversations ->
conversations.count(VkConversation::isPinned)
}.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
init {
userSettings.useContactNames.listenValue(::updateConversationsNames)
updatesParser.onNewMessage(::handleNewMessage)
updatesParser.onMessageEdited(::handleEditedMessage)
updatesParser.onMessageIncomingRead(::handleReadIncomingMessage)
updatesParser.onMessageOutgoingRead(::handleReadOutgoingMessage)
updatesParser.onConversationPinStateChanged(::handlePinStateChanged)
updatesParser.onInteractions(::handleInteraction)
loadConversations()
}
override fun onDeleteDialogDismissed() {
emitShowOptions { old -> old.copy(showDeleteDialog = null) }
}
override fun onDeleteDialogPositiveClick(conversationId: Int) {
deleteConversation(conversationId)
hideOptions(conversationId)
}
override fun onRefresh() {
loadConversations(offset = 0)
}
override fun onConversationItemClick(conversationId: Int) {
screenState.setValue { old ->
old.copy(
conversations = old.conversations.map { item ->
item.copy(isExpanded = false)
}
)
}
}
override fun onConversationItemLongClick(conversation: UiConversation) {
val options = mutableListOf<ConversationOption>()
if (!conversation.isExpanded) {
conversation.lastMessage?.run {
if (conversation.isUnread && !this.isOut) {
options += ConversationOption.MarkAsRead
}
}
val conversationsSize = screenState.value.conversations.size
val pinnedCount = pinnedConversationsCount.value
val canPinOneMoreDialog =
conversationsSize > 4 && pinnedCount < 5 && !conversation.isPinned
if (conversation.isPinned) {
options += ConversationOption.Unpin
} else if (canPinOneMoreDialog) {
options += ConversationOption.Pin
}
options += ConversationOption.Delete
}
screenState.setValue { old ->
old.copy(
conversations = old.conversations.map { item ->
item.copy(
isExpanded =
if (item.id == conversation.id) {
!item.isExpanded
} else {
false
},
options = ImmutableList.copyOf(options)
)
}
)
}
}
override fun onPinDialogDismissed() {
emitShowOptions { old -> old.copy(showPinDialog = null) }
}
override fun onPinDialogPositiveClick(conversation: UiConversation) {
pinConversation(conversation.id, !conversation.isPinned)
hideOptions(conversation.id)
}
override fun onOptionClicked(conversation: UiConversation, option: ConversationOption) {
when (option) {
ConversationOption.Delete -> {
emitShowOptions { old ->
old.copy(showDeleteDialog = conversation.id)
}
}
ConversationOption.MarkAsRead -> {
conversation.lastMessageId?.let { lastMessageId ->
readConversation(
peerId = conversation.id,
startMessageId = lastMessageId
)
hideOptions(conversation.id)
}
}
ConversationOption.Pin,
ConversationOption.Unpin -> {
emitShowOptions { old -> old.copy(showPinDialog = conversation) }
}
}
}
override fun onErrorConsumed() {
baseError.setValue { null }
}
private fun hideOptions(conversationId: Int) {
screenState.setValue { old ->
old.copy(
conversations = old.conversations.map { item ->
if (item.id == conversationId) {
item.copy(isExpanded = false)
} else item
}
)
}
}
private fun emitShowOptions(function: (ConversationsShowOptions) -> ConversationsShowOptions) {
val newShowOptions = function.invoke(screenState.value.showOptions)
screenState.setValue { old -> old.copy(showOptions = newShowOptions) }
}
private fun loadConversations(
offset: Int = currentOffset.value
) {
conversationsUseCase.getConversations(count = 30, offset = offset).listenValue { state ->
state.processState(
error = { error ->
when (error) {
is State.Error.ApiError -> {
val (code, message) = error
when (code) {
VkErrorCodes.UserAuthorizationFailed -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> {
Unit
}
}
}
State.Error.ConnectionError -> TODO()
State.Error.InternalError -> TODO()
is State.Error.OAuthError -> TODO()
State.Error.Unknown -> TODO()
}
},
success = { response ->
val itemsCountSufficient = response.size == 30
canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient &&
screenState.value.conversations.isNotEmpty()
imagesToPreload.setValue {
response.mapNotNull { it.extractAvatar().extractUrl() }
}
conversationsUseCase.storeConversations(response)
val loadedConversations = response.map {
it.asPresentation(
resources,
userSettings.useContactNames.value
)
}
val newState = screenState.value.copy(
isPaginationExhausted = paginationExhausted
)
if (offset == 0) {
conversations.emit(response)
screenState.setValue {
newState.copy(conversations = loadedConversations)
}
} else {
conversations.emit(conversations.value.plus(response))
screenState.setValue {
newState.copy(
conversations = newState.conversations.plus(loadedConversations)
)
}
}
}
)
screenState.setValue { old ->
old.copy(
isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading()
)
}
}
}
private fun deleteConversation(peerId: Int) {
conversationsUseCase.delete(peerId).listenValue { state ->
state.processState(
error = { error ->
},
success = {
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == peerId }
?: return@processState
newConversations.removeAt(conversationIndex)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
}
}
)
screenState.emit(screenState.value.copy(isLoading = state.isLoading()))
}
}
private fun pinConversation(peerId: Int, pin: Boolean) {
conversationsUseCase.changePinState(peerId, pin)
.listenValue { state ->
state.processState(
error = { error ->
},
success = {
handlePinStateChanged(
LongPollEvent.VkConversationPinStateChangedEvent(
peerId = peerId,
majorId = if (pin) {
(pinnedConversationsCount.value + 1) * 16
} else {
0
}
)
)
}
)
screenState.setValue { old -> old.copy(isLoading = state.isLoading()) }
}
}
private fun handleNewMessage(event: LongPollEvent.VkMessageNewEvent) {
val message = event.message
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == message.peerId }
if (conversationIndex == null) { // диалога нет в списке
// pizdets
// TODO: 04/07/2024, Danil Nikolaev: load conversation and store info
} else {
val conversation = newConversations[conversationIndex]
var newConversation = conversation.copy(
lastMessage = message,
lastMessageId = message.id,
lastConversationMessageId = -1,
unreadCount = if (message.isOut) conversation.unreadCount
else conversation.unreadCount + 1
)
interactionsTimers[conversation.id]?.let { job ->
if (job.interactionType == InteractionType.Typing
&& message.fromId in conversation.interactionIds
) {
val newInteractionIds = newConversation.interactionIds.filter { id ->
id != message.fromId
}
newConversation = newConversation.copy(
interactionType = if (newInteractionIds.isEmpty()) -1 else {
newConversation.interactionType
},
interactionIds = newInteractionIds
)
}
}
if (conversation.isPinned()) {
newConversations[conversationIndex] = newConversation
} else {
newConversations.removeAt(conversationIndex)
val toPosition = pinnedConversationsCount.value
newConversations.add(toPosition, newConversation)
}
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
}
}
}
private fun handleEditedMessage(event: LongPollEvent.VkMessageEditEvent) {
val message = event.message
val newConversations = conversations.value.toMutableList()
val conversationIndex = newConversations.indexOfFirstOrNull { it.id == message.peerId }
if (conversationIndex == null) { // диалога нет в списке
// pizdets
} else {
val conversation = newConversations[conversationIndex]
newConversations[conversationIndex] = conversation.copy(
lastMessage = message,
lastMessageId = message.id,
lastConversationMessageId = -1
)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
}
}
}
private fun handleReadIncomingMessage(event: LongPollEvent.VkMessageReadIncomingEvent) {
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId } ?: return
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(
inRead = event.messageId,
unreadCount = event.unreadCount
)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
}
}
private fun handleReadOutgoingMessage(event: LongPollEvent.VkMessageReadOutgoingEvent) {
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId } ?: return
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(
outRead = event.messageId,
unreadCount = event.unreadCount
)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
}
}
private fun handlePinStateChanged(event: LongPollEvent.VkConversationPinStateChangedEvent) {
var pinnedCount = pinnedConversationsCount.value
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId } ?: return
val pin = event.majorId > 0
val conversation = newConversations[conversationIndex].copy(majorId = event.majorId)
newConversations.removeAt(conversationIndex)
if (pin) {
newConversations.add(0, conversation)
} else {
pinnedCount -= 1
newConversations.add(conversation)
val pinnedSubList = newConversations.filter(VkConversation::isPinned)
val unpinnedSubList = newConversations
.filterNot(VkConversation::isPinned)
.sortedByDescending { it.lastMessage?.date }
newConversations.clear()
newConversations += pinnedSubList + unpinnedSubList
}
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(conversations = newConversations.map { it.asPresentation(resources) })
}
}
private val interactionsTimers = hashMapOf<Int, InteractionJob?>()
private data class InteractionJob(
val interactionType: InteractionType,
val timerJob: Job
)
private object NewInteractionException : CancellationException()
private fun handleInteraction(event: LongPollEvent.Interaction) {
val interactionType = event.interactionType
val peerId = event.peerId
val userIds = event.userIds
val newConversations = conversations.value.toMutableList()
val conversationAndIndex =
newConversations.findWithIndex { it.id == peerId } ?: return
newConversations[conversationAndIndex.first] =
conversationAndIndex.second.copy(
interactionType = interactionType.value,
interactionIds = userIds
)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
}
interactionsTimers[peerId]?.let { interactionJob ->
if (interactionJob.interactionType == interactionType) {
interactionJob.timerJob.cancel(NewInteractionException)
}
}
var timeoutAction: (() -> Unit)? = null
val timerJob = createTimerFlow(
time = 5,
onTimeoutAction = { timeoutAction?.invoke() }
).launchIn(viewModelScope)
val newInteractionJob = InteractionJob(
interactionType = interactionType,
timerJob = timerJob
)
interactionsTimers[peerId] = newInteractionJob
timeoutAction = {
stopInteraction(peerId, newInteractionJob)
}
}
private fun stopInteraction(peerId: Int, interactionJob: InteractionJob) {
interactionsTimers[peerId] ?: return
val newConversations = conversations.value.toMutableList()
val conversationAndIndex =
newConversations.findWithIndex { it.id == peerId } ?: return
newConversations[conversationAndIndex.first] =
conversationAndIndex.second.copy(
interactionType = -1,
interactionIds = emptyList()
)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
}
interactionJob.timerJob.cancel()
interactionsTimers[peerId] = null
}
private fun readConversation(peerId: Int, startMessageId: Int) {
messagesUseCase.markAsRead(
peerId = peerId,
startMessageId = startMessageId
).listenValue { state ->
state.processState(
error = { error ->
},
success = {
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == peerId }
?: return@listenValue
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(inRead = startMessageId)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
}
}
)
}
}
private fun updateConversationsNames(useContactNames: Boolean) {
val conversations = conversations.value
if (conversations.isEmpty()) return
val uiConversations = conversations.map { conversation ->
conversation.asPresentation(resources, useContactNames)
}
screenState.setValue { old ->
old.copy(conversations = uiConversations)
}
}
}
@@ -0,0 +1,342 @@
package com.meloda.app.fast.conversations
// TODO: 26.08.2023, Danil Nikolaev: rewrite
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.meloda.app.fast.designsystem.AppTheme
const val numberOfDots = 3
val dotSize = 6.dp
val dotColor: Color = Color.Blue
const val delayUnit = 300
const val duration = numberOfDots * delayUnit
val spaceBetween = 2.dp
@Composable
fun DotsPulsing() {
@Composable
fun Dot(scale: Float) {
Spacer(
Modifier
.size(dotSize)
.scale(scale)
.background(
color = dotColor,
shape = CircleShape
)
)
}
val infiniteTransition = rememberInfiniteTransition(label = "")
@Composable
fun animateScaleWithDelay(delay: Int) = infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 0f,
animationSpec = infiniteRepeatable(animation = keyframes {
durationMillis = delayUnit * numberOfDots
0f at delay using LinearEasing
1f at delay + delayUnit using LinearEasing
0f at delay + duration
}), label = ""
)
val scales = arrayListOf<State<Float>>()
for (i in 0 until numberOfDots) {
scales.add(animateScaleWithDelay(delay = i * delayUnit))
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
scales.forEach {
Dot(it.value)
Spacer(Modifier.width(spaceBetween))
}
}
}
@Composable
fun DotsElastic() {
val minScale = 0.6f
@Composable
fun Dot(scale: Float) {
Spacer(
Modifier
.size(dotSize)
.scale(scaleX = minScale, scaleY = scale)
.background(
color = dotColor,
shape = CircleShape
)
)
}
val infiniteTransition = rememberInfiniteTransition(label = "")
@Composable
fun animateScaleWithDelay(delay: Int) = infiniteTransition.animateFloat(
initialValue = minScale,
targetValue = minScale,
animationSpec = infiniteRepeatable(animation = keyframes {
durationMillis = duration
minScale at delay using LinearEasing
1f at delay + delayUnit using LinearEasing
minScale at delay + duration
}), label = ""
)
val scales = arrayListOf<State<Float>>()
for (i in 0 until numberOfDots) {
scales.add(animateScaleWithDelay(delay = i * delayUnit))
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
scales.forEach {
Dot(it.value)
Spacer(Modifier.width(spaceBetween))
}
}
}
@Composable
fun DotsFlashing(
modifier: Modifier = Modifier,
dotSize: Dp = 6.dp,
dotColor: Color = Color.Blue,
spaceBetween: Dp = 2.dp,
numberOfDots: Int = 3,
) {
val minAlpha = 0.1f
@Composable
fun Dot(alpha: Float) = Spacer(
Modifier
.size(dotSize)
.alpha(alpha)
.background(
color = dotColor, shape = CircleShape
)
)
val infiniteTransition = rememberInfiniteTransition(label = "")
@Composable
fun animateAlphaWithDelay(delay: Int) = infiniteTransition.animateFloat(
initialValue = minAlpha,
targetValue = minAlpha,
animationSpec = infiniteRepeatable(animation = keyframes {
durationMillis = duration
minAlpha at delay using LinearEasing
1f at delay + delayUnit using LinearEasing
minAlpha at delay + duration
}), label = ""
)
val alphas = arrayListOf<State<Float>>()
for (i in 0 until numberOfDots) {
alphas.add(animateAlphaWithDelay(delay = i * delayUnit))
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
alphas.forEach {
Dot(it.value)
Spacer(Modifier.width(spaceBetween))
}
}
}
@Composable
fun DotsTyping(
modifier: Modifier = Modifier,
dotSize: Dp = 6.dp,
dotColor: Color = Color.Blue,
spaceBetween: Dp = 2.dp,
numberOfDots: Int = 3,
) {
val maxOffset = (numberOfDots * 2).toFloat()
@Composable
fun Dot(offset: Float) {
Spacer(
Modifier
.size(dotSize)
.offset(y = -offset.dp)
.background(
color = dotColor,
shape = CircleShape
)
)
}
val infiniteTransition = rememberInfiniteTransition(label = "")
@Composable
fun animateOffsetWithDelay(delay: Int) = infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 0f,
animationSpec = infiniteRepeatable(animation = keyframes {
durationMillis = duration
0f at delay using LinearEasing
maxOffset at delay + delayUnit using LinearEasing
0f at delay + (duration / 2)
}), label = ""
)
val offsets = arrayListOf<State<Float>>()
for (i in 0 until numberOfDots) {
offsets.add(animateOffsetWithDelay(delay = i * delayUnit))
}
Row(
modifier = modifier.padding(top = maxOffset.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
offsets.forEach {
Dot(it.value)
Spacer(Modifier.width(spaceBetween))
}
}
}
@Composable
fun DotsCollision() {
val maxOffset = 30f
val delayUnit = 500
@Composable
fun Dot(offset: Float) {
Spacer(
Modifier
.size(dotSize)
.offset(x = offset.dp)
.background(
color = dotColor,
shape = CircleShape
)
)
}
val infiniteTransition = rememberInfiniteTransition(label = "")
val offsetLeft by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 0f,
animationSpec = infiniteRepeatable(animation = keyframes {
durationMillis = delayUnit * 3
0f at 0 using LinearEasing
-maxOffset at delayUnit / 2 using LinearEasing
0f at delayUnit
}), label = ""
)
val offsetRight by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 0f,
animationSpec = infiniteRepeatable(animation = keyframes {
durationMillis = delayUnit * 3
0f at delayUnit using LinearEasing
maxOffset at delayUnit + delayUnit / 2 using LinearEasing
0f at delayUnit * 2
}), label = ""
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.padding(horizontal = maxOffset.dp)
) {
Dot(offsetLeft)
Spacer(Modifier.width(spaceBetween))
for (i in 0 until numberOfDots - 2) {
Dot(0f)
Spacer(Modifier.width(spaceBetween))
}
Dot(offsetRight)
}
}
@Preview(showBackground = true)
@Composable
fun DotsPreview() = AppTheme {
Column(
modifier = Modifier
.padding(4.dp)
.fillMaxSize()
) {
val spaceSize = 16.dp
Text(
text = "Dots pulsing", //style = MaterialTheme.typography.h5
)
DotsPulsing()
Spacer(Modifier.height(spaceSize))
Text(
text = "Dots elastic", //style = MaterialTheme.typography.h5
)
DotsElastic()
Spacer(Modifier.height(spaceSize))
Text(
text = "Dots flashing", //style = MaterialTheme.typography.h5
)
DotsFlashing()
Spacer(Modifier.height(spaceSize))
Text(
text = "Dots typing", //style = MaterialTheme.typography.h5
)
DotsTyping()
Spacer(Modifier.height(spaceSize))
Text(
text = "Dots collision", //style = MaterialTheme.typography.h5
)
DotsCollision()
}
}
@@ -0,0 +1,119 @@
package com.meloda.app.fast.conversations.data
import com.meloda.app.fast.data.State
import com.meloda.app.fast.data.api.conversations.ConversationsRepository
import com.meloda.app.fast.data.api.conversations.ConversationsUseCase
import com.meloda.app.fast.data.mapToState
import com.meloda.app.fast.model.api.domain.VkConversation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
class ConversationsUseCaseImpl(
private val repository: ConversationsRepository,
) : ConversationsUseCase {
// override fun getConversations(
// count: Int?,
// offset: Int?,
// fields: String,
// filter: String,
// extended: Boolean?,
// startMessageId: Int?
// ): Flow<com.meloda.app.fast.network.State<ConversationsResponseDomain>> = flow {
// emit(com.meloda.app.fast.network.State.Loading)
//
// val newState = conversationsRepository.getConversations(
// params = ConversationsGetRequest(
// count = count,
// offset = offset,
// fields = fields,
// filter = filter,
// extended = extended,
// startMessageId = startMessageId
// )
// ).fold(
// onSuccess = { response -> com.meloda.app.fast.network.State.Success(response.toDomain()) },
// onNetworkFailure = { com.meloda.app.fast.network.State.Error.ConnectionError },
// onUnknownFailure = { com.meloda.app.fast.network.State.UNKNOWN_ERROR },
// onHttpFailure = { result -> result.error.toStateApiError() },
// onApiFailure = { result -> result.error.toStateApiError() }
// )
// emit(newState)
// }
//
//
// override fun pin(peerId: Int): Flow<com.meloda.app.fast.network.State<Unit>> = flow {
// emit(com.meloda.app.fast.network.State.Loading)
//
// val newState = conversationsRepository.pin(
// ConversationsPinRequest(peerId = peerId)
// ).fold(
// onSuccess = { com.meloda.app.fast.network.State.Success(Unit) },
// onNetworkFailure = { com.meloda.app.fast.network.State.Error.ConnectionError },
// onUnknownFailure = { com.meloda.app.fast.network.State.UNKNOWN_ERROR },
// onHttpFailure = { result -> result.error.toStateApiError() },
// onApiFailure = { result -> result.error.toStateApiError() }
// )
// emit(newState)
// }
//
// override fun unpin(peerId: Int): Flow<com.meloda.app.fast.network.State<Unit>> = flow {
// emit(com.meloda.app.fast.network.State.Loading)
//
// val newState = conversationsRepository.unpin(
// ConversationsUnpinRequest(peerId = peerId)
// ).fold(
// onSuccess = { com.meloda.app.fast.network.State.Success(Unit) },
// onNetworkFailure = { com.meloda.app.fast.network.State.Error.ConnectionError },
// onUnknownFailure = { com.meloda.app.fast.network.State.UNKNOWN_ERROR },
// onHttpFailure = { result -> result.error.toStateApiError() },
// onApiFailure = { result -> result.error.toStateApiError() }
// )
// emit(newState)
// }
//
// override suspend fun storeConversations(conversations: List<VkConversationDomain>) {
// conversationsDao.insertAll(conversations.map(VkConversationDomain::mapToDb))
// }
//
// override suspend fun storeGroups(groups: List<VkGroupDomain>) {
// groupsDao.insertAll(groups.map(VkGroupDomain::mapToDB))
// }
override fun getConversations(
count: Int?,
offset: Int?
): Flow<State<List<VkConversation>>> = flow {
emit(State.Loading)
val newState = repository.getConversations(count, offset).mapToState()
emit(newState)
}
override suspend fun storeConversations(
conversations: List<VkConversation>
) = withContext(Dispatchers.IO) {
repository.storeConversations(conversations)
}
override fun delete(peerId: Int): Flow<State<Int>> = flow {
emit(State.Loading)
val newState = repository.delete(peerId = peerId).mapToState()
emit(newState)
}
override fun changePinState(peerId: Int, pin: Boolean): Flow<State<Int>> = flow {
emit(State.Loading)
val newState = if (pin) {
repository.pin(peerId)
} else {
repository.unpin(peerId)
}.mapToState()
emit(newState)
}
}
@@ -0,0 +1,15 @@
package com.meloda.app.fast.conversations.di
import com.meloda.app.fast.conversations.ConversationsViewModelImpl
import com.meloda.app.fast.conversations.data.ConversationsUseCaseImpl
import com.meloda.app.fast.data.api.conversations.ConversationsUseCase
import org.koin.androidx.viewmodel.dsl.viewModelOf
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
val conversationsModule = module {
singleOf(::ConversationsUseCaseImpl) bind ConversationsUseCase::class
viewModelOf(::ConversationsViewModelImpl)
}
@@ -0,0 +1,20 @@
package com.meloda.app.fast.conversations.model
enum class ActionState {
PHANTOM, CALL_IN_PROGRESS, NONE;
// TODO: 11/04/2024, Danil Nikolaev: implement
fun getResourceId(): Int {
return -1
}
companion object {
fun parse(isPhantom: Boolean, isCallInProgress: Boolean): ActionState {
return when {
isPhantom -> PHANTOM
isCallInProgress -> CALL_IN_PROGRESS
else -> NONE
}
}
}
}
@@ -0,0 +1,31 @@
package com.meloda.app.fast.conversations.model
import com.meloda.app.fast.common.UiImage
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.designsystem.R as UiR
sealed class ConversationOption(
val title: UiText,
val icon: UiImage
) {
data object MarkAsRead : ConversationOption(
title = UiText.Resource(UiR.string.action_mark_as_read),
icon = UiImage.Resource(UiR.drawable.round_done_all_24)
)
data object Pin : ConversationOption(
title = UiText.Resource(UiR.string.action_pin),
icon = UiImage.Resource(UiR.drawable.pin_outline_24)
)
data object Unpin : ConversationOption(
title = UiText.Resource(UiR.string.action_unpin),
icon = UiImage.Resource(UiR.drawable.pin_off_outline_24)
)
data object Delete : ConversationOption(
title = UiText.Resource(UiR.string.action_delete),
icon = UiImage.Resource(UiR.drawable.round_delete_outline_24)
)
}
@@ -0,0 +1,23 @@
package com.meloda.app.fast.conversations.model
import androidx.compose.runtime.Immutable
@Immutable
data class ConversationsScreenState(
val showOptions: ConversationsShowOptions,
val conversations: List<UiConversation>,
val isLoading: Boolean,
val isPaginating: Boolean,
val isPaginationExhausted: Boolean
) {
companion object {
val EMPTY: ConversationsScreenState = ConversationsScreenState(
showOptions = ConversationsShowOptions.EMPTY,
conversations = emptyList(),
isLoading = true,
isPaginating = false,
isPaginationExhausted = false
)
}
}
@@ -0,0 +1,14 @@
package com.meloda.app.fast.conversations.model
data class ConversationsShowOptions(
val showDeleteDialog: Int?,
val showPinDialog: UiConversation?
) {
companion object {
val EMPTY: ConversationsShowOptions = ConversationsShowOptions(
showDeleteDialog = null,
showPinDialog = null
)
}
}
@@ -0,0 +1,31 @@
package com.meloda.app.fast.conversations.model
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.AnnotatedString
import com.meloda.app.fast.common.UiImage
import com.meloda.app.fast.designsystem.ImmutableList
import com.meloda.app.fast.model.api.PeerType
import com.meloda.app.fast.model.api.domain.VkMessage
@Immutable
data class UiConversation(
val id: Int,
val lastMessageId: Int?,
val avatar: UiImage?,
val title: String,
val unreadCount: String?,
val date: String,
val message: AnnotatedString,
val attachmentImage: UiImage?,
val isPinned: Boolean,
val actionImageId: Int,
val isBirthday: Boolean,
val isUnread: Boolean,
val isAccount: Boolean,
val isOnline: Boolean,
val lastMessage: VkMessage?,
val peerType: PeerType,
val interactionText: String?,
val isExpanded: Boolean,
val options: ImmutableList<ConversationOption>,
)
@@ -0,0 +1,33 @@
package com.meloda.app.fast.conversations.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.meloda.app.fast.common.extensions.navigation.sharedViewModel
import com.meloda.app.fast.conversations.ConversationsViewModel
import com.meloda.app.fast.conversations.ConversationsViewModelImpl
import com.meloda.app.fast.conversations.presentation.ConversationsScreen
import com.meloda.app.fast.model.BaseError
import kotlinx.serialization.Serializable
@Serializable
object Conversations
fun NavGraphBuilder.conversationsRoute(
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (id: Int) -> Unit,
onListScrollingUp: (Boolean) -> Unit,
navController: NavController,
) {
composable<Conversations> {
val viewModel: ConversationsViewModel =
it.sharedViewModel<ConversationsViewModelImpl>(navController = navController)
ConversationsScreen(
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onListScrollingUp = onListScrollingUp,
viewModel = viewModel
)
}
}
@@ -0,0 +1,392 @@
package com.meloda.app.fast.conversations.presentation
import android.graphics.drawable.ColorDrawable
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
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
import androidx.compose.foundation.layout.Spacer
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ElevatedAssistChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.meloda.app.fast.common.UiImage
import com.meloda.app.fast.conversations.DotsFlashing
import com.meloda.app.fast.conversations.model.ConversationOption
import com.meloda.app.fast.conversations.model.UiConversation
import com.meloda.app.fast.designsystem.ContentAlpha
import com.meloda.app.fast.designsystem.LocalContentAlpha
import com.meloda.app.fast.designsystem.getString
import com.meloda.app.fast.designsystem.R as UiR
val BirthdayColor = Color(0xffb00b69)
@Composable
fun UiImage.getResourcePainter(): Painter? {
return when (this) {
is UiImage.Resource -> painterResource(id = resId)
else -> null
}
}
@Composable
fun UiImage.getImage(): Any {
return when (this) {
is UiImage.Color -> ColorDrawable(color)
is UiImage.ColorResource -> ColorDrawable(colorResource(id = resId).toArgb())
is UiImage.Resource -> painterResource(id = resId)
is UiImage.Simple -> drawable
is UiImage.Url -> url
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ConversationItem(
onItemClick: (Int) -> Unit,
onItemLongClick: (conversation: UiConversation) -> Unit,
onOptionClicked: (UiConversation, ConversationOption) -> Unit,
maxLines: Int,
isUserAccount: Boolean,
conversation: UiConversation,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val hapticFeedback = LocalHapticFeedback.current
val bottomStartCornerRadius by animateDpAsState(
targetValue = if (conversation.isExpanded) 10.dp else 34.dp,
label = "bottomStartCornerRadius"
)
Box(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onClick = { onItemClick(conversation.id) },
onLongClick = {
onItemLongClick(conversation)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
)
) {
val showBackground by remember(conversation) {
derivedStateOf { conversation.isUnread || conversation.isExpanded }
}
AnimatedVisibility(
visible = showBackground,
modifier = Modifier
.matchParentSize()
.padding(start = 8.dp),
enter = fadeIn(),
exit = fadeOut()
) {
Box(
modifier = Modifier
.matchParentSize()
.clip(
RoundedCornerShape(
topStart = 34.dp,
bottomStart = bottomStartCornerRadius
)
)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp))
)
}
Column(modifier = Modifier.fillMaxWidth()) {
Spacer(modifier = Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Spacer(modifier = Modifier.width(16.dp))
Box(modifier = Modifier.size(56.dp)) {
if (isUserAccount) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
) {
Image(
modifier = Modifier
.align(Alignment.Center)
.size(32.dp),
painter = painterResource(id = UiR.drawable.ic_round_bookmark_border_24),
contentDescription = "Favorites icon"
)
}
} else {
val avatarImage = conversation.avatar?.getImage()
if (avatarImage is Painter) {
Image(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
painter = avatarImage,
contentDescription = "Avatar",
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground)
)
} else {
AsyncImage(
model = ImageRequest.Builder(context).data(avatarImage)
.crossfade(true).build(),
contentDescription = "Avatar",
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut)
)
}
}
if (conversation.isPinned) {
Box(
modifier = Modifier
.clip(CircleShape)
.defaultMinSize(minWidth = 20.dp, minHeight = 20.dp)
.background(MaterialTheme.colorScheme.outline)
) {
Image(
modifier = Modifier
.height(14.dp)
.align(Alignment.Center),
painter = painterResource(id = UiR.drawable.ic_round_push_pin_24),
contentDescription = "Pin icon"
)
}
}
if (conversation.isOnline) {
Box(
modifier = Modifier
.clip(CircleShape)
.size(18.dp)
.background(
if (conversation.isUnread) {
MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)
} else {
MaterialTheme.colorScheme.background
}
)
.padding(2.dp)
.align(Alignment.BottomEnd)
) {
Box(
modifier = Modifier
.clip(CircleShape)
.matchParentSize()
.background(MaterialTheme.colorScheme.primary)
)
}
}
if (conversation.isBirthday) {
Box(
modifier = Modifier
.clip(CircleShape)
.size(16.dp)
.background(
if (conversation.isUnread) {
MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)
} else {
MaterialTheme.colorScheme.background
}
)
.padding(2.dp)
.align(Alignment.TopEnd)
) {
Box(
modifier = Modifier
.clip(CircleShape)
.matchParentSize()
.background(BirthdayColor)
) {
Image(
modifier = Modifier
.align(Alignment.Center)
.size(10.dp),
painter = painterResource(id = UiR.drawable.round_cake_24),
contentDescription = "Birthday icon"
)
}
}
}
}
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = conversation.title,
minLines = 1,
maxLines = maxLines,
style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp)
)
Row {
if (conversation.interactionText != null) {
Text(
text = conversation.interactionText,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
DotsFlashing(
modifier = Modifier
.align(Alignment.Bottom)
.padding(bottom = 7.dp),
dotSize = 4.dp,
dotColor = MaterialTheme.colorScheme.primary
)
} else {
conversation.attachmentImage?.getResourcePainter()?.let { painter ->
Column {
Spacer(modifier = Modifier.height(4.dp))
Image(
modifier = Modifier.size(14.dp),
painter = painter,
contentDescription = "attachment image",
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimaryContainer)
)
}
Spacer(modifier = Modifier.width(2.dp))
}
LocalContentAlpha(alpha = ContentAlpha.medium) {
Text(
modifier = Modifier.weight(1f),
text = conversation.message,
minLines = 1,
maxLines = maxLines,
style = MaterialTheme.typography.bodyLarge,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
Spacer(modifier = Modifier.width(4.dp))
Column {
LocalContentAlpha(alpha = ContentAlpha.medium) {
Text(
text = conversation.date,
style = MaterialTheme.typography.bodySmall
)
}
conversation.unreadCount?.let { count ->
Spacer(modifier = Modifier.height(6.dp))
Box(
modifier = Modifier
.clip(CircleShape)
.defaultMinSize(minWidth = 20.dp, minHeight = 20.dp)
.background(MaterialTheme.colorScheme.primary)
.align(Alignment.CenterHorizontally)
) {
Text(
modifier = Modifier
.padding(2.dp)
.align(Alignment.Center),
text = count,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimary
)
}
}
}
Spacer(modifier = Modifier.width(24.dp))
}
AnimatedVisibility(conversation.isExpanded) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp)
) {
Spacer(modifier = Modifier.height(12.dp))
HorizontalDivider()
Row(
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
conversation.options.forEach { option ->
ElevatedAssistChip(
onClick = { onOptionClicked(conversation, option) },
leadingIcon = {
option.icon.getResourcePainter()?.let { painter ->
Icon(
painter = painter,
contentDescription = "Chip icon",
modifier = Modifier.size(16.dp)
)
}
},
label = {
Text(text = option.title.getString().orEmpty())
}
)
}
}
}
}
val bottomSpacerHeight by animateDpAsState(
targetValue = if (conversation.isExpanded) 4.dp else 8.dp,
label = "bottomSpacerHeight"
)
Spacer(modifier = Modifier.height(bottomSpacerHeight))
}
}
}
@@ -0,0 +1,109 @@
package com.meloda.app.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.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.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.meloda.app.fast.common.UserConfig
import com.meloda.app.fast.conversations.model.ConversationOption
import com.meloda.app.fast.conversations.model.ConversationsScreenState
import com.meloda.app.fast.conversations.model.UiConversation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun ConversationsListComposable(
onConversationsClick: (Int) -> Unit,
onConversationsLongClick: (UiConversation) -> Unit,
screenState: ConversationsScreenState,
state: LazyListState,
maxLines: Int,
modifier: Modifier,
onOptionClicked: (UiConversation, ConversationOption) -> Unit,
padding: PaddingValues
) {
val coroutineScope = rememberCoroutineScope()
val conversations = screenState.conversations
LazyColumn(
modifier = modifier,
state = state
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(
items = conversations,
key = UiConversation::id,
) { conversation ->
val isUserAccount by remember(conversation) {
derivedStateOf {
conversation.id == UserConfig.userId
}
}
ConversationItem(
onItemClick = onConversationsClick,
onItemLongClick = onConversationsLongClick,
onOptionClicked = onOptionClicked,
maxLines = maxLines,
isUserAccount = isUserAccount,
conversation = conversation,
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
)
Spacer(modifier = Modifier.height(8.dp))
}
item {
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null)
.navigationBarsPadding(),
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
)
}
}
}
}
}
}
@@ -0,0 +1,441 @@
package com.meloda.app.fast.conversations.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideIn
import androidx.compose.animation.slideOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
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
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.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.imageLoader
import coil.request.ImageRequest
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.conversations.ConversationsViewModel
import com.meloda.app.fast.conversations.ConversationsViewModelImpl
import com.meloda.app.fast.conversations.model.ConversationsScreenState
import com.meloda.app.fast.conversations.model.UiConversation
import com.meloda.app.fast.designsystem.LocalTheme
import com.meloda.app.fast.designsystem.MaterialDialog
import com.meloda.app.fast.designsystem.components.FullScreenLoader
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.ui.ErrorView
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class,
)
@Composable
fun ConversationsScreen(
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (conversationId: Int) -> Unit,
onListScrollingUp: (Boolean) -> Unit,
viewModel: ConversationsViewModel = koinViewModel<ConversationsViewModelImpl>()
) {
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val context = LocalContext.current
val imagesToPreload by viewModel.imagesToPreload.collectAsStateWithLifecycle()
imagesToPreload.forEach { url ->
context.imageLoader.enqueue(
ImageRequest.Builder(context)
.data(url)
.build()
)
}
val view = LocalView.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val currentTheme = LocalTheme.current
val maxLines by remember {
derivedStateOf {
if (currentTheme.multiline) 2 else 1
}
}
val listState = rememberLazyListState()
val isListScrollingUp = listState.isScrollingUp()
LaunchedEffect(isListScrollingUp) {
onListScrollingUp(isListScrollingUp)
}
val paginationConditionMet by remember {
derivedStateOf {
canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
}
}
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
viewModel.onMetPaginationCondition()
}
}
val hazeState = remember { HazeState() }
var dropDownMenuExpanded by remember {
mutableStateOf(false)
}
val toolbarColorAlpha by animateFloatAsState(
targetValue = if (!listState.canScrollBackward) 1f else 0f,
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
)
val toolbarContainerColor by animateColorAsState(
targetValue =
if (currentTheme.usingBlur || !listState.canScrollBackward)
MaterialTheme.colorScheme.surface
else
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
)
val pullToRefreshAlpha by animateFloatAsState(
targetValue = if (!listState.canScrollBackward) 1f else 0f,
label = "pullToRefreshAlpha",
animationSpec = tween(durationMillis = 50)
)
Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.statusBars,
topBar = {
Column(modifier = Modifier.fillMaxWidth()) {
TopAppBar(
title = {
Text(
text = stringResource(
id = if (screenState.isLoading) UiR.string.title_loading
else UiR.string.title_conversations
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
actions = {
IconButton(
onClick = {
dropDownMenuExpanded = true
}
) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = "Options button"
)
}
DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
expanded = dropDownMenuExpanded,
onDismissRequest = {
dropDownMenuExpanded = false
},
offset = DpOffset(x = (-4).dp, y = (-60).dp)
) {
DropdownMenuItem(
onClick = {
viewModel.onRefresh()
dropDownMenuExpanded = false
},
text = {
Text(text = stringResource(id = UiR.string.action_refresh))
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = null
)
}
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = toolbarContainerColor.copy(
alpha = if (currentTheme.usingBlur) toolbarColorAlpha else 1f
)
),
modifier = Modifier
.then(
if (currentTheme.usingBlur) {
Modifier.hazeChild(
state = hazeState,
style = HazeMaterials.thick()
)
} else Modifier
)
.fillMaxWidth(),
)
val showHorizontalProgressBar by remember(screenState) {
derivedStateOf { screenState.isLoading && screenState.conversations.isNotEmpty() }
}
AnimatedVisibility(showHorizontalProgressBar) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
},
floatingActionButton = {
val scope = rememberCoroutineScope()
val rotation = remember { Animatable(0f) }
AnimatedVisibility(
visible = isListScrollingUp,
modifier = Modifier.navigationBarsPadding(),
enter = slideIn { IntOffset(0, 400) },
exit = slideOut { IntOffset(0, 400) }
) {
FloatingActionButton(
onClick = {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
scope.launch {
for (i in 20 downTo 0 step 4) {
rotation.animateTo(
targetValue = i.toFloat(),
animationSpec = tween(50)
)
if (i > 0) {
rotation.animateTo(
targetValue = -i.toFloat(),
animationSpec = tween(50)
)
}
}
}
},
modifier = Modifier.rotate(rotation.value)
) {
Icon(
painter = painterResource(id = UiR.drawable.ic_baseline_create_24),
contentDescription = "Add chat button"
)
}
}
}
) { padding ->
when {
baseError is BaseError.SessionExpired -> {
ErrorView(
text = "Session expired",
buttonText = "Log out",
onButtonClick = { onError(BaseError.SessionExpired) }
)
}
screenState.isLoading && screenState.conversations.isEmpty() -> FullScreenLoader()
else -> {
val pullToRefreshState = rememberPullToRefreshState()
Box(
modifier = Modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr))
.padding(bottom = padding.calculateBottomPadding())
.nestedScroll(pullToRefreshState.nestedScrollConnection)
) {
ConversationsListComposable(
onConversationsClick = { id ->
onNavigateToMessagesHistory(id)
viewModel.onConversationItemClick(id)
},
onConversationsLongClick = viewModel::onConversationItemLongClick,
screenState = screenState,
state = listState,
maxLines = maxLines,
modifier = if (currentTheme.usingBlur) {
Modifier.haze(
state = hazeState,
style = HazeMaterials.thick()
)
} else {
Modifier
}.fillMaxSize(),
onOptionClicked = viewModel::onOptionClicked,
padding = padding
)
if (pullToRefreshState.isRefreshing) {
LaunchedEffect(true) {
viewModel.onRefresh()
}
}
LaunchedEffect(screenState.isLoading) {
if (!screenState.isLoading) {
pullToRefreshState.endRefresh()
}
}
PullToRefreshContainer(
state = pullToRefreshState,
modifier = Modifier
.alpha(pullToRefreshAlpha)
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
contentColor = MaterialTheme.colorScheme.primary
)
}
}
}
HandleDialogs(
screenState = screenState,
viewModel = viewModel
)
}
}
// 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) {
val conversationId = showOptions.showDeleteDialog
DeleteDialog(
conversationId = conversationId,
viewModel = viewModel
)
}
showOptions.showPinDialog?.let { conversation ->
PinDialog(
conversation = conversation,
viewModel = viewModel
)
}
}
@Composable
fun DeleteDialog(
conversationId: Int,
viewModel: ConversationsViewModel
) {
MaterialDialog(
title = UiText.Resource(UiR.string.confirm_delete_conversation),
confirmText = UiText.Resource(UiR.string.action_delete),
confirmAction = { viewModel.onDeleteDialogPositiveClick(conversationId) },
cancelText = UiText.Resource(UiR.string.cancel),
onDismissAction = viewModel::onDeleteDialogDismissed
)
}
@Composable
fun PinDialog(
conversation: UiConversation,
viewModel: ConversationsViewModel
) {
MaterialDialog(
title = UiText.Resource(
if (conversation.isPinned) UiR.string.confirm_unpin_conversation
else UiR.string.confirm_pin_conversation
),
confirmText = UiText.Resource(
if (conversation.isPinned) UiR.string.action_unpin
else UiR.string.action_pin
),
confirmAction = {
viewModel.onPinDialogPositiveClick(conversation)
},
cancelText = UiText.Resource(UiR.string.cancel),
onDismissAction = viewModel::onPinDialogDismissed
)
}
@Composable
private fun LazyListState.isScrollingUp(): Boolean {
var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) }
var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) }
return remember(this) {
derivedStateOf {
if (previousIndex != firstVisibleItemIndex) {
previousIndex > firstVisibleItemIndex
} else {
previousScrollOffset >= firstVisibleItemScrollOffset
}.also {
previousIndex = firstVisibleItemIndex
previousScrollOffset = firstVisibleItemScrollOffset
}
}
}.value
}
@@ -0,0 +1,848 @@
package com.meloda.app.fast.conversations.util
import android.content.res.Resources
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import com.conena.nanokt.jvm.util.dayOfMonth
import com.conena.nanokt.jvm.util.month
import com.meloda.app.fast.common.UiImage
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.common.UserConfig
import com.meloda.app.fast.common.extensions.orDots
import com.meloda.app.fast.common.parseString
import com.meloda.app.fast.common.util.TimeUtils
import com.meloda.app.fast.conversations.model.ActionState
import com.meloda.app.fast.conversations.model.UiConversation
import com.meloda.app.fast.data.VkMemoryCache
import com.meloda.app.fast.designsystem.ImmutableList
import com.meloda.app.fast.model.InteractionType
import com.meloda.app.fast.model.api.PeerType
import com.meloda.app.fast.model.api.data.AttachmentType
import com.meloda.app.fast.model.api.domain.VkAttachment
import com.meloda.app.fast.model.api.domain.VkConversation
import com.meloda.app.fast.model.api.domain.VkMessage
import java.util.Calendar
import java.util.Locale
import kotlin.math.ln
import kotlin.math.pow
import com.meloda.app.fast.designsystem.R as UiR
fun VkConversation.asPresentation(
resources: Resources,
useContactName: Boolean = false
): UiConversation = UiConversation(
id = id,
lastMessageId = lastMessageId,
avatar = extractAvatar(),
title = extractTitle(this, useContactName, resources),
unreadCount = extractUnreadCount(lastMessage, this),
date = TimeUtils.getLocalizedTime(resources, (lastMessage?.date ?: -1) * 1000L),
message = extractMessage(resources, lastMessage, id, peerType),
attachmentImage = if (lastMessage?.text == null) null
else getAttachmentConversationIcon(lastMessage),
isPinned = majorId > 0,
actionImageId = ActionState.parse(isPhantom, isCallInProgress).getResourceId(),
isBirthday = extractBirthday(this),
isUnread = extractReadCondition(this, lastMessage),
isAccount = isAccount(id),
isOnline = !isAccount(id) && user?.onlineStatus?.isOnline() == true,
lastMessage = lastMessage,
peerType = peerType,
interactionText = extractInteractionText(resources, this),
isExpanded = false,
options = ImmutableList.empty()
)
fun VkConversation.extractAvatar() = when (peerType) {
PeerType.USER -> {
if (isAccount(id)) null
else user?.photo200
}
PeerType.GROUP -> {
group?.photo200
}
PeerType.CHAT -> {
photo200
}
}?.let(UiImage::Url) ?: UiImage.Resource(UiR.drawable.ic_account_circle_cut)
private fun extractTitle(
conversation: VkConversation,
useContactName: Boolean,
resources: Resources
) = when (conversation.peerType) {
PeerType.USER -> {
if (isAccount(conversation.id)) {
UiText.Resource(UiR.string.favorites)
} else {
val userName = conversation.user?.let { user ->
if (useContactName) {
VkMemoryCache.getContact(user.id)?.name ?: user.fullName
} else {
user.fullName
}
}
UiText.Simple(userName.orDots())
}
}
PeerType.GROUP -> UiText.Simple(conversation.group?.name.orDots())
PeerType.CHAT -> UiText.Simple(conversation.title.orDots())
}.parseString(resources).orDots()
private fun extractUnreadCount(
lastMessage: VkMessage?,
conversation: VkConversation
): String? = when {
lastMessage?.isOut == false && !conversation.isInUnread() -> null
conversation.unreadCount == 0 -> null
conversation.unreadCount < 1000 -> conversation.unreadCount.toString()
else -> {
val exp = (ln(conversation.unreadCount.toDouble()) / ln(1000.0)).toInt()
val suffix = "KMBT"[exp - 1]
val result = conversation.unreadCount / 1000.0.pow(exp.toDouble())
if (result.toLong().toDouble() == result) {
String.format(Locale.getDefault(), "%.0f%s", result, suffix)
} else {
String.format(Locale.getDefault(), "%.1f%s", result, suffix)
}
}
}
private fun extractMessage(
resources: Resources,
lastMessage: VkMessage?,
peerId: Int,
peerType: PeerType
): AnnotatedString {
val youPrefix = UiText.Resource(UiR.string.you_message_prefix)
.parseString(resources)
.orDots()
val actionMessage = extractActionText(
lastMessage = lastMessage,
resources = resources,
youPrefix = youPrefix
)
val attachmentIcon: UiImage? = extractAttachmentIcon(lastMessage)
val attachmentText: AnnotatedString? =
if (attachmentIcon != null) null
else extractAttachmentText(resources, lastMessage)
val forwardsMessage =
if (lastMessage?.text != null) null
else extractForwardsText(resources, lastMessage)
val messageText = lastMessage?.text.orEmpty()
val prefixText: AnnotatedString? = when {
actionMessage != null -> null
lastMessage == null -> null
peerId == UserConfig.userId -> null
!peerType.isChat() && !lastMessage.isOut -> null
lastMessage.isOut -> buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
append(youPrefix)
}
}
else ->
when {
lastMessage.user?.firstName.orEmpty().isNotEmpty() -> buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
append(lastMessage.user?.firstName)
}
}
lastMessage.group?.name.orEmpty().isNotEmpty() -> buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
append(lastMessage.group?.name)
}
}
else -> null
}
}
val prefix = buildAnnotatedString {
if (prefixText != null) {
append(prefixText)
append(": ")
}
}
val finalText = when {
actionMessage != null -> {
prefix + actionMessage
}
forwardsMessage != null -> {
prefix + forwardsMessage
}
attachmentText != null -> {
prefix + attachmentText
}
else ->
messageText
.replace("\n", " ")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("<br>", " ")
.replace("&gt;", ">")
.replace("&lt;", "<")
.replace("<br/>", " ")
.replace("&ndash;", "-")
.trim()
.let { text -> getTextWithVisualizedMentions(text, Color.Red) }
.let { text -> prefix + text }
}
return finalText
}
private fun extractActionText(
lastMessage: VkMessage?,
resources: Resources,
youPrefix: String
): AnnotatedString? {
if (lastMessage == null) return null
val fromId = lastMessage.fromId
val text = lastMessage.actionText.orDots()
val groupName = lastMessage.group?.name.orDots()
val userName = lastMessage.user?.fullName.orDots()
val actionGroupName = lastMessage.actionGroup?.name.orDots()
val actionUserName = lastMessage.actionUser?.fullName.orDots()
val memberId = lastMessage.actionMemberId
val isMemberUser = (memberId ?: 0) > 0
val isMemberGroup = (memberId ?: 0) < 0
val prefix = when {
lastMessage.fromId == UserConfig.userId -> youPrefix
lastMessage.isGroup() -> groupName
lastMessage.isUser() -> userName
else -> null
}.orDots()
val memberPrefix = when {
memberId == UserConfig.userId -> youPrefix
isMemberUser -> actionUserName
isMemberGroup -> actionGroupName
else -> null
}.orDots()
return buildAnnotatedString {
when (lastMessage.action) {
null -> return null
VkMessage.Action.CHAT_CREATE -> {
val string = UiText.ResourceParams(
UiR.string.message_action_chat_created,
listOf(prefix, text)
).parseString(resources).orEmpty()
append(string)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
val textStartIndex = string.indexOf(text)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = textStartIndex,
end = textStartIndex + text.length
)
}
VkMessage.Action.CHAT_TITLE_UPDATE -> {
val string = UiText.ResourceParams(
UiR.string.message_action_chat_renamed,
listOf(prefix, text)
).parseString(resources).orEmpty()
append(string)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
val textStartIndex = string.indexOf(text)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = textStartIndex,
end = textStartIndex + text.length
)
}
VkMessage.Action.CHAT_PHOTO_UPDATE -> {
UiText.ResourceParams(
UiR.string.message_action_chat_photo_update,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_PHOTO_REMOVE -> {
UiText.ResourceParams(
UiR.string.message_action_chat_photo_remove,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_KICK_USER -> {
if (memberId == fromId) {
UiText.ResourceParams(
UiR.string.message_action_chat_user_left,
listOf(memberPrefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = memberPrefix.length
)
} else {
val postfix =
if (memberId == UserConfig.userId) youPrefix.lowercase()
else lastMessage.actionUser.toString()
val string = UiText.ResourceParams(
UiR.string.message_action_chat_user_kicked,
listOf(prefix, postfix)
).parseString(resources).orEmpty()
append(string)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
val postfixStartIndex = string.indexOf(postfix)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = postfixStartIndex,
end = postfixStartIndex + postfix.length
)
}
}
VkMessage.Action.CHAT_INVITE_USER -> {
if (memberId == lastMessage.fromId) {
UiText.ResourceParams(
UiR.string.message_action_chat_user_returned,
listOf(memberPrefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = memberPrefix.length
)
} else {
val postfix =
if (memberId == UserConfig.userId) youPrefix.lowercase()
else lastMessage.actionUser.toString()
val string = UiText.ResourceParams(
UiR.string.message_action_chat_user_invited,
listOf(memberPrefix, postfix)
).parseString(resources).orEmpty()
append(string)
val postfixStartIndex = string.indexOf(postfix)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = postfixStartIndex,
end = postfixStartIndex + postfix.length
)
}
}
VkMessage.Action.CHAT_INVITE_USER_BY_LINK -> {
UiText.ResourceParams(
UiR.string.message_action_chat_user_joined_by_link,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_INVITE_USER_BY_CALL -> {
UiText.ResourceParams(
UiR.string.message_action_chat_user_joined_by_call,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_INVITE_USER_BY_CALL_LINK -> {
UiText.ResourceParams(
UiR.string.message_action_chat_user_joined_by_call_link,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_PIN_MESSAGE -> {
UiText.ResourceParams(
UiR.string.message_action_chat_pin_message,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_UNPIN_MESSAGE -> {
UiText.ResourceParams(
UiR.string.message_action_chat_unpin_message,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_SCREENSHOT -> {
UiText.ResourceParams(
UiR.string.message_action_chat_screenshot,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_STYLE_UPDATE -> {
UiText.ResourceParams(
UiR.string.message_action_chat_style_update,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
}
}
}
private fun extractAttachmentIcon(
lastMessage: VkMessage?
): UiImage? = when {
lastMessage == null -> null
lastMessage.text == null -> null
!lastMessage.forwards.isNullOrEmpty() -> {
if (lastMessage.forwards.orEmpty().size == 1) {
UiImage.Resource(UiR.drawable.ic_attachment_forwarded_message)
} else {
UiImage.Resource(UiR.drawable.ic_attachment_forwarded_messages)
}
}
else -> {
lastMessage.attachments?.let { attachments ->
if (attachments.isEmpty()) return null
if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) {
lastMessage.geoType?.let {
return UiImage.Resource(UiR.drawable.ic_map_marker)
}
getAttachmentIconByType(attachments.first().type)
} else {
UiImage.Resource(UiR.drawable.ic_baseline_attach_file_24)
}
}
}
}
private fun extractAttachmentText(
resources: Resources,
lastMessage: VkMessage?
): AnnotatedString? = when {
lastMessage == null -> null
lastMessage.geoType != null -> {
buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
when (lastMessage.geoType) {
"point" -> {
UiText.Resource(UiR.string.message_geo_point)
.parseString(resources)
.let(::append)
}
else -> {
UiText.Resource(UiR.string.message_geo)
.parseString(resources)
.let(::append)
}
}
}
}
}
lastMessage.hasAttachments() -> {
buildAnnotatedString {
val attachments = lastMessage.attachments.orEmpty()
withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
if (attachments.size == 1) {
getAttachmentUiText(attachments.first())
.parseString(resources)
.let(::append)
} else {
when {
isAttachmentsHaveOneType(attachments) -> {
getAttachmentUiText(attachments.first(), attachments.size)
.parseString(resources)
.let(::append)
}
attachments.any { it.type == AttachmentType.ARTIST } -> {
getAttachmentUiText(
attachments.first { it.type == AttachmentType.ARTIST }
)
.parseString(resources)
.let(::append)
}
else -> {
UiText.Resource(UiR.string.message_attachments_many)
.parseString(resources)
.let(::append)
}
}
}
}
}
}
else -> null
}
private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? {
return when (attachmentType) {
AttachmentType.PHOTO -> UiR.drawable.ic_attachment_photo
AttachmentType.VIDEO -> UiR.drawable.ic_attachment_video
AttachmentType.AUDIO -> UiR.drawable.ic_attachment_audio
AttachmentType.FILE -> UiR.drawable.ic_attachment_file
AttachmentType.LINK -> UiR.drawable.ic_attachment_link
AttachmentType.AUDIO_MESSAGE -> UiR.drawable.ic_attachment_voice
AttachmentType.MINI_APP -> UiR.drawable.ic_attachment_mini_app
AttachmentType.STICKER -> UiR.drawable.ic_attachment_sticker
AttachmentType.GIFT -> UiR.drawable.ic_attachment_gift
AttachmentType.WALL -> UiR.drawable.ic_attachment_wall
AttachmentType.GRAFFITI -> UiR.drawable.ic_attachment_graffiti
AttachmentType.POLL -> UiR.drawable.ic_attachment_poll
AttachmentType.WALL_REPLY -> UiR.drawable.ic_attachment_wall_reply
AttachmentType.CALL -> UiR.drawable.ic_attachment_call
AttachmentType.GROUP_CALL_IN_PROGRESS -> UiR.drawable.ic_attachment_group_call
AttachmentType.STORY -> UiR.drawable.ic_attachment_story
AttachmentType.UNKNOWN -> null
AttachmentType.CURATOR -> null
AttachmentType.EVENT -> null
AttachmentType.WIDGET -> null
AttachmentType.ARTIST -> null
AttachmentType.AUDIO_PLAYLIST -> null
AttachmentType.PODCAST -> null
}?.let(UiImage::Resource)
}
private fun isAttachmentsHaveOneType(attachments: List<VkAttachment>): Boolean {
if (attachments.isEmpty()) return true
if (attachments.size == 1) return true
val firstType = attachments.first().type
for (attachment in attachments) {
if (firstType != attachment.type) return false
}
return true
}
private fun extractForwardsText(
resources: Resources,
lastMessage: VkMessage?
): AnnotatedString? = when {
lastMessage == null -> null
lastMessage.hasForwards() -> buildAnnotatedString {
val forwards = lastMessage.forwards.orEmpty()
withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
append(
UiText.Resource(
if (forwards.size == 1) UiR.string.forwarded_message
else UiR.string.forwarded_messages
).parseString(resources)
)
}
}
else -> null
}
private fun getTextWithVisualizedMentions(
originalText: String,
mentionColor: Color,
): AnnotatedString = buildAnnotatedString {
val regex = """\[(id|club)(\d+)\|([^]]+)]""".toRegex()
val mentions = mutableListOf<MentionIndex>()
var currentIndex = 0
val replacements = mutableListOf<Pair<IntRange, String>>()
// TODO: 25/04/2024, Danil Nikolaev: check why not working ([id279494346|@iworld2rist] да убери ты Елену Шлипс от меня)
val result = regex.replace(originalText) { matchResult ->
val idPrefix = matchResult.groups[1]?.value.orEmpty()
val startIndex = matchResult.range.first
val endIndex = matchResult.range.last
val id = matchResult.groups[2]?.value ?: ""
val text = matchResult.groups[3]?.value ?: ""
val replaced =
text.substring(startIndex, endIndex + 1)
.replace("[$idPrefix$id|$text]", text)
val indexRange =
(startIndex + currentIndex)..startIndex + currentIndex + replaced.length
replacements.add(indexRange to replaced)
mentions += MentionIndex(
id = id.toIntOrNull() ?: -1,
idPrefix = idPrefix,
indexRange = indexRange
)
currentIndex += replaced.length - (endIndex - startIndex + 1)
replaced
}
append(result)
mentions.forEach { mention ->
val startIndex = mention.indexRange.first
val endIndex = mention.indexRange.last
addStyle(
style = SpanStyle(color = mentionColor),
start = startIndex,
end = endIndex
)
addStringAnnotation(
tag = mention.idPrefix,
annotation = mention.id.toString(),
start = startIndex,
end = endIndex
)
}
}
data class MentionIndex(
val id: Int,
val idPrefix: String,
val indexRange: IntRange
)
private fun getAttachmentUiText(
attachment: VkAttachment,
size: Int = 1,
): UiText {
if (attachment.type.isMultiple()) {
return when (attachment.type) {
AttachmentType.PHOTO -> UiR.plurals.attachment_photos
AttachmentType.VIDEO -> UiR.plurals.attachment_videos
AttachmentType.AUDIO -> UiR.plurals.attachment_audios
AttachmentType.FILE -> UiR.plurals.attachment_files
else -> throw IllegalArgumentException("Unknown multiple type: ${attachment.type}")
}.let { resId -> UiText.QuantityResource(resId, size) }
}
return when (attachment.type) {
AttachmentType.UNKNOWN,
AttachmentType.PHOTO,
AttachmentType.VIDEO,
AttachmentType.AUDIO,
AttachmentType.FILE -> {
throw IllegalArgumentException("Unknown multiple type: ${attachment.type}")
}
AttachmentType.LINK -> UiR.string.message_attachments_link
AttachmentType.AUDIO_MESSAGE -> UiR.string.message_attachments_audio_message
AttachmentType.MINI_APP -> UiR.string.message_attachments_mini_app
AttachmentType.STICKER -> UiR.string.message_attachments_sticker
AttachmentType.GIFT -> UiR.string.message_attachments_gift
AttachmentType.WALL -> UiR.string.message_attachments_wall
AttachmentType.GRAFFITI -> UiR.string.message_attachments_graffiti
AttachmentType.POLL -> UiR.string.message_attachments_poll
AttachmentType.WALL_REPLY -> UiR.string.message_attachments_wall_reply
AttachmentType.CALL -> UiR.string.message_attachments_call
AttachmentType.GROUP_CALL_IN_PROGRESS -> UiR.string.message_attachments_call_in_progress
AttachmentType.CURATOR -> UiR.string.message_attachments_curator
AttachmentType.EVENT -> UiR.string.message_attachments_event
AttachmentType.STORY -> UiR.string.message_attachments_story
AttachmentType.WIDGET -> UiR.string.message_attachments_widget
AttachmentType.ARTIST -> UiR.string.message_attachments_artist
AttachmentType.AUDIO_PLAYLIST -> UiR.string.message_attachments_audio_playlist
AttachmentType.PODCAST -> UiR.string.message_attachments_podcast
}.let(UiText::Resource)
}
private fun getAttachmentConversationIcon(message: VkMessage?): UiImage? {
return message?.attachments?.let { attachments ->
if (attachments.isEmpty()) return null
if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) {
message.geoType?.let {
return UiImage.Resource(UiR.drawable.ic_map_marker)
}
getAttachmentIconByType(attachments.first().type)
} else {
UiImage.Resource(UiR.drawable.ic_baseline_attach_file_24)
}
}
}
private fun extractBirthday(conversation: VkConversation): Boolean {
val birthday = conversation.user?.birthday ?: return false
val splitBirthday = birthday.split(".").mapNotNull(String::toIntOrNull)
if (splitBirthday.isEmpty()) return false
return if (splitBirthday.size > 1) {
val (day, month) = splitBirthday
val birthdayCalendar = Calendar.getInstance().also { calendar ->
calendar.dayOfMonth = day
calendar.month = month - 1
}
val nowCalendar = Calendar.getInstance()
nowCalendar.dayOfMonth == birthdayCalendar.dayOfMonth &&
nowCalendar.month == birthdayCalendar.month
} else false
}
private fun extractReadCondition(
conversation: VkConversation,
lastMessage: VkMessage?
): Boolean = (lastMessage?.isOut == true && conversation.isOutUnread()) ||
(lastMessage?.isOut == false && conversation.isInUnread())
private fun isAccount(peerId: Int) = peerId == UserConfig.userId
private fun extractInteractionText(
resources: Resources,
conversation: VkConversation
): String? {
val interactionType = InteractionType.parse(conversation.interactionType)
val interactiveUsers = extractInteractionUsers(conversation)
val typingText =
if (interactionType == null) {
null
} else {
if (!conversation.peerType.isChat() && interactiveUsers.size == 1) {
when (interactionType) {
InteractionType.File -> UiR.string.chat_interaction_uploading_file
InteractionType.Photo -> UiR.string.chat_interaction_uploading_photo
InteractionType.Typing -> UiR.string.chat_interaction_typing
InteractionType.Video -> UiR.string.chat_interaction_uploading_video
InteractionType.VoiceMessage -> UiR.string.chat_interaction_recording_audio_message
}.let(UiText::Resource)
} else {
if (interactiveUsers.size == 1) {
UiR.string.chat_interaction_chat_single_typing
} else {
UiR.string.chat_interaction_chat_typing
}.let { resId ->
UiText.ResourceParams(
resId,
listOf(interactiveUsers.joinToString(separator = ", "))
)
}
}.parseString(resources)
}
return typingText
}
private fun extractInteractionUsers(conversation: VkConversation): List<String> {
return conversation.interactionIds.mapNotNull { id ->
when {
id > 0 -> VkMemoryCache.getUser(id)?.fullName
id < 0 -> VkMemoryCache.getGroup(id)?.name
else -> null
}
}
}
+1
View File
@@ -0,0 +1 @@
/build
+69
View File
@@ -0,0 +1,69 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.com.google.devtools.ksp)
alias(libs.plugins.kotlin.compose.compiler)
alias(libs.plugins.kotlin.serialization)
}
group = "com.meloda.app.fast.friends"
android {
namespace = "com.meloda.app.fast.friends"
compileSdk = Configs.compileSdk
defaultConfig {
minSdk = Configs.minSdk
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = Configs.java
targetCompatibility = Configs.java
}
kotlinOptions {
jvmTarget = Configs.java.toString()
freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-receivers")
}
buildFeatures {
compose = true
}
composeOptions {
useLiveLiterals = true
}
}
dependencies {
implementation(projects.core.common)
implementation(projects.core.data)
implementation(projects.core.model)
implementation(projects.core.ui)
implementation(libs.nanokt.android)
implementation(libs.nanokt.jvm)
implementation(libs.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)
// TODO: 03/07/2024, Danil Nikolaev: remove when stable release
implementation("androidx.compose.foundation:foundation:1.7.0-beta04")
implementation(libs.eithernet)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization)
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,175 @@
package com.meloda.app.fast.friends
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.meloda.app.fast.common.extensions.listenValue
import com.meloda.app.fast.common.extensions.setValue
import com.meloda.app.fast.data.State
import com.meloda.app.fast.data.api.friends.FriendsUseCase
import com.meloda.app.fast.data.processState
import com.meloda.app.fast.datastore.UserSettings
import com.meloda.app.fast.friends.model.FriendsScreenState
import com.meloda.app.fast.friends.model.UiFriend
import com.meloda.app.fast.friends.util.asPresentation
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.model.api.domain.VkUser
import com.meloda.app.fast.network.VkErrorCodes
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
interface FriendsViewModel {
val screenState: StateFlow<FriendsScreenState>
val uiFriends: StateFlow<List<UiFriend>>
val uiOnlineFriends: StateFlow<List<UiFriend>>
val baseError: StateFlow<BaseError?>
val imagesToPreload: StateFlow<List<String>>
val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean>
fun onMetPaginationCondition()
fun onRefresh()
fun onErrorConsumed()
}
class FriendsViewModelImpl(
private val friendsUseCase: FriendsUseCase,
private val userSettings: UserSettings
) : ViewModel(), FriendsViewModel {
override val screenState = MutableStateFlow(FriendsScreenState.EMPTY)
override val uiFriends = screenState.map { it.friends }
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
override val uiOnlineFriends = MutableStateFlow<List<UiFriend>>(emptyList())
override val baseError = MutableStateFlow<BaseError?>(null)
override val imagesToPreload = MutableStateFlow<List<String>>(emptyList())
override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false)
private val friends = MutableStateFlow<List<VkUser>>(emptyList())
init {
userSettings.useContactNames.listenValue(::updateFriendsNames)
loadFriends()
}
override fun onMetPaginationCondition() {
currentOffset.update { screenState.value.friends.size }
loadFriends()
}
override fun onRefresh() {
loadFriends(offset = 0)
}
override fun onErrorConsumed() {
baseError.setValue { null }
}
private fun loadFriends(offset: Int = currentOffset.value) {
friendsUseCase.getAllFriends(count = 30, offset = offset).listenValue { state ->
state.processState(
error = { error ->
when (error) {
is State.Error.ApiError -> {
val (code, message) = error
when (code) {
VkErrorCodes.UserAuthorizationFailed -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> {
Unit
}
}
}
State.Error.ConnectionError -> TODO()
State.Error.InternalError -> TODO()
is State.Error.OAuthError -> TODO()
State.Error.Unknown -> TODO()
}
},
success = { info ->
val response = info.friends
val itemsCountSufficient = response.size == 30
canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient &&
screenState.value.friends.size >= 30
imagesToPreload.setValue {
response.mapNotNull(VkUser::photo100)
}
friendsUseCase.storeUsers(response)
val loadedFriends = response.map {
it.asPresentation(userSettings.useContactNames.value)
}
val loadedOnlineFriends = info.onlineFriends.map {
it.asPresentation(userSettings.useContactNames.value)
}
val newState = screenState.value.copy(
isPaginationExhausted = paginationExhausted
)
if (offset == 0) {
friends.emit(response)
screenState.setValue {
newState.copy(friends = loadedFriends)
}
uiOnlineFriends.setValue { loadedOnlineFriends }
} else {
friends.emit(friends.value.plus(response))
screenState.setValue {
newState.copy(
friends = newState.friends.plus(loadedFriends)
)
}
uiOnlineFriends.setValue { old ->
old.plus(loadedFriends)
}
}
}
)
screenState.setValue { old ->
old.copy(
isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading()
)
}
}
}
private fun updateFriendsNames(useContactNames: Boolean) {
val friends = friends.value
if (friends.isEmpty()) return
val uiFriends = friends.map { conversation ->
conversation.asPresentation(useContactNames)
}
val onlineUiFriends = uiOnlineFriends.value.mapNotNull { friend ->
uiFriends.find { it.userId == friend.userId }
}
screenState.setValue { old ->
old.copy(friends = uiFriends)
}
uiOnlineFriends.setValue { onlineUiFriends }
}
}
@@ -0,0 +1,15 @@
package com.meloda.app.fast.friends.di
import com.meloda.app.fast.data.api.friends.FriendsUseCase
import com.meloda.app.fast.friends.FriendsViewModelImpl
import com.meloda.app.fast.friends.domain.FriendsUseCaseImpl
import org.koin.androidx.viewmodel.dsl.viewModelOf
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
val friendsModule = module {
singleOf(::FriendsUseCaseImpl) bind FriendsUseCase::class
viewModelOf(::FriendsViewModelImpl)
}
@@ -0,0 +1,42 @@
package com.meloda.app.fast.friends.domain
import com.meloda.app.fast.data.State
import com.meloda.app.fast.data.api.friends.FriendsRepository
import com.meloda.app.fast.data.api.friends.FriendsUseCase
import com.meloda.app.fast.data.mapToState
import com.meloda.app.fast.model.FriendsInfo
import com.meloda.app.fast.model.api.domain.VkUser
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class FriendsUseCaseImpl(private val repository: FriendsRepository) : FriendsUseCase {
override fun getAllFriends(count: Int?, offset: Int?): Flow<State<FriendsInfo>> = flow {
emit(State.Loading)
val newState = repository.getAllFriends(count, offset).mapToState()
emit(newState)
}
override fun getFriends(
count: Int?, offset: Int?
): Flow<State<List<VkUser>>> = flow {
emit(State.Loading)
val newState = repository.getFriends(count, offset).mapToState()
emit(newState)
}
override fun getOnlineFriends(
count: Int?, offset: Int?
): Flow<State<List<Int>>> = flow {
emit(State.Loading)
val newState = repository.getOnlineFriends(count, offset).mapToState()
emit(newState)
}
override suspend fun storeUsers(users: List<VkUser>) {
repository.storeUsers(users)
}
}
@@ -0,0 +1,21 @@
package com.meloda.app.fast.friends.model
import androidx.compose.runtime.Immutable
@Immutable
data class FriendsScreenState(
val isLoading: Boolean,
val friends: List<UiFriend>,
val isPaginating: Boolean,
val isPaginationExhausted: Boolean
) {
companion object {
val EMPTY: FriendsScreenState = FriendsScreenState(
isLoading = true,
friends = emptyList(),
isPaginating = false,
isPaginationExhausted = false
)
}
}
@@ -0,0 +1,5 @@
package com.meloda.app.fast.friends.model
enum class OnlineState {
OFFLINE, ONLINE, ONLINE_MOBILE
}
@@ -0,0 +1,11 @@
package com.meloda.app.fast.friends.model
import com.meloda.app.fast.common.UiImage
import com.meloda.app.fast.model.api.domain.OnlineStatus
data class UiFriend(
val userId: Int,
val avatar: UiImage?,
val title: String,
val onlineStatus: OnlineStatus
)
@@ -0,0 +1,29 @@
package com.meloda.app.fast.friends.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.meloda.app.fast.common.extensions.navigation.sharedViewModel
import com.meloda.app.fast.friends.FriendsViewModel
import com.meloda.app.fast.friends.FriendsViewModelImpl
import com.meloda.app.fast.friends.presentation.FriendsScreen
import com.meloda.app.fast.model.BaseError
import kotlinx.serialization.Serializable
@Serializable
object Friends
fun NavGraphBuilder.friendsRoute(
onError: (BaseError) -> Unit,
navController: NavController
) {
composable<Friends> {
val viewModel: FriendsViewModel =
it.sharedViewModel<FriendsViewModelImpl>(navController = navController)
FriendsScreen(
onError = onError,
viewModel = viewModel
)
}
}
@@ -0,0 +1,100 @@
package com.meloda.app.fast.friends.presentation
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
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.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.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.meloda.app.fast.designsystem.R
import com.meloda.app.fast.friends.model.UiFriend
@Composable
fun FriendItem(
modifier: Modifier = Modifier,
friend: UiFriend,
maxLines: Int
) {
val context = LocalContext.current
Row(
modifier = modifier.fillMaxWidth(),
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 = ImageRequest.Builder(context)
.data(friendAvatar)
.crossfade(true)
.build(),
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)
)
Spacer(modifier = Modifier.width(16.dp))
}
}
@@ -0,0 +1,95 @@
package com.meloda.app.fast.friends.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.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.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.meloda.app.fast.designsystem.ImmutableList
import com.meloda.app.fast.friends.model.FriendsScreenState
import com.meloda.app.fast.friends.model.UiFriend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun FriendsList(
modifier: Modifier = Modifier,
screenState: FriendsScreenState,
uiFriends: ImmutableList<UiFriend>,
listState: LazyListState,
maxLines: Int,
padding: PaddingValues
) {
val coroutineScope = rememberCoroutineScope()
val friends = uiFriends.toList()
LazyColumn(
modifier = modifier,
state = listState
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
Spacer(modifier = Modifier.height(64.dp))
}
items(
items = friends,
key = UiFriend::userId,
) { friend ->
FriendItem(
friend = friend,
maxLines = maxLines
)
Spacer(modifier = Modifier.height(16.dp))
}
item {
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null)
.navigationBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (screenState.isPaginating) {
CircularProgressIndicator()
}
if (screenState.isPaginationExhausted) {
IconButton(
onClick = {
coroutineScope.launch(Dispatchers.Main) {
listState.scrollToItem(14)
listState.animateScrollToItem(0)
}
},
colors = IconButtonDefaults.filledIconButtonColors()
) {
Icon(
imageVector = Icons.Rounded.KeyboardArrowUp,
contentDescription = null
)
}
}
}
}
}
}
@@ -0,0 +1,308 @@
package com.meloda.app.fast.friends.presentation
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
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.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.imageLoader
import coil.request.ImageRequest
import com.meloda.app.fast.designsystem.ImmutableList
import com.meloda.app.fast.designsystem.LocalTheme
import com.meloda.app.fast.designsystem.TabItem
import com.meloda.app.fast.designsystem.components.BlurrableTopAppBar
import com.meloda.app.fast.designsystem.components.FullScreenLoader
import com.meloda.app.fast.designsystem.components.NoItemsView
import com.meloda.app.fast.friends.FriendsViewModel
import com.meloda.app.fast.friends.FriendsViewModelImpl
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.ui.ErrorView
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun FriendsScreen(
onError: (BaseError) -> Unit,
viewModel: FriendsViewModel = koinViewModel<FriendsViewModelImpl>()
) {
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val context = LocalContext.current
val imagesToPreload by viewModel.imagesToPreload.collectAsStateWithLifecycle()
imagesToPreload.forEach { url ->
context.imageLoader.enqueue(
ImageRequest.Builder(context)
.data(url)
.build()
)
}
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val friends by viewModel.uiFriends.collectAsStateWithLifecycle()
val onlineFriends by viewModel.uiOnlineFriends.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val currentTheme = LocalTheme.current
val maxLines by remember {
derivedStateOf {
if (currentTheme.multiline) 2 else 1
}
}
val listState = rememberLazyListState()
val paginationConditionMet by remember {
derivedStateOf {
canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
}
}
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
viewModel.onMetPaginationCondition()
}
}
val hazeState = remember { HazeState() }
val pullToRefreshAlpha by animateFloatAsState(
targetValue = if (!listState.canScrollBackward) 1f else 0f,
label = "pullToRefreshAlpha",
animationSpec = tween(durationMillis = 50)
)
val tabsColorAlpha by animateFloatAsState(
targetValue = if (!listState.canScrollBackward) 1f else 0f,
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
)
val tabsContainerColor by animateColorAsState(
targetValue =
if (currentTheme.usingBlur || !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()) {
BlurrableTopAppBar(
title = stringResource(id = UiR.string.title_friends),
listState = listState,
hazeState = hazeState
)
}
}
) { padding ->
when {
baseError is BaseError.SessionExpired -> {
ErrorView(
text = "Session expired",
buttonText = "Log out",
onButtonClick = { onError(BaseError.SessionExpired) }
)
}
screenState.isLoading && friends.isEmpty() -> FullScreenLoader()
else -> {
val pullToRefreshState = rememberPullToRefreshState()
var selectedTabIndex by rememberSaveable {
mutableIntStateOf(0)
}
val tabItems = listOf(
TabItem(
titleResId = UiR.string.title_friends_all,
unselectedIconResId = null,
selectedIconResId = null
),
TabItem(
titleResId = UiR.string.title_friends_online,
unselectedIconResId = null,
selectedIconResId = null
)
)
val pagerState = rememberPagerState { tabItems.size }
LaunchedEffect(selectedTabIndex) {
pagerState.animateScrollToPage(selectedTabIndex)
}
LaunchedEffect(pagerState) {
snapshotFlow {
pagerState.currentPage
}.collect { page ->
selectedTabIndex = page
}
}
Box(modifier = Modifier.fillMaxSize()) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
) { index ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr))
.padding(bottom = padding.calculateBottomPadding())
.nestedScroll(pullToRefreshState.nestedScrollConnection)
) {
val friendsToDisplay = if (index == 0) friends
else onlineFriends
FriendsList(
modifier = if (currentTheme.usingBlur) {
Modifier.haze(
state = hazeState,
style = HazeMaterials.thick()
)
} else {
Modifier
}.fillMaxSize(),
screenState = screenState,
uiFriends = ImmutableList.copyOf(friendsToDisplay),
listState = listState,
maxLines = maxLines,
padding = padding
)
if (friendsToDisplay.isEmpty()) {
NoItemsView(
modifier = Modifier
.padding(padding.calculateTopPadding())
.padding(top = 64.dp),
customText = "No${if (index == 1) " online" else ""} friends :("
)
}
if (pullToRefreshState.isRefreshing) {
LaunchedEffect(true) {
viewModel.onRefresh()
}
}
LaunchedEffect(screenState.isLoading) {
if (!screenState.isLoading) {
pullToRefreshState.endRefresh()
}
}
PullToRefreshContainer(
state = pullToRefreshState,
modifier = Modifier
.padding(top = padding.calculateTopPadding())
.padding(top = 46.dp)
.alpha(pullToRefreshAlpha)
.align(Alignment.TopCenter),
contentColor = MaterialTheme.colorScheme.primary
)
}
}
TabRow(
selectedTabIndex = selectedTabIndex,
modifier = Modifier
.padding(top = padding.calculateTopPadding() - 4.dp)
.height(56.dp)
.then(
if (currentTheme.usingBlur) {
Modifier.hazeChild(
state = hazeState,
style = HazeMaterials.thick()
)
} else {
Modifier
}
),
containerColor = tabsContainerColor.copy(
alpha = if (currentTheme.usingBlur) tabsColorAlpha else 1f
),
indicator = { tabPositions ->
TabRowDefaults.PrimaryIndicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]),
width = 48.dp
)
}
) {
tabItems.forEachIndexed { index, item ->
Tab(
selected = index == selectedTabIndex,
onClick = {
if (selectedTabIndex != index) {
selectedTabIndex = index
}
},
text = {
item.titleResId?.let { resId ->
Text(text = stringResource(id = resId))
}
}
)
}
}
}
}
}
}
}
@@ -0,0 +1,19 @@
package com.meloda.app.fast.friends.util
import com.meloda.app.fast.common.UiImage
import com.meloda.app.fast.data.VkMemoryCache
import com.meloda.app.fast.friends.model.UiFriend
import com.meloda.app.fast.model.api.domain.VkUser
fun VkUser.asPresentation(
useContactNames: Boolean = false
): UiFriend = UiFriend(
userId = id,
avatar = photo100?.let(UiImage::Url),
title = if (useContactNames) {
VkMemoryCache.getContact(id)?.name ?: fullName
} else {
fullName
},
onlineStatus = onlineStatus
)

Some files were not shown because too many files have changed in this diff Show More