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..8fae908 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/di/NetworkModule.kt @@ -0,0 +1,62 @@ +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 { + + // Базовый URL для API Дадата + private const val DADATA_BASE_URL = "https://suggestions.dadata.ru/" + + @Provides + @Singleton + fun provideDadataAuthInterceptor(): DadataAuthInterceptor { + // Получаем ключи из BuildConfig, которые были сгенерированы из local.properties + return DadataAuthInterceptor( + apiKey = BuildConfig.DADATA_API_KEY, + secretKey = BuildConfig.DADATA_SECRET_KEY + ) + } + + @Provides + @Singleton + fun provideOkHttpClient(authInterceptor: DadataAuthInterceptor): OkHttpClient { + val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + return OkHttpClient.Builder() + .addInterceptor(authInterceptor) // Добавляем интерцептор аутентификации перед логированием + .addInterceptor(loggingInterceptor) + .build() + } + + @Provides + @Singleton + fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { + return Retrofit.Builder() + .baseUrl(DADATA_BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + // API интерфейс для работы с Дадата + @Provides + @Singleton + fun provideDadataApi(retrofit: Retrofit): DadataApi { + return retrofit.create(DadataApi::class.java) + } +} + 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..e72d69e --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/network/dadata/DadataApi.kt @@ -0,0 +1,14 @@ +package ru.otus.basicarchitecture.network.dadata + +import retrofit2.http.Body +import retrofit2.http.POST + +/** + * Интерфейс для работы с API Дадата + * Используется для получения подсказок адресов по введенному тексту + */ +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..59c0d2b --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/network/dadata/DadataAuthInterceptor.kt @@ -0,0 +1,30 @@ +package ru.otus.basicarchitecture.network.dadata + +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Интерцептор для добавления заголовков аутентификации к запросам API Дадата + * Добавляет Authorization и X-Secret заголовки + */ +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..a755ea2 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/network/dadata/DadataRequest.kt @@ -0,0 +1,8 @@ +package ru.otus.basicarchitecture.network.dadata + +// Модель запроса к API Дадата +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..c6f8729 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/network/dadata/DadataResponse.kt @@ -0,0 +1,13 @@ +package ru.otus.basicarchitecture.network.dadata + +// Модель ответа от API Дадата +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..3e16059 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressFragment.kt @@ -0,0 +1,165 @@ +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() + + // Адаптер для отображения подсказок в AutoCompleteTextView + 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?) { + super.onViewCreated(view, savedInstanceState) + + // Адаптер для подсказок с кастомным фильтром + // Фильтр не фильтрует результаты, так как фильтрация уже выполнена на сервере + suggestionsAdapter = object : ArrayAdapter( + requireContext(), + android.R.layout.simple_dropdown_item_1line, + mutableListOf() + ) { + override fun getFilter(): Filter { + val adapter = this + return object : Filter() { + override fun performFiltering(constraint: CharSequence?): FilterResults { + // Возвращаем все элементы без фильтрации + // так как фильтрация уже выполнена на сервере API Дадата + val results = FilterResults() + // Создаем список всех элементов из адаптера + val items = ArrayList() + for (i in 0 until adapter.count) { + adapter.getItem(i)?.let { items.add(it) } + } + results.values = items + results.count = items.size + return results + } + + override fun publishResults(constraint: CharSequence?, results: FilterResults?) { + // Уведомляем об изменении данных + if (results != null && results.count > 0) { + notifyDataSetChanged() + } else { + notifyDataSetInvalidated() + } + } + } + } + } + binding.etAddress.setAdapter(suggestionsAdapter) + // Устанавливаем threshold = 0, чтобы подсказки отображались сразу + binding.etAddress.threshold = 0 + + // Добавляем слушатель выбора элемента, после нажатия подсказка должна скрыться + binding.etAddress.onItemClickListener = AdapterView.OnItemClickListener { _, _, _, _ -> + binding.etAddress.post { + binding.etAddress.dismissDropDown() + } + } + + // Подписка на изменения подсказок из ViewModel + viewLifecycleOwner.lifecycleScope.launch { + viewModel.addressSuggestions.collect { suggestions -> + // Обновляем адаптер + suggestionsAdapter.clear() + if (suggestions.isNotEmpty()) { + suggestionsAdapter.addAll(suggestions) + suggestionsAdapter.notifyDataSetChanged() + + // Используем post для отложенного показа, чтобы избежать конфликтов + binding.etAddress.post { + val hasFocus = binding.etAddress.hasFocus() + val hasText = binding.etAddress.text.isNotEmpty() + val hasSuggestions = suggestionsAdapter.count > 0 + + if (hasFocus && hasText && hasSuggestions) { + // Принудительно показываем dropdown + binding.etAddress.showDropDown() + } else if (!hasSuggestions) { + // Скрываем dropdown если подсказок нет + binding.etAddress.dismissDropDown() + } + } + } else { + suggestionsAdapter.notifyDataSetChanged() + // Скрываем dropdown если подсказок нет + binding.etAddress.dismissDropDown() + } + } + } + + // Обработчик изменения текста в поле ввода + 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) { + // Запрашиваем подсказки при изменении текста + val query = s?.toString() ?: "" + viewModel.getAddressSuggestions(query) + } + + override fun afterTextChanged(s: Editable?) { + // После изменения текста проверяем, нужно ли показать dropdown + if (suggestionsAdapter.count > 0 && binding.etAddress.hasFocus()) { + binding.etAddress.post { + if (binding.etAddress.hasFocus() && suggestionsAdapter.count > 0) { + binding.etAddress.showDropDown() + } + } + } + } + }) + + // Обработчик фокуса для показа подсказок при получении фокуса + binding.etAddress.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus && suggestionsAdapter.count > 0 && binding.etAddress.text.isNotEmpty()) { + binding.etAddress.post { + if (binding.etAddress.hasFocus() && suggestionsAdapter.count > 0) { + 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 + } +} + 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..630ed97 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressViewModel.kt @@ -0,0 +1,84 @@ +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.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 + + // Job для отмены предыдущего запроса при новом вводе + private var searchJob: Job? = null + + /** + * Получение подсказок адресов по введенному тексту + * Использует debounce для уменьшения количества запросов + */ + fun getAddressSuggestions(query: String) { + // Отменяем предыдущий запрос, если он еще выполняется + searchJob?.cancel() + + // Если запрос слишком короткий, очищаем подсказки + if (query.trim().length < 3) { + _addressSuggestions.value = emptyList() + return + } + + // Запускаем новый запрос с задержкой (debounce) + val currentQuery = query.trim() + searchJob = viewModelScope.launch { + try { + delay(500) // Задержка 500мс для debounce + + // Выполняем запрос к API Дадата + val response = dadataApi.getAddressSuggestions( + DadataRequest(query = currentQuery, count = 10) + ) + + // Извлекаем значения адресов из ответа + // Если корутина была отменена, будет выброшена CancellationException + val suggestions = response.suggestions.map { it.value } + _addressSuggestions.value = suggestions + } catch (e: kotlinx.coroutines.CancellationException) { + // Игнорируем отмену при новом вводе + throw e + } catch (e: Exception) { + // В случае ошибки очищаем подсказки + // Если корутина была отменена, CancellationException уже обработана выше + Log.e("DaData", "Ошибка запроса", e) + _addressSuggestions.value = emptyList() + } + } + } + + /** + * Сохранение адреса в кеш + */ + fun saveData(address: String) { + cache.address = address + } + + override fun onCleared() { + super.onCleared() + // Отменяем все корутины при уничтожении ViewModel + searchJob?.cancel() + } +} + 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..5aace3c --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/interests/InterestsFragment.kt @@ -0,0 +1,57 @@ +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?) { + 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_interestsFragment_to_summaryFragment) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + 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..3477c37 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/interests/InterestsViewModel.kt @@ -0,0 +1,23 @@ +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("Спорт", "Музыка", "Кино", "Путешествия", "Игры") + + /** + * Сохранение выбранных интересов в кеш + */ + 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..73877f0 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/personalinfo/DateValidator.kt @@ -0,0 +1,37 @@ +package ru.otus.basicarchitecture.ui.personalinfo + +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale + +/** + * Валидатор для проверки даты рождения и возраста + * Вынесен в отдельный класс для удобства тестирования + */ +object DateValidator { + /** + * Проверяет, является ли пользователь совершеннолетним + */ + 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 + } + } +} + 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..5d9c7c3 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/personalinfo/PersonalInfoFragment.kt @@ -0,0 +1,126 @@ +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?) { + 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_personalInfoFragment_to_addressFragment) + } else { + // Показываем Toast с ошибкой, если она есть + state.error?.let { error -> + Toast.makeText(context, error, Toast.LENGTH_SHORT).show() + } + } + } + + // Подписка на изменения состояния UI + viewLifecycleOwner.lifecycleScope.launch { + var previousError: String? = null + viewModel.uiState.collectLatest { state -> + binding.btnNext.isEnabled = state.isValid + + // Показываем Toast только если ошибка изменилась и дата полностью введена + if (state.birthDate.length == 10 && + state.error != null && + state.error != previousError) { + Toast.makeText(context, state.error, Toast.LENGTH_SHORT).show() + } + previousError = state.error + } + } + } + + /** + * Создает TextWatcher для обработки изменений текста + */ + 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()) + } + } + + /** + * Создает TextWatcher для поля даты рождения + * Автоматически форматирует ввод в формат DD.MM.YYYY + */ + 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 показывается через подписку на uiState в onViewCreated + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + 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..65602ac --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/personalinfo/PersonalInfoViewModel.kt @@ -0,0 +1,91 @@ +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) { + _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 + } + // isValid = true только если нет ошибки И дата либо пустая, либо полная и валидная + val isValid = error == null && (state.birthDate.isBlank() || state.birthDate.length == 10) + _uiState.value = _uiState.value.copy(error = error, isValid = isValid) + } + + /** + * Проверка, является ли пользователь совершеннолетним + */ + fun isAdult(birthDate: String): Boolean { + return DateValidator.isAdult(birthDate) + } + + /** + * Сохранение данных в кеш и переход к следующему экрану + */ + 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 +) + 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..8ee0422 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/summary/SummaryFragment.kt @@ -0,0 +1,46 @@ +package ru.otus.basicarchitecture.ui.summary + +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.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 + } + + @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.address} + Интересы: ${cache.interests.joinToString(", ")} + """.trimIndent() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + 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..ede1b30 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/summary/SummaryViewModel.kt @@ -0,0 +1,17 @@ +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 @@ + + + + + +