Merge pull request 'MB-43443' (#11) from MB-43443 into feature/MB-43443
Reviewed-on: #11 Reviewed-by: Daniil Bakherov <daniil.bakherov@noreply.localhost>
This commit is contained in:
commit
08d255d73c
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
Alerts
|
||||
=====
|
||||
|
||||
### Общее описание
|
||||
|
||||
Модуль содержит:
|
||||
`AlertDialogManager` - служит для демонстрации AlertDialog с использованием View, необходимо вызвать метод `showAlertDialog`, который
|
||||
в качестве агруметов может принимать:
|
||||
* `context`,
|
||||
* `style` - стиль для элементов дефолтного диалога (по умолчанию R.style.AlertDialogDefault),
|
||||
* `title` - Заголовок диалога,
|
||||
* `message` - дополнительное сообщение,
|
||||
* `positiveButtonText` - текст правой кнопки (по умолчанию "ОК"),
|
||||
* `onPositiveAction` - колбэк при нажатии на правую кнопку,
|
||||
* `negativeBtnTitle` - текст левой кнопки (по умолчаннию null - в этом случаи не отображается),
|
||||
* `onNegativeAction` - колбэк при нажатии на левую кнопку,
|
||||
* `dialogLayout` - id кастомного layout (по умолчанию R.layout.dialog_alert).
|
||||
|
||||
---
|
||||
`ComposableAlertDialog` - служит для демонстрации AlertDialog с использованием Jetpack Compose, необходимо вызвать метод `ShowAlertDialog`, который
|
||||
в качестве агруметов может принимать:
|
||||
* `isDialogOpen` - индикатор состояния диалога,
|
||||
* `title` - Заголовок диалога,
|
||||
* `message` - дополнительное сообщение,
|
||||
* `positiveButtonText` - текст правой кнопки,
|
||||
* `onPositiveAction` - колбэк при нажатии на правую кнопку,
|
||||
* `negativeBtnTitle` - текст левой кнопки (по умолчаннию null - в этом случаи не отображается),
|
||||
* `onNegativeAction` - колбэк при нажатии на левую кнопку.
|
||||
|
||||
Кастомизация Compose версии происходит по средствам инициализации полей: customTitle, customMessage, customConfirmBtn, customNegativeBtn
|
||||
|
||||
### Примеры
|
||||
|
||||
View версия (ViewableAlertDialog) ok/cancel диалога:
|
||||
```kotlin
|
||||
alertDialogManager.showAlertDialog(
|
||||
context = activity,
|
||||
title = "Ой, что-то пошло не так",
|
||||
message = "Попробуйте ещё раз",
|
||||
positiveButtonText = "Ещё раз",
|
||||
onPositiveAction = { retryConnection() },
|
||||
negativeBtnTitle = "Отмена"
|
||||
)
|
||||
```
|
||||
|
||||
View версия (ViewableAlertDialog) ok диалога:
|
||||
```kotlin
|
||||
alertDialogManager.showOkDialog(
|
||||
context = dialog?.window?.decorView?.context ?: throw Exception(),
|
||||
title = "Необходимо изменить настройки",
|
||||
okButtonText = "Ок",
|
||||
onOkAction = {
|
||||
viewModel.dispatchAction(ItemAction.ChangeSettings)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Для катомизации стилей элементов в дефолтной разметке диалога необходимо создать стиль - наследника от `ThemeOverlay.MaterialComponents.MaterialAlertDialog` и переопределить стили:
|
||||
* `materialAlertDialogTitleTextStyle` - стиль для заголока (наследник от `MaterialAlertDialog.MaterialComponents.Title.Text`),
|
||||
* `materialAlertDialogBodyTextStyle` - стиль для подзаголовка (наследник от `MaterialAlertDialog.MaterialComponents.Body.Text`),
|
||||
* `buttonBarPositiveButtonStyle` - стиль для позитивной кнопки (наследник от `Widget.MaterialComponents.Button.TextButton.Dialog`),
|
||||
* `buttonBarNegativeButtonStyle` - стиль для негативной кнопки (наследник от `Widget.MaterialComponents.Button.TextButton.Dialog`).
|
||||
|
||||
Compose версия (ComposableAlertDialog):
|
||||
```kotlin
|
||||
val isDialogOpen = remember { mutableStateOf(false)}
|
||||
....
|
||||
//Создание диалога
|
||||
ComposableAlertDialog
|
||||
.apply { customTitle = { Text(text = "Ой, что-то пошло не так", color = Color.Blue) } }
|
||||
.ShowAlertDialog(isDialogOpen, message = "Проблемы с сетью", positiveButtonText = "ОК")
|
||||
....
|
||||
//Отображение диалога
|
||||
isDialogOpen.value = true
|
||||
```
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
apply from: "../android-configs/lib-config.gradle"
|
||||
|
||||
ext {
|
||||
composeVersion = '1.1.1'
|
||||
}
|
||||
|
||||
android {
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx")
|
||||
implementation("androidx.constraintlayout:constraintlayout")
|
||||
implementation("com.google.android.material:material")
|
||||
implementation project(":kotlin-extensions")
|
||||
|
||||
implementation "androidx.compose.runtime:runtime:$composeVersion"
|
||||
implementation "androidx.compose.ui:ui:$composeVersion"
|
||||
implementation "androidx.compose.foundation:foundation:$composeVersion"
|
||||
implementation "androidx.compose.foundation:foundation-layout:$composeVersion"
|
||||
implementation "androidx.compose.material:material:$composeVersion"
|
||||
implementation "androidx.compose.runtime:runtime-livedata:$composeVersion"
|
||||
implementation "androidx.compose.ui:ui-tooling:$composeVersion"
|
||||
implementation "com.google.android.material:compose-theme-adapter:1.1.9"
|
||||
|
||||
constraints {
|
||||
implementation("androidx.core:core-ktx") {
|
||||
version {
|
||||
require '1.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
implementation("androidx.constraintlayout:constraintlayout") {
|
||||
version {
|
||||
require '2.2.0-alpha03'
|
||||
}
|
||||
}
|
||||
|
||||
implementation("com.google.android.material:material") {
|
||||
version {
|
||||
require '1.1.0'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<manifest
|
||||
package="ru.touchin.roboswag.alerts"/>
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package ru.touchin.roboswag.composable_dialog
|
||||
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
|
||||
object ComposableAlertDialog {
|
||||
var customTitle: @Composable (() -> Unit)? = null
|
||||
var customMessage: @Composable (() -> Unit)? = null
|
||||
var customConfirmBtn: @Composable (() -> Unit)? = null
|
||||
var customNegativeBtn: @Composable (() -> Unit)? = null
|
||||
|
||||
@Composable
|
||||
fun ShowAlertDialog(
|
||||
isDialogOpen: MutableState<Boolean>,
|
||||
title: String? = null,
|
||||
message: String? = null,
|
||||
positiveButtonText: String? = null,
|
||||
onPositiveAction: (() -> Unit)? = null,
|
||||
negativeBtnTitle: String? = null,
|
||||
onNegativeAction: (() -> Unit)? = null
|
||||
) {
|
||||
if (!isDialogOpen.value) return
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { isDialogOpen.value = false },
|
||||
title = customTitle ?: { Text(title.orEmpty()) },
|
||||
text = customMessage ?: { Text(message.orEmpty()) },
|
||||
confirmButton = customConfirmBtn ?: createButton(positiveButtonText.orEmpty()) {
|
||||
onPositiveAction?.invoke()
|
||||
isDialogOpen.value = false
|
||||
},
|
||||
dismissButton = when {
|
||||
customNegativeBtn != null -> customNegativeBtn
|
||||
negativeBtnTitle != null -> createButton(negativeBtnTitle) {
|
||||
onNegativeAction?.invoke()
|
||||
isDialogOpen.value = false
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun createButton(text: String, onClickAction: () -> Unit): @Composable (() -> Unit) =
|
||||
{
|
||||
TextButton(onClick = onClickAction) {
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package ru.touchin.roboswag.viewable_dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.view.ContextThemeWrapper
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import ru.touchin.roboswag.alerts.R
|
||||
|
||||
class AlertDialogManager {
|
||||
|
||||
@SuppressWarnings("detekt.LongParameterList")
|
||||
fun showAlertDialog(
|
||||
context: Context,
|
||||
style: Int = R.style.AlertDialogDefault,
|
||||
title: String? = null,
|
||||
message: String? = null,
|
||||
positiveButtonText: String = context.getString(R.string.positive_btn),
|
||||
onPositiveAction: (() -> Unit)? = null,
|
||||
negativeBtnTitle: String? = null,
|
||||
onNegativeAction: (() -> Unit)? = null,
|
||||
dialogLayout: Int = R.layout.dialog_alert,
|
||||
cancelable: Boolean = true,
|
||||
onCancelAction: () -> Unit = {}
|
||||
) {
|
||||
val styledContext = ContextThemeWrapper(context, style)
|
||||
|
||||
MaterialAlertDialogBuilder(styledContext)
|
||||
.setView(LayoutInflater.from(styledContext).inflate(dialogLayout, null))
|
||||
.show()
|
||||
.setupAlertDialog(
|
||||
title = title,
|
||||
message = message,
|
||||
positiveButtonText = positiveButtonText,
|
||||
onPositiveClick = onPositiveAction,
|
||||
negativeButtonText = negativeBtnTitle,
|
||||
onNegativeClick = onNegativeAction,
|
||||
cancelable = cancelable,
|
||||
onCancelAction = onCancelAction
|
||||
)
|
||||
}
|
||||
|
||||
fun showOkDialog(
|
||||
context: Context,
|
||||
style: Int = R.style.AlertDialogDefault,
|
||||
title: String? = null,
|
||||
message: String? = null,
|
||||
okButtonText: String = context.getString(R.string.positive_btn),
|
||||
onOkAction: (() -> Unit)? = null,
|
||||
cancelable: Boolean = true,
|
||||
onCancelAction: () -> Unit = {}
|
||||
) = showAlertDialog(
|
||||
context = context,
|
||||
style = style,
|
||||
title = title,
|
||||
message = message,
|
||||
positiveButtonText = okButtonText,
|
||||
onPositiveAction = onOkAction,
|
||||
cancelable = cancelable,
|
||||
onCancelAction = onCancelAction
|
||||
)
|
||||
|
||||
private fun AlertDialog.setupAlertDialog(
|
||||
title: String? = null,
|
||||
message: String? = null,
|
||||
positiveButtonText: String,
|
||||
onPositiveClick: (() -> Unit)? = null,
|
||||
negativeButtonText: String? = null,
|
||||
onNegativeClick: (() -> Unit)? = null,
|
||||
cancelable: Boolean = true,
|
||||
onCancelAction: () -> Unit = {}
|
||||
) {
|
||||
setCancelable(cancelable)
|
||||
setOnDismissListener { onCancelAction() }
|
||||
findViewById<TextView>(R.id.alert_title)?.setTextOrGone(title)
|
||||
findViewById<TextView>(R.id.alert_message)?.setTextOrGone(message)
|
||||
findViewById<TextView>(R.id.alert_positive_button)?.let { buttonView ->
|
||||
setupButton(this, buttonView, positiveButtonText, onPositiveClick)
|
||||
}
|
||||
findViewById<TextView>(R.id.alert_negative_button)?.let { buttonView ->
|
||||
setupButton(this, buttonView, negativeButtonText, onNegativeClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package ru.touchin.roboswag.viewable_dialog
|
||||
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isVisible
|
||||
import ru.touchin.extensions.setOnRippleClickListener
|
||||
|
||||
fun setupButton(alertDialog: AlertDialog, buttonView: TextView, text: String?, onButtonClick: (() -> Unit)?) {
|
||||
buttonView.setTextOrGone(text)
|
||||
buttonView.setOnRippleClickListener {
|
||||
onButtonClick?.invoke()
|
||||
alertDialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
fun TextView.setTextOrGone(text: CharSequence?) {
|
||||
isVisible = !text.isNullOrEmpty()
|
||||
setText(text)
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="22dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/alert_title"
|
||||
style="?attr/materialAlertDialogTitleTextStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="Header" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/alert_message"
|
||||
style="?attr/materialAlertDialogBodyTextStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@id/alert_title"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/alert_title"
|
||||
tools:text="Text" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/alert_positive_button"
|
||||
style="?attr/buttonBarPositiveButtonStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@id/alert_message"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_alignParentRight="true"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/alert_message"
|
||||
tools:text="OK" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/alert_negative_button"
|
||||
style="?attr/buttonBarNegativeButtonStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@id/alert_message"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_toStartOf="@id/alert_positive_button"
|
||||
android:layout_toLeftOf="@id/alert_positive_button"
|
||||
app:layout_constraintRight_toLeftOf="@id/alert_positive_button"
|
||||
app:layout_constraintTop_toBottomOf="@id/alert_message"
|
||||
tools:text="Cancel" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="positive_btn">OK</string>
|
||||
<string name="negative_btn">Cancel</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AlertDialogDefault" parent="ThemeOverlay.MaterialComponents.MaterialAlertDialog">
|
||||
<item name="colorSurface">#FFFFFF</item>
|
||||
<item name="materialAlertDialogTitleTextStyle">@style/MaterialAlertDialog.MaterialComponents.Title.Text.Default</item>
|
||||
<item name="buttonBarPositiveButtonStyle">@style/MaterialAlertDialog.MaterialComponents.Button.Default</item>
|
||||
<item name="buttonBarNegativeButtonStyle">@style/MaterialAlertDialog.MaterialComponents.Button.Default</item>
|
||||
<item name="materialAlertDialogBodyTextStyle">@style/MaterialAlertDialog.MaterialComponents.Body.Text.Default</item>
|
||||
</style>
|
||||
|
||||
<style name="MaterialAlertDialog.MaterialComponents.Title.Text.Default" parent="MaterialAlertDialog.MaterialComponents.Title.Text">
|
||||
<item name="android:textColor">#383838</item>
|
||||
<item name="android:textSize">15sp</item>
|
||||
<item name="android:layout_marginLeft">24dp</item>
|
||||
<item name="android:layout_marginRight">24dp</item>
|
||||
<item name="android:paddingBottom">16dp</item>
|
||||
</style>
|
||||
|
||||
<style name="MaterialAlertDialog.MaterialComponents.Body.Text.Default" parent="MaterialAlertDialog.MaterialComponents.Body.Text">
|
||||
<item name="android:textColor">#383838</item>
|
||||
<item name="android:textSize">12sp</item>
|
||||
<item name="android:layout_marginLeft">24dp</item>
|
||||
<item name="android:layout_marginRight">24dp</item>
|
||||
<item name="android:paddingBottom">28dp</item>
|
||||
<item name="android:lineSpacingExtra">8dp</item>
|
||||
</style>
|
||||
|
||||
<style name="MaterialAlertDialog.MaterialComponents.Button.Default" parent="Widget.MaterialComponents.Button.TextButton.Dialog">
|
||||
<item name="android:textColor">#383838</item>
|
||||
<item name="android:minWidth">56dp</item>
|
||||
<item name="android:gravity">center</item>
|
||||
<item name="android:textAllCaps">true</item>
|
||||
<item name="android:textSize">14sp</item>
|
||||
<item name="android:paddingTop">11dp</item>
|
||||
<item name="android:paddingBottom">9dp</item>
|
||||
<item name="android:layout_marginTop">8dp</item>
|
||||
<item name="android:layout_marginRight">8dp</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
@ -3,5 +3,5 @@ apply plugin: 'com.android.application'
|
|||
apply from: '../RoboSwag/android-configs/common-config.gradle'
|
||||
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
apply plugin: 'kotlin-android'
|
||||
|
||||
rootProject.ext {
|
||||
compileSdk = 29
|
||||
compileSdk = 33
|
||||
|
||||
minSdk = 21
|
||||
targetSdk = 29
|
||||
targetSdk = 33
|
||||
}
|
||||
|
||||
android {
|
||||
|
|
@ -16,12 +16,12 @@ android {
|
|||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
apply from: "../android-configs/lib-config.gradle"
|
||||
|
||||
android {
|
||||
namespace "ru.touchin.templates.logansquare"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(":utils")
|
||||
implementation project(":logging")
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
# Описание
|
||||
|
||||
Модуль содержит реализацию следующих типов фильтров:
|
||||
|
||||
1. Выбор одного/нескольких из доступных значений списка
|
||||
2. Выбор одного/нескольких значений из перечня тегов
|
||||
3. Выбор минимального и максимального значения из диапозона
|
||||
|
||||
# Использование
|
||||
|
||||
## 1. Выбор одного/нескольких из доступных значений списка
|
||||
|
||||
### Как использовать
|
||||
``` kotlin
|
||||
val selectorView = ListSelectionView<DefaultSelectionItem, SelectionItemViewHolder<DefaultSelectionItem>>(context)
|
||||
.Builder()
|
||||
.setItems(navArgs.items)
|
||||
.addItemDecoration((TopDividerItemDecoration(
|
||||
context = requireContext(),
|
||||
drawableId = R.drawable.list_divider_1dp,
|
||||
startMargin = START_MARGIN_DIVIDER_DP.px
|
||||
)))
|
||||
.withSelectionType(ListSelectionView.SelectionType.SINGLE_SELECT)
|
||||
.onResultListener { items ->
|
||||
viewModel.dispatchAction(SelectItemAction.SelectItem(items))
|
||||
}
|
||||
.build()
|
||||
```
|
||||
### Конфигурации
|
||||
* при создании `ListSelectionView<ItemType, HolderType>` необходимо передлать `ItemType` - класс модели данных в списке, `HolderType` - класс viewHolder-а в recyclerView.
|
||||
Для использования дефолтной реализации необходимо использовать типы `<DefaultSelectionItem, SelectionItemViewHolder<DefaultSelectionItem>>`
|
||||
* в метод `setItems(List<ItemType>)` необходимо передать список объектов
|
||||
* метод `addItemDecoration(itemDecoration: RecyclerView.ItemDecoration)` можно использовать для передачи объекта `RecyclerView.ItemDecoration`
|
||||
* метод `withSelectionType(type: SelectionType)` используется для указания типа выбора:
|
||||
* `SINGLE_SELECT` - <em>по умолчанию</em> - позволяет выбрать один выариант, при этом будет выбран всегда как минимум один вариант
|
||||
* `MULTI_SELECT` - позволяет выбрать несколько вариантов из списка, при этом можно полностью выбрать все варианты и убрать выделение со всех вариантов
|
||||
* метод `showInHolder(HolderFactoryType<ItemType>)` используется для определения кастомного viewHolder для списка с недефолтной разметкой.
|
||||
``` kotlin
|
||||
val selectorView = ListSelectionView<TestSelectionItem, TestItemViewHolder>(context)
|
||||
.Builder()
|
||||
.showInHolder { parent, clickListener, selectionType ->
|
||||
TestItemViewHolder(
|
||||
binding = TestSelectionItemBinding.inflate(LayoutInflater.from(parent.context), parent, false),
|
||||
onItemSelectAction = clickListener,
|
||||
selectionType = selectionType
|
||||
)
|
||||
}
|
||||
...
|
||||
.build()
|
||||
```
|
||||
* колбэк `onSelectedItemsListener(listener: OnSelectedItemsListener<ItemType>)` можно использовать для получения списка всех элементов `ItemType` после каждого выбора
|
||||
* колбэк `onSelectedItemListener(listener: OnSelectedItemListener<ItemType>)` можно использовать для получения элемента списка `ItemType`, по которому произошел клик
|
||||
* после вызова конфигурационных методов обязательно необходимо вызать метод `build()`
|
||||
|
||||
### Кастомизация стиля дефолтной реализации ViewHolder без необходимости создания кастомного layout и viewHolder
|
||||
|
||||
#### 1) Определить кастомную тему и стили элементов
|
||||
1. Стиль для **текста элемента списка** должен быть наследником стиля `Widget.FilterSelection.Item`
|
||||
``` xml
|
||||
<style name="Widget.Custom.FilterSelection.Item" parent="@style/Widget.FilterSelection.Item">
|
||||
<item name="android:textAppearance">@style/Text15sp.Regular.Black</item>
|
||||
<item name="android:paddingTop">2dp</item>
|
||||
<item name="android:lineSpacingExtra">3sp</item>
|
||||
<item name="android:translationY">-1.71sp</item>
|
||||
</style>
|
||||
```
|
||||
2. Стиль для **индикатора выбора** должен быть наследником стиля `Widget.FilterSelection.Radio`
|
||||
Передайте `selector-drawable` для кастомизации вида индикатора в конце строки
|
||||
``` xml
|
||||
<style name="Widget.Custom.FilterSelection.Radio" parent="@style/Widget.FilterSelection.Radio">
|
||||
<item name="android:button">@drawable/selector_checkbox</item>
|
||||
</style>
|
||||
```
|
||||
3. Создайте **тему**, которая должна быть наследником `Theme.FilterSelection`
|
||||
``` xml
|
||||
<style name="Theme.Custom.FilterSelection" parent="@style/Theme.FilterSelection">
|
||||
<item name="sheetSelection_itemStyle">@style/Widget.Custom.FilterSelection.Item</item>
|
||||
<item name="sheetSelection_radioStyle">@style/Widget.Custom.FilterSelection.Radio</item>
|
||||
</style>
|
||||
```
|
||||
#### 2) Применить тему при создании view
|
||||
При создании вью в коде можно указать тему, используя `ContextThemeWrapper`
|
||||
``` kotlin
|
||||
val newContext = ContextThemeWrapper(requireContext(), R.style.Theme_Custom_FilterSelection)
|
||||
val selectorView = ListSelectionView(newContext)
|
||||
.Builder()
|
||||
...
|
||||
.build()
|
||||
```
|
||||
|
||||
## 2. Выбор одного/нескольких значений из перечня тегов
|
||||
|
||||
* `TagLayoutView` - view-контейнер для тегов
|
||||
* `TagView` - view для тега. <em>Кастомная разметка для тега должна содержать в корне `TagView`</em>
|
||||
|
||||
### Как использовать
|
||||
``` kotlin
|
||||
binding.tagItemLayout
|
||||
.Builder(getFilterItem())
|
||||
.setSpacing(16)
|
||||
.setSelectionType(SelectionType.MULTI_SELECT) // по умолчанию
|
||||
.isSingleLine(false) // по умолчанию
|
||||
.onPropertySelectedAction { filterProperty: FilterProperty ->
|
||||
//Do something
|
||||
}
|
||||
.build()
|
||||
```
|
||||
### Конфигурации
|
||||
* метод `setSelectionType(SelectionType)` конфигурирует тип выбора:
|
||||
* `SINGLE_SELECT` - выбор одного варианта сбрасывает select у всех остальных
|
||||
* `MULTI_SELECT` - <em>по умолчанию</em> - мультивыбор тегов с учетом исключающих фильтров
|
||||
* метод `isSingleLine(Boolean)` конфигурирует вид контейнера с тегами: `true` соответствует горизонтальному контейнеру со скроллом
|
||||
* `setTagLayout(Int)` устанавливает разметку для тега. Если не задано - то используется дефолтная разметка `layout_default_tag.xml`
|
||||
* `setMaxTagCount(Int)` позволяет ограничить количество отображаемых тегов. По умолчанию ограничения нет.
|
||||
* `setMoreTagLayout(Int, String)` устанавливает разметку для тега, который отображается для дополнительного тега. Если не указана - то тег не будет создан
|
||||
* `setSpacing(Int)`, `setSpacingHorizontal(Int)` и `setSpacingVertical(Int)` можно использовать для настройки расстояния между тегами. По умолчанию - 0
|
||||
* `onMoreValuesAction(FilterMoreAction)` и `onPropertySelectedAction(PropertySelectedAction)` используются для передачи колбэков на клик по тегу типа "Еще" и обычного тега соответственно
|
||||
* после вызова конфигурационных методов обязательно необходимо вызать метод `build()`
|
||||
* в Builder необходимо передать объект `filterItem: FilterItem`
|
||||
|
||||
|
||||
## 3. Выбор минимального и максимального значения из диапозона
|
||||
|
||||
* `RangeChoiceView` - контейнер для слайдера и редактируемых полей
|
||||
* `FilterRangeSlider` - слайдер - <em>Можно использовать как отдельный элемент</em>
|
||||
* `HintInputView` - view для редактируемого поля начала и окончания диапозона
|
||||
|
||||
### Как использовать
|
||||
|
||||
В разметке
|
||||
``` xml
|
||||
<ru.touchin.roboswag.base_filters.range.RangeChoiceView
|
||||
android:id="@+id/range_values_test"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
style="@style/FilterRangeChoice" //не забудьте указать стиль
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
```
|
||||
|
||||
Настройка в коде
|
||||
``` kotlin
|
||||
fun setupValues(item: FilterRangeItem) {
|
||||
binding.rangeValuesTest.setupRangeValues(
|
||||
rangeFilterItem = item,
|
||||
onChangeCallback = callback
|
||||
)
|
||||
}
|
||||
|
||||
fun resetValues() {
|
||||
binding.rangeValuesTest.resetRangeValue()
|
||||
}
|
||||
```
|
||||
### Конфигурации
|
||||
Вся конфигурация вьюх осуществляется через стили:
|
||||
* Для `RangeChoiceView`:
|
||||
* `filterRange_sliderMargin` - расстояние от слайдера до редактируемых полей
|
||||
* `filterRange_startHint` - ссылка на строку с текстом подсказки в редактируемом поле для начального значения
|
||||
* `filterRange_endHint` - ссылка на строку с текстом подсказки в редактируемом поле для конечного значения
|
||||
* `filterRange_theme` - ссылка на тему
|
||||
* В теме:
|
||||
* атрибут `filterRange_sliderStyle` - ссылка на стиль слайдера
|
||||
* атрибут `filterRange_hintViewStyle` - ссылка на стиль `HintInputView`
|
||||
* атрибут `filterRange_hintTextStyle` - ссылка на стиль `TextView` внутри `HintInputView`
|
||||
* атрибут `filterRange_valueEditTextStyle` - ссылка на стиль `EditText` внутри `HintInputView`
|
||||
* Для `FilterRangeSlider`:
|
||||
* `trackColorActive`
|
||||
* `trackColorInactive`
|
||||
* `trackHeight`
|
||||
* `thumbElevation`
|
||||
* `thumbColor`
|
||||
* `labelBehavior`
|
||||
* `haloRadius`
|
||||
* `filterRange_stepTextAppearance`
|
||||
* `filterRange_activeTickColor`
|
||||
* `filterRange_inactiveTickColor`
|
||||
* `filterRange_stepValueMarginTop`
|
||||
* `filterRange_sliderPointSize`
|
||||
* `filterRange_pointShape`
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
apply from: "../android-configs/lib-config.gradle"
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
|
||||
android {
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(":utils")
|
||||
implementation project(":recyclerview-adapters")
|
||||
implementation project(":navigation-base")
|
||||
implementation project(":kotlin-extensions")
|
||||
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib")
|
||||
|
||||
implementation("androidx.core:core-ktx")
|
||||
|
||||
implementation("androidx.appcompat:appcompat")
|
||||
implementation("com.google.android.material:material")
|
||||
implementation("androidx.constraintlayout:constraintlayout") {
|
||||
version {
|
||||
require '2.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
constraints {
|
||||
implementation("androidx.appcompat:appcompat") {
|
||||
version {
|
||||
require '1.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
implementation("androidx.core:core-ktx") {
|
||||
version {
|
||||
require '1.0.0'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<manifest
|
||||
package="ru.touchin.roboswag.base_filters"/>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package ru.touchin.roboswag.base_filters
|
||||
|
||||
enum class SelectionType {
|
||||
SINGLE_SELECT, MULTI_SELECT
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
package ru.touchin.roboswag.base_filters.range
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Paint.Cap
|
||||
import android.util.AttributeSet
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import com.google.android.material.shape.CornerFamily
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import com.google.android.material.slider.RangeSlider
|
||||
import ru.touchin.roboswag.base_filters.R
|
||||
import ru.touchin.roboswag.components.utils.getColorSimple
|
||||
|
||||
class FilterRangeSlider @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
defStyleRes: Int = 0
|
||||
) : RangeSlider(context, attrs, defStyleAttr) {
|
||||
|
||||
var points: List<Int>? = null
|
||||
set(value) {
|
||||
field = value?.sorted()?.filter { it > valueFrom && it < valueTo }
|
||||
}
|
||||
|
||||
private val innerThumbRadius: Int = thumbRadius
|
||||
|
||||
private var stepValueMarginTop = 0f
|
||||
private var inactiveTickColor: Int = context.getColorSimple(R.color.slider_point_inactive)
|
||||
private var activeTickColor: Int = context.getColorSimple(R.color.slider_point_active)
|
||||
private var sliderPointSize: Float = 5f
|
||||
|
||||
@StyleRes
|
||||
private var stepTextAppearance: Int = -1
|
||||
private var shape: Shape = Shape.CIRCLE
|
||||
|
||||
private var trackCenterY: Float = -1F
|
||||
|
||||
init {
|
||||
// Set original thumb radius to zero to draw custom one on top
|
||||
thumbRadius = 0
|
||||
|
||||
context.withStyledAttributes(attrs, R.styleable.FilterRangeSlider, defStyleAttr, defStyleRes) {
|
||||
stepValueMarginTop = getDimension(R.styleable.FilterRangeSlider_filterRange_stepValueMarginTop, stepValueMarginTop)
|
||||
inactiveTickColor = getColor(R.styleable.FilterRangeSlider_filterRange_inactiveTickColor, inactiveTickColor)
|
||||
activeTickColor = getColor(R.styleable.FilterRangeSlider_filterRange_activeTickColor, activeTickColor)
|
||||
sliderPointSize = getDimension(R.styleable.FilterRangeSlider_filterRange_sliderPointSize, sliderPointSize)
|
||||
stepTextAppearance = getResourceId(R.styleable.FilterRangeSlider_filterRange_stepTextAppearance, -1)
|
||||
shape = Shape.values()[getInt(R.styleable.FilterRangeSlider_filterRange_pointShape, Shape.CIRCLE.ordinal)]
|
||||
}
|
||||
}
|
||||
|
||||
private val thumbDrawable = MaterialShapeDrawable().apply {
|
||||
shadowCompatibilityMode = MaterialShapeDrawable.SHADOW_COMPAT_MODE_ALWAYS
|
||||
setBounds(0, 0, innerThumbRadius * 2, innerThumbRadius * 2)
|
||||
elevation = thumbElevation
|
||||
state = drawableState
|
||||
fillColor = thumbTintList
|
||||
shapeAppearanceModel = ShapeAppearanceModel
|
||||
.builder()
|
||||
.setAllCorners(shape.value, innerThumbRadius.toFloat())
|
||||
.build()
|
||||
}
|
||||
|
||||
private val inactiveTicksPaint = getDefaultTickPaint().apply { color = inactiveTickColor }
|
||||
|
||||
private val activeTicksPaint = getDefaultTickPaint().apply { color = activeTickColor }
|
||||
|
||||
private fun getDefaultTickPaint() = Paint().apply {
|
||||
isAntiAlias = true
|
||||
style = Paint.Style.STROKE
|
||||
strokeCap = Cap.ROUND
|
||||
strokeWidth = sliderPointSize
|
||||
}
|
||||
|
||||
// Using TextView as a bridge to get text params
|
||||
private val stepValuePaint: Paint = AppCompatTextView(context).apply {
|
||||
stepTextAppearance.takeIf { it != -1 }?.let { TextViewCompat.setTextAppearance(this, it) }
|
||||
}.let { textView ->
|
||||
Paint().apply {
|
||||
isAntiAlias = true
|
||||
color = textView.currentTextColor
|
||||
textSize = textView.textSize
|
||||
typeface = textView.typeface
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
|
||||
trackCenterY = measuredHeight / 2F
|
||||
|
||||
val height = trackCenterY + trackHeight / 2F + stepValueMarginTop + stepValuePaint.textSize
|
||||
setMeasuredDimension(measuredWidth, height.toInt())
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
drawTicks(canvas)
|
||||
drawThumb(canvas)
|
||||
drawStepValues(canvas)
|
||||
}
|
||||
|
||||
private fun drawTicks(canvas: Canvas) {
|
||||
if (points.isNullOrEmpty()) return
|
||||
|
||||
val ticksCoordinates = mutableListOf<Float>()
|
||||
points?.forEach { point ->
|
||||
ticksCoordinates.add(normalizeValue(point.toFloat()) * trackWidth + trackSidePadding)
|
||||
ticksCoordinates.add(trackCenterY)
|
||||
}
|
||||
|
||||
val leftPointsSize = points?.count { it < values[0] } ?: 0
|
||||
val rightPointSize = points?.count { it > values[1] } ?: 0
|
||||
val activePointSize = (points?.size ?: 0) - leftPointsSize - rightPointSize
|
||||
|
||||
// Draw inactive ticks to the left of the smallest thumb.
|
||||
canvas.drawPoints(ticksCoordinates.toFloatArray(), 0, leftPointsSize * 2, inactiveTicksPaint)
|
||||
|
||||
// Draw active ticks between the thumbs.
|
||||
canvas.drawPoints(
|
||||
ticksCoordinates.toFloatArray(),
|
||||
leftPointsSize * 2,
|
||||
activePointSize * 2,
|
||||
activeTicksPaint
|
||||
)
|
||||
|
||||
// Draw inactive ticks to the right of the largest thumb.
|
||||
canvas.drawPoints(
|
||||
ticksCoordinates.toFloatArray(),
|
||||
leftPointsSize * 2 + activePointSize * 2,
|
||||
rightPointSize * 2,
|
||||
inactiveTicksPaint
|
||||
)
|
||||
}
|
||||
|
||||
private fun drawThumb(canvas: Canvas) {
|
||||
for (value in values) {
|
||||
canvas.save()
|
||||
canvas.translate(
|
||||
(trackSidePadding + (normalizeValue(value) * trackWidth).toInt() - innerThumbRadius).toFloat(),
|
||||
trackCenterY - innerThumbRadius
|
||||
)
|
||||
thumbDrawable.draw(canvas)
|
||||
canvas.restore()
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawStepValues(canvas: Canvas) {
|
||||
points?.forEach { point ->
|
||||
canvas.drawText(
|
||||
point.toString(),
|
||||
normalizeValue(point.toFloat()) * trackWidth + trackSidePadding,
|
||||
trackCenterY + trackHeight / 2F + stepValueMarginTop + stepValuePaint.textSize - 3F,
|
||||
stepValuePaint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeValue(value: Float) = (value - valueFrom) / (valueTo - valueFrom)
|
||||
|
||||
private enum class Shape(val value: Int) {
|
||||
CIRCLE(CornerFamily.ROUNDED),
|
||||
CUT(CornerFamily.CUT)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package ru.touchin.roboswag.base_filters.range
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import ru.touchin.roboswag.base_filters.databinding.ViewHintInputBinding
|
||||
|
||||
class HintInputView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
defStyleRes: Int = 0
|
||||
) : ConstraintLayout(context, attrs, defStyleAttr, defStyleRes) {
|
||||
|
||||
private val binding = ViewHintInputBinding.inflate(LayoutInflater.from(context), this)
|
||||
|
||||
var inputText: String = ""
|
||||
set(value) {
|
||||
setText(value)
|
||||
field = value
|
||||
}
|
||||
|
||||
fun setHint(value: String?) {
|
||||
binding.startHint.text = value.orEmpty()
|
||||
}
|
||||
|
||||
fun setOnEditorActionListener(listener: TextView.OnEditorActionListener) =
|
||||
binding.editText.setOnEditorActionListener(listener)
|
||||
|
||||
private fun setText(value: String) {
|
||||
binding.editText.run {
|
||||
setText(value)
|
||||
setSelection(text?.length ?: 0)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
package ru.touchin.roboswag.base_filters.range
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.ContextThemeWrapper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import com.google.android.material.slider.RangeSlider
|
||||
import ru.touchin.roboswag.base_filters.R
|
||||
import ru.touchin.roboswag.base_filters.databinding.RangeChoiceViewBinding
|
||||
import ru.touchin.roboswag.base_filters.range.model.FilterRangeItem
|
||||
import ru.touchin.roboswag.base_filters.range.model.SelectedValues
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
typealias FilterRangeChanged = (FilterRangeItem) -> Unit
|
||||
|
||||
class RangeChoiceView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
defStyleRes: Int = 0
|
||||
) : ConstraintLayout(context, attrs, defStyleAttr, defStyleRes) {
|
||||
|
||||
private val defaultTheme = R.style.Theme_FilterRangeSlider
|
||||
private var binding: RangeChoiceViewBinding by Delegates.notNull()
|
||||
|
||||
private var valueChangedAction: FilterRangeChanged? = null
|
||||
|
||||
private var rangeItem: FilterRangeItem? = null
|
||||
set(value) {
|
||||
field = value
|
||||
|
||||
binding.fromInput.inputText = value?.selectedValues?.min?.toString()
|
||||
?: value?.start?.toString().orEmpty()
|
||||
binding.toInput.inputText = value?.selectedValues?.max?.toString()
|
||||
?: value?.end?.toString().orEmpty()
|
||||
|
||||
binding.rangeSlider.run {
|
||||
values = listOf(
|
||||
value?.selectedValues?.min?.toFloat() ?: value?.start?.toFloat(),
|
||||
value?.selectedValues?.max?.toFloat() ?: value?.end?.toFloat()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
context.withStyledAttributes(attrs, R.styleable.FilterRangeChoice, defStyleAttr, defStyleRes) {
|
||||
val theme = getResourceId(R.styleable.FilterRangeChoice_filterRange_theme, defaultTheme)
|
||||
val themeContext = ContextThemeWrapper(context, theme)
|
||||
binding = RangeChoiceViewBinding.inflate(LayoutInflater.from(themeContext), this@RangeChoiceView)
|
||||
|
||||
binding.fromInput.setHint(getString(R.styleable.FilterRangeChoice_filterRange_startHint))
|
||||
binding.toInput.setHint(getString(R.styleable.FilterRangeChoice_filterRange_endHint))
|
||||
binding.rangeSliderGuideline.updateLayoutParams<MarginLayoutParams> {
|
||||
topMargin = getDimension(R.styleable.FilterRangeChoice_filterRange_sliderMargin, 0f).toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setupRangeValues(
|
||||
rangeFilterItem: FilterRangeItem,
|
||||
onChangeCallback: FilterRangeChanged
|
||||
) {
|
||||
rangeItem = rangeFilterItem
|
||||
valueChangedAction = onChangeCallback
|
||||
|
||||
with(binding) {
|
||||
addChangeValuesListener()
|
||||
setupRangeSlider(rangeFilterItem)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetRangeValue() {
|
||||
rangeItem = rangeItem?.resetSelectedValues()
|
||||
}
|
||||
|
||||
private fun addChangeValuesListener() {
|
||||
binding.fromInput.addChangeValueListener { rangeItem?.setValue(selectedMinValue = it.toIntOrNull()) }
|
||||
binding.toInput.addChangeValueListener { rangeItem?.setValue(selectedMaxValue = it.toIntOrNull()) }
|
||||
}
|
||||
|
||||
private fun setupRangeSlider(rangeFilterItem: FilterRangeItem) {
|
||||
with(binding) {
|
||||
rangeSlider.apply {
|
||||
valueFrom = rangeFilterItem.start.toFloat()
|
||||
valueTo = rangeFilterItem.end.toFloat()
|
||||
points = rangeFilterItem.intermediates
|
||||
}
|
||||
|
||||
rangeSlider.addOnChangeListener { _, _, _ ->
|
||||
fromInput.inputText = rangeSlider.values[0].toInt().toString()
|
||||
toInput.inputText = rangeSlider.values[1].toInt().toString()
|
||||
}
|
||||
|
||||
rangeSlider.addOnSliderTouchListener(object : RangeSlider.OnSliderTouchListener {
|
||||
@SuppressLint("RestrictedApi")
|
||||
override fun onStartTrackingTouch(slider: RangeSlider) = Unit
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
override fun onStopTrackingTouch(slider: RangeSlider) {
|
||||
binding.rangeSlider.apply {
|
||||
when (focusedThumbIndex) {
|
||||
0 -> {
|
||||
rangeItem = rangeItem?.setValue(selectedMinValue = from().toInt())
|
||||
rangeItem?.let { valueChangedAction?.invoke(it) }
|
||||
}
|
||||
1 -> {
|
||||
rangeItem = rangeItem?.setValue(selectedMaxValue = to().toInt())
|
||||
rangeItem?.let { valueChangedAction?.invoke(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun HintInputView.addChangeValueListener(updateValue: (String) -> FilterRangeItem?) {
|
||||
setOnEditorActionListener { view, actionId, _ ->
|
||||
if (actionId == EditorInfo.IME_ACTION_DONE) {
|
||||
rangeItem = updateValue(view.text.toString().filterNot { it.isWhitespace() })
|
||||
rangeItem?.let { valueChangedAction?.invoke(it) }
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun RangeSlider.from() = values[0].toInt().toString()
|
||||
|
||||
private fun RangeSlider.to() = values[1].toInt().toString()
|
||||
|
||||
@SuppressWarnings("detekt.ComplexMethod")
|
||||
private fun FilterRangeItem.setValue(
|
||||
selectedMaxValue: Int? = selectedValues?.max,
|
||||
selectedMinValue: Int? = selectedValues?.min
|
||||
): FilterRangeItem {
|
||||
val isMaxValueUpdated = selectedMaxValue != selectedValues?.max
|
||||
val isMinValueUpdated = selectedMinValue != selectedValues?.min
|
||||
|
||||
val isMinValueOutOfRange = selectedMinValue != null && isMinValueUpdated && selectedMinValue > (selectedMaxValue ?: end)
|
||||
val isMaxValueOutOfRange = selectedMaxValue != null && isMaxValueUpdated && selectedMaxValue < (selectedMinValue ?: start)
|
||||
|
||||
val updatedValues = when {
|
||||
selectedMaxValue == end && selectedMinValue == start -> null
|
||||
isMinValueOutOfRange -> SelectedValues(
|
||||
max = selectedMaxValue ?: end,
|
||||
min = selectedMaxValue ?: end
|
||||
)
|
||||
isMaxValueOutOfRange -> SelectedValues(
|
||||
max = selectedMinValue ?: start,
|
||||
min = selectedMinValue ?: start
|
||||
)
|
||||
else -> SelectedValues(
|
||||
max = selectedMaxValue?.takeIf { it < end } ?: end,
|
||||
min = selectedMinValue?.takeIf { it > start } ?: start
|
||||
)
|
||||
}
|
||||
|
||||
return copyWithSelectedValue(selectedValues = updatedValues)
|
||||
}
|
||||
|
||||
private fun FilterRangeItem.resetSelectedValues() = copyWithSelectedValue(selectedValues = null)
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package ru.touchin.roboswag.base_filters.range.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
open class FilterRangeItem(
|
||||
val id: String,
|
||||
val start: Int,
|
||||
val end: Int,
|
||||
val title: String,
|
||||
val intermediates: List<Int>? = null,
|
||||
val step: Int? = null,
|
||||
val selectedValues: SelectedValues? = null
|
||||
) : Parcelable {
|
||||
|
||||
fun isCorrectValues() = end > start
|
||||
|
||||
fun copyWithSelectedValue(selectedValues: SelectedValues?) = FilterRangeItem(
|
||||
id = id,
|
||||
start = start,
|
||||
end = end,
|
||||
title = title,
|
||||
intermediates = intermediates,
|
||||
step = step,
|
||||
selectedValues = selectedValues
|
||||
)
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class SelectedValues(
|
||||
val max: Int,
|
||||
val min: Int
|
||||
) : Parcelable
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
package ru.touchin.roboswag.base_filters.select_list_item
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.ContextThemeWrapper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import ru.touchin.roboswag.base_filters.databinding.SelectionItemBinding
|
||||
import ru.touchin.roboswag.base_filters.select_list_item.adapter.BaseSelectionViewHolder
|
||||
import ru.touchin.roboswag.base_filters.select_list_item.adapter.HolderFactoryType
|
||||
import ru.touchin.roboswag.base_filters.select_list_item.adapter.SelectionItemViewHolder
|
||||
import ru.touchin.roboswag.base_filters.select_list_item.adapter.SheetSelectionAdapter
|
||||
import ru.touchin.roboswag.base_filters.select_list_item.model.BaseSelectionItem
|
||||
import ru.touchin.roboswag.base_filters.SelectionType
|
||||
|
||||
private typealias OnSelectedItemListener<ItemType> = (item: ItemType) -> Unit
|
||||
private typealias OnSelectedItemsListener<ItemType> = (items: List<ItemType>) -> Unit
|
||||
|
||||
/**
|
||||
* Base [ListSelectionView] to use in filters screen for choosing single or multi items in list.
|
||||
*
|
||||
* @param ItemType Type of model's element in list.
|
||||
* It must implement [BaseSelectionItem] abstract class.
|
||||
*
|
||||
* @param HolderType Type of viewHolder in recyclerView.
|
||||
* It must extend [BaseSelectionViewHolder] abstract class.
|
||||
*
|
||||
**/
|
||||
|
||||
class ListSelectionView<ItemType, HolderType> @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : RecyclerView(context, attrs, defStyleAttr)
|
||||
where ItemType : BaseSelectionItem,
|
||||
HolderType : BaseSelectionViewHolder<ItemType> {
|
||||
|
||||
enum class SelectionType { SINGLE_SELECT, MULTI_SELECT }
|
||||
|
||||
constructor(context: Context, @StyleRes themeResId: Int) : this(ContextThemeWrapper(context, themeResId))
|
||||
|
||||
private var mutableItems: List<ItemType> = emptyList()
|
||||
private var selectionType = SelectionType.SINGLE_SELECT
|
||||
|
||||
private var onSelectedItemChanged: OnSelectedItemListener<ItemType>? = null
|
||||
private var onSelectedItemsChanged: OnSelectedItemsListener<ItemType>? = null
|
||||
private var factory: HolderFactoryType<ItemType> = getDefaultFactory()
|
||||
|
||||
init {
|
||||
layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
}
|
||||
|
||||
private fun getDefaultFactory(): HolderFactoryType<ItemType> = { parent, clickListener, selectionType ->
|
||||
SelectionItemViewHolder(
|
||||
binding = SelectionItemBinding.inflate(LayoutInflater.from(parent.context), parent, false),
|
||||
onItemSelectAction = clickListener,
|
||||
selectionType = selectionType
|
||||
)
|
||||
}
|
||||
|
||||
private val selectionAdapter by lazy {
|
||||
SheetSelectionAdapter(
|
||||
onItemSelectAction = onItemSelectedListener,
|
||||
selectionType = selectionType,
|
||||
factory = factory
|
||||
)
|
||||
}
|
||||
|
||||
private val onItemSelectedListener: (item: ItemType) -> Unit = { item ->
|
||||
onSelectedItemChanged?.invoke(item)
|
||||
updateAfterSelection(item)
|
||||
onSelectedItemsChanged?.invoke(mutableItems)
|
||||
}
|
||||
|
||||
fun updateItems(items: List<ItemType>) {
|
||||
mutableItems = items
|
||||
updateList()
|
||||
}
|
||||
|
||||
private fun updateList() {
|
||||
selectionAdapter.submitList(mutableItems)
|
||||
}
|
||||
|
||||
private fun updateAfterSelection(selectedItem: ItemType) {
|
||||
mutableItems = mutableItems.map { item ->
|
||||
when {
|
||||
item.isItemTheSame(selectedItem) -> selectedItem
|
||||
selectionType == SelectionType.SINGLE_SELECT -> item.copyWithSelection(isSelected = false)
|
||||
else -> item
|
||||
}
|
||||
}
|
||||
updateList()
|
||||
}
|
||||
|
||||
inner class Builder {
|
||||
|
||||
fun setItems(items: List<ItemType>) = apply {
|
||||
mutableItems = items
|
||||
}
|
||||
|
||||
fun <T> setItems(
|
||||
source: List<T>,
|
||||
mapper: (T) -> ItemType
|
||||
) = setItems(source.map { item -> mapper.invoke(item) })
|
||||
|
||||
fun showInHolder(holderFactory: HolderFactoryType<ItemType>) = apply {
|
||||
factory = holderFactory
|
||||
}
|
||||
|
||||
fun addItemDecoration(itemDecoration: RecyclerView.ItemDecoration) = apply {
|
||||
this@ListSelectionView.addItemDecoration(itemDecoration)
|
||||
}
|
||||
|
||||
fun onSelectedItemListener(listener: OnSelectedItemListener<ItemType>) = apply {
|
||||
this@ListSelectionView.onSelectedItemChanged = listener
|
||||
}
|
||||
|
||||
fun onSelectedItemsListener(listener: OnSelectedItemsListener<ItemType>) = apply {
|
||||
this@ListSelectionView.onSelectedItemsChanged = listener
|
||||
}
|
||||
|
||||
fun withSelectionType(type: SelectionType) = apply {
|
||||
selectionType = type
|
||||
}
|
||||
|
||||
fun build() = this@ListSelectionView.also {
|
||||
it.adapter = selectionAdapter
|
||||
updateList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package ru.touchin.roboswag.base_filters.select_list_item.adapter
|
||||
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import ru.touchin.roboswag.base_filters.select_list_item.model.BaseSelectionItem
|
||||
|
||||
abstract class BaseSelectionViewHolder<ItemType : BaseSelectionItem>(val view: View)
|
||||
: RecyclerView.ViewHolder(view) {
|
||||
|
||||
abstract fun bind(item: ItemType)
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package ru.touchin.roboswag.base_filters.select_list_item.adapter
|
||||
|
||||
import android.view.View
|
||||
import ru.touchin.roboswag.base_filters.databinding.SelectionItemBinding
|
||||
import ru.touchin.roboswag.base_filters.select_list_item.model.BaseSelectionItem
|
||||
import ru.touchin.roboswag.base_filters.SelectionType
|
||||
|
||||
class SelectionItemViewHolder<ItemType : BaseSelectionItem>(
|
||||
private val binding: SelectionItemBinding,
|
||||
private val onItemSelectAction: (ItemType) -> Unit,
|
||||
private val selectionType: SelectionType
|
||||
) : BaseSelectionViewHolder<ItemType>(binding.root) {
|
||||
|
||||
override fun bind(item: ItemType) {
|
||||
binding.itemTitle.text = item.title
|
||||
binding.itemRadiobutton.isChecked = item.isSelected
|
||||
|
||||
setupCheckListener(item)
|
||||
}
|
||||
|
||||
private fun setupCheckListener(item: ItemType) = with(binding) {
|
||||
val checkListener = View.OnClickListener {
|
||||
itemRadiobutton.isChecked = true
|
||||
onItemSelectAction.invoke(item.copyWithSelection(isSelected = when (selectionType) {
|
||||
SelectionType.SINGLE_SELECT -> true
|
||||
else -> !item.isSelected
|
||||
}))
|
||||
}
|
||||
root.setOnClickListener(checkListener)
|
||||
itemRadiobutton.setOnClickListener(checkListener)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package ru.touchin.roboswag.base_filters.select_list_item.adapter
|
||||
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import ru.touchin.roboswag.base_filters.select_list_item.model.BaseSelectionItem
|
||||
import ru.touchin.roboswag.base_filters.SelectionType
|
||||
import ru.touchin.roboswag.recyclerview_adapters.adapters.DelegationListAdapter
|
||||
|
||||
class SheetSelectionAdapter<ItemType : BaseSelectionItem>(
|
||||
onItemSelectAction: (ItemType) -> Unit,
|
||||
selectionType: SelectionType,
|
||||
factory: HolderFactoryType<ItemType>
|
||||
) : DelegationListAdapter<BaseSelectionItem>(object : DiffUtil.ItemCallback<BaseSelectionItem>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: BaseSelectionItem, newItem: BaseSelectionItem): Boolean =
|
||||
oldItem.isItemTheSame(newItem)
|
||||
|
||||
override fun areContentsTheSame(oldItem: BaseSelectionItem, newItem: BaseSelectionItem): Boolean =
|
||||
oldItem.isContentTheSame(newItem)
|
||||
|
||||
}) {
|
||||
|
||||
init {
|
||||
addDelegate(SheetSelectionDelegate(
|
||||
onItemSelectAction = onItemSelectAction,
|
||||
selectionType = selectionType,
|
||||
factory = factory
|
||||
))
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package ru.touchin.roboswag.base_filters.select_list_item.adapter
|
||||
|
||||
import android.view.ViewGroup
|
||||
import ru.touchin.roboswag.base_filters.select_list_item.model.BaseSelectionItem
|
||||
import ru.touchin.roboswag.base_filters.SelectionType
|
||||
import ru.touchin.roboswag.recyclerview_adapters.adapters.ItemAdapterDelegate
|
||||
|
||||
typealias HolderFactoryType<ItemType> = (ViewGroup, (ItemType) -> Unit, SelectionType) -> BaseSelectionViewHolder<ItemType>
|
||||
|
||||
class SheetSelectionDelegate<ItemType>(
|
||||
private val onItemSelectAction: (ItemType) -> Unit,
|
||||
private val selectionType: SelectionType,
|
||||
private val factory: HolderFactoryType<ItemType>
|
||||
) : ItemAdapterDelegate<BaseSelectionViewHolder<ItemType>, ItemType>()
|
||||
where ItemType : BaseSelectionItem {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup): BaseSelectionViewHolder<ItemType> =
|
||||
factory.invoke(parent, onItemSelectAction, selectionType)
|
||||
|
||||
override fun onBindViewHolder(
|
||||
holder: BaseSelectionViewHolder<ItemType>,
|
||||
item: ItemType,
|
||||
adapterPosition: Int,
|
||||
collectionPosition: Int,
|
||||
payloads: MutableList<Any>
|
||||
) = holder.bind(item)
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package ru.touchin.roboswag.base_filters.select_list_item.model
|
||||
|
||||
abstract class BaseSelectionItem(
|
||||
open val id: Int,
|
||||
open val title: String,
|
||||
open val isSelected: Boolean
|
||||
) {
|
||||
|
||||
abstract fun isItemTheSame(compareItem: BaseSelectionItem): Boolean
|
||||
|
||||
abstract fun isContentTheSame(compareItem: BaseSelectionItem): Boolean
|
||||
|
||||
abstract fun <ItemType> copyWithSelection(isSelected: Boolean): ItemType
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package ru.touchin.roboswag.base_filters.select_list_item.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class DefaultSelectionItem(
|
||||
override val id: Int,
|
||||
override val title: String,
|
||||
override val isSelected: Boolean = false
|
||||
) : BaseSelectionItem(id, title, isSelected), Parcelable {
|
||||
|
||||
override fun isItemTheSame(compareItem: BaseSelectionItem): Boolean = when {
|
||||
compareItem is DefaultSelectionItem && id == compareItem.id -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
override fun isContentTheSame(compareItem: BaseSelectionItem): Boolean =
|
||||
this == compareItem
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <ItemType> copyWithSelection(isSelected: Boolean): ItemType =
|
||||
this.copy(isSelected = isSelected) as ItemType
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
package ru.touchin.roboswag.base_filters.tags
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.core.view.children
|
||||
import com.google.android.material.chip.ChipGroup
|
||||
import ru.touchin.roboswag.base_filters.R
|
||||
import ru.touchin.roboswag.base_filters.SelectionType
|
||||
import ru.touchin.roboswag.base_filters.databinding.LayoutMultiLineTagGroupBinding
|
||||
import ru.touchin.roboswag.base_filters.databinding.LayoutSingleLineTagGroupBinding
|
||||
import ru.touchin.roboswag.base_filters.tags.model.FilterItem
|
||||
import ru.touchin.roboswag.base_filters.tags.model.FilterProperty
|
||||
import ru.touchin.roboswag.components.utils.UiUtils
|
||||
import ru.touchin.roboswag.components.utils.px
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
typealias PropertySelectedAction = (FilterProperty) -> Unit
|
||||
typealias FilterMoreAction = (FilterItem) -> Unit
|
||||
|
||||
class TagLayoutView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private var filterItem: FilterItem by Delegates.notNull()
|
||||
|
||||
private var tagsContainer: ChipGroup by Delegates.notNull()
|
||||
|
||||
private var propertySelectedAction: PropertySelectedAction? = null
|
||||
private var moreValuesAction: FilterMoreAction? = null
|
||||
|
||||
private var selectionType = SelectionType.MULTI_SELECT
|
||||
private var isSingleLine = false
|
||||
private var tagSpacingHorizontalDp: Int = 0
|
||||
private var tagSpacingVerticalDp: Int = 0
|
||||
|
||||
@LayoutRes
|
||||
private var tagLayout: Int = R.layout.layout_default_tag
|
||||
|
||||
private var moreTagText: String = ""
|
||||
private var maxTagCount = Int.MAX_VALUE
|
||||
|
||||
@LayoutRes
|
||||
private var moreTagLayout: Int = tagLayout
|
||||
|
||||
private fun inflateAndGetChipGroup(isSingleLine: Boolean): ChipGroup = when (isSingleLine) {
|
||||
true -> LayoutSingleLineTagGroupBinding.inflate(LayoutInflater.from(context), this, true).tagGroup
|
||||
false -> LayoutMultiLineTagGroupBinding.inflate(LayoutInflater.from(context), this, true).tagGroup
|
||||
}
|
||||
|
||||
private fun createTag(property: FilterProperty): TagView {
|
||||
val tagView = UiUtils.inflate(tagLayout, this)
|
||||
require(tagView is TagView) { "Layout for tag must contain TagView as root view" }
|
||||
|
||||
return tagView.apply {
|
||||
text = property.title
|
||||
isChecked = property.isSelected
|
||||
tagId = property.id
|
||||
|
||||
setOnCheckAction { view, isChecked ->
|
||||
when {
|
||||
selectionType == SelectionType.SINGLE_SELECT && isChecked -> clearCheck(property.id)
|
||||
selectionType == SelectionType.MULTI_SELECT && isChecked -> clearExcludedCheck(property)
|
||||
}
|
||||
view.isChecked = isChecked
|
||||
propertySelectedAction?.invoke(property.copyWithSelected(isSelected = isChecked))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMoreTag(filter: FilterItem): View {
|
||||
val moreTag = UiUtils.inflate(moreTagLayout, this)
|
||||
require(moreTag is TextView) { "Layout for more tag must contain TextView as root view" }
|
||||
|
||||
return moreTag.apply {
|
||||
text = moreTagText
|
||||
setOnClickListener { moreValuesAction?.invoke(filter) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearCheck(selectedId: Int) {
|
||||
tagsContainer.children.forEach { tagView ->
|
||||
if (tagView is TagView && tagView.tagId != selectedId) {
|
||||
tagView.isChecked = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearExcludedCheck(property: FilterProperty) {
|
||||
val excludingIds = property.excludes.map { it.id }
|
||||
|
||||
tagsContainer.children.forEach { tagView ->
|
||||
if (tagView is TagView && tagView.tagId in excludingIds) {
|
||||
tagView.isChecked = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class Builder(private val filterItem: FilterItem) {
|
||||
|
||||
fun onMoreValuesAction(action: FilterMoreAction) = apply {
|
||||
moreValuesAction = action
|
||||
}
|
||||
|
||||
fun onPropertySelectedAction(action: PropertySelectedAction) = apply {
|
||||
propertySelectedAction = action
|
||||
}
|
||||
|
||||
fun setMaxTagCount(count: Int) = apply {
|
||||
maxTagCount = count
|
||||
}
|
||||
|
||||
fun setSpacingHorizontal(horizontalSpacingDp: Int) = apply {
|
||||
tagSpacingHorizontalDp = horizontalSpacingDp
|
||||
}
|
||||
|
||||
fun setSpacingVertical(verticalSpacingDp: Int) = apply {
|
||||
tagSpacingVerticalDp = verticalSpacingDp
|
||||
}
|
||||
|
||||
fun setSpacing(value: Int) = apply {
|
||||
tagSpacingHorizontalDp = value
|
||||
tagSpacingVerticalDp = value
|
||||
}
|
||||
|
||||
fun setSelectionType(type: SelectionType) = apply {
|
||||
selectionType = type
|
||||
}
|
||||
|
||||
fun isSingleLine(value: Boolean) = apply {
|
||||
isSingleLine = value
|
||||
}
|
||||
|
||||
fun setTagLayout(@LayoutRes layoutId: Int) = apply {
|
||||
tagLayout = layoutId
|
||||
}
|
||||
|
||||
fun setMoreTagLayout(@LayoutRes layoutId: Int, text: String) = apply {
|
||||
moreTagLayout = layoutId
|
||||
moreTagText = text
|
||||
}
|
||||
|
||||
fun build() {
|
||||
this@TagLayoutView.filterItem = filterItem
|
||||
tagsContainer = inflateAndGetChipGroup(isSingleLine)
|
||||
|
||||
with(tagsContainer) {
|
||||
removeAllViews()
|
||||
|
||||
this.isSingleLine = isSingleLine
|
||||
|
||||
chipSpacingHorizontal = tagSpacingHorizontalDp.px
|
||||
chipSpacingVertical = tagSpacingVerticalDp.px
|
||||
|
||||
filterItem.properties.take(maxTagCount).forEach { property ->
|
||||
addView(createTag(property))
|
||||
}
|
||||
|
||||
if (filterItem.properties.size > maxTagCount) {
|
||||
addView(createMoreTag(filterItem))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package ru.touchin.roboswag.base_filters.tags
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatCheckBox
|
||||
|
||||
class TagView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
): AppCompatCheckBox(context, attrs, defStyleAttr) {
|
||||
|
||||
var tagId: Int? = null
|
||||
|
||||
private var action: (( view: TagView, isChecked: Boolean) -> Unit)? = null
|
||||
|
||||
init {
|
||||
setOnClickListener {
|
||||
action?.invoke(this, isChecked)
|
||||
}
|
||||
}
|
||||
|
||||
fun setOnCheckAction(action: (view: TagView, isChecked: Boolean) -> Unit) {
|
||||
this.action = action
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package ru.touchin.roboswag.base_filters.tags.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
open class FilterItem(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val properties: List<FilterProperty>
|
||||
) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
open class FilterProperty(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val excludes: List<PropertyExcludingValue>,
|
||||
val isSelected: Boolean = false
|
||||
) : Parcelable {
|
||||
|
||||
open fun copyWithSelected(isSelected: Boolean) = FilterProperty(
|
||||
id = id,
|
||||
title = title,
|
||||
excludes = excludes,
|
||||
isSelected = isSelected
|
||||
)
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
open class PropertyExcludingValue(
|
||||
val id: Int
|
||||
) : Parcelable
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_checked="true" android:color="#12B052" />
|
||||
<item android:state_checked="false" android:color="#1E1E1E" />
|
||||
</selector>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="6dp" />
|
||||
<solid android:color="@android:color/darker_gray"/>
|
||||
</shape>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="6dp" />
|
||||
<solid android:color="@android:color/white"/>
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#12B052" />
|
||||
</shape>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_checked="true" android:drawable="@drawable/background_chip_checked" />
|
||||
<item android:state_checked="false" android:drawable="@drawable/background_chip" />
|
||||
</selector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<corners android:radius="8dp" />
|
||||
<solid android:color="@android:color/white"/>
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/slider_point_inactive" />
|
||||
</shape>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle" >
|
||||
<size
|
||||
android:width="1dp" />
|
||||
<solid
|
||||
android:color="@color/slider_thumb" />
|
||||
<padding
|
||||
android:top="-2sp"
|
||||
android:bottom="-2sp" />
|
||||
</shape>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ru.touchin.roboswag.base_filters.tags.TagView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/background_chip_choice"
|
||||
android:button="@null"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="6dp"
|
||||
android:textColor="@color/color_chip_choice"
|
||||
tools:text="String" />
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/tag_group"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:chipSpacing="8dp"
|
||||
app:selectionRequired="false"
|
||||
app:singleLine="false"
|
||||
app:singleSelection="true" />
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<HorizontalScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/line_tag_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:clipToPadding="false"
|
||||
android:scrollbars="none">
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/tag_group"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:selectionRequired="true" />
|
||||
|
||||
</HorizontalScrollView>
|
||||
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
|
||||
|
||||
<androidx.constraintlayout.widget.Guideline
|
||||
android:id="@+id/center_guideline"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintGuide_percent="0.5" />
|
||||
|
||||
<ru.touchin.roboswag.base_filters.range.HintInputView
|
||||
android:id="@+id/from_input"
|
||||
style="?attr/filterRange_hintViewStyle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="42dp"
|
||||
app:layout_constraintEnd_toStartOf="@+id/center_guideline"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"/>
|
||||
|
||||
<ru.touchin.roboswag.base_filters.range.HintInputView
|
||||
android:id="@+id/to_input"
|
||||
style="?attr/filterRange_hintViewStyle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="42dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="@+id/center_guideline"
|
||||
app:layout_constraintTop_toTopOf="parent"/>
|
||||
|
||||
<ru.touchin.roboswag.base_filters.range.FilterRangeSlider
|
||||
android:id="@+id/range_slider"
|
||||
style="?attr/filterRange_sliderStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toTopOf="@id/range_slider_guideline"
|
||||
app:layout_constraintBottom_toTopOf="@id/range_slider_guideline"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"/>
|
||||
|
||||
<View
|
||||
android:id="@+id/range_slider_guideline"
|
||||
android:layout_height="0dp"
|
||||
android:layout_width="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
app:layout_constraintTop_toBottomOf="@id/from_input"/>
|
||||
|
||||
</merge>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/item_title"
|
||||
style="?attr/sheetSelection_itemStyle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/item_radiobutton"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
tools:text="Заголовок" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatRadioButton
|
||||
android:id="@+id/item_radiobutton"
|
||||
style="?attr/sheetSelection_radioStyle"
|
||||
android:layout_width="22dp"
|
||||
android:layout_height="22dp"
|
||||
android:checked="true"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/edit_text"
|
||||
style="?attr/filterRange_valueEditTextStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/start_hint"
|
||||
style="?attr/filterRange_hintTextStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/input_text"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@+id/input_text" />
|
||||
|
||||
</merge>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<declare-styleable name="FilterSelection">
|
||||
<attr name="sheetSelection_itemStyle" format="reference" />
|
||||
<attr name="sheetSelection_radioStyle" format="reference" />
|
||||
</declare-styleable>
|
||||
|
||||
<declare-styleable name="FilterRangeSlider">
|
||||
<attr name="filterRange_activeTickColor" format="color"/>
|
||||
<attr name="filterRange_inactiveTickColor" format="color"/>
|
||||
<attr name="filterRange_stepValueMarginTop" format="dimension"/>
|
||||
<attr name="filterRange_sliderPointSize" format="dimension"/>
|
||||
<attr name="filterRange_stepTextAppearance" format="reference"/>
|
||||
<attr name="filterRange_pointShape" format="enum">
|
||||
<enum name="circle" value="0" />
|
||||
<enum name="cut" value="1" />
|
||||
</attr>
|
||||
</declare-styleable>
|
||||
|
||||
<declare-styleable name="FilterRangeChoice">
|
||||
<attr name="filterRange_sliderStyle" format="reference" />
|
||||
<attr name="filterRange_hintViewStyle" format="reference" />
|
||||
<attr name="filterRange_editTextStyle" format="reference" />
|
||||
<attr name="filterRange_sliderMargin" format="dimension" />
|
||||
<attr name="filterRange_startHint" format="reference" />
|
||||
<attr name="filterRange_endHint" format="reference" />
|
||||
<attr name="filterRange_theme" format="reference" />
|
||||
</declare-styleable>
|
||||
|
||||
<declare-styleable name="FilterEditTextWithHint">
|
||||
<attr name="filterRange_hintTextStyle" format="reference" />
|
||||
<attr name="filterRange_valueEditTextStyle" format="reference" />
|
||||
</declare-styleable>
|
||||
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="slider_point_inactive">#B9B9B9</color>
|
||||
<color name="slider_point_active">#E35100</color>
|
||||
<color name="slider_track_active_part">#EE9766</color>
|
||||
<color name="slider_track_inactive_part">#E7E7E7</color>
|
||||
<color name="slider_thumb">#E35100</color>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="start_hint">от</string>
|
||||
<string name="end_hint">до</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.FilterSelection" parent="@style/Theme.MaterialComponents.Light.BottomSheetDialog">
|
||||
<item name="sheetSelection_itemStyle">@style/Widget.FilterSelection.Item</item>
|
||||
<item name="sheetSelection_radioStyle">@style/Widget.FilterSelection.Radio</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.FilterSelection.Item" parent="@style/Widget.MaterialComponents.TextView">
|
||||
<item name="android:layout_margin">16dp</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.FilterSelection.Radio" parent="@style/Widget.MaterialComponents.CompoundButton.RadioButton">
|
||||
<item name="android:layout_marginTop">16dp</item>
|
||||
<item name="android:layout_marginEnd">16dp</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.FilterRangeSlider.Default" parent="@style/Widget.MaterialComponents.Slider">
|
||||
<item name="trackColorActive">@color/slider_track_active_part</item>
|
||||
<item name="trackColorInactive">@color/slider_track_inactive_part</item>
|
||||
<item name="trackHeight">3dp</item>
|
||||
<item name="thumbElevation">0dp</item>
|
||||
<item name="thumbColor">@color/slider_thumb</item>
|
||||
<item name="labelBehavior">gone</item>
|
||||
<item name="haloRadius">0dp</item>
|
||||
<item name="filterRange_activeTickColor">@color/slider_point_active</item>
|
||||
<item name="filterRange_inactiveTickColor">@color/slider_point_inactive</item>
|
||||
<item name="filterRange_stepValueMarginTop">12dp</item>
|
||||
<item name="filterRange_sliderPointSize">5dp</item>
|
||||
<item name="filterRange_pointShape">circle</item>
|
||||
<item name="android:layout_marginStart">11dp</item>
|
||||
<item name="android:layout_marginEnd">11dp</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.FilterRangeSlider" parent="@style/Theme.MaterialComponents.Light">
|
||||
<item name="filterRange_sliderStyle">@style/Widget.FilterRangeSlider.Default</item>
|
||||
<item name="filterRange_hintViewStyle">@style/Widget.HintInputView</item>
|
||||
<item name="filterRange_hintTextStyle">@style/Widget.HintInputView.HintText</item>
|
||||
<item name="filterRange_valueEditTextStyle">@style/Widget.HintInputView.EditText</item>
|
||||
</style>
|
||||
|
||||
<style name="FilterRangeChoice">
|
||||
<item name="filterRange_sliderMargin">23dp</item>
|
||||
<item name="filterRange_startHint">@string/start_hint</item>
|
||||
<item name="filterRange_endHint">@string/end_hint</item>
|
||||
<item name="filterRange_theme">@style/Theme.FilterRangeSlider</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.HintInputView" parent="@style/Widget.MaterialComponents.TextView">
|
||||
<item name="android:layout_marginEnd">5dp</item>
|
||||
<item name="android:layout_marginTop">12dp</item>
|
||||
<item name="android:layout_marginStart">16dp</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.HintInputView.HintText" parent="@style/Widget.MaterialComponents.TextView">
|
||||
<item name="android:layout_marginStart">12dp</item>
|
||||
<item name="android:elevation">1dp</item>
|
||||
<item name="android:gravity">center_vertical</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.HintInputView.EditText" parent="@style/Widget.AppCompat.EditText">
|
||||
<item name="android:paddingStart">34dp</item>
|
||||
<item name="android:inputType">number</item>
|
||||
<item name="android:imeOptions">actionDone</item>
|
||||
<item name="android:background">@drawable/background_hint_input</item>
|
||||
<item name="android:textCursorDrawable">@drawable/cursor_background_text_input_view</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
|
|
@ -18,6 +18,10 @@ abstract class AbstractMapManager<TMapView : View, TMap : Any, TLocation : Any>(
|
|||
|
||||
abstract fun getCameraTilt(): Float
|
||||
|
||||
abstract fun getDefaultDuration(): Float
|
||||
|
||||
abstract fun getDefaultZoomStep(): Int
|
||||
|
||||
abstract fun moveCamera(
|
||||
target: TLocation,
|
||||
zoom: Float = getCameraZoom(),
|
||||
|
|
@ -29,12 +33,17 @@ abstract class AbstractMapManager<TMapView : View, TMap : Any, TLocation : Any>(
|
|||
target: TLocation,
|
||||
zoom: Float = getCameraZoom(),
|
||||
azimuth: Float = getCameraAzimuth(),
|
||||
tilt: Float = getCameraTilt()
|
||||
tilt: Float = getCameraTilt(),
|
||||
animationDuration: Float = getDefaultDuration()
|
||||
)
|
||||
|
||||
abstract fun smoothMoveCamera(targets: List<TLocation>, padding: Int = 0)
|
||||
abstract fun smoothMoveCamera(targets: List<TLocation>, padding: Int = 0, animationDuration: Float = getDefaultDuration())
|
||||
|
||||
abstract fun smoothMoveCamera(targets: List<TLocation>, width: Int, height: Int, padding: Int)
|
||||
abstract fun smoothMoveCamera(targets: List<TLocation>, width: Int, height: Int, padding: Int, animationDuration: Float = getDefaultDuration())
|
||||
|
||||
abstract fun increaseZoom(target: TLocation, zoomIncreaseValue: Int = getDefaultZoomStep())
|
||||
|
||||
abstract fun decreaseZoom(target: TLocation, zoomDecreaseValue: Int = getDefaultZoomStep())
|
||||
|
||||
abstract fun setMapAllGesturesEnabled(enabled: Boolean)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
package ru.touchin.basemap
|
||||
|
||||
interface BaseIconGenerator<TPoint, TCluster, TViewIcon> {
|
||||
|
||||
fun getClusterIcon(cluster: TCluster): TViewIcon?
|
||||
|
||||
fun getClusterItemIcon(clusterItem: TPoint): TViewIcon?
|
||||
|
||||
fun getClusterItemView(clusterItem: TPoint): TViewIcon?
|
||||
|
||||
fun getClusterView(cluster: TCluster): TViewIcon?
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package ru.touchin.basemap
|
||||
|
||||
interface BaseMapItemRenderer<TPoint, TCluster, TViewIcon> {
|
||||
|
||||
var iconGenerator: BaseIconGenerator<TPoint, TCluster, TViewIcon>
|
||||
|
||||
fun getClusterItemIcon(item: TPoint): TViewIcon? = iconGenerator.getClusterItemView(item)
|
||||
|
||||
fun getClusterIcon(cluster: TCluster): TViewIcon? = iconGenerator.getClusterView(cluster)
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package ru.touchin.basemap
|
||||
|
||||
import android.util.SparseArray
|
||||
|
||||
inline fun <K, V> MutableMap<K, V>.getOrPutIfNotNull(key: K, defaultValue: () -> V?): V? =
|
||||
get(key) ?: defaultValue()?.also { value ->
|
||||
put(key, value)
|
||||
}
|
||||
|
||||
inline fun <V> SparseArray<V>.getOrPutIfNotNull(key: Int, defaultValue: () -> V?): V? =
|
||||
get(key) ?: defaultValue()?.also { value ->
|
||||
put(key, value)
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="22dp"
|
||||
android:height="22dp"
|
||||
android:viewportWidth="22"
|
||||
android:viewportHeight="22">
|
||||
<path
|
||||
android:pathData="M11,11m-11,0a11,11 0,1 1,22 0a11,11 0,1 1,-22 0"
|
||||
android:fillColor="#FF5100"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
apply from: "../android-configs/lib-config.gradle"
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
dependencies {
|
||||
implementation project(":navigation-base")
|
||||
|
||||
implementation 'androidx.core:core-ktx'
|
||||
implementation 'com.google.android.material:material'
|
||||
|
||||
implementation("androidx.core:core-ktx") {
|
||||
version {
|
||||
require '1.9.0'
|
||||
}
|
||||
}
|
||||
implementation("com.google.android.material:material") {
|
||||
version {
|
||||
require '1.4.0'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
# BottomSheet Utils
|
||||
|
||||
- `BaseBottomSheet` - класс, содержащий парамерты `BottomSheetOptions`
|
||||
|
||||
- `DefaultBottomSheet` - класс с классическим хедером и скруглением, в котором нужно переопределить `createContentView()`
|
||||
|
||||
## BottomSheetOptions
|
||||
- `styleId` - xml-стиль, в котором можно задать скругление
|
||||
- `canDismiss` - может ли модалка быть срыта по тапу/свайпу/backButton
|
||||
- `canTouchOutside` - возможность передавать жесты под модалкой
|
||||
- `isSkipCollapsed` - убирает промежуточное состояние модалки
|
||||
- `isFullscreen` - модалка откроется на весь экран, даже при маленьком контенте
|
||||
- `isShiftedWithKeyboard` - модалка будет полностью подниматься при открытии клавиатуры
|
||||
- `defaultDimAmount` - константное затемнение
|
||||
- `animatedMaxDimAmount` - максимальное затемнение, при этом будет анимироваться в зависимости от offset
|
||||
- `fadeAnimationOptions` - позволяет настроить fade анимацию при изменении высоты
|
||||
- `heightStatesOptions` - позволяет задать 3 состояния высоты модалки
|
||||
|
||||
## ContentFadeAnimationOptions
|
||||
- `foregroundRes` - drawableId, который будет показыватся сверху во время анимации
|
||||
- `duration` - длительность fade анимации
|
||||
- `minAlpha` - минимальная прозрачность во время анимации
|
||||
|
||||
## HeightStatesOptions
|
||||
- `collapsedHeightPx` - высота минимального состояния
|
||||
- `halfExpandedHalfPx` - высота промежуточного состояния
|
||||
- `canTouchOutsideWhenCollapsed` - могут ли жесты передаватья под модалку в минимальном состоянии
|
||||
|
||||
Тестовый проект: https://github.com/duwna/BottomSheets
|
||||
|
|
@ -0,0 +1 @@
|
|||
<manifest package="ru.touchin.roboswag.bottomsheet" />
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
package ru.touchin.roboswag.bottomsheet
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Dialog
|
||||
import android.content.res.Resources
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import kotlin.math.abs
|
||||
|
||||
abstract class BaseBottomSheet : BottomSheetDialogFragment() {
|
||||
|
||||
protected abstract val layoutId: Int
|
||||
|
||||
protected open val bottomSheetOptions = BottomSheetOptions()
|
||||
|
||||
protected val decorView: View
|
||||
get() = checkNotNull(dialog?.window?.decorView)
|
||||
|
||||
protected val bottomSheetView: FrameLayout
|
||||
get() = decorView.findViewById(com.google.android.material.R.id.design_bottom_sheet)
|
||||
|
||||
protected val touchOutsideView: View
|
||||
get() = decorView.findViewById(com.google.android.material.R.id.touch_outside)
|
||||
|
||||
protected val behavior: BottomSheetBehavior<FrameLayout>
|
||||
get() = BottomSheetBehavior.from(bottomSheetView)
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
|
||||
inflater.inflate(layoutId, container)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
bottomSheetOptions.styleId?.let { setStyle(DialogFragment.STYLE_NORMAL, it) }
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
bottomSheetOptions.defaultDimAmount?.let { dialog?.window?.setDimAmount(it) }
|
||||
bottomSheetOptions.animatedMaxDimAmount?.let { setupDimAmountChanges(it) }
|
||||
|
||||
if (bottomSheetOptions.isShiftedWithKeyboard) setupShiftWithKeyboard()
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
isCancelable = bottomSheetOptions.canDismiss
|
||||
return super.onCreateDialog(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
if (bottomSheetOptions.isSkipCollapsed) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
behavior.skipCollapsed = true
|
||||
}
|
||||
|
||||
if (bottomSheetOptions.isFullscreen) {
|
||||
bottomSheetView.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
}
|
||||
|
||||
if (bottomSheetOptions.canTouchOutside) {
|
||||
setupTouchOutside()
|
||||
}
|
||||
|
||||
bottomSheetOptions.fadeAnimationOptions?.let {
|
||||
setupFadeAnimationOnHeightChanges(it)
|
||||
}
|
||||
|
||||
bottomSheetOptions.heightStatesOptions?.let {
|
||||
setupHeightOptions(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupDimAmountChanges(maxDimAmount: Float) {
|
||||
behavior.peekHeight = 0
|
||||
dialog?.window?.setDimAmount(maxDimAmount)
|
||||
|
||||
behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) = Unit
|
||||
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {
|
||||
dialog?.window?.setDimAmount(abs(slideOffset) * maxDimAmount)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun setupFadeAnimationOnHeightChanges(options: ContentFadeAnimationOptions) {
|
||||
val foreground = checkNotNull(
|
||||
ContextCompat.getDrawable(requireContext(), options.foregroundRes)
|
||||
).apply {
|
||||
alpha = 0
|
||||
bottomSheetView.foreground = this
|
||||
}
|
||||
|
||||
bottomSheetView.addOnLayoutChangeListener { _, _, top, _, _, _, oldTop, _, _ ->
|
||||
if (top != oldTop) showFadeAnimation(foreground, options)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFadeAnimation(foreground: Drawable, options: ContentFadeAnimationOptions) {
|
||||
val maxAlpha = 255
|
||||
foreground.alpha = maxAlpha
|
||||
bottomSheetView.alpha = options.minAlpha
|
||||
|
||||
ValueAnimator.ofInt(maxAlpha, 0).apply {
|
||||
duration = options.duration
|
||||
addUpdateListener {
|
||||
val value = it.animatedValue as Int
|
||||
foreground.alpha = value
|
||||
bottomSheetView.alpha = (1 - value.toFloat() / maxAlpha).coerceAtLeast(options.minAlpha)
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupHeightOptions(options: HeightStatesOptions) = with(behavior) {
|
||||
isFitToContents = false
|
||||
peekHeight = options.collapsedHeightPx
|
||||
halfExpandedRatio = options.halfExpandedHalfPx / Resources.getSystem().displayMetrics.heightPixels.toFloat()
|
||||
state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
if (options.canTouchOutsideWhenCollapsed) setupTouchOutsideWhenCollapsed()
|
||||
}
|
||||
|
||||
private fun setupTouchOutsideWhenCollapsed() {
|
||||
setupTouchOutside()
|
||||
|
||||
behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
|
||||
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
when (newState) {
|
||||
BottomSheetBehavior.STATE_COLLAPSED -> setupTouchOutside()
|
||||
else -> touchOutsideView.setOnTouchListener(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun setupShiftWithKeyboard() {
|
||||
dialog?.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun setupTouchOutside() {
|
||||
touchOutsideView.setOnTouchListener { _, event ->
|
||||
requireActivity().dispatchTouchEvent(event)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package ru.touchin.roboswag.bottomsheet
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StyleRes
|
||||
|
||||
/**
|
||||
* See explanation in readme
|
||||
* */
|
||||
data class BottomSheetOptions(
|
||||
@StyleRes val styleId: Int? = null,
|
||||
val canDismiss: Boolean = true,
|
||||
val canTouchOutside: Boolean = false,
|
||||
val isSkipCollapsed: Boolean = true,
|
||||
val isFullscreen: Boolean = false,
|
||||
val isShiftedWithKeyboard: Boolean = false,
|
||||
val defaultDimAmount: Float? = null,
|
||||
val animatedMaxDimAmount: Float? = null,
|
||||
val fadeAnimationOptions: ContentFadeAnimationOptions? = null,
|
||||
val heightStatesOptions: HeightStatesOptions? = null
|
||||
)
|
||||
|
||||
data class ContentFadeAnimationOptions(
|
||||
@DrawableRes val foregroundRes: Int,
|
||||
val duration: Long,
|
||||
val minAlpha: Float
|
||||
)
|
||||
|
||||
data class HeightStatesOptions(
|
||||
val collapsedHeightPx: Int,
|
||||
val halfExpandedHalfPx: Int,
|
||||
val canTouchOutsideWhenCollapsed: Boolean = true
|
||||
)
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package ru.touchin.roboswag.bottomsheet
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import ru.touchin.roboswag.bottomsheet.databinding.DefaultBottomSheetBinding
|
||||
import ru.touchin.roboswag.navigation_base.fragments.viewBinding
|
||||
|
||||
abstract class DefaultBottomSheet : BaseBottomSheet() {
|
||||
|
||||
abstract fun createContentView(inflater: LayoutInflater): View
|
||||
|
||||
final override val layoutId = R.layout.default_bottom_sheet
|
||||
|
||||
override val bottomSheetOptions = BottomSheetOptions(
|
||||
styleId = R.style.RoundedBottomSheetStyle,
|
||||
fadeAnimationOptions = ContentFadeAnimationOptions(
|
||||
foregroundRes = R.drawable.bottom_sheet_background_rounded_16,
|
||||
duration = 150,
|
||||
minAlpha = 0.5f
|
||||
)
|
||||
)
|
||||
|
||||
protected val rootBinding by viewBinding(DefaultBottomSheetBinding::bind)
|
||||
|
||||
protected val contentView: View get() = rootBinding.linearRoot.getChildAt(1)
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
|
||||
super.onCreateView(inflater, container, savedInstanceState)
|
||||
.also {
|
||||
DefaultBottomSheetBinding.bind(checkNotNull(it))
|
||||
.linearRoot.addView(createContentView(inflater))
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
rootBinding.closeText.setOnClickListener { dismiss() }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<shape
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
|
||||
<corners
|
||||
android:topLeftRadius="16dp"
|
||||
android:topRightRadius="16dp" />
|
||||
|
||||
<solid android:color="@android:color/white" />
|
||||
|
||||
</shape>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/linear_root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/top_icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|center"
|
||||
tools:ignore="ContentDescription"
|
||||
tools:src="@android:mipmap/sym_def_app_icon" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/close_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|left"
|
||||
tools:text="Закрыть" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="RoundedBottomSheetStyle" parent="Theme.Design.Light.BottomSheetDialog">
|
||||
<item name="android:windowIsFloating">false</item>
|
||||
<item name="bottomSheetStyle">@style/RoundedBackground</item>
|
||||
</style>
|
||||
|
||||
<style name="RoundedBackground" parent="Widget.Design.BottomSheet.Modal">
|
||||
<item name="android:background">@drawable/bottom_sheet_background_rounded_16</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
|
|
@ -17,7 +17,7 @@ allprojects {
|
|||
google()
|
||||
jcenter()
|
||||
maven {
|
||||
url "https://dl.bintray.com/touchin/touchin-tools"
|
||||
url "https://maven.dev.touchin.ru/"
|
||||
metadataSources {
|
||||
artifact()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
apply from: "../android-configs/lib-config.gradle"
|
||||
|
||||
dependencies {
|
||||
def coroutinesVersion = '1.6.4'
|
||||
def junitVersion = '4.13.2'
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
|
||||
testImplementation("junit:junit")
|
||||
|
||||
constraints {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core") {
|
||||
version {
|
||||
require(coroutinesVersion)
|
||||
}
|
||||
}
|
||||
testImplementation("junit:junit") {
|
||||
version {
|
||||
require(junitVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<manifest package="ru.touchin.roboswag.core.cart_utils" />
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package ru.touchin.roboswag.cart_utils.models
|
||||
|
||||
abstract class CartModel<TProductModel : ProductModel> {
|
||||
|
||||
abstract val products: List<TProductModel>
|
||||
|
||||
open val promocodeList: List<PromocodeModel> = emptyList()
|
||||
|
||||
open val availableBonuses: Int = 0
|
||||
open val usedBonuses: Int = 0
|
||||
|
||||
val availableProducts: List<TProductModel>
|
||||
get() = products.filter { it.isAvailable && !it.isDeleted }
|
||||
|
||||
val totalPrice: Int
|
||||
get() = availableProducts.sumOf { it.countInCart * it.price }
|
||||
|
||||
val totalBonuses: Int
|
||||
get() = availableProducts.sumOf { it.countInCart * (it.bonuses ?: 0) }
|
||||
|
||||
fun getPriceWithPromocode(): Int = promocodeList
|
||||
.sortedByDescending { it.discount is PromocodeDiscount.ByPercent }
|
||||
.fold(initial = totalPrice) { price, promo ->
|
||||
promo.discount.applyTo(price)
|
||||
}
|
||||
|
||||
abstract fun <TCart> copyWith(
|
||||
products: List<TProductModel> = this.products,
|
||||
promocodeList: List<PromocodeModel> = this.promocodeList,
|
||||
usedBonuses: Int = this.usedBonuses
|
||||
): TCart
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <TCart> asCart() = this as TCart
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package ru.touchin.roboswag.cart_utils.models
|
||||
|
||||
abstract class ProductModel {
|
||||
abstract val id: Int
|
||||
abstract val countInCart: Int
|
||||
abstract val price: Int
|
||||
abstract val isAvailable: Boolean
|
||||
abstract val isDeleted: Boolean
|
||||
|
||||
open val bonuses: Int? = null
|
||||
|
||||
open val variants: List<ProductModel> = emptyList()
|
||||
open val selectedVariantId: Int? = null
|
||||
|
||||
val selectedVariant get() = variants.find { it.id == selectedVariantId }
|
||||
|
||||
abstract fun <TProduct> copyWith(
|
||||
countInCart: Int = this.countInCart,
|
||||
isDeleted: Boolean = this.isDeleted,
|
||||
selectedVariantId: Int? = this.selectedVariantId
|
||||
): TProduct
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <TProduct> asProduct(): TProduct = this as TProduct
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package ru.touchin.roboswag.cart_utils.models
|
||||
|
||||
open class PromocodeModel(
|
||||
val code: String,
|
||||
val discount: PromocodeDiscount,
|
||||
)
|
||||
|
||||
abstract class PromocodeDiscount {
|
||||
|
||||
abstract fun applyTo(totalPrice: Int): Int
|
||||
|
||||
class ByValue(private val value: Int) : PromocodeDiscount() {
|
||||
override fun applyTo(totalPrice: Int): Int = totalPrice - value
|
||||
}
|
||||
|
||||
class ByPercent(private val percent: Int) : PromocodeDiscount() {
|
||||
override fun applyTo(totalPrice: Int): Int = totalPrice - totalPrice * percent / 100
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package ru.touchin.roboswag.cart_utils.repositories
|
||||
|
||||
import ru.touchin.roboswag.cart_utils.models.CartModel
|
||||
import ru.touchin.roboswag.cart_utils.models.ProductModel
|
||||
|
||||
/**
|
||||
* Interface for server-side cart repository where each request should return updated [CartModel]
|
||||
*/
|
||||
interface IRemoteCartRepository<TCart : CartModel<TProduct>, TProduct : ProductModel> {
|
||||
|
||||
suspend fun getCart(): TCart
|
||||
|
||||
suspend fun addProduct(product: TProduct): TCart
|
||||
|
||||
suspend fun removeProduct(id: Int): TCart
|
||||
|
||||
suspend fun editProductCount(id: Int, count: Int): TCart
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package ru.touchin.roboswag.cart_utils.repositories
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import ru.touchin.roboswag.cart_utils.models.CartModel
|
||||
import ru.touchin.roboswag.cart_utils.models.ProductModel
|
||||
import ru.touchin.roboswag.cart_utils.models.PromocodeModel
|
||||
|
||||
/**
|
||||
* Class that contains StateFlow of current [CartModel] which can be subscribed in ViewModels
|
||||
*/
|
||||
class LocalCartRepository<TCart : CartModel<TProduct>, TProduct : ProductModel>(
|
||||
initialCart: TCart
|
||||
) {
|
||||
|
||||
private val _currentCart = MutableStateFlow(initialCart)
|
||||
val currentCart = _currentCart.asStateFlow()
|
||||
|
||||
fun updateCart(cart: TCart) {
|
||||
_currentCart.value = cart
|
||||
}
|
||||
|
||||
fun addProduct(product: TProduct) {
|
||||
updateCartProducts {
|
||||
add(product)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeProduct(id: Int) {
|
||||
updateCartProducts {
|
||||
remove(find { it.id == id })
|
||||
}
|
||||
}
|
||||
|
||||
fun editProductCount(id: Int, count: Int) {
|
||||
updateCartProducts {
|
||||
updateProduct(id) { copyWith(countInCart = count) }
|
||||
}
|
||||
}
|
||||
|
||||
fun markProductDeleted(id: Int) {
|
||||
updateCartProducts {
|
||||
updateProduct(id) { copyWith(isDeleted = true) }
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreDeletedProduct(id: Int) {
|
||||
updateCartProducts {
|
||||
updateProduct(id) { copyWith(isDeleted = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun applyPromocode(promocode: PromocodeModel) {
|
||||
updatePromocodeList { add(promocode) }
|
||||
}
|
||||
|
||||
fun removePromocode(code: String) {
|
||||
updatePromocodeList { removeAt(indexOfFirst { it.code == code }) }
|
||||
}
|
||||
|
||||
fun useBonuses(bonuses: Int) {
|
||||
require(currentCart.value.availableBonuses >= bonuses) { "Can't use bonuses more than available" }
|
||||
_currentCart.update { it.copyWith(usedBonuses = bonuses) }
|
||||
}
|
||||
|
||||
fun chooseVariant(productId: Int, variantId: Int?) {
|
||||
updateCartProducts {
|
||||
updateProduct(productId) {
|
||||
if (variantId != null) {
|
||||
check(variants.any { it.id == variantId }) {
|
||||
"Product with id=$productId doesn't have variant with id=$variantId"
|
||||
}
|
||||
}
|
||||
copyWith(selectedVariantId = variantId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCartProducts(updateAction: MutableList<TProduct>.() -> Unit) {
|
||||
_currentCart.update { cart ->
|
||||
cart.copyWith(products = cart.products.toMutableList().apply(updateAction))
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePromocodeList(updateAction: MutableList<PromocodeModel>.() -> Unit) {
|
||||
_currentCart.update { cart ->
|
||||
cart.copyWith(promocodeList = cart.promocodeList.toMutableList().apply(updateAction))
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<TProduct>.updateProduct(id: Int, updateAction: TProduct.() -> TProduct) {
|
||||
val index = indexOfFirst { it.id == id }
|
||||
if (index >= 0) this[index] = updateAction.invoke(this[index])
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package ru.touchin.roboswag.cart_utils.requests_qeue
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.consumeAsFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
/**
|
||||
* Queue for abstract requests which will be executed one after another
|
||||
*/
|
||||
typealias Request<TResponse> = suspend () -> TResponse
|
||||
|
||||
class RequestsQueue<TRequest : Request<*>> {
|
||||
|
||||
private val requestChannel = Channel<TRequest>(capacity = Channel.BUFFERED)
|
||||
|
||||
fun initRequestsExecution(
|
||||
coroutineScope: CoroutineScope,
|
||||
executeRequestAction: suspend (TRequest) -> Unit,
|
||||
) {
|
||||
requestChannel
|
||||
.consumeAsFlow()
|
||||
.onEach { executeRequestAction.invoke(it) }
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
fun addToQueue(request: TRequest) {
|
||||
requestChannel.trySend(request)
|
||||
}
|
||||
|
||||
fun clearQueue() {
|
||||
while (hasPendingRequests()) requestChannel.tryReceive()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun hasPendingRequests() = !requestChannel.isEmpty
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
package ru.touchin.roboswag.cart_utils.update_manager
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import ru.touchin.roboswag.cart_utils.models.CartModel
|
||||
import ru.touchin.roboswag.cart_utils.models.ProductModel
|
||||
import ru.touchin.roboswag.cart_utils.repositories.IRemoteCartRepository
|
||||
import ru.touchin.roboswag.cart_utils.repositories.LocalCartRepository
|
||||
import ru.touchin.roboswag.cart_utils.requests_qeue.Request
|
||||
import ru.touchin.roboswag.cart_utils.requests_qeue.RequestsQueue
|
||||
|
||||
/**
|
||||
* Combines local and remote cart update actions
|
||||
*/
|
||||
open class CartUpdateManager<TCart : CartModel<TProduct>, TProduct : ProductModel>(
|
||||
private val localCartRepository: LocalCartRepository<TCart, TProduct>,
|
||||
private val remoteCartRepository: IRemoteCartRepository<TCart, TProduct>,
|
||||
private val maxRequestAttemptsCount: Int = MAX_REQUEST_CART_ATTEMPTS_COUNT,
|
||||
private val errorHandler: (Throwable) -> Unit = {},
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val MAX_REQUEST_CART_ATTEMPTS_COUNT = 3
|
||||
}
|
||||
|
||||
private val requestsQueue = RequestsQueue<Request<TCart>>()
|
||||
|
||||
@Volatile
|
||||
var lastRemoteCart: TCart? = null
|
||||
private set
|
||||
|
||||
fun initCartRequestsQueue(
|
||||
coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
) {
|
||||
requestsQueue.initRequestsExecution(coroutineScope) { request ->
|
||||
runCatching {
|
||||
lastRemoteCart = request.invoke()
|
||||
if (!requestsQueue.hasPendingRequests()) updateLocalCartWithRemote()
|
||||
}.onFailure { error ->
|
||||
errorHandler.invoke(error)
|
||||
requestsQueue.clearQueue()
|
||||
tryToGetRemoteCartAgain()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun addProduct(product: TProduct, restoreDeleted: Boolean = false) {
|
||||
with(localCartRepository) {
|
||||
if (restoreDeleted) restoreDeletedProduct(product.id) else addProduct(product)
|
||||
}
|
||||
|
||||
requestsQueue.addToQueue {
|
||||
remoteCartRepository.addProduct(product)
|
||||
}
|
||||
}
|
||||
|
||||
open fun removeProduct(id: Int, markDeleted: Boolean = false) {
|
||||
with(localCartRepository) {
|
||||
if (markDeleted) markProductDeleted(id) else removeProduct(id)
|
||||
}
|
||||
|
||||
requestsQueue.addToQueue {
|
||||
remoteCartRepository.removeProduct(id)
|
||||
}
|
||||
}
|
||||
|
||||
open fun editProductCount(id: Int, count: Int) {
|
||||
localCartRepository.editProductCount(id, count)
|
||||
|
||||
requestsQueue.addToQueue {
|
||||
remoteCartRepository.editProductCount(id, count)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun tryToGetRemoteCartAgain() {
|
||||
repeat(maxRequestAttemptsCount) {
|
||||
runCatching {
|
||||
lastRemoteCart = remoteCartRepository.getCart()
|
||||
updateLocalCartWithRemote()
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateLocalCartWithRemote() {
|
||||
val remoteCart = lastRemoteCart ?: return
|
||||
val remoteProducts = remoteCart.products
|
||||
val localProducts = localCartRepository.currentCart.value.products
|
||||
|
||||
val newProductsFromRemoteCart = remoteProducts.filter { remoteProduct ->
|
||||
localProducts.none { it.id == remoteProduct.id }
|
||||
}
|
||||
|
||||
val mergedProducts = localProducts.mapNotNull { localProduct ->
|
||||
val sameRemoteProduct = remoteProducts.find { it.id == localProduct.id }
|
||||
|
||||
when {
|
||||
sameRemoteProduct != null -> sameRemoteProduct
|
||||
localProduct.isDeleted -> localProduct
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
val mergedCart = remoteCart.copyWith<TCart>(products = mergedProducts + newProductsFromRemoteCart)
|
||||
localCartRepository.updateCart(mergedCart)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
apply from: "../android-configs/lib-config.gradle"
|
||||
apply plugin: 'com.huawei.agconnect'
|
||||
|
||||
dependencies {
|
||||
implementation "androidx.core:core"
|
||||
implementation "androidx.annotation:annotation"
|
||||
implementation "com.google.android.gms:play-services-base"
|
||||
implementation "com.huawei.hms:base"
|
||||
|
||||
constraints {
|
||||
implementation("androidx.core:core") {
|
||||
version {
|
||||
require '1.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
implementation("androidx.annotation:annotation") {
|
||||
version {
|
||||
require '1.1.0'
|
||||
}
|
||||
}
|
||||
|
||||
implementation("com.google.android.gms:play-services-base") {
|
||||
version {
|
||||
require '18.0.1'
|
||||
}
|
||||
}
|
||||
|
||||
implementation("com.huawei.hms:base") {
|
||||
version {
|
||||
require '6.3.0.303'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="ru.touchin.client_services">
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package ru.touchin.client_services
|
||||
|
||||
enum class MobileService {
|
||||
HUAWEI_SERVICE, GOOGLE_SERVICE
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package ru.touchin.client_services
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import com.huawei.hms.api.HuaweiApiAvailability
|
||||
|
||||
/**
|
||||
* A class with utils for interacting with Google, Huawei services
|
||||
*/
|
||||
|
||||
class ServicesUtils {
|
||||
|
||||
fun getCurrentService(context: Context): MobileService = when {
|
||||
checkHuaweiServices(context) -> MobileService.HUAWEI_SERVICE
|
||||
checkGooglePlayServices(context) -> MobileService.GOOGLE_SERVICE
|
||||
else -> MobileService.GOOGLE_SERVICE
|
||||
}
|
||||
|
||||
private fun checkHuaweiServices(context: Context): Boolean =
|
||||
HuaweiApiAvailability.getInstance()
|
||||
.isHuaweiMobileNoticeAvailable(context) == ConnectionResult.SUCCESS
|
||||
|
||||
private fun checkGooglePlayServices(context: Context): Boolean =
|
||||
GoogleApiAvailability.getInstance()
|
||||
.isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
apply from: "../android-configs/lib-config.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation project(":mvi-arch")
|
||||
implementation project(":lifecycle")
|
||||
|
||||
implementation "androidx.lifecycle:lifecycle-extensions"
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx")
|
||||
|
||||
def lifecycleVersion = "2.2.0"
|
||||
|
||||
constraints {
|
||||
implementation("androidx.lifecycle:lifecycle-extensions") {
|
||||
version {
|
||||
require '2.1.0'
|
||||
}
|
||||
}
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx") {
|
||||
version {
|
||||
require(lifecycleVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<manifest package="ru.touchin.code_confirm"/>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package ru.touchin.code_confirm
|
||||
|
||||
/**
|
||||
* [CodeConfirmAction] is interface for the action that will call
|
||||
* the confirmation request with entered code
|
||||
*/
|
||||
interface CodeConfirmAction
|
||||
|
||||
/**
|
||||
* [UpdatedCodeInputAction] is interface for the action, that should be called
|
||||
* after each update of codeInput
|
||||
* @param code Updated string with code from codeInput
|
||||
*/
|
||||
interface UpdatedCodeInputAction {
|
||||
val code: String?
|
||||
}
|
||||
|
||||
/**
|
||||
* [GetRefreshCodeAction] is interface for the action that will call
|
||||
* the request of a repeat code after it's expired
|
||||
*/
|
||||
interface GetRefreshCodeAction
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package ru.touchin.code_confirm
|
||||
|
||||
import ru.touchin.roboswag.mvi_arch.marker.ViewState
|
||||
|
||||
abstract class BaseCodeConfirmState(
|
||||
open var codeLifetime: String,
|
||||
open var isLoadingState: Boolean,
|
||||
open var isWrongCode: Boolean,
|
||||
open var isExpired: Boolean,
|
||||
open var isRefreshCodeLoading: Boolean = false,
|
||||
open var needSendCode: Boolean = true
|
||||
) : ViewState {
|
||||
|
||||
val canRequestNewCode: Boolean
|
||||
get() = isExpired && !isRefreshCodeLoading
|
||||
|
||||
abstract fun <T : BaseCodeConfirmState> copyWith(updateBlock: T.() -> Unit): T
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
package ru.touchin.code_confirm
|
||||
|
||||
import android.os.CountDownTimer
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.touchin.code_confirm.LifeTimer.Companion.getFormattedCodeLifetimeString
|
||||
import ru.touchin.lifecycle.extensions.toImmutable
|
||||
import ru.touchin.lifecycle.livedata.SingleLiveEvent
|
||||
import ru.touchin.roboswag.mvi_arch.core.MviViewModel
|
||||
import ru.touchin.roboswag.mvi_arch.marker.ViewAction
|
||||
|
||||
@SuppressWarnings("detekt.TooGenericExceptionCaught")
|
||||
abstract class BaseCodeConfirmViewModel<NavArgs : Parcelable, Action : ViewAction, State : BaseCodeConfirmState>(
|
||||
initialState: State,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : MviViewModel<NavArgs, Action, State>(initialState, savedStateHandle) {
|
||||
|
||||
/** [requireCodeId] uses for auto-filling */
|
||||
protected open var requireCodeId: String? = null
|
||||
|
||||
private var timer: CountDownTimer? = null
|
||||
|
||||
private var currentConfirmationCode: String? = null
|
||||
|
||||
private val _updateCodeEvent = SingleLiveEvent<String>()
|
||||
val updateCodeEvent = _updateCodeEvent.toImmutable()
|
||||
|
||||
init {
|
||||
_state.value = currentState.copyWith {
|
||||
codeLifetime = getFormattedCodeLifetimeString(getTimerDuration().toLong())
|
||||
}
|
||||
|
||||
startTimer(seconds = getTimerDuration())
|
||||
}
|
||||
|
||||
protected abstract fun getTimerDuration(): Int
|
||||
protected abstract suspend fun requestNewCode(): BaseCodeResponse
|
||||
protected abstract suspend fun requestCodeConfirmation(code: String)
|
||||
|
||||
protected open fun onRefreshCodeRequestError(e: Throwable) {}
|
||||
protected open fun onCodeConfirmationError(e: Throwable) {}
|
||||
protected open fun onSuccessCodeConfirmation(code: String) {}
|
||||
|
||||
override fun dispatchAction(action: Action) {
|
||||
super.dispatchAction(action)
|
||||
when (action) {
|
||||
is CodeConfirmAction -> {
|
||||
if (currentState.needSendCode) confirmCode()
|
||||
}
|
||||
is GetRefreshCodeAction -> {
|
||||
getRefreshCode()
|
||||
}
|
||||
is UpdatedCodeInputAction -> {
|
||||
val confirmationCodeChanged = currentConfirmationCode != action.code
|
||||
|
||||
_state.value = currentState.copyWith {
|
||||
isWrongCode = isWrongCode && !confirmationCodeChanged
|
||||
needSendCode = confirmationCodeChanged
|
||||
}
|
||||
currentConfirmationCode = action.code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun startTimer(seconds: Int) {
|
||||
timer?.cancel()
|
||||
timer = LifeTimer(
|
||||
seconds = seconds,
|
||||
tickAction = { millis ->
|
||||
_state.value = currentState.copyWith {
|
||||
codeLifetime = getFormattedCodeLifetimeString(millis)
|
||||
isExpired = false
|
||||
}
|
||||
},
|
||||
finishAction = {
|
||||
_state.value = currentState.copyWith {
|
||||
isExpired = true
|
||||
}
|
||||
}
|
||||
)
|
||||
timer?.start()
|
||||
}
|
||||
|
||||
protected open fun getRefreshCode() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_state.value = currentState.copyWith {
|
||||
isRefreshCodeLoading = true
|
||||
isWrongCode = false
|
||||
}
|
||||
val confirmationData = requestNewCode()
|
||||
requireCodeId = confirmationData.codeId
|
||||
|
||||
startTimer(seconds = confirmationData.codeLifetime)
|
||||
} catch (throwable: Throwable) {
|
||||
_state.value = currentState.copyWith { needSendCode = false }
|
||||
onRefreshCodeRequestError(throwable)
|
||||
} finally {
|
||||
_state.value = currentState.copyWith { isRefreshCodeLoading = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun confirmCode() {
|
||||
currentConfirmationCode?.let { code ->
|
||||
_state.value = currentState.copyWith { isLoadingState = true }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
requestCodeConfirmation(code)
|
||||
onSuccessCodeConfirmation(code)
|
||||
} catch (throwable: Throwable) {
|
||||
_state.value = currentState.copyWith { needSendCode = false }
|
||||
onCodeConfirmationError(throwable)
|
||||
} finally {
|
||||
_state.value = currentState.copyWith { isLoadingState = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun autofillCode(code: String, codeId: String? = null) {
|
||||
if (codeId == requireCodeId) {
|
||||
_updateCodeEvent.setValue(code)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
timer?.cancel()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package ru.touchin.code_confirm
|
||||
|
||||
abstract class BaseCodeResponse(
|
||||
open val codeLifetime: Int,
|
||||
open val codeId: String? = null
|
||||
)
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package ru.touchin.code_confirm
|
||||
|
||||
import android.os.CountDownTimer
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.Date
|
||||
|
||||
/** [LifeTimer] is extends [CountDownTimer] for countdown in seconds and lifetime text formatting
|
||||
* @param seconds Lifetime of timer in seconds
|
||||
* @param tickAction Action will be called on regular interval
|
||||
* @param finishAction Action will be called on finish */
|
||||
class LifeTimer(
|
||||
seconds: Int,
|
||||
private val tickAction: (Long) -> Unit,
|
||||
private val finishAction: () -> Unit
|
||||
) : CountDownTimer(seconds.toLong() * 1000, 1000) {
|
||||
|
||||
override fun onTick(millisUntilFinished: Long) {
|
||||
tickAction.invoke(millisUntilFinished / 1000)
|
||||
}
|
||||
|
||||
override fun onFinish() {
|
||||
finishAction.invoke()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val formatter = SimpleDateFormat("mm:ss", Locale.ROOT)
|
||||
|
||||
fun getFormattedCodeLifetimeString(secondsUntilFinished: Long): String =
|
||||
formatter.format(Date(secondsUntilFinished * 1000L))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -4,6 +4,10 @@ dependencies {
|
|||
api project(":base-map")
|
||||
|
||||
implementation "com.google.android.gms:play-services-maps"
|
||||
implementation "com.google.maps.android:android-maps-utils"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core"
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx"
|
||||
implementation "androidx.core:core-ktx"
|
||||
|
||||
constraints {
|
||||
implementation("com.google.android.gms:play-services-maps") {
|
||||
|
|
@ -11,5 +15,29 @@ dependencies {
|
|||
require '17.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
implementation("com.google.maps.android:android-maps-utils") {
|
||||
version {
|
||||
require '0.4'
|
||||
}
|
||||
}
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core") {
|
||||
version {
|
||||
require '1.4.0'
|
||||
}
|
||||
}
|
||||
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx") {
|
||||
version {
|
||||
require '2.4.1'
|
||||
}
|
||||
}
|
||||
|
||||
implementation("androidx.core:core-ktx") {
|
||||
version {
|
||||
require '1.6.0'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
package ru.touchin.googlemap
|
||||
|
||||
import android.content.Context
|
||||
import android.util.SparseArray
|
||||
import android.view.LayoutInflater
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import com.google.android.gms.maps.model.BitmapDescriptor
|
||||
import com.google.android.gms.maps.model.BitmapDescriptorFactory
|
||||
import com.google.maps.android.clustering.Cluster
|
||||
import com.google.maps.android.clustering.ClusterItem
|
||||
import com.google.maps.android.ui.IconGenerator
|
||||
import ru.touchin.basemap.BaseIconGenerator
|
||||
import ru.touchin.basemap.getOrPutIfNotNull
|
||||
|
||||
open class GoogleIconGenerator<T : ClusterItem>(
|
||||
private val context: Context
|
||||
) : IconGenerator(context), BaseIconGenerator<T, Cluster<T>, BitmapDescriptor> {
|
||||
|
||||
private val clusterIconsCache = SparseArray<BitmapDescriptor>()
|
||||
private val clusterItemIconsCache = mutableMapOf<T, BitmapDescriptor>()
|
||||
|
||||
fun setDefaultViewAndBackground() {
|
||||
val defaultLayout = LayoutInflater.from(context).inflate(R.layout.view_google_map_cluster_item, null)
|
||||
setBackground(ContextCompat.getDrawable(context, R.drawable.default_cluster_background))
|
||||
setContentView(defaultLayout)
|
||||
}
|
||||
|
||||
override fun getClusterIcon(cluster: Cluster<T>): BitmapDescriptor? {
|
||||
val clusterSize = cluster.size
|
||||
return BitmapDescriptorFactory.fromBitmap(makeIcon(clusterSize.toString()))
|
||||
}
|
||||
|
||||
override fun getClusterItemIcon(clusterItem: T): BitmapDescriptor? {
|
||||
val defaultIcon = context.getDrawable(ru.touchin.basemap.R.drawable.marker_default_icon)
|
||||
return BitmapDescriptorFactory.fromBitmap(defaultIcon?.toBitmap())
|
||||
}
|
||||
|
||||
override fun getClusterItemView(clusterItem: T): BitmapDescriptor? =
|
||||
clusterItemIconsCache.getOrPutIfNotNull(clusterItem) { getClusterItemIcon(clusterItem) }
|
||||
|
||||
override fun getClusterView(cluster: Cluster<T>): BitmapDescriptor? =
|
||||
clusterIconsCache.getOrPutIfNotNull(cluster.size) { getClusterIcon(cluster) }
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package ru.touchin.googlemap
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.gms.maps.GoogleMap
|
||||
import com.google.android.gms.maps.model.BitmapDescriptor
|
||||
import com.google.android.gms.maps.model.MarkerOptions
|
||||
import com.google.maps.android.clustering.Cluster
|
||||
import com.google.maps.android.clustering.ClusterItem
|
||||
import com.google.maps.android.clustering.ClusterManager
|
||||
import com.google.maps.android.clustering.view.DefaultClusterRenderer
|
||||
import ru.touchin.basemap.BaseIconGenerator
|
||||
|
||||
open class GoogleMapItemRenderer<TClusterItem : ClusterItem>(
|
||||
val context: Context,
|
||||
googleMap: GoogleMap,
|
||||
clusterManager: ClusterManager<TClusterItem>,
|
||||
private val minClusterItemSize: Int = 1
|
||||
) : DefaultClusterRenderer<TClusterItem>(context, googleMap, clusterManager) {
|
||||
|
||||
var iconGenerator: BaseIconGenerator<TClusterItem, Cluster<TClusterItem>, BitmapDescriptor> =
|
||||
GoogleIconGenerator<TClusterItem>(context).apply { setDefaultViewAndBackground() }
|
||||
|
||||
override fun shouldRenderAsCluster(cluster: Cluster<TClusterItem>): Boolean =
|
||||
cluster.size > minClusterItemSize
|
||||
|
||||
override fun onBeforeClusterItemRendered(item: TClusterItem, markerOptions: MarkerOptions) {
|
||||
markerOptions.icon(getMarkerIcon(item))
|
||||
}
|
||||
|
||||
override fun onBeforeClusterRendered(cluster: Cluster<TClusterItem>, markerOptions: MarkerOptions) {
|
||||
markerOptions.icon(getClusterIcon(cluster = cluster))
|
||||
}
|
||||
|
||||
private fun getMarkerIcon(item: TClusterItem): BitmapDescriptor? =
|
||||
iconGenerator.getClusterItemView(item)
|
||||
|
||||
private fun getClusterIcon(cluster: Cluster<TClusterItem>): BitmapDescriptor? =
|
||||
iconGenerator.getClusterView(cluster)
|
||||
|
||||
}
|
||||
|
|
@ -13,9 +13,14 @@ import ru.touchin.basemap.AbstractMapManager
|
|||
@Suppress("detekt.TooManyFunctions")
|
||||
class GoogleMapManager(mapView: MapView) : AbstractMapManager<MapView, GoogleMap, LatLng>(mapView) {
|
||||
|
||||
companion object {
|
||||
private const val CAMERA_ANIMATION_DURATION = 1f
|
||||
private const val CAMERA_DEFAULT_STEP = 2
|
||||
}
|
||||
|
||||
override fun initialize(mapListener: AbstractMapListener<MapView, GoogleMap, LatLng>?) {
|
||||
super.initialize(mapListener)
|
||||
mapView.getMapAsync(::initMap)
|
||||
mapView.getMapAsync(this::initMap)
|
||||
}
|
||||
|
||||
override fun initMap(map: GoogleMap) {
|
||||
|
|
@ -77,22 +82,52 @@ class GoogleMapManager(mapView: MapView) : AbstractMapManager<MapView, GoogleMap
|
|||
|
||||
override fun getCameraTilt(): Float = map.cameraPosition.tilt
|
||||
|
||||
override fun getDefaultDuration(): Float = CAMERA_ANIMATION_DURATION
|
||||
|
||||
override fun getDefaultZoomStep(): Int = CAMERA_DEFAULT_STEP
|
||||
|
||||
override fun moveCamera(target: LatLng, zoom: Float, azimuth: Float, tilt: Float) {
|
||||
map.moveCamera(CameraUpdateFactory.newCameraPosition(buildCameraPosition(target, zoom, azimuth, tilt)))
|
||||
}
|
||||
|
||||
override fun smoothMoveCamera(target: LatLng, zoom: Float, azimuth: Float, tilt: Float) {
|
||||
map.animateCamera(CameraUpdateFactory.newCameraPosition(buildCameraPosition(target, zoom, azimuth, tilt)))
|
||||
override fun smoothMoveCamera(target: LatLng, zoom: Float, azimuth: Float, tilt: Float, animationDuration: Float) {
|
||||
map.animateCamera(
|
||||
CameraUpdateFactory.newCameraPosition(buildCameraPosition(target, zoom, azimuth, tilt)),
|
||||
animationDuration.toInt(),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
override fun smoothMoveCamera(targets: List<LatLng>, padding: Int) {
|
||||
override fun smoothMoveCamera(targets: List<LatLng>, padding: Int, animationDuration: Float) {
|
||||
val boundingBox = getBoundingBox(targets)
|
||||
map.animateCamera(CameraUpdateFactory.newLatLngBounds(boundingBox, padding))
|
||||
map.animateCamera(
|
||||
CameraUpdateFactory.newLatLngBounds(boundingBox, padding),
|
||||
animationDuration.toInt(),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
override fun smoothMoveCamera(targets: List<LatLng>, width: Int, height: Int, padding: Int) {
|
||||
override fun smoothMoveCamera(targets: List<LatLng>, width: Int, height: Int, padding: Int, animationDuration: Float) {
|
||||
val boundingBox = getBoundingBox(targets)
|
||||
map.animateCamera(CameraUpdateFactory.newLatLngBounds(boundingBox, width, height, padding))
|
||||
map.animateCamera(
|
||||
CameraUpdateFactory.newLatLngBounds(boundingBox, width, height, padding),
|
||||
animationDuration.toInt(),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
override fun increaseZoom(target: LatLng, zoomIncreaseValue: Int) {
|
||||
smoothMoveCamera(
|
||||
target = target,
|
||||
zoom = getCameraZoom() + zoomIncreaseValue
|
||||
)
|
||||
}
|
||||
|
||||
override fun decreaseZoom(target: LatLng, zoomDecreaseValue: Int) {
|
||||
smoothMoveCamera(
|
||||
target = target,
|
||||
zoom = getCameraZoom() - zoomDecreaseValue
|
||||
)
|
||||
}
|
||||
|
||||
override fun setMapAllGesturesEnabled(enabled: Boolean) = map.uiSettings.setAllGesturesEnabled(enabled)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
package ru.touchin.googlemap
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.gms.maps.GoogleMap
|
||||
import com.google.android.gms.maps.model.VisibleRegion
|
||||
import com.google.maps.android.clustering.Cluster
|
||||
import com.google.maps.android.clustering.ClusterItem
|
||||
import com.google.maps.android.clustering.ClusterManager
|
||||
import com.google.maps.android.clustering.algo.Algorithm
|
||||
import com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm
|
||||
import com.google.maps.android.clustering.algo.PreCachingAlgorithmDecorator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.sample
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
|
||||
class GooglePlacemarkManager<TClusterItem : ClusterItem>(
|
||||
context: Context,
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
private val googleMap: GoogleMap,
|
||||
clusterItemTapAction: (TClusterItem) -> Boolean,
|
||||
clusterTapAction: (Cluster<TClusterItem>) -> Boolean,
|
||||
clusterAlgorithm: Algorithm<TClusterItem> = PreCachingAlgorithmDecorator(NonHierarchicalDistanceBasedAlgorithm())
|
||||
) : ClusterManager<TClusterItem>(context, googleMap), GoogleMap.OnCameraIdleListener {
|
||||
|
||||
private var clusteringJob: Job? = null
|
||||
private val onVisibilityChangedEvent = MutableSharedFlow<Pair<VisibleRegion?, List<TClusterItem>>>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
|
||||
private var cameraIdleJob: Job? = null
|
||||
private val onCameraIdleEvent = MutableSharedFlow<Boolean>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
|
||||
private val markers = mutableListOf<TClusterItem>()
|
||||
private var lastVisibleItems = emptyList<TClusterItem>()
|
||||
|
||||
var onCameraIdleListener: (() -> Unit)? = null
|
||||
|
||||
var clusterRenderer: GoogleMapItemRenderer<TClusterItem>? = null
|
||||
set(value) {
|
||||
field = value
|
||||
setRenderer(value)
|
||||
}
|
||||
|
||||
init {
|
||||
googleMap.setOnCameraIdleListener(this)
|
||||
googleMap.setOnMarkerClickListener(this)
|
||||
setAlgorithm(clusterAlgorithm)
|
||||
setOnClusterClickListener(clusterTapAction)
|
||||
setOnClusterItemClickListener(clusterItemTapAction)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun addItems(items: Collection<TClusterItem>) {
|
||||
markers.addAll(items)
|
||||
onDataChanged()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun addItem(clusterItem: TClusterItem) {
|
||||
markers.add(clusterItem)
|
||||
onDataChanged()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun removeItem(atmClusterItem: TClusterItem) {
|
||||
markers.remove(atmClusterItem)
|
||||
onDataChanged()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun clearItems() {
|
||||
markers.clear()
|
||||
onDataChanged()
|
||||
}
|
||||
|
||||
override fun onCameraIdle() {
|
||||
onDataChanged()
|
||||
onCameraIdleEvent.tryEmit(true)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setItems(items: Collection<TClusterItem>) {
|
||||
markers.clear()
|
||||
markers.addAll(items)
|
||||
onDataChanged()
|
||||
}
|
||||
|
||||
fun startClustering() {
|
||||
if (clusteringJob != null || cameraIdleJob != null) return
|
||||
clusteringJob = lifecycleOwner.lifecycleScope.launchWhenStarted {
|
||||
onVisibilityChangedEvent
|
||||
.debounce(CLUSTERING_START_DEBOUNCE_MILLI)
|
||||
.flowOn(Dispatchers.Default)
|
||||
.onStart { emit(getData()) }
|
||||
.mapNotNull { (region, items) -> findItemsInRegion(region, items) }
|
||||
.sample(CLUSTERING_NEW_LIST_CONSUMING_THROTTLE_MILLIS)
|
||||
.catch { emit(lastVisibleItems) }
|
||||
.flowOn(Dispatchers.Main)
|
||||
.collect { markersToShow ->
|
||||
lastVisibleItems = markersToShow
|
||||
super.clearItems()
|
||||
super.addItems(markersToShow)
|
||||
cluster()
|
||||
}
|
||||
}
|
||||
listenToCameraIdleEvents()
|
||||
}
|
||||
|
||||
private fun listenToCameraIdleEvents() {
|
||||
cameraIdleJob = lifecycleOwner.lifecycleScope.launchWhenStarted {
|
||||
onCameraIdleEvent
|
||||
.debounce(CAMERA_DEBOUNCE_MILLI)
|
||||
.flowOn(Dispatchers.Main)
|
||||
.collect {
|
||||
onCameraIdleListener?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopClustering() {
|
||||
clusteringJob?.cancel()
|
||||
cameraIdleJob?.cancel()
|
||||
}
|
||||
|
||||
private fun onDataChanged() {
|
||||
onVisibilityChangedEvent.tryEmit(getData())
|
||||
}
|
||||
|
||||
private fun getData(): Pair<VisibleRegion?, List<TClusterItem>> =
|
||||
googleMap.projection.visibleRegion to markers
|
||||
|
||||
private fun findItemsInRegion(region: VisibleRegion?, items: List<TClusterItem>): List<TClusterItem>? =
|
||||
region?.let { items.filter { item -> item.position in region.latLngBounds } }
|
||||
|
||||
private companion object {
|
||||
const val CAMERA_DEBOUNCE_MILLI = 50L
|
||||
|
||||
const val CLUSTERING_START_DEBOUNCE_MILLI = 50L
|
||||
|
||||
const val CLUSTERING_NEW_LIST_CONSUMING_THROTTLE_MILLIS = 350L
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#FF5722"/>
|
||||
</shape>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:minWidth="36dp"
|
||||
android:minHeight="52dp"
|
||||
android:padding="6dp"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="17sp"/>
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
apply from: "../android-configs/lib-config.gradle"
|
||||
|
||||
android {
|
||||
namespace "ru.touchin.extensions"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "androidx.recyclerview:recyclerview"
|
||||
implementation "androidx.fragment:fragment-ktx"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
package ru.touchin.extensions
|
||||
|
||||
import android.content.res.TypedArray
|
||||
import androidx.annotation.StyleableRes
|
||||
|
||||
private const val NOT_FOUND_VALUE = -1
|
||||
|
||||
fun TypedArray.getResourceIdOrNull(@StyleableRes index: Int) = getResourceId(index, NOT_FOUND_VALUE)
|
||||
.takeIf { it != NOT_FOUND_VALUE }
|
||||
|
||||
fun TypedArray.getColorOrNull(@StyleableRes index: Int) = getColor(index, NOT_FOUND_VALUE)
|
||||
.takeIf { it != NOT_FOUND_VALUE }
|
||||
|
|
@ -9,13 +9,15 @@ object ActionThrottler {
|
|||
// action invoking start user may be in time to click and launch action again
|
||||
private const val PREVENTION_OF_CLICK_AGAIN_COEFFICIENT = 2
|
||||
private const val DELAY_MS = PREVENTION_OF_CLICK_AGAIN_COEFFICIENT * RIPPLE_EFFECT_DELAY_MS
|
||||
|
||||
const val DEFAULT_THROTTLE_DELAY_MS = 500L
|
||||
private var lastActionTime = 0L
|
||||
|
||||
fun throttleAction(action: () -> Unit): Boolean {
|
||||
fun throttleAction(throttleDelay: Long = DELAY_MS, action: () -> Unit): Boolean {
|
||||
val currentTime = SystemClock.elapsedRealtime()
|
||||
val diff = currentTime - lastActionTime
|
||||
|
||||
return if (diff >= DELAY_MS) {
|
||||
return if (diff >= throttleDelay) {
|
||||
lastActionTime = currentTime
|
||||
action.invoke()
|
||||
true
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
apply from: "../android-configs/lib-config.gradle"
|
||||
|
||||
android {
|
||||
namespace "ru.touchin.lifecycle.rx"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api project(":utils")
|
||||
api project(":logging")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
apply from: "../android-configs/lib-config.gradle"
|
||||
|
||||
android {
|
||||
namespace "ru.touchin.lifecycle_viewcontroller"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(":lifecycle")
|
||||
implementation project(":navigation-viewcontroller")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
apply from: "../android-configs/lib-config.gradle"
|
||||
|
||||
android {
|
||||
namespace "ru.touchin.lifecycle"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly "javax.inject:javax.inject:1"
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue