diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index e515992..0000000 --- a/app/build.gradle +++ /dev/null @@ -1,44 +0,0 @@ -plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' -} - -android { - namespace 'ru.otus.basicarchitecture' - compileSdk 35 - - defaultConfig { - applicationId "ru.otus.basicarchitecture" - minSdk 24 - targetSdk 35 - versionCode 1 - versionName "1.0" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = '17' - } -} - -dependencies { - - implementation 'androidx.core:core-ktx:1.15.0' - implementation 'androidx.appcompat:appcompat:1.7.0' - implementation 'com.google.android.material:material:1.12.0' - implementation 'androidx.constraintlayout:constraintlayout:2.2.0' - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' -} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..0dadde1 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,94 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt.android) + alias(libs.plugins.androidx.navigation.safeargs) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.secrets.gradle.plugin) + id("kotlin-kapt") +} + +android { + namespace = "ru.otus.basicarchitecture" + compileSdk = 36 + + defaultConfig { + applicationId = "ru.otus.basicarchitecture" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } + } + + buildFeatures { + viewBinding = true + buildConfig = true + } + + setPublishNonDefault(true) + + testOptions { + packaging{ + jniLibs.useLegacyPackaging = true + } + } +} +tasks.withType { + useJUnitPlatform() +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.constraintlayout) + + implementation(libs.androidx.navigation.fragment.ktx) + implementation(libs.androidx.navigation.ui.ktx) + implementation(libs.androidx.legacy.support.v4) + implementation(libs.androidx.lifecycle.livedata.ktx) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.fragment.ktx) + implementation(libs.hilt.android) + implementation(libs.bundles.serialization) + implementation(libs.bundles.net) + ksp(libs.hilt.compiler) + testImplementation(libs.junit) + testImplementation(libs.jupiter) + testImplementation(libs.junitParams) + testRuntimeOnly(libs.junit.platform.launcher) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.espresso.core) + androidTestImplementation(libs.hilt.android.testing) + androidTestImplementation(libs.kotlin.coroutines.core) + androidTestImplementation(libs.kotlin.coroutines.test) + androidTestImplementation(libs.mockk) + androidTestImplementation(libs.mockk.android) + androidTestImplementation(libs.mockk.agent.android) + +} \ No newline at end of file diff --git a/app/src/androidTest/java/ru/otus/basicarchitecture/view_model/AddressFragmentModelTest.kt b/app/src/androidTest/java/ru/otus/basicarchitecture/view_model/AddressFragmentModelTest.kt new file mode 100644 index 0000000..55f9273 --- /dev/null +++ b/app/src/androidTest/java/ru/otus/basicarchitecture/view_model/AddressFragmentModelTest.kt @@ -0,0 +1,297 @@ +package ru.otus.basicarchitecture.view_model + +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.confirmVerified +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import ru.otus.basicarchitecture.use_case.AddressSuggestUseCase +import ru.otus.basicarchitecture.view.LoadAddressesViewState + +/** + * Тесты для AddressFragmentModel + * + * Проверяют: + * - Обновление адреса + * - Очистку вариантов + * - Выбор адреса + * - Загрузку вариантов адресов (успешный и ошибочный сценарии) + * + * Особенности: + * - Использует mockk для мокирования зависимостей + * - Проверяет промежуточные состояния (LoadingProgress) + * - Использует задержку для перехвата промежуточных состояний + */ + +@OptIn(ExperimentalCoroutinesApi::class) +class AddressFragmentModelTest { + + private val testDispatcher = StandardTestDispatcher() + + @MockK + private lateinit var addressSuggestUseCase: AddressSuggestUseCase + private lateinit var wizardCache: WizardCache.Impl + private lateinit var viewModel: AddressFragmentModel + + @Before + fun setUp() { + // Устанавливаем тестовый диспетчер как основной + Dispatchers.setMain(testDispatcher) + + // Инициализируем моки, помеченные аннотацией @MockK + MockKAnnotations.init(this) + + // Создаем зависимости + wizardCache = WizardCache.Impl() + + // Создаем ViewModel с мокированными зависимостями + viewModel = AddressFragmentModel(addressSuggestUseCase, wizardCache) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + /** + * Тестирует обновление адреса. + * + * Проверяет, что при вызове updateAddress с непустым и отличающимся от текущего адресом + * возвращается true и новый адрес корректно устанавливается в ViewModel и кеше. + */ + @Test + fun updateAddress_emits_new_address_when_it_is_not_blank_and_different_from_current() = + runTest { + val newAddress = "New Address" + + val result = viewModel.updateAddress(newAddress) + advanceUntilIdle() + + assertTrue(result) + assertEquals(newAddress, viewModel.addressFlow.first()) + assertEquals(newAddress, wizardCache.address) + } + + /** + * Тестирует обновление адреса с пустым значением. + * + * Проверяет, что при попытке установить пустой адрес + * метод возвращает false и состояние не изменяется. + */ + @Test + fun updateAddress_returns_false_when_address_is_blank() = runTest { + val blankAddress = "" + + val result = viewModel.updateAddress(blankAddress) + + assertFalse(result) + assertEquals("", viewModel.addressFlow.first()) + } + + /** + * Тестирует обновление адреса на уже установленное значение. + * + * Проверяет, что при попытке установить уже существующий адрес + * метод возвращает false, так как изменений не происходит. + */ + @Test + fun updateAddress_returns_false_when_address_is_the_same_as_current() = runTest { + val sameAddress = "Same Address" + viewModel.updateAddress(sameAddress) // Устанавливаем текущий адрес + + val result = viewModel.updateAddress(sameAddress) + + assertFalse(result) + } + + /** + * Тестирует очистку вариантов адресов. + * + * Проверяет, что метод clearVariants устанавливает состояние + * LoadAddresses без ошибки, сбрасывая предыдущие данные. + */ + @Test + fun clearVariants_emits_LoadAddresses_state() = runTest { + val initialState = viewModel.state.first() + assertTrue(initialState is LoadAddressesViewState.LoadAddresses) + + viewModel.clearVariants() + + val newState = viewModel.state.first() + assertTrue(newState is LoadAddressesViewState.LoadAddresses) + assertNull((newState as LoadAddressesViewState.LoadAddresses).error) + } + + /** + * Тестирует выбор адреса. + * + * Проверяет, что метод addressSelected устанавливает состояние + * AddressSelected, сигнализируя о завершении выбора адреса. + */ + @Test + fun addressSelected_emits_AddressSelected_state() = runTest { + val initialState = viewModel.state.first() + assertTrue(initialState is LoadAddressesViewState.LoadAddresses) + + viewModel.addressSelected() + + val newState = viewModel.state.first() + assertTrue(newState is LoadAddressesViewState.AddressSelected) + } + + /** + * Тестирует загрузку вариантов адресов в случае успеха. + * + * Проверяет, что при успешном получении вариантов адресов: + * 1. Сначала испускается состояние LoadingProgress + * 2. Затем испускается состояние Content с полученными вариантами + * 3. Сохраняется правильный порядок состояний + * 4. Вызывается use case ровно один раз + */ + @Test + fun loadAddressVariants_emits_LoadingProgress_then_Content_on_success() = runTest { + + // Дано + val rawAddress = "Test Address" + val expectedVariants = listOf("Variant 1", "Variant 2") + + // Мокируем ответ use case с задержкой для перехвата промежуточного состояния + coEvery { addressSuggestUseCase.findAddress(rawAddress) } coAnswers { + kotlinx.coroutines.delay(100) + expectedVariants + } + + val states = mutableListOf() + + // Подписываемся на изменения состояния + val job = viewModel.state.onEach { state -> + states.add(state) + }.launchIn(this) + + + viewModel.loadAddressVariants(rawAddress) + + // Ждем завершения корутины + advanceUntilIdle() + + // Отменяем подписку + job.cancel() + + + // Проверяем что состояния пришли в правильном порядке + // Может быть 2 или 3 состояния из-за начального состояния + assertTrue("Ожидалось 2 или 3 состояния, но было ${states.size}", states.size in 2..3) + + // Находим индекс LoadingProgress состояния + val loadingProgressIndex = + states.indexOfFirst { it is LoadAddressesViewState.LoadingProgress } + val contentIndex = states.indexOfFirst { it is LoadAddressesViewState.Content } + + // Проверяем что LoadingProgress было перед Content + assertTrue("LoadingProgress должно быть перед Content", loadingProgressIndex < contentIndex) + + // Проверяем что последнее состояние - Content + assertTrue( + "Последнее состояние должно быть Content, но было ${states.last().javaClass.simpleName}", + states.last() is LoadAddressesViewState.Content + ) + + // Проверяем финальное состояние + coVerify(exactly = 1) { addressSuggestUseCase.findAddress(rawAddress) } + confirmVerified(addressSuggestUseCase) + + val contentState = states.last() as LoadAddressesViewState.Content + assertEquals("Ожидаемые варианты не совпадают", expectedVariants, contentState.addresses) + } + + /** + * Тестирует загрузку вариантов адресов в случае ошибки. + * + * Проверяет, что при ошибке получения вариантов адресов: + * 1. Сначала испускается состояние LoadingProgress + * 2. Затем испускается состояние LoadAddresses с ошибкой + * 3. Сохраняется правильный порядок состояний + * 4. Вызывается use case ровно один раз + * 5. Ошибка корректно передается в состояние + */ + @Test + fun loadAddressVariants_emits_LoadingProgress_then_Error_on_failure() = runTest { + + // Дано + val rawAddress = "Test Address" + val testException = Exception("Network error") + + // Мокируем ошибку в use case с задержкой для перехвата промежуточного состояния + coEvery { addressSuggestUseCase.findAddress(rawAddress) } coAnswers { + kotlinx.coroutines.delay(100) + throw testException + } + val states = mutableListOf() + + // Подписываемся на изменения состояния + val job = viewModel.state.onEach { state -> + states.add(state) + }.launchIn(this) + + + viewModel.loadAddressVariants(rawAddress) + + // Ждем завершения корутины + advanceUntilIdle() + + // Отменяем подписку + job.cancel() + + + // Проверяем что состояния пришли в правильном порядке + // Может быть 2 или 3 состояния из-за начального состояния + assertTrue("Ожидалось 2 или 3 состояния, но было ${states.size}", states.size in 2..3) + + // Находим индекс LoadingProgress состояния + val loadingProgressIndex = + states.indexOfFirst { it is LoadAddressesViewState.LoadingProgress } + val errorIndex = + states.indexOfFirst { it is LoadAddressesViewState.LoadAddresses && it.error != null } + + // Проверяем что LoadingProgress было перед состоянием с ошибкой + assertTrue( + "LoadingProgress должно быть перед состоянием с ошибкой", + loadingProgressIndex < errorIndex + ) + + // Проверяем что последнее состояние - LoadAddresses с ошибкой + val lastState = states.last() + assertTrue( + "Последнее состояние должно быть LoadAddresses с ошибкой, но было ${lastState.javaClass.simpleName}", + lastState is LoadAddressesViewState.LoadAddresses && lastState.error != null + ) + + // Проверяем финальное состояние + coVerify(exactly = 1) { addressSuggestUseCase.findAddress(rawAddress) } + confirmVerified(addressSuggestUseCase) + + val errorState = lastState as LoadAddressesViewState.LoadAddresses + assertEquals( + "Ожидаемая ошибка не совпадает", + testException.message, + errorState.error?.message + ) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ea17fa5..a48a1d0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ - + + tools:targetApi="31" + android:name=".MvvmApp" + > diff --git a/app/src/main/java/ru/otus/basicarchitecture/MainActivity.kt b/app/src/main/java/ru/otus/basicarchitecture/MainActivity.kt deleted file mode 100644 index 623aba9..0000000 --- a/app/src/main/java/ru/otus/basicarchitecture/MainActivity.kt +++ /dev/null @@ -1,11 +0,0 @@ -package ru.otus.basicarchitecture - -import androidx.appcompat.app.AppCompatActivity -import android.os.Bundle - -class MainActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/MvvmApp.kt b/app/src/main/java/ru/otus/basicarchitecture/MvvmApp.kt new file mode 100644 index 0000000..d81cfd8 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/MvvmApp.kt @@ -0,0 +1,8 @@ +package ru.otus.basicarchitecture + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class MvvmApp : Application() { +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/config/ModuleConfig.kt b/app/src/main/java/ru/otus/basicarchitecture/config/ModuleConfig.kt new file mode 100644 index 0000000..dac598e --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/config/ModuleConfig.kt @@ -0,0 +1,77 @@ +package ru.otus.basicarchitecture.config + +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped +import dagger.hilt.components.SingletonComponent +import jakarta.inject.Singleton +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import ru.otus.basicarchitecture.BuildConfig +import ru.otus.basicarchitecture.model.net.GetAddresses +import ru.otus.basicarchitecture.service.DaDataService +import ru.otus.basicarchitecture.service.InterestsRepository +import ru.otus.basicarchitecture.service.net.AuthInterceptor +import ru.otus.basicarchitecture.service.net.DaDataApi +import ru.otus.basicarchitecture.service.net.buildRetrofit +import ru.otus.basicarchitecture.use_case.AddressSuggestUseCase +import ru.otus.basicarchitecture.use_case.FieldValidationUseCase +import ru.otus.basicarchitecture.view_model.WizardCache +import kotlin.time.Duration + + +@Module +@InstallIn(ActivityRetainedComponent::class) +abstract class ActivityModule { + @Binds + @ActivityRetainedScoped + abstract fun wizardCache(impl: WizardCache.Impl): WizardCache +} + +@Module +@InstallIn(SingletonComponent::class) +abstract class AppModule { + @Binds + @Singleton + abstract fun interestsRepository(impl: InterestsRepository.Impl): InterestsRepository + + @Binds + @Singleton + abstract fun daDataService(impl: DaDataService.Impl): DaDataService + + @Binds + abstract fun fieldValidationUseCase(impl: FieldValidationUseCase.Impl): FieldValidationUseCase + + @Binds + abstract fun addressSuggestUseCase(impl: AddressSuggestUseCase.Impl): AddressSuggestUseCase + + @Binds + @Singleton + abstract fun getAddressesCommand(impl: GetAddresses.Impl): GetAddresses +} + +@Module +@InstallIn(SingletonComponent::class) +class NetModuleProvider { + @Provides + @Singleton + fun okHttp(authInterceptor: AuthInterceptor): OkHttpClient = OkHttpClient.Builder() + .callTimeout(Duration.parse(BuildConfig.LOAD_ADDRESS_TIMOUT)) + .addInterceptor(authInterceptor) + .addInterceptor(HttpLoggingInterceptor().apply { + setLevel(HttpLoggingInterceptor.Level.BASIC) + }) + .build() + + @Provides + @Singleton + fun retrofit(okHttp: OkHttpClient): Retrofit = buildRetrofit(okHttp) + + @Provides + @Singleton + fun api(retrofit: Retrofit): DaDataApi = retrofit.create(DaDataApi::class.java) +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/model/FieldValueDto.kt b/app/src/main/java/ru/otus/basicarchitecture/model/FieldValueDto.kt new file mode 100644 index 0000000..6e42161 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/model/FieldValueDto.kt @@ -0,0 +1,6 @@ +package ru.otus.basicarchitecture.model + +interface FieldValueDto { + val fValue: T + val isValid: Boolean +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/model/Interest.kt b/app/src/main/java/ru/otus/basicarchitecture/model/Interest.kt new file mode 100644 index 0000000..548d8bb --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/model/Interest.kt @@ -0,0 +1,30 @@ +package ru.otus.basicarchitecture.model + +data class Interest ( + val id: Int, + val title: String, +) + +val interests = setOf( + Interest(1, "IT"), + Interest(2, "Sport"), + Interest(3, "Music"), + Interest(4, "Reading"), + Interest(5, "Travel"), + Interest(6, "Photography"), + Interest(7, "Cooking"), + Interest(8, "Dancing"), + Interest(9, "Art"), + Interest(10, "Gaming"), + Interest(11, "Movies"), + Interest(12, "Fitness"), + Interest(13, "Yoga"), + Interest(14, "Fashion"), + Interest(15, "Programming"), + Interest(16, "Science"), + Interest(17, "Nature"), + Interest(18, "Cars"), + Interest(19, "History"), + Interest(20, "Languages"), + Interest(21, "Writing") +) diff --git a/app/src/main/java/ru/otus/basicarchitecture/model/LongFiledDto.kt b/app/src/main/java/ru/otus/basicarchitecture/model/LongFiledDto.kt new file mode 100644 index 0000000..c0f9392 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/model/LongFiledDto.kt @@ -0,0 +1,3 @@ +package ru.otus.basicarchitecture.model + +class LongFiledDto(override val fValue: Long, override val isValid: Boolean) : FieldValueDto \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/model/StringFiledDto.kt b/app/src/main/java/ru/otus/basicarchitecture/model/StringFiledDto.kt new file mode 100644 index 0000000..dc7be93 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/model/StringFiledDto.kt @@ -0,0 +1,4 @@ +package ru.otus.basicarchitecture.model + +class StringFiledDto(override val fValue: String, override val isValid: Boolean) : + FieldValueDto \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/model/UserData.kt b/app/src/main/java/ru/otus/basicarchitecture/model/UserData.kt new file mode 100644 index 0000000..a9a9962 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/model/UserData.kt @@ -0,0 +1,9 @@ +package ru.otus.basicarchitecture.model + +data class UserData( + val name: String, + val surname: String, + val birthDate: Long, + val address: String, + val tags: Set, +) \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/model/ValidationEvent.kt b/app/src/main/java/ru/otus/basicarchitecture/model/ValidationEvent.kt new file mode 100644 index 0000000..a6b0944 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/model/ValidationEvent.kt @@ -0,0 +1,10 @@ +package ru.otus.basicarchitecture.model + +sealed interface ValidationEvent { + object InvalidName : ValidationEvent + object ValidName : ValidationEvent + object InvalidSurname : ValidationEvent + object ValidSurname : ValidationEvent + object InvalidAge : ValidationEvent + object ValidAge : ValidationEvent +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/model/net/AddressRequest.kt b/app/src/main/java/ru/otus/basicarchitecture/model/net/AddressRequest.kt new file mode 100644 index 0000000..657c68a --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/model/net/AddressRequest.kt @@ -0,0 +1,6 @@ +package ru.otus.basicarchitecture.model.net + +import kotlinx.serialization.Serializable + +@Serializable +data class AddressRequest(val query: String, val count: Int) \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/model/net/GetAddresses.kt b/app/src/main/java/ru/otus/basicarchitecture/model/net/GetAddresses.kt new file mode 100644 index 0000000..15e6a0d --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/model/net/GetAddresses.kt @@ -0,0 +1,20 @@ +package ru.otus.basicarchitecture.model.net + + +import ru.otus.basicarchitecture.service.net.DaDataApi +import java.io.IOException +import javax.inject.Inject + +interface GetAddresses { + suspend operator fun invoke(rawAddress : String): Suggestions + + class Impl @Inject constructor(private val api: DaDataApi) : GetAddresses { + override suspend fun invoke(rawAddress : String): Suggestions { + val response = api.getAddress(AddressRequest(rawAddress, 30)) + if (!response.isSuccessful) { + throw IOException("Unexpected code $response") + } + return response.body() ?: throw IOException("Empty body $response") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/model/net/Suggestion.kt b/app/src/main/java/ru/otus/basicarchitecture/model/net/Suggestion.kt new file mode 100644 index 0000000..86bd800 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/model/net/Suggestion.kt @@ -0,0 +1,20 @@ +package ru.otus.basicarchitecture.model.net + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonIgnoreUnknownKeys + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +@JsonIgnoreUnknownKeys +data class Suggestion ( + val value: String, + @SerialName("unrestricted_value") + val unrestrictedValue: String +) + +@Serializable +data class Suggestions( + val suggestions: List +) \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/service/DaDataService.kt b/app/src/main/java/ru/otus/basicarchitecture/service/DaDataService.kt new file mode 100644 index 0000000..8ba36cf --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/service/DaDataService.kt @@ -0,0 +1,19 @@ +package ru.otus.basicarchitecture.service + +import jakarta.inject.Inject +import ru.otus.basicarchitecture.model.net.GetAddresses +import ru.otus.basicarchitecture.model.net.Suggestions + + +interface DaDataService { + + suspend fun getAddresses(rawAddress: String): Suggestions + + class Impl @Inject constructor( + private val getAddressesCommand: GetAddresses + ) : DaDataService { + override suspend fun getAddresses(rawAddress: String) = getAddressesCommand(rawAddress) + } + +} + diff --git a/app/src/main/java/ru/otus/basicarchitecture/service/InterestsRepository.kt b/app/src/main/java/ru/otus/basicarchitecture/service/InterestsRepository.kt new file mode 100644 index 0000000..cbc9ae2 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/service/InterestsRepository.kt @@ -0,0 +1,28 @@ +package ru.otus.basicarchitecture.service + +import dagger.hilt.android.scopes.ViewModelScoped +import jakarta.inject.Inject +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import ru.otus.basicarchitecture.model.Interest +import ru.otus.basicarchitecture.model.interests + + +@ViewModelScoped +interface InterestsRepository { + fun getInterests(): Flow> + + class Impl @Inject constructor() : InterestsRepository { + override fun getInterests(): Flow> { + return flow { + delay(NETWORK_DELAY) + emit(interests) + } + } + companion object { + const val NETWORK_DELAY = 1000L + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/service/net/AuthInterceptor.kt b/app/src/main/java/ru/otus/basicarchitecture/service/net/AuthInterceptor.kt new file mode 100644 index 0000000..fa6213d --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/service/net/AuthInterceptor.kt @@ -0,0 +1,20 @@ +package ru.otus.basicarchitecture.service.net + +import okhttp3.Interceptor +import okhttp3.Response +import ru.otus.basicarchitecture.BuildConfig + +import javax.inject.Inject + + +class AuthInterceptor @Inject constructor() : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val requestWithAuth = request.newBuilder() + .header("Authorization", "Token ${BuildConfig.DADATA_API_KEY}") + .header("X-Secret", BuildConfig.DADATA_SECRET_KEY) + .build() + + return chain.proceed(requestWithAuth) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/service/net/DaDataApi.kt b/app/src/main/java/ru/otus/basicarchitecture/service/net/DaDataApi.kt new file mode 100644 index 0000000..c106a0e --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/service/net/DaDataApi.kt @@ -0,0 +1,25 @@ +package ru.otus.basicarchitecture.service.net + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.http.Body +import retrofit2.http.POST +import ru.otus.basicarchitecture.BuildConfig +import ru.otus.basicarchitecture.model.net.AddressRequest +import ru.otus.basicarchitecture.model.net.Suggestions + + +interface DaDataApi { + @POST("address") + suspend fun getAddress(@Body addressRequest: AddressRequest): Response +} + +fun buildRetrofit(okHttpClient: OkHttpClient): Retrofit = Retrofit.Builder() + .baseUrl(BuildConfig.DADATA_API_URL) + .client(okHttpClient) + .addConverterFactory(Json.asConverterFactory("application/json".toMediaType())) + .build() diff --git a/app/src/main/java/ru/otus/basicarchitecture/use_case/AddressSuggestUseCase.kt b/app/src/main/java/ru/otus/basicarchitecture/use_case/AddressSuggestUseCase.kt new file mode 100644 index 0000000..a9ec38b --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/use_case/AddressSuggestUseCase.kt @@ -0,0 +1,23 @@ +package ru.otus.basicarchitecture.use_case + +import android.util.Log +import dagger.hilt.android.scopes.ViewModelScoped +import jakarta.inject.Inject +import ru.otus.basicarchitecture.service.DaDataService + +interface AddressSuggestUseCase { + + suspend fun findAddress(rawAddress: String): List + + class Impl @Inject constructor( + private val daDataService: DaDataService + ) : AddressSuggestUseCase { + override suspend fun findAddress(rawAddress: String): List { + return runCatching { daDataService.getAddresses(rawAddress).suggestions.map { it.value }} + .getOrElse { + Log.e("AddressSuggestUseCase", "Error while getting addresses", it) + throw it + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/use_case/FieldValidationUseCase.kt b/app/src/main/java/ru/otus/basicarchitecture/use_case/FieldValidationUseCase.kt new file mode 100644 index 0000000..161b179 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/use_case/FieldValidationUseCase.kt @@ -0,0 +1,33 @@ +package ru.otus.basicarchitecture.use_case + +import dagger.hilt.android.scopes.ViewModelScoped +import jakarta.inject.Inject +import java.util.regex.Pattern + +@ViewModelScoped +interface FieldValidationUseCase { + fun isNameInvalid(name: String): Boolean + fun isSurnameInvalid(surname: String): Boolean + fun isAgeInvalid(birthDate: Long?): Boolean + + class Impl @Inject constructor() : FieldValidationUseCase { + override fun isNameInvalid(name: String) = + name.isEmpty() || !pattern.toRegex().matches(name) + + override fun isSurnameInvalid(surname: String) = + surname.isEmpty() || !pattern.toRegex().matches(surname) + + override fun isAgeInvalid(birthDate: Long?) = + birthDate == null || birthDate > (System.currentTimeMillis() - EIGHTEEN_YEARS) + + companion object { + // Валидация: 1–18 символов, начинается с заглавной, далее строчные или разрешённые символы (не подряд, не в конце) + private const val USERNAME_PATTERN = "^(?=.{1,18}\$)[A-ZА-Я](?:[.'_-](?![.'_-]))?[a-zа-я]*\$" + + private const val EIGHTEEN_YEARS = (18 * 365.25 * 24 * 60 * 60 * 1000).toLong() + + private val pattern: Pattern = + Pattern.compile(USERNAME_PATTERN, Pattern.CASE_INSENSITIVE) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/AddressAdapter.kt b/app/src/main/java/ru/otus/basicarchitecture/view/AddressAdapter.kt new file mode 100644 index 0000000..8e59b18 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/AddressAdapter.kt @@ -0,0 +1,56 @@ +package ru.otus.basicarchitecture.view + + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import ru.otus.basicarchitecture.databinding.ListItemBinding + + +class AddressAdapter( + private val addressItemListener: AddressItemListener, + //todo использовать дефолтный адаптер? +) : ListAdapter( + AddressVariantsCallback +) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ) = + AddressVariantsViewHolder( + binding = ListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), + addressItemListener = addressItemListener + ) + + override fun onBindViewHolder(holder: AddressVariantsViewHolder, position: Int) = + holder.bind(getItem(position)) + + class AddressVariantsViewHolder( + private val binding: ListItemBinding, + private val addressItemListener: AddressItemListener, + ) : RecyclerView.ViewHolder(binding.root) { + + private val address: TextView = binding.root + + fun bind(addressItem: AddressItem) { + address.text = addressItem.address + binding.root.setOnClickListener { + addressItemListener.onItemClick(addressItem.address) + } + } + } + +} + +object AddressVariantsCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: AddressItem, newItem: AddressItem): Boolean { + return oldItem.address == newItem.address + } + + override fun areContentsTheSame(oldItem: AddressItem, newItem: AddressItem): Boolean { + return oldItem.address == newItem.address + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/AddressFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/view/AddressFragment.kt new file mode 100644 index 0000000..30110be --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/AddressFragment.kt @@ -0,0 +1,142 @@ +package ru.otus.basicarchitecture.view + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import androidx.core.view.isVisible +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import ru.otus.basicarchitecture.databinding.FragmentAddressBinding +import ru.otus.basicarchitecture.view_model.AddressFragmentModel + + +@AndroidEntryPoint +class AddressFragment : Fragment(), AddressItemListener { + + private var binding = FragmentBindingDelegate(this) + + private val viewModel by viewModels() + + private val adapter: AddressAdapter by lazy { AddressAdapter(this) } + + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View = binding.bind(container, FragmentAddressBinding::inflate) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupTextWatchers() + setupClickListeners() + setupRecyclerView() + collectToAddressVariantsStateFlow() + collectToSelectedFlow() + } + + private fun setupRecyclerView() = binding.withBinding { addressVariants.adapter = adapter } + + private fun collectToSelectedFlow() { + viewModel.addressFlow.onEach { + viewModel.addressFlow + .flowWithLifecycle(viewLifecycleOwner.lifecycle) + .collect(::onAddressSelected) + }.launchIn(lifecycleScope) + } + + private fun onAddressSelected(address: String) { + binding.withBinding { + addressMenu.editText?.let { + it.setText(address) + it.setSelection(address.length) //сдвинуть курсор в конец + } + } + } + + private fun collectToAddressVariantsStateFlow() { + viewModel.state.onEach { + binding.withBinding { + when (it) { + is LoadAddressesViewState.Content -> { + addressVariants.isVisible = true + loadIndicator.isVisible = false + errorMessage.isVisible = false + adapter.submitList(it.addresses.map { adr -> AddressItem(adr) }) + } + + is LoadAddressesViewState.LoadAddresses -> { + addressVariants.isVisible = false + loadIndicator.isVisible = false + errorMessage.isVisible = it.error != null + } + + LoadAddressesViewState.LoadingProgress -> { + addressVariants.isVisible = false + loadIndicator.isVisible = true + errorMessage.isVisible = false + } + + LoadAddressesViewState.AddressSelected -> { + addressVariants.isVisible = false + loadIndicator.isVisible = false + errorMessage.isVisible = false + hideKeyboard() + toTagsNextButton.requestFocus() + } + } + } + }.launchIn(lifecycleScope) + } + + private fun setupTextWatchers() { + binding.withBinding { + addressMenu.editText?.doOnTextChanged { text, start, before, count -> + if (count > 3) { + viewModel.loadAddressVariants(text.toString()) + } + } + } + } + + private fun setupClickListeners() = + binding.withBinding { toTagsNextButton.setOnClickListener(::toTagsButtonClickListener) } + + private fun toTagsButtonClickListener(view: View) { + findNavController().navigate(AddressFragmentDirections.actionAddressFragmentToTagsFragment()) + } + + override fun onItemClick(address: String) { + binding.withBinding { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.clearVariants() + if (viewModel.updateAddress(address)) { + viewModel.loadAddressVariants(address) + } else { + viewModel.addressSelected() + } + } + } + } + + private fun FragmentAddressBinding.hideKeyboard() { + val imm = + requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(addressMenu.editText?.windowToken, 0) + } +} + + +interface AddressItemListener { + fun onItemClick(address: String) +} diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/AddressItem.kt b/app/src/main/java/ru/otus/basicarchitecture/view/AddressItem.kt new file mode 100644 index 0000000..32d5ca2 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/AddressItem.kt @@ -0,0 +1,14 @@ +package ru.otus.basicarchitecture.view + +import androidx.annotation.LayoutRes +import ru.otus.basicarchitecture.R + +data class AddressItem( + val address: String, +) : WithLayoutId by AddressItem { + companion object : WithLayoutId { + @get:LayoutRes + override val layoutId: Int = R.layout.list_item + } + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/FragmentBindingDelegate.kt b/app/src/main/java/ru/otus/basicarchitecture/view/FragmentBindingDelegate.kt new file mode 100644 index 0000000..bfaf949 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/FragmentBindingDelegate.kt @@ -0,0 +1,49 @@ +package ru.otus.basicarchitecture.view + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.viewbinding.ViewBinding + +/** + * Binds fragment view-binding + */ +class FragmentBindingDelegate(private val fragment: Fragment) { + + private var binding: VB? = null + + /** + * Binds fragment view-binding + * Put inside `onCreateView` + * See: https://developer.android.com/topic/libraries/view-binding#fragments + * @param container View container + * @param inflate Binding inflater + */ + fun bind( + container: ViewGroup?, + inflate: (LayoutInflater, ViewGroup?, Boolean) -> VB + ): View { + fragment.viewLifecycleOwner.lifecycle.addObserver(BindingDestroyer()) + binding = inflate(fragment.layoutInflater, container, false) + return binding!!.root + } + + /** + * Runs [block] with binding + */ + fun withBinding(block: VB.() -> R): R { + return checkNotNull(binding) { "Binding is not initialized" }.block() + } + + /** + * Destroys binding on view destroy + */ + private inner class BindingDestroyer : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + binding = null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/LoadAddressesViewState.kt b/app/src/main/java/ru/otus/basicarchitecture/view/LoadAddressesViewState.kt new file mode 100644 index 0000000..a889706 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/LoadAddressesViewState.kt @@ -0,0 +1,19 @@ +package ru.otus.basicarchitecture.view + + +sealed class LoadAddressesViewState { + /** + * Загрузка не началась + * */ + data class LoadAddresses(val error: Exception? = null) : LoadAddressesViewState() + /** + * Загрузка в процессе + * */ + data object LoadingProgress : LoadAddressesViewState() + /** + * Данные загружены + * */ + data class Content(val addresses: List) : LoadAddressesViewState() + + data object AddressSelected : LoadAddressesViewState() +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/MainActivity.kt b/app/src/main/java/ru/otus/basicarchitecture/view/MainActivity.kt new file mode 100644 index 0000000..e2e75b2 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/MainActivity.kt @@ -0,0 +1,20 @@ +package ru.otus.basicarchitecture.view + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint +import jakarta.inject.Inject +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.view_model.WizardCache + +@AndroidEntryPoint +class MainActivity : AppCompatActivity() { + + @Inject + lateinit var wizardCache: WizardCache + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/NameFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/view/NameFragment.kt new file mode 100644 index 0000000..53f55da --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/NameFragment.kt @@ -0,0 +1,167 @@ +package ru.otus.basicarchitecture.view + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import com.google.android.material.datepicker.MaterialDatePicker +import com.google.android.material.textfield.TextInputEditText +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.databinding.FragmentNameBinding +import ru.otus.basicarchitecture.model.ValidationEvent +import ru.otus.basicarchitecture.view_model.NameFragmentModel +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import kotlin.time.ExperimentalTime + + +@AndroidEntryPoint +class NameFragment : Fragment() { + + private val binding = FragmentBindingDelegate(this) + + private val viewModel by viewModels() + + + private val dataPickerTitle by lazy { getString(R.string.data_picker_title) } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View = binding.bind(container, FragmentNameBinding::inflate) + + @OptIn(ExperimentalTime::class) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setDataPicker() + setupTextWatchers() + setupClickListeners() + setupObservers() + } + + private fun toAddressButtonClickListener(view: View) { + findNavController().navigate(NameFragmentDirections.actionNameFragmentToAddressFragment()) + } + + private fun setupTextWatchers() { + binding.withBinding { + name.onFocusChangeListener = + createValidationOnFocusChangeListener { viewModel.setName(it) } + surname.onFocusChangeListener = + createValidationOnFocusChangeListener { viewModel.setSurname(it) } + } + } + + private fun setupClickListeners() { + binding.withBinding { toAddressNextButton.setOnClickListener(::toAddressButtonClickListener)} + } + + private fun setupObservers() { + // Наблюдение за событиями валидации + viewLifecycleOwner.lifecycleScope.launch { + viewModel.validationEvent.collect { event -> + when (event) { + ValidationEvent.InvalidName -> doInvalidName() + ValidationEvent.InvalidSurname -> doInvalidSurname() + ValidationEvent.InvalidAge -> doInvalidAge() + + ValidationEvent.ValidAge, + ValidationEvent.ValidName, + ValidationEvent.ValidSurname -> doSomeValid() + + } + } + } + } + + private fun doSomeValid() { + if ( + viewModel.nameFlow.value.isValid + && viewModel.surnameFlow.value.isValid + && viewModel.birthDateFlow.value.isValid + ) + enableNextButton() + } + + private fun enableNextButton() { + binding.withBinding { + toAddressNextButton.isEnabled = true + toAddressNextButton.isClickable = true + } + } + + private fun disableNextButton() { + binding.withBinding { + toAddressNextButton.isEnabled = false + toAddressNextButton.isClickable = false + } + } + + private fun doInvalidName() { + disableNextButton() + Toast.makeText(context, getString(R.string.invalid_name), Toast.LENGTH_SHORT).show() + } + + private fun doInvalidSurname() { + disableNextButton() + Toast.makeText(context, getString(R.string.invalid_surname), Toast.LENGTH_SHORT).show() + } + + private fun doInvalidAge() { + disableNextButton() + Toast.makeText(context, getString(R.string.invalid_age), Toast.LENGTH_SHORT).show() + } + + private fun setDataPicker() { + binding.withBinding { + val datePickerBuilder = MaterialDatePicker + .Builder + .datePicker() + .setTitleText(dataPickerTitle) + val textData = birthday.text.toString() + if (textData.isNotEmpty()) { + runCatching { + parseTextData(textData)?.let { datePickerBuilder.setSelection(it.time) } + }.getOrElse { it.printStackTrace() } + } + val datePicker = datePickerBuilder.build() + birthday.setOnFocusChangeListener { v, hasFocus -> + if (hasFocus) { + datePicker.addOnPositiveButtonClickListener { selection: Long -> + birthday.setText(dateFormat.format(Date(selection))) + } + datePicker.addOnNegativeButtonClickListener { + birthday.clearFocus() + } + datePicker.addOnDismissListener { + birthday.clearFocus() + } + datePicker.show(childFragmentManager, "birthday_date") + + } else { + (v as? TextInputEditText) + ?.text?.toString() + ?.let { viewModel.setBirthDate(parseTextData(it)?.time ?: 0) } + ?: viewModel.setBirthDate(0) + + } + } + } + } + + private fun parseTextData(textData: String): Date? = runCatching { + dateFormat.parse(textData) + }.getOrNull() + + +} + diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/ResultFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/view/ResultFragment.kt new file mode 100644 index 0000000..f5fa32b --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/ResultFragment.kt @@ -0,0 +1,61 @@ +package ru.otus.basicarchitecture.view + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.DividerItemDecoration +import dagger.hilt.android.AndroidEntryPoint +import jakarta.inject.Inject +import ru.otus.basicarchitecture.databinding.FragmentResultBinding +import ru.otus.basicarchitecture.model.Interest +import ru.otus.basicarchitecture.view_model.WizardCache +import java.util.Date + + +@AndroidEntryPoint +class ResultFragment : Fragment(), TagItemListener { // TODO: заменить tag view для реализации финального экрана + + @Inject + lateinit var dataCache: WizardCache + + private val binding = FragmentBindingDelegate(this) + + private val tagAdapter: TagAdapter by lazy { TagAdapter(this) } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View = binding.bind(container, FragmentResultBinding::inflate) + + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupRecyclerView() + tagAdapter.submitList(dataCache.userData.tags.map { TagItem(it) }) + binding.withBinding { + val u = dataCache.userData + nameResult.text = u.name + surnameResult.text = u.name + birthdayResult.text = dateFormat.format(Date(u.birthDate)) + addressResult.text = u.address + } + } + + private fun setupRecyclerView() = binding.withBinding { + groupTagsResult.addItemDecoration( + DividerItemDecoration( + this@ResultFragment.requireActivity(), + LinearLayout.VERTICAL + ) + ) + groupTagsResult.adapter = tagAdapter + } + + override fun onItemClick(interest: Interest, isSelected: Boolean) { + // do nothing + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/TagAdapter.kt b/app/src/main/java/ru/otus/basicarchitecture/view/TagAdapter.kt new file mode 100644 index 0000000..5bfe59a --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/TagAdapter.kt @@ -0,0 +1,64 @@ +package ru.otus.basicarchitecture.view + + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.databinding.VhTagBinding + + +class TagAdapter( + private val tagItemListener: TagItemListener, +) : ListAdapter(TagDiffCallback) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ) = + TagViewHolder( + binding = VhTagBinding.inflate(LayoutInflater.from(parent.context), parent, false), + tagItemListener = tagItemListener + ) + + override fun onBindViewHolder(holder: TagViewHolder, position: Int) = + holder.bind(getItem(position)) + + class TagViewHolder( + private val binding: VhTagBinding, + private val tagItemListener: TagItemListener, + ) : RecyclerView.ViewHolder(binding.root) { + + private val tag: TextView = binding.root + + fun bind(tagItem: TagItem) { + with(tagItem) { + tag.text = name + } + binding.root.setOnClickListener { + tagItem.isSelected = !tagItem.isSelected + val context = binding.root.context + val color = + if (tagItem.isSelected) ContextCompat.getColor(context, R.color.teal_200) + else ContextCompat.getColor(context, R.color.white) + + tag.setBackgroundColor(color) + tagItemListener.onItemClick(tagItem.interest, tagItem.isSelected) + } + } + } + +} + +object TagDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: TagItem, newItem: TagItem): Boolean { + return oldItem.interest.id == newItem.interest.id + } + + override fun areContentsTheSame(oldItem: TagItem, newItem: TagItem): Boolean { + return oldItem.interest.title == newItem.interest.title + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/TagItem.kt b/app/src/main/java/ru/otus/basicarchitecture/view/TagItem.kt new file mode 100644 index 0000000..f49aec3 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/TagItem.kt @@ -0,0 +1,17 @@ +package ru.otus.basicarchitecture.view + +import androidx.annotation.LayoutRes +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.model.Interest + +data class TagItem( + val interest: Interest, + var isSelected: Boolean = false +) : WithLayoutId by TagItem { + companion object : WithLayoutId { + @get:LayoutRes + override val layoutId: Int = R.layout.vh_tag + } + + val name: String get() = interest.title +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/TagsFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/view/TagsFragment.kt new file mode 100644 index 0000000..29c804f --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/TagsFragment.kt @@ -0,0 +1,79 @@ +package ru.otus.basicarchitecture.view + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.DividerItemDecoration +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import ru.otus.basicarchitecture.databinding.FragmentTagsBinding +import ru.otus.basicarchitecture.model.Interest +import ru.otus.basicarchitecture.view_model.TagsViewModel + +@AndroidEntryPoint +class TagsFragment : Fragment(), TagItemListener { + + + private val binding = FragmentBindingDelegate(this) + + private val viewModel: TagsViewModel by viewModels() + + private val tagAdapter: TagAdapter by lazy { TagAdapter(this) } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View = binding.bind(container, FragmentTagsBinding::inflate) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupRecyclerView() + collectToTagsFlow() + setupClickListeners() + } + + private fun collectToTagsFlow() { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.tagsFlow + .flowWithLifecycle(viewLifecycleOwner.lifecycle) + .collect(::submitToList) + } + } + + private fun submitToList(interestList: Set) { + tagAdapter.submitList(interestList.map { TagItem(it) }) + } + + private fun setupRecyclerView() = binding.withBinding { + groupTags.addItemDecoration( + DividerItemDecoration( + this@TagsFragment.requireActivity(), + LinearLayout.VERTICAL + ) + ) + groupTags.adapter = tagAdapter + } + + override fun onItemClick(interest: Interest, isSelected: Boolean) { + viewModel.onTagSelected(interest, isSelected) + } + + private fun setupClickListeners() = + binding.withBinding { toResultNextButton.setOnClickListener(::toTagsButtonClickListener) } + + private fun toTagsButtonClickListener(view: View) { + findNavController().navigate(TagsFragmentDirections.actionTagsFragmentToResultFragment()) + } + +} + +interface TagItemListener { + fun onItemClick(interest: Interest, isSelected: Boolean) +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/Utils.kt b/app/src/main/java/ru/otus/basicarchitecture/view/Utils.kt new file mode 100644 index 0000000..2a8497c --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/Utils.kt @@ -0,0 +1,23 @@ +package ru.otus.basicarchitecture.view + +import android.view.View +import com.google.android.material.textfield.TextInputEditText +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + + +fun createValidationOnFocusChangeListener( + validate: (String) -> Unit +): View.OnFocusChangeListener { + return View.OnFocusChangeListener { view, hasFocus -> + val text = (view as? TextInputEditText)?.text?.toString() ?: "" + if (!hasFocus) { + validate(text) + } + } +} + +val dateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).apply { + timeZone = TimeZone.getTimeZone("UTC") +} diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/WithLayoutId.kt b/app/src/main/java/ru/otus/basicarchitecture/view/WithLayoutId.kt new file mode 100644 index 0000000..2c6bc7e --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/WithLayoutId.kt @@ -0,0 +1,11 @@ +package ru.otus.basicarchitecture.view + +import androidx.annotation.LayoutRes + +/** + * Represents an object that has a layout ID. + */ +interface WithLayoutId { + @get:LayoutRes + val layoutId: Int +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view_model/AddressFragmentModel.kt b/app/src/main/java/ru/otus/basicarchitecture/view_model/AddressFragmentModel.kt new file mode 100644 index 0000000..3847be4 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view_model/AddressFragmentModel.kt @@ -0,0 +1,83 @@ +package ru.otus.basicarchitecture.view_model + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import jakarta.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import ru.otus.basicarchitecture.BuildConfig +import ru.otus.basicarchitecture.use_case.AddressSuggestUseCase +import ru.otus.basicarchitecture.view.LoadAddressesViewState +import kotlin.time.Duration + +@HiltViewModel +class AddressFragmentModel @Inject constructor( + private val addressSuggestUseCase: AddressSuggestUseCase, + private val dataCache: WizardCache +) : ViewModel() { + + private val _addressFlow = MutableStateFlow("") + val addressFlow = _addressFlow.asStateFlow() + + private val _state: MutableStateFlow = + MutableStateFlow(LoadAddressesViewState.LoadAddresses()) + val state = _state.asStateFlow() + + private var loadAddressJob: Job? = null + private val loadAddressDelay = Duration.parse(BuildConfig.LOAD_ADDRESS_DELLAY) +// private val loadAddressDelay = 1000L + + init { + // все изменения в адресе сохраняются в кеше + addressFlow.onEach { + dataCache.address = it + }.launchIn(viewModelScope) + } + + suspend fun updateAddress(address: String): Boolean { + if (address.isBlank()) return false + if (_addressFlow.value != address) { + _addressFlow.emit(address) + return true + } + return false + } + + suspend fun clearVariants() { + _state.emit(LoadAddressesViewState.LoadAddresses()) + } + + suspend fun addressSelected() { + _state.emit(LoadAddressesViewState.AddressSelected) + } + + fun loadAddressVariants(rawAddress: String) { + loadAddressJob?.cancel() + loadAddressJob = viewModelScope.launch { + delay(loadAddressDelay) + _state.emit(LoadAddressesViewState.LoadingProgress) + runCatching { + val addressVariants: List = + addressSuggestUseCase.findAddress(rawAddress) + Log.i( + "AddressFragmentModel", + "AddressVariants: \n${addressVariants.joinToString("\n")}" + ) + _state.emit(LoadAddressesViewState.Content(addressVariants)) + }.getOrElse { + Log.e("AddressFragmentModel", "Error when loading address variants: ${it.message}") + _state.emit(LoadAddressesViewState.LoadAddresses(it as? Exception)) + } + + + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view_model/NameFragmentModel.kt b/app/src/main/java/ru/otus/basicarchitecture/view_model/NameFragmentModel.kt new file mode 100644 index 0000000..cef563e --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view_model/NameFragmentModel.kt @@ -0,0 +1,96 @@ +package ru.otus.basicarchitecture.view_model + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import jakarta.inject.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import ru.otus.basicarchitecture.model.LongFiledDto +import ru.otus.basicarchitecture.model.StringFiledDto +import ru.otus.basicarchitecture.model.ValidationEvent +import ru.otus.basicarchitecture.use_case.FieldValidationUseCase + +@HiltViewModel +class NameFragmentModel @Inject constructor( + private val fieldValidationUseCase: FieldValidationUseCase, + private val dataCache: WizardCache +) : ViewModel() { + + private val _nameFlow = MutableStateFlow(StringFiledDto("", false)) + val nameFlow: StateFlow = _nameFlow.asStateFlow() + + private val _surnameFlow = MutableStateFlow(StringFiledDto("", false)) + val surnameFlow: StateFlow = _surnameFlow.asStateFlow() + + private val _birthDateFlow = MutableStateFlow(LongFiledDto(0, false)) + val birthDateFlow: StateFlow = _birthDateFlow.asStateFlow() + + private val _validationEvent = MutableSharedFlow() + val validationEvent: SharedFlow = _validationEvent + + init { + nameFlow.onEach { + if (it.isValid) { + dataCache.name = it.fValue + Log.i("CACHE", "Name: ${it.fValue}") + } + }.launchIn(viewModelScope) + + surnameFlow.onEach { surname -> + if (surname.isValid) { + dataCache.surname = surname.fValue + Log.i("CACHE", "Surname: $surname") + } + }.launchIn(viewModelScope) + + birthDateFlow.onEach { birthDate -> + if (birthDate.isValid) { + dataCache.birthDate = birthDate.fValue + Log.i("CACHE", "BirthDate: $birthDate") + } + }.launchIn(viewModelScope) + } + + + fun setName(name: String) { + if (fieldValidationUseCase.isNameInvalid(name)) { + _nameFlow.value = StringFiledDto(name, false) + sendEvent(ValidationEvent.InvalidName) + } else { + sendEvent(ValidationEvent.ValidName) + _nameFlow.value = StringFiledDto(name, true) + } + } + + fun setSurname(surname: String) { + if (fieldValidationUseCase.isSurnameInvalid(surname)) { + _surnameFlow.value = StringFiledDto(surname, false) + sendEvent(ValidationEvent.InvalidSurname) + } else { + _surnameFlow.value = StringFiledDto(surname, true) + sendEvent(ValidationEvent.ValidSurname) + } + } + + fun setBirthDate(date: Long) { + if (fieldValidationUseCase.isAgeInvalid(date)) { + _birthDateFlow.value = LongFiledDto(date, false) + sendEvent(ValidationEvent.InvalidAge) + } else { + _birthDateFlow.value = LongFiledDto(date, true) + sendEvent(ValidationEvent.ValidAge) + } + } + + private fun sendEvent(event: ValidationEvent) = viewModelScope.launch { + _validationEvent.emit(event) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view_model/TagsViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/view_model/TagsViewModel.kt new file mode 100644 index 0000000..48dde04 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view_model/TagsViewModel.kt @@ -0,0 +1,41 @@ +package ru.otus.basicarchitecture.view_model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import jakarta.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import ru.otus.basicarchitecture.model.Interest +import ru.otus.basicarchitecture.service.InterestsRepository + +@HiltViewModel +class TagsViewModel @Inject constructor( + private val interestsRepository: InterestsRepository, + private val dataCache: WizardCache +) : ViewModel() { + + private val _selectedTagsFlow = MutableStateFlow>(mutableSetOf()) + val selectedTagsFlow = _selectedTagsFlow.asStateFlow() + + val tagsFlow: Flow> = interestsRepository.getInterests() + + init { + selectedTagsFlow.onEach { + dataCache.tags = it + }.launchIn(viewModelScope) + } + + fun onTagSelected(id: Interest, isSelected: Boolean) { + val currentSet = _selectedTagsFlow.value + if (isSelected) { + currentSet.add(id) + } else { + currentSet.remove(id) + } + _selectedTagsFlow.value = currentSet + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view_model/WizardCache.kt b/app/src/main/java/ru/otus/basicarchitecture/view_model/WizardCache.kt new file mode 100644 index 0000000..8a4759a --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view_model/WizardCache.kt @@ -0,0 +1,49 @@ +package ru.otus.basicarchitecture.view_model + +import dagger.hilt.android.scopes.ActivityScoped +import jakarta.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import ru.otus.basicarchitecture.model.Interest +import ru.otus.basicarchitecture.model.UserData + + +@ActivityScoped +interface WizardCache : AutoCloseable { + + val userData: UserData + + var name: String + var surname: String + var birthDate: Long + var address: String + var tags: Set + + class Impl @Inject constructor() : WizardCache { + + override var name: String = "" + override var surname: String = "" + override var birthDate: Long = 0 + + override var address: String = "" + + override var tags: Set = emptySet() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + override val userData: UserData + get() = UserData( + name, + surname, + birthDate, + address, + tags + ) + + override fun close() { + scope.cancel() + } + } +} diff --git a/app/src/main/res/drawable/shape_tag_bg.xml b/app/src/main/res/drawable/shape_tag_bg.xml new file mode 100644 index 0000000..17e59bf --- /dev/null +++ b/app/src/main/res/drawable/shape_tag_bg.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 0b15a20..e303472 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -4,6 +4,15 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".MainActivity"> + tools:context=".view.MainActivity"> + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_address.xml b/app/src/main/res/layout/fragment_address.xml new file mode 100644 index 0000000..4261880 --- /dev/null +++ b/app/src/main/res/layout/fragment_address.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + +