add booking to main branch #4

Merged
student-18211 merged 3 commits from student-15047/NTO-2025-Android-TeamTask:main into main 2025-12-05 17:24:18 +00:00
14 changed files with 481 additions and 17 deletions

View File

@@ -1,7 +1,7 @@
package ru.myitschool.work.core package ru.myitschool.work.core
object Constants { 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 AUTH_URL = "/auth"
const val INFO_URL = "/info" const val INFO_URL = "/info"
const val BOOKING_URL = "/booking" const val BOOKING_URL = "/booking"

View File

@@ -1,11 +1,21 @@
package ru.myitschool.work.data.repo package ru.myitschool.work.data.repo
import android.content.Context
import ru.myitschool.work.App
import ru.myitschool.work.data.source.NetworkDataSource import ru.myitschool.work.data.source.NetworkDataSource
object AuthRepository { object AuthRepository {
private var codeCache: String? = null 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<Boolean> { suspend fun checkAndSave(text: String): Result<Boolean> {
return NetworkDataSource.checkAuth(text).onSuccess { success -> return NetworkDataSource.checkAuth(text).onSuccess { success ->
if (success) { if (success) {

View File

@@ -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<List<Booking>> {
return NetworkDataSource.getFreeBooking(code)
}
suspend fun sendData(code: String, booking: Booking) : Result<Boolean> {
return NetworkDataSource.createNewBooking(code, booking)
}
}

View File

@@ -1,16 +1,23 @@
package ru.myitschool.work.data.source package ru.myitschool.work.data.source
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.InternalAPI
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.myitschool.work.core.Constants import ru.myitschool.work.core.Constants
import ru.myitschool.work.ui.screen.Booking
import ru.myitschool.work.ui.screen.UserInfo
object NetworkDataSource { object NetworkDataSource {
private val client by lazy { private val client by lazy {
@@ -31,6 +38,40 @@ 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 { return@withContext runCatching {
val response = client.get(getUrl(code, Constants.AUTH_URL)) val response = client.get(getUrl(code, Constants.AUTH_URL))
when (response.status) {
HttpStatusCode.OK -> true
else -> error("Неверный код для авторизации")
}
}
}
suspend fun getInfo(code: String): Result<UserInfo> = 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<List<Booking>> = 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<Boolean> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response = client.post(getUrl(code, Constants.BOOK_URL)) {
contentType(ContentType.Application.Json)
body = booking
}
when (response.status) { when (response.status) {
HttpStatusCode.OK -> true HttpStatusCode.OK -> true
else -> error(response.bodyAsText()) else -> error(response.bodyAsText())

View File

@@ -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<Booking>
)
@Serializable
data class Booking(
val date: String,
val place: String
)

View File

@@ -7,6 +7,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
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
@@ -15,6 +16,8 @@ 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.book.BookScreen
import ru.myitschool.work.ui.screen.book.BookViewModel
@Composable @Composable
fun AppNavHost( fun AppNavHost(
@@ -39,11 +42,10 @@ fun AppNavHost(
} }
} }
composable<BookScreenDestination> { composable<BookScreenDestination> {
Box( BookScreen(
contentAlignment = Alignment.Center viewModel = BookViewModel(),
) { navController = navController
Text(text = "Hello") )
}
} }
} }
} }

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -21,7 +22,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
@@ -74,6 +74,11 @@ private fun Content(
state: AuthState.Data state: AuthState.Data
) { ) {
var inputText by remember { mutableStateOf("") } 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)) Spacer(modifier = Modifier.size(16.dp))
TextField( TextField(
modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(), modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(),
@@ -82,15 +87,23 @@ 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)) },
) )
if (state.error != null) {
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 = isValidCode
) { ) {
Text(stringResource(R.string.auth_sign_in)) Text(stringResource(R.string.auth_sign_in))
} }

View File

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

View File

@@ -15,7 +15,7 @@ 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<Unit> = MutableSharedFlow()
@@ -26,18 +26,21 @@ class AuthViewModel : ViewModel() {
is AuthIntent.Send -> { is AuthIntent.Send -> {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
_uiState.update { AuthState.Loading } _uiState.update { AuthState.Loading }
checkAndSaveAuthCodeUseCase.invoke("9999").fold( checkAndSaveAuthCodeUseCase.invoke(intent.text).fold(
onSuccess = { onSuccess = {
_actionFlow.emit(Unit) _actionFlow.emit(Unit)// переход на MainScreen
}, },
onFailure = { error -> onFailure = { error ->
error.printStackTrace() _uiState.update {
_actionFlow.emit(Unit) AuthState.Data(error.message ?: "Неверный код для авторизации")
}
} }
) )
} }
} }
is AuthIntent.TextInput -> Unit is AuthIntent.TextInput -> {
_uiState.update { AuthState.Data() }
}
} }
} }
} }

View File

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

View File

@@ -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<String?> = mutableStateOf(null)
var selectedBooking: MutableState<Booking?> = mutableStateOf(null)
var currentTime: MutableState<String?> = 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<String>) {
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<Booking>) {
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)
)
}
}
}
}
}

View File

@@ -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<String, List<Booking>> = mapOf()
): BookState
data class Error(
val error : String
): BookState
object NotData : BookState
}

View File

@@ -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>(BookState.Loading)
val uiState: StateFlow<BookState> = _uiState.asStateFlow();
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
val actionFlow: SharedFlow<Unit> = _actionFlow
private val _navigationFlow = MutableSharedFlow<BookNavigationEvent>()
val navigationFlow: SharedFlow<BookNavigationEvent> = _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<Booking>.toMap() : Map<String, List<Booking>> {
val options = this
val map : MutableMap<String, MutableList<Booking>> = 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
}

View File

@@ -4,4 +4,8 @@
<string name="auth_title">Привет! Введи код для авторизации</string> <string name="auth_title">Привет! Введи код для авторизации</string>
<string name="auth_label">Код</string> <string name="auth_label">Код</string>
<string name="auth_sign_in">Войти</string> <string name="auth_sign_in">Войти</string>
<string name="to_book">забронировать</string>
<string name="back">Назад</string>
<string name="not_book">Всё забранировано</string>
<string name="upadate">Обновить</string>
</resources> </resources>