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