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