diff --git a/app/build.gradle b/app/build.gradle index e515992..9e285d7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,9 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + id 'com.google.dagger.hilt.android' + id 'com.google.devtools.ksp' + id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin' } android { @@ -30,14 +33,52 @@ 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 'androidx.core:core-ktx:1.16.0' + implementation 'androidx.appcompat:appcompat:1.7.1' implementation 'com.google.android.material:material:1.12.0' - implementation 'androidx.constraintlayout:constraintlayout:2.2.0' + implementation 'androidx.constraintlayout:constraintlayout:2.2.1' + + implementation 'androidx.activity:activity-ktx:1.10.1' + implementation 'androidx.fragment:fragment-ktx:1.8.8' + + implementation "com.google.dagger:hilt-android:2.56.2" + implementation 'androidx.navigation:navigation-fragment-ktx:2.8.6' + ksp "com.google.dagger:hilt-compiler:2.56.2" + + implementation("com.google.android.material:material:1.12.0") + + 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("junit:junit:4.13.2") + 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..92ed7f8 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/data/AddressApiService.kt b/app/src/main/java/ru/otus/basicarchitecture/data/AddressApiService.kt new file mode 100644 index 0000000..529f913 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/data/AddressApiService.kt @@ -0,0 +1,19 @@ +package ru.otus.basicarchitecture.data + +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.Headers +import retrofit2.http.POST +import ru.otus.basicarchitecture.data.dto.AddressRequestDto +import ru.otus.basicarchitecture.data.dto.AddressResponseDto + +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/data/AddressRepositoryImpl.kt b/app/src/main/java/ru/otus/basicarchitecture/data/AddressRepositoryImpl.kt new file mode 100644 index 0000000..43aa2b1 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/data/AddressRepositoryImpl.kt @@ -0,0 +1,31 @@ +package ru.otus.basicarchitecture.data + +import android.util.Log +import ru.otus.basicarchitecture.BuildConfig +import ru.otus.basicarchitecture.data.mapper.AddressMapper +import ru.otus.basicarchitecture.data.dto.AddressRequestDto +import ru.otus.basicarchitecture.domain.AddressRepository +import ru.otus.basicarchitecture.domain.UserAddress +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") + } + 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/data/dto/AddressDataDto.kt b/app/src/main/java/ru/otus/basicarchitecture/data/dto/AddressDataDto.kt new file mode 100644 index 0000000..f963dac --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/data/dto/AddressDataDto.kt @@ -0,0 +1,10 @@ +package ru.otus.basicarchitecture.data.dto + +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/data/dto/AddressRequestDto.kt b/app/src/main/java/ru/otus/basicarchitecture/data/dto/AddressRequestDto.kt new file mode 100644 index 0000000..880bdd0 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/data/dto/AddressRequestDto.kt @@ -0,0 +1,5 @@ +package ru.otus.basicarchitecture.data.dto + +data class AddressRequestDto( + val query: String +) \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/data/dto/AddressResponseDto.kt b/app/src/main/java/ru/otus/basicarchitecture/data/dto/AddressResponseDto.kt new file mode 100644 index 0000000..fbda98f --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/data/dto/AddressResponseDto.kt @@ -0,0 +1,5 @@ +package ru.otus.basicarchitecture.data.dto + +data class AddressResponseDto( + val suggestions: List +) \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/data/dto/AddressSuggestionDto.kt b/app/src/main/java/ru/otus/basicarchitecture/data/dto/AddressSuggestionDto.kt new file mode 100644 index 0000000..aac4795 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/data/dto/AddressSuggestionDto.kt @@ -0,0 +1,7 @@ +package ru.otus.basicarchitecture.data.dto + +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/java/ru/otus/basicarchitecture/data/mapper/AddressMapper.kt b/app/src/main/java/ru/otus/basicarchitecture/data/mapper/AddressMapper.kt new file mode 100644 index 0000000..c2914d8 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/data/mapper/AddressMapper.kt @@ -0,0 +1,17 @@ +package ru.otus.basicarchitecture.data.mapper + +import ru.otus.basicarchitecture.data.dto.AddressDataDto +import ru.otus.basicarchitecture.domain.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/di/AppModule.kt b/app/src/main/java/ru/otus/basicarchitecture/di/AppModule.kt new file mode 100644 index 0000000..ec6145c --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/di/AppModule.kt @@ -0,0 +1,49 @@ +package ru.otus.basicarchitecture.di + +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.data.AddressApiService +import ru.otus.basicarchitecture.data.AddressRepositoryImpl +import ru.otus.basicarchitecture.domain.AddressRepository +import ru.otus.basicarchitecture.domain.AddressSuggestUseCase +import ru.otus.basicarchitecture.presentation.WizardCache +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + @Provides + @Singleton + fun userInfo() = WizardCache() + + @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) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/domain/AddressRepository.kt b/app/src/main/java/ru/otus/basicarchitecture/domain/AddressRepository.kt new file mode 100644 index 0000000..941b05e --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/domain/AddressRepository.kt @@ -0,0 +1,5 @@ +package ru.otus.basicarchitecture.domain + +interface AddressRepository { + suspend fun suggestAddress(query: String): List +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/domain/AddressSuggestUseCase.kt b/app/src/main/java/ru/otus/basicarchitecture/domain/AddressSuggestUseCase.kt new file mode 100644 index 0000000..55f8a7a --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/domain/AddressSuggestUseCase.kt @@ -0,0 +1,8 @@ +package ru.otus.basicarchitecture.domain + +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/domain/User.kt b/app/src/main/java/ru/otus/basicarchitecture/domain/User.kt new file mode 100644 index 0000000..3f7ecaa --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/domain/User.kt @@ -0,0 +1,17 @@ +package ru.otus.basicarchitecture.domain + + +data class UserName( + 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/java/ru/otus/basicarchitecture/presentation/AddressFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/presentation/AddressFragment.kt new file mode 100644 index 0000000..046f414 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/presentation/AddressFragment.kt @@ -0,0 +1,155 @@ +package ru.otus.basicarchitecture.presentation + +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import dagger.hilt.android.AndroidEntryPoint +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.databinding.FragmentAddressBinding +import ru.otus.basicarchitecture.domain.UserAddress + +@AndroidEntryPoint +class AddressFragment : 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, InterestsFragment.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 { + + fun newInstance() = AddressFragment() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/presentation/AddressViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/presentation/AddressViewModel.kt new file mode 100644 index 0000000..6815ed1 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/presentation/AddressViewModel.kt @@ -0,0 +1,74 @@ +package ru.otus.basicarchitecture.presentation + +import android.util.Log +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.domain.AddressRepository +import ru.otus.basicarchitecture.domain.AddressSuggestUseCase +import ru.otus.basicarchitecture.domain.UserAddress +import javax.inject.Inject + +@HiltViewModel +class AddressViewModel @Inject constructor( + private val wizardCache: WizardCache, + private val addressSuggestUseCase: AddressSuggestUseCase +): ViewModel() { + + private var _canContinue = MutableLiveData(false) + val canContinue: LiveData + get() = _canContinue + + private var _listUserAddress = MutableLiveData>() + val listUserAddress: LiveData> + get() = _listUserAddress + + private var _errorNetwork = MutableLiveData() + val errorNetwork: LiveData + get() = _errorNetwork + + private var _errorEmptyAddress = MutableLiveData() + val errorEmptyAddress: LiveData + get() = _errorEmptyAddress + + fun validateData() { + var successful = true + successful = checkEmptyFields() + if (successful == false){ + _canContinue.value = false + return + } + _canContinue.value = true + } + + fun setAddress(fullAddress: String) { + wizardCache.userAddress.fullAddress = fullAddress + } + + fun searchAddress(query: String) { + viewModelScope.launch { + try { + val result = addressSuggestUseCase.invoke(query) + _listUserAddress.postValue(result) + Log.d("AddressViewModel", result.toString()) + } catch (e: Exception) { + _errorNetwork.postValue(true) + } + } + } + + + private fun checkEmptyFields(): Boolean{ + var successful = true + if (wizardCache.userAddress.fullAddress == ""){ + _errorEmptyAddress.value = true + successful = false + } else{ + _errorEmptyAddress.value = false + } + return successful + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/presentation/InterestsFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/presentation/InterestsFragment.kt new file mode 100644 index 0000000..d96ecd5 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/presentation/InterestsFragment.kt @@ -0,0 +1,103 @@ +package ru.otus.basicarchitecture.presentation + +import android.content.res.ColorStateList +import android.os.Bundle +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.Fragment +import androidx.fragment.app.viewModels +import com.google.android.material.chip.Chip +import dagger.hilt.android.AndroidEntryPoint +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.databinding.FragmentInterestsBinding + +@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 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, ResultFragment.Companion.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() = InterestsFragment() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/presentation/InterestsViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/presentation/InterestsViewModel.kt new file mode 100644 index 0000000..e4ea7ef --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/presentation/InterestsViewModel.kt @@ -0,0 +1,46 @@ +package ru.otus.basicarchitecture.presentation + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@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( + "Reading", "Music", "Movies", "Travel", "Cooking", + "Sports", "Technology", "Photography", "Art", "Fashion", + "Fitness", "Gaming", "Science", "History", "Nature", + "Animals", "Books", "Programming", "Design", "Dance", + "Food", "Cars", "Space", "Education", "Politics", + "Health", "Business", "Finance", "Writing", "Comedy" + ) + + fun checkInterests(){ + if (wizardCache.interests.size <= 0){ + _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/presentation/MainActivity.kt similarity index 64% rename from app/src/main/java/ru/otus/basicarchitecture/MainActivity.kt rename to app/src/main/java/ru/otus/basicarchitecture/presentation/MainActivity.kt index 623aba9..0589c00 100644 --- a/app/src/main/java/ru/otus/basicarchitecture/MainActivity.kt +++ b/app/src/main/java/ru/otus/basicarchitecture/presentation/MainActivity.kt @@ -1,9 +1,14 @@ -package ru.otus.basicarchitecture +package ru.otus.basicarchitecture.presentation -import androidx.appcompat.app.AppCompatActivity import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint +import ru.otus.basicarchitecture.R +@AndroidEntryPoint class MainActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) diff --git a/app/src/main/java/ru/otus/basicarchitecture/presentation/NameFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/presentation/NameFragment.kt new file mode 100644 index 0000000..294609a --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/presentation/NameFragment.kt @@ -0,0 +1,185 @@ +package ru.otus.basicarchitecture.presentation + +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import dagger.hilt.android.AndroidEntryPoint +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.databinding.FragmentNameBinding + +@AndroidEntryPoint +class NameFragment : Fragment() { + + private var _binding: FragmentNameBinding? = null + private val binding: FragmentNameBinding + get() = _binding ?: throw RuntimeException("FragmentNameBinding == null") + + private val viewModel: NameViewModel 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 = FragmentNameBinding.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.you_are_under_18) + } else { + editTextBirthday.error = null + } + } + + } + viewModel.errorBirthday.observe(viewLifecycleOwner) { + with(binding) { + if (it){ + editTextBirthday.error = resources.getString(R.string.incorrectly_birthday) + } else { + editTextBirthday.error = null + } + } + + } + viewModel.canContinue.observe(viewLifecycleOwner) { + if (it){ + requireActivity().supportFragmentManager.beginTransaction() + .replace(R.id.mainContainer, AddressFragment.Companion.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/presentation/NameViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/presentation/NameViewModel.kt new file mode 100644 index 0000000..8b18b44 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/presentation/NameViewModel.kt @@ -0,0 +1,113 @@ +package ru.otus.basicarchitecture.presentation + +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 NameViewModel @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 = true + 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.userName.name = name + } + + fun setSurname(surname: String) { + wizardCache.userName.surname = surname + } + + fun setBirthday(birthday: String) { + wizardCache.userName.birthday = birthday + } + + private fun checkAge(): Boolean { + var successful = true + val sdf = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()) + try { + val birthDate = sdf.parse(wizardCache.userName.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.userName.name == ""){ + _errorEmptyName.value = true + successful = false + } else{ + _errorEmptyName.value = false + } + if (wizardCache.userName.surname == ""){ + _errorEmptySurname.value = true + successful = false + } else { + _errorEmptySurname.value = false + } + if (wizardCache.userName.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/presentation/ResultFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/presentation/ResultFragment.kt new file mode 100644 index 0000000..944706f --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/presentation/ResultFragment.kt @@ -0,0 +1,82 @@ +package ru.otus.basicarchitecture.presentation + +import android.content.res.ColorStateList +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import com.google.android.material.chip.Chip +import dagger.hilt.android.AndroidEntryPoint +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.databinding.FragmentResultBinding + +@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 onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } + + 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 + Log.d("ResultFragment", it.toString()) + } + } + viewModel.userAddress.observe(viewLifecycleOwner) { + with(binding){ + textViewAddress.text = it.fullAddress + Log.d("ResultFragment", it.toString()) + } + } + viewModel.interests.observe(viewLifecycleOwner) { + val defaultColor = ContextCompat.getColor(requireContext(), R.color.gray_light) + with(binding){ + it.forEach { interest -> + val chip = Chip(requireContext()).apply { + Log.d("ResultFragment", interest) + text = interest + chipBackgroundColor = ColorStateList.valueOf(defaultColor) + } + binding.chipGroupInterests.addView(chip) + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + fun newInstance() = ResultFragment() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/presentation/ResultViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/presentation/ResultViewModel.kt new file mode 100644 index 0000000..ef245e3 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/presentation/ResultViewModel.kt @@ -0,0 +1,45 @@ +package ru.otus.basicarchitecture.presentation + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import ru.otus.basicarchitecture.domain.UserAddress +import ru.otus.basicarchitecture.domain.UserName +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.userName + } + + private fun loadUserAddress(){ + _userAddress.value = wizardCache.userAddress + } + + private fun loadInterests(){ + _interests.value = wizardCache.interests + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/presentation/WizardApp.kt b/app/src/main/java/ru/otus/basicarchitecture/presentation/WizardApp.kt new file mode 100644 index 0000000..33b4439 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/presentation/WizardApp.kt @@ -0,0 +1,8 @@ +package ru.otus.basicarchitecture.presentation + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class WizardApp: Application() { +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/presentation/WizardCache.kt b/app/src/main/java/ru/otus/basicarchitecture/presentation/WizardCache.kt new file mode 100644 index 0000000..0e4b354 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/presentation/WizardCache.kt @@ -0,0 +1,11 @@ +package ru.otus.basicarchitecture.presentation + +import ru.otus.basicarchitecture.domain.UserAddress +import ru.otus.basicarchitecture.domain.UserName +import javax.inject.Inject + +class WizardCache @Inject constructor(){ + var userName: UserName = UserName("", "", "") + var userAddress: UserAddress = UserAddress("", "", "", "", "", "") + var interests: List = listOf() +} \ 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..e9ea1f2 --- /dev/null +++ b/app/src/main/res/drawable/border_black.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/border_with_blue.xml b/app/src/main/res/drawable/border_with_blue.xml new file mode 100644 index 0000000..029a1c6 --- /dev/null +++ b/app/src/main/res/drawable/border_with_blue.xml @@ -0,0 +1,7 @@ + + + + + \ 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..200146a 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,9 +1,14 @@ + tools:context=".presentation.MainActivity"> + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_address.xml b/app/src/main/res/layout/fragment_address.xml new file mode 100644 index 0000000..69ab52c --- /dev/null +++ b/app/src/main/res/layout/fragment_address.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + \ 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..ea2093a --- /dev/null +++ b/app/src/main/res/layout/fragment_interests.xml @@ -0,0 +1,31 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_name.xml b/app/src/main/res/layout/fragment_name.xml new file mode 100644 index 0000000..8f34b00 --- /dev/null +++ b/app/src/main/res/layout/fragment_name.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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..a62a928 --- /dev/null +++ b/app/src/main/res/layout/fragment_result.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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..63e117f 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,4 +7,8 @@ #FF018786 #FF000000 #FFFFFFFF + #0F6DD1 + #aa0F6DD1 + #383838 + #DEDEDE \ 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..646f4cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,18 @@ BasicArchitecture + %s, %s, %s + Сountry + City + Address + Next + Name + Surname + Birthday + Date of birth + Interests + The birthday is entered incorrectly + You are under 18 + The field \"%s\" is empty + Country, city, address + An error occurred while interacting with the network \ No newline at end of file diff --git a/app/src/test/java/ru/otus/basicarchitecture/presentation/AddressViewModelTest.kt b/app/src/test/java/ru/otus/basicarchitecture/presentation/AddressViewModelTest.kt new file mode 100644 index 0000000..4c4d916 --- /dev/null +++ b/app/src/test/java/ru/otus/basicarchitecture/presentation/AddressViewModelTest.kt @@ -0,0 +1,96 @@ +package ru.otus.basicarchitecture.presentation + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +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.domain.AddressSuggestUseCase +import ru.otus.basicarchitecture.domain.UserAddress + + +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("Россия", "Москва", "Тверская", "3", "", "Россия, Москва, Тверская, 3")) + } +} \ No newline at end of file diff --git a/app/src/test/java/ru/otus/basicarchitecture/presentation/NameViewModelTest.kt b/app/src/test/java/ru/otus/basicarchitecture/presentation/NameViewModelTest.kt new file mode 100644 index 0000000..233fa83 --- /dev/null +++ b/app/src/test/java/ru/otus/basicarchitecture/presentation/NameViewModelTest.kt @@ -0,0 +1,101 @@ +package ru.otus.basicarchitecture.presentation + +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 +import ru.otus.basicarchitecture.domain.UserName + +class NameViewModelTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val wizardCache: WizardCache = mock() + private val viewModel = NameViewModel(wizardCache) + + @Before + fun before(){ + println("Начинается тест") + } + + @After + fun after(){ + println("Тест закончился") + } + + @Test + fun `empty name returns error`() { + runTest { + whenever(wizardCache.userName).thenReturn(UserName("", "Иванов", "19.09.1999")) + viewModel.validateData() + val actual = viewModel.errorEmptyName.value ?: throw RuntimeException("errorEmptyName == null") + assertTrue(actual) + } + } + + @Test + fun `empty surname returns error`() { + runTest { + whenever(wizardCache.userName).thenReturn(UserName("Иван", "", "19.09.1999")) + viewModel.validateData() + val actual = viewModel.errorEmptySurname.value ?: throw RuntimeException("errorEmptySurname == null") + assertTrue(actual) + } + } + + @Test + fun `empty birthday returns error`() { + runTest { + whenever(wizardCache.userName).thenReturn(UserName("Иван", "Иванов", "")) + viewModel.validateData() + val actual = viewModel.errorEmptyBirthday.value ?: throw RuntimeException("errorEmptyBirthday == null") + assertTrue(actual) + } + } + + @Test + fun `young age returns error`() { + runTest { + whenever(wizardCache.userName).thenReturn(UserName("Иван", "Иванов", "19.09.2009")) + viewModel.validateData() + val actual = viewModel.errorAge.value ?: throw RuntimeException("errorAge == null") + assertTrue(actual) + } + } + + @Test + fun `incorrect birthday entry returns error`() { + runTest { + whenever(wizardCache.userName).thenReturn(UserName("Иван", "Иванов", "19.09/2009")) + viewModel.validateData() + val actual = viewModel.errorBirthday.value ?: throw RuntimeException("errorBirthday == null") + assertTrue(actual) + } + } + + @Test + fun `valid input returns success`() { + runTest { + whenever(wizardCache.userName).thenReturn(UserName("Иван", "Иванов", "19.09.1999")) + 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) + } + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 7b166ff..633ffe2 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,14 @@ // 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 'org.jetbrains.kotlin.android' version '2.0.0' apply false id 'com.android.library' version '8.7.3' apply false - id 'org.jetbrains.kotlin.android' version '2.0.21' apply false + id 'com.google.dagger.hilt.android' version '2.56.2' apply false + id'com.google.devtools.ksp' version '2.0.0-1.0.23' apply false } \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 9717fd6..7a66128 100644 --- a/settings.gradle +++ b/settings.gradle @@ -4,12 +4,16 @@ pluginManagement { mavenCentral() gradlePluginPortal() } + plugins { + id("androidx.hilt.lifecycle-viewmodel") version "1.0.0" apply false + } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() + gradlePluginPortal() } } rootProject.name = "BasicArchitecture"