Skip to content
Open
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
*.iml
*.log
.gradle
/local.properties
/.idea/caches
Expand Down
97 changes: 96 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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"
}
11 changes: 10 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />

<application
android:name=".App"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
Expand All @@ -14,7 +17,13 @@
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="false" />
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>
224 changes: 224 additions & 0 deletions app/src/main/java/ru/otus/basicarchitecture/AddressFragment.kt
Original file line number Diff line number Diff line change
@@ -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<String>()
)
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<String>()
)
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<String>()
)
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
}
}
Loading