diff --git a/app/build.gradle b/app/build.gradle index 9c99d98..672dd86 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,20 +1,28 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.kapt' + id 'com.google.dagger.hilt.android' + id("org.jetbrains.kotlin.plugin.serialization") } android { namespace 'ru.otus.basicarchitecture' - compileSdk 33 + compileSdk 34 defaultConfig { applicationId "ru.otus.basicarchitecture" - minSdk 24 + minSdk 26 targetSdk 33 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + Properties properties = new Properties() + properties.load(project.rootProject.file("local.properties").newDataInputStream()) + resValue "string", "dadata_api_key", properties.getProperty("dadata.api.key", "") + } buildTypes { @@ -30,14 +38,29 @@ android { kotlinOptions { jvmTarget = '1.8' } + buildFeatures { + viewBinding true + } } dependencies { - implementation 'androidx.core:core-ktx:1.8.0' + implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.9.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'com.google.dagger:hilt-android:2.50' + implementation 'androidx.navigation:navigation-fragment-ktx:2.7.6' + implementation 'androidx.navigation:navigation-ui-ktx:2.7.6' + kapt 'com.google.dagger:hilt-compiler:2.50' + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.retrofit2:retrofit:2.9.0") + implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/app/src/androidTest/java/ru/otus/basicarchitecture/ExampleInstrumentedTest.kt b/app/src/androidTest/java/ru/otus/basicarchitecture/ExampleInstrumentedTest.kt index a987f13..0a180ff 100644 --- a/app/src/androidTest/java/ru/otus/basicarchitecture/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/ru/otus/basicarchitecture/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package ru.otus.basicarchitecture -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1e81fea..fc657f6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,9 +1,11 @@ + + tools:targetApi="31" + android:name=".BasicArchitectureApplication"> + android:exported="true"> + + + + + \ 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..85a6d42 100644 --- a/app/src/main/java/ru/otus/basicarchitecture/MainActivity.kt +++ b/app/src/main/java/ru/otus/basicarchitecture/MainActivity.kt @@ -1,8 +1,10 @@ package ru.otus.basicarchitecture -import androidx.appcompat.app.AppCompatActivity import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +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/WizardCache.kt b/app/src/main/java/ru/otus/basicarchitecture/WizardCache.kt new file mode 100644 index 0000000..3a85a39 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/WizardCache.kt @@ -0,0 +1,15 @@ +package ru.otus.basicarchitecture + +import dagger.hilt.android.scopes.ActivityRetainedScoped +import java.time.LocalDate +import javax.inject.Inject + +@ActivityRetainedScoped +class WizardCache @Inject constructor() { + var firstName: String = "" + var lastName: String? = "" + var birthDate: LocalDate = LocalDate.now().minusYears(18) + var address: String = "" + var interests: Set = emptySet() + var selectedInterests: Set = emptySet() +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/address/AddressFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/address/AddressFragment.kt new file mode 100644 index 0000000..8ac368e --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/address/AddressFragment.kt @@ -0,0 +1,80 @@ +package ru.otus.basicarchitecture.address + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.distinctUntilChanged +import androidx.navigation.fragment.findNavController +import com.google.android.material.chip.Chip +import dagger.hilt.android.AndroidEntryPoint +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.address.suggestions.SuggestionsAdapter +import ru.otus.basicarchitecture.databinding.AddressFragmentBinding +import ru.otus.basicarchitecture.interests.InterestsViewModel + +@AndroidEntryPoint +class AddressFragment : Fragment(R.layout.address_fragment) { + + private lateinit var binding: AddressFragmentBinding + private val viewModel: AddressViewModel by viewModels() + private val adapter: SuggestionsAdapter = SuggestionsAdapter( + onItemClicked = { + binding.addressField.setText(it) + } + ) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = AddressFragmentBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.suggestionsContent.suggestions.adapter = adapter + binding.addressField.addTextChangedListener { + viewModel.loadSuggestions(input = it?.toString() ?: "") + } + viewModel.suggestionsGroupState.observe(viewLifecycleOwner) { state -> + when (state) { + AddressViewModel.SuggestionsGroupState.Content -> { + binding.suggestionsLoading.loadingGroup.isVisible = false + binding.suggestionsContent.contentGroup.isVisible = true + binding.suggestionsError.errorGroup.isVisible = false + } + AddressViewModel.SuggestionsGroupState.Loading -> { + binding.suggestionsLoading.loadingGroup.isVisible = true + binding.suggestionsContent.contentGroup.isVisible = false + binding.suggestionsError.errorGroup.isVisible = false + } + AddressViewModel.SuggestionsGroupState.Error -> { + binding.suggestionsLoading.loadingGroup.isVisible = false + binding.suggestionsContent.contentGroup.isVisible = false + binding.suggestionsError.errorGroup.isVisible = true + } + AddressViewModel.SuggestionsGroupState.NotSet -> { + binding.suggestionsLoading.loadingGroup.isVisible = false + binding.suggestionsContent.contentGroup.isVisible = false + binding.suggestionsError.errorGroup.isVisible = false + } + } + } + viewModel.suggestionsState.observe(viewLifecycleOwner) { + adapter.submitList(it) + } + viewModel.fillFieldsFromCache(binding) + binding.nextButton.setOnClickListener { + viewModel.saveFieldsToCache(binding) + findNavController().navigate(R.id.addressNext) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/address/AddressUseCase.kt b/app/src/main/java/ru/otus/basicarchitecture/address/AddressUseCase.kt new file mode 100644 index 0000000..4dc0ead --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/address/AddressUseCase.kt @@ -0,0 +1,19 @@ +package ru.otus.basicarchitecture.address + +import dagger.hilt.android.scopes.ViewModelScoped +import ru.otus.basicarchitecture.networkCall +import ru.otus.basicarchitecture.service.DaDataService +import ru.otus.basicarchitecture.service.dto.DaDataSuggestionsRequest +import javax.inject.Inject + +@ViewModelScoped +class AddressUseCase @Inject constructor() { + + @Inject + lateinit var daDataService: DaDataService + + suspend fun getSuggestions(input: String) = networkCall { + daDataService.getSuggestions(DaDataSuggestionsRequest(query = input)) + } + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/address/AddressViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/address/AddressViewModel.kt new file mode 100644 index 0000000..e1dea99 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/address/AddressViewModel.kt @@ -0,0 +1,76 @@ +package ru.otus.basicarchitecture.address + +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.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import ru.otus.basicarchitecture.WizardCache +import ru.otus.basicarchitecture.address.suggestions.SuggestionsItem +import ru.otus.basicarchitecture.databinding.AddressFragmentBinding +import javax.inject.Inject + +@HiltViewModel +class AddressViewModel @Inject constructor( + private val useCase: AddressUseCase, + private val cache: WizardCache +) : ViewModel() { + + private val mSuggestionsGroupState = + MutableLiveData(SuggestionsGroupState.NotSet) + val suggestionsGroupState: LiveData get() = mSuggestionsGroupState + + private val mSuggestionsState = MutableLiveData>() + val suggestionsState: LiveData> get() = mSuggestionsState + + private var loadingSuggestionsTask: Job = Job() + + fun loadSuggestions(input: String) { + loadingSuggestionsTask.cancel() + loadingSuggestionsTask = viewModelScope.launch { + mSuggestionsGroupState.value = SuggestionsGroupState.Loading + try { + withContext(Dispatchers.IO) { useCase.getSuggestions(input) } + .takeIf { it.isSuccess } + ?.let { + mSuggestionsState.value = + it.getOrNull() + ?.suggestions + ?.filter { s -> s.value != input } + ?.mapNotNull { s -> s.value?.let { v -> SuggestionsItem(v) } } + ?: emptyList() + mSuggestionsGroupState.value = SuggestionsGroupState.Content + } ?: let { + mSuggestionsGroupState.value = SuggestionsGroupState.Error + } + } catch (t: Throwable) { + mSuggestionsGroupState.value = SuggestionsGroupState.Error + } + } + } + + fun fillFieldsFromCache(binding: AddressFragmentBinding) { + binding.addressField.setText(cache.address) + } + + fun saveFieldsToCache(binding: AddressFragmentBinding) { + cache.address = binding.addressField.text?.toString() ?: "" + } + + sealed class SuggestionsGroupState { + + data object NotSet: SuggestionsGroupState() + + data object Loading: SuggestionsGroupState() + + data object Content: SuggestionsGroupState() + + data object Error: SuggestionsGroupState() + } + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/address/suggestions/SuggestionsAdapter.kt b/app/src/main/java/ru/otus/basicarchitecture/address/suggestions/SuggestionsAdapter.kt new file mode 100644 index 0000000..9dc3453 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/address/suggestions/SuggestionsAdapter.kt @@ -0,0 +1,21 @@ +package ru.otus.basicarchitecture.address.suggestions + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import ru.otus.basicarchitecture.R + +class SuggestionsAdapter( + private val onItemClicked: (String) -> Unit +) : ListAdapter(SuggestionsItemCallback()) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SuggestionsItemViewHolder = + SuggestionsItemViewHolder( + view = LayoutInflater.from(parent.context) + .inflate(R.layout.suggestion_item, parent, false), + onItemClicked = onItemClicked) + + override fun onBindViewHolder(holder: SuggestionsItemViewHolder, position: Int) = + holder.bind(getItem(position)) + + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/address/suggestions/SuggestionsItem.kt b/app/src/main/java/ru/otus/basicarchitecture/address/suggestions/SuggestionsItem.kt new file mode 100644 index 0000000..20912dc --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/address/suggestions/SuggestionsItem.kt @@ -0,0 +1,5 @@ +package ru.otus.basicarchitecture.address.suggestions + +data class SuggestionsItem( + val value: String +) diff --git a/app/src/main/java/ru/otus/basicarchitecture/address/suggestions/SuggestionsItemCallback.kt b/app/src/main/java/ru/otus/basicarchitecture/address/suggestions/SuggestionsItemCallback.kt new file mode 100644 index 0000000..7064ffb --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/address/suggestions/SuggestionsItemCallback.kt @@ -0,0 +1,10 @@ +package ru.otus.basicarchitecture.address.suggestions + +import androidx.recyclerview.widget.DiffUtil + +class SuggestionsItemCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(p0: SuggestionsItem, p1: SuggestionsItem): Boolean = false + + override fun areContentsTheSame(p0: SuggestionsItem, p1: SuggestionsItem): Boolean = false +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/address/suggestions/SuggestionsItemViewHolder.kt b/app/src/main/java/ru/otus/basicarchitecture/address/suggestions/SuggestionsItemViewHolder.kt new file mode 100644 index 0000000..d2b48c4 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/address/suggestions/SuggestionsItemViewHolder.kt @@ -0,0 +1,22 @@ +package ru.otus.basicarchitecture.address.suggestions + +import android.view.View +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import ru.otus.basicarchitecture.R + +class SuggestionsItemViewHolder( + view: View, + private val onItemClicked: (String) -> Unit = {} +) : RecyclerView.ViewHolder(view) { + + private val textView: TextView by lazy { itemView.findViewById(R.id.value) } + + fun bind(item: SuggestionsItem) { + textView.text = item.value + itemView.setOnClickListener { + onItemClicked(item.value) + } + } + +} \ 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..6b6cf64 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/application.kt @@ -0,0 +1,33 @@ +package ru.otus.basicarchitecture + +import android.app.Application +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.HiltAndroidApp +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import ru.otus.basicarchitecture.service.DaDataService +import ru.otus.basicarchitecture.service.InterestsService +import ru.otus.basicarchitecture.service.impl.DaDataServiceImpl +import ru.otus.basicarchitecture.service.impl.InterestsServiceStubImpl +import javax.inject.Singleton + +@HiltAndroidApp +class BasicArchitectureApplication : Application() + +@Module +@InstallIn(SingletonComponent::class) +class Module { + + @Provides + @Singleton + fun interestsService(): InterestsService = InterestsServiceStubImpl() + + @Provides + @Singleton + fun daDataService(@ApplicationContext context: Context): DaDataService = + DaDataServiceImpl.create(context.getString(R.string.dadata_api_key)) + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/helpers.kt b/app/src/main/java/ru/otus/basicarchitecture/helpers.kt new file mode 100644 index 0000000..77ce7fa --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/helpers.kt @@ -0,0 +1,20 @@ +package ru.otus.basicarchitecture + +import android.text.Editable +import retrofit2.Response +import java.io.IOException +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +fun LocalDate.toBrithDateString(): String = + format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) + +fun String.toBirthDate() : LocalDate = + LocalDate.parse(this, DateTimeFormatter.ofPattern("dd.MM.yyyy")) + +fun Editable.toBirthDate() : LocalDate = toString().toBirthDate() + +suspend inline fun networkCall(crossinline block: suspend () -> Response): Result = + runCatching { + block().body() ?: throw IOException("Network error") + } \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/interests/InterestsFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/interests/InterestsFragment.kt new file mode 100644 index 0000000..b935135 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/interests/InterestsFragment.kt @@ -0,0 +1,86 @@ +package ru.otus.basicarchitecture.interests + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.forEach +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.distinctUntilChanged +import androidx.navigation.fragment.findNavController +import com.google.android.material.chip.Chip +import dagger.hilt.android.AndroidEntryPoint +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.databinding.InterestsFragmentBinding + +@AndroidEntryPoint +class InterestsFragment : Fragment(R.layout.interests_fragment_content) { + + private lateinit var binding: InterestsFragmentBinding + private val viewModel: InterestsViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = InterestsFragmentBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.interestsGroupState.observe(viewLifecycleOwner) { state -> + when (state) { + is InterestsViewModel.InterestsGroupState.Content -> { + binding.interestsLoading.loadingGroup.isVisible = false + binding.interestsContent.contentGroup.isVisible = true + binding.interestsError.errorGroup.isVisible = false + } + InterestsViewModel.InterestsGroupState.Loading -> { + binding.interestsLoading.loadingGroup.isVisible = true + binding.interestsContent.contentGroup.isVisible = false + binding.interestsError.errorGroup.isVisible = false + } + InterestsViewModel.InterestsGroupState.Error -> { + binding.interestsLoading.loadingGroup.isVisible = false + binding.interestsContent.contentGroup.isVisible = false + binding.interestsError.errorGroup.isVisible = true + } + InterestsViewModel.InterestsGroupState.NotSet -> { + binding.interestsLoading.loadingGroup.isVisible = false + binding.interestsContent.contentGroup.isVisible = false + binding.interestsError.errorGroup.isVisible = false + } + } + } + viewModel.interestsState.distinctUntilChanged().observe(viewLifecycleOwner) { interests -> + interests.forEach { tag -> + Chip(binding.interestsContent.tags.context).apply { + text = tag + isClickable = true + isCheckable = true + setOnCheckedChangeListener { chip, state -> + viewModel.processInterestClick(chip.text.toString(), state) + } + binding.interestsContent.tags.addView(this) + } + } + } + viewModel.selectedInterestsState.distinctUntilChanged().observe(viewLifecycleOwner) { selectedInterests -> + binding.interestsContent.tags.forEach { tag -> + (tag as? Chip) + ?.takeIf { (it.isChecked xor selectedInterests.contains(it.text)) } + ?.apply { toggle() } + } + } + viewModel.fillInterestsFromCache() + binding.interestsContent.nextButton.setOnClickListener { + viewModel.saveInterestsToCache() + findNavController().navigate(R.id.interestsNext) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/interests/InterestsUseCase.kt b/app/src/main/java/ru/otus/basicarchitecture/interests/InterestsUseCase.kt new file mode 100644 index 0000000..6f763a7 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/interests/InterestsUseCase.kt @@ -0,0 +1,15 @@ +package ru.otus.basicarchitecture.interests + +import dagger.hilt.android.scopes.ViewModelScoped +import ru.otus.basicarchitecture.service.InterestsService +import javax.inject.Inject + +@ViewModelScoped +class InterestsUseCase @Inject constructor() { + + @Inject + lateinit var interestsService: InterestsService + + suspend fun getAvailableInterests(): Set = interestsService.getAvailableInterests() + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/interests/InterestsViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/interests/InterestsViewModel.kt new file mode 100644 index 0000000..4179447 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/interests/InterestsViewModel.kt @@ -0,0 +1,77 @@ +package ru.otus.basicarchitecture.interests + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.material.chip.Chip +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import ru.otus.basicarchitecture.WizardCache +import javax.inject.Inject + +@HiltViewModel +class InterestsViewModel @Inject constructor( + private val useCase: InterestsUseCase, + private val cache: WizardCache +) : ViewModel() { + + private val mInterestsGroupState = MutableLiveData(InterestsGroupState.NotSet) + val interestsGroupState: LiveData get() = mInterestsGroupState + + private val mInterestsState = MutableLiveData>() + val interestsState: LiveData> get() = mInterestsState + + private val mSelectedInterestsState = MutableLiveData>() + val selectedInterestsState: LiveData> get() = mSelectedInterestsState + + private fun loadAvailableInterests() = + viewModelScope.launch { + mInterestsGroupState.value = InterestsGroupState.Loading + try { + cache.interests = withContext(Dispatchers.IO) { + useCase.getAvailableInterests() + } + mInterestsState.value = cache.interests + mInterestsGroupState.value = InterestsGroupState.Content + } catch (t: Throwable) { + mInterestsGroupState.value = InterestsGroupState.Error + } + } + + fun fillInterestsFromCache() { + cache.interests + .takeIf { it.isEmpty() } + ?.let { loadAvailableInterests() } + ?: let { + mInterestsState.value = cache.interests + mInterestsGroupState.value = InterestsGroupState.Content + } + mSelectedInterestsState.value = cache.selectedInterests + } + + fun saveInterestsToCache() { + cache.selectedInterests = mSelectedInterestsState.value ?: emptySet() + } + + fun processInterestClick(name: String, state: Boolean) = + if (state) { + mSelectedInterestsState.value = (mSelectedInterestsState.value ?: emptySet()) + name + } else { + mSelectedInterestsState.value = (mSelectedInterestsState.value ?: emptySet()) - name + } + + sealed class InterestsGroupState { + + data object NotSet: InterestsGroupState() + + data object Loading: InterestsGroupState() + + data object Content: InterestsGroupState() + + data object Error: InterestsGroupState() + } + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/personal/PersonalDataFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/personal/PersonalDataFragment.kt new file mode 100644 index 0000000..7b79bf4 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/personal/PersonalDataFragment.kt @@ -0,0 +1,45 @@ +package ru.otus.basicarchitecture.personal + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.databinding.PersonalDataFragmentBinding + +@AndroidEntryPoint +class PersonalDataFragment : Fragment(R.layout.personal_data_fragment) { + + private lateinit var binding: PersonalDataFragmentBinding + private val viewModel: PersonalDataViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = PersonalDataFragmentBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.nextButtonState.observe(viewLifecycleOwner) { + binding.nextButton.isEnabled = it + } + binding.dateField.addTextChangedListener { + viewModel.updateNextButtonStatus(it?.toString()) + } + viewModel.fillFieldsFromCache(binding) + binding.nextButton.setOnClickListener { + viewModel.saveFieldsToCache(binding) + findNavController().navigate(R.id.personalDataNext) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/personal/PersonalDataUseCase.kt b/app/src/main/java/ru/otus/basicarchitecture/personal/PersonalDataUseCase.kt new file mode 100644 index 0000000..fade996 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/personal/PersonalDataUseCase.kt @@ -0,0 +1,13 @@ +package ru.otus.basicarchitecture.personal + +import dagger.hilt.android.scopes.ViewModelScoped +import java.time.LocalDate +import javax.inject.Inject + +@ViewModelScoped +class PersonalDataUseCase @Inject constructor() { + + fun isBirthDateValid(date: LocalDate) = + !date.isAfter(LocalDate.now().minusYears(18)) + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/personal/PersonalDataViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/personal/PersonalDataViewModel.kt new file mode 100644 index 0000000..8f05600 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/personal/PersonalDataViewModel.kt @@ -0,0 +1,50 @@ +package ru.otus.basicarchitecture.personal + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import ru.otus.basicarchitecture.WizardCache +import ru.otus.basicarchitecture.databinding.PersonalDataFragmentBinding +import ru.otus.basicarchitecture.toBirthDate +import ru.otus.basicarchitecture.toBrithDateString +import java.time.format.DateTimeParseException +import javax.inject.Inject + +@HiltViewModel +class PersonalDataViewModel @Inject constructor( + private val useCase: PersonalDataUseCase, + private val cache: WizardCache +) : ViewModel() { + + private val mNextButtonState = MutableLiveData(false) + val nextButtonState: LiveData get() = mNextButtonState + + fun updateNextButtonStatus(date: String?) { + try { + mNextButtonState.value = + date?.let { useCase.isBirthDateValid(it.toBirthDate()) + } ?: false + } catch (e: DateTimeParseException) { + mNextButtonState.value = false + } + } + + fun fillFieldsFromCache(binding: PersonalDataFragmentBinding) { + binding.firstNameField.setText(cache.firstName) + binding.lastNameField.setText(cache.lastName) + binding.dateField.setText( + cache.birthDate.toBrithDateString()) + } + + fun saveFieldsToCache(binding: PersonalDataFragmentBinding) { + cache.firstName = binding.firstNameField.text?.toString() ?: "" + cache.lastName = binding.lastNameField.text?.toString() ?: "" + try { + binding.dateField.text?.apply { + cache.birthDate = toBirthDate() + } + } catch (_: DateTimeParseException) {} + } + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/service/DaDataService.kt b/app/src/main/java/ru/otus/basicarchitecture/service/DaDataService.kt new file mode 100644 index 0000000..048eee0 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/service/DaDataService.kt @@ -0,0 +1,11 @@ +package ru.otus.basicarchitecture.service + +import retrofit2.Response +import ru.otus.basicarchitecture.service.dto.DaDataSuggestionsRequest +import ru.otus.basicarchitecture.service.dto.DaDataSuggestionsResponse + +interface DaDataService { + + suspend fun getSuggestions(body: DaDataSuggestionsRequest): Response + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/service/InterestsService.kt b/app/src/main/java/ru/otus/basicarchitecture/service/InterestsService.kt new file mode 100644 index 0000000..ebda767 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/service/InterestsService.kt @@ -0,0 +1,7 @@ +package ru.otus.basicarchitecture.service + +interface InterestsService { + + suspend fun getAvailableInterests(): Set + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/service/dto/DaDataSuggestionsRequest.kt b/app/src/main/java/ru/otus/basicarchitecture/service/dto/DaDataSuggestionsRequest.kt new file mode 100644 index 0000000..838793a --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/service/dto/DaDataSuggestionsRequest.kt @@ -0,0 +1,8 @@ +package ru.otus.basicarchitecture.service.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class DaDataSuggestionsRequest( + val query: String +) diff --git a/app/src/main/java/ru/otus/basicarchitecture/service/dto/DaDataSuggestionsResponse.kt b/app/src/main/java/ru/otus/basicarchitecture/service/dto/DaDataSuggestionsResponse.kt new file mode 100644 index 0000000..6776cd5 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/service/dto/DaDataSuggestionsResponse.kt @@ -0,0 +1,8 @@ +package ru.otus.basicarchitecture.service.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class DaDataSuggestionsResponse( + val suggestions: List? +) diff --git a/app/src/main/java/ru/otus/basicarchitecture/service/dto/Suggestion.kt b/app/src/main/java/ru/otus/basicarchitecture/service/dto/Suggestion.kt new file mode 100644 index 0000000..a2b873b --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/service/dto/Suggestion.kt @@ -0,0 +1,8 @@ +package ru.otus.basicarchitecture.service.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class Suggestion( + val value: String? +) diff --git a/app/src/main/java/ru/otus/basicarchitecture/service/impl/DaDataServiceImpl.kt b/app/src/main/java/ru/otus/basicarchitecture/service/impl/DaDataServiceImpl.kt new file mode 100644 index 0000000..89eb41f --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/service/impl/DaDataServiceImpl.kt @@ -0,0 +1,62 @@ +package ru.otus.basicarchitecture.service.impl + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.serialization.json.Json +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import retrofit2.Call +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.Headers +import retrofit2.http.POST +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.service.DaDataService +import ru.otus.basicarchitecture.service.dto.DaDataSuggestionsRequest +import ru.otus.basicarchitecture.service.dto.DaDataSuggestionsResponse +import java.util.concurrent.TimeUnit + +interface DaDataServiceImpl : DaDataService { + + @POST("suggestions/api/4_1/rs/suggest/address") + override suspend fun getSuggestions( + @Body body: DaDataSuggestionsRequest + ): Response + + companion object { + + fun create(apiKey: String): DaDataService { + val okHttp = OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .addInterceptor(Interceptor { chain -> + chain.proceed( + chain.request().newBuilder() + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header( + "Authorization", + "Token $apiKey") + .build()) + }) + .build() + + val json = Json { + coerceInputValues = true + ignoreUnknownKeys = true + } + + val retrofit = Retrofit.Builder() + .client(okHttp) + .baseUrl("http://suggestions.dadata.ru/") + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + + return retrofit.create(DaDataServiceImpl::class.java) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/service/impl/InterestsServiceStubImpl.kt b/app/src/main/java/ru/otus/basicarchitecture/service/impl/InterestsServiceStubImpl.kt new file mode 100644 index 0000000..2d38b38 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/service/impl/InterestsServiceStubImpl.kt @@ -0,0 +1,10 @@ +package ru.otus.basicarchitecture.service.impl + +import ru.otus.basicarchitecture.service.InterestsService + +class InterestsServiceStubImpl : InterestsService { + + override suspend fun getAvailableInterests(): Set = setOf( + "Cooking", "Hiking", "Programming", "Travelling", "Sleeping" + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/summary/SummaryFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/summary/SummaryFragment.kt new file mode 100644 index 0000000..56ba174 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/summary/SummaryFragment.kt @@ -0,0 +1,45 @@ +package ru.otus.basicarchitecture.summary + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +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.SummaryFragmentBinding + +@AndroidEntryPoint +class SummaryFragment : Fragment(R.layout.summary_fragment) { + + private lateinit var binding: SummaryFragmentBinding + private val viewModel: SummaryViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = SummaryFragmentBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.selectedInterestsState.observe(viewLifecycleOwner) { selectedInterests -> + binding.interestsTitle.visibility = + if (selectedInterests.isEmpty()) View.GONE else View.VISIBLE + selectedInterests.forEach { tag -> + Chip(binding.interests.context).apply { + text = tag + isClickable = false + binding.interests.addView(this) + } + } + } + viewModel.fillFieldsFromCache(binding) + } + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/summary/SummaryUseCase.kt b/app/src/main/java/ru/otus/basicarchitecture/summary/SummaryUseCase.kt new file mode 100644 index 0000000..bdca13f --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/summary/SummaryUseCase.kt @@ -0,0 +1,8 @@ +package ru.otus.basicarchitecture.summary + +import dagger.hilt.android.scopes.ViewModelScoped +import javax.inject.Inject + +@ViewModelScoped +class SummaryUseCase @Inject constructor() { +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/summary/SummaryViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/summary/SummaryViewModel.kt new file mode 100644 index 0000000..2934acc --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/summary/SummaryViewModel.kt @@ -0,0 +1,29 @@ +package ru.otus.basicarchitecture.summary + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import ru.otus.basicarchitecture.WizardCache +import ru.otus.basicarchitecture.databinding.SummaryFragmentBinding +import ru.otus.basicarchitecture.toBrithDateString +import javax.inject.Inject + +@HiltViewModel +class SummaryViewModel @Inject constructor( + private val useCase: SummaryUseCase, + private val cache: WizardCache +) : ViewModel() { + + private val mSelectedInterestsState = MutableLiveData>() + val selectedInterestsState: LiveData> get() = mSelectedInterestsState + + fun fillFieldsFromCache(binding: SummaryFragmentBinding) { + binding.firstName.text = cache.firstName + binding.lastName.text = cache.lastName + binding.birthDate.text = cache.birthDate.toBrithDateString() + binding.address.text = cache.address + mSelectedInterestsState.value = cache.selectedInterests + } + +} \ 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..bbbca8d 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -6,4 +6,12 @@ android:layout_height="match_parent" tools:context=".MainActivity"> + + \ No newline at end of file diff --git a/app/src/main/res/layout/address_fragment.xml b/app/src/main/res/layout/address_fragment.xml new file mode 100644 index 0000000..02d407a --- /dev/null +++ b/app/src/main/res/layout/address_fragment.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/address_suggestions_content.xml b/app/src/main/res/layout/address_suggestions_content.xml new file mode 100644 index 0000000..e55ecea --- /dev/null +++ b/app/src/main/res/layout/address_suggestions_content.xml @@ -0,0 +1,26 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/error.xml b/app/src/main/res/layout/error.xml new file mode 100644 index 0000000..f173f68 --- /dev/null +++ b/app/src/main/res/layout/error.xml @@ -0,0 +1,23 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/interests_fragment.xml b/app/src/main/res/layout/interests_fragment.xml new file mode 100644 index 0000000..435c31b --- /dev/null +++ b/app/src/main/res/layout/interests_fragment.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/interests_fragment_content.xml b/app/src/main/res/layout/interests_fragment_content.xml new file mode 100644 index 0000000..1f0e03c --- /dev/null +++ b/app/src/main/res/layout/interests_fragment_content.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/loading.xml b/app/src/main/res/layout/loading.xml new file mode 100644 index 0000000..5ccbd57 --- /dev/null +++ b/app/src/main/res/layout/loading.xml @@ -0,0 +1,23 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/personal_data_fragment.xml b/app/src/main/res/layout/personal_data_fragment.xml new file mode 100644 index 0000000..d6dd06f --- /dev/null +++ b/app/src/main/res/layout/personal_data_fragment.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/suggestion_item.xml b/app/src/main/res/layout/suggestion_item.xml new file mode 100644 index 0000000..bf3d012 --- /dev/null +++ b/app/src/main/res/layout/suggestion_item.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/summary_fragment.xml b/app/src/main/res/layout/summary_fragment.xml new file mode 100644 index 0000000..74e5d22 --- /dev/null +++ b/app/src/main/res/layout/summary_fragment.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/navigation.xml b/app/src/main/res/navigation/navigation.xml new file mode 100644 index 0000000..1564516 --- /dev/null +++ b/app/src/main/res/navigation/navigation.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index bbaa36f..b3d6383 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,4 +1,4 @@ - +