diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a5ccda1..9b849bb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -36,7 +36,7 @@ android { dependencies { defaultComposeLibrary() - implementation("androidx.datastore:datastore-preferences:1.1.7") + implementation("androidx.datastore:datastore-preferences:1.2.0") implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0") implementation("androidx.navigation:navigation-compose:2.9.6") val coil = "3.3.0" diff --git a/app/src/main/java/ru/myitschool/work/App.kt b/app/src/main/java/ru/myitschool/work/App.kt index aa33483..fcf320f 100644 --- a/app/src/main/java/ru/myitschool/work/App.kt +++ b/app/src/main/java/ru/myitschool/work/App.kt @@ -2,11 +2,21 @@ package ru.myitschool.work import android.app.Application import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import ru.myitschool.work.data.datastore.DataStoreManager + +val Context.dataStore: DataStore by preferencesDataStore(name = "datastore") class App: Application() { + + lateinit var dataStoreManager: DataStoreManager + override fun onCreate() { super.onCreate() context = this + dataStoreManager = DataStoreManager(dataStore) } companion object { 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/repo/AuthRepository.kt b/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt index 3ef28f1..f76836c 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 @@ -4,13 +4,8 @@ import ru.myitschool.work.data.source.NetworkDataSource object AuthRepository { - private var codeCache: String? = null suspend fun checkAndSave(text: String): Result { - return NetworkDataSource.checkAuth(text).onSuccess { success -> - if (success) { - codeCache = text - } - } + return NetworkDataSource.checkAuth(text) } } \ 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 new file mode 100644 index 0000000..bfdc8c3 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repo/BookRepository.kt @@ -0,0 +1,21 @@ +package ru.myitschool.work.data.repo + +import ru.myitschool.work.data.source.NetworkDataSource +import ru.myitschool.work.domain.book.entities.BookingEntity +import ru.myitschool.work.domain.main.entities.UserEntity + +object BookRepository { + + suspend fun loadBooking(text: String): Result { + return NetworkDataSource.loadBooking(text) + } + + suspend fun bookPlace( + userCode: String, + date: String, + placeId: Int, + placeName: String + ): Result { + return NetworkDataSource.bookPlace(userCode, date, placeId, placeName) + } +} \ 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 new file mode 100644 index 0000000..34f65ac --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repo/MainRepository.kt @@ -0,0 +1,11 @@ +package ru.myitschool.work.data.repo + +import ru.myitschool.work.data.source.NetworkDataSource +import ru.myitschool.work.domain.main.entities.UserEntity + +object MainRepository { + + suspend fun loadData(text: String): Result { + return NetworkDataSource.loadData(text) + } +} \ 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 fbdfef5..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,9 +1,13 @@ 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.contentnegotiation.ContentNegotiation 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.http.HttpStatusCode import io.ktor.serialization.kotlinx.json.json @@ -11,6 +15,30 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import ru.myitschool.work.core.Constants +import ru.myitschool.work.domain.book.entities.BookingEntity +import ru.myitschool.work.domain.book.entities.PlaceInfo +import ru.myitschool.work.domain.main.entities.UserEntity + +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 { @@ -28,9 +56,38 @@ object NetworkDataSource { } } + 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 when (response.status) { HttpStatusCode.OK -> true else -> error(response.bodyAsText()) @@ -38,5 +95,35 @@ object NetworkDataSource { } } + suspend fun loadData(code: String): Result = withContext(Dispatchers.IO) { + return@withContext runCatching { + +// Json.decodeFromString(testJson) // удалить при проверке + + val response = client.get(getUrl(code, Constants.INFO_URL)) + when (response.status) { + HttpStatusCode.OK -> { + response.body() + } + else -> error(response.bodyAsText()) + } + } + } + + suspend fun loadBooking(code: String): Result = withContext(Dispatchers.IO) { + return@withContext runCatching { + +// BookingEntity(Json.decodeFromString>>(testBookingJson)) // удалить при проверке + + val response = client.get(getUrl(code, Constants.BOOKING_URL)) + when (response.status) { + HttpStatusCode.OK -> { + BookingEntity(response.body>>()) + } + else -> error(response.bodyAsText()) + } + } + } + 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..a15018d --- /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 BaseNoBackgroundButton( + 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.logo), + 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 01b0f32..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 @@ -14,7 +14,11 @@ import androidx.navigation.compose.rememberNavController 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( @@ -26,24 +30,19 @@ fun AppNavHost( enterTransition = { EnterTransition.None }, exitTransition = { ExitTransition.None }, navController = navController, - startDestination = AuthScreenDestination, + startDestination = SplashScreenDestination, ) { + composable { + SplashScreen(navController = navController) + } composable { AuthScreen(navController = navController) } composable { - Box( - contentAlignment = Alignment.Center - ) { - Text(text = "Hello") - } + MainScreen(navController = navController) } composable { - Box( - contentAlignment = Alignment.Center - ) { - Text(text = "Hello") - } + BookScreen(navController = navController) } } } \ No newline at end of file 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 f99978e..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,27 +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.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.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.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -30,12 +23,20 @@ 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(), - navController: NavController + navController: NavController, ) { val state by viewModel.uiState.collectAsState() @@ -45,24 +46,34 @@ fun AuthScreen( } } - Column( - modifier = Modifier - .fillMaxSize() - .padding(all = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() ) { - Text( - text = stringResource(R.string.auth_title), - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center - ) - when (val currentState = state) { - is AuthState.Data -> Content(viewModel, currentState) - is AuthState.Loading -> { - CircularProgressIndicator( - modifier = Modifier.size(64.dp) - ) + Column( + modifier = Modifier + .width(400.dp) + .fillMaxHeight() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + Logo() + + BaseText24( + text = stringResource(R.string.auth_title), + textAlign = TextAlign.Center + ) + + when (state) { + is AuthState.Data -> Content(viewModel) + is AuthState.Loading -> { + CircularProgressIndicator( + modifier = Modifier + .padding(top = 40.dp) + .size(64.dp) + ) + } } } } @@ -70,28 +81,50 @@ fun AuthScreen( @Composable private fun Content( - viewModel: AuthViewModel, - state: AuthState.Data + viewModel: AuthViewModel ) { - var inputText by remember { mutableStateOf("") } - Spacer(modifier = Modifier.size(16.dp)) - TextField( - modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(), - value = inputText, - onValueChange = { - inputText = it - viewModel.onIntent(AuthIntent.TextInput(it)) - }, - label = { Text(stringResource(R.string.auth_label)) } - ) - Spacer(modifier = Modifier.size(16.dp)) - Button( - modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(), - onClick = { - viewModel.onIntent(AuthIntent.Send(inputText)) - }, - enabled = true + + val isButtonEnabled by viewModel.isButtonEnabled.collectAsState() + val errorStateValue by viewModel.errorStateValue.collectAsState() + val textState by viewModel.textState.collectAsState() + + Column( + modifier = Modifier.padding(vertical = 20.dp) ) { - Text(stringResource(R.string.auth_sign_in)) + + BaseInputText( + value = textState, + placeholder = stringResource(R.string.auth_label), + modifier = Modifier + .testTag(TestIds.Auth.CODE_INPUT) + .fillMaxWidth(), + onValueChange = { viewModel.onIntent(AuthIntent.TextInput(it)) } + ) + + 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/AuthViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt index 3153640..5a142ac 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,6 +1,7 @@ package ru.myitschool.work.ui.screen.auth -import androidx.lifecycle.ViewModel +import android.app.Application +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow @@ -10,34 +11,52 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update 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 AuthViewModel : ViewModel() { +class AuthViewModel(application: Application) : AndroidViewModel(application) { + + private val dataStoreManager by lazy { + (getApplication() as App).dataStoreManager + } private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) } private val _uiState = MutableStateFlow(AuthState.Data) val uiState: StateFlow = _uiState.asStateFlow() - private val _actionFlow: MutableSharedFlow = MutableSharedFlow() val actionFlow: SharedFlow = _actionFlow + private val _errorStateValue = MutableStateFlow("") + val errorStateValue: StateFlow = _errorStateValue.asStateFlow() + private val _isButtonEnabled = MutableStateFlow(false) + val isButtonEnabled: StateFlow = _isButtonEnabled.asStateFlow() + private val _textState = MutableStateFlow("") + val textState: StateFlow = _textState.asStateFlow() fun onIntent(intent: AuthIntent) { when (intent) { is AuthIntent.Send -> { - viewModelScope.launch(Dispatchers.Default) { + viewModelScope.launch(Dispatchers.IO) { _uiState.update { AuthState.Loading } - checkAndSaveAuthCodeUseCase.invoke("9999").fold( + checkAndSaveAuthCodeUseCase.invoke(intent.text).fold( onSuccess = { + dataStoreManager.saveUserCode(UserCode(code = intent.text)) _actionFlow.emit(Unit) }, onFailure = { error -> error.printStackTrace() - _actionFlow.emit(Unit) + _uiState.update { AuthState.Data } + _errorStateValue.value = error.message.toString() ?: "Неизвестная ошибка" } ) } } - is AuthIntent.TextInput -> Unit + is AuthIntent.TextInput -> { + _textState.value = intent.text + _errorStateValue.value = "" + _isButtonEnabled.value = if (intent.text.length == 4 && intent.text.matches(Regex("^[a-zA-Z0-9]*\$"))) + true else false + } } } } \ 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 new file mode 100644 index 0000000..c9b8c05 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt @@ -0,0 +1,12 @@ +package ru.myitschool.work.ui.screen.book + +sealed interface BookIntent { + object Back: BookIntent + object LoadBooking: BookIntent + object Book : 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 new file mode 100644 index 0000000..2fae136 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt @@ -0,0 +1,424 @@ + package ru.myitschool.work.ui.screen.book + + 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 + import ru.myitschool.work.core.TestIds.Book + import ru.myitschool.work.core.TestIds.Main + 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.formatDate + import ru.myitschool.work.ui.BaseButton + import ru.myitschool.work.ui.BaseNoBackgroundButton + 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.screen.main.MainIntent + 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 + + @Composable + fun BookScreen( + navController: NavController, + viewModel: BookViewModel = viewModel(), + ) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.actionFlow.collect { action -> + when(action) { + is BookAction.Auth -> navController.navigate(AuthScreenDestination) + + is BookAction.Main -> navController.navigate(MainScreenDestination) + } + } + } + + 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 + ) + } + } + } + + @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) + ) { + + 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 + ) + } + } + } + + @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) + ) + BaseNoBackgroundButton( + 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(13.dp) + ) { + 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(), + icon = { Image( + painter = painterResource(R.drawable.add_icon), + contentDescription = stringResource(R.string.add_icon_description) + ) } + ) + } + } + } + } + + @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 { + places.forEachIndexed { index, placeInfo -> + BookPlaceListElement( + placeInfo = placeInfo, + isSelected = placeInfo.id == selectedPlaceId, + onPlaceSelected = { onPlaceSelected(placeInfo.id, placeInfo.place) }, + index = index + ) + } + } + } + } + + @Composable + fun BookPlaceListElement( + placeInfo: PlaceInfo, + isSelected: Boolean, + onPlaceSelected: () -> Unit, + index: Int + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = isSelected, + onClick = onPlaceSelected + ) + .testTag(Book.getIdPlaceItemByPosition(index)) + .padding(vertical = 12.dp, horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + 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) + ) + } + } + } + } + + @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 new file mode 100644 index 0000000..856dccf --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt @@ -0,0 +1,15 @@ +package ru.myitschool.work.ui.screen.book + +import ru.myitschool.work.domain.book.entities.BookingEntity + +sealed interface BookState { + object Loading: BookState + data class Data( + 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 new file mode 100644 index 0000000..84dd63f --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt @@ -0,0 +1,165 @@ +package ru.myitschool.work.ui.screen.book + +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.App +import ru.myitschool.work.data.repo.BookRepository +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(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 { + 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) { + is BookIntent.LoadBooking -> loadBooking() + + is BookIntent.Back -> { + viewModelScope.launch(Dispatchers.Default) { + _actionFlow.emit(BookAction.Main) + } + } + + is BookIntent.Book -> bookSelectedPlace() + + is BookIntent.SelectDate -> { + 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 + ) + } + } + } + + is BookIntent.SelectPlace -> { + val currentState = _uiState.value + if (currentState is BookState.Data) { + _uiState.update { + currentState.copy( + selectedPlaceId = intent.placeId, + selectedPlaceName = intent.placeName + ) + } + } + } + } + } +} \ 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 new file mode 100644 index 0000000..c324687 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt @@ -0,0 +1,7 @@ +package ru.myitschool.work.ui.screen.main + +sealed interface 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 new file mode 100644 index 0000000..68f24fb --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt @@ -0,0 +1,307 @@ +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.foundation.shape.RoundedCornerShape +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.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.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 ru.myitschool.work.R +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.BaseNoBackgroundButton +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( + navController: NavController, + viewModel: MainViewModel = viewModel() +) { + + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.actionFlow.collect { action -> + when(action) { + is MainAction.Auth -> navController.navigate(AuthScreenDestination) + + is MainAction.Booking -> navController.navigate(BookScreenDestination) + } + } + } + + when(state) { + is MainState.Loading -> { + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + CircularProgressIndicator( + modifier = Modifier.size(64.dp) + ) + } + } + is MainState.Error -> { + ErrorContent(viewModel) + } + is MainState.Data -> { + DataContent( + viewModel, + userData = (state as MainState.Data).userData + ) + } + } +} + +@Composable +fun ErrorContent(viewModel: MainViewModel){ + 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.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() + ) { + BaseNoBackgroundButton( + text = stringResource(R.string.main_update), + onClick = { viewModel.onIntent(MainIntent.LoadData) }, + modifier = Modifier.testTag(Main.REFRESH_BUTTON) + ) + BaseNoBackgroundButton( + 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.avatar) + ), + 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(), + icon = {Image( + painter = painterResource(R.drawable.add_icon), + contentDescription = stringResource(R.string.add_icon_description) + )} + + ) + } + } +} + +@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 new file mode 100644 index 0000000..285be1f --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt @@ -0,0 +1,9 @@ +package ru.myitschool.work.ui.screen.main + +import ru.myitschool.work.domain.main.entities.UserEntity + +sealed interface MainState { + 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 new file mode 100644 index 0000000..381d3a9 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt @@ -0,0 +1,82 @@ +package ru.myitschool.work.ui.screen.main + +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.App +import ru.myitschool.work.data.repo.MainRepository +import ru.myitschool.work.domain.main.LoadDataUseCase + +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 + + init { + loadData() + } + + private fun loadData() { + viewModelScope.launch(Dispatchers.IO) { + _uiState.update { MainState.Loading } + + try { + val userCode = dataStoreManager.getUserCode().first() + + 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 } + } + } + } + + 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) + } + } + } + } +} \ 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..4ef268d 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,34 @@ 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 MontserratFontFamily = FontFamily( + Font(R.font.montserrat_bold, FontWeight.Bold), + Font(R.font.montserrat_medium, FontWeight.Medium), + Font(R.font.montserrat_semibold, weight = FontWeight.SemiBold) +) + val Typography = Typography( + bodySmall = TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.Medium, + ), + bodyMedium = TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold, + ), bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) + fontFamily = MontserratFontFamily, + 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/drawable/add_icon.xml b/app/src/main/res/drawable/add_icon.xml new file mode 100644 index 0000000..3a2acfa --- /dev/null +++ b/app/src/main/res/drawable/add_icon.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/avatar.xml b/app/src/main/res/drawable/avatar.xml new file mode 100644 index 0000000..98811f3 --- /dev/null +++ b/app/src/main/res/drawable/avatar.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/logo.xml b/app/src/main/res/drawable/logo.xml new file mode 100644 index 0000000..1f187f8 --- /dev/null +++ b/app/src/main/res/drawable/logo.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/font/montserrat_bold.ttf b/app/src/main/res/font/montserrat_bold.ttf new file mode 100644 index 0000000..4033587 Binary files /dev/null and b/app/src/main/res/font/montserrat_bold.ttf differ diff --git a/app/src/main/res/font/montserrat_medium.ttf b/app/src/main/res/font/montserrat_medium.ttf new file mode 100644 index 0000000..c9a39ea Binary files /dev/null and b/app/src/main/res/font/montserrat_medium.ttf differ diff --git a/app/src/main/res/font/montserrat_semibold.ttf b/app/src/main/res/font/montserrat_semibold.ttf new file mode 100644 index 0000000..161477a Binary files /dev/null and b/app/src/main/res/font/montserrat_semibold.ttf differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fa8bda6..d59bbf0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,7 +1,21 @@ Work RootActivity - Привет! Введи код для авторизации + Введите код для авторизации Код Войти + Обновить + Выйти + Фото пользователя + Ваши забронированные места + Бронировать + Иконка добавления + Ошибка загрузки данных + Нет бронирований + Новая встреча + Назад + Доступные даты + Выберите место встречи + Всё забронировано + Ошибка сервера \ No newline at end of file