From 01b75dda8e6bdf34254481fe3e911035d39c9652 Mon Sep 17 00:00:00 2001 From: drnal Date: Wed, 10 Dec 2025 16:24:59 +0700 Subject: [PATCH] Project --- app/build.gradle.kts | 5 + app/src/main/java/ru/myitschool/work/App.kt | 2 + .../main/java/ru/myitschool/work/AppModule.kt | 41 +++ .../java/ru/myitschool/work/core/Constants.kt | 5 +- .../work/data/repo/AuthRepository.kt | 49 ++- .../work/data/source/NetworkDataSource.kt | 91 ++++- .../work/domain/book/AvailableBookingDate.kt | 9 + .../work/domain/book/CreateBookingUseCase.kt | 36 ++ .../domain/book/GetAvailableBookingUseCase.kt | 60 ++++ .../work/domain/main/GetUserInfoUseCase.kt | 82 +++++ .../work/domain/main/LogoutUseCase.kt | 9 + .../myitschool/work/ui/nav/AppDestination.kt | 6 +- .../work/ui/nav/AuthScreenDestination.kt | 5 +- .../work/ui/nav/BookScreenDestination.kt | 5 +- .../work/ui/nav/MainScreenDestination.kt | 5 +- .../myitschool/work/ui/root/RootActivity.kt | 19 +- .../work/ui/screen/NavigationGraph.kt | 122 +++++-- .../work/ui/screen/auth/AuthScreen.kt | 8 +- .../work/ui/screen/auth/AuthViewModel.kt | 20 +- .../work/ui/screen/book/BookIntent.kt | 9 + .../work/ui/screen/book/BookScreen.kt | 333 ++++++++++++------ .../work/ui/screen/book/BookState.kt | 13 + .../work/ui/screen/book/BookViewModel.kt | 131 ++++++- .../work/ui/screen/main/MainIntent.kt | 9 + .../work/ui/screen/main/MainScreen.kt | 264 ++++++++++++++ .../work/ui/screen/main/MainState.kt | 16 + .../work/ui/screen/main/MainViewModel.kt | 84 +++++ app/src/main/res/drawable/icon_add.xml | 5 + 28 files changed, 1279 insertions(+), 164 deletions(-) create mode 100644 app/src/main/java/ru/myitschool/work/AppModule.kt create mode 100644 app/src/main/java/ru/myitschool/work/domain/book/AvailableBookingDate.kt create mode 100644 app/src/main/java/ru/myitschool/work/domain/book/CreateBookingUseCase.kt create mode 100644 app/src/main/java/ru/myitschool/work/domain/book/GetAvailableBookingUseCase.kt create mode 100644 app/src/main/java/ru/myitschool/work/domain/main/GetUserInfoUseCase.kt create mode 100644 app/src/main/java/ru/myitschool/work/domain/main/LogoutUseCase.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt create mode 100644 app/src/main/res/drawable/icon_add.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a5ccda1..0c8614a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/java/ru/myitschool/work/App.kt b/app/src/main/java/ru/myitschool/work/App.kt index aa33483..94ea8b8 100644 --- a/app/src/main/java/ru/myitschool/work/App.kt +++ b/app/src/main/java/ru/myitschool/work/App.kt @@ -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 { diff --git a/app/src/main/java/ru/myitschool/work/AppModule.kt b/app/src/main/java/ru/myitschool/work/AppModule.kt new file mode 100644 index 0000000..443f2e5 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/AppModule.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/core/Constants.kt b/app/src/main/java/ru/myitschool/work/core/Constants.kt index a8b7cc5..e3a62b7 100644 --- a/app/src/main/java/ru/myitschool/work/core/Constants.kt +++ b/app/src/main/java/ru/myitschool/work/core/Constants.kt @@ -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" } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt b/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt index 3ef28f1..aa8b687 100644 --- a/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt +++ b/app/src/main/java/ru/myitschool/work/data/repo/AuthRepository.kt @@ -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 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 { - return NetworkDataSource.checkAuth(text).onSuccess { success -> + return networkDataSource.checkAuth(text).onSuccess { success -> if (success) { codeCache = text + saveAuthCode(text) } } } + fun getAuthCode(): Flow { + 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 { + return getAuthCode().map { it != null } + } + fun getNetworkDataSource(): NetworkDataSource = networkDataSource } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt b/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt index fbdfef5..cca3477 100644 --- a/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt +++ b/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt @@ -1,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 +) -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 + suspend fun getUserInfo(code: String): Result + suspend fun getAvailableBookings(code: String): Result>> + suspend fun createBooking(code: String, date: String, placeId: Int): Result +} + +class NetworkDataSourceImpl : NetworkDataSource { private val client by lazy { HttpClient(CIO) { install(ContentNegotiation) { @@ -28,7 +65,7 @@ object NetworkDataSource { } } - suspend fun checkAuth(code: String): Result = withContext(Dispatchers.IO) { + override suspend fun checkAuth(code: String): Result = 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 = 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>> = 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 = 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" } \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/book/AvailableBookingDate.kt b/app/src/main/java/ru/myitschool/work/domain/book/AvailableBookingDate.kt new file mode 100644 index 0000000..06c29fc --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/book/AvailableBookingDate.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/book/CreateBookingUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/book/CreateBookingUseCase.kt new file mode 100644 index 0000000..dd3ef3f --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/book/CreateBookingUseCase.kt @@ -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 { + 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/book/GetAvailableBookingUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/book/GetAvailableBookingUseCase.kt new file mode 100644 index 0000000..945869c --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/book/GetAvailableBookingUseCase.kt @@ -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> { + 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/main/GetUserInfoUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/main/GetUserInfoUseCase.kt new file mode 100644 index 0000000..8357999 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/main/GetUserInfoUseCase.kt @@ -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 { + 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 +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/main/LogoutUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/main/LogoutUseCase.kt new file mode 100644 index 0000000..594f1d7 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/main/LogoutUseCase.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/nav/AppDestination.kt b/app/src/main/java/ru/myitschool/work/ui/nav/AppDestination.kt index 557b893..6b17817 100644 --- a/app/src/main/java/ru/myitschool/work/ui/nav/AppDestination.kt +++ b/app/src/main/java/ru/myitschool/work/ui/nav/AppDestination.kt @@ -1,3 +1,7 @@ package ru.myitschool.work.ui.nav -sealed interface AppDestination \ No newline at end of file +sealed class AppDestination(val route: String) { + object Auth : AppDestination("auth") + object Main : AppDestination("main") + object Book : AppDestination("book") +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/nav/AuthScreenDestination.kt b/app/src/main/java/ru/myitschool/work/ui/nav/AuthScreenDestination.kt index 52660b1..92958b7 100644 --- a/app/src/main/java/ru/myitschool/work/ui/nav/AuthScreenDestination.kt +++ b/app/src/main/java/ru/myitschool/work/ui/nav/AuthScreenDestination.kt @@ -2,5 +2,6 @@ package ru.myitschool.work.ui.nav import kotlinx.serialization.Serializable -@Serializable -data object AuthScreenDestination: AppDestination \ No newline at end of file +object AuthScreenDestination { + const val route = "auth" +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/nav/BookScreenDestination.kt b/app/src/main/java/ru/myitschool/work/ui/nav/BookScreenDestination.kt index 9a33073..a13a0f8 100644 --- a/app/src/main/java/ru/myitschool/work/ui/nav/BookScreenDestination.kt +++ b/app/src/main/java/ru/myitschool/work/ui/nav/BookScreenDestination.kt @@ -2,5 +2,6 @@ package ru.myitschool.work.ui.nav import kotlinx.serialization.Serializable -@Serializable -data object BookScreenDestination: AppDestination \ No newline at end of file +object BookScreenDestination { + const val route = "book" +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/nav/MainScreenDestination.kt b/app/src/main/java/ru/myitschool/work/ui/nav/MainScreenDestination.kt index deca45f..fef554a 100644 --- a/app/src/main/java/ru/myitschool/work/ui/nav/MainScreenDestination.kt +++ b/app/src/main/java/ru/myitschool/work/ui/nav/MainScreenDestination.kt @@ -2,5 +2,6 @@ package ru.myitschool.work.ui.nav import kotlinx.serialization.Serializable -@Serializable -data object MainScreenDestination: AppDestination \ No newline at end of file +object MainScreenDestination { + const val route = "main" +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/root/RootActivity.kt b/app/src/main/java/ru/myitschool/work/ui/root/RootActivity.kt index 54b156d..65824b5 100644 --- a/app/src/main/java/ru/myitschool/work/ui/root/RootActivity.kt +++ b/app/src/main/java/ru/myitschool/work/ui/root/RootActivity.kt @@ -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() } } } diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt b/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt index 7b1377e..8a47a63 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/NavigationGraph.kt @@ -5,52 +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() ) { - NavHost( - modifier = modifier, - enterTransition = { EnterTransition.None }, - exitTransition = { ExitTransition.None }, - navController = navController, - startDestination = AuthScreenDestination, - ) { - composable { - AuthScreen(navController = navController) + + + + val isAuthorized by AppModule.authRepository.isAuthorizedFlow() + .collectAsState(initial = false) + + + val startDestination = remember(isAuthorized) { + if (isAuthorized) AppDestination.Main.route else AppDestination.Auth.route } - composable { - Box( - contentAlignment = Alignment.Center - ) { - Text(text = "MainScreen") + + + NavHost( + navController = navController, + startDestination = startDestination + ) { + 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 { - BookScreen( - selectedDateIndex = 0, - onDateClick = {}, - //selectedPlaceIndex = null, - //onPlaceSelect = {}, - error = null, - isEmpty = false, - onRefresh = {}, - onBack = { navController.popBackStack() }, - onBook = {} + + 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 -> {} + } + } + } + } -} \ No newline at end of file +}} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt index 1e85042..3db9351 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthScreen.kt @@ -31,15 +31,19 @@ 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() - LaunchedEffect(Unit) { + LaunchedEffect(Unit) { viewModel.actionFlow.collect { navController.navigate(MainScreenDestination) } diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt index b2ef051..cf8f7d4 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/auth/AuthViewModel.kt @@ -2,24 +2,31 @@ package ru.myitschool.work.ui.screen.auth import android.app.Application import android.content.Context -import android.content.Context.MODE_PRIVATE -import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.AndroidViewModel -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.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(application: Application) : AndroidViewModel(application) { - private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) } + + private val _navigation = MutableSharedFlow() + val navigation: SharedFlow = _navigation.asSharedFlow() + private val repository by lazy { + AuthRepository(getApplication().applicationContext) + } + private val checkAndSaveAuthCodeUseCase by lazy { + CheckAndSaveAuthCodeUseCase(repository) } private val _uiState = MutableStateFlow(AuthState.Data()) val uiState: StateFlow = _uiState.asStateFlow() @@ -37,10 +44,12 @@ class AuthViewModel(application: Application) : AndroidViewModel(application) { _actionFlow.emit(Unit) } } + } + fun onIntent(intent: AuthIntent) { when (intent) { is AuthIntent.Send -> { @@ -56,7 +65,8 @@ class AuthViewModel(application: Application) : AndroidViewModel(application) { prefs.edit() .putString("saved_code", intent.text) .apply() - _actionFlow.emit(Unit) + _navigation.emit(AppDestination.Main) + }, onFailure = { error -> error.printStackTrace() diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt new file mode 100644 index 0000000..e690b93 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookIntent.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt index b71ef91..fa0604d 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookScreen.kt @@ -2,6 +2,7 @@ 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 @@ -15,15 +16,31 @@ 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 @@ -34,139 +51,249 @@ 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( - dates: List = listOf("19.04", "20.04", "21.04", "22.04"), - selectedDateIndex: Int, - onDateClick: (Int) -> Unit, - - places: List = listOf( -"Рабочее место у окна", -"Переговорная комната №1", -"Коворкинг A" -), - myColor: Color = Color(0xFF0090FF), - error: String?, - isEmpty: Boolean, - onRefresh: () -> Unit, - onBack: () -> Unit, - onBook: () -> Unit + state: BookState, + onIntent: (BookIntent) -> Unit ) { - - var selectedPlaceIndex by remember { mutableStateOf(null) } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { - - Button( - modifier = Modifier.testTag(TestIds.Book.BACK_BUTTON), - onClick = onBack, - colors = ButtonDefaults.buttonColors( containerColor = myColor ), - ) - {Text("Назад")} - - - Row( + if (state.showError) { + Column( modifier = Modifier - .horizontalScroll(rememberScrollState()) - .padding(top = 16.dp) + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center ) { - dates.forEachIndexed { index, date -> - Box( - modifier = Modifier - .width(60.dp) - .height(40.dp) - .border( - width = 2.dp, - color = if (index == selectedDateIndex) myColor else Color.LightGray, - shape = RoundedCornerShape(6.dp) - ) - .background(Color.White, RoundedCornerShape(6.dp)) - .clickable { onDateClick(index) } - .padding(8.dp) - ) { - Text( - text = date, - modifier = Modifier.align(Alignment.Center), - textAlign = TextAlign.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("Обновить") } - Spacer(modifier = Modifier.width(8.dp)) } } + return + } - Spacer(Modifier.height(20.dp)) + 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) + ) { - Column { - places.forEachIndexed { index, place -> - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + 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(12.dp) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) ) { - Text(place) + 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 = "Нет доступных мест на выбранную дату", - RadioButton( - selected = selectedPlaceIndex == index, - onClick = { selectedPlaceIndex = index }, - colors = RadioButtonDefaults.colors( - selectedColor = myColor, - unselectedColor = Color.Gray - ) ) } } - } - - if (error != null) { - Text( - text = error, - color = Color.Red, - modifier = Modifier.padding(top = 8.dp).testTag(TestIds.Book.ERROR) - ) - } - - if (isEmpty) { - Text( - text = "Всё забронировано", - modifier = Modifier.padding(top = 12.dp).testTag(TestIds.Book.EMPTY) - ) - Button( - onClick = onRefresh, - modifier = Modifier.padding(top = 12.dp).testTag(TestIds.Book.REFRESH_BUTTON), - colors = ButtonDefaults.buttonColors( containerColor = myColor ) - + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 32.dp), + horizontalArrangement = Arrangement.SpaceBetween ) { - Text("Обновить") + 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("Забронировать") + } } } - - Button( - onClick = onBook, - modifier = Modifier - .fillMaxWidth() - .padding(top = 20.dp), - colors = ButtonDefaults.buttonColors( containerColor = myColor ) - - ) { - Text("Забронировать") - } } -} \ No newline at end of file +} diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt new file mode 100644 index 0000000..1c7b3ec --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookState.kt @@ -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 = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, + val isEmpty: Boolean = false, + val showError: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt index f22f49a..79f2a5c 100644 --- a/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt +++ b/app/src/main/java/ru/myitschool/work/ui/screen/book/BookViewModel.kt @@ -1,6 +1,129 @@ -package ru.myitschool.work.ui.screen.book; +package ru.myitschool.work.ui.screen.book -import android.app.Application; +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 { -} + + +class BookViewModel : ViewModel() { + + private val _state = MutableStateFlow(BookState()) + val state: StateFlow = _state.asStateFlow() + + private val _navigation = MutableSharedFlow() + val navigation: SharedFlow = _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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt new file mode 100644 index 0000000..8933d4e --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainIntent.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt new file mode 100644 index 0000000..4472f02 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainScreen.kt @@ -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 + ) + } + } + } + } + } + } + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt new file mode 100644 index 0000000..f8f0fa1 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainState.kt @@ -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 = emptyList(), + val error: String? = null, + val showError: Boolean = false +) + +data class BookingItem( + val date: String, + val place: String, + val originalDate: String +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt new file mode 100644 index 0000000..ba28bd5 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/screen/main/MainViewModel.kt @@ -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 = _state.asStateFlow() + + private val _navigation = MutableSharedFlow() + val navigation: SharedFlow = _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) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_add.xml b/app/src/main/res/drawable/icon_add.xml new file mode 100644 index 0000000..9f83b8f --- /dev/null +++ b/app/src/main/res/drawable/icon_add.xml @@ -0,0 +1,5 @@ + + + + +