Compare commits

12 Commits
main ... main

Author SHA1 Message Date
0a6f948a72 Merge pull request 'main' (#2) from student-28725/NTO-2025-Android-TeamTask:main into main
Some checks failed
Android Test / validate-and-test (pull_request) Has been cancelled
Reviewed-on: student-15031/NTO-2025-Android-TeamTask#2
2025-11-29 16:46:26 +00:00
«Владимир
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> { suspend fun checkAndSave(text: String): Result<Boolean> {
return NetworkDataSource.checkAuth(text).onSuccess { success -> return NetworkDataSource.checkAuth(text).onSuccess { success ->
if (success) { if (success) codeCache = text
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) { suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) {
return@withContext runCatching { runCatching {
val response = client.get(getUrl(code, Constants.AUTH_URL)) val response = client.get(getUrl(code, Constants.AUTH_URL))
when (response.status) { when (response.status) {
HttpStatusCode.OK -> true 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" private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl"
} }

View File

@@ -8,6 +8,10 @@ class CheckAndSaveAuthCodeUseCase(
suspend operator fun invoke( suspend operator fun invoke(
text: String text: String
): Result<Unit> { ): Result<Unit> {
val isValid = text.length == 4 && text.all {it.isLetterOrDigit()}
if (!isValid) return Result.failure(IllegalArgumentException("Неверный формат кода!"))
return repository.checkAndSave(text).mapCatching { success -> return repository.checkAndSave(text).mapCatching { success ->
if (!success) error("Code is incorrect") 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 import kotlinx.serialization.Serializable
@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.EnterTransition
import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExitTransition
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController 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.AuthScreenDestination
import ru.myitschool.work.ui.nav.BookScreenDestination import ru.myitschool.work.ui.nav.BookScreenDestination
import ru.myitschool.work.ui.nav.MainScreenDestination import ru.myitschool.work.ui.nav.MainScreenDestination
import ru.myitschool.work.ui.screen.auth.AuthScreen 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 @Composable
fun AppNavHost( fun AppNavHost(
@@ -26,23 +34,42 @@ fun AppNavHost(
enterTransition = { EnterTransition.None }, enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None }, exitTransition = { ExitTransition.None },
navController = navController, navController = navController,
startDestination = AuthScreenDestination, startDestination = AuthScreenDestination
) { ) {
composable<AuthScreenDestination> { composable<AuthScreenDestination> {
AuthScreen(navController = navController) AuthScreen(navController = navController)
} }
composable<MainScreenDestination> {
Box( composable<MainScreenDestination> { backStackEntry ->
contentAlignment = Alignment.Center val authCode = backStackEntry.toRoute<MainScreenDestination>().authCode
) {
Text(text = "Hello") 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> { composable<BookScreenDestination> {
Box( Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center 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.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -39,9 +38,9 @@ fun AuthScreen(
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) { LaunchedEffect(viewModel) {
viewModel.actionFlow.collect { viewModel.actionFlow.collect { authCode ->
navController.navigate(MainScreenDestination) navController.navigate(MainScreenDestination(authCode = authCode))
} }
} }
@@ -67,14 +66,17 @@ fun AuthScreen(
} }
} }
} }
@Composable @Composable
private fun Content( private fun Content(
viewModel: AuthViewModel, viewModel: AuthViewModel,
state: AuthState.Data state: AuthState.Data
) { ) {
var inputText by remember { mutableStateOf("") } var inputText by remember { mutableStateOf("") }
val isCodeValid = inputText.length == 4 && inputText.all { it.isLetterOrDigit() }
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
TextField( TextField(
modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(), modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(),
value = inputText, value = inputText,
@@ -82,15 +84,28 @@ private fun Content(
inputText = it inputText = it
viewModel.onIntent(AuthIntent.TextInput(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)) Spacer(modifier = Modifier.size(16.dp))
Button( Button(
modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(), modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(),
onClick = { onClick = {
viewModel.onIntent(AuthIntent.Send(inputText)) viewModel.onIntent(AuthIntent.Send(inputText))
}, },
enabled = true enabled = isCodeValid
) { ) {
Text(stringResource(R.string.auth_sign_in)) Text(stringResource(R.string.auth_sign_in))
} }

View File

@@ -1,6 +1,8 @@
package ru.myitschool.work.ui.screen.auth package ru.myitschool.work.ui.screen.auth
sealed interface AuthState { sealed class AuthState {
object Loading: AuthState object Loading : AuthState()
object Data: 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.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.myitschool.work.data.repo.AuthRepository import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
class AuthViewModel : ViewModel() { class AuthViewModel : ViewModel() {
private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) } 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() val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow() private val _actionFlow = MutableSharedFlow<String>()
val actionFlow: SharedFlow<Unit> = _actionFlow val actionFlow: SharedFlow<String> = _actionFlow
fun onIntent(intent: AuthIntent) { fun onIntent(intent: AuthIntent) {
when (intent) { when (intent) {
is AuthIntent.Send -> { is AuthIntent.Send -> {
val code = intent.text
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
_uiState.update { AuthState.Loading } _uiState.value = AuthState.Loading
checkAndSaveAuthCodeUseCase.invoke("9999").fold( checkAndSaveAuthCodeUseCase.invoke(code).fold(
onSuccess = { onSuccess = {
_actionFlow.emit(Unit) _actionFlow.emit(intent.text)
}, },
onFailure = { error -> 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
}
}
}