forked from Olympic/NTO-2025-Android-TeamTask
commit 1
This commit is contained in:
@@ -4,7 +4,8 @@ import ru.myitschool.work.data.source.NetworkDataSource
|
||||
|
||||
object AuthRepository {
|
||||
|
||||
private var codeCache: String? = null
|
||||
var codeCache: String? = null
|
||||
private set
|
||||
|
||||
suspend fun checkAndSave(text: String): Result<Boolean> {
|
||||
return NetworkDataSource.checkAuth(text).onSuccess { success ->
|
||||
@@ -13,4 +14,8 @@ object AuthRepository {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
codeCache = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.myitschool.work.data.repo
|
||||
|
||||
import ru.myitschool.work.data.source.NetworkDataSource
|
||||
|
||||
class BookingRepository {
|
||||
suspend fun getBookingInfo() = NetworkDataSource.getBookingInfo()
|
||||
suspend fun bookPlace(date: String, placeId: Int) = NetworkDataSource.bookPlace(date, placeId)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.myitschool.work.data.repo
|
||||
|
||||
import ru.myitschool.work.data.source.NetworkDataSource
|
||||
|
||||
class InfoRepository {
|
||||
suspend fun getInfo() = NetworkDataSource.getInfo()
|
||||
}
|
||||
@@ -4,13 +4,37 @@ import io.ktor.client.HttpClient
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import ru.myitschool.work.core.Constants
|
||||
import ru.myitschool.work.data.repo.AuthRepository
|
||||
|
||||
data class Info(
|
||||
val name: String,
|
||||
val photoUrl: String,
|
||||
val bookings: List<Booking>
|
||||
)
|
||||
|
||||
data class Booking(
|
||||
val date: String,
|
||||
val place: String
|
||||
)
|
||||
|
||||
typealias BookingInfo = JsonObject
|
||||
|
||||
object NetworkDataSource {
|
||||
private val client by lazy {
|
||||
@@ -33,10 +57,97 @@ object NetworkDataSource {
|
||||
val response = client.get(getUrl(code, Constants.AUTH_URL))
|
||||
when (response.status) {
|
||||
HttpStatusCode.OK -> true
|
||||
HttpStatusCode.BadRequest -> error("Bad request")
|
||||
HttpStatusCode.Unauthorized -> error("Unauthorized")
|
||||
else -> error(response.bodyAsText())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getInfo(): Result<Info> = withContext(Dispatchers.IO) {
|
||||
return@withContext runCatching {
|
||||
val code = AuthRepository.codeCache ?: error("No auth code")
|
||||
val response = client.get(getUrl(code, Constants.INFO_URL))
|
||||
when (response.status) {
|
||||
HttpStatusCode.OK -> {
|
||||
val json = response.bodyAsText()
|
||||
val jsonElement = Json.parseToJsonElement(json)
|
||||
if (jsonElement !is JsonObject) {
|
||||
error("Response is not a JSON object")
|
||||
}
|
||||
val name = jsonElement["name"]?.jsonPrimitive?.content ?: ""
|
||||
val photoUrl = jsonElement["photoUrl"]?.jsonPrimitive?.content ?: ""
|
||||
val bookingsElement = jsonElement["booking"]
|
||||
val bookings = mutableListOf<Booking>()
|
||||
if (bookingsElement is JsonObject) {
|
||||
for ((isoDate, bookingElement) in bookingsElement) {
|
||||
if (bookingElement is JsonObject) {
|
||||
val date = formatDate(isoDate)
|
||||
val place = bookingElement["place"]?.jsonPrimitive?.content ?: ""
|
||||
bookings.add(Booking(date, place))
|
||||
}
|
||||
}
|
||||
}
|
||||
Info(name, photoUrl, bookings)
|
||||
}
|
||||
HttpStatusCode.BadRequest -> error("Bad request")
|
||||
HttpStatusCode.Unauthorized -> error("Unauthorized")
|
||||
else -> error(response.bodyAsText())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDate(isoDate: String): String {
|
||||
return try {
|
||||
val parts = isoDate.split("-")
|
||||
"${parts[2]}.${parts[1]}.${parts[0]}"
|
||||
} catch (e: Exception) {
|
||||
isoDate
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getBookingInfo(): Result<BookingInfo> = withContext(Dispatchers.IO) {
|
||||
return@withContext runCatching {
|
||||
val code = AuthRepository.codeCache ?: error("No auth code")
|
||||
val response = client.get(getUrl(code, Constants.BOOKING_URL))
|
||||
when (response.status) {
|
||||
HttpStatusCode.OK -> {
|
||||
val json = response.bodyAsText()
|
||||
val jsonElement = Json.parseToJsonElement(json)
|
||||
if (jsonElement is JsonObject) {
|
||||
jsonElement
|
||||
} else {
|
||||
error("Response is not a JSON object")
|
||||
}
|
||||
}
|
||||
HttpStatusCode.BadRequest -> error("Bad request")
|
||||
HttpStatusCode.Unauthorized -> error("Unauthorized")
|
||||
else -> error(response.bodyAsText())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun bookPlace(date: String, placeId: Int): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
return@withContext runCatching {
|
||||
val code = AuthRepository.codeCache ?: error("No auth code")
|
||||
val response = client.post(getUrl(code, Constants.BOOK_URL)) {
|
||||
setBody(BookRequest(date, placeId))
|
||||
contentType(io.ktor.http.ContentType.Application.Json)
|
||||
}
|
||||
when (response.status) {
|
||||
HttpStatusCode.Created -> Unit
|
||||
HttpStatusCode.Conflict -> error("Already booked")
|
||||
HttpStatusCode.BadRequest -> error("Bad request")
|
||||
HttpStatusCode.Unauthorized -> error("Unauthorized")
|
||||
else -> error(response.bodyAsText())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class BookRequest(
|
||||
val date: String,
|
||||
val placeId: Int
|
||||
)
|
||||
|
||||
private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl"
|
||||
}
|
||||
@@ -9,7 +9,7 @@ class CheckAndSaveAuthCodeUseCase(
|
||||
text: String
|
||||
): Result<Unit> {
|
||||
return repository.checkAndSave(text).mapCatching { success ->
|
||||
if (!success) error("Code is Incorrect")
|
||||
if (!success) error("Code is incorrect")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package ru.myitschool.work.domain.booking
|
||||
|
||||
import ru.myitschool.work.data.repo.BookingRepository
|
||||
import ru.myitschool.work.data.source.BookingInfo as SourceBookingInfo
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
data class PlaceInfo(
|
||||
val id: Int,
|
||||
val name: String
|
||||
)
|
||||
|
||||
data class BookingInfo(
|
||||
val availableDates: List<String>,
|
||||
val availablePlaces: Map<String, List<PlaceInfo>>
|
||||
)
|
||||
|
||||
class GetBookingInfoUseCase(
|
||||
private val repository: BookingRepository
|
||||
) {
|
||||
private fun formatDate(isoDate: String): String {
|
||||
return try {
|
||||
val parts = isoDate.split("-")
|
||||
"${parts[2]}.${parts[1]}.${parts[0]}"
|
||||
} catch (e: Exception) {
|
||||
isoDate
|
||||
}
|
||||
}
|
||||
|
||||
suspend operator fun invoke(): Result<BookingInfo> {
|
||||
return repository.getBookingInfo().map { sourceInfo ->
|
||||
val availableDates = mutableListOf<String>()
|
||||
val availablePlaces = mutableMapOf<String, List<PlaceInfo>>()
|
||||
|
||||
// Итерируемся по всем элементам JSON объекта
|
||||
for ((isoDate, placesElement) in sourceInfo) {
|
||||
val date = formatDate(isoDate)
|
||||
availableDates.add(date)
|
||||
|
||||
if (placesElement is JsonArray) {
|
||||
val places = placesElement.mapNotNull { placeElement ->
|
||||
if (placeElement is JsonObject) {
|
||||
val id = placeElement["id"]?.jsonPrimitive?.int ?: 0
|
||||
val placeName = placeElement["place"]?.jsonPrimitive?.content ?: ""
|
||||
if (id != null && placeName != null) {
|
||||
PlaceInfo(id = id, name = placeName)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
availablePlaces[date] = places
|
||||
}
|
||||
}
|
||||
|
||||
BookingInfo(
|
||||
availableDates = availableDates.sorted(),
|
||||
availablePlaces = availablePlaces
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BookPlaceUseCase(
|
||||
private val repository: BookingRepository
|
||||
) {
|
||||
suspend operator fun invoke(date: String, placeId: Int): Result<Unit> {
|
||||
return repository.bookPlace(date, placeId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package ru.myitschool.work.domain.info
|
||||
|
||||
import ru.myitschool.work.data.repo.InfoRepository
|
||||
import ru.myitschool.work.data.source.Info as SourceInfo
|
||||
import ru.myitschool.work.data.source.Booking as SourceBooking
|
||||
|
||||
data class Info(
|
||||
val name: String,
|
||||
val photoUrl: String,
|
||||
val bookings: List<Booking>
|
||||
)
|
||||
|
||||
data class Booking(
|
||||
val date: String,
|
||||
val place: String
|
||||
)
|
||||
|
||||
class GetInfoUseCase(
|
||||
private val repository: InfoRepository
|
||||
) {
|
||||
suspend operator fun invoke(): Result<Info> {
|
||||
return repository.getInfo().map { sourceInfo ->
|
||||
Info(
|
||||
name = sourceInfo.name,
|
||||
photoUrl = sourceInfo.photoUrl,
|
||||
bookings = sourceInfo.bookings.map { sourceBooking ->
|
||||
Booking(
|
||||
date = sourceBooking.date,
|
||||
place = sourceBooking.place
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,14 @@
|
||||
package ru.myitschool.work.ui.nav
|
||||
|
||||
sealed interface AppDestination
|
||||
import androidx.navigation.NavDestination
|
||||
|
||||
sealed interface AppDestination {
|
||||
val route: String
|
||||
get() = javaClass.simpleName
|
||||
}
|
||||
|
||||
val AppDestination.asRoute: String
|
||||
get() = route
|
||||
|
||||
fun AppDestination.matches(destination: NavDestination?): Boolean =
|
||||
destination?.route?.startsWith(route) == true
|
||||
@@ -10,6 +10,12 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.ui.Modifier
|
||||
import ru.myitschool.work.ui.screen.AppNavHost
|
||||
import ru.myitschool.work.ui.theme.WorkTheme
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import ru.myitschool.work.ui.nav.AuthScreenDestination
|
||||
import ru.myitschool.work.ui.nav.MainScreenDestination
|
||||
import ru.myitschool.work.data.repo.AuthRepository
|
||||
import ru.myitschool.work.ui.nav.asRoute
|
||||
|
||||
|
||||
class RootActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -21,7 +27,20 @@ class RootActivity : ComponentActivity() {
|
||||
AppNavHost(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(innerPadding),
|
||||
navController = rememberNavController().apply {
|
||||
addOnDestinationChangedListener { controller, destination, arguments ->
|
||||
if (destination.route == MainScreenDestination.route && AuthRepository.codeCache == null) {
|
||||
controller.navigate(AuthScreenDestination) {
|
||||
popUpTo(controller.graph.startDestinationId) { inclusive = true }
|
||||
}
|
||||
} else if (destination.route == AuthScreenDestination.route && AuthRepository.codeCache != null) {
|
||||
controller.navigate(MainScreenDestination) {
|
||||
popUpTo(controller.graph.startDestinationId) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ 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(
|
||||
@@ -32,18 +35,10 @@ fun AppNavHost(
|
||||
AuthScreen(navController = navController)
|
||||
}
|
||||
composable<MainScreenDestination> {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = "Hello")
|
||||
}
|
||||
MainScreen(navController = navController)
|
||||
}
|
||||
composable<BookScreenDestination> {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = "Hello")
|
||||
}
|
||||
BookScreen(navController = navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ private fun Content(
|
||||
viewModel: AuthViewModel,
|
||||
state: AuthState.Data
|
||||
) {
|
||||
var inputText by remember { mutableStateOf("") }
|
||||
var inputText by remember { mutableStateOf(state.inputCode) }
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
TextField(
|
||||
modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(),
|
||||
@@ -82,15 +82,24 @@ private fun Content(
|
||||
inputText = it
|
||||
viewModel.onIntent(AuthIntent.TextInput(it))
|
||||
},
|
||||
label = { Text(stringResource(R.string.auth_label)) }
|
||||
label = { Text(stringResource(R.string.auth_label)) },
|
||||
isError = state.error != null
|
||||
)
|
||||
if (state.error != null) {
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = state.error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.testTag(TestIds.Auth.ERROR)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
Button(
|
||||
modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(),
|
||||
onClick = {
|
||||
viewModel.onIntent(AuthIntent.Send(inputText))
|
||||
},
|
||||
enabled = true
|
||||
enabled = inputText.isNotBlank() && inputText.length == 4 && inputText.matches(Regex("^[a-zA-Z0-9]*$"))
|
||||
) {
|
||||
Text(stringResource(R.string.auth_sign_in))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package ru.myitschool.work.ui.screen.auth
|
||||
|
||||
sealed interface AuthState {
|
||||
object Loading: AuthState
|
||||
object Data: AuthState
|
||||
object Loading : AuthState
|
||||
data class Data(
|
||||
val error: String? = null,
|
||||
val inputCode: String = ""
|
||||
) : AuthState
|
||||
}
|
||||
@@ -15,29 +15,62 @@ import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
|
||||
|
||||
class AuthViewModel : ViewModel() {
|
||||
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()
|
||||
|
||||
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
|
||||
val actionFlow: SharedFlow<Unit> = _actionFlow
|
||||
|
||||
init {
|
||||
// Проверяем, есть ли уже сохранённый код авторизации
|
||||
if (AuthRepository.codeCache != null) {
|
||||
viewModelScope.launch {
|
||||
_actionFlow.emit(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onIntent(intent: AuthIntent) {
|
||||
when (intent) {
|
||||
is AuthIntent.Send -> {
|
||||
val code = intent.text
|
||||
if (code.isEmpty()) {
|
||||
_uiState.update { AuthState.Data(error = "Код не может быть пустым") }
|
||||
return
|
||||
}
|
||||
|
||||
if (code.length != 4) {
|
||||
_uiState.update { AuthState.Data(error = "Код должен содержать 4 символа") }
|
||||
return
|
||||
}
|
||||
|
||||
if (!code.matches(Regex("^[a-zA-Z0-9]*$"))) {
|
||||
_uiState.update { AuthState.Data(error = "Код может содержать только латинские буквы и цифры") }
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
_uiState.update { AuthState.Loading }
|
||||
checkAndSaveAuthCodeUseCase.invoke("9999").fold(
|
||||
checkAndSaveAuthCodeUseCase.invoke(code).fold(
|
||||
onSuccess = {
|
||||
_actionFlow.emit(Unit)
|
||||
},
|
||||
onFailure = { error ->
|
||||
error.printStackTrace()
|
||||
_uiState.update { AuthState.Data(error = "Неверный код авторизации", inputCode = code) }
|
||||
_actionFlow.emit(Unit)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
is AuthIntent.TextInput -> Unit
|
||||
is AuthIntent.TextInput -> {
|
||||
_uiState.update { currentState ->
|
||||
when (currentState) {
|
||||
is AuthState.Data -> currentState.copy(inputCode = intent.text, error = null)
|
||||
else -> currentState
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package ru.myitschool.work.ui.screen.book
|
||||
|
||||
import ru.myitschool.work.domain.booking.PlaceInfo
|
||||
|
||||
sealed interface BookIntent {
|
||||
data class SelectDate(val date: String) : BookIntent
|
||||
data class SelectPlace(val place: PlaceInfo) : BookIntent
|
||||
object Book : BookIntent
|
||||
object Refresh : BookIntent
|
||||
object Back : BookIntent
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package ru.myitschool.work.ui.screen.book
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
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.ui.nav.MainScreenDestination
|
||||
import ru.myitschool.work.domain.booking.PlaceInfo
|
||||
|
||||
@Composable
|
||||
fun BookScreen(
|
||||
viewModel: BookViewModel = viewModel(),
|
||||
navController: NavController
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.actionFlow.collect {
|
||||
// При успешном бронировании или возврате - возвращаемся на главный экран
|
||||
navController.navigateUp()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(all = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
// Кнопка возврата
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestIds.Book.BACK_BUTTON),
|
||||
onClick = {
|
||||
viewModel.onIntent(BookIntent.Back)
|
||||
}
|
||||
) {
|
||||
Text("Назад")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
|
||||
when (val currentState = state) {
|
||||
is BookState.Loading -> LoadingContent()
|
||||
is BookState.Data -> DataContent(currentState, viewModel)
|
||||
is BookState.Error -> ErrorContent(viewModel)
|
||||
is BookState.Empty -> EmptyContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingContent() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(64.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DataContent(
|
||||
state: BookState.Data,
|
||||
viewModel: BookViewModel
|
||||
) {
|
||||
// Вкладки с датами
|
||||
LazyRow(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
items(state.availableDates.size) { index ->
|
||||
val date = state.availableDates[index]
|
||||
DateTab(
|
||||
date = date,
|
||||
isSelected = state.selectedDate == date,
|
||||
position = index
|
||||
) {
|
||||
viewModel.onIntent(BookIntent.SelectDate(date))
|
||||
}
|
||||
if (index < state.availableDates.size - 1) {
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
|
||||
// Список мест для выбранной даты
|
||||
if (state.selectedDate != null) {
|
||||
val places = state.availablePlaces[state.selectedDate] ?: emptyList()
|
||||
|
||||
if (places.isEmpty()) {
|
||||
Text("Нет доступных мест")
|
||||
} else {
|
||||
Text(
|
||||
text = "Доступные места на ${state.selectedDate}",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
places.forEachIndexed { index, place ->
|
||||
PlaceItem(
|
||||
place = place,
|
||||
isSelected = state.selectedPlace == place,
|
||||
position = index
|
||||
) {
|
||||
viewModel.onIntent(BookIntent.SelectPlace(place))
|
||||
}
|
||||
if (index < places.size - 1) {
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
|
||||
// Кнопка бронирования
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestIds.Book.BOOK_BUTTON),
|
||||
onClick = {
|
||||
viewModel.onIntent(BookIntent.Book)
|
||||
},
|
||||
enabled = state.selectedDate != null && state.selectedPlace != null
|
||||
) {
|
||||
Text("Забронировать")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DateTab(
|
||||
date: String,
|
||||
isSelected: Boolean,
|
||||
position: Int,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.testTag(TestIds.Book.getIdDateItemByPosition(position))
|
||||
.selectable(
|
||||
selected = isSelected,
|
||||
onClick = onClick,
|
||||
role = Role.Tab
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = date,
|
||||
modifier = Modifier.testTag(TestIds.Book.ITEM_DATE),
|
||||
style = if (isSelected) {
|
||||
MaterialTheme.typography.titleMedium
|
||||
} else {
|
||||
MaterialTheme.typography.bodyMedium
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceItem(
|
||||
place: PlaceInfo,
|
||||
isSelected: Boolean,
|
||||
position: Int,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestIds.Book.getIdPlaceItemByPosition(position))
|
||||
.selectable(
|
||||
selected = isSelected,
|
||||
onClick = onClick
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = place.name,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.testTag(TestIds.Book.ITEM_PLACE_TEXT)
|
||||
)
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
modifier = Modifier.testTag(TestIds.Book.ITEM_PLACE_SELECTOR),
|
||||
onClick = null // null because onClick handled by Modifier.selectable
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorContent(
|
||||
viewModel: BookViewModel
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Произошла ошибка при загрузке данных",
|
||||
modifier = Modifier.testTag(TestIds.Book.ERROR)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
Button(
|
||||
modifier = Modifier.testTag(TestIds.Book.REFRESH_BUTTON),
|
||||
onClick = {
|
||||
viewModel.onIntent(BookIntent.Refresh)
|
||||
}
|
||||
) {
|
||||
Text("Обновить")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyContent() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Всё забронировано",
|
||||
modifier = Modifier.testTag(TestIds.Book.EMPTY)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package ru.myitschool.work.ui.screen.book
|
||||
|
||||
import ru.myitschool.work.ui.screen.main.Booking
|
||||
import ru.myitschool.work.domain.booking.PlaceInfo
|
||||
|
||||
sealed interface BookState {
|
||||
object Loading : BookState
|
||||
data class Data(
|
||||
val availableDates: List<String>,
|
||||
val availablePlaces: Map<String, List<PlaceInfo>>,
|
||||
val selectedDate: String? = null,
|
||||
val selectedPlace: PlaceInfo? = null
|
||||
) : BookState
|
||||
object Error : BookState
|
||||
object Empty : BookState
|
||||
}
|
||||
|
||||
// Используем тот же Booking из MainState для единообразия
|
||||
@@ -0,0 +1,124 @@
|
||||
package ru.myitschool.work.ui.screen.book
|
||||
|
||||
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.BookingRepository
|
||||
import ru.myitschool.work.domain.booking.GetBookingInfoUseCase
|
||||
import ru.myitschool.work.domain.booking.BookPlaceUseCase
|
||||
import ru.myitschool.work.domain.booking.PlaceInfo
|
||||
import java.time.LocalDate
|
||||
|
||||
|
||||
class BookViewModel : ViewModel() {
|
||||
private val getBookingInfoUseCase by lazy { GetBookingInfoUseCase(BookingRepository()) }
|
||||
private val bookPlaceUseCase by lazy { BookPlaceUseCase(BookingRepository()) }
|
||||
|
||||
private val _uiState = MutableStateFlow<BookState>(BookState.Loading)
|
||||
val uiState: StateFlow<BookState> = _uiState.asStateFlow()
|
||||
|
||||
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
|
||||
val actionFlow: SharedFlow<Unit> = _actionFlow
|
||||
|
||||
init {
|
||||
loadBookingInfo()
|
||||
}
|
||||
|
||||
fun onIntent(intent: BookIntent) {
|
||||
when (intent) {
|
||||
is BookIntent.SelectDate -> {
|
||||
_uiState.update { currentState ->
|
||||
when (currentState) {
|
||||
is BookState.Data -> {
|
||||
currentState.copy(selectedDate = intent.date, selectedPlace = null)
|
||||
}
|
||||
else -> currentState
|
||||
}
|
||||
}
|
||||
}
|
||||
is BookIntent.SelectPlace -> {
|
||||
_uiState.update { currentState ->
|
||||
when (currentState) {
|
||||
is BookState.Data -> {
|
||||
currentState.copy(selectedPlace = intent.place)
|
||||
}
|
||||
else -> currentState
|
||||
}
|
||||
}
|
||||
}
|
||||
BookIntent.Book -> {
|
||||
bookSelectedPlace()
|
||||
}
|
||||
BookIntent.Refresh -> {
|
||||
loadBookingInfo()
|
||||
}
|
||||
BookIntent.Back -> {
|
||||
viewModelScope.launch {
|
||||
_actionFlow.emit(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadBookingInfo() {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
_uiState.update { BookState.Loading }
|
||||
getBookingInfoUseCase.invoke().fold(
|
||||
onSuccess = { bookingInfo ->
|
||||
if (bookingInfo.availableDates.isEmpty()) {
|
||||
_uiState.update { BookState.Empty }
|
||||
} else {
|
||||
_uiState.update {
|
||||
BookState.Data(
|
||||
availableDates = bookingInfo.availableDates.sorted(),
|
||||
availablePlaces = bookingInfo.availablePlaces,
|
||||
selectedDate = bookingInfo.availableDates.minOrNull()
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
error.printStackTrace()
|
||||
_uiState.update { BookState.Error }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bookSelectedPlace() {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val currentState = _uiState.value
|
||||
if (currentState is BookState.Data &&
|
||||
currentState.selectedDate != null &&
|
||||
currentState.selectedPlace != null) {
|
||||
|
||||
_uiState.update { BookState.Loading }
|
||||
|
||||
// Используем реальный placeId из выбранного места
|
||||
val placeId = currentState.selectedPlace.id
|
||||
|
||||
bookPlaceUseCase.invoke(currentState.selectedDate, placeId).fold(
|
||||
onSuccess = {
|
||||
// Успешное бронирование
|
||||
_actionFlow.emit(Unit)
|
||||
},
|
||||
onFailure = { error ->
|
||||
error.printStackTrace()
|
||||
// При ошибке не переходим в состояние Error, а остаемся на экране
|
||||
// и показываем ошибку в интерфейсе
|
||||
_uiState.update {
|
||||
currentState.copy(selectedDate = currentState.selectedDate)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.myitschool.work.ui.screen.main
|
||||
|
||||
sealed interface MainIntent {
|
||||
object Refresh : MainIntent
|
||||
object Logout : MainIntent
|
||||
object AddBooking : MainIntent
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package ru.myitschool.work.ui.screen.main
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
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.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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.ui.nav.BookScreenDestination
|
||||
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
viewModel: MainViewModel = viewModel(),
|
||||
navController: NavController
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.actionFlow.collect { action ->
|
||||
when (action) {
|
||||
// Переход к экрану бронирования
|
||||
else -> navController.navigate(BookScreenDestination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.onIntent(MainIntent.Refresh)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(all = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
when (val currentState = state) {
|
||||
is MainState.Loading -> LoadingContent()
|
||||
is MainState.Data -> DataContent(currentState, viewModel)
|
||||
is MainState.Error -> ErrorContent(viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingContent() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(64.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DataContent(
|
||||
state: MainState.Data,
|
||||
viewModel: MainViewModel
|
||||
) {
|
||||
// Кнопка выхода
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestIds.Main.LOGOUT_BUTTON),
|
||||
onClick = {
|
||||
viewModel.onIntent(MainIntent.Logout)
|
||||
}
|
||||
) {
|
||||
Text("Выйти")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
|
||||
// Информация о пользователе
|
||||
Text(
|
||||
text = "Привет, ${state.name}!",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.testTag(TestIds.Main.PROFILE_NAME)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
|
||||
// Фото пользователя
|
||||
// В реальном приложении здесь будет загрузка изображения
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.testTag(TestIds.Main.PROFILE_IMAGE),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Фото")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
|
||||
// Кнопка обновления
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Текущие бронирования",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Button(
|
||||
modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON),
|
||||
onClick = {
|
||||
viewModel.onIntent(MainIntent.Refresh)
|
||||
}
|
||||
) {
|
||||
Text("Обновить")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
|
||||
// Список бронирований
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
items(state.bookings.size) { index ->
|
||||
val booking = state.bookings[index]
|
||||
BookingItem(
|
||||
booking = booking,
|
||||
position = index
|
||||
)
|
||||
if (index < state.bookings.size - 1) {
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
|
||||
// Кнопка добавления бронирования
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestIds.Main.ADD_BUTTON),
|
||||
onClick = {
|
||||
viewModel.onIntent(MainIntent.AddBooking)
|
||||
}
|
||||
) {
|
||||
Text("Забронировать место")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BookingItem(
|
||||
booking: Booking,
|
||||
position: Int
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestIds.Main.getIdItemByPosition(position))
|
||||
) {
|
||||
Text(
|
||||
text = booking.date,
|
||||
modifier = Modifier.testTag(TestIds.Main.ITEM_DATE)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
Text(
|
||||
text = booking.place,
|
||||
modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorContent(
|
||||
viewModel: MainViewModel
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Произошла ошибка при загрузке данных",
|
||||
modifier = Modifier.testTag(TestIds.Main.ERROR)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
Button(
|
||||
modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON),
|
||||
onClick = {
|
||||
viewModel.onIntent(MainIntent.Refresh)
|
||||
}
|
||||
) {
|
||||
Text("Обновить")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ru.myitschool.work.ui.screen.main
|
||||
|
||||
sealed interface MainState {
|
||||
object Loading : MainState
|
||||
data class Data(
|
||||
val name: String,
|
||||
val photoUrl: String,
|
||||
val bookings: List<Booking>
|
||||
) : MainState
|
||||
object Error : MainState
|
||||
}
|
||||
|
||||
data class Booking(
|
||||
val date: String,
|
||||
val place: String
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
package ru.myitschool.work.ui.screen.main
|
||||
|
||||
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.InfoRepository
|
||||
import ru.myitschool.work.domain.info.GetInfoUseCase
|
||||
import ru.myitschool.work.data.repo.AuthRepository
|
||||
|
||||
class MainViewModel : ViewModel() {
|
||||
private val getInfoUseCase by lazy { GetInfoUseCase(InfoRepository()) }
|
||||
private val _uiState = MutableStateFlow<MainState>(MainState.Loading)
|
||||
val uiState: StateFlow<MainState> = _uiState.asStateFlow()
|
||||
|
||||
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
|
||||
val actionFlow: SharedFlow<Unit> = _actionFlow
|
||||
|
||||
init {
|
||||
loadInfo()
|
||||
}
|
||||
|
||||
fun onIntent(intent: MainIntent) {
|
||||
when (intent) {
|
||||
MainIntent.Refresh -> loadInfo()
|
||||
MainIntent.Logout -> {
|
||||
// Очистка данных авторизации
|
||||
AuthRepository.clear()
|
||||
// Навигация на экран авторизации
|
||||
viewModelScope.launch {
|
||||
_actionFlow.emit(Unit)
|
||||
}
|
||||
}
|
||||
MainIntent.AddBooking -> {
|
||||
viewModelScope.launch {
|
||||
_actionFlow.emit(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadInfo() {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
_uiState.update { MainState.Loading }
|
||||
getInfoUseCase.invoke().fold(
|
||||
onSuccess = { info ->
|
||||
_uiState.update {
|
||||
MainState.Data(
|
||||
name = info.name,
|
||||
photoUrl = info.photoUrl,
|
||||
bookings = info.bookings.sortedBy { it.date }.map { Booking(it.date, it.place) }
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
error.printStackTrace()
|
||||
_uiState.update { MainState.Error }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user