diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8c899db..fe08381 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,7 @@ plugins { + composeCompiler kotlinAndroid + kotlinSerialization version Version.Kotlin.language androidApplication } @@ -19,6 +21,7 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + buildFeatures.compose = true buildFeatures.viewBinding = true compileOptions { @@ -32,6 +35,11 @@ android { } dependencies { - defaultLibrary() - + defaultComposeLibrary() + val ktor = "3.3.1" + implementation("io.ktor:ktor-client-core:$ktor") + implementation("io.ktor:ktor-client-cio:$ktor") + implementation("io.ktor:ktor-client-content-negotiation:$ktor") + implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9ee5c40..5531bab 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + + android:usesCleartextTraffic="true" + tools:targetApi="31"> + + + + + + + + \ 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 new file mode 100644 index 0000000..4d49e30 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/core/Constants.kt @@ -0,0 +1,9 @@ +package ru.myitschool.work.core + +object Constants { + const val HOST = "http://10.0.2.2:8080" + const val USER_URL = "/user" + const val FULL_USER_URL = "$HOST$USER_URL" + const val BOOK_URL = "/book" + const val FULL_BOOK_URL = "$HOST$BOOK_URL" +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/AppRepository.kt b/app/src/main/java/ru/myitschool/work/data/AppRepository.kt new file mode 100644 index 0000000..8c17dc7 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/AppRepository.kt @@ -0,0 +1,26 @@ +package ru.myitschool.work.data + +import ru.myitschool.work.data.source.NetworkDataSource +import ru.myitschool.work.domain.entities.BookingEntity +import ru.myitschool.work.domain.entities.UserEntity + +object AppRepository { + suspend fun loadData(): Result { + return NetworkDataSource.getUser().map { dto -> + UserEntity( + name = dto.name, + booking = dto.booking.map { bookingDto -> + BookingEntity( + room = bookingDto.room, + time = bookingDto.time + ) + } + ) + } + } + + suspend fun addBook(room: String, time: String): Result { + return NetworkDataSource.addBook(room, time) + } + +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/dto/ErrorDto.kt b/app/src/main/java/ru/myitschool/work/data/dto/ErrorDto.kt new file mode 100644 index 0000000..c04ff69 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/dto/ErrorDto.kt @@ -0,0 +1,10 @@ +package ru.myitschool.work.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ErrorDto( + @SerialName("error") + val error: String, +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/dto/UserDto.kt b/app/src/main/java/ru/myitschool/work/data/dto/UserDto.kt new file mode 100644 index 0000000..dd574d3 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/dto/UserDto.kt @@ -0,0 +1,20 @@ +package ru.myitschool.work.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UserDto( + @SerialName("name") + val name: String, + @SerialName("booking") + val booking: List +) { + @Serializable + data class BookingDto( + @SerialName("room") + val room: String, + @SerialName("time") + val time: String, + ) +} \ 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 new file mode 100644 index 0000000..305c70b --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/source/NetworkDataSource.kt @@ -0,0 +1,71 @@ +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.forms.FormPart +import io.ktor.client.request.forms.MultiPartFormDataContent +import io.ktor.client.request.forms.formData +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import ru.myitschool.work.core.Constants +import ru.myitschool.work.data.dto.ErrorDto +import ru.myitschool.work.data.dto.UserDto + +object NetworkDataSource { + private val client by lazy { + HttpClient(CIO) { + install(ContentNegotiation) { + json( + Json { + isLenient = true + ignoreUnknownKeys = true + explicitNulls = true + encodeDefaults = true + } + ) + } + } + } + + suspend fun getUser(): Result = withContext(Dispatchers.IO) { + return@withContext runCatching { + val response = client.get(Constants.FULL_USER_URL) + if (response.status == HttpStatusCode.OK) { + response.body() + } else { + error(response.body().error) + } + } + } + + suspend fun addBook( + room: String, + time: String, + ): Result = withContext(Dispatchers.IO) { + return@withContext runCatching { + val response = client.post(Constants.FULL_BOOK_URL) { + setBody( + MultiPartFormDataContent( + formData( + FormPart("room", room), + FormPart("time", time), + ) + ) + ) + } + if (response.status == HttpStatusCode.OK) { + Unit + } else { + error(response.body().error) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/AddBookUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/AddBookUseCase.kt new file mode 100644 index 0000000..12dc66b --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/AddBookUseCase.kt @@ -0,0 +1,17 @@ +package ru.myitschool.work.domain + +import ru.myitschool.work.data.AppRepository + +class AddBookUseCase( + private val repository: AppRepository +) { + suspend operator fun invoke( + room: String, + time: String, + ): Result { + return repository.addBook( + room = room, + time = time, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/GetUserDataUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/GetUserDataUseCase.kt new file mode 100644 index 0000000..3417a93 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/GetUserDataUseCase.kt @@ -0,0 +1,12 @@ +package ru.myitschool.work.domain + +import ru.myitschool.work.data.AppRepository +import ru.myitschool.work.domain.entities.UserEntity + +class GetUserDataUseCase( + private val repository: AppRepository +) { + suspend operator fun invoke(): Result { + return repository.loadData() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/entities/BookingEntity.kt b/app/src/main/java/ru/myitschool/work/domain/entities/BookingEntity.kt new file mode 100644 index 0000000..770758b --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/entities/BookingEntity.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.domain.entities + +data class BookingEntity( + val room: String, + val time: String, +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/entities/UserEntity.kt b/app/src/main/java/ru/myitschool/work/domain/entities/UserEntity.kt new file mode 100644 index 0000000..f763a78 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/entities/UserEntity.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.domain.entities + +data class UserEntity( + val name: String, + val booking: List +) \ 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 new file mode 100644 index 0000000..29370da --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/root/RootActivity.kt @@ -0,0 +1,181 @@ +package ru.myitschool.work.ui.root + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +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.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import ru.myitschool.work.ui.theme.WorkTheme + +class RootActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + WorkTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Screen( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) + } + } + } + } +} + +@Composable +fun Screen( + modifier: Modifier = Modifier, + viewModel: RootViewModel = viewModel() +) { + val state by viewModel.uiState.collectAsState() + + when (val currentState = state) { + is RootState.Content -> { + Column( + modifier = modifier, + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = currentState.userEntity.name, + style = MaterialTheme.typography.headlineSmall + ) + ButtonGetData(viewModel) + } + LazyColumn( + modifier = Modifier.weight(1f) + ) { + items(currentState.userEntity.booking) { book -> + Row( + modifier = Modifier.padding(vertical = 16.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = book.room, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = book.time, + style = MaterialTheme.typography.bodySmall + ) + } + } + } + Row( + modifier = Modifier.padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + var roomText by remember { mutableStateOf("") } + var timeText by remember { mutableStateOf("") } + + Column(modifier = Modifier.weight(1f)) { + TextField( + modifier = Modifier.fillMaxWidth(), + value = roomText, + onValueChange = { roomText = it }, + label = { Text("Room") } + ) + TextField( + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth(), + value = timeText, + onValueChange = { timeText = it }, + label = { Text("Time") } + ) + } + Column( + modifier = Modifier.padding(start = 8.dp), + ) { + Button( + onClick = { + viewModel.onIntent( + RootIntent.AddBook(room = roomText, time = timeText) + ) + roomText = "" + timeText = "" + } + ) { + Text(text = "Add") + } + if (currentState.errorText != null) { + Text(text = currentState.errorText) + } + } + } + } + + } + + is RootState.Error -> { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = currentState.message) + ButtonGetData(viewModel) + } + } + + is RootState.Loading -> { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(64.dp)) + } + } + + is RootState.NotLoaded -> { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + ButtonGetData(viewModel) + } + + } + } +} + +@Composable +private fun ButtonGetData( + viewModel: RootViewModel +) { + Button(onClick = { viewModel.onIntent(RootIntent.LoadData) }) { + Text(text = "Get load") + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/root/RootIntent.kt b/app/src/main/java/ru/myitschool/work/ui/root/RootIntent.kt new file mode 100644 index 0000000..9804e07 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/root/RootIntent.kt @@ -0,0 +1,9 @@ +package ru.myitschool.work.ui.root + +sealed interface RootIntent { + data object LoadData: RootIntent + data class AddBook( + val room: String, + val time: String + ): RootIntent +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/root/RootState.kt b/app/src/main/java/ru/myitschool/work/ui/root/RootState.kt new file mode 100644 index 0000000..1c5d32f --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/root/RootState.kt @@ -0,0 +1,13 @@ +package ru.myitschool.work.ui.root + +import ru.myitschool.work.domain.entities.UserEntity + +sealed interface RootState { + data object NotLoaded: RootState + data object Loading: RootState + data class Error(val message: String): RootState + data class Content( + val userEntity: UserEntity, + val errorText: String?, + ): RootState +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/root/RootViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/root/RootViewModel.kt new file mode 100644 index 0000000..d95ac89 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/root/RootViewModel.kt @@ -0,0 +1,68 @@ +package ru.myitschool.work.ui.root + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import ru.myitschool.work.data.AppRepository +import ru.myitschool.work.domain.AddBookUseCase +import ru.myitschool.work.domain.GetUserDataUseCase + +class RootViewModel : ViewModel() { + private val getUserDataUseCase by lazy { + GetUserDataUseCase( + repository = AppRepository + ) + } + private val addBookUseCase by lazy { + AddBookUseCase( + repository = AppRepository + ) + } + private val _uiState = MutableStateFlow(RootState.NotLoaded) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onIntent(intent: RootIntent) { + when (intent) { + is RootIntent.LoadData -> loadData() + is RootIntent.AddBook -> addBook(intent) + } + } + + private fun loadData() { + viewModelScope.launch { + _uiState.emit(RootState.Loading) + getUserDataUseCase.invoke().fold( + onSuccess = { value -> + _uiState.emit(RootState.Content(userEntity = value, errorText = null)) + }, + onFailure = { error -> + _uiState.emit(RootState.Error(error.message.orEmpty())) + } + ) + } + } + + private fun addBook(intent: RootIntent.AddBook) { + viewModelScope.launch { + addBookUseCase.invoke( + room = intent.room, + time = intent.time + ).fold( + onSuccess = { + loadData() + }, + onFailure = { error -> + _uiState.update { state -> + (state as? RootState.Content)?.copy( + errorText = error.message + ) ?: state + } + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/theme/Color.kt b/app/src/main/java/ru/myitschool/work/ui/theme/Color.kt new file mode 100644 index 0000000..22226f4 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package ru.myitschool.work.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/theme/Theme.kt b/app/src/main/java/ru/myitschool/work/ui/theme/Theme.kt new file mode 100644 index 0000000..d9cc58f --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/theme/Theme.kt @@ -0,0 +1,57 @@ +package ru.myitschool.work.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun WorkTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/theme/Type.kt b/app/src/main/java/ru/myitschool/work/ui/theme/Type.kt new file mode 100644 index 0000000..61b2923 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package ru.myitschool.work.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 96034ac..f175030 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ Work + RootActivity \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml deleted file mode 100644 index 89e63d4..0000000 --- a/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 64d8748..572c00e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,4 +2,6 @@ plugins { androidApplication version Version.agp apply false kotlinJvm version Version.Kotlin.language apply false + kotlinAndroid version Version.Kotlin.language apply false + composeCompiler version Version.Kotlin.language apply false } \ No newline at end of file diff --git a/buildSrc b/buildSrc index d959060..a2f9220 160000 --- a/buildSrc +++ b/buildSrc @@ -1 +1 @@ -Subproject commit d9590600045906edeb852eaa3f0b9bf7d1875813 +Subproject commit a2f9220e215ba488ddacdee3761182f23b060d2f