From 066dc5fdac541fae70b7411a5ed6c54267b0c8dd Mon Sep 17 00:00:00 2001 From: Dmitriy Bulygin Date: Wed, 3 Dec 2025 17:35:49 +0300 Subject: [PATCH 1/4] =?UTF-8?q?=D0=97=D0=B0=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=201.=20=D0=90=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D1=80=D0=B0=20MVVM=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=BE=D0=B7?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B2=D0=B8=D0=B7=D0=B0=D1=80?= =?UTF-8?q?=D0=B4=D0=B0=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle | 17 ++- app/src/main/AndroidManifest.xml | 1 + .../ru/otus/basicarchitecture/MainActivity.kt | 2 + .../otus/basicarchitecture/MyApplication.kt | 7 ++ .../ru/otus/basicarchitecture/WizardCache.kt | 15 +++ .../ui/first/FirstFragment.kt | 113 ++++++++++++++++++ .../ui/first/FirstViewModel.kt | 86 +++++++++++++ .../ui/fourth/FourthFragment.kt | 48 ++++++++ .../ui/fourth/FourthViewModel.kt | 13 ++ .../ui/second/SecondFragment.kt | 45 +++++++ .../ui/second/SecondViewModel.kt | 18 +++ .../ui/third/ThirdFragment.kt | 56 +++++++++ .../ui/third/ThirdViewModel.kt | 18 +++ app/src/main/res/layout/activity_main.xml | 11 +- app/src/main/res/layout/fragment_first.xml | 39 ++++++ app/src/main/res/layout/fragment_fourth.xml | 14 +++ app/src/main/res/layout/fragment_second.xml | 38 ++++++ app/src/main/res/layout/fragment_third.xml | 25 ++++ app/src/main/res/navigation/nav_graph.xml | 39 ++++++ app/src/main/res/values-ru-rRU/strings.xml | 12 ++ app/src/main/res/values/strings.xml | 9 ++ build.gradle | 8 +- gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 5 +- 24 files changed, 628 insertions(+), 13 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/ui/first/FirstFragment.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/first/FirstViewModel.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/fourth/FourthFragment.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/fourth/FourthViewModel.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/second/SecondFragment.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/second/SecondViewModel.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/third/ThirdFragment.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/third/ThirdViewModel.kt create mode 100644 app/src/main/res/layout/fragment_first.xml create mode 100644 app/src/main/res/layout/fragment_fourth.xml create mode 100644 app/src/main/res/layout/fragment_second.xml create mode 100644 app/src/main/res/layout/fragment_third.xml create mode 100644 app/src/main/res/navigation/nav_graph.xml create mode 100644 app/src/main/res/values-ru-rRU/strings.xml diff --git a/app/build.gradle b/app/build.gradle index e515992..42ef68d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,15 +1,19 @@ 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' } 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" @@ -17,6 +21,10 @@ android { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } + buildFeatures { + viewBinding = true + } + buildTypes { release { minifyEnabled false @@ -38,6 +46,13 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.0' + 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' + implementation 'androidx.navigation:navigation-fragment:2.8.5' + implementation 'androidx.navigation:navigation-ui:2.8.5' + implementation "com.google.dagger:hilt-android:2.57.2" + ksp "com.google.dagger:hilt-compiler:2.57.2" 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..d7641b9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> = emptyList() +} diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/first/FirstFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/first/FirstFragment.kt new file mode 100644 index 0000000..b0606ba --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/first/FirstFragment.kt @@ -0,0 +1,113 @@ +// FirstFragment.kt +package ru.otus.basicarchitecture.ui.first + +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.FragmentFirstBinding + +@AndroidEntryPoint +class FirstFragment : Fragment() { + private var _binding: FragmentFirstBinding? = null + private val binding get() = _binding!! + private val viewModel: FirstViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentFirstBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.etFirstName.addTextChangedListener(textWatcher { viewModel.onFirstNameChange(it) }) + binding.etLastName.addTextChangedListener(textWatcher { viewModel.onLastNameChange(it) }) + binding.etBirthDate.addTextChangedListener(dateTextWatcher()) + + binding.btnNext.setOnClickListener { + val state = viewModel.uiState.value + if (state.isValid) { + viewModel.saveAndProceed() + findNavController().navigate(R.id.action_firstFragment_to_secondFragment) + } else { + // Показываем Toast с ошибкой если она есть + state.error?.let { error -> + Toast.makeText(context, error, Toast.LENGTH_SHORT).show() + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.uiState.collectLatest { state -> + binding.btnNext.isEnabled = state.isValid + } + } + } + + 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 = StringBuilder() + for (i in input.indices) { + formatted.append(input[i]) + when (i) { + 1, 3 -> formatted.append(".") + } + } + + isUpdating = true + binding.etBirthDate.setText(formatted.toString()) + binding.etBirthDate.setSelection(formatted.length) + isUpdating = false + } + + override fun afterTextChanged(s: Editable?) { + val text = s.toString() + viewModel.onBirthDateChange(text) + + // Проверяем полную дату и показываем Toast если есть ошибка + if (text.length == 10) { + val state = viewModel.uiState.value + if (!state.isValid && state.error != null) { + Toast.makeText(context, state.error, Toast.LENGTH_SHORT).show() + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/first/FirstViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/first/FirstViewModel.kt new file mode 100644 index 0000000..d1fb19b --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/first/FirstViewModel.kt @@ -0,0 +1,86 @@ +package ru.otus.basicarchitecture.ui.first + +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 java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import javax.inject.Inject + +@HiltViewModel +class FirstViewModel @Inject constructor( + private val cache: WizardCache +) : ViewModel() { + + private val _uiState = MutableStateFlow(FirstUiState()) + val uiState: StateFlow = _uiState + + fun onFirstNameChange(value: String) { + _uiState.value = _uiState.value.copy(firstName = value) + validate() + } + + fun onLastNameChange(value: String) { + _uiState.value = _uiState.value.copy(lastName = value) + validate() + } + + fun onBirthDateChange(value: String) { + _uiState.value = _uiState.value.copy(birthDate = value) + 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 // Не показываем ошибку если дата еще не введена полностью + !isAdult(state.birthDate) -> "Возраст должен быть 18+" + else -> null + } + _uiState.value = _uiState.value.copy(error = error, isValid = error == null) + } + + fun isAdult(birthDate: String): Boolean { + return try { + // Проверяем что дата полная + if (birthDate.length < 10) return true + + val sdf = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()) + sdf.isLenient = false + val date = sdf.parse(birthDate) ?: return false + + val birthCalendar = Calendar.getInstance().apply { time = date } + val currentCalendar = Calendar.getInstance() + + var age = currentCalendar.get(Calendar.YEAR) - birthCalendar.get(Calendar.YEAR) + if (currentCalendar.get(Calendar.DAY_OF_YEAR) < birthCalendar.get(Calendar.DAY_OF_YEAR)) { + age-- + } + + age >= 18 + } catch (e: Exception) { + false + } + } + + fun saveAndProceed() { + val state = _uiState.value + cache.firstName = state.firstName + cache.lastName = state.lastName + cache.birthDate = state.birthDate + } +} + +data class FirstUiState( + val firstName: String = "", + val lastName: String = "", + val birthDate: String = "", + val error: String? = null, + val isValid: Boolean = false +) diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/fourth/FourthFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/fourth/FourthFragment.kt new file mode 100644 index 0000000..1559e68 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/fourth/FourthFragment.kt @@ -0,0 +1,48 @@ +package ru.otus.basicarchitecture.ui.fourth + +import android.annotation.SuppressLint +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.R +import ru.otus.basicarchitecture.databinding.FragmentFourthBinding + +@AndroidEntryPoint +class FourthFragment : Fragment() { + private var _binding: FragmentFourthBinding? = null + private val binding get() = _binding!! + private val viewModel: FourthViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentFourthBinding.inflate(inflater, container, false) + return binding.root + } + + @SuppressLint("SetTextI18n") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val cache = viewModel.getData() + binding.tvResult.text = """ + Имя: ${cache.firstName} + Фамилия: ${cache.lastName} + Дата рождения: ${cache.birthDate} + Страна: ${cache.country} + Город: ${cache.city} + Адрес: ${cache.address} + Интересы: ${cache.interests.joinToString(", ")} + """.trimIndent() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/fourth/FourthViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/fourth/FourthViewModel.kt new file mode 100644 index 0000000..ed74d63 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/fourth/FourthViewModel.kt @@ -0,0 +1,13 @@ +package ru.otus.basicarchitecture.ui.fourth + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import ru.otus.basicarchitecture.WizardCache +import javax.inject.Inject + +@HiltViewModel +class FourthViewModel @Inject constructor( + val cache: WizardCache +) : ViewModel() { + fun getData() = cache +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/second/SecondFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/second/SecondFragment.kt new file mode 100644 index 0000000..07a9ca9 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/second/SecondFragment.kt @@ -0,0 +1,45 @@ +package ru.otus.basicarchitecture.ui.second + +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 dagger.hilt.android.AndroidEntryPoint +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.databinding.FragmentSecondBinding + +@AndroidEntryPoint +class SecondFragment : Fragment() { + private var _binding: FragmentSecondBinding? = null + private val binding get() = _binding!! + private val viewModel: SecondViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSecondBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.btnNext.setOnClickListener { + val country = binding.etCountry.text.toString() + val city = binding.etCity.text.toString() + val address = binding.etAddress.text.toString() + + viewModel.saveData(country, city, address) + findNavController().navigate(R.id.action_secondFragment_to_thirdFragment) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/second/SecondViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/second/SecondViewModel.kt new file mode 100644 index 0000000..97bd49a --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/second/SecondViewModel.kt @@ -0,0 +1,18 @@ +package ru.otus.basicarchitecture.ui.second + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import ru.otus.basicarchitecture.WizardCache +import javax.inject.Inject + +@HiltViewModel +class SecondViewModel @Inject constructor( + private val cache: WizardCache +) : ViewModel() { + + fun saveData(country: String, city: String, address: String) { + cache.country = country + cache.city = city + cache.address = address + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/third/ThirdFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/third/ThirdFragment.kt new file mode 100644 index 0000000..6224440 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/third/ThirdFragment.kt @@ -0,0 +1,56 @@ +// ThirdFragment.kt +package ru.otus.basicarchitecture.ui.third + +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.FragmentThirdBinding + +@AndroidEntryPoint +class ThirdFragment : Fragment() { + private var _binding: FragmentThirdBinding? = null + private val binding get() = _binding!! + private val viewModel: ThirdViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentThirdBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Создаем чипы для каждого интереса + viewModel.interests.forEach { interest -> + val chip = Chip(requireContext()).apply { + text = interest + isCheckable = true + isClickable = true + } + binding.chipGroup.addView(chip) + } + + binding.btnNext.setOnClickListener { + val selectedInterests = binding.chipGroup.checkedChipIds.mapNotNull { + binding.chipGroup.findViewById(it)?.text?.toString() + } + viewModel.savedInterests(selectedInterests) + findNavController().navigate(R.id.action_thirdFragment_to_fourthFragment) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/third/ThirdViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/third/ThirdViewModel.kt new file mode 100644 index 0000000..a8a5aef --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/third/ThirdViewModel.kt @@ -0,0 +1,18 @@ +package ru.otus.basicarchitecture.ui.third + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import ru.otus.basicarchitecture.WizardCache +import javax.inject.Inject + +@HiltViewModel +class ThirdViewModel @Inject constructor( + private val cache: WizardCache +) : ViewModel() { + + val interests = listOf("Спорт", "Музыка", "Кино", "Путешествия", "Игры") + + fun savedInterests(selected: List) { + cache.interests = selected + } +} \ 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..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_first.xml b/app/src/main/res/layout/fragment_first.xml new file mode 100644 index 0000000..e6a8455 --- /dev/null +++ b/app/src/main/res/layout/fragment_first.xml @@ -0,0 +1,39 @@ + + + + + + + + + +