Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbfdf844ed | |||
| 85b23119d9 | |||
| 03e87ad36c | |||
| f5cab8e875 | |||
| c53bd718c0 | |||
| 7adb05efb9 | |||
| 3e404ed765 | |||
| 053a916b55 | |||
| 100a3adbb2 | |||
| 8a6430f20e | |||
| 011804aa61 | |||
| e572d2dd1e | |||
| 0bd59b81e3 | |||
| acb65d63cb |
@@ -35,6 +35,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation("androidx.compose.material3:material3:1.4.0")
|
||||||
defaultComposeLibrary()
|
defaultComposeLibrary()
|
||||||
implementation("androidx.datastore:datastore-preferences:1.1.7")
|
implementation("androidx.datastore:datastore-preferences:1.1.7")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0")
|
||||||
@@ -48,4 +49,6 @@ dependencies {
|
|||||||
implementation("io.ktor:ktor-client-content-negotiation:$ktor")
|
implementation("io.ktor:ktor-client-content-negotiation:$ktor")
|
||||||
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor")
|
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||||
|
implementation("androidx.datastore:datastore-preferences:1.2.0")
|
||||||
|
implementation("androidx.compose.material:material-icons-extended:1.7.8")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ object TestIds {
|
|||||||
const val SIGN_BUTTON = "auth_sign_button"
|
const val SIGN_BUTTON = "auth_sign_button"
|
||||||
const val CODE_INPUT = "auth_code_input"
|
const val CODE_INPUT = "auth_code_input"
|
||||||
}
|
}
|
||||||
|
|
||||||
object Main {
|
object Main {
|
||||||
const val ERROR = "main_error"
|
const val ERROR = "main_error"
|
||||||
const val ADD_BUTTON = "main_add_button"
|
const val ADD_BUTTON = "main_add_button"
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package ru.myitschool.work.data.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BookBody (
|
||||||
|
val date: String,
|
||||||
|
val placeId: Int
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package ru.myitschool.work.data.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Booking(
|
||||||
|
val id: Int,
|
||||||
|
val place: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package ru.myitschool.work.data.models
|
||||||
|
|
||||||
|
typealias BookingInfo = Map<String, List<Booking>>
|
||||||
10
app/src/main/java/ru/myitschool/work/data/models/UserInfo.kt
Normal file
10
app/src/main/java/ru/myitschool/work/data/models/UserInfo.kt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package ru.myitschool.work.data.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserInfo(
|
||||||
|
val name: String,
|
||||||
|
val photoUrl: String,
|
||||||
|
val booking: Map<String, Booking>
|
||||||
|
)
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
package ru.myitschool.work.data.repo
|
package ru.myitschool.work.data.repo
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import ru.myitschool.work.data.source.LocalDataSource
|
||||||
import ru.myitschool.work.data.source.NetworkDataSource
|
import ru.myitschool.work.data.source.NetworkDataSource
|
||||||
|
|
||||||
object AuthRepository {
|
object AuthRepository {
|
||||||
|
suspend fun clearCode() {
|
||||||
|
LocalDataSource.setCode("")
|
||||||
|
}
|
||||||
|
suspend fun getCode(): String {
|
||||||
|
return LocalDataSource.getCode()
|
||||||
|
}
|
||||||
|
|
||||||
private var codeCache: String? = null
|
val isCodePresentFlow: Flow<Boolean> = LocalDataSource.isCodePresentFlow
|
||||||
|
|
||||||
suspend fun checkAndSave(text: String): Result<Boolean> {
|
suspend fun checkAndSave(text: String): Result<Boolean> {
|
||||||
return NetworkDataSource.checkAuth(text).onSuccess { success ->
|
return NetworkDataSource.checkAuth(text).onSuccess { success ->
|
||||||
if (success) {
|
if (success) {
|
||||||
codeCache = text
|
LocalDataSource.setCode(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package ru.myitschool.work.data.repo
|
||||||
|
|
||||||
|
import ru.myitschool.work.data.models.BookingInfo
|
||||||
|
import ru.myitschool.work.data.repo.AuthRepository.getCode
|
||||||
|
import ru.myitschool.work.data.source.NetworkDataSource
|
||||||
|
|
||||||
|
object BookRepository {
|
||||||
|
suspend fun fetch(): Result<BookingInfo> {
|
||||||
|
return NetworkDataSource.bookInfo(getCode()).onSuccess { data -> data }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun book(date: String, placeId: Int): Result<Boolean> {
|
||||||
|
return NetworkDataSource.book(getCode(), date, placeId).onSuccess { success -> success }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package ru.myitschool.work.data.repo
|
||||||
|
|
||||||
|
import ru.myitschool.work.data.models.UserInfo
|
||||||
|
import ru.myitschool.work.data.repo.AuthRepository.getCode
|
||||||
|
import ru.myitschool.work.data.source.NetworkDataSource
|
||||||
|
|
||||||
|
object MainRepository {
|
||||||
|
suspend fun fetch(): Result<UserInfo> {
|
||||||
|
return NetworkDataSource.info(getCode()).onSuccess { data -> data }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package ru.myitschool.work.data.source
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
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.App
|
||||||
|
|
||||||
|
object LocalDataSource {
|
||||||
|
private val Context.dataStore by preferencesDataStore("user_data")
|
||||||
|
|
||||||
|
object Keys {
|
||||||
|
val CODE = stringPreferencesKey("Username")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val appContext get() = App.context
|
||||||
|
|
||||||
|
suspend fun getCode(): String {
|
||||||
|
return appContext.dataStore.data.map { it[Keys.CODE] ?: "" }.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setCode(code: String) {
|
||||||
|
appContext.dataStore.edit { it[Keys.CODE] = code }
|
||||||
|
}
|
||||||
|
|
||||||
|
val isCodePresentFlow: Flow<Boolean> = appContext.dataStore.data.map { it[Keys.CODE] != "" && it[Keys.CODE] != null }
|
||||||
|
}
|
||||||
@@ -1,16 +1,24 @@
|
|||||||
package ru.myitschool.work.data.source
|
package ru.myitschool.work.data.source
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.engine.cio.CIO
|
import io.ktor.client.engine.cio.CIO
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
import io.ktor.client.request.get
|
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.client.statement.bodyAsText
|
||||||
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.http.contentType
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import ru.myitschool.work.core.Constants
|
import ru.myitschool.work.core.Constants
|
||||||
|
import ru.myitschool.work.data.models.BookBody
|
||||||
|
import ru.myitschool.work.data.models.BookingInfo
|
||||||
|
import ru.myitschool.work.data.models.UserInfo
|
||||||
|
|
||||||
object NetworkDataSource {
|
object NetworkDataSource {
|
||||||
private val client by lazy {
|
private val client by lazy {
|
||||||
@@ -38,5 +46,39 @@ object NetworkDataSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun info(code: String): Result<UserInfo> = withContext(Dispatchers.IO) {
|
||||||
|
return@withContext runCatching {
|
||||||
|
val response = client.get(getUrl(code, Constants.INFO_URL))
|
||||||
|
when (response.status) {
|
||||||
|
HttpStatusCode.OK -> Json.decodeFromString<UserInfo>(response.body())
|
||||||
|
else -> error(response.bodyAsText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun bookInfo(code: String): Result<BookingInfo> = withContext(Dispatchers.IO) {
|
||||||
|
return@withContext runCatching {
|
||||||
|
val response = client.get(getUrl(code, Constants.BOOKING_URL))
|
||||||
|
when (response.status) {
|
||||||
|
HttpStatusCode.OK -> Json.decodeFromString<BookingInfo>(response.body())
|
||||||
|
else -> error(response.bodyAsText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun book(code: String, date: String, placeId: Int): Result<Boolean> = withContext(Dispatchers.IO) {
|
||||||
|
return@withContext runCatching {
|
||||||
|
// val requestBodyString = Json.encodeToString(BookBody(date, placeId))
|
||||||
|
val response = client.post((getUrl(code, Constants.BOOK_URL))) {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(BookBody(date, placeId))
|
||||||
|
}
|
||||||
|
when(response.status) {
|
||||||
|
HttpStatusCode.Created -> true
|
||||||
|
else -> error(response.bodyAsText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl"
|
private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package ru.myitschool.work.domain.auth
|
package ru.myitschool.work.domain.auth
|
||||||
|
|
||||||
import ru.myitschool.work.data.repo.AuthRepository
|
import ru.myitschool.work.data.repo.AuthRepository
|
||||||
|
import ru.myitschool.work.App
|
||||||
|
|
||||||
class CheckAndSaveAuthCodeUseCase(
|
class CheckAndSaveAuthCodeUseCase(
|
||||||
private val repository: AuthRepository
|
private val repository: AuthRepository
|
||||||
|
|||||||
16
app/src/main/java/ru/myitschool/work/domain/book/Book.kt
Normal file
16
app/src/main/java/ru/myitschool/work/domain/book/Book.kt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package ru.myitschool.work.domain.book
|
||||||
|
|
||||||
|
import ru.myitschool.work.data.repo.BookRepository
|
||||||
|
|
||||||
|
class Book (
|
||||||
|
private val repository: BookRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
date: String,
|
||||||
|
placeId: Int
|
||||||
|
): Result<Unit> {
|
||||||
|
return repository.book(date, placeId).mapCatching { success ->
|
||||||
|
if (!success) error("Code is incorrect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/src/main/java/ru/myitschool/work/domain/book/Fetch.kt
Normal file
12
app/src/main/java/ru/myitschool/work/domain/book/Fetch.kt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package ru.myitschool.work.domain.book
|
||||||
|
|
||||||
|
import ru.myitschool.work.data.models.BookingInfo
|
||||||
|
import ru.myitschool.work.data.repo.BookRepository
|
||||||
|
|
||||||
|
class Fetch (
|
||||||
|
private val repository: BookRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(): Result<BookingInfo> {
|
||||||
|
return repository.fetch().mapCatching { success -> success.filter { it.value.isNotEmpty() } as BookingInfo }
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/src/main/java/ru/myitschool/work/domain/main/Fetch.kt
Normal file
12
app/src/main/java/ru/myitschool/work/domain/main/Fetch.kt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package ru.myitschool.work.domain.main
|
||||||
|
|
||||||
|
import ru.myitschool.work.data.models.UserInfo
|
||||||
|
import ru.myitschool.work.data.repo.MainRepository
|
||||||
|
|
||||||
|
class Fetch(
|
||||||
|
private val repository: MainRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(): Result<UserInfo> {
|
||||||
|
return repository.fetch().mapCatching { success -> success }
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/src/main/java/ru/myitschool/work/domain/main/Logout.kt
Normal file
12
app/src/main/java/ru/myitschool/work/domain/main/Logout.kt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package ru.myitschool.work.domain.main
|
||||||
|
|
||||||
|
import ru.myitschool.work.data.repo.AuthRepository
|
||||||
|
import kotlin.mapCatching
|
||||||
|
|
||||||
|
class Logout (
|
||||||
|
private val repository: AuthRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(): Unit {
|
||||||
|
return repository.clearCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package ru.myitschool.work.ui.components
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Modifier.conditionalImePadding(): Modifier {
|
||||||
|
return if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
|
||||||
|
this.then(Modifier.imePadding())
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package ru.myitschool.work.ui.nav
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object SplashScreenDestination: AppDestination
|
||||||
@@ -7,18 +7,28 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import ru.myitschool.work.data.repo.AuthRepository
|
||||||
import ru.myitschool.work.ui.screen.AppNavHost
|
import ru.myitschool.work.ui.screen.AppNavHost
|
||||||
import ru.myitschool.work.ui.theme.WorkTheme
|
import ru.myitschool.work.ui.theme.WorkTheme
|
||||||
|
|
||||||
class RootActivity : ComponentActivity() {
|
class RootActivity() : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
actionBar?.hide()
|
||||||
setContent {
|
setContent {
|
||||||
WorkTheme {
|
WorkTheme {
|
||||||
|
val codePresence by AuthRepository.isCodePresentFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||||
AppNavHost(
|
AppNavHost(
|
||||||
|
codePresence = codePresence,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package ru.myitschool.work.ui.root
|
||||||
|
|
||||||
|
sealed interface RootState {
|
||||||
|
object Loading: RootState
|
||||||
|
|
||||||
|
object CodePresent: RootState
|
||||||
|
|
||||||
|
object CodeAbsent: RootState
|
||||||
|
}
|
||||||
@@ -5,45 +5,56 @@ import androidx.compose.animation.ExitTransition
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.myitschool.work.data.repo.AuthRepository
|
||||||
import ru.myitschool.work.ui.nav.AuthScreenDestination
|
import ru.myitschool.work.ui.nav.AuthScreenDestination
|
||||||
import ru.myitschool.work.ui.nav.BookScreenDestination
|
import ru.myitschool.work.ui.nav.BookScreenDestination
|
||||||
import ru.myitschool.work.ui.nav.MainScreenDestination
|
import ru.myitschool.work.ui.nav.MainScreenDestination
|
||||||
|
import ru.myitschool.work.ui.nav.SplashScreenDestination
|
||||||
|
import ru.myitschool.work.ui.root.RootState
|
||||||
|
import ru.myitschool.work.ui.screen.auth.AuthIntent
|
||||||
import ru.myitschool.work.ui.screen.auth.AuthScreen
|
import ru.myitschool.work.ui.screen.auth.AuthScreen
|
||||||
|
import ru.myitschool.work.ui.screen.book.BookScreen
|
||||||
|
import ru.myitschool.work.ui.screen.main.MainScreen
|
||||||
|
import ru.myitschool.work.ui.screen.splash.SplashScreen
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavHost(
|
fun AppNavHost(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
navController: NavHostController = rememberNavController()
|
navController: NavHostController = rememberNavController(),
|
||||||
|
codePresence: Boolean?
|
||||||
) {
|
) {
|
||||||
|
val startDestination = if (codePresence == null) SplashScreenDestination
|
||||||
|
else if (codePresence) MainScreenDestination
|
||||||
|
else AuthScreenDestination
|
||||||
|
|
||||||
NavHost(
|
NavHost(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
enterTransition = { EnterTransition.None },
|
// enterTransition = { EnterTransition.None },
|
||||||
exitTransition = { ExitTransition.None },
|
// exitTransition = { ExitTransition.None },
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = AuthScreenDestination,
|
startDestination = startDestination,
|
||||||
) {
|
) {
|
||||||
composable<AuthScreenDestination> {
|
composable<AuthScreenDestination> {
|
||||||
AuthScreen(navController = navController)
|
AuthScreen(navController = navController)
|
||||||
}
|
}
|
||||||
|
composable<SplashScreenDestination> {
|
||||||
|
SplashScreen()
|
||||||
|
}
|
||||||
composable<MainScreenDestination> {
|
composable<MainScreenDestination> {
|
||||||
Box(
|
MainScreen(navController = navController)
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(text = "Hello")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
composable<BookScreenDestination> {
|
composable<BookScreenDestination> {
|
||||||
Box(
|
BookScreen(navController = navController)
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(text = "Hello")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
package ru.myitschool.work.ui.screen.auth
|
package ru.myitschool.work.ui.screen.auth
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextField
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -30,6 +35,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import ru.myitschool.work.R
|
import ru.myitschool.work.R
|
||||||
import ru.myitschool.work.core.TestIds
|
import ru.myitschool.work.core.TestIds
|
||||||
|
import ru.myitschool.work.ui.components.conditionalImePadding
|
||||||
import ru.myitschool.work.ui.nav.MainScreenDestination
|
import ru.myitschool.work.ui.nav.MainScreenDestination
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -48,22 +54,18 @@ fun AuthScreen(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(all = 24.dp),
|
.padding(all = 32.dp)
|
||||||
|
.conditionalImePadding(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.auth_title),
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
when (val currentState = state) {
|
when (val currentState = state) {
|
||||||
is AuthState.Data -> Content(viewModel, currentState)
|
|
||||||
is AuthState.Loading -> {
|
is AuthState.Loading -> {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(64.dp)
|
modifier = Modifier.size(64.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
else -> Content(viewModel, currentState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,27 +73,53 @@ fun AuthScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun Content(
|
private fun Content(
|
||||||
viewModel: AuthViewModel,
|
viewModel: AuthViewModel,
|
||||||
state: AuthState.Data
|
state: AuthState,
|
||||||
) {
|
) {
|
||||||
var inputText by remember { mutableStateOf("") }
|
var inputText by remember { mutableStateOf("") }
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
val err by viewModel.errorFlow.collectAsState()
|
||||||
TextField(
|
val isButtonEnabled by viewModel.isButtonEnabled.collectAsState()
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.auth_title),
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(48.dp))
|
||||||
|
OutlinedTextField(
|
||||||
modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(),
|
modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(),
|
||||||
value = inputText,
|
value = inputText,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
inputText = it
|
inputText = it
|
||||||
viewModel.onIntent(AuthIntent.TextInput(it))
|
viewModel.onIntent(AuthIntent.TextInput(it))
|
||||||
},
|
},
|
||||||
label = { Text(stringResource(R.string.auth_label)) }
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
label = { Text(stringResource(R.string.auth_label)) },
|
||||||
|
placeholder = { Text(stringResource(R.string.auth_label)) }
|
||||||
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(24.dp))
|
||||||
|
if (state == AuthState.Error) {
|
||||||
|
Text(
|
||||||
|
text = err,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.testTag(TestIds.Auth.ERROR)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
|
}
|
||||||
Button(
|
Button(
|
||||||
modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Auth.SIGN_BUTTON)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp),
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.onIntent(AuthIntent.Send(inputText))
|
viewModel.onIntent(AuthIntent.Send(inputText))
|
||||||
},
|
},
|
||||||
enabled = true
|
shape = MaterialTheme.shapes.large,
|
||||||
|
enabled = isButtonEnabled
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.auth_sign_in))
|
Text(
|
||||||
|
text = stringResource(R.string.auth_sign_in),
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,4 +3,5 @@ package ru.myitschool.work.ui.screen.auth
|
|||||||
sealed interface AuthState {
|
sealed interface AuthState {
|
||||||
object Loading: AuthState
|
object Loading: AuthState
|
||||||
object Data: AuthState
|
object Data: AuthState
|
||||||
|
object Error: AuthState
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package ru.myitschool.work.ui.screen.auth
|
package ru.myitschool.work.ui.screen.auth
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -13,31 +14,45 @@ import kotlinx.coroutines.launch
|
|||||||
import ru.myitschool.work.data.repo.AuthRepository
|
import ru.myitschool.work.data.repo.AuthRepository
|
||||||
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
|
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
|
||||||
|
|
||||||
class AuthViewModel : ViewModel() {
|
class AuthViewModel() : ViewModel() {
|
||||||
private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) }
|
private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) }
|
||||||
private val _uiState = MutableStateFlow<AuthState>(AuthState.Data)
|
private val _uiState = MutableStateFlow<AuthState>(AuthState.Data)
|
||||||
val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
|
val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _errorFlow = MutableStateFlow<String>("")
|
||||||
|
val errorFlow: StateFlow<String> = _errorFlow.asStateFlow()
|
||||||
|
|
||||||
|
private val _isButtonEnabled = MutableStateFlow<Boolean>(false)
|
||||||
|
val isButtonEnabled: StateFlow<Boolean> = _isButtonEnabled.asStateFlow()
|
||||||
|
|
||||||
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
|
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
|
||||||
val actionFlow: SharedFlow<Unit> = _actionFlow
|
val actionFlow: SharedFlow<Unit> = _actionFlow
|
||||||
|
|
||||||
fun onIntent(intent: AuthIntent) {
|
fun onIntent(intent: AuthIntent) {
|
||||||
when (intent) {
|
when (intent) {
|
||||||
is AuthIntent.Send -> {
|
is AuthIntent.Send -> {
|
||||||
|
onIntent(AuthIntent.TextInput(""))
|
||||||
viewModelScope.launch(Dispatchers.Default) {
|
viewModelScope.launch(Dispatchers.Default) {
|
||||||
_uiState.update { AuthState.Loading }
|
_uiState.update { AuthState.Loading }
|
||||||
checkAndSaveAuthCodeUseCase.invoke("9999").fold(
|
checkAndSaveAuthCodeUseCase.invoke(intent.text).fold(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
_actionFlow.emit(Unit)
|
_actionFlow.emit(Unit)
|
||||||
},
|
},
|
||||||
onFailure = { error ->
|
onFailure = { error ->
|
||||||
error.printStackTrace()
|
error.printStackTrace()
|
||||||
_actionFlow.emit(Unit)
|
_errorFlow.update { error.message.toString() }
|
||||||
|
_uiState.update { AuthState.Error }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is AuthIntent.TextInput -> Unit
|
|
||||||
|
is AuthIntent.TextInput -> {
|
||||||
|
if (_uiState.value == AuthState.Error) _uiState.update { AuthState.Data }
|
||||||
|
if (intent.text.matches("[a-zA-Z0-9]{4}".toRegex())) {
|
||||||
|
_isButtonEnabled.update { true }
|
||||||
|
} else _isButtonEnabled.update { false }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package ru.myitschool.work.ui.screen.book
|
||||||
|
|
||||||
|
sealed interface BookIntent {
|
||||||
|
data object Fetch: BookIntent
|
||||||
|
data class Book(val date: String, val placeId: Int): BookIntent
|
||||||
|
data object GoBack: BookIntent
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package ru.myitschool.work.ui.screen.book
|
||||||
|
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBackIosNew
|
||||||
|
import androidx.compose.material.icons.filled.BookmarkAdd
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
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.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import ru.myitschool.work.core.TestIds
|
||||||
|
import ru.myitschool.work.data.models.Booking
|
||||||
|
import ru.myitschool.work.ui.nav.MainScreenDestination
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BookScreen(
|
||||||
|
navController: NavController,
|
||||||
|
viewModel: BookViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
val info by viewModel.infoFlow.collectAsState()
|
||||||
|
val err by viewModel.errorFlow.collectAsState()
|
||||||
|
var selectedTabIndex by remember { mutableStateOf(0) }
|
||||||
|
var selectedPlaceIndex by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
){
|
||||||
|
when(val currentState = state) {
|
||||||
|
is BookState.Loading -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BookState.Error -> {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = err,
|
||||||
|
modifier = Modifier.testTag(TestIds.Book.ERROR),
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = { viewModel.onIntent(BookIntent.Fetch) },
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.offset(x = -16.dp, y = -16.dp)
|
||||||
|
.testTag(TestIds.Book.REFRESH_BUTTON)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BookState.DataPresent -> {
|
||||||
|
val entriesList = info!!.entries.toList()
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
|
||||||
|
PrimaryScrollableTabRow(
|
||||||
|
selectedTabIndex = selectedTabIndex,
|
||||||
|
edgePadding = 16.dp,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.primary,
|
||||||
|
) {
|
||||||
|
entriesList.forEachIndexed { index, entry ->
|
||||||
|
Tab(
|
||||||
|
selected = selectedTabIndex == index,
|
||||||
|
onClick = {
|
||||||
|
selectedTabIndex = index
|
||||||
|
selectedPlaceIndex = 0
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = LocalDate.parse(entry.key).format(DateTimeFormatter.ofPattern("dd.MM")),
|
||||||
|
modifier = Modifier.testTag(TestIds.Book.ITEM_DATE)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.testTag(TestIds.Book.getIdDateItemByPosition(index))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
itemsIndexed(entriesList[selectedTabIndex].value) { index, booking ->
|
||||||
|
Booking(
|
||||||
|
booking = booking,
|
||||||
|
index = index,
|
||||||
|
selected = selectedPlaceIndex,
|
||||||
|
onRadioChange = { selectedPlaceIndex = index }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = { viewModel.onIntent(BookIntent.Book(
|
||||||
|
date = entriesList[selectedTabIndex].key,
|
||||||
|
placeId = entriesList[selectedTabIndex].value[selectedPlaceIndex].id
|
||||||
|
)) },
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
text = {
|
||||||
|
Text("Бронировать")
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
Icon(Icons.Default.BookmarkAdd, contentDescription = "Бронировать")
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.offset(x = -16.dp, y = -16.dp)
|
||||||
|
.testTag(TestIds.Book.BOOK_BUTTON)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is BookState.DataAbsent -> {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
Text(text = "Всё забронировано", modifier = Modifier.testTag(TestIds.Book.EMPTY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = { viewModel.onIntent(BookIntent.GoBack) },
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.offset(x = 16.dp, y = -16.dp)
|
||||||
|
.testTag(TestIds.Book.BACK_BUTTON)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.ArrowBackIosNew, contentDescription = "Назад")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.onIntent(BookIntent.Fetch)
|
||||||
|
viewModel.actionFlow.collect {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Booking(booking: Booking, index: Int, selected: Int, onRadioChange: Function0<Unit>) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
// .clickable { }
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
.testTag(TestIds.Book.getIdPlaceItemByPosition(index)),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = selected == index,
|
||||||
|
onClick = onRadioChange,
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Book.ITEM_PLACE_SELECTOR)
|
||||||
|
// .selectable(
|
||||||
|
// selected = false,
|
||||||
|
// onClick = {}
|
||||||
|
// )
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = booking.place,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.testTag(TestIds.Book.ITEM_PLACE_TEXT)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
thickness = 1.dp,
|
||||||
|
modifier = Modifier.padding(start = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package ru.myitschool.work.ui.screen.book
|
||||||
|
|
||||||
|
sealed interface BookState {
|
||||||
|
data object Loading: BookState
|
||||||
|
data object DataPresent: BookState
|
||||||
|
data object DataAbsent: BookState
|
||||||
|
data object Error: BookState
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package ru.myitschool.work.ui.screen.book
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
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.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.myitschool.work.data.models.BookingInfo
|
||||||
|
import ru.myitschool.work.data.repo.BookRepository
|
||||||
|
import ru.myitschool.work.domain.book.Book
|
||||||
|
import ru.myitschool.work.domain.book.Fetch
|
||||||
|
|
||||||
|
class BookViewModel(): ViewModel() {
|
||||||
|
private val fetch by lazy { Fetch(BookRepository) }
|
||||||
|
private val book by lazy { Book(BookRepository) }
|
||||||
|
private val _uiState = MutableStateFlow<BookState>(BookState.Loading)
|
||||||
|
val uiState: StateFlow<BookState> = _uiState.asStateFlow()
|
||||||
|
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
|
||||||
|
val actionFlow: SharedFlow<Unit> = _actionFlow
|
||||||
|
private val _infoFlow: MutableStateFlow<BookingInfo?> = MutableStateFlow(null)
|
||||||
|
val infoFlow: StateFlow<BookingInfo?> = _infoFlow.asStateFlow()
|
||||||
|
private val _errorFlow = MutableStateFlow<String>("")
|
||||||
|
val errorFlow: StateFlow<String> = _errorFlow.asStateFlow()
|
||||||
|
|
||||||
|
fun onIntent(intent: BookIntent) {
|
||||||
|
when(intent) {
|
||||||
|
is BookIntent.Fetch -> viewModelScope.launch {
|
||||||
|
_uiState.update { BookState.Loading }
|
||||||
|
fetch.invoke().fold(
|
||||||
|
onSuccess = { success ->
|
||||||
|
if (success.isEmpty()) {
|
||||||
|
_uiState.update { BookState.DataAbsent }
|
||||||
|
} else {
|
||||||
|
_infoFlow.update { success }
|
||||||
|
_uiState.update { BookState.DataPresent }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = { failure ->
|
||||||
|
Log.d(failure.message, "failure")
|
||||||
|
_uiState.update { BookState.Error }
|
||||||
|
_errorFlow.update { failure.message.toString() }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is BookIntent.Book -> viewModelScope.launch {
|
||||||
|
_uiState.update { BookState.Loading }
|
||||||
|
book.invoke(intent.date, intent.placeId).fold(
|
||||||
|
onSuccess = { success ->
|
||||||
|
_actionFlow.emit(Unit)
|
||||||
|
},
|
||||||
|
onFailure = { failure ->
|
||||||
|
Log.d(failure.message, "failure")
|
||||||
|
_uiState.update { BookState.Error }
|
||||||
|
_errorFlow.update { failure.message.toString() }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is BookIntent.GoBack -> viewModelScope.launch {
|
||||||
|
_actionFlow.emit(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package ru.myitschool.work.ui.screen.main
|
||||||
|
|
||||||
|
sealed interface MainIntent {
|
||||||
|
data object Fetch: MainIntent
|
||||||
|
data object Logout: MainIntent
|
||||||
|
data object NewBooking: MainIntent
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
package ru.myitschool.work.ui.screen.main
|
||||||
|
|
||||||
|
import android.graphics.drawable.Icon
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.Logout
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.BookmarkBorder
|
||||||
|
import androidx.compose.material.icons.filled.Bookmarks
|
||||||
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import coil3.compose.rememberAsyncImagePainter
|
||||||
|
import ru.myitschool.work.core.TestIds
|
||||||
|
import ru.myitschool.work.data.models.Booking
|
||||||
|
import ru.myitschool.work.ui.nav.BookScreenDestination
|
||||||
|
import ru.myitschool.work.ui.nav.MainScreenDestination
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(
|
||||||
|
viewModel: MainViewModel = viewModel(),
|
||||||
|
navController: NavController
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
val err by viewModel.errorFlow.collectAsState()
|
||||||
|
val info by viewModel.infoFlow.collectAsState()
|
||||||
|
|
||||||
|
when (val currentState = state) {
|
||||||
|
is MainState.Error -> {
|
||||||
|
Column (
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = err,
|
||||||
|
modifier = Modifier.testTag(TestIds.Main.ERROR),
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.onIntent(MainIntent.Fetch) },
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.testTag(TestIds.Main.REFRESH_BUTTON),
|
||||||
|
enabled = true,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Refresh,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is MainState.Loading -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MainState.Data -> {
|
||||||
|
info?.let {
|
||||||
|
Column (
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 20.dp),
|
||||||
|
) {
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = { viewModel.onIntent(MainIntent.Logout) },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.size(40.dp)
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.offset(x = -16.dp)
|
||||||
|
.testTag(TestIds.Main.LOGOUT_BUTTON),
|
||||||
|
enabled = true,
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Outlined.Logout,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(120.dp)
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.background(MaterialTheme.colorScheme.inverseOnSurface, CircleShape)
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = rememberAsyncImagePainter(info!!.photoUrl),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(105.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.testTag(TestIds.Main.PROFILE_IMAGE)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.size(12.dp))
|
||||||
|
Text(
|
||||||
|
text = info!!.name,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(TestIds.Main.PROFILE_NAME)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerLow, RoundedCornerShape(topEnd = 24.dp , topStart = 24.dp))
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(vertical = 16.dp, horizontal = 16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Absolute.SpaceBetween
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.BookmarkBorder,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Бронирования",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.onIntent(MainIntent.Fetch) },
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.testTag(TestIds.Main.REFRESH_BUTTON),
|
||||||
|
enabled = true,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Refresh,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
thickness = 1.dp,
|
||||||
|
)
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
itemsIndexed(info!!.booking.entries.toList()) { index, booking ->
|
||||||
|
Booking(booking = booking.value, date = booking.key, index = index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = { viewModel.onIntent(MainIntent.NewBooking) },
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.offset(x = -16.dp, y = -16.dp)
|
||||||
|
.testTag(TestIds.Main.ADD_BUTTON)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Добавить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.onIntent(MainIntent.Fetch)
|
||||||
|
viewModel.actionFlow.collect {
|
||||||
|
navController.navigate(BookScreenDestination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Booking(booking: Booking, date: String, index: Int){
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
// .clickable { }
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
.testTag(TestIds.Main.getIdItemByPosition(index)),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = LocalDate.parse(date).format(DateTimeFormatter.ofPattern("dd.MM.yyyy")),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.testTag(TestIds.Main.ITEM_DATE)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = booking.place,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ChevronRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
thickness = 1.dp,
|
||||||
|
modifier = Modifier.padding(start = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package ru.myitschool.work.ui.screen.main
|
||||||
|
|
||||||
|
sealed interface MainState {
|
||||||
|
object Loading: MainState
|
||||||
|
|
||||||
|
object Data: MainState
|
||||||
|
|
||||||
|
object Error: MainState
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package ru.myitschool.work.ui.screen.main
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
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.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.myitschool.work.data.models.UserInfo
|
||||||
|
import ru.myitschool.work.data.repo.AuthRepository
|
||||||
|
import ru.myitschool.work.data.repo.MainRepository
|
||||||
|
import ru.myitschool.work.domain.main.Fetch
|
||||||
|
import ru.myitschool.work.domain.main.Logout
|
||||||
|
|
||||||
|
class MainViewModel(): ViewModel() {
|
||||||
|
private val fetch by lazy { Fetch(MainRepository) }
|
||||||
|
private val logout by lazy { Logout(AuthRepository) }
|
||||||
|
private val _uiState = MutableStateFlow<MainState>(MainState.Loading)
|
||||||
|
val uiState: StateFlow<MainState> = _uiState.asStateFlow()
|
||||||
|
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
|
||||||
|
val actionFlow: SharedFlow<Unit> = _actionFlow
|
||||||
|
private val _infoFlow: MutableStateFlow<UserInfo?> = MutableStateFlow(null)
|
||||||
|
val infoFlow: StateFlow<UserInfo?> = _infoFlow.asStateFlow()
|
||||||
|
private val _errorFlow = MutableStateFlow<String>("")
|
||||||
|
val errorFlow: StateFlow<String> = _errorFlow.asStateFlow()
|
||||||
|
|
||||||
|
fun onIntent(intent: MainIntent) {
|
||||||
|
when (intent) {
|
||||||
|
is MainIntent.Fetch -> viewModelScope.launch {
|
||||||
|
_uiState.update { MainState.Loading }
|
||||||
|
fetch.invoke().fold(
|
||||||
|
onSuccess = { success ->
|
||||||
|
_infoFlow.update { success }
|
||||||
|
_uiState.update { MainState.Data }
|
||||||
|
},
|
||||||
|
onFailure = { failure ->
|
||||||
|
Log.d(failure.message, "failure")
|
||||||
|
_uiState.update { MainState.Error }
|
||||||
|
_errorFlow.update { failure.message.toString() }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MainIntent.Logout -> {
|
||||||
|
viewModelScope.launch {
|
||||||
|
logout.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is MainIntent.NewBooking -> {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_actionFlow.emit(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package ru.myitschool.work.ui.screen.splash
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SplashScreen() {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -52,6 +52,6 @@ fun WorkTheme(
|
|||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
typography = Typography,
|
typography = Typography,
|
||||||
content = content
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Work</string>
|
<string name="app_name">Work</string>
|
||||||
<string name="title_activity_root">RootActivity</string>
|
<string name="title_activity_root">RootActivity</string>
|
||||||
<string name="auth_title">Привет! Введи код для авторизации</string>
|
<string name="auth_title">Авторизируйтесь при помощи кода</string>
|
||||||
<string name="auth_label">Код</string>
|
<string name="auth_label">Код</string>
|
||||||
<string name="auth_sign_in">Войти</string>
|
<string name="auth_sign_in">Войти</string>
|
||||||
</resources>
|
</resources>
|
||||||
Reference in New Issue
Block a user