main #6

Closed
student-20690 wants to merge 20 commits from (deleted):main into main
18 changed files with 375 additions and 109 deletions
Showing only changes of commit 257755a25a - Show all commits

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,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.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
@@ -28,7 +29,20 @@ object DataStoreDataSource {
App.context.dataStore.updateData {
it.toMutablePreferences().also { preferences ->
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.serialization.json.Json
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 {
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"
}

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)
}
composable<MainScreenDestination> {
MainScreen(
navController = navController,
onNavigateToBooking = {
navController.navigate(BookScreenDestination)
}
)
MainScreen(navController = navController)
}
composable<BookScreenDestination> {
BookScreen(

View File

@@ -27,7 +27,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun BookingScreen(
uiState: BookingUiState, // состояние интерфейса
uiState: BookingState, // состояние интерфейса
onSelectDate: (LocalDate) -> Unit, // callback при выборе даты
onSelectPlace: (String) -> 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
fun BookScreen(
onBack: () -> Unit, // callback при возврате назад
onBookingSuccess: () -> Unit // callback при успешном бронировании
) {
val viewModel: BookingViewModel = viewModel()
val viewModel: BookingViewModel = BookingViewModel()
val uiState by viewModel.uiState.collectAsState()
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() {
private val _uiState = MutableStateFlow(BookingUiState())
val uiState: StateFlow<BookingUiState> = _uiState.asStateFlow()
private val _uiState = MutableStateFlow(BookingState())
val uiState: StateFlow<BookingState> = _uiState.asStateFlow()
init {
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
import android.util.Log
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.navigation.NavController
import kotlinx.coroutines.launch
import coil3.compose.AsyncImage
import ru.myitschool.work.core.TestIds
import java.text.SimpleDateFormat
import java.util.*
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
// Модель данных для бронирования
data class BookingItem(
val date: String, // Формат "dd.MM.yyyy"
val place: String,
val id: Int
)
@Composable
fun MainScreen(
navController: NavController,
onNavigateToBooking: () -> Unit
) {
val viewModel = MainViewModel()
// Состояния
var userName by remember { mutableStateOf("Иван Иванов") }
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()
val event = viewModel.actionFlow.collectAsState(initial = null)
// Функция загрузки данных
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) {
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) {
Column(
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("Обновить")
}
}
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()
@@ -106,28 +87,33 @@ fun MainScreen(
verticalAlignment = Alignment.CenterVertically
) {
// Фото пользователя (main_photo)
Image(
painter = painterResource(id = android.R.drawable.ic_menu_gallery),
employee?.photoUrl?.let { msg -> Log.d("AnnaKonda", msg) }
AsyncImage(
model = employee?.photoUrl ?: "",
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))
// Имя пользователя (main_name)
Text(
text = userName,
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 = {
Button(
onClick = {
// Очистка данных и переход на авторизацию
userName = ""
viewModel.onIntent(MainIntent.LogOut)
bookingItems = emptyList()
navController.navigate("auth") { popUpTo(0) }
},
modifier = Modifier.testTag(TestIds.Main.LOGOUT_BUTTON)
) {
@@ -145,11 +131,11 @@ fun MainScreen(
) {
// Кнопка обновления (main_refresh_button)
Button(
onClick = { loadData() },
enabled = !isLoading,
onClick = { viewModel.onIntent(MainIntent.LoadData) },
enabled = state !is MainState.Loading,
modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON)
) {
if (isLoading) {
if (state is MainState.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
color = MaterialTheme.colorScheme.onPrimary
@@ -160,7 +146,8 @@ fun MainScreen(
}
// кнопка бронирования
Button(
onClick = { navController.navigate(BookScreenDestination)
onClick = {
navController.navigate(BookScreenDestination)
},
modifier = Modifier.testTag(TestIds.Main.ADD_BUTTON)
) {
@@ -171,14 +158,13 @@ fun MainScreen(
Spacer(modifier = Modifier.height(16.dp))
// Список бронирований
if (bookingItems.isNotEmpty()) {
if (!bookingItems.isNullOrEmpty()) {
LazyColumn(
modifier = Modifier.weight(1f)
) {
itemsIndexed(bookingItems) { index, item ->
itemsIndexed(bookingItems as List<Booking?>) { index, item ->
// Элемент списка (main_book_pos_{index})
Log.d("Nicoly", index.toString())
Card(
modifier = Modifier
.fillMaxWidth()
@@ -187,10 +173,13 @@ fun MainScreen(
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)
Text(
text = "Дата: ${item.date}",
text = "Дата: ${item?.date}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag(TestIds.Main.ITEM_DATE)
)
@@ -199,7 +188,7 @@ fun MainScreen(
// Место бронирования (main_item_place)
Text(
text = "Место: ${item.place}",
text = "Место: ${item?.place?.place}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
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()))
}
}
)
}
}
}