diff --git a/app/build.gradle b/app/build.gradle index e515992..b8de331 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,19 +1,24 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + id "com.google.devtools.ksp" + id 'com.google.dagger.hilt.android' + id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin' } android { namespace 'ru.otus.basicarchitecture' - compileSdk 35 + compileSdk 36 defaultConfig { applicationId "ru.otus.basicarchitecture" minSdk 24 - targetSdk 35 + targetSdk 36 versionCode 1 versionName "1.0" + buildConfigField "String", "dadata_api_key", "\"666c54c11310bd246fa5fb41224b9e4e74df886b\"" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -30,14 +35,46 @@ android { kotlinOptions { jvmTarget = '17' } + buildFeatures{ + viewBinding = true + buildConfig = true + } + ksp { + arg("dagger.hilt.disableModulesHaveInstallInCheck", "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' + implementation 'androidx.navigation:navigation-fragment-ktx:2.8.6' + + implementation("com.google.dagger:hilt-android:2.57.2") + ksp "com.google.dagger:hilt-compiler:2.57.2" + + 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' + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ea17fa5..ecedc7d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,10 @@ + + +} diff --git a/app/src/main/java/ru/otus/basicarchitecture/AddressDataDto.kt b/app/src/main/java/ru/otus/basicarchitecture/AddressDataDto.kt new file mode 100644 index 0000000..279c4fc --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/AddressDataDto.kt @@ -0,0 +1,10 @@ +package ru.otus.basicarchitecture + +data class AddressDataDto( + val country: String?, + val city: String?, + val street: String?, + val house: String?, + val block: String?, + val fullAddress: String? = null +) diff --git a/app/src/main/java/ru/otus/basicarchitecture/AddressMapper.kt b/app/src/main/java/ru/otus/basicarchitecture/AddressMapper.kt new file mode 100644 index 0000000..16bd672 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/AddressMapper.kt @@ -0,0 +1,12 @@ +package ru.otus.basicarchitecture + +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/AddressRepository.kt b/app/src/main/java/ru/otus/basicarchitecture/AddressRepository.kt new file mode 100644 index 0000000..032c42e --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/AddressRepository.kt @@ -0,0 +1,5 @@ +package ru.otus.basicarchitecture + +interface AddressRepository { + suspend fun suggestAddress(query: String): List +} diff --git a/app/src/main/java/ru/otus/basicarchitecture/AddressRepositoryImpl.kt b/app/src/main/java/ru/otus/basicarchitecture/AddressRepositoryImpl.kt new file mode 100644 index 0000000..054a203 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/AddressRepositoryImpl.kt @@ -0,0 +1,32 @@ +package ru.otus.basicarchitecture + +import android.util.Log +import java.io.IOException +import javax.inject.Inject + +class AddressRepositoryImpl @Inject constructor( + private val addressApiService: AddressApiService +) : AddressRepository { + 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/AddressRequestDto.kt b/app/src/main/java/ru/otus/basicarchitecture/AddressRequestDto.kt new file mode 100644 index 0000000..6023f1d --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/AddressRequestDto.kt @@ -0,0 +1,5 @@ +package ru.otus.basicarchitecture + +data class AddressRequestDto( + val query: String +) diff --git a/app/src/main/java/ru/otus/basicarchitecture/AddressResponseDto.kt b/app/src/main/java/ru/otus/basicarchitecture/AddressResponseDto.kt new file mode 100644 index 0000000..dfa70c4 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/AddressResponseDto.kt @@ -0,0 +1,5 @@ +package ru.otus.basicarchitecture + +data class AddressResponseDto( + val suggestions: List +) diff --git a/app/src/main/java/ru/otus/basicarchitecture/AddressSuggestUseCase.kt b/app/src/main/java/ru/otus/basicarchitecture/AddressSuggestUseCase.kt new file mode 100644 index 0000000..65a389f --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/AddressSuggestUseCase.kt @@ -0,0 +1,8 @@ +package ru.otus.basicarchitecture + + +class AddressSuggestUseCase(private val addressRepository: AddressRepository) { + 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/AddressSuggestionDto.kt b/app/src/main/java/ru/otus/basicarchitecture/AddressSuggestionDto.kt new file mode 100644 index 0000000..e0d0e97 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/AddressSuggestionDto.kt @@ -0,0 +1,7 @@ +package ru.otus.basicarchitecture + +data class AddressSuggestionDto( + val value: String, + val unrestricted_value: String, + val data: AddressDataDto +) 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..4800cb1 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/AddressVewModel.kt @@ -0,0 +1,70 @@ +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.Job +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AddressViewModel @Inject constructor( + private val wizardCache: WizardCache, + private val addressSuggestUseCase: AddressSuggestUseCase +): ViewModel() { + private var _listUserAddress = MutableLiveData>() + val listUserAddress: LiveData> + get() = _listUserAddress + private var _canContinue = MutableLiveData(false) + val canContinue: LiveData + get() = _canContinue + private var _errorNetwork = MutableLiveData() + val errorNetwork: LiveData + get() = _errorNetwork + private var _errorEmptyAddress = MutableLiveData() + val errorEmptyAddress: LiveData + get() = _errorEmptyAddress + + private var current_job: Job? = null + + fun validateData() { + val successful = checkEmptyFields() + + if (successful == false){ + _canContinue.value = false + return + } + _canContinue.value = true + } + + fun setAddress(fullAddress: String) { + wizardCache.userAddress.fullAddress = fullAddress + } + + fun searchAddress(query: String) { + current_job?.cancel() + + current_job = viewModelScope.launch { + try { + val result = addressSuggestUseCase.invoke(query) + _listUserAddress.postValue(result) + } catch (e: Exception) { + _errorNetwork.postValue(true) + } + } + } + + private fun checkEmptyFields(): Boolean{ + var successful = true + if (wizardCache.userAddress.fullAddress.isBlank()){ + _errorEmptyAddress.value = true + successful = false + } else{ + _errorEmptyAddress.value = false + } + return successful + } +} diff --git a/app/src/main/java/ru/otus/basicarchitecture/FragmentAddress.kt b/app/src/main/java/ru/otus/basicarchitecture/FragmentAddress.kt new file mode 100644 index 0000000..37702e7 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/FragmentAddress.kt @@ -0,0 +1,151 @@ +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 android.widget.Toast +import androidx.fragment.app.viewModels +import kotlin.getValue +import ru.otus.basicarchitecture.databinding.FragmentAddressBinding +import dagger.hilt.android.AndroidEntryPoint +import android.widget.ArrayAdapter + +@AndroidEntryPoint +class FragmentAddress : Fragment() { + private var _binding: FragmentAddressBinding? = null + private val binding: FragmentAddressBinding + get() = _binding ?: throw RuntimeException("FragmentAddressBinding == null") + + private val viewModel: AddressViewModel by viewModels() + private val adapter by lazy { + ArrayAdapter( + requireContext(), + android.R.layout.simple_dropdown_item_1line, + mutableListOf() + ) + } + + 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.errorEmptyAddress.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.canContinue.observe(viewLifecycleOwner) { + if (it){ + requireActivity().supportFragmentManager.beginTransaction() + .replace(R.id.mainContainer, FragmentInterests.newInstance()) + .commit() + } + } + } + + private fun addTextChangedListeners(){ + with(binding){ + addressInput.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 + ) { + val address = addressInput.text.toString() + viewModel.setAddress(address) + viewModel.searchAddress(address) + } + override fun afterTextChanged(s: Editable?) {} + }) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + private const val EXTRA_USER_NAME = "user_name" + + fun newInstance() = FragmentAddress() + } +} diff --git a/app/src/main/java/ru/otus/basicarchitecture/FragmentInterests.kt b/app/src/main/java/ru/otus/basicarchitecture/FragmentInterests.kt new file mode 100644 index 0000000..ba6f29f --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/FragmentInterests.kt @@ -0,0 +1,107 @@ +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 FragmentInterests : 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 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.canContinue.observe(viewLifecycleOwner) { + if (it){ + requireActivity().supportFragmentManager.beginTransaction() + .replace(R.id.mainContainer, FragmentResult.newInstance()) + .commit() + } else { + Toast.makeText(requireContext(), "It is necessary to mark at least one tag", Toast.LENGTH_SHORT).show() + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + _binding = FragmentInterestsBinding.inflate(inflater, container, false) + return binding.root + } + + 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 + } + + companion object { + fun newInstance() = FragmentInterests() + } +} diff --git a/app/src/main/java/ru/otus/basicarchitecture/FragmentResult.kt b/app/src/main/java/ru/otus/basicarchitecture/FragmentResult.kt new file mode 100644 index 0000000..9180d95 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/FragmentResult.kt @@ -0,0 +1,76 @@ +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 FragmentResult : Fragment() { + private var _binding: FragmentResultBinding? = null + private val binding: FragmentResultBinding + get() = _binding ?: throw RuntimeException("FragmentResultBinding == null") + + private val viewModel: ResultViewModel by viewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + observeViewModel() + viewModel.loadUserInfo() + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + _binding = FragmentResultBinding.inflate(inflater, container, false) + return binding.root + } + + private fun observeViewModel(){ + viewModel.userName.observe(viewLifecycleOwner) { + with(binding){ + textViewName.text = it.name + textViewSurname.text = it.surname + textViewBirthday.text = it.birthday + } + } + + viewModel.userAddress.observe(viewLifecycleOwner) { + with(binding){ + textViewAddress.text = String.format("%s, %s, %s", + it.country, + it.city, + it.fullAddress + ) + } + } + + 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) + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + companion object { + fun newInstance() = FragmentResult() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/FragmentUser.kt b/app/src/main/java/ru/otus/basicarchitecture/FragmentUser.kt new file mode 100644 index 0000000..63c4aa2 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/FragmentUser.kt @@ -0,0 +1,185 @@ +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 FragmentUser : 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 onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + observeViewModel() + addTextChangedListeners() + binding.buttonNext.setOnClickListener { + viewModel.validateData() + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentUserBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + fun observeViewModel(){ + viewModel.errorEmptyName.observe(viewLifecycleOwner) { + with(binding) { + if (it){ + editTextName.error = String.format(resources.getString(R.string.empty_field), + textInputName.hint.toString() + ) + } else { + editTextName.error = null + } + } + } + + viewModel.errorEmptySurname.observe(viewLifecycleOwner) { + with(binding) { + if (it){ + editTextSurname.error = String.format(resources.getString(R.string.empty_field), + textInputSurname.hint.toString() + ) + } else { + editTextSurname.error = null + } + } + } + + viewModel.errorEmptyBirthday.observe(viewLifecycleOwner) { + with(binding) { + if (it){ + editTextBirthday.error = String.format(resources.getString(R.string.empty_field), + textInputBirthday.hint.toString() + ) + } else { + editTextBirthday.error = null + } + } + } + + viewModel.errorAge.observe(viewLifecycleOwner) { + with(binding) { + if (it){ + editTextBirthday.error = resources.getString(R.string.invalid_age) + } else { + editTextBirthday.error = null + } + } + } + + viewModel.errorBirthday.observe(viewLifecycleOwner) { + with(binding) { + if (it){ + editTextBirthday.error = resources.getString(R.string.invalid_birthday) + } else { + editTextBirthday.error = null + } + } + } + + viewModel.canContinue.observe(viewLifecycleOwner) { + if (it){ + requireActivity().supportFragmentManager.beginTransaction() + .replace(R.id.mainContainer, FragmentAddress.newInstance()) + .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?) { + } + }) + } + } + + companion object { + } +} \ 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..27503ee --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/InterestsViewModel.kt @@ -0,0 +1,43 @@ +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 wizardCache: WizardCache): ViewModel() { + private var _canContinue = MutableLiveData() + val canContinue: LiveData + get() = _canContinue + 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 (wizardCache.interests.size < 1){ + _canContinue.value = false + } else { + _canContinue.value = true + } + } + + fun setInterests(interests: List){ + wizardCache.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/MyApp.kt b/app/src/main/java/ru/otus/basicarchitecture/MyApp.kt new file mode 100644 index 0000000..0b98bff --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/MyApp.kt @@ -0,0 +1,45 @@ +package ru.otus.basicarchitecture +import android.app.Application +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.HiltAndroidApp +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Singleton + +@HiltAndroidApp +class MyApp: 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: AddressRepositoryImpl): AddressRepository { + return impl + } + + @Provides + @Singleton + fun provideAddressSuggestUseCase(repository: AddressRepository): AddressSuggestUseCase { + return AddressSuggestUseCase(repository) + } +} 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..a8fc5f2 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ResultViewModel.kt @@ -0,0 +1,40 @@ +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 wizardCache: WizardCache): ViewModel() { + private var _userName = MutableLiveData() + val userName: LiveData + get() = _userName + private var _userAddress = MutableLiveData() + val userAddress: LiveData + get() = _userAddress + private var _interests = MutableLiveData>() + val interests: LiveData> + get() = _interests + + fun loadUserInfo(){ + loadUserName() + loadUserAddress() + loadInterests() + } + + private fun loadUserName(){ + _userName.value = wizardCache.userNameDate + } + + private fun loadUserAddress(){ + _userAddress.value = wizardCache.userAddress + } + + private fun loadInterests(){ + _interests.value = wizardCache.interests + } +} 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..f9254c5 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/UserViewModel.kt @@ -0,0 +1,116 @@ +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 wizardCache: WizardCache +): ViewModel() { + private var _canContinue = MutableLiveData(false) + val canContinue: LiveData + get() = _canContinue + private var _errorAge = MutableLiveData() + val errorAge: LiveData + get() = _errorAge + private var _errorEmptyName = MutableLiveData() + val errorEmptyName: LiveData + get() = _errorEmptyName + private var _errorEmptySurname = MutableLiveData() + val errorEmptySurname: LiveData + get() = _errorEmptySurname + private var _errorEmptyBirthday = MutableLiveData() + val errorEmptyBirthday: LiveData + get() = _errorEmptyBirthday + + private var _errorBirthday = MutableLiveData() + val errorBirthday: LiveData + get() = _errorBirthday + + fun validateData() { + var successful = checkEmptyFields() + + if (successful == false){ + _canContinue.value = false + return + } + + successful = checkAge() + + if (successful == false){ + _canContinue.value = false + return + } + _canContinue.value = true + } + + fun setName(name: String) { + wizardCache.userNameDate.name = name + } + + fun setSurname(surname: String) { + wizardCache.userNameDate.surname = surname + } + + fun setBirthday(birthday: String) { + wizardCache.userNameDate.birthday = birthday + } + + private fun checkAge(): Boolean { + var successful = true + val sdf = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()) + + try { + val birthDate = sdf.parse(wizardCache.userNameDate.birthday) + _errorBirthday.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) { + _errorAge.value = true + successful = false + } else { + _errorAge.value = false + } + } catch (e: Exception) { + _errorBirthday.value = true + successful = false + } + + return successful + } + + private fun checkEmptyFields(): Boolean{ + var successful = true + + if (wizardCache.userNameDate.name == ""){ + _errorEmptyName.value = true + successful = false + } else{ + _errorEmptyName.value = false + } + + if (wizardCache.userNameDate.surname == ""){ + _errorEmptySurname.value = true + successful = false + } else { + _errorEmptySurname.value = false + } + + if (wizardCache.userNameDate.birthday == ""){ + _errorEmptyBirthday.value = true + successful = false + } else{ + _errorEmptyBirthday.value = false + } + return successful + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/WizardCash.kt b/app/src/main/java/ru/otus/basicarchitecture/WizardCash.kt new file mode 100644 index 0000000..72fbe06 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/WizardCash.kt @@ -0,0 +1,25 @@ +package ru.otus.basicarchitecture +import dagger.hilt.android.scopes.ActivityRetainedScoped +import javax.inject.Inject + +@ActivityRetainedScoped +class WizardCache @Inject constructor(){ + var userNameDate: UserNameDate = UserNameDate("", "", "") + var userAddress: UserAddress = UserAddress("", "", "", "", "", "") + var interests: List = listOf() +} + +data class UserNameDate( + var name: String, + var surname: String, + var birthday: String +) + +data class UserAddress( + var country: String, + var city: String, + var street: String, + var house: String, + var block: String, + var fullAddress: String +) \ 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..d0145bc --- /dev/null +++ b/app/src/main/res/drawable/border_black.xml @@ -0,0 +1,4 @@ + + + + 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..54592c6 --- /dev/null +++ b/app/src/main/res/drawable/border_blue.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 0b15a20..78477c2 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,9 +1,13 @@ + \ 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..d5dbbf3 --- /dev/null +++ b/app/src/main/res/layout/fragment_address.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + 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..41e4dda --- /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..b769e28 --- /dev/null +++ b/app/src/main/res/layout/fragment_result.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + \ 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..1867022 --- /dev/null +++ b/app/src/main/res/layout/fragment_user.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + \ 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..98a560a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,16 @@ 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 + An error occurred while interacting with the network \ 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..6cd9d11 --- /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 + + +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 returns error`() { + runTest { + whenever(wizardCache.userAddress).thenReturn(UserAddress("", "", "", "", "", "")) + viewModel.validateData() + val actual = viewModel.errorEmptyAddress.value ?: throw RuntimeException("errorEmptyAddress == null") + assertTrue(actual) + } + } + @Test + fun `valid input returns success`() { + runTest { + whenever(wizardCache.userAddress).thenReturn(UserAddress("", "", "", "", "", "Россия, Москва")) + viewModel.validateData() + val actual = viewModel.errorEmptyAddress.value ?: throw RuntimeException("errorEmptyAddress == 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("Россия", "Москва", "Фёдора Полетаева", "13", "", "Россия, Москва, Фёдора Полетаева, 13")) + } +} 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..a2040cb --- /dev/null +++ b/app/src/test/java/ru/otus/basicarchitecture/UserViewModelTest.kt @@ -0,0 +1,90 @@ +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 name returns error`() { + runTest { + whenever(wizardCache.userNameDate).thenReturn(UserNameDate("", "Петров", "11.01.1976")) + viewModel.validateData() + val actual = viewModel.errorEmptyName.value ?: throw RuntimeException("errorEmptyName == null") + assertTrue(actual) + } + } + @Test + fun `empty surname returns error`() { + runTest { + whenever(wizardCache.userNameDate).thenReturn(UserNameDate("Василий", "", "11.01.1976")) + viewModel.validateData() + val actual = viewModel.errorEmptySurname.value ?: throw RuntimeException("errorEmptySurname == null") + assertTrue(actual) + } + } + @Test + fun `empty birthday returns error`() { + runTest { + whenever(wizardCache.userNameDate).thenReturn(UserNameDate("Василий", "Петров", "")) + viewModel.validateData() + val actual = viewModel.errorEmptyBirthday.value ?: throw RuntimeException("errorEmptyBirthday == null") + assertTrue(actual) + } + } + @Test + fun `young age returns error`() { + runTest { + whenever(wizardCache.userNameDate).thenReturn(UserNameDate("Василий", "Петров", "11.01.2010")) + viewModel.validateData() + val actual = viewModel.errorAge.value ?: throw RuntimeException("errorAge == null") + assertTrue(actual) + } + } + @Test + fun `incorrect birthday entry returns error`() { + runTest { + whenever(wizardCache.userNameDate).thenReturn(UserNameDate("Василий", "Петров", "11.012010")) + viewModel.validateData() + val actual = viewModel.errorBirthday.value ?: throw RuntimeException("errorBirthday == null") + assertTrue(actual) + } + } + @Test + fun `valid input returns success`() { + runTest { + whenever(wizardCache.userNameDate).thenReturn(UserNameDate("Василий", "Петров", "11.01.1976")) + viewModel.validateData() + val actualList = listOf( + viewModel.errorEmptyName.value, + viewModel.errorEmptySurname.value, + viewModel.errorEmptyBirthday.value, + viewModel.errorBirthday.value, + viewModel.errorAge.value, + ) + if (actualList.contains(null)) { + throw RuntimeException("actual == null") + } + val actual = actualList.contains(true) + assertFalse(actual) + } + } +} diff --git a/build.gradle b/build.gradle index 7b166ff..8b3a53d 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,15 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + dependencies { + classpath("com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1") + } +} + 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 -} \ No newline at end of file + id 'com.android.application' version '8.13.1' apply false + id 'com.android.library' version '8.13.1' 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 +} 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