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} +