My job and Egor's job #6

Merged
student-18211 merged 2 commits from student-15047/NTO-2025-Android-TeamTask:main into main 2025-12-08 19:17:52 +00:00
18 changed files with 568 additions and 152 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://127.0.0.1:8080" const val HOST = "http://192.168.1.74: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,6 +1,7 @@
package ru.myitschool.work.data.repo package ru.myitschool.work.data.repo
import android.content.Context import android.content.Context
import android.util.Log
import ru.myitschool.work.App import ru.myitschool.work.App
import ru.myitschool.work.data.source.NetworkDataSource import ru.myitschool.work.data.source.NetworkDataSource
@@ -10,17 +11,32 @@ object AuthRepository {
private const val PREF_NAME = "auth_prefs" private const val PREF_NAME = "auth_prefs"
private const val KEY_SAVED_CODE = "saved_code" private const val KEY_SAVED_CODE = "saved_code"
private val context: Context get() = App.context private val context: Context get() = App.context
private fun loadSavedCode(): String? { private fun loadSavedCode(): String? {
// return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
// .getString(KEY_SAVED_CODE, null) .getString(KEY_SAVED_CODE, null)
return ""
} }
fun getSavedCode(): String? = loadSavedCode() fun getSavedCode(): String? = loadSavedCode()
suspend fun checkAndSave(text: String): Result<Boolean> {
return NetworkDataSource.checkAuth(text).onSuccess { success -> suspend fun checkAndSave(text: String): Result<Unit> {
return NetworkDataSource.checkAuth(text).fold(
onSuccess = { success ->
if (success) { if (success) {
codeCache = text codeCache = text
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.edit()
.putString(KEY_SAVED_CODE, text)
.apply()
Result.success(Unit)
} else {
Result.failure(IllegalStateException("Неверный код для авторизации"))
} }
},
onFailure = { error ->
Log.e("AuthRepository", "Auth failed", error)
Result.failure(error)
} }
)
} }
} }

View File

@@ -1,15 +1,14 @@
package ru.myitschool.work.data.repo package ru.myitschool.work.data.repo
import ru.myitschool.work.data.source.NetworkDataSource import ru.myitschool.work.data.source.NetworkDataSource
import ru.myitschool.work.ui.screen.Booking import ru.myitschool.work.ui.screen.BookingItem
import ru.myitschool.work.ui.screen.UserInfo
object BookRepository { object BookRepository {
suspend fun loadBooks(code: String): Result<List<Booking>> { suspend fun loadBooks(code: String): Result<Map<String, List<BookingItem>>> {
return NetworkDataSource.getFreeBooking(code) return NetworkDataSource.getFreeBooking(code)
} }
suspend fun sendData(code: String, booking: Booking) : Result<Boolean> { suspend fun sendData(code: String, data: String, placeId: Int) : Result<Boolean> {
return NetworkDataSource.createNewBooking(code, booking) return NetworkDataSource.createNewBooking(code, data, placeId)
} }
} }

View File

@@ -0,0 +1,16 @@
package ru.myitschool.work.data.repo
import androidx.core.content.edit
import ru.myitschool.work.data.source.NetworkDataSource
import ru.myitschool.work.ui.screen.UserInfo
object MainRepository {
suspend fun loadUserInfo(code: String): Result<UserInfo> {
return NetworkDataSource.getInfo(code)
}
fun clearAuth() {
val prefs = ru.myitschool.work.App.context
.getSharedPreferences("auth_prefs", android.content.Context.MODE_PRIVATE)
prefs.edit { clear() }
}
}

View File

@@ -1,11 +1,13 @@
package ru.myitschool.work.data.source package ru.myitschool.work.data.source
import android.util.Log
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body 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.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
@@ -16,7 +18,8 @@ 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.BookingItem
import ru.myitschool.work.ui.screen.CreateBookingRequest
import ru.myitschool.work.ui.screen.UserInfo import ru.myitschool.work.ui.screen.UserInfo
object NetworkDataSource { object NetworkDataSource {
@@ -35,43 +38,56 @@ 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))
Log.d("NetworkDataSource", "Auth response: ${response.status}")
when (response.status) { when (response.status) {
HttpStatusCode.OK -> true HttpStatusCode.OK -> true
else -> error("Неверный код для авторизации") else -> error("Неверный код для авторизации")
} }
}.onFailure { error ->
Log.e("NetworkDataSource", "Auth request failed", error)
} }
} }
suspend fun getInfo(code: String): Result<UserInfo> = withContext(Dispatchers.IO){ suspend fun getInfo(code: String): Result<UserInfo> = withContext(Dispatchers.IO){
return@withContext runCatching { return@withContext runCatching {
val response = client.get(getUrl(code, Constants.INFO_URL)) val response = client.get(getUrl(code, Constants.INFO_URL))
when(response.status){ when(response.status){
HttpStatusCode.OK -> response.body() HttpStatusCode.OK -> {
response.body<UserInfo>()
}
else -> error("Ошибка получения данных") else -> error("Ошибка получения данных")
} }
} }
} }
suspend fun getFreeBooking(code: String) : Result<List<Booking>> = withContext(Dispatchers.IO) {
return@withContext runCatching { suspend fun getFreeBooking(code: String): Result<Map<String, List<BookingItem>>> =
withContext(Dispatchers.IO) {
runCatching {
val response = client.get(getUrl(code, Constants.BOOKING_URL)) val response = client.get(getUrl(code, Constants.BOOKING_URL))
when (response.status) { when (response.status) {
HttpStatusCode.OK -> response.body() HttpStatusCode.OK -> {
// Используйте response.body с явным указанием типа
response.body<Map<String, List<BookingItem>>>()
}
else -> error(response.bodyAsText()) else -> error(response.bodyAsText())
} }
} }
} }
@OptIn(InternalAPI::class) @OptIn(InternalAPI::class)
suspend fun createNewBooking(code: String, booking: Booking) : Result<Boolean> = withContext(Dispatchers.IO) { suspend fun createNewBooking(code: String, data: String, placeId: Int): Result<Boolean> =
withContext(Dispatchers.IO) {
return@withContext runCatching { return@withContext runCatching {
val response = client.post(getUrl(code, Constants.BOOK_URL)) { val response = client.post(getUrl(code, Constants.BOOK_URL)) {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
body = booking setBody(CreateBookingRequest(data, placeId)) // используйте setBody вместо body
} }
when (response.status) { when (response.status) {
HttpStatusCode.OK -> true HttpStatusCode.OK -> true
else -> error(response.bodyAsText()) else -> error(response.bodyAsText())

View File

@@ -5,11 +5,7 @@ import ru.myitschool.work.data.repo.AuthRepository
class CheckAndSaveAuthCodeUseCase( class CheckAndSaveAuthCodeUseCase(
private val repository: AuthRepository private val repository: AuthRepository
) { ) {
suspend operator fun invoke( suspend operator fun invoke(text: String): Result<Unit> {
text: String return repository.checkAndSave(text)
): Result<Unit> {
return repository.checkAndSave(text).mapCatching { success ->
if (!success) error("Code is incorrect")
}
} }
} }

View File

@@ -5,12 +5,34 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class UserInfo( data class UserInfo(
val name: String, val name: String,
val photo: String? = null, val photoUrl: String? = null,
val bookings: List<Booking> val booking: Map<String, List<BookingItem>>
) ) {
fun bookingToList() : List<Booking> {
val bookings = mutableListOf<Booking>()
booking.forEach {
it.value.forEach { value ->
bookings.add(Booking(it.key, value.place))
}
}
return bookings
}
}
@Serializable @Serializable
data class Booking( data class Booking(
val date: String, val date: String,
val place: String val place: String
) )
@Serializable
data class BookingItem(
val id: Int,
val place: String
)
@Serializable
data class CreateBookingRequest(
val date: String,
val placeId: Int
)

View File

@@ -1,11 +1,9 @@
package ru.myitschool.work.ui.screen package ru.myitschool.work.ui.screen
import android.util.Log
import androidx.compose.animation.EnterTransition import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExitTransition
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
@@ -17,7 +15,7 @@ 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.BookScreen
import ru.myitschool.work.ui.screen.book.BookViewModel import ru.myitschool.work.ui.screen.main.MainScreen
@Composable @Composable
fun AppNavHost( fun AppNavHost(
@@ -32,18 +30,17 @@ fun AppNavHost(
startDestination = AuthScreenDestination, startDestination = AuthScreenDestination,
) { ) {
composable<AuthScreenDestination> { composable<AuthScreenDestination> {
Log.d("compose", "Auth")
AuthScreen(navController = navController) AuthScreen(navController = navController)
} }
composable<MainScreenDestination> { composable<MainScreenDestination> {
Box( Log.d("compose", "Main")
contentAlignment = Alignment.Center MainScreen(viewModel = viewModel(), navController)
) {
Text(text = "Hello")
}
} }
composable<BookScreenDestination> { composable<BookScreenDestination> {
Log.d("compose", "Book")
BookScreen( BookScreen(
viewModel = BookViewModel(), viewModel = viewModel(),
navController = navController navController = navController
) )
} }

View File

@@ -5,7 +5,6 @@ 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
@@ -30,6 +29,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import ru.myitschool.work.R import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds import ru.myitschool.work.core.TestIds
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.ui.nav.MainScreenDestination import ru.myitschool.work.ui.nav.MainScreenDestination
@Composable @Composable
@@ -45,6 +45,8 @@ fun AuthScreen(
} }
} }
AuthRepository.getSavedCode()?.let { viewModel.onIntent(AuthIntent.Send(it)) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -75,7 +77,7 @@ private fun Content(
) { ) {
var inputText by remember { mutableStateOf("") } var inputText by remember { mutableStateOf("") }
val isValidCode = inputText.length >= 4 && inputText.isNotEmpty() && inputText.none { it.isWhitespace() } && inputText.all { ch -> 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' ch in '0'..'9' || ch in 'A'..'Z' || ch in 'a'..'z'
} }

View File

@@ -24,11 +24,11 @@ class AuthViewModel : ViewModel() {
fun onIntent(intent: AuthIntent) { fun onIntent(intent: AuthIntent) {
when (intent) { when (intent) {
is AuthIntent.Send -> { is AuthIntent.Send -> {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.IO) {
_uiState.update { AuthState.Loading } _uiState.update { AuthState.Loading }
checkAndSaveAuthCodeUseCase.invoke(intent.text).fold( checkAndSaveAuthCodeUseCase.invoke(intent.text).fold(
onSuccess = { onSuccess = {
_actionFlow.emit(Unit)// переход на MainScreen _actionFlow.emit(Unit)
}, },
onFailure = { error -> onFailure = { error ->
_uiState.update { _uiState.update {

View File

@@ -1,9 +1,7 @@
package ru.myitschool.work.ui.screen.book package ru.myitschool.work.ui.screen.book
import ru.myitschool.work.ui.screen.Booking
sealed interface BookIntent { sealed interface BookIntent {
data class Send(val code : String, val booking: Booking) : BookIntent data class Send(val code : String, val data: String, val placeId : Int) : BookIntent
data object BackToMainScreen : BookIntent data object BackToMainScreen : BookIntent
data object ToAuthScreen : BookIntent data object ToAuthScreen : BookIntent
data object LoadData : BookIntent data object LoadData : BookIntent

View File

@@ -18,10 +18,11 @@ import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.platform.testTag import androidx.compose.ui.platform.testTag
@@ -34,11 +35,10 @@ import ru.myitschool.work.core.TestIds
import ru.myitschool.work.data.repo.AuthRepository import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.ui.nav.AuthScreenDestination import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.MainScreenDestination import ru.myitschool.work.ui.nav.MainScreenDestination
import ru.myitschool.work.ui.screen.Booking import java.time.LocalDate
import java.time.format.DateTimeFormatter
var selectedTime: MutableState<String?> = mutableStateOf(null)
var selectedBooking: MutableState<Booking?> = mutableStateOf(null)
var currentTime: MutableState<String?> = mutableStateOf(null)
@Composable @Composable
fun BookScreen( fun BookScreen(
viewModel: BookViewModel = viewModel(), viewModel: BookViewModel = viewModel(),
@@ -76,22 +76,93 @@ fun BookScreen(
// Text("" + selectedTime.value + "," + currentTime.value + "," + selectedBooking.value) // Text("" + selectedTime.value + "," + currentTime.value + "," + selectedBooking.value)
when (state) { when (state) {
is BookState.Data -> { is BookState.Data -> {
var selectedTime : String? by remember { mutableStateOf(null) }
var selectedBooking : Int? by remember { mutableStateOf(null) }
var currentTime : String? by remember { mutableStateOf(null) }
val options = (state as BookState.Data).booking val options = (state as BookState.Data).booking
if (currentTime.value == null)
if (currentTime == null)
for (el in options) { for (el in options) {
currentTime.value = el.key if (!el.value.isEmpty()) {
currentTime = el.key
break break
} }
TabGroup(options.keys) }
options[currentTime.value]?.let { SelectBooking(it) }
NavigationBar(
Modifier.fillMaxWidth()
) {
val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val outputFormatter = DateTimeFormatter.ofPattern("dd.MM")
options.keys
.map { LocalDate.parse(it, inputFormatter) }
.sorted()
// .map { it.format(inputFormatter) }
.forEachIndexed { index, label ->
options[label.format(inputFormatter)]?.let {
if (!it.isEmpty()) {
NavigationBarItem(
selected = currentTime == label.format(inputFormatter),
onClick = {
currentTime = label.format(inputFormatter)
},
icon = {
Text(
text = label.format(outputFormatter),
modifier = Modifier
.testTag(TestIds.Book.ITEM_DATE)
)
},
modifier = Modifier
.testTag(TestIds.Book.getIdDateItemByPosition(index))
)
}
}
}
}
LazyColumn(
Modifier
.fillMaxWidth()
) {
options[currentTime]?.forEach { book ->
item {
Row(
Modifier
.fillMaxWidth()
.selectableGroup()
.testTag(TestIds.Book.getIdPlaceItemByPosition(book.id)),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = book.place,
modifier = Modifier
.testTag(TestIds.Book.ITEM_PLACE_TEXT)
)
RadioButton(
selected = book.id == selectedBooking && currentTime == selectedTime,
onClick = {
selectedBooking = book.id
selectedTime = currentTime
},
modifier = Modifier
.testTag(TestIds.Book.ITEM_PLACE_SELECTOR)
)
}
}
}
}
Button( Button(
onClick = { onClick = {
viewModel.onIntent(BookIntent.Send(AuthRepository.getSavedCode()!!, selectedBooking.value!!)) viewModel.onIntent(BookIntent.Send(AuthRepository.getSavedCode()!!, selectedTime!!, selectedBooking!!))
}, },
modifier = Modifier modifier = Modifier
.testTag(TestIds.Book.BOOK_BUTTON), .testTag(TestIds.Book.BOOK_BUTTON),
enabled = selectedBooking.value != null enabled = selectedBooking != null
) { ) {
Text(stringResource(R.string.to_book)) Text(stringResource(R.string.to_book))
} }
@@ -145,64 +216,5 @@ fun BookScreen(
} }
@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

@@ -1,11 +1,11 @@
package ru.myitschool.work.ui.screen.book package ru.myitschool.work.ui.screen.book
import ru.myitschool.work.ui.screen.Booking import ru.myitschool.work.ui.screen.BookingItem
sealed interface BookState { sealed interface BookState {
object Loading: BookState object Loading: BookState
data class Data( data class Data(
val booking : Map<String, List<Booking>> = mapOf() val booking: Map<String, List<BookingItem>> = mapOf()
): BookState ): BookState
data class Error( data class Error(
val error : String val error : String

View File

@@ -8,19 +8,17 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.myitschool.work.data.repo.AuthRepository import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.data.repo.BookRepository import ru.myitschool.work.data.repo.BookRepository
import ru.myitschool.work.ui.screen.Booking
class BookViewModel : ViewModel() { class BookViewModel : ViewModel() {
private val _uiState = MutableStateFlow<BookState>(BookState.Loading) private val _uiState = MutableStateFlow<BookState>(BookState.Loading)
val uiState: StateFlow<BookState> = _uiState.asStateFlow(); val uiState: StateFlow<BookState> = _uiState.asStateFlow();
private val _navigationFlow = MutableSharedFlow<BookNavigationEvent>() private val _navigationFlow = MutableSharedFlow<BookNavigationEvent>(replay = 0, extraBufferCapacity = 1)
val navigationFlow: SharedFlow<BookNavigationEvent> = _navigationFlow.asSharedFlow() val navigationFlow: SharedFlow<BookNavigationEvent> = _navigationFlow
init { init {
loadData() loadData()
@@ -61,9 +59,10 @@ class BookViewModel : ViewModel() {
onSuccess = { onSuccess = {
it.let { bookings -> it.let { bookings ->
_uiState.update { _uiState.update {
when (bookings.isEmpty()) { Log.d("test", bookings.isEmpty().toString())
when (!bookings.isEmpty()) {
true -> BookState.Data( true -> BookState.Data(
booking = bookings.toMap() booking = bookings
) )
false -> BookState.NotData false -> BookState.NotData
} }
@@ -84,15 +83,19 @@ class BookViewModel : ViewModel() {
fun onIntent(intent: BookIntent) { fun onIntent(intent: BookIntent) {
when (intent) { when (intent) {
is BookIntent.Send -> { is BookIntent.Send -> {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.IO) {
_uiState.update { BookState.Loading } _uiState.update { BookState.Loading }
BookRepository.sendData(intent.code, intent.booking).fold( BookRepository.sendData(intent.code, intent.data, intent.placeId).fold(
onSuccess = { onSuccess = {
_navigationFlow.tryEmit(BookNavigationEvent.NavigateToMain) Log.d("send date", "success")
_navigationFlow.emit(BookNavigationEvent.NavigateToMain)
}, },
onFailure = { error -> onFailure = { error ->
Log.d("send date", "error: $error")
_uiState.update {
BookState.Error(error.message ?: "Неизвестная ошибка") BookState.Error(error.message ?: "Неизвестная ошибка")
} }
}
) )
} }
} }
@@ -111,15 +114,6 @@ class BookViewModel : ViewModel() {
} }
} }
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 { sealed interface BookNavigationEvent {
object NavigateToAuth : BookNavigationEvent object NavigateToAuth : BookNavigationEvent

View File

@@ -0,0 +1,7 @@
package ru.myitschool.work.ui.screen.main
sealed interface MainIntent {
data object LoadData : MainIntent
data object Logout : MainIntent
data object AddBooking : MainIntent
}

View File

@@ -0,0 +1,223 @@
package ru.myitschool.work.ui.screen.main
import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil3.compose.AsyncImage
import ru.myitschool.work.core.TestIds
import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.BookScreenDestination
import ru.myitschool.work.ui.screen.Booking
@Composable
fun MainScreen(
viewModel: MainViewModel = viewModel(),
navController: NavController
) {
val state by viewModel.uiState.collectAsState()
LaunchedEffect(state) {
Log.d("MainScreen", "UI State: $state")
}
LaunchedEffect(viewModel) {
viewModel.navigationFlow.collect { event ->
when (event) {
MainNavigationEvent.NavigateToAuth -> {
navController.navigate(AuthScreenDestination) {
popUpTo(navController.graph.startDestinationId) { inclusive = true }
}
}
MainNavigationEvent.NavigateToBook -> {
navController.navigate(BookScreenDestination)
}
}
}
}
when (state) {
MainState.Loading -> {
CircularProgressIndicator(
modifier = Modifier
.fillMaxSize()
.wrapContentSize(Alignment.Center)
)
}
is MainState.Content -> {
Content(
state = state as MainState.Content,
onRefresh = { viewModel.onIntent(MainIntent.LoadData) },
onLogout = { viewModel.onIntent(MainIntent.Logout) },
onAddBooking = { viewModel.onIntent(MainIntent.AddBooking) }
)
}
is MainState.ErrorOnly -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = (state as MainState.ErrorOnly).message,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag(TestIds.Main.ERROR)
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { viewModel.onIntent(MainIntent.LoadData) },
modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON)
) {
Text("Обновить")
}
}
}
}
}
@Composable
private fun Content(
state: MainState.Content,
onRefresh: () -> Unit,
onLogout: () -> Unit,
onAddBooking: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
state.userPhotoUrl?.let { url ->
AsyncImage(
model = url,
contentDescription = "Аватар",
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.testTag(TestIds.Main.PROFILE_IMAGE)
)
}
state.userName?.let {
Text(
text = it,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.testTag(TestIds.Main.PROFILE_NAME)
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Button(
onClick = onLogout,
modifier = Modifier.testTag(TestIds.Main.LOGOUT_BUTTON)
) {
Text("Выйти")
}
Button(
onClick = onRefresh,
modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON)
) {
Text("Обновить")
}
Button(
onClick = onAddBooking,
modifier = Modifier.testTag(TestIds.Main.ADD_BUTTON)
) {
Text("Забронировать")
}
}
Spacer(modifier = Modifier.height(16.dp))
if (state.error != null) {
Text(
text = state.error,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag(TestIds.Main.ERROR)
)
Spacer(modifier = Modifier.height(8.dp))
}
Text(
text = "Бронирования",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.fillMaxWidth()
.testTag("main_bookings_title")
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Дата",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.testTag("main_bookings_header_date")
)
Text(
text = "Место",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.testTag("main_bookings_header_place")
)
}
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(state.bookings) { index, item ->
BookingItemView(booking = item, index = index)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
@Composable
private fun BookingItemView(booking: Booking, index: Int) {
Row(
modifier = Modifier
.fillMaxWidth()
.testTag(TestIds.Main.getIdItemByPosition(index))
.padding(8.dp)
) {
Text(
text = booking.date,
modifier = Modifier.testTag(TestIds.Main.ITEM_DATE)
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = booking.place,
modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE)
)
}
}

View File

@@ -0,0 +1,16 @@
package ru.myitschool.work.ui.screen.main
import ru.myitschool.work.ui.screen.Booking
sealed interface MainState {
object Loading : MainState
data class Content(
val userName: String?,
val userPhotoUrl: String?,
val bookings: List<Booking>,
val error: String? = null
) : MainState
data class ErrorOnly(
val message: String
) : MainState
}

View File

@@ -0,0 +1,102 @@
package ru.myitschool.work.ui.screen.main
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.data.repo.MainRepository
import ru.myitschool.work.ui.screen.Booking
import java.time.LocalDate
import java.time.format.DateTimeFormatter
class MainViewModel : ViewModel() {
private val _uiState = MutableStateFlow<MainState>(MainState.Loading)
val uiState: StateFlow<MainState> = _uiState.asStateFlow()
private val _navigationFlow = MutableSharedFlow<MainNavigationEvent>(replay = 0, extraBufferCapacity = 1)
val navigationFlow: SharedFlow<MainNavigationEvent> = _navigationFlow
fun formatDateString(isoDate: String): String {
val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val outputFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
val date = LocalDate.parse(isoDate, inputFormatter)
return date.format(outputFormatter)
}
init {
loadData()
}
private fun loadData() {
viewModelScope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) {
_uiState.value = MainState.Loading
}
val code = AuthRepository.getSavedCode() ?: run {
_navigationFlow.emit(MainNavigationEvent.NavigateToAuth)
return@launch
}
MainRepository.loadUserInfo(code).fold(
onSuccess = { userInfo ->
val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val outputFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
val sortedBookings = userInfo.bookingToList()
.sortedBy { LocalDate.parse(it.date, inputFormatter) }
.map { booking ->
Booking(
date = LocalDate.parse(booking.date, inputFormatter).format(outputFormatter),
place = booking.place
)
}
withContext(Dispatchers.Main) {
_uiState.value = MainState.Content(
userName = userInfo.name,
userPhotoUrl = userInfo.photoUrl ?: "",
bookings = sortedBookings,
error = null
)
}
},
onFailure = { error ->
Log.e("MainViewModel", "Ошибка загрузки", error)
withContext(Dispatchers.Main) {
_uiState.value = MainState.ErrorOnly(
error.message ?: "Не удалось загрузить данные"
)
}
}
)
}
}
fun onIntent(intent: MainIntent) {
when (intent) {
MainIntent.LoadData -> loadData()
MainIntent.Logout -> {
MainRepository.clearAuth()
viewModelScope.launch {
_navigationFlow.emit(MainNavigationEvent.NavigateToAuth)
}
}
MainIntent.AddBooking -> {
viewModelScope.launch {
_navigationFlow.emit(MainNavigationEvent.NavigateToBook)
}
}
}
}
}
sealed interface MainNavigationEvent {
object NavigateToAuth : MainNavigationEvent
object NavigateToBook : MainNavigationEvent
}