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..690c2b0 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://192.168.1.74: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..1807f66 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,16 +1,42 @@ package ru.myitschool.work.data.repo +import android.content.Context +import android.util.Log +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 - suspend fun checkAndSave(text: String): Result { - return NetworkDataSource.checkAuth(text).onSuccess { success -> - if (success) { - codeCache = text + private fun loadSavedCode(): String? { + return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + .getString(KEY_SAVED_CODE, null) + } + + fun getSavedCode(): String? = loadSavedCode() + + suspend fun checkAndSave(text: String): Result { + return NetworkDataSource.checkAuth(text).fold( + onSuccess = { success -> + if (success) { + codeCache = text + context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + .edit() + .putString(KEY_SAVED_CODE, text) + .apply() + Result.success(Unit) + } else { + Result.failure(IllegalStateException("Неверный код для авторизации")) + } + }, + onFailure = { error -> + Log.e("AuthRepository", "Auth failed", error) + Result.failure(error) } - } + ) } } \ No newline at end of file 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..d36e059 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repo/BookRepository.kt @@ -0,0 +1,14 @@ +package ru.myitschool.work.data.repo + +import ru.myitschool.work.data.source.NetworkDataSource +import ru.myitschool.work.ui.screen.BookingItem + +object BookRepository { + suspend fun loadBooks(code: String): Result>> { + return NetworkDataSource.getFreeBooking(code) + } + + suspend fun sendData(code: String, data: String, placeId: Int) : Result { + return NetworkDataSource.createNewBooking(code, data, placeId) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/repo/MainRepository.kt b/app/src/main/java/ru/myitschool/work/data/repo/MainRepository.kt new file mode 100644 index 0000000..aff98a4 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repo/MainRepository.kt @@ -0,0 +1,16 @@ +package ru.myitschool.work.data.repo + +import androidx.core.content.edit +import ru.myitschool.work.data.source.NetworkDataSource +import ru.myitschool.work.ui.screen.UserInfo + +object MainRepository { + suspend fun loadUserInfo(code: String): Result { + return NetworkDataSource.getInfo(code) + } + fun clearAuth() { + val prefs = ru.myitschool.work.App.context + .getSharedPreferences("auth_prefs", android.content.Context.MODE_PRIVATE) + prefs.edit { clear() } + } +} \ 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..cea8a13 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,26 @@ package ru.myitschool.work.data.source +import android.util.Log 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.request.setBody 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.BookingItem +import ru.myitschool.work.ui.screen.CreateBookingRequest +import ru.myitschool.work.ui.screen.UserInfo object NetworkDataSource { private val client by lazy { @@ -28,15 +38,62 @@ object NetworkDataSource { } } + suspend fun checkAuth(code: String): Result = withContext(Dispatchers.IO) { return@withContext runCatching { val response = client.get(getUrl(code, Constants.AUTH_URL)) + Log.d("NetworkDataSource", "Auth response: ${response.status}") when (response.status) { HttpStatusCode.OK -> true - else -> error(response.bodyAsText()) + else -> error("Неверный код для авторизации") + } + }.onFailure { error -> + Log.e("NetworkDataSource", "Auth request failed", 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) { + runCatching { + val response = client.get(getUrl(code, Constants.BOOKING_URL)) + when (response.status) { + HttpStatusCode.OK -> { + // Используйте response.body с явным указанием типа + response.body>>() + } + else -> error(response.bodyAsText()) + } + } + } + + + @OptIn(InternalAPI::class) + suspend fun createNewBooking(code: String, data: String, placeId: Int): Result = + withContext(Dispatchers.IO) { + return@withContext runCatching { + val response = client.post(getUrl(code, Constants.BOOK_URL)) { + contentType(ContentType.Application.Json) + setBody(CreateBookingRequest(data, placeId)) // используйте setBody вместо body + } + + when (response.status) { + HttpStatusCode.OK -> true + else -> error(response.bodyAsText()) + } + } + } + private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl" } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/auth/CheckAndSaveAuthCodeUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/auth/CheckAndSaveAuthCodeUseCase.kt index 012fb6f..733d95c 100644 --- a/app/src/main/java/ru/myitschool/work/domain/auth/CheckAndSaveAuthCodeUseCase.kt +++ b/app/src/main/java/ru/myitschool/work/domain/auth/CheckAndSaveAuthCodeUseCase.kt @@ -5,11 +5,7 @@ import ru.myitschool.work.data.repo.AuthRepository class CheckAndSaveAuthCodeUseCase( private val repository: AuthRepository ) { - suspend operator fun invoke( - text: String - ): Result { - return repository.checkAndSave(text).mapCatching { success -> - if (!success) error("Code is incorrect") - } + suspend operator fun invoke(text: String): Result { + return repository.checkAndSave(text) } } \ No newline at end of file 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..0874863 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/BookingUserInfo.kt @@ -0,0 +1,38 @@ +package ru.myitschool.work.ui.screen + +import kotlinx.serialization.Serializable + +@Serializable +data class UserInfo( + val name: String, + val photoUrl: String? = null, + val booking: Map> +) { + fun bookingToList() : List { + val bookings = mutableListOf() + booking.forEach { + it.value.forEach { value -> + bookings.add(Booking(it.key, value.place)) + } + } + return bookings + } +} + +@Serializable +data class Booking( + val date: String, + val place: String +) + +@Serializable +data class BookingItem( + val id: Int, + val place: String +) + +@Serializable +data class CreateBookingRequest( + val date: String, + val placeId: Int +) \ 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..c662bb9 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 @@ -1,12 +1,11 @@ package ru.myitschool.work.ui.screen +import android.util.Log import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition -import androidx.compose.foundation.layout.Box -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 +14,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.main.MainScreen @Composable fun AppNavHost( @@ -29,21 +30,19 @@ fun AppNavHost( startDestination = AuthScreenDestination, ) { composable { + Log.d("compose", "Auth") AuthScreen(navController = navController) } composable { - Box( - contentAlignment = Alignment.Center - ) { - Text(text = "Hello") - } + Log.d("compose", "Main") + MainScreen(viewModel = viewModel(), navController) } composable { - Box( - contentAlignment = Alignment.Center - ) { - Text(text = "Hello") - } + Log.d("compose", "Book") + BookScreen( + viewModel = viewModel(), + 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..d77ac73 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 @@ -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 @@ -30,6 +29,7 @@ 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.MainScreenDestination @Composable @@ -45,6 +45,8 @@ fun AuthScreen( } } + AuthRepository.getSavedCode()?.let { viewModel.onIntent(AuthIntent.Send(it)) } + Column( modifier = Modifier .fillMaxSize() @@ -74,6 +76,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 +89,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..e6d3779 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() @@ -24,20 +24,23 @@ class AuthViewModel : ViewModel() { fun onIntent(intent: AuthIntent) { when (intent) { is AuthIntent.Send -> { - viewModelScope.launch(Dispatchers.Default) { + viewModelScope.launch(Dispatchers.IO) { _uiState.update { AuthState.Loading } - checkAndSaveAuthCodeUseCase.invoke("9999").fold( + checkAndSaveAuthCodeUseCase.invoke(intent.text).fold( onSuccess = { _actionFlow.emit(Unit) }, 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..d983c38 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt @@ -0,0 +1,8 @@ +package ru.myitschool.work.ui.screen.book + +sealed interface BookIntent { + data class Send(val code : String, val data: String, val placeId : Int) : 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..b76cd92 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt @@ -0,0 +1,220 @@ +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.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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 java.time.LocalDate +import java.time.format.DateTimeFormatter + + +@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 { event -> + Log.d("navigation", "Event received: $event") + when (event) { + 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 -> { + var selectedTime : String? by remember { mutableStateOf(null) } + var selectedBooking : Int? by remember { mutableStateOf(null) } + var currentTime : String? by remember { mutableStateOf(null) } + + val options = (state as BookState.Data).booking + + if (currentTime == null) + for (el in options) { + if (!el.value.isEmpty()) { + currentTime = el.key + break + } + } + + NavigationBar( + Modifier.fillMaxWidth() + ) { + val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + val outputFormatter = DateTimeFormatter.ofPattern("dd.MM") + + options.keys + .map { LocalDate.parse(it, inputFormatter) } + .sorted() +// .map { it.format(inputFormatter) } + .forEachIndexed { index, label -> + options[label.format(inputFormatter)]?.let { + if (!it.isEmpty()) { + NavigationBarItem( + selected = currentTime == label.format(inputFormatter), + onClick = { + currentTime = label.format(inputFormatter) + }, + icon = { + Text( + text = label.format(outputFormatter), + modifier = Modifier + .testTag(TestIds.Book.ITEM_DATE) + ) + }, + modifier = Modifier + .testTag(TestIds.Book.getIdDateItemByPosition(index)) + ) + } + } + } + } + + LazyColumn( + Modifier + .fillMaxWidth() + ) { + options[currentTime]?.forEach { book -> + item { + Row( + Modifier + .fillMaxWidth() + .selectableGroup() + .testTag(TestIds.Book.getIdPlaceItemByPosition(book.id)), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = book.place, + modifier = Modifier + .testTag(TestIds.Book.ITEM_PLACE_TEXT) + ) + RadioButton( + selected = book.id == selectedBooking && currentTime == selectedTime, + onClick = { + selectedBooking = book.id + selectedTime = currentTime + }, + modifier = Modifier + .testTag(TestIds.Book.ITEM_PLACE_SELECTOR) + ) + } + } + } + } + + Button( + onClick = { + viewModel.onIntent(BookIntent.Send(AuthRepository.getSavedCode()!!, selectedTime!!, selectedBooking!!)) + }, + modifier = Modifier + .testTag(TestIds.Book.BOOK_BUTTON), + enabled = selectedBooking != 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)) + } + } +} + + + + 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..ff8c214 --- /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.BookingItem + +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..787127d --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt @@ -0,0 +1,121 @@ +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 + +class BookViewModel : ViewModel() { + private val _uiState = MutableStateFlow(BookState.Loading) + val uiState: StateFlow = _uiState.asStateFlow(); + private val _navigationFlow = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val navigationFlow: SharedFlow = _navigationFlow + + init { + loadData() + } + + private fun loadData() { + viewModelScope.launch(Dispatchers.IO) { + _uiState.update { BookState.Loading } + val code = AuthRepository.getSavedCode() ?: run { + _navigationFlow.emit(BookNavigationEvent.NavigateToAuth) + 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 { + Log.d("test", bookings.isEmpty().toString()) + when (!bookings.isEmpty()) { + true -> BookState.Data( + booking = bookings + ) + false -> BookState.NotData + } + } + } + }, + onFailure = { error -> + _uiState.update { + BookState.Error( + error = error.message ?: "Не удалось загрузить данные" + ) + } + } + ) + } + } + + fun onIntent(intent: BookIntent) { + when (intent) { + is BookIntent.Send -> { + viewModelScope.launch(Dispatchers.IO) { + _uiState.update { BookState.Loading } + BookRepository.sendData(intent.code, intent.data, intent.placeId).fold( + onSuccess = { + Log.d("send date", "success") + _navigationFlow.emit(BookNavigationEvent.NavigateToMain) + }, + onFailure = { error -> + Log.d("send date", "error: $error") + _uiState.update { + BookState.Error(error.message ?: "Неизвестная ошибка") + } + } + ) + } + } + BookIntent.BackToMainScreen -> { + viewModelScope.launch { + _navigationFlow.emit(BookNavigationEvent.NavigateToMain) + } + } + BookIntent.LoadData -> loadData() + BookIntent.ToAuthScreen -> { + viewModelScope.launch { + _navigationFlow.emit(BookNavigationEvent.NavigateToAuth) + } + } + } + } +} + + +sealed interface BookNavigationEvent { + object NavigateToAuth : BookNavigationEvent + object NavigateToMain : BookNavigationEvent +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt new file mode 100644 index 0000000..d7b99f3 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt @@ -0,0 +1,7 @@ +package ru.myitschool.work.ui.screen.main + +sealed interface MainIntent { + data object LoadData : MainIntent + data object Logout : MainIntent + data object AddBooking : MainIntent +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt new file mode 100644 index 0000000..507ac27 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt @@ -0,0 +1,223 @@ +package ru.myitschool.work.ui.screen.main + +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.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.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import coil3.compose.AsyncImage +import ru.myitschool.work.core.TestIds +import ru.myitschool.work.ui.nav.AuthScreenDestination +import ru.myitschool.work.ui.nav.BookScreenDestination +import ru.myitschool.work.ui.screen.Booking + +@Composable +fun MainScreen( + viewModel: MainViewModel = viewModel(), + navController: NavController +) { + val state by viewModel.uiState.collectAsState() + LaunchedEffect(state) { + Log.d("MainScreen", "UI State: $state") + } + LaunchedEffect(viewModel) { + viewModel.navigationFlow.collect { event -> + when (event) { + MainNavigationEvent.NavigateToAuth -> { + navController.navigate(AuthScreenDestination) { + popUpTo(navController.graph.startDestinationId) { inclusive = true } + } + } + MainNavigationEvent.NavigateToBook -> { + navController.navigate(BookScreenDestination) + } + } + } + } + + when (state) { + MainState.Loading -> { + CircularProgressIndicator( + modifier = Modifier + .fillMaxSize() + .wrapContentSize(Alignment.Center) + ) + } + is MainState.Content -> { + Content( + state = state as MainState.Content, + onRefresh = { viewModel.onIntent(MainIntent.LoadData) }, + onLogout = { viewModel.onIntent(MainIntent.Logout) }, + onAddBooking = { viewModel.onIntent(MainIntent.AddBooking) } + ) + } + is MainState.ErrorOnly -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = (state as MainState.ErrorOnly).message, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag(TestIds.Main.ERROR) + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { viewModel.onIntent(MainIntent.LoadData) }, + modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON) + ) { + Text("Обновить") + } + } + } + } +} + +@Composable +private fun Content( + state: MainState.Content, + onRefresh: () -> Unit, + onLogout: () -> Unit, + onAddBooking: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + state.userPhotoUrl?.let { url -> + AsyncImage( + model = url, + contentDescription = "Аватар", + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .testTag(TestIds.Main.PROFILE_IMAGE) + ) + } + state.userName?.let { + Text( + text = it, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.testTag(TestIds.Main.PROFILE_NAME) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Button( + onClick = onLogout, + modifier = Modifier.testTag(TestIds.Main.LOGOUT_BUTTON) + ) { + Text("Выйти") + } + Button( + onClick = onRefresh, + modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON) + ) { + Text("Обновить") + } + Button( + onClick = onAddBooking, + modifier = Modifier.testTag(TestIds.Main.ADD_BUTTON) + ) { + Text("Забронировать") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + if (state.error != null) { + Text( + text = state.error, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag(TestIds.Main.ERROR) + ) + Spacer(modifier = Modifier.height(8.dp)) + } + Text( + text = "Бронирования", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .fillMaxWidth() + .testTag("main_bookings_title") + ) + + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Дата", + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.testTag("main_bookings_header_date") + ) + Text( + text = "Место", + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.testTag("main_bookings_header_place") + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + LazyColumn(modifier = Modifier.fillMaxSize()) { + itemsIndexed(state.bookings) { index, item -> + BookingItemView(booking = item, index = index) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } +} +@Composable +private fun BookingItemView(booking: Booking, index: Int) { + Row( + modifier = Modifier + .fillMaxWidth() + .testTag(TestIds.Main.getIdItemByPosition(index)) + .padding(8.dp) + ) { + Text( + text = booking.date, + modifier = Modifier.testTag(TestIds.Main.ITEM_DATE) + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = booking.place, + modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt new file mode 100644 index 0000000..29e8434 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt @@ -0,0 +1,16 @@ +package ru.myitschool.work.ui.screen.main + +import ru.myitschool.work.ui.screen.Booking + +sealed interface MainState { + object Loading : MainState + data class Content( + val userName: String?, + val userPhotoUrl: String?, + val bookings: List, + val error: String? = null + ) : MainState + data class ErrorOnly( + val message: String + ) : MainState +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt new file mode 100644 index 0000000..842ddfb --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt @@ -0,0 +1,102 @@ +package ru.myitschool.work.ui.screen.main + +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.launch +import kotlinx.coroutines.withContext +import ru.myitschool.work.data.repo.AuthRepository +import ru.myitschool.work.data.repo.MainRepository +import ru.myitschool.work.ui.screen.Booking +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +class MainViewModel : ViewModel() { + private val _uiState = MutableStateFlow(MainState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _navigationFlow = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val navigationFlow: SharedFlow = _navigationFlow + fun formatDateString(isoDate: String): String { + val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + val outputFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy") + val date = LocalDate.parse(isoDate, inputFormatter) + return date.format(outputFormatter) + } + init { + loadData() + } + + private fun loadData() { + viewModelScope.launch(Dispatchers.IO) { + withContext(Dispatchers.Main) { + _uiState.value = MainState.Loading + } + + val code = AuthRepository.getSavedCode() ?: run { + _navigationFlow.emit(MainNavigationEvent.NavigateToAuth) + return@launch + } + + MainRepository.loadUserInfo(code).fold( + onSuccess = { userInfo -> + val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + val outputFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy") + + val sortedBookings = userInfo.bookingToList() + .sortedBy { LocalDate.parse(it.date, inputFormatter) } + .map { booking -> + Booking( + date = LocalDate.parse(booking.date, inputFormatter).format(outputFormatter), + place = booking.place + ) + } + + withContext(Dispatchers.Main) { + _uiState.value = MainState.Content( + userName = userInfo.name, + userPhotoUrl = userInfo.photoUrl ?: "", + bookings = sortedBookings, + error = null + ) + } + }, + onFailure = { error -> + Log.e("MainViewModel", "Ошибка загрузки", error) + withContext(Dispatchers.Main) { + _uiState.value = MainState.ErrorOnly( + error.message ?: "Не удалось загрузить данные" + ) + } + } + ) + } + } + + fun onIntent(intent: MainIntent) { + when (intent) { + MainIntent.LoadData -> loadData() + MainIntent.Logout -> { + MainRepository.clearAuth() + viewModelScope.launch { + _navigationFlow.emit(MainNavigationEvent.NavigateToAuth) + } + } + MainIntent.AddBooking -> { + viewModelScope.launch { + _navigationFlow.emit(MainNavigationEvent.NavigateToBook) + } + } + } + } +} +sealed interface MainNavigationEvent { + object NavigateToAuth : MainNavigationEvent + object NavigateToBook : MainNavigationEvent +} \ 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