diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index e515992..0000000 --- a/app/build.gradle +++ /dev/null @@ -1,44 +0,0 @@ -plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' -} - -android { - namespace 'ru.otus.basicarchitecture' - compileSdk 35 - - defaultConfig { - applicationId "ru.otus.basicarchitecture" - minSdk 24 - targetSdk 35 - versionCode 1 - versionName "1.0" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = '17' - } -} - -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' -} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..dfaa2ed --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,61 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt.android) + alias(libs.plugins.androidx.navigation.safeargs) +} + +android { + namespace = "ru.otus.basicarchitecture" + compileSdk = 36 + + defaultConfig { + applicationId = "ru.otus.basicarchitecture" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + viewBinding = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.constraintlayout) + + implementation(libs.androidx.navigation.fragment.ktx) + implementation(libs.androidx.navigation.ui.ktx) + implementation(libs.androidx.legacy.support.v4) + implementation(libs.androidx.lifecycle.livedata.ktx) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.fragment.ktx) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.espresso.core) +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ea17fa5..a10e394 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,9 +11,11 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.BasicArchitecture" - tools:targetApi="31"> + tools:targetApi="31" + android:name=".MvvmApp" + > diff --git a/app/src/main/java/ru/otus/basicarchitecture/MainActivity.kt b/app/src/main/java/ru/otus/basicarchitecture/MainActivity.kt deleted file mode 100644 index 623aba9..0000000 --- a/app/src/main/java/ru/otus/basicarchitecture/MainActivity.kt +++ /dev/null @@ -1,11 +0,0 @@ -package ru.otus.basicarchitecture - -import androidx.appcompat.app.AppCompatActivity -import android.os.Bundle - -class MainActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/MvvmApp.kt b/app/src/main/java/ru/otus/basicarchitecture/MvvmApp.kt new file mode 100644 index 0000000..d81cfd8 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/MvvmApp.kt @@ -0,0 +1,8 @@ +package ru.otus.basicarchitecture + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class MvvmApp : Application() { +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/config/ModuleConfig.kt b/app/src/main/java/ru/otus/basicarchitecture/config/ModuleConfig.kt new file mode 100644 index 0000000..b890528 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/config/ModuleConfig.kt @@ -0,0 +1,45 @@ +package ru.otus.basicarchitecture.config + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped +import dagger.hilt.components.SingletonComponent +import jakarta.inject.Singleton +import ru.otus.basicarchitecture.service.DaDataService +import ru.otus.basicarchitecture.service.InterestsRepository +import ru.otus.basicarchitecture.use_case.AddressSuggestUseCase +import ru.otus.basicarchitecture.use_case.FieldValidationUseCase +import ru.otus.basicarchitecture.view_model.WizardCache + +@Module +@InstallIn(SingletonComponent::class) +abstract class AppModule { + @Binds + @Singleton + abstract fun daDataService(impl: DaDataService.Impl): DaDataService + + @Binds + @Singleton + abstract fun interestsRepository(impl: InterestsRepository.Impl): InterestsRepository +} + +@Module +@InstallIn(ActivityRetainedComponent::class) +abstract class ActivityModule { + @Binds + @ActivityRetainedScoped + abstract fun wizardCache(impl: WizardCache.Impl): WizardCache +} + +@Module +@InstallIn(ViewModelComponent::class) +abstract class ViewModelModule { + @Binds + abstract fun fieldValidationUseCase(impl: FieldValidationUseCase.Impl): FieldValidationUseCase + + @Binds + abstract fun addressSuggestUseCase(impl: AddressSuggestUseCase.Impl): AddressSuggestUseCase +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/model/Address.kt b/app/src/main/java/ru/otus/basicarchitecture/model/Address.kt new file mode 100644 index 0000000..a0bfe58 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/model/Address.kt @@ -0,0 +1,9 @@ +package ru.otus.basicarchitecture.model + +data class Address( + var country: String = "", + var city: String = "", + var street: String = "" +) { + override fun toString() = "$country, $city, $street" +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/model/FieldValueDto.kt b/app/src/main/java/ru/otus/basicarchitecture/model/FieldValueDto.kt new file mode 100644 index 0000000..6e42161 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/model/FieldValueDto.kt @@ -0,0 +1,6 @@ +package ru.otus.basicarchitecture.model + +interface FieldValueDto { + val fValue: T + val isValid: Boolean +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/model/Interest.kt b/app/src/main/java/ru/otus/basicarchitecture/model/Interest.kt new file mode 100644 index 0000000..548d8bb --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/model/Interest.kt @@ -0,0 +1,30 @@ +package ru.otus.basicarchitecture.model + +data class Interest ( + val id: Int, + val title: String, +) + +val interests = setOf( + Interest(1, "IT"), + Interest(2, "Sport"), + Interest(3, "Music"), + Interest(4, "Reading"), + Interest(5, "Travel"), + Interest(6, "Photography"), + Interest(7, "Cooking"), + Interest(8, "Dancing"), + Interest(9, "Art"), + Interest(10, "Gaming"), + Interest(11, "Movies"), + Interest(12, "Fitness"), + Interest(13, "Yoga"), + Interest(14, "Fashion"), + Interest(15, "Programming"), + Interest(16, "Science"), + Interest(17, "Nature"), + Interest(18, "Cars"), + Interest(19, "History"), + Interest(20, "Languages"), + Interest(21, "Writing") +) diff --git a/app/src/main/java/ru/otus/basicarchitecture/model/LongFiledDto.kt b/app/src/main/java/ru/otus/basicarchitecture/model/LongFiledDto.kt new file mode 100644 index 0000000..c0f9392 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/model/LongFiledDto.kt @@ -0,0 +1,3 @@ +package ru.otus.basicarchitecture.model + +class LongFiledDto(override val fValue: Long, override val isValid: Boolean) : FieldValueDto \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/model/StringFiledDto.kt b/app/src/main/java/ru/otus/basicarchitecture/model/StringFiledDto.kt new file mode 100644 index 0000000..dc7be93 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/model/StringFiledDto.kt @@ -0,0 +1,4 @@ +package ru.otus.basicarchitecture.model + +class StringFiledDto(override val fValue: String, override val isValid: Boolean) : + FieldValueDto \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/model/UserData.kt b/app/src/main/java/ru/otus/basicarchitecture/model/UserData.kt new file mode 100644 index 0000000..f1c8c30 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/model/UserData.kt @@ -0,0 +1,9 @@ +package ru.otus.basicarchitecture.model + +data class UserData( + val name: String, + val surname: String, + val birthDate: Long, + val address: Address, + val tags: Set, +) \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/model/ValidationEvent.kt b/app/src/main/java/ru/otus/basicarchitecture/model/ValidationEvent.kt new file mode 100644 index 0000000..a6b0944 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/model/ValidationEvent.kt @@ -0,0 +1,10 @@ +package ru.otus.basicarchitecture.model + +sealed interface ValidationEvent { + object InvalidName : ValidationEvent + object ValidName : ValidationEvent + object InvalidSurname : ValidationEvent + object ValidSurname : ValidationEvent + object InvalidAge : ValidationEvent + object ValidAge : ValidationEvent +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/service/DaDataService.kt b/app/src/main/java/ru/otus/basicarchitecture/service/DaDataService.kt new file mode 100644 index 0000000..a6d779a --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/service/DaDataService.kt @@ -0,0 +1,12 @@ +package ru.otus.basicarchitecture.service + +import jakarta.inject.Inject + + +interface DaDataService { + class Impl @Inject constructor() : DaDataService { + + } + +} + diff --git a/app/src/main/java/ru/otus/basicarchitecture/service/InterestsRepository.kt b/app/src/main/java/ru/otus/basicarchitecture/service/InterestsRepository.kt new file mode 100644 index 0000000..cbc9ae2 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/service/InterestsRepository.kt @@ -0,0 +1,28 @@ +package ru.otus.basicarchitecture.service + +import dagger.hilt.android.scopes.ViewModelScoped +import jakarta.inject.Inject +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import ru.otus.basicarchitecture.model.Interest +import ru.otus.basicarchitecture.model.interests + + +@ViewModelScoped +interface InterestsRepository { + fun getInterests(): Flow> + + class Impl @Inject constructor() : InterestsRepository { + override fun getInterests(): Flow> { + return flow { + delay(NETWORK_DELAY) + emit(interests) + } + } + companion object { + const val NETWORK_DELAY = 1000L + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/use_case/AddressSuggestUseCase.kt b/app/src/main/java/ru/otus/basicarchitecture/use_case/AddressSuggestUseCase.kt new file mode 100644 index 0000000..409c1e1 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/use_case/AddressSuggestUseCase.kt @@ -0,0 +1,14 @@ +package ru.otus.basicarchitecture.use_case + +import dagger.hilt.android.scopes.ViewModelScoped +import jakarta.inject.Inject +import ru.otus.basicarchitecture.service.DaDataService + +@ViewModelScoped +interface AddressSuggestUseCase { + class Impl @Inject constructor( + private val daDataService: DaDataService + ) : AddressSuggestUseCase { + + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/use_case/FieldValidationUseCase.kt b/app/src/main/java/ru/otus/basicarchitecture/use_case/FieldValidationUseCase.kt new file mode 100644 index 0000000..e8f5582 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/use_case/FieldValidationUseCase.kt @@ -0,0 +1,34 @@ +package ru.otus.basicarchitecture.use_case + +import dagger.hilt.android.scopes.ViewModelScoped +import jakarta.inject.Inject +import java.util.regex.Pattern + + +@ViewModelScoped +interface FieldValidationUseCase { + fun isNameInvalid(name: String): Boolean + fun isSurnameInvalid(surname: String): Boolean + fun isAgeInvalid(birthDate: Long?): Boolean + + + class Impl @Inject constructor() : FieldValidationUseCase { + override fun isNameInvalid(name: String) = + name.length > 2 && !pattern.toRegex().matches(name) + + override fun isSurnameInvalid(surname: String) = + surname.length > 2 && !pattern.toRegex().matches(surname) + + override fun isAgeInvalid(birthDate: Long?) = + birthDate == null || birthDate > eighteenYearsAgo + + companion object { + private const val USERNAME_PATTERN = "^[A-ZА-Я]([._-](?![._-])|[a-zа-я]){1,18}[a-zа-я]$" + private val eighteenYearsAgo = System.currentTimeMillis() - (18 * 365.25 * 24 * 60 * 60 * 1000).toLong() + + private val pattern: Pattern = + Pattern.compile(USERNAME_PATTERN, Pattern.CASE_INSENSITIVE) + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/AddressFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/view/AddressFragment.kt new file mode 100644 index 0000000..baa593e --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/AddressFragment.kt @@ -0,0 +1,68 @@ +package ru.otus.basicarchitecture.view + +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.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import ru.otus.basicarchitecture.databinding.FragmentAddressBinding +import ru.otus.basicarchitecture.view_model.AddressFragmentModel + +@AndroidEntryPoint +class AddressFragment : Fragment() { + + + private var binding = FragmentBindingDelegate(this) + + private val viewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View = binding.bind(container, FragmentAddressBinding::inflate) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupTextWatchers() + setupClickListeners() + } + + private fun setupTextWatchers() { + binding.withBinding { + country.onFocusChangeListener = + createValidationOnFocusChangeListener { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.updateAddress(country = it) + } + } + city.onFocusChangeListener = + createValidationOnFocusChangeListener { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.updateAddress( + city = it + ) + } + } + street.onFocusChangeListener = + createValidationOnFocusChangeListener { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.updateAddress( + street = it + ) + } + } + } + } + + private fun setupClickListeners() = + binding.withBinding { toTagsNextButton.setOnClickListener(::toTagsButtonClickListener) } + + private fun toTagsButtonClickListener(view: View) { + findNavController().navigate(AddressFragmentDirections.actionAddressFragmentToTagsFragment()) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/FragmentBindingDelegate.kt b/app/src/main/java/ru/otus/basicarchitecture/view/FragmentBindingDelegate.kt new file mode 100644 index 0000000..bfaf949 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/FragmentBindingDelegate.kt @@ -0,0 +1,49 @@ +package ru.otus.basicarchitecture.view + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.viewbinding.ViewBinding + +/** + * Binds fragment view-binding + */ +class FragmentBindingDelegate(private val fragment: Fragment) { + + private var binding: VB? = null + + /** + * Binds fragment view-binding + * Put inside `onCreateView` + * See: https://developer.android.com/topic/libraries/view-binding#fragments + * @param container View container + * @param inflate Binding inflater + */ + fun bind( + container: ViewGroup?, + inflate: (LayoutInflater, ViewGroup?, Boolean) -> VB + ): View { + fragment.viewLifecycleOwner.lifecycle.addObserver(BindingDestroyer()) + binding = inflate(fragment.layoutInflater, container, false) + return binding!!.root + } + + /** + * Runs [block] with binding + */ + fun withBinding(block: VB.() -> R): R { + return checkNotNull(binding) { "Binding is not initialized" }.block() + } + + /** + * Destroys binding on view destroy + */ + private inner class BindingDestroyer : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + binding = null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/MainActivity.kt b/app/src/main/java/ru/otus/basicarchitecture/view/MainActivity.kt new file mode 100644 index 0000000..e2e75b2 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/MainActivity.kt @@ -0,0 +1,20 @@ +package ru.otus.basicarchitecture.view + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint +import jakarta.inject.Inject +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.view_model.WizardCache + +@AndroidEntryPoint +class MainActivity : AppCompatActivity() { + + @Inject + lateinit var wizardCache: WizardCache + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/NameFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/view/NameFragment.kt new file mode 100644 index 0000000..53f55da --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/NameFragment.kt @@ -0,0 +1,167 @@ +package ru.otus.basicarchitecture.view + +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 androidx.navigation.fragment.findNavController +import com.google.android.material.datepicker.MaterialDatePicker +import com.google.android.material.textfield.TextInputEditText +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.databinding.FragmentNameBinding +import ru.otus.basicarchitecture.model.ValidationEvent +import ru.otus.basicarchitecture.view_model.NameFragmentModel +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import kotlin.time.ExperimentalTime + + +@AndroidEntryPoint +class NameFragment : Fragment() { + + private val binding = FragmentBindingDelegate(this) + + private val viewModel by viewModels() + + + private val dataPickerTitle by lazy { getString(R.string.data_picker_title) } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View = binding.bind(container, FragmentNameBinding::inflate) + + @OptIn(ExperimentalTime::class) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setDataPicker() + setupTextWatchers() + setupClickListeners() + setupObservers() + } + + private fun toAddressButtonClickListener(view: View) { + findNavController().navigate(NameFragmentDirections.actionNameFragmentToAddressFragment()) + } + + private fun setupTextWatchers() { + binding.withBinding { + name.onFocusChangeListener = + createValidationOnFocusChangeListener { viewModel.setName(it) } + surname.onFocusChangeListener = + createValidationOnFocusChangeListener { viewModel.setSurname(it) } + } + } + + private fun setupClickListeners() { + binding.withBinding { toAddressNextButton.setOnClickListener(::toAddressButtonClickListener)} + } + + private fun setupObservers() { + // Наблюдение за событиями валидации + viewLifecycleOwner.lifecycleScope.launch { + viewModel.validationEvent.collect { event -> + when (event) { + ValidationEvent.InvalidName -> doInvalidName() + ValidationEvent.InvalidSurname -> doInvalidSurname() + ValidationEvent.InvalidAge -> doInvalidAge() + + ValidationEvent.ValidAge, + ValidationEvent.ValidName, + ValidationEvent.ValidSurname -> doSomeValid() + + } + } + } + } + + private fun doSomeValid() { + if ( + viewModel.nameFlow.value.isValid + && viewModel.surnameFlow.value.isValid + && viewModel.birthDateFlow.value.isValid + ) + enableNextButton() + } + + private fun enableNextButton() { + binding.withBinding { + toAddressNextButton.isEnabled = true + toAddressNextButton.isClickable = true + } + } + + private fun disableNextButton() { + binding.withBinding { + toAddressNextButton.isEnabled = false + toAddressNextButton.isClickable = false + } + } + + private fun doInvalidName() { + disableNextButton() + Toast.makeText(context, getString(R.string.invalid_name), Toast.LENGTH_SHORT).show() + } + + private fun doInvalidSurname() { + disableNextButton() + Toast.makeText(context, getString(R.string.invalid_surname), Toast.LENGTH_SHORT).show() + } + + private fun doInvalidAge() { + disableNextButton() + Toast.makeText(context, getString(R.string.invalid_age), Toast.LENGTH_SHORT).show() + } + + private fun setDataPicker() { + binding.withBinding { + val datePickerBuilder = MaterialDatePicker + .Builder + .datePicker() + .setTitleText(dataPickerTitle) + val textData = birthday.text.toString() + if (textData.isNotEmpty()) { + runCatching { + parseTextData(textData)?.let { datePickerBuilder.setSelection(it.time) } + }.getOrElse { it.printStackTrace() } + } + val datePicker = datePickerBuilder.build() + birthday.setOnFocusChangeListener { v, hasFocus -> + if (hasFocus) { + datePicker.addOnPositiveButtonClickListener { selection: Long -> + birthday.setText(dateFormat.format(Date(selection))) + } + datePicker.addOnNegativeButtonClickListener { + birthday.clearFocus() + } + datePicker.addOnDismissListener { + birthday.clearFocus() + } + datePicker.show(childFragmentManager, "birthday_date") + + } else { + (v as? TextInputEditText) + ?.text?.toString() + ?.let { viewModel.setBirthDate(parseTextData(it)?.time ?: 0) } + ?: viewModel.setBirthDate(0) + + } + } + } + } + + private fun parseTextData(textData: String): Date? = runCatching { + dateFormat.parse(textData) + }.getOrNull() + + +} + diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/ResultFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/view/ResultFragment.kt new file mode 100644 index 0000000..2963d52 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/ResultFragment.kt @@ -0,0 +1,61 @@ +package ru.otus.basicarchitecture.view + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.DividerItemDecoration +import dagger.hilt.android.AndroidEntryPoint +import jakarta.inject.Inject +import ru.otus.basicarchitecture.databinding.FragmentResultBinding +import ru.otus.basicarchitecture.model.Interest +import ru.otus.basicarchitecture.view_model.WizardCache +import java.util.Date + + +@AndroidEntryPoint +class ResultFragment : Fragment(), ItemListener { + + @Inject + lateinit var dataCache: WizardCache + + private val binding = FragmentBindingDelegate(this) + + private val tagAdapter: TagAdapter by lazy { TagAdapter(this) } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View = binding.bind(container, FragmentResultBinding::inflate) + + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupRecyclerView() + tagAdapter.submitList(dataCache.userData.tags.map { TagItem(it) }) + binding.withBinding { + val u = dataCache.userData + nameResult.text = u.name + surnameResult.text = u.name + birthdayResult.text = dateFormat.format(Date(u.birthDate)) + addressResult.text = u.address.toString() + } + } + + private fun setupRecyclerView() = binding.withBinding { + groupTagsResult.addItemDecoration( + DividerItemDecoration( + this@ResultFragment.requireActivity(), + LinearLayout.VERTICAL + ) + ) + groupTagsResult.adapter = tagAdapter + } + + override fun onItemClick(interest: Interest, isSelected: Boolean) { + // do nothing + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/TagAdapter.kt b/app/src/main/java/ru/otus/basicarchitecture/view/TagAdapter.kt new file mode 100644 index 0000000..a498bba --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/TagAdapter.kt @@ -0,0 +1,59 @@ +package ru.otus.basicarchitecture.view + + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.databinding.VhTagBinding + + +class TagAdapter( + private val itemListener: ItemListener, +) : ListAdapter(TagDiffCallback) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ) = + TagViewHolder( + binding = VhTagBinding.inflate(LayoutInflater.from(parent.context), parent, false), + itemListener = itemListener + ) + + override fun onBindViewHolder(holder: TagViewHolder, position: Int) = + holder.bind(getItem(position)) + + class TagViewHolder( + private val binding: VhTagBinding, + private val itemListener: ItemListener, + ) : RecyclerView.ViewHolder(binding.root) { + + private val tag: TextView = binding.root + + fun bind(tagItem: TagItem) { + with(tagItem) { + tag.text = name + } + binding.root.setOnClickListener { + tagItem.isSelected = !tagItem.isSelected + if (tagItem.isSelected) tag.setBackgroundColor(R.color.teal_200) + else tag.setBackgroundColor(R.color.white) + itemListener.onItemClick(tagItem.interest, tagItem.isSelected) + } + } + } + +} + +object TagDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: TagItem, newItem: TagItem): Boolean { + return oldItem.interest.id == newItem.interest.id + } + + override fun areContentsTheSame(oldItem: TagItem, newItem: TagItem): Boolean { + return oldItem.interest.title == newItem.interest.title + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/TagItem.kt b/app/src/main/java/ru/otus/basicarchitecture/view/TagItem.kt new file mode 100644 index 0000000..f49aec3 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/TagItem.kt @@ -0,0 +1,17 @@ +package ru.otus.basicarchitecture.view + +import androidx.annotation.LayoutRes +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.model.Interest + +data class TagItem( + val interest: Interest, + var isSelected: Boolean = false +) : WithLayoutId by TagItem { + companion object : WithLayoutId { + @get:LayoutRes + override val layoutId: Int = R.layout.vh_tag + } + + val name: String get() = interest.title +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/TagsFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/view/TagsFragment.kt new file mode 100644 index 0000000..f666818 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/TagsFragment.kt @@ -0,0 +1,81 @@ +package ru.otus.basicarchitecture.view + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.DividerItemDecoration +import dagger.hilt.android.AndroidEntryPoint +import jakarta.inject.Inject +import kotlinx.coroutines.launch +import ru.otus.basicarchitecture.databinding.FragmentTagsBinding +import ru.otus.basicarchitecture.model.Interest +import ru.otus.basicarchitecture.view_model.TagsViewModel +import ru.otus.basicarchitecture.view_model.WizardCache + +@AndroidEntryPoint +class TagsFragment : Fragment(), ItemListener { + + + private val binding = FragmentBindingDelegate(this) + + private val viewModel: TagsViewModel by viewModels() + + private val tagAdapter: TagAdapter by lazy { TagAdapter(this) } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View = binding.bind(container, FragmentTagsBinding::inflate) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupRecyclerView() + collectToTagsFlow() + setupClickListeners() + } + + private fun collectToTagsFlow() { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.tagsFlow + .flowWithLifecycle(viewLifecycleOwner.lifecycle) + .collect(::submitToList) + } + } + + private fun submitToList(interestList: Set) { + tagAdapter.submitList(interestList.map { TagItem(it) }) + } + + private fun setupRecyclerView() = binding.withBinding { + groupTags.addItemDecoration( + DividerItemDecoration( + this@TagsFragment.requireActivity(), + LinearLayout.VERTICAL + ) + ) + groupTags.adapter = tagAdapter + } + + override fun onItemClick(interest: Interest, isSelected: Boolean) { + viewModel.onTagSelected(interest, isSelected) + } + + private fun setupClickListeners() = + binding.withBinding { toResultNextButton.setOnClickListener(::toTagsButtonClickListener) } + + private fun toTagsButtonClickListener(view: View) { + findNavController().navigate(TagsFragmentDirections.actionTagsFragmentToResultFragment()) + } + +} + +interface ItemListener { + fun onItemClick(interest: Interest, isSelected: Boolean) +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/Utils.kt b/app/src/main/java/ru/otus/basicarchitecture/view/Utils.kt new file mode 100644 index 0000000..2a8497c --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/Utils.kt @@ -0,0 +1,23 @@ +package ru.otus.basicarchitecture.view + +import android.view.View +import com.google.android.material.textfield.TextInputEditText +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + + +fun createValidationOnFocusChangeListener( + validate: (String) -> Unit +): View.OnFocusChangeListener { + return View.OnFocusChangeListener { view, hasFocus -> + val text = (view as? TextInputEditText)?.text?.toString() ?: "" + if (!hasFocus) { + validate(text) + } + } +} + +val dateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).apply { + timeZone = TimeZone.getTimeZone("UTC") +} diff --git a/app/src/main/java/ru/otus/basicarchitecture/view/WithLayoutId.kt b/app/src/main/java/ru/otus/basicarchitecture/view/WithLayoutId.kt new file mode 100644 index 0000000..2c6bc7e --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view/WithLayoutId.kt @@ -0,0 +1,11 @@ +package ru.otus.basicarchitecture.view + +import androidx.annotation.LayoutRes + +/** + * Represents an object that has a layout ID. + */ +interface WithLayoutId { + @get:LayoutRes + val layoutId: Int +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view_model/AddressFragmentModel.kt b/app/src/main/java/ru/otus/basicarchitecture/view_model/AddressFragmentModel.kt new file mode 100644 index 0000000..7156f1c --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view_model/AddressFragmentModel.kt @@ -0,0 +1,40 @@ +package ru.otus.basicarchitecture.view_model + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import jakarta.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import ru.otus.basicarchitecture.model.Address +import ru.otus.basicarchitecture.use_case.AddressSuggestUseCase + +@HiltViewModel +class AddressFragmentModel @Inject constructor( + private val addressSuggestUseCase: AddressSuggestUseCase, + private val dataCache: WizardCache +) : ViewModel() { + + private val _addressFlow = MutableStateFlow(Address()) + val addressFlow = _addressFlow.asStateFlow() + + init { + addressFlow.onEach { + dataCache.address = it + }.launchIn(viewModelScope) + } + + suspend fun updateAddress( + country: String = addressFlow.value.country, + city: String = addressFlow.value.city, + street: String = addressFlow.value.street + ) { + val newAddress = Address(country, city, street) + _addressFlow.emit(newAddress) + Log.i("ADDRESS_FLOW", "Emitted new address: $newAddress") + } + +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view_model/NameFragmentModel.kt b/app/src/main/java/ru/otus/basicarchitecture/view_model/NameFragmentModel.kt new file mode 100644 index 0000000..cef563e --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view_model/NameFragmentModel.kt @@ -0,0 +1,96 @@ +package ru.otus.basicarchitecture.view_model + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import jakarta.inject.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import ru.otus.basicarchitecture.model.LongFiledDto +import ru.otus.basicarchitecture.model.StringFiledDto +import ru.otus.basicarchitecture.model.ValidationEvent +import ru.otus.basicarchitecture.use_case.FieldValidationUseCase + +@HiltViewModel +class NameFragmentModel @Inject constructor( + private val fieldValidationUseCase: FieldValidationUseCase, + private val dataCache: WizardCache +) : ViewModel() { + + private val _nameFlow = MutableStateFlow(StringFiledDto("", false)) + val nameFlow: StateFlow = _nameFlow.asStateFlow() + + private val _surnameFlow = MutableStateFlow(StringFiledDto("", false)) + val surnameFlow: StateFlow = _surnameFlow.asStateFlow() + + private val _birthDateFlow = MutableStateFlow(LongFiledDto(0, false)) + val birthDateFlow: StateFlow = _birthDateFlow.asStateFlow() + + private val _validationEvent = MutableSharedFlow() + val validationEvent: SharedFlow = _validationEvent + + init { + nameFlow.onEach { + if (it.isValid) { + dataCache.name = it.fValue + Log.i("CACHE", "Name: ${it.fValue}") + } + }.launchIn(viewModelScope) + + surnameFlow.onEach { surname -> + if (surname.isValid) { + dataCache.surname = surname.fValue + Log.i("CACHE", "Surname: $surname") + } + }.launchIn(viewModelScope) + + birthDateFlow.onEach { birthDate -> + if (birthDate.isValid) { + dataCache.birthDate = birthDate.fValue + Log.i("CACHE", "BirthDate: $birthDate") + } + }.launchIn(viewModelScope) + } + + + fun setName(name: String) { + if (fieldValidationUseCase.isNameInvalid(name)) { + _nameFlow.value = StringFiledDto(name, false) + sendEvent(ValidationEvent.InvalidName) + } else { + sendEvent(ValidationEvent.ValidName) + _nameFlow.value = StringFiledDto(name, true) + } + } + + fun setSurname(surname: String) { + if (fieldValidationUseCase.isSurnameInvalid(surname)) { + _surnameFlow.value = StringFiledDto(surname, false) + sendEvent(ValidationEvent.InvalidSurname) + } else { + _surnameFlow.value = StringFiledDto(surname, true) + sendEvent(ValidationEvent.ValidSurname) + } + } + + fun setBirthDate(date: Long) { + if (fieldValidationUseCase.isAgeInvalid(date)) { + _birthDateFlow.value = LongFiledDto(date, false) + sendEvent(ValidationEvent.InvalidAge) + } else { + _birthDateFlow.value = LongFiledDto(date, true) + sendEvent(ValidationEvent.ValidAge) + } + } + + private fun sendEvent(event: ValidationEvent) = viewModelScope.launch { + _validationEvent.emit(event) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view_model/TagsViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/view_model/TagsViewModel.kt new file mode 100644 index 0000000..48dde04 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view_model/TagsViewModel.kt @@ -0,0 +1,41 @@ +package ru.otus.basicarchitecture.view_model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import jakarta.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import ru.otus.basicarchitecture.model.Interest +import ru.otus.basicarchitecture.service.InterestsRepository + +@HiltViewModel +class TagsViewModel @Inject constructor( + private val interestsRepository: InterestsRepository, + private val dataCache: WizardCache +) : ViewModel() { + + private val _selectedTagsFlow = MutableStateFlow>(mutableSetOf()) + val selectedTagsFlow = _selectedTagsFlow.asStateFlow() + + val tagsFlow: Flow> = interestsRepository.getInterests() + + init { + selectedTagsFlow.onEach { + dataCache.tags = it + }.launchIn(viewModelScope) + } + + fun onTagSelected(id: Interest, isSelected: Boolean) { + val currentSet = _selectedTagsFlow.value + if (isSelected) { + currentSet.add(id) + } else { + currentSet.remove(id) + } + _selectedTagsFlow.value = currentSet + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/view_model/WizardCache.kt b/app/src/main/java/ru/otus/basicarchitecture/view_model/WizardCache.kt new file mode 100644 index 0000000..b2151c7 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/view_model/WizardCache.kt @@ -0,0 +1,51 @@ +package ru.otus.basicarchitecture.view_model + +import dagger.hilt.android.scopes.ActivityScoped +import jakarta.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import ru.otus.basicarchitecture.model.Address +import ru.otus.basicarchitecture.model.Interest +import ru.otus.basicarchitecture.model.UserData + + +@ActivityScoped +interface WizardCache : AutoCloseable { + + val userData: UserData + + var name: String + var surname: String + var birthDate: Long + var address: Address + var tags: Set + + fun printCache() = println(userData) + + class Impl @Inject constructor() : WizardCache { + + override var name: String = "" + override var surname: String = "" + override var birthDate: Long = 0 + override var address: Address = Address() + + override var tags: Set = emptySet() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + override val userData: UserData + get() = UserData( + name, + surname, + birthDate, + address, + tags + ) + + override fun close() { + scope.cancel() + } + } +} diff --git a/app/src/main/res/drawable/shape_tag_bg.xml b/app/src/main/res/drawable/shape_tag_bg.xml new file mode 100644 index 0000000..17e59bf --- /dev/null +++ b/app/src/main/res/drawable/shape_tag_bg.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 0b15a20..e303472 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -4,6 +4,15 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".MainActivity"> + tools:context=".view.MainActivity"> + + + \ 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..83d3e6d --- /dev/null +++ b/app/src/main/res/layout/fragment_address.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + +