diff --git a/app/build.gradle b/app/build.gradle index e515992..6926f25 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,12 @@ plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' + 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 { @@ -9,7 +15,7 @@ android { defaultConfig { applicationId "ru.otus.basicarchitecture" - minSdk 24 + minSdk 26 targetSdk 35 versionCode 1 versionName "1.0" @@ -30,15 +36,36 @@ android { kotlinOptions { jvmTarget = '17' } + + buildFeatures { + viewBinding = true + buildConfig = 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.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-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 ea17fa5..bdb26cd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,10 @@ + + = emptySet() + + fun setNewUser(newUser: WizardUser) { + user = newUser + } + + fun setNewAddress(newAddress: WizardAddress) { + address = newAddress + } + + fun setHobbies(newHobbies: Set) { + hobbies = newHobbies + } + + fun getUser(): WizardUser { + return user + } + + fun getAddress(): WizardAddress { + return address + } + + 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 new file mode 100644 index 0000000..3b05c23 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/data/MockData.kt @@ -0,0 +1,38 @@ +package ru.otus.basicarchitecture.data + +class MockData { + val hobbies = setOf( + "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" + ) + + val searchResult = "{\"suggestions\":[{\"value\":\"Респ Бурятия, Тункинский р-н, улус Охор-Шибирь, ул Ленина, д 17\",\"unrestricted_value\":\"Респ Бурятия, Тункинский р-н, улус Охор-Шибирь, ул Ленина, д 17\",\"data\":{\"postal_code\":null,\"country\":\"Россия\",\"country_iso_code\":\"RU\",\"federal_district\":\"Дальневосточный\",\"region_fias_id\":\"a84ebed3-153d-4ba9-8532-8bdf879e1f5a\",\"region_kladr_id\":\"0300000000000\",\"region_iso_code\":\"RU-BU\",\"region_with_type\":\"Респ Бурятия\",\"region_type\":\"Респ\",\"region_type_full\":\"республика\",\"region\":\"Бурятия\",\"area_fias_id\":\"d455c68f-329d-45c0-b2e9-f87b8b807ef8\",\"area_kladr_id\":\"0302000000000\",\"area_with_type\":\"Тункинский р-н\",\"area_type\":\"р-н\",\"area_type_full\":\"район\",\"area\":\"Тункинский\",\"city_fias_id\":null,\"city_kladr_id\":null,\"city_with_type\":null,\"city_type\":null,\"city_type_full\":null,\"city\":null,\"city_area\":null,\"city_district_fias_id\":null,\"city_district_kladr_id\":null,\"city_district_with_type\":null,\"city_district_type\":null,\"city_district_type_full\":null,\"city_district\":null,\"settlement_fias_id\":\"c96a2089-3f3b-40ff-a8b5-a9dfa498cedf\",\"settlement_kladr_id\":\"0302000001600\",\"settlement_with_type\":\"улус Охор-Шибирь\",\"settlement_type\":\"у\",\"settlement_type_full\":\"улус\",\"settlement\":\"Охор-Шибирь\",\"street_fias_id\":\"2788547b-af79-4931-accd-2dac483551eb\",\"street_kladr_id\":\"03020000016000100\",\"street_with_type\":\"ул Ленина\",\"street_type\":\"ул\",\"street_type_full\":\"улица\",\"street\":\"Ленина\",\"stead_fias_id\":null,\"stead_cadnum\":null,\"stead_type\":null,\"stead_type_full\":null,\"stead\":null,\"house_fias_id\":null,\"house_kladr_id\":null,\"house_cadnum\":null,\"house_flat_count\":null,\"house_type\":\"д\",\"house_type_full\":\"дом\",\"house\":\"17\",\"block_type\":null,\"block_type_full\":null,\"block\":null,\"entrance\":null,\"floor\":null,\"flat_fias_id\":null,\"flat_cadnum\":null,\"flat_type\":null,\"flat_type_full\":null,\"flat\":null,\"flat_area\":null,\"square_meter_price\":null,\"flat_price\":null,\"room_fias_id\":null,\"room_cadnum\":null,\"room_type\":null,\"room_type_full\":null,\"room\":null,\"postal_box\":null,\"fias_id\":\"2788547b-af79-4931-accd-2dac483551eb\",\"fias_code\":null,\"fias_level\":\"7\",\"fias_actuality_state\":\"0\",\"kladr_id\":\"03020000016000100\",\"geoname_id\":null,\"capital_marker\":\"0\",\"okato\":\"81251811002\",\"oktmo\":\"81651411111\",\"tax_office\":\"0300\",\"tax_office_legal\":\"0300\",\"timezone\":null,\"geo_lat\":\"51.675343\",\"geo_lon\":\"102.417482\",\"beltway_hit\":null,\"beltway_distance\":null,\"metro\":null,\"divisions\":null,\"qc_geo\":\"3\",\"qc_complete\":null,\"qc_house\":null,\"history_values\":null,\"unparsed_parts\":null,\"source\":null,\"qc\":null}}]}" +} \ 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..9f7f92c --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/helpers/ChipLoader.kt @@ -0,0 +1,35 @@ +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: 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) + } + } + } +} \ 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/network/Api.kt b/app/src/main/java/ru/otus/basicarchitecture/network/Api.kt new file mode 100644 index 0000000..5305704 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/network/Api.kt @@ -0,0 +1,30 @@ +package ru.otus.basicarchitecture.network + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.http.Body +import retrofit2.http.POST +import ru.otus.basicarchitecture.ui.address.SuggestionResponse +import ru.otus.basicarchitecture.ui.address.SuggestQuery + + +private const val suggestionUrl = + "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/" + +interface Api { + @POST("address") + suspend fun getSuggestions(@Body query: SuggestQuery): Response +} + +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 new file mode 100644 index 0000000..64ffe69 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/address/AddressViewModel.kt @@ -0,0 +1,141 @@ +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, + 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() + } ?: false + }.stateIn( + scope = viewModelScope, + initialValue = false, + 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 + } + + fun initAddress() { + _address.value = wizardCache.getAddress() + } + + 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 new file mode 100644 index 0000000..88e182b --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/address/FragmentAddress.kt @@ -0,0 +1,99 @@ +package ru.otus.basicarchitecture.ui.address + +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.databinding.FragmentAddressBinding +import ru.otus.basicarchitecture.helpers.navController + +@AndroidEntryPoint +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() + 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.saveToWizardCache() + navController.navigate(R.id.action_address_to_tags) + } + + lifecycleScope.launch { + viewModel.nextAvailable.collect { isAvailable -> + btnAddressNext.isEnabled = isAvailable + } + } + + viewModel.initAddress() + viewModel.address.value.let { + viewModel.setStateDataIsSet() + txtAddress.setText(it?.address) + } + + 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 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/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..c3d1571 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/togs/FragmentTags.kt @@ -0,0 +1,72 @@ +package ru.otus.basicarchitecture.ui.togs + +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 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 +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 { + viewModel.saveTagsToWizard() + navController.navigate(R.id.action_tags_to_summary) + } + 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 new file mode 100644 index 0000000..0697f0d --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/togs/TagsViewModel.kt @@ -0,0 +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() { + + 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 new file mode 100644 index 0000000..dc9098d --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/user/FragmentUser.kt @@ -0,0 +1,120 @@ +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.core.widget.doAfterTextChanged +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.saveUserToWizard() + navController.navigate(R.id.action_person_to_address) + } + txtBirthday.setOnClickListener { + showDatePicker() + } + lifecycleScope.launch { + 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.user.value?.birthday ?: 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") + } + + 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 new file mode 100644 index 0000000..6b89fe5 --- /dev/null +++ b/app/src/main/java/ru/otus/basicarchitecture/ui/user/UserViewModel.kt @@ -0,0 +1,50 @@ +package ru.otus.basicarchitecture.ui.user + +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 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 var _user = MutableStateFlow(null) + val user = _user.asStateFlow() + + val isUserBirthdayValid = _user.map { user -> + Period.between(user?.birthday ?: LocalDate.now(), LocalDate.now()).years >= 18 + }.stateIn( + initialValue = false, + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000L) + ) + + 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 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/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..e008718 --- /dev/null +++ b/app/src/main/res/layout/fragment_address.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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/layout/vh_address_suggestions.xml b/app/src/main/res/layout/vh_address_suggestions.xml new file mode 100644 index 0000000..528cf77 --- /dev/null +++ b/app/src/main/res/layout/vh_address_suggestions.xml @@ -0,0 +1,29 @@ + + + + + + + + \ 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..2930327 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,14 @@ +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 id 'com.android.library' version '8.7.3' apply false - id 'org.jetbrains.kotlin.android' version '2.0.21' apply false -} \ No newline at end of file + 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 + id("org.jetbrains.kotlin.plugin.serialization") version("1.9.0") apply false +}