Compare commits

...

4 Commits

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 2025-11-29 18:08:05 +03:00
12 changed files with 374 additions and 24 deletions

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

@@ -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

@@ -31,7 +31,6 @@ import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds import ru.myitschool.work.core.TestIds
import ru.myitschool.work.ui.nav.MainScreenDestination import ru.myitschool.work.ui.nav.MainScreenDestination
@Composable @Composable
fun AuthScreen( fun AuthScreen(
viewModel: AuthViewModel = viewModel(), viewModel: AuthViewModel = viewModel(),
@@ -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,7 +66,6 @@ fun AuthScreen(
} }
} }
} }
@Composable @Composable
private fun Content( private fun Content(
viewModel: AuthViewModel, viewModel: AuthViewModel,
@@ -76,7 +74,6 @@ private fun Content(
var inputText by remember { mutableStateOf("") } var inputText by remember { mutableStateOf("") }
val isCodeValid = inputText.length == 4 && inputText.all { it.isLetterOrDigit() } val isCodeValid = inputText.length == 4 && inputText.all { it.isLetterOrDigit() }
val isButtonEnabled = isCodeValid
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
@@ -108,9 +105,8 @@ private fun Content(
onClick = { onClick = {
viewModel.onIntent(AuthIntent.Send(inputText)) viewModel.onIntent(AuthIntent.Send(inputText))
}, },
enabled = isButtonEnabled enabled = isCodeValid
) { ) {
Text(stringResource(R.string.auth_sign_in)) Text(stringResource(R.string.auth_sign_in))
} }
} }

View File

@@ -8,7 +8,6 @@ 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
@@ -18,8 +17,8 @@ class AuthViewModel : ViewModel() {
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) {
@@ -29,7 +28,7 @@ class AuthViewModel : ViewModel() {
_uiState.value = AuthState.Loading _uiState.value = AuthState.Loading
checkAndSaveAuthCodeUseCase.invoke(code).fold( checkAndSaveAuthCodeUseCase.invoke(code).fold(
onSuccess = { onSuccess = {
_actionFlow.emit(Unit) _actionFlow.emit(intent.text)
}, },
onFailure = { error -> onFailure = { error ->

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,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
}
}
}