This commit is contained in:
@@ -47,5 +47,21 @@ dependencies {
|
||||
implementation("io.ktor:ktor-client-cio:$ktor")
|
||||
implementation("io.ktor:ktor-client-content-negotiation:$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")
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:name=".Application"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
@@ -16,10 +16,10 @@
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".ui.root.RootActivity"
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:label="@string/title_activity_root">
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<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 {
|
||||
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 INFO_URL = "/info"
|
||||
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 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>
|
||||
<string name="app_name">Work</string>
|
||||
<string name="title_activity_root">RootActivity</string>
|
||||
<string name="app_name">Result</string>
|
||||
<string name="enter">Войти</string>
|
||||
<string name="code">Код</string>
|
||||
<string name="auth_title">Привет! Введи код для авторизации</string>
|
||||
<string name="auth_label">Код</string>
|
||||
<string name="auth_sign_in">Войти</string>
|
||||
<string name="user">Пользователь</string>
|
||||
<string name="updateData">Обновить данные</string>
|
||||
<string name="myBookings">Мои бронирования</string>
|
||||
<string name="noBookings">У вас пока нет бронирований</string>
|
||||
<string name="SomethingGoneWrong">Что-то пошло не так</string>
|
||||
<string name="tryAgain">Попробовать заново</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user