forked from melod1n/fast-messenger
Upstream changes (#23)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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
@@ -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>
|
||||
+81
@@ -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
|
||||
}
|
||||
+12
@@ -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
|
||||
+20
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
@@ -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
|
||||
}
|
||||
+48
@@ -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)
|
||||
}
|
||||
+234
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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
@@ -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?
|
||||
)
|
||||
+12
@@ -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
|
||||
}
|
||||
+44
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+16
@@ -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
|
||||
+12
@@ -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()
|
||||
}
|
||||
+14
@@ -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
|
||||
+56
@@ -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)
|
||||
}
|
||||
+330
@@ -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
|
||||
}
|
||||
}
|
||||
+110
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
+16
@@ -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
|
||||
+28
@@ -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 = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
+6
@@ -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()
|
||||
}
|
||||
+8
@@ -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
|
||||
}
|
||||
+23
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -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)
|
||||
}
|
||||
+255
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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>
|
||||
+14
@@ -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
|
||||
+53
@@ -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))
|
||||
}
|
||||
+97
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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>
|
||||
+91
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -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(""))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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>
|
||||
+625
@@ -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()
|
||||
}
|
||||
}
|
||||
+119
@@ -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)
|
||||
}
|
||||
}
|
||||
+15
@@ -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)
|
||||
}
|
||||
+20
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -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)
|
||||
)
|
||||
}
|
||||
+23
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+14
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+31
@@ -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>,
|
||||
)
|
||||
+33
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+392
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+109
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+441
@@ -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
|
||||
}
|
||||
+848
@@ -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("&", "&")
|
||||
.replace(""", "\"")
|
||||
.replace("<br>", " ")
|
||||
.replace(">", ">")
|
||||
.replace("<", "<")
|
||||
.replace("<br/>", " ")
|
||||
.replace("–", "-")
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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)
|
||||
}
|
||||
Vendored
+21
@@ -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)
|
||||
}
|
||||
+42
@@ -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)
|
||||
}
|
||||
}
|
||||
+21
@@ -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
|
||||
)
|
||||
+29
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+100
@@ -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))
|
||||
}
|
||||
}
|
||||
+95
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+308
@@ -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
Reference in New Issue
Block a user