Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01b75dda8e | |||
| eb5eacfa11 | |||
| 88ccddcdbb | |||
| 262573b71e | |||
| b81bc48de5 | |||
| d0d2e1f849 |
@@ -35,6 +35,11 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation("androidx.compose.ui:ui")
|
||||||
|
implementation("androidx.compose.ui:ui-graphics")
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
defaultComposeLibrary()
|
defaultComposeLibrary()
|
||||||
implementation("androidx.datastore:datastore-preferences:1.1.7")
|
implementation("androidx.datastore:datastore-preferences:1.1.7")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0")
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package ru.myitschool.work
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import ru.myitschool.work.data.repo.AuthRepository
|
||||||
|
|
||||||
class App: Application() {
|
class App: Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
context = this
|
context = this
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
41
app/src/main/java/ru/myitschool/work/AppModule.kt
Normal file
41
app/src/main/java/ru/myitschool/work/AppModule.kt
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package ru.myitschool.work
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import ru.myitschool.work.data.repo.AuthRepository
|
||||||
|
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
|
||||||
|
import ru.myitschool.work.domain.book.CreateBookingUseCase
|
||||||
|
import ru.myitschool.work.domain.book.GetAvailableBookingsUseCase
|
||||||
|
import ru.myitschool.work.domain.main.GetUserInfoUseCase
|
||||||
|
import ru.myitschool.work.domain.main.LogoutUseCase
|
||||||
|
|
||||||
|
object AppModule {
|
||||||
|
private lateinit var _authRepository: AuthRepository
|
||||||
|
|
||||||
|
val authRepository: AuthRepository
|
||||||
|
get() = _authRepository
|
||||||
|
|
||||||
|
lateinit var checkAndSaveAuthCodeUseCase: CheckAndSaveAuthCodeUseCase
|
||||||
|
private set
|
||||||
|
|
||||||
|
lateinit var getUserInfoUseCase: GetUserInfoUseCase
|
||||||
|
private set
|
||||||
|
|
||||||
|
lateinit var getAvailableBookingsUseCase: GetAvailableBookingsUseCase
|
||||||
|
private set
|
||||||
|
|
||||||
|
lateinit var createBookingUseCase: CreateBookingUseCase
|
||||||
|
private set
|
||||||
|
|
||||||
|
lateinit var logoutUseCase: LogoutUseCase
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun init(context: Context) {
|
||||||
|
_authRepository = AuthRepository(context)
|
||||||
|
val networkDataSource = _authRepository.getNetworkDataSource()
|
||||||
|
checkAndSaveAuthCodeUseCase = CheckAndSaveAuthCodeUseCase(_authRepository)
|
||||||
|
getUserInfoUseCase = GetUserInfoUseCase(_authRepository, networkDataSource)
|
||||||
|
getAvailableBookingsUseCase = GetAvailableBookingsUseCase(_authRepository, networkDataSource)
|
||||||
|
createBookingUseCase = CreateBookingUseCase(_authRepository, networkDataSource)
|
||||||
|
logoutUseCase = LogoutUseCase(_authRepository)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
package ru.myitschool.work.core
|
package ru.myitschool.work.core
|
||||||
|
|
||||||
object Constants {
|
object Constants {
|
||||||
const val HOST = "http://10.0.2.2:8080"
|
const val HOST = "http://10.0.2.2:8080/"
|
||||||
const val AUTH_URL = "/auth"
|
const val AUTH_URL = "/auth"
|
||||||
const val INFO_URL = "/info"
|
const val INFO_URL = "/info"
|
||||||
const val BOOKING_URL = "/booking"
|
const val BOOKING_URL = "/booking"
|
||||||
const val BOOK_URL = "/book"
|
const val BOOK_URL = "/book"
|
||||||
|
|
||||||
|
const val AUTH_CODE_KEY = "auth_code"
|
||||||
|
const val AUTH_PREFS_NAME = "auth_prefs"
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,61 @@
|
|||||||
package ru.myitschool.work.data.repo
|
package ru.myitschool.work.data.repo
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import ru.myitschool.work.core.Constants
|
||||||
import ru.myitschool.work.data.source.NetworkDataSource
|
import ru.myitschool.work.data.source.NetworkDataSource
|
||||||
|
import ru.myitschool.work.data.source.NetworkDataSourceImpl
|
||||||
|
|
||||||
object AuthRepository {
|
val Context.authDataStore: DataStore<Preferences> by preferencesDataStore(name = Constants.AUTH_PREFS_NAME)
|
||||||
|
|
||||||
|
class AuthRepository(
|
||||||
|
private val context: Context
|
||||||
|
)
|
||||||
|
{
|
||||||
|
private val networkDataSource: NetworkDataSource = NetworkDataSourceImpl()
|
||||||
|
private val authCodeKey = stringPreferencesKey(Constants.AUTH_CODE_KEY)
|
||||||
private var codeCache: String? = null
|
private var codeCache: String? = null
|
||||||
|
|
||||||
suspend fun checkAndSave(text: String): Result<Boolean> {
|
suspend fun checkAndSave(text: String): Result<Boolean> {
|
||||||
return NetworkDataSource.checkAuth(text).onSuccess { success ->
|
return networkDataSource.checkAuth(text).onSuccess { success ->
|
||||||
if (success) {
|
if (success) {
|
||||||
codeCache = text
|
codeCache = text
|
||||||
|
saveAuthCode(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fun getAuthCode(): Flow<String?> {
|
||||||
|
return context.authDataStore.data.map { preferences ->
|
||||||
|
preferences[authCodeKey]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private suspend fun saveAuthCode(code: String) {
|
||||||
|
context.authDataStore.edit { preferences ->
|
||||||
|
preferences[authCodeKey] = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suspend fun isAuthorized(): Boolean {
|
||||||
|
return try {
|
||||||
|
val code = getAuthCode().first()
|
||||||
|
code != null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suspend fun logout() {
|
||||||
|
context.authDataStore.edit { preferences ->
|
||||||
|
preferences.remove(authCodeKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun isAuthorizedFlow(): Flow<Boolean> {
|
||||||
|
return getAuthCode().map { it != null }
|
||||||
|
}
|
||||||
|
fun getNetworkDataSource(): NetworkDataSource = networkDataSource
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,55 @@
|
|||||||
package ru.myitschool.work.data.source
|
package ru.myitschool.work.data.source
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.engine.cio.CIO
|
import io.ktor.client.engine.cio.CIO
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
import io.ktor.client.statement.bodyAsText
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.http.contentType
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import ru.myitschool.work.core.Constants
|
import ru.myitschool.work.core.Constants
|
||||||
|
@Serializable
|
||||||
|
data class UserInfoResponse(
|
||||||
|
val name: String,
|
||||||
|
val photoUrl: String,
|
||||||
|
val booking: Map<String, BookingInfo>
|
||||||
|
)
|
||||||
|
|
||||||
object NetworkDataSource {
|
@Serializable
|
||||||
|
data class BookingInfo(
|
||||||
|
val id: Int,
|
||||||
|
val place: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BookingPlace(
|
||||||
|
val id: Int,
|
||||||
|
val place: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BookRequest(
|
||||||
|
val date: String,
|
||||||
|
val placeId: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
interface NetworkDataSource {
|
||||||
|
suspend fun checkAuth(code: String): Result<Boolean>
|
||||||
|
suspend fun getUserInfo(code: String): Result<UserInfoResponse>
|
||||||
|
suspend fun getAvailableBookings(code: String): Result<Map<String, List<BookingPlace>>>
|
||||||
|
suspend fun createBooking(code: String, date: String, placeId: Int): Result<Boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
class NetworkDataSourceImpl : NetworkDataSource {
|
||||||
private val client by lazy {
|
private val client by lazy {
|
||||||
HttpClient(CIO) {
|
HttpClient(CIO) {
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
@@ -28,7 +65,7 @@ object NetworkDataSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) {
|
override suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) {
|
||||||
return@withContext runCatching {
|
return@withContext runCatching {
|
||||||
val response = client.get(getUrl(code, Constants.AUTH_URL))
|
val response = client.get(getUrl(code, Constants.AUTH_URL))
|
||||||
when (response.status) {
|
when (response.status) {
|
||||||
@@ -38,5 +75,53 @@ object NetworkDataSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl"
|
|
||||||
|
override suspend fun getUserInfo(code: String): Result<UserInfoResponse> = withContext(Dispatchers.IO) {
|
||||||
|
return@withContext runCatching {
|
||||||
|
val response = client.get("${Constants.HOST}api/$code/info")
|
||||||
|
|
||||||
|
when (response.status) {
|
||||||
|
HttpStatusCode.OK -> response.body()
|
||||||
|
HttpStatusCode.Unauthorized -> throw Exception("Invalid auth code")
|
||||||
|
HttpStatusCode.BadRequest -> throw Exception("Bad request")
|
||||||
|
else -> throw Exception("Failed to get user info: ${response.status}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAvailableBookings(code: String): Result<Map<String, List<BookingPlace>>> = withContext(Dispatchers.IO) {
|
||||||
|
return@withContext runCatching {
|
||||||
|
val response = client.get("${Constants.HOST}api/$code/booking")
|
||||||
|
|
||||||
|
when (response.status) {
|
||||||
|
HttpStatusCode.OK -> response.body()
|
||||||
|
HttpStatusCode.Unauthorized -> throw Exception("Invalid auth code")
|
||||||
|
HttpStatusCode.BadRequest -> throw Exception("Bad request")
|
||||||
|
else -> throw Exception("Failed to get available bookings: ${response.status}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createBooking(code: String, date: String, placeId: Int): Result<Boolean> = withContext(Dispatchers.IO) {
|
||||||
|
return@withContext runCatching {
|
||||||
|
val request = BookRequest(date, placeId)
|
||||||
|
|
||||||
|
val response = client.post("${Constants.HOST}api/$code/book") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (response.status) {
|
||||||
|
HttpStatusCode.Created -> true
|
||||||
|
HttpStatusCode.Conflict -> throw Exception("Already booked")
|
||||||
|
HttpStatusCode.Unauthorized -> throw Exception("Invalid auth code")
|
||||||
|
HttpStatusCode.BadRequest -> throw Exception("Bad request")
|
||||||
|
else -> throw Exception("Failed to create booking: ${response.status}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}api/$code$targetUrl"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package ru.myitschool.work.domain.book
|
||||||
|
|
||||||
|
import ru.myitschool.work.data.source.BookingPlace
|
||||||
|
|
||||||
|
data class AvailableBookingDate(
|
||||||
|
val date: String,
|
||||||
|
val originalDate: String,
|
||||||
|
val places: List<BookingPlace>
|
||||||
|
)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package ru.myitschool.work.domain.book
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import ru.myitschool.work.data.repo.AuthRepository
|
||||||
|
import ru.myitschool.work.data.source.NetworkDataSource
|
||||||
|
|
||||||
|
class CreateBookingUseCase(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val networkDataSource: NetworkDataSource
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(date: String, placeId: Int): Result<Unit> {
|
||||||
|
val code = getCurrentCode()
|
||||||
|
return if (code != null) {
|
||||||
|
try {
|
||||||
|
val result = networkDataSource.createBooking(code, date, placeId)
|
||||||
|
if (result.isSuccess && result.getOrDefault(false)) {
|
||||||
|
Result.success(Unit)
|
||||||
|
} else {
|
||||||
|
Result.failure(result.exceptionOrNull() ?: Exception("Ошибка бронирования"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("Пользователь не авторизован"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getCurrentCode(): String? {
|
||||||
|
return try {
|
||||||
|
authRepository.getAuthCode().first()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package ru.myitschool.work.domain.book
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import ru.myitschool.work.data.repo.AuthRepository
|
||||||
|
import ru.myitschool.work.data.source.NetworkDataSource
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class GetAvailableBookingsUseCase(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val networkDataSource: NetworkDataSource
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(): Result<List<AvailableBookingDate>> {
|
||||||
|
val code = getCurrentCode()
|
||||||
|
return if (code != null) {
|
||||||
|
try {
|
||||||
|
val response = networkDataSource.getAvailableBookings(code)
|
||||||
|
if (response.isSuccess) {
|
||||||
|
val bookingsMap = response.getOrThrow()
|
||||||
|
val availableDates = bookingsMap.entries.mapNotNull { entry ->
|
||||||
|
try {
|
||||||
|
val dateFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
|
||||||
|
val displayFormatter = SimpleDateFormat("dd.MM", Locale.getDefault())
|
||||||
|
val date = dateFormatter.parse(entry.key)
|
||||||
|
AvailableBookingDate(
|
||||||
|
date = displayFormatter.format(date),
|
||||||
|
originalDate = entry.key,
|
||||||
|
places = entry.value
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.sortedBy {
|
||||||
|
try {
|
||||||
|
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(it.originalDate)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success(availableDates)
|
||||||
|
} else {
|
||||||
|
Result.failure(response.exceptionOrNull() ?: Exception("Ошибка получения данных"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("Пользователь не авторизован"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getCurrentCode(): String? {
|
||||||
|
return try {
|
||||||
|
authRepository.getAuthCode().first()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package ru.myitschool.work.domain.main
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import ru.myitschool.work.data.repo.AuthRepository
|
||||||
|
import ru.myitschool.work.data.source.NetworkDataSource
|
||||||
|
import ru.myitschool.work.ui.screen.main.BookingItem
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
import ru.myitschool.work.data.source.BookingInfo as SourceBookingInfo
|
||||||
|
|
||||||
|
class GetUserInfoUseCase(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val networkDataSource: NetworkDataSource
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(): Result<UserInfo> {
|
||||||
|
val code = getCurrentCode()
|
||||||
|
return if (code != null) {
|
||||||
|
try {
|
||||||
|
val response = networkDataSource.getUserInfo(code)
|
||||||
|
if (response.isSuccess) {
|
||||||
|
val userInfoResponse = response.getOrThrow()
|
||||||
|
|
||||||
|
val bookings = userInfoResponse.booking.entries.mapNotNull { entry ->
|
||||||
|
try {
|
||||||
|
val dateFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
|
||||||
|
val displayFormatter = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault())
|
||||||
|
val date = dateFormatter.parse(entry.key)
|
||||||
|
|
||||||
|
// Получаем место из BookingInfo
|
||||||
|
val place = when (val bookingInfo = entry.value) {
|
||||||
|
is SourceBookingInfo -> bookingInfo.place
|
||||||
|
else -> entry.value.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
BookingItem(
|
||||||
|
date = displayFormatter.format(date),
|
||||||
|
place = place,
|
||||||
|
originalDate = entry.key
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.sortedBy {
|
||||||
|
try {
|
||||||
|
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(it.originalDate)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success(
|
||||||
|
UserInfo(
|
||||||
|
name = userInfoResponse.name,
|
||||||
|
photoUrl = userInfoResponse.photoUrl,
|
||||||
|
bookings = bookings
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Result.failure(response.exceptionOrNull() ?: Exception("Ошибка получения данных"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("Пользователь не авторизован"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getCurrentCode(): String? {
|
||||||
|
return try {
|
||||||
|
authRepository.getAuthCode().first()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class UserInfo(
|
||||||
|
val name: String,
|
||||||
|
val photoUrl: String,
|
||||||
|
val bookings: List<BookingItem>
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package ru.myitschool.work.domain.main
|
||||||
|
|
||||||
|
import ru.myitschool.work.data.repo.AuthRepository
|
||||||
|
|
||||||
|
class LogoutUseCase(private val authRepository: AuthRepository) {
|
||||||
|
suspend operator fun invoke() {
|
||||||
|
authRepository.logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
package ru.myitschool.work.ui.nav
|
package ru.myitschool.work.ui.nav
|
||||||
|
|
||||||
sealed interface AppDestination
|
sealed class AppDestination(val route: String) {
|
||||||
|
object Auth : AppDestination("auth")
|
||||||
|
object Main : AppDestination("main")
|
||||||
|
object Book : AppDestination("book")
|
||||||
|
}
|
||||||
@@ -2,5 +2,6 @@ package ru.myitschool.work.ui.nav
|
|||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
object AuthScreenDestination {
|
||||||
data object AuthScreenDestination: AppDestination
|
const val route = "auth"
|
||||||
|
}
|
||||||
@@ -2,5 +2,6 @@ package ru.myitschool.work.ui.nav
|
|||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
object BookScreenDestination {
|
||||||
data object BookScreenDestination: AppDestination
|
const val route = "book"
|
||||||
|
}
|
||||||
@@ -2,5 +2,6 @@ package ru.myitschool.work.ui.nav
|
|||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
object MainScreenDestination {
|
||||||
data object MainScreenDestination: AppDestination
|
const val route = "main"
|
||||||
|
}
|
||||||
@@ -6,23 +6,26 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import ru.myitschool.work.ui.screen.AppNavHost
|
import ru.myitschool.work.AppModule
|
||||||
|
import ru.myitschool.work.ui.screen.NavigationGraph
|
||||||
import ru.myitschool.work.ui.theme.WorkTheme
|
import ru.myitschool.work.ui.theme.WorkTheme
|
||||||
|
|
||||||
class RootActivity : ComponentActivity() {
|
class RootActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
AppModule.init(applicationContext)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
WorkTheme {
|
WorkTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
Surface(
|
||||||
AppNavHost(
|
modifier = Modifier.fillMaxSize(),
|
||||||
modifier = Modifier
|
color = MaterialTheme.colorScheme.background
|
||||||
.fillMaxSize()
|
) {
|
||||||
.padding(innerPadding)
|
NavigationGraph()
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,45 +5,116 @@ import androidx.compose.animation.ExitTransition
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import ru.myitschool.work.ui.nav.AuthScreenDestination
|
import ru.myitschool.work.AppModule
|
||||||
|
import ru.myitschool.work.ui.nav.AppDestination
|
||||||
|
|
||||||
import ru.myitschool.work.ui.nav.BookScreenDestination
|
import ru.myitschool.work.ui.nav.BookScreenDestination
|
||||||
import ru.myitschool.work.ui.nav.MainScreenDestination
|
import ru.myitschool.work.ui.nav.MainScreenDestination
|
||||||
import ru.myitschool.work.ui.screen.auth.AuthScreen
|
import ru.myitschool.work.ui.screen.auth.AuthScreen
|
||||||
|
import ru.myitschool.work.ui.screen.auth.AuthViewModel
|
||||||
|
import ru.myitschool.work.ui.screen.book.BookScreen
|
||||||
|
import ru.myitschool.work.ui.screen.book.BookViewModel
|
||||||
|
import ru.myitschool.work.ui.screen.main.MainScreen
|
||||||
|
import ru.myitschool.work.ui.screen.main.MainViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavHost(
|
fun NavigationGraph(
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
navController: NavHostController = rememberNavController()
|
navController: NavHostController = rememberNavController()
|
||||||
) {
|
) {
|
||||||
NavHost(
|
|
||||||
modifier = modifier,
|
|
||||||
enterTransition = { EnterTransition.None },
|
|
||||||
exitTransition = { ExitTransition.None },
|
val isAuthorized by AppModule.authRepository.isAuthorizedFlow()
|
||||||
|
.collectAsState(initial = false)
|
||||||
|
|
||||||
|
|
||||||
|
val startDestination = remember(isAuthorized) {
|
||||||
|
if (isAuthorized) AppDestination.Main.route else AppDestination.Auth.route
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = AuthScreenDestination,
|
startDestination = startDestination
|
||||||
) {
|
) {
|
||||||
composable<AuthScreenDestination> {
|
composable(AppDestination.Auth.route) {
|
||||||
AuthScreen(navController = navController)
|
val viewModel: AuthViewModel = viewModel()
|
||||||
}
|
val state = viewModel.uiState.collectAsState()
|
||||||
composable<MainScreenDestination> {
|
|
||||||
Box(
|
AuthScreen(
|
||||||
contentAlignment = Alignment.Center
|
state = state.value,
|
||||||
) {
|
navController = navController
|
||||||
Text(text = "Hello")
|
//onIntent = viewModel::processIntent
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.navigation.collect { destination ->
|
||||||
|
navController.navigate(destination.route) {
|
||||||
|
popUpTo(AppDestination.Auth.route) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
composable<BookScreenDestination> {
|
|
||||||
Box(
|
composable(AppDestination.Main.route) {
|
||||||
contentAlignment = Alignment.Center
|
val viewModel: MainViewModel = viewModel()
|
||||||
) {
|
val state = viewModel.state.collectAsState()
|
||||||
Text(text = "Hello")
|
|
||||||
|
MainScreen(
|
||||||
|
state = state.value,
|
||||||
|
onIntent = viewModel::processIntent
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.navigation.collect { destination ->
|
||||||
|
when (destination) {
|
||||||
|
AppDestination.Auth -> {
|
||||||
|
navController.navigate(destination.route) {
|
||||||
|
popUpTo(AppDestination.Main.route) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppDestination.Book -> {
|
||||||
|
navController.navigate(destination.route)
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
composable(AppDestination.Book.route) {
|
||||||
|
val viewModel: BookViewModel = viewModel()
|
||||||
|
val state = viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
BookScreen(
|
||||||
|
state = state.value,
|
||||||
|
onIntent = viewModel::processIntent
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.navigation.collect { destination ->
|
||||||
|
when (destination) {
|
||||||
|
AppDestination.Main -> {
|
||||||
|
navController.navigate(destination.route) {
|
||||||
|
popUpTo(AppDestination.Book.route) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
@@ -31,20 +31,27 @@ import androidx.navigation.NavController
|
|||||||
import ru.myitschool.work.R
|
import ru.myitschool.work.R
|
||||||
import ru.myitschool.work.core.TestIds
|
import ru.myitschool.work.core.TestIds
|
||||||
import ru.myitschool.work.ui.nav.MainScreenDestination
|
import ru.myitschool.work.ui.nav.MainScreenDestination
|
||||||
|
import ru.myitschool.work.ui.screen.main.MainIntent
|
||||||
|
import ru.myitschool.work.ui.screen.main.MainState
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AuthScreen(
|
fun AuthScreen(
|
||||||
viewModel: AuthViewModel = viewModel(),
|
viewModel: AuthViewModel = viewModel(),
|
||||||
navController: NavController
|
navController: NavController,
|
||||||
|
state: AuthState
|
||||||
|
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.actionFlow.collect {
|
viewModel.actionFlow.collect {
|
||||||
navController.navigate(MainScreenDestination)
|
navController.navigate(MainScreenDestination)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -52,11 +59,7 @@ fun AuthScreen(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.auth_title),
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
when (val currentState = state) {
|
when (val currentState = state) {
|
||||||
is AuthState.Data -> Content(viewModel, currentState)
|
is AuthState.Data -> Content(viewModel, currentState)
|
||||||
is AuthState.Loading -> {
|
is AuthState.Loading -> {
|
||||||
@@ -64,6 +67,7 @@ fun AuthScreen(
|
|||||||
modifier = Modifier.size(64.dp)
|
modifier = Modifier.size(64.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
is AuthState.Error -> Content(viewModel, currentState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,15 +75,23 @@ fun AuthScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun Content(
|
private fun Content(
|
||||||
viewModel: AuthViewModel,
|
viewModel: AuthViewModel,
|
||||||
state: AuthState.Data
|
state: AuthState
|
||||||
) {
|
) {
|
||||||
var inputText by remember { mutableStateOf("") }
|
val inputText = when (state) {
|
||||||
|
is AuthState.Data -> state.text
|
||||||
|
is AuthState.Error -> state.text
|
||||||
|
else -> "" }
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.auth_title),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
TextField(
|
TextField(
|
||||||
modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(),
|
modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(),
|
||||||
value = inputText,
|
value = inputText,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
inputText = it
|
|
||||||
viewModel.onIntent(AuthIntent.TextInput(it))
|
viewModel.onIntent(AuthIntent.TextInput(it))
|
||||||
},
|
},
|
||||||
label = { Text(stringResource(R.string.auth_label)) }
|
label = { Text(stringResource(R.string.auth_label)) }
|
||||||
@@ -89,9 +101,20 @@ private fun Content(
|
|||||||
modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(),
|
modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(),
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.onIntent(AuthIntent.Send(inputText))
|
viewModel.onIntent(AuthIntent.Send(inputText))
|
||||||
|
|
||||||
},
|
},
|
||||||
enabled = true
|
enabled = inputText.length == 4 && !inputText.isNullOrEmpty() && inputText.matches("[a-zA-Z0-9]+".toRegex()),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.auth_sign_in))
|
Text(stringResource(R.string.auth_sign_in))
|
||||||
}
|
}
|
||||||
}
|
Spacer(modifier = Modifier.size(24.dp))
|
||||||
|
if (state is AuthState.Error) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.testTag(TestIds.Auth.ERROR),
|
||||||
|
text = state.message,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ package ru.myitschool.work.ui.screen.auth
|
|||||||
|
|
||||||
sealed interface AuthState {
|
sealed interface AuthState {
|
||||||
object Loading: AuthState
|
object Loading: AuthState
|
||||||
object Data: AuthState
|
class Data(val text: String = ""): AuthState
|
||||||
|
data class Error(val text: String, val message: String) : AuthState
|
||||||
}
|
}
|
||||||
@@ -1,43 +1,88 @@
|
|||||||
package ru.myitschool.work.ui.screen.auth
|
package ru.myitschool.work.ui.screen.auth
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import ru.myitschool.work.data.repo.AuthRepository
|
import ru.myitschool.work.data.repo.AuthRepository
|
||||||
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
|
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
|
||||||
|
import ru.myitschool.work.ui.nav.AppDestination
|
||||||
|
import ru.myitschool.work.ui.screen.main.MainIntent
|
||||||
|
|
||||||
class AuthViewModel : ViewModel() {
|
class AuthViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) }
|
|
||||||
private val _uiState = MutableStateFlow<AuthState>(AuthState.Data)
|
private val _navigation = MutableSharedFlow<AppDestination>()
|
||||||
|
val navigation: SharedFlow<AppDestination> = _navigation.asSharedFlow()
|
||||||
|
private val repository by lazy {
|
||||||
|
AuthRepository(getApplication<Application>().applicationContext)
|
||||||
|
}
|
||||||
|
private val checkAndSaveAuthCodeUseCase by lazy {
|
||||||
|
CheckAndSaveAuthCodeUseCase(repository) }
|
||||||
|
private val _uiState = MutableStateFlow<AuthState>(AuthState.Data())
|
||||||
val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
|
val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
|
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
|
||||||
val actionFlow: SharedFlow<Unit> = _actionFlow
|
val actionFlow: SharedFlow<Unit> = _actionFlow
|
||||||
|
private val context = getApplication<Application>().applicationContext
|
||||||
|
private val prefs by lazy {
|
||||||
|
context.getSharedPreferences("auth", Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
val savedCode = prefs.getString("saved_code", null)
|
||||||
|
if (!savedCode.isNullOrEmpty()) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_actionFlow.emit(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fun onIntent(intent: AuthIntent) {
|
fun onIntent(intent: AuthIntent) {
|
||||||
when (intent) {
|
when (intent) {
|
||||||
is AuthIntent.Send -> {
|
is AuthIntent.Send -> {
|
||||||
viewModelScope.launch(Dispatchers.Default) {
|
viewModelScope.launch(Dispatchers.Default) {
|
||||||
|
val currentText = when (val s = _uiState.value) {
|
||||||
|
is AuthState.Data -> s.text
|
||||||
|
is AuthState.Error -> s.text
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
_uiState.update { AuthState.Loading }
|
_uiState.update { AuthState.Loading }
|
||||||
checkAndSaveAuthCodeUseCase.invoke("9999").fold(
|
checkAndSaveAuthCodeUseCase.invoke(intent.text).fold(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
_actionFlow.emit(Unit)
|
prefs.edit()
|
||||||
|
.putString("saved_code", intent.text)
|
||||||
|
.apply()
|
||||||
|
_navigation.emit(AppDestination.Main)
|
||||||
|
|
||||||
},
|
},
|
||||||
onFailure = { error ->
|
onFailure = { error ->
|
||||||
error.printStackTrace()
|
error.printStackTrace()
|
||||||
_actionFlow.emit(Unit)
|
_uiState.value = AuthState.Error(text = currentText,error.message ?: "Ошибка")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is AuthIntent.TextInput -> Unit
|
is AuthIntent.TextInput -> {
|
||||||
|
when (val stateValue = _uiState.value) {
|
||||||
|
is AuthState.Data -> _uiState.value = AuthState.Data(text = intent.text)
|
||||||
|
is AuthState.Error -> _uiState.value = AuthState.Data(intent.text)
|
||||||
|
is AuthState.Loading -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package ru.myitschool.work.ui.screen.book
|
||||||
|
|
||||||
|
sealed class BookIntent {
|
||||||
|
data class DateSelected(val index: Int) : BookIntent()
|
||||||
|
data class PlaceSelected(val placeId: Int) : BookIntent()
|
||||||
|
object Book : BookIntent()
|
||||||
|
object Refresh : BookIntent()
|
||||||
|
object Back : BookIntent()
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package ru.myitschool.work.ui.screen.book
|
||||||
|
|
||||||
|
import android.R
|
||||||
|
import androidx.annotation.ColorRes
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
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.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.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.BottomAppBar
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||||
|
import androidx.compose.material3.CheckboxDefaults.colors
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.RadioButtonDefaults
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
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.runtime.setValue
|
||||||
|
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.semantics.Role
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.google.android.material.bottomappbar.BottomAppBar
|
||||||
|
import com.google.android.material.progressindicator.CircularProgressIndicator
|
||||||
|
import ru.myitschool.work.core.TestIds
|
||||||
|
import ru.myitschool.work.ui.nav.MainScreenDestination
|
||||||
|
import ru.myitschool.work.ui.screen.auth.AuthState
|
||||||
|
import ru.myitschool.work.ui.screen.auth.AuthViewModel
|
||||||
|
import kotlin.collections.getOrNull
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BookScreen(
|
||||||
|
state: BookState,
|
||||||
|
onIntent: (BookIntent) -> Unit
|
||||||
|
) {
|
||||||
|
if (state.showError) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.testTag(TestIds.Book.ERROR),
|
||||||
|
text = state.error ?: "Ошибка загрузки",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onIntent(BookIntent.Back) }) {
|
||||||
|
Text("Назад")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
modifier = Modifier.testTag(TestIds.Book.REFRESH_BUTTON),
|
||||||
|
onClick = { onIntent(BookIntent.Refresh) }) {
|
||||||
|
Text("Обновить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isEmpty) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text("Всё забронировано",
|
||||||
|
modifier = Modifier.testTag(TestIds.Book.EMPTY),
|
||||||
|
style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
OutlinedButton(onClick = { onIntent(BookIntent.Back) }) {
|
||||||
|
Text("Назад")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
|
title = { Text("Бронирование") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
|
||||||
|
if (state.isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
return@Scaffold
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
) {
|
||||||
|
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 32.dp, top = 32.dp, end = 32.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
itemsIndexed(state.availableDates) { index, date ->
|
||||||
|
val isSelected = state.selectedDateIndex == index
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag("book_date_pos_$index")
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.border(
|
||||||
|
width = 1.dp,
|
||||||
|
color = if (isSelected)
|
||||||
|
Color(0xFF2962FF)
|
||||||
|
else
|
||||||
|
Color(0xFFBDBDBD),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
.clickable {
|
||||||
|
onIntent(BookIntent.DateSelected(index))
|
||||||
|
}
|
||||||
|
.padding(horizontal = 14.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.testTag(TestIds.Book.ITEM_DATE),
|
||||||
|
text = date.date,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
val selectedDate = state.availableDates
|
||||||
|
.getOrNull(state.selectedDateIndex)
|
||||||
|
|
||||||
|
if (selectedDate != null && selectedDate.places.isNotEmpty()) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
itemsIndexed(selectedDate.places) {index, place ->
|
||||||
|
val isSelected =
|
||||||
|
state.selectedPlaceId == place.id
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag("book_place_pos_$index")
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(Color.Transparent)
|
||||||
|
.clickable {
|
||||||
|
onIntent(
|
||||||
|
BookIntent.PlaceSelected(place.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(vertical = 16.dp, horizontal = 24.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.testTag(TestIds.Book.ITEM_PLACE_TEXT).weight(1f),
|
||||||
|
text = place.place,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Book.ITEM_PLACE_SELECTOR)
|
||||||
|
.size(20.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.selectable(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = {
|
||||||
|
onIntent(BookIntent.PlaceSelected(place.id))
|
||||||
|
},
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
|
||||||
|
.border(
|
||||||
|
2.dp,
|
||||||
|
if (isSelected)
|
||||||
|
Color(0xFF2962FF)
|
||||||
|
else
|
||||||
|
Color(0xFF9E9E9E),
|
||||||
|
CircleShape
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (isSelected) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(10.dp)
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFF2962FF))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Нет доступных мест на выбранную дату",
|
||||||
|
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 32.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
modifier = Modifier.testTag(TestIds.Book.BOOK_BUTTON),
|
||||||
|
onClick = { onIntent(BookIntent.Back) }) {
|
||||||
|
Text("Назад")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
modifier = Modifier.testTag(TestIds.Book.BACK_BUTTON),
|
||||||
|
onClick = { onIntent(BookIntent.Book) },
|
||||||
|
enabled = state.selectedPlaceId != null
|
||||||
|
) {
|
||||||
|
Text("Забронировать")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package ru.myitschool.work.ui.screen.book
|
||||||
|
|
||||||
|
import ru.myitschool.work.domain.book.AvailableBookingDate
|
||||||
|
|
||||||
|
data class BookState(
|
||||||
|
val selectedDateIndex: Int = 0,
|
||||||
|
val selectedPlaceId: Int? = null,
|
||||||
|
val availableDates: List<AvailableBookingDate> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val isEmpty: Boolean = false,
|
||||||
|
val showError: Boolean = false
|
||||||
|
)
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package ru.myitschool.work.ui.screen.book
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
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.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.myitschool.work.AppModule
|
||||||
|
import ru.myitschool.work.ui.nav.AppDestination
|
||||||
|
import kotlin.collections.getOrNull
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class BookViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(BookState())
|
||||||
|
val state: StateFlow<BookState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _navigation = MutableSharedFlow<AppDestination>()
|
||||||
|
val navigation: SharedFlow<AppDestination> = _navigation.asSharedFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadAvailableBookings()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun processIntent(intent: BookIntent) {
|
||||||
|
when (intent) {
|
||||||
|
is BookIntent.DateSelected -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
selectedDateIndex = intent.index,
|
||||||
|
selectedPlaceId = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is BookIntent.PlaceSelected -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(selectedPlaceId = intent.placeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BookIntent.Book -> {
|
||||||
|
createBooking()
|
||||||
|
}
|
||||||
|
BookIntent.Refresh -> {
|
||||||
|
loadAvailableBookings()
|
||||||
|
}
|
||||||
|
BookIntent.Back -> {
|
||||||
|
backToMain()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadAvailableBookings() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = true,
|
||||||
|
error = null,
|
||||||
|
showError = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = AppModule.getAvailableBookingsUseCase()
|
||||||
|
|
||||||
|
if (result.isSuccess) {
|
||||||
|
val availableDates = result.getOrThrow()
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
availableDates = availableDates,
|
||||||
|
isEmpty = availableDates.isEmpty(),
|
||||||
|
selectedDateIndex = if (availableDates.isNotEmpty()) 0 else 0,
|
||||||
|
selectedPlaceId = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = result.exceptionOrNull()?.message ?: "Ошибка загрузки",
|
||||||
|
showError = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createBooking() {
|
||||||
|
val currentState = _state.value
|
||||||
|
val selectedDate = currentState.availableDates.getOrNull(currentState.selectedDateIndex)
|
||||||
|
val selectedPlaceId = currentState.selectedPlaceId
|
||||||
|
|
||||||
|
if (selectedDate == null || selectedPlaceId == null) {
|
||||||
|
_state.update { it.copy(error = "Выберите место для бронирования") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
|
||||||
|
val result = AppModule.createBookingUseCase(
|
||||||
|
selectedDate.originalDate,
|
||||||
|
selectedPlaceId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.isSuccess) {
|
||||||
|
_navigation.emit(AppDestination.Main)
|
||||||
|
} else {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = result.exceptionOrNull()?.message ?: "Ошибка бронирования"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun backToMain() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_navigation.emit(AppDestination.Main)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package ru.myitschool.work.ui.screen.main
|
||||||
|
|
||||||
|
sealed class MainIntent {
|
||||||
|
object LoadData : MainIntent()
|
||||||
|
object Logout : MainIntent()
|
||||||
|
object Refresh : MainIntent()
|
||||||
|
object NavigateToBooking : MainIntent()
|
||||||
|
data class BookingSelected(val index: Int) : MainIntent()
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
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.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.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
import coil3.compose.AsyncImagePainter
|
||||||
|
import coil3.compose.rememberAsyncImagePainter
|
||||||
|
import ru.myitschool.work.R
|
||||||
|
import ru.myitschool.work.core.TestIds
|
||||||
|
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(
|
||||||
|
state: MainState,
|
||||||
|
onIntent: (MainIntent) -> Unit
|
||||||
|
) {
|
||||||
|
|
||||||
|
if (state.showError) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.testTag(TestIds.Main.ERROR),
|
||||||
|
text = state.error ?: "Ошибка загрузки",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
|
TextButton(onClick = { onIntent(MainIntent.Refresh) }) {
|
||||||
|
Text("Повторить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(
|
||||||
|
modifier = Modifier.testTag(TestIds.Main.ADD_BUTTON),
|
||||||
|
onClick = { onIntent(MainIntent.NavigateToBooking) }) {
|
||||||
|
Icon(painterResource(id = R.drawable.icon_add), contentDescription = "Бронировать")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
) { padding ->
|
||||||
|
|
||||||
|
when {
|
||||||
|
state.isLoading -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 16.dp, end = 16.dp, top = 100.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
val painter = rememberAsyncImagePainter(state.userPhotoUrl)
|
||||||
|
val painterState = painter.state
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(96.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painter,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize().testTag(TestIds.Main.PROFILE_IMAGE)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
when (painterState) {
|
||||||
|
is AsyncImagePainter.State.Loading -> {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is AsyncImagePainter.State.Error -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.PROFILE_NAME),
|
||||||
|
text = state.userName,
|
||||||
|
style = MaterialTheme.typography.headlineMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onIntent(MainIntent.Logout) },
|
||||||
|
modifier = Modifier.weight(1f).testTag(TestIds.Main.LOGOUT_BUTTON)
|
||||||
|
) {
|
||||||
|
Text("Выйти")
|
||||||
|
}
|
||||||
|
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { onIntent(MainIntent.Refresh) },
|
||||||
|
modifier = Modifier.weight(1f).testTag(TestIds.Main.REFRESH_BUTTON)
|
||||||
|
) {
|
||||||
|
Text("Обновить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "Мои бронирования",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (state.bookings.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 32.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Пока нет бронирований",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
itemsIndexed(state.bookings) { index, booking ->
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag("main_book_pos_$index")
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.testTag(TestIds.Main.ITEM_DATE),
|
||||||
|
text = booking.date,
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE),
|
||||||
|
text = booking.place,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package ru.myitschool.work.ui.screen.main
|
||||||
|
|
||||||
|
data class MainState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val userName: String = "",
|
||||||
|
val userPhotoUrl: String = "",
|
||||||
|
val bookings: List<BookingItem> = emptyList(),
|
||||||
|
val error: String? = null,
|
||||||
|
val showError: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BookingItem(
|
||||||
|
val date: String,
|
||||||
|
val place: String,
|
||||||
|
val originalDate: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package ru.myitschool.work.ui.screen.main
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
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.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.myitschool.work.AppModule
|
||||||
|
import ru.myitschool.work.ui.nav.AppDestination
|
||||||
|
|
||||||
|
class MainViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(MainState())
|
||||||
|
val state: StateFlow<MainState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _navigation = MutableSharedFlow<AppDestination>()
|
||||||
|
val navigation: SharedFlow<AppDestination> = _navigation.asSharedFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadUserInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun processIntent(intent: MainIntent) {
|
||||||
|
when (intent) {
|
||||||
|
MainIntent.LoadData -> loadUserInfo()
|
||||||
|
MainIntent.Logout -> logout()
|
||||||
|
MainIntent.Refresh -> refresh()
|
||||||
|
MainIntent.NavigateToBooking -> navigateToBooking()
|
||||||
|
is MainIntent.BookingSelected -> {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadUserInfo() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null, showError = false) }
|
||||||
|
|
||||||
|
val result = AppModule.getUserInfoUseCase()
|
||||||
|
|
||||||
|
if (result.isSuccess) {
|
||||||
|
val userInfo = result.getOrThrow()
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
userName = userInfo.name,
|
||||||
|
userPhotoUrl = userInfo.photoUrl,
|
||||||
|
bookings = userInfo.bookings
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = result.exceptionOrNull()?.message ?: "Ошибка загрузки",
|
||||||
|
showError = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logout() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
AppModule.logoutUseCase()
|
||||||
|
_navigation.emit(AppDestination.Auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refresh() {
|
||||||
|
loadUserInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateToBooking() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_navigation.emit(AppDestination.Book)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
app/src/main/res/drawable/icon_add.xml
Normal file
5
app/src/main/res/drawable/icon_add.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||||
|
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -7,4 +7,5 @@
|
|||||||
<color name="teal_700">#FF018786</color>
|
<color name="teal_700">#FF018786</color>
|
||||||
<color name="black">#FF000000</color>
|
<color name="black">#FF000000</color>
|
||||||
<color name="white">#FFFFFFFF</color>
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
<color name="primary_blue">#517DFF</color>
|
||||||
</resources>
|
</resources>
|
||||||
Reference in New Issue
Block a user