diff --git a/app/src/main/java/ru/myitschool/work/App.kt b/app/src/main/java/ru/myitschool/work/App.kt index 72e003b..fcf320f 100644 --- a/app/src/main/java/ru/myitschool/work/App.kt +++ b/app/src/main/java/ru/myitschool/work/App.kt @@ -2,24 +2,24 @@ package ru.myitschool.work import android.app.Application import android.content.Context -import ru.myitschool.work.data.repo.AuthRepository +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import ru.myitschool.work.data.datastore.DataStoreManager -class App : Application() { +val Context.dataStore: DataStore by preferencesDataStore(name = "datastore") - companion object { - @Volatile - private var instance: App? = null +class App: Application() { - fun getAppContext(): Context { - return instance?.applicationContext ?: throw IllegalStateException( - "app not initialized") - } - } + lateinit var dataStoreManager: DataStoreManager override fun onCreate() { super.onCreate() - instance = this + context = this + dataStoreManager = DataStoreManager(dataStore) + } - AuthRepository.getInstance(applicationContext) + companion object { + lateinit var context: Context } } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/core/Constants.kt b/app/src/main/java/ru/myitschool/work/core/Constants.kt index 70070ea..a8b7cc5 100644 --- a/app/src/main/java/ru/myitschool/work/core/Constants.kt +++ b/app/src/main/java/ru/myitschool/work/core/Constants.kt @@ -1,10 +1,7 @@ package ru.myitschool.work.core object Constants { - const val HOST = "http://10.0.2.2:8080" -// const val HOST = "http://127.0.0.1:8080" - const val AUTH_URL = "/auth" const val INFO_URL = "/info" const val BOOKING_URL = "/booking" diff --git a/app/src/main/java/ru/myitschool/work/data/datastore/DataStoreManager.kt b/app/src/main/java/ru/myitschool/work/data/datastore/DataStoreManager.kt new file mode 100644 index 0000000..262260d --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/datastore/DataStoreManager.kt @@ -0,0 +1,35 @@ +package ru.myitschool.work.data.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class DataStoreManager( + private val dataStore: DataStore +) { + + companion object { + private val USER_CODE_KEY = stringPreferencesKey("user_code") + } + + suspend fun clearUserCode() { + dataStore.edit { preferences -> + preferences.remove(USER_CODE_KEY) + } + } + + suspend fun saveUserCode(userCode: UserCode) { + dataStore.edit { preferences -> + preferences[USER_CODE_KEY] = userCode.code + } + } + + fun getUserCode(): Flow = dataStore.data.map { preferences -> + UserCode( + code = preferences[USER_CODE_KEY] ?: "" + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/datastore/UserCode.kt b/app/src/main/java/ru/myitschool/work/data/datastore/UserCode.kt new file mode 100644 index 0000000..e408283 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/datastore/UserCode.kt @@ -0,0 +1,5 @@ +package ru.myitschool.work.data.datastore + +data class UserCode( + val code: String +) diff --git a/app/src/main/java/ru/myitschool/work/data/dtos/BookingDto.kt b/app/src/main/java/ru/myitschool/work/data/dtos/BookingDto.kt deleted file mode 100644 index 08cce25..0000000 --- a/app/src/main/java/ru/myitschool/work/data/dtos/BookingDto.kt +++ /dev/null @@ -1,6 +0,0 @@ -package ru.myitschool.work.data.dtos - -class BookingDto { - - -} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/model/BookingInfoResponse.kt b/app/src/main/java/ru/myitschool/work/data/model/BookingInfoResponse.kt deleted file mode 100644 index 22341bd..0000000 --- a/app/src/main/java/ru/myitschool/work/data/model/BookingInfoResponse.kt +++ /dev/null @@ -1,13 +0,0 @@ -package ru.myitschool.work.data.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - - -typealias BookingInfoResponse = Map> - -@Serializable -data class PlaceInfo( - val id: Int, - val place: String -) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/model/UserInfoResponse.kt b/app/src/main/java/ru/myitschool/work/data/model/UserInfoResponse.kt deleted file mode 100644 index 10f14cc..0000000 --- a/app/src/main/java/ru/myitschool/work/data/model/UserInfoResponse.kt +++ /dev/null @@ -1,41 +0,0 @@ -package ru.myitschool.work.data.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class UserInfoResponse( - @SerialName("name") - val name: String, - - @SerialName("photoUrl") - val photoUrl: String?, - - @SerialName("booking") - val bookingMap: Map = emptyMap() -) { - val bookings: List - get() = bookingMap.map { (date, info) -> - BookingResponse( - date = date, - place = info.place, - bookingId = info.id - ) - } -} - -@Serializable -data class BookingInfo( - @SerialName("id") - val id: Int, - - @SerialName("place") - val place: String -) - -@Serializable -data class BookingResponse( - val date: String, - val place: String, - val bookingId: Int -) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt b/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt index d0d17e2..2cbd520 100644 --- a/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt +++ b/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt @@ -1,94 +1,10 @@ package ru.myitschool.work.data.repo -import android.content.Context -import android.content.Context.MODE_PRIVATE -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import ru.myitschool.work.data.source.NetworkDataSource -class AuthRepository private constructor(context: Context) { +object AuthRepository { - companion object { - @Volatile - private var INSTANCE: AuthRepository? = null - - fun getInstance(context: Context): AuthRepository { - return INSTANCE ?: synchronized(this) { - INSTANCE ?: AuthRepository(context.applicationContext).also { INSTANCE = it } - } - } - - fun clearInstance() { - INSTANCE = null - } - } - - private val PREFS_NAME = "auth_prefs" - private val KEY_CODE = "auth_code" - private val KEY_NAME = "user_name" - private val KEY_PHOTO = "user_photo" - - private val context: Context = context.applicationContext - private var codeCache: String? = null - private var userCache: UserCache? = null - - private val _isAuthorized = MutableStateFlow(false) - val isAuthorized: StateFlow = _isAuthorized.asStateFlow() - - init { - val prefs = context.getSharedPreferences(PREFS_NAME, MODE_PRIVATE) - codeCache = prefs.getString(KEY_CODE, null) - val name = prefs.getString(KEY_NAME, null) - val photo = prefs.getString(KEY_PHOTO, null) - - if (codeCache != null && name != null) { - userCache = UserCache(name, photo) - _isAuthorized.value = true - } else { - clear() - } - } - - private fun getPrefs() = context.getSharedPreferences(PREFS_NAME, MODE_PRIVATE) - - suspend fun checkAndSave(text: String): Result { - return NetworkDataSource.checkAuth(text).onSuccess { success -> - if (success) { - codeCache = text - _isAuthorized.value = true - getPrefs().edit() - .putString(KEY_CODE, text) - .apply() - } - }.onFailure { exception -> - println("Auth error: ${exception.message}") - } - } - - fun getCurrentCode(): String? = codeCache - - fun saveUserInfo(name: String, photo: String?) { - userCache = UserCache(name, photo) - getPrefs().edit() - .putString(KEY_NAME, name) - .putString(KEY_PHOTO, photo) - .apply() - } - - fun getUserInfo(): UserCache? = userCache - - fun clear() { - codeCache = null - userCache = null - _isAuthorized.value = false - getPrefs().edit() - .clear() - .apply() + suspend fun checkAndSave(code: String): Result { + return NetworkDataSource.checkAuth(code) } } - -data class UserCache( - val name: String, - val photo: String? -) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/repo/BookRepository.kt b/app/src/main/java/ru/myitschool/work/data/repo/BookRepository.kt index 37cd267..9f7a2b2 100644 --- a/app/src/main/java/ru/myitschool/work/data/repo/BookRepository.kt +++ b/app/src/main/java/ru/myitschool/work/data/repo/BookRepository.kt @@ -1,26 +1,25 @@ package ru.myitschool.work.data.repo -import ru.myitschool.work.data.model.PlaceInfo -import ru.myitschool.work.data.source.AuthException import ru.myitschool.work.data.source.NetworkDataSource +import ru.myitschool.work.domain.book.entities.BookingEntity -class BookRepository(private val authRepository: AuthRepository) { +object ReserveRepo { - suspend fun getAvailableBookings(): Result>> { - val code = authRepository.getCurrentCode() - return if (code != null) { - NetworkDataSource.getBookingInfo(code) - } else { - Result.failure(AuthException("user not authorized")) - } + suspend fun loadAvailable(code: String): Result { + return NetworkDataSource.loadBooking(code) } - suspend fun book(date: String, placeId: Int): Result { - val code = authRepository.getCurrentCode() - return if (code != null) { - NetworkDataSource.book(code, date, placeId) - } else { - Result.failure(AuthException("user not authorized")) - } + suspend fun reserve( + userCode: String, + day: String, + placeId: Int, + placeLabel: String + ): Result { + return NetworkDataSource.bookPlace( + userCode = userCode, + date = day, + placeId = placeId, + placeName = placeLabel + ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/ru/myitschool/work/data/repo/MainRepository.kt b/app/src/main/java/ru/myitschool/work/data/repo/MainRepository.kt index 5f7aeca..0576640 100644 --- a/app/src/main/java/ru/myitschool/work/data/repo/MainRepository.kt +++ b/app/src/main/java/ru/myitschool/work/data/repo/MainRepository.kt @@ -1,18 +1,11 @@ package ru.myitschool.work.data.repo -import ru.myitschool.work.data.model.UserInfoResponse -import ru.myitschool.work.data.source.AuthException import ru.myitschool.work.data.source.NetworkDataSource +import ru.myitschool.work.domain.main.entities.UserEntity +object ProfileRepo { -class MainRepository(private val authRepository: AuthRepository) { - - suspend fun getUserInfo(): Result { - val code = authRepository.getCurrentCode() - return if (code != null) { - NetworkDataSource.getInfo(code) - } else { - Result.failure(AuthException("user not pass auth")) - } + suspend fun loadProfile(code: String): Result { + return NetworkDataSource.loadData(code) } -} \ No newline at end of file +} diff --git a/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt b/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt index 8f0f323..70a66cc 100644 --- a/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt +++ b/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt @@ -1,192 +1,129 @@ package ru.myitschool.work.data.source +import android.util.Log import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.cio.CIO -import io.ktor.client.plugins.ClientRequestException -import io.ktor.client.plugins.HttpRequestTimeoutException -import io.ktor.client.plugins.HttpResponseValidator import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.defaultRequest import io.ktor.client.request.get import io.ktor.client.request.post import io.ktor.client.request.setBody -import io.ktor.http.ContentType +import io.ktor.client.statement.bodyAsText 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.model.PlaceInfo -import ru.myitschool.work.data.model.UserInfoResponse -import java.net.ConnectException -import java.net.SocketTimeoutException +import ru.myitschool.work.domain.book.entities.BookingEntity +import ru.myitschool.work.domain.book.entities.PlaceInfo +import ru.myitschool.work.domain.main.entities.UserEntity -@Serializable -data class BookRequest( - val date: String, - val placeId: Int -) +private const val testJson = """ +{ + "name": "Иванов Петр Федорович", + "photoUrl": "https://funnyducks.ru/upload/iblock/0cd/0cdeb7ec3ed6fddda0f90fccee05557d.jpg", + "booking": { + "2025-01-05": {"id":1,"place":"102"}, + "2025-01-06": {"id":2,"place":"209.13"}, + "2025-01-09": {"id":3,"place":"Зона 51. 50"} + } +} +""" + +private const val testBookingJson = """ +{ + "2025-01-05": [{"id": 1, "place": "102"},{"id": 2, "place": "209.13"}], + "2025-01-06": [{"id": 3, "place": "Зона 51. 50"}], + "2025-01-07": [{"id": 1, "place": "102"},{"id": 2, "place": "209.13"}], + "2025-01-08": [{"id": 2, "place": "209.13"}] +} +""" object NetworkDataSource { private val client by lazy { HttpClient(CIO) { - engine { - requestTimeout = 10000 - } - - HttpResponseValidator { - validateResponse { response -> - val statusCode = response.status.value - when (statusCode) { - in 400..499 -> { - when (response.status) { - HttpStatusCode.Unauthorized -> { - throw AuthException("Неверный код авторизации") - } - HttpStatusCode.NotFound -> { - throw NotFoundException("Ресурс не найден") - } - HttpStatusCode.Conflict -> { - throw ConflictException("Место уже забронировано") - } - HttpStatusCode.BadRequest -> { - throw BadRequestException("Некорректный запрос") - } - else -> { - val exceptionMessage = response.body() - throw ClientRequestException( - response, - exceptionMessage.ifBlank { - "Клиентская ошибка: $statusCode" - } - ) - } - } - } - in 500..599 -> { - throw ServerException("Ошибка сервера: $statusCode") - } - } - } - - handleResponseExceptionWithRequest { exception, _ -> - when (exception) { - is SocketTimeoutException -> { - throw NetworkException("Таймаут соединения") - } - is ConnectException -> { - throw NetworkException("Не удалось подключиться к серверу") - } - is HttpRequestTimeoutException -> { - throw NetworkException("Таймаут запроса") - } - } - } - } - install(ContentNegotiation) { json( Json { isLenient = true ignoreUnknownKeys = true - explicitNulls = false + explicitNulls = true encodeDefaults = true } ) } + } + } - defaultRequest { - contentType(ContentType.Application.Json) + suspend fun bookPlace( + userCode: String, + date: String, + placeId: Int, + placeName: String + ): Result = withContext(Dispatchers.IO) { + return@withContext runCatching { +// Log.i("aaa", "Booking: userCode=$userCode, date=$date, placeId=$placeId, placeName=$placeName") +// println("Booking: userCode=$userCode, date=$date, placeId=$placeId, placeName=$placeName") + + val response = client.post(getUrl(userCode, Constants.BOOK_URL)) { + setBody(mapOf( + "date" to date, + "placeId" to placeId, + "placeName" to placeName + )) + } + + when (response.status) { + HttpStatusCode.OK -> Unit + else -> error(response.bodyAsText()) } } } suspend fun checkAuth(code: String): Result = withContext(Dispatchers.IO) { return@withContext runCatching { + + // true // удалить при проверке + val response = client.get(getUrl(code, Constants.AUTH_URL)) - response.status == HttpStatusCode.OK - }.recoverCatching { throwable -> - when (throwable) { - is AuthException -> false - else -> throw throwable - } - } - } - - suspend fun getInfo(code: String): Result = withContext(Dispatchers.IO) { - return@withContext runCatching { - val response = client.get(getUrl(code, Constants.INFO_URL)) - + response.status when (response.status) { - HttpStatusCode.OK -> response.body() - else -> { - val errorMessage = response.body().ifBlank { - "Ошибка: ${response.status}" - } - throw RuntimeException(errorMessage) - } + HttpStatusCode.OK -> true + else -> error(response.bodyAsText()) } } } - suspend fun getBookingInfo(code: String): Result>> = withContext(Dispatchers.IO) { + suspend fun loadData(code: String): Result = withContext(Dispatchers.IO) { return@withContext runCatching { - val response = client.get(getUrl(code, Constants.BOOKING_URL)) +// Json.decodeFromString(testJson) // удалить при проверке + + val response = client.get(getUrl(code, Constants.INFO_URL)) when (response.status) { HttpStatusCode.OK -> { - try { - val body = response.body>>() - body - } catch (e: Exception) { - emptyMap() - } - } - HttpStatusCode.NoContent -> { - emptyMap() - } - else -> { - val errorMsg = response.body().ifBlank { - "Ошибка загрузки данных: ${response.status}" - } - throw RuntimeException(errorMsg) + response.body() } + else -> error(response.bodyAsText()) } } } - suspend fun book(code: String, date: String, placeId: Int): Result = withContext(Dispatchers.IO) { + suspend fun loadBooking(code: String): Result = withContext(Dispatchers.IO) { return@withContext runCatching { - val response = client.post(getUrl(code, Constants.BOOK_URL)) { - setBody(BookRequest(date, placeId)) - } +// BookingEntity(Json.decodeFromString>>(testBookingJson)) // удалить при проверке + + val response = client.get(getUrl(code, Constants.BOOKING_URL)) when (response.status) { - HttpStatusCode.OK -> Unit - HttpStatusCode.Created -> Unit - HttpStatusCode.Conflict -> throw ConflictException("Место уже забронировано") - else -> { - val errorMsg = response.body().ifBlank { - "Ошибка бронирования: ${response.status}" - } - throw RuntimeException(errorMsg) + HttpStatusCode.OK -> { + BookingEntity(response.body>>()) } + else -> error(response.bodyAsText()) } } } - private fun getUrl(code: String, targetUrl: String): String { - return "${Constants.HOST}/api/$code$targetUrl" - } -} - -class NetworkException(message: String) : Exception(message) -class AuthException(message: String) : Exception(message) -class NotFoundException(message: String) : Exception(message) -class ConflictException(message: String) : Exception(message) -class ServerException(message: String) : Exception(message) -class BadRequestException(message: String) : Exception(message) \ No newline at end of file + private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl" +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/book/BookingUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/book/BookingUseCase.kt new file mode 100644 index 0000000..cd06657 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/book/BookingUseCase.kt @@ -0,0 +1,16 @@ +package ru.myitschool.work.domain.book + +import ru.myitschool.work.data.repo.BookRepository + +class BookingUseCase( + private val repository: BookRepository +) { + suspend operator fun invoke( + userCode: String, + date: String, + placeId: Int, + placeName: String + ): Result { + return repository.bookPlace(userCode, date, placeId, placeName) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/book/LoadBookingUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/book/LoadBookingUseCase.kt new file mode 100644 index 0000000..735a004 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/book/LoadBookingUseCase.kt @@ -0,0 +1,16 @@ +package ru.myitschool.work.domain.book + +import ru.myitschool.work.data.repo.BookRepository +import ru.myitschool.work.data.repo.MainRepository +import ru.myitschool.work.domain.book.entities.BookingEntity +import ru.myitschool.work.domain.main.entities.UserEntity + +class LoadBookingUseCase( + private val repository: BookRepository +) { + suspend operator fun invoke( + text: String + ): Result { + return repository.loadBooking(text) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/book/entities/BookingEntity.kt b/app/src/main/java/ru/myitschool/work/domain/book/entities/BookingEntity.kt new file mode 100644 index 0000000..6e31444 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/book/entities/BookingEntity.kt @@ -0,0 +1,8 @@ +package ru.myitschool.work.domain.book.entities + +import kotlinx.serialization.Serializable + +@Serializable +data class BookingEntity( + val bookings: Map> +) diff --git a/app/src/main/java/ru/myitschool/work/domain/book/entities/PlaceInfo.kt b/app/src/main/java/ru/myitschool/work/domain/book/entities/PlaceInfo.kt new file mode 100644 index 0000000..16d2281 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/book/entities/PlaceInfo.kt @@ -0,0 +1,9 @@ +package ru.myitschool.work.domain.book.entities + +import kotlinx.serialization.Serializable + +@Serializable +data class PlaceInfo( + val id: Int, + val place: String +) diff --git a/app/src/main/java/ru/myitschool/work/domain/main/LoadDataUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/main/LoadDataUseCase.kt new file mode 100644 index 0000000..afe9a34 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/main/LoadDataUseCase.kt @@ -0,0 +1,14 @@ +package ru.myitschool.work.domain.main + +import ru.myitschool.work.data.repo.MainRepository +import ru.myitschool.work.domain.main.entities.UserEntity + +class LoadDataUseCase( + private val repository: MainRepository +) { + suspend operator fun invoke( + text: String + ): Result { + return repository.loadData(text) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/main/entities/BookingInfo.kt b/app/src/main/java/ru/myitschool/work/domain/main/entities/BookingInfo.kt new file mode 100644 index 0000000..15d7ca8 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/main/entities/BookingInfo.kt @@ -0,0 +1,9 @@ +package ru.myitschool.work.domain.main.entities + +import kotlinx.serialization.Serializable + +@Serializable +data class BookingInfo( + val id: Int, + val place: String +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/main/entities/UserEntity.kt b/app/src/main/java/ru/myitschool/work/domain/main/entities/UserEntity.kt new file mode 100644 index 0000000..fd7bfd5 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/main/entities/UserEntity.kt @@ -0,0 +1,26 @@ +package ru.myitschool.work.domain.main.entities + +import kotlinx.serialization.Serializable +import ru.myitschool.work.formatDate + +@Serializable +data class UserEntity( + val name: String, + val photoUrl: String, + val booking: Map? = null +) { + fun getSortedBookings(): List> { + return booking?.entries + ?.sortedBy { (date, _) -> date } + ?.map { it.toPair() } + ?: emptyList() + } + + fun getSortedBookingsWithFormattedDate(): List> { + return getSortedBookings().map { (date, bookingInfo) -> + Triple(date, date.formatDate(), bookingInfo) + } + } + + fun hasBookings(): Boolean = !booking.isNullOrEmpty() +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/Composables.kt b/app/src/main/java/ru/myitschool/work/ui/Composables.kt new file mode 100644 index 0000000..9a22575 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/Composables.kt @@ -0,0 +1,198 @@ +package ru.myitschool.work.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ru.myitschool.work.R +import ru.myitschool.work.ui.theme.Black +import ru.myitschool.work.ui.theme.Gray +import ru.myitschool.work.ui.theme.LightBlue +import ru.myitschool.work.ui.theme.LightGray +import ru.myitschool.work.ui.theme.Typography +import ru.myitschool.work.ui.theme.White + +@Composable +fun BaseText24( + text: String, + modifier: Modifier = Modifier, + color: Color = Black, + textAlign: TextAlign = TextAlign.Left +) { + Text( + text = text, + fontSize = 24.sp, + style = Typography.bodyLarge, + modifier = modifier, + color = color, + textAlign = textAlign + ) +} + +@Composable +fun BaseButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Button( + onClick = onClick, + modifier = modifier, + colors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = White, + disabledContainerColor = Color.Transparent, + disabledContentColor = White + ) + ) { + BaseText16(text = text, color = White) + } +} + +@Composable +fun Logo() { + Image( + painter = painterResource(R.drawable.ic_git_clone_mini), + contentDescription = "Logo", + modifier = Modifier.padding(top = 40.dp, bottom = 60.dp) + ) +} + +@Composable +fun BaseText16( + text: String, + modifier: Modifier = Modifier, + color: Color = Black, +) { + Text( + text = text, + style = Typography.bodySmall, + fontSize = 16.sp, + color = color, + modifier = modifier + ) +} + +@Composable +fun BaseText12( + modifier: Modifier = Modifier, + text: String, + color: Color = Black, +) { + Text( + text = text, + style = Typography.bodySmall, + fontSize = 12.sp, + color = color, + modifier = modifier, + ) +} + +@Composable +fun BaseText14( + modifier: Modifier = Modifier, + text: String, + color: Color = Black, +) { + Text( + text = text, + style = Typography.bodySmall, + fontSize = 14.sp, + color = color, + modifier = modifier, + ) +} + +@Composable +fun BaseInputText( + placeholder: String= "", + modifier: Modifier = Modifier, + onValueChange: (String) -> Unit, + value: String +) { + + TextField( + value = value, + onValueChange = onValueChange, + shape = RoundedCornerShape(16.dp), + placeholder = { + BaseText16( + text = placeholder, + color = Gray + ) + }, + textStyle = Typography.bodySmall.copy(fontSize = 16.sp), + colors = TextFieldDefaults.colors( + focusedContainerColor = LightBlue, + unfocusedContainerColor = LightBlue, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent + ), + singleLine = true, + modifier = modifier + ) +} + +@Composable +fun BaseText20( + text: String, + color: Color = Color.Unspecified, + style: TextStyle = Typography.bodySmall, + modifier: Modifier = Modifier.padding(7.dp), + textAlign: TextAlign = TextAlign.Unspecified +) { + Text( + text = text, + style = style, + fontSize = 20.sp, + modifier = modifier, + color = color, + textAlign = textAlign + ) +} + +@Composable +fun BaseButton( + border: BorderStroke? = null, + enable: Boolean = true, + text: String, + btnColor: Color, + btnContentColor: Color, + onClick: () -> Unit, + icon: @Composable RowScope.() -> Unit = {}, + modifier: Modifier = Modifier.fillMaxWidth() +) { + Button( + border = border, + enabled = enable, + onClick = onClick, + colors = ButtonDefaults.buttonColors( + containerColor = btnColor, + contentColor = btnContentColor, + disabledContainerColor = LightGray, + disabledContentColor = Gray + ), + modifier = modifier, + shape = RoundedCornerShape(16.dp), + ) { + icon() + BaseText20(text = text) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/nav/SplashScreenDestination.kt b/app/src/main/java/ru/myitschool/work/ui/nav/SplashScreenDestination.kt new file mode 100644 index 0000000..4fc41c7 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/nav/SplashScreenDestination.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.nav + +import kotlinx.serialization.Serializable + +@Serializable +data object SplashScreenDestination: AppDestination \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt b/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt index f8abc91..e2a67ad 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt @@ -2,54 +2,39 @@ 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.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import ru.myitschool.work.data.repo.AuthRepository 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.nav.SplashScreenDestination import ru.myitschool.work.ui.screen.auth.AuthScreen import ru.myitschool.work.ui.screen.book.BookScreen import ru.myitschool.work.ui.screen.main.MainScreen +import ru.myitschool.work.ui.screen.splash.SplashScreen @Composable fun AppNavHost( modifier: Modifier = Modifier, navController: NavHostController = rememberNavController() ) { - val context = LocalContext.current - val authRepository = remember { AuthRepository.getInstance(context) } - - val isAuthorized by authRepository.isAuthorized.collectAsState() - - LaunchedEffect(isAuthorized) { - if (isAuthorized) { - navController.navigate(MainScreenDestination) { - popUpTo(0) { inclusive = false } - } - } else { - navController.navigate(AuthScreenDestination) { - popUpTo(0) { inclusive = false } - } - } - } - NavHost( modifier = modifier, enterTransition = { EnterTransition.None }, exitTransition = { ExitTransition.None }, navController = navController, - startDestination = if (isAuthorized) MainScreenDestination else AuthScreenDestination, + startDestination = SplashScreenDestination, ) { + composable { + SplashScreen(navController = navController) + } composable { AuthScreen(navController = navController) } diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt index fd5d0df..8e15357 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt @@ -1,28 +1,20 @@ package ru.myitschool.work.ui.screen.auth -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight 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.material3.Button +import androidx.compose.foundation.layout.width import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -31,60 +23,55 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import ru.myitschool.work.R import ru.myitschool.work.core.TestIds +import ru.myitschool.work.ui.BaseButton +import ru.myitschool.work.ui.BaseInputText +import ru.myitschool.work.ui.BaseText12 +import ru.myitschool.work.ui.BaseText24 +import ru.myitschool.work.ui.Logo import ru.myitschool.work.ui.nav.MainScreenDestination +import ru.myitschool.work.ui.theme.Blue +import ru.myitschool.work.ui.theme.Red +import ru.myitschool.work.ui.theme.White @Composable fun AuthScreen( - viewModel: AuthViewModel = viewModel(factory = AuthViewModelFactory(LocalContext.current)), - navController: NavController + viewModel: AuthViewModel = viewModel(), + navController: NavController, ) { val state by viewModel.uiState.collectAsState() - val keyboardController = LocalSoftwareKeyboardController.current LaunchedEffect(Unit) { - viewModel.actionFlow.collect { action -> - when (action) { - is AuthAction.NavigateToMain -> { - navController.navigate(MainScreenDestination) { - popUpTo(0) - } - } - } + viewModel.actionFlow.collect { + navController.navigate(MainScreenDestination) } } Box( - modifier = Modifier - .fillMaxSize() - .padding(all = 24.dp), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() ) { Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + modifier = Modifier + .width(400.dp) + .fillMaxHeight() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Text( + + Logo() + + BaseText24( text = stringResource(R.string.auth_title), - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center, - modifier = Modifier.padding(bottom = 32.dp) + textAlign = TextAlign.Center ) - when (val currentState = state) { + when (state) { + is AuthState.Data -> Content(viewModel) is AuthState.Loading -> { CircularProgressIndicator( - modifier = Modifier.size(64.dp) - ) - } - is AuthState.Data -> { - Content( - state = currentState, - // ?????? bug fix - onTextChange = { viewModel.onIntent(AuthIntent.TextInput(it)) }, - onSendClick = { - keyboardController?.hide() - viewModel.onIntent(AuthIntent.Send(it)) - } + modifier = Modifier + .padding(top = 40.dp) + .size(64.dp) ) } } @@ -94,48 +81,50 @@ fun AuthScreen( @Composable private fun Content( - state: AuthState.Data, - onTextChange: (String) -> Unit, - onSendClick: (String) -> Unit + viewModel: AuthViewModel ) { - val isButtonEnabled = state.code.length == 4 && - state.code.matches(Regex("^[a-zA-Z0-9]{4}$")) + + val isButtonEnabled by viewModel.isButtonEnabled.collectAsState() + val errorStateValue by viewModel.errorStateValue.collectAsState() + val textState by viewModel.textState.collectAsState() Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier.padding(vertical = 20.dp) ) { - if (state.error != null) { - Text( - text = state.error, - color = Color.Red, - modifier = Modifier - .testTag(TestIds.Auth.ERROR) - .padding(bottom = 16.dp) - ) - } - TextField( + BaseInputText( + value = textState, + placeholder = stringResource(R.string.auth_label), modifier = Modifier .testTag(TestIds.Auth.CODE_INPUT) .fillMaxWidth(), - value = state.code, - onValueChange = onTextChange, - label = { Text(stringResource(R.string.auth_label)) }, - singleLine = true, - isError = state.error != null + onValueChange = { viewModel.onIntent(AuthIntent.TextInput(it)) } ) - Spacer(modifier = Modifier.height(16.dp)) - - Button( - modifier = Modifier - .testTag(TestIds.Auth.SIGN_BUTTON) - .fillMaxWidth(), - onClick = { onSendClick(state.code) }, - enabled = isButtonEnabled - ) { - Text(stringResource(R.string.auth_sign_in)) + if (errorStateValue != "") { + BaseText12( + text = errorStateValue, + color = Red, + modifier = Modifier + .testTag(TestIds.Auth.ERROR) + .padding( + start = 10.dp, + top = 5.dp, + bottom = 0.dp + ) + .fillMaxWidth() + ) } } + + BaseButton( + text = stringResource(R.string.auth_sign_in), + onClick = { viewModel.onIntent(AuthIntent.Send(textState)) }, + btnColor = Blue, + enable = isButtonEnabled, + btnContentColor = White, + modifier = Modifier + .testTag(TestIds.Auth.SIGN_BUTTON) + .fillMaxWidth() + ) } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt index 0f116ad..a06ba76 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthState.kt @@ -1,9 +1,6 @@ package ru.myitschool.work.ui.screen.auth sealed interface AuthState { - object Loading : AuthState - data class Data( - val code: String = "", - val error: String? = null - ) : AuthState + object Loading: AuthState + object Data: AuthState } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt index 8df8756..e05d62d 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt @@ -1,88 +1,62 @@ package ru.myitschool.work.ui.screen.auth -import android.content.Context -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider +import android.app.Application +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -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.Dispatchers +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import ru.myitschool.work.App +import ru.myitschool.work.data.datastore.UserCode import ru.myitschool.work.data.repo.AuthRepository -import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase +class LoginViewModel(application: Application) : AndroidViewModel(application) { -class AuthViewModel( - private val authRepository: AuthRepository, - private val checkAndSaveAuthCodeUseCase: CheckAndSaveAuthCodeUseCase -) : ViewModel() { - private val _uiState = MutableStateFlow(AuthState.Data()) - val uiState: StateFlow = _uiState.asStateFlow() + private val store by lazy { (getApplication() as App).dataStoreManager } - private val _actionFlow: MutableSharedFlow = MutableSharedFlow() - val actionFlow: SharedFlow = _actionFlow + private val _screenState = MutableStateFlow(LoginState.Ready) + val screenState: StateFlow = _screenState.asStateFlow() - fun onIntent(intent: AuthIntent) { + private val _navigate = MutableSharedFlow() + val navigate: SharedFlow = _navigate + + private val _input = MutableStateFlow("") + val input: StateFlow get() = _input + + private val _error = MutableStateFlow("") + val error: StateFlow get() = _error + + val isValid = input.map { it.length == 4 && it.matches(Regex("[A-Za-z0-9]+")) } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + fun perform(intent: LoginIntent) { when (intent) { - is AuthIntent.Send -> { - if (validateCode(intent.text)) { - viewModelScope.launch { - _uiState.update { AuthState.Loading } - checkAndSaveAuthCodeUseCase.invoke(intent.text).fold( - onSuccess = { - _actionFlow.emit(AuthAction.NavigateToMain) - }, - onFailure = { error -> - _uiState.update { - AuthState.Data( - code = intent.text, - error = error.message ?: "error" - ) - } - } - ) - } - } else { - _uiState.update { - AuthState.Data( - code = intent.text, - error = "wrong" - ) - } - } + is LoginIntent.Type -> { + _input.value = intent.value + _error.value = "" } - is AuthIntent.TextInput -> { - _uiState.update { - AuthState.Data( - code = intent.text, - error = null - ) - } + + is LoginIntent.Submit -> { + authorize() } } } - private fun validateCode(code: String): Boolean { - return code.length == 4 && code.matches(Regex("^[a-zA-Z0-9]{4}$")) - } -} + private fun authorize() { + viewModelScope.launch(Dispatchers.IO) { + _screenState.value = LoginState.Loading - -class AuthViewModelFactory(private val context: Context) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(AuthViewModel::class.java)) { - val authRepository = AuthRepository.getInstance(context) - val useCase = CheckAndSaveAuthCodeUseCase(authRepository) - return AuthViewModel(authRepository, useCase) as T + AuthRepository.validateCode(_input.value).fold( + onSuccess = { + store.saveUserCode(UserCode(_input.value)) + _navigate.emit(Unit) + }, + onFailure = { err -> + _screenState.value = LoginState.Ready + _error.value = err.message ?: "Ошибка" + } + ) } - throw IllegalArgumentException("Unknown ViewModel class") } } - -sealed interface AuthAction { - object NavigateToMain : AuthAction -} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookAction.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookAction.kt new file mode 100644 index 0000000..7633dea --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookAction.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.screen.book + +sealed interface BookAction { + object Auth: BookAction + object Main: BookAction +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt index bd2f09a..c9b8c05 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt @@ -1,9 +1,12 @@ package ru.myitschool.work.ui.screen.book sealed interface BookIntent { - object Refresh : BookIntent - object Back : BookIntent + object Back: BookIntent + object LoadBooking: BookIntent object Book : BookIntent - data class SelectDate(val index: Int) : BookIntent - data class SelectPlace(val index: Int) : BookIntent + data class SelectDate(val date: String) : BookIntent + data class SelectPlace( + val placeId: Int, + val placeName: String + ) : BookIntent } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt index c010c88..3cbafc3 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt @@ -1,255 +1,415 @@ -package ru.myitschool.work.ui.screen.book + package ru.myitschool.work.ui.screen.book -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavController -import ru.myitschool.work.R -import ru.myitschool.work.core.TestIds -import androidx.compose.material3.Icon -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextAlign + import androidx.compose.foundation.BorderStroke + import androidx.compose.foundation.Image + import androidx.compose.foundation.background + import androidx.compose.foundation.border + import androidx.compose.foundation.layout.Arrangement + import androidx.compose.foundation.layout.Box + import androidx.compose.foundation.layout.Column + import androidx.compose.foundation.layout.FlowRow + import androidx.compose.foundation.layout.PaddingValues + import androidx.compose.foundation.layout.Row + import androidx.compose.foundation.layout.Spacer + import androidx.compose.foundation.layout.fillMaxHeight + 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.width + import androidx.compose.foundation.selection.selectable + import androidx.compose.foundation.shape.CircleShape + import androidx.compose.foundation.shape.RoundedCornerShape + import androidx.compose.material3.Button + import androidx.compose.material3.ButtonColors + import androidx.compose.material3.CircularProgressIndicator + 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.graphics.Color + import androidx.compose.ui.platform.testTag + import androidx.compose.ui.res.painterResource + import androidx.compose.ui.res.stringResource + import androidx.compose.ui.text.style.TextAlign + import androidx.compose.ui.unit.dp + import androidx.compose.ui.unit.sp + import androidx.lifecycle.viewmodel.compose.viewModel + import androidx.navigation.NavController + import ru.myitschool.work.R + import ru.myitschool.work.core.TestIds.Book + import ru.myitschool.work.domain.book.entities.BookingEntity + import ru.myitschool.work.domain.book.entities.PlaceInfo + import ru.myitschool.work.formatBookingDate + import ru.myitschool.work.ui.BaseButton + import ru.myitschool.work.ui.BaseText16 + import ru.myitschool.work.ui.BaseText24 + import ru.myitschool.work.ui.nav.AuthScreenDestination + import ru.myitschool.work.ui.nav.MainScreenDestination + import ru.myitschool.work.ui.theme.Black + import ru.myitschool.work.ui.theme.Blue + import ru.myitschool.work.ui.theme.Typography + import ru.myitschool.work.ui.theme.White -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun BookScreen( - viewModel: BookViewModel = viewModel(factory = BookViewModelFactory(LocalContext.current)), - navController: NavController -) { - val state by viewModel.uiState.collectAsState() + @Composable + fun BookScreen( + navController: NavController, + viewModel: BookViewModel = viewModel(), + ) { + val state by viewModel.uiState.collectAsState() - LaunchedEffect(Unit) { - viewModel.actionFlow.collect { action -> - when (action) { - BookAction.NavigateBack -> { - navController.popBackStack() + LaunchedEffect(Unit) { + viewModel.actionFlow.collect { action -> + when(action) { + is BookAction.Auth -> navController.navigate(AuthScreenDestination) + + is BookAction.Main -> navController.navigate(MainScreenDestination) } - BookAction.NavigateBackWithRefresh -> { - navController.previousBackStackEntry?.savedStateHandle?.set( - "shouldRefresh", true) - navController.popBackStack() + } + } + + when(state) { + is BookState.Loading -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + CircularProgressIndicator( + modifier = Modifier.size(64.dp) + ) } } + is BookState.Data -> { + val dataState = state as BookState.Data + DataContent( + viewModel = viewModel, + bookingData = dataState.userBooking, + selectedDate = dataState.selectedDate, + selectedPlaceId = dataState.selectedPlaceId + ) + } + is BookState.Error -> ErrorContent(viewModel) + is BookState.Empty -> EmptyContent(viewModel) + } + } + + @Composable + fun EmptyContent( + viewModel: BookViewModel + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(15.dp) + .fillMaxHeight() + .width(320.dp) + ) { + + Spacer(modifier = Modifier.height(80.dp)) + + BaseText24( + text = stringResource(R.string.book_all_booked), + modifier = Modifier.testTag(Book.EMPTY), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(20.dp)) + + BaseButton( + text = stringResource(R.string.book_back), + modifier = Modifier + .fillMaxWidth() + .testTag(Book.BACK_BUTTON), + onClick = { viewModel.onIntent(BookIntent.Back) }, + btnContentColor = White, + btnColor = Blue + ) + } } } - when (val currentState = state) { - BookState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + @Composable + fun ErrorContent( + viewModel: BookViewModel + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(15.dp) + .fillMaxHeight() + .width(320.dp) ) { - CircularProgressIndicator() + + Spacer(modifier = Modifier.height(80.dp)) + + BaseText24( + text = stringResource(R.string.book_error), + modifier = Modifier.testTag(Book.ERROR), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(20.dp)) + + BaseButton( + border = BorderStroke(1.dp, Blue), + text = stringResource(R.string.book_back), + modifier = Modifier + .fillMaxWidth() + .testTag(Book.BACK_BUTTON), + onClick = { viewModel.onIntent(BookIntent.Back) }, + btnContentColor = Blue, + btnColor = Color.Transparent + ) + + Spacer(modifier = Modifier.height(15.dp)) + + BaseButton( + text = stringResource(R.string.main_update), + modifier = Modifier + .fillMaxWidth() + .testTag(Book.REFRESH_BUTTON), + onClick = { viewModel.onIntent(BookIntent.LoadBooking) }, + btnContentColor = White, + btnColor = Blue + ) } } + } - BookState.Empty -> { - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.book_title)) }, - navigationIcon = { - IconButton( - onClick = { viewModel.onIntent(BookIntent.Back) }, - modifier = Modifier.testTag(TestIds.Book.BACK_BUTTON) - ) { - Icon( - painter = painterResource(id = R.drawable.back), - contentDescription = null - ) - } - } - ) - } - ) { paddingValues -> + @Composable + fun DataContent( + viewModel: BookViewModel, + bookingData: BookingEntity, + selectedDate: String, + selectedPlaceId: Int + ) { + + val availableDates = bookingData.bookings + .filter { it.value.isNotEmpty() } + .keys + .sorted() + val placesForSelectedDate = bookingData.bookings[selectedDate] ?: emptyList() + + Column { + Row( + modifier = Modifier + .clip(RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp)) + .background(Blue) + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 15.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + BaseText24( + text = stringResource(R.string.book_new_book), + color = White, + modifier = Modifier.padding(start = 15.dp) + ) + BaseButton( + text = stringResource(R.string.book_back), + modifier = Modifier.testTag(Book.BACK_BUTTON), + onClick = { viewModel.onIntent(BookIntent.Back) } + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(vertical = 20.dp, horizontal = 10.dp) + .clip(RoundedCornerShape(16.dp)) + .background(White) + ) { Column( + verticalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxSize() - .padding(paddingValues) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + .padding(13.dp) ) { - Text( - text = stringResource(R.string.book_empty), - modifier = Modifier.testTag(TestIds.Book.EMPTY), - textAlign = TextAlign.Center + Column { + Text( + text = stringResource(R.string.book_available_date), + style = Typography.bodyMedium, + fontSize = 16.sp, + ) + + BookDateList( + dates = availableDates, + selectedDate = selectedDate, + onDateSelected = { date -> + viewModel.onIntent(BookIntent.SelectDate(date)) + } + ) + + Text( + text = stringResource(R.string.book_choose_place), + style = Typography.bodyMedium, + fontSize = 16.sp, + ) + + BookPlaceList( + places = placesForSelectedDate, + selectedPlaceId = selectedPlaceId, + onPlaceSelected = { placeId, placeName -> + viewModel.onIntent(BookIntent.SelectPlace(placeId, placeName)) + } + ) + } + + BaseButton( + enable = selectedPlaceId != -1, + text = stringResource(R.string.booking_button), + btnColor = Blue, + btnContentColor = White, + onClick = { viewModel.onIntent(BookIntent.Book) }, + modifier = Modifier + .testTag(Book.BOOK_BUTTON) + .padding(horizontal = 10.dp) + .fillMaxWidth(), ) } } } + } - is BookState.Data -> { - if (currentState.error != null) { - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.book_title)) }, - navigationIcon = { - IconButton( - onClick = { viewModel.onIntent(BookIntent.Back) }, - modifier = Modifier.testTag(TestIds.Book.BACK_BUTTON) - ) { - Icon( - painter = painterResource(id = R.drawable.back), - contentDescription = null - ) - } - } - ) - } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = currentState.error, - modifier = Modifier.testTag(TestIds.Book.ERROR), - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = { viewModel.onIntent(BookIntent.Refresh) }, - modifier = Modifier.testTag(TestIds.Book.REFRESH_BUTTON) - ) { - Text(stringResource(R.string.book_refresh)) - } - } - } + @Composable + fun BookPlaceList( + places: List, + selectedPlaceId: Int, + onPlaceSelected: (Int, String) -> Unit + ) { + Column( + modifier = Modifier.padding(vertical = 15.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (places.isEmpty()) { + Text( + text = "Нет доступных мест для выбранной даты", + color = Color.Gray, + style = Typography.bodyMedium, + modifier = Modifier.padding(vertical = 8.dp) + ) } else { - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.book_title)) }, - navigationIcon = { - IconButton( - onClick = { viewModel.onIntent(BookIntent.Back) }, - modifier = Modifier.testTag(TestIds.Book.BACK_BUTTON) - ) { - Icon( - painter = painterResource(id = R.drawable.back), - contentDescription = null - ) - } - } - ) - } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(horizontal = 16.dp, vertical = 8.dp) - ) { - Content( - state = currentState, - onDateSelect = { viewModel.onIntent(BookIntent.SelectDate(it)) }, - onPlaceSelect = { viewModel.onIntent(BookIntent.SelectPlace(it)) }, - onBookClick = { viewModel.onIntent(BookIntent.Book) } - ) - } + places.forEachIndexed { index, placeInfo -> + BookPlaceListElement( + placeInfo = placeInfo, + isSelected = placeInfo.id == selectedPlaceId, + onPlaceSelected = { onPlaceSelected(placeInfo.id, placeInfo.place) }, + index = index + ) } } } } -} -@Composable -private fun Content( - state: BookState.Data, - onDateSelect: (Int) -> Unit, - onPlaceSelect: (Int) -> Unit, - onBookClick: () -> Unit -) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) + @Composable + fun BookPlaceListElement( + placeInfo: PlaceInfo, + isSelected: Boolean, + onPlaceSelected: () -> Unit, + index: Int ) { - ScrollableTabRow( - selectedTabIndex = state.selectedDateIndex, - modifier = Modifier.fillMaxWidth(), - edgePadding = 0.dp - ) { - state.dates.forEachIndexed { index, dateItem -> - Tab( - selected = state.selectedDateIndex == index, - onClick = { onDateSelect(index) }, - modifier = Modifier.testTag(TestIds.Book.getIdDateItemByPosition(index)) - ) { - Column( - modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp) - ) { - Text( - text = dateItem.displayDate, - modifier = Modifier.testTag(TestIds.Book.ITEM_DATE) - ) - } - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - val selectedDate = state.dates.getOrNull(state.selectedDateIndex) - if (selectedDate != null) { - Column { - selectedDate.places.forEachIndexed { index, placeItem -> - Row( - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = state.selectedPlaceIndex == index, - onClick = { onPlaceSelect(index) } - ) - .padding(vertical = 8.dp, horizontal = 16.dp) - .testTag(TestIds.Book.getIdPlaceItemByPosition(index)) - ) { - RadioButton( - selected = state.selectedPlaceIndex == index, - onClick = { onPlaceSelect(index) }, - modifier = Modifier.testTag(TestIds.Book.ITEM_PLACE_SELECTOR) - ) - Spacer(modifier = Modifier.width(16.dp)) - Text( - text = placeItem.name, - modifier = Modifier.testTag(TestIds.Book.ITEM_PLACE_TEXT), - style = MaterialTheme.typography.bodyLarge - ) - } - } - } - } - - Spacer(modifier = Modifier.height(32.dp)) - - Button( - onClick = onBookClick, + Row( modifier = Modifier .fillMaxWidth() - .testTag(TestIds.Book.BOOK_BUTTON), - enabled = state.selectedPlaceIndex != null + .selectable( + selected = isSelected, + onClick = onPlaceSelected + ) + .testTag(Book.getIdPlaceItemByPosition(index)) + .padding(vertical = 12.dp, horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Text(stringResource(R.string.book_book)) + BaseText16( + text = placeInfo.place, + modifier = Modifier.testTag(Book.ITEM_PLACE_TEXT) + ) + Box( + modifier = Modifier + .size(24.dp) + .border( + width = 2.dp, + color = if (isSelected) Blue else Color.Gray, + shape = CircleShape + ) + .background( + color = if (isSelected) Blue else Color.Transparent, + shape = CircleShape + ) + .testTag(Book.ITEM_PLACE_SELECTOR) + ) { + if (isSelected) { + Box( + modifier = Modifier + .size(12.dp) + .background(Color.White, CircleShape) + .align(Alignment.Center) + ) + } + } } } -} \ No newline at end of file + + @Composable + fun BookDateList( + dates: List, + selectedDate: String, + onDateSelected: (String) -> Unit + ) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(7.dp), + modifier = Modifier.padding(vertical = 15.dp) + ) { + dates.forEachIndexed { index, date -> + BookDateListElement( + date = date, + isSelected = date == selectedDate, + onClick = { onDateSelected(date) }, + index = index + ) + } + } + } + + @Composable + fun BookDateListElement( + date: String, + isSelected: Boolean, + onClick: () -> Unit, + index: Int + ) { + Button( + contentPadding = PaddingValues(0.dp), + modifier = Modifier + .testTag(Book.getIdDateItemByPosition(index)) + .padding(0.dp), + border = BorderStroke(1.dp, if (isSelected) Blue else Black,), + onClick = onClick, + colors = ButtonColors( + contentColor = if (isSelected) White else Black, + containerColor = if (isSelected) Blue else Color.Transparent, + disabledContentColor = Black, + disabledContainerColor = Color.Transparent), + ) { + val formattedDate = date.formatBookingDate() + BaseText16( + text = formattedDate, + modifier = Modifier.testTag(Book.ITEM_DATE), + color = if (isSelected) White else Black, + ) + } + } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt index ebf897e..856dccf 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt @@ -1,24 +1,15 @@ package ru.myitschool.work.ui.screen.book +import ru.myitschool.work.domain.book.entities.BookingEntity + sealed interface BookState { - object Loading : BookState + object Loading: BookState data class Data( - val dates: List = emptyList(), - val selectedDateIndex: Int = 0, - val selectedPlaceIndex: Int? = null, - val error: String? = null - ) : BookState - object Empty : BookState -} - -data class DateItem( - val id: String, - val displayDate: String, // dd.MM - val rawDate: String, // for api - val places: List -) - -data class PlaceItem( - val id: String, - val name: String -) \ No newline at end of file + val userBooking: BookingEntity, + val selectedDate: String = "", + val selectedPlaceId: Int = -1, + val selectedPlaceName: String = "" + ): BookState + object Error: BookState + object Empty: BookState +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt index 9c55ab1..84dd63f 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt @@ -1,196 +1,165 @@ package ru.myitschool.work.ui.screen.book -import android.content.Context -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider +import android.app.Application +import androidx.lifecycle.AndroidViewModel 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.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import ru.myitschool.work.data.model.PlaceInfo -import ru.myitschool.work.data.repo.AuthRepository +import ru.myitschool.work.App import ru.myitschool.work.data.repo.BookRepository -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale +import ru.myitschool.work.data.repo.MainRepository +import ru.myitschool.work.domain.book.BookingUseCase +import ru.myitschool.work.domain.book.LoadBookingUseCase +import ru.myitschool.work.domain.main.LoadDataUseCase +import ru.myitschool.work.ui.screen.main.MainAction +import ru.myitschool.work.ui.screen.main.MainIntent +import ru.myitschool.work.ui.screen.main.MainState +import kotlin.text.isEmpty -class BookViewModel(private val bookRepo: BookRepository) : ViewModel() { +class BookViewModel(application: Application) : AndroidViewModel(application) { + + private val loadBookingUseCase by lazy { LoadBookingUseCase(BookRepository) } + + private val bookingUseCase by lazy { BookingUseCase (BookRepository) } + + private val dataStoreManager by lazy { + (getApplication() as App).dataStoreManager + } private val _uiState = MutableStateFlow(BookState.Loading) val uiState: StateFlow = _uiState.asStateFlow() - private val _actionFlow: MutableSharedFlow = MutableSharedFlow() val actionFlow: SharedFlow = _actionFlow init { - loadData() + loadBooking() + } + + private fun bookSelectedPlace() { + viewModelScope.launch(Dispatchers.IO) { + try { + val userCode = dataStoreManager.getUserCode().first() + val currentState = _uiState.value + + if (currentState is BookState.Data && currentState.selectedPlaceId != -1) { + bookingUseCase.invoke( + userCode = userCode.code, + date = currentState.selectedDate, + placeId = currentState.selectedPlaceId, + placeName = currentState.selectedPlaceName + ).fold( + onSuccess = { + _actionFlow.emit(BookAction.Main) + }, + onFailure = { error -> + error.printStackTrace() + _uiState.update { BookState.Error } + } + ) + } + } catch (error: Exception) { + error.printStackTrace() + _uiState.update { BookState.Error } + } + } + } + + private fun loadBooking() { + viewModelScope.launch(Dispatchers.IO) { + _uiState.update { BookState.Loading } + + try { + val userCode = dataStoreManager.getUserCode().first() + + if (userCode.code.isEmpty()) { + _actionFlow.emit(BookAction.Auth) + return@launch + } + + loadBookingUseCase.invoke(userCode.code).fold( + onSuccess = { data -> + val availableDates = data.bookings + .filter { it.value.isNotEmpty() } + .keys + .sorted() + + if (availableDates.isEmpty()) { + _uiState.update { BookState.Empty } + } else { + val selectedDate = availableDates.first() + val placesForSelectedDate = data.bookings[selectedDate] ?: emptyList() + val selectedPlaceId = placesForSelectedDate.firstOrNull()?.id ?: -1 + val selectedPlaceName = placesForSelectedDate.firstOrNull()?.place ?: "" + + _uiState.update { + BookState.Data( + userBooking = data, + selectedDate = selectedDate, + selectedPlaceId = selectedPlaceId, + selectedPlaceName = selectedPlaceName + ) + } + } + }, + onFailure = { error -> + error.printStackTrace() + _uiState.update { BookState.Error } + } + ) + } catch (error: Exception) { + error.printStackTrace() + _uiState.update { BookState.Error } + } + } } fun onIntent(intent: BookIntent) { when (intent) { - BookIntent.Refresh -> { - loadData() - } - BookIntent.Back -> { - viewModelScope.launch { - _actionFlow.emit(BookAction.NavigateBack) + is BookIntent.LoadBooking -> loadBooking() + + is BookIntent.Back -> { + viewModelScope.launch(Dispatchers.Default) { + _actionFlow.emit(BookAction.Main) } } - BookIntent.Book -> { - bookSelected() - } + + is BookIntent.Book -> bookSelectedPlace() + is BookIntent.SelectDate -> { - _uiState.update { state -> - if (state is BookState.Data) { - state.copy( - selectedDateIndex = intent.index, - selectedPlaceIndex = null + val currentState = _uiState.value + if (currentState is BookState.Data) { + val placesForDate = + currentState.userBooking.bookings[intent.date] ?: emptyList() + val newSelectedPlaceId = placesForDate.firstOrNull()?.id ?: -1 + val newSelectedPlaceName = placesForDate.firstOrNull()?.place ?: "" + + _uiState.update { + currentState.copy( + selectedDate = intent.date, + selectedPlaceId = newSelectedPlaceId, + selectedPlaceName = newSelectedPlaceName ) - } else { - state } } } + is BookIntent.SelectPlace -> { - _uiState.update { state -> - if (state is BookState.Data) { - state.copy(selectedPlaceIndex = intent.index) - } else { - state - } - } - } - } - } - - private fun loadData() { - viewModelScope.launch { - _uiState.update { BookState.Loading } - - bookRepo.getAvailableBookings().fold( - onSuccess = { response -> - val datesMap = response ?: emptyMap() - - val dateItems = datesMap.entries - .sortedBy { (date, _) -> parseDate(date) } - .filter { (_, places) -> places.isNotEmpty() } - .map { (dateString, places) -> - DateItem( - id = dateString, - displayDate = formatDateForDisplay(dateString), - rawDate = dateString, - places = places.map { placeInfo -> - PlaceItem( - id = placeInfo.id.toString(), - name = placeInfo.place - ) - } - ) - } - if (dateItems.isEmpty()) { - _uiState.update { BookState.Empty } - } else { - _uiState.update { - BookState.Data( - dates = dateItems, - selectedDateIndex = 0 - ) - } - } - }, - onFailure = { error -> + val currentState = _uiState.value + if (currentState is BookState.Data) { _uiState.update { - BookState.Data( - error = error.message ?: "Ошибка загрузки данных" - ) - } - } - ) - } - } - - private fun bookSelected() { - viewModelScope.launch { - val state = _uiState.value - if (state is BookState.Data) { - val selectedDate = state.dates.getOrNull(state.selectedDateIndex) - val selectedPlaceIndex = state.selectedPlaceIndex - - if (selectedDate != null && selectedPlaceIndex != null) { - val selectedPlace = selectedDate.places.getOrNull(selectedPlaceIndex) - - if (selectedPlace != null) { - _uiState.update { BookState.Loading } - - bookRepo.book( - date = selectedDate.rawDate, - placeId = selectedPlace.id.toInt() - ).fold( - onSuccess = { - _actionFlow.emit(BookAction.NavigateBackWithRefresh) - }, - onFailure = { error -> - _uiState.update { - state.copy( - error = error.message ?: "Ошибка бронирования" - ) - } - } - ) - } else { - _uiState.update { - state.copy( - error = "Место не выбрано" - ) - } - } - } else { - _uiState.update { - state.copy( - error = "Выберите место для бронирования" + currentState.copy( + selectedPlaceId = intent.placeId, + selectedPlaceName = intent.placeName ) } } } } } - - private fun parseDate(dateString: String): Long { - return try { - val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) - format.parse(dateString)?.time ?: 0L - } catch (e: Exception) { - 0L - } - } - - private fun formatDateForDisplay(dateString: String): String { - return try { - val inputFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) - val outputFormat = SimpleDateFormat("dd.MM", Locale.getDefault()) - val date = inputFormat.parse(dateString) - date?.let { outputFormat.format(it) } ?: dateString - } catch (e: Exception) { - dateString - } - } -} - -sealed interface BookAction { - object NavigateBack : BookAction - object NavigateBackWithRefresh : BookAction -} - -class BookViewModelFactory(private val context: Context) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(BookViewModel::class.java)) { - val authRepository = AuthRepository.getInstance(context) - val bookRepository = BookRepository(authRepository) - return BookViewModel(bookRepository) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainAction.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainAction.kt new file mode 100644 index 0000000..0a59f7e --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainAction.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.screen.main + +sealed interface MainAction { + object Booking: MainAction + object Auth: MainAction +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt index 354c37d..c324687 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt @@ -1,8 +1,7 @@ package ru.myitschool.work.ui.screen.main sealed interface MainIntent { - object Logout : MainIntent - object Refresh : MainIntent - object AddBooking : MainIntent - data class ItemClick(val position: Int) : MainIntent + object Logout: MainIntent + object Booking: MainIntent + object LoadData: MainIntent } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt index ee30e3c..ae90497 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt @@ -1,280 +1,301 @@ package ru.myitschool.work.ui.screen.main import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight 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.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -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.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import coil3.compose.rememberAsyncImagePainter -import coil3.request.ImageRequest import ru.myitschool.work.R -import ru.myitschool.work.core.TestIds +import ru.myitschool.work.core.TestIds.Main +import ru.myitschool.work.domain.main.entities.BookingInfo +import ru.myitschool.work.domain.main.entities.UserEntity +import ru.myitschool.work.ui.BaseButton +import ru.myitschool.work.ui.BaseText14 +import ru.myitschool.work.ui.BaseText16 +import ru.myitschool.work.ui.BaseText20 +import ru.myitschool.work.ui.BaseText24 import ru.myitschool.work.ui.nav.AuthScreenDestination import ru.myitschool.work.ui.nav.BookScreenDestination +import ru.myitschool.work.ui.theme.Black +import ru.myitschool.work.ui.theme.Blue +import ru.myitschool.work.ui.theme.LightGray +import ru.myitschool.work.ui.theme.Typography +import ru.myitschool.work.ui.theme.White @Composable fun MainScreen( - viewModel: MainViewModel = viewModel(factory = MainViewModelFactory(LocalContext.current)), - navController: NavController + navController: NavController, + viewModel: MainViewModel = viewModel() ) { + val state by viewModel.uiState.collectAsState() - val shouldRefresh by navController.currentBackStackEntry - ?.savedStateHandle - ?.getStateFlow("shouldRefresh", false) - ?.collectAsState() ?: remember { mutableStateOf(false) } - - LaunchedEffect(shouldRefresh) { - if (shouldRefresh) { - viewModel.onIntent(MainIntent.Refresh) - navController.currentBackStackEntry?.savedStateHandle?.remove("shouldRefresh") - } - } - LaunchedEffect(Unit) { viewModel.actionFlow.collect { action -> - when (action) { - is MainAction.NavigateToAuth -> { - navController.navigate(AuthScreenDestination) { - popUpTo(0) - } - } - is MainAction.NavigateToBooking -> { - navController.navigate(BookScreenDestination) - } + when(action) { + is MainAction.Auth -> navController.navigate(AuthScreenDestination) + + is MainAction.Booking -> navController.navigate(BookScreenDestination) } } } - Box( - modifier = Modifier.fillMaxSize() - ) { - when (val currentState = state) { - is MainState.Loading -> { + when(state) { + is MainState.Loading -> { + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center) + modifier = Modifier.size(64.dp) ) } - is MainState.Data -> { - if (currentState.error != null) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = currentState.error, - color = Color.Red, - modifier = Modifier - .testTag(TestIds.Main.ERROR) - .padding(bottom = 16.dp) - ) - Button( - onClick = { viewModel.onIntent(MainIntent.Refresh) }, - modifier = Modifier.testTag(TestIds.Main.REFRESH_BUTTON) - ) { - Text(stringResource(R.string.main_refresh)) - } - } - } else { - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp) - ) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (!currentState.userPhotoUrl.isNullOrEmpty()) { - Image( - painter = rememberAsyncImagePainter( - ImageRequest.Builder(LocalContext.current) - .data(currentState.userPhotoUrl) - .build() - ), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(64.dp) - .testTag(TestIds.Main.PROFILE_IMAGE) - ) - } else { - Icon( - painter = painterResource(id = R.drawable.github), - contentDescription = null, - modifier = Modifier - .size(64.dp) - .testTag(TestIds.Main.PROFILE_IMAGE) - ) - } - - Spacer(modifier = Modifier.size(16.dp)) - - Column { - Text( - text = currentState.userName, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.testTag(TestIds.Main.PROFILE_NAME) - ) - if (currentState.bookings.isNotEmpty()) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Забронировано мест: ${currentState.bookings.size}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Button( - onClick = { viewModel.onIntent(MainIntent.Logout) }, - modifier = Modifier - .weight(1f) - .testTag(TestIds.Main.LOGOUT_BUTTON) - ) { - Text(stringResource(R.string.main_logout)) - } - - Button( - onClick = { viewModel.onIntent(MainIntent.Refresh) }, - modifier = Modifier - .weight(1f) - .testTag(TestIds.Main.REFRESH_BUTTON) - ) { - Text(stringResource(R.string.main_refresh)) - } - - Button( - onClick = { viewModel.onIntent(MainIntent.AddBooking) }, - modifier = Modifier - .weight(1f) - .testTag(TestIds.Main.ADD_BUTTON) - ) { - Text(stringResource(R.string.main_add_booking)) - } - } - - if (currentState.bookings.isNotEmpty()) { - Text( - text = "Мои бронирования:", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(bottom = 8.dp) - ) - - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) { - itemsIndexed(currentState.bookings) { index, booking -> - BookingItem( - booking = booking, - position = index, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .testTag(TestIds.Main.getIdItemByPosition(index)) - ) - } - } - } else { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center - ) { - Text( - text = "У вас нет активных бронирований", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } + } + is MainState.Error -> { + ErrorContent(viewModel) + } + is MainState.Data -> { + DataContent( + viewModel, + userData = (state as MainState.Data).userData + ) } } } @Composable -private fun BookingItem( - booking: BookingItem, - position: Int, - modifier: Modifier = Modifier -) { +fun ErrorContent(viewModel: MainViewModel){ Box( - modifier = modifier + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() ) { - Card( - modifier = Modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(15.dp) + .fillMaxHeight() + .width(320.dp) ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - Text( - text = booking.place, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE) - ) - Text( - text = booking.getFormattedDate(), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.testTag(TestIds.Main.ITEM_DATE) - ) - } + + Spacer(modifier = Modifier.height(80.dp)) + + BaseText24( + text = stringResource(R.string.data_error_message), + modifier = Modifier.testTag(Main.ERROR), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(20.dp)) + + BaseButton( + text = stringResource(R.string.main_update), + modifier = Modifier + .fillMaxWidth() + .testTag(Main.REFRESH_BUTTON), + onClick = { viewModel.onIntent(MainIntent.LoadData) }, + btnContentColor = White, + btnColor = Blue + ) } } +} + +@Composable +fun DataContent( + viewModel: MainViewModel, + userData: UserEntity +) { + Column ( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .background(LightGray) + .fillMaxSize() + .width(400.dp) + + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .clip(RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp)) + .background(Blue) + .fillMaxWidth() + .padding(10.dp) + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + BaseButton( + text = stringResource(R.string.main_update), + onClick = { viewModel.onIntent(MainIntent.LoadData) }, + modifier = Modifier.testTag(Main.REFRESH_BUTTON) + ) + BaseButton( + text = stringResource(R.string.main_log_out), + onClick = { viewModel.onIntent(MainIntent.Logout) }, + modifier = Modifier.testTag(Main.LOGOUT_BUTTON) + ) + } + + Image( + painter = rememberAsyncImagePainter( + model = userData.photoUrl, + error = painterResource(R.drawable.github) + ), + contentDescription = stringResource(R.string.main_avatar_description), + modifier = Modifier + .clip(RoundedCornerShape(999.dp)) + .testTag(Main.PROFILE_IMAGE) + .width(150.dp) + .height(150.dp) + .padding(20.dp) + ) + + BaseText20( + text = userData.name, + color = White, + textAlign = TextAlign.Center, + modifier = Modifier + .testTag(Main.PROFILE_NAME) + .width(250.dp), + style = Typography.bodyLarge + ) + + Spacer(modifier = Modifier.height(20.dp)) + } + Column( + verticalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxSize() + .padding(20.dp) + .clip(RoundedCornerShape(16.dp)) + .background(White) + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.main_booking_title), + style = Typography.bodyMedium, + color = Black, + fontSize = 16.sp, + modifier = Modifier.padding( + horizontal = 10.dp, + vertical = 20.dp + ) + ) + if (userData.hasBookings()) { + SortedBookingList(userData = userData) + } else { + EmptyBookings() + } + } + BaseButton( + text = stringResource(R.string.booking_button), + btnColor = Blue, + btnContentColor = White, + onClick = { viewModel.onIntent(MainIntent.Booking) }, + modifier = Modifier + .testTag(Main.ADD_BUTTON) + .padding(horizontal = 10.dp, vertical = 15.dp) + .fillMaxWidth(), + ) + } + } +} + +@Composable +fun SortedBookingList(userData: UserEntity) { + val sortedBookings = remember(userData.booking) { + userData.getSortedBookingsWithFormattedDate() + } + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + ) { + itemsIndexed( + items = sortedBookings + ) { index, (originalDate, formattedDate, bookingInfo) -> + BookingItem( + originalDate = originalDate, + formattedDate = formattedDate, + bookingInfo = bookingInfo, + index = index + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + +@Composable +fun BookingItem( + originalDate: String, + formattedDate: String, + bookingInfo: BookingInfo, + index: Int +) { + Row( + modifier = Modifier + .testTag(Main.getIdItemByPosition(index)) + .fillMaxWidth() + .padding(vertical = 20.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + BaseText14( + text = bookingInfo.place, + modifier = Modifier.testTag(Main.ITEM_PLACE) + ) + BaseText14( + text = formattedDate, + modifier = Modifier.testTag(Main.ITEM_DATE) + ) + } +} + +@Composable +fun EmptyBookings() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + BaseText16( + text = stringResource(R.string.main_empty_booking) + ) + } } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt index 7ccb5be..285be1f 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt @@ -1,24 +1,9 @@ package ru.myitschool.work.ui.screen.main -import java.time.LocalDate -import java.time.format.DateTimeFormatter +import ru.myitschool.work.domain.main.entities.UserEntity sealed interface MainState { - object Loading : MainState - data class Data( - val userName: String = "", - val userPhotoUrl: String? = null, - val bookings: List = emptyList(), - val error: String? = null - ) : MainState -} - -data class BookingItem( - val id: String, - val date: LocalDate, - val place: String -) { - fun getFormattedDate(): String { - return date.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) - } + data class Data(val userData: UserEntity): MainState + object Loading: MainState + object Error: MainState } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt index a680393..381d3a9 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt @@ -1,112 +1,82 @@ package ru.myitschool.work.ui.screen.main -import android.content.Context -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider +import android.app.Application +import androidx.lifecycle.AndroidViewModel 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.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import ru.myitschool.work.data.repo.AuthRepository +import ru.myitschool.work.App import ru.myitschool.work.data.repo.MainRepository +import ru.myitschool.work.domain.main.LoadDataUseCase -class MainViewModel( - private val authRepo: AuthRepository, - private val mainRepo: MainRepository -) : ViewModel() { +class MainViewModel(application: Application) : AndroidViewModel(application) { + + private val dataStoreManager by lazy { + (getApplication() as App).dataStoreManager + } + + private val loadDataUseCase by lazy { LoadDataUseCase(MainRepository) } private val _uiState = MutableStateFlow(MainState.Loading) val uiState: StateFlow = _uiState.asStateFlow() - private val _actionFlow: MutableSharedFlow = MutableSharedFlow() val actionFlow: SharedFlow = _actionFlow - private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") - init { loadData() } - fun onIntent(intent: MainIntent) { - when (intent) { - MainIntent.Logout -> { - authRepo.clear() - viewModelScope.launch { - _actionFlow.emit(MainAction.NavigateToAuth) - } - } - MainIntent.Refresh -> { - loadData() - } - MainIntent.AddBooking -> { - viewModelScope.launch { - _actionFlow.emit(MainAction.NavigateToBooking) - } - } - is MainIntent.ItemClick -> { - } - } - } - private fun loadData() { - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { _uiState.update { MainState.Loading } - mainRepo.getUserInfo().fold( - onSuccess = { userInfo -> - val bookings = userInfo.bookings.mapNotNull { bookingResponse -> - try { - BookingItem( - id = bookingResponse.bookingId.toString(), - date = LocalDate.parse(bookingResponse.date, dateFormatter), - place = bookingResponse.place - ) - } catch (e: Exception) { - null - } - }.sortedBy { it.date } - authRepo.saveUserInfo(userInfo.name, userInfo.photoUrl) + try { + val userCode = dataStoreManager.getUserCode().first() - _uiState.update { - MainState.Data( - userName = userInfo.name, - userPhotoUrl = userInfo.photoUrl, - bookings = bookings - ) - } - }, - onFailure = { error -> - _uiState.update { - MainState.Data( - userName = "", - userPhotoUrl = null, - bookings = emptyList(), - error = error.message ?: "Ошибка загрузки данных" - ) - } + if (userCode.code.isEmpty()) { + _actionFlow.emit(MainAction.Auth) + return@launch } - ) + + loadDataUseCase.invoke(userCode.code).fold( + onSuccess = { data -> + _uiState.update { MainState.Data(data) } + }, + onFailure = { error -> + error.printStackTrace() + _uiState.update { MainState.Error } + } + ) + } catch (error: Exception) { + error.printStackTrace() + _uiState.update { MainState.Error } + } } } -} -class MainViewModelFactory(private val context: Context) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(MainViewModel::class.java)) { - val authRepository = AuthRepository.getInstance(context) - val mainRepository = MainRepository(authRepository) - return MainViewModel(authRepository, mainRepository) as T + fun onIntent( intent: MainIntent) { + when(intent) { + is MainIntent.LoadData -> loadData() + + is MainIntent.Booking -> { + viewModelScope.launch(Dispatchers.Default) { + _actionFlow.emit(MainAction.Booking) + } + } + + is MainIntent.Logout -> { + viewModelScope.launch(Dispatchers.IO) { + + dataStoreManager.clearUserCode() + _actionFlow.emit(MainAction.Auth) + } + } } - throw IllegalArgumentException("Unknown ViewModel class") } -} - -sealed interface MainAction { - object NavigateToAuth : MainAction - object NavigateToBooking : MainAction } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/splash/SplashScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/splash/SplashScreen.kt new file mode 100644 index 0000000..2b995ce --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/splash/SplashScreen.kt @@ -0,0 +1,49 @@ +package ru.myitschool.work.ui.screen.splash + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import ru.myitschool.work.ui.nav.AuthScreenDestination +import ru.myitschool.work.ui.nav.MainScreenDestination + +@Composable +fun SplashScreen( + navController: NavController, + viewModel: SplashViewModel = viewModel() + ) { + + val splashState by viewModel.splashState.collectAsState() + + LaunchedEffect(splashState) { + when (splashState) { + is SplashState.Authenticated -> { + navController.navigate(MainScreenDestination) + } + is SplashState.UnAuthenticated -> { + navController.navigate(AuthScreenDestination) + } + is SplashState.Error -> { + navController.navigate(AuthScreenDestination) + } + SplashState.Loading -> { + } + } + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(64.dp)) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/splash/SplashState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/splash/SplashState.kt new file mode 100644 index 0000000..9920f30 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/splash/SplashState.kt @@ -0,0 +1,10 @@ +package ru.myitschool.work.ui.screen.splash + +import android.os.Message + +sealed interface SplashState { + object Loading: SplashState + object Authenticated: SplashState + object UnAuthenticated: SplashState + class Error(message: String): SplashState +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/splash/SplashViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/splash/SplashViewModel.kt new file mode 100644 index 0000000..b7d2ecd --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/splash/SplashViewModel.kt @@ -0,0 +1,44 @@ +package ru.myitschool.work.ui.screen.splash + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import ru.myitschool.work.App + +class SplashViewModel(application: Application) : AndroidViewModel(application) { + + private val dataStoreManager by lazy { + (getApplication() as App).dataStoreManager + } + + private val _splashState = MutableStateFlow(SplashState.Loading) + val splashState: StateFlow = _splashState.asStateFlow() + + init { + checkAuthStatus() + } + + private fun checkAuthStatus() { + viewModelScope.launch { + try { + val userCode = dataStoreManager.getUserCode().first() + + val isAuthenticated = if (userCode.code.isEmpty()) false else true + + _splashState.value = if (isAuthenticated) { + SplashState.Authenticated + } else { + SplashState.UnAuthenticated + } + } catch (e: Exception) { + _splashState.value = SplashState.Error(e.message ?: "Unknown error") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/theme/Color.kt b/app/src/main/java/ru/myitschool/work/ui/theme/Color.kt index 22226f4..81685a2 100644 --- a/app/src/main/java/ru/myitschool/work/ui/theme/Color.kt +++ b/app/src/main/java/ru/myitschool/work/ui/theme/Color.kt @@ -8,4 +8,18 @@ val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val Pink40 = Color(0xFF7D5260) + +val Blue = Color(0xFF004BFF) + +val Gray = Color(0xFF777777) + +val LightBlue = Color(0xFFF2EFFF) + +val White = Color(0xFFFFFFFF) + +val Red = Color(0xFFFF4D4D) + +val LightGray = Color(0xFFF2F1F7) + +val Black = Color(0xFF000000) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/theme/Type.kt b/app/src/main/java/ru/myitschool/work/ui/theme/Type.kt index 61b2923..a509494 100644 --- a/app/src/main/java/ru/myitschool/work/ui/theme/Type.kt +++ b/app/src/main/java/ru/myitschool/work/ui/theme/Type.kt @@ -2,19 +2,27 @@ package ru.myitschool.work.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp +import ru.myitschool.work.R // Set of Material typography styles to start with + + + val Typography = Typography( + bodySmall = TextStyle( + fontWeight = FontWeight.Medium, + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + ), bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) + fontWeight = FontWeight.Bold, + ), + /* Other default text styles to override titleLarge = TextStyle( fontFamily = FontFamily.Default, diff --git a/app/src/main/java/ru/myitschool/work/utils.kt b/app/src/main/java/ru/myitschool/work/utils.kt new file mode 100644 index 0000000..88e89e4 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/utils.kt @@ -0,0 +1,26 @@ +package ru.myitschool.work + +import java.text.SimpleDateFormat +import java.util.Locale + +fun String.formatDate(): String { + return try { + val inputFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val outputFormat = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()) + val date = inputFormat.parse(this) + outputFormat.format(date) + } catch (e: Exception) { + this + } +} + +fun String.formatBookingDate(): String { + return try { + val inputFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val outputFormat = SimpleDateFormat("dd.MM", Locale.getDefault()) + val date = inputFormat.parse(this) + outputFormat.format(date) + } catch (e: Exception) { + this + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e2ff56b..d59bbf0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,16 +1,21 @@ Work RootActivity - Привет! Введи код для авторизации + Введите код для авторизации Код Войти - Мои бронирования - Выйти - Обновить - Забронировать - Бронирование + Обновить + Выйти + Фото пользователя + Ваши забронированные места + Бронировать + Иконка добавления + Ошибка загрузки данных + Нет бронирований + Новая встреча Назад - Забронировать - Повторить - Всё забронировано + Доступные даты + Выберите место встречи + Всё забронировано + Ошибка сервера \ No newline at end of file