twoFa -> validation naming; fixes for preview for screens (separating view model from ui); some improvements & fixes

This commit is contained in:
2024-07-13 22:45:49 +03:00
parent dfdc48b682
commit 733627f935
98 changed files with 1611 additions and 1637 deletions
+5 -5
View File
@@ -112,11 +112,11 @@ android {
useLiveLiterals = true
}
// packaging {
// resources {
// excludes += "/META-INF/{AL2.0,LGPL2.1}"
// }
// }
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
@@ -1,46 +0,0 @@
package com.meloda.app.fast.tests
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import org.junit.Rule
import org.junit.Test
class LoginSignInTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun signInButtonIsClickable() {
composeTestRule.setContent {
// LogoScreen(onAction = {})
}
composeTestRule.onNodeWithTag(testTag = "Sign in button").assertHasClickAction()
}
@Test
fun signInButtonTriggersSignInAction() {
var signInClicked = true
composeTestRule.setContent {
// com.meloda.fast.auth.login.presentation.LogoScreen(
// onAction = { action ->
// when (action) {
// UiAction.NextClicked -> {
// signInClicked = true
// }
//
// else -> Unit
// }
// }
// )
}
composeTestRule.onNodeWithTag("Sign in button").performClick()
assert(signInClicked)
}
}
+1 -1
View File
@@ -20,7 +20,7 @@
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:name=".presentation.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
@@ -1,197 +0,0 @@
package com.meloda.app.fast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideIn
import androidx.compose.animation.slideOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarDefaults
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import com.meloda.app.fast.conversations.navigation.Conversations
import com.meloda.app.fast.conversations.navigation.conversationsRoute
import com.meloda.app.fast.designsystem.LocalBottomPadding
import com.meloda.app.fast.designsystem.LocalHazeState
import com.meloda.app.fast.designsystem.LocalTheme
import com.meloda.app.fast.friends.navigation.Friends
import com.meloda.app.fast.friends.navigation.friendsRoute
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.profile.navigation.Profile
import com.meloda.app.fast.profile.navigation.profileRoute
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import kotlinx.serialization.Serializable
import com.meloda.app.fast.designsystem.R as UiR
@Serializable
object MainGraph
@Serializable
object Main
data class BottomNavigationItem(
val titleResId: Int,
val selectedIconResId: Int,
val unselectedIconResId: Int,
val route: Any,
)
@OptIn(ExperimentalHazeMaterialsApi::class)
fun NavGraphBuilder.mainScreen(
onError: (BaseError) -> Unit,
onNavigateToSettings: () -> Unit,
onNavigateToMessagesHistory: (conversationId: Int) -> Unit,
) {
val items = listOf(
BottomNavigationItem(
titleResId = UiR.string.title_friends,
selectedIconResId = UiR.drawable.baseline_people_alt_24,
unselectedIconResId = UiR.drawable.outline_people_alt_24,
route = Friends,
),
BottomNavigationItem(
titleResId = UiR.string.title_conversations,
selectedIconResId = UiR.drawable.baseline_chat_24,
unselectedIconResId = UiR.drawable.outline_chat_24,
route = Conversations
),
BottomNavigationItem(
titleResId = UiR.string.title_profile,
selectedIconResId = UiR.drawable.baseline_account_circle_24,
unselectedIconResId = UiR.drawable.outline_account_circle_24,
route = Profile
)
)
val routes = items.map(BottomNavigationItem::route)
composable<Main> {
val currentTheme = LocalTheme.current
val hazeState = remember { HazeState() }
val navController = rememberNavController()
var selectedItemIndex by rememberSaveable {
mutableIntStateOf(1)
}
var isBottomBarVisible by rememberSaveable {
mutableStateOf(true)
}
Scaffold(
bottomBar = {
AnimatedVisibility(
visible = isBottomBarVisible,
enter = slideIn { IntOffset(0, 400) },
exit = slideOut { IntOffset(0, 400) }
) {
NavigationBar(
modifier = Modifier
.then(
if (currentTheme.usingBlur) {
Modifier.hazeChild(
state = hazeState,
style = HazeMaterials.thick()
)
} else Modifier
)
.fillMaxWidth(),
containerColor = NavigationBarDefaults.containerColor.copy(
alpha = if (currentTheme.usingBlur) 0f else 1f
)
) {
items.forEachIndexed { index, item ->
NavigationBarItem(
selected = selectedItemIndex == index,
onClick = {
if (selectedItemIndex != index) {
val currentRoute = routes[selectedItemIndex]
selectedItemIndex = index
navController.navigate(item.route) {
popUpTo(route = currentRoute) {
inclusive = true
}
}
}
},
icon = {
Icon(
painter = painterResource(
id = if (selectedItemIndex == index) item.selectedIconResId
else item.unselectedIconResId
),
contentDescription = null
)
},
alwaysShowLabel = false
)
}
}
}
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = if (currentTheme.usingBlur) 0.dp else padding.calculateBottomPadding())
) {
CompositionLocalProvider(
LocalHazeState provides hazeState,
LocalBottomPadding provides if (currentTheme.usingBlur) padding.calculateBottomPadding() else 0.dp
) {
NavHost(
navController = navController,
startDestination = MainGraph,
enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) }
) {
navigation<MainGraph>(startDestination = Conversations) {
friendsRoute(
onError = onError,
navController = navController
)
conversationsRoute(
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
navController = navController,
onListScrollingUp = { isScrolling ->
// isBottomBarVisible = isScrolling
}
)
profileRoute(
onError = onError,
onNavigateToSettings = onNavigateToSettings,
navController = navController
)
}
}
}
}
}
}
}
@@ -16,11 +16,13 @@ import com.meloda.app.fast.model.MainScreenState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
interface MainViewModel {
val screenState: StateFlow<MainScreenState>
val isNeedToOpenAuth: StateFlow<Boolean>
val longPollState: StateFlow<LongPollState>
val startOnlineService: StateFlow<Boolean>
@@ -38,7 +40,7 @@ interface MainViewModel {
fun onError(error: BaseError)
fun onAuthOpened()
fun onNavigatedToAuth()
}
class MainViewModelImpl(
@@ -51,6 +53,7 @@ class MainViewModelImpl(
}
override val screenState = MutableStateFlow(MainScreenState.EMPTY)
override val isNeedToOpenAuth = MutableStateFlow(false)
override val longPollState = MutableStateFlow(
if (SettingsController.getBoolean(
@@ -109,13 +112,13 @@ class MainViewModelImpl(
override fun onError(error: BaseError) {
when (error) {
BaseError.SessionExpired -> {
screenState.setValue { old -> old.copy(isNeedToOpenAuth = true) }
isNeedToOpenAuth.update { true }
}
}
}
override fun onAuthOpened() {
screenState.setValue { old -> old.copy(isNeedToOpenAuth = false) }
override fun onNavigatedToAuth() {
isNeedToOpenAuth.update { false }
}
private fun loadAccounts() {
@@ -0,0 +1,8 @@
package com.meloda.app.fast.model
data class BottomNavigationItem(
val titleResId: Int,
val selectedIconResId: Int,
val unselectedIconResId: Int,
val route: Any,
)
@@ -10,21 +10,17 @@ data class MainScreenState(
val useDarkTheme: Boolean,
val useDynamicColors: Boolean,
val isNeedToRequestNotifications: Boolean,
val isNeedToOpenAppPermissions: Boolean,
val isNeedToOpenAuth: Boolean,
val isNeedToOpenAppPermissions: Boolean
) {
companion object {
val EMPTY: MainScreenState = MainScreenState(
accounts = emptyList(),
accountsLoaded = false,
// TODO: 05/05/2024, Danil Nikolaev: implement
useDarkTheme = false,
useDynamicColors = false,
isNeedToRequestNotifications = false,
isNeedToOpenAppPermissions = false,
isNeedToOpenAuth = false,
isNeedToOpenAppPermissions = false
)
}
}
@@ -0,0 +1,54 @@
package com.meloda.app.fast.navigation
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.meloda.app.fast.conversations.navigation.Conversations
import com.meloda.app.fast.friends.navigation.Friends
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.model.BottomNavigationItem
import com.meloda.app.fast.presentation.MainScreen
import com.meloda.app.fast.profile.navigation.Profile
import kotlinx.serialization.Serializable
import com.meloda.app.fast.designsystem.R as UiR
@Serializable
object MainGraph
@Serializable
object Main
fun NavGraphBuilder.mainScreen(
onError: (BaseError) -> Unit,
onSettingsButtonClicked: () -> Unit,
onConversationClicked: (conversationId: Int) -> Unit,
) {
val navigationItems = listOf(
BottomNavigationItem(
titleResId = UiR.string.title_friends,
selectedIconResId = UiR.drawable.baseline_people_alt_24,
unselectedIconResId = UiR.drawable.outline_people_alt_24,
route = Friends,
),
BottomNavigationItem(
titleResId = UiR.string.title_conversations,
selectedIconResId = UiR.drawable.baseline_chat_24,
unselectedIconResId = UiR.drawable.outline_chat_24,
route = Conversations
),
BottomNavigationItem(
titleResId = UiR.string.title_profile,
selectedIconResId = UiR.drawable.baseline_account_circle_24,
unselectedIconResId = UiR.drawable.outline_account_circle_24,
route = Profile
)
)
composable<Main> {
MainScreen(
navigationItems = navigationItems,
onError = onError,
onSettingsButtonClicked = onSettingsButtonClicked,
onConversationItemClicked = onConversationClicked,
)
}
}
@@ -1,4 +1,4 @@
package com.meloda.app.fast
package com.meloda.app.fast.presentation
import android.Manifest
import android.app.NotificationChannel
@@ -26,6 +26,7 @@ import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState
import com.meloda.app.fast.MainViewModel
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.common.extensions.ifEmpty
import com.meloda.app.fast.common.extensions.isSdkAtLeast
@@ -100,13 +101,15 @@ class MainActivity : AppCompatActivity() {
multiline = theme.multiline
)
) {
val currentTheme = LocalTheme.current
AppTheme(
useDarkTheme = LocalTheme.current.usingDarkStyle,
useDynamicColors = LocalTheme.current.usingDynamicColors,
selectedColorScheme = LocalTheme.current.selectedColorScheme,
useAmoledBackground = LocalTheme.current.usingAmoledBackground,
useDarkTheme = currentTheme.usingDarkStyle,
useDynamicColors = currentTheme.usingDynamicColors,
selectedColorScheme = currentTheme.selectedColorScheme,
useAmoledBackground = currentTheme.usingAmoledBackground,
) {
RootGraph()
RootScreen()
}
}
}
@@ -0,0 +1,141 @@
package com.meloda.app.fast.presentation
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarDefaults
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import com.meloda.app.fast.navigation.MainGraph
import com.meloda.app.fast.conversations.navigation.Conversations
import com.meloda.app.fast.conversations.navigation.conversationsScreen
import com.meloda.app.fast.designsystem.LocalBottomPadding
import com.meloda.app.fast.designsystem.LocalHazeState
import com.meloda.app.fast.designsystem.LocalTheme
import com.meloda.app.fast.friends.navigation.friendsScreen
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.model.BottomNavigationItem
import com.meloda.app.fast.profile.navigation.profileScreen
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
fun MainScreen(
navigationItems: List<BottomNavigationItem>,
onError: (BaseError) -> Unit = {},
onSettingsButtonClicked: () -> Unit = {},
onConversationItemClicked: (conversationId: Int) -> Unit = {}
) {
val currentTheme = LocalTheme.current
val hazeState = remember { HazeState() }
val navController = rememberNavController()
var selectedItemIndex by rememberSaveable {
mutableIntStateOf(1)
}
Scaffold(
bottomBar = {
NavigationBar(
modifier = Modifier
.then(
if (currentTheme.usingBlur) {
Modifier.hazeChild(
state = hazeState,
style = HazeMaterials.thick()
)
} else Modifier
)
.fillMaxWidth(),
containerColor = NavigationBarDefaults.containerColor.copy(
alpha = if (currentTheme.usingBlur) 0f else 1f
)
) {
navigationItems.forEachIndexed { index, item ->
NavigationBarItem(
selected = selectedItemIndex == index,
onClick = {
if (selectedItemIndex != index) {
val currentRoute = navigationItems[selectedItemIndex].route
selectedItemIndex = index
navController.navigate(item.route) {
popUpTo(route = currentRoute) {
inclusive = true
}
}
}
},
icon = {
Icon(
painter = painterResource(
id = if (selectedItemIndex == index) item.selectedIconResId
else item.unselectedIconResId
),
contentDescription = null
)
},
alwaysShowLabel = false
)
}
}
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = if (currentTheme.usingBlur) 0.dp else padding.calculateBottomPadding())
) {
CompositionLocalProvider(
LocalHazeState provides hazeState,
LocalBottomPadding provides if (currentTheme.usingBlur) padding.calculateBottomPadding() else 0.dp
) {
NavHost(
navController = navController,
startDestination = MainGraph,
enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) }
) {
navigation<MainGraph>(startDestination = Conversations) {
friendsScreen(
onError = onError,
navController = navController
)
conversationsScreen(
onError = onError,
onConversationItemClicked = onConversationItemClicked,
navController = navController
)
profileScreen(
onError = onError,
onSettingsButtonClicked = onSettingsButtonClicked,
navController = navController
)
}
}
}
}
}
}
@@ -1,10 +1,10 @@
package com.meloda.app.fast
package com.meloda.app.fast.presentation
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -12,33 +12,40 @@ import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import com.meloda.app.fast.MainViewModel
import com.meloda.app.fast.MainViewModelImpl
import com.meloda.app.fast.auth.AuthGraph
import com.meloda.app.fast.auth.authNavGraph
import com.meloda.app.fast.auth.navigateToAuth
import com.meloda.app.fast.chatmaterials.navigation.chatMaterialsRoute
import com.meloda.app.fast.chatmaterials.navigation.chatMaterialsScreen
import com.meloda.app.fast.chatmaterials.navigation.navigateToChatMaterials
import com.meloda.app.fast.common.UserConfig
import com.meloda.app.fast.languagepicker.navigation.languagePickerRoute
import com.meloda.app.fast.languagepicker.navigation.languagePickerScreen
import com.meloda.app.fast.languagepicker.navigation.navigateToLanguagePicker
import com.meloda.app.fast.messageshistory.navigation.messagesHistoryRoute
import com.meloda.app.fast.messageshistory.navigation.messagesHistoryScreen
import com.meloda.app.fast.messageshistory.navigation.navigateToMessagesHistory
import com.meloda.app.fast.settings.presentation.navigateToSettings
import com.meloda.app.fast.settings.presentation.settingsRoute
import com.meloda.app.fast.navigation.Main
import com.meloda.app.fast.navigation.mainScreen
import com.meloda.app.fast.settings.navigation.navigateToSettings
import com.meloda.app.fast.settings.navigation.settingsScreen
import org.koin.androidx.compose.koinViewModel
@Composable
fun RootGraph(navController: NavHostController = rememberNavController()) {
fun RootScreen(navController: NavHostController = rememberNavController()) {
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenAuth by viewModel.isNeedToOpenAuth.collectAsStateWithLifecycle()
if (screenState.isNeedToOpenAuth) {
viewModel.onAuthOpened()
navController.navigateToAuth(clearBackStack = true)
LaunchedEffect(isNeedToOpenAuth) {
if (isNeedToOpenAuth) {
viewModel.onNavigatedToAuth()
navController.navigateToAuth(clearBackStack = true)
}
}
if (screenState.accountsLoaded) {
val isNeedToShowConversations by remember {
derivedStateOf { screenState.accounts.isNotEmpty() && UserConfig.isLoggedIn() }
val isNeedToShowConversations = remember {
screenState.accounts.isNotEmpty() && UserConfig.isLoggedIn()
}
NavHost(
@@ -48,32 +55,30 @@ fun RootGraph(navController: NavHostController = rememberNavController()) {
exitTransition = { fadeOut(animationSpec = tween(200)) }
) {
authNavGraph(
onError = viewModel::onError,
onNavigateToMain = navController::navigateToMain,
navController = navController
)
mainScreen(
onError = viewModel::onError,
onNavigateToSettings = navController::navigateToSettings,
onNavigateToMessagesHistory = navController::navigateToMessagesHistory
onSettingsButtonClicked = navController::navigateToSettings,
onConversationClicked = navController::navigateToMessagesHistory
)
messagesHistoryRoute(
messagesHistoryScreen(
onError = viewModel::onError,
onBack = navController::navigateUp,
onNavigateToChatAttachments = navController::navigateToChatMaterials
onChatMaterialsDropdownItemClicked = navController::navigateToChatMaterials
)
chatMaterialsRoute(
chatMaterialsScreen(
onBack = navController::navigateUp
)
settingsRoute(
onError = viewModel::onError,
settingsScreen(
onBack = navController::navigateUp,
onNavigateToAuth = { navController.navigateToAuth(true) },
onNavigateToLanguagePicker = navController::navigateToLanguagePicker
onLogOutButtonClicked = { navController.navigateToAuth(true) },
onLanguageItemClicked = navController::navigateToLanguagePicker
)
languagePickerRoute(onBack = navController::navigateUp)
languagePickerScreen(onBack = navController::navigateUp)
}
}
@@ -8,7 +8,7 @@ interface OAuthRepository {
login: String,
password: String,
forceSms: Boolean,
twoFaCode: String?,
validationCode: String?,
captchaSid: String?,
captchaKey: String?
): AuthDirectResponse
@@ -16,7 +16,7 @@ class OAuthRepositoryImpl(
login: String,
password: String,
forceSms: Boolean,
twoFaCode: String?,
validationCode: String?,
captchaSid: String?,
captchaKey: String?
): AuthDirectResponse = withContext(Dispatchers.IO) {
@@ -27,8 +27,8 @@ class OAuthRepositoryImpl(
username = login,
password = password,
scope = VkConstants.Auth.SCOPE,
twoFaForceSms = forceSms,
twoFaCode = twoFaCode,
validationForceSms = forceSms,
validationCode = validationCode,
captchaSid = captchaSid,
captchaKey = captchaKey,
)
@@ -38,8 +38,6 @@ object SettingsKeys {
const val KEY_DEBUG_PERFORM_CRASH = "debug_perform_crash"
const val KEY_DEBUG_SHOW_CRASH_ALERT = "debug_show_crash_alert"
const val KEY_DEBUG_HIDE_DEBUG_LIST = "debug_hide_debug_list"
const val KEY_SHOW_NAME_IN_BUBBLES = "debug_show_title_in_bubbles"
const val KEY_SHOW_DATE_UNDER_BUBBLES = "debug_show_date_under_bubbles"
const val KEY_ENABLE_ANIMATIONS_IN_MESSAGES = "debug_enable_animations_in_messages"
const val KEY_SHOW_DEBUG_CATEGORY = "show_debug_category"
@@ -20,10 +20,7 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import com.meloda.app.fast.datastore.isUsingAmoledBackground
import com.meloda.app.fast.datastore.isUsingDynamicColors
import com.meloda.app.fast.datastore.model.ThemeConfig
import com.meloda.app.fast.datastore.selectedColorScheme
import com.meloda.app.fast.designsystem.colorschemes.ClassicColorScheme
import dev.chrisbanes.haze.HazeState
@@ -128,10 +125,10 @@ val LocalBottomPadding = compositionLocalOf {
@Composable
fun AppTheme(
predefinedColorScheme: ColorScheme? = null,
useDarkTheme: Boolean = isUsingDarkTheme(),
useDynamicColors: Boolean = isUsingDynamicColors(),
selectedColorScheme: Int = selectedColorScheme(),
useAmoledBackground: Boolean = isUsingAmoledBackground(),
useDarkTheme: Boolean = false,
useDynamicColors: Boolean = false,
useAmoledBackground: Boolean = false,
selectedColorScheme: Int = 0,
content: @Composable () -> Unit
) {
val colorScheme: ColorScheme = when {
@@ -81,167 +81,166 @@ fun MaterialDialog(
)
}
AppTheme {
if (isVisible) {
// AlertAnimation(visible = isVisible) {
BasicAlertDialog(
onDismissRequest = onDismissRequest
) {
val scrollState = rememberScrollState()
val canScrollBackward by remember { derivedStateOf { scrollState.value > 0 } }
val canScrollForward by remember { derivedStateOf { scrollState.value < scrollState.maxValue } }
Surface(
modifier = Modifier.fillMaxWidth(),
color = AlertDialogDefaults.containerColor,
shape = AlertDialogDefaults.shape,
tonalElevation = AlertDialogDefaults.TonalElevation
) {
Column(modifier = Modifier.padding(bottom = 10.dp)) {
val stringTitle = title?.getString()
if (stringTitle != null) {
if (isVisible) {
// AlertAnimation(visible = isVisible) {
BasicAlertDialog(
onDismissRequest = onDismissRequest
) {
val scrollState = rememberScrollState()
val canScrollBackward by remember { derivedStateOf { scrollState.value > 0 } }
val canScrollForward by remember { derivedStateOf { scrollState.value < scrollState.maxValue } }
Surface(
modifier = Modifier.fillMaxWidth(),
color = AlertDialogDefaults.containerColor,
shape = AlertDialogDefaults.shape,
tonalElevation = AlertDialogDefaults.TonalElevation
) {
Column(modifier = Modifier.padding(bottom = 10.dp)) {
val stringTitle = title?.getString()
if (stringTitle != null) {
Spacer(modifier = Modifier.height(20.dp))
}
Row {
stringTitle?.let { title ->
Spacer(modifier = Modifier.width(24.dp))
Text(
modifier = Modifier.weight(1f),
text = title,
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.width(20.dp))
}
}
if (canScrollBackward) {
HorizontalDivider(modifier = Modifier.fillMaxWidth())
}
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f, fill = false)
.verticalScroll(scrollState)
) {
Spacer(modifier = Modifier.height(8.dp))
val stringMessage = text?.getString()
if (stringMessage != null && stringTitle == null) {
Spacer(modifier = Modifier.height(20.dp))
}
Row {
stringTitle?.let { title ->
stringMessage?.let { message ->
Spacer(modifier = Modifier.width(24.dp))
Text(
modifier = Modifier.weight(1f),
text = title,
style = MaterialTheme.typography.headlineSmall
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(20.dp))
}
}
if (canScrollBackward) {
HorizontalDivider(modifier = Modifier.fillMaxWidth())
}
Spacer(modifier = Modifier.height(8.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f, fill = false)
.verticalScroll(scrollState)
) {
Spacer(modifier = Modifier.height(8.dp))
if (alertItems.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
AlertItems(
selectionType = itemsSelectionType,
items = alertItems,
onItemClick = { index ->
onItemClick?.invoke(index)
val stringMessage = text?.getString()
if (stringMessage != null && stringTitle == null) {
Spacer(modifier = Modifier.height(20.dp))
}
if (itemsSelectionType == ItemsSelectionType.None) {
onDismissRequest.invoke()
} else {
val newItems =
alertItems.mapIndexed { itemIndex, item ->
item.copy(isSelected = itemIndex == index)
}
Row {
stringMessage?.let { message ->
Spacer(modifier = Modifier.width(24.dp))
Text(
modifier = Modifier.weight(1f),
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(20.dp))
alertItems = newItems
}
},
onItemCheckedChanged = { index ->
val newItems = alertItems.toMutableList()
val oldItem = newItems[index]
newItems[index] =
oldItem.copy(isSelected = !oldItem.isSelected)
alertItems = newItems.toImmutableList()
}
}
Spacer(modifier = Modifier.height(8.dp))
if (alertItems.isNotEmpty()) {
)
Spacer(modifier = Modifier.height(10.dp))
} else {
customContent?.let { content ->
Spacer(modifier = Modifier.height(4.dp))
AlertItems(
selectionType = itemsSelectionType,
items = alertItems,
onItemClick = { index ->
onItemClick?.invoke(index)
if (itemsSelectionType == ItemsSelectionType.None) {
onDismissRequest.invoke()
} else {
val newItems =
alertItems.mapIndexed { itemIndex, item ->
item.copy(isSelected = itemIndex == index)
}
alertItems = newItems
}
},
onItemCheckedChanged = { index ->
val newItems = alertItems.toMutableList()
val oldItem = newItems[index]
newItems[index] =
oldItem.copy(isSelected = !oldItem.isSelected)
alertItems = newItems.toImmutableList()
}
)
content.invoke(this)
Spacer(modifier = Modifier.height(10.dp))
} else {
customContent?.let { content ->
Spacer(modifier = Modifier.height(4.dp))
content.invoke(this)
Spacer(modifier = Modifier.height(10.dp))
}
}
}
if (canScrollForward) {
HorizontalDivider(modifier = Modifier.fillMaxWidth())
}
Row {
Spacer(modifier = Modifier.width(20.dp))
neutralText?.getString()?.let { text ->
TextButton(
onClick = {
if (buttonsInvokeDismiss) {
onDismissRequest.invoke()
} else {
isVisible = false
}
neutralAction?.invoke()
}
) {
Text(text = text)
}
}
if (canScrollForward) {
HorizontalDivider(modifier = Modifier.fillMaxWidth())
Spacer(modifier = Modifier.weight(1f))
cancelText?.getString()?.let { text ->
TextButton(
onClick = {
if (buttonsInvokeDismiss) {
onDismissRequest.invoke()
} else {
isVisible = false
}
cancelAction?.invoke()
}
) {
Text(text = text)
}
}
Row {
Spacer(modifier = Modifier.width(20.dp))
neutralText?.getString()?.let { text ->
TextButton(
onClick = {
if (buttonsInvokeDismiss) {
onDismissRequest.invoke()
} else {
isVisible = false
}
neutralAction?.invoke()
Spacer(modifier = Modifier.width(2.dp))
confirmText?.getString()?.let { text ->
TextButton(
onClick = {
if (buttonsInvokeDismiss) {
onDismissRequest.invoke()
} else {
isVisible = false
}
) {
Text(text = text)
confirmAction?.invoke()
}
) {
Text(text = text)
}
Spacer(modifier = Modifier.weight(1f))
cancelText?.getString()?.let { text ->
TextButton(
onClick = {
if (buttonsInvokeDismiss) {
onDismissRequest.invoke()
} else {
isVisible = false
}
cancelAction?.invoke()
}
) {
Text(text = text)
}
}
Spacer(modifier = Modifier.width(2.dp))
confirmText?.getString()?.let { text ->
TextButton(
onClick = {
if (buttonsInvokeDismiss) {
onDismissRequest.invoke()
} else {
isVisible = false
}
confirmAction?.invoke()
}
) {
Text(text = text)
}
}
Spacer(modifier = Modifier.width(20.dp))
}
Spacer(modifier = Modifier.width(20.dp))
}
}
}
@@ -263,23 +262,6 @@ fun AlertAnimation(
)
}
@Preview
@Composable
fun AlertItemsPreview() {
AppTheme {
AlertItems(
selectionType = ItemsSelectionType.None,
items = ImmutableList(5) { index ->
DialogItem(
title = "Item #${index + 1}",
isSelected = index % 2 == 0
)
},
onItemClick = {}
)
}
}
@Composable
private fun AlertItems(
selectionType: ItemsSelectionType,
@@ -7,9 +7,9 @@ data class AuthDirectRequest(
val username: String,
val password: String,
val scope: String,
val twoFaSupported: Boolean = true,
val twoFaForceSms: Boolean = false,
val twoFaCode: String? = null,
val validationSupported: Boolean = true,
val validationForceSms: Boolean = false,
val validationCode: String? = null,
val captchaSid: String? = null,
val captchaKey: String? = null,
val trustedHash: String? = null
@@ -23,11 +23,11 @@ data class AuthDirectRequest(
"username" to username,
"password" to password,
"scope" to scope,
"2fa_supported" to if (twoFaSupported) "1" else "0",
"force_sms" to if (twoFaForceSms) "1" else "0"
"2fa_supported" to if (validationSupported) "1" else "0",
"force_sms" to if (validationForceSms) "1" else "0"
)
.apply {
twoFaCode?.let { this["code"] = it }
validationCode?.let { this["code"] = it }
captchaSid?.let { this["captcha_sid"] = it }
captchaKey?.let { this["captcha_key"] = it }
trustedHash?.let { this["trusted_hash"] = it }
@@ -7,7 +7,7 @@ import com.squareup.moshi.JsonClass
data class AuthDirectResponse(
@Json(name = "access_token") val accessToken: String?,
@Json(name = "user_id") val userId: Int?,
@Json(name = "trusted_hash") val twoFaHash: String?,
@Json(name = "trusted_hash") val validationHash: String?,
@Json(name = "validation_sid") val validationSid: String?,
@Json(name = "validation_type") val validationType: String?,
@Json(name = "phone_mask") val phoneMask: String?,
@@ -75,7 +75,7 @@ data class InvalidCredentialsError(
)
@JsonClass(generateAdapter = true)
data class WrongTwoFaCodeError(
data class WrongValidationCodeError(
@Json(name = "error") override val error: String, // "invalid_request"
@Json(name = "error_description") override val errorDescription: String,
@Json(name = "error_type") override val errorType: String // "wrong_otp"
@@ -86,7 +86,7 @@ data class WrongTwoFaCodeError(
)
@JsonClass(generateAdapter = true)
data class WrongTwoFaCodeFormatError(
data class WrongValidationCodeFormatError(
@Json(name = "error") override val error: String, // "invalid_request"
@Json(name = "error_description") override val errorDescription: String,
@Json(name = "error_type") override val errorType: String // "otp_format_is_incorrect"
@@ -140,12 +140,12 @@ fun OAuthError.toDomain(): OAuthErrorDomain? = when (this) {
OAuthErrorDomain.InvalidCredentialsError
}
is WrongTwoFaCodeError -> {
OAuthErrorDomain.WrongTwoFaCode
is WrongValidationCodeError -> {
OAuthErrorDomain.WrongValidationCode
}
is WrongTwoFaCodeFormatError -> {
OAuthErrorDomain.WrongTwoFaCodeFormat
is WrongValidationCodeFormatError -> {
OAuthErrorDomain.WrongValidationCodeFormat
}
is TooManyTriesError -> {
@@ -25,8 +25,8 @@ sealed class OAuthErrorDomain {
) : OAuthErrorDomain()
data object InvalidCredentialsError : OAuthErrorDomain()
data object WrongTwoFaCode : OAuthErrorDomain()
data object WrongTwoFaCodeFormat : OAuthErrorDomain()
data object WrongValidationCode : OAuthErrorDomain()
data object WrongValidationCodeFormat : OAuthErrorDomain()
data object TooManyTriesError: OAuthErrorDomain()
data object UnknownError : OAuthErrorDomain()
@@ -128,12 +128,12 @@ internal class ResultCall<R : Any, E : OAuthError>(
"invalid_request" -> {
when (val type = baseError.errorType) {
"wrong_otp" -> {
moshi.adapter(WrongTwoFaCodeError::class.java)
moshi.adapter(WrongValidationCodeError::class.java)
.fromJson(errorBodyString.orEmpty())
}
"otp_format_is_incorrect" -> {
moshi.adapter(WrongTwoFaCodeFormatError::class.java)
moshi.adapter(WrongValidationCodeFormatError::class.java)
.fromJson(errorBodyString.orEmpty())
}
+1 -1
View File
@@ -81,7 +81,7 @@ dependencies {
implementation(projects.feature.auth.login)
implementation(projects.feature.auth.captcha)
implementation(projects.feature.auth.twofa)
implementation(projects.feature.auth.validation)
implementation(projects.feature.auth.userbanned)
implementation(libs.koin.androidx.compose)
@@ -18,7 +18,7 @@ interface CaptchaViewModel {
fun onCodeInputChanged(newCode: String)
fun onTextFieldDoneClicked()
fun onTextFieldDoneAction()
fun onDoneButtonClicked()
fun onNavigatedToLogin()
@@ -32,24 +32,22 @@ class CaptchaViewModelImpl(
override val screenState = MutableStateFlow(CaptchaScreenState.EMPTY)
override val isNeedToOpenLogin = MutableStateFlow(false)
init {
val arguments = Captcha.from(savedStateHandle).arguments
val captchaImage = Captcha.from(savedStateHandle).captchaImageUrl
screenState.setValue { old ->
old.copy(
captchaSid = arguments.captchaSid,
captchaImage = URLDecoder.decode(arguments.captchaImage, "utf-8")
)
old.copy(captchaImageUrl = URLDecoder.decode(captchaImage, "utf-8"))
}
}
override fun onCodeInputChanged(newCode: String) {
val newState = screenState.value.copy(captchaCode = newCode.trim())
val newState = screenState.value.copy(code = newCode.trim())
screenState.update { newState }
processValidation()
}
override fun onTextFieldDoneClicked() {
override fun onTextFieldDoneAction() {
onDoneButtonClicked()
}
@@ -1,12 +0,0 @@
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
@@ -1,17 +1,15 @@
package com.meloda.app.fast.auth.captcha.model
data class CaptchaScreenState(
val captchaSid: String,
val captchaImage: String,
val captchaCode: String,
val captchaImageUrl: String,
val code: String,
val codeError: Boolean
) {
companion object {
val EMPTY = CaptchaScreenState(
captchaSid = "",
captchaImage = "",
captchaCode = "",
captchaImageUrl = "",
code = "",
codeError = false
)
}
@@ -0,0 +1,40 @@
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.presentation.CaptchaRoute
import kotlinx.serialization.Serializable
@Serializable
data class Captcha(val captchaImageUrl: String) {
companion object {
fun from(savedStateHandle: SavedStateHandle) = savedStateHandle.toRoute<Captcha>()
}
}
fun NavGraphBuilder.captchaScreen(
onBack: () -> Unit,
onResult: (String) -> Unit
) {
composable<Captcha> {
CaptchaRoute(
onBack = onBack,
onResult = onResult
)
}
}
fun NavController.navigateToCaptcha(captchaImageUrl: String) {
this.navigate(Captcha(captchaImageUrl))
}
fun NavController.setCaptchaResult(code: String?) {
this.currentBackStackEntry
?.savedStateHandle
?.set("captcha_code", code)
}
@@ -1,48 +0,0 @@
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)
}
@@ -49,6 +49,7 @@ 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.auth.captcha.model.CaptchaScreenState
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.designsystem.MaterialDialog
import com.meloda.app.fast.designsystem.TextFieldErrorText
@@ -56,7 +57,7 @@ import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@Composable
fun CaptchaScreen(
fun CaptchaRoute(
onBack: () -> Unit,
onResult: (String) -> Unit,
viewModel: CaptchaViewModel = koinViewModel<CaptchaViewModelImpl>()
@@ -64,6 +65,30 @@ fun CaptchaScreen(
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenLogin by viewModel.isNeedToOpenLogin.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToOpenLogin) {
if (isNeedToOpenLogin) {
viewModel.onNavigatedToLogin()
onResult(screenState.code)
}
}
CaptchaScreen(
screenState = screenState,
onBack = onBack,
onCodeInputChanged = viewModel::onCodeInputChanged,
onTextFieldDoneAction = viewModel::onTextFieldDoneAction,
onDoneButtonClicked = viewModel::onDoneButtonClicked
)
}
@Composable
fun CaptchaScreen(
screenState: CaptchaScreenState = CaptchaScreenState.EMPTY,
onBack: () -> Unit = {},
onCodeInputChanged: (String) -> Unit = {},
onTextFieldDoneAction: () -> Unit = {},
onDoneButtonClicked: () -> Unit = {}
) {
var confirmedExit by rememberSaveable {
mutableStateOf(false)
}
@@ -97,13 +122,6 @@ fun CaptchaScreen(
)
}
LaunchedEffect(isNeedToOpenLogin) {
if (isNeedToOpenLogin) {
viewModel.onNavigatedToLogin()
onResult(screenState.captchaCode)
}
}
val focusManager = LocalFocusManager.current
Scaffold { padding ->
@@ -171,7 +189,7 @@ fun CaptchaScreen(
} else {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(screenState.captchaImage)
.data(screenState.captchaImageUrl)
.crossfade(true)
.build(),
contentDescription = "Captcha image",
@@ -183,14 +201,14 @@ fun CaptchaScreen(
Spacer(modifier = Modifier.height(30.dp))
var code by remember { mutableStateOf(TextFieldValue(screenState.captchaCode)) }
var code by remember { mutableStateOf(TextFieldValue(screenState.code)) }
val showError = screenState.codeError
TextField(
value = code,
onValueChange = { newText ->
code = newText
viewModel.onCodeInputChanged(newText.text)
onCodeInputChanged(newText.text)
},
label = { Text(text = "Code") },
placeholder = { Text(text = "Code") },
@@ -213,7 +231,7 @@ fun CaptchaScreen(
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
viewModel.onTextFieldDoneClicked()
onTextFieldDoneAction()
}
),
isError = showError
@@ -225,7 +243,7 @@ fun CaptchaScreen(
}
FloatingActionButton(
onClick = viewModel::onDoneButtonClicked,
onClick = onDoneButtonClicked,
containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
@@ -7,7 +7,7 @@ class CaptchaValidator {
fun validate(screenState: CaptchaScreenState): CaptchaValidationResult {
return when {
screenState.captchaCode.isEmpty() -> CaptchaValidationResult.Empty
screenState.code.trim().isEmpty() -> CaptchaValidationResult.Empty
else -> CaptchaValidationResult.Valid
}
}
+3
View File
@@ -91,4 +91,7 @@ dependencies {
implementation(libs.kotlin.serialization)
implementation(libs.rebugger)
androidTestImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.ui.test.manifest)
}
@@ -0,0 +1,23 @@
package com.meloda.fast.auth.login
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import com.meloda.fast.auth.login.presentation.LoginScreen
import org.junit.Rule
import org.junit.Test
class LoginSignInTests {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun singInButton_isClickable() {
composeTestRule.setContent {
LoginScreen()
}
composeTestRule.onNodeWithTag(testTag = "sing_in_fab").assertHasClickAction()
}
}
@@ -15,10 +15,10 @@ 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.LoginCaptchaArguments
import com.meloda.fast.auth.login.model.CaptchaArguments
import com.meloda.fast.auth.login.model.LoginError
import com.meloda.fast.auth.login.model.LoginScreenState
import com.meloda.fast.auth.login.model.LoginTwoFaArguments
import com.meloda.fast.auth.login.model.LoginValidationArguments
import com.meloda.fast.auth.login.model.LoginUserBannedArguments
import com.meloda.fast.auth.login.model.LoginValidationResult
import com.meloda.fast.auth.login.validation.LoginValidator
@@ -36,10 +36,10 @@ interface LoginViewModel {
val screenState: StateFlow<LoginScreenState>
val loginError: StateFlow<LoginError?>
val twoFaCode: StateFlow<String?>
val twoFaArguments: StateFlow<LoginTwoFaArguments?>
val validationCode: StateFlow<String?>
val validationArguments: StateFlow<LoginValidationArguments?>
val captchaCode: StateFlow<String?>
val captchaArguments: StateFlow<LoginCaptchaArguments?>
val captchaArguments: StateFlow<CaptchaArguments?>
val userBannedArguments: StateFlow<LoginUserBannedArguments?>
val isNeedToOpenMain: StateFlow<Boolean>
@@ -55,9 +55,9 @@ interface LoginViewModel {
fun onNavigatedToMain()
fun onNavigatedToUserBanned()
fun onNavigatedToCaptcha()
fun onNavigatedToTwoFa()
fun onNavigatedToValidation()
fun onTwoFaCodeReceived(code: String)
fun onValidationCodeReceived(code: String)
fun onCaptchaCodeReceived(code: String)
fun onLogoLongClicked()
@@ -73,10 +73,10 @@ class LoginViewModelImpl(
override val screenState = MutableStateFlow(LoginScreenState.EMPTY)
override val loginError = MutableStateFlow<LoginError?>(null)
override val twoFaCode = MutableStateFlow<String?>(null)
override val twoFaArguments = MutableStateFlow<LoginTwoFaArguments?>(null)
override val validationCode = MutableStateFlow<String?>(null)
override val validationArguments = MutableStateFlow<LoginValidationArguments?>(null)
override val captchaCode = MutableStateFlow<String?>(null)
override val captchaArguments = MutableStateFlow<LoginCaptchaArguments?>(null)
override val captchaArguments = MutableStateFlow<CaptchaArguments?>(null)
override val userBannedArguments = MutableStateFlow<LoginUserBannedArguments?>(null)
override val isNeedToOpenMain = MutableStateFlow(false)
@@ -125,12 +125,12 @@ class LoginViewModelImpl(
captchaArguments.update { null }
}
override fun onNavigatedToTwoFa() {
twoFaArguments.update { null }
override fun onNavigatedToValidation() {
validationArguments.update { null }
}
override fun onTwoFaCodeReceived(code: String) {
twoFaCode.update { code }
override fun onValidationCodeReceived(code: String) {
validationCode.update { code }
login()
}
@@ -186,7 +186,7 @@ class LoginViewModelImpl(
"LoginViewModel",
"auth: login: ${currentState.login}; " +
"password: ${currentState.password}; " +
"2fa code: ${twoFaCode.value}; " +
"2fa code: ${validationCode.value}; " +
"captcha code: ${captchaCode.value}"
)
@@ -197,7 +197,7 @@ class LoginViewModelImpl(
login = currentState.login,
password = currentState.password,
forceSms = forceSms,
twoFaCode = twoFaCode.value,
validationCode = validationCode.value,
captchaSid = captchaArguments.value?.captchaSid,
captchaKey = captchaCode.value
).listenValue { state ->
@@ -205,7 +205,7 @@ class LoginViewModelImpl(
error = { error ->
Log.d("LoginViewModelImpl", "login: error: $error")
twoFaCode.update { null }
validationCode.update { null }
captchaCode.update { null }
parseError(error)
@@ -229,7 +229,7 @@ class LoginViewModelImpl(
userId = userId,
accessToken = accessToken,
fastToken = null,
trustedHash = response.twoFaHash
trustedHash = response.validationHash
).also { account ->
UserConfig.currentUserId = account.userId
UserConfig.userId = account.userId
@@ -243,8 +243,8 @@ class LoginViewModelImpl(
captchaArguments.update { null }
captchaCode.update { null }
twoFaArguments.update { null }
twoFaCode.update { null }
validationArguments.update { null }
validationCode.update { null }
screenState.setValue { old ->
old.copy(
@@ -265,7 +265,7 @@ class LoginViewModelImpl(
is State.Error.OAuthError -> {
when (val error = stateError.error) {
is OAuthErrorDomain.ValidationRequiredError -> {
val arguments = LoginTwoFaArguments(
val arguments = LoginValidationArguments(
validationSid = error.validationSid,
redirectUri = error.redirectUri,
phoneMask = error.phoneMask,
@@ -273,13 +273,13 @@ class LoginViewModelImpl(
canResendSms = error.validationResend == "sms",
wrongCodeError = null
)
twoFaArguments.update { arguments }
validationArguments.update { arguments }
}
is OAuthErrorDomain.CaptchaRequiredError -> {
val arguments = LoginCaptchaArguments(
val arguments = CaptchaArguments(
captchaSid = error.captchaSid,
captchaImage = error.captchaImageUrl
captchaImageUrl = error.captchaImageUrl
)
captchaArguments.update { arguments }
}
@@ -298,12 +298,12 @@ class LoginViewModelImpl(
userBannedArguments.update { arguments }
}
OAuthErrorDomain.WrongTwoFaCode -> {
loginError.update { LoginError.WrongTwoFaCode }
OAuthErrorDomain.WrongValidationCode -> {
loginError.update { LoginError.WrongValidationCode }
}
OAuthErrorDomain.WrongTwoFaCodeFormat -> {
loginError.update { LoginError.WrongTwoFaCodeFormat }
OAuthErrorDomain.WrongValidationCodeFormat -> {
loginError.update { LoginError.WrongValidationCodeFormat }
}
OAuthErrorDomain.TooManyTriesError -> {
@@ -320,102 +320,6 @@ class LoginViewModelImpl(
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() {
@@ -10,7 +10,7 @@ interface OAuthUseCase {
login: String,
password: String,
forceSms: Boolean,
twoFaCode: String?,
validationCode: String?,
captchaSid: String?,
captchaKey: String?
): Flow<State<AuthInfo>>
@@ -18,7 +18,7 @@ class OAuthUseCaseImpl(
login: String,
password: String,
forceSms: Boolean,
twoFaCode: String?,
validationCode: String?,
captchaSid: String?,
captchaKey: String?
): Flow<State<AuthInfo>> = flow {
@@ -27,7 +27,7 @@ class OAuthUseCaseImpl(
val response = oAuthRepository.auth(
login = login,
password = password,
twoFaCode = twoFaCode,
validationCode = validationCode,
captchaSid = captchaSid,
captchaKey = captchaKey,
forceSms = forceSms
@@ -39,7 +39,7 @@ class OAuthUseCaseImpl(
AuthInfo(
userId = response.userId,
accessToken = response.accessToken,
twoFaHash = response.twoFaHash
validationHash = response.validationHash
)
)
}
@@ -92,11 +92,11 @@ class OAuthUseCaseImpl(
VkOAuthErrors.INVALID_REQUEST -> {
when (response.errorType) {
VkErrorTypes.WRONG_OTP -> {
State.Error.OAuthError(OAuthErrorDomain.WrongTwoFaCode)
State.Error.OAuthError(OAuthErrorDomain.WrongValidationCode)
}
VkErrorTypes.WRONG_OTP_FORMAT -> {
State.Error.OAuthError(OAuthErrorDomain.WrongTwoFaCodeFormat)
State.Error.OAuthError(OAuthErrorDomain.WrongValidationCodeFormat)
}
else -> {
@@ -3,5 +3,5 @@ package com.meloda.fast.auth.login.model
data class AuthInfo(
val userId: Int?,
val accessToken: String?,
val twoFaHash: String?
val validationHash: String?
)
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class LoginCaptchaArguments(
data class CaptchaArguments(
val captchaSid: String,
val captchaImage: String
val captchaImageUrl: String
) : Parcelable
@@ -7,6 +7,6 @@ sealed class LoginError {
data object Unknown : LoginError()
data object WrongCredentials : LoginError()
data object TooManyTries : LoginError()
data object WrongTwoFaCode : LoginError()
data object WrongTwoFaCodeFormat : LoginError()
data object WrongValidationCode : LoginError()
data object WrongValidationCodeFormat : LoginError()
}
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class LoginTwoFaArguments(
data class LoginValidationArguments(
val validationSid: String,
val redirectUri: String,
val phoneMask: String,
@@ -5,14 +5,13 @@ 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.LoginCaptchaArguments
import com.meloda.fast.auth.login.model.LoginTwoFaArguments
import com.meloda.fast.auth.login.model.CaptchaArguments
import com.meloda.fast.auth.login.model.LoginValidationArguments
import com.meloda.fast.auth.login.model.LoginUserBannedArguments
import com.meloda.fast.auth.login.presentation.LoginScreen
import com.meloda.fast.auth.login.presentation.LogoScreen
import com.meloda.fast.auth.login.presentation.LoginRoute
import com.meloda.fast.auth.login.presentation.LogoRoute
import kotlinx.serialization.Serializable
@Serializable
@@ -21,10 +20,9 @@ object Login
@Serializable
object Logo
fun NavGraphBuilder.loginRoute(
onError: (BaseError) -> Unit,
onNavigateToCaptcha: (LoginCaptchaArguments) -> Unit,
onNavigateToTwoFa: (LoginTwoFaArguments) -> Unit,
fun NavGraphBuilder.loginScreen(
onNavigateToCaptcha: (CaptchaArguments) -> Unit,
onNavigateToValidation: (LoginValidationArguments) -> Unit,
onNavigateToMain: () -> Unit,
onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit,
onNavigateToCredentials: () -> Unit,
@@ -34,25 +32,24 @@ fun NavGraphBuilder.loginRoute(
val viewModel: LoginViewModel =
backStackEntry.sharedViewModel<LoginViewModelImpl>(navController = navController)
val twoFaCode = backStackEntry.getTwoFaResult()
val validationCode = backStackEntry.getValidationResult()
val captchaCode = backStackEntry.getCaptchaResult()
LoginScreen(
onError = onError,
LoginRoute(
onNavigateToUserBanned = onNavigateToUserBanned,
onNavigateToMain = onNavigateToMain,
onNavigateToCaptcha = onNavigateToCaptcha,
onNavigateToTwoFa = onNavigateToTwoFa,
twoFaCode = twoFaCode,
onNavigateToValidation = onNavigateToValidation,
validationCode = validationCode,
captchaCode = captchaCode,
viewModel = viewModel
)
}
composable<Logo> {
LogoScreen(
LogoRoute(
onNavigateToMain = onNavigateToMain,
onShowCredentials = onNavigateToCredentials
onGoNextButtonClicked = onNavigateToCredentials
)
}
}
@@ -61,10 +58,10 @@ fun NavController.navigateToLogin() {
this.navigate(route = Login)
}
fun NavBackStackEntry.getTwoFaResult(): String? {
return savedStateHandle["twofacode"]
fun NavBackStackEntry.getValidationResult(): String? {
return savedStateHandle["validation_code"]
}
fun NavBackStackEntry.getCaptchaResult(): String? {
return savedStateHandle["captchacode"]
return savedStateHandle["captcha_code"]
}
@@ -55,25 +55,23 @@ 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.LoginCaptchaArguments
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.LoginScreenState
import com.meloda.fast.auth.login.model.LoginValidationArguments
import com.meloda.fast.auth.login.model.LoginUserBannedArguments
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun LoginScreen(
onError: (BaseError) -> Unit,
fun LoginRoute(
onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit,
onNavigateToMain: () -> Unit,
onNavigateToCaptcha: (LoginCaptchaArguments) -> Unit,
onNavigateToTwoFa: (LoginTwoFaArguments) -> Unit,
twoFaCode: String?,
onNavigateToCaptcha: (CaptchaArguments) -> Unit,
onNavigateToValidation: (LoginValidationArguments) -> Unit,
validationCode: String?,
captchaCode: String?,
viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>()
) {
@@ -81,7 +79,7 @@ fun LoginScreen(
val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle()
val userBannedArguments by viewModel.userBannedArguments.collectAsStateWithLifecycle()
val captchaArguments by viewModel.captchaArguments.collectAsStateWithLifecycle()
val twoFaArguments by viewModel.twoFaArguments.collectAsStateWithLifecycle()
val validationArguments by viewModel.validationArguments.collectAsStateWithLifecycle()
val loginError by viewModel.loginError.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToOpenMain) {
@@ -105,16 +103,16 @@ fun LoginScreen(
}
}
LaunchedEffect(twoFaArguments) {
twoFaArguments?.let { arguments ->
viewModel.onNavigatedToTwoFa()
onNavigateToTwoFa(arguments)
LaunchedEffect(validationArguments) {
validationArguments?.let { arguments ->
viewModel.onNavigatedToValidation()
onNavigateToValidation(arguments)
}
}
LaunchedEffect(twoFaCode) {
if (twoFaCode != null) {
viewModel.onTwoFaCodeReceived(twoFaCode)
LaunchedEffect(validationCode) {
if (validationCode != null) {
viewModel.onValidationCodeReceived(validationCode)
}
}
@@ -124,9 +122,41 @@ fun LoginScreen(
}
}
LoginScreen(
screenState = screenState,
onLoginAutoFilled = viewModel::onLoginInputChanged,
onPasswordAutoFilled = viewModel::onPasswordInputChanged,
onLoginInputChanged = viewModel::onLoginInputChanged,
onPasswordInputChanged = viewModel::onPasswordInputChanged,
onPasswordFieldEnterKeyClicked = viewModel::onSignInButtonClicked,
onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked,
onPasswordFieldGoAction = viewModel::onSignInButtonClicked,
onSignInButtonClicked = viewModel::onSignInButtonClicked
)
HandleError(
onDismiss = viewModel::onErrorDialogDismissed,
error = loginError
)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun LoginScreen(
screenState: LoginScreenState = LoginScreenState.EMPTY,
onLoginAutoFilled: (String) -> Unit = {},
onPasswordAutoFilled: (String) -> Unit = {},
onLoginInputChanged: (String) -> Unit = {},
onPasswordInputChanged: (String) -> Unit = {},
onPasswordFieldEnterKeyClicked: () -> Unit = {},
onPasswordVisibilityButtonClicked: () -> Unit = {},
onPasswordFieldGoAction: () -> Unit = {},
onSignInButtonClicked: () -> Unit = {}
) {
val focusManager = LocalFocusManager.current
val (loginFocusable, passwordFocusable) = FocusRequester.createRefs()
// TODO: 13/07/2024, Danil Nikolaev: remove
var loginText by remember { mutableStateOf(TextFieldValue(screenState.login)) }
val showLoginError = screenState.loginError
@@ -135,7 +165,7 @@ fun LoginScreen(
onFill = { value ->
loginText =
TextFieldValue(text = value, selection = TextRange(value.length))
viewModel.onLoginInputChanged(value)
onLoginAutoFilled(value)
}
)
@@ -147,7 +177,7 @@ fun LoginScreen(
onFill = { value ->
passwordText =
TextFieldValue(text = value, selection = TextRange(value.length))
viewModel.onPasswordInputChanged(value)
onPasswordAutoFilled(value)
}
)
@@ -200,7 +230,7 @@ fun LoginScreen(
}
loginText = newText
viewModel.onLoginInputChanged(text)
onLoginInputChanged(text)
},
label = { Text(text = stringResource(id = UiR.string.login_hint)) },
placeholder = { Text(text = stringResource(id = UiR.string.login_hint)) },
@@ -236,7 +266,7 @@ fun LoginScreen(
.clip(RoundedCornerShape(10.dp))
.handleEnterKey {
focusManager.clearFocus()
viewModel.onSignInButtonClicked()
onPasswordFieldEnterKeyClicked()
true
}
.focusRequester(passwordFocusable)
@@ -250,7 +280,7 @@ fun LoginScreen(
}
passwordText = newText
viewModel.onPasswordInputChanged(text)
onPasswordInputChanged(text)
},
label = { Text(text = stringResource(id = UiR.string.password_login_hint)) },
placeholder = { Text(text = stringResource(id = UiR.string.password_login_hint)) },
@@ -271,7 +301,7 @@ fun LoginScreen(
else UiR.drawable.round_visibility_24
)
IconButton(onClick = viewModel::onPasswordVisibilityButtonClicked) {
IconButton(onClick = onPasswordVisibilityButtonClicked) {
Icon(
painter = imagePainter,
contentDescription = if (screenState.passwordVisible) "Password visible icon"
@@ -286,7 +316,7 @@ fun LoginScreen(
keyboardActions = KeyboardActions(
onGo = {
focusManager.clearFocus()
viewModel.onSignInButtonClicked()
onPasswordFieldGoAction()
}
),
isError = showPasswordError,
@@ -310,10 +340,10 @@ fun LoginScreen(
FloatingActionButton(
onClick = {
focusManager.clearFocus()
viewModel.onSignInButtonClicked()
onSignInButtonClicked()
},
containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.testTag("Sign in button")
modifier = Modifier.testTag("sing_in_fab")
) {
Icon(
painter = painterResource(id = UiR.drawable.ic_arrow_end),
@@ -332,11 +362,6 @@ fun LoginScreen(
}
}
}
HandleError(
onDismiss = viewModel::onErrorDialogDismissed,
error = loginError
)
}
@Composable
@@ -375,7 +400,7 @@ fun HandleError(
}
LoginError.WrongTwoFaCode -> {
LoginError.WrongValidationCode -> {
MaterialDialog(
onDismissAction = onDismiss,
title = UiText.Simple("Error"),
@@ -384,7 +409,7 @@ fun HandleError(
)
}
LoginError.WrongTwoFaCodeFormat -> {
LoginError.WrongValidationCodeFormat -> {
MaterialDialog(
onDismissAction = onDismiss,
title = UiText.Simple("Error"),
@@ -34,11 +34,10 @@ 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(
fun LogoRoute(
onNavigateToMain: () -> Unit,
onShowCredentials: () -> Unit,
onGoNextButtonClicked: () -> Unit,
viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>()
) {
val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle()
@@ -50,15 +49,37 @@ fun LogoScreen(
}
}
LogoScreen(
onLogoLongClicked = viewModel::onLogoLongClicked,
onGoNextButtonClicked = onGoNextButtonClicked
)
}
// TODO: 13/07/2024, Danil Nikolaev: replace with scaffold?
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LogoScreen(
onLogoLongClicked: () -> Unit = {},
onGoNextButtonClicked: () -> Unit = {}
) {
Scaffold { padding ->
val topPadding by animateDpAsState(targetValue = padding.calculateTopPadding())
val bottomPadding by animateDpAsState(targetValue = padding.calculateBottomPadding())
val topPadding by animateDpAsState(
targetValue = padding.calculateTopPadding(),
label = "topPaddingAnimation"
)
val bottomPadding by animateDpAsState(
targetValue = padding.calculateBottomPadding(),
label = "bottomPaddingAnimation"
)
val endPadding by animateDpAsState(
targetValue = padding.calculateEndPadding(LayoutDirection.Ltr)
targetValue = padding.calculateEndPadding(LayoutDirection.Ltr),
label = "endPaddingAnimation"
)
val startPadding by animateDpAsState(
targetValue = padding.calculateStartPadding(LayoutDirection.Ltr)
targetValue = padding.calculateStartPadding(LayoutDirection.Ltr),
label = "startPaddingAnimation"
)
Box(
@@ -85,7 +106,7 @@ fun LogoScreen(
modifier = Modifier.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onLongClick = viewModel::onLogoLongClicked,
onLongClick = onLogoLongClicked,
onClick = {}
)
)
@@ -98,7 +119,7 @@ fun LogoScreen(
}
FloatingActionButton(
onClick = onShowCredentials,
onClick = onGoNextButtonClicked,
containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.align(Alignment.BottomCenter)
) {
@@ -3,20 +3,19 @@ package com.meloda.app.fast.auth
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
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.captchaScreen
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.auth.validation.model.ValidationArguments
import com.meloda.app.fast.auth.validation.navigation.navigateToValidation
import com.meloda.app.fast.auth.validation.navigation.setValidationResult
import com.meloda.app.fast.auth.validation.navigation.validationScreen
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.loginScreen
import com.meloda.fast.auth.login.navigation.navigateToLogin
import kotlinx.serialization.Serializable
import java.net.URLEncoder
@@ -25,26 +24,21 @@ import java.net.URLEncoder
object AuthGraph
fun NavGraphBuilder.authNavGraph(
onError: (BaseError) -> Unit,
onNavigateToMain: () -> Unit,
navController: NavController
) {
navigation<AuthGraph>(
startDestination = Logo
) {
loginRoute(
onError = onError,
loginScreen(
onNavigateToCaptcha = { arguments ->
navController.navigateToCaptcha(
CaptchaArguments(
arguments.captchaSid,
URLEncoder.encode(arguments.captchaImage, "utf-8")
)
captchaImageUrl = URLEncoder.encode(arguments.captchaImageUrl, "utf-8")
)
},
onNavigateToTwoFa = { arguments ->
navController.navigateToTwoFa(
TwoFaArguments(
onNavigateToValidation = { arguments ->
navController.navigateToValidation(
ValidationArguments(
validationSid = arguments.validationSid,
redirectUri = URLEncoder.encode(arguments.redirectUri, "utf-8"),
phoneMask = arguments.phoneMask,
@@ -58,7 +52,7 @@ fun NavGraphBuilder.authNavGraph(
onNavigateToUserBanned = { arguments ->
navController.navigateToUserBanned(
UserBannedArguments(
name = arguments.name,
userName = arguments.name,
message = arguments.message,
restoreUrl = arguments.restoreUrl,
accessToken = arguments.accessToken
@@ -69,18 +63,18 @@ fun NavGraphBuilder.authNavGraph(
navController = navController
)
twoFaRoute(
validationScreen(
onBack = {
navController.navigateUp()
navController.setTwoFaResult(null)
navController.setValidationResult(null)
},
onResult = { code ->
navController.popBackStack()
navController.setTwoFaResult(code)
navController.setValidationResult(code)
}
)
captchaRoute(
captchaScreen(
onBack = {
navController.navigateUp()
navController.setCaptchaResult(null)
@@ -1,14 +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.app.fast.auth.validation.di.validationModule
import com.meloda.fast.auth.login.di.loginModule
import org.koin.dsl.module
val authModule = module {
includes(
loginModule,
twoFaModule,
validationModule,
captchaModule,
)
}
@@ -1,17 +0,0 @@
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
}
@@ -1,26 +0,0 @@
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 phoneMask: String
) {
companion object {
val EMPTY = TwoFaScreenState(
twoFaSid = "",
twoFaCode = null,
twoFaText = UiText.Simple(""),
canResendSms = false,
codeError = null,
delayTime = 0,
phoneMask = ""
)
}
}
@@ -1,8 +0,0 @@
package com.meloda.app.fast.auth.twofa.model
sealed class TwoFaValidationResult {
data object Empty : TwoFaValidationResult()
data object Valid : TwoFaValidationResult()
fun isValid() = this == Valid
}
@@ -1,23 +0,0 @@
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)
}
}
}
}
@@ -1,46 +0,0 @@
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.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(
onBack = onBack,
onCodeResult = onResult
)
}
}
fun NavController.navigateToTwoFa(arguments: TwoFaArguments) {
this.navigate(TwoFa(arguments))
}
fun NavController.setTwoFaResult(code: String?) {
this.currentBackStackEntry
?.savedStateHandle
?.set("twofacode", code)
}
@@ -1,14 +0,0 @@
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
}
}
}
+2
View File
@@ -57,4 +57,6 @@ dependencies {
implementation(libs.coil.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization)
debugImplementation(libs.androidx.ui.tooling)
}
@@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class UserBannedArguments(
val name: String,
val userName: String,
val message: String,
val restoreUrl: String,
val accessToken: String
@@ -0,0 +1,14 @@
package com.meloda.app.fast.userbanned.model
data class UserBannedScreenState(
val userName: String,
val message: String
) {
companion object {
val EMPTY: UserBannedScreenState = UserBannedScreenState(
userName = "",
message = ""
)
}
}
@@ -8,7 +8,7 @@ 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 com.meloda.app.fast.userbanned.presentation.UserBannedRoute
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@@ -32,18 +32,16 @@ val UserBannedNavType = object : NavType<UserBannedArguments>(isNullableAllowed
override val name: String = "UserBannedArguments"
}
fun NavGraphBuilder.userBannedRoute(
onBack: () -> Unit
) {
fun NavGraphBuilder.userBannedRoute(onBack: () -> Unit) {
composable<UserBanned>(
typeMap = mapOf(typeOf<UserBannedArguments>() to UserBannedNavType)
) { backStackEntry ->
val arguments: UserBannedArguments = backStackEntry.toRoute()
UserBannedScreen(
UserBannedRoute(
onBack = onBack,
name = arguments.name,
message = arguments.message,
userName = arguments.userName,
message = arguments.message
)
}
}
@@ -14,6 +14,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
@@ -22,27 +23,44 @@ 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.userbanned.model.UserBannedScreenState
import com.meloda.app.fast.designsystem.R as UiR
@Preview
@Composable
fun UserBannedScreenPreview() {
AppTheme {
UserBannedScreen(
onBack = {},
name = "Calvin Harris",
message = "Eto konets"
UserBannedScreen(
screenState = UserBannedScreenState(
userName = "Andre Shultz",
message = "Bruteforce"
)
)
}
@Composable
fun UserBannedRoute(
onBack: () -> Unit,
userName: String,
message: String
) {
val screenState = remember(userName, message) {
UserBannedScreenState(
userName = userName,
message = message
)
}
UserBannedScreen(
screenState = screenState,
onBack = onBack
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UserBannedScreen(
onBack: () -> Unit,
name: String,
message: String,
screenState: UserBannedScreenState = UserBannedScreenState.EMPTY,
onBack: () -> Unit = {},
) {
Scaffold(
topBar = {
@@ -80,7 +98,7 @@ fun UserBannedScreen(
append(": ")
}
append(name)
append(screenState.userName)
}
)
Text(
@@ -89,7 +107,7 @@ fun UserBannedScreen(
append(stringResource(id = UiR.string.blocking_reason_title))
append(": ")
}
append(message)
append(screenState.message)
}
)
}
@@ -7,7 +7,7 @@ plugins {
}
android {
namespace = "com.meloda.app.fast.twofa"
namespace = "com.meloda.app.fast.validation"
compileSdk = Configs.compileSdk
defaultConfig {
@@ -1,4 +1,4 @@
package com.meloda.app.fast.auth.twofa
package com.meloda.app.fast.auth.validation
import com.meloda.app.fast.data.State
import com.meloda.app.fast.model.api.responses.SendSmsResponse
@@ -1,4 +1,4 @@
package com.meloda.app.fast.auth.twofa
package com.meloda.app.fast.auth.validation
import com.meloda.app.fast.data.State
import com.meloda.app.fast.data.api.auth.AuthRepository
@@ -1,12 +1,12 @@
package com.meloda.app.fast.auth.twofa
package com.meloda.app.fast.auth.validation
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.auth.validation.model.ValidationScreenState
import com.meloda.app.fast.auth.validation.model.ValidationType
import com.meloda.app.fast.auth.validation.navigation.Validation
import com.meloda.app.fast.auth.validation.validation.ValidationValidator
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.common.extensions.createTimerFlow
import com.meloda.app.fast.common.extensions.listenValue
@@ -22,9 +22,9 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
interface TwoFaViewModel {
interface ValidationViewModel {
val screenState: StateFlow<TwoFaScreenState>
val screenState: StateFlow<ValidationScreenState>
val isNeedToOpenLogin: StateFlow<Boolean>
@@ -33,36 +33,39 @@ interface TwoFaViewModel {
fun onBackButtonClicked()
fun onCancelButtonClicked()
fun onRequestSmsButtonClicked()
fun onTextFieldDoneClicked()
fun onTextFieldDoneAction()
fun onDoneButtonClicked()
fun onNavigatedToLogin()
}
class TwoFaViewModelImpl(
private val validator: TwoFaValidator,
class ValidationViewModelImpl(
private val validator: ValidationValidator,
private val authUseCase: AuthUseCase,
savedStateHandle: SavedStateHandle
) : TwoFaViewModel, ViewModel() {
) : ValidationViewModel, ViewModel() {
override val screenState = MutableStateFlow(TwoFaScreenState.EMPTY)
override val screenState = MutableStateFlow(ValidationScreenState.EMPTY)
override val isNeedToOpenLogin = MutableStateFlow(false)
private var validationSid: String? = null
private var delayJob: Job? = null
init {
// TODO: 08/07/2024, Danil Nikolaev: use when fixed
//savedStateHandle.toRoute<TwoFa>().arguments
//savedStateHandle.toRoute<Validation>().arguments
val arguments = TwoFa.from(savedStateHandle).arguments
val arguments = Validation.from(savedStateHandle).arguments
validationSid = arguments.validationSid
screenState.setValue { old ->
old.copy(
twoFaSid = arguments.validationSid,
canResendSms = arguments.canResendSms,
isSmsButtonVisible = arguments.canResendSms,
codeError = arguments.wrongCodeError,
twoFaText = getTwoFaText(TwoFaValidationType.parse(arguments.validationType)),
validationText = getValidationText(ValidationType.parse(arguments.validationType)),
phoneMask = arguments.phoneMask
)
}
@@ -71,7 +74,7 @@ class TwoFaViewModelImpl(
override fun onCodeInputChanged(newCode: String) {
screenState.updateValue(
screenState.value.copy(
twoFaCode = newCode.trim(),
code = newCode.trim(),
codeError = null
)
)
@@ -89,7 +92,7 @@ class TwoFaViewModelImpl(
}
override fun onCancelButtonClicked() {
screenState.setValue { old -> old.copy(twoFaCode = null) }
screenState.setValue { old -> old.copy(code = null) }
isNeedToOpenLogin.update { true }
}
@@ -97,7 +100,7 @@ class TwoFaViewModelImpl(
sendValidationCode()
}
override fun onTextFieldDoneClicked() {
override fun onTextFieldDoneAction() {
onDoneButtonClicked()
}
@@ -108,7 +111,7 @@ class TwoFaViewModelImpl(
}
override fun onNavigatedToLogin() {
screenState.updateValue(TwoFaScreenState.EMPTY)
screenState.updateValue(ValidationScreenState.EMPTY)
isNeedToOpenLogin.update { false }
}
@@ -126,9 +129,7 @@ class TwoFaViewModelImpl(
}
private fun sendValidationCode() {
val validationSid = screenState.value.twoFaSid
authUseCase.sendSms(validationSid)
authUseCase.sendSms(validationSid.orEmpty())
.listenValue { state ->
state.processState(
error = { error ->
@@ -140,9 +141,9 @@ class TwoFaViewModelImpl(
screenState.setValue { old ->
old.copy(
canResendSms = newCanResendSms,
twoFaText = getTwoFaText(
TwoFaValidationType.parse(newValidationType.orEmpty())
isSmsButtonVisible = newCanResendSms,
validationText = getValidationText(
ValidationType.parse(newValidationType.orEmpty())
)
)
}
@@ -152,7 +153,7 @@ class TwoFaViewModelImpl(
)
if (state.isLoading()) {
screenState.emit(screenState.value.copy(canResendSms = false))
screenState.emit(screenState.value.copy(isSmsButtonVisible = false))
}
}
}
@@ -164,7 +165,7 @@ class TwoFaViewModelImpl(
time = delay,
onStartAction = {
screenState.updateValue(
screenState.value.copy(canResendSms = false)
screenState.value.copy(isSmsButtonVisible = false)
)
},
onTickAction = { remainedTime ->
@@ -175,24 +176,24 @@ class TwoFaViewModelImpl(
onTimeoutAction = {
screenState.updateValue(
screenState.value.copy(
canResendSms = true
isSmsButtonVisible = true
)
)
},
).launchIn(viewModelScope)
}
private fun getTwoFaText(validationType: TwoFaValidationType): UiText {
private fun getValidationText(validationType: ValidationType): UiText {
return when (validationType) {
TwoFaValidationType.Sms -> {
ValidationType.Sms -> {
UiText.Simple("SMS with the code is sent to ${screenState.value.phoneMask}")
}
TwoFaValidationType.TwoFaApp -> {
ValidationType.App -> {
UiText.Simple("Enter the code from the code generator application")
}
is TwoFaValidationType.Another -> UiText.Simple(validationType.type)
is ValidationType.Other -> UiText.Simple(validationType.type)
}
}
}
@@ -0,0 +1,17 @@
package com.meloda.app.fast.auth.validation.di
import com.meloda.app.fast.auth.validation.AuthUseCase
import com.meloda.app.fast.auth.validation.AuthUseCaseImpl
import com.meloda.app.fast.auth.validation.ValidationViewModel
import com.meloda.app.fast.auth.validation.ValidationViewModelImpl
import com.meloda.app.fast.auth.validation.validation.ValidationValidator
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 validationModule = module {
singleOf(::ValidationValidator)
viewModelOf(::ValidationViewModelImpl) bind ValidationViewModel::class
singleOf(::AuthUseCaseImpl) bind AuthUseCase::class
}
@@ -1,4 +1,4 @@
package com.meloda.app.fast.auth.twofa.model
package com.meloda.app.fast.auth.validation.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class TwoFaArguments(
data class ValidationArguments(
val validationSid: String,
val redirectUri: String,
val phoneMask: String,
@@ -0,0 +1,27 @@
package com.meloda.app.fast.auth.validation.model
import com.meloda.app.fast.common.UiText
data class ValidationScreenState(
val code: String?,
val codeError: String?,
val isSmsButtonVisible: Boolean,
val delayTime: Int,
val phoneMask: String,
// TODO: 13/07/2024, Danil Nikolaev: check wtf is this
val validationText: UiText,
) {
companion object {
val EMPTY = ValidationScreenState(
code = null,
codeError = null,
isSmsButtonVisible = false,
delayTime = 0,
phoneMask = "",
validationText = UiText.Simple("")
)
}
}
@@ -0,0 +1,23 @@
package com.meloda.app.fast.auth.validation.model
sealed class ValidationType(val value: String) {
data object Sms : ValidationType(TYPE_SMS)
data object App : ValidationType(TYPE_TWO_FA_APP)
data class Other(val type: String) : ValidationType(type)
companion object {
private const val TYPE_SMS = "sms"
private const val TYPE_TWO_FA_APP = "2fa_app"
fun parse(validationType: String): ValidationType {
return when (validationType) {
TYPE_SMS -> Sms
TYPE_TWO_FA_APP -> App
else -> Other(validationType)
}
}
}
}
@@ -0,0 +1,8 @@
package com.meloda.app.fast.auth.validation.model
sealed class ValidationValidationResult {
data object Empty : ValidationValidationResult()
data object Valid : ValidationValidationResult()
fun isValid() = this == Valid
}
@@ -0,0 +1,46 @@
package com.meloda.app.fast.auth.validation.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.validation.model.ValidationArguments
import com.meloda.app.fast.auth.validation.presentation.ValidationRoute
import com.meloda.app.fast.common.customNavType
import kotlinx.serialization.Serializable
import kotlin.reflect.typeOf
@Serializable
data class Validation(val arguments: ValidationArguments) {
companion object {
val typeMap = mapOf(typeOf<ValidationArguments>() to customNavType<ValidationArguments>())
fun from(savedStateHandle: SavedStateHandle) =
savedStateHandle.toRoute<Validation>(typeMap)
}
}
fun NavGraphBuilder.validationScreen(
onBack: () -> Unit,
onResult: (String) -> Unit
) {
composable<Validation>(typeMap = Validation.typeMap) {
ValidationRoute(
onBack = onBack,
onResult = onResult
)
}
}
fun NavController.navigateToValidation(arguments: ValidationArguments) {
this.navigate(Validation(arguments))
}
fun NavController.setValidationResult(code: String?) {
this.currentBackStackEntry
?.savedStateHandle
?.set("validation_code", code)
}
@@ -1,4 +1,4 @@
package com.meloda.app.fast.auth.twofa.presentation
package com.meloda.app.fast.auth.validation.presentation
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
@@ -27,6 +27,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -42,8 +43,9 @@ 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.validation.ValidationViewModel
import com.meloda.app.fast.auth.validation.ValidationViewModelImpl
import com.meloda.app.fast.auth.validation.model.ValidationScreenState
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.designsystem.MaterialDialog
import com.meloda.app.fast.designsystem.TextFieldErrorText
@@ -52,17 +54,48 @@ import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@Composable
fun TwoFaScreen(
fun ValidationRoute(
onBack: () -> Unit,
onCodeResult: (code: String) -> Unit,
viewModel: TwoFaViewModel = koinViewModel<TwoFaViewModelImpl>(),
onResult: (String) -> Unit,
viewModel: ValidationViewModel = koinViewModel<ValidationViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenLogin by viewModel.isNeedToOpenLogin.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToOpenLogin) {
if (isNeedToOpenLogin) {
viewModel.onNavigatedToLogin()
val code = screenState.code
if (code == null) {
onBack()
} else {
onResult(code)
}
}
}
ValidationScreen(
screenState = screenState,
onBack = onBack,
onCodeInputChanged = viewModel::onCodeInputChanged,
onTextFieldDoneAction = viewModel::onTextFieldDoneAction,
onRequestSmsButtonClicked = viewModel::onRequestSmsButtonClicked,
onDoneButtonClicked = viewModel::onDoneButtonClicked
)
}
@Composable
fun ValidationScreen(
screenState: ValidationScreenState = ValidationScreenState.EMPTY,
onBack: () -> Unit = {},
onCodeInputChanged: (String) -> Unit = {},
onTextFieldDoneAction: () -> Unit = {},
onRequestSmsButtonClicked: () -> Unit = {},
onDoneButtonClicked: () -> Unit = {}
) {
val focusManager = LocalFocusManager.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenLogin by viewModel.isNeedToOpenLogin.collectAsStateWithLifecycle()
var confirmedExit by rememberSaveable {
mutableStateOf(false)
}
@@ -96,20 +129,7 @@ fun TwoFaScreen(
)
}
LaunchedEffect(isNeedToOpenLogin) {
if (isNeedToOpenLogin) {
viewModel.onNavigatedToLogin()
val code = screenState.twoFaCode
if (code == null) {
onBack()
} else {
onCodeResult(code)
}
}
}
var code by remember { mutableStateOf(TextFieldValue(screenState.twoFaCode.orEmpty())) }
var code by remember { mutableStateOf(TextFieldValue(screenState.code.orEmpty())) }
val codeError = screenState.codeError
Scaffold { padding ->
@@ -140,7 +160,6 @@ fun TwoFaScreen(
Column(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Two-Factor\nAuthentication",
style = MaterialTheme.typography.displayMedium,
@@ -148,16 +167,18 @@ fun TwoFaScreen(
)
Spacer(modifier = Modifier.height(38.dp))
Text(
text = screenState.twoFaText.getString().orEmpty(),
text = screenState.validationText.getString().orEmpty(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(10.dp))
val delayRemainedTime = screenState.delayTime
AnimatedVisibility(visible = delayRemainedTime > 0) {
val isResendTextVisible by remember {
derivedStateOf { screenState.delayTime > 0 }
}
AnimatedVisibility(visible = isResendTextVisible) {
Text(
text = "Can resend after $delayRemainedTime seconds",
text = "Can resend after ${screenState.delayTime} seconds",
style = MaterialTheme.typography.bodySmall
)
}
@@ -169,7 +190,7 @@ fun TwoFaScreen(
if (newText.text.length > 6) return@TextField
code = newText
viewModel.onCodeInputChanged((newText.text))
onCodeInputChanged((newText.text))
},
label = { Text(text = "Code") },
placeholder = { Text(text = "Code") },
@@ -195,7 +216,7 @@ fun TwoFaScreen(
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
viewModel.onTextFieldDoneClicked()
onTextFieldDoneAction()
}
),
isError = codeError != null
@@ -211,13 +232,13 @@ fun TwoFaScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
val canResendSms = screenState.canResendSms
val canResendSms = screenState.isSmsButtonVisible
AnimatedVisibility(
visible = canResendSms,
) {
ExtendedFloatingActionButton(
onClick = viewModel::onRequestSmsButtonClicked,
onClick = onRequestSmsButtonClicked,
text = {
Text(
text = "Request SMS",
@@ -238,7 +259,7 @@ fun TwoFaScreen(
Spacer(modifier = Modifier.width(16.dp))
FloatingActionButton(
onClick = viewModel::onDoneButtonClicked,
onClick = onDoneButtonClicked,
containerColor = MaterialTheme.colorScheme.secondaryContainer,
) {
Icon(
@@ -0,0 +1,14 @@
package com.meloda.app.fast.auth.validation.validation
import com.meloda.app.fast.auth.validation.model.ValidationScreenState
import com.meloda.app.fast.auth.validation.model.ValidationValidationResult
class ValidationValidator {
fun validate(screenState: ValidationScreenState): ValidationValidationResult {
return when {
screenState.code.isNullOrEmpty() -> ValidationValidationResult.Empty
else -> ValidationValidationResult.Valid
}
}
}
@@ -5,7 +5,7 @@ import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.meloda.app.fast.chatmaterials.presentation.ChatMaterialsScreen
import com.meloda.app.fast.chatmaterials.presentation.ChatMaterialsRoute
import kotlinx.serialization.Serializable
@Serializable
@@ -19,13 +19,11 @@ data class ChatMaterials(
}
}
fun NavGraphBuilder.chatMaterialsRoute(
fun NavGraphBuilder.chatMaterialsScreen(
onBack: () -> Unit
) {
composable<ChatMaterials> {
ChatMaterialsScreen(
onBack = onBack
)
ChatMaterialsRoute(onBack = onBack)
}
}
@@ -66,6 +66,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.imageLoader
import com.meloda.app.fast.chatmaterials.ChatMaterialsViewModel
import com.meloda.app.fast.chatmaterials.ChatMaterialsViewModelImpl
import com.meloda.app.fast.chatmaterials.model.ChatMaterialsScreenState
import com.meloda.app.fast.designsystem.LocalTheme
import com.meloda.app.fast.designsystem.R
import dev.chrisbanes.haze.HazeState
@@ -75,6 +76,22 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import org.koin.androidx.compose.koinViewModel
@Composable
fun ChatMaterialsRoute(
onBack: () -> Unit,
viewModel: ChatMaterialsViewModel = koinViewModel<ChatMaterialsViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
ChatMaterialsScreen(
screenState = screenState,
onBack = onBack,
onTypeChanged = viewModel::onTypeChanged,
onRefreshDropdownItemClicked = viewModel::onRefresh,
onRefresh = viewModel::onRefresh
)
}
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(
ExperimentalMaterial3Api::class,
@@ -82,11 +99,14 @@ import org.koin.androidx.compose.koinViewModel
)
@Composable
fun ChatMaterialsScreen(
onBack: () -> Unit,
viewModel: ChatMaterialsViewModel = koinViewModel<ChatMaterialsViewModelImpl>()
screenState: ChatMaterialsScreenState,
onBack: () -> Unit = {},
onTypeChanged: (String) -> Unit = {},
onRefreshDropdownItemClicked: () -> Unit = {},
onRefresh: () -> Unit = {}
) {
val currentTheme = LocalTheme.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val attachments = screenState.materials
val imageLoader = LocalContext.current.imageLoader
@@ -106,7 +126,7 @@ fun ChatMaterialsScreen(
}
LaunchedEffect(checkedTypeIndex) {
viewModel.onTypeChanged(
onTypeChanged(
when (checkedTypeIndex) {
0 -> "photo"
1 -> "video"
@@ -213,7 +233,7 @@ fun ChatMaterialsScreen(
) {
DropdownMenuItem(
onClick = {
viewModel.onRefresh()
onRefreshDropdownItemClicked()
dropDownMenuExpanded = false
},
text = {
@@ -342,7 +362,7 @@ fun ChatMaterialsScreen(
if (pullToRefreshState.isRefreshing) {
LaunchedEffect(true) {
viewModel.onRefresh()
onRefresh()
}
}
@@ -44,7 +44,7 @@ interface ConversationsViewModel {
val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean>
fun onMetPaginationCondition()
fun onPaginationConditionsMet()
fun onDeleteDialogDismissed()
@@ -52,7 +52,7 @@ interface ConversationsViewModel {
fun onRefresh()
fun onConversationItemClick(conversationId: Int)
fun onConversationItemClick()
fun onConversationItemLongClick(conversation: UiConversation)
fun onPinDialogDismissed()
@@ -76,7 +76,7 @@ class ConversationsViewModelImpl(
override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false)
override fun onMetPaginationCondition() {
override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.conversations.size }
loadConversations()
}
@@ -113,7 +113,7 @@ class ConversationsViewModelImpl(
loadConversations(offset = 0)
}
override fun onConversationItemClick(conversationId: Int) {
override fun onConversationItemClick() {
screenState.setValue { old ->
old.copy(
conversations = old.conversations.map { item ->
@@ -225,25 +225,14 @@ class ConversationsViewModelImpl(
conversationsUseCase.getConversations(count = LOAD_COUNT, 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
}
if (error is State.Error.ApiError) {
when (error.errorCode) {
VkErrorCodes.UserAuthorizationFailed -> {
baseError.setValue { BaseError.SessionExpired }
}
}
State.Error.ConnectionError -> TODO()
State.Error.InternalError -> TODO()
is State.Error.OAuthError -> TODO()
State.Error.Unknown -> TODO()
else -> Unit
}
}
},
success = { response ->
@@ -31,7 +31,6 @@ 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
@@ -298,7 +297,7 @@ fun DotsCollision() {
@Preview(showBackground = true)
@Composable
fun DotsPreview() = AppTheme {
fun DotsPreview() {
Column(
modifier = Modifier
.padding(4.dp)
@@ -6,27 +6,25 @@ 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.conversations.presentation.ConversationsRoute
import com.meloda.app.fast.model.BaseError
import kotlinx.serialization.Serializable
@Serializable
object Conversations
fun NavGraphBuilder.conversationsRoute(
fun NavGraphBuilder.conversationsScreen(
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (id: Int) -> Unit,
onListScrollingUp: (Boolean) -> Unit,
onConversationItemClicked: (id: Int) -> Unit,
navController: NavController,
) {
composable<Conversations> {
val viewModel: ConversationsViewModel =
it.sharedViewModel<ConversationsViewModelImpl>(navController = navController)
ConversationsScreen(
ConversationsRoute(
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onListScrollingUp = onListScrollingUp,
onConversationItemClicked = onConversationItemClicked,
viewModel = viewModel
)
}
@@ -72,6 +72,7 @@ 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.ConversationOption
import com.meloda.app.fast.conversations.model.ConversationsScreenState
import com.meloda.app.fast.conversations.model.UiConversation
import com.meloda.app.fast.designsystem.LocalBottomPadding
@@ -89,34 +90,70 @@ import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@Composable
fun ConversationsRoute(
onError: (BaseError) -> Unit,
onConversationItemClicked: (conversationId: Int) -> Unit,
viewModel: ConversationsViewModel = koinViewModel<ConversationsViewModelImpl>()
) {
val context = LocalContext.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val imagesToPreload by viewModel.imagesToPreload.collectAsStateWithLifecycle()
LaunchedEffect(imagesToPreload) {
imagesToPreload.forEach { url ->
context.imageLoader.enqueue(
ImageRequest.Builder(context)
.data(url)
.build()
)
}
}
ConversationsScreen(
screenState = screenState,
baseError = baseError,
canPaginate = canPaginate,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onConversationItemClicked = { id ->
onConversationItemClicked(id)
viewModel.onConversationItemClick()
},
onConversationItemLongClicked = viewModel::onConversationItemLongClick,
onOptionClicked = viewModel::onOptionClicked,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefreshDropdownItemClicked = viewModel::onRefresh,
onRefresh = viewModel::onRefresh
)
HandleDialogs(
screenState = screenState,
viewModel = viewModel
)
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class,
)
@Composable
fun ConversationsScreen(
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (conversationId: Int) -> Unit,
onListScrollingUp: (Boolean) -> Unit,
viewModel: ConversationsViewModel = koinViewModel<ConversationsViewModelImpl>()
screenState: ConversationsScreenState = ConversationsScreenState.EMPTY,
baseError: BaseError? = null,
canPaginate: Boolean = false,
onSessionExpiredLogOutButtonClicked: () -> Unit,
onConversationItemClicked: (conversationId: Int) -> Unit = {},
onConversationItemLongClicked: (conversation: UiConversation) -> Unit = {},
onOptionClicked: (UiConversation, ConversationOption) -> Unit = { _, _ -> },
onPaginationConditionsMet: () -> Unit = {},
onRefreshDropdownItemClicked: () -> Unit = {},
onRefresh: () -> Unit = {}
) {
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 {
@@ -129,10 +166,6 @@ fun ConversationsScreen(
val isListScrollingUp = listState.isScrollingUp()
LaunchedEffect(isListScrollingUp) {
onListScrollingUp(isListScrollingUp)
}
val paginationConditionMet by remember {
derivedStateOf {
canPaginate &&
@@ -143,7 +176,7 @@ fun ConversationsScreen(
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
viewModel.onMetPaginationCondition()
onPaginationConditionsMet()
}
}
@@ -213,7 +246,7 @@ fun ConversationsScreen(
) {
DropdownMenuItem(
onClick = {
viewModel.onRefresh()
onRefreshDropdownItemClicked()
dropDownMenuExpanded = false
},
text = {
@@ -301,7 +334,7 @@ fun ConversationsScreen(
ErrorView(
text = "Session expired",
buttonText = "Log out",
onButtonClick = { onError(BaseError.SessionExpired) }
onButtonClick = onSessionExpiredLogOutButtonClicked
)
}
@@ -320,10 +353,10 @@ fun ConversationsScreen(
) {
ConversationsListComposable(
onConversationsClick = { id ->
onNavigateToMessagesHistory(id)
viewModel.onConversationItemClick(id)
onConversationItemClicked(id)
},
onConversationsLongClick = viewModel::onConversationItemLongClick,
onConversationsLongClick = onConversationItemLongClicked,
screenState = screenState,
state = listState,
maxLines = maxLines,
@@ -335,13 +368,13 @@ fun ConversationsScreen(
} else {
Modifier
}.fillMaxSize(),
onOptionClicked = viewModel::onOptionClicked,
onOptionClicked = onOptionClicked,
padding = padding
)
if (pullToRefreshState.isRefreshing) {
LaunchedEffect(true) {
viewModel.onRefresh()
onRefresh()
}
}
@@ -362,11 +395,6 @@ fun ConversationsScreen(
}
}
}
HandleDialogs(
screenState = screenState,
viewModel = viewModel
)
}
}
@@ -1,7 +1,6 @@
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
@@ -9,30 +8,24 @@ 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
// TODO: 13/07/2024, Danil Nikolaev: separate two lists and their pagination
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 onPaginationConditionsMet()
fun onRefresh()
@@ -46,11 +39,6 @@ class FriendsViewModelImpl(
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)
@@ -64,7 +52,7 @@ class FriendsViewModelImpl(
loadFriends()
}
override fun onMetPaginationCondition() {
override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.friends.size }
loadFriends()
}
@@ -130,19 +118,19 @@ class FriendsViewModelImpl(
if (offset == 0) {
friends.emit(response)
screenState.setValue {
newState.copy(friends = loadedFriends)
newState.copy(
friends = loadedFriends,
onlineFriends = loadedOnlineFriends
)
}
uiOnlineFriends.setValue { loadedOnlineFriends }
} else {
friends.emit(friends.value.plus(response))
screenState.setValue {
newState.copy(
friends = newState.friends.plus(loadedFriends)
friends = newState.friends.plus(loadedFriends),
onlineFriends = newState.onlineFriends.plus(loadedOnlineFriends)
)
}
uiOnlineFriends.setValue { old ->
old.plus(loadedFriends)
}
}
}
)
@@ -164,17 +152,19 @@ class FriendsViewModelImpl(
conversation.asPresentation(useContactNames)
}
val onlineUiFriends = uiOnlineFriends.value.mapNotNull { friend ->
val onlineUiFriends = screenState.value.onlineFriends.mapNotNull { friend ->
uiFriends.find { it.userId == friend.userId }
}
screenState.setValue { old ->
old.copy(friends = uiFriends)
old.copy(
friends = uiFriends,
onlineFriends = onlineUiFriends
)
}
uiOnlineFriends.setValue { onlineUiFriends }
}
companion object {
const val LOAD_COUNT = 30
const val LOAD_COUNT = 60
}
}
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Immutable
data class FriendsScreenState(
val isLoading: Boolean,
val friends: List<UiFriend>,
val onlineFriends: List<UiFriend>,
val isPaginating: Boolean,
val isPaginationExhausted: Boolean
) {
@@ -14,6 +15,7 @@ data class FriendsScreenState(
val EMPTY: FriendsScreenState = FriendsScreenState(
isLoading = true,
friends = emptyList(),
onlineFriends = emptyList(),
isPaginating = false,
isPaginationExhausted = false
)
@@ -6,14 +6,14 @@ 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.friends.presentation.FriendsRoute
import com.meloda.app.fast.model.BaseError
import kotlinx.serialization.Serializable
@Serializable
object Friends
fun NavGraphBuilder.friendsRoute(
fun NavGraphBuilder.friendsScreen(
onError: (BaseError) -> Unit,
navController: NavController
) {
@@ -21,7 +21,7 @@ fun NavGraphBuilder.friendsRoute(
val viewModel: FriendsViewModel =
it.sharedViewModel<FriendsViewModelImpl>(navController = navController)
FriendsScreen(
FriendsRoute(
onError = onError,
viewModel = viewModel
)
@@ -58,6 +58,7 @@ 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.friends.model.FriendsScreenState
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.ui.ErrorView
import dev.chrisbanes.haze.haze
@@ -67,30 +68,53 @@ 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(
fun FriendsRoute(
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 baseError by viewModel.baseError.collectAsStateWithLifecycle()
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 imagesToPreload by viewModel.imagesToPreload.collectAsStateWithLifecycle()
LaunchedEffect(imagesToPreload) {
imagesToPreload.forEach { url ->
context.imageLoader.enqueue(
ImageRequest.Builder(context)
.data(url)
.build()
)
}
}
FriendsScreen(
screenState = screenState,
baseError = baseError,
canPaginate = canPaginate,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefresh = viewModel::onRefresh
)
}
// TODO: 13/07/2024, Danil Nikolaev: support for online
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class
)
@Composable
fun FriendsScreen(
screenState: FriendsScreenState = FriendsScreenState.EMPTY,
baseError: BaseError? = null,
canPaginate: Boolean = false,
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onPaginationConditionsMet: () -> Unit = {},
onRefresh: () -> Unit = {}
) {
val currentTheme = LocalTheme.current
val maxLines by remember {
@@ -111,7 +135,7 @@ fun FriendsScreen(
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
viewModel.onMetPaginationCondition()
onPaginationConditionsMet()
}
}
@@ -223,11 +247,11 @@ fun FriendsScreen(
ErrorView(
text = "Session expired",
buttonText = "Log out",
onButtonClick = { onError(BaseError.SessionExpired) }
onButtonClick = onSessionExpiredLogOutButtonClicked
)
}
screenState.isLoading && friends.isEmpty() -> FullScreenLoader()
screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader()
else -> {
val pullToRefreshState = rememberPullToRefreshState()
@@ -259,8 +283,7 @@ fun FriendsScreen(
.padding(bottom = padding.calculateBottomPadding())
.nestedScroll(pullToRefreshState.nestedScrollConnection)
) {
val friendsToDisplay = if (index == 0) friends
else onlineFriends
val friendsToDisplay = screenState.friends
FriendsList(
modifier = if (currentTheme.usingBlur) {
@@ -289,7 +312,7 @@ fun FriendsScreen(
if (pullToRefreshState.isRefreshing) {
LaunchedEffect(true) {
viewModel.onRefresh()
onRefresh()
}
}
@@ -1,9 +1,13 @@
package com.meloda.app.fast.languagepicker
import android.content.res.Resources
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.ViewModel
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.common.extensions.setValue
import com.meloda.app.fast.common.parseString
import com.meloda.app.fast.designsystem.R
import com.meloda.app.fast.languagepicker.model.LanguagePickerScreenState
import com.meloda.app.fast.languagepicker.model.SelectableLanguage
import kotlinx.coroutines.flow.MutableStateFlow
@@ -12,25 +16,54 @@ import kotlinx.coroutines.flow.StateFlow
interface LanguagePickerViewModel {
val screenState: StateFlow<LanguagePickerScreenState>
fun setLanguages(languages: List<SelectableLanguage>)
fun onLanguagePicked(newLanguage: SelectableLanguage)
fun onApplyButtonClicked()
fun updateCurrentLocale(locale: String)
}
class LanguagePickerViewModelImpl : LanguagePickerViewModel, ViewModel() {
class LanguagePickerViewModelImpl(
private val resources: Resources
) : LanguagePickerViewModel, ViewModel() {
override val screenState = MutableStateFlow(
LanguagePickerScreenState(
languages = emptyList(),
currentLanguage = AppCompatDelegate.getApplicationLocales().toLanguageTags()
)
)
override val screenState = MutableStateFlow(LanguagePickerScreenState.EMPTY)
init {
val languages = listOf(
Triple(
"",
UiText.Resource(R.string.language_key_system),
UiText.Resource(R.string.language_system)
),
Triple(
"en-US",
UiText.Resource(R.string.language_key_english),
UiText.Resource(R.string.language_english),
),
Triple(
"ru-RU",
UiText.Resource(R.string.language_key_russian),
UiText.Resource(R.string.language_russian)
),
Triple(
"uk-UA",
UiText.Resource(R.string.language_key_ukrainian),
UiText.Resource(R.string.language_ukrainian)
)
).map { (key, language, local) ->
Triple(
key,
language.parseString(resources).orEmpty(),
local.parseString(resources).orEmpty()
)
}.map { (key, language, local) ->
SelectableLanguage(
local = local,
language = language,
key = key,
isSelected = key == AppCompatDelegate.getApplicationLocales().toLanguageTags()
)
}
override fun setLanguages(languages: List<SelectableLanguage>) {
screenState.setValue { old -> old.copy(languages = languages) }
}
@@ -6,4 +6,12 @@ import androidx.compose.runtime.Immutable
data class LanguagePickerScreenState(
val languages: List<SelectableLanguage>,
val currentLanguage: String?,
)
) {
companion object {
val EMPTY: LanguagePickerScreenState = LanguagePickerScreenState(
languages = emptyList(),
currentLanguage = null
)
}
}
@@ -0,0 +1,22 @@
package com.meloda.app.fast.languagepicker.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.meloda.app.fast.languagepicker.presentation.LanguagePickerRoute
import kotlinx.serialization.Serializable
@Serializable
object LanguagePicker
fun NavGraphBuilder.languagePickerScreen(
onBack: () -> Unit,
) {
composable<LanguagePicker> {
LanguagePickerRoute(onBack = onBack)
}
}
fun NavController.navigateToLanguagePicker() {
this.navigate(LanguagePicker)
}
@@ -1,78 +0,0 @@
package com.meloda.app.fast.languagepicker.navigation
import android.content.res.Resources
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.common.parseString
import com.meloda.app.fast.designsystem.R
import com.meloda.app.fast.languagepicker.LanguagePickerViewModel
import com.meloda.app.fast.languagepicker.LanguagePickerViewModelImpl
import com.meloda.app.fast.languagepicker.model.SelectableLanguage
import com.meloda.app.fast.languagepicker.presentation.LanguagePickerScreen
import kotlinx.serialization.Serializable
import org.koin.androidx.compose.koinViewModel
@Serializable
object LanguagePicker
private fun getLanguages(resources: Resources): List<SelectableLanguage> {
return listOf(
Triple(
"",
UiText.Resource(R.string.language_key_system),
UiText.Resource(R.string.language_system)
),
Triple(
"en-US",
UiText.Resource(R.string.language_key_english),
UiText.Resource(R.string.language_english),
),
Triple(
"ru-RU",
UiText.Resource(R.string.language_key_russian),
UiText.Resource(R.string.language_russian)
),
Triple(
"uk-UA",
UiText.Resource(R.string.language_key_ukrainian),
UiText.Resource(R.string.language_ukrainian)
)
).map { (key, language, local) ->
Triple(
key,
language.parseString(resources).orEmpty(),
local.parseString(resources).orEmpty()
)
}.map { (key, language, local) ->
SelectableLanguage(
local = local,
language = language,
key = key,
isSelected = key == AppCompatDelegate.getApplicationLocales().toLanguageTags()
)
}
}
fun NavGraphBuilder.languagePickerRoute(
onBack: () -> Unit,
) {
composable<LanguagePicker> {
val languages = getLanguages(LocalContext.current.resources)
val viewModel: LanguagePickerViewModel = koinViewModel<LanguagePickerViewModelImpl>()
viewModel.setLanguages(languages)
LanguagePickerScreen(
onBack = onBack,
viewModel = viewModel
)
}
}
fun NavController.navigateToLanguagePicker() {
this.navigate(LanguagePicker)
}
@@ -56,31 +56,46 @@ import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meloda.app.fast.languagepicker.LanguagePickerViewModel
import com.meloda.app.fast.languagepicker.LanguagePickerViewModelImpl
import com.meloda.app.fast.languagepicker.model.LanguagePickerScreenState
import com.meloda.app.fast.languagepicker.model.SelectableLanguage
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LanguagePickerScreen(
fun LanguagePickerRoute(
onBack: () -> Unit,
viewModel: LanguagePickerViewModel = koinViewModel<LanguagePickerViewModelImpl>()
) {
val context = LocalContext.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val languages = screenState.languages
LifecycleResumeEffect(true) {
viewModel.updateCurrentLocale(AppCompatDelegate.getApplicationLocales().toLanguageTags())
onPauseOrDispose {}
}
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
LanguagePickerScreen(
screenState = screenState,
onBack = onBack,
onLanguagePicked = viewModel::onLanguagePicked,
onApplyButtonClicked = viewModel::onApplyButtonClicked
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LanguagePickerScreen(
screenState: LanguagePickerScreenState = LanguagePickerScreenState.EMPTY,
onBack: () -> Unit = {},
onLanguagePicked: (SelectableLanguage) -> Unit = {},
onApplyButtonClicked: () -> Unit = {}
) {
val context = LocalContext.current
val isButtonEnabled by remember(screenState) {
derivedStateOf {
screenState.currentLanguage != null &&
languages.isNotEmpty() &&
languages.find(SelectableLanguage::isSelected)?.key != screenState.currentLanguage
screenState.languages.isNotEmpty() &&
screenState.languages.find(SelectableLanguage::isSelected)?.key != screenState.currentLanguage
}
}
@@ -165,10 +180,13 @@ fun LanguagePickerScreen(
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
items(screenState.languages.toList()) { item ->
items(
items = screenState.languages.toList(),
key = SelectableLanguage::key
) { item ->
LanguageItem(
item = item,
onClick = viewModel::onLanguagePicked
onClick = onLanguagePicked
)
}
@@ -183,7 +201,7 @@ fun LanguagePickerScreen(
}
Button(
onClick = viewModel::onApplyButtonClicked,
onClick = onApplyButtonClicked,
enabled = isButtonEnabled,
modifier = Modifier
.fillMaxWidth()
@@ -4,6 +4,7 @@ import android.content.SharedPreferences
import android.content.res.Resources
import android.util.Log
import androidx.core.content.edit
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.conena.nanokt.collections.indexOfFirstOrNull
@@ -20,11 +21,10 @@ import com.meloda.app.fast.data.api.messages.MessagesUseCase
import com.meloda.app.fast.data.processState
import com.meloda.app.fast.datastore.SettingsKeys
import com.meloda.app.fast.messageshistory.model.ActionMode
import com.meloda.app.fast.messageshistory.model.MessagesHistoryArguments
import com.meloda.app.fast.messageshistory.model.MessagesHistoryScreenState
import com.meloda.app.fast.messageshistory.navigation.MessagesHistory
import com.meloda.app.fast.messageshistory.util.asPresentation
import com.meloda.app.fast.messageshistory.util.extractAvatar
import com.meloda.app.fast.messageshistory.util.extractShowName
import com.meloda.app.fast.messageshistory.util.extractTitle
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.model.LongPollEvent
@@ -48,17 +48,14 @@ interface MessagesHistoryViewModel {
val canPaginate: StateFlow<Boolean>
fun onRefresh()
fun onAttachmentButtonClicked()
fun onInputChanged(newText: String)
fun onMessageInputChanged(newText: String)
fun onEmojiButtonClicked()
fun onActionButtonClicked()
fun onTopAppBarMenuClicked(id: Int)
fun setArguments(arguments: MessagesHistoryArguments)
fun onMetPaginationCondition()
fun onShowDatesClicked(showDates: Boolean)
fun onShowNamesClicked(showNames: Boolean)
fun onEnableAnimationsClicked(enableAnimations: Boolean)
fun onPaginationConditionsMet()
fun onToggleAnimationsDropdownItemClicked(enableAnimations: Boolean)
}
class MessagesHistoryViewModelImpl(
@@ -67,6 +64,7 @@ class MessagesHistoryViewModelImpl(
private val preferences: SharedPreferences,
private val resources: Resources,
updatesParser: LongPollUpdatesParser,
savedStateHandle: SavedStateHandle
) : MessagesHistoryViewModel, ViewModel() {
override val screenState = MutableStateFlow(MessagesHistoryScreenState.EMPTY)
@@ -85,17 +83,26 @@ class MessagesHistoryViewModelImpl(
private val sendingMessages: MutableList<VkMessage> = mutableListOf()
init {
val arguments = MessagesHistory.from(savedStateHandle).arguments
screenState.setValue { old -> old.copy(conversationId = arguments.conversationId) }
loadMessagesHistory()
updatesParser.onNewMessage(::handleNewMessage)
updatesParser.onMessageEdited(::handleEditedMessage)
updatesParser.onMessageIncomingRead(::handleReadIncomingEvent)
updatesParser.onMessageOutgoingRead(::handleReadOutgoingEvent)
}
override fun onRefresh() {
loadMessagesHistory(offset = 0)
}
override fun onAttachmentButtonClicked() {
}
override fun onInputChanged(newText: String) {
override fun onMessageInputChanged(newText: String) {
screenState.setValue { old ->
old.copy(
message = newText,
@@ -131,58 +138,12 @@ class MessagesHistoryViewModelImpl(
}
}
override fun onTopAppBarMenuClicked(id: Int) {
when (id) {
0 -> loadMessagesHistory(0)
else -> Unit
}
}
override fun setArguments(arguments: MessagesHistoryArguments) {
if (arguments.conversationId == screenState.value.conversationId) return
screenState.setValue { old -> old.copy(conversationId = arguments.conversationId) }
loadMessagesHistory()
}
override fun onMetPaginationCondition() {
override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.messages.size }
loadMessagesHistory()
}
override fun onShowDatesClicked(showDates: Boolean) {
preferences.edit { putBoolean(SettingsKeys.KEY_SHOW_DATE_UNDER_BUBBLES, showDates) }
screenState.setValue { old ->
old.copy(
messages = old.messages.map { message ->
message.copy(showDate = showDates)
}
)
}
}
override fun onShowNamesClicked(showNames: Boolean) {
preferences.edit { putBoolean(SettingsKeys.KEY_SHOW_NAME_IN_BUBBLES, showNames) }
screenState.setValue { old ->
old.copy(
messages = old.messages.map { message ->
message.copy(
showName = if (showNames) {
val index = messages.value.indexOfFirst { it.id == message.id }
val domainMessage = messages.value[index]
val prevMessage = messages.value.getOrNull(index + 1)
domainMessage.extractShowName(prevMessage)
} else false
)
}
)
}
}
override fun onEnableAnimationsClicked(enableAnimations: Boolean) {
override fun onToggleAnimationsDropdownItemClicked(enableAnimations: Boolean) {
preferences.edit {
putBoolean(
SettingsKeys.KEY_ENABLE_ANIMATIONS_IN_MESSAGES,
@@ -285,15 +246,10 @@ class MessagesHistoryViewModelImpl(
messagesUseCase.storeMessages(messages)
conversationsUseCase.storeConversations(conversations)
val showDate =
preferences.getBoolean(SettingsKeys.KEY_SHOW_DATE_UNDER_BUBBLES, false)
val showName =
preferences.getBoolean(SettingsKeys.KEY_SHOW_NAME_IN_BUBBLES, false)
val loadedMessages = fullMessages.mapIndexed { index, message ->
message.asPresentation(
showDate = showDate,
showName = showName,
showDate = false,
showName = false,
prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1),
)
@@ -0,0 +1,43 @@
package com.meloda.app.fast.messageshistory.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.common.customNavType
import com.meloda.app.fast.messageshistory.model.MessagesHistoryArguments
import com.meloda.app.fast.messageshistory.presentation.MessagesHistoryRoute
import com.meloda.app.fast.model.BaseError
import kotlinx.serialization.Serializable
import kotlin.reflect.typeOf
@Serializable
data class MessagesHistory(val arguments: MessagesHistoryArguments) {
companion object {
val typeMap =
mapOf(typeOf<MessagesHistoryArguments>() to customNavType<MessagesHistoryArguments>())
fun from(savedStateHandle: SavedStateHandle) =
savedStateHandle.toRoute<MessagesHistory>(typeMap)
}
}
fun NavGraphBuilder.messagesHistoryScreen(
onError: (BaseError) -> Unit,
onBack: () -> Unit,
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit
) {
composable<MessagesHistory>(typeMap = MessagesHistory.typeMap) {
MessagesHistoryRoute(
onError = onError,
onBack = onBack,
onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked,
)
}
}
fun NavController.navigateToMessagesHistory(conversationId: Int) {
this.navigate(MessagesHistory(MessagesHistoryArguments(conversationId)))
}
@@ -1,64 +0,0 @@
package com.meloda.app.fast.messageshistory.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.messageshistory.MessagesHistoryViewModel
import com.meloda.app.fast.messageshistory.MessagesHistoryViewModelImpl
import com.meloda.app.fast.messageshistory.model.MessagesHistoryArguments
import com.meloda.app.fast.messageshistory.presentation.MessagesHistoryScreen
import com.meloda.app.fast.model.BaseError
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.koin.androidx.compose.koinViewModel
import kotlin.reflect.typeOf
@Serializable
data class MessagesHistory(val arguments: MessagesHistoryArguments)
val MessagesHistoryNavType = object : NavType<MessagesHistoryArguments>(isNullableAllowed = false) {
override fun get(bundle: Bundle, key: String): MessagesHistoryArguments? =
BundleCompat.getParcelable(bundle, key, MessagesHistoryArguments::class.java)
override fun parseValue(value: String): MessagesHistoryArguments = Json.decodeFromString(value)
override fun serializeAsValue(value: MessagesHistoryArguments): String =
Json.encodeToString(value)
override fun put(bundle: Bundle, key: String, value: MessagesHistoryArguments) {
bundle.putParcelable(key, value)
}
override val name: String = "MessagesHistoryArguments"
}
fun NavGraphBuilder.messagesHistoryRoute(
onError: (BaseError) -> Unit,
onBack: () -> Unit,
onNavigateToChatAttachments: (peerId: Int, conversationMessageId: Int) -> Unit
) {
composable<MessagesHistory>(
typeMap = mapOf(typeOf<MessagesHistoryArguments>() to MessagesHistoryNavType)
) { backStackEntry ->
val arguments: MessagesHistory = backStackEntry.toRoute()
val viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
viewModel.setArguments(arguments.arguments)
MessagesHistoryScreen(
onError = onError,
onBack = onBack,
onNavigateToChatMaterials = onNavigateToChatAttachments,
viewModel = viewModel
)
}
}
fun NavController.navigateToMessagesHistory(conversationId: Int) {
this.navigate(MessagesHistory(MessagesHistoryArguments(conversationId)))
}
@@ -71,6 +71,7 @@ import com.meloda.app.fast.designsystem.LocalTheme
import com.meloda.app.fast.messageshistory.MessagesHistoryViewModel
import com.meloda.app.fast.messageshistory.MessagesHistoryViewModelImpl
import com.meloda.app.fast.messageshistory.model.ActionMode
import com.meloda.app.fast.messageshistory.model.MessagesHistoryScreenState
import com.meloda.app.fast.model.BaseError
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeChild
@@ -81,6 +82,32 @@ import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import com.meloda.app.fast.designsystem.R as UiR
@Composable
fun MessagesHistoryRoute(
onError: (BaseError) -> Unit,
onBack: () -> Unit,
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit,
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
MessagesHistoryScreen(
screenState = screenState,
baseError = baseError,
canPaginate = canPaginate,
onBack = onBack,
onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked,
onRefreshDropdownItemClicked = viewModel::onRefresh,
onToggleAnimationsDropdownItemClicked = viewModel::onToggleAnimationsDropdownItemClicked,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onMessageInputChanged = viewModel::onMessageInputChanged,
onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked,
onActionButtonClicked = viewModel::onActionButtonClicked
)
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class,
@@ -88,21 +115,23 @@ import com.meloda.app.fast.designsystem.R as UiR
)
@Composable
fun MessagesHistoryScreen(
onError: (BaseError) -> Unit,
onBack: () -> Unit,
onNavigateToChatMaterials: (peerId: Int, conversationMessageId: Int) -> Unit,
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
screenState: MessagesHistoryScreenState = MessagesHistoryScreenState.EMPTY,
baseError: BaseError? = null,
canPaginate: Boolean = false,
onBack: () -> Unit = {},
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit = { _, _ -> },
onRefreshDropdownItemClicked: () -> Unit = {},
onToggleAnimationsDropdownItemClicked: (Boolean) -> Unit = {},
onPaginationConditionsMet: () -> Unit = {},
onMessageInputChanged: (String) -> Unit = {},
onAttachmentButtonClicked: () -> Unit = {},
onActionButtonClicked: () -> Unit = {}
) {
val view = LocalView.current
val preferences: SharedPreferences = koinInject()
val currentTheme = LocalTheme.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val messages = screenState.messages
val listState = rememberLazyListState()
val paginationConditionMet by remember {
@@ -115,7 +144,7 @@ fun MessagesHistoryScreen(
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
viewModel.onMetPaginationCondition()
onPaginationConditionsMet()
}
}
@@ -125,24 +154,6 @@ fun MessagesHistoryScreen(
val hazeSate = remember { HazeState() }
var datesShown by remember {
mutableStateOf(
preferences.getBoolean(
SettingsKeys.KEY_SHOW_DATE_UNDER_BUBBLES,
false
)
)
}
var namesShown by remember {
mutableStateOf(
preferences.getBoolean(
SettingsKeys.KEY_SHOW_NAME_IN_BUBBLES,
false
)
)
}
var animationsEnabled by remember {
mutableStateOf(
preferences.getBoolean(
@@ -217,7 +228,8 @@ fun MessagesHistoryScreen(
dropDownMenuExpanded = false
// TODO: 11/07/2024, Danil Nikolaev: to VM
onNavigateToChatMaterials(
onChatMaterialsDropdownItemClicked(
screenState.conversationId,
screenState.messages.first().conversationMessageId
)
@@ -228,7 +240,7 @@ fun MessagesHistoryScreen(
)
DropdownMenuItem(
onClick = {
viewModel.onTopAppBarMenuClicked(0)
onRefreshDropdownItemClicked()
dropDownMenuExpanded = false
},
text = {
@@ -248,27 +260,6 @@ fun MessagesHistoryScreen(
)
) {
HorizontalDivider()
DropdownMenuItem(
text = {
Text(text = if (datesShown) "Hide dates" else "Show dates")
},
onClick = {
dropDownMenuExpanded = false
datesShown = !datesShown
viewModel.onShowDatesClicked(datesShown)
}
)
DropdownMenuItem(
text = {
Text(text = if (namesShown) "Hide names" else "Show names")
},
onClick = {
dropDownMenuExpanded = false
namesShown = !namesShown
viewModel.onShowNamesClicked(namesShown)
}
)
DropdownMenuItem(
text = {
@@ -277,14 +268,14 @@ fun MessagesHistoryScreen(
onClick = {
dropDownMenuExpanded = false
animationsEnabled = !animationsEnabled
viewModel.onEnableAnimationsClicked(animationsEnabled)
onToggleAnimationsDropdownItemClicked(animationsEnabled)
}
)
}
}
}
)
if (screenState.isLoading && messages.isNotEmpty()) {
if (screenState.isLoading && screenState.messages.isNotEmpty()) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
@@ -300,7 +291,7 @@ fun MessagesHistoryScreen(
MessagesList(
hazeState = hazeSate,
listState = listState,
immutableMessages = ImmutableList.copyOf(messages),
immutableMessages = ImmutableList.copyOf(screenState.messages),
isPaginating = screenState.isPaginating,
enableAnimations = animationsEnabled
)
@@ -372,7 +363,7 @@ fun MessagesHistoryScreen(
TextField(
modifier = Modifier.weight(1f),
value = screenState.message,
onValueChange = viewModel::onInputChanged,
onValueChange = onMessageInputChanged,
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
@@ -382,7 +373,7 @@ fun MessagesHistoryScreen(
placeholder = { Text(text = stringResource(id = UiR.string.message_input_hint)) }
)
IconButton(onClick = viewModel::onAttachmentButtonClicked) {
IconButton(onClick = onAttachmentButtonClicked) {
Icon(
painter = painterResource(id = UiR.drawable.round_attach_file_24),
contentDescription = "Add attachment button",
@@ -414,7 +405,7 @@ fun MessagesHistoryScreen(
}
}
} else {
viewModel.onActionButtonClicked()
onActionButtonClicked()
}
},
modifier = Modifier.rotate(rotation.value)
@@ -445,7 +436,7 @@ fun MessagesHistoryScreen(
}
}
if (screenState.isLoading && messages.isEmpty()) {
if (screenState.isLoading && screenState.messages.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
@@ -5,14 +5,18 @@ 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.data.State
import com.meloda.app.fast.data.api.users.UsersUseCase
import com.meloda.app.fast.data.processState
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.network.VkErrorCodes
import com.meloda.app.fast.profile.model.ProfileScreenState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
interface ProfileViewModel {
val screenState: StateFlow<ProfileScreenState>
val baseError: StateFlow<BaseError?>
}
class ProfileViewModelImpl(
@@ -20,6 +24,7 @@ class ProfileViewModelImpl(
) : ViewModel(), ProfileViewModel {
override val screenState = MutableStateFlow(ProfileScreenState.EMPTY)
override val baseError = MutableStateFlow<BaseError?>(null)
init {
getLocalAccountInfo()
@@ -30,6 +35,16 @@ class ProfileViewModelImpl(
.listenValue { state ->
state.processState(
error = { error ->
if (error is State.Error.ApiError) {
when (error.errorCode) {
VkErrorCodes.UserAuthorizationFailed -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> Unit
}
}
screenState.setValue { old ->
old.copy(
avatarUrl = null,
@@ -7,24 +7,24 @@ import com.meloda.app.fast.common.extensions.navigation.sharedViewModel
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.profile.ProfileViewModel
import com.meloda.app.fast.profile.ProfileViewModelImpl
import com.meloda.app.fast.profile.presentation.ProfileScreen
import com.meloda.app.fast.profile.presentation.ProfileRoute
import kotlinx.serialization.Serializable
@Serializable
object Profile
fun NavGraphBuilder.profileRoute(
fun NavGraphBuilder.profileScreen(
onError: (BaseError) -> Unit,
onNavigateToSettings: () -> Unit,
onSettingsButtonClicked: () -> Unit,
navController: NavController
) {
composable<Profile> {
val viewModel: ProfileViewModel =
it.sharedViewModel<ProfileViewModelImpl>(navController = navController)
ProfileScreen(
ProfileRoute(
onError = onError,
onNavigateToSettings = onNavigateToSettings,
onSettingsButtonClicked = onSettingsButtonClicked,
viewModel = viewModel
)
}
@@ -1,6 +1,5 @@
package com.meloda.app.fast.profile.presentation
import android.util.Log
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -38,27 +37,42 @@ import coil.compose.AsyncImage
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.profile.ProfileViewModel
import com.meloda.app.fast.profile.ProfileViewModelImpl
import com.meloda.app.fast.profile.model.ProfileScreenState
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen(
fun ProfileRoute(
onError: (BaseError) -> Unit,
onNavigateToSettings: () -> Unit,
onSettingsButtonClicked: () -> Unit,
viewModel: ProfileViewModel = koinViewModel<ProfileViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
Log.d("ProfileScreen", "isLoading: ${screenState.isLoading}")
ProfileScreen(
screenState = screenState,
baseError = baseError,
onSettingsButtonClicked = onSettingsButtonClicked
)
}
// TODO: 13/07/2024, Danil Nikolaev: handle expired session
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen(
screenState: ProfileScreenState = ProfileScreenState.EMPTY,
baseError: BaseError? = null,
onSettingsButtonClicked: () -> Unit = {},
) {
Scaffold(
topBar = {
TopAppBar(
title = {},
actions = {
IconButton(onClick = onNavigateToSettings) {
IconButton(onClick = onSettingsButtonClicked) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = null
@@ -12,6 +12,7 @@ import com.meloda.app.fast.common.extensions.setValue
import com.meloda.app.fast.data.db.AccountsRepository
import com.meloda.app.fast.datastore.SettingsController
import com.meloda.app.fast.datastore.SettingsKeys
import com.meloda.app.fast.datastore.UserSettings
import com.meloda.app.fast.datastore.isDebugSettingsShown
import com.meloda.app.fast.model.database.AccountEntity
import com.meloda.app.fast.settings.model.SettingsItem
@@ -44,13 +45,14 @@ interface SettingsViewModel {
fun onSettingsItemLongClicked(key: String)
fun onSettingsItemChanged(key: String, newValue: Any?)
fun onHapticsUsed()
fun onHapticPerformed()
fun onNotificationsPermissionRequested()
}
class SettingsViewModelImpl(
private val accountsRepository: AccountsRepository,
private val userSettings: UserSettings
) : SettingsViewModel, ViewModel() {
override val screenState = MutableStateFlow(SettingsScreenState.EMPTY)
@@ -159,6 +161,7 @@ class SettingsViewModelImpl(
when (key) {
SettingsKeys.KEY_FEATURES_LONG_POLL_IN_BACKGROUND -> {
val isEnabled = (newValue as? Boolean) == true
userSettings.setLongPollBackground(isEnabled)
if (isEnabled) {
// TODO: 26/11/2023, Danil Nikolaev: implement
@@ -169,10 +172,41 @@ class SettingsViewModelImpl(
}
}
}
SettingsKeys.KEY_APPEARANCE_MULTILINE -> {
val isUsing = newValue as? Boolean ?: false
userSettings.useMultiline(isUsing)
}
SettingsKeys.KEY_APPEARANCE_AMOLED_THEME -> {
val isUsing = newValue as? Boolean ?: false
userSettings.useAmoledThemeChanged(isUsing)
}
SettingsKeys.KEY_USE_DYNAMIC_COLORS -> {
val isUsing = newValue as? Boolean ?: false
userSettings.useDynamicColorsChanged(isUsing)
}
SettingsKeys.KEY_APPEARANCE_BLUR -> {
val isUsing = newValue as? Boolean ?: false
userSettings.useBlurChanged(isUsing)
}
SettingsKeys.KEY_ACTIVITY_SEND_ONLINE_STATUS -> {
val isUsing = newValue as? Boolean ?: false
userSettings.setOnline(isUsing)
}
SettingsKeys.KEY_USE_CONTACT_NAMES -> {
val isUsing = newValue as? Boolean ?: false
userSettings.onUseContactNamesChanged(isUsing)
}
}
}
override fun onHapticsUsed() {
override fun onHapticPerformed() {
screenState.setValue { old -> old.copy(useHaptics = HapticType.None) }
}
@@ -0,0 +1,32 @@
package com.meloda.app.fast.settings.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.meloda.app.fast.datastore.SettingsKeys
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.settings.model.OnSettingsClickListener
import com.meloda.app.fast.settings.presentation.SettingsRoute
import com.meloda.app.fast.settings.presentation.SettingsScreen
import kotlinx.serialization.Serializable
@Serializable
object Settings
fun NavGraphBuilder.settingsScreen(
onBack: () -> Unit,
onLogOutButtonClicked: () -> Unit,
onLanguageItemClicked: () -> Unit
) {
composable<Settings> {
SettingsRoute(
onBack = onBack,
onLogOutButtonClicked = onLogOutButtonClicked,
onLanguageItemClicked = onLanguageItemClicked
)
}
}
fun NavController.navigateToSettings() {
this.navigate(Settings)
}
@@ -1,30 +0,0 @@
package com.meloda.app.fast.settings.presentation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.meloda.app.fast.model.BaseError
import kotlinx.serialization.Serializable
@Serializable
object Settings
fun NavGraphBuilder.settingsRoute(
onError: (BaseError) -> Unit,
onBack: () -> Unit,
onNavigateToAuth: () -> Unit,
onNavigateToLanguagePicker: () -> Unit
) {
composable<Settings> {
SettingsScreen(
onError = onError,
onBack = onBack,
onNavigateToAuth = onNavigateToAuth,
onNavigateToLanguagePicker = onNavigateToLanguagePicker
)
}
}
fun NavController.navigateToSettings() {
this.navigate(Settings)
}
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -23,7 +24,6 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@@ -41,13 +41,9 @@ import com.meloda.app.fast.datastore.UserSettings
import com.meloda.app.fast.datastore.isUsingDarkMode
import com.meloda.app.fast.designsystem.LocalTheme
import com.meloda.app.fast.designsystem.MaterialDialog
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.settings.HapticType
import com.meloda.app.fast.settings.SettingsViewModel
import com.meloda.app.fast.settings.SettingsViewModelImpl
import com.meloda.app.fast.settings.model.OnSettingsChangeListener
import com.meloda.app.fast.settings.model.OnSettingsClickListener
import com.meloda.app.fast.settings.model.OnSettingsLongClickListener
import com.meloda.app.fast.settings.model.SettingsItem
import com.meloda.app.fast.settings.model.SettingsScreenState
import com.meloda.app.fast.settings.presentation.items.EditTextSettingsItem
@@ -64,102 +60,94 @@ import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import com.meloda.app.fast.designsystem.R as UiR
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class
)
@Composable
fun SettingsScreen(
onError: (BaseError) -> Unit,
fun SettingsRoute(
onBack: () -> Unit,
onNavigateToAuth: () -> Unit,
onNavigateToLanguagePicker: () -> Unit,
onLogOutButtonClicked: () -> Unit,
onLanguageItemClicked: () -> Unit,
viewModel: SettingsViewModel = koinViewModel<SettingsViewModelImpl>()
) {
val context = LocalContext.current
val view = LocalView.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val hapticType = screenState.useHaptics
if (hapticType != HapticType.None) {
view.performHapticFeedback(hapticType.getHaptic())
viewModel.onHapticsUsed()
}
val userSettings: UserSettings = koinInject()
LaunchedEffect(true) {
userSettings.enableDebugSettings(screenState.showDebugOptions)
}
SettingsScreen(screenState = screenState,
onBack = onBack,
onHapticPerformed = viewModel::onHapticPerformed,
onSettingsItemClicked = { key ->
when (key) {
SettingsKeys.KEY_APPEARANCE_LANGUAGE -> {
onLanguageItemClicked()
}
else -> viewModel.onSettingsItemClicked(key)
}
},
onSettingsItemLongClicked = viewModel::onSettingsItemLongClicked,
onSettingsItemValueChanged = { key, newValue ->
when (key) {
SettingsKeys.KEY_APPEARANCE_DARK_THEME -> {
val newMode = newValue as? Int ?: 0
AppCompatDelegate.setDefaultNightMode(newMode)
val isUsing = context.getSystemService<PowerManager>()?.let { manager ->
isUsingDarkMode(
context.resources,
manager
)
} ?: false
userSettings.useDarkThemeChanged(isUsing)
}
else -> viewModel.onSettingsItemChanged(key, newValue)
}
}
)
HandlePopups(
performCrashPositiveClick = viewModel::onPerformCrashPositiveButtonClicked,
performCrashDismissed = viewModel::onPerformCrashAlertDismissed,
logoutPositiveClick = {
viewModel.onLogOutAlertPositiveClick()
onLogOutButtonClicked()
},
logoutDismissed = viewModel::onLogOutAlertDismissed,
longPollingPositiveClick = viewModel::onLongPollingAlertPositiveClicked,
longPollingDismissed = viewModel::onLongPollingAlertDismissed,
screenState = screenState
)
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class
)
@Composable
fun SettingsScreen(
screenState: SettingsScreenState = SettingsScreenState.EMPTY,
onBack: () -> Unit = {},
onHapticPerformed: () -> Unit = {},
onSettingsItemClicked: (key: String) -> Unit = {},
onSettingsItemLongClicked: (key: String) -> Unit = {},
onSettingsItemValueChanged: (key: String, newValue: Any?) -> Unit = { _, _ -> }
) {
val view = LocalView.current
val hapticType = screenState.useHaptics
LaunchedEffect(hapticType) {
if (hapticType != HapticType.None) {
view.performHapticFeedback(hapticType.getHaptic())
onHapticPerformed()
}
}
val currentTheme = LocalTheme.current
val settingsList = screenState.settings
val clickListener = OnSettingsClickListener { key ->
when (key) {
SettingsKeys.KEY_APPEARANCE_LANGUAGE -> {
onNavigateToLanguagePicker()
}
else -> viewModel.onSettingsItemClicked(key)
}
}
val longClickListener = OnSettingsLongClickListener(viewModel::onSettingsItemLongClicked)
val changeListener = OnSettingsChangeListener { key, newValue ->
when (key) {
SettingsKeys.KEY_APPEARANCE_MULTILINE -> {
val isUsing = newValue as? Boolean ?: false
userSettings.useMultiline(isUsing)
}
SettingsKeys.KEY_APPEARANCE_DARK_THEME -> {
val newMode = newValue as? Int ?: return@OnSettingsChangeListener
AppCompatDelegate.setDefaultNightMode(newMode)
val isUsing = context.getSystemService<PowerManager>()?.let { manager ->
isUsingDarkMode(
context.resources,
manager
)
} ?: false
userSettings.useDarkThemeChanged(isUsing)
}
SettingsKeys.KEY_APPEARANCE_AMOLED_THEME -> {
val isUsing = newValue as? Boolean ?: false
userSettings.useAmoledThemeChanged(isUsing)
}
SettingsKeys.KEY_USE_DYNAMIC_COLORS -> {
val isUsing = newValue as? Boolean ?: false
userSettings.useDynamicColorsChanged(isUsing)
}
SettingsKeys.KEY_APPEARANCE_BLUR -> {
val isUsing = newValue as? Boolean ?: false
userSettings.useBlurChanged(isUsing)
}
SettingsKeys.KEY_FEATURES_LONG_POLL_IN_BACKGROUND -> {
val isUsing = newValue as? Boolean ?: false
userSettings.setLongPollBackground(isUsing)
}
SettingsKeys.KEY_ACTIVITY_SEND_ONLINE_STATUS -> {
val isUsing = newValue as? Boolean ?: false
userSettings.setOnline(isUsing)
}
SettingsKeys.KEY_USE_CONTACT_NAMES -> {
val isUsing = newValue as? Boolean ?: false
userSettings.onUseContactNamesChanged(isUsing)
}
else -> viewModel.onSettingsItemChanged(key, newValue)
}
}
val hazeState = remember { HazeState() }
@@ -167,19 +155,16 @@ fun SettingsScreen(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.statusBars,
topBar = {
val title = @Composable { Text(text = stringResource(id = UiR.string.title_settings)) }
val navigationIcon = @Composable {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(id = UiR.drawable.ic_round_arrow_back_24),
contentDescription = "Back button"
)
}
}
TopAppBar(
title = title,
navigationIcon = navigationIcon,
title = { Text(text = stringResource(id = UiR.string.title_settings)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(id = UiR.drawable.ic_round_arrow_back_24),
contentDescription = "Back button"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface.copy(
alpha = if (currentTheme.usingBlur) 0f else 1f
@@ -215,33 +200,23 @@ fun SettingsScreen(
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr))
.padding(bottom = padding.calculateBottomPadding())
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(
count = settingsList.size,
// key = { index ->
// val item = settingsList[index]
// requireNotNull(item.title ?: item.summary)
// },
contentType = { index ->
when (settingsList[index]) {
is SettingsItem.ListItem -> "listitem"
items = screenState.settings,
key = { item -> item.key },
contentType = { item ->
when (item) {
is SettingsItem.ListItem -> "list_item"
is SettingsItem.Switch -> "switch"
is SettingsItem.TextField -> "textfield"
is SettingsItem.TextField -> "text_field"
is SettingsItem.Title -> "title"
is SettingsItem.TitleSummary -> "titlesummary"
is SettingsItem.TitleSummary -> "title_summary"
}
}
) { index ->
val needToShowSpacer by remember {
derivedStateOf {
index == 0
}
}
if (needToShowSpacer) {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
when (val item = settingsList[index]) {
) { item ->
when (item) {
is SettingsItem.Title -> TitleSettingsItem(
item = item,
isMultiline = currentTheme.multiline,
@@ -251,67 +226,47 @@ fun SettingsScreen(
is SettingsItem.TitleSummary -> TitleSummarySettingsItem(
item = item,
isMultiline = currentTheme.multiline,
onSettingsClickListener = clickListener,
onSettingsLongClickListener = longClickListener,
onSettingsClickListener = onSettingsItemClicked,
onSettingsLongClickListener = onSettingsItemLongClicked,
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
)
is SettingsItem.Switch -> SwitchSettingsItem(
item = item,
isMultiline = currentTheme.multiline,
onSettingsClickListener = clickListener,
onSettingsLongClickListener = longClickListener,
onSettingsChangeListener = changeListener,
onSettingsClickListener = onSettingsItemClicked,
onSettingsLongClickListener = onSettingsItemLongClicked,
onSettingsChangeListener = onSettingsItemValueChanged,
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
)
is SettingsItem.TextField -> EditTextSettingsItem(
item = item,
isMultiline = currentTheme.multiline,
onSettingsClickListener = clickListener,
onSettingsLongClickListener = longClickListener,
onSettingsChangeListener = changeListener,
onSettingsClickListener = onSettingsItemClicked,
onSettingsLongClickListener = onSettingsItemLongClicked,
onSettingsChangeListener = onSettingsItemValueChanged,
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
)
is SettingsItem.ListItem -> ListSettingsItem(
item = item,
isMultiline = currentTheme.multiline,
onSettingsClickListener = clickListener,
onSettingsLongClickListener = longClickListener,
onSettingsChangeListener = changeListener,
onSettingsClickListener = onSettingsItemClicked,
onSettingsLongClickListener = onSettingsItemLongClicked,
onSettingsChangeListener = onSettingsItemValueChanged,
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
)
}
}
val showBottomNavigationBarsSpacer by remember {
derivedStateOf {
index == settingsList.size - 1
}
}
if (showBottomNavigationBarsSpacer) {
Spacer(modifier = Modifier.navigationBarsPadding())
}
item {
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
HandlePopups(
performCrashPositiveClick = viewModel::onPerformCrashPositiveButtonClicked,
performCrashDismissed = viewModel::onPerformCrashAlertDismissed,
logoutPositiveClick = {
viewModel.onLogOutAlertPositiveClick()
onNavigateToAuth()
},
logoutDismissed = viewModel::onLogOutAlertDismissed,
longPollingPositiveClick = viewModel::onLongPollingAlertPositiveClicked,
longPollingDismissed = viewModel::onLongPollingAlertDismissed,
screenState = screenState
)
}
// TODO: 12/04/2024, Danil Nikolaev: rewrite to UiAction
@Composable
fun HandlePopups(
performCrashPositiveClick: () -> Unit,
+36 -18
View File
@@ -7,16 +7,18 @@ kotlin = "2.0.0"
ksp = "2.0.0-1.0.22"
vkompose = "0.5.4-k2"
compose-bom = "2024.06.00"
koin = "3.5.6"
accompanist = "0.34.0"
coil = "2.6.0"
compose-bom = "2024.06.00"
coroutines = "1.9.0-RC"
junit = "4.13.2"
chucker = "4.0.0"
guava = "33.2.1-jre"
lifecycle = "2.8.3"
core-ktx = "1.13.1"
koin = "3.5.6"
material = "1.12.0"
loggingInterceptor = "5.0.0-alpha.14"
moshi = "1.15.1"
@@ -30,24 +32,13 @@ appcompat = "1.7.0"
androidx-navigation = "2.8.0-beta05"
serialization = "1.7.1"
rebugger = "1.0.0-rc03"
uiTooling = "1.6.8"
[libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
coil = { module = "io.coil-kt:coil", version.ref = "coil" }
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "converterMoshi" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-material3-windowsize = { module = "androidx.compose.material3:material3-window-size-class" }
compose-activity = { module = "androidx.activity:activity-compose" }
compose-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-compose" }
compose-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-compose" }
compose-runtime-saveable = { module = "androidx.compose.runtime:runtime-saveable" }
compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
eithernet = { module = "com.slack.eithernet:eithernet", version.ref = "eithernet" }
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
@@ -59,9 +50,6 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c
lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" }
koin-androidx-compose-navigation = { module = "io.insert-koin:koin-androidx-compose-navigation", version.ref = "koin" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
material = { module = "com.google.android.material:material", version.ref = "material" }
logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" }
moshi-kotlin-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" }
@@ -84,6 +72,30 @@ kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-j
rebugger = { module = "io.github.theapache64:rebugger-android", version.ref = "rebugger" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-material3-windowsize = { module = "androidx.compose.material3:material3-window-size-class" }
compose-activity = { module = "androidx.activity:activity-compose" }
compose-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-compose" }
compose-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-compose" }
compose-runtime-saveable = { module = "androidx.compose.runtime:runtime-saveable" }
compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
# TODO: remove version from non-bom dependencies
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-core-coroutines = { module = "io.insert-koin:koin-core-coroutines", version.ref = "koin" }
koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-android-test = { module = "io.insert-koin:koin-android-test", version.ref = "koin" }
koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" }
koin-androidx-compose-navigation = { module = "io.insert-koin:koin-androidx-compose-navigation", version.ref = "koin" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" }
[bundles]
compose = [
"compose-material3",
@@ -95,6 +107,13 @@ compose = [
"compose-lifecycle-runtime",
"compose-runtime-saveable"
]
koin = [
"koin-core",
"koin-core-coroutines",
"koin-android",
"koin-androidx-compose",
"koin-androidx-compose-navigation"
]
[plugins]
com-android-application = { id = "com.android.application", version.ref = "agp" }
@@ -104,4 +123,3 @@ com-google-devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp"
org-jetbrains-kotlin-plugin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
com-vk-vkompose = { id = "com.vk.vkompose", version.ref = "vkompose" }
+1 -1
View File
@@ -34,7 +34,7 @@ include(":feature:languagepicker")
include(":feature:photoviewer")
include(":feature:settings")
include(":feature:auth:login")
include(":feature:auth:twofa")
include(":feature:auth:validation")
include(":feature:auth:captcha")
include(":feature:auth:userbanned")
include(":feature:friends")