diff --git a/app/build.gradle b/app/build.gradle index 9c99d98..417a10a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,16 +1,18 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + id 'kotlin-kapt' + id 'com.google.dagger.hilt.android' } android { namespace 'ru.otus.basicarchitecture' - compileSdk 33 + compileSdk 34 defaultConfig { applicationId "ru.otus.basicarchitecture" minSdk 24 - targetSdk 33 + targetSdk 34 versionCode 1 versionName "1.0" @@ -24,21 +26,43 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = "17" } + viewBinding { + enabled = true + } +} + +kapt { + correctErrorTypes true } dependencies { - implementation 'androidx.core:core-ktx:1.8.0' - implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'com.google.android.material:material:1.9.0' + implementation 'androidx.core:core-ktx:1.13.1' + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.annotation:annotation:1.8.2' + // ViewModel + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4" + + implementation "com.squareup.retrofit2:retrofit:2.11.0" + implementation "com.squareup.retrofit2:converter-gson:2.11.0" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1" + + implementation 'androidx.legacy:legacy-support-v4:1.0.0' + implementation 'androidx.fragment:fragment-ktx:1.8.2' testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + + //Hilt + implementation "com.google.dagger:hilt-android:2.52" + kapt "com.google.dagger:hilt-compiler:2.52" } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1e81fea..320b989 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,10 @@ + + + android:name=".presentation.MainActivity" + android:exported="true" > + + + + + + + \ No newline at end of file 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/app/MyApplication.kt b/app/src/main/java/ru/otus/basicarchitecture/app/MyApplication.kt new file mode 100644 index 0000000..f35e312 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/app/MyApplication.kt @@ -0,0 +1,7 @@ +package ru.otus.basicarchitecture.app + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class MyApplication : Application() {} diff --git a/app/src/main/java/ru/otus/basicarchitecture/network/DaDataApi.kt b/app/src/main/java/ru/otus/basicarchitecture/network/DaDataApi.kt new file mode 100644 index 0000000..602482b --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/network/DaDataApi.kt @@ -0,0 +1,20 @@ +package ru.otus.basicarchitecture.network + +import retrofit2.http.Body +import retrofit2.http.Headers +import retrofit2.http.POST + +interface DaDataApi { + @Headers( + "Content-Type: application/json", + "Authorization: Token 1f17bb43e8c5a22a491f517c8cf95a70704bb456" + ) + @POST("suggest/address") + suspend fun suggestAddress(@Body request: SuggestRequest): SuggestResponse +} + +data class SuggestRequest(val query: String) + +data class SuggestResponse(val suggestions: List) + +data class Suggestion(val value: String) \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/network/RetrofitClient.kt b/app/src/main/java/ru/otus/basicarchitecture/network/RetrofitClient.kt new file mode 100644 index 0000000..cb4a44f --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/network/RetrofitClient.kt @@ -0,0 +1,17 @@ +package ru.otus.basicarchitecture.network + +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +object RetrofitClient { + private const val BASE_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/" + + val instance: DaDataApi by lazy { + val retrofit = Retrofit.Builder() + .baseUrl(BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + retrofit.create(DaDataApi::class.java) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/presentation/MainActivity.kt b/app/src/main/java/ru/otus/basicarchitecture/presentation/MainActivity.kt new file mode 100644 index 0000000..3b566b9 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/presentation/MainActivity.kt @@ -0,0 +1,21 @@ +package ru.otus.basicarchitecture.presentation + +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import dagger.hilt.android.AndroidEntryPoint +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.presentation.personalInfoFragment.PersonalInfoFragment + +@AndroidEntryPoint +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + if (savedInstanceState == null) { + supportFragmentManager.beginTransaction() + .replace(R.id.fragment_container, PersonalInfoFragment()) + .commit() + } + } +} diff --git a/app/src/main/java/ru/otus/basicarchitecture/presentation/addressFragment/AddressFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/presentation/addressFragment/AddressFragment.kt new file mode 100644 index 0000000..6321472 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/presentation/addressFragment/AddressFragment.kt @@ -0,0 +1,68 @@ +package ru.otus.basicarchitecture.presentation.addressFragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import androidx.core.widget.addTextChangedListener +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.FragmentAddressBinding +import ru.otus.basicarchitecture.presentation.interestsFragment.InterestsFragment + +@AndroidEntryPoint +class AddressFragment : Fragment() { + + private var _binding: FragmentAddressBinding? = null + private val binding get() = _binding!! + + private val viewModel: AddressViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAddressBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, emptyList()) + binding.autoCompleteTextViewAddress.setAdapter(adapter) + + binding.autoCompleteTextViewAddress.addTextChangedListener { text -> + val query = text.toString() + if (query.length < 3) { + viewModel.fetchAddressSuggestions(query) { suggestions -> + adapter.clear() + adapter.addAll(suggestions) + adapter.notifyDataSetChanged() + } + } + } + + binding.buttonNext.setOnClickListener { + viewModel.address = binding.autoCompleteTextViewAddress.text.toString() + if (viewModel.saveData()) { + navigateToNextFragment() + } + } + } + + private fun navigateToNextFragment() { + parentFragmentManager.beginTransaction() + .replace(R.id.fragment_container, InterestsFragment()) + .addToBackStack(null) + .commit() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/presentation/addressFragment/AddressViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/presentation/addressFragment/AddressViewModel.kt new file mode 100644 index 0000000..25820dc --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/presentation/addressFragment/AddressViewModel.kt @@ -0,0 +1,50 @@ +package ru.otus.basicarchitecture.presentation.addressFragment + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.* +import retrofit2.HttpException +import ru.otus.basicarchitecture.network.RetrofitClient +import ru.otus.basicarchitecture.network.SuggestRequest +import ru.otus.basicarchitecture.repository.WizardCache +import javax.inject.Inject + +@HiltViewModel +class AddressViewModel @Inject constructor( + private val wizardCache: WizardCache, +) : ViewModel() { + + // Получаем экземпляр DaDataApi через RetrofitClient + private val daDataApi = RetrofitClient.instance + + private val coroutineScope = CoroutineScope(Dispatchers.Main + Job()) + + var address: String? = null + var suggestions: List = emptyList() + + fun fetchAddressSuggestions(query: String, onResult: (List) -> Unit) { + coroutineScope.launch { + try { + val response = withContext(Dispatchers.IO) { + daDataApi.suggestAddress(SuggestRequest(query)) + } + suggestions = response.suggestions.map { it.value } + onResult(suggestions) + } catch (e: HttpException) { + onResult(emptyList()) + } catch (e: Exception) { + onResult(emptyList()) + } + } + } + + fun saveData(): Boolean { + wizardCache.address = address + return true + } + + override fun onCleared() { + super.onCleared() + coroutineScope.cancel() // Отменяем все запущенные корутины при уничтожении ViewModel + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/presentation/interestsFragment/InterestsFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/presentation/interestsFragment/InterestsFragment.kt new file mode 100644 index 0000000..79aeefd --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/presentation/interestsFragment/InterestsFragment.kt @@ -0,0 +1,75 @@ +package ru.otus.basicarchitecture.presentation.interestsFragment + +import androidx.fragment.app.viewModels +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.google.android.material.chip.Chip +import dagger.hilt.android.AndroidEntryPoint +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.databinding.FragmentInterestsBinding +import ru.otus.basicarchitecture.presentation.summaryFragment.SummaryFragment + +@AndroidEntryPoint +class InterestsFragment : Fragment() { + + private var _binding: FragmentInterestsBinding? = null + private val binding get() = _binding!! + + private val viewModel: InterestsViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentInterestsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupTagCloud() + setupListeners() + } + + private fun setupTagCloud() { + val interests = viewModel.getInterests() + interests.forEach { interest -> + val chip = Chip(requireContext()).apply { + text = interest + isCheckable = true + isChecked = viewModel.selectedInterests.contains(interest) + setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + viewModel.selectedInterests.add(interest) + } else { + viewModel.selectedInterests.remove(interest) + } + } + } + binding.chipGroupInterests.addView(chip) + } + } + + private fun setupListeners() { + binding.buttonNext.setOnClickListener { + if (viewModel.saveData()) { + navigateToNextFragment() + } + } + } + + private fun navigateToNextFragment() { + parentFragmentManager.beginTransaction() + .replace(R.id.fragment_container, SummaryFragment()) + .addToBackStack(null) + .commit() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/presentation/interestsFragment/InterestsViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/presentation/interestsFragment/InterestsViewModel.kt new file mode 100644 index 0000000..bc6f917 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/presentation/interestsFragment/InterestsViewModel.kt @@ -0,0 +1,23 @@ +package ru.otus.basicarchitecture.presentation.interestsFragment + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import ru.otus.basicarchitecture.repository.WizardCache +import javax.inject.Inject + +@HiltViewModel +class InterestsViewModel @Inject constructor( + private val wizardCache: WizardCache +) : ViewModel() { + + val selectedInterests = mutableSetOf() + + fun getInterests(): List { + return listOf("Sports", "Music", "Travel", "Books", "Movies", "Tech", "Gaming") + } + + fun saveData(): Boolean { + wizardCache.interests = selectedInterests.toList() + return true + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/presentation/personalInfoFragment/PersonalInfoFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/presentation/personalInfoFragment/PersonalInfoFragment.kt new file mode 100644 index 0000000..ca1fac4 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/presentation/personalInfoFragment/PersonalInfoFragment.kt @@ -0,0 +1,69 @@ +package ru.otus.basicarchitecture.presentation.personalInfoFragment + +import androidx.fragment.app.viewModels +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import dagger.hilt.android.AndroidEntryPoint +import ru.otus.basicarchitecture.R +import ru.otus.basicarchitecture.databinding.FragmentPersonalInfoBinding +import ru.otus.basicarchitecture.presentation.addressFragment.AddressFragment + +@AndroidEntryPoint +class PersonalInfoFragment : Fragment() { + + private var _binding: FragmentPersonalInfoBinding? = null + private val binding get() = _binding!! + + private val viewModel: PersonalInfoViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentPersonalInfoBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupListeners() + } + + private fun setupListeners() { + binding.buttonNext.setOnClickListener { + val name = binding.editTextName.text.toString() + val surname = binding.editTextSurname.text.toString() + val dateOfBirth = binding.editTextDateOfBirth.text.toString() + + viewModel.name = name + viewModel.surname = surname + viewModel.dateOfBirth = dateOfBirth + + if (viewModel.validateAndProceed()) { + navigateToNextFragment() + } else { + Toast.makeText( + context, + "You must be 18 years or older to continue.", + Toast.LENGTH_SHORT + ).show() + } + } + } + + private fun navigateToNextFragment() { + parentFragmentManager.beginTransaction() + .replace(R.id.fragment_container, AddressFragment()) + .addToBackStack(null) + .commit() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/presentation/personalInfoFragment/PersonalInfoViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/presentation/personalInfoFragment/PersonalInfoViewModel.kt new file mode 100644 index 0000000..0556d08 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/presentation/personalInfoFragment/PersonalInfoViewModel.kt @@ -0,0 +1,50 @@ +package ru.otus.basicarchitecture.presentation.personalInfoFragment + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import ru.otus.basicarchitecture.repository.WizardCache +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import javax.inject.Inject + +@HiltViewModel +class PersonalInfoViewModel @Inject constructor( + private val wizardCache: WizardCache +) : ViewModel() { + var name: String? = null + var surname: String? = null + var dateOfBirth: String? = null + + fun validateAndProceed(): Boolean { + return if (validateAge(dateOfBirth)) { + wizardCache.name = name + wizardCache.surname = surname + wizardCache.dateOfBirth = dateOfBirth + true + } else { + false + } + } + + private fun validateAge(date: String?): Boolean { + if (date.isNullOrBlank()) return false + + val dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()) + dateFormat.isLenient = false // Строгая проверка даты + + return try { + val birthDate = dateFormat.parse(date) ?: return false + val today = Calendar.getInstance().time + val eighteenYearsLater = Calendar.getInstance().apply { + time = birthDate + add(Calendar.YEAR, 18) + }.time + + !eighteenYearsLater.after(today) // Дата рождения должна быть такой, что спустя 18 лет не позже текущей даты + } catch (e: ParseException) { + false // Если формат даты неверен, возвращаем false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/basicarchitecture/presentation/summaryFragment/SummaryFragment.kt b/app/src/main/java/ru/otus/basicarchitecture/presentation/summaryFragment/SummaryFragment.kt new file mode 100644 index 0000000..321f839 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/presentation/summaryFragment/SummaryFragment.kt @@ -0,0 +1,43 @@ +package ru.otus.basicarchitecture.presentation.summaryFragment + +import androidx.fragment.app.viewModels +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import dagger.hilt.android.AndroidEntryPoint +import ru.otus.basicarchitecture.databinding.FragmentSummaryBinding + +@AndroidEntryPoint +class SummaryFragment : Fragment() { + + private var _binding: FragmentSummaryBinding? = null + private val binding get() = _binding!! + + private val viewModel: SummaryViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSummaryBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Устанавливаем данные из ViewModel в UI + binding.textViewName.text = viewModel.getName() + binding.textViewSurname.text = viewModel.getSurname() + binding.textViewDateOfBirth.text = viewModel.getDateOfBirth() + binding.textViewAddress.text = viewModel.getFullAddress() + binding.textViewInterests.text = viewModel.getInterestsAsString() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/ru/otus/basicarchitecture/presentation/summaryFragment/SummaryViewModel.kt b/app/src/main/java/ru/otus/basicarchitecture/presentation/summaryFragment/SummaryViewModel.kt new file mode 100644 index 0000000..048db9d --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/presentation/summaryFragment/SummaryViewModel.kt @@ -0,0 +1,32 @@ +package ru.otus.basicarchitecture.presentation.summaryFragment + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import ru.otus.basicarchitecture.repository.WizardCache +import javax.inject.Inject + +@HiltViewModel +class SummaryViewModel @Inject constructor( + private val wizardCache: WizardCache +) : ViewModel() { + + fun getName(): String? { + return wizardCache.name + } + + fun getSurname(): String? { + return wizardCache.surname + } + + fun getDateOfBirth(): String? { + return wizardCache.dateOfBirth + } + + fun getFullAddress(): String { + return "${wizardCache.address ?: "Unknown Address"}, ${wizardCache.city ?: "Unknown City"}, ${wizardCache.country ?: "Unknown Country"}" + } + + fun getInterestsAsString(): String { + return wizardCache.interests?.joinToString(", ") ?: "No Interests" + } +} diff --git a/app/src/main/java/ru/otus/basicarchitecture/repository/WizardCache.kt b/app/src/main/java/ru/otus/basicarchitecture/repository/WizardCache.kt new file mode 100644 index 0000000..768719c --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/repository/WizardCache.kt @@ -0,0 +1,15 @@ +package ru.otus.basicarchitecture.repository + +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WizardCache @Inject constructor() { + var name: String? = null + var surname: String? = null + var dateOfBirth: String? = null + var country: String? = null + var city: String? = null + var address: String? = null + var interests: List = emptyList() +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 0b15a20..04e2bd8 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,9 +1,14 @@ - + android:layout_height="match_parent"> - \ 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..7b8736c --- /dev/null +++ b/app/src/main/res/layout/fragment_address.xml @@ -0,0 +1,21 @@ + + + + + +