full mainscreen

This commit is contained in:
2025-12-02 21:08:22 +03:00
parent 46b82ee353
commit 257755a25a
18 changed files with 375 additions and 109 deletions

View File

@@ -1,7 +1,7 @@
package ru.myitschool.work.core package ru.myitschool.work.core
object Constants { object Constants {
const val HOST = "http://10.0.2.2:8080" const val HOST = "http://192.168.1.39: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

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

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

@@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import ru.myitschool.work.App import ru.myitschool.work.App
import ru.myitschool.work.core.OurConstants.DS_AUTH_KEY import ru.myitschool.work.core.OurConstants.DS_AUTH_KEY
@@ -28,7 +29,20 @@ object DataStoreDataSource {
App.context.dataStore.updateData { App.context.dataStore.updateData {
it.toMutablePreferences().also { preferences -> it.toMutablePreferences().also { preferences ->
preferences[AUTH_KEY] = code preferences[AUTH_KEY] = code
Log.d("AnnaKonda", "Code added to ds") }
}
}
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

@@ -11,6 +11,11 @@ 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.data.entity.Employee
import kotlinx.serialization.json.*
import ru.myitschool.work.data.entity.Booking
import ru.myitschool.work.data.entity.Place
import java.time.LocalDate
object NetworkDataSource { object NetworkDataSource {
private val client by lazy { private val client by lazy {
@@ -37,6 +42,69 @@ object NetworkDataSource {
} }
} }
} }
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("Пустой ответ от сервера")
}
val jsonObject = try {
Json.parseToJsonElement(json).jsonObject
} catch (e: Exception) {
error("Ошибка парсинга: ${e.message}")
}
val name = jsonObject["name"]?.jsonPrimitive?.content
?: error("Отсутствует поле 'name'")
val photoUrl = jsonObject["photoUrl"]?.jsonPrimitive?.content
?: error("Отсутствует поле 'photoUrl'")
val bookingJson = jsonObject["booking"]?.jsonObject
?: error("Отсутствует поле 'booking' в ответе")
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("Отсутствует поле id")
val placeString = bookingObj["place"]?.jsonPrimitive?.content
?: error("Отсутствует поле 'place' $dateString")
if (placeString.isBlank()) {
error("Пустое поле 'place' $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("Список бронирований пуст")
}
employee.bookingList.addAll(bookingList)
employee
}
else -> error(response.bodyAsText())
}
}
}
private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl" private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl"
} }

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

@@ -33,12 +33,7 @@ fun AppNavHost(
AuthScreen(navController = navController) AuthScreen(navController = navController)
} }
composable<MainScreenDestination> { composable<MainScreenDestination> {
MainScreen( MainScreen(navController = navController)
navController = navController,
onNavigateToBooking = {
navController.navigate(BookScreenDestination)
}
)
} }
composable<BookScreenDestination> { composable<BookScreenDestination> {
BookScreen( BookScreen(

View File

@@ -27,7 +27,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
@Composable @Composable
fun BookingScreen( fun BookingScreen(
uiState: BookingUiState, // состояние интерфейса uiState: BookingState, // состояние интерфейса
onSelectDate: (LocalDate) -> Unit, // callback при выборе даты onSelectDate: (LocalDate) -> Unit, // callback при выборе даты
onSelectPlace: (String) -> Unit, // callback при выборе места onSelectPlace: (String) -> Unit, // callback при выборе места
onBook: () -> Unit, // callback при бронировании onBook: () -> Unit, // callback при бронировании
@@ -136,22 +136,15 @@ fun BookingScreen(
} }
} }
// Модель состояния интерфейса
data class BookingUiState(
val dates: List<LocalDate> = emptyList(), // список доступных дат
val places: Map<LocalDate, List<String>> = emptyMap(), // места по датам
val selectedDate: LocalDate? = null, // выбранная дата
val selectedPlace: String? = null, // выбранное место
val isError: Boolean = false, // флаг ошибки
val errorMessage: String? = null // сообщение об ошибке
)
@Composable @Composable
fun BookScreen( fun BookScreen(
onBack: () -> Unit, // callback при возврате назад onBack: () -> Unit, // callback при возврате назад
onBookingSuccess: () -> Unit // callback при успешном бронировании onBookingSuccess: () -> Unit // callback при успешном бронировании
) { ) {
val viewModel: BookingViewModel = viewModel() val viewModel: BookingViewModel = BookingViewModel()
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
BookingScreen( BookingScreen(

View File

@@ -0,0 +1,12 @@
package ru.myitschool.work.ui.screen.book
import java.time.LocalDate
data class BookingState(
val dates: List<LocalDate> = emptyList(), // список доступных дат
val places: Map<LocalDate, List<String>> = emptyMap(), // места по датам
val selectedDate: LocalDate? = null, // выбранная дата
val selectedPlace: String? = null, // выбранное место
val isError: Boolean = false, // флаг ошибки
val errorMessage: String? = null // сообщение об ошибке
)

View File

@@ -10,8 +10,8 @@ import java.time.LocalDate
class BookingViewModel : ViewModel() { class BookingViewModel : ViewModel() {
private val _uiState = MutableStateFlow(BookingUiState()) private val _uiState = MutableStateFlow(BookingState())
val uiState: StateFlow<BookingUiState> = _uiState.asStateFlow() val uiState: StateFlow<BookingState> = _uiState.asStateFlow()
init { init {
loadBookingData() loadBookingData()

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

@@ -1,100 +1,81 @@
package ru.myitschool.work.ui.screen.main package ru.myitschool.work.ui.screen.main
import android.util.Log import android.util.Log
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import kotlinx.coroutines.launch import coil3.compose.AsyncImage
import ru.myitschool.work.core.TestIds import ru.myitschool.work.core.TestIds
import java.text.SimpleDateFormat import ru.myitschool.work.data.entity.Booking
import java.util.* import ru.myitschool.work.data.entity.Employee
import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.BookScreenDestination import ru.myitschool.work.ui.nav.BookScreenDestination
// Модель данных для бронирования
data class BookingItem(
val date: String, // Формат "dd.MM.yyyy"
val place: String,
val id: Int
)
@Composable @Composable
fun MainScreen( fun MainScreen(
navController: NavController, navController: NavController,
onNavigateToBooking: () -> Unit
) { ) {
val viewModel = MainViewModel()
// Состояния // Состояния
var userName by remember { mutableStateOf("Иван Иванов") } val event = viewModel.actionFlow.collectAsState(initial = null)
var isLoading by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") }
var bookingItems by remember { mutableStateOf(emptyList<BookingItem>()) }
var hasError by remember { mutableStateOf(false) }
// Для корутин
val coroutineScope = rememberCoroutineScope()
// Функция загрузки данных // Функция загрузки данных
fun loadData() {
isLoading = true
hasError = false
coroutineScope.launch {
kotlinx.coroutines.delay(1000) // Имитация задержки
// Имитация ответа от сервера
val response = listOf(
BookingItem("20.12.2023", "Конференц-зал А", 1),
BookingItem("15.12.2023", "Переговорная Б", 2),
BookingItem("25.12.2023", "Спортзал", 3)
)
// Сортировка по дате (увеличение)
bookingItems = response.sortedBy {
SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()).parse(it.date)
}
isLoading = false
}
}
// Первая загрузка при открытии экрана
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
loadData() 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
}
}
Log.d("AnnaKonda", errorMessage.toString())
// Если ошибка - показываем только ошибку и кнопку обновления // Если ошибка - показываем только ошибку и кнопку обновления
if (hasError) { if (errorMessage != null) {
Column( ErrorScreen(viewModel = viewModel, navController = navController, errorMessage)
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Текстовое поле с ошибкой (main_error)
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag(TestIds.Main.ERROR)
)
Spacer(modifier = Modifier.height(16.dp))
// Кнопка обновления (main_refresh_button)
Button(onClick = { loadData() }) {
Text("Обновить")
}
}
} else { } 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -106,28 +87,33 @@ fun MainScreen(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Фото пользователя (main_photo) // Фото пользователя (main_photo)
Image( employee?.photoUrl?.let { msg -> Log.d("AnnaKonda", msg) }
painter = painterResource(id = android.R.drawable.ic_menu_gallery), AsyncImage(
model = employee?.photoUrl ?: "",
contentDescription = "Фото", contentDescription = "Фото",
modifier = Modifier.size(64.dp).testTag(TestIds.Main.PROFILE_IMAGE) 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)) Spacer(modifier = Modifier.width(16.dp))
// Имя пользователя (main_name) // Имя пользователя (main_name)
Text( Text(
text = userName, text = employee!!.name,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f).testTag(TestIds.Main.PROFILE_NAME), modifier = Modifier.weight(1f).testTag(TestIds.Main.PROFILE_NAME),
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
// Кнопка выхода (main_logout_button) // Кнопка выхода (main_logout_button)
Button(onClick = { Button(
onClick = {
// Очистка данных и переход на авторизацию // Очистка данных и переход на авторизацию
userName = "" viewModel.onIntent(MainIntent.LogOut)
bookingItems = emptyList() bookingItems = emptyList()
navController.navigate("auth") { popUpTo(0) }
}, },
modifier = Modifier.testTag(TestIds.Main.LOGOUT_BUTTON) modifier = Modifier.testTag(TestIds.Main.LOGOUT_BUTTON)
) { ) {
@@ -145,11 +131,11 @@ fun MainScreen(
) { ) {
// Кнопка обновления (main_refresh_button) // Кнопка обновления (main_refresh_button)
Button( Button(
onClick = { loadData() }, onClick = { viewModel.onIntent(MainIntent.LoadData) },
enabled = !isLoading, enabled = state !is MainState.Loading,
modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON) modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON)
) { ) {
if (isLoading) { if (state is MainState.Loading) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
color = MaterialTheme.colorScheme.onPrimary color = MaterialTheme.colorScheme.onPrimary
@@ -160,7 +146,8 @@ fun MainScreen(
} }
// кнопка бронирования // кнопка бронирования
Button( Button(
onClick = { navController.navigate(BookScreenDestination) onClick = {
navController.navigate(BookScreenDestination)
}, },
modifier = Modifier.testTag(TestIds.Main.ADD_BUTTON) modifier = Modifier.testTag(TestIds.Main.ADD_BUTTON)
) { ) {
@@ -171,14 +158,13 @@ fun MainScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Список бронирований // Список бронирований
if (bookingItems.isNotEmpty()) { if (!bookingItems.isNullOrEmpty()) {
LazyColumn( LazyColumn(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
itemsIndexed(bookingItems) { index, item -> itemsIndexed(bookingItems as List<Booking?>) { index, item ->
// Элемент списка (main_book_pos_{index}) // Элемент списка (main_book_pos_{index})
Log.d("Nicoly", index.toString())
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -187,10 +173,13 @@ fun MainScreen(
containerColor = MaterialTheme.colorScheme.surfaceVariant containerColor = MaterialTheme.colorScheme.surfaceVariant
) )
) { ) {
Column(modifier = Modifier.padding(16.dp).testTag(TestIds.Main.getIdItemByPosition(index))) { Column(
modifier = Modifier.padding(16.dp)
.testTag(TestIds.Main.getIdItemByPosition(index))
) {
// Дата бронирования (main_item_date) // Дата бронирования (main_item_date)
Text( Text(
text = "Дата: ${item.date}", text = "Дата: ${item?.date}",
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag(TestIds.Main.ITEM_DATE) modifier = Modifier.testTag(TestIds.Main.ITEM_DATE)
) )
@@ -199,7 +188,7 @@ fun MainScreen(
// Место бронирования (main_item_place) // Место бронирования (main_item_place)
Text( Text(
text = "Место: ${item.place}", text = "Место: ${item?.place?.place}",
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE) modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE)
) )
@@ -223,3 +212,34 @@ fun MainScreen(
} }
} }
} }
@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,66 @@
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.auth.CheckAndSaveAuthCodeUseCase
import ru.myitschool.work.domain.main.GetUserDataUseCase
import ru.myitschool.work.ui.screen.auth.AuthAction
import ru.myitschool.work.ui.screen.auth.AuthIntent
import ru.myitschool.work.ui.screen.auth.AuthState
class MainViewModel : ViewModel() {
init {
loadData()
}
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()
}
}
}
}
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()))
}
}
)
}
}
}