main #6

Closed
student-20690 wants to merge 20 commits from (deleted):main into main
33 changed files with 1179 additions and 50 deletions

View File

@@ -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.39:8080"
const val AUTH_URL = "/auth"
const val INFO_URL = "/info"
const val BOOKING_URL = "/booking"

View File

@@ -0,0 +1,9 @@
package ru.myitschool.work.core
import androidx.datastore.preferences.core.intPreferencesKey
// Не добавляйте ничего, что уже есть в Constants!
object OurConstants {
const val SHABLON = "^[a-zA-Z0-9]*\$"
const val DS_AUTH_KEY = "authkey"
}

View File

@@ -0,0 +1,11 @@
package ru.myitschool.work.core
import ru.myitschool.work.core.OurConstants.SHABLON
class Utils {
companion object {
fun CheckCodeInput(text : String) : Boolean{
return !text.isEmpty() && text.length == 4 && text.matches(Regex(SHABLON))
}
}
}

View File

@@ -0,0 +1,11 @@
package ru.myitschool.work.data.entity
import java.time.LocalDate
data class Booking ( val id: Long,
val date: LocalDate,
val place: Place,
val employeeCode: String){
}

View File

@@ -0,0 +1,9 @@
package ru.myitschool.work.data.entity
data class Employee (
val name: String,
val code: String,
val photoUrl: String,
val bookingList: MutableList<Booking?>) {
}

View File

@@ -0,0 +1,5 @@
package ru.myitschool.work.data.entity
data class Place(
val id: Long,
val place: String ){}

View File

@@ -1,16 +1,21 @@
package ru.myitschool.work.data.repo
import android.util.Log
import io.ktor.client.statement.bodyAsText
import ru.myitschool.work.data.source.DataStoreDataSource.createAuthCode
import ru.myitschool.work.data.source.NetworkDataSource
object AuthRepository {
object AuthRepository {
private var codeCache: String? = null
suspend fun checkAndSave(text: String): Result<Boolean> {
return NetworkDataSource.checkAuth(text).onSuccess { success ->
if (success) {
val result = NetworkDataSource.checkAuth(text)
if (result.isSuccess) {
codeCache = text
}
}
createAuthCode(code = text)
}
return result
}
}

View File

@@ -0,0 +1,25 @@
package ru.myitschool.work.data.repo
import ru.myitschool.work.data.entity.Place
import ru.myitschool.work.data.source.DataStoreDataSource
import ru.myitschool.work.data.source.NetworkDataSource
import java.time.LocalDate
class BookingRepository {
suspend fun getAvailableBookings(): Result<Map<LocalDate, List<Place>>> {
val code = DataStoreDataSource.getAuthCode()
if (code.isEmpty() || code == "0") {
return Result.failure(Exception("Auth code not found"))
}
return NetworkDataSource.getAvailableBookings(code)
}
suspend fun createBooking(date: LocalDate, placeId: Long): Result<Boolean> {
val code = DataStoreDataSource.getAuthCode()
if (code.isEmpty() || code == "0") {
return Result.failure(Exception("Auth code not found"))
}
return NetworkDataSource.createBooking(code, date, placeId)
}
}

View File

@@ -0,0 +1,34 @@
package ru.myitschool.work.data.repo
import ru.myitschool.work.data.entity.Employee
import ru.myitschool.work.data.source.DataStoreDataSource
import ru.myitschool.work.data.source.DataStoreDataSource.createAuthCode
import ru.myitschool.work.data.source.DataStoreDataSource.getAuthCode
import ru.myitschool.work.data.source.NetworkDataSource
class MainRepository {
private var employee: Employee? = null
suspend fun getUserInfo(): Result<Employee> {
return try {
val code = getCode()
val result = NetworkDataSource.getUserInfo(code)
result.onSuccess { success ->
employee = success
}
result
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun getCode(): String {
return getAuthCode()
}
suspend fun logOut(){
DataStoreDataSource.logOut()
}
}

View File

@@ -0,0 +1,48 @@
package ru.myitschool.work.data.source
import android.content.Context
import android.util.Log
import androidx.compose.material3.rememberTimePickerState
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import ru.myitschool.work.App
import ru.myitschool.work.core.OurConstants.DS_AUTH_KEY
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "auth")
val AUTH_KEY = stringPreferencesKey(DS_AUTH_KEY)
object DataStoreDataSource {
fun authFlow(): Flow<String> {
return App.context.dataStore.data.map { preferences ->
(preferences[AUTH_KEY] ?: 0).toString()
}
}
suspend fun createAuthCode(code: String) {
App.context.dataStore.updateData {
it.toMutablePreferences().also { preferences ->
preferences[AUTH_KEY] = code
}
}
}
suspend fun getAuthCode(): String {
return App.context.dataStore.data.map { preferences ->
preferences[AUTH_KEY] ?: ""
}.first()
}
suspend fun logOut() {
App.context.dataStore.updateData {
it.toMutablePreferences().also { preferences ->
preferences.remove(AUTH_KEY)
}
}
}
}

View File

@@ -1,16 +1,30 @@
package ru.myitschool.work.data.source
import android.annotation.SuppressLint
import android.util.Log
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.Serializable
import kotlinx.serialization.json.Json
import ru.myitschool.work.core.Constants
import ru.myitschool.work.data.entity.Employee
import kotlinx.serialization.json.*
import ru.myitschool.work.App
import ru.myitschool.work.R
import ru.myitschool.work.data.entity.Booking
import ru.myitschool.work.data.entity.Place
import java.time.LocalDate
object NetworkDataSource {
private val client by lazy {
@@ -31,12 +45,133 @@ object NetworkDataSource {
suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response = client.get(getUrl(code, Constants.AUTH_URL))
when (response.status) {
HttpStatusCode.OK -> true
HttpStatusCode.Unauthorized -> error(App.context.getString(R.string.auth_wrong_code))
else -> error(App.context.getString(R.string.error_request, response.bodyAsText()))
}
}
}
suspend fun getUserInfo(code: String): Result<Employee> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response = client.get(getUrl(code, Constants.INFO_URL))
when (response.status) {
HttpStatusCode.OK -> {
val json = response.bodyAsText()
if (json.isBlank()) {
error(App.context.getString(R.string.error_empty_server_response))
}
val jsonObject = try {
Json.parseToJsonElement(json).jsonObject
} catch (e: Exception) {
error(App.context.getString(R.string.error_parsing, e.message))
}
val name = jsonObject["name"]?.jsonPrimitive?.content
?: error(App.context.getString(R.string.error_missing_name_field))
val photoUrl = jsonObject["photoUrl"]?.jsonPrimitive?.content
?: error(App.context.getString(R.string.error_missing_photo_url_field))
val bookingJson = jsonObject["booking"]?.jsonObject
?: error(App.context.getString(R.string.error_missing_booking_field))
val employee = Employee(
name = name,
code = code,
photoUrl = photoUrl,
bookingList = mutableListOf()
)
val bookingList = mutableListOf<Booking>()
for ((dateString, bookingElement) in bookingJson) {
val date = LocalDate.parse(dateString)
val bookingObj = bookingElement.jsonObject
val bookingId = bookingObj["id"]?.jsonPrimitive?.long
?: error(App.context.getString(R.string.error_missing_id_field))
val placeString = bookingObj["place"]?.jsonPrimitive?.content
?: error(App.context.getString(R.string.error_missing_place_field, dateString))
if (placeString.isBlank()) {
error(App.context.getString(R.string.error_empty_place_field, dateString))
}
val placeId = bookingId
val place = Place(placeId, placeString)
val booking = Booking(
id = bookingId,
date = date,
place = place,
employeeCode = employee.code
)
bookingList.add(booking)
}
/* if (bookingList.isEmpty()) {
error(App.context.getString(R.string.error_booking_list_empty))
}*/
employee.bookingList.addAll(bookingList)
employee
}
else -> error(response.bodyAsText())
}
}
}
suspend fun getAvailableBookings(code: String): Result<Map<LocalDate, List<Place>>> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response = client.get(getUrl(code, Constants.BOOKING_URL))
when (response.status) {
HttpStatusCode.OK -> {
val json = response.bodyAsText()
val jsonObject = Json.parseToJsonElement(json).jsonObject
val availableBookings = mutableMapOf<LocalDate, List<Place>>()
for ((dateString, placesArray) in jsonObject) {
val date = LocalDate.parse(dateString)
val places = placesArray.jsonArray.map { placeElement ->
val placeObj = placeElement.jsonObject
val id = placeObj["id"]?.jsonPrimitive?.long
?: error(App.context.getString(R.string.error_missing_id_in_place))
val placeName = placeObj["place"]?.jsonPrimitive?.content
?: error(App.context.getString(R.string.error_missing_place_in_place))
Place(id, placeName)
}
if (places.isNotEmpty()) {
availableBookings[date] = places
}
}
availableBookings.toSortedMap()
}
else -> error(App.context.getString(R.string.error_request, response.bodyAsText()))
}
}
}
@Serializable
private data class CreateBookingBody(val date: String, val placeId: Long)
suspend fun createBooking(code: String, date: LocalDate, placeId: Long): Result<Boolean> = withContext(Dispatchers.IO) {
return@withContext runCatching {
// Формируем тело запроса
val requestBody = CreateBookingBody(date.toString(), placeId)
val response = client.post(getUrl(code, Constants.BOOK_URL)) {
contentType(ContentType.Application.Json)
setBody(requestBody)
}
when (response.status) {
HttpStatusCode.Created -> true
else -> {
val errorBody = response.bodyAsText()
error(if (errorBody.isNotBlank()) App.context.getString(R.string.error_booking, errorBody) else App.context.getString(R.string.error_booking_default))
}
}
}
}
private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl"
}

View File

@@ -7,9 +7,7 @@ class CheckAndSaveAuthCodeUseCase(
) {
suspend operator fun invoke(
text: String
): Result<Unit> {
return repository.checkAndSave(text).mapCatching { success ->
if (!success) error("Code is incorrect")
}
): Result<Boolean> {
return repository.checkAndSave(text)
}
}

View File

@@ -0,0 +1,12 @@
package ru.myitschool.work.domain.book
import ru.myitschool.work.data.repo.BookingRepository
import java.time.LocalDate
class CreateBookingUseCase(
private val repository: BookingRepository
) {
suspend operator fun invoke(date: LocalDate, placeId: Long): Result<Boolean> {
return repository.createBooking(date, placeId)
}
}

View File

@@ -0,0 +1,13 @@
package ru.myitschool.work.domain.book
import ru.myitschool.work.data.entity.Place
import ru.myitschool.work.data.repo.BookingRepository
import java.time.LocalDate
class GetAvailableBookingsUseCase(
private val repository: BookingRepository
) {
suspend operator fun invoke(): Result<Map<LocalDate, List<Place>>> {
return repository.getAvailableBookings()
}
}

View File

@@ -0,0 +1,13 @@
package ru.myitschool.work.domain.main
import ru.myitschool.work.data.entity.Employee
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.data.repo.MainRepository
class GetUserDataUseCase(
private val repository: MainRepository
) {
suspend operator fun invoke(): Result<Employee> {
return repository.getUserInfo()
}
}

View File

@@ -8,12 +8,15 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import ru.myitschool.work.App
import ru.myitschool.work.data.source.DataStoreDataSource.authFlow
import ru.myitschool.work.ui.screen.AppNavHost
import ru.myitschool.work.ui.theme.WorkTheme
class RootActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
App.context = applicationContext
enableEdgeToEdge()
setContent {
WorkTheme {

View File

@@ -2,19 +2,20 @@ package ru.myitschool.work.ui.screen
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.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import ru.myitschool.work.data.source.DataStoreDataSource.authFlow
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.auth.AuthViewModel
import ru.myitschool.work.ui.screen.book.BookScreen
import ru.myitschool.work.ui.screen.main.MainScreen
@Composable
fun AppNavHost(
@@ -32,18 +33,16 @@ 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(
onBack = { navController.popBackStack() },
onBookSuccess = {
// Возвращаемся на главный экран и обновляем его
navController.popBackStack()
}
)
}
}
}

View File

@@ -0,0 +1,7 @@
package ru.myitschool.work.ui.screen.auth
sealed interface AuthAction {
data class ShowError(val message: String?) : AuthAction
data class LogIn(val isLogged: Boolean): AuthAction
data class AuthBtnEnabled(val enabled: Boolean) : AuthAction
}

View File

@@ -3,4 +3,5 @@ package ru.myitschool.work.ui.screen.auth
sealed interface AuthIntent {
data class Send(val text: String): AuthIntent
data class TextInput(val text: String): AuthIntent
object CheckLogIntent: AuthIntent
}

View File

@@ -1,5 +1,6 @@
package ru.myitschool.work.ui.screen.auth
import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -21,15 +22,18 @@ 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
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import io.ktor.util.collections.setValue
import ru.myitschool.work.App
import ru.myitschool.work.R
import ru.myitschool.work.core.OurConstants.SHABLON
import ru.myitschool.work.core.TestIds
import ru.myitschool.work.core.Utils
import ru.myitschool.work.ui.nav.MainScreenDestination
@Composable
@@ -38,13 +42,11 @@ fun AuthScreen(
navController: NavController
) {
val state by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.actionFlow.collect {
navController.navigate(MainScreenDestination)
}
viewModel.onIntent(AuthIntent.CheckLogIntent)
}
Column(
modifier = Modifier
.fillMaxSize()
@@ -58,12 +60,16 @@ fun AuthScreen(
textAlign = TextAlign.Center
)
when (val currentState = state) {
is AuthState.Data -> Content(viewModel, currentState)
is AuthState.Data -> Content(viewModel, currentState, navController)
is AuthState.Loading -> {
CircularProgressIndicator(
modifier = Modifier.size(64.dp)
)
}
is AuthState.LoggedIn -> {
navController.navigate(MainScreenDestination)
}
}
}
}
@@ -71,12 +77,36 @@ fun AuthScreen(
@Composable
private fun Content(
viewModel: AuthViewModel,
state: AuthState.Data
state: AuthState.Data,
navController: NavController
) {
var inputText by remember { mutableStateOf("") }
var errorText: String? by remember { mutableStateOf(null) }
var btnEnabled: Boolean by remember { mutableStateOf(false) }
val event = viewModel.actionFlow.collectAsState(initial = null)
// В UI (Composable)
val actionFlow = viewModel.actionFlow // SharedFlow<AuthAction>
LaunchedEffect(Unit) {
// Collect Flow<T> здесь, чтобы потреблять все события
actionFlow.collect { action ->
when (action) {
is AuthAction.ShowError -> {
errorText = action.message
}
is AuthAction.AuthBtnEnabled -> {
btnEnabled = action.enabled
} else -> {}
}
}
}
Spacer(modifier = Modifier.size(16.dp))
TextField(
modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(),
modifier = Modifier
.testTag(TestIds.Auth.CODE_INPUT)
.fillMaxWidth(),
value = inputText,
onValueChange = {
inputText = it
@@ -86,12 +116,20 @@ private fun Content(
)
Spacer(modifier = Modifier.size(16.dp))
Button(
modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(),
modifier = Modifier
.testTag(TestIds.Auth.SIGN_BUTTON)
.fillMaxWidth(),
onClick = {
if (Utils.CheckCodeInput(inputText)) {
viewModel.onIntent(AuthIntent.Send(inputText))
} else {
errorText = App.context.getString(R.string.auth_nasty_code)
}
},
enabled = true
) {
Text(stringResource(R.string.auth_sign_in))
enabled = btnEnabled
) { Text(stringResource(R.string.auth_sign_in)) }
if (errorText != null) {
Text(errorText.toString(), modifier = Modifier.testTag(TestIds.Auth.ERROR))
}
}

View File

@@ -3,4 +3,5 @@ package ru.myitschool.work.ui.screen.auth
sealed interface AuthState {
object Loading: AuthState
object Data: AuthState
object LoggedIn: AuthState
}

View File

@@ -1,5 +1,7 @@
package ru.myitschool.work.ui.screen.auth
import android.util.Log
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
@@ -8,9 +10,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.myitschool.work.App
import ru.myitschool.work.R
import ru.myitschool.work.core.Utils.Companion.CheckCodeInput
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.data.source.DataStoreDataSource.authFlow
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
class AuthViewModel : ViewModel() {
@@ -18,26 +25,55 @@ class AuthViewModel : ViewModel() {
private val _uiState = MutableStateFlow<AuthState>(AuthState.Data)
val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
val actionFlow: SharedFlow<Unit> = _actionFlow
private val _actionFlow: MutableSharedFlow<AuthAction> = MutableSharedFlow(replay = 1)
val actionFlow: SharedFlow<AuthAction> = _actionFlow
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)
_uiState.update { AuthState.LoggedIn }
},
onFailure = { error ->
error.printStackTrace()
_actionFlow.emit(Unit)
if (error.message != null) {
_actionFlow.emit(AuthAction.ShowError(error.message))
_uiState.update { AuthState.Data }
}
}
)
}
}
is AuthIntent.TextInput -> Unit
is AuthIntent.TextInput -> {
viewModelScope.launch {
authFlow().collect {
if (CheckCodeInput(intent.text)) {
_actionFlow.emit(AuthAction.AuthBtnEnabled(true))
} else {
_actionFlow.emit(AuthAction.AuthBtnEnabled(false))
}
}
}
}
is AuthIntent.CheckLogIntent -> {
viewModelScope.launch {
_uiState.update { AuthState.Loading }
val authCode = authFlow().first()
if (authCode != "0") {
_actionFlow.emit(AuthAction.LogIn(true))
_uiState.update { AuthState.LoggedIn }
} else {
_uiState.update { AuthState.Data }
}
}
}
}
}
}

View File

@@ -0,0 +1,6 @@
package ru.myitschool.work.ui.screen.book
sealed interface BookAction {
data class ShowError(val message: String?) : BookAction
object BookSuccess : BookAction
}

View File

@@ -0,0 +1,12 @@
package ru.myitschool.work.ui.screen.book
import ru.myitschool.work.data.entity.Place
import java.time.LocalDate
sealed interface BookIntent {
object LoadData : BookIntent
object Refresh : BookIntent
object BookPlace : BookIntent
data class SelectDate(val date: LocalDate) : BookIntent
data class SelectPlace(val place: Place) : BookIntent
}

View File

@@ -0,0 +1,180 @@
package ru.myitschool.work.ui.screen.book
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.RadioButton
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
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.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import ru.myitschool.work.core.TestIds
import ru.myitschool.work.data.entity.Place
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@Composable
fun BookScreen(
onBack: () -> Unit,
onBookSuccess: () -> Unit
) {
val viewModel: BookViewModel = viewModel()
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(viewModel.actionFlow) {
viewModel.actionFlow.collect { action ->
if (action is BookAction.BookSuccess) {
onBookSuccess()
}
}
}
LaunchedEffect(Unit) {
viewModel.onIntent(BookIntent.LoadData)
}
when (val state = uiState) {
is BookState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is BookState.Data -> {
BookContentScreen(
uiState = state,
onSelectDate = { date -> viewModel.onIntent(BookIntent.SelectDate(date)) },
onSelectPlace = { place -> viewModel.onIntent(BookIntent.SelectPlace(place)) },
onBook = { viewModel.onIntent(BookIntent.BookPlace) },
onBack = onBack,
onRefresh = { viewModel.onIntent(BookIntent.Refresh) }
)
}
}
}
@Composable
fun BookContentScreen(
uiState: BookState.Data,
onSelectDate: (LocalDate) -> Unit,
onSelectPlace: (Place) -> Unit,
onBook: () -> Unit,
onBack: () -> Unit,
onRefresh: () -> Unit
) {
val sortedDates = uiState.dates.sorted()
val availableDates = sortedDates.filter { date -> uiState.places[date]?.isNotEmpty() == true }
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
if (availableDates.isNotEmpty()) {
ScrollableTabRow(
selectedTabIndex = availableDates.indexOf(uiState.selectedDate),
) {
availableDates.forEachIndexed { index, date ->
Tab(
selected = date == uiState.selectedDate,
onClick = { onSelectDate(date) },
text = {
Text(
text = date.format(DateTimeFormatter.ofPattern("dd.MM")),
modifier = Modifier.testTag(TestIds.Book.getIdDateItemByPosition(index))
)
},
modifier = Modifier.testTag(TestIds.Book.ITEM_DATE)
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
val placesForDate = uiState.selectedDate?.let { uiState.places[it] } ?: emptyList()
if (placesForDate.isNotEmpty()) {
Column {
placesForDate.forEachIndexed { index, place ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.selectable(
selected = uiState.selectedPlace == place,
onClick = { onSelectPlace(place) }
)
.testTag(TestIds.Book.getIdPlaceItemByPosition(index)),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = place.place,
modifier = Modifier.weight(1f).testTag(TestIds.Book.ITEM_PLACE_TEXT)
)
RadioButton(
selected = uiState.selectedPlace == place,
onClick = { onSelectPlace(place) },
modifier = Modifier.testTag(TestIds.Book.ITEM_PLACE_SELECTOR)
)
}
}
}
}
if (availableDates.isEmpty() && !uiState.isError) {
Text(
text = "Всё забронировано",
modifier = Modifier.testTag(TestIds.Book.EMPTY)
)
}
if (uiState.isError) {
Text(
text = uiState.errorMessage ?: "Ошибка загрузки",
color = Color.Red,
modifier = Modifier.testTag(TestIds.Book.ERROR)
)
Button(
onClick = onRefresh,
modifier = Modifier.testTag(TestIds.Book.REFRESH_BUTTON)
) {
Text("Обновить")
}
}
Spacer(modifier = Modifier.weight(1f))
if (!uiState.isError && placesForDate.isNotEmpty()) {
Button(
onClick = onBook,
enabled = uiState.selectedPlace != null, // активна только при выбранном месте
modifier = Modifier.fillMaxWidth().testTag(TestIds.Book.BOOK_BUTTON)
) { Text("Забронировать") }
}
Button(
onClick = onBack,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).testTag(TestIds.Book.BACK_BUTTON)
) {
Text("Назад")
}
}
}

View File

@@ -0,0 +1,16 @@
package ru.myitschool.work.ui.screen.book
import ru.myitschool.work.data.entity.Place
import java.time.LocalDate
sealed interface BookState {
object Loading : BookState
data class Data(
val dates: List<LocalDate> = emptyList(),
val places: Map<LocalDate, List<Place>> = emptyMap(),
val selectedDate: LocalDate? = null,
val selectedPlace: Place? = null,
val isError: Boolean = false,
val errorMessage: String? = null
) : BookState
}

View File

@@ -0,0 +1,150 @@
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.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.myitschool.work.App
import ru.myitschool.work.R
import ru.myitschool.work.data.entity.Place
import ru.myitschool.work.data.repo.BookingRepository
import ru.myitschool.work.domain.book.CreateBookingUseCase
import ru.myitschool.work.domain.book.GetAvailableBookingsUseCase
import java.time.LocalDate
class BookViewModel : ViewModel() {
private val repository by lazy { BookingRepository() }
private val getAvailableBookingsUseCase by lazy { GetAvailableBookingsUseCase(repository) }
private val createBookingUseCase by lazy { CreateBookingUseCase(repository) }
private val _uiState = MutableStateFlow<BookState>(BookState.Loading)
val uiState: StateFlow<BookState> = _uiState.asStateFlow()
private val _actionFlow = MutableSharedFlow<BookAction>()
val actionFlow: SharedFlow<BookAction> = _actionFlow
private var selectedPlaceId: Long? = null
init {
loadBookData()
}
fun onIntent(intent: BookIntent) {
when (intent) {
is BookIntent.LoadData -> loadBookData()
is BookIntent.Refresh -> refresh()
is BookIntent.BookPlace -> bookPlace()
is BookIntent.SelectDate -> selectDate(intent.date)
is BookIntent.SelectPlace -> selectPlace(intent.place)
}
}
private fun loadBookData() {
viewModelScope.launch(Dispatchers.IO) {
_uiState.update { BookState.Loading }
getAvailableBookingsUseCase().fold(
onSuccess = { bookings ->
if (bookings.isEmpty()) {
_uiState.update {
BookState.Data(
isError = true,
errorMessage = App.context.getString(R.string.error_no_available_dates)
)
}
} else {
val dates = bookings.keys.toList()
_uiState.update {
BookState.Data(
dates = dates,
places = bookings,
selectedDate = dates.first(),
selectedPlace = null,
isError = false,
errorMessage = null
)
}
}
},
onFailure = { error ->
error.printStackTrace()
_uiState.update {
BookState.Data(
isError = true,
errorMessage = error.message ?: App.context.getString(R.string.error_loading_data)
)
}
}
)
}
}
private fun selectDate(date: LocalDate) {
_uiState.update { currentState ->
if (currentState is BookState.Data) {
currentState.copy(
selectedDate = date,
selectedPlace = null
)
} else {
currentState
}
}
selectedPlaceId = null
}
private fun selectPlace(place: Place) {
_uiState.update { currentState ->
if (currentState is BookState.Data) {
currentState.copy(selectedPlace = place)
} else {
currentState
}
}
selectedPlaceId = place.id
}
private fun bookPlace() {
val currentState = _uiState.value
if (currentState is BookState.Data && currentState.selectedPlace != null && currentState.selectedDate != null) {
val placeId = selectedPlaceId ?: return
val date = currentState.selectedDate
viewModelScope.launch(Dispatchers.IO) {
createBookingUseCase (date, placeId).fold(
onSuccess = {
Log.d("AnnaKonda", "method is calling")
_actionFlow.emit(BookAction.BookSuccess)
},
onFailure = { error ->
Log.d("AnnaKonda", "ERROR method is calling")
error.printStackTrace()
_uiState.update { currentState ->
if (currentState is BookState.Data) {
currentState.copy(
isError = true
)
} else {
currentState
}
}
// _actionFlow.emit(BookAction.ShowError(error.message ?: App.context.getString(R.string.error_booking_default)))
}
)
}
}
}
private fun refresh() {
loadBookData()
}
}

View File

@@ -0,0 +1,8 @@
package ru.myitschool.work.ui.screen.main
import ru.myitschool.work.ui.screen.auth.AuthAction
sealed interface MainAction {
data class SetName(val name: String)
data class ShowError(val message: String?) : MainAction
}

View File

@@ -0,0 +1,9 @@
package ru.myitschool.work.ui.screen.main
sealed interface MainIntent {
/* data class Send(val text: String): AuthIntent
data class TextInput(val text: String): AuthIntent
object CheckLogIntent: AuthIntent*/
object LoadData: MainIntent
object LogOut: MainIntent
}

View File

@@ -0,0 +1,244 @@
package ru.myitschool.work.ui.screen.main
import android.util.Log
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.res.painterResource
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.data.entity.Booking
import ru.myitschool.work.data.entity.Employee
import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.BookScreenDestination
@Composable
fun MainScreen(
navController: NavController,
) {
val viewModel: MainViewModel = viewModel()
// Состояния
val event = viewModel.actionFlow.collectAsState(initial = null)
// Функция загрузки данных
LaunchedEffect(Unit) {
viewModel.onIntent(MainIntent.LoadData)
}
var errorMessage: String? by remember { mutableStateOf("") }
LaunchedEffect(event.value) {
if (event.value is MainAction.ShowError) {
errorMessage = (event.value as MainAction.ShowError).message
}
}
// Если ошибка - показываем только ошибку и кнопку обновления
if (errorMessage != null) {
ErrorScreen(viewModel = viewModel, navController = navController, errorMessage)
} else {
DefaultScreen(viewModel = viewModel, navController = navController)
}
}
@Composable
fun DefaultScreen(viewModel: MainViewModel,
navController: NavController){
val state by viewModel.uiState.collectAsState()
var employee : Employee? by remember { mutableStateOf(null) }
var errorMessage by remember { mutableStateOf("") }
var bookingItems : List<Booking?>? by remember { mutableStateOf(emptyList<Booking>()) }
var isLoading by remember { mutableStateOf(true) }
LaunchedEffect(state) {
when (state) {
is MainState.Loading -> {
errorMessage = ""
isLoading = true
}
is MainState.Data -> {
isLoading = false
employee = (state as MainState.Data).employee
if (employee == null){
navController.navigate(AuthScreenDestination) { popUpTo(0) }
} else {
bookingItems = employee?.bookingList?.sortedBy { item ->
item?.date
}
}
}
}
}
employee?.let {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// Верхняя строка
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Фото пользователя (main_photo)
AsyncImage(
model = employee?.photoUrl ?: "",
contentDescription = "Фото",
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.testTag(TestIds.Main.PROFILE_IMAGE),
error = painterResource(id = android.R.drawable.ic_menu_gallery)
)
Spacer(modifier = Modifier.width(16.dp))
// Имя пользователя (main_name)
Text(
text = employee!!.name,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f).testTag(TestIds.Main.PROFILE_NAME),
color = MaterialTheme.colorScheme.onSurface
)
// Кнопка выхода (main_logout_button)
Button(
onClick = {
// Очистка данных и переход на авторизацию
viewModel.onIntent(MainIntent.LogOut)
bookingItems = emptyList()
},
modifier = Modifier.testTag(TestIds.Main.LOGOUT_BUTTON)
) {
Text("Выход")
}
}
Spacer(modifier = Modifier.height(16.dp))
// Кнопки действий
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
// Кнопка обновления (main_refresh_button)
Button(
onClick = { viewModel.onIntent(MainIntent.LoadData) },
enabled = state !is MainState.Loading,
modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON)
) {
if (state is MainState.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Обновить")
}
}
// кнопка бронирования
Button(
onClick = {
navController.navigate(BookScreenDestination)
},
modifier = Modifier.testTag(TestIds.Main.ADD_BUTTON)
) {
Text("Перейти к бронированию")
}
}
Spacer(modifier = Modifier.height(16.dp))
// Список бронирований
if (!bookingItems.isNullOrEmpty()) {
LazyColumn(
modifier = Modifier.weight(1f)
) {
itemsIndexed(bookingItems as List<Booking?>) { index, item ->
// Элемент списка (main_book_pos_{index})
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp)
.testTag(TestIds.Main.getIdItemByPosition(index))
) {
// Дата бронирования (main_item_date)
Text(
text = "Дата: ${item?.date}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag(TestIds.Main.ITEM_DATE)
)
Spacer(modifier = Modifier.height(4.dp))
// Место бронирования (main_item_place)
Text(
text = "Место: ${item?.place?.place}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE)
)
}
}
}
}
} else {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
text = "Нет бронирований",
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}
}
}
@Composable
fun ErrorScreen(viewModel: MainViewModel,
navController: NavController,
errorMessage: String?){
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Текстовое поле с ошибкой (main_error)
if (errorMessage != null) {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag(TestIds.Main.ERROR)
)
}
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.onIntent(MainIntent.LoadData) }) {
Text("Обновить")
}
}
}

View File

@@ -0,0 +1,9 @@
package ru.myitschool.work.ui.screen.main
import ru.myitschool.work.data.entity.Employee
import ru.myitschool.work.ui.screen.auth.AuthState
sealed interface MainState {
object Loading: MainState
data class Data (val employee: Employee?): MainState
}

View File

@@ -0,0 +1,61 @@
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.AuthRepository
import ru.myitschool.work.data.repo.MainRepository
import ru.myitschool.work.domain.main.GetUserDataUseCase
class MainViewModel : ViewModel() {
private val repository by lazy { MainRepository() }
private val getUserDataUseCase by lazy { GetUserDataUseCase(repository) }
private val _uiState = MutableStateFlow<MainState>(MainState.Loading)
val uiState: StateFlow<MainState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<MainAction> = MutableSharedFlow()
val actionFlow: SharedFlow<MainAction> = _actionFlow
fun onIntent(intent: MainIntent) {
when (intent) {
is MainIntent.LoadData -> {
loadData()
}
is MainIntent.LogOut -> {
viewModelScope.launch(Dispatchers.IO) {
_uiState.update { MainState.Data(null) }
repository.logOut()
}
}
}
}
private fun loadData() {
viewModelScope.launch(Dispatchers.IO) {
_uiState.update { MainState.Loading }
getUserDataUseCase.invoke().fold(
onSuccess = { employee ->
_uiState.update { MainState.Data(employee) }
_actionFlow.emit(MainAction.ShowError(null))
},
onFailure = { error ->
error.printStackTrace()
if (error.message != null) {
_actionFlow.emit(MainAction.ShowError(error.message.toString()))
}
_uiState.update { MainState.Data(null) }
}
)
}
}
}

View File

@@ -4,4 +4,25 @@
<string name="auth_title">Привет! Введи код для авторизации</string>
<string name="auth_label">Код</string>
<string name="auth_sign_in">Войти</string>
<string name="auth_wrong_code">Введён неверный код</string>
<string name="auth_nasty_code">Неправильный формат кода</string>
<string name="error_request">Ошибка запроса</string>
<string name="error_empty_server_response">Пустой ответ от сервера</string>
<string name="error_parsing">Ошибка парсинга</string>
<string name="error_missing_name_field">В ответе отсутствует поле name</string>
<string name="error_missing_photo_url_field">В ответе отсутствует поле photoUrl</string>
<string name="error_missing_booking_field">В ответе отсутствует поле booking</string>
<string name="error_missing_id_field">В ответе отсутствует поле id</string>
<string name="error_missing_place_field">В ответе отсутствует поле place для даты</string>
<string name="error_empty_place_field">В ответе поле place пусто для даты</string>
<string name="error_booking_list_empty">Список бронирований пуст</string>
<string name="error_missing_id_in_place">В информации о месте отсутствует id</string>
<string name="error_missing_place_in_place">В информации о месте отсутствует place</string>
<string name="error_booking">Ошибка бронирования</string>
<string name="error_booking_default">Ошибка бронирования</string>
<string name="error_no_available_dates">Нет доступных дат для бронирования</string>
<string name="error_loading_data">Ошибка загрузки данных</string>
</resources>