diff --git a/app/src/main/java/ru/myitschool/work/core/Constants.kt b/app/src/main/java/ru/myitschool/work/core/Constants.kt index a8b7cc5..1aae4fd 100644 --- a/app/src/main/java/ru/myitschool/work/core/Constants.kt +++ b/app/src/main/java/ru/myitschool/work/core/Constants.kt @@ -1,7 +1,7 @@ package ru.myitschool.work.core object Constants { - const val HOST = "http://10.0.2.2:8080" + const val HOST = "http://127.0.0.1:8080" const val AUTH_URL = "/auth" const val INFO_URL = "/info" const val BOOKING_URL = "/booking" diff --git a/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt b/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt index 3ef28f1..da5682d 100644 --- a/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt +++ b/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt @@ -1,11 +1,21 @@ package ru.myitschool.work.data.repo +import android.content.Context +import ru.myitschool.work.App import ru.myitschool.work.data.source.NetworkDataSource object AuthRepository { private var codeCache: String? = null - + private const val PREF_NAME = "auth_prefs" + private const val KEY_SAVED_CODE = "saved_code" + private val context: Context get() = App.context + private fun loadSavedCode(): String? { +// return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) +// .getString(KEY_SAVED_CODE, null) + return "" + } + fun getSavedCode(): String? = loadSavedCode() suspend fun checkAndSave(text: String): Result { return NetworkDataSource.checkAuth(text).onSuccess { success -> if (success) { diff --git a/app/src/main/java/ru/myitschool/work/data/repo/BookRepository.kt b/app/src/main/java/ru/myitschool/work/data/repo/BookRepository.kt new file mode 100644 index 0000000..2887f8e --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repo/BookRepository.kt @@ -0,0 +1,15 @@ +package ru.myitschool.work.data.repo + +import ru.myitschool.work.data.source.NetworkDataSource +import ru.myitschool.work.ui.screen.Booking +import ru.myitschool.work.ui.screen.UserInfo + +object BookRepository { + suspend fun loadBooks(code: String): Result> { + return NetworkDataSource.getFreeBooking(code) + } + + suspend fun sendData(code: String, booking: Booking) : Result { + return NetworkDataSource.createNewBooking(code, booking) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt b/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt index fbdfef5..62c7504 100644 --- a/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt +++ b/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt @@ -1,16 +1,23 @@ package ru.myitschool.work.data.source import io.ktor.client.HttpClient +import io.ktor.client.call.body import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get +import io.ktor.client.request.post import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json +import io.ktor.utils.io.InternalAPI import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import ru.myitschool.work.core.Constants +import ru.myitschool.work.ui.screen.Booking +import ru.myitschool.work.ui.screen.UserInfo object NetworkDataSource { private val client by lazy { @@ -31,6 +38,40 @@ object NetworkDataSource { suspend fun checkAuth(code: String): Result = withContext(Dispatchers.IO) { return@withContext runCatching { val response = client.get(getUrl(code, Constants.AUTH_URL)) + when (response.status) { + HttpStatusCode.OK -> true + else -> error("Неверный код для авторизации") + } + } + } + + suspend fun getInfo(code: String): Result = withContext(Dispatchers.IO){ + return@withContext runCatching { + val response = client.get(getUrl(code, Constants.INFO_URL)) + when(response.status){ + HttpStatusCode.OK -> response.body() + else -> error("Ошибка получения данных") + } + } + } + + suspend fun getFreeBooking(code: String) : Result> = withContext(Dispatchers.IO) { + return@withContext runCatching { + val response = client.get(getUrl(code, Constants.BOOKING_URL)) + when (response.status) { + HttpStatusCode.OK -> response.body() + else -> error(response.bodyAsText()) + } + } + } + + @OptIn(InternalAPI::class) + suspend fun createNewBooking(code: String, booking: Booking) : Result = withContext(Dispatchers.IO) { + return@withContext runCatching { + val response = client.post(getUrl(code, Constants.BOOK_URL)) { + contentType(ContentType.Application.Json) + body = booking + } when (response.status) { HttpStatusCode.OK -> true else -> error(response.bodyAsText()) diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/BookingUserInfo.kt b/app/src/main/java/ru/myitschool/work/ui/screen/BookingUserInfo.kt new file mode 100644 index 0000000..39c3e4b --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/BookingUserInfo.kt @@ -0,0 +1,16 @@ +package ru.myitschool.work.ui.screen + +import kotlinx.serialization.Serializable + +@Serializable +data class UserInfo( + val name: String, + val photo: String? = null, + val bookings: List +) + +@Serializable +data class Booking( + val date: String, + val place: String +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt b/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt index 01b0f32..b70bbc0 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt @@ -7,6 +7,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -15,6 +16,8 @@ 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.book.BookScreen +import ru.myitschool.work.ui.screen.book.BookViewModel @Composable fun AppNavHost( @@ -39,11 +42,10 @@ fun AppNavHost( } } composable { - Box( - contentAlignment = Alignment.Center - ) { - Text(text = "Hello") - } + BookScreen( + viewModel = BookViewModel(), + navController = navController + ) } } } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt index f99978e..ccbb6df 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Button @@ -21,7 +22,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 @@ -74,6 +74,11 @@ private fun Content( state: AuthState.Data ) { var inputText by remember { mutableStateOf("") } + + val isValidCode = inputText.length >= 4 && inputText.isNotEmpty() && inputText.none { it.isWhitespace() } && inputText.all { ch -> + ch in '0'..'9' || ch in 'A'..'Z' || ch in 'a'..'z' + } + Spacer(modifier = Modifier.size(16.dp)) TextField( modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(), @@ -82,15 +87,23 @@ private fun Content( inputText = it viewModel.onIntent(AuthIntent.TextInput(it)) }, - label = { Text(stringResource(R.string.auth_label)) } + label = { Text(stringResource(R.string.auth_label)) }, ) + if (state.error != null) { + 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 = isValidCode ) { Text(stringResource(R.string.auth_sign_in)) } diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt index a06ba76..9c1a8dc 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt @@ -2,5 +2,7 @@ package ru.myitschool.work.ui.screen.auth sealed interface AuthState { object Loading: AuthState - object Data: AuthState + data class Data( + val error: String? = null + ) : AuthState } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt index 3153640..fe7d469 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt @@ -15,7 +15,7 @@ import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase class AuthViewModel : ViewModel() { private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) } - private val _uiState = MutableStateFlow(AuthState.Data) + private val _uiState = MutableStateFlow(AuthState.Data()) val uiState: StateFlow = _uiState.asStateFlow() private val _actionFlow: MutableSharedFlow = MutableSharedFlow() @@ -26,18 +26,21 @@ class AuthViewModel : ViewModel() { is AuthIntent.Send -> { viewModelScope.launch(Dispatchers.Default) { _uiState.update { AuthState.Loading } - checkAndSaveAuthCodeUseCase.invoke("9999").fold( + checkAndSaveAuthCodeUseCase.invoke(intent.text).fold( onSuccess = { - _actionFlow.emit(Unit) + _actionFlow.emit(Unit)// переход на MainScreen }, onFailure = { error -> - error.printStackTrace() - _actionFlow.emit(Unit) + _uiState.update { + AuthState.Data(error.message ?: "Неверный код для авторизации") + } } ) } } - is AuthIntent.TextInput -> Unit + is AuthIntent.TextInput -> { + _uiState.update { AuthState.Data() } + } } } } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt new file mode 100644 index 0000000..8d77d08 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt @@ -0,0 +1,10 @@ +package ru.myitschool.work.ui.screen.book + +import ru.myitschool.work.ui.screen.Booking + +sealed interface BookIntent { + data class Send(val code : String, val booking: Booking) : BookIntent + data object BackToMainScreen : BookIntent + data object ToAuthScreen : BookIntent + data object LoadData : BookIntent +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt new file mode 100644 index 0000000..47dd7ad --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt @@ -0,0 +1,209 @@ +package ru.myitschool.work.ui.screen.book; + +import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import ru.myitschool.work.R +import ru.myitschool.work.core.TestIds +import ru.myitschool.work.data.repo.AuthRepository +import ru.myitschool.work.ui.nav.AuthScreenDestination +import ru.myitschool.work.ui.nav.MainScreenDestination +import ru.myitschool.work.ui.screen.Booking + +var selectedTime: MutableState = mutableStateOf(null) +var selectedBooking: MutableState = mutableStateOf(null) +var currentTime: MutableState = mutableStateOf(null) +@Composable +fun BookScreen( + viewModel: BookViewModel = viewModel(), + navController: NavController, + modifier: Modifier = Modifier +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + Log.d("BookScreen", "1") + viewModel.navigationFlow.collect { + // TODO настроить наконец это переход между экранами +// Log.d("BookScreen", "2") + when (it) { + BookNavigationEvent.NavigateToMain -> { + Log.d("BookScreen", "3") + navController.navigate(MainScreenDestination) + } + BookNavigationEvent.NavigateToAuth -> { + Log.d("BookScreen", "4") + navController.navigate(AuthScreenDestination) { + Log.d("BookScreen", "5") + popUpTo(navController.graph.startDestinationId) { + Log.d("BookScreen", "6") + inclusive = true + } + } + } + } + } + } + + Column( + modifier = modifier.fillMaxSize() + ) { +// Text("" + selectedTime.value + "," + currentTime.value + "," + selectedBooking.value) + when (state) { + is BookState.Data -> { + val options = (state as BookState.Data).booking + if (currentTime.value == null) + for (el in options) { + currentTime.value = el.key + break + } + TabGroup(options.keys) + options[currentTime.value]?.let { SelectBooking(it) } + + Button( + onClick = { + viewModel.onIntent(BookIntent.Send(AuthRepository.getSavedCode()!!, selectedBooking.value!!)) + }, + modifier = Modifier + .testTag(TestIds.Book.BOOK_BUTTON), + enabled = selectedBooking.value != null + ) { + Text(stringResource(R.string.to_book)) + } + } + + is BookState.Error -> { + Text( + text = (state as BookState.Error).error, + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .testTag(TestIds.Book.ERROR) + ) + + Button( + onClick = { + viewModel.onIntent(BookIntent.LoadData) + }, + modifier = Modifier + .testTag(TestIds.Book.REFRESH_BUTTON) + ) { + Text(stringResource(R.string.upadate)) // А что сюда писать? + } + } + + BookState.Loading -> { + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .size(64.dp) + ) + } + + BookState.NotData -> { + Text( + text = stringResource(R.string.not_book), + modifier = Modifier + .testTag(TestIds.Book.EMPTY) + ) + } + } + Button( + onClick = { + viewModel.onIntent(BookIntent.BackToMainScreen) + }, + modifier = Modifier + .testTag(TestIds.Book.BACK_BUTTON) + ) { + Text(stringResource(R.string.back)) + } + } +} + + +@Composable +fun TabGroup(options: Set) { + NavigationBar( + Modifier.fillMaxWidth() + ) { + options.forEachIndexed { index, label -> + NavigationBarItem( + selected = currentTime.value == label, + onClick = { + currentTime.value = label + }, + icon = { + Text( + text = label, + modifier = Modifier + .testTag(TestIds.Book.ITEM_DATE) + ) + }, + modifier = Modifier + .testTag(TestIds.Book.getIdDateItemByPosition(index)) + ) + } + } +} + +@Composable +fun SelectBooking(options: List) { + LazyColumn( + Modifier + .fillMaxWidth() + ) { + options.forEachIndexed { index, book -> + item { + Row( + Modifier + .fillMaxWidth() + .selectableGroup() + .testTag(TestIds.Book.getIdPlaceItemByPosition(index)), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = book.place, + modifier = Modifier + .testTag(TestIds.Book.ITEM_PLACE_TEXT) + ) + RadioButton( + selected = book == selectedBooking.value && currentTime.value == selectedTime.value, + onClick = { + selectedBooking.value = book + selectedTime.value = currentTime.value + }, + modifier = Modifier + .testTag(TestIds.Book.ITEM_PLACE_SELECTOR) + ) + } + } + } + } +} + diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt new file mode 100644 index 0000000..1b32a2f --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt @@ -0,0 +1,14 @@ +package ru.myitschool.work.ui.screen.book + +import ru.myitschool.work.ui.screen.Booking + +sealed interface BookState { + object Loading: BookState + data class Data( + val booking : Map> = mapOf() + ): BookState + data class Error( + val error : String + ): BookState + object NotData : BookState +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt new file mode 100644 index 0000000..ce5a423 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt @@ -0,0 +1,125 @@ +package ru.myitschool.work.ui.screen.book + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +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.data.repo.BookRepository +import ru.myitschool.work.ui.screen.Booking + +class BookViewModel : ViewModel() { + private val _uiState = MutableStateFlow(BookState.Loading) + val uiState: StateFlow = _uiState.asStateFlow(); + + private val _actionFlow: MutableSharedFlow = MutableSharedFlow() + val actionFlow: SharedFlow = _actionFlow + private val _navigationFlow = MutableSharedFlow() + val navigationFlow: SharedFlow = _navigationFlow + + init { + loadData() + } + + private fun loadData() { + _uiState.update { BookState.Loading } + viewModelScope.launch(Dispatchers.IO) { + val code = AuthRepository.getSavedCode() ?: run { + onIntent(BookIntent.ToAuthScreen) + Log.d("", "Go to AuthScreen") + return@launch + } + Log.d("", "Проверка") +// _uiState.update { +// BookState.Data( +// listOf( +// Booking( +// "19.04", +// "Рабочее место у окна" +// ), +// Booking( +// "19.04", +// "Переговорная комната № 1" +// ), +// Booking( +// "19.04", +// "Коворкинг А" +// ), +// Booking( +// "20.04", +// "Кабинет № 33" +// ), +// ).toMap() +// ) +// } + BookRepository.loadBooks(code).fold( + onSuccess = { + it.let { bookings -> + _uiState.update { + when (bookings.isEmpty()) { + true -> BookState.Data( + booking = bookings.toMap() + ) + false -> BookState.NotData + } + } + } + }, + onFailure = { error -> + _uiState.update { + BookState.Error( + error = error.message ?: "Не удалось загрузить данные" + ) + } + } + ) + } + } + + fun onIntent(intent: BookIntent) { + when (intent) { + is BookIntent.Send -> { + viewModelScope.launch(Dispatchers.Default) { + _uiState.update { BookState.Loading } + BookRepository.sendData(intent.code, intent.booking).fold( + onSuccess = { + _actionFlow.emit(Unit) + }, + onFailure = { error -> + BookState.Error(error.message ?: "Неизвестная ошибка") + } + ) + } + } + BookIntent.BackToMainScreen -> { + _navigationFlow.tryEmit(BookNavigationEvent.NavigateToMain) + } + BookIntent.LoadData -> loadData() + BookIntent.ToAuthScreen -> { + _navigationFlow.tryEmit(BookNavigationEvent.NavigateToAuth) + } + } + } +} + +fun List.toMap() : Map> { + val options = this + val map : MutableMap> = mutableMapOf() + options.forEach { + if (map[it.date] == null) map[it.date] = mutableListOf(it) + else map[it.date]?.add(it) + } + return map +} + +sealed interface BookNavigationEvent { + object NavigateToAuth : BookNavigationEvent + object NavigateToMain : BookNavigationEvent +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fa8bda6..fa27628 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,4 +4,8 @@ Привет! Введи код для авторизации Код Войти + забронировать + Назад + Всё забранировано + Обновить \ No newline at end of file