From f76179b8921b9d0b7fe7150245dc890ab5e99165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=B0=D0=BC=D0=B8=D0=BB=D1=8C?= Date: Wed, 25 Mar 2026 16:36:44 +0300 Subject: [PATCH] done --- app/build.gradle | 42 +++++- app/src/main/AndroidManifest.xml | 4 + .../ru/otus/basicarchitecture/MainActivity.kt | 2 + .../otus/basicarchitecture/MyApplication.kt | 7 + .../ru/otus/basicarchitecture/WizardCache.kt | 18 +++ .../basicarchitecture/di/NetworkModule.kt | 61 ++++++++ .../network/dadata/DadataApi.kt | 10 ++ .../network/dadata/DadataAuthInterceptor.kt | 26 ++++ .../network/dadata/DadataRequest.kt | 7 + .../network/dadata/DadataResponse.kt | 11 ++ .../ui/address/AddressFragment.kt | 130 ++++++++++++++++++ .../ui/address/AddressViewModel.kt | 65 +++++++++ .../ui/interests/InterestsFragment.kt | 63 +++++++++ .../ui/interests/InterestsViewModel.kt | 19 +++ .../ui/personalinfo/DateValidator.kt | 16 +++ .../ui/personalinfo/PersonalInfoFragment.kt | 121 ++++++++++++++++ .../ui/personalinfo/PersonalInfoViewModel.kt | 67 +++++++++ .../ui/summary/SummaryFragment.kt | 45 ++++++ .../ui/summary/SummaryViewModel.kt | 14 ++ app/src/main/res/layout/activity_main.xml | 11 +- app/src/main/res/layout/fragment_address.xml | 23 ++++ .../main/res/layout/fragment_interests.xml | 25 ++++ .../res/layout/fragment_personal_info.xml | 39 ++++++ app/src/main/res/layout/fragment_summary.xml | 14 ++ app/src/main/res/navigation/nav_graph.xml | 39 ++++++ app/src/main/res/values-ru-rRU/strings.xml | 10 ++ app/src/main/res/values/strings.xml | 7 + .../ui/address/AddressViewModelTest.kt | 106 ++++++++++++++ .../ui/personalinfo/DateValidatorTest.kt | 64 +++++++++ .../personalinfo/PersonalInfoViewModelTest.kt | 94 +++++++++++++ build.gradle | 9 +- gradle.properties | 5 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 33 files changed, 1164 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/ru/otus/basicarchitecture/MyApplication.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/WizardCache.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/di/NetworkModule.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/network/dadata/DadataApi.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/network/dadata/DadataAuthInterceptor.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/network/dadata/DadataRequest.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/network/dadata/DadataResponse.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressFragment.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressViewModel.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/interests/InterestsFragment.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/interests/InterestsViewModel.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/personalinfo/DateValidator.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/personalinfo/PersonalInfoFragment.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/personalinfo/PersonalInfoViewModel.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/summary/SummaryFragment.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/summary/SummaryViewModel.kt create mode 100644 app/src/main/res/layout/fragment_address.xml create mode 100644 app/src/main/res/layout/fragment_interests.xml create mode 100644 app/src/main/res/layout/fragment_personal_info.xml create mode 100644 app/src/main/res/layout/fragment_summary.xml create mode 100644 app/src/main/res/navigation/nav_graph.xml create mode 100644 app/src/main/res/values-ru-rRU/strings.xml create mode 100644 app/src/test/java/ru/otus/basicarchitecture/ui/address/AddressViewModelTest.kt create mode 100644 app/src/test/java/ru/otus/basicarchitecture/ui/personalinfo/DateValidatorTest.kt create mode 100644 app/src/test/java/ru/otus/basicarchitecture/ui/personalinfo/PersonalInfoViewModelTest.kt diff --git a/app/build.gradle b/app/build.gradle index e515992..6ecf615 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,20 +1,38 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + id 'com.google.devtools.ksp' + id 'com.google.dagger.hilt.android' + id 'dagger.hilt.android.plugin' + 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 + //noinspection OldTargetApi targetSdk 35 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + // Получаем ключи из local.properties через secrets-gradle-plugin + buildConfigField "String", "DADATA_API_KEY", "\"${project.findProperty("DADATA_API_KEY") ?: ""}\"" + buildConfigField "String", "DADATA_SECRET_KEY", "\"${project.findProperty("DADATA_SECRET_KEY") ?: ""}\"" + } + + buildFeatures { + viewBinding = true + buildConfig = true + } + + testOptions { + unitTests.returnDefaultValues = true } buildTypes { @@ -38,7 +56,29 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.0' + // Lifecycle + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1' + // Navigation + implementation 'androidx.navigation:navigation-fragment:2.8.5' + implementation 'androidx.navigation:navigation-ui:2.8.5' + // Hilt + implementation "com.google.dagger:hilt-android:2.57.2" + ksp "com.google.dagger:hilt-compiler:2.57.2" + // Retrofit + 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' + implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0' + // Корутины + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + // Тестирование testImplementation 'junit:junit:4.13.2' + testImplementation 'io.mockk:mockk:1.13.8' + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' + testImplementation 'app.cash.turbine:turbine:1.0.0' + testImplementation 'androidx.arch.core:core-testing:2.2.0' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ea17fa5..2e54682 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,11 @@ + + + = emptyList() +} diff --git a/app/src/main/java/ru/otus/basicarchitecture/di/NetworkModule.kt b/app/src/main/java/ru/otus/basicarchitecture/di/NetworkModule.kt new file mode 100644 index 0000000..56d722a --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/di/NetworkModule.kt @@ -0,0 +1,61 @@ +package ru.otus.basicarchitecture.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import ru.otus.basicarchitecture.BuildConfig +import ru.otus.basicarchitecture.network.dadata.DadataApi +import ru.otus.basicarchitecture.network.dadata.DadataAuthInterceptor +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + private const val DADATA_BASE_URL = "https://suggestions.dadata.ru/" + + @Provides + @Singleton + fun provideDadataAuthInterceptor(): DadataAuthInterceptor = + DadataAuthInterceptor( + apiKey = BuildConfig.DADATA_API_KEY, + secretKey = BuildConfig.DADATA_SECRET_KEY + ) + + @Provides + @Singleton + fun provideLoggingInterceptor(): HttpLoggingInterceptor = + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + @Provides + @Singleton + fun provideOkHttpClient( + authInterceptor: DadataAuthInterceptor, + loggingInterceptor: HttpLoggingInterceptor + ): OkHttpClient = + OkHttpClient.Builder() + .addInterceptor(authInterceptor) + .addInterceptor(loggingInterceptor) + .build() + + @Provides + @Singleton + fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit = + Retrofit.Builder() + .baseUrl(DADATA_BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + @Provides + @Singleton + fun provideDadataApi(retrofit: Retrofit): DadataApi = + retrofit.create(DadataApi::class.java) +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/network/dadata/DadataApi.kt b/app/src/main/java/ru/otus/basicarchitecture/network/dadata/DadataApi.kt new file mode 100644 index 0000000..6aae6ce --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/network/dadata/DadataApi.kt @@ -0,0 +1,10 @@ +package ru.otus.basicarchitecture.network.dadata + +import retrofit2.http.Body +import retrofit2.http.POST + +interface DadataApi { + @POST("suggestions/api/4_1/rs/suggest/address") + suspend fun getAddressSuggestions(@Body request: DadataRequest): DadataResponse +} + diff --git a/app/src/main/java/ru/otus/basicarchitecture/network/dadata/DadataAuthInterceptor.kt b/app/src/main/java/ru/otus/basicarchitecture/network/dadata/DadataAuthInterceptor.kt new file mode 100644 index 0000000..3f6f78d --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/network/dadata/DadataAuthInterceptor.kt @@ -0,0 +1,26 @@ +package ru.otus.basicarchitecture.network.dadata + +import okhttp3.Interceptor +import okhttp3.Response + + +class DadataAuthInterceptor( + private val apiKey: String, + private val secretKey: String +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + val authenticatedRequest = originalRequest.newBuilder() + .header("Authorization", "Token $apiKey") + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("X-Secret", secretKey) + .build() + + return chain.proceed(authenticatedRequest) + } +} + + diff --git a/app/src/main/java/ru/otus/basicarchitecture/network/dadata/DadataRequest.kt b/app/src/main/java/ru/otus/basicarchitecture/network/dadata/DadataRequest.kt new file mode 100644 index 0000000..18eb090 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/network/dadata/DadataRequest.kt @@ -0,0 +1,7 @@ +package ru.otus.basicarchitecture.network.dadata + +data class DadataRequest( + val query: String, + val count: Int = 10 +) + diff --git a/app/src/main/java/ru/otus/basicarchitecture/network/dadata/DadataResponse.kt b/app/src/main/java/ru/otus/basicarchitecture/network/dadata/DadataResponse.kt new file mode 100644 index 0000000..bd22504 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/network/dadata/DadataResponse.kt @@ -0,0 +1,11 @@ +package ru.otus.basicarchitecture.network.dadata + +data class DadataResponse( + val suggestions: List +) + +data class Suggestion( + val value: String, + val unrestricted_value: String +) + diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressFragment.kt new file mode 100644 index 0000000..e276c85 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressFragment.kt @@ -0,0 +1,130 @@ +package ru.otus.basicarchitecture.ui.address + +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.AdapterView +import android.widget.ArrayAdapter +import android.widget.Filter +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.databinding.FragmentAddressBinding + +@AndroidEntryPoint +class AddressFragment : Fragment() { + + private var _binding: FragmentAddressBinding? = null + private val binding get() = _binding!! + + private val viewModel: AddressViewModel by viewModels() + + private lateinit var suggestionsAdapter: ArrayAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAddressBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupAdapter() + setupObservers() + setupListeners() + } + + private fun setupAdapter() { + suggestionsAdapter = object : ArrayAdapter( + requireContext(), + android.R.layout.simple_dropdown_item_1line, + mutableListOf() + ) { + override fun getFilter(): Filter { + return object : Filter() { + override fun performFiltering(constraint: CharSequence?): FilterResults { + return FilterResults().apply { + values = (0 until count).mapNotNull { getItem(it) } + count = this@AddressFragment.suggestionsAdapter.count + } + } + + override fun publishResults(constraint: CharSequence?, results: FilterResults?) { + if ((results?.count ?: 0) > 0) notifyDataSetChanged() + else notifyDataSetInvalidated() + } + } + } + } + + binding.etAddress.apply { + setAdapter(suggestionsAdapter) + threshold = 0 + onItemClickListener = AdapterView.OnItemClickListener { _, _, _, _ -> + post { dismissDropDown() } + } + } + } + + private fun setupObservers() { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.addressSuggestions.collect { suggestions -> + suggestionsAdapter.clear() + suggestionsAdapter.addAll(suggestions) + suggestionsAdapter.notifyDataSetChanged() + + binding.etAddress.post { + val shouldShow = + binding.etAddress.hasFocus() && + binding.etAddress.text.isNotEmpty() && + suggestionsAdapter.count > 0 + + if (shouldShow) binding.etAddress.showDropDown() + else binding.etAddress.dismissDropDown() + } + } + } + } + + private fun setupListeners() { + binding.etAddress.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.getAddressSuggestions(s?.toString().orEmpty()) + } + + override fun afterTextChanged(s: Editable?) { + if (suggestionsAdapter.count > 0 && binding.etAddress.hasFocus()) { + binding.etAddress.post { binding.etAddress.showDropDown() } + } + } + }) + + binding.etAddress.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus && suggestionsAdapter.count > 0 && binding.etAddress.text.isNotEmpty()) { + binding.etAddress.post { binding.etAddress.showDropDown() } + } + } + + binding.btnNext.setOnClickListener { + val address = binding.etAddress.text.toString() + viewModel.saveData(address) + findNavController().navigate(R.id.action_addressFragment_to_interestsFragment) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressViewModel.kt new file mode 100644 index 0000000..d816e83 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressViewModel.kt @@ -0,0 +1,65 @@ +package ru.otus.basicarchitecture.ui.address + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import ru.otus.basicarchitecture.WizardCache +import ru.otus.basicarchitecture.network.dadata.DadataApi +import ru.otus.basicarchitecture.network.dadata.DadataRequest +import javax.inject.Inject + +@HiltViewModel +class AddressViewModel @Inject constructor( + private val cache: WizardCache, + private val dadataApi: DadataApi +) : ViewModel() { + + private val _addressSuggestions = MutableStateFlow>(emptyList()) + val addressSuggestions: StateFlow> = _addressSuggestions + + private var searchJob: Job? = null + + fun getAddressSuggestions(query: String) { + searchJob?.cancel() + + val currentQuery = query.trim() + if (currentQuery.length < 3) { + _addressSuggestions.value = emptyList() + return + } + + searchJob = viewModelScope.launch { + try { + delay(500) + + val response = dadataApi.getAddressSuggestions( + DadataRequest(query = currentQuery, count = 10) + ) + + _addressSuggestions.value = response.suggestions.map { it.value } + + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e("DaData", "Request error", e) + _addressSuggestions.value = emptyList() + } + } + } + + fun saveData(address: String) { + cache.address = address + } + + override fun onCleared() { + searchJob?.cancel() + super.onCleared() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/interests/InterestsFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/interests/InterestsFragment.kt new file mode 100644 index 0000000..82d814d --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/interests/InterestsFragment.kt @@ -0,0 +1,63 @@ +package ru.otus.basicarchitecture.ui.interests + +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 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.FragmentInterestsBinding + +@AndroidEntryPoint +class InterestsFragment : Fragment() { + + private var _binding: FragmentInterestsBinding? = null + private val binding get() = _binding!! + + private val viewModel: InterestsViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentInterestsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupChips() + setupNextButton() + } + + private fun setupChips() { + viewModel.interests.forEach { interest -> + val chip = Chip(requireContext()).apply { + text = interest + isCheckable = true + isClickable = true + } + binding.chipGroup.addView(chip) + } + } + + private fun setupNextButton() { + binding.btnNext.setOnClickListener { + val selectedInterests = binding.chipGroup.checkedChipIds.mapNotNull { id -> + binding.chipGroup.findViewById(id)?.text?.toString() + } + + viewModel.savedInterests(selectedInterests) + findNavController().navigate(R.id.action_interestsFragment_to_summaryFragment) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/interests/InterestsViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/interests/InterestsViewModel.kt new file mode 100644 index 0000000..526d583 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/interests/InterestsViewModel.kt @@ -0,0 +1,19 @@ +package ru.otus.basicarchitecture.ui.interests + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import ru.otus.basicarchitecture.WizardCache +import javax.inject.Inject + +@HiltViewModel +class InterestsViewModel @Inject constructor( + private val cache: WizardCache +) : ViewModel() { + + val interests = listOf("Running", "Books", "Clubbing", "Knitting", "Dota 2") + + fun savedInterests(selected: List) { + cache.interests = selected + } +} + diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/personalinfo/DateValidator.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/personalinfo/DateValidator.kt new file mode 100644 index 0000000..588d3d0 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/personalinfo/DateValidator.kt @@ -0,0 +1,16 @@ +package ru.otus.basicarchitecture.ui.personalinfo + +import java.time.LocalDate +import java.time.Period +import java.time.format.DateTimeFormatter + +object DateValidator { + + private val formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy") + + fun isAdult(birthDate: String): Boolean = + runCatching { + val date = LocalDate.parse(birthDate, formatter) + Period.between(date, LocalDate.now()).years >= 18 + }.getOrDefault(false) +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/personalinfo/PersonalInfoFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/personalinfo/PersonalInfoFragment.kt new file mode 100644 index 0000000..cd612d5 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/personalinfo/PersonalInfoFragment.kt @@ -0,0 +1,121 @@ +package ru.otus.basicarchitecture.ui.personalinfo + +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.Toast +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.databinding.FragmentPersonalInfoBinding + +@AndroidEntryPoint +class PersonalInfoFragment : Fragment() { + + private var _binding: FragmentPersonalInfoBinding? = null + private val binding get() = _binding!! + + private val viewModel: PersonalInfoViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentPersonalInfoBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupTextWatchers() + setupNextButton() + observeState() + } + + private fun setupTextWatchers() { + binding.etFirstName.addTextChangedListener(textWatcher(viewModel::onFirstNameChange)) + binding.etLastName.addTextChangedListener(textWatcher(viewModel::onLastNameChange)) + binding.etBirthDate.addTextChangedListener(dateTextWatcher()) + } + + private fun setupNextButton() { + binding.btnNext.setOnClickListener { + val state = viewModel.uiState.value + if (state.isValid) { + viewModel.saveAndProceed() + findNavController().navigate(R.id.action_personalInfoFragment_to_addressFragment) + } else { + state.error?.let { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() } + } + } + } + + private fun observeState() { + viewLifecycleOwner.lifecycleScope.launch { + var previousError: String? = null + + viewModel.uiState.collectLatest { state -> + binding.btnNext.isEnabled = state.isValid + + if (state.birthDate.length == 10 && + state.error != null && + state.error != previousError + ) { + Toast.makeText(context, state.error, Toast.LENGTH_SHORT).show() + } + + previousError = state.error + } + } + } + + private fun textWatcher(onChange: (String) -> Unit) = object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable?) { + onChange(s.toString()) + } + } + + private fun dateTextWatcher() = object : TextWatcher { + private var isUpdating = false + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + if (isUpdating) return + + val input = s.toString().replace(".", "") + if (input.length > 8) return + + val formatted = buildString { + input.forEachIndexed { i, c -> + append(c) + if (i == 1 || i == 3) append(".") + } + } + + isUpdating = true + binding.etBirthDate.setText(formatted) + binding.etBirthDate.setSelection(formatted.length) + isUpdating = false + } + + override fun afterTextChanged(s: Editable?) { + viewModel.onBirthDateChange(s.toString()) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/personalinfo/PersonalInfoViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/personalinfo/PersonalInfoViewModel.kt new file mode 100644 index 0000000..9d733ad --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/personalinfo/PersonalInfoViewModel.kt @@ -0,0 +1,67 @@ +package ru.otus.basicarchitecture.ui.personalinfo + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import ru.otus.basicarchitecture.WizardCache +import javax.inject.Inject + +@HiltViewModel +class PersonalInfoViewModel @Inject constructor( + private val cache: WizardCache +) : ViewModel() { + + private val _uiState = MutableStateFlow(PersonalInfoUiState()) + val uiState: StateFlow = _uiState + + init { + validate() + } + + fun onFirstNameChange(value: String) = update { copy(firstName = value) } + + fun onLastNameChange(value: String) = update { copy(lastName = value) } + + fun onBirthDateChange(value: String) = update { copy(birthDate = value) } + + private fun update(block: PersonalInfoUiState.() -> PersonalInfoUiState) { + _uiState.value = _uiState.value.block() + validate() + } + + private fun validate() { + val state = _uiState.value + + val error = when { + state.firstName.isBlank() -> "Введите имя" + state.lastName.isBlank() -> "Введите фамилию" + state.birthDate.isBlank() -> "Введите дату рождения" + state.birthDate.length < 10 -> null + !DateValidator.isAdult(state.birthDate) -> "Возраст должен быть 18+" + else -> null + } + + val isValid = error == null && (state.birthDate.isBlank() || state.birthDate.length == 10) + + _uiState.value = state.copy( + error = error, + isValid = isValid + ) + } + + fun saveAndProceed() { + val state = _uiState.value + cache.firstName = state.firstName + cache.lastName = state.lastName + cache.birthDate = state.birthDate + } +} + +data class PersonalInfoUiState( + val firstName: String = "", + val lastName: String = "", + val birthDate: String = "", + val error: String? = null, + val isValid: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/summary/SummaryFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/summary/SummaryFragment.kt new file mode 100644 index 0000000..55e3a68 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/summary/SummaryFragment.kt @@ -0,0 +1,45 @@ +package ru.otus.basicarchitecture.ui.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 dagger.hilt.android.AndroidEntryPoint +import ru.otus.basicarchitecture.databinding.FragmentSummaryBinding + +@AndroidEntryPoint +class SummaryFragment : Fragment() { + + private var _binding: FragmentSummaryBinding? = null + private val binding get() = _binding!! + + private val viewModel: SummaryViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSummaryBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val cache = viewModel.getData() + + binding.tvResult.text = buildString { + appendLine("Имя: ${cache.firstName}") + appendLine("Фамилия: ${cache.lastName}") + appendLine("Дата рождения: ${cache.birthDate}") + appendLine("Адрес: ${cache.address}") + append("Интересы: ${cache.interests.joinToString(", ")}") + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/summary/SummaryViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/summary/SummaryViewModel.kt new file mode 100644 index 0000000..0a891b3 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/summary/SummaryViewModel.kt @@ -0,0 +1,14 @@ +package ru.otus.basicarchitecture.ui.summary + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import ru.otus.basicarchitecture.WizardCache +import javax.inject.Inject + +@HiltViewModel +class SummaryViewModel @Inject constructor( + val cache: WizardCache +) : ViewModel() { + fun getData() = cache +} + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 0b15a20..8f9a393 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,9 +1,10 @@ - - - \ No newline at end of file + app:defaultNavHost="true" + app:navGraph="@navigation/nav_graph" /> \ 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..60f9dd1 --- /dev/null +++ b/app/src/main/res/layout/fragment_address.xml @@ -0,0 +1,23 @@ + + + + + +