From 2895cc82022a4f4a74074f89aa6b9e3c29ba2072 Mon Sep 17 00:00:00 2001 From: Anv0l Date: Sun, 25 May 2025 16:52:07 +0300 Subject: [PATCH 1/3] =?UTF-8?q?=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20?= =?UTF-8?q?=D0=94=D0=97=20=E2=84=96=2016,=20=D0=BF=D0=B5=D1=80=D0=B2=D0=BE?= =?UTF-8?q?=D0=B5=20=D0=B7=D0=B0=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle | 30 +++-- app/src/main/AndroidManifest.xml | 1 + .../ru/otus/basicarchitecture/MainActivity.kt | 16 ++- .../basicarchitecture/WizardApplication.kt | 8 ++ .../ru/otus/basicarchitecture/WizardCache.kt | 63 +++++++++++ .../otus/basicarchitecture/data/MockData.kt | 36 ++++++ .../basicarchitecture/helpers/ChipLoader.kt | 26 +++++ .../helpers/DateConverters.kt | 10 ++ .../helpers/NavController.kt | 7 ++ .../ui/address/AddressViewModel.kt | 14 +++ .../ui/address/FragmentAddress.kt | 55 +++++++++ .../ui/summary/FragmentSummary.kt | 67 +++++++++++ .../ui/summary/SummaryViewModel.kt | 13 +++ .../basicarchitecture/ui/togs/FragmentTags.kt | 57 ++++++++++ .../ui/togs/TagsViewModel.kt | 13 +++ .../basicarchitecture/ui/user/FragmentUser.kt | 104 ++++++++++++++++++ .../ui/user/UserViewModel.kt | 44 ++++++++ .../res/color/m3_chip_background_color.xml | 28 +++++ app/src/main/res/layout/activity_main.xml | 8 +- app/src/main/res/layout/fragment_address.xml | 68 ++++++++++++ app/src/main/res/layout/fragment_person.xml | 79 +++++++++++++ app/src/main/res/layout/fragment_summary.xml | 91 +++++++++++++++ app/src/main/res/layout/fragment_tags.xml | 26 +++++ app/src/main/res/navigation/nav_graph.xml | 48 ++++++++ app/src/main/res/values-night/themes.xml | 4 +- app/src/main/res/values/themes.xml | 5 +- build.gradle | 4 +- 27 files changed, 909 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/ru/otus/basicarchitecture/WizardApplication.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/WizardCache.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/data/MockData.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/helpers/ChipLoader.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/helpers/DateConverters.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/helpers/NavController.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/address/FragmentAddress.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/summary/FragmentSummary.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/summary/SummaryViewModel.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/togs/FragmentTags.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/togs/TagsViewModel.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/user/FragmentUser.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/user/UserViewModel.kt create mode 100644 app/src/main/res/color/m3_chip_background_color.xml create mode 100644 app/src/main/res/layout/fragment_address.xml create mode 100644 app/src/main/res/layout/fragment_person.xml create mode 100644 app/src/main/res/layout/fragment_summary.xml create mode 100644 app/src/main/res/layout/fragment_tags.xml create mode 100644 app/src/main/res/navigation/nav_graph.xml diff --git a/app/build.gradle b/app/build.gradle index e515992..e9b55ae 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,9 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + id("androidx.navigation.safeargs.kotlin") version("2.9.0") apply( false) + id("com.google.devtools.ksp") + id("com.google.dagger.hilt.android") } android { @@ -9,7 +12,7 @@ android { defaultConfig { applicationId "ru.otus.basicarchitecture" - minSdk 24 + minSdk 26 targetSdk 35 versionCode 1 versionName "1.0" @@ -30,15 +33,26 @@ android { kotlinOptions { jvmTarget = '17' } + + buildFeatures { + viewBinding = true + } } dependencies { - implementation 'androidx.core:core-ktx:1.15.0' - implementation 'androidx.appcompat:appcompat:1.7.0' - implementation 'com.google.android.material:material:1.12.0' - implementation 'androidx.constraintlayout:constraintlayout:2.2.0' - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + implementation "androidx.core:core-ktx:1.16.0" + implementation "androidx.appcompat:appcompat:1.7.0" + implementation "com.google.android.material:material:1.12.0" + implementation "androidx.constraintlayout:constraintlayout:2.2.1" + + implementation("androidx.navigation:navigation-fragment-ktx:2.9.0" ) + implementation("androidx.navigation:navigation-ui-ktx:2.9.0") + + implementation("com.google.dagger:hilt-android:2.56.2") + ksp("com.google.dagger:hilt-android-compiler:2.56.2") + + testImplementation "junit:junit:4.13.2" + 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..a0d597a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.BasicArchitecture" + android:name=".WizardApplication" tools:targetApi="31"> + + fun setNewUser(newUser: WizardUser) { + user = newUser + } + + fun setNewAddress(newAddress: WizardAddress) { + address = newAddress + } + + fun setHobbies(newHobbies: List) { + hobbies = newHobbies + } + + fun getUser(): WizardUser { + return user + } + + fun getAddress(): WizardAddress { + return address + } + + fun getHobbies(): List { + return hobbies + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/data/MockData.kt b/app/src/main/java/ru/otus/basicarchitecture/data/MockData.kt new file mode 100644 index 0000000..fee6fa3 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/data/MockData.kt @@ -0,0 +1,36 @@ +package ru.otus.basicarchitecture.data + +class MockData { + val hobbies = listOf( + "Reading", + "Writing", + "Painting", + "Drawing", + "Photography", + "Playing musical instruments", + "Singing", + "Dancing", + "Hiking", + "Cycling", + "Running", + "Swimming", + "Yoga", + "Meditation", + "Gardening", + "Cooking", + "Baking", + "Knitting", + "Chess", + "Video gaming", + "Board games", + "Pottery", + "Woodworking", + "Fishing", + "Bird watching", + "Astronomy", + "Traveling", + "Language learning", + "Collecting stamps", + "Geocaching" + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/helpers/ChipLoader.kt b/app/src/main/java/ru/otus/basicarchitecture/helpers/ChipLoader.kt new file mode 100644 index 0000000..5186dcd --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/helpers/ChipLoader.kt @@ -0,0 +1,26 @@ +package ru.otus.basicarchitecture.helpers + +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipGroup +import ru.otus.basicarchitecture.data.MockData + +object ChipLoader { + private val defaultHobbies = MockData().hobbies + + fun loadChipInto( + chipGroup: ChipGroup, tags: List = defaultHobbies, + style: (Chip.() -> Unit) = { + isClickable = true + isCheckable = true + } + ) { + tags.forEach { tag -> + Chip(chipGroup.context).apply { + text = tag + style() + }.also { + chipGroup.addView(it) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/helpers/DateConverters.kt b/app/src/main/java/ru/otus/basicarchitecture/helpers/DateConverters.kt new file mode 100644 index 0000000..1bd18a6 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/helpers/DateConverters.kt @@ -0,0 +1,10 @@ +package ru.otus.basicarchitecture.helpers + +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +fun LocalDate.toText(pattern: String = "dd.MM.yyyy"): Result { + return runCatching { + this.format(DateTimeFormatter.ofPattern(pattern)) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/helpers/NavController.kt b/app/src/main/java/ru/otus/basicarchitecture/helpers/NavController.kt new file mode 100644 index 0000000..4f97fe3 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/helpers/NavController.kt @@ -0,0 +1,7 @@ +package ru.otus.basicarchitecture.helpers + +import androidx.fragment.app.Fragment +import androidx.navigation.NavController +import androidx.navigation.fragment.findNavController + +val Fragment.navController: NavController get() = findNavController() \ 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..4fefce9 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressViewModel.kt @@ -0,0 +1,14 @@ +package ru.otus.basicarchitecture.ui.address + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import ru.otus.basicarchitecture.WizardAddress +import ru.otus.basicarchitecture.WizardCache +import javax.inject.Inject + +@HiltViewModel +class AddressViewModel @Inject constructor(private val wizardCache: WizardCache) : ViewModel() { + fun setAddress(newAddress: WizardAddress) { + wizardCache.setNewAddress(newAddress) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/address/FragmentAddress.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/address/FragmentAddress.kt new file mode 100644 index 0000000..fd989cc --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/address/FragmentAddress.kt @@ -0,0 +1,55 @@ +package ru.otus.basicarchitecture.ui.address + +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.WizardAddress +import ru.otus.basicarchitecture.databinding.FragmentAddressBinding +import ru.otus.basicarchitecture.helpers.navController + +@AndroidEntryPoint +class FragmentAddress : Fragment() { + private lateinit var binding: FragmentAddressBinding + + private val viewModel: AddressViewModel by viewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupBindings() + super.onViewCreated(view, savedInstanceState) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate( + R.layout.fragment_address, + container, + false + ) + binding = FragmentAddressBinding.bind(view) + return binding.root + } + + private fun setupBindings() { + with(binding) { + btnAddressNext.setOnClickListener { + viewModel.setAddress( + WizardAddress( + country = binding.txtCountry.text.toString(), + city = binding.txtCity.text.toString(), + address = binding.txtAddress.text.toString() + ) + ) + navController.navigate(R.id.action_address_to_tags) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/summary/FragmentSummary.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/summary/FragmentSummary.kt new file mode 100644 index 0000000..e1eb463 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/summary/FragmentSummary.kt @@ -0,0 +1,67 @@ +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.R +import ru.otus.basicarchitecture.databinding.FragmentSummaryBinding +import ru.otus.basicarchitecture.helpers.ChipLoader +import ru.otus.basicarchitecture.helpers.toText +import ru.otus.basicarchitecture.toText + +@AndroidEntryPoint +class FragmentSummary : Fragment() { + private lateinit var binding: FragmentSummaryBinding + private val tagsLoader: ChipLoader = ChipLoader + private val viewModel: SummaryViewModel by viewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupBindings() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate( + R.layout.fragment_summary, + container, + false + ) + binding = FragmentSummaryBinding.bind(view) + return binding.root + } + + private fun setupBindings() { + val wizardCache = viewModel.getWizard() + + val user = wizardCache.getUser() + val address = wizardCache.getAddress() + val hobbies = wizardCache.getHobbies() + with(binding) { + txtName.text = user.name + txtSurname.text = user.lastname + user.birthday.toText() + .onSuccess { date -> + txtBirthday.text = date + } + .onFailure { e -> + txtBirthday.text = e.message + } + + txtAddress.text = address.toText() + tagsLoader.loadChipInto(chipHobbiesTagsGroup, hobbies, { + isCheckable = false + isClickable = false + isSelected = true + setChipBackgroundColorResource(R.color.m3_chip_background_color) + }) + } + } +} \ 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..da14c46 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/summary/SummaryViewModel.kt @@ -0,0 +1,13 @@ +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(private val wizardCache: WizardCache) : ViewModel() { + fun getWizard(): WizardCache { + return wizardCache + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/togs/FragmentTags.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/togs/FragmentTags.kt new file mode 100644 index 0000000..68a449f --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/togs/FragmentTags.kt @@ -0,0 +1,57 @@ +package ru.otus.basicarchitecture.ui.togs + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.children +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.FragmentTagsBinding +import ru.otus.basicarchitecture.helpers.ChipLoader +import ru.otus.basicarchitecture.helpers.navController + +@AndroidEntryPoint +class FragmentTags : Fragment() { + private lateinit var binding: FragmentTagsBinding + private val viewModel: TagsViewModel by viewModels() + + private val tagsLoader: ChipLoader = ChipLoader + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupBindings() + + super.onViewCreated(view, savedInstanceState) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate( + R.layout.fragment_tags, + container, + false + ) + binding = FragmentTagsBinding.bind(view) + return binding.root + } + + private fun setupBindings() { + with(binding) { + btnTagsNext.setOnClickListener { + val hobbies = + chipHobbiesTagsGroup.children.toList().filterIsInstance().filter { + it.isChecked + }.map { it.text.toString() } + viewModel.setHobbies(hobbies) + navController.navigate(R.id.action_tags_to_summary) + } + tagsLoader.loadChipInto(chipHobbiesTagsGroup) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/togs/TagsViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/togs/TagsViewModel.kt new file mode 100644 index 0000000..2ad0a33 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/togs/TagsViewModel.kt @@ -0,0 +1,13 @@ +package ru.otus.basicarchitecture.ui.togs + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import ru.otus.basicarchitecture.WizardCache +import javax.inject.Inject + +@HiltViewModel +class TagsViewModel @Inject constructor(private val wizardCache: WizardCache) : ViewModel() { + fun setHobbies(newHobbies: List) { + wizardCache.setHobbies(newHobbies) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/user/FragmentUser.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/user/FragmentUser.kt new file mode 100644 index 0000000..d9a523a --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/user/FragmentUser.kt @@ -0,0 +1,104 @@ +package ru.otus.basicarchitecture.ui.user + +import android.icu.util.Calendar +import android.os.Bundle +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 com.google.android.material.datepicker.MaterialDatePicker +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.databinding.FragmentPersonBinding +import ru.otus.basicarchitecture.helpers.navController +import ru.otus.basicarchitecture.helpers.toText +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +@AndroidEntryPoint +class FragmentUser : Fragment() { + private lateinit var binding: FragmentPersonBinding + private val viewModel: UserViewModel by viewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupBindings() + super.onViewCreated(view, savedInstanceState) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate( + R.layout.fragment_person, + container, + false + ) + binding = FragmentPersonBinding.bind(view) + return binding.root + } + + private fun setupBindings() { + with(binding) { + btnPersonNext.setOnClickListener { + viewModel.setUserData(txtName.text.toString(), txtSurname.text.toString()) + navController.navigate(R.id.action_person_to_address) + } + txtBirthday.setOnClickListener { + showDatePicker() + } + lifecycleScope.launch { + viewModel.isBirthdayValid.collect { isValid -> + btnPersonNext.isEnabled = isValid + txt18Yo.visibility = if (isValid) View.GONE else View.VISIBLE + } + } + } + } + + private fun showDatePicker() { + val calendar = Calendar.getInstance() + val currentBirthday = viewModel.birthday.value ?: LocalDate.now() + calendar.set( + currentBirthday.year, + currentBirthday.monthValue - 1, + currentBirthday.dayOfMonth + ) + val timestamp = calendar.timeInMillis + + val datePicker = MaterialDatePicker.Builder.datePicker() + .setTitleText("Select your birthday") + .setSelection(MaterialDatePicker.todayInUtcMilliseconds()) + .setInputMode(MaterialDatePicker.INPUT_MODE_CALENDAR) + .setSelection(timestamp) + .build() + + datePicker.addOnPositiveButtonClickListener { selectedDate -> + val localDate = Instant.ofEpochMilli(selectedDate) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + + val formattedDateResult = localDate.toText() + formattedDateResult + .onSuccess { formattedDate -> + binding.txtBirthday.setText(formattedDate) + viewModel.setBirthday(localDate) + } + .onFailure { + Toast.makeText( + context, + "Invalid date format for birthday!", + Toast.LENGTH_SHORT + ).show() + } + } + datePicker.show(parentFragmentManager, "DATE_PICKER") + } + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/user/UserViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/user/UserViewModel.kt new file mode 100644 index 0000000..065b24c --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/user/UserViewModel.kt @@ -0,0 +1,44 @@ +package ru.otus.basicarchitecture.ui.user + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import ru.otus.basicarchitecture.WizardCache +import ru.otus.basicarchitecture.WizardUser +import java.time.LocalDate +import java.time.Period +import javax.inject.Inject + +@HiltViewModel +class UserViewModel @Inject constructor(private val wizardCache: WizardCache) : ViewModel() { + private val _birthday = MutableStateFlow(null) + var birthday = _birthday.asStateFlow() + + val isBirthdayValid = _birthday.combine(_birthday) { day, _ -> + Period.between(day ?: LocalDate.now(), LocalDate.now()).years >= 18 + }.stateIn( + initialValue = false, + scope = CoroutineScope(Dispatchers.Default), + started = SharingStarted.WhileSubscribed(5000L) + ) + + fun setUserData(name: String, surname: String) { + wizardCache.setNewUser( + WizardUser( + name = name, + lastname = surname, + birthday = _birthday.value ?: LocalDate.now() + ) + ) + } + + fun setBirthday(newDate: LocalDate) { + _birthday.value = newDate + } +} \ No newline at end of file diff --git a/app/src/main/res/color/m3_chip_background_color.xml b/app/src/main/res/color/m3_chip_background_color.xml new file mode 100644 index 0000000..d81612d --- /dev/null +++ b/app/src/main/res/color/m3_chip_background_color.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 0b15a20..0fee419 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,9 +1,13 @@ - - \ 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..ca0aa61 --- /dev/null +++ b/app/src/main/res/layout/fragment_address.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_person.xml b/app/src/main/res/layout/fragment_person.xml new file mode 100644 index 0000000..a0644a0 --- /dev/null +++ b/app/src/main/res/layout/fragment_person.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_summary.xml b/app/src/main/res/layout/fragment_summary.xml new file mode 100644 index 0000000..9a6df50 --- /dev/null +++ b/app/src/main/res/layout/fragment_summary.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_tags.xml b/app/src/main/res/layout/fragment_tags.xml new file mode 100644 index 0000000..5970ae5 --- /dev/null +++ b/app/src/main/res/layout/fragment_tags.xml @@ -0,0 +1,26 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml new file mode 100644 index 0000000..dd26745 --- /dev/null +++ b/app/src/main/res/navigation/nav_graph.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + \ 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..fc493fe 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,6 +1,6 @@ - + - + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 7b166ff..34e7902 100644 --- a/build.gradle +++ b/build.gradle @@ -2,5 +2,7 @@ plugins { id 'com.android.application' version '8.7.3' apply false id 'com.android.library' version '8.7.3' apply false - id 'org.jetbrains.kotlin.android' version '2.0.21' apply false + id 'org.jetbrains.kotlin.android' version '2.1.10' apply false + id("com.google.dagger.hilt.android") version "2.56.2" apply false + id("com.google.devtools.ksp") version "2.1.10-1.0.30" apply false } \ No newline at end of file From 609781d9e51df6c51d09ad7e3dce7e7c3f0d7cf1 Mon Sep 17 00:00:00 2001 From: Anv0l Date: Tue, 3 Jun 2025 22:47:37 +0300 Subject: [PATCH 2/3] =?UTF-8?q?=D0=92=D0=BD=D1=91=D1=81=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BC=D0=B5=D1=87=D0=B0=D0=BD=D0=B8=D1=8F=D0=BC:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Значения из фрагмента сохраняются в viewmodel. * Условие перехода (доступность кнопки "Далее") опеределяется по состоянию переменной в viewmodel. * При переходе на следующий фрагмент значения из viewmodel сохраняются в wizardcache, а на (вновь) загруженном фрагменте значения подгружаются из wizardcache в viewmodel. * Доработал поля в wizardcache (var -> val, List -> Set, значения по умолчанию). --- .../ru/otus/basicarchitecture/WizardCache.kt | 22 +++++----- .../otus/basicarchitecture/data/MockData.kt | 2 +- .../basicarchitecture/helpers/ChipLoader.kt | 13 +++++- .../ui/address/AddressViewModel.kt | 29 +++++++++++++- .../ui/address/FragmentAddress.kt | 38 ++++++++++++++---- .../basicarchitecture/ui/togs/FragmentTags.kt | 29 ++++++++++---- .../ui/togs/TagsViewModel.kt | 35 +++++++++++++++- .../basicarchitecture/ui/user/FragmentUser.kt | 22 ++++++++-- .../ui/user/UserViewModel.kt | 40 +++++++++++-------- app/src/main/res/layout/fragment_address.xml | 6 +-- 10 files changed, 182 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/ru/otus/basicarchitecture/WizardCache.kt b/app/src/main/java/ru/otus/basicarchitecture/WizardCache.kt index 223ba0b..63f31d4 100644 --- a/app/src/main/java/ru/otus/basicarchitecture/WizardCache.kt +++ b/app/src/main/java/ru/otus/basicarchitecture/WizardCache.kt @@ -8,15 +8,15 @@ import dagger.hilt.android.scopes.ActivityRetainedScoped import java.time.LocalDate data class WizardUser( - var name: String, - var lastname: String, - var birthday: LocalDate + val name: String, + val lastname: String, + val birthday: LocalDate ) data class WizardAddress( - var country: String, - var city: String, - var address: String + val country: String, + val city: String, + val address: String ) fun WizardAddress.toText(): String { @@ -32,10 +32,10 @@ object WizardModule { } class WizardCache { - private lateinit var user: WizardUser - private lateinit var address: WizardAddress + private var user: WizardUser = WizardUser("", "", LocalDate.now()) + private var address: WizardAddress = WizardAddress("", "", "") - private lateinit var hobbies: List + private var hobbies: Set = emptySet() fun setNewUser(newUser: WizardUser) { user = newUser @@ -45,7 +45,7 @@ class WizardCache { address = newAddress } - fun setHobbies(newHobbies: List) { + fun setHobbies(newHobbies: Set) { hobbies = newHobbies } @@ -57,7 +57,7 @@ class WizardCache { return address } - fun getHobbies(): List { + fun getHobbies(): Set { return hobbies } } \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/data/MockData.kt b/app/src/main/java/ru/otus/basicarchitecture/data/MockData.kt index fee6fa3..a22b4ac 100644 --- a/app/src/main/java/ru/otus/basicarchitecture/data/MockData.kt +++ b/app/src/main/java/ru/otus/basicarchitecture/data/MockData.kt @@ -1,7 +1,7 @@ package ru.otus.basicarchitecture.data class MockData { - val hobbies = listOf( + val hobbies = setOf( "Reading", "Writing", "Painting", diff --git a/app/src/main/java/ru/otus/basicarchitecture/helpers/ChipLoader.kt b/app/src/main/java/ru/otus/basicarchitecture/helpers/ChipLoader.kt index 5186dcd..9f7f92c 100644 --- a/app/src/main/java/ru/otus/basicarchitecture/helpers/ChipLoader.kt +++ b/app/src/main/java/ru/otus/basicarchitecture/helpers/ChipLoader.kt @@ -8,16 +8,25 @@ object ChipLoader { private val defaultHobbies = MockData().hobbies fun loadChipInto( - chipGroup: ChipGroup, tags: List = defaultHobbies, + chipGroup: ChipGroup, tags: Set = defaultHobbies, style: (Chip.() -> Unit) = { isClickable = true isCheckable = true - } + }, + onChipClicked: ((Chip) -> Unit)? = null, + checkedChips: Set = emptySet() ) { tags.forEach { tag -> Chip(chipGroup.context).apply { text = tag + isSelected = checkedChips.contains(text) style() + + onChipClicked?.let { listener -> + setOnClickListener { + listener(this) + } + } }.also { chipGroup.addView(it) } 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 index 4fefce9..e040764 100644 --- a/app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressViewModel.kt +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressViewModel.kt @@ -1,14 +1,41 @@ package ru.otus.basicarchitecture.ui.address import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import ru.otus.basicarchitecture.WizardAddress import ru.otus.basicarchitecture.WizardCache import javax.inject.Inject @HiltViewModel class AddressViewModel @Inject constructor(private val wizardCache: WizardCache) : ViewModel() { + private var _address = MutableStateFlow(null) + val address = _address.asStateFlow() + + val nextAvailable = _address.map { value -> + value?.let { + it.address.isNotBlank() && it.city.isNotBlank() && it.country.isNotBlank() + } ?: false + }.stateIn( + scope = viewModelScope, + initialValue = false, + started = SharingStarted.WhileSubscribed(5000) + ) + fun setAddress(newAddress: WizardAddress) { - wizardCache.setNewAddress(newAddress) + _address.value = newAddress + } + + fun initAddress() { + _address.value = wizardCache.getAddress() + } + + fun saveToWizardCache() { + _address.value?.let { wizardCache.setNewAddress(it) } } } \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/address/FragmentAddress.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/address/FragmentAddress.kt index fd989cc..20d6261 100644 --- a/app/src/main/java/ru/otus/basicarchitecture/ui/address/FragmentAddress.kt +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/address/FragmentAddress.kt @@ -4,9 +4,12 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import ru.otus.basicarchitecture.R import ru.otus.basicarchitecture.WizardAddress import ru.otus.basicarchitecture.databinding.FragmentAddressBinding @@ -40,16 +43,37 @@ class FragmentAddress : Fragment() { private fun setupBindings() { with(binding) { btnAddressNext.setOnClickListener { - viewModel.setAddress( - WizardAddress( - country = binding.txtCountry.text.toString(), - city = binding.txtCity.text.toString(), - address = binding.txtAddress.text.toString() - ) - ) + viewModel.saveToWizardCache() navController.navigate(R.id.action_address_to_tags) } + + lifecycleScope.launch { + viewModel.nextAvailable.collect { isAvailable -> + btnAddressNext.isEnabled = isAvailable + } + } + + viewModel.initAddress() + viewModel.address.value.let { + txtAddress.setText(it?.address) + txtCity.setText(it?.city) + txtCountry.setText(it?.address) + } + + txtAddress.doAfterTextChanged { updateAddress() } + txtCountry.doAfterTextChanged { updateAddress() } + txtCountry.doAfterTextChanged { updateAddress() } } } + private fun updateAddress() { + viewModel.setAddress( + WizardAddress( + country = binding.txtCountry.text.toString(), + city = binding.txtCity.text.toString(), + address = binding.txtAddress.text.toString() + ) + ) + } + } \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/togs/FragmentTags.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/togs/FragmentTags.kt index 68a449f..c3d1571 100644 --- a/app/src/main/java/ru/otus/basicarchitecture/ui/togs/FragmentTags.kt +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/togs/FragmentTags.kt @@ -4,11 +4,12 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.view.children import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import com.google.android.material.chip.Chip import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import ru.otus.basicarchitecture.R import ru.otus.basicarchitecture.databinding.FragmentTagsBinding import ru.otus.basicarchitecture.helpers.ChipLoader @@ -43,15 +44,29 @@ class FragmentTags : Fragment() { private fun setupBindings() { with(binding) { + btnTagsNext.setOnClickListener { - val hobbies = - chipHobbiesTagsGroup.children.toList().filterIsInstance().filter { - it.isChecked - }.map { it.text.toString() } - viewModel.setHobbies(hobbies) + viewModel.saveTagsToWizard() navController.navigate(R.id.action_tags_to_summary) } - tagsLoader.loadChipInto(chipHobbiesTagsGroup) + viewModel.initHobbies() + + tagsLoader.loadChipInto( + chipHobbiesTagsGroup, onChipClicked = { chip -> + toggleChip(chip) + }, checkedChips = viewModel.selectedTags.value + ) + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.isTagsEmpty.collect { isEmpty -> + btnTagsNext.isEnabled = !isEmpty + } + } } } + + private fun toggleChip(chip: Chip) { + viewModel.toggleChip(chip.text.toString()) + } + } \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/togs/TagsViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/togs/TagsViewModel.kt index 2ad0a33..0697f0d 100644 --- a/app/src/main/java/ru/otus/basicarchitecture/ui/togs/TagsViewModel.kt +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/togs/TagsViewModel.kt @@ -1,13 +1,44 @@ package ru.otus.basicarchitecture.ui.togs import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import ru.otus.basicarchitecture.WizardCache import javax.inject.Inject @HiltViewModel class TagsViewModel @Inject constructor(private val wizardCache: WizardCache) : ViewModel() { - fun setHobbies(newHobbies: List) { - wizardCache.setHobbies(newHobbies) + + private var _selectedTags = MutableStateFlow>(emptySet()) + val selectedTags = _selectedTags.asStateFlow() + + val isTagsEmpty = _selectedTags.map { tags -> + tags.isEmpty() + }.stateIn( + scope = viewModelScope, + initialValue = false, + started = SharingStarted.WhileSubscribed(5000) + ) + + fun initHobbies() { + _selectedTags.value = wizardCache.getHobbies() + } + + fun saveTagsToWizard() { + _selectedTags.value.let { wizardCache.setHobbies(it) } } + + fun toggleChip(text: String) { + if (_selectedTags.value.contains(text)) + _selectedTags.value -= text + else + _selectedTags.value += text + } + + } \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/user/FragmentUser.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/user/FragmentUser.kt index d9a523a..dc9098d 100644 --- a/app/src/main/java/ru/otus/basicarchitecture/ui/user/FragmentUser.kt +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/user/FragmentUser.kt @@ -6,6 +6,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast +import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope @@ -47,24 +48,35 @@ class FragmentUser : Fragment() { private fun setupBindings() { with(binding) { btnPersonNext.setOnClickListener { - viewModel.setUserData(txtName.text.toString(), txtSurname.text.toString()) + viewModel.saveUserToWizard() navController.navigate(R.id.action_person_to_address) } txtBirthday.setOnClickListener { showDatePicker() } lifecycleScope.launch { - viewModel.isBirthdayValid.collect { isValid -> + viewModel.isUserBirthdayValid.collect { isValid -> btnPersonNext.isEnabled = isValid txt18Yo.visibility = if (isValid) View.GONE else View.VISIBLE } } + + viewModel.initUser() + viewModel.user.value.let { + txtName.setText(it?.name) + txtSurname.setText(it?.lastname) + val birthdayResult = it?.birthday?.toText() + birthdayResult?.onSuccess { date -> txtBirthday.setText(date) } + } + + txtName.doAfterTextChanged { updateUser() } + txtSurname.doAfterTextChanged { updateUser() } } } private fun showDatePicker() { val calendar = Calendar.getInstance() - val currentBirthday = viewModel.birthday.value ?: LocalDate.now() + val currentBirthday = viewModel.user.value?.birthday ?: LocalDate.now() calendar.set( currentBirthday.year, currentBirthday.monthValue - 1, @@ -101,4 +113,8 @@ class FragmentUser : Fragment() { datePicker.show(parentFragmentManager, "DATE_PICKER") } + private fun updateUser() { + viewModel.setUser(binding.txtName.text.toString(), binding.txtSurname.text.toString()) + } + } \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/user/UserViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/user/UserViewModel.kt index 065b24c..6b89fe5 100644 --- a/app/src/main/java/ru/otus/basicarchitecture/ui/user/UserViewModel.kt +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/user/UserViewModel.kt @@ -1,13 +1,12 @@ package ru.otus.basicarchitecture.ui.user import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import ru.otus.basicarchitecture.WizardCache import ru.otus.basicarchitecture.WizardUser @@ -17,28 +16,35 @@ import javax.inject.Inject @HiltViewModel class UserViewModel @Inject constructor(private val wizardCache: WizardCache) : ViewModel() { - private val _birthday = MutableStateFlow(null) - var birthday = _birthday.asStateFlow() + private var _user = MutableStateFlow(null) + val user = _user.asStateFlow() - val isBirthdayValid = _birthday.combine(_birthday) { day, _ -> - Period.between(day ?: LocalDate.now(), LocalDate.now()).years >= 18 + val isUserBirthdayValid = _user.map { user -> + Period.between(user?.birthday ?: LocalDate.now(), LocalDate.now()).years >= 18 }.stateIn( initialValue = false, - scope = CoroutineScope(Dispatchers.Default), + scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000L) ) - fun setUserData(name: String, surname: String) { - wizardCache.setNewUser( - WizardUser( - name = name, - lastname = surname, - birthday = _birthday.value ?: LocalDate.now() - ) + fun setBirthday(newBirthday: LocalDate) { + _user.value = _user.value?.copy(birthday = newBirthday) ?: WizardUser("", "", newBirthday) + } + + fun setUser(userName: String, userSurname: String) { + _user.value = _user.value?.copy(name = userName, lastname = userSurname) ?: WizardUser( + userName, + userSurname, + LocalDate.now() ) } - fun setBirthday(newDate: LocalDate) { - _birthday.value = newDate + fun initUser() { + _user.value = wizardCache.getUser() } + + fun saveUserToWizard() { + _user.value?.let { wizardCache.setNewUser(it) } + } + } \ 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 index ca0aa61..70864da 100644 --- a/app/src/main/res/layout/fragment_address.xml +++ b/app/src/main/res/layout/fragment_address.xml @@ -19,7 +19,7 @@ android:id="@+id/txt_country" android:layout_width="match_parent" android:layout_height="match_parent" - android:text="Country" /> + android:text="" /> @@ -36,7 +36,7 @@ android:id="@+id/txt_city" android:layout_width="match_parent" android:layout_height="match_parent" - android:text="City" /> + android:text="" /> + android:text="" /> Date: Sun, 15 Jun 2025 12:25:12 +0300 Subject: [PATCH 3/3] =?UTF-8?q?=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20=E2=84=962?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle | 23 +++- app/src/main/AndroidManifest.xml | 4 +- .../otus/basicarchitecture/SessionManager.kt | 13 +++ .../basicarchitecture/WizardApplication.kt | 13 +++ .../ru/otus/basicarchitecture/WizardCache.kt | 5 +- .../otus/basicarchitecture/data/MockData.kt | 2 + .../ru/otus/basicarchitecture/network/Api.kt | 30 ++++++ .../network/AuthInterceptor.kt | 20 ++++ .../basicarchitecture/network/Debouncer.kt | 5 + .../network/DebouncerProvider.kt | 24 +++++ .../network/GetSuggestions.kt | 22 ++++ .../basicarchitecture/network/NetService.kt | 15 +++ .../ui/address/AddressAdapter.kt | 31 ++++++ .../ui/address/AddressViewHolder.kt | 19 ++++ .../ui/address/AddressViewModel.kt | 102 +++++++++++++++++- .../ui/address/FragmentAddress.kt | 48 ++++++--- .../ui/address/SuggestAddress.kt | 39 +++++++ app/src/main/res/layout/fragment_address.xml | 34 +++++- .../res/layout/vh_address_suggestions.xml | 29 +++++ build.gradle | 8 +- 20 files changed, 462 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/ru/otus/basicarchitecture/SessionManager.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/network/Api.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/network/AuthInterceptor.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/network/Debouncer.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/network/DebouncerProvider.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/network/GetSuggestions.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/network/NetService.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressAdapter.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressViewHolder.kt create mode 100644 app/src/main/java/ru/otus/basicarchitecture/ui/address/SuggestAddress.kt create mode 100644 app/src/main/res/layout/vh_address_suggestions.xml diff --git a/app/build.gradle b/app/build.gradle index e9b55ae..6926f25 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,9 +1,12 @@ plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' - id("androidx.navigation.safeargs.kotlin") version("2.9.0") apply( false) + id "com.android.application" + id "org.jetbrains.kotlin.android" + id("androidx.navigation.safeargs.kotlin") version("2.9.0") apply(false) id("com.google.devtools.ksp") + id("kotlin-kapt") id("com.google.dagger.hilt.android") + id("kotlinx-serialization") + id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") } android { @@ -36,23 +39,33 @@ android { buildFeatures { viewBinding = true + buildConfig = true } } dependencies { implementation "androidx.core:core-ktx:1.16.0" - implementation "androidx.appcompat:appcompat:1.7.0" + implementation "androidx.appcompat:appcompat:1.7.1" implementation "com.google.android.material:material:1.12.0" implementation "androidx.constraintlayout:constraintlayout:2.2.1" - implementation("androidx.navigation:navigation-fragment-ktx:2.9.0" ) + implementation("androidx.navigation:navigation-fragment-ktx:2.9.0") implementation("androidx.navigation:navigation-ui-ktx:2.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + implementation("com.google.dagger:hilt-android:2.56.2") ksp("com.google.dagger:hilt-android-compiler:2.56.2") testImplementation "junit:junit:4.13.2" androidTestImplementation "androidx.test.ext:junit:1.2.1" androidTestImplementation "androidx.test.espresso:espresso-core:3.6.1" + + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0") + } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a0d597a..bdb26cd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,10 @@ + + +} + +fun buildRetrofit(okHttpClient: OkHttpClient): Retrofit { + val json = Json { ignoreUnknownKeys = true } + return Retrofit.Builder() + .baseUrl(suggestionUrl) + .client(okHttpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/network/AuthInterceptor.kt b/app/src/main/java/ru/otus/basicarchitecture/network/AuthInterceptor.kt new file mode 100644 index 0000000..7aacc98 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/network/AuthInterceptor.kt @@ -0,0 +1,20 @@ +package ru.otus.basicarchitecture.network + +import okhttp3.Interceptor +import okhttp3.Response +import ru.otus.basicarchitecture.SessionManager +import javax.inject.Inject + +class AuthInterceptor @Inject constructor(private val sessionManager: SessionManager) : + Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val requestWithToken = request.newBuilder() + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("Authorization", "Token ${sessionManager.getToken()}") + .build() + + return chain.proceed(requestWithToken) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/network/Debouncer.kt b/app/src/main/java/ru/otus/basicarchitecture/network/Debouncer.kt new file mode 100644 index 0000000..98c18ea --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/network/Debouncer.kt @@ -0,0 +1,5 @@ +package ru.otus.basicarchitecture.network + +interface Debouncer { + suspend fun debounce() +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/network/DebouncerProvider.kt b/app/src/main/java/ru/otus/basicarchitecture/network/DebouncerProvider.kt new file mode 100644 index 0000000..87e92b4 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/network/DebouncerProvider.kt @@ -0,0 +1,24 @@ +package ru.otus.basicarchitecture.network + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import kotlinx.coroutines.delay + +@Module +@InstallIn(ViewModelComponent::class) +object DebouncerProvider { + private const val debouncePeriod = 500L + + class DebouncerImpl : Debouncer { + override suspend fun debounce() { + delay(debouncePeriod) + } + } + + @Provides + fun provideDebouncer(): Debouncer { + return DebouncerImpl() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/network/GetSuggestions.kt b/app/src/main/java/ru/otus/basicarchitecture/network/GetSuggestions.kt new file mode 100644 index 0000000..3efef56 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/network/GetSuggestions.kt @@ -0,0 +1,22 @@ +package ru.otus.basicarchitecture.network + +import okio.IOException +import ru.otus.basicarchitecture.ui.address.SuggestionResponse +import ru.otus.basicarchitecture.ui.address.SuggestQuery +import javax.inject.Inject + +interface GetSuggestions { + + suspend operator fun invoke(searchString: SuggestQuery): SuggestionResponse + + class Impl @Inject constructor(private val api: Api) : + GetSuggestions { + override suspend fun invoke(searchString: SuggestQuery): SuggestionResponse { + val response = api.getSuggestions(searchString) + if (response.isSuccessful) { + return response.body() ?: throw IOException("Empty body $response") + } else + throw IOException("Unexpected code: $response") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/network/NetService.kt b/app/src/main/java/ru/otus/basicarchitecture/network/NetService.kt new file mode 100644 index 0000000..efdd3b0 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/network/NetService.kt @@ -0,0 +1,15 @@ +package ru.otus.basicarchitecture.network + +import ru.otus.basicarchitecture.ui.address.SuggestionResponse +import ru.otus.basicarchitecture.ui.address.SuggestQuery +import javax.inject.Inject + +interface NetService { + suspend fun getSuggestion(searchString: String): SuggestionResponse + + class Impl @Inject constructor(private val suggestion: GetSuggestions) : NetService { + override suspend fun getSuggestion(searchString: String): SuggestionResponse { + return suggestion(SuggestQuery(searchString)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressAdapter.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressAdapter.kt new file mode 100644 index 0000000..4a5d8bd --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressAdapter.kt @@ -0,0 +1,31 @@ +package ru.otus.basicarchitecture.ui.address + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import ru.otus.basicarchitecture.WizardAddress +import ru.otus.basicarchitecture.databinding.VhAddressSuggestionsBinding + +class AddressAdapter(private val onClick: (Int) -> Unit) : + RecyclerView.Adapter() { + private var suggestions: List = emptyList() + + fun setSuggestions(newSuggestions: List) { + suggestions = newSuggestions + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddressViewHolder { + return AddressViewHolder( + VhAddressSuggestionsBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) + } + + override fun getItemCount(): Int = suggestions.size + + override fun onBindViewHolder(holder: AddressViewHolder, position: Int) { + val suggestion = suggestions[position] + holder.bind(suggestion, onClick) + } + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressViewHolder.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressViewHolder.kt new file mode 100644 index 0000000..5a45295 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressViewHolder.kt @@ -0,0 +1,19 @@ +package ru.otus.basicarchitecture.ui.address + +import androidx.recyclerview.widget.RecyclerView +import ru.otus.basicarchitecture.WizardAddress +import ru.otus.basicarchitecture.databinding.VhAddressSuggestionsBinding + +class AddressViewHolder(private val binding: VhAddressSuggestionsBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind( + suggestItem: WizardAddress, + onClick: (Int) -> Unit + ) { + with(binding) { + txtSuggestStreetHouse.text = "${suggestItem.street} ${suggestItem.house}" + txtSuggestRegionCity.text = suggestItem.value + itemSuggestion.setOnClickListener { onClick(adapterPosition) } + } + } +} \ 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 index e040764..64ffe69 100644 --- a/app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressViewModel.kt +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressViewModel.kt @@ -2,21 +2,50 @@ package ru.otus.basicarchitecture.ui.address import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit import ru.otus.basicarchitecture.WizardAddress import ru.otus.basicarchitecture.WizardCache +import ru.otus.basicarchitecture.network.Api +import ru.otus.basicarchitecture.network.AuthInterceptor +import ru.otus.basicarchitecture.network.Debouncer +import ru.otus.basicarchitecture.network.GetSuggestions +import ru.otus.basicarchitecture.network.NetService +import ru.otus.basicarchitecture.network.buildRetrofit +import java.util.concurrent.TimeUnit import javax.inject.Inject @HiltViewModel -class AddressViewModel @Inject constructor(private val wizardCache: WizardCache) : ViewModel() { +class AddressViewModel @Inject constructor( + private val wizardCache: WizardCache, + private val service: NetService, + private val debouncer: Debouncer +) : ViewModel() { private var _address = MutableStateFlow(null) val address = _address.asStateFlow() + private val _addressSuggestions = MutableStateFlow>(emptyList()) + val addressSuggestions = _addressSuggestions.asStateFlow() + + private val _state = MutableStateFlow(State.Ready) + val state = _state.asStateFlow() + val nextAvailable = _address.map { value -> value?.let { it.address.isNotBlank() && it.city.isNotBlank() && it.country.isNotBlank() @@ -27,7 +56,37 @@ class AddressViewModel @Inject constructor(private val wizardCache: WizardCache) started = SharingStarted.WhileSubscribed(5000) ) + private var suggestionJob: Job? = null + + fun getSuggestion(searchString: String) { + suggestionJob?.cancel() + _state.value = State.Ready + + if (searchString.length < 3) return + + suggestionJob = CoroutineScope(Dispatchers.IO).launch { + debouncer.debounce() + _state.value = State.Loading + kotlin.runCatching { + service.getSuggestion(searchString).suggestions.map { it.toWizardAddress() } + }.onSuccess { res -> _addressSuggestions.value = res } + + _state.value = State.Ready + } + } + + fun setStateReady() { + _state.value = State.Ready + } + + fun setStateDataIsSet() { + _state.value = State.DataIsSet + } + + fun setAddress(newAddress: WizardAddress) { + _state.value = State.DataIsSet + _addressSuggestions.value = emptyList() _address.value = newAddress } @@ -38,4 +97,45 @@ class AddressViewModel @Inject constructor(private val wizardCache: WizardCache) fun saveToWizardCache() { _address.value?.let { wizardCache.setNewAddress(it) } } +} + +@Module +@InstallIn(ViewModelComponent::class) +abstract class MainModule { + @Binds + abstract fun netService(impl: NetService.Impl): NetService + + @Binds + abstract fun getSuggestion(impl: GetSuggestions.Impl): GetSuggestions +} + +@Module +@InstallIn(ViewModelComponent::class) +class MainModuleProvider { + @Provides + fun okHttp(authInterceptor: AuthInterceptor): OkHttpClient { + return OkHttpClient.Builder() + .callTimeout(30L, TimeUnit.SECONDS) + .addInterceptor(authInterceptor) + .addInterceptor(HttpLoggingInterceptor().apply { + setLevel(HttpLoggingInterceptor.Level.BASIC) + }) + .build() + } + + @Provides + fun retrofit(okhttp: OkHttpClient): Retrofit { + return buildRetrofit(okhttp) + } + + @Provides + fun api(retrofit: Retrofit): Api { + return retrofit.create(Api::class.java) + } +} + +sealed class State { + data object Loading : State() + data object Ready : State() + data object DataIsSet : State() } \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/address/FragmentAddress.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/address/FragmentAddress.kt index 20d6261..88e182b 100644 --- a/app/src/main/java/ru/otus/basicarchitecture/ui/address/FragmentAddress.kt +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/address/FragmentAddress.kt @@ -11,7 +11,6 @@ import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import ru.otus.basicarchitecture.R -import ru.otus.basicarchitecture.WizardAddress import ru.otus.basicarchitecture.databinding.FragmentAddressBinding import ru.otus.basicarchitecture.helpers.navController @@ -20,6 +19,7 @@ class FragmentAddress : Fragment() { private lateinit var binding: FragmentAddressBinding private val viewModel: AddressViewModel by viewModels() + private lateinit var adapter: AddressAdapter override fun onViewCreated(view: View, savedInstanceState: Bundle?) { setupBindings() @@ -55,25 +55,45 @@ class FragmentAddress : Fragment() { viewModel.initAddress() viewModel.address.value.let { + viewModel.setStateDataIsSet() txtAddress.setText(it?.address) - txtCity.setText(it?.city) - txtCountry.setText(it?.address) } - txtAddress.doAfterTextChanged { updateAddress() } - txtCountry.doAfterTextChanged { updateAddress() } - txtCountry.doAfterTextChanged { updateAddress() } + txtAddress.doAfterTextChanged { + if (viewModel.state.value == State.DataIsSet) { + viewModel.setStateReady() + } else if (viewModel.state.value == State.Ready) + findAddress() + } + + adapter = AddressAdapter { pos -> + val wizardAddress = viewModel.addressSuggestions.value[pos] + viewModel.setAddress(wizardAddress) + txtAddress.setText(wizardAddress.value) + } + lstAddressSuggestions.adapter = adapter + + lifecycleScope.launch { + viewModel.addressSuggestions.collect { suggestions -> + adapter.setSuggestions(suggestions) + } + } + + lifecycleScope.launch { + viewModel.state.collect { state -> + when (state) { + State.Loading -> viewLoading.visibility = View.VISIBLE + else -> viewLoading.visibility = View.GONE + } + } + } } } - private fun updateAddress() { - viewModel.setAddress( - WizardAddress( - country = binding.txtCountry.text.toString(), - city = binding.txtCity.text.toString(), - address = binding.txtAddress.text.toString() - ) - ) + private fun findAddress() { + val searchString = binding.txtAddress.text.toString() + + viewModel.getSuggestion(searchString) } } \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/ui/address/SuggestAddress.kt b/app/src/main/java/ru/otus/basicarchitecture/ui/address/SuggestAddress.kt new file mode 100644 index 0000000..dfbc54e --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/address/SuggestAddress.kt @@ -0,0 +1,39 @@ +package ru.otus.basicarchitecture.ui.address + +import kotlinx.serialization.Serializable +import ru.otus.basicarchitecture.WizardAddress + +@Serializable +data class SuggestionResponse( + val suggestions: List +) + +@Serializable +data class Suggestion( + val value: String, + val data: AddressData +) { + fun toWizardAddress(): WizardAddress { + return WizardAddress( + country = this.data.country ?: "", + city = this.data.city ?: "", + address = this.value, + value = this.value, + house = this.data.house ?: "", + street = this.data.street ?: "" + ) + } +} + +@Serializable +data class AddressData( + val city: String? = "", + val street: String? = "", + val house: String? = "", + val country: String? = "" +) + +@Serializable +data class SuggestQuery( + val query: String +) \ 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 index 70864da..e008718 100644 --- a/app/src/main/res/layout/fragment_address.xml +++ b/app/src/main/res/layout/fragment_address.xml @@ -1,6 +1,7 @@ + android:text="test" /> + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 34e7902..2930327 100644 --- a/build.gradle +++ b/build.gradle @@ -1,3 +1,8 @@ +buildscript { + dependencies { + classpath("com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1") + } +} // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { id 'com.android.application' version '8.7.3' apply false @@ -5,4 +10,5 @@ plugins { id 'org.jetbrains.kotlin.android' version '2.1.10' apply false id("com.google.dagger.hilt.android") version "2.56.2" apply false id("com.google.devtools.ksp") version "2.1.10-1.0.30" apply false -} \ No newline at end of file + id("org.jetbrains.kotlin.plugin.serialization") version("1.9.0") apply false +}