diff --git a/.gitignore b/.gitignore index b3de91f7e0f..9ab78f4efeb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # IntelliJ *.iml +/.idea /.idea/caches /.idea/libraries /.idea/modules.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 821554dc7ea..44cb024b2c1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,8 +1,11 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.ksp) + id("ru.practicum.android.diploma.plugins.developproperties") } @@ -11,6 +14,7 @@ android { compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { + applicationId = "ru.practicum.android.diploma" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() @@ -34,6 +38,7 @@ android { } buildFeatures { buildConfig = true + compose = true } } @@ -44,14 +49,59 @@ kotlin { } dependencies { + // Core implementation(libs.core.ktx) implementation(libs.appcompat) + implementation(libs.legacy.support.v4) + implementation(libs.lifecycle.livedata.ktx) + implementation(libs.lifecycle.viewmodel.ktx) // UI layer libraries implementation(libs.material) implementation(libs.constraintlayout) + implementation(libs.firebase.crashlytics.buildtools) + + implementation(libs.coroutines.core) + implementation(libs.coroutines.android) + + implementation(platform(libs.compose.bom)) + implementation(libs.compose.coil) + implementation("io.coil-kt.coil3:coil-network-okhttp:3.1.0") + implementation(libs.coil.svg) + implementation(libs.compose.ui) + implementation(libs.androidx.viewmodel) testImplementation(libs.junit4) + testImplementation(libs.coroutines.test) + androidTestImplementation(libs.junit.ext) + androidTestImplementation(libs.espresso.core) + implementation(libs.navigation.fragment.ktx) + implementation(libs.navigation.ui.ktx) + implementation(libs.fragment.ktx) + implementation(libs.material.v180) + + implementation(libs.material3) + implementation(libs.ui.tooling.preview) + debugImplementation(libs.ui.tooling) + implementation(libs.activity.compose) + implementation(libs.lifecycle.viewmodel.compose) + + // Data layer + implementation(libs.retrofit) + implementation(libs.retrofit.converter) + + ksp(libs.room.compiler) + implementation(libs.room.runtime) + implementation(libs.room.ktx) + + // DI + implementation(libs.koin) + + // Test + testImplementation(libs.junit4) + testImplementation(libs.coroutines.test) + + androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.junit.ext) androidTestImplementation(libs.espresso.core) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 267e2845990..994c3c7a848 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,11 @@ + + + + android:exported="true" + android:screenOrientation="portrait"> @@ -23,4 +29,4 @@ - \ No newline at end of file + diff --git a/app/src/main/java/ru/practicum/android/diploma/data/NetworkClient.kt b/app/src/main/java/ru/practicum/android/diploma/data/NetworkClient.kt new file mode 100644 index 00000000000..a7bcd342520 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/data/NetworkClient.kt @@ -0,0 +1,7 @@ +package ru.practicum.android.diploma.data + +import ru.practicum.android.diploma.data.dto.Response + +interface NetworkClient { + suspend fun doRequest(dto: Any): Response +} diff --git a/app/src/main/java/ru/practicum/android/diploma/data/UserDataRepositoryImpl.kt b/app/src/main/java/ru/practicum/android/diploma/data/UserDataRepositoryImpl.kt new file mode 100644 index 00000000000..51fef216720 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/data/UserDataRepositoryImpl.kt @@ -0,0 +1,9 @@ +package ru.practicum.android.diploma.data + +import ru.practicum.android.diploma.BuildConfig +import ru.practicum.android.diploma.domain.api.UserDataRepository + +class UserDataRepositoryImpl : UserDataRepository { + + override fun getAuthToken(): String = BuildConfig.API_ACCESS_TOKEN +} diff --git a/app/src/main/java/ru/practicum/android/diploma/data/VacanciesRepositoryImpl.kt b/app/src/main/java/ru/practicum/android/diploma/data/VacanciesRepositoryImpl.kt new file mode 100644 index 00000000000..adcc8ded1b7 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/data/VacanciesRepositoryImpl.kt @@ -0,0 +1,33 @@ +package ru.practicum.android.diploma.data + +import ru.practicum.android.diploma.data.dto.VacanciesResponse +import ru.practicum.android.diploma.data.network.VacanciesRequest +import ru.practicum.android.diploma.domain.api.VacanciesRepository +import ru.practicum.android.diploma.domain.models.SearchVacanciesOutcome + +class VacanciesRepositoryImpl( + private val networkClient: NetworkClient, +) : VacanciesRepository { + + override suspend fun searchVacancies(searchText: String, page: Int): SearchVacanciesOutcome { + val response = networkClient.doRequest( + VacanciesRequest(searchText = searchText, page = page), + ) + val data = response.data as? VacanciesResponse + return when { + response.resultCode != HTTP_OK || data == null -> SearchVacanciesOutcome.Error + else -> { + val domainResult = data.toDomain() + if (domainResult.vacancies.isEmpty()) { + SearchVacanciesOutcome.Empty + } else { + SearchVacanciesOutcome.Success(domainResult) + } + } + } + } + + private companion object { + const val HTTP_OK = 200 + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/data/VacancyMapper.kt b/app/src/main/java/ru/practicum/android/diploma/data/VacancyMapper.kt new file mode 100644 index 00000000000..8356db1244a --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/data/VacancyMapper.kt @@ -0,0 +1,30 @@ +package ru.practicum.android.diploma.data + +import ru.practicum.android.diploma.data.dto.VacanciesResponse +import ru.practicum.android.diploma.data.dto.VacancyCardDto +import ru.practicum.android.diploma.data.dto.VacancyCardSalaryDto +import ru.practicum.android.diploma.domain.models.Salary +import ru.practicum.android.diploma.domain.models.VacanciesSearchResult +import ru.practicum.android.diploma.domain.models.Vacancy + +internal fun VacanciesResponse.toDomain(): VacanciesSearchResult = VacanciesSearchResult( + found = found, + page = page, + pages = pages, + vacancies = items.map { it.toDomain() }, +) + +private fun VacancyCardDto.toDomain(): Vacancy = Vacancy( + id = id, + name = name, + company = company, + city = city, + salary = salary?.toDomain(), + logo = logo, +) + +private fun VacancyCardSalaryDto.toDomain(): Salary = Salary( + from = from, + to = to, + currency = currency, +) diff --git a/app/src/main/java/ru/practicum/android/diploma/data/dto/Currency.kt b/app/src/main/java/ru/practicum/android/diploma/data/dto/Currency.kt new file mode 100644 index 00000000000..0732a1377d6 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/data/dto/Currency.kt @@ -0,0 +1,15 @@ +package ru.practicum.android.diploma.data.dto + +enum class Currency { + RUR, + RUB, + BYR, + USD, + EUR, + KZT, + UAH, + AZN, + UZS, + GEL, + KGT +} diff --git a/app/src/main/java/ru/practicum/android/diploma/data/dto/FilterAreaDto.kt b/app/src/main/java/ru/practicum/android/diploma/data/dto/FilterAreaDto.kt new file mode 100644 index 00000000000..8f3b05d977d --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/data/dto/FilterAreaDto.kt @@ -0,0 +1,8 @@ +package ru.practicum.android.diploma.data.dto + +data class FilterAreaDto( + val id: Int, + val name: String, + val parentId: Int?, + val areas: List +) diff --git a/app/src/main/java/ru/practicum/android/diploma/data/dto/FilterIndustryDto.kt b/app/src/main/java/ru/practicum/android/diploma/data/dto/FilterIndustryDto.kt new file mode 100644 index 00000000000..42d7a01c412 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/data/dto/FilterIndustryDto.kt @@ -0,0 +1,6 @@ +package ru.practicum.android.diploma.data.dto + +data class FilterIndustryDto( + val id: Int, + val name: String +) diff --git a/app/src/main/java/ru/practicum/android/diploma/data/dto/Response.kt b/app/src/main/java/ru/practicum/android/diploma/data/dto/Response.kt new file mode 100644 index 00000000000..13d168e7c14 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/data/dto/Response.kt @@ -0,0 +1,6 @@ +package ru.practicum.android.diploma.data.dto + +data class Response( + val data: T?, + val resultCode: Int = 0, +) diff --git a/app/src/main/java/ru/practicum/android/diploma/data/dto/VacanciesResponse.kt b/app/src/main/java/ru/practicum/android/diploma/data/dto/VacanciesResponse.kt new file mode 100644 index 00000000000..a6298c9139f --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/data/dto/VacanciesResponse.kt @@ -0,0 +1,8 @@ +package ru.practicum.android.diploma.data.dto + +data class VacanciesResponse( + val found: Int, + val pages: Int, + val page: Int, + val items: List +) diff --git a/app/src/main/java/ru/practicum/android/diploma/data/dto/VacancyCardDto.kt b/app/src/main/java/ru/practicum/android/diploma/data/dto/VacancyCardDto.kt new file mode 100644 index 00000000000..72b7675852b --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/data/dto/VacancyCardDto.kt @@ -0,0 +1,10 @@ +package ru.practicum.android.diploma.data.dto + +data class VacancyCardDto( + val id: String, + val name: String, + val company: String?, + val city: String?, + val salary: VacancyCardSalaryDto?, + val logo: String?, +) diff --git a/app/src/main/java/ru/practicum/android/diploma/data/dto/VacancyCardSalaryDto.kt b/app/src/main/java/ru/practicum/android/diploma/data/dto/VacancyCardSalaryDto.kt new file mode 100644 index 00000000000..19dc95f65ab --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/data/dto/VacancyCardSalaryDto.kt @@ -0,0 +1,7 @@ +package ru.practicum.android.diploma.data.dto + +data class VacancyCardSalaryDto( + val from: Int?, + val to: Int?, + val currency: String? +) diff --git a/app/src/main/java/ru/practicum/android/diploma/data/dto/VacancyDetailDto.kt b/app/src/main/java/ru/practicum/android/diploma/data/dto/VacancyDetailDto.kt new file mode 100644 index 00000000000..e06fec1e03f --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/data/dto/VacancyDetailDto.kt @@ -0,0 +1,65 @@ +package ru.practicum.android.diploma.data.dto + +data class VacancyDetailDto( + val id: String, + val name: String, + val description: String, + val salary: Salary?, + val address: Address?, + val experience: Experience?, + val schedule: Schedule?, + val employment: Employment?, + val contacts: Contacts?, + val employer: Employer, + val area: FilterAreaDto, + val skills: List?, + val url: String, + val industry: FilterIndustryDto +) + +data class Salary( + val from: Int?, + val to: Int?, + val currency: String? +) + +data class Address( + val id: String, + val city: String, + val street: String, + val building: String, + val raw: String +) + +data class Experience( + val id: String, + val name: String +) + +data class Schedule( + val id: String, + val name: String +) + +data class Employment( + val id: String, + val name: String +) + +data class Contacts( + val id: String, + val name: String, + val email: String, + val phones: List +) + +data class Phone( + val comment: String?, + val formatted: String +) + +data class Employer( + val id: String, + val name: String, + val logo: String +) diff --git a/app/src/main/java/ru/practicum/android/diploma/data/network/ApiService.kt b/app/src/main/java/ru/practicum/android/diploma/data/network/ApiService.kt new file mode 100644 index 00000000000..fd0435db101 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/data/network/ApiService.kt @@ -0,0 +1,27 @@ +package ru.practicum.android.diploma.data.network + +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.QueryMap +import ru.practicum.android.diploma.data.dto.FilterAreaDto +import ru.practicum.android.diploma.data.dto.FilterIndustryDto +import ru.practicum.android.diploma.data.dto.VacanciesResponse +import ru.practicum.android.diploma.data.dto.VacancyDetailDto + +interface ApiService { + @GET("/areas") + suspend fun getAreas(): List + + @GET("/industries") + suspend fun getIndustries(): List + + @GET("/vacancies") + suspend fun getVacancies( + @QueryMap options: Map + ): VacanciesResponse + + @GET("/vacancies/{id}") + suspend fun getVacancyDetails( + @Path("id") id: String + ): VacancyDetailDto +} diff --git a/app/src/main/java/ru/practicum/android/diploma/data/network/AreasRequest.kt b/app/src/main/java/ru/practicum/android/diploma/data/network/AreasRequest.kt new file mode 100644 index 00000000000..431b102e4cc --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/data/network/AreasRequest.kt @@ -0,0 +1,3 @@ +package ru.practicum.android.diploma.data.network + +object AreasRequest diff --git a/app/src/main/java/ru/practicum/android/diploma/data/network/AuthInterceptor.kt b/app/src/main/java/ru/practicum/android/diploma/data/network/AuthInterceptor.kt new file mode 100644 index 00000000000..87cdf6d09af --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/data/network/AuthInterceptor.kt @@ -0,0 +1,26 @@ +package ru.practicum.android.diploma.data.network + +import android.util.Log +import okhttp3.Interceptor +import okhttp3.Response +import ru.practicum.android.diploma.domain.api.UserDataRepository + +class AuthInterceptor(private val userDataRepository: UserDataRepository) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + val token = userDataRepository.getAuthToken() + + val requestBuilder = if (token != null) { + originalRequest.newBuilder() + .header("Authorization", "Bearer $token") + } else { + Log.e("AuthInterceptor", "Token is null") + originalRequest.newBuilder() + } + + val request = requestBuilder + .header("Content-Type", "application/json") + .build() + return chain.proceed(request) + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/data/network/IndustriesRequest.kt b/app/src/main/java/ru/practicum/android/diploma/data/network/IndustriesRequest.kt new file mode 100644 index 00000000000..b86b6517899 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/data/network/IndustriesRequest.kt @@ -0,0 +1,3 @@ +package ru.practicum.android.diploma.data.network + +object IndustriesRequest diff --git a/app/src/main/java/ru/practicum/android/diploma/data/network/RetrofitNetworkClient.kt b/app/src/main/java/ru/practicum/android/diploma/data/network/RetrofitNetworkClient.kt new file mode 100644 index 00000000000..fe5991f0cdb --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/data/network/RetrofitNetworkClient.kt @@ -0,0 +1,48 @@ +package ru.practicum.android.diploma.data.network + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import retrofit2.HttpException +import ru.practicum.android.diploma.data.NetworkClient +import ru.practicum.android.diploma.data.dto.Response +import ru.practicum.android.diploma.util.NetworkConnectionChecker +import java.io.IOException + +class RetrofitNetworkClient( + private val apiService: ApiService, + private val networkConnectionChecker: NetworkConnectionChecker +) : NetworkClient { + + override suspend fun doRequest(dto: Any): Response { + if (!networkConnectionChecker.isConnected()) { + return Response(data = null, resultCode = -1) + } + + return withContext(Dispatchers.IO) { + try { + val response = when (dto) { + is VacanciesRequest -> apiService.getVacancies(dto.toMap()) + is VacancyDetailsRequest -> apiService.getVacancyDetails(dto.id) + is AreasRequest -> apiService.getAreas() + is IndustriesRequest -> apiService.getIndustries() + else -> null + } + if (response == null) { + Response(data = null, resultCode = 400) + } else { + Response(data = response, resultCode = 200) + } + } catch (e: IOException) { + Log.w(TAG, "Network request failed", e) + Response(data = null, resultCode = -1) + } catch (e: HttpException) { + Response(data = null, resultCode = e.code()) + } + } + } + + private companion object { + const val TAG = "RetrofitNetworkClient" + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/data/network/VacanciesRequest.kt b/app/src/main/java/ru/practicum/android/diploma/data/network/VacanciesRequest.kt new file mode 100644 index 00000000000..5259aab6d97 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/data/network/VacanciesRequest.kt @@ -0,0 +1,23 @@ +package ru.practicum.android.diploma.data.network + +data class VacanciesRequest( + val searchText: String, + val area: Int? = null, + val industry: Int? = null, + val salary: Int? = null, + val page: Int = 0, + val onlyWithSalary: Boolean = false +) + +fun VacanciesRequest.toMap(): Map { + val options = mutableMapOf() + options["text"] = searchText + options["page"] = page.toString() + area?.let { options["area"] = it.toString() } + industry?.let { options["industry"] = it.toString() } + salary?.let { options["salary"] = it.toString() } + if (onlyWithSalary) { + options["only_with_salary"] = "true" + } + return options +} diff --git a/app/src/main/java/ru/practicum/android/diploma/data/network/VacancyDetailsRequest.kt b/app/src/main/java/ru/practicum/android/diploma/data/network/VacancyDetailsRequest.kt new file mode 100644 index 00000000000..8ed5de5da70 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/data/network/VacancyDetailsRequest.kt @@ -0,0 +1,3 @@ +package ru.practicum.android.diploma.data.network + +data class VacancyDetailsRequest(val id: String) diff --git a/app/src/main/java/ru/practicum/android/diploma/di/DataModule.kt b/app/src/main/java/ru/practicum/android/diploma/di/DataModule.kt new file mode 100644 index 00000000000..1a01a2bb511 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/di/DataModule.kt @@ -0,0 +1,41 @@ +package ru.practicum.android.diploma.di + +import com.google.gson.Gson +import okhttp3.OkHttpClient +import org.koin.dsl.module +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import ru.practicum.android.diploma.data.NetworkClient +import ru.practicum.android.diploma.data.UserDataRepositoryImpl +import ru.practicum.android.diploma.data.network.ApiService +import ru.practicum.android.diploma.data.network.AuthInterceptor +import ru.practicum.android.diploma.data.network.RetrofitNetworkClient +import ru.practicum.android.diploma.domain.api.UserDataRepository + +val dataModule = module { + + single { + OkHttpClient.Builder() + .addInterceptor(AuthInterceptor(get())) + .build() + } + + single { + Retrofit.Builder() + .baseUrl("https://android-diploma.education-services.ru") + .client(get()) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(ApiService::class.java) + } + + factory { Gson() } + + single { + RetrofitNetworkClient(get(), get()) + } + + single { + UserDataRepositoryImpl() + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/di/InteractorModule.kt b/app/src/main/java/ru/practicum/android/diploma/di/InteractorModule.kt new file mode 100644 index 00000000000..a03cd17f02b --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/di/InteractorModule.kt @@ -0,0 +1,10 @@ +package ru.practicum.android.diploma.di + +import org.koin.dsl.module +import ru.practicum.android.diploma.domain.impl.SearchInteractor + +val interactorModule = module { + factory { + SearchInteractor(get()) + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/di/RepositoryModule.kt b/app/src/main/java/ru/practicum/android/diploma/di/RepositoryModule.kt new file mode 100644 index 00000000000..322c776959c --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/di/RepositoryModule.kt @@ -0,0 +1,11 @@ +package ru.practicum.android.diploma.di + +import org.koin.dsl.module +import ru.practicum.android.diploma.data.VacanciesRepositoryImpl +import ru.practicum.android.diploma.domain.api.VacanciesRepository + +val repositoryModule = module { + single { + VacanciesRepositoryImpl(get()) + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/di/UtilsModule.kt b/app/src/main/java/ru/practicum/android/diploma/di/UtilsModule.kt new file mode 100644 index 00000000000..0a5fb7bd387 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/di/UtilsModule.kt @@ -0,0 +1,12 @@ +package ru.practicum.android.diploma.di + +import org.koin.dsl.module +import ru.practicum.android.diploma.util.NetworkConnectionChecker +import ru.practicum.android.diploma.util.NetworkConnectionCheckerImpl + +val utilsModule = module { + + single { + NetworkConnectionCheckerImpl(get()) + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/di/ViewModelModule.kt b/app/src/main/java/ru/practicum/android/diploma/di/ViewModelModule.kt new file mode 100644 index 00000000000..a5942781957 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/di/ViewModelModule.kt @@ -0,0 +1,9 @@ +package ru.practicum.android.diploma.di + +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module +import ru.practicum.android.diploma.presentation.search.viewmodel.JobSearchViewModel + +val viewModelModule = module { + viewModelOf(::JobSearchViewModel) +} diff --git a/app/src/main/java/ru/practicum/android/diploma/domain/api/UserDataRepository.kt b/app/src/main/java/ru/practicum/android/diploma/domain/api/UserDataRepository.kt new file mode 100644 index 00000000000..8ff893ab8ab --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/domain/api/UserDataRepository.kt @@ -0,0 +1,5 @@ +package ru.practicum.android.diploma.domain.api + +interface UserDataRepository { + fun getAuthToken(): String? +} diff --git a/app/src/main/java/ru/practicum/android/diploma/domain/api/VacanciesRepository.kt b/app/src/main/java/ru/practicum/android/diploma/domain/api/VacanciesRepository.kt new file mode 100644 index 00000000000..120f26491f5 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/domain/api/VacanciesRepository.kt @@ -0,0 +1,7 @@ +package ru.practicum.android.diploma.domain.api + +import ru.practicum.android.diploma.domain.models.SearchVacanciesOutcome + +interface VacanciesRepository { + suspend fun searchVacancies(searchText: String, page: Int = 0): SearchVacanciesOutcome +} diff --git a/app/src/main/java/ru/practicum/android/diploma/domain/impl/SearchInteractor.kt b/app/src/main/java/ru/practicum/android/diploma/domain/impl/SearchInteractor.kt new file mode 100644 index 00000000000..e58a1d7847a --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/domain/impl/SearchInteractor.kt @@ -0,0 +1,16 @@ +package ru.practicum.android.diploma.domain.impl + +import ru.practicum.android.diploma.domain.api.VacanciesRepository +import ru.practicum.android.diploma.domain.models.SearchVacanciesOutcome + +class SearchInteractor( + private val vacanciesRepository: VacanciesRepository, +) { + suspend fun searchVacancies(query: String, page: Int = 0): SearchVacanciesOutcome { + val normalizedQuery = query.trim().lowercase() + if (normalizedQuery.isEmpty()) { + return SearchVacanciesOutcome.Error + } + return vacanciesRepository.searchVacancies(normalizedQuery, page) + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/domain/models/Salary.kt b/app/src/main/java/ru/practicum/android/diploma/domain/models/Salary.kt new file mode 100644 index 00000000000..0a111cb5c3a --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/domain/models/Salary.kt @@ -0,0 +1,7 @@ +package ru.practicum.android.diploma.domain.models + +data class Salary( + val from: Int?, + val to: Int?, + val currency: String? +) diff --git a/app/src/main/java/ru/practicum/android/diploma/domain/models/SearchVacanciesOutcome.kt b/app/src/main/java/ru/practicum/android/diploma/domain/models/SearchVacanciesOutcome.kt new file mode 100644 index 00000000000..ea270062108 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/domain/models/SearchVacanciesOutcome.kt @@ -0,0 +1,9 @@ +package ru.practicum.android.diploma.domain.models + +sealed interface SearchVacanciesOutcome { + data class Success(val result: VacanciesSearchResult) : SearchVacanciesOutcome + + data object Empty : SearchVacanciesOutcome + + data object Error : SearchVacanciesOutcome +} diff --git a/app/src/main/java/ru/practicum/android/diploma/domain/models/VacanciesSearchResult.kt b/app/src/main/java/ru/practicum/android/diploma/domain/models/VacanciesSearchResult.kt new file mode 100644 index 00000000000..5904c83eaa7 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/domain/models/VacanciesSearchResult.kt @@ -0,0 +1,8 @@ +package ru.practicum.android.diploma.domain.models + +data class VacanciesSearchResult( + val found: Int, + val page: Int, + val pages: Int, + val vacancies: List, +) diff --git a/app/src/main/java/ru/practicum/android/diploma/domain/models/Vacancy.kt b/app/src/main/java/ru/practicum/android/diploma/domain/models/Vacancy.kt new file mode 100644 index 00000000000..bf2a051e63d --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/domain/models/Vacancy.kt @@ -0,0 +1,10 @@ +package ru.practicum.android.diploma.domain.models + +data class Vacancy( + val id: String, + val name: String, + val company: String?, + val city: String?, + val salary: Salary?, + val logo: String?, +) diff --git a/app/src/main/java/ru/practicum/android/diploma/presentation/favorites/viewmodel/FavoritesViewModel.kt b/app/src/main/java/ru/practicum/android/diploma/presentation/favorites/viewmodel/FavoritesViewModel.kt new file mode 100644 index 00000000000..43b25f59abd --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/presentation/favorites/viewmodel/FavoritesViewModel.kt @@ -0,0 +1,5 @@ +package ru.practicum.android.diploma.presentation.favorites.viewmodel + +import androidx.lifecycle.ViewModel + +class FavoritesViewModel : ViewModel() diff --git a/app/src/main/java/ru/practicum/android/diploma/presentation/filtration/country/viewmodel/ChooseCountryViewModel.kt b/app/src/main/java/ru/practicum/android/diploma/presentation/filtration/country/viewmodel/ChooseCountryViewModel.kt new file mode 100644 index 00000000000..2cb9c1042e9 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/presentation/filtration/country/viewmodel/ChooseCountryViewModel.kt @@ -0,0 +1,5 @@ +package ru.practicum.android.diploma.presentation.filtration.country.viewmodel + +import androidx.lifecycle.ViewModel + +class ChooseCountryViewModel : ViewModel() diff --git a/app/src/main/java/ru/practicum/android/diploma/presentation/filtration/industry/viewmodel/ChooseIndustryViewModel.kt b/app/src/main/java/ru/practicum/android/diploma/presentation/filtration/industry/viewmodel/ChooseIndustryViewModel.kt new file mode 100644 index 00000000000..91b8bbe9a43 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/presentation/filtration/industry/viewmodel/ChooseIndustryViewModel.kt @@ -0,0 +1,5 @@ +package ru.practicum.android.diploma.presentation.filtration.industry.viewmodel + +import androidx.lifecycle.ViewModel + +class ChooseIndustryViewModel : ViewModel() diff --git a/app/src/main/java/ru/practicum/android/diploma/presentation/filtration/region/viewmodel/ChooseRegionViewModel.kt b/app/src/main/java/ru/practicum/android/diploma/presentation/filtration/region/viewmodel/ChooseRegionViewModel.kt new file mode 100644 index 00000000000..ce206d2bcd9 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/presentation/filtration/region/viewmodel/ChooseRegionViewModel.kt @@ -0,0 +1,5 @@ +package ru.practicum.android.diploma.presentation.filtration.region.viewmodel + +import androidx.lifecycle.ViewModel + +class ChooseRegionViewModel : ViewModel() diff --git a/app/src/main/java/ru/practicum/android/diploma/presentation/filtration/viewmodel/FiltrationViewModel.kt b/app/src/main/java/ru/practicum/android/diploma/presentation/filtration/viewmodel/FiltrationViewModel.kt new file mode 100644 index 00000000000..5c56c593ce6 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/presentation/filtration/viewmodel/FiltrationViewModel.kt @@ -0,0 +1,5 @@ +package ru.practicum.android.diploma.presentation.filtration.viewmodel + +import androidx.lifecycle.ViewModel + +class FiltrationViewModel : ViewModel() diff --git a/app/src/main/java/ru/practicum/android/diploma/presentation/filtration/workplace/viewmodel/PlaceOfWorkViewModel.kt b/app/src/main/java/ru/practicum/android/diploma/presentation/filtration/workplace/viewmodel/PlaceOfWorkViewModel.kt new file mode 100644 index 00000000000..986270ef034 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/presentation/filtration/workplace/viewmodel/PlaceOfWorkViewModel.kt @@ -0,0 +1,5 @@ +package ru.practicum.android.diploma.presentation.filtration.workplace.viewmodel + +import androidx.lifecycle.ViewModel + +class PlaceOfWorkViewModel : ViewModel() diff --git a/app/src/main/java/ru/practicum/android/diploma/presentation/search/state/JobSearchState.kt b/app/src/main/java/ru/practicum/android/diploma/presentation/search/state/JobSearchState.kt new file mode 100644 index 00000000000..105f8a75092 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/presentation/search/state/JobSearchState.kt @@ -0,0 +1,17 @@ +package ru.practicum.android.diploma.presentation.search.state + +import ru.practicum.android.diploma.domain.models.Vacancy + +sealed interface JobSearchState { + data object Initial : JobSearchState + + data object Empty : JobSearchState + + data object Error : JobSearchState + + data class Content( + val found: Int, + val vacancies: List, + val isLoading: Boolean = false, + ) : JobSearchState +} diff --git a/app/src/main/java/ru/practicum/android/diploma/presentation/search/viewmodel/JobSearchViewModel.kt b/app/src/main/java/ru/practicum/android/diploma/presentation/search/viewmodel/JobSearchViewModel.kt new file mode 100644 index 00000000000..df3fe7b24d2 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/presentation/search/viewmodel/JobSearchViewModel.kt @@ -0,0 +1,151 @@ +package ru.practicum.android.diploma.presentation.search.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import ru.practicum.android.diploma.domain.impl.SearchInteractor +import ru.practicum.android.diploma.domain.models.SearchVacanciesOutcome +import ru.practicum.android.diploma.domain.models.Vacancy +import ru.practicum.android.diploma.presentation.search.state.JobSearchState +import ru.practicum.android.diploma.util.SEARCH_DEBOUNCE_MS + +@OptIn(FlowPreview::class) +class JobSearchViewModel( + private val searchInteractor: SearchInteractor, +) : ViewModel() { + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _state = MutableStateFlow(JobSearchState.Initial) + val state: StateFlow = _state.asStateFlow() + + private val _uiState = MutableStateFlow(SearchState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var currentPage = 0 + private var maxPages = 0 + + init { + _searchQuery + .debounce(SEARCH_DEBOUNCE_MS) + .distinctUntilChanged() + .filter { it.isNotBlank() } + .onEach { query -> performSearch(query, page = 0) } + .launchIn(viewModelScope) + } + + fun onSearchQueryChanged(query: String) { + _searchQuery.value = query + if (query.isBlank()) { + resetSearchState() + } + } + + fun clearSearch() { + _searchQuery.value = "" + resetSearchState() + } + + fun loadNextPage() { + val query = _searchQuery.value.trim() + val content = _state.value as? JobSearchState.Content ?: return + val nextPage = currentPage + 1 + if (!canLoadNextPage(content, query, nextPage)) { + return + } + viewModelScope.launch { + _state.value = content.copy(isLoading = true) + when (val outcome = searchInteractor.searchVacancies(query, nextPage)) { + is SearchVacanciesOutcome.Success -> { + currentPage = outcome.result.page + _state.value = JobSearchState.Content( + found = outcome.result.found, + vacancies = content.vacancies + outcome.result.vacancies, + isLoading = false, + ) + } + is SearchVacanciesOutcome.Empty, + is SearchVacanciesOutcome.Error, + -> stopPaginationLoading() + } + } + } + + private fun canLoadNextPage( + content: JobSearchState.Content, + query: String, + nextPage: Int, + ): Boolean { + if (query.isEmpty()) { + return false + } + return !content.isLoading && nextPage < maxPages + } + + private fun stopPaginationLoading() { + val currentContent = _state.value as? JobSearchState.Content ?: return + _state.value = currentContent.copy(isLoading = false) + } + + private fun performSearch(query: String, page: Int) { + viewModelScope.launch { + resetPagination() + _state.value = JobSearchState.Content( + found = 0, + vacancies = emptyList(), + isLoading = true, + ) + when (val outcome = searchInteractor.searchVacancies(query, page)) { + is SearchVacanciesOutcome.Success -> applySuccess(outcome, replaceList = true) + SearchVacanciesOutcome.Empty -> _state.value = JobSearchState.Empty + SearchVacanciesOutcome.Error -> _state.value = JobSearchState.Error + } + } + } + + private fun applySuccess(outcome: SearchVacanciesOutcome.Success, replaceList: Boolean) { + currentPage = outcome.result.page + maxPages = outcome.result.pages + val currentContent = _state.value as? JobSearchState.Content + val vacancies = if (replaceList) { + outcome.result.vacancies + } else { + currentContent?.vacancies.orEmpty() + outcome.result.vacancies + } + _state.value = JobSearchState.Content( + found = outcome.result.found, + vacancies = vacancies, + isLoading = false, + ) + } + + private fun resetPagination() { + currentPage = 0 + maxPages = 0 + } + + private fun resetSearchState() { + resetPagination() + _state.value = JobSearchState.Initial + } + + data class SearchState( + val jobList: List = emptyList(), + val selectedJob: Vacancy? = null, + val errorVisible: Boolean = false, + val recyclerVisible: Boolean = false, + val progressBarVisible: Boolean = false, + val errorText: String = "", + val errorIcon: Int = 0, + ) +} diff --git a/app/src/main/java/ru/practicum/android/diploma/presentation/team/viewmodel/TeamViewModel.kt b/app/src/main/java/ru/practicum/android/diploma/presentation/team/viewmodel/TeamViewModel.kt new file mode 100644 index 00000000000..b7ee7ba4efd --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/presentation/team/viewmodel/TeamViewModel.kt @@ -0,0 +1,5 @@ +package ru.practicum.android.diploma.presentation.team.viewmodel + +import androidx.lifecycle.ViewModel + +class TeamViewModel : ViewModel() diff --git a/app/src/main/java/ru/practicum/android/diploma/presentation/vacancy/viewmodel/VacancyViewModel.kt b/app/src/main/java/ru/practicum/android/diploma/presentation/vacancy/viewmodel/VacancyViewModel.kt new file mode 100644 index 00000000000..164c3330bc1 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/presentation/vacancy/viewmodel/VacancyViewModel.kt @@ -0,0 +1,5 @@ +package ru.practicum.android.diploma.presentation.vacancy.viewmodel + +import androidx.lifecycle.ViewModel + +class VacancyViewModel : ViewModel() diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/common/BadgeItem.kt b/app/src/main/java/ru/practicum/android/diploma/ui/common/BadgeItem.kt new file mode 100644 index 00000000000..ff896ec0a34 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/common/BadgeItem.kt @@ -0,0 +1,53 @@ +package ru.practicum.android.diploma.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import ru.practicum.android.diploma.R +import ru.practicum.android.diploma.ui.theme.AppTheme + +@Composable +fun BadgeItem(modifier: Modifier = Modifier, vacancyAmount: Int) { + val text = if (vacancyAmount > 0) { + " ${ + pluralStringResource( + id = R.plurals.vacancy_found_count, + count = vacancyAmount, + vacancyAmount + ) + }" + } else { + stringResource(R.string.no_vacancies) + } + + Text( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(color = MaterialTheme.colorScheme.primary) + .padding(horizontal = 12.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onPrimary, + text = text, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center + ) +} + +private const val VACANCY_AMOUNT = 395 + +@Preview +@Composable +private fun BadgeItemPreview() { + AppTheme { + BadgeItem(Modifier, VACANCY_AMOUNT) + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/common/Loader.kt b/app/src/main/java/ru/practicum/android/diploma/ui/common/Loader.kt new file mode 100644 index 00000000000..4d718ae5e8a --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/common/Loader.kt @@ -0,0 +1,29 @@ +package ru.practicum.android.diploma.ui.common + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview + +@Composable +fun Loader(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary + ) + } +} + +@Preview +@Composable +private fun LoaderPreview() { + Loader() +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/common/PlaceholderLayout.kt b/app/src/main/java/ru/practicum/android/diploma/ui/common/PlaceholderLayout.kt new file mode 100644 index 00000000000..8b0b695d3a3 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/common/PlaceholderLayout.kt @@ -0,0 +1,49 @@ +package ru.practicum.android.diploma.ui.common + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign + +private const val PLACEHOLDER_IMAGE_ASPECT_RATIO = 1.5f + +@Composable +fun PlaceholderLayout( + @DrawableRes imageRes: Int, + @StringRes textRes: Int? = null +) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Image( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(PLACEHOLDER_IMAGE_ASPECT_RATIO), + painter = painterResource(imageRes), + contentDescription = null + ) + + if (textRes != null) { + Text( + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + text = stringResource(textRes) + ) + } + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/common/TopBar.kt b/app/src/main/java/ru/practicum/android/diploma/ui/common/TopBar.kt new file mode 100644 index 00000000000..b6c06b9651a --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/common/TopBar.kt @@ -0,0 +1,79 @@ +package ru.practicum.android.diploma.ui.common + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import ru.practicum.android.diploma.R +import ru.practicum.android.diploma.ui.theme.Dimens + +@Composable +fun TopBar( + text: String, + navIconVisible: Boolean, + endFirstIconVisible: Boolean, + endFirstIconId: Int = R.drawable.ic_share, + endSecondIconVisible: Boolean, + endSecondIconId: Int = R.drawable.ic_like_empty +) { + Row( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.statusBars) + .height(64.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (navIconVisible) { + IconImage(R.drawable.ic_back) + } else { + Spacer(modifier = Modifier.width(Dimens.ScreenHorizontalPadding)) + } + Text( + text = text, + style = MaterialTheme.typography.titleLarge + .copy(color = MaterialTheme.colorScheme.onBackground), + modifier = Modifier.padding(start = 12.dp), + ) + if (endFirstIconVisible || endSecondIconVisible) { + Spacer(modifier = Modifier.weight(1f)) + } + if (endFirstIconVisible) { + IconImage(resId = endFirstIconId) + } + if (endSecondIconVisible) { + IconImage(resId = endSecondIconId) + } + } +} + +@Composable +fun IconImage( + resId: Int, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .height(48.dp) + .width(48.dp), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(id = resId), + contentDescription = null, + ) + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/common/search/VacanciesContent.kt b/app/src/main/java/ru/practicum/android/diploma/ui/common/search/VacanciesContent.kt new file mode 100644 index 00000000000..168583bf6e7 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/common/search/VacanciesContent.kt @@ -0,0 +1,64 @@ +package ru.practicum.android.diploma.ui.common.search + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import ru.practicum.android.diploma.domain.models.Vacancy +import ru.practicum.android.diploma.ui.common.BadgeItem +import ru.practicum.android.diploma.ui.common.Loader +import ru.practicum.android.diploma.ui.mocks.MocData +import ru.practicum.android.diploma.ui.theme.AppTheme + +@Composable +fun VacanciesContent( + modifier: Modifier = Modifier, + vacancies: List, + vacancyAmount: Int, + isLoading: Boolean, + onVacancyClick: () -> Unit, + onLoadNextPage: () -> Unit +) { + Column(modifier = modifier) { + Box(contentAlignment = Alignment.TopCenter) { + VacancyList( + vacancies = vacancies, + isLoading = isLoading, + onClick = onVacancyClick, + onLoadNextPage = onLoadNextPage + ) + if (vacancies.isNotEmpty()) { + BadgeItem( + modifier = Modifier + .padding(top = 12.dp, bottom = 8.dp) + .zIndex(1f), + vacancyAmount = vacancyAmount + ) + } + } + if (isLoading && vacancies.isEmpty()) { + Loader(modifier.weight(1F).imePadding()) + } + } + +} + +@Preview(showSystemUi = true) +@Composable +private fun VacanciesContentPreview() { + AppTheme { + VacanciesContent( + vacancies = MocData.vacancies, + vacancyAmount = MocData.VACANCY_AMOUNT, + isLoading = true, + onVacancyClick = {}, + onLoadNextPage = {} + ) + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/common/search/VacancyItem.kt b/app/src/main/java/ru/practicum/android/diploma/ui/common/search/VacancyItem.kt new file mode 100644 index 00000000000..44e89f7c1aa --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/common/search/VacancyItem.kt @@ -0,0 +1,121 @@ +package ru.practicum.android.diploma.ui.common.search + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +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.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.request.ImageRequest +import coil3.request.crossfade +import ru.practicum.android.diploma.R +import ru.practicum.android.diploma.domain.models.Vacancy +import ru.practicum.android.diploma.ui.mocks.MocData +import ru.practicum.android.diploma.ui.theme.AppTheme +import ru.practicum.android.diploma.util.extentions.formatDescription +import ru.practicum.android.diploma.util.extentions.formatSalary + +@Composable +fun VacancyItem( + modifier: Modifier = Modifier, + vacancy: Vacancy, + onClick: () -> Unit = {} +) { + val vacancyDescription = vacancy.formatDescription() + val salary = vacancy.salary.formatSalary() + val imageModifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)) + val context = LocalContext.current + val imageRequest = remember(vacancy.logo) { + ImageRequest.Builder(context) + .data(vacancy.logo) + .crossfade(true) + .httpHeaders( + NetworkHeaders.Builder() + .set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") + .build() + ) + .build() + } + Row( + modifier = modifier + .fillMaxWidth() + .clickable( + onClick = onClick + ) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (vacancy.logo != null) { + AsyncImage( + modifier = imageModifier, + model = imageRequest, + contentDescription = null, + placeholder = painterResource(R.drawable.ic_logo_48), + contentScale = ContentScale.Fit, + error = painterResource(R.drawable.ic_logo_48) + ) + } else { + Image( + modifier = imageModifier, + painter = painterResource(R.drawable.ic_logo_48), + contentDescription = null + ) + } + + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = vacancyDescription, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + text = vacancy.company ?: stringResource(R.string.no_company), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = salary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun VacancyItemPreview() { + AppTheme { + VacancyItem(modifier = Modifier.fillMaxWidth(), MocData.vacancy) + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/common/search/VacancyList.kt b/app/src/main/java/ru/practicum/android/diploma/ui/common/search/VacancyList.kt new file mode 100644 index 00000000000..914942f6e35 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/common/search/VacancyList.kt @@ -0,0 +1,84 @@ +package ru.practicum.android.diploma.ui.common.search + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import ru.practicum.android.diploma.domain.models.Vacancy +import ru.practicum.android.diploma.ui.common.Loader +import ru.practicum.android.diploma.ui.mocks.MocData +import ru.practicum.android.diploma.ui.theme.AppTheme + +@Composable +fun VacancyList( + modifier: Modifier = Modifier, + vacancies: List, + isLoading: Boolean, + onClick: () -> Unit, + onLoadNextPage: () -> Unit +) { + val listState = rememberLazyListState() + val shouldLoadNext = remember { + derivedStateOf { + val lastVisibleItemIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index + val totalItemsCount = listState.layoutInfo.totalItemsCount + + lastVisibleItemIndex != null && lastVisibleItemIndex >= totalItemsCount - 1 + } + } + + LaunchedEffect(shouldLoadNext.value) { + if (shouldLoadNext.value) { + onLoadNextPage() + } + } + + LazyColumn( + modifier = modifier, + state = listState, + contentPadding = PaddingValues(top = 40.dp) + ) { + items( + items = vacancies, + key = { it.id } + ) { vacancy -> + VacancyItem( + modifier = Modifier + .fillMaxWidth() + .animateItem(), + vacancy, + onClick = onClick + ) + } + item { + if (isLoading && vacancies.isNotEmpty()) { + Loader( + modifier = modifier + .heightIn(min = 80.dp) + ) + } + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun VacancyListPreview() { + AppTheme { + VacancyList( + vacancies = MocData.vacancies, + isLoading = true, + onClick = {}, + onLoadNextPage = {} + ) + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/favorites/fragment/FavoritesFragment.kt b/app/src/main/java/ru/practicum/android/diploma/ui/favorites/fragment/FavoritesFragment.kt new file mode 100644 index 00000000000..e1bc027f387 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/favorites/fragment/FavoritesFragment.kt @@ -0,0 +1,28 @@ +package ru.practicum.android.diploma.ui.favorites.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import ru.practicum.android.diploma.ui.favorites.screen.FavoritesScreen +import ru.practicum.android.diploma.ui.theme.AppTheme + +class FavoritesFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppTheme { + FavoritesScreen() + } + } + } + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/favorites/screen/FavoritesScreen.kt b/app/src/main/java/ru/practicum/android/diploma/ui/favorites/screen/FavoritesScreen.kt new file mode 100644 index 00000000000..d7451a337f8 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/favorites/screen/FavoritesScreen.kt @@ -0,0 +1,10 @@ +package ru.practicum.android.diploma.ui.favorites.screen + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun FavoritesScreen(modifier: Modifier = Modifier) { + Box(modifier = modifier) +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/filtration/country/fragment/ChooseCountryFragment.kt b/app/src/main/java/ru/practicum/android/diploma/ui/filtration/country/fragment/ChooseCountryFragment.kt new file mode 100644 index 00000000000..13d34386a2b --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/filtration/country/fragment/ChooseCountryFragment.kt @@ -0,0 +1,28 @@ +package ru.practicum.android.diploma.ui.filtration.country.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import ru.practicum.android.diploma.ui.filtration.country.screen.ChooseCountryScreen +import ru.practicum.android.diploma.ui.theme.AppTheme + +class ChooseCountryFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppTheme { + ChooseCountryScreen() + } + } + } + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/filtration/country/screen/ChooseCountryScreen.kt b/app/src/main/java/ru/practicum/android/diploma/ui/filtration/country/screen/ChooseCountryScreen.kt new file mode 100644 index 00000000000..7b452ffdbcd --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/filtration/country/screen/ChooseCountryScreen.kt @@ -0,0 +1,10 @@ +package ru.practicum.android.diploma.ui.filtration.country.screen + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun ChooseCountryScreen(modifier: Modifier = Modifier) { + Box(modifier = modifier) +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/filtration/fragment/FiltrationFragment.kt b/app/src/main/java/ru/practicum/android/diploma/ui/filtration/fragment/FiltrationFragment.kt new file mode 100644 index 00000000000..752f8de7ed7 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/filtration/fragment/FiltrationFragment.kt @@ -0,0 +1,28 @@ +package ru.practicum.android.diploma.ui.filtration.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import ru.practicum.android.diploma.ui.filtration.screen.FiltrationScreen +import ru.practicum.android.diploma.ui.theme.AppTheme + +class FiltrationFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppTheme { + FiltrationScreen() + } + } + } + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/filtration/industry/fragment/ChooseIndustryFragment.kt b/app/src/main/java/ru/practicum/android/diploma/ui/filtration/industry/fragment/ChooseIndustryFragment.kt new file mode 100644 index 00000000000..bd31a370890 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/filtration/industry/fragment/ChooseIndustryFragment.kt @@ -0,0 +1,28 @@ +package ru.practicum.android.diploma.ui.filtration.industry.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import ru.practicum.android.diploma.ui.filtration.industry.screen.ChooseIndustryScreen +import ru.practicum.android.diploma.ui.theme.AppTheme + +class ChooseIndustryFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppTheme { + ChooseIndustryScreen() + } + } + } + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/filtration/industry/screen/ChooseIndustryScreen.kt b/app/src/main/java/ru/practicum/android/diploma/ui/filtration/industry/screen/ChooseIndustryScreen.kt new file mode 100644 index 00000000000..98015b4d4af --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/filtration/industry/screen/ChooseIndustryScreen.kt @@ -0,0 +1,10 @@ +package ru.practicum.android.diploma.ui.filtration.industry.screen + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun ChooseIndustryScreen(modifier: Modifier = Modifier) { + Box(modifier = modifier) +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/filtration/region/fragment/ChooseRegionFragment.kt b/app/src/main/java/ru/practicum/android/diploma/ui/filtration/region/fragment/ChooseRegionFragment.kt new file mode 100644 index 00000000000..84eda1170c9 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/filtration/region/fragment/ChooseRegionFragment.kt @@ -0,0 +1,28 @@ +package ru.practicum.android.diploma.ui.filtration.region.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import ru.practicum.android.diploma.ui.filtration.region.screen.ChooseRegionScreen +import ru.practicum.android.diploma.ui.theme.AppTheme + +class ChooseRegionFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppTheme { + ChooseRegionScreen() + } + } + } + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/filtration/region/screen/ChooseRegionScreen.kt b/app/src/main/java/ru/practicum/android/diploma/ui/filtration/region/screen/ChooseRegionScreen.kt new file mode 100644 index 00000000000..6724554efa3 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/filtration/region/screen/ChooseRegionScreen.kt @@ -0,0 +1,12 @@ +package ru.practicum.android.diploma.ui.filtration.region.screen + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import ru.practicum.android.diploma.R + +@Composable +fun ChooseRegionScreen(modifier: Modifier = Modifier) { + Text(stringResource(R.string.team)) +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/filtration/screen/FiltrationScreen.kt b/app/src/main/java/ru/practicum/android/diploma/ui/filtration/screen/FiltrationScreen.kt new file mode 100644 index 00000000000..ca07d2257f7 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/filtration/screen/FiltrationScreen.kt @@ -0,0 +1,10 @@ +package ru.practicum.android.diploma.ui.filtration.screen + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun FiltrationScreen(modifier: Modifier = Modifier) { + Box(modifier = modifier) +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/filtration/workplace/fragment/PlaceOfWorkFragment.kt b/app/src/main/java/ru/practicum/android/diploma/ui/filtration/workplace/fragment/PlaceOfWorkFragment.kt new file mode 100644 index 00000000000..1f23375dc67 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/filtration/workplace/fragment/PlaceOfWorkFragment.kt @@ -0,0 +1,28 @@ +package ru.practicum.android.diploma.ui.filtration.workplace.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import ru.practicum.android.diploma.ui.filtration.workplace.screen.PlaceOfWorkScreen +import ru.practicum.android.diploma.ui.theme.AppTheme + +class PlaceOfWorkFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppTheme { + PlaceOfWorkScreen() + } + } + } + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/filtration/workplace/screen/PlaceOfWorkScreen.kt b/app/src/main/java/ru/practicum/android/diploma/ui/filtration/workplace/screen/PlaceOfWorkScreen.kt new file mode 100644 index 00000000000..9c4c22f8160 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/filtration/workplace/screen/PlaceOfWorkScreen.kt @@ -0,0 +1,10 @@ +package ru.practicum.android.diploma.ui.filtration.workplace.screen + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun PlaceOfWorkScreen(modifier: Modifier = Modifier) { + Box(modifier = modifier) +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/mocks/MocData.kt b/app/src/main/java/ru/practicum/android/diploma/ui/mocks/MocData.kt new file mode 100644 index 00000000000..95fc30a34d9 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/mocks/MocData.kt @@ -0,0 +1,60 @@ +package ru.practicum.android.diploma.ui.mocks + +import ru.practicum.android.diploma.domain.models.Salary +import ru.practicum.android.diploma.domain.models.Vacancy + +object MocData { + const val SALARY_FROM = 10_000 + const val SALARY_TO = 20_000 + const val VACANCY_AMOUNT = 395 + val vacancy: Vacancy = Vacancy( + id = "5", + name = "Android-разработчик", + company = "Еда", + city = "Мoсква", + salary = Salary(SALARY_FROM, SALARY_TO, "Р"), + logo = null + ) + val vacancies = listOf( + Vacancy( + id = "1", + name = "Senior Android Developer", + company = "TechCorp", + city = "Москва", + salary = Salary(from = 300_000, to = 450_000, currency = "RUB"), + logo = null + ), + Vacancy( + id = "2", + name = "Junior Kotlin Developer", + company = "StartupO", + city = "Санкт-Петербург", + salary = Salary(from = 70_000, to = 100_000, currency = "RUB"), + logo = null + ), + Vacancy( + id = "3", + name = "Middle Android Engineer", + company = "GlobalFinance", + city = null, + salary = Salary(from = 3_000, to = 4_500, currency = "USD"), + logo = null + ), + Vacancy( + id = "4", + name = "QA Automation (Kotlin)", + company = "MegaRetail", + city = "Казань", + salary = null, + logo = null + ), + Vacancy( + id = "5", + name = "Team Lead Android", + company = null, + city = "Новосибирск", + salary = Salary(from = 500_000, to = null, currency = "RUB"), + logo = null + ) + ) +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/root/App.kt b/app/src/main/java/ru/practicum/android/diploma/ui/root/App.kt new file mode 100644 index 00000000000..45808a88f2b --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/root/App.kt @@ -0,0 +1,21 @@ +package ru.practicum.android.diploma.ui.root + +import android.app.Application +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin +import ru.practicum.android.diploma.di.dataModule +import ru.practicum.android.diploma.di.interactorModule +import ru.practicum.android.diploma.di.repositoryModule +import ru.practicum.android.diploma.di.utilsModule +import ru.practicum.android.diploma.di.viewModelModule + +class App : Application() { + + override fun onCreate() { + super.onCreate() + startKoin { + androidContext(this@App) + modules(dataModule, repositoryModule, interactorModule, viewModelModule, utilsModule) + } + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/root/RootActivity.kt b/app/src/main/java/ru/practicum/android/diploma/ui/root/RootActivity.kt index 1b2630a2421..509ed0fffb9 100644 --- a/app/src/main/java/ru/practicum/android/diploma/ui/root/RootActivity.kt +++ b/app/src/main/java/ru/practicum/android/diploma/ui/root/RootActivity.kt @@ -1,21 +1,45 @@ package ru.practicum.android.diploma.ui.root import android.os.Bundle +import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowCompat +import androidx.core.view.isVisible +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.setupWithNavController +import com.google.android.material.bottomnavigation.BottomNavigationView import ru.practicum.android.diploma.BuildConfig import ru.practicum.android.diploma.R class RootActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + enableEdgeToEdge() setContentView(R.layout.activity_root) + val bottomNavigationView = findViewById(R.id.bottomNavigationView) + // Пример использования access token для HeadHunter API networkRequestExample(accessToken = BuildConfig.API_ACCESS_TOKEN) + + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container_view) as NavHostFragment + val navController = navHostFragment.navController + WindowCompat.setDecorFitsSystemWindows(window, false) + bottomNavigationView.setupWithNavController(navController) + + navController.addOnDestinationChangedListener { _, destination, _ -> + when (destination.id) { + R.id.jobSearchFragment, R.id.favoritesFragment, R.id.teamFragment -> { + bottomNavigationView.isVisible = true + } + + else -> bottomNavigationView.isVisible = false + } + } } private fun networkRequestExample(accessToken: String) { // ... } - } diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/search/fragment/JobSearchFragment.kt b/app/src/main/java/ru/practicum/android/diploma/ui/search/fragment/JobSearchFragment.kt new file mode 100644 index 00000000000..dd3c12900f2 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/search/fragment/JobSearchFragment.kt @@ -0,0 +1,56 @@ +package ru.practicum.android.diploma.ui.search.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.fragment.findNavController +import org.koin.androidx.viewmodel.ext.android.viewModel +import ru.practicum.android.diploma.R +import ru.practicum.android.diploma.presentation.search.viewmodel.JobSearchViewModel +import ru.practicum.android.diploma.ui.search.screen.JobSearchScreen +import ru.practicum.android.diploma.ui.theme.AppTheme + +class JobSearchFragment : Fragment() { + private val viewModel: JobSearchViewModel by viewModel() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + + setContent { + AppTheme { + val state = viewModel.state.collectAsStateWithLifecycle() + val query = viewModel.searchQuery.collectAsStateWithLifecycle() + + JobSearchScreen( + state = state.value, + searchQuery = query.value, + onSearchTextChange = { viewModel.onSearchQueryChanged(it) }, + onVacancyClick = { + findNavController().navigate(R.id.action_jobSearchFragment_to_vacancyFragment) + }, + onClear = { viewModel.clearSearch() }, + onLoadNextPage = { viewModel.loadNextPage() }, + onNetworkError = { showToast(context.getString(R.string.network_error_toast)) } + ) + } + } + } + } + + fun showToast(message: String?) { + requireActivity().runOnUiThread { + Toast.makeText(requireActivity(), message ?: "Empty message", Toast.LENGTH_LONG) + .show() + } + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/search/screen/JobSearchScreen.kt b/app/src/main/java/ru/practicum/android/diploma/ui/search/screen/JobSearchScreen.kt new file mode 100644 index 00000000000..494a6b83d13 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/search/screen/JobSearchScreen.kt @@ -0,0 +1,269 @@ +package ru.practicum.android.diploma.ui.search.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import ru.practicum.android.diploma.R +import ru.practicum.android.diploma.presentation.search.state.JobSearchState +import ru.practicum.android.diploma.ui.common.BadgeItem +import ru.practicum.android.diploma.ui.common.IconImage +import ru.practicum.android.diploma.ui.common.PlaceholderLayout +import ru.practicum.android.diploma.ui.common.TopBar +import ru.practicum.android.diploma.ui.common.search.VacanciesContent +import ru.practicum.android.diploma.ui.theme.Blue +import ru.practicum.android.diploma.ui.theme.Dimens + +@Composable +fun JobSearchScreen( + state: JobSearchState, + searchQuery: String, + onVacancyClick: () -> Unit, + onSearchTextChange: (String) -> Unit, + onClear: () -> Unit, + onLoadNextPage: () -> Unit, + onNetworkError: () -> Unit +) { + val keyboardController = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + val isPreview = LocalInspectionMode.current + val interactionSource = remember { MutableInteractionSource() } + + LaunchedEffect(Unit) { + if (!isPreview) { + focusRequester.requestFocus() + } + } + + Scaffold( + topBar = { + TopBar( + text = stringResource(R.string.search_screen_title), + navIconVisible = false, + endFirstIconVisible = true, + endFirstIconId = R.drawable.ic_filter, + endSecondIconVisible = false + ) + }, + contentWindowInsets = WindowInsets(bottom = 0), + ) { paddingValues -> + Column( + modifier = Modifier.padding(paddingValues) + ) { + SearchQueryField( + searchQuery = searchQuery, + focusRequester = focusRequester, + interactionSource = interactionSource, + onSearchTextChange = onSearchTextChange, + onClear = onClear, + onKeyboardDone = { keyboardController?.hide() }, + ) + Box(modifier = Modifier.weight(1F)) { + JobSearchStateContent( + state = state, + onVacancyClick = onVacancyClick, + onLoadNextPage = onLoadNextPage, + ) + } + } + } +} + +@Composable +private fun SearchQueryField( + searchQuery: String, + focusRequester: FocusRequester, + interactionSource: MutableInteractionSource, + onSearchTextChange: (String) -> Unit, + onClear: () -> Unit, + onKeyboardDone: () -> Unit, +) { + val fieldShape = RoundedCornerShape(8.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = Dimens.ScreenHorizontalPadding, + top = 8.dp, + end = Dimens.ScreenHorizontalPadding, + ) + .height(56.dp) + .clip(fieldShape) + .background(MaterialTheme.colorScheme.surfaceContainer), + verticalAlignment = Alignment.CenterVertically + ) { + BasicTextField( + value = searchQuery, + onValueChange = onSearchTextChange, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(start = 20.dp) + .focusRequester(focusRequester), + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium + .copy(color = MaterialTheme.colorScheme.onBackground), + cursorBrush = SolidColor(Blue), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { onKeyboardDone() }), + interactionSource = interactionSource, + decorationBox = { innerTextField -> + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.CenterStart + ) { + if (searchQuery.isEmpty()) { + Text( + text = stringResource(R.string.search_input_hint), + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.inverseOnSurface + ), + maxLines = 1, + modifier = Modifier.align(Alignment.CenterStart), + ) + } + innerTextField() + } + } + ) + IconImage( + modifier = Modifier + .padding(end = 4.dp) + .clickable(enabled = true, onClick = onClear), + resId = if (searchQuery.isEmpty()) R.drawable.ic_search else R.drawable.ic_cross, + ) + } +} + +@Composable +private fun JobSearchStateContent( + state: JobSearchState, + onVacancyClick: () -> Unit, + onLoadNextPage: () -> Unit, +) { + when (state) { + is JobSearchState.Content -> VacanciesContent( + modifier = Modifier + .fillMaxSize() + .padding( + start = Dimens.ScreenHorizontalPadding, + top = 8.dp, + end = Dimens.ScreenHorizontalPadding, + ), + vacancies = state.vacancies, + vacancyAmount = state.found, + isLoading = state.isLoading, + onVacancyClick = onVacancyClick, + onLoadNextPage = onLoadNextPage + ) + + JobSearchState.Initial -> { + PlaceholderLayout(R.drawable.img_search_initial_placeholder) + } + + JobSearchState.Empty -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + BadgeItem( + modifier = Modifier.padding(top = 12.dp), + vacancyAmount = 0 + ) + PlaceholderLayout( + R.drawable.img_nothing_found, + R.string.no_vacancies_error + ) + } + } + + JobSearchState.Error -> { + PlaceholderLayout( + R.drawable.img_no_internet, + R.string.no_internet_error + ) + } + } +} + +@Composable +fun TextField( + searchQuery: String, + onSearchTextChange: (String) -> Unit, +) { + val keyboardController = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + val interactionSource = remember { MutableInteractionSource() } + + return BasicTextField( + value = searchQuery, + onValueChange = { newText -> + onSearchTextChange(newText) + }, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(start = 20.dp) + .focusRequester(focusRequester), + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onBackground), + cursorBrush = SolidColor(Blue), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + keyboardController?.hide() + }), + interactionSource = interactionSource, + decorationBox = { innerTextField -> + Box( + Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart + ) { + if (searchQuery.isEmpty()) { + Text( + text = stringResource(R.string.search_input_hint), + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.inverseOnSurface + ), + maxLines = 1, + modifier = Modifier.align(Alignment.CenterStart), + ) + } + innerTextField() + } + }) +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/team/TeamMembers.kt b/app/src/main/java/ru/practicum/android/diploma/ui/team/TeamMembers.kt new file mode 100644 index 00000000000..3f3429b679f --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/team/TeamMembers.kt @@ -0,0 +1,45 @@ +package ru.practicum.android.diploma.ui.team + +import androidx.compose.runtime.Composable +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import ru.practicum.android.diploma.R +import ru.practicum.android.diploma.ui.team.model.TeamMember +import ru.practicum.android.diploma.ui.team.theme.TeamColors + +@Composable +fun rememberTeamMembers(): List = listOf( + TeamMember( + name = stringResource(R.string.team_member_1_name), + avatarResId = R.drawable.team_avatar_inna, + role = stringResource(R.string.team_member_1_role), + roleIconResId = R.drawable.ic_role_captain, + roleColor = TeamColors.CaptainRole, + roleBackgroundColor = TeamColors.CaptainRoleBackground, + ), + TeamMember( + name = stringResource(R.string.team_member_2_name), + avatarResId = R.drawable.team_avatar_maria, + role = stringResource(R.string.team_member_2_role), + roleIconResId = R.drawable.ic_role_mate, + roleColor = TeamColors.MateRole, + roleBackgroundColor = TeamColors.MateRoleBackground, + avatarContentScale = ContentScale.Fit, + ), + TeamMember( + name = stringResource(R.string.team_member_3_name), + avatarResId = R.drawable.team_avatar_denis, + role = stringResource(R.string.team_member_3_role), + roleIconResId = R.drawable.ic_role_boatswain, + roleColor = TeamColors.BoatswainRole, + roleBackgroundColor = TeamColors.BoatswainRoleBackground, + ), + TeamMember( + name = stringResource(R.string.team_member_4_name), + avatarResId = R.drawable.team_avatar_timofey, + role = stringResource(R.string.team_member_4_role), + roleIconResId = R.drawable.ic_role_cook, + roleColor = TeamColors.CookRole, + roleBackgroundColor = TeamColors.CookRoleBackground, + ), +) diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/team/component/RoleBadge.kt b/app/src/main/java/ru/practicum/android/diploma/ui/team/component/RoleBadge.kt new file mode 100644 index 00000000000..5c2ab1e8839 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/team/component/RoleBadge.kt @@ -0,0 +1,53 @@ +package ru.practicum.android.diploma.ui.team.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +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.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import ru.practicum.android.diploma.ui.team.theme.TeamTypography + +@Composable +fun RoleBadge( + role: String, + roleIconResId: Int, + roleColor: Color, + roleBackgroundColor: Color, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .height(38.dp) + .clip(RoundedCornerShape(12.dp)) + .background(roleBackgroundColor) + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(roleIconResId), + contentDescription = null, + tint = roleColor, + ) + Text( + text = role, + style = TeamTypography.Role, + color = roleColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/team/component/TeamBottomNavigation.kt b/app/src/main/java/ru/practicum/android/diploma/ui/team/component/TeamBottomNavigation.kt new file mode 100644 index 00000000000..0bac507859b --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/team/component/TeamBottomNavigation.kt @@ -0,0 +1,51 @@ +package ru.practicum.android.diploma.ui.team.component + +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import ru.practicum.android.diploma.ui.team.model.TeamBottomTab +import ru.practicum.android.diploma.ui.team.theme.TeamColors +import ru.practicum.android.diploma.ui.theme.Regular12 + +@Composable +fun TeamBottomNavigation( + selectedTab: TeamBottomTab, + onTabSelected: (TeamBottomTab) -> Unit, +) { + NavigationBar( + containerColor = TeamColors.CardBackground, + ) { + TeamBottomTab.entries.forEach { tab -> + val selected = tab == selectedTab + NavigationBarItem( + selected = selected, + onClick = { onTabSelected(tab) }, + icon = { + Icon( + painter = painterResource(tab.iconResId), + contentDescription = stringResource(tab.titleResId), + ) + }, + label = { + Text( + text = stringResource(tab.titleResId), + style = Regular12, + ) + }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = TeamColors.Accent, + selectedTextColor = TeamColors.Accent, + unselectedIconColor = TeamColors.BottomNavInactive, + unselectedTextColor = TeamColors.BottomNavInactive, + indicatorColor = Color.Transparent, + ), + ) + } + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/team/component/TeamHeader.kt b/app/src/main/java/ru/practicum/android/diploma/ui/team/component/TeamHeader.kt new file mode 100644 index 00000000000..a3990ebd9e2 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/team/component/TeamHeader.kt @@ -0,0 +1,36 @@ +package ru.practicum.android.diploma.ui.team.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import ru.practicum.android.diploma.R +import ru.practicum.android.diploma.ui.team.theme.TeamColors +import ru.practicum.android.diploma.ui.team.theme.TeamTypography + +@Composable +fun TeamHeader( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 20.dp), + ) { + Text( + text = stringResource(R.string.team), + style = TeamTypography.ScreenTitle, + color = TeamColors.PrimaryBlue, + ) + Text( + modifier = Modifier.padding(top = 12.dp), + text = stringResource(R.string.team_screen_headline), + style = TeamTypography.MainTitle, + color = TeamColors.PrimaryText, + ) + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/team/component/TeamInfoCard.kt b/app/src/main/java/ru/practicum/android/diploma/ui/team/component/TeamInfoCard.kt new file mode 100644 index 00000000000..95777b83749 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/team/component/TeamInfoCard.kt @@ -0,0 +1,34 @@ +package ru.practicum.android.diploma.ui.team.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import ru.practicum.android.diploma.R +import ru.practicum.android.diploma.ui.team.theme.TeamColors +import ru.practicum.android.diploma.ui.team.theme.TeamTypography + +@Composable +fun TeamInfoCard( + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = TeamColors.InfoCardBackground), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + text = stringResource(R.string.team_footer_tagline), + style = TeamTypography.FooterTagline, + color = TeamColors.PrimaryBlue, + ) + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/team/component/TeamMemberCard.kt b/app/src/main/java/ru/practicum/android/diploma/ui/team/component/TeamMemberCard.kt new file mode 100644 index 00000000000..a887b26a6c7 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/team/component/TeamMemberCard.kt @@ -0,0 +1,99 @@ +package ru.practicum.android.diploma.ui.team.component + +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.Row +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +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.draw.shadow +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import ru.practicum.android.diploma.ui.team.model.TeamMember +import ru.practicum.android.diploma.ui.team.theme.TeamColors +import ru.practicum.android.diploma.ui.team.theme.TeamTypography + +@Composable +fun TeamMemberCard( + member: TeamMember, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .fillMaxWidth() + .height(96.dp) + .shadow( + elevation = 10.dp, + shape = RoundedCornerShape(22.dp), + spotColor = TeamColors.Accent.copy(alpha = 0.12f), + ambientColor = TeamColors.Accent.copy(alpha = 0.08f), + ), + shape = RoundedCornerShape(22.dp), + colors = CardDefaults.cardColors(containerColor = TeamColors.CardBackground), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + TeamMemberAvatar(member = member) + Text( + modifier = Modifier.weight(1f), + text = member.name, + style = TeamTypography.MemberName, + color = TeamColors.PrimaryText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + RoleBadge( + role = member.role, + roleIconResId = member.roleIconResId, + roleColor = member.roleColor, + roleBackgroundColor = member.roleBackgroundColor, + ) + } + } +} + +@Composable +private fun TeamMemberAvatar( + member: TeamMember, + modifier: Modifier = Modifier, +) { + val avatarPadding = if (member.avatarContentScale == ContentScale.Fit) 4.dp else 0.dp + + Box( + modifier = modifier + .size(64.dp) + .clip(CircleShape) + .background(TeamColors.AvatarBackground), + contentAlignment = Alignment.Center, + ) { + Image( + modifier = Modifier + .fillMaxSize() + .padding(avatarPadding), + painter = painterResource(member.avatarResId), + contentDescription = member.name, + contentScale = member.avatarContentScale, + ) + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/team/fragment/TeamFragment.kt b/app/src/main/java/ru/practicum/android/diploma/ui/team/fragment/TeamFragment.kt new file mode 100644 index 00000000000..e3489b3b65a --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/team/fragment/TeamFragment.kt @@ -0,0 +1,29 @@ +package ru.practicum.android.diploma.ui.team.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import ru.practicum.android.diploma.ui.team.screen.TeamScreen +import ru.practicum.android.diploma.ui.theme.AppTheme + +class TeamFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppTheme { + // Нижняя навигация уже есть в RootActivity — не дублируем её в Compose. + TeamScreen(showBottomNavigation = false) + } + } + } + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/team/model/TeamBottomTab.kt b/app/src/main/java/ru/practicum/android/diploma/ui/team/model/TeamBottomTab.kt new file mode 100644 index 00000000000..7782211d721 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/team/model/TeamBottomTab.kt @@ -0,0 +1,14 @@ +package ru.practicum.android.diploma.ui.team.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import ru.practicum.android.diploma.R + +enum class TeamBottomTab( + @StringRes val titleResId: Int, + @DrawableRes val iconResId: Int, +) { + Main(R.string.main, R.drawable.ic_main_24), + Favorites(R.string.favorites, R.drawable.ic_favorites_24), + Team(R.string.team, R.drawable.ic_team_24), +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/team/model/TeamMember.kt b/app/src/main/java/ru/practicum/android/diploma/ui/team/model/TeamMember.kt new file mode 100644 index 00000000000..777bc067baa --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/team/model/TeamMember.kt @@ -0,0 +1,15 @@ +package ru.practicum.android.diploma.ui.team.model + +import androidx.annotation.DrawableRes +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale + +data class TeamMember( + val name: String, + @DrawableRes val avatarResId: Int, + val role: String, + @DrawableRes val roleIconResId: Int, + val roleColor: Color, + val roleBackgroundColor: Color, + val avatarContentScale: ContentScale = ContentScale.Crop, +) diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/team/screen/TeamScreen.kt b/app/src/main/java/ru/practicum/android/diploma/ui/team/screen/TeamScreen.kt new file mode 100644 index 00000000000..1c366e4e989 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/team/screen/TeamScreen.kt @@ -0,0 +1,111 @@ +package ru.practicum.android.diploma.ui.team.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import ru.practicum.android.diploma.ui.team.component.TeamBottomNavigation +import ru.practicum.android.diploma.ui.team.component.TeamHeader +import ru.practicum.android.diploma.ui.team.component.TeamInfoCard +import ru.practicum.android.diploma.ui.team.component.TeamMemberCard +import ru.practicum.android.diploma.ui.team.model.TeamBottomTab +import ru.practicum.android.diploma.ui.team.model.TeamMember +import ru.practicum.android.diploma.ui.team.rememberTeamMembers +import ru.practicum.android.diploma.ui.team.theme.TeamColors +import ru.practicum.android.diploma.ui.theme.AppTheme + +@Composable +fun TeamScreen( + showBottomNavigation: Boolean = true, + modifier: Modifier = Modifier, +) { + val members = rememberTeamMembers() + var selectedTab by remember { mutableStateOf(TeamBottomTab.Team) } + + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = TeamColors.ScreenBackgroundSolid, + bottomBar = { + if (showBottomNavigation) { + TeamBottomNavigation( + selectedTab = selectedTab, + onTabSelected = { selectedTab = it }, + ) + } + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .background(TeamColors.screenBackgroundBrush) + .padding(innerPadding), + ) { + TeamScreenContent(members = members) + } + } +} + +@Composable +fun TeamScreenContent( + members: List, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(bottom = 24.dp), + ) { + item(key = "header") { + TeamHeader() + } + items( + items = members, + key = { it.name }, + ) { member -> + TeamMemberCard( + modifier = Modifier.padding(bottom = 12.dp), + member = member, + ) + } + item(key = "footer_tagline") { + TeamInfoCard( + modifier = Modifier.padding(top = 8.dp, bottom = 16.dp), + ) + } + } +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +private fun TeamScreenPreview() { + AppTheme { + TeamScreen() + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 800) +@Composable +private fun TeamScreenContentPreview() { + AppTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(TeamColors.screenBackgroundBrush), + ) { + TeamScreenContent(members = rememberTeamMembers()) + } + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/team/theme/TeamColors.kt b/app/src/main/java/ru/practicum/android/diploma/ui/team/theme/TeamColors.kt new file mode 100644 index 00000000000..08627b5961b --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/team/theme/TeamColors.kt @@ -0,0 +1,52 @@ +package ru.practicum.android.diploma.ui.team.theme + +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color + +private const val HEX_SCREEN_BACKGROUND_TOP = 0xFFFAFAFF +private const val HEX_SCREEN_BACKGROUND_BOTTOM = 0xFFF3F0FF +private const val HEX_SCREEN_BACKGROUND_SOLID = 0xFFF8F7FF +private const val HEX_PRIMARY_TEXT = 0xFF1F2937 +private const val HEX_SECONDARY_TEXT = 0xFF6B7280 +private const val HEX_ACCENT = 0xFF6C63FF +private const val HEX_PRIMARY_BLUE = 0xFF3772E7 +private const val HEX_AVATAR_BACKGROUND = 0xFFF3F4F6 +private const val HEX_CARD_BACKGROUND = 0xFFFFFFFF +private const val HEX_INFO_CARD_BACKGROUND = 0xFFF1EEFF +private const val HEX_CAPTAIN_ROLE = 0xFF7C3AED +private const val HEX_CAPTAIN_ROLE_BACKGROUND = 0xFFEDE9FE +private const val HEX_MATE_ROLE = 0xFF2563EB +private const val HEX_MATE_ROLE_BACKGROUND = 0xFFDBEAFE +private const val HEX_BOATSWAIN_ROLE = 0xFF16A34A +private const val HEX_BOATSWAIN_ROLE_BACKGROUND = 0xFFDCFCE7 +private const val HEX_COOK_ROLE = 0xFFEA580C +private const val HEX_COOK_ROLE_BACKGROUND = 0xFFFFEDD5 +private const val HEX_BOTTOM_NAV_INACTIVE = 0xFF9CA3AF + +object TeamColors { + val ScreenBackgroundTop = Color(HEX_SCREEN_BACKGROUND_TOP) + val ScreenBackgroundBottom = Color(HEX_SCREEN_BACKGROUND_BOTTOM) + val ScreenBackgroundSolid = Color(HEX_SCREEN_BACKGROUND_SOLID) + val PrimaryText = Color(HEX_PRIMARY_TEXT) + val SecondaryText = Color(HEX_SECONDARY_TEXT) + val Accent = Color(HEX_ACCENT) + val PrimaryBlue = Color(HEX_PRIMARY_BLUE) + val AvatarBackground = Color(HEX_AVATAR_BACKGROUND) + val CardBackground = Color(HEX_CARD_BACKGROUND) + val InfoCardBackground = Color(HEX_INFO_CARD_BACKGROUND) + + val CaptainRole = Color(HEX_CAPTAIN_ROLE) + val CaptainRoleBackground = Color(HEX_CAPTAIN_ROLE_BACKGROUND) + val MateRole = Color(HEX_MATE_ROLE) + val MateRoleBackground = Color(HEX_MATE_ROLE_BACKGROUND) + val BoatswainRole = Color(HEX_BOATSWAIN_ROLE) + val BoatswainRoleBackground = Color(HEX_BOATSWAIN_ROLE_BACKGROUND) + val CookRole = Color(HEX_COOK_ROLE) + val CookRoleBackground = Color(HEX_COOK_ROLE_BACKGROUND) + + val BottomNavInactive = Color(HEX_BOTTOM_NAV_INACTIVE) + + val screenBackgroundBrush: Brush = Brush.verticalGradient( + colors = listOf(ScreenBackgroundTop, ScreenBackgroundBottom), + ) +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/team/theme/TeamTypography.kt b/app/src/main/java/ru/practicum/android/diploma/ui/team/theme/TeamTypography.kt new file mode 100644 index 00000000000..9737db20285 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/team/theme/TeamTypography.kt @@ -0,0 +1,51 @@ +package ru.practicum.android.diploma.ui.team.theme + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import ru.practicum.android.diploma.ui.theme.AppFontFamily + +object TeamTypography { + val ScreenTitle = TextStyle( + fontFamily = AppFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + lineHeight = 28.sp, + ) + val MainTitle = TextStyle( + fontFamily = AppFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 34.sp, + lineHeight = 40.sp, + ) + val MemberName = TextStyle( + fontFamily = AppFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 17.sp, + lineHeight = 22.sp, + ) + val Role = TextStyle( + fontFamily = AppFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 18.sp, + ) + val InfoTitle = TextStyle( + fontFamily = AppFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 22.sp, + ) + val InfoSubtitle = TextStyle( + fontFamily = AppFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 13.sp, + lineHeight = 18.sp, + ) + val FooterTagline = TextStyle( + fontFamily = AppFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 22.sp, + ) +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/theme/Color.kt b/app/src/main/java/ru/practicum/android/diploma/ui/theme/Color.kt new file mode 100644 index 00000000000..f0eb217863a --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/theme/Color.kt @@ -0,0 +1,47 @@ +package ru.practicum.android.diploma.ui.theme + +import androidx.compose.ui.graphics.Color + +private const val HEX_BLACK = 0xFF1A1B22 +private const val HEX_WHITE = 0xFFFDFDFD +private const val HEX_BLUE = 0xFF3772E7 +private const val HEX_RED = 0xFFF56B6C +private const val HEX_GREY = 0xFFAEAFB4 +private const val HEX_LIGHT_GREY = 0xFFE6E8EB +private const val BACKGROUND_OVERLAY_ALPHA = 0.5f + +val Black = Color(HEX_BLACK) +val White = Color(HEX_WHITE) +val Blue = Color(HEX_BLUE) +val Red = Color(HEX_RED) +val Grey = Color(HEX_GREY) +val LightGrey = Color(HEX_LIGHT_GREY) +val Background = Color(HEX_BLACK).copy(alpha = BACKGROUND_OVERLAY_ALPHA) + +val LightPrimary = Blue +val LightOnPrimary = White +val LightSecondary = Grey +val LightOnSecondary = Black +val LightBackground = White +val LightOnBackground = Black +val LightSurface = White +val LightOnSurface = Black +val LightSurfaceVariant = LightGrey +val LightOnSurfaceVariant = Grey +val LightError = Red +val LightSurfaceContainer = LightGrey +val LightInversionOnSurface = Grey + +val DarkPrimary = Blue +val DarkOnPrimary = White +val DarkSecondary = Grey +val DarkOnSecondary = Black +val DarkBackground = Black +val DarkOnBackground = White +val DarkSurface = Black +val DarkOnSurface = White +val DarkSurfaceVariant = LightGrey +val DarkOnSurfaceVariant = Grey +val DarkError = Red +val DarkSurfaceContainer = Grey +val DarkInversionOnSurface = White diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/theme/Dimens.kt b/app/src/main/java/ru/practicum/android/diploma/ui/theme/Dimens.kt new file mode 100644 index 00000000000..a85ec5bd3c4 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/theme/Dimens.kt @@ -0,0 +1,7 @@ +package ru.practicum.android.diploma.ui.theme + +import androidx.compose.ui.unit.dp + +object Dimens { + val ScreenHorizontalPadding = 16.dp +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/theme/Theme.kt b/app/src/main/java/ru/practicum/android/diploma/ui/theme/Theme.kt new file mode 100644 index 00000000000..3085adc7c9b --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/theme/Theme.kt @@ -0,0 +1,45 @@ +package ru.practicum.android.diploma.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +@Composable +fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { + val colors = if (darkTheme) DarkColors else LightColors + MaterialTheme(colorScheme = colors, typography = AppTypography, content = content) +} + +private val LightColors = lightColorScheme( + primary = LightPrimary, + onPrimary = LightOnPrimary, + secondary = LightSecondary, + onSecondary = LightOnSecondary, + background = LightBackground, + onBackground = LightOnBackground, + surface = LightSurface, + onSurface = LightOnSurface, + surfaceVariant = LightSurfaceVariant, + onSurfaceVariant = LightOnSurfaceVariant, + error = LightError, + surfaceContainer = LightSurfaceContainer, + inverseOnSurface = LightInversionOnSurface +) + +private val DarkColors = darkColorScheme( + primary = DarkPrimary, + onPrimary = DarkOnPrimary, + secondary = DarkSecondary, + onSecondary = DarkOnSecondary, + background = DarkBackground, + onBackground = DarkOnBackground, + surface = DarkSurface, + onSurface = DarkOnSurface, + surfaceVariant = DarkSurfaceVariant, + onSurfaceVariant = DarkOnSurfaceVariant, + error = DarkError, + surfaceContainer = DarkSurfaceContainer, + inverseOnSurface = DarkInversionOnSurface +) diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/theme/Type.kt b/app/src/main/java/ru/practicum/android/diploma/ui/theme/Type.kt new file mode 100644 index 00000000000..95c2099b74c --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/theme/Type.kt @@ -0,0 +1,62 @@ +package ru.practicum.android.diploma.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import ru.practicum.android.diploma.R + +val AppFontFamily = FontFamily( + Font(R.font.ys_display_bold, FontWeight.W700), + Font(R.font.ys_display_medium, FontWeight.W500), + Font(R.font.ys_display_regular, FontWeight.W400) +) +val Bold32 = TextStyle( + fontSize = 32.sp, + fontFamily = AppFontFamily, + letterSpacing = 0.sp, + lineHeight = 38.sp, + fontWeight = FontWeight.W700 +) +val Medium22 = TextStyle( + fontSize = 22.sp, + fontFamily = AppFontFamily, + letterSpacing = 0.sp, + lineHeight = 26.sp, + fontWeight = FontWeight.W500 +) +val Medium16 = TextStyle( + fontSize = 16.sp, + fontFamily = AppFontFamily, + letterSpacing = 0.sp, + lineHeight = 19.sp, + fontWeight = FontWeight.W500 +) +val Regular16 = TextStyle( + fontSize = 16.sp, + fontFamily = AppFontFamily, + letterSpacing = 0.sp, + lineHeight = 19.sp, + fontWeight = FontWeight.W400 +) +val Regular12 = TextStyle( + fontSize = 12.sp, + fontFamily = AppFontFamily, + letterSpacing = 0.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.W400 +) + +val AppTypography = Typography( + headlineLarge = Bold32, + headlineMedium = Medium22, + titleLarge = Medium22, + titleMedium = Regular16, + bodyMedium = Regular16, + bodySmall = Regular12, + labelLarge = Medium16, + labelMedium = Regular16, + labelSmall = Regular12, +) diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/vacancy/fragment/VacancyFragment.kt b/app/src/main/java/ru/practicum/android/diploma/ui/vacancy/fragment/VacancyFragment.kt new file mode 100644 index 00000000000..73acbb29752 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/vacancy/fragment/VacancyFragment.kt @@ -0,0 +1,28 @@ +package ru.practicum.android.diploma.ui.vacancy.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import ru.practicum.android.diploma.ui.theme.AppTheme +import ru.practicum.android.diploma.ui.vacancy.screen.VacancyScreen + +class VacancyFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppTheme { + VacancyScreen() + } + } + } + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/ui/vacancy/screen/VacancyScreen.kt b/app/src/main/java/ru/practicum/android/diploma/ui/vacancy/screen/VacancyScreen.kt new file mode 100644 index 00000000000..ca70eaefaa9 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/ui/vacancy/screen/VacancyScreen.kt @@ -0,0 +1,10 @@ +package ru.practicum.android.diploma.ui.vacancy.screen + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun VacancyScreen(modifier: Modifier = Modifier) { + Box(modifier = modifier) +} diff --git a/app/src/main/java/ru/practicum/android/diploma/util/Const.kt b/app/src/main/java/ru/practicum/android/diploma/util/Const.kt new file mode 100644 index 00000000000..ea797e5b37e --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/util/Const.kt @@ -0,0 +1,3 @@ +package ru.practicum.android.diploma.util + +object Const diff --git a/app/src/main/java/ru/practicum/android/diploma/util/Debounce.kt b/app/src/main/java/ru/practicum/android/diploma/util/Debounce.kt new file mode 100644 index 00000000000..dba7cb8c01d --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/util/Debounce.kt @@ -0,0 +1,34 @@ +package ru.practicum.android.diploma.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch + +/** Задержка перед повторным поиском при вводе текста (ТЗ: 2000 мс). */ +const val SEARCH_DEBOUNCE_MS = 2_000L + +@OptIn(FlowPreview::class) +fun Flow.debounceSearch(): Flow = debounce(SEARCH_DEBOUNCE_MS) + +/** + * Откладывает выполнение [action] на [delayMillis]. + * При повторном вызове предыдущая отложенная операция отменяется. + */ +class Debounce( + private val scope: CoroutineScope, + private val delayMillis: Long = SEARCH_DEBOUNCE_MS, +) { + private var job: Job? = null + + operator fun invoke(action: suspend () -> Unit) { + job?.cancel() + job = scope.launch { + delay(delayMillis) + action() + } + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/util/NetworkConnectionChecker.kt b/app/src/main/java/ru/practicum/android/diploma/util/NetworkConnectionChecker.kt new file mode 100644 index 00000000000..13852bf3b1a --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/util/NetworkConnectionChecker.kt @@ -0,0 +1,8 @@ +package ru.practicum.android.diploma.util + +/** + * Проверка доступности сетевого подключения перед выполнением запросов к API. + */ +fun interface NetworkConnectionChecker { + fun isConnected(): Boolean +} diff --git a/app/src/main/java/ru/practicum/android/diploma/util/NetworkConnectionCheckerImpl.kt b/app/src/main/java/ru/practicum/android/diploma/util/NetworkConnectionCheckerImpl.kt new file mode 100644 index 00000000000..49682544701 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/util/NetworkConnectionCheckerImpl.kt @@ -0,0 +1,26 @@ +package ru.practicum.android.diploma.util + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities + +class NetworkConnectionCheckerImpl( + context: Context, +) : NetworkConnectionChecker { + + private val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + override fun isConnected(): Boolean { + val capabilities = connectivityManager.activeNetwork + ?.let { connectivityManager.getNetworkCapabilities(it) } + ?: return false + + val hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + val hasTransport = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + + return hasInternet && hasTransport + } +} diff --git a/app/src/main/java/ru/practicum/android/diploma/util/extentions/IntExtentions.kt b/app/src/main/java/ru/practicum/android/diploma/util/extentions/IntExtentions.kt new file mode 100644 index 00000000000..190f0fb29a0 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/util/extentions/IntExtentions.kt @@ -0,0 +1,5 @@ +package ru.practicum.android.diploma.util.extentions + +fun Int.formatWithSpaces(): String { + return "%,d".format(this).replace(",", " ") +} diff --git a/app/src/main/java/ru/practicum/android/diploma/util/extentions/SalaryExtentions.kt b/app/src/main/java/ru/practicum/android/diploma/util/extentions/SalaryExtentions.kt new file mode 100644 index 00000000000..eb9902e9f31 --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/util/extentions/SalaryExtentions.kt @@ -0,0 +1,25 @@ +package ru.practicum.android.diploma.util.extentions + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import ru.practicum.android.diploma.R +import ru.practicum.android.diploma.domain.models.Salary + +@Composable +fun Salary?.formatSalary(): String { + if (this == null) return stringResource(R.string.no_salary) + + return buildString { + from?.let { + append("${stringResource(R.string.from)} ${it.formatWithSpaces()} ") + } + to?.let { + append( + "${ + stringResource(R.string.to) + } ${it.formatWithSpaces()} " + ) + } + append(currency) + }.trim() +} diff --git a/app/src/main/java/ru/practicum/android/diploma/util/extentions/VacancyExtentions.kt b/app/src/main/java/ru/practicum/android/diploma/util/extentions/VacancyExtentions.kt new file mode 100644 index 00000000000..2997de0528a --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/util/extentions/VacancyExtentions.kt @@ -0,0 +1,14 @@ +package ru.practicum.android.diploma.util.extentions + +import ru.practicum.android.diploma.domain.models.Vacancy + +fun Vacancy.formatDescription(): String { + return buildString { + append(name) + city?.let { + append( + ", $city" + ) + } + }.trim() +} diff --git a/app/src/main/java/ru/practicum/android/diploma/util/extentions/ViewExtentions.kt b/app/src/main/java/ru/practicum/android/diploma/util/extentions/ViewExtentions.kt new file mode 100644 index 00000000000..4d6f309ed9b --- /dev/null +++ b/app/src/main/java/ru/practicum/android/diploma/util/extentions/ViewExtentions.kt @@ -0,0 +1,22 @@ +package ru.practicum.android.diploma.util.extentions + +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.fragment.app.FragmentContainerView +import com.google.android.material.bottomnavigation.BottomNavigationView + +fun FragmentContainerView.applySystemBarsPadding(bottomNavigationView: BottomNavigationView) { + ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + + view.setPadding( + 0, + systemBars.top, + 0, + if (!bottomNavigationView.isVisible) systemBars.bottom else 0 + ) + + insets + } +} diff --git a/app/src/main/res/color/bottom_nav_colors.xml b/app/src/main/res/color/bottom_nav_colors.xml new file mode 100644 index 00000000000..baf227b3931 --- /dev/null +++ b/app/src/main/res/color/bottom_nav_colors.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_back.xml b/app/src/main/res/drawable/ic_back.xml new file mode 100644 index 00000000000..63429340a9e --- /dev/null +++ b/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_cross.xml b/app/src/main/res/drawable/ic_cross.xml new file mode 100644 index 00000000000..fa811ebf41b --- /dev/null +++ b/app/src/main/res/drawable/ic_cross.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_favorites_24.xml b/app/src/main/res/drawable/ic_favorites_24.xml new file mode 100644 index 00000000000..248a15a0412 --- /dev/null +++ b/app/src/main/res/drawable/ic_favorites_24.xml @@ -0,0 +1,27 @@ + + + + diff --git a/app/src/main/res/drawable/ic_filter.xml b/app/src/main/res/drawable/ic_filter.xml new file mode 100644 index 00000000000..3d49a8c9154 --- /dev/null +++ b/app/src/main/res/drawable/ic_filter.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 07d5da9cbf1..db23c9a0df2 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -2,169 +2,15 @@ + android:viewportWidth="512" + android:viewportHeight="512"> + android:fillColor="#1A1B22" + android:pathData="M0,0h512v512H0z" /> + android:fillColor="#3772E7" + android:pathData="M0,0h256v256H0z" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:fillColor="#3772E7" + android:pathData="M256,256h256v256H256z" /> diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 2b068d11462..1e9c6352767 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,30 +1,11 @@ + - - - - - - - - + android:viewportWidth="512" + android:viewportHeight="512"> - \ No newline at end of file + android:fillColor="#FDFDFD" + android:fillType="evenOdd" + android:pathData="M191.893,151L191.893,179.966L321.107,179.966L321.107,151L191.893,151ZM156,194.448L184.714,194.448L184.714,361L156,361L156,194.448ZM328.286,194.448L357,194.448L357,360.346L328.286,360.346L328.286,194.448Z" /> + diff --git a/app/src/main/res/drawable/ic_like_empty.xml b/app/src/main/res/drawable/ic_like_empty.xml new file mode 100644 index 00000000000..74d67511229 --- /dev/null +++ b/app/src/main/res/drawable/ic_like_empty.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/ic_logo_48.xml b/app/src/main/res/drawable/ic_logo_48.xml new file mode 100644 index 00000000000..07228304ba3 --- /dev/null +++ b/app/src/main/res/drawable/ic_logo_48.xml @@ -0,0 +1,33 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_main_24.xml b/app/src/main/res/drawable/ic_main_24.xml new file mode 100644 index 00000000000..3e08b646129 --- /dev/null +++ b/app/src/main/res/drawable/ic_main_24.xml @@ -0,0 +1,27 @@ + + + + diff --git a/app/src/main/res/drawable/ic_role_boatswain.xml b/app/src/main/res/drawable/ic_role_boatswain.xml new file mode 100644 index 00000000000..756d7c85771 --- /dev/null +++ b/app/src/main/res/drawable/ic_role_boatswain.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_role_captain.xml b/app/src/main/res/drawable/ic_role_captain.xml new file mode 100644 index 00000000000..22f5758a8da --- /dev/null +++ b/app/src/main/res/drawable/ic_role_captain.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_role_cook.xml b/app/src/main/res/drawable/ic_role_cook.xml new file mode 100644 index 00000000000..b42da81c256 --- /dev/null +++ b/app/src/main/res/drawable/ic_role_cook.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_role_mate.xml b/app/src/main/res/drawable/ic_role_mate.xml new file mode 100644 index 00000000000..f3becb99a06 --- /dev/null +++ b/app/src/main/res/drawable/ic_role_mate.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 00000000000..77a71cbbeb7 --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 00000000000..660fff81fa8 --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_team_24.xml b/app/src/main/res/drawable/ic_team_24.xml new file mode 100644 index 00000000000..7463bd93848 --- /dev/null +++ b/app/src/main/res/drawable/ic_team_24.xml @@ -0,0 +1,27 @@ + + + + diff --git a/app/src/main/res/drawable/ic_team_heart.xml b/app/src/main/res/drawable/ic_team_heart.xml new file mode 100644 index 00000000000..9f3cbc79d38 --- /dev/null +++ b/app/src/main/res/drawable/ic_team_heart.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_team_member_placeholder.xml b/app/src/main/res/drawable/ic_team_member_placeholder.xml new file mode 100644 index 00000000000..70bfa8c59ff --- /dev/null +++ b/app/src/main/res/drawable/ic_team_member_placeholder.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/drawable/img_no_internet.png b/app/src/main/res/drawable/img_no_internet.png new file mode 100644 index 00000000000..e9dbab638c1 Binary files /dev/null and b/app/src/main/res/drawable/img_no_internet.png differ diff --git a/app/src/main/res/drawable/img_nothing_found.png b/app/src/main/res/drawable/img_nothing_found.png new file mode 100644 index 00000000000..cd911e0971e Binary files /dev/null and b/app/src/main/res/drawable/img_nothing_found.png differ diff --git a/app/src/main/res/drawable/img_search_initial_placeholder.png b/app/src/main/res/drawable/img_search_initial_placeholder.png new file mode 100644 index 00000000000..1b111303ac2 Binary files /dev/null and b/app/src/main/res/drawable/img_search_initial_placeholder.png differ diff --git a/app/src/main/res/drawable/team_avatar_denis.png b/app/src/main/res/drawable/team_avatar_denis.png new file mode 100644 index 00000000000..ecc3a45e140 Binary files /dev/null and b/app/src/main/res/drawable/team_avatar_denis.png differ diff --git a/app/src/main/res/drawable/team_avatar_inna.png b/app/src/main/res/drawable/team_avatar_inna.png new file mode 100644 index 00000000000..29badcb8e3f Binary files /dev/null and b/app/src/main/res/drawable/team_avatar_inna.png differ diff --git a/app/src/main/res/drawable/team_avatar_maria.png b/app/src/main/res/drawable/team_avatar_maria.png new file mode 100644 index 00000000000..6517ae0bc22 Binary files /dev/null and b/app/src/main/res/drawable/team_avatar_maria.png differ diff --git a/app/src/main/res/drawable/team_avatar_timofey.png b/app/src/main/res/drawable/team_avatar_timofey.png new file mode 100644 index 00000000000..9f922718c92 Binary files /dev/null and b/app/src/main/res/drawable/team_avatar_timofey.png differ diff --git a/app/src/main/res/font/ys_display_bold.ttf b/app/src/main/res/font/ys_display_bold.ttf new file mode 100644 index 00000000000..f9b3f03cce5 Binary files /dev/null and b/app/src/main/res/font/ys_display_bold.ttf differ diff --git a/app/src/main/res/font/ys_display_medium.ttf b/app/src/main/res/font/ys_display_medium.ttf new file mode 100644 index 00000000000..cc63032e212 Binary files /dev/null and b/app/src/main/res/font/ys_display_medium.ttf differ diff --git a/app/src/main/res/font/ys_display_regular.ttf b/app/src/main/res/font/ys_display_regular.ttf new file mode 100644 index 00000000000..02173eb8290 Binary files /dev/null and b/app/src/main/res/font/ys_display_regular.ttf differ diff --git a/app/src/main/res/layout/activity_root.xml b/app/src/main/res/layout/activity_root.xml index ea46b92d7b9..9a1f3aba5cd 100644 --- a/app/src/main/res/layout/activity_root.xml +++ b/app/src/main/res/layout/activity_root.xml @@ -1,19 +1,35 @@ - - + - \ No newline at end of file + + diff --git a/app/src/main/res/menu/bottom_navigation_view.xml b/app/src/main/res/menu/bottom_navigation_view.xml new file mode 100644 index 00000000000..341ba12faf2 --- /dev/null +++ b/app/src/main/res/menu/bottom_navigation_view.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp index c209e78ecd3..9a2bb0406ce 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index b2dfe3d1ba5..9a2bb0406ce 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp index 4f0f1d64e58..29f6db63ea7 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index 62b611da081..29f6db63ea7 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index 948a3070fe3..6d77347d595 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 1b9a6956b3a..6d77347d595 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index 28d4b77f9f0..65f7f8a1e45 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index 9287f508362..65f7f8a1e45 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index aa7d6427e6f..4f9d460ca2a 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index 9126ae37cbc..4f9d460ca2a 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/navigation/navigation_graph.xml b/app/src/main/res/navigation/navigation_graph.xml new file mode 100644 index 00000000000..398208dda8c --- /dev/null +++ b/app/src/main/res/navigation/navigation_graph.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index b5195a2f799..6eb43f3ee4a 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,7 +1,14 @@ - - + + - \ No newline at end of file + + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 472697695a0..e1bf229ea7d 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,9 +1,14 @@ - - + +