Skip to content
Open

done #39

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
@@ -1,20 +1,38 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'com.google.devtools.ksp'
id 'com.google.dagger.hilt.android'
id 'dagger.hilt.android.plugin'
id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin'
}

android {
namespace 'ru.otus.basicarchitecture'
compileSdk 35
compileSdk 36

defaultConfig {
applicationId "ru.otus.basicarchitecture"
minSdk 24
//noinspection OldTargetApi
targetSdk 35
versionCode 1
versionName "1.0"

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

// Получаем ключи из local.properties через secrets-gradle-plugin
buildConfigField "String", "DADATA_API_KEY", "\"${project.findProperty("DADATA_API_KEY") ?: ""}\""
buildConfigField "String", "DADATA_SECRET_KEY", "\"${project.findProperty("DADATA_SECRET_KEY") ?: ""}\""
}

buildFeatures {
viewBinding = true
buildConfig = true
}

testOptions {
unitTests.returnDefaultValues = true
}

buildTypes {
Expand All @@ -38,7 +56,29 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
// Lifecycle
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
// Navigation
implementation 'androidx.navigation:navigation-fragment:2.8.5'
implementation 'androidx.navigation:navigation-ui:2.8.5'
// Hilt
implementation "com.google.dagger:hilt-android:2.57.2"
ksp "com.google.dagger:hilt-compiler:2.57.2"
// Retrofit
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
// Корутины
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
// Тестирование
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.8'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
testImplementation 'app.cash.turbine:turbine:1.0.0'
testImplementation 'androidx.arch.core:core-testing:2.2.0'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
}
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<application
android:name=".MyApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/ru/otus/basicarchitecture/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package ru.otus.basicarchitecture

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/ru/otus/basicarchitecture/MyApplication.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package ru.otus.basicarchitecture

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class MyApplication : Application()
18 changes: 18 additions & 0 deletions app/src/main/java/ru/otus/basicarchitecture/WizardCache.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package ru.otus.basicarchitecture

import dagger.hilt.android.scopes.ActivityRetainedScoped
import javax.inject.Inject

/**
* Кеш для хранения данных мастера регистрации
* Использует ActivityRetainedScoped - данные сохраняются при повороте экрана,
* но очищаются при уничтожении Activity
*/
@ActivityRetainedScoped
class WizardCache @Inject constructor() {
var firstName: String = ""
var lastName: String = ""
var birthDate: String = ""
var address: String = ""
var interests: List<String> = emptyList()
}
61 changes: 61 additions & 0 deletions app/src/main/java/ru/otus/basicarchitecture/di/NetworkModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package ru.otus.basicarchitecture.di

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import ru.otus.basicarchitecture.BuildConfig
import ru.otus.basicarchitecture.network.dadata.DadataApi
import ru.otus.basicarchitecture.network.dadata.DadataAuthInterceptor
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

private const val DADATA_BASE_URL = "https://suggestions.dadata.ru/"

@Provides
@Singleton
fun provideDadataAuthInterceptor(): DadataAuthInterceptor =
DadataAuthInterceptor(
apiKey = BuildConfig.DADATA_API_KEY,
secretKey = BuildConfig.DADATA_SECRET_KEY
)

@Provides
@Singleton
fun provideLoggingInterceptor(): HttpLoggingInterceptor =
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}

@Provides
@Singleton
fun provideOkHttpClient(
authInterceptor: DadataAuthInterceptor,
loggingInterceptor: HttpLoggingInterceptor
): OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.build()

@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit =
Retrofit.Builder()
.baseUrl(DADATA_BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()

@Provides
@Singleton
fun provideDadataApi(retrofit: Retrofit): DadataApi =
retrofit.create(DadataApi::class.java)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package ru.otus.basicarchitecture.network.dadata

import retrofit2.http.Body
import retrofit2.http.POST

interface DadataApi {
@POST("suggestions/api/4_1/rs/suggest/address")
suspend fun getAddressSuggestions(@Body request: DadataRequest): DadataResponse
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package ru.otus.basicarchitecture.network.dadata

import okhttp3.Interceptor
import okhttp3.Response


class DadataAuthInterceptor(
private val apiKey: String,
private val secretKey: String
) : Interceptor {

override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()

val authenticatedRequest = originalRequest.newBuilder()
.header("Authorization", "Token $apiKey")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("X-Secret", secretKey)
.build()

return chain.proceed(authenticatedRequest)
}
}


Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package ru.otus.basicarchitecture.network.dadata

data class DadataRequest(
val query: String,
val count: Int = 10
)

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package ru.otus.basicarchitecture.network.dadata

data class DadataResponse(
val suggestions: List<Suggestion>
)

data class Suggestion(
val value: String,
val unrestricted_value: String
)

Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package ru.otus.basicarchitecture.ui.address

import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Filter
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import ru.otus.basicarchitecture.R
import ru.otus.basicarchitecture.databinding.FragmentAddressBinding

@AndroidEntryPoint
class AddressFragment : Fragment() {

private var _binding: FragmentAddressBinding? = null
private val binding get() = _binding!!

private val viewModel: AddressViewModel by viewModels()

private lateinit var suggestionsAdapter: ArrayAdapter<String>

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?) {
setupAdapter()
setupObservers()
setupListeners()
}

private fun setupAdapter() {
suggestionsAdapter = object : ArrayAdapter<String>(
requireContext(),
android.R.layout.simple_dropdown_item_1line,
mutableListOf()
) {
override fun getFilter(): Filter {
return object : Filter() {
override fun performFiltering(constraint: CharSequence?): FilterResults {
return FilterResults().apply {
values = (0 until count).mapNotNull { getItem(it) }
count = this@AddressFragment.suggestionsAdapter.count
}
}

override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
if ((results?.count ?: 0) > 0) notifyDataSetChanged()
else notifyDataSetInvalidated()
}
}
}
}

binding.etAddress.apply {
setAdapter(suggestionsAdapter)
threshold = 0
onItemClickListener = AdapterView.OnItemClickListener { _, _, _, _ ->
post { dismissDropDown() }
}
}
}

private fun setupObservers() {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.addressSuggestions.collect { suggestions ->
suggestionsAdapter.clear()
suggestionsAdapter.addAll(suggestions)
suggestionsAdapter.notifyDataSetChanged()

binding.etAddress.post {
val shouldShow =
binding.etAddress.hasFocus() &&
binding.etAddress.text.isNotEmpty() &&
suggestionsAdapter.count > 0

if (shouldShow) binding.etAddress.showDropDown()
else binding.etAddress.dismissDropDown()
}
}
}
}

private fun setupListeners() {
binding.etAddress.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}

override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
viewModel.getAddressSuggestions(s?.toString().orEmpty())
}

override fun afterTextChanged(s: Editable?) {
if (suggestionsAdapter.count > 0 && binding.etAddress.hasFocus()) {
binding.etAddress.post { binding.etAddress.showDropDown() }
}
}
})

binding.etAddress.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus && suggestionsAdapter.count > 0 && binding.etAddress.text.isNotEmpty()) {
binding.etAddress.post { binding.etAddress.showDropDown() }
}
}

binding.btnNext.setOnClickListener {
val address = binding.etAddress.text.toString()
viewModel.saveData(address)
findNavController().navigate(R.id.action_addressFragment_to_interestsFragment)
}
}

override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
Loading