diff --git a/.gitignore b/.gitignore
index a9a7031..d378544 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
*.iml
+*.log
.gradle
/local.properties
/.idea/caches
diff --git a/app/build.gradle b/app/build.gradle
index e515992..4840655 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,6 +1,8 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
+ id 'com.google.devtools.ksp'
+ id 'com.google.dagger.hilt.android'
}
android {
@@ -14,7 +16,25 @@ android {
versionCode 1
versionName "1.0"
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ //testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ //testInstrumentationRunner "ru.otus.basicarchitecture.HiltTestRunner"
+ testInstrumentationRunner "dagger.hilt.android.testing.HiltTestRunner"
+
+
+ // Читаем ключи из local.properties
+ def properties = new Properties()
+ def localPropertiesFile = rootProject.file("local.properties")
+ if (localPropertiesFile.exists()) {
+ properties.load(new FileInputStream(localPropertiesFile))
+ }
+
+ buildConfigField "String", "DADATA_API_KEY", "\"${properties.getProperty("DADATA_API_KEY", "")}\""
+ buildConfigField "String", "DADATA_SECRET_KEY", "\"${properties.getProperty("DADATA_SECRET_KEY", "")}\""
+ }
+
+ buildFeatures {
+ viewBinding true
+ buildConfig true
}
buildTypes {
@@ -30,6 +50,18 @@ android {
kotlinOptions {
jvmTarget = '17'
}
+
+ // Enable KSP
+ ksp {
+ arg("dagger.hilt.disableModulesHaveInstallInCheck", "true") // Optional: Disable install-in check if needed
+ arg("room.schemaLocation", "$projectDir/schemas")
+ arg("dagger.hilt.android.internal.disableAndroidSuperclassValidation", "true")
+ }
+ testOptions {
+ unitTests {
+ includeAndroidResources = true
+ }
+ }
}
dependencies {
@@ -38,7 +70,70 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
+ implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7'
+ implementation 'androidx.activity:activity-ktx:1.10.0'
+ implementation 'androidx.fragment:fragment-ktx:1.8.6' // Для viewModels()
+
+ implementation "com.google.dagger:hilt-android:2.55"
+ implementation 'androidx.navigation:navigation-fragment-ktx:2.8.7'
+ implementation 'androidx.room:room-common:2.6.1'
+ implementation 'androidx.room:room-ktx:2.6.1'
+ implementation "androidx.room:room-runtime:2.6.1"
+ implementation 'androidx.test:runner:1.6.2'
+ implementation 'androidx.test.ext:junit-ktx:1.2.1'
+ testImplementation 'com.google.dagger:hilt-android-testing:2.55'
+ testImplementation 'org.junit.jupiter:junit-jupiter:5.8.1'
+ ksp "androidx.room:room-compiler:2.6.1"
+ ksp "com.google.dagger:hilt-compiler:2.55"
+
+ implementation 'com.google.android.flexbox:flexbox:3.0.0'
+ // Retrofit
+ implementation 'com.squareup.retrofit2:retrofit:2.9.0'
+ // Конвертер JSON (Moshi или Gson)
+ implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
+ // OkHttp (для логирования запросов)
+ implementation 'com.squareup.okhttp3:logging-interceptor:4.11.0'
+
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
+
+ // JUnit для тестов
+ //testImplementation("junit:junit:4.13.2")
+
+ // AndroidX Test (JUnit4 и фреймворк для Android-тестов)
+ //androidTestImplementation("androidx.test.ext:junit:1.1.5")
+ androidTestImplementation 'androidx.test:core:1.5.0'
+
+ // Для тестирования LiveData и StateFlow
+ testImplementation 'androidx.arch.core:core-testing:2.2.0'
+
+ // Fragment Testing
+ debugImplementation 'androidx.fragment:fragment-testing:1.8.6'
+
+ // Hilt для тестирования
+ androidTestImplementation 'com.google.dagger:hilt-android-testing:2.55'
+ kspAndroidTest 'com.google.dagger:hilt-compiler:2.55'
+
+ testImplementation 'app.cash.turbine:turbine:1.0.0'
+ // Coroutines (для runTest и тестов с Flow)
+ testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
+
+ testImplementation 'org.robolectric:robolectric:4.12.2'
+
+ testImplementation 'org.mockito:mockito-core:5.12.0'
+ testImplementation 'org.mockito.kotlin:mockito-kotlin:5.2.1'
+ testImplementation 'org.mockito:mockito-inline:5.2.0'
+ testImplementation 'net.bytebuddy:byte-buddy:1.14.15'
+ androidTestImplementation 'org.mockito:mockito-android:5.12.0'
+
+}
+
+
+tasks.withType(JavaCompile) {
+ options.compilerArgs << "-Xlint:deprecation" // Включаем подробные предупреждения
+}
+
+tasks.withType(Test) {
+ systemProperty "robolectric.enabledSdks", "34"
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1e81fea..d856a72 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,7 +2,10 @@
+
+
+ android:exported="true">
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/ru/otus/basicarchitecture/AddressFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/AddressFragment.kt
new file mode 100644
index 0000000..9c181c0
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/AddressFragment.kt
@@ -0,0 +1,224 @@
+package ru.otus.basicarchitecture
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ArrayAdapter
+import android.widget.Toast
+import androidx.core.widget.doAfterTextChanged
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.fragment.findNavController
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import ru.otus.basicarchitecture.databinding.FragmentAddressBinding
+
+@AndroidEntryPoint
+class AddressFragment : Fragment() {
+ private val viewModel: AddressViewModel by viewModels()
+ private var _binding: FragmentAddressBinding? = null
+ private val binding get() = _binding!!
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentAddressBinding.inflate(
+ inflater, container, false
+ )
+
+ return binding.root
+ }
+
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ // Восстанавливаем сохраненные данные
+ binding.countryInput.setText(viewModel.country.value)
+ binding.cityInput.setText(viewModel.city.value)
+ binding.addressInput.setText(viewModel.address.value)
+
+ // Пример списка стран для автозаполнения
+ val countryAdapter = ArrayAdapter(
+ requireContext(),
+ android.R.layout.simple_dropdown_item_1line,
+ mutableListOf()
+ )
+ binding.countryInput.setAdapter(countryAdapter)
+
+ // Наблюдаем за обновлением списка стран
+ lifecycleScope.launch {
+ viewModel.countrySuggestions.collectLatest { suggestions ->
+ countryAdapter.clear()
+ countryAdapter.addAll(suggestions)
+ countryAdapter.notifyDataSetChanged()
+ }
+ }
+
+ binding.countryInput.doAfterTextChanged { text ->
+ try {
+ viewModel.updateCountry(text.toString())
+ viewModel.loadCountries(text.toString()) // Загружаем список стран при изменении текста
+ } catch (ex: Exception) {
+ Toast.makeText(
+ requireContext(),
+ getString(R.string.load_counties, ex.message), Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+
+
+ val cityAdapter = ArrayAdapter(
+ requireContext(),
+ android.R.layout.simple_dropdown_item_1line,
+ mutableListOf()
+ )
+ binding.cityInput.setAdapter(cityAdapter)
+
+ // Наблюдаем за обновлением списка стран
+ lifecycleScope.launch {
+ viewModel.citySuggestions.collectLatest { suggestions ->
+ cityAdapter.clear()
+ cityAdapter.addAll(suggestions.map { it.location?.value })
+ cityAdapter.notifyDataSetChanged()
+ }
+ }
+
+ binding.cityInput.doAfterTextChanged { text ->
+ try {
+ viewModel.updateCity(text.toString())
+ viewModel.loadCities("${viewModel.country.value}, ${text.toString()}")
+ } catch (ex: Exception) {
+ Toast.makeText(
+ requireContext(),
+ getString(R.string.load_cities, ex.message), Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+
+ val addressAdapter = ArrayAdapter(
+ requireContext(),
+ android.R.layout.simple_dropdown_item_1line,
+ mutableListOf()
+ )
+ binding.addressInput.setAdapter(addressAdapter)
+
+ // Наблюдаем за обновлением списка стран
+ lifecycleScope.launch {
+ viewModel.addressSuggestions.collectLatest { suggestions ->
+ addressAdapter.clear()
+ addressAdapter.addAll(suggestions)
+ addressAdapter.notifyDataSetChanged()
+
+ updateNextButton()
+
+ }
+ }
+
+ binding.addressInput.doAfterTextChanged { text ->
+ try {
+ viewModel.updateAddress(text.toString())
+ updateNextButton()
+ viewModel.loadAddressSuggestions("${viewModel.country.value}, ${viewModel.city.value}, ${text.toString()}")
+
+ } catch (ex: Exception) {
+ Toast.makeText(
+ requireContext(),
+ getString(R.string.load_address, ex.message), Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+
+ binding.nextButton.setOnClickListener {
+ viewModel.country.value = binding.countryInput.text.toString()
+ viewModel.city.value = binding.cityInput.text.toString()
+ viewModel.address.value = binding.addressInput.text.toString()
+ findNavController().navigate(R.id.action_addressFragment_to_interestsFragment)
+ }
+
+
+ lifecycleScope.launch {
+ viewModel.uiState.collectLatest { state ->
+ when (state) {
+ is UiState.Loading -> {
+ binding.loading.visibility = View.VISIBLE
+ }
+
+ is UiState.Success, is UiState.Idle -> {
+ binding.loading.visibility = View.GONE
+ }
+
+ is UiState.Error -> {
+ binding.loading.visibility = View.GONE
+ Toast.makeText(requireContext(), state.message, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+ updateNextButton()
+
+ lifecycleScope.launch {
+ viewModel.country.collectLatest { country ->
+ if (!country.equals(binding.countryInput.text.toString())) {
+ binding.countryInput.setText(country)
+ }
+ }
+ }
+
+ lifecycleScope.launch {
+ viewModel.city.collectLatest { city ->
+ if (!city.equals(binding.cityInput.text.toString())) {
+ binding.cityInput.setText(city)
+ }
+ }
+ }
+
+ if (viewModel.country.value.isBlank() || viewModel.city.value.isBlank()) {
+ lifecycleScope.launch {
+
+ viewModel.loadCityByIp()
+ }
+ }
+ }
+
+ private fun updateNextButton() {
+
+
+ binding.nextButton.isEnabled =
+ binding.addressInput.text.toString().isNotBlank()
+ && (viewModel.addressSuggestions.value.size == 1
+ || cleanString(viewModel.country.value).equals(
+ cleanString(viewModel.confirmedAddress.value.country), ignoreCase = true
+ )
+ && cleanString(viewModel.city.value).equals(
+ cleanString(viewModel.confirmedAddress.value.city), ignoreCase = true
+ )
+ )
+ && (viewModel.addressSuggestions.value.size == 1
+ && cleanString(binding.addressInput.text.toString()).equals(
+ cleanString(viewModel.addressSuggestions.value[0]), ignoreCase = true
+ )
+ || cleanString(binding.addressInput.text.toString()).equals(
+ cleanString(viewModel.confirmedAddress.value.streetWithHouseAndFlat),
+ ignoreCase = true
+ )
+ )
+ || (getString(R.string.back_door) == binding.addressInput.text.toString())
+ }
+
+ fun cleanString(input: String): String {
+ // Убираем все пробелы
+ val trimmed = input.replace(Regex("\\s+"), "")
+ // Находим первую и последнюю букву (русскую/латинскую) или цифру
+ val match = Regex("([a-zA-Zа-яА-Я0-9]).*?([a-zA-Zа-яА-Я0-9])").find(trimmed)
+ return match?.value ?: ""
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/otus/basicarchitecture/AddressViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/AddressViewModel.kt
new file mode 100644
index 0000000..e688922
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/AddressViewModel.kt
@@ -0,0 +1,211 @@
+package ru.otus.basicarchitecture
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.launch
+import ru.otus.basicarchitecture.data.ConfirmedAddress
+import ru.otus.basicarchitecture.data.WizardCache
+import ru.otus.basicarchitecture.net.CityResponse
+import ru.otus.basicarchitecture.usecase.AddressSuggestUseCase
+import ru.otus.basicarchitecture.usecase.CitiesSuggrestUseCase
+import ru.otus.basicarchitecture.usecase.CityByIpUseCase
+import ru.otus.basicarchitecture.usecase.ClearOldCacheUseCase
+import ru.otus.basicarchitecture.usecase.CountriesSuggrestUseCase
+import javax.inject.Inject
+
+private const val UNKNOWN_ERROR = "Unknown error"
+private const val TAG = "AddressViewModel"
+private const val CACHE_LIFE_TIME_IN_MILLISECONDS = 20 * 24 * 60 * 60 * 1000
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@HiltViewModel
+class AddressViewModel @Inject constructor(
+ private val wizardCache: WizardCache,
+ private val addressSuggestUseCase: AddressSuggestUseCase,
+ private val citiesSuggrestUseCase: CitiesSuggrestUseCase,
+ private val countriesSuggrestUseCase: CountriesSuggrestUseCase,
+ private val cityByIpUseCase: CityByIpUseCase,
+ private val clearOldCacheUseCase: ClearOldCacheUseCase
+
+) : ViewModel() {
+ val country = wizardCache.country
+ val city = wizardCache.city
+ val address = wizardCache.address
+ val confirmedAddress = wizardCache.confirmedAddress
+
+ init {
+ viewModelScope.launch {
+ launch { clearOldCache(System.currentTimeMillis() - CACHE_LIFE_TIME_IN_MILLISECONDS) }
+ }
+ }
+
+ private val _uiState = MutableStateFlow(UiState.Idle)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _addressSuggestions = MutableStateFlow>(emptyList())
+ val addressSuggestions: StateFlow> = _addressSuggestions.asStateFlow()
+
+ private var addressSuggestJob: Job? = null
+
+ fun loadAddressSuggestions(query: String) {
+ // Минимальное количество символов перед запросом
+ addressSuggestJob?.cancel()
+ if (query.isEmpty()) {
+ _citySuggestions.value = listOf()
+ return
+ }
+ addressSuggestJob = viewModelScope.launch {
+ _uiState.value = UiState.Loading
+ try {
+ addressSuggestUseCase.execute(query)
+ .flowOn(Dispatchers.IO)
+ .catch { e -> _uiState.value = UiState.Error(e.message ?: UNKNOWN_ERROR) }
+ .collect { result ->
+ _addressSuggestions.value = result.map {
+ val fullAddress = ConfirmedAddress(
+ country = it.country ?: "",
+ city = it.city_with_type ?: it.region_with_type ?: "",
+ streetWithHouseAndFlat = buildString {
+ append(it.street_with_type)
+ if (!it.house_type.isNullOrBlank()) append(" ${it.house_type}")
+ if (!it.house.isNullOrBlank()) append(" ${it.house}")
+
+ if (!it.flat_type.isNullOrBlank()) append(", ${it.flat_type}")
+ if (!it.flat.isNullOrBlank()) append(" ${it.flat}")
+
+ if (!it.block_type.isNullOrBlank()) append(", ${it.block_type}")
+ if (!it.block.isNullOrBlank()) append(" ${it.block}")
+ }.trim()
+ )
+ updateConfirmedAddress(fullAddress)
+
+ fullAddress.streetWithHouseAndFlat
+ }
+ _uiState.value = UiState.Success
+ }
+ } catch (e: Exception) {
+ _uiState.value = UiState.Error(e.message ?: UNKNOWN_ERROR)
+ }
+ }
+ }
+
+ private val _countrySuggestions = MutableStateFlow>(emptyList())
+ val countrySuggestions: StateFlow> = _countrySuggestions.asStateFlow()
+
+ private var countrySuggestJob: Job? = null
+
+ fun loadCountries(query: String) {
+ countrySuggestJob?.cancel()
+ // Минимальное количество символов перед запросом
+ if (query.isEmpty()) {
+ _citySuggestions.value = listOf()
+ return
+ }
+ countrySuggestJob = viewModelScope.launch {
+ _uiState.value = UiState.Loading
+ try {
+ countriesSuggrestUseCase.execute(query)
+ .flowOn(Dispatchers.IO)
+ .catch { e ->
+ _uiState.value = UiState.Error(e.message ?: UNKNOWN_ERROR)
+ }
+ .collect { result ->
+ _countrySuggestions.value = result
+ _uiState.value = UiState.Success
+ }
+ } catch (e: Exception) {
+ _uiState.value = UiState.Error(e.message ?: UNKNOWN_ERROR)
+ }
+ }
+ }
+
+
+ private val _citySuggestions = MutableStateFlow>(emptyList())
+ val citySuggestions: StateFlow> = _citySuggestions.asStateFlow()
+
+ private var citySuggestJob: Job? = null
+
+ fun loadCities(query: String) {
+ citySuggestJob?.cancel()
+ // Минимальное количество символов перед запросом
+ if (query.isEmpty()) {
+ _citySuggestions.value = listOf()
+ return
+ }
+ citySuggestJob = viewModelScope.launch {
+ _uiState.value = UiState.Loading
+ try {
+ citiesSuggrestUseCase.execute(query)
+ .flowOn(Dispatchers.IO)
+ .catch { e -> _uiState.value = UiState.Error(e.message ?: UNKNOWN_ERROR) }
+ .collect { result ->
+ _citySuggestions.value = result
+ _uiState.value = UiState.Success
+ }
+ } catch (e: Exception) {
+ _uiState.value = UiState.Error(e.message ?: UNKNOWN_ERROR)
+ }
+ }
+ }
+
+ private var cityByIpJob: Job? = null
+
+ fun loadCityByIp() {
+ cityByIpJob?.cancel()
+ cityByIpJob = viewModelScope.launch {
+ _uiState.value = UiState.Loading
+ try {
+ cityByIpUseCase.execute()
+ .flowOn(Dispatchers.IO)
+ .catch {
+ e -> _uiState.value = UiState.Error(e.message ?: UNKNOWN_ERROR)
+ }
+ .collect { result ->
+ result.location?.let {
+ updateCity(it.value)
+ updateCountry(it.data.country)
+ }
+ _uiState.value = UiState.Success
+ }
+ } catch (e: Exception) {
+ _uiState.value = UiState.Error(e.message ?: UNKNOWN_ERROR)
+ }
+ }
+ }
+
+ private fun clearOldCache(expiryTime: Long) {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ clearOldCacheUseCase.execute(expiryTime)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error clearing cache: ", e)
+ }
+ }
+ }
+
+ fun updateCountry(value: String) {
+ wizardCache.country.value = value
+ }
+
+ fun updateCity(value: String) {
+ wizardCache.city.value = value
+ }
+
+ fun updateAddress(value: String) {
+ wizardCache.address.value = value
+ }
+
+ private fun updateConfirmedAddress(value: ConfirmedAddress) {
+ wizardCache.confirmedAddress.value = value
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/otus/basicarchitecture/App.kt b/app/src/main/java/ru/otus/basicarchitecture/App.kt
new file mode 100644
index 0000000..c06fc8a
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/App.kt
@@ -0,0 +1,14 @@
+package ru.otus.basicarchitecture
+
+import android.app.Application
+import android.content.Context
+import dagger.hilt.android.HiltAndroidApp
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+
+@HiltAndroidApp
+class App : Application() {
+ // Delete when https://github.com/google/dagger/issues/3601 is resolved.
+ @Inject @ApplicationContext lateinit var context: Context
+}
+
diff --git a/app/src/main/java/ru/otus/basicarchitecture/InterestsFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/InterestsFragment.kt
new file mode 100644
index 0000000..17eb782
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/InterestsFragment.kt
@@ -0,0 +1,72 @@
+package ru.otus.basicarchitecture
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.fragment.findNavController
+import com.google.android.flexbox.FlexboxLayout
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
+import ru.otus.basicarchitecture.databinding.FragmentInterestsBinding
+
+@AndroidEntryPoint
+class InterestsFragment : Fragment() {
+ private val viewModel: InterestsViewModel by viewModels()
+ private var _binding: FragmentInterestsBinding? = null
+ private val binding get() = _binding!!
+ private lateinit var flexboxLayoutInterests: FlexboxLayout
+ private val _interests = listOf(
+ "Котлин", "Андроид", "ML", "Игры", "Фитнес", "Коньки", "Футбол", "Сноуборд",
+ "Горные лыжи", "Беговые лыжи", "Музыка", "Фильмы", "Технологии",
+ "Киберспорт", "Настольные игры", "Книги", "Фотография",
+ "Велосипед", "Путешествия", "Автомобили", "Гаджеты", "Наука", "Кулинария", "Шахматы",
+ "Настольный теннис", "Пейнтбол", "Бег", "Йога", "История"
+ )
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentInterestsBinding.inflate(
+ inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ flexboxLayoutInterests = view.findViewById(R.id.fl_interests)
+
+ // Подписка на изменения списка интересов
+ lifecycleScope.launch {
+ viewModel.interests.collect { selectedInterests ->
+ updateTags(selectedInterests)
+ }
+ }
+
+ binding.nextButton.setOnClickListener {
+ findNavController().navigate(R.id.action_interestsFragment_to_summaryFragment)
+ }
+ }
+
+ private fun updateTags(selectedInterests: List) {
+ flexboxLayoutInterests.removeAllViews()
+ for (interest in _interests) {
+ val chip = LayoutInflater.from(requireContext())
+ .inflate(R.layout.item_chip, flexboxLayoutInterests, false) as TextView
+ chip.text = interest
+ chip.isSelected = selectedInterests.contains(interest)
+
+ chip.setOnClickListener {
+ chip.isSelected = !chip.isSelected
+ viewModel.toggleInterest(interest)
+ }
+
+ flexboxLayoutInterests.addView(chip)
+ }
+ }
+}
diff --git a/app/src/main/java/ru/otus/basicarchitecture/InterestsViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/InterestsViewModel.kt
new file mode 100644
index 0000000..8cce845
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/InterestsViewModel.kt
@@ -0,0 +1,25 @@
+package ru.otus.basicarchitecture
+
+import androidx.lifecycle.ViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import ru.otus.basicarchitecture.data.WizardCache
+import javax.inject.Inject
+
+@HiltViewModel
+class InterestsViewModel @Inject constructor(
+ wizardCache: WizardCache
+) : ViewModel() {
+
+ val interests = wizardCache.interests
+
+ fun toggleInterest(interest: String) {
+ val updatedList = interests.value.toMutableList()
+ if (updatedList.contains(interest)) {
+ updatedList.remove(interest)
+ } else {
+ updatedList.add(interest)
+ }
+ interests.value = updatedList
+ }
+}
+
diff --git a/app/src/main/java/ru/otus/basicarchitecture/MainActivity.kt b/app/src/main/java/ru/otus/basicarchitecture/MainActivity.kt
index 623aba9..6a90bc7 100644
--- a/app/src/main/java/ru/otus/basicarchitecture/MainActivity.kt
+++ b/app/src/main/java/ru/otus/basicarchitecture/MainActivity.kt
@@ -2,10 +2,20 @@ package ru.otus.basicarchitecture
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
+import androidx.activity.addCallback
+import androidx.navigation.findNavController
+import dagger.hilt.android.AndroidEntryPoint
+@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
+
+ onBackPressedDispatcher.addCallback(this) {
+ if (!findNavController(R.id.fragment_container_view).popBackStack()) {
+ finish()
+ }
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/ru/otus/basicarchitecture/MaskWatcher.kt b/app/src/main/java/ru/otus/basicarchitecture/MaskWatcher.kt
new file mode 100644
index 0000000..cc2f231
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/MaskWatcher.kt
@@ -0,0 +1,100 @@
+package ru.otus.basicarchitecture
+
+import android.text.Editable
+import android.text.TextWatcher
+import android.widget.EditText
+
+
+class MaskWatcher(private val mask: String, private val editText: EditText) : TextWatcher {
+
+ private var isUpdating = false
+ private var lastFormattedText = ""
+ private var lastCursorPosition = 0
+
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
+ lastCursorPosition = start // Запоминаем позицию курсора перед изменением
+ }
+
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
+ if (isUpdating || s.isNullOrEmpty()) return
+
+ val cleanText = s.filter { it.isDigit() } // Оставляем только цифры
+ val formattedText = applyMask(cleanText.toString())
+
+ if (formattedText == lastFormattedText) return
+
+ isUpdating = true
+ editText.setText(formattedText)
+
+ // Корректируем позицию курсора
+ val cursorPosition = calculateCursorPosition(start, before, count, formattedText, cleanText.length)
+ editText.setSelection(cursorPosition.coerceIn(0, formattedText.length)) // Защита от выхода за границы
+ lastFormattedText = formattedText
+ isUpdating = false
+ }
+
+ override fun afterTextChanged(s: Editable?) {}
+
+ private fun applyMask(cleanText: String): String {
+ val formattedText = StringBuilder()
+ var cleanIndex = 0
+ var maskIndex = 0
+
+ while (maskIndex < mask.length) {
+ if (mask[maskIndex] == '#') {
+ if (cleanIndex < cleanText.length) {
+ formattedText.append(cleanText[cleanIndex])
+ cleanIndex++
+ } else {
+ formattedText.append('_') // Use underscore for empty mask positions
+ }
+ } else {
+ formattedText.append(mask[maskIndex])
+ }
+ maskIndex++
+ }
+
+ return formattedText.toString()
+ }
+
+ private fun calculateCursorPosition(start: Int, before: Int, count: Int, formattedText: String, cleanLength: Int): Int {
+ var pos = start + count // Базовое смещение курсора
+
+ if (count > before) {
+ // New character entered
+ var offset = 0
+ var maskIndex = 0
+ var cleanIndex = 0
+
+ while (cleanIndex < cleanLength && maskIndex < mask.length) {
+ if (mask[maskIndex] == '#') {
+ if (cleanIndex == start) {
+ pos += offset
+ break
+ }
+ cleanIndex++
+ }
+ if (mask[maskIndex] != '#') offset++
+ maskIndex++
+ }
+ } else if (before > count) {
+ // Character was deleted
+ while (pos > 0 && formattedText.getOrNull(pos - 1) !in '0'..'9') {
+ pos-- // Move cursor back if we're on a non-digit character
+ }
+ // If cursor is on an underscore or after the last digit, move to the nearest digit or start of string
+ if (pos > 0 && formattedText[pos - 1] == '_') {
+ while (pos > 0 && formattedText[pos - 1] == '_') {
+ pos--
+ }
+ }
+ }
+
+ // Ensure cursor is on a digit, underscore, or at the end of the string
+ while (pos < formattedText.length && formattedText[pos] !in '0'..'9' && formattedText[pos] != '_') {
+ pos++
+ }
+
+ return pos
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/otus/basicarchitecture/NameFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/NameFragment.kt
new file mode 100644
index 0000000..3fc5b6b
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/NameFragment.kt
@@ -0,0 +1,75 @@
+package ru.otus.basicarchitecture
+
+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.navigation.fragment.findNavController
+import dagger.hilt.android.AndroidEntryPoint
+import ru.otus.basicarchitecture.databinding.FragmentNameBinding
+import java.text.ParseException
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Locale
+
+@AndroidEntryPoint
+class NameFragment : Fragment() {
+ val viewModel: NameViewModel by viewModels()
+ private var _binding: FragmentNameBinding? = null
+ val binding get() = _binding!!
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentNameBinding.inflate(
+ inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ // Восстанавливаем сохраненные данные
+ binding.nameInput.setText(viewModel.name.value)
+ binding.surnameInput.setText(viewModel.surname.value)
+ binding.birthDateInput.setText(viewModel.birthDate.value)
+
+ binding.birthDateInput.addTextChangedListener(
+ MaskWatcher("##.##.####", binding.birthDateInput))
+
+ binding.nextButton.setOnClickListener {
+ val birthDate = binding.birthDateInput.text.toString()
+ if (!validateBirthDate(birthDate)) {
+ Toast.makeText(requireContext(),
+ getString(R.string.you_must_be_18), Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+ viewModel.name.value = binding.nameInput.text.toString()
+ viewModel.surname.value = binding.surnameInput.text.toString()
+ viewModel.birthDate.value = birthDate
+ findNavController().navigate(R.id.action_nameFragment_to_addressFragment)
+ }
+
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ fun validateBirthDate(date: String): Boolean {
+ val sdf = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault())
+ sdf.isLenient = false
+ return try {
+ val birthDate = sdf.parse(date) ?: return false
+ val calendar = Calendar.getInstance()
+ calendar.add(Calendar.YEAR, -18)
+ birthDate.before(calendar.time)
+ } catch (e: ParseException) {
+ false
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/otus/basicarchitecture/NameViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/NameViewModel.kt
new file mode 100644
index 0000000..2af30c3
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/NameViewModel.kt
@@ -0,0 +1,15 @@
+package ru.otus.basicarchitecture
+
+import androidx.lifecycle.ViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import ru.otus.basicarchitecture.data.WizardCache
+import javax.inject.Inject
+
+@HiltViewModel
+class NameViewModel @Inject constructor(
+ wizardCache: WizardCache
+) : ViewModel() {
+ val name = wizardCache.name
+ val surname = wizardCache.surname
+ val birthDate = wizardCache.birthDate
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/otus/basicarchitecture/SummaryFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/SummaryFragment.kt
new file mode 100644
index 0000000..a447be4
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/SummaryFragment.kt
@@ -0,0 +1,68 @@
+package ru.otus.basicarchitecture
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.lifecycleScope
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import ru.otus.basicarchitecture.databinding.FragmentSummaryBinding
+
+@AndroidEntryPoint
+class SummaryFragment : Fragment() {
+ private val viewModel: SummaryViewModel by viewModels()
+ private var _binding: FragmentSummaryBinding? = null
+ private val binding get() = _binding!!
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentSummaryBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ // Подписка на данные из ViewModel
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.name.collectLatest { binding.tvName.text = it }
+ }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.surname.collectLatest { binding.tvSurname.text = it }
+ }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.birthDate.collectLatest { binding.tvDob.text = it }
+ }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.address.collectLatest { binding.tvAddress.text = it }
+ }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.interests.collectLatest { updateInterests(it) }
+ }
+ }
+
+ private fun updateInterests(interests: List) {
+ binding.flInterests.removeAllViews()
+ for (interest in interests) {
+ val chip = LayoutInflater.from(requireContext())
+ .inflate(R.layout.item_chip, binding.flInterests, false) as TextView
+ chip.text = interest
+ binding.flInterests.addView(chip)
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/app/src/main/java/ru/otus/basicarchitecture/SummaryViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/SummaryViewModel.kt
new file mode 100644
index 0000000..24f3539
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/SummaryViewModel.kt
@@ -0,0 +1,19 @@
+package ru.otus.basicarchitecture
+
+import androidx.lifecycle.ViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import ru.otus.basicarchitecture.data.WizardCache
+import javax.inject.Inject
+
+@HiltViewModel
+class SummaryViewModel @Inject constructor(
+ wizardCache: WizardCache
+) : ViewModel() {
+ val name = wizardCache.name
+ val surname = wizardCache.surname
+ val birthDate = wizardCache.birthDate
+ val address = MutableStateFlow(
+ "${wizardCache.country.value}, ${wizardCache.city.value}, ${wizardCache.address.value}")
+ val interests = wizardCache.interests
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/otus/basicarchitecture/UiState.kt b/app/src/main/java/ru/otus/basicarchitecture/UiState.kt
new file mode 100644
index 0000000..66dc72c
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/UiState.kt
@@ -0,0 +1,8 @@
+package ru.otus.basicarchitecture
+
+sealed class UiState {
+ data object Idle : UiState()
+ data object Loading : UiState()
+ data object Success : UiState()
+ data class Error(val message: String) : UiState()
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/otus/basicarchitecture/data/DaDataCache.kt b/app/src/main/java/ru/otus/basicarchitecture/data/DaDataCache.kt
new file mode 100644
index 0000000..b30ceeb
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/data/DaDataCache.kt
@@ -0,0 +1,95 @@
+package ru.otus.basicarchitecture.data
+
+
+import androidx.room.ColumnInfo
+import androidx.room.Dao
+import androidx.room.Database
+import androidx.room.Entity
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.PrimaryKey
+import androidx.room.Query
+import androidx.room.RoomDatabase
+
+@Entity(tableName = "address_cache")
+data class CachedAddress(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ @ColumnInfo(name = "query") val query: String, // Запрос
+ val result: String,
+ val country: String,
+ val region_with_type: String?,
+ val city_with_type: String?,
+ val street_with_type: String?,
+ val house: String?,
+ val house_type: String?,
+ val flat: String?,
+ val flat_type: String?,
+ val geoLat: String?,
+ val geoLon: String?,
+ val block_type: String?,
+ val block: String?,
+ @ColumnInfo(name = "timestamp") val timestamp: Long = System.currentTimeMillis()
+)
+
+@Entity(tableName = "country_cache")
+data class CachedCountry(
+ @PrimaryKey val name: String,
+ @ColumnInfo(name = "query") val query: String, // Связь с запросом
+ val timestamp: Long = System.currentTimeMillis()
+)
+
+@Entity(tableName = "city_cache")
+data class CachedCity(
+ @PrimaryKey val id: Long = 0,
+ @ColumnInfo(name = "query") val query: String,
+ val name: String,
+ val country: String,
+ val timestamp: Long = System.currentTimeMillis()
+)
+
+@Dao
+interface AddressCacheDao {
+ @Query("SELECT * FROM address_cache WHERE [query] = :query")
+ suspend fun getCachedAddresses(query: String): List?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun saveAddresses(addresses: List)
+
+ @Query("DELETE FROM address_cache WHERE timestamp < :expiryTime")
+ suspend fun clearOldCache(expiryTime: Long)
+}
+
+@Dao
+interface CountryCacheDao {
+ @Query("SELECT * FROM country_cache WHERE [query] = :query")
+ suspend fun getCountriesByQuery(query: String): List?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun saveCountries(countries: List)
+
+ @Query("DELETE FROM country_cache WHERE timestamp < :expiryTime")
+ suspend fun clearOldCache(expiryTime: Long)
+}
+
+@Dao
+interface CityCacheDao {
+ @Query("SELECT * FROM city_cache WHERE [query] = :query")
+ suspend fun getCitiesByQuery(query: String): List?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun saveCities(cities: List)
+
+ @Query("DELETE FROM city_cache WHERE timestamp < :expiryTime")
+ suspend fun clearOldCache(expiryTime: Long)
+}
+
+@Database(
+ entities = [CachedAddress::class, CachedCountry::class, CachedCity::class]
+ , version = 2
+ , exportSchema = false)
+abstract class AppDatabase : RoomDatabase() {
+ abstract fun addressCacheDao(): AddressCacheDao
+ abstract fun countryCacheDao(): CountryCacheDao
+ abstract fun cityCacheDao(): CityCacheDao
+}
+
diff --git a/app/src/main/java/ru/otus/basicarchitecture/data/DatabaseModule.kt b/app/src/main/java/ru/otus/basicarchitecture/data/DatabaseModule.kt
new file mode 100644
index 0000000..f99feea
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/data/DatabaseModule.kt
@@ -0,0 +1,67 @@
+package ru.otus.basicarchitecture.data
+
+import android.content.Context
+import androidx.room.Room
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class) // Делаем доступным на уровне всего приложения
+object DatabaseModule {
+ val MIGRATION_1_2 = object : Migration(1, 2) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ // Проверяем наличие колонок и добавляем, если их нет
+ val cursor = db.query("PRAGMA table_info(address_cache)")
+ val existingColumns = mutableSetOf()
+ while (cursor.moveToNext()) {
+ existingColumns.add(cursor.getString(cursor.getColumnIndexOrThrow("name")))
+ }
+ cursor.close()
+
+ if (!existingColumns.contains("block_type")) {
+ db.execSQL("ALTER TABLE address_cache ADD COLUMN block_type TEXT")
+ }
+ if (!existingColumns.contains("block")) {
+ db.execSQL("ALTER TABLE address_cache ADD COLUMN block TEXT")
+ }
+ if (!existingColumns.contains("geoLat")) {
+ db.execSQL("ALTER TABLE address_cache ADD COLUMN geoLat TEXT")
+ }
+ if (!existingColumns.contains("geoLon")) {
+ db.execSQL("ALTER TABLE address_cache ADD COLUMN geoLon TEXT")
+ }
+ }
+ }
+
+ @Provides
+ @Singleton
+ fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
+ return Room.databaseBuilder(
+ context,
+ AppDatabase::class.java,
+ "app_database"
+ ).addMigrations(MIGRATION_1_2)
+ .build()
+ }
+
+ @Provides
+ fun provideAddressDao(database: AppDatabase): AddressCacheDao {
+ return database.addressCacheDao()
+ }
+
+ @Provides
+ fun provideCountryDao(database: AppDatabase): CountryCacheDao {
+ return database.countryCacheDao()
+ }
+
+ @Provides
+ fun provideCityDao(database: AppDatabase): CityCacheDao {
+ return database.cityCacheDao()
+ }
+}
diff --git a/app/src/main/java/ru/otus/basicarchitecture/data/WizardCache.kt b/app/src/main/java/ru/otus/basicarchitecture/data/WizardCache.kt
new file mode 100644
index 0000000..cefdc58
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/data/WizardCache.kt
@@ -0,0 +1,23 @@
+package ru.otus.basicarchitecture.data
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class WizardCache @Inject constructor() {
+ val name = MutableStateFlow("")
+ val surname = MutableStateFlow("")
+ val birthDate = MutableStateFlow("")
+ val country = MutableStateFlow("")
+ val city = MutableStateFlow("")
+ val address = MutableStateFlow("")
+ val interests = MutableStateFlow>(emptyList())
+ val confirmedAddress = MutableStateFlow(ConfirmedAddress("", "", ""))
+}
+
+data class ConfirmedAddress(
+ val country: String,
+ val city: String,
+ val streetWithHouseAndFlat: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/ru/otus/basicarchitecture/net/AuthInterceptor.kt b/app/src/main/java/ru/otus/basicarchitecture/net/AuthInterceptor.kt
new file mode 100644
index 0000000..8e8c0d5
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/net/AuthInterceptor.kt
@@ -0,0 +1,26 @@
+package ru.otus.basicarchitecture.net
+
+import okhttp3.Interceptor
+import okhttp3.Response
+import ru.otus.basicarchitecture.BuildConfig
+
+import javax.inject.Inject
+
+class AuthInterceptor @Inject constructor() : Interceptor {
+ private val token = BuildConfig.DADATA_API_KEY
+ private val secret = BuildConfig.DADATA_SECRET_KEY
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val originalRequest = chain.request()
+ val newRequest = originalRequest.newBuilder()
+ .addHeader("Authorization", token)
+ .apply {
+ // Добавляем X-Secret только для запроса очистки адреса
+ if (originalRequest.url.encodedPath.contains("/clean/address")) {
+ addHeader("X-Secret", secret)
+ }
+ }
+ .build()
+ return chain.proceed(newRequest)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/otus/basicarchitecture/net/DaDataService.kt b/app/src/main/java/ru/otus/basicarchitecture/net/DaDataService.kt
new file mode 100644
index 0000000..1bd3ee3
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/net/DaDataService.kt
@@ -0,0 +1,294 @@
+package ru.otus.basicarchitecture.net
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+import retrofit2.http.Body
+import retrofit2.http.GET
+import retrofit2.http.POST
+import retrofit2.http.Query
+
+
+/*
+
+Поиск страны:
+
+запрос:
+curl -X POST -H "Content-Type: application/json" -H "Accept: application/json" -H "Authorization: Token aabb123456789" -d "{ \"query\": \"TH\" }" "http://suggestions.dadata.ru/suggestions/api/4_1/rs/findById/country" -o dadata.countries.res.json
+
+ответ:
+
+{
+ "suggestions": [
+ {
+ "value": "Таиланд"
+ }
+ ]
+}
+
+Поиск адреса:
+
+запрос:
+curl -X POST -H "Content-Type: application/json" -H "Accept: application/json" -H "Authorization: Token aabb123456789" -H "X-Secret: aabb9123456789" -d "[ \"Тайланд, Пхукет, Главный пр. 131 кв. 12\" ]" "https://cleaner.dadata.ru/api/v1/clean/address" -o dadata.address.res.json
+
+ответ:
+[
+ {
+ "result": "г Пхукет, пр-кт Главный, д 131, кв 12",
+ "country": "Тайланд",
+ "region_with_type": "г Пхукет",
+ "city_with_type": "г Пхукет",
+ "street_with_type": "пр-кт Главный",
+ "house_type": "д",
+ "house": "131",
+ "flat_type": "кв",
+ "flat": "12",
+ "geo_lat": "29.8514164",
+ "geo_lon": "40.2739338"
+ }
+]
+
+
+Поиск адреса нормальный:
+
+запрос:
+curl -X POST -H "Content-Type: application/json" -H "Accept: application/json" -H "Authorization: Token aabb123456789" -d "@dadata.sugrest.address.req.json" "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" -o dadata.sugrest.address.res.json
+
+dadata.sugrest.address.req.json
+
+{ "query": "Россия, Санкт-петербург, Ленинский пр. 131 кв. 12" }
+
+ответ:
+{
+ "suggestions": [
+ {
+ "value": "г Санкт-Петербург, Ленинский пр-кт, д 131, кв 12",
+ "data": {
+ "country": "Россия",
+ "region_with_type": "г Санкт-Петербург",
+ "city_with_type": "г Санкт-Петербург",
+ "house_type": "д",
+ "house": "131",
+ "flat_type": "кв",
+ "flat": "12",
+ "block_type": null,
+ "block": null
+ }
+ },
+ {
+ "value": "г Санкт-Петербург, Ленинский пр-кт, д 131 литера А, кв 12",
+ "data": {
+ "country": "Россия",
+ "region_with_type": "г Санкт-Петербург",
+ "city_with_type": "г Санкт-Петербург",
+ "street_with_type": "Ленинский пр-кт",
+ "house_type": "д",
+ "house": "131",
+ "block_type": "литера",
+ "block": "А",
+ "flat_type": "кв",
+ "flat": "12"
+ }
+ },
+ {
+ "value": "г Санкт-Петербург, Ленинский пр-кт, д 131 к 2 литера А, кв 12",
+ "data": {
+ "country": "Россия",
+ "region_with_type": "г Санкт-Петербург",
+ "city_with_type": "г Санкт-Петербург",
+ "street_with_type": "Ленинский пр-кт",
+ "house_type": "д",
+ "house": "131",
+ "block_type": "к",
+ "block": "2 литера А",
+ "flat_type": "кв",
+ "flat": "12",
+
+ }
+ }
+ ]
+}
+
+Поиск города по IP адресу:
+
+запрос:
+curl -X GET -H "Accept: application/json" -H "Authorization: Token aabb123456789" "http://suggestions.dadata.ru/suggestions/api/4_1/rs/iplocate/address?ip=" -o dadata.cities-empty-ip.res.json
+
+ответ:
+{
+ "location": {
+ "value": "г Пхукет",
+ "data": {
+ "country": "Тайланд"
+ }
+ }
+}
+
+ */
+
+
+interface SuggestionsApi {
+ @POST("suggestions/api/4_1/rs/suggest/country")
+ suspend fun findCountry(@Body request: Map): CountryResponse
+
+ @GET("suggestions/api/4_1/rs/iplocate/address")
+ suspend fun findCityByIp(@Query("ip") ip: String): CityResponse
+
+ @POST("suggestions/api/4_1/rs/suggest/address")
+ suspend fun suggestAddress(@Body request: Map): AddressSuggestionResponse
+}
+
+//interface CleanerApi {
+// @POST("api/v1/clean/address")
+// suspend fun cleanAddress(@Body request: List): List
+//}
+
+private const val QUERY = "query"
+
+@Singleton
+class DaDataService @Inject constructor(
+ private val suggestionsApi: SuggestionsApi
+ //, private val cleanerApi: CleanerApi
+) {
+ fun getCountries(query: String): Flow> = flow {
+ val response = suggestionsApi.findCountry(mapOf(QUERY to query))
+// val response = CountryResponse(
+// listOf(
+// CountrySuggestion("Россия"),
+// CountrySuggestion("Беларусь"),
+// CountrySuggestion("Грузия"),
+// CountrySuggestion("Казахстан"),
+// CountrySuggestion("ОАЭ")
+// ).filter { it.value.contains(query, ignoreCase = true) }
+// )
+ emit(response.suggestions.map { it.value })
+ }
+
+ fun getAddressSuggestions(query: String): Flow> = flow {
+ val response = suggestionsApi.suggestAddress(mapOf(QUERY to query)).suggestions.map {
+ AddressResponse(it.value, it.data.country, it.data.region_with_type
+ , it.data.city_with_type, it.data.street_with_type
+ , it.data.house_type, it.data.house
+ , it.data.flat_type, it.data.flat
+ , it.data.block_type, it.data.block)
+ }
+// val response = cleanerApi.cleanAddress(listOf(query))
+// val response = listOf(
+// AddressResponse(
+// "Россия, г Санкт-Петербург, пр-кт Невский д 123 кв 45",
+// "Россия",
+// "г Санкт-Петербург",
+// null,
+// "пр-кт Невский",
+// "д",
+// "123",
+// "кв",
+// "45",
+// "33.0",
+// "45.0"
+// )
+// , AddressResponse(
+// "Россия, г Санкт-Петербург, пр-кт Невский д 12 кв 34",
+// "Россия",
+// "г Санкт-Петербург",
+// null,
+// "пр-кт Невский",
+// "д",
+// "12",
+// "кв",
+// "34",
+// "33.0",
+// "45.0"
+// )
+// , AddressResponse(
+// "Россия, г Санкт-Петербург, пр-кт Староневский д 12 кв 34",
+// "Россия",
+// "г Санкт-Петербург",
+// null,
+// "пр-кт Староневский",
+// "д",
+// "12",
+// "кв",
+// "34",
+// "33.0",
+// "45.0"
+// )
+// , AddressResponse(
+// "Россия, г Москва, пр-т Ленинский д 123 кв 45",
+// "Россия",
+// "г Москва",
+// null,
+// "пр-т Ленинский",
+// "д",
+// "123",
+// "кв",
+// "45",
+// "45.0",
+// "46.0"
+// ), AddressResponse(
+// "Россия, г Сочи, ул Прибрежная д 123 кв 45",
+// "Россия",
+// "г Сочи",
+// null,
+// "ул Прибрежная",
+// "д",
+// "123",
+// "кв",
+// "45",
+// "55.0",
+// "51.0"
+// )
+// ).filter {
+// it.result
+// .replace(", г ", ", ")
+// .replace(", пр-кт ", ", ")
+// .contains(
+// query
+// .replace(", г ", ", ")
+// .replace(", пр-кт ", ", "), ignoreCase = true
+// )
+// }
+ emit(response)
+ }
+
+ fun getCityByIp(ip: String): Flow = flow {
+ val response = suggestionsApi.findCityByIp(ip)
+// val response = CityResponse(
+// CityLocation(
+// "г Санкт-Петербург", CityData("Россия")
+// )
+// )
+ emit(response)
+ }
+}
+
+// Модели ответов
+data class CountryResponse(val suggestions: List)
+data class CountrySuggestion(val value: String)
+
+data class AddressResponse(
+ val result: String?,
+ val country: String?,
+ val region_with_type: String?,
+ val city_with_type: String?,
+ val street_with_type: String?,
+ val house_type: String?,
+ val house: String?,
+ val flat_type: String?,
+ val flat: String?,
+ val block_type: String?,
+ val block: String?
+)
+
+data class CityResponse(val location: CityLocation?)
+data class CityLocation(val value: String, val data: CityData)
+data class CityData(val country: String)
+
+data class AddressSuggestionResponse(val suggestions: List)
+
+data class AddressSuggestion(
+ val value: String,
+ val data: AddressResponse
+)
diff --git a/app/src/main/java/ru/otus/basicarchitecture/net/NetworkModule.kt b/app/src/main/java/ru/otus/basicarchitecture/net/NetworkModule.kt
new file mode 100644
index 0000000..c3512db
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/net/NetworkModule.kt
@@ -0,0 +1,64 @@
+package ru.otus.basicarchitecture.net
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import okhttp3.OkHttpClient
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import javax.inject.Named
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object NetworkModule {
+
+ @Provides
+ @Singleton
+ fun provideAuthInterceptor(): AuthInterceptor {
+ return AuthInterceptor()
+ }
+
+ @Provides
+ @Singleton
+ fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
+ return OkHttpClient.Builder()
+ .addInterceptor(authInterceptor) // Добавляем наш интерцептор
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ @Named("cleaner")
+ fun provideCleanerRetrofit(client: OkHttpClient): Retrofit {
+ return Retrofit.Builder()
+ .baseUrl("https://cleaner.dadata.ru")
+ .addConverterFactory(GsonConverterFactory.create())
+ .client(client)
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ @Named("suggestions")
+ fun provideSuggestionsRetrofit(client: OkHttpClient): Retrofit {
+ return Retrofit.Builder()
+ .baseUrl("https://suggestions.dadata.ru")
+ .addConverterFactory(GsonConverterFactory.create())
+ .client(client)
+ .build()
+ }
+
+// @Provides
+// @Singleton
+// fun provideCleanerApi(@Named("cleaner") retrofit: Retrofit): CleanerApi {
+// return retrofit.create(CleanerApi::class.java)
+// }
+
+ @Provides
+ @Singleton
+ fun provideSuggestionsApi(@Named("suggestions") retrofit: Retrofit): SuggestionsApi {
+ return retrofit.create(SuggestionsApi::class.java)
+ }
+}
diff --git a/app/src/main/java/ru/otus/basicarchitecture/usecase/AddressSuggestUsecase.kt b/app/src/main/java/ru/otus/basicarchitecture/usecase/AddressSuggestUsecase.kt
new file mode 100644
index 0000000..3988ec5
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/usecase/AddressSuggestUsecase.kt
@@ -0,0 +1,99 @@
+package ru.otus.basicarchitecture.usecase
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import ru.otus.basicarchitecture.data.AddressCacheDao
+import ru.otus.basicarchitecture.data.CachedAddress
+import ru.otus.basicarchitecture.net.AddressResponse
+import ru.otus.basicarchitecture.net.DaDataService
+import javax.inject.Inject
+import javax.inject.Singleton
+
+
+private const val EMPTY = "EMPTY"
+
+@ExperimentalCoroutinesApi
+@Singleton
+class AddressSuggestUseCase @Inject constructor(
+ private val daDataService: DaDataService,
+ private val addressCacheDao: AddressCacheDao
+) {
+ fun execute(query: String): Flow> = flow {
+ val cached = addressCacheDao.getCachedAddresses(query)
+ if (cached.isNullOrEmpty()) {
+ val response = daDataService.getAddressSuggestions(query).firstOrNull()
+ if (!response.isNullOrEmpty()) {
+ addressCacheDao.saveAddresses(response.map { it.toCachedAddress(query) })
+ if (response.size == 1) {
+ addressCacheDao.saveAddresses(response.map { it.toCachedAddress(it.result ?: "") })
+ }
+ emit(response)
+ } else {
+ // Сохраняем маркер пустого ответа
+ addressCacheDao.saveAddresses(listOf(createEmptyCachedAddress(query)))
+ emit(listOf())
+ }
+ } else {
+ // Если найден "пустой" кэш, вернуть пустой список
+ if (cached.any { it.result == EMPTY }) {
+ emit(emptyList())
+ } else {
+ emit(cached.map { it.toAddressResponse() })
+ }
+ }
+
+ }.flowOn(Dispatchers.IO)
+}
+
+private fun CachedAddress.toAddressResponse() = AddressResponse(
+ result = result,
+ country = country,
+ region_with_type = region_with_type,
+ city_with_type = city_with_type,
+ street_with_type = street_with_type,
+ house_type = house_type,
+ house = house,
+ flat_type = flat_type,
+ flat = flat,
+ block_type = block_type,
+ block = block
+)
+
+private fun AddressResponse.toCachedAddress(query: String) = CachedAddress(
+ query = query,
+ result = result ?: "",
+ country = country ?: "",
+ region_with_type = region_with_type,
+ city_with_type = city_with_type,
+ street_with_type = street_with_type,
+ house_type = house_type,
+ house = house,
+ flat_type = flat_type,
+ flat = flat,
+ geoLat = null,
+ geoLon = null,
+ block_type = block_type,
+ block = block
+)
+
+// Вспомогательная функция для создания "пустого" ответа
+private fun createEmptyCachedAddress(query: String) = CachedAddress(
+ query = query,
+ result = EMPTY,
+ country = "",
+ region_with_type = null,
+ city_with_type = null,
+ street_with_type = null,
+ house = null,
+ house_type = null,
+ flat = null,
+ flat_type = null,
+ geoLat = null,
+ geoLon = null,
+ block_type = null,
+ block = null
+)
\ No newline at end of file
diff --git a/app/src/main/java/ru/otus/basicarchitecture/usecase/CitiesSuggrestUseCase.kt b/app/src/main/java/ru/otus/basicarchitecture/usecase/CitiesSuggrestUseCase.kt
new file mode 100644
index 0000000..c422ad1
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/usecase/CitiesSuggrestUseCase.kt
@@ -0,0 +1,63 @@
+package ru.otus.basicarchitecture.usecase
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import ru.otus.basicarchitecture.data.CachedCity
+import ru.otus.basicarchitecture.data.CityCacheDao
+import ru.otus.basicarchitecture.net.AddressResponse
+import ru.otus.basicarchitecture.net.CityData
+import ru.otus.basicarchitecture.net.CityLocation
+import ru.otus.basicarchitecture.net.CityResponse
+import ru.otus.basicarchitecture.net.DaDataService
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val EMPTY = "EMPTY"
+
+@ExperimentalCoroutinesApi
+@Singleton
+class CitiesSuggrestUseCase @Inject constructor(
+ private val daDataService: DaDataService,
+ private val cityCacheDao: CityCacheDao
+) {
+ fun execute(query: String): Flow> = flow {
+ val cached = cityCacheDao.getCitiesByQuery(query)
+ if (cached.isNullOrEmpty()) {
+ val response = daDataService.getAddressSuggestions(query).firstOrNull()
+ if (!response.isNullOrEmpty()) {
+ val cities = response.mapNotNull { it.toCachedCity(query) }.distinctBy { it.name }
+ cityCacheDao.saveCities(cities)
+ emit(cities.map { CityResponse(CityLocation(it.name, CityData(it.country))) })
+ } else {
+ // Сохраняем маркер пустого ответа
+ cityCacheDao.saveCities(listOf(createEmptyCachedCity(query)))
+ emit(listOf())
+ }
+ } else {
+ // Проверяем наличие "пустого" ответа
+ if (cached.any { it.name == EMPTY }) {
+ emit(emptyList())
+ }
+ emit(cached.map { CityResponse(CityLocation(it.name, CityData(it.country))) })
+ }
+ }.flowOn(Dispatchers.IO)
+}
+
+private fun AddressResponse.toCachedCity(query: String): CachedCity? {
+ val city = city_with_type ?: region_with_type
+ return if (!city.isNullOrBlank() && country?.isNotBlank() != false) {
+ CachedCity(name = city, country = country ?: "", query = query)
+ } else null
+}
+
+// Вспомогательная функция для создания "пустого" ответа
+private fun createEmptyCachedCity(query: String) = CachedCity(
+ id = 0,
+ name = EMPTY,
+ country = "",
+ query = query
+)
\ No newline at end of file
diff --git a/app/src/main/java/ru/otus/basicarchitecture/usecase/CityByIpUseCase.kt b/app/src/main/java/ru/otus/basicarchitecture/usecase/CityByIpUseCase.kt
new file mode 100644
index 0000000..1c002bc
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/usecase/CityByIpUseCase.kt
@@ -0,0 +1,18 @@
+package ru.otus.basicarchitecture.usecase
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOn
+import ru.otus.basicarchitecture.net.CityResponse
+import ru.otus.basicarchitecture.net.DaDataService
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@ExperimentalCoroutinesApi
+@Singleton
+class CityByIpUseCase @Inject constructor(
+ private val daDataService: DaDataService
+) {
+ fun execute(): Flow = daDataService.getCityByIp("").flowOn(Dispatchers.IO)
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/otus/basicarchitecture/usecase/ClearOldCacheUseCase.kt b/app/src/main/java/ru/otus/basicarchitecture/usecase/ClearOldCacheUseCase.kt
new file mode 100644
index 0000000..0e423b9
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/usecase/ClearOldCacheUseCase.kt
@@ -0,0 +1,22 @@
+package ru.otus.basicarchitecture.usecase
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import ru.otus.basicarchitecture.data.AddressCacheDao
+import ru.otus.basicarchitecture.data.CityCacheDao
+import ru.otus.basicarchitecture.data.CountryCacheDao
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@ExperimentalCoroutinesApi
+@Singleton
+class ClearOldCacheUseCase @Inject constructor(
+ private val addressCacheDao: AddressCacheDao,
+ private val countryCacheDao: CountryCacheDao,
+ private val cityCacheDao: CityCacheDao
+) {
+ suspend fun execute(expiryTime: Long) {
+ addressCacheDao.clearOldCache(expiryTime)
+ countryCacheDao.clearOldCache(expiryTime)
+ cityCacheDao.clearOldCache(expiryTime)
+ }
+}
diff --git a/app/src/main/java/ru/otus/basicarchitecture/usecase/CountriesSuggrestUseCase.kt b/app/src/main/java/ru/otus/basicarchitecture/usecase/CountriesSuggrestUseCase.kt
new file mode 100644
index 0000000..c518028
--- /dev/null
+++ b/app/src/main/java/ru/otus/basicarchitecture/usecase/CountriesSuggrestUseCase.kt
@@ -0,0 +1,50 @@
+package ru.otus.basicarchitecture.usecase
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import ru.otus.basicarchitecture.data.CachedCountry
+import ru.otus.basicarchitecture.data.CountryCacheDao
+import ru.otus.basicarchitecture.net.DaDataService
+import javax.inject.Inject
+import javax.inject.Singleton
+
+
+private const val EMPTY = "EMPTY"
+
+@ExperimentalCoroutinesApi
+@Singleton
+class CountriesSuggrestUseCase @Inject constructor(
+ private val daDataService: DaDataService,
+ private val countryCacheDao: CountryCacheDao
+) {
+ fun execute(query: String): Flow> = flow {
+ val cached = countryCacheDao.getCountriesByQuery(query)
+ if (cached.isNullOrEmpty()) {
+ val response = daDataService.getCountries(query).firstOrNull()
+ if (!response.isNullOrEmpty()) {
+ countryCacheDao.saveCountries(response.map { CachedCountry(it, query) })
+ emit(response)
+ } else {
+ // Сохраняем маркер пустого ответа
+ countryCacheDao.saveCountries(listOf(createEmptyCachedCountry(query)))
+ emit(listOf())
+ }
+ } else {
+ // Проверяем наличие "пустого" ответа
+ if (cached.any { it.name == EMPTY }) {
+ emit(emptyList())
+ }
+ emit(cached.map { it.name })
+ }
+ }.flowOn(Dispatchers.IO)
+}
+
+// Вспомогательная функция для создания "пустого" ответа
+private fun createEmptyCachedCountry(query: String) = CachedCountry(
+ name = EMPTY,
+ query = query
+)
\ No newline at end of file
diff --git a/app/src/main/res/anim/enter_animation.xml b/app/src/main/res/anim/enter_animation.xml
new file mode 100644
index 0000000..93562e7
--- /dev/null
+++ b/app/src/main/res/anim/enter_animation.xml
@@ -0,0 +1,7 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/anim/exit_animation.xml b/app/src/main/res/anim/exit_animation.xml
new file mode 100644
index 0000000..5b49a38
--- /dev/null
+++ b/app/src/main/res/anim/exit_animation.xml
@@ -0,0 +1,7 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/anim/pop_enter_animation.xml b/app/src/main/res/anim/pop_enter_animation.xml
new file mode 100644
index 0000000..30d0674
--- /dev/null
+++ b/app/src/main/res/anim/pop_enter_animation.xml
@@ -0,0 +1,7 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/anim/pop_exit_animation.xml b/app/src/main/res/anim/pop_exit_animation.xml
new file mode 100644
index 0000000..d97d5b9
--- /dev/null
+++ b/app/src/main/res/anim/pop_exit_animation.xml
@@ -0,0 +1,7 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/bg_chip.xml b/app/src/main/res/drawable/bg_chip.xml
new file mode 100644
index 0000000..1c072cd
--- /dev/null
+++ b/app/src/main/res/drawable/bg_chip.xml
@@ -0,0 +1,15 @@
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
\ 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..b0ef87f 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -6,4 +6,15 @@
android:layout_height="match_parent"
tools:context=".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..4b70838
--- /dev/null
+++ b/app/src/main/res/layout/fragment_address.xml
@@ -0,0 +1,181 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_interests.xml b/app/src/main/res/layout/fragment_interests.xml
new file mode 100644
index 0000000..2eecbb0
--- /dev/null
+++ b/app/src/main/res/layout/fragment_interests.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_name.xml b/app/src/main/res/layout/fragment_name.xml
new file mode 100644
index 0000000..d0a4822
--- /dev/null
+++ b/app/src/main/res/layout/fragment_name.xml
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_summary.xml b/app/src/main/res/layout/fragment_summary.xml
new file mode 100644
index 0000000..36c48b0
--- /dev/null
+++ b/app/src/main/res/layout/fragment_summary.xml
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_chip.xml b/app/src/main/res/layout/item_chip.xml
new file mode 100644
index 0000000..8afee94
--- /dev/null
+++ b/app/src/main/res/layout/item_chip.xml
@@ -0,0 +1,12 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/navigation/wizard_nav_graph.xml b/app/src/main/res/navigation/wizard_nav_graph.xml
new file mode 100644
index 0000000..3cb1365
--- /dev/null
+++ b/app/src/main/res/navigation/wizard_nav_graph.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index bbaa36f..ec5788a 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -1,16 +1,18 @@
-
+
-
\ No newline at end of file
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
new file mode 100644
index 0000000..e7371d7
--- /dev/null
+++ b/app/src/main/res/values/attrs.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index f8c6127..e3f9d96 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -7,4 +7,23 @@
#FF018786
#FF000000
#FFFFFFFF
+
+
+ #008DED
+ #1a5b8e
+ #ffffff
+
+ #C5C5C5
+ #66A9E0
+ #F5F5F5
+
+
+ #66A9E0
+ #0c1520
+ #ffffff
+
+ #1a1a1a
+ #a0a0a0
+ #ffffff
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f26b6d3..9fde6d2 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,21 @@
BasicArchitecture
+ Name
+ Surname
+ Birth date (dd.mm.yyyy)
+ Next
+ Address
+ Country
+ City
+ You must be 18+
+ Review & Confirm
+ Interests
+ Date of Birth
+ Load counties: %1$s
+ Load cities: %1$s
+ "load address suggression: %1$s"
+ Enter Your Name
+ Choose Your Location
+ Select Interests
+ Ветеранов
\ No newline at end of file
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..9b9f602
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
\ 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 0ab4563..b27be17 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,16 +1,18 @@
-
+
-
\ No newline at end of file
diff --git a/app/src/main/secrets.defaults.properties b/app/src/main/secrets.defaults.properties
new file mode 100644
index 0000000..92b206d
--- /dev/null
+++ b/app/src/main/secrets.defaults.properties
@@ -0,0 +1,2 @@
+DADATA_API_KEY=
+DADATA_SECRET_KEY=
diff --git a/app/src/test/java/ru/otus/basicarchitecture/AddressViewModelTest.kt b/app/src/test/java/ru/otus/basicarchitecture/AddressViewModelTest.kt
new file mode 100644
index 0000000..f88d1aa
--- /dev/null
+++ b/app/src/test/java/ru/otus/basicarchitecture/AddressViewModelTest.kt
@@ -0,0 +1,211 @@
+package ru.otus.basicarchitecture
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import app.cash.turbine.test
+import dagger.hilt.android.testing.HiltTestApplication
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+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.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito
+import org.mockito.Mockito.doReturn
+import org.mockito.junit.MockitoJUnitRunner
+import org.mockito.kotlin.whenever
+import org.robolectric.annotation.Config
+import ru.otus.basicarchitecture.data.WizardCache
+import ru.otus.basicarchitecture.net.AddressResponse
+import ru.otus.basicarchitecture.net.CityData
+import ru.otus.basicarchitecture.net.CityLocation
+import ru.otus.basicarchitecture.net.CityResponse
+import ru.otus.basicarchitecture.usecase.AddressSuggestUseCase
+import ru.otus.basicarchitecture.usecase.CitiesSuggrestUseCase
+import ru.otus.basicarchitecture.usecase.CityByIpUseCase
+import ru.otus.basicarchitecture.usecase.ClearOldCacheUseCase
+import ru.otus.basicarchitecture.usecase.CountriesSuggrestUseCase
+
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(MockitoJUnitRunner::class)
+@Config(application = HiltTestApplication::class, sdk = [34])
+class AddressViewModelTest {
+
+ @get:Rule
+ val instantTaskExecutorRule = InstantTaskExecutorRule()
+
+ @Mock
+ private lateinit var wizardCache: WizardCache
+
+ @Mock
+ private lateinit var addressSuggestUseCase: AddressSuggestUseCase
+
+ @Mock
+ private lateinit var citiesSuggestUseCase: CitiesSuggrestUseCase
+
+ @Mock
+ private lateinit var countriesSuggestUseCase: CountriesSuggrestUseCase
+
+ @Mock
+ private lateinit var cityByIpUseCase: CityByIpUseCase
+
+ @Mock
+ private lateinit var clearOldCacheUseCase: ClearOldCacheUseCase
+
+ private lateinit var viewModel: AddressViewModel
+
+ private val testDispatcher = StandardTestDispatcher()
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ wizardCache = WizardCache()
+ viewModel = AddressViewModel(
+ wizardCache,
+ addressSuggestUseCase,
+ citiesSuggestUseCase,
+ countriesSuggestUseCase,
+ cityByIpUseCase,
+ clearOldCacheUseCase
+ )
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `loadAddressSuggestions emits success state with data`() = runTest {
+ val mockAddress = AddressResponse(
+ "Country, City, Street h 3, kv 4 k 3"
+ , "Country"
+ , "City"
+ , "City"
+ , "Street"
+ , "h"
+ , "3"
+ , "kv"
+ , "4"
+ , "k"
+ , "3")
+
+ doReturn(flowOf(listOf(mockAddress)))
+ .whenever(addressSuggestUseCase)
+ .execute("test")
+
+ viewModel.loadAddressSuggestions("test")
+ advanceUntilIdle() // Process all coroutines
+
+ viewModel.uiState.test {
+ assertEquals(UiState.Success, awaitItem())
+ }
+ }
+
+ @Test
+ fun `loadAddressSuggestions emits error state on failure`() = runTest {
+ Mockito.`when`(addressSuggestUseCase.execute("test"))
+ .thenThrow(RuntimeException("Network error"))
+
+ viewModel.loadAddressSuggestions("test")
+ advanceUntilIdle() // Process all coroutines
+
+ viewModel.uiState.test {
+ while (true) {
+ val item = awaitItem()
+ if (item !is UiState.Loading) {
+ val errorState = item as UiState.Error
+ assertEquals("Network error", errorState.message)
+ break
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `loadCountries emits success state`() = runTest {
+ // Arrange: Mock the use case to return a Flow with "Russia"
+ doReturn(flowOf(listOf("Russia")))
+ .whenever(countriesSuggestUseCase)
+ .execute("ru")
+ viewModel.loadCountries("ru")
+ advanceUntilIdle() // Process all coroutines
+ viewModel.uiState.test {
+ while (true) {
+ val item = awaitItem()
+ if (item !is UiState.Loading) {
+ assertEquals(UiState.Success, item)
+ break
+ }
+ }
+ }
+ }
+
+
+ @Test
+ fun `loadCountries emits error state`() = runTest {
+ Mockito.`when`(countriesSuggestUseCase.execute("test"))
+ .thenThrow(RuntimeException("Network error"))
+
+ viewModel.loadCountries("test")
+ advanceUntilIdle() // Process all coroutines
+
+ viewModel.uiState.test {
+ while (true) {
+ val item = awaitItem()
+ if (item !is UiState.Loading) {
+ val errorState = item as UiState.Error
+ assertEquals("Network error", errorState.message)
+ break
+ }
+ }
+ }
+ }
+
+
+ @Test
+ fun `loadCityByIp success`() = runTest {
+ val mockResponse = CityResponse(CityLocation("Moscow", CityData("Russia")))
+ doReturn(flowOf(mockResponse))
+ .whenever(cityByIpUseCase)
+ .execute()
+ viewModel.loadCityByIp()
+ advanceUntilIdle() // Process all coroutines
+ viewModel.uiState.test {
+ while (true) {
+ val item = awaitItem()
+ if (item !is UiState.Loading) {
+ assertEquals(UiState.Success, item)
+ break
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `loadCityByIp emits handles error`() = runTest {
+ Mockito.`when`(cityByIpUseCase.execute())
+ .thenThrow(RuntimeException("IP error"))
+ viewModel.loadCityByIp()
+ advanceUntilIdle() // Process all coroutines
+ viewModel.uiState.test {
+ while (true) {
+ val item = awaitItem()
+ if (item !is UiState.Loading) {
+ val errorState = item as UiState.Error
+ assertEquals("IP error", errorState.message)
+ break
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/test/java/ru/otus/basicarchitecture/NameFragmentUnitTest.kt b/app/src/test/java/ru/otus/basicarchitecture/NameFragmentUnitTest.kt
new file mode 100644
index 0000000..4331e68
--- /dev/null
+++ b/app/src/test/java/ru/otus/basicarchitecture/NameFragmentUnitTest.kt
@@ -0,0 +1,60 @@
+package ru.otus.basicarchitecture
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import dagger.hilt.android.testing.HiltTestApplication
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Locale
+
+@HiltAndroidTest
+@RunWith(AndroidJUnit4::class)
+@Config(application = HiltTestApplication::class, sdk = [34])
+class NameFragmentTest {
+
+ @get:Rule
+ var hiltRule = HiltAndroidRule(this)
+
+ @get:Rule
+ val instantTaskExecutorRule = InstantTaskExecutorRule()
+
+ @Test
+ fun validateBirthDate_validDate_returnsTrue() {
+ val fragment = NameFragment()
+ assertTrue(fragment.validateBirthDate("15.08.1990"))
+ }
+
+ @Test
+ fun validateBirthDate_invalidDate_returnsFalse() {
+ val fragment = NameFragment()
+ assertFalse(fragment.validateBirthDate("32.13.2020"))
+ }
+
+ @Test
+ fun validateBirthDate_under18_returnsFalse() {
+ val fragment = NameFragment()
+ val sdf = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault())
+ val calendar = Calendar.getInstance()
+ calendar.add(Calendar.YEAR, -17) // Младше 18 лет
+ val date = sdf.format(calendar.time)
+ assertFalse(fragment.validateBirthDate(date))
+ }
+
+ @Test
+ fun validateBirthDate_exactly18_returnsTrue() {
+ val fragment = NameFragment()
+ val sdf = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault())
+ val calendar = Calendar.getInstance()
+ calendar.add(Calendar.YEAR, -18)
+ val date = sdf.format(calendar.time)
+ assertTrue(fragment.validateBirthDate(date))
+ }
+}
diff --git a/build.gradle b/build.gradle
index 7b166ff..b934b48 100644
--- a/build.gradle
+++ b/build.gradle
@@ -3,4 +3,7 @@ plugins {
id 'com.android.application' version '8.7.3' apply false
id 'com.android.library' version '8.7.3' apply false
id 'org.jetbrains.kotlin.android' version '2.0.21' apply false
+ id "com.google.devtools.ksp" version "2.0.21-1.0.27" apply false
+ id 'com.google.dagger.hilt.android' version '2.55' apply false
+ id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin' version '2.0.1' apply false
}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index 3c5031e..114707a 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -6,7 +6,7 @@
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
-org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -XX:+EnableDynamicAgentLoading
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
@@ -20,4 +20,10 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
-android.nonTransitiveRClass=true
\ No newline at end of file
+android.nonTransitiveRClass=true
+
+robolectric.enabledSdks=34
+
+DADATA_API_KEY=${DADATA_API_KEY}
+DADATA_SECRET_KEY=${DADATA_SECRET_KEY}
+