diff --git a/app/build.gradle b/app/build.gradle index e515992..4235653 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,19 +1,23 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + id "com.google.devtools.ksp" + id 'com.google.dagger.hilt.android' } android { namespace 'ru.otus.basicarchitecture' - compileSdk 35 + compileSdk 36 defaultConfig { applicationId "ru.otus.basicarchitecture" - minSdk 24 - targetSdk 35 + minSdk 36 + targetSdk 36 versionCode 1 versionName "1.0" + buildConfigField "String", "dadata_api_key", "\"666c54c11310bd246fa5fb41224b9e4e74df886b\"" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -30,15 +34,41 @@ android { kotlinOptions { jvmTarget = '17' } + + buildFeatures{ + viewBinding = true + buildConfig = true + } } dependencies { - implementation 'androidx.core:core-ktx:1.15.0' - implementation 'androidx.appcompat:appcompat:1.7.0' - implementation 'com.google.android.material:material:1.12.0' + implementation 'androidx.core:core-ktx:1.17.0' + implementation 'androidx.activity:activity-ktx:1.12.0' + implementation 'androidx.fragment:fragment-ktx:1.8.8' + implementation("androidx.navigation:navigation-fragment-ktx:2.9.0" ) + implementation 'androidx.appcompat:appcompat:1.7.1' + implementation 'com.google.android.material:material:1.13.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.0' - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + implementation 'androidx.navigation:navigation-fragment-ktx:2.8.6' + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.2") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("com.google.code.gson:gson:2.8.5") + implementation("com.squareup.retrofit2:retrofit:2.9.0") + implementation("com.squareup.retrofit2:converter-gson:2.9.0") + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + + testImplementation 'androidx.arch.core:core-testing:2.2.0' + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0' + 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' + + + implementation("com.google.dagger:hilt-android:2.57.2") + ksp "com.google.dagger:hilt-compiler:2.57.2" } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ea17fa5..dfb2643 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,10 @@ + + () + ) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.addressInput.setAdapter(adapter) + observeViewModel() + addTextChangedListeners() + binding.buttonNext.setOnClickListener { + viewModel.validateData() + } + + binding.addressInput.setOnItemClickListener { _, _, position, _ -> + val selectedItem = binding.addressInput.adapter.getItem(position) as? UserAddress + ?: return@setOnItemClickListener + selectedItem.let { + val address = listOf( + it.country, + it.city, + it.street, + it.house, + it.block + ).filter { !it.isBlank() } + .joinToString(", ") + binding.addressInput.setText(address) + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + _binding = FragmentAddressBinding.inflate(inflater, container, false) + return binding.root + } + + fun observeViewModel(){ + viewModel.errorNetwork.observe(viewLifecycleOwner) { + if (it) { + Toast.makeText( + requireContext(), + getString(R.string.error_network), + Toast.LENGTH_SHORT + ).show() + } + } + + viewModel.errAddress.observe(viewLifecycleOwner) { + with(binding) { + if (it){ + addressInput.error = String.format(resources.getString(R.string.empty_field), + addressInput.hint.toString() + ) + } else { + addressInput.error = null + } + } + } + + viewModel.listUserAddress.observe(viewLifecycleOwner) { listUserAddress -> + adapter.clear() + adapter.addAll(listUserAddress.map { + listOf( + it.country, + it.city, + it.street, + it.house, + it.block + ).filter { !it.isBlank() } + .joinToString(", ") + }) + } + + viewModel.canGoNext.observe(viewLifecycleOwner) { + if (it){ + requireActivity().supportFragmentManager.beginTransaction() + .replace(R.id.mainContainer, InterestsFragment()) + .commit() + } + } + } + + private fun addTextChangedListeners(){ + with(binding){ + addressInput.doOnTextChanged { text, _, _, _ -> + val address = addressInput.text.toString() + viewModel.setAddress(address) + viewModel.searchAddress(address) + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + private const val EXTRA_USER_NAME = "user_name" + + fun newInstance() = AddressFragment() + } +} + + + diff --git a/app/src/main/java/ru/otus/basicarchitecture/AddressVewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/AddressVewModel.kt new file mode 100644 index 0000000..c097b54 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/AddressVewModel.kt @@ -0,0 +1,64 @@ +package ru.otus.basicarchitecture + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import ru.otus.basicarchitecture.address_by_dadata.AddressSuggestUseCase +import javax.inject.Inject + +@HiltViewModel +class AddressViewModel @Inject constructor( + private val cache: WizardCache, + private val addressSuggestUseCase: AddressSuggestUseCase +): ViewModel() { + private var _listUserAddress = MutableLiveData>() + val listUserAddress: LiveData> + get() = _listUserAddress + private var _canGoNext = MutableLiveData(false) + val canGoNext: LiveData get() = _canGoNext + private var _errAddress = MutableLiveData() + val errAddress: LiveData get() = _errAddress + private var _errorNetwork = MutableLiveData() + val errorNetwork: LiveData + get() = _errorNetwork + + fun validateData() { + var success = checkEmptyFields() + + if (success == false){ + _canGoNext.value = false + return + } + _canGoNext.value = true + } + + fun setAddress(fullAddress: String) { + cache.userAddress.fullAddress = fullAddress + } + + fun searchAddress(query: String) { + viewModelScope.launch { + try { + val result = addressSuggestUseCase.invoke(query) + _listUserAddress.postValue(result) + } catch (e: Exception) { + _errorNetwork.postValue(true) + } + } + } + + private fun checkEmptyFields(): Boolean{ + var success = true + + if (cache.userAddress.fullAddress.isBlank()){ + _errAddress.value = true + success = false + } else{ + _errAddress.value = false + } + return success + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/Application.kt b/app/src/main/java/ru/otus/basicarchitecture/Application.kt new file mode 100644 index 0000000..58c9ca1 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/Application.kt @@ -0,0 +1,51 @@ +package ru.otus.basicarchitecture + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import ru.otus.basicarchitecture.address_by_dadata.AddressApiService +import ru.otus.basicarchitecture.address_by_dadata.AddressCollector +import ru.otus.basicarchitecture.address_by_dadata.AddressCollectorImpl +import ru.otus.basicarchitecture.address_by_dadata.AddressSuggestUseCase +import javax.inject.Singleton + +@HiltAndroidApp +class HiltApplication : Application() { + +} + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + @Provides + @Singleton + fun provideRetrofit(): Retrofit { + return Retrofit.Builder() + .baseUrl("https://suggestions.dadata.ru/suggestions/api/4_1/rs/") + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + @Provides + @Singleton + fun provideDaDataService(retrofit: Retrofit): AddressApiService { + return retrofit.create(AddressApiService::class.java) + } + + @Provides + @Singleton + fun provideAddressRepository(impl: AddressCollectorImpl): AddressCollector { + return impl + } + + @Provides + @Singleton + fun provideAddressSuggestUseCase(repository: AddressCollector): AddressSuggestUseCase { + return AddressSuggestUseCase(repository) + } +} \ No newline at end of file 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..62aade9 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/InterestsFragment.kt @@ -0,0 +1,100 @@ +package ru.otus.basicarchitecture + +import android.os.Bundle +import android.content.res.ColorStateList +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.fragment.app.viewModels +import com.google.android.material.chip.Chip +import ru.otus.basicarchitecture.databinding.FragmentInterestsBinding +import kotlin.getValue +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class InterestsFragment : Fragment() { + private var _binding: FragmentInterestsBinding? = null + private val binding: FragmentInterestsBinding + get() = _binding ?: throw RuntimeException("FragmentInterestsBinding == null") + private val viewModel: InterestsViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } + + 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) + viewModel.loadListOfInterests() + observeViewModel() + binding.buttonNext.setOnClickListener { + viewModel.setInterests(getSelectedInterests()) + viewModel.checkInterests() + } + } + + private fun observeViewModel(){ + viewModel.listOfInterests.observe(viewLifecycleOwner) { + val selectedColor = ContextCompat.getColor(requireContext(), R.color.blue_light) + val defaultColor = ContextCompat.getColor(requireContext(), R.color.gray_light) + setupChips(it, selectedColor, defaultColor) + } + + viewModel.canGoNext.observe(viewLifecycleOwner) { + if (it){ + requireActivity().supportFragmentManager.beginTransaction() + .replace(R.id.mainContainer, ResultFragment()) + .commit() + } else { + Toast.makeText(requireContext(), "It is necessary to mark at least one tag", Toast.LENGTH_SHORT).show() + } + } + } + + private fun setupChips(interests: List, selectedColor: Int, defaultColor: Int) { + interests.forEach { interest -> + val chip = Chip(requireContext()).apply { + text = interest + isCheckable = true + isCheckedIconVisible = false + chipBackgroundColor = ColorStateList.valueOf(defaultColor) + + setOnCheckedChangeListener { _, isChecked -> + chipBackgroundColor = ColorStateList.valueOf( + if (isChecked) selectedColor else defaultColor + ) + } + } + + binding.chipGroupInterests.addView(chip) + } + } + + fun getSelectedInterests(): List { + val selected = mutableListOf() + + for (i in 0 until binding.chipGroupInterests.childCount) { + val child = binding.chipGroupInterests.getChildAt(i) + + if (child is Chip && child.isChecked) { + selected.add(child.text.toString()) + } + } + return selected + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file 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..bcea383 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/InterestsViewModel.kt @@ -0,0 +1,41 @@ +package ru.otus.basicarchitecture + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData + +@HiltViewModel +class InterestsViewModel @Inject constructor( + private val cache: WizardCache): ViewModel() { + private var _canGoNext = MutableLiveData() + val canGoNext: LiveData get() = _canGoNext + private var _listOfInterests = MutableLiveData>() + val listOfInterests: LiveData> get() = _listOfInterests + + private val interests = listOf( + "Animals", "Art", "Books", "Business", "Cars", + "Cooking", "Dance", "Design", "Education", + "Fashion", "Finance", "Fitness", "Food", "Gaming", + "Health", "History", "Movies", "Music", "Politics", + "Photography", "Programming", "Reading", "Science", + "Space", "Sports", "Technology", "Travel" + ) + + fun checkInterests(){ + if (cache.interests.size < 1){ + _canGoNext.value = false + } else { + _canGoNext.value = true + } + } + + fun setInterests(interests: List){ + cache.interests = interests + } + + fun loadListOfInterests(){ + _listOfInterests.value = interests + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/MainActivity.kt b/app/src/main/java/ru/otus/basicarchitecture/MainActivity.kt index 623aba9..080ecdb 100644 --- a/app/src/main/java/ru/otus/basicarchitecture/MainActivity.kt +++ b/app/src/main/java/ru/otus/basicarchitecture/MainActivity.kt @@ -2,7 +2,9 @@ package ru.otus.basicarchitecture import androidx.appcompat.app.AppCompatActivity import android.os.Bundle +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/ru/otus/basicarchitecture/ResultFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/ResultFragment.kt new file mode 100644 index 0000000..ba09b82 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ResultFragment.kt @@ -0,0 +1,86 @@ +package ru.otus.basicarchitecture + +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.fragment.app.viewModels +import com.google.android.material.chip.Chip +import ru.otus.basicarchitecture.databinding.FragmentResultBinding +import kotlin.getValue +import android.content.res.ColorStateList +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class ResultFragment : Fragment() { + private var _binding: FragmentResultBinding? = null + private val binding: FragmentResultBinding + get() = _binding ?: throw RuntimeException("FragmentResultBinding == null") + + private val viewModel: ResultViewModel by viewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + _binding = FragmentResultBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + observeViewModel() + viewModel.loadUserInfo() + } + + private fun observeViewModel(){ + viewModel.firstName.observe(viewLifecycleOwner) { + with(binding){ + textViewName.text = it + } + } + + viewModel.lastName.observe(viewLifecycleOwner) { + with(binding){ + textViewSurname.text = it + } + } + + viewModel.birthDate.observe(viewLifecycleOwner) { + with(binding){ + textViewBirthday.text = it + } + } + + viewModel.country.observe(viewLifecycleOwner) { + with(binding){ + textViewCountry.text = it + } + } + + viewModel.city.observe(viewLifecycleOwner) { + with(binding){ + textViewCity.text = it + } + } + + viewModel.address.observe(viewLifecycleOwner) { + with(binding){ + textViewAddress.text = it + } + } + + viewModel.interests.observe(viewLifecycleOwner) { + val defaultColor = ContextCompat.getColor(requireContext(), R.color.gray_light) + with(binding){ + it.forEach { interest -> + val chip = Chip(requireContext()).apply { + text = interest + chipBackgroundColor = ColorStateList.valueOf(defaultColor) + } + binding.chipGroupInterests.addView(chip) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ResultViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/ResultViewModel.kt new file mode 100644 index 0000000..9ed4714 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ResultViewModel.kt @@ -0,0 +1,36 @@ +package ru.otus.basicarchitecture + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import javax.inject.Inject + +@HiltViewModel +class ResultViewModel @Inject constructor( + private val cache: WizardCache): ViewModel() { + private var _firstName = MutableLiveData() + val firstName: LiveData get() = _firstName + private var _lastName = MutableLiveData() + val lastName: LiveData get() = _lastName + private var _birthDate = MutableLiveData() + val birthDate: LiveData get() = _birthDate + private var _country = MutableLiveData() + val country: LiveData get() = _country + private var _city = MutableLiveData() + val city: LiveData get() = _city + private var _address = MutableLiveData() + val address: LiveData get() = _address + private var _interests = MutableLiveData>() + val interests: LiveData> get() = _interests + + fun loadUserInfo(){ + _firstName.value = cache.firstName + _lastName.value = cache.lastName + _birthDate.value = cache.birthDate + _country.value = cache.userAddress.country + _city.value = cache.userAddress.city + _address.value = cache.userAddress.fullAddress + _interests.value = cache.interests + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/UserFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/UserFragment.kt new file mode 100644 index 0000000..8e98408 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/UserFragment.kt @@ -0,0 +1,187 @@ +package ru.otus.basicarchitecture + +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.viewModels +import dagger.hilt.android.AndroidEntryPoint +import ru.otus.basicarchitecture.databinding.FragmentUserBinding + +@AndroidEntryPoint +class UserFragment : Fragment() { + private var _binding: FragmentUserBinding? = null + private val binding: FragmentUserBinding + get() = _binding ?: throw RuntimeException("FragmentNameBinding == null") + + private val viewModel: UserViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentUserBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + observeViewModel() + addTextChangedListeners() + binding.buttonNext.setOnClickListener { + viewModel.validateData() + } + } + + fun observeViewModel() { + viewModel.errName.observe(viewLifecycleOwner) { + with(binding) { + if (it) { + editTextName.error = String.format( + resources.getString(R.string.empty_field), + textInputName.hint.toString() + ) + } else { + editTextName.error = null + } + } + } + + viewModel.errSurname.observe(viewLifecycleOwner) { + with(binding) { + if (it) { + editTextSurname.error = String.format( + resources.getString(R.string.empty_field), + textInputSurname.hint.toString() + ) + } else { + editTextSurname.error = null + } + } + } + + viewModel.errBirthday.observe(viewLifecycleOwner) { + with(binding) { + if (it) { + editTextBirthday.error = String.format( + resources.getString(R.string.empty_field), + textInputBirthday.hint.toString() + ) + } else { + editTextBirthday.error = null + } + } + } + + viewModel.errAge.observe(viewLifecycleOwner) { + with(binding) { + if (it) { + editTextBirthday.error = resources.getString(R.string.invalid_age) + } else { + editTextBirthday.error = null + } + } + } + + viewModel.errBirthdayFormat.observe(viewLifecycleOwner) { + with(binding) { + if (it) { + editTextBirthday.error = resources.getString(R.string.invalid_birthday) + } else { + editTextBirthday.error = null + } + } + } + + viewModel.canGoNext.observe(viewLifecycleOwner) { + if (it) { + requireActivity().supportFragmentManager.beginTransaction() + .replace(R.id.mainContainer, AddressFragment()) + .commit() + } + } + } + + private fun addTextChangedListeners() { + + with(binding) { + editTextName.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged( + s: CharSequence?, + start: Int, + count: Int, + after: Int + ) { + } + + override fun onTextChanged( + s: CharSequence?, + start: Int, + before: Int, + count: Int + ) { + viewModel.setName(editTextName.text.toString()) + } + + override fun afterTextChanged(s: Editable?) { + } + }) + + editTextSurname.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged( + s: CharSequence?, + start: Int, + count: Int, + after: Int + ) { + } + + override fun onTextChanged( + s: CharSequence?, + start: Int, + before: Int, + count: Int + ) { + viewModel.setSurname(editTextSurname.text.toString()) + } + + override fun afterTextChanged(s: Editable?) { + } + }) + + editTextBirthday.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged( + s: CharSequence?, + start: Int, + count: Int, + after: Int + ) { + } + + override fun onTextChanged( + s: CharSequence?, + start: Int, + before: Int, + count: Int + ) { + viewModel.setBirthday(editTextBirthday.text.toString()) + } + + override fun afterTextChanged(s: Editable?) { + } + }) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/UserViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/UserViewModel.kt new file mode 100644 index 0000000..d206256 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/UserViewModel.kt @@ -0,0 +1,109 @@ +package ru.otus.basicarchitecture + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import javax.inject.Inject + +@HiltViewModel +class UserViewModel @Inject constructor( + private val cache: WizardCache +): ViewModel() { + private var _canGoNext = MutableLiveData(false) + val canGoNext: LiveData get() = _canGoNext + private var _errAge = MutableLiveData() + val errAge: LiveData get() = _errAge + private var _errName = MutableLiveData() + val errName: LiveData get() = _errName + private var _errSurname = MutableLiveData() + val errSurname: LiveData get() = _errSurname + private var _errBirthday = MutableLiveData() + val errBirthday: LiveData get() = _errBirthday + private var _errBirthdayFormat = MutableLiveData() + val errBirthdayFormat: LiveData get() = _errBirthdayFormat + + fun validateData() { + var success = checkEmptyFields() + + if (success == false){ + _canGoNext.value = false + return + } + + success = checkAge() + + if (success == false){ + _canGoNext.value = false + return + } + _canGoNext.value = true + } + + fun setName(name: String) { + cache.firstName = name + } + + fun setSurname(surname: String) { + cache.lastName = surname + } + + fun setBirthday(birthday: String) { + cache.birthDate = birthday + } + + private fun checkAge(): Boolean { + var success = true + val sdf = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()) + + try { + val birthDate = sdf.parse(cache.birthDate) + _errBirthdayFormat.value = false + + val today = Calendar.getInstance().time + val diff = today.time - birthDate.time + val years = (diff / (1000L * 60 * 60 *24 * 365)).toInt() + + if (years < 18) { + _errAge.value = true + success = false + } else { + _errAge.value = false + } + } catch (e: Exception) { + _errBirthdayFormat.value = true + success = false + } + + return success + } + + private fun checkEmptyFields(): Boolean{ + var success = true + + if (cache.firstName == ""){ + _errName.value = true + success = false + } else{ + _errName.value = false + } + + if (cache.lastName == ""){ + _errSurname.value = true + success = false + } else { + _errSurname.value = false + } + + if (cache.birthDate == ""){ + _errBirthday.value = true + success = false + } else{ + _errBirthday.value = false + } + return success + } +} diff --git a/app/src/main/java/ru/otus/basicarchitecture/WizardCache.kt b/app/src/main/java/ru/otus/basicarchitecture/WizardCache.kt new file mode 100644 index 0000000..ffe3de0 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/WizardCache.kt @@ -0,0 +1,22 @@ +package ru.otus.basicarchitecture + +import javax.inject.Inject +import dagger.hilt.android.scopes.ActivityRetainedScoped + +@ActivityRetainedScoped +class WizardCache @Inject constructor() { + var firstName: String = "" + var lastName: String = "" + var birthDate: String = "" + var userAddress: UserAddress = UserAddress("", "", "", "", "", "") + var interests: List = emptyList() +} + +data class UserAddress( + var country: String, + var city: String, + var street: String, + var house: String, + var block: String, + var fullAddress: String +) diff --git a/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressApiService.kt b/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressApiService.kt new file mode 100644 index 0000000..ce4bb5c --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressApiService.kt @@ -0,0 +1,16 @@ +package ru.otus.basicarchitecture.address_by_dadata + +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.Headers +import retrofit2.http.POST + +interface AddressApiService { + @Headers("Content-Type: application/json") + @POST("suggest/address") + suspend fun suggestAddress( + @Header("Authorization") token: String, + @Body request: AddressRequestDto, + ): Response +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressCollector.kt b/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressCollector.kt new file mode 100644 index 0000000..bffa73f --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressCollector.kt @@ -0,0 +1,7 @@ +package ru.otus.basicarchitecture.address_by_dadata + +import ru.otus.basicarchitecture.UserAddress + +interface AddressCollector { + suspend fun suggestAddress(query: String): List +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressCollectorImpl.kt b/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressCollectorImpl.kt new file mode 100644 index 0000000..89111f7 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressCollectorImpl.kt @@ -0,0 +1,34 @@ +package ru.otus.basicarchitecture.address_by_dadata + +import android.util.Log +import ru.otus.basicarchitecture.BuildConfig +import ru.otus.basicarchitecture.UserAddress +import javax.inject.Inject +import java.io.IOException + +class AddressCollectorImpl @Inject constructor( + private val addressApiService: AddressApiService +) : AddressCollector { + override suspend fun suggestAddress(query: String): List { + val token = "Token ${BuildConfig.dadata_api_key}" + + val response = addressApiService.suggestAddress(token, AddressRequestDto(query)) + + if (!response.isSuccessful){ + val errorResponse = response.errorBody()?.string() + Log.e("API Error", "Error response: $errorResponse") + throw IOException("Error response: $errorResponse") + } + + val listAddressDataDto = response.body()?.suggestions?.map { suggestion -> + suggestion.data.copy(fullAddress = suggestion.unrestricted_value) + } + + val mapper = AddressMapper() + val listUserAddress = listAddressDataDto?.map { + mapper.mapDtoToEntity(it) + } + + return listUserAddress ?: listOf() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressDataDto.kt b/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressDataDto.kt new file mode 100644 index 0000000..a414121 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressDataDto.kt @@ -0,0 +1,10 @@ +package ru.otus.basicarchitecture.address_by_dadata + +data class AddressDataDto( + val country: String?, + val city: String?, + val street: String?, + val house: String?, + val block: String?, + val fullAddress: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressMapper.kt b/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressMapper.kt new file mode 100644 index 0000000..62dcfc5 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressMapper.kt @@ -0,0 +1,14 @@ +package ru.otus.basicarchitecture.address_by_dadata + +import ru.otus.basicarchitecture.UserAddress + +class AddressMapper { + fun mapDtoToEntity(dto: AddressDataDto) = UserAddress( + fullAddress = dto.fullAddress ?: "", + country = dto.country ?: "", + city = dto.city ?: "", + street = dto.street ?: "", + house = dto.house ?: "", + block = dto.block ?: "" + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressRequestDto.kt b/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressRequestDto.kt new file mode 100644 index 0000000..a90fc28 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressRequestDto.kt @@ -0,0 +1,5 @@ +package ru.otus.basicarchitecture.address_by_dadata + +data class AddressRequestDto( + val query: String +) \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressResponseDto.kt b/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressResponseDto.kt new file mode 100644 index 0000000..1664462 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressResponseDto.kt @@ -0,0 +1,7 @@ +package ru.otus.basicarchitecture.address_by_dadata + +import ru.otus.basicarchitecture.address_by_dadata.AddressSuggestionDto + +data class AddressResponseDto( + val suggestions: List +) \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressSuggestUseCase.kt b/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressSuggestUseCase.kt new file mode 100644 index 0000000..4c1da7a --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressSuggestUseCase.kt @@ -0,0 +1,9 @@ +package ru.otus.basicarchitecture.address_by_dadata + +import ru.otus.basicarchitecture.UserAddress + +class AddressSuggestUseCase(private val addressRepository: AddressCollector) { + suspend operator fun invoke(query: String): List { + return addressRepository.suggestAddress(query) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressSuggestionDto.kt b/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressSuggestionDto.kt new file mode 100644 index 0000000..45e3637 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/address_by_dadata/AddressSuggestionDto.kt @@ -0,0 +1,7 @@ +package ru.otus.basicarchitecture.address_by_dadata + +data class AddressSuggestionDto( + val value: String, + val unrestricted_value: String, + val data: AddressDataDto +) \ No newline at end of file diff --git a/app/src/main/res/drawable/border_black.xml b/app/src/main/res/drawable/border_black.xml new file mode 100644 index 0000000..0fb4b03 --- /dev/null +++ b/app/src/main/res/drawable/border_black.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/border_blue.xml b/app/src/main/res/drawable/border_blue.xml new file mode 100644 index 0000000..29125ea --- /dev/null +++ b/app/src/main/res/drawable/border_blue.xml @@ -0,0 +1,5 @@ + + + + + \ 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..583c960 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,9 +1,14 @@ + + \ 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..9874e7c --- /dev/null +++ b/app/src/main/res/layout/fragment_address.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + \ 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..4a41da7 --- /dev/null +++ b/app/src/main/res/layout/fragment_interests.xml @@ -0,0 +1,30 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_result.xml b/app/src/main/res/layout/fragment_result.xml new file mode 100644 index 0000000..f5d5071 --- /dev/null +++ b/app/src/main/res/layout/fragment_result.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_user.xml b/app/src/main/res/layout/fragment_user.xml new file mode 100644 index 0000000..8a02a6f --- /dev/null +++ b/app/src/main/res/layout/fragment_user.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + \ 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..5019906 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,4 +7,8 @@ #FF018786 #FF000000 #FFFFFFFF + #1764F0 + #AA0F6DD1 + #DEDEDE + #383838 \ 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..71dae89 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,17 @@ BasicArchitecture + Name + Surname + Birthday + Next + The field \"%s\" is empty. + You are under 18 years old. + The birthday is entered incorrectly + Сountry + City + Address + Date of birth + Interests + Network error + Hello blank fragment \ No newline at end of file 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..dbde661 --- /dev/null +++ b/app/src/test/java/ru/otus/basicarchitecture/AddressViewModelTest.kt @@ -0,0 +1,86 @@ +package ru.otus.basicarchitecture + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +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.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import ru.otus.basicarchitecture.address_by_dadata.AddressSuggestUseCase + +class AddressViewModelTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + private val testDispatcher = StandardTestDispatcher() + private val wizardCache: WizardCache = mock() + private val addressSuggestUseCase: AddressSuggestUseCase = mock() + private val viewModel: AddressViewModel = AddressViewModel(wizardCache, addressSuggestUseCase) + + @Before + fun before(){ + Dispatchers.setMain(testDispatcher) + println("Старт теста") + } + @After + fun after(){ + Dispatchers.resetMain() + println("Завершение теста") + } + @Test + fun `Empty full address error`() { + runTest { + whenever(wizardCache.userAddress).thenReturn(UserAddress("", "", "", "", "", "")) + viewModel.validateData() + val actual = viewModel.errAddress.value ?: throw RuntimeException("errAddress == null") + assertTrue(actual) + } + } + @Test + fun `Valid input full address`() { + runTest { + whenever(wizardCache.userAddress).thenReturn(UserAddress("", "", "", "", "", "Россия, Москва")) + viewModel.validateData() + val actual = viewModel.errAddress.value ?: throw RuntimeException("errAddress == null") + assertFalse(actual) + } + } + @Test + fun `Network success`() { + runTest { + whenever(addressSuggestUseCase.invoke(any())).thenReturn(getAddress()) + launch { + viewModel.searchAddress("query") + } + advanceUntilIdle() + val actual = viewModel.listUserAddress.value + assertNotNull(actual) + } + } + + @Test + fun `Network error`() { + runTest { + whenever(addressSuggestUseCase.invoke(any())).thenThrow(RuntimeException("Network error")) + launch { + viewModel.searchAddress("query") + } + advanceUntilIdle() + val actual = viewModel.errorNetwork.value ?: throw RuntimeException("errorNetwork == null") + assertTrue(actual) + } + } + + private fun getAddress(): List { + return listOf(UserAddress("Россия", "Москва", "Ленинский", "10", "", "Россия, Москва, Ленинский, 10")) + } +} \ No newline at end of file diff --git a/app/src/test/java/ru/otus/basicarchitecture/UserViewModelTest.kt b/app/src/test/java/ru/otus/basicarchitecture/UserViewModelTest.kt new file mode 100644 index 0000000..b37d62b --- /dev/null +++ b/app/src/test/java/ru/otus/basicarchitecture/UserViewModelTest.kt @@ -0,0 +1,102 @@ +package ru.otus.basicarchitecture + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.whenever + +class UserViewModelTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + private val wizardCache: WizardCache = mock() + private val viewModel = UserViewModel(wizardCache) + @Before + fun before(){ + println("Старт теста") + } + @After + fun after(){ + println("Завершение теста") + } + @Test + fun `Empty firstName error`() { + runTest { + whenever(wizardCache.firstName).thenReturn("") + whenever(wizardCache.lastName).thenReturn("Владимиров") + whenever(wizardCache.birthDate).thenReturn("10.10.2000") + viewModel.validateData() + val actual = viewModel.errName.value ?: throw RuntimeException("errName == null") + assertTrue(actual) + } + } + @Test + fun `Empty lastName error`() { + runTest { + whenever(wizardCache.firstName).thenReturn("Владимир") + whenever(wizardCache.lastName).thenReturn("") + whenever(wizardCache.birthDate).thenReturn("10.10.2000") + viewModel.validateData() + val actual = viewModel.errSurname.value ?: throw RuntimeException("errSurname == null") + assertTrue(actual) + } + } + @Test + fun `Empty birthday error`() { + runTest { + whenever(wizardCache.firstName).thenReturn("Владимир") + whenever(wizardCache.lastName).thenReturn("Владимиров") + whenever(wizardCache.birthDate).thenReturn("") + viewModel.validateData() + val actual = viewModel.errBirthday.value ?: throw RuntimeException("errBirthday == null") + assertTrue(actual) + } + } + @Test + fun `Legal age error`() { + runTest { + whenever(wizardCache.firstName).thenReturn("Владимир") + whenever(wizardCache.lastName).thenReturn("Владимиров") + whenever(wizardCache.birthDate).thenReturn("01.01.2010") + viewModel.validateData() + val actual = viewModel.errAge.value ?: throw RuntimeException("errAge == null") + assertTrue(actual) + } + } + @Test + fun `Birthday format error`() { + runTest { + whenever(wizardCache.firstName).thenReturn("Владимир") + whenever(wizardCache.lastName).thenReturn("Владимиров") + whenever(wizardCache.birthDate).thenReturn("01012010") + viewModel.validateData() + val actual = viewModel.errBirthdayFormat.value ?: throw RuntimeException("errBirthdayFormat == null") + assertTrue(actual) + } + } + @Test + fun `Valid input user data`() { + runTest { + whenever(wizardCache.firstName).thenReturn("Владимир") + whenever(wizardCache.lastName).thenReturn("Владимиров") + whenever(wizardCache.birthDate).thenReturn("10.10.2000") + viewModel.validateData() + val actualList = listOf( + viewModel.errName.value, + viewModel.errSurname.value, + viewModel.errBirthday.value, + viewModel.errAge.value, + viewModel.errBirthdayFormat.value, + ) + if (actualList.contains(null)) { + throw RuntimeException("actual == null") + } + val actual = actualList.contains(true) + assertFalse(actual) + } + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 7b166ff..68d9046 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,8 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. 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.android.application' version '8.12.3' apply false + id 'com.android.library' version '8.12.3' apply false + id "com.google.devtools.ksp" version "2.2.21-2.0.4" apply false + id 'org.jetbrains.kotlin.android' version '2.2.21' apply false + id 'com.google.dagger.hilt.android' version '2.57.2' apply false } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 09523c0..37f853b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME