NtoTeamTask #16
@@ -47,5 +47,21 @@ dependencies {
|
|||||||
implementation("io.ktor:ktor-client-cio:$ktor")
|
implementation("io.ktor:ktor-client-cio:$ktor")
|
||||||
implementation("io.ktor:ktor-client-content-negotiation:$ktor")
|
implementation("io.ktor:ktor-client-content-negotiation:$ktor")
|
||||||
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor")
|
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor")
|
||||||
|
implementation("io.coil-kt.coil3:coil-compose-core:$coil")
|
||||||
|
|
||||||
|
implementation("io.ktor:ktor-client-okhttp:$ktor")
|
||||||
|
implementation("io.ktor:ktor-client-logging:$ktor")
|
||||||
|
implementation("io.ktor:ktor-client-auth:$ktor")
|
||||||
|
|
||||||
|
implementation("androidx.compose.material:material-icons-extended:1.6.3")
|
||||||
|
|
||||||
|
implementation(platform("io.insert-koin:koin-bom:4.0.0"))
|
||||||
|
implementation("io.insert-koin:koin-core")
|
||||||
|
implementation("io.insert-koin:koin-android")
|
||||||
|
implementation("io.insert-koin:koin-compose")
|
||||||
|
implementation("io.insert-koin:koin-androidx-compose")
|
||||||
|
|
||||||
|
implementation(platform("io.coil-kt.coil3:coil-bom:3.0.0"))
|
||||||
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".App"
|
android:name=".Application"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
@@ -16,10 +16,10 @@
|
|||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.root.RootActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:label="@string/title_activity_root">
|
android:label="@string/app_name">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
package ru.myitschool.work
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.Context
|
|
||||||
|
|
||||||
class App: Application() {
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
context = this
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
lateinit var context: Context
|
|
||||||
}
|
|
||||||
}
|
|
||||||
16
app/src/main/java/ru/myitschool/work/Application.kt
Normal file
16
app/src/main/java/ru/myitschool/work/Application.kt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package ru.myitschool.work
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import ru.myitschool.work.comp.di.appModule
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
|
||||||
|
class Application : Application(){
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
startKoin {
|
||||||
|
androidContext(this@Application)
|
||||||
|
modules(appModule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/src/main/java/ru/myitschool/work/MainActivity.kt
Normal file
19
app/src/main/java/ru/myitschool/work/MainActivity.kt
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package ru.myitschool.work
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import ru.myitschool.work.ui.theme.WorkTheme
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
WorkTheme {
|
||||||
|
AppNavHost()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/src/main/java/ru/myitschool/work/NavigationGraph.kt
Normal file
83
app/src/main/java/ru/myitschool/work/NavigationGraph.kt
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package ru.myitschool.work
|
||||||
|
|
||||||
|
import androidx.compose.animation.EnterTransition
|
||||||
|
import androidx.compose.animation.ExitTransition
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import ru.myitschool.work.comp.presentation.auth.AuthScreenRoot
|
||||||
|
import ru.myitschool.work.comp.presentation.bookingScreen.BookingScreenRoot
|
||||||
|
import ru.myitschool.work.comp.presentation.main.MainScreenRoot
|
||||||
|
import ru.myitschool.work.comp.domain.nav.AuthScreenDestination
|
||||||
|
import ru.myitschool.work.comp.domain.nav.BookScreenDestination
|
||||||
|
import ru.myitschool.work.comp.domain.nav.MainScreenDestination
|
||||||
|
import ru.myitschool.work.comp.domain.nav.SplashScreenDestination
|
||||||
|
import com.example.result.comp.presentation.splash.SplashScreen
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppNavHost(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
navController: NavHostController = rememberNavController()
|
||||||
|
) {
|
||||||
|
NavHost(
|
||||||
|
modifier = modifier,
|
||||||
|
enterTransition = { EnterTransition.None },
|
||||||
|
exitTransition = { ExitTransition.None },
|
||||||
|
navController = navController,
|
||||||
|
startDestination = SplashScreenDestination,
|
||||||
|
) {
|
||||||
|
composable<SplashScreenDestination> {
|
||||||
|
SplashScreen(
|
||||||
|
onNavigateToMainScreen = {
|
||||||
|
navController.navigate(MainScreenDestination){
|
||||||
|
popUpTo(SplashScreenDestination){
|
||||||
|
inclusive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigateToAuthScreen = {
|
||||||
|
navController.navigate(AuthScreenDestination){
|
||||||
|
popUpTo(SplashScreenDestination){
|
||||||
|
inclusive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable<AuthScreenDestination> {
|
||||||
|
AuthScreenRoot(
|
||||||
|
onNavigateToMainScreen = {
|
||||||
|
navController.navigate(MainScreenDestination){
|
||||||
|
popUpTo(AuthScreenDestination){
|
||||||
|
inclusive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable<MainScreenDestination> {
|
||||||
|
MainScreenRoot(
|
||||||
|
onNavigateToAuthScreen = {
|
||||||
|
navController.navigate(AuthScreenDestination){
|
||||||
|
popUpTo(MainScreenDestination){
|
||||||
|
inclusive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigateToBookingScreen = {
|
||||||
|
navController.navigate(BookScreenDestination)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable<BookScreenDestination> {
|
||||||
|
BookingScreenRoot(
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package ru.myitschool.work.comp.data.dtos
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BookDto(
|
||||||
|
val date : String,
|
||||||
|
val placeId : Int
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package ru.myitschool.work.comp.data.dtos
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PlaceDto(
|
||||||
|
val id: Int,
|
||||||
|
val place: String
|
||||||
|
)
|
||||||
|
|
||||||
|
typealias BookingsResponse = Map<String, List<PlaceDto>>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package ru.myitschool.work.comp.data.dtos
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GetInfoDto(
|
||||||
|
val photoUrl : String,
|
||||||
|
val booking : Map<String, BookingDto>,
|
||||||
|
val name : String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BookingDto(
|
||||||
|
val id: Int,
|
||||||
|
val place: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package ru.myitschool.work.comp.data.mappers
|
||||||
|
|
||||||
|
import ru.myitschool.work.comp.domain.model.BookModel
|
||||||
|
import ru.myitschool.work.comp.data.dtos.BookDto
|
||||||
|
|
||||||
|
|
||||||
|
fun BookModel.toDto(): BookDto {
|
||||||
|
return BookDto(
|
||||||
|
date = date,
|
||||||
|
placeId = placeId
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package ru.myitschool.work.comp.data.mappers
|
||||||
|
|
||||||
|
|
||||||
|
import ru.myitschool.work.comp.domain.model.GetBookingsModel
|
||||||
|
import ru.myitschool.work.comp.domain.model.PlaceModel
|
||||||
|
import ru.myitschool.work.comp.data.dtos.PlaceDto
|
||||||
|
|
||||||
|
fun Map<String, List<PlaceDto>>.toModel(): GetBookingsModel {
|
||||||
|
return GetBookingsModel(
|
||||||
|
bookings = this.mapValues { (_, places) ->
|
||||||
|
places.map { placeDto ->
|
||||||
|
PlaceModel(
|
||||||
|
id = placeDto.id,
|
||||||
|
place = placeDto.place
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package ru.myitschool.work.comp.data.mappers
|
||||||
|
|
||||||
|
import ru.myitschool.work.comp.data.dtos.BookingDto
|
||||||
|
import ru.myitschool.work.comp.data.dtos.GetInfoDto
|
||||||
|
import ru.myitschool.work.comp.domain.model.BookingModel
|
||||||
|
import ru.myitschool.work.comp.domain.model.GetInfoModel
|
||||||
|
|
||||||
|
fun GetInfoDto.toModel(): GetInfoModel {
|
||||||
|
return GetInfoModel(
|
||||||
|
photoUrl = photoUrl,
|
||||||
|
booking = booking.map { (date, bookingItem) ->
|
||||||
|
bookingItem.toModel(date)
|
||||||
|
}.toList(),
|
||||||
|
name = name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun BookingDto.toModel(date: String): BookingModel {
|
||||||
|
return BookingModel(
|
||||||
|
id = id,
|
||||||
|
date = date,
|
||||||
|
place = place
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package ru.myitschool.work.comp.data.repository
|
||||||
|
|
||||||
|
import ru.myitschool.work.comp.data.dtos.BookDto
|
||||||
|
import ru.myitschool.work.comp.data.dtos.GetInfoDto
|
||||||
|
import ru.myitschool.work.comp.data.dtos.PlaceDto
|
||||||
|
import ru.myitschool.work.comp.domain.repository.AppRepository
|
||||||
|
import ru.myitschool.work.core.auth.CodeProvider
|
||||||
|
import ru.myitschool.work.core.data.safeCall
|
||||||
|
import ru.myitschool.work.core.domain.Constants
|
||||||
|
import ru.myitschool.work.core.domain.DataError
|
||||||
|
import ru.myitschool.work.core.domain.Result
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.client.statement.HttpResponse
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
|
||||||
|
class AppRepositoryImpl(
|
||||||
|
private val httpClient: HttpClient,
|
||||||
|
private val codeProvider: CodeProvider
|
||||||
|
) : AppRepository {
|
||||||
|
private val code: String?
|
||||||
|
get() = codeProvider.getCode()
|
||||||
|
|
||||||
|
override suspend fun auth(codeToSend: String): Result<Unit, DataError.Remote> {
|
||||||
|
return try {
|
||||||
|
val response: HttpResponse = httpClient.get(
|
||||||
|
urlString = "/api/" + codeToSend + Constants.AUTH_URL
|
||||||
|
)
|
||||||
|
|
||||||
|
when (response.status.value) {
|
||||||
|
200 -> {
|
||||||
|
Result.Success(Unit)
|
||||||
|
}
|
||||||
|
401 -> {
|
||||||
|
Result.Error(DataError.Remote.UNAUTHORIZED)
|
||||||
|
}
|
||||||
|
400 -> {
|
||||||
|
Result.Error(DataError.Remote.BAD_REQUEST)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Result.Error(DataError.Remote.UNKNOWN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
Result.Error(DataError.Remote.NETWORK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getInfo(): Result<GetInfoDto, DataError.Remote> {
|
||||||
|
return safeCall<GetInfoDto> {
|
||||||
|
httpClient.get(
|
||||||
|
urlString = code + Constants.INFO_URL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getBooking(): Result<Map<String, List<PlaceDto>>, DataError.Remote> {
|
||||||
|
return safeCall<Map<String, List<PlaceDto>>> {
|
||||||
|
httpClient.get(
|
||||||
|
urlString = code + Constants.BOOKING_URL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun book(bookDto: BookDto): Result<Unit, DataError.Remote> {
|
||||||
|
return safeCall<Unit> {
|
||||||
|
httpClient.post(
|
||||||
|
urlString = code + Constants.BOOK_URL
|
||||||
|
) {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(bookDto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/src/main/java/ru/myitschool/work/comp/di/AppModule.kt
Normal file
56
app/src/main/java/ru/myitschool/work/comp/di/AppModule.kt
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package ru.myitschool.work.comp.di
|
||||||
|
|
||||||
|
import android.content.Context.MODE_PRIVATE
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import ru.myitschool.work.comp.data.repository.AppRepositoryImpl
|
||||||
|
import ru.myitschool.work.comp.domain.repository.AppRepository
|
||||||
|
import ru.myitschool.work.core.auth.SharedPrefCodeProvider
|
||||||
|
import ru.myitschool.work.core.data.HttpClientFactory
|
||||||
|
import ru.myitschool.work.comp.domain.use_case.AppUseCases
|
||||||
|
import ru.myitschool.work.comp.domain.use_case.AuthUseCase
|
||||||
|
import ru.myitschool.work.comp.domain.use_case.BookUseCase
|
||||||
|
import ru.myitschool.work.comp.domain.use_case.GetBookingUseCase
|
||||||
|
import ru.myitschool.work.comp.domain.use_case.GetInfoUseCase
|
||||||
|
import ru.myitschool.work.comp.presentation.auth.AuthViewModel
|
||||||
|
import ru.myitschool.work.comp.presentation.bookingScreen.BookingViewModel
|
||||||
|
import ru.myitschool.work.comp.presentation.main.MainScreenViewModel
|
||||||
|
import ru.myitschool.work.comp.presentation.splash.SplashScreenViewModel
|
||||||
|
import ru.myitschool.work.core.auth.CodeProvider
|
||||||
|
import ru.myitschool.work.core.domain.validation.ValidateCode
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.core.module.dsl.viewModelOf
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val appModule = module {
|
||||||
|
single<SharedPreferences> {
|
||||||
|
androidContext().getSharedPreferences("code", MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
single<CodeProvider>{SharedPrefCodeProvider(get())}
|
||||||
|
|
||||||
|
single { HttpClientFactory.create() }
|
||||||
|
|
||||||
|
single { ValidateCode() }
|
||||||
|
|
||||||
|
single<AppRepository> { AppRepositoryImpl(get(), get()) }
|
||||||
|
|
||||||
|
single { AuthUseCase(get()) }
|
||||||
|
single { BookUseCase(get()) }
|
||||||
|
single { GetBookingUseCase(get()) }
|
||||||
|
single { GetInfoUseCase(get()) }
|
||||||
|
|
||||||
|
single {
|
||||||
|
AppUseCases(
|
||||||
|
authUseCase = AuthUseCase(get()),
|
||||||
|
bookUseCase = BookUseCase(get()),
|
||||||
|
getBookingUseCase = GetBookingUseCase(get()),
|
||||||
|
getInfoUseCase = GetInfoUseCase(get())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelOf(::SplashScreenViewModel)
|
||||||
|
viewModelOf(::AuthViewModel)
|
||||||
|
viewModelOf(::MainScreenViewModel)
|
||||||
|
viewModelOf(::BookingViewModel)
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package ru.myitschool.work.comp.domain.model
|
||||||
|
|
||||||
|
data class BookModel(
|
||||||
|
val date : String,
|
||||||
|
val placeId : Int
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package ru.myitschool.work.comp.domain.model
|
||||||
|
|
||||||
|
|
||||||
|
data class GetBookingsModel(
|
||||||
|
val bookings: Map<String, List<PlaceModel>>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PlaceModel(
|
||||||
|
val id: Int,
|
||||||
|
val place: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package ru.myitschool.work.comp.domain.model
|
||||||
|
|
||||||
|
data class GetInfoModel(
|
||||||
|
val photoUrl: String,
|
||||||
|
val booking: List<BookingModel>,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BookingModel(
|
||||||
|
val id: Int,
|
||||||
|
val date: String,
|
||||||
|
val place: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package ru.myitschool.work.comp.domain.nav
|
||||||
|
|
||||||
|
sealed interface AppDestination
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package ru.myitschool.work.comp.domain.nav
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object AuthScreenDestination: AppDestination
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package ru.myitschool.work.comp.domain.nav
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object BookScreenDestination: AppDestination
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package ru.myitschool.work.comp.domain.nav
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object MainScreenDestination: AppDestination
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package ru.myitschool.work.comp.domain.nav
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object SplashScreenDestination : AppDestination
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package ru.myitschool.work.comp.domain.repository
|
||||||
|
|
||||||
|
import ru.myitschool.work.comp.data.dtos.BookDto
|
||||||
|
import ru.myitschool.work.comp.data.dtos.GetInfoDto
|
||||||
|
import ru.myitschool.work.comp.data.dtos.PlaceDto
|
||||||
|
import ru.myitschool.work.core.domain.DataError
|
||||||
|
import ru.myitschool.work.core.domain.Result
|
||||||
|
|
||||||
|
interface AppRepository {
|
||||||
|
suspend fun auth(codeToSend: String): Result<Unit, DataError.Remote>
|
||||||
|
suspend fun getInfo(): Result<GetInfoDto, DataError.Remote>
|
||||||
|
suspend fun getBooking(): Result<Map<String, List<PlaceDto>>, DataError.Remote>
|
||||||
|
suspend fun book(bookDto: BookDto): Result<Unit, DataError.Remote>
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package ru.myitschool.work.comp.domain.use_case
|
||||||
|
|
||||||
|
data class AppUseCases(
|
||||||
|
val authUseCase: AuthUseCase,
|
||||||
|
val bookUseCase: BookUseCase,
|
||||||
|
val getBookingUseCase: GetBookingUseCase,
|
||||||
|
val getInfoUseCase: GetInfoUseCase
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package ru.myitschool.work.comp.domain.use_case
|
||||||
|
import ru.myitschool.work.comp.domain.repository.AppRepository
|
||||||
|
import ru.myitschool.work.core.domain.DataError
|
||||||
|
import ru.myitschool.work.core.domain.Result
|
||||||
|
|
||||||
|
class AuthUseCase(
|
||||||
|
private val repository: AppRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(codeToSend: String): Result<Unit, DataError.Remote> {
|
||||||
|
return repository.auth(codeToSend)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package ru.myitschool.work.comp.domain.use_case
|
||||||
|
|
||||||
|
import ru.myitschool.work.comp.data.mappers.toDto
|
||||||
|
import ru.myitschool.work.comp.domain.model.BookModel
|
||||||
|
import ru.myitschool.work.comp.domain.repository.AppRepository
|
||||||
|
import ru.myitschool.work.core.domain.DataError
|
||||||
|
import ru.myitschool.work.core.domain.Result
|
||||||
|
|
||||||
|
class BookUseCase(
|
||||||
|
val repository: AppRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(bookModel: BookModel) : Result<Unit, DataError.Remote> {
|
||||||
|
return repository
|
||||||
|
.book(bookModel.toDto())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package ru.myitschool.work.comp.domain.use_case
|
||||||
|
|
||||||
|
import ru.myitschool.work.comp.data.dtos.PlaceDto
|
||||||
|
import ru.myitschool.work.comp.domain.model.GetBookingsModel
|
||||||
|
import ru.myitschool.work.comp.domain.model.PlaceModel
|
||||||
|
import ru.myitschool.work.comp.domain.repository.AppRepository
|
||||||
|
import ru.myitschool.work.core.domain.DataError
|
||||||
|
import ru.myitschool.work.core.domain.Result
|
||||||
|
import ru.myitschool.work.core.domain.map
|
||||||
|
|
||||||
|
class GetBookingUseCase(
|
||||||
|
private val repository: AppRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(): Result<GetBookingsModel, DataError.Remote> {
|
||||||
|
return repository
|
||||||
|
.getBooking()
|
||||||
|
.map { bookingsMap: Map<String, List<PlaceDto>> ->
|
||||||
|
convertToModel(bookingsMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun convertToModel(bookingsMap: Map<String, List<PlaceDto>>): GetBookingsModel {
|
||||||
|
return GetBookingsModel(
|
||||||
|
bookings = bookingsMap.mapValues { (_, places) ->
|
||||||
|
places.map { placeDto ->
|
||||||
|
PlaceModel(
|
||||||
|
id = placeDto.id,
|
||||||
|
place = placeDto.place
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package ru.myitschool.work.comp.domain.use_case
|
||||||
|
|
||||||
|
import ru.myitschool.work.comp.data.mappers.toModel
|
||||||
|
import ru.myitschool.work.comp.domain.model.GetInfoModel
|
||||||
|
import ru.myitschool.work.comp.domain.repository.AppRepository
|
||||||
|
import ru.myitschool.work.core.domain.DataError
|
||||||
|
import ru.myitschool.work.core.domain.Result
|
||||||
|
import ru.myitschool.work.core.domain.map
|
||||||
|
|
||||||
|
class GetInfoUseCase(
|
||||||
|
private val repository: AppRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke() : Result<GetInfoModel, DataError.Remote> {
|
||||||
|
return repository
|
||||||
|
.getInfo()
|
||||||
|
.map { response ->
|
||||||
|
response.toModel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package ru.myitschool.work.comp.presentation.auth
|
||||||
|
|
||||||
|
interface AuthEvent {
|
||||||
|
data class CodeChanged(val code : String) : AuthEvent
|
||||||
|
object Submit : AuthEvent
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package ru.myitschool.work.comp.presentation.auth
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
|
import androidx.compose.foundation.gestures.rememberScrollableState
|
||||||
|
import androidx.compose.foundation.gestures.scrollable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.sizeIn
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import ru.myitschool.work.comp.presentation.auth.component.CustomTextField
|
||||||
|
import ru.myitschool.work.core.domain.TestIds
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
import ru.myitschool.work.ui.RememberWindowInfo
|
||||||
|
import ru.myitschool.work.ui.WindowInfo
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AuthScreenRoot(
|
||||||
|
viewModel: AuthViewModel = koinViewModel(),
|
||||||
|
onNavigateToMainScreen : () -> Unit
|
||||||
|
) {
|
||||||
|
val windowInfo = RememberWindowInfo()
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.uiEvent.collectLatest { event ->
|
||||||
|
when(event){
|
||||||
|
UiEvent.Success -> {
|
||||||
|
onNavigateToMainScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when{
|
||||||
|
state.isLoading -> {
|
||||||
|
LoadingScreen()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
if (windowInfo.screenWidthInfo is WindowInfo.WindowType.Compact){
|
||||||
|
AuthScreen(
|
||||||
|
state = state,
|
||||||
|
onEvent = { event ->
|
||||||
|
viewModel.authEvent(event)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else{
|
||||||
|
TabletAuthScreen(
|
||||||
|
state = state,
|
||||||
|
onEvent = { event ->
|
||||||
|
viewModel.authEvent(event)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AuthScreen(
|
||||||
|
state : AuthState,
|
||||||
|
onEvent: (AuthEvent) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.White)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Привет! Введи код для авторизации",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
CustomTextField(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Auth.CODE_INPUT)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
label = "Код",
|
||||||
|
value = state.code,
|
||||||
|
onValueChange = {
|
||||||
|
onEvent(AuthEvent.CodeChanged(it))
|
||||||
|
},
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
error = state.authError
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onEvent(AuthEvent.Submit)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Auth.SIGN_BUTTON)
|
||||||
|
.padding(top = 24.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFF007BFF),
|
||||||
|
disabledContainerColor = Color.Gray
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
enabled = state.isCodeValid
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Войти",
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier.padding(vertical = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Composable
|
||||||
|
fun TabletAuthScreen(
|
||||||
|
state: AuthState,
|
||||||
|
onEvent: (AuthEvent) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.White)
|
||||||
|
.padding(horizontal = 32.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(40.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Привет! Введи код для авторизации",
|
||||||
|
style = MaterialTheme.typography.headlineMedium.copy(fontSize = 28.sp),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(bottom = 32.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.sizeIn(maxWidth = 500.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
CustomTextField(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Auth.CODE_INPUT)
|
||||||
|
.width(500.dp),
|
||||||
|
label = "Код",
|
||||||
|
value = state.code,
|
||||||
|
onValueChange = {
|
||||||
|
onEvent(AuthEvent.CodeChanged(it))
|
||||||
|
},
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
error = state.authError,
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onEvent(AuthEvent.Submit)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Auth.SIGN_BUTTON)
|
||||||
|
.padding(top = 24.dp)
|
||||||
|
.width(200.dp)
|
||||||
|
.heightIn(min = 48.dp, max = 56.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFF007BFF),
|
||||||
|
disabledContainerColor = Color.Gray
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
enabled = state.isCodeValid
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Войти",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(40.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoadingScreen () {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(64.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package ru.myitschool.work.comp.presentation.auth
|
||||||
|
|
||||||
|
data class AuthState(
|
||||||
|
val code : String = "",
|
||||||
|
val isLoading : Boolean = false,
|
||||||
|
val authError : String? = null,
|
||||||
|
val isCodeValid : Boolean = false
|
||||||
|
)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package ru.myitschool.work.comp.presentation.auth
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import ru.myitschool.work.comp.domain.use_case.AppUseCases
|
||||||
|
import ru.myitschool.work.core.auth.CodeProvider
|
||||||
|
import ru.myitschool.work.core.domain.onError
|
||||||
|
import ru.myitschool.work.core.domain.onSuccess
|
||||||
|
import ru.myitschool.work.core.domain.validation.ValidateCode
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class AuthViewModel(
|
||||||
|
private val codeProvider: CodeProvider,
|
||||||
|
private val appUseCases: AppUseCases,
|
||||||
|
private val validateCode: ValidateCode,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(AuthState())
|
||||||
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _uiEvent = MutableSharedFlow<UiEvent>()
|
||||||
|
val uiEvent = _uiEvent.asSharedFlow()
|
||||||
|
|
||||||
|
fun authEvent(event: AuthEvent){
|
||||||
|
when(event){
|
||||||
|
is AuthEvent.CodeChanged -> {
|
||||||
|
codeChanged(code = event.code)
|
||||||
|
}
|
||||||
|
is AuthEvent.Submit -> {
|
||||||
|
submit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun codeChanged(code : String){
|
||||||
|
_state.update {
|
||||||
|
it.copy(code = code, authError = null)
|
||||||
|
}
|
||||||
|
val result = validateCode.execute(code)
|
||||||
|
if (!result.success){
|
||||||
|
_state.update {
|
||||||
|
it.copy(isCodeValid = false)
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
_state.update {
|
||||||
|
it.copy(isCodeValid = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun submit(){
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update {
|
||||||
|
it.copy(isLoading = true)
|
||||||
|
}
|
||||||
|
appUseCases.authUseCase
|
||||||
|
.invoke(codeToSend = _state.value.code)
|
||||||
|
.onSuccess {
|
||||||
|
codeProvider.saveCode(code = _state.value.code)
|
||||||
|
_uiEvent.emit(UiEvent.Success)
|
||||||
|
_state.update {
|
||||||
|
it.copy(isLoading = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onError {
|
||||||
|
_state.update {
|
||||||
|
it.copy(isLoading = false, authError = it.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface UiEvent{
|
||||||
|
object Success : UiEvent
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package ru.myitschool.work.comp.presentation.auth.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme.shapes
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import ru.myitschool.work.core.domain.TestIds
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CustomTextField(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
isPassword: Boolean = false,
|
||||||
|
keyboardType: KeyboardType,
|
||||||
|
error: String?
|
||||||
|
){
|
||||||
|
var isPasswordVisible by remember { mutableStateOf(false) }
|
||||||
|
Column {
|
||||||
|
OutlinedTextField(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(horizontal = 8.dp, vertical = 12.dp),
|
||||||
|
shape = shapes.large,
|
||||||
|
isError = error != null,
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
unfocusedLabelColor = Color(0xFF61758A),
|
||||||
|
unfocusedContainerColor = Color(0xFFF0F2F5),
|
||||||
|
unfocusedBorderColor = Color(0xFF61758A),
|
||||||
|
unfocusedTextColor = Color(0xFF61758A),
|
||||||
|
focusedLabelColor = Color(0xFF61758A),
|
||||||
|
focusedContainerColor = Color(0xFFF0F2F5),
|
||||||
|
focusedBorderColor = Color(0xFF61758A),
|
||||||
|
focusedTextColor = Color(0xFF61758A),
|
||||||
|
),
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = label
|
||||||
|
)
|
||||||
|
},
|
||||||
|
visualTransformation = if(isPassword && !isPasswordVisible) PasswordVisualTransformation() else VisualTransformation.None,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = keyboardType
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
if(isPassword) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
isPasswordVisible = !isPasswordVisible
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if(isPasswordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
error?.let {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Auth.ERROR)
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
text = it,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
color = Color(0xFFb73028)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
package ru.myitschool.work.comp.presentation.bookingScreen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
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.semantics.Role
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import ru.myitschool.work.comp.domain.model.PlaceModel
|
||||||
|
import ru.myitschool.work.comp.presentation.auth.LoadingScreen
|
||||||
|
import ru.myitschool.work.core.domain.TestIds
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
import ru.myitschool.work.ui.RememberWindowInfo
|
||||||
|
import ru.myitschool.work.ui.WindowInfo
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BookingScreenRoot(
|
||||||
|
viewModel: BookingViewModel = koinViewModel(),
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val windowInfo = RememberWindowInfo()
|
||||||
|
val selectedDate = state.selectedDateKey
|
||||||
|
|
||||||
|
when {
|
||||||
|
state.isLoading -> {
|
||||||
|
LoadingScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
state.isError -> {
|
||||||
|
ErrorState(
|
||||||
|
onRefresh = {
|
||||||
|
viewModel.onEvent(BookingScreenEvent.RefreshData)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.isEmpty -> {
|
||||||
|
EmptyState()
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
if (windowInfo.screenWidthInfo is WindowInfo.WindowType.Compact){
|
||||||
|
BookingScreen(
|
||||||
|
state = state,
|
||||||
|
selectedDateKey = selectedDate,
|
||||||
|
onDateSelected = { dateKey ->
|
||||||
|
viewModel.onEvent(BookingScreenEvent.SelectDate(dateKey))
|
||||||
|
},
|
||||||
|
onPlaceSelected = { place ->
|
||||||
|
viewModel.onEvent(BookingScreenEvent.SelectPlace(place))
|
||||||
|
},
|
||||||
|
onNavigateBack = onNavigateBack,
|
||||||
|
onEvent = { event ->
|
||||||
|
viewModel.onEvent(event)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else{
|
||||||
|
BookingScreen(
|
||||||
|
state = state,
|
||||||
|
selectedDateKey = selectedDate,
|
||||||
|
onDateSelected = { dateKey ->
|
||||||
|
viewModel.onEvent(BookingScreenEvent.SelectDate(dateKey))
|
||||||
|
},
|
||||||
|
onPlaceSelected = { place ->
|
||||||
|
viewModel.onEvent(BookingScreenEvent.SelectPlace(place))
|
||||||
|
},
|
||||||
|
onNavigateBack = onNavigateBack,
|
||||||
|
onEvent = { event ->
|
||||||
|
viewModel.onEvent(event)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun BookingScreen(
|
||||||
|
state: BookingState,
|
||||||
|
selectedDateKey: String?,
|
||||||
|
onDateSelected: (String) -> Unit,
|
||||||
|
onPlaceSelected: (PlaceModel) -> Unit,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onEvent: (BookingScreenEvent) -> Unit
|
||||||
|
) {
|
||||||
|
val bookingsMap = state.bookings
|
||||||
|
val selectedPlaces = if (selectedDateKey != null) bookingsMap[selectedDateKey] else emptyList()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text("Бронирование",
|
||||||
|
color = Color.Black
|
||||||
|
) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Book.BACK_BUTTON),
|
||||||
|
onClick = onNavigateBack,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.Black
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = Color.White
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
if (!state.isEmpty && !state.isError && bookingsMap.isNotEmpty()) {
|
||||||
|
BookingBottomBar(
|
||||||
|
state = state,
|
||||||
|
onEvent = { onEvent(BookingScreenEvent.Book) },
|
||||||
|
onNavigateBack = onNavigateBack
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.background(Color.White),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
if (bookingsMap.isNotEmpty()) {
|
||||||
|
DateTabs(
|
||||||
|
dateKeys = bookingsMap.keys.toList(),
|
||||||
|
selectedDateKey = selectedDateKey,
|
||||||
|
onDateSelected = onDateSelected
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
if (selectedDateKey != null && selectedPlaces?.isNotEmpty() == true) {
|
||||||
|
PlacesList(
|
||||||
|
places = selectedPlaces,
|
||||||
|
selectedPlace = state.selectedPlace,
|
||||||
|
onPlaceSelected = onPlaceSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun TabletBookingScreen(
|
||||||
|
state: BookingState,
|
||||||
|
selectedDateKey: String?,
|
||||||
|
onDateSelected: (String) -> Unit,
|
||||||
|
onPlaceSelected: (PlaceModel) -> Unit,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onEvent: (BookingScreenEvent) -> Unit
|
||||||
|
) {
|
||||||
|
val bookingsMap = state.bookings
|
||||||
|
val selectedPlaces = if (selectedDateKey != null) bookingsMap[selectedDateKey] else emptyList()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(Color.White),
|
||||||
|
title = { Text("Бронирование") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Book.BACK_BUTTON),
|
||||||
|
onClick = onNavigateBack,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
if (!state.isEmpty && !state.isError && bookingsMap.isNotEmpty()) {
|
||||||
|
BookingBottomBar(
|
||||||
|
state = state,
|
||||||
|
onEvent = { onEvent(BookingScreenEvent.Book) },
|
||||||
|
onNavigateBack = onNavigateBack
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.White),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(700.dp)
|
||||||
|
.height(600.dp)
|
||||||
|
.padding(paddingValues),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
if (bookingsMap.isNotEmpty()) {
|
||||||
|
DateTabs(
|
||||||
|
dateKeys = bookingsMap.keys.toList(),
|
||||||
|
selectedDateKey = selectedDateKey,
|
||||||
|
onDateSelected = onDateSelected
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
if (selectedDateKey != null && selectedPlaces?.isNotEmpty() == true) {
|
||||||
|
PlacesList(
|
||||||
|
places = selectedPlaces,
|
||||||
|
selectedPlace = state.selectedPlace,
|
||||||
|
onPlaceSelected = onPlaceSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DateTabs(
|
||||||
|
dateKeys: List<String>,
|
||||||
|
selectedDateKey: String?,
|
||||||
|
onDateSelected: (String) -> Unit
|
||||||
|
) {
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
itemsIndexed(dateKeys.sorted()) { index, dateKey ->
|
||||||
|
val isSelected = selectedDateKey == dateKey
|
||||||
|
|
||||||
|
ElevatedAssistChip(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Book.getIdDateItemByPosition(index)),
|
||||||
|
onClick = { onDateSelected(dateKey) },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Book.ITEM_DATE),
|
||||||
|
text = formatDate(dateKey),
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
},
|
||||||
|
border = BorderStroke(
|
||||||
|
width = 2.dp,
|
||||||
|
color = if (isSelected) {
|
||||||
|
Color(0xFF007BFF)
|
||||||
|
} else {
|
||||||
|
Color.Transparent
|
||||||
|
}
|
||||||
|
),
|
||||||
|
colors = AssistChipDefaults.assistChipColors(
|
||||||
|
containerColor = Color.White
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlacesList(
|
||||||
|
places: List<PlaceModel>,
|
||||||
|
selectedPlace: PlaceModel?,
|
||||||
|
onPlaceSelected: (PlaceModel) -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.selectableGroup(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
places.forEach { place ->
|
||||||
|
val isSelected = selectedPlace?.id == place.id
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = Color.Transparent,
|
||||||
|
tonalElevation = if (isSelected) 4.dp else 1.dp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectable(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = { onPlaceSelected(place) },
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Book.ITEM_PLACE_TEXT),
|
||||||
|
text = place.place,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
|
||||||
|
RadioButton(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = null,
|
||||||
|
colors = RadioButtonDefaults.colors(
|
||||||
|
selectedColor = Color(0xFF007BFF)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BookingBottomBar(
|
||||||
|
state: BookingState,
|
||||||
|
onEvent: (BookingScreenEvent) -> Unit,
|
||||||
|
onNavigateBack: () -> Unit
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
tonalElevation = 8.dp,
|
||||||
|
color = Color.White
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Book.BOOK_BUTTON),
|
||||||
|
onClick = {
|
||||||
|
onEvent(BookingScreenEvent.Book)
|
||||||
|
onNavigateBack()
|
||||||
|
},
|
||||||
|
enabled = state.selectedPlace != null && !state.isBookingLoading,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFF007BFF)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (state.isBookingLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Забронировать",
|
||||||
|
color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ErrorState(onRefresh: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(20.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Что-то пошло не так",
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.ERROR)
|
||||||
|
.padding(vertical = 6.dp)
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onRefresh()
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.REFRESH_BUTTON)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 24.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF007BFF)),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Попробовать заново",
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier.padding(vertical = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyState() {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Book.EMPTY),
|
||||||
|
text = "Всё забронировано",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDate(dateString: String): String {
|
||||||
|
return try {
|
||||||
|
val inputFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
|
||||||
|
val outputFormat = SimpleDateFormat("dd.MM", Locale.getDefault())
|
||||||
|
val date = inputFormat.parse(dateString)
|
||||||
|
outputFormat.format(date ?: return dateString)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
dateString
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package ru.myitschool.work.comp.presentation.bookingScreen
|
||||||
|
|
||||||
|
import ru.myitschool.work.comp.domain.model.PlaceModel
|
||||||
|
|
||||||
|
sealed class BookingScreenEvent {
|
||||||
|
data object GetData : BookingScreenEvent()
|
||||||
|
data object RefreshData : BookingScreenEvent()
|
||||||
|
data class SelectDate(val date: String) : BookingScreenEvent()
|
||||||
|
data class SelectPlace(val place: PlaceModel) : BookingScreenEvent()
|
||||||
|
data object Book : BookingScreenEvent()
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package ru.myitschool.work.comp.presentation.bookingScreen
|
||||||
|
|
||||||
|
import ru.myitschool.work.comp.domain.model.PlaceModel
|
||||||
|
|
||||||
|
data class BookingState(
|
||||||
|
val bookings: Map<String, List<PlaceModel>> = emptyMap(),
|
||||||
|
val selectedDateKey: String? = null,
|
||||||
|
val selectedPlace: PlaceModel? = null,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isBookingLoading: Boolean = false,
|
||||||
|
val isEmpty: Boolean = false,
|
||||||
|
val isBookingSuccess: Boolean = false,
|
||||||
|
val isError: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package ru.myitschool.work.comp.presentation.bookingScreen
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import ru.myitschool.work.comp.domain.model.BookModel
|
||||||
|
import ru.myitschool.work.comp.domain.use_case.BookUseCase
|
||||||
|
import ru.myitschool.work.comp.domain.use_case.GetBookingUseCase
|
||||||
|
import ru.myitschool.work.core.domain.Result
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class BookingViewModel(
|
||||||
|
private val getBookingUseCase: GetBookingUseCase,
|
||||||
|
private val bookUseCase: BookUseCase
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(BookingState())
|
||||||
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadBookings()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEvent(event: BookingScreenEvent) {
|
||||||
|
when (event) {
|
||||||
|
is BookingScreenEvent.GetData -> {
|
||||||
|
if (!_state.value.isError) {
|
||||||
|
loadBookings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is BookingScreenEvent.RefreshData -> {
|
||||||
|
loadBookings()
|
||||||
|
}
|
||||||
|
is BookingScreenEvent.SelectDate -> {
|
||||||
|
selectDate(event.date)
|
||||||
|
}
|
||||||
|
is BookingScreenEvent.SelectPlace -> {
|
||||||
|
_state.update { currentState ->
|
||||||
|
currentState.copy(
|
||||||
|
selectedPlace = event.place,
|
||||||
|
isError = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is BookingScreenEvent.Book -> {
|
||||||
|
bookSelected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectDate(dateKey: String) {
|
||||||
|
_state.update { currentState ->
|
||||||
|
val placesForDate = currentState.bookings[dateKey]
|
||||||
|
val updatedSelectedPlace = if (currentState.selectedPlace != null &&
|
||||||
|
placesForDate?.contains(currentState.selectedPlace) != true
|
||||||
|
) {
|
||||||
|
placesForDate?.firstOrNull()
|
||||||
|
} else {
|
||||||
|
currentState.selectedPlace ?: placesForDate?.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
currentState.copy(
|
||||||
|
selectedDateKey = dateKey,
|
||||||
|
selectedPlace = updatedSelectedPlace,
|
||||||
|
isError = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadBookings() {
|
||||||
|
_state.update { it.copy(isLoading = true, isError = false) }
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = getBookingUseCase()) {
|
||||||
|
is Result.Success -> {
|
||||||
|
val bookingsModel = result.data
|
||||||
|
val bookingsMap = bookingsModel.bookings
|
||||||
|
val hasAvailableBookings = bookingsMap.any { (_, places) ->
|
||||||
|
places.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAvailableBookings) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
bookings = emptyMap(),
|
||||||
|
selectedDateKey = null,
|
||||||
|
selectedPlace = null,
|
||||||
|
isLoading = false,
|
||||||
|
isEmpty = true,
|
||||||
|
isError = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val sortedDates = bookingsMap.keys.sorted()
|
||||||
|
|
||||||
|
val selectedDateKey = _state.value.selectedDateKey ?: sortedDates.firstOrNull()
|
||||||
|
val selectedPlace = if (selectedDateKey != null) {
|
||||||
|
val currentPlace = _state.value.selectedPlace
|
||||||
|
val placesForDate = bookingsMap[selectedDateKey]
|
||||||
|
|
||||||
|
if (currentPlace != null && placesForDate?.contains(currentPlace) == true) {
|
||||||
|
currentPlace
|
||||||
|
} else {
|
||||||
|
placesForDate?.firstOrNull()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
bookings = bookingsMap,
|
||||||
|
selectedDateKey = selectedDateKey,
|
||||||
|
selectedPlace = selectedPlace,
|
||||||
|
isLoading = false,
|
||||||
|
isEmpty = false,
|
||||||
|
isError = false,
|
||||||
|
isBookingSuccess = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Result.Error -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isError = true,
|
||||||
|
isEmpty = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bookSelected() {
|
||||||
|
val currentState = _state.value
|
||||||
|
val selectedDateKey = currentState.selectedDateKey
|
||||||
|
val selectedPlace = currentState.selectedPlace
|
||||||
|
|
||||||
|
if (selectedDateKey == null || selectedPlace == null) return
|
||||||
|
|
||||||
|
_state.update { it.copy(isBookingLoading = true, isError = false) }
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val bookModel = BookModel(
|
||||||
|
date = selectedDateKey,
|
||||||
|
placeId = selectedPlace.id
|
||||||
|
)
|
||||||
|
|
||||||
|
when (val result = bookUseCase(bookModel)) {
|
||||||
|
is Result.Success -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isBookingLoading = false,
|
||||||
|
isBookingSuccess = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
loadBookings()
|
||||||
|
}
|
||||||
|
is Result.Error -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isBookingLoading = false,
|
||||||
|
isError = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
package ru.myitschool.work.comp.presentation.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.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.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ExitToApp
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.rememberAsyncImagePainter
|
||||||
|
import ru.myitschool.work.comp.presentation.auth.LoadingScreen
|
||||||
|
import ru.myitschool.work.comp.presentation.main.component.BookingItem
|
||||||
|
import ru.myitschool.work.core.domain.TestIds
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
import ru.myitschool.work.ui.RememberWindowInfo
|
||||||
|
import ru.myitschool.work.ui.WindowInfo
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainScreenRoot(
|
||||||
|
viewModel : MainScreenViewModel = koinViewModel(),
|
||||||
|
onNavigateToAuthScreen : () -> Unit,
|
||||||
|
onNavigateToBookingScreen : () -> Unit
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val windowInfo = RememberWindowInfo()
|
||||||
|
when{
|
||||||
|
state.isLoading -> {
|
||||||
|
LoadingScreen()
|
||||||
|
}
|
||||||
|
state.isError ->{
|
||||||
|
if (windowInfo.screenWidthInfo is WindowInfo.WindowType.Compact){
|
||||||
|
ErrorScreen {
|
||||||
|
viewModel.onEvent(MainScreenEvent.Refresh)
|
||||||
|
}
|
||||||
|
} else{
|
||||||
|
TabletErrorScreen {
|
||||||
|
viewModel.onEvent(MainScreenEvent.Refresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
state.shouldNavigateToAuth -> {
|
||||||
|
onNavigateToAuthScreen()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
if (windowInfo.screenWidthInfo is WindowInfo.WindowType.Compact){
|
||||||
|
MainScreen(
|
||||||
|
state = state,
|
||||||
|
onNavigateToAuthScreen = onNavigateToAuthScreen,
|
||||||
|
onNavigateToBookingScreen = onNavigateToBookingScreen,
|
||||||
|
onEvent = {event ->
|
||||||
|
viewModel.onEvent(event)
|
||||||
|
},
|
||||||
|
onRefresh = {viewModel.onEvent(MainScreenEvent.Refresh)}
|
||||||
|
)
|
||||||
|
} else{
|
||||||
|
MainScreen(
|
||||||
|
state = state,
|
||||||
|
onNavigateToAuthScreen = onNavigateToAuthScreen,
|
||||||
|
onNavigateToBookingScreen = onNavigateToBookingScreen,
|
||||||
|
onEvent = {event ->
|
||||||
|
viewModel.onEvent(event)
|
||||||
|
},
|
||||||
|
onRefresh = {viewModel.onEvent(MainScreenEvent.Refresh)}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(
|
||||||
|
state: MainScreenState,
|
||||||
|
onNavigateToAuthScreen : () -> Unit,
|
||||||
|
onNavigateToBookingScreen : () -> Unit,
|
||||||
|
onRefresh : () -> Unit,
|
||||||
|
onEvent : (MainScreenEvent) -> Unit
|
||||||
|
) {
|
||||||
|
val userInfo = state.data
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.ADD_BUTTON),
|
||||||
|
onClick = onNavigateToBookingScreen,
|
||||||
|
containerColor = Color(0xFF007BFF),
|
||||||
|
contentColor = Color.White
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Add,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.background(Color.White)
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color.White)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.PROFILE_IMAGE)
|
||||||
|
.size(80.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFFcfe6ff)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (userInfo?.photoUrl?.isNotEmpty() == true) {
|
||||||
|
Image(painter = rememberAsyncImagePainter(userInfo.photoUrl),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(80.dp)
|
||||||
|
.clip(CircleShape), contentScale = ContentScale.Crop)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Person,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
tint = Color.Black
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = userInfo?.name ?: "Пользователь",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black,
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.PROFILE_NAME)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
|
|
||||||
|
Column{
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
onEvent(MainScreenEvent.Logout)
|
||||||
|
onNavigateToAuthScreen()
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.LOGOUT_BUTTON)
|
||||||
|
.padding(end = 8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ExitToApp,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.Black,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onRefresh()
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.REFRESH_BUTTON)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFF007BFF)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Refresh,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
Text("Обновить Данные",
|
||||||
|
color = Color.White)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "Мои Бронирования",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
if (userInfo?.booking?.isEmpty() == true) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "У вас пока нет бронирований",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val sortedBookings = userInfo?.booking?.sortedBy { it.date } ?: emptyList()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
itemsIndexed(sortedBookings) { index, booking ->
|
||||||
|
BookingItem(
|
||||||
|
booking = booking,
|
||||||
|
index = index,
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.getIdItemByPosition(index))
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun TabletMainScreen(
|
||||||
|
state: MainScreenState,
|
||||||
|
onNavigateToAuthScreen : () -> Unit,
|
||||||
|
onNavigateToBookingScreen : () -> Unit,
|
||||||
|
onRefresh : () -> Unit,
|
||||||
|
onEvent : (MainScreenEvent) -> Unit
|
||||||
|
) {
|
||||||
|
val userInfo = state.data
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.ADD_BUTTON),
|
||||||
|
onClick = onNavigateToBookingScreen,
|
||||||
|
containerColor = Color(0xFF007BFF),
|
||||||
|
contentColor = Color.White
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Add,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.background(Color.White)
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color.White)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.PROFILE_IMAGE)
|
||||||
|
.size(80.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFFcfe6ff)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (userInfo?.photoUrl?.isNotEmpty() == true) {
|
||||||
|
Image(painter = rememberAsyncImagePainter(userInfo.photoUrl),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(80.dp)
|
||||||
|
.clip(CircleShape), contentScale = ContentScale.Crop)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Person,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
tint = Color.Black
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = userInfo?.name ?: "Пользователь",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black,
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.PROFILE_NAME)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(3f))
|
||||||
|
|
||||||
|
Column{
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
onEvent(MainScreenEvent.Logout)
|
||||||
|
onNavigateToAuthScreen()
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.LOGOUT_BUTTON)
|
||||||
|
.padding(end = 8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ExitToApp,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.Black,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onRefresh()
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.REFRESH_BUTTON)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFF007BFF)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Refresh,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
Text("Обновить данные")
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "Мои Бронирования",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Black,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
if (userInfo?.booking?.isEmpty() == true) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "У вас пока нет бронирований",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val sortedBookings = userInfo?.booking?.sortedBy { it.date } ?: emptyList()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
itemsIndexed(sortedBookings) { index, booking ->
|
||||||
|
BookingItem(
|
||||||
|
booking = booking,
|
||||||
|
index = index,
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.getIdItemByPosition(index))
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ErrorScreen(
|
||||||
|
onRetry : () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(20.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Что-то пошло не так",
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.ERROR)
|
||||||
|
.padding(vertical = 6.dp)
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onRetry()
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.REFRESH_BUTTON)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 24.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF007BFF)),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Попробовать заново",
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier.padding(vertical = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TabletErrorScreen(
|
||||||
|
onRetry : () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(20.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Что-то пошло не так",
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.ERROR)
|
||||||
|
.padding(vertical = 6.dp)
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onRetry()
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.REFRESH_BUTTON)
|
||||||
|
.padding(top = 24.dp)
|
||||||
|
.width(400.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF007BFF)),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Попробовать заново",
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier.padding(vertical = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package ru.myitschool.work.comp.presentation.main
|
||||||
|
|
||||||
|
interface MainScreenEvent {
|
||||||
|
object Refresh : MainScreenEvent
|
||||||
|
object Logout : MainScreenEvent
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package ru.myitschool.work.comp.presentation.main
|
||||||
|
|
||||||
|
import ru.myitschool.work.comp.domain.model.GetInfoModel
|
||||||
|
|
||||||
|
data class MainScreenState(
|
||||||
|
val isLoading : Boolean = false,
|
||||||
|
val isError: Boolean = false,
|
||||||
|
val data : GetInfoModel? = null,
|
||||||
|
val shouldNavigateToAuth : Boolean = false
|
||||||
|
)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package ru.myitschool.work.comp.presentation.main
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import ru.myitschool.work.comp.domain.use_case.AppUseCases
|
||||||
|
import ru.myitschool.work.core.auth.CodeProvider
|
||||||
|
import ru.myitschool.work.core.domain.onError
|
||||||
|
import ru.myitschool.work.core.domain.onSuccess
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class MainScreenViewModel(
|
||||||
|
private val appUseCases: AppUseCases,
|
||||||
|
private val codeProvider: CodeProvider
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(MainScreenState())
|
||||||
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val code = codeProvider.getCode()
|
||||||
|
if (code != null) {
|
||||||
|
getInfo()
|
||||||
|
} else {
|
||||||
|
_state.update { it.copy(shouldNavigateToAuth = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEvent(event: MainScreenEvent){
|
||||||
|
when(event){
|
||||||
|
is MainScreenEvent.Refresh -> {
|
||||||
|
getInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
is MainScreenEvent.Logout -> {
|
||||||
|
logOut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getInfo(){
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
appUseCases.getInfoUseCase
|
||||||
|
.invoke()
|
||||||
|
.onSuccess { info ->
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
data = info
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onError {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isError = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logOut(){
|
||||||
|
codeProvider.clearCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package ru.myitschool.work.comp.presentation.main.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import ru.myitschool.work.comp.domain.model.BookingModel
|
||||||
|
import ru.myitschool.work.core.domain.TestIds
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BookingItem(
|
||||||
|
booking: BookingModel,
|
||||||
|
index: Int,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier,
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color.White)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = booking.date,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = Color.Black,
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.ITEM_DATE)
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = booking.place,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.Gray,
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.ITEM_PLACE)
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.example.result.comp.presentation.splash
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.VectorConverter
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
import ru.myitschool.work.R
|
||||||
|
import ru.myitschool.work.comp.presentation.splash.SplashScreenViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SplashScreen(
|
||||||
|
onNavigateToAuthScreen : () -> Unit,
|
||||||
|
onNavigateToMainScreen : () -> Unit,
|
||||||
|
viewModel: SplashScreenViewModel = koinViewModel()
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val size = remember { Animatable(initialValue = 75.dp, Dp.VectorConverter) }
|
||||||
|
|
||||||
|
LaunchedEffect(key1 = Unit) {
|
||||||
|
launch {
|
||||||
|
while (true){
|
||||||
|
size.animateTo(
|
||||||
|
targetValue = 200.dp,
|
||||||
|
animationSpec = tween(durationMillis = 800, easing = LinearEasing)
|
||||||
|
)
|
||||||
|
size.animateTo(
|
||||||
|
targetValue = 150.dp,
|
||||||
|
animationSpec = tween(durationMillis = 800, easing = LinearEasing)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delay(1500)
|
||||||
|
if (state.isAuthenticated == true){
|
||||||
|
onNavigateToMainScreen()
|
||||||
|
}else{
|
||||||
|
onNavigateToAuthScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
){
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(size.value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package ru.myitschool.work.comp.presentation.splash
|
||||||
|
|
||||||
|
data class SplashScreenState(
|
||||||
|
val isAuthenticated : Boolean? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package ru.myitschool.work.comp.presentation.splash
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import ru.myitschool.work.core.auth.CodeProvider
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
|
class SplashScreenViewModel(
|
||||||
|
private val codeProvider: CodeProvider
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(SplashScreenState())
|
||||||
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
isAuthenticated()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAuthenticated(){
|
||||||
|
val code = codeProvider.getCode()
|
||||||
|
if(!code.isNullOrBlank()){
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isAuthenticated = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isAuthenticated = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package ru.myitschool.work.core.auth
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.core.content.edit
|
||||||
|
|
||||||
|
interface CodeProvider {
|
||||||
|
fun getCode() : String?
|
||||||
|
fun saveCode(code : String)
|
||||||
|
fun clearCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
class SharedPrefCodeProvider(
|
||||||
|
private val sharedPreferences: SharedPreferences,
|
||||||
|
) : CodeProvider {
|
||||||
|
override fun getCode(): String? {
|
||||||
|
return sharedPreferences.getString("code", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun saveCode(code: String) {
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putString("code", "/api/$code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearCode() {
|
||||||
|
sharedPreferences.edit {
|
||||||
|
remove("code",)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package ru.myitschool.work.core.data
|
||||||
|
|
||||||
|
import ru.myitschool.work.core.domain.DataError
|
||||||
|
import ru.myitschool.work.core.domain.Result
|
||||||
|
import io.ktor.client.call.NoTransformationFoundException
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.network.sockets.SocketTimeoutException
|
||||||
|
import io.ktor.client.statement.HttpResponse
|
||||||
|
import io.ktor.util.network.UnresolvedAddressException
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
|
||||||
|
suspend inline fun <reified T> safeCall(
|
||||||
|
execute: () -> HttpResponse
|
||||||
|
) : Result<T, DataError.Remote>{
|
||||||
|
val response = try{
|
||||||
|
execute()
|
||||||
|
}catch (e: SocketTimeoutException){
|
||||||
|
return Result.Error(DataError.Remote.REQUEST_TIMEOUT)
|
||||||
|
}catch (e: UnresolvedAddressException){
|
||||||
|
return Result.Error(DataError.Remote.NO_INTERNET)
|
||||||
|
}catch (e: Exception){
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
|
return Result.Error(DataError.Remote.UNKNOWN)
|
||||||
|
}
|
||||||
|
return responseToResult(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline fun <reified T> responseToResult(
|
||||||
|
response : HttpResponse
|
||||||
|
) : Result<T, DataError.Remote>{
|
||||||
|
return when(response.status.value){
|
||||||
|
in 200..290 -> {
|
||||||
|
try {
|
||||||
|
Result.Success(response.body())
|
||||||
|
}catch (e: NoTransformationFoundException){
|
||||||
|
Result.Error(DataError.Remote.SERIALIZATION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
400 -> Result.Error(DataError.Remote.REQUEST_TIMEOUT)
|
||||||
|
429 -> Result.Error(DataError.Remote.TOO_MANY_REQUESTS)
|
||||||
|
in 500..599 -> Result.Error(DataError.Remote.SERVER_ERROR)
|
||||||
|
else -> Result.Error(DataError.Remote.UNKNOWN)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package ru.myitschool.work.core.data
|
||||||
|
|
||||||
|
import ru.myitschool.work.core.domain.Constants
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.okhttp.OkHttp
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.plugins.defaultRequest
|
||||||
|
import io.ktor.client.plugins.logging.LogLevel
|
||||||
|
import io.ktor.client.plugins.logging.Logger
|
||||||
|
import io.ktor.client.plugins.logging.Logging
|
||||||
|
import io.ktor.client.request.accept
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
object HttpClientFactory {
|
||||||
|
fun create(): HttpClient {
|
||||||
|
val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
explicitNulls = true
|
||||||
|
isLenient
|
||||||
|
}
|
||||||
|
|
||||||
|
return HttpClient(OkHttp) {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(json)
|
||||||
|
}
|
||||||
|
install(Logging) {
|
||||||
|
logger = object : Logger {
|
||||||
|
override fun log(message: String) {
|
||||||
|
println(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
level = LogLevel.ALL
|
||||||
|
}
|
||||||
|
defaultRequest {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
accept(ContentType.Application.Json)
|
||||||
|
url(Constants.HOST)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package ru.myitschool.work.core
|
package ru.myitschool.work.core.domain
|
||||||
|
|
||||||
object Constants {
|
object Constants {
|
||||||
const val HOST = "http://10.0.2.2:8080"
|
const val HOST = "http://192.168.1.106: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"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package ru.myitschool.work.core.domain
|
||||||
|
|
||||||
|
interface DataError : Error {
|
||||||
|
enum class Remote : DataError{
|
||||||
|
REQUEST_TIMEOUT,
|
||||||
|
TOO_MANY_REQUESTS,
|
||||||
|
NO_INTERNET,
|
||||||
|
SERVER_ERROR,
|
||||||
|
SERIALIZATION,
|
||||||
|
UNKNOWN,
|
||||||
|
UNAUTHORIZED,
|
||||||
|
BAD_REQUEST,
|
||||||
|
NETWORK
|
||||||
|
}
|
||||||
|
enum class Local : DataError{
|
||||||
|
DISK_FULL,
|
||||||
|
UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package ru.myitschool.work.core.domain
|
||||||
|
|
||||||
|
interface Error
|
||||||
31
app/src/main/java/ru/myitschool/work/core/domain/Result.kt
Normal file
31
app/src/main/java/ru/myitschool/work/core/domain/Result.kt
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package ru.myitschool.work.core.domain
|
||||||
|
|
||||||
|
sealed interface Result<out D, out E>{
|
||||||
|
data class Success<out D>(val data : D) : Result<D, Nothing>
|
||||||
|
data class Error<out E : ru.myitschool.work.core.domain.Error>(val error : E) : Result<Nothing, E>
|
||||||
|
}
|
||||||
|
inline fun <T, E : Error, R> Result<T, E>.map(map : (T) -> (R)) : Result<R, E>{
|
||||||
|
return when(this){
|
||||||
|
is Result.Success -> Result.Success(map(data))
|
||||||
|
is Result.Error -> this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T, E : Error> Result<T, E>.onSuccess(action : (T) -> Unit) : Result<T, E>{
|
||||||
|
return when(this){
|
||||||
|
is Result.Success -> {
|
||||||
|
action(data)
|
||||||
|
this
|
||||||
|
}
|
||||||
|
is Result.Error -> this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inline fun <T, E : Error> Result<T, E>.onError(action : (E) -> Unit) : Result<T, E>{
|
||||||
|
return when(this){
|
||||||
|
is Result.Success -> this
|
||||||
|
is Result.Error -> {
|
||||||
|
action(error)
|
||||||
|
this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package ru.myitschool.work.core
|
package ru.myitschool.work.core.domain
|
||||||
|
|
||||||
object TestIds {
|
object TestIds {
|
||||||
object Auth {
|
object Auth {
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package ru.myitschool.work.core.domain.validation
|
||||||
|
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
|
class ValidateCode {
|
||||||
|
fun execute(code : String) : ValidationResult {
|
||||||
|
if(code.isBlank()){
|
||||||
|
return ValidationResult(
|
||||||
|
success = false,
|
||||||
|
errorMessage = "Поле не может быть пустым"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if(code.length != 4){
|
||||||
|
return ValidationResult(
|
||||||
|
success = false,
|
||||||
|
errorMessage = "Длина кода должна быть рава 4-ем"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val pattern = Pattern.compile("^[A-Za-z0-9]+\$")
|
||||||
|
if(!pattern.matcher(code).matches()){
|
||||||
|
return ValidationResult(
|
||||||
|
success = false,
|
||||||
|
errorMessage = "Код может содержать только латинские буквы, цифры и специальные символы"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ValidationResult(
|
||||||
|
success = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package ru.myitschool.work.core.domain.validation
|
||||||
|
|
||||||
|
data class ValidationResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val errorMessage : String? = null
|
||||||
|
)
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package ru.myitschool.work.data.repo
|
|
||||||
|
|
||||||
import ru.myitschool.work.data.source.NetworkDataSource
|
|
||||||
|
|
||||||
object AuthRepository {
|
|
||||||
|
|
||||||
private var codeCache: String? = null
|
|
||||||
|
|
||||||
suspend fun checkAndSave(text: String): Result<Boolean> {
|
|
||||||
return NetworkDataSource.checkAuth(text).onSuccess { success ->
|
|
||||||
if (success) {
|
|
||||||
codeCache = text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package ru.myitschool.work.data.source
|
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.engine.cio.CIO
|
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
|
||||||
import io.ktor.client.request.get
|
|
||||||
import io.ktor.client.statement.bodyAsText
|
|
||||||
import io.ktor.http.HttpStatusCode
|
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import ru.myitschool.work.core.Constants
|
|
||||||
|
|
||||||
object NetworkDataSource {
|
|
||||||
private val client by lazy {
|
|
||||||
HttpClient(CIO) {
|
|
||||||
install(ContentNegotiation) {
|
|
||||||
json(
|
|
||||||
Json {
|
|
||||||
isLenient = true
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
explicitNulls = true
|
|
||||||
encodeDefaults = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) {
|
|
||||||
return@withContext runCatching {
|
|
||||||
val response = client.get(getUrl(code, Constants.AUTH_URL))
|
|
||||||
when (response.status) {
|
|
||||||
HttpStatusCode.OK -> true
|
|
||||||
else -> error(response.bodyAsText())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl"
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package ru.myitschool.work.domain.auth
|
|
||||||
|
|
||||||
import ru.myitschool.work.data.repo.AuthRepository
|
|
||||||
|
|
||||||
class CheckAndSaveAuthCodeUseCase(
|
|
||||||
private val repository: AuthRepository
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(
|
|
||||||
text: String
|
|
||||||
): Result<Unit> {
|
|
||||||
return repository.checkAndSave(text).mapCatching { success ->
|
|
||||||
if (!success) error("Code is incorrect")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package ru.myitschool.work.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RememberWindowInfo() : WindowInfo {
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
return WindowInfo(
|
||||||
|
screenWidthInfo = when{
|
||||||
|
configuration.screenWidthDp < 600 -> WindowInfo.WindowType.Compact
|
||||||
|
configuration.screenWidthDp < 840 -> WindowInfo.WindowType.Medium
|
||||||
|
else -> WindowInfo.WindowType.Expanded
|
||||||
|
},
|
||||||
|
screenHeightInfo = when{
|
||||||
|
configuration.screenHeightDp < 480 -> WindowInfo.WindowType.Compact
|
||||||
|
configuration.screenHeightDp < 900 -> WindowInfo.WindowType.Medium
|
||||||
|
else -> WindowInfo.WindowType.Expanded
|
||||||
|
},
|
||||||
|
screenWidth = configuration.screenWidthDp.dp,
|
||||||
|
screenHeight = configuration.screenHeightDp.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class WindowInfo(
|
||||||
|
val screenWidthInfo : WindowType,
|
||||||
|
val screenHeightInfo : WindowType,
|
||||||
|
val screenWidth : Dp,
|
||||||
|
val screenHeight: Dp
|
||||||
|
){
|
||||||
|
sealed class WindowType{
|
||||||
|
object Compact : WindowType()
|
||||||
|
object Medium : WindowType()
|
||||||
|
object Expanded : WindowType()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package ru.myitschool.work.ui.nav
|
|
||||||
|
|
||||||
sealed interface AppDestination
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package ru.myitschool.work.ui.nav
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data object AuthScreenDestination: AppDestination
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package ru.myitschool.work.ui.nav
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data object BookScreenDestination: AppDestination
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package ru.myitschool.work.ui.nav
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data object MainScreenDestination: AppDestination
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package ru.myitschool.work.ui.root
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import ru.myitschool.work.ui.screen.AppNavHost
|
|
||||||
import ru.myitschool.work.ui.theme.WorkTheme
|
|
||||||
|
|
||||||
class RootActivity : ComponentActivity() {
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
enableEdgeToEdge()
|
|
||||||
setContent {
|
|
||||||
WorkTheme {
|
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
|
||||||
AppNavHost(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package ru.myitschool.work.ui.screen
|
|
||||||
|
|
||||||
import androidx.compose.animation.EnterTransition
|
|
||||||
import androidx.compose.animation.ExitTransition
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.navigation.NavHostController
|
|
||||||
import androidx.navigation.compose.NavHost
|
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
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.screen.auth.AuthScreen
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AppNavHost(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
navController: NavHostController = rememberNavController()
|
|
||||||
) {
|
|
||||||
NavHost(
|
|
||||||
modifier = modifier,
|
|
||||||
enterTransition = { EnterTransition.None },
|
|
||||||
exitTransition = { ExitTransition.None },
|
|
||||||
navController = navController,
|
|
||||||
startDestination = AuthScreenDestination,
|
|
||||||
) {
|
|
||||||
composable<AuthScreenDestination> {
|
|
||||||
AuthScreen(navController = navController)
|
|
||||||
}
|
|
||||||
composable<MainScreenDestination> {
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(text = "Hello")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
composable<BookScreenDestination> {
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(text = "Hello")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package ru.myitschool.work.ui.screen.auth
|
|
||||||
|
|
||||||
sealed interface AuthIntent {
|
|
||||||
data class Send(val text: String): AuthIntent
|
|
||||||
data class TextInput(val text: String): AuthIntent
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
package ru.myitschool.work.ui.screen.auth
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
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.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
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import ru.myitschool.work.R
|
|
||||||
import ru.myitschool.work.core.TestIds
|
|
||||||
import ru.myitschool.work.ui.nav.MainScreenDestination
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AuthScreen(
|
|
||||||
viewModel: AuthViewModel = viewModel(),
|
|
||||||
navController: NavController
|
|
||||||
) {
|
|
||||||
val state by viewModel.uiState.collectAsState()
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
viewModel.actionFlow.collect {
|
|
||||||
navController.navigate(MainScreenDestination)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(all = 24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun Content(
|
|
||||||
viewModel: AuthViewModel,
|
|
||||||
state: AuthState.Data
|
|
||||||
) {
|
|
||||||
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
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.auth_sign_in))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package ru.myitschool.work.ui.screen.auth
|
|
||||||
|
|
||||||
sealed interface AuthState {
|
|
||||||
object Loading: AuthState
|
|
||||||
object Data: AuthState
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package ru.myitschool.work.ui.screen.auth
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import ru.myitschool.work.data.repo.AuthRepository
|
|
||||||
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
|
|
||||||
|
|
||||||
class AuthViewModel : ViewModel() {
|
|
||||||
private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) }
|
|
||||||
private val _uiState = MutableStateFlow<AuthState>(AuthState.Data)
|
|
||||||
val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
|
|
||||||
val actionFlow: SharedFlow<Unit> = _actionFlow
|
|
||||||
|
|
||||||
fun onIntent(intent: AuthIntent) {
|
|
||||||
when (intent) {
|
|
||||||
is AuthIntent.Send -> {
|
|
||||||
viewModelScope.launch(Dispatchers.Default) {
|
|
||||||
_uiState.update { AuthState.Loading }
|
|
||||||
checkAndSaveAuthCodeUseCase.invoke("9999").fold(
|
|
||||||
onSuccess = {
|
|
||||||
_actionFlow.emit(Unit)
|
|
||||||
},
|
|
||||||
onFailure = { error ->
|
|
||||||
error.printStackTrace()
|
|
||||||
_actionFlow.emit(Unit)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is AuthIntent.TextInput -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Work</string>
|
<string name="app_name">Result</string>
|
||||||
<string name="title_activity_root">RootActivity</string>
|
<string name="enter">Войти</string>
|
||||||
|
<string name="code">Код</string>
|
||||||
<string name="auth_title">Привет! Введи код для авторизации</string>
|
<string name="auth_title">Привет! Введи код для авторизации</string>
|
||||||
<string name="auth_label">Код</string>
|
<string name="user">Пользователь</string>
|
||||||
<string name="auth_sign_in">Войти</string>
|
<string name="updateData">Обновить данные</string>
|
||||||
|
<string name="myBookings">Мои бронирования</string>
|
||||||
|
<string name="noBookings">У вас пока нет бронирований</string>
|
||||||
|
<string name="SomethingGoneWrong">Что-то пошло не так</string>
|
||||||
|
<string name="tryAgain">Попробовать заново</string>
|
||||||
</resources>
|
</resources>
|
||||||
Reference in New Issue
Block a user