Compare commits

11 Commits
main ... main

Author SHA1 Message Date
«Владимир
714b223117 add main interface logical2 2025-11-29 19:41:48 +03:00
«Владимир
ca67f90884 add main interface logical 2025-11-29 19:41:36 +03:00
c4913ec0bc merge upstream 2025-11-29 15:13:20 +00:00
2ecbc7339a AuthRepository.kt fix
Some checks failed
Android Test / validate-and-test (pull_request) Has been cancelled
2025-11-29 18:08:05 +03:00
00162984a9 merge upstream 2025-11-29 15:03:42 +00:00
bc7e14ab06 AuthRepository.kt fix
Some checks failed
Android Test / validate-and-test (pull_request) Has been cancelled
2025-11-29 16:27:55 +03:00
b98c7f410f add auth interface logical 2025-11-29 13:03:50 +00:00
67e26d142a merge upstream 2025-11-29 12:52:36 +00:00
«Владимир
286b752974 add auth interface logical 2025-11-29 15:51:41 +03:00
cbc91ee472 Auth Case and Repo done 2025-11-29 15:37:18 +03:00
«Владимир
d92ac5a10c add space 2025-11-29 14:40:25 +03:00
16 changed files with 436 additions and 34 deletions

View File

@@ -8,9 +8,7 @@ object AuthRepository {
suspend fun checkAndSave(text: String): Result<Boolean> {
return NetworkDataSource.checkAuth(text).onSuccess { success ->
if (success) {
codeCache = text
}
if (success) codeCache = text
}
}
}

View File

@@ -0,0 +1,56 @@
package ru.myitschool.work.data.repo
import ru.myitschool.work.data.source.NetworkDataSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import ru.myitschool.work.domain.auth.entities.BookingEntity
import ru.myitschool.work.domain.auth.entities.UserEntity
import kotlinx.serialization.json.Json
object MainRepository {
@Serializable
data class BookingDto(
val roomName: String,
val time: String
)
@Serializable
data class UserDto(
val name: String,
val photoUrl: String,
val bookingList: List<BookingDto>
)
private val json = Json { ignoreUnknownKeys = true }
private var codeCache: String? = null
private var cachedUser: UserEntity? = null
suspend fun getUserInfo(code: String): Result<UserEntity> {
return withContext(Dispatchers.IO) {
runCatching {
if (codeCache == code && cachedUser != null) return@runCatching cachedUser!!
codeCache = code
val isAuth = NetworkDataSource.checkAuth(code).getOrThrow()
if (!isAuth) throw Exception("Авторизация не пройдена")
val userJson = NetworkDataSource.getUserData(code).getOrThrow()
val userResponse = json.decodeFromString<UserDto>(userJson)
val user = UserEntity(
name = userResponse.name,
photoUrl = userResponse.photoUrl,
bookingList = userResponse.bookingList.map { BookingEntity(it.roomName, it.time) }
)
cachedUser = user
user
}
}
}
}

View File

@@ -29,7 +29,7 @@ object NetworkDataSource {
}
suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) {
return@withContext runCatching {
runCatching {
val response = client.get(getUrl(code, Constants.AUTH_URL))
when (response.status) {
HttpStatusCode.OK -> true
@@ -38,5 +38,15 @@ object NetworkDataSource {
}
}
suspend fun getUserData(code: String): Result<String> = withContext(Dispatchers.IO) {
runCatching {
val response = client.get(getUrl(code, Constants.INFO_URL))
when (response.status) {
HttpStatusCode.OK -> response.bodyAsText()
else -> error(response.bodyAsText())
}
}
}
private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl"
}
}

View File

@@ -8,6 +8,10 @@ class CheckAndSaveAuthCodeUseCase(
suspend operator fun invoke(
text: String
): Result<Unit> {
val isValid = text.length == 4 && text.all {it.isLetterOrDigit()}
if (!isValid) return Result.failure(IllegalArgumentException("Неверный формат кода!"))
return repository.checkAndSave(text).mapCatching { success ->
if (!success) error("Code is incorrect")
}

View File

@@ -0,0 +1,12 @@
package ru.myitschool.work.domain.auth
import ru.myitschool.work.data.repo.MainRepository
import ru.myitschool.work.domain.auth.entities.UserEntity
class GetUserInfoUseCase(
private val repository: MainRepository = MainRepository
) {
suspend operator fun invoke(code: String): Result<UserEntity> {
return repository.getUserInfo(code)
}
}

View File

@@ -0,0 +1,6 @@
package ru.myitschool.work.domain.auth.entities
data class BookingEntity(
val roomName: String,
val time: String,
)

View File

@@ -0,0 +1,7 @@
package ru.myitschool.work.domain.auth.entities
data class UserEntity(
val name: String,
val photoUrl: String,
val bookingList: List<BookingEntity>
)

View File

@@ -0,0 +1,12 @@
package ru.myitschool.work.domain.main
import ru.myitschool.work.data.repo.MainRepository
import ru.myitschool.work.domain.auth.entities.UserEntity
class GetUserInfoUseCase(
private val repository: MainRepository = MainRepository
) {
suspend operator fun invoke(code: String): Result<UserEntity> {
return repository.getUserInfo(code)
}
}

View File

@@ -3,4 +3,4 @@ package ru.myitschool.work.ui.nav
import kotlinx.serialization.Serializable
@Serializable
data object MainScreenDestination: AppDestination
data class MainScreenDestination(val authCode: String)

View File

@@ -3,18 +3,26 @@ package ru.myitschool.work.ui.screen
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions
import androidx.navigation.toRoute
import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.BookScreenDestination
import ru.myitschool.work.ui.nav.MainScreenDestination
import ru.myitschool.work.ui.screen.auth.AuthScreen
import ru.myitschool.work.ui.screen.main.MainScreen
import ru.myitschool.work.ui.screen.main.MainViewModel
@Composable
fun AppNavHost(
@@ -26,23 +34,42 @@ fun AppNavHost(
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None },
navController = navController,
startDestination = AuthScreenDestination,
startDestination = AuthScreenDestination
) {
composable<AuthScreenDestination> {
AuthScreen(navController = navController)
}
composable<MainScreenDestination> {
Box(
contentAlignment = Alignment.Center
) {
Text(text = "Hello")
}
composable<MainScreenDestination> { backStackEntry ->
val authCode = backStackEntry.toRoute<MainScreenDestination>().authCode
val viewModel = remember(authCode) { MainViewModel(authCode) }
val state by viewModel.uiState.collectAsState()
MainScreen(
state = state,
onRefresh = { viewModel.onRefresh() },
onLogout = {
viewModel.onLogout()
navController.navigate(AuthScreenDestination) {
popUpTo(navController.graph.startDestinationId) {
inclusive = true
}
launchSingleTop = true
}
},
onBookClick = {
navController.navigate(BookScreenDestination)
}
)
}
composable<BookScreenDestination> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(text = "Hello")
Text(text = "Экран бронирования (в разработке)")
}
}
}

View File

@@ -21,7 +21,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@@ -39,9 +38,9 @@ fun AuthScreen(
) {
val state by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.actionFlow.collect {
navController.navigate(MainScreenDestination)
LaunchedEffect(viewModel) {
viewModel.actionFlow.collect { authCode ->
navController.navigate(MainScreenDestination(authCode = authCode))
}
}
@@ -67,14 +66,17 @@ fun AuthScreen(
}
}
}
@Composable
private fun Content(
viewModel: AuthViewModel,
state: AuthState.Data
) {
var inputText by remember { mutableStateOf("") }
val isCodeValid = inputText.length == 4 && inputText.all { it.isLetterOrDigit() }
Spacer(modifier = Modifier.size(16.dp))
TextField(
modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(),
value = inputText,
@@ -82,15 +84,28 @@ private fun Content(
inputText = it
viewModel.onIntent(AuthIntent.TextInput(it))
},
label = { Text(stringResource(R.string.auth_label)) }
label = { Text(stringResource(R.string.auth_label)) },
placeholder = { Text("Код") },
isError = state.error != null
)
if (state.error != null) {
Spacer(modifier = Modifier.size(8.dp))
Text(
text = state.error,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag(TestIds.Auth.ERROR)
)
}
Spacer(modifier = Modifier.size(16.dp))
Button(
modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(),
onClick = {
viewModel.onIntent(AuthIntent.Send(inputText))
},
enabled = true
enabled = isCodeValid
) {
Text(stringResource(R.string.auth_sign_in))
}

View File

@@ -1,6 +1,8 @@
package ru.myitschool.work.ui.screen.auth
sealed interface AuthState {
object Loading: AuthState
object Data: AuthState
sealed class AuthState {
object Loading : AuthState()
data class Data(
val error: String? = null
) : AuthState()
}

View File

@@ -8,36 +8,45 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
class AuthViewModel : ViewModel() {
private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) }
private val _uiState = MutableStateFlow<AuthState>(AuthState.Data)
private val _uiState = MutableStateFlow<AuthState>(AuthState.Data())
val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
val actionFlow: SharedFlow<Unit> = _actionFlow
private val _actionFlow = MutableSharedFlow<String>()
val actionFlow: SharedFlow<String> = _actionFlow
fun onIntent(intent: AuthIntent) {
when (intent) {
is AuthIntent.Send -> {
val code = intent.text
viewModelScope.launch(Dispatchers.Default) {
_uiState.update { AuthState.Loading }
checkAndSaveAuthCodeUseCase.invoke("9999").fold(
_uiState.value = AuthState.Loading
checkAndSaveAuthCodeUseCase.invoke(code).fold(
onSuccess = {
_actionFlow.emit(Unit)
_actionFlow.emit(intent.text)
},
onFailure = { error ->
error.printStackTrace()
_actionFlow.emit(Unit)
val errorMsg = when {
error.message?.contains("401") == true -> "Неверный код"
else -> "Ошибка подключения"
}
_uiState.value = AuthState.Data(error = errorMsg)
}
)
}
}
is AuthIntent.TextInput -> Unit
is AuthIntent.TextInput -> {
if (_uiState.value is AuthState.Data) {
_uiState.value = AuthState.Data(error = null)
}
}
}
}
}

View File

@@ -0,0 +1,150 @@
package ru.myitschool.work.ui.screen.main
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import ru.myitschool.work.core.TestIds
@Composable
fun MainScreen(
state: MainState,
onRefresh: () -> Unit,
onLogout: () -> Unit,
onBookClick: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
when (state) {
is MainState.Loading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
is MainState.Error -> {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize()
) {
Text(
text = state.message,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier.testTag(TestIds.Main.ERROR)
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onRefresh,
modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON)
) {
Text("Обновить")
}
}
}
is MainState.Success -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
AsyncImage(
model = state.photoUrl,
contentDescription = "Аватар",
modifier = Modifier
.size(56.dp)
.testTag(TestIds.Main.PROFILE_IMAGE)
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = state.name,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.testTag(TestIds.Main.PROFILE_NAME)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
modifier = Modifier
.weight(1f)
.testTag(TestIds.Main.REFRESH_BUTTON),
onClick = onRefresh
) {
Text("Обновить")
}
Button(
modifier = Modifier
.weight(1f)
.testTag(TestIds.Main.LOGOUT_BUTTON),
onClick = onLogout
) {
Text("Выйти")
}
Button(
modifier = Modifier
.weight(1f)
.testTag(TestIds.Main.ADD_BUTTON),
onClick = onBookClick
) {
Text("Забронировать")
}
}
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(state.bookings) { index, item ->
BookingRow(item = item, index = index)
}
}
}
}
}
}
}
@Composable
fun AsyncImage(model: String, contentDescription: String, modifier: Modifier) {
}
@Composable
private fun BookingRow(item: BookingItem, index: Int) {
Card(
modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Main.getIdItemByPosition(index))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = item.date,
modifier = Modifier.testTag(TestIds.Main.ITEM_DATE)
)
Text(
text = item.place,
modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE)
)
}
}
}

View File

@@ -0,0 +1,19 @@
package ru.myitschool.work.ui.screen.main
sealed interface MainState {
object Loading : MainState
data class Success(
val name: String,
val photoUrl: String,
val bookings: List<BookingItem>,
) : MainState
data class Error(
val message: String,
) : MainState
}
data class BookingItem(
val date: String,
val place: String,
)

View File

@@ -0,0 +1,75 @@
package ru.myitschool.work.ui.screen.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ru.myitschool.work.domain.main.GetUserInfoUseCase
class MainViewModel(
private val authCode: String
) : ViewModel() {
private val getUserInfoUseCase = GetUserInfoUseCase()
private val _uiState = MutableStateFlow<MainState>(MainState.Loading)
val uiState: StateFlow<MainState> = _uiState.asStateFlow()
init {
loadUserInfo()
}
fun onRefresh() {
loadUserInfo()
}
fun onLogout() {
}
fun onBookClick() {
}
private fun loadUserInfo() {
viewModelScope.launch {
getUserInfoUseCase.invoke(authCode).fold(
onSuccess = { user ->
val bookings = user.bookingList.map { bookingEntity ->
BookingItem(
date = formatDate(bookingEntity.time),
place = bookingEntity.roomName
)
}.sortedBy { it.date }
_uiState.value = MainState.Success(
name = user.name,
photoUrl = user.photoUrl,
bookings = bookings
)
},
onFailure = { error ->
val msg = when {
error.message?.contains("401") == true -> "Пользователь не найден"
else -> "Не удалось загрузить данные"
}
_uiState.value = MainState.Error(msg)
}
)
}
}
private fun formatDate(input: String): String {
return try {
val parts = input.split("-")
if (parts.size == 3) {
"${parts[2]}.${parts[1]}.${parts[0]}"
} else {
input
}
} catch (e: Exception) {
input
}
}
}