diff --git a/base-filters/.gitignore b/base-filters/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/base-filters/.gitignore @@ -0,0 +1 @@ +/build diff --git a/base-filters/README.md b/base-filters/README.md new file mode 100644 index 0000000..95bf577 --- /dev/null +++ b/base-filters/README.md @@ -0,0 +1,178 @@ +# Описание + +Модуль содержит реализацию следующих типов фильтров: + +1. Выбор одного/нескольких из доступных значений списка +2. Выбор одного/нескольких значений из перечня тегов +3. Выбор минимального и максимального значения из диапозона + +# Использование + +## 1. Выбор одного/нескольких из доступных значений списка + +### Как использовать +``` kotlin +val selectorView = ListSelectionView>(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` - класс viewHolder-а в recyclerView. +Для использования дефолтной реализации необходимо использовать типы `>` +* в метод `setItems(List)` необходимо передать список объектов +* метод `addItemDecoration(itemDecoration: RecyclerView.ItemDecoration)` можно использовать для передачи объекта `RecyclerView.ItemDecoration` +* метод `withSelectionType(type: SelectionType)` используется для указания типа выбора: + * `SINGLE_SELECT` - по умолчанию - позволяет выбрать один выариант, при этом будет выбран всегда как минимум один вариант + * `MULTI_SELECT` - позволяет выбрать несколько вариантов из списка, при этом можно полностью выбрать все варианты и убрать выделение со всех вариантов +* метод `showInHolder(HolderFactoryType)` используется для определения кастомного viewHolder для списка с недефолтной разметкой. +``` kotlin +val selectorView = ListSelectionView(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` после каждого выбора +* колбэк `onSelectedItemListener(listener: OnSelectedItemListener)` можно использовать для получения элемента списка `ItemType`, по которому произошел клик +* после вызова конфигурационных методов обязательно необходимо вызать метод `build()` + +### Кастомизация стиля дефолтной реализации ViewHolder без необходимости создания кастомного layout и viewHolder + +#### 1) Определить кастомную тему и стили элементов +1. Стиль для **текста элемента списка** должен быть наследником стиля `Widget.FilterSelection.Item` +``` xml + +``` +2. Стиль для **индикатора выбора** должен быть наследником стиля `Widget.FilterSelection.Radio` +Передайте `selector-drawable` для кастомизации вида индикатора в конце строки +``` xml + +``` +3. Создайте **тему**, которая должна быть наследником `Theme.FilterSelection` +``` xml + +``` +#### 2) Применить тему при создании view +При создании вью в коде можно указать тему, используя `ContextThemeWrapper` +``` kotlin +val newContext = ContextThemeWrapper(requireContext(), R.style.Theme_Custom_FilterSelection) +val selectorView = ListSelectionView(newContext) + .Builder() + ... + .build() +``` + +## 2. Выбор одного/нескольких значений из перечня тегов + +* `TagLayoutView` - view-контейнер для тегов +* `TagView` - view для тега. Кастомная разметка для тега должна содержать в корне `TagView` + +### Как использовать +``` 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` - по умолчанию - мультивыбор тегов с учетом исключающих фильтров +* метод `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` - слайдер - Можно использовать как отдельный элемент +* `HintInputView` - view для редактируемого поля начала и окончания диапозона + +### Как использовать + +В разметке +``` xml + +``` + +Настройка в коде +``` 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` diff --git a/base-filters/build.gradle b/base-filters/build.gradle new file mode 100644 index 0000000..6c57558 --- /dev/null +++ b/base-filters/build.gradle @@ -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' + } + } + } +} diff --git a/base-filters/src/main/AndroidManifest.xml b/base-filters/src/main/AndroidManifest.xml new file mode 100644 index 0000000..14e5b35 --- /dev/null +++ b/base-filters/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/SelectionType.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/SelectionType.kt new file mode 100644 index 0000000..31f1f0e --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/SelectionType.kt @@ -0,0 +1,5 @@ +package ru.touchin.roboswag.base_filters + +enum class SelectionType { + SINGLE_SELECT, MULTI_SELECT +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/FilterRangeSlider.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/FilterRangeSlider.kt new file mode 100644 index 0000000..0fdfc1d --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/FilterRangeSlider.kt @@ -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? = 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() + 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) + } +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/HintInputView.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/HintInputView.kt new file mode 100644 index 0000000..9f860a6 --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/HintInputView.kt @@ -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) + } + } + +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/RangeChoiceView.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/RangeChoiceView.kt new file mode 100644 index 0000000..c03143a --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/RangeChoiceView.kt @@ -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 { + 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) + +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/model/FilterRangeItem.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/model/FilterRangeItem.kt new file mode 100644 index 0000000..1c452de --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/model/FilterRangeItem.kt @@ -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? = 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 diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/ListSelectionView.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/ListSelectionView.kt new file mode 100644 index 0000000..3e9e903 --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/ListSelectionView.kt @@ -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 = (item: ItemType) -> Unit +private typealias OnSelectedItemsListener = (items: List) -> 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 @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : RecyclerView(context, attrs, defStyleAttr) + where ItemType : BaseSelectionItem, + HolderType : BaseSelectionViewHolder { + + enum class SelectionType { SINGLE_SELECT, MULTI_SELECT } + + constructor(context: Context, @StyleRes themeResId: Int) : this(ContextThemeWrapper(context, themeResId)) + + private var mutableItems: List = emptyList() + private var selectionType = SelectionType.SINGLE_SELECT + + private var onSelectedItemChanged: OnSelectedItemListener? = null + private var onSelectedItemsChanged: OnSelectedItemsListener? = null + private var factory: HolderFactoryType = getDefaultFactory() + + init { + layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + layoutManager = LinearLayoutManager(context) + } + + private fun getDefaultFactory(): HolderFactoryType = { 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) { + 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) = apply { + mutableItems = items + } + + fun setItems( + source: List, + mapper: (T) -> ItemType + ) = setItems(source.map { item -> mapper.invoke(item) }) + + fun showInHolder(holderFactory: HolderFactoryType) = apply { + factory = holderFactory + } + + fun addItemDecoration(itemDecoration: RecyclerView.ItemDecoration) = apply { + this@ListSelectionView.addItemDecoration(itemDecoration) + } + + fun onSelectedItemListener(listener: OnSelectedItemListener) = apply { + this@ListSelectionView.onSelectedItemChanged = listener + } + + fun onSelectedItemsListener(listener: OnSelectedItemsListener) = apply { + this@ListSelectionView.onSelectedItemsChanged = listener + } + + fun withSelectionType(type: SelectionType) = apply { + selectionType = type + } + + fun build() = this@ListSelectionView.also { + it.adapter = selectionAdapter + updateList() + } + } +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/BaseSelectionViewHolder.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/BaseSelectionViewHolder.kt new file mode 100644 index 0000000..847bfc2 --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/BaseSelectionViewHolder.kt @@ -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(val view: View) + : RecyclerView.ViewHolder(view) { + + abstract fun bind(item: ItemType) +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/SelectionItemViewHolder.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/SelectionItemViewHolder.kt new file mode 100644 index 0000000..f08dd4b --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/SelectionItemViewHolder.kt @@ -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( + private val binding: SelectionItemBinding, + private val onItemSelectAction: (ItemType) -> Unit, + private val selectionType: SelectionType +) : BaseSelectionViewHolder(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) + } + +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/SheetSelectionAdapter.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/SheetSelectionAdapter.kt new file mode 100644 index 0000000..9424309 --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/SheetSelectionAdapter.kt @@ -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( + onItemSelectAction: (ItemType) -> Unit, + selectionType: SelectionType, + factory: HolderFactoryType +) : DelegationListAdapter(object : DiffUtil.ItemCallback() { + + 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 + )) + } + +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/SheetSelectionDelegate.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/SheetSelectionDelegate.kt new file mode 100644 index 0000000..39021ab --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/SheetSelectionDelegate.kt @@ -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 = (ViewGroup, (ItemType) -> Unit, SelectionType) -> BaseSelectionViewHolder + +class SheetSelectionDelegate( + private val onItemSelectAction: (ItemType) -> Unit, + private val selectionType: SelectionType, + private val factory: HolderFactoryType +) : ItemAdapterDelegate, ItemType>() + where ItemType : BaseSelectionItem { + + override fun onCreateViewHolder(parent: ViewGroup): BaseSelectionViewHolder = + factory.invoke(parent, onItemSelectAction, selectionType) + + override fun onBindViewHolder( + holder: BaseSelectionViewHolder, + item: ItemType, + adapterPosition: Int, + collectionPosition: Int, + payloads: MutableList + ) = holder.bind(item) + +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/model/BaseSelectionItem.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/model/BaseSelectionItem.kt new file mode 100644 index 0000000..8f59990 --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/model/BaseSelectionItem.kt @@ -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 copyWithSelection(isSelected: Boolean): ItemType +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/model/DefaultSelectionItem.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/model/DefaultSelectionItem.kt new file mode 100644 index 0000000..603961f --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/model/DefaultSelectionItem.kt @@ -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 copyWithSelection(isSelected: Boolean): ItemType = + this.copy(isSelected = isSelected) as ItemType +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/tags/TagLayoutView.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/tags/TagLayoutView.kt new file mode 100644 index 0000000..2c20c82 --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/tags/TagLayoutView.kt @@ -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)) + } + } + } + } +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/tags/TagView.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/tags/TagView.kt new file mode 100644 index 0000000..4a4ee35 --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/tags/TagView.kt @@ -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 + } +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/tags/model/FilterProperty.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/tags/model/FilterProperty.kt new file mode 100644 index 0000000..c45742d --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/tags/model/FilterProperty.kt @@ -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 +) : Parcelable + +@Parcelize +open class FilterProperty( + val id: Int, + val title: String, + val excludes: List, + 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 diff --git a/base-filters/src/main/res/color/color_chip_choice.xml b/base-filters/src/main/res/color/color_chip_choice.xml new file mode 100644 index 0000000..d19976d --- /dev/null +++ b/base-filters/src/main/res/color/color_chip_choice.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/base-filters/src/main/res/drawable/background_chip.xml b/base-filters/src/main/res/drawable/background_chip.xml new file mode 100644 index 0000000..177d7bd --- /dev/null +++ b/base-filters/src/main/res/drawable/background_chip.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/base-filters/src/main/res/drawable/background_chip_checked.xml b/base-filters/src/main/res/drawable/background_chip_checked.xml new file mode 100644 index 0000000..9e00394 --- /dev/null +++ b/base-filters/src/main/res/drawable/background_chip_checked.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/base-filters/src/main/res/drawable/background_chip_choice.xml b/base-filters/src/main/res/drawable/background_chip_choice.xml new file mode 100644 index 0000000..cef4af3 --- /dev/null +++ b/base-filters/src/main/res/drawable/background_chip_choice.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/base-filters/src/main/res/drawable/background_hint_input.xml b/base-filters/src/main/res/drawable/background_hint_input.xml new file mode 100644 index 0000000..354e22c --- /dev/null +++ b/base-filters/src/main/res/drawable/background_hint_input.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/base-filters/src/main/res/drawable/cursor_background_text_input_view.xml b/base-filters/src/main/res/drawable/cursor_background_text_input_view.xml new file mode 100644 index 0000000..4633444 --- /dev/null +++ b/base-filters/src/main/res/drawable/cursor_background_text_input_view.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/base-filters/src/main/res/layout/layout_default_tag.xml b/base-filters/src/main/res/layout/layout_default_tag.xml new file mode 100644 index 0000000..a5236c7 --- /dev/null +++ b/base-filters/src/main/res/layout/layout_default_tag.xml @@ -0,0 +1,12 @@ + + diff --git a/base-filters/src/main/res/layout/layout_multi_line_tag_group.xml b/base-filters/src/main/res/layout/layout_multi_line_tag_group.xml new file mode 100644 index 0000000..27118fd --- /dev/null +++ b/base-filters/src/main/res/layout/layout_multi_line_tag_group.xml @@ -0,0 +1,11 @@ + + diff --git a/base-filters/src/main/res/layout/layout_single_line_tag_group.xml b/base-filters/src/main/res/layout/layout_single_line_tag_group.xml new file mode 100644 index 0000000..3a32ef3 --- /dev/null +++ b/base-filters/src/main/res/layout/layout_single_line_tag_group.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/base-filters/src/main/res/layout/range_choice_view.xml b/base-filters/src/main/res/layout/range_choice_view.xml new file mode 100644 index 0000000..6d749ef --- /dev/null +++ b/base-filters/src/main/res/layout/range_choice_view.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + diff --git a/base-filters/src/main/res/layout/selection_item.xml b/base-filters/src/main/res/layout/selection_item.xml new file mode 100644 index 0000000..72bb7eb --- /dev/null +++ b/base-filters/src/main/res/layout/selection_item.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/base-filters/src/main/res/layout/view_hint_input.xml b/base-filters/src/main/res/layout/view_hint_input.xml new file mode 100644 index 0000000..1f66eb4 --- /dev/null +++ b/base-filters/src/main/res/layout/view_hint_input.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/base-filters/src/main/res/values/attrs.xml b/base-filters/src/main/res/values/attrs.xml new file mode 100644 index 0000000..32e1c69 --- /dev/null +++ b/base-filters/src/main/res/values/attrs.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/base-filters/src/main/res/values/colors.xml b/base-filters/src/main/res/values/colors.xml new file mode 100644 index 0000000..aed3bb2 --- /dev/null +++ b/base-filters/src/main/res/values/colors.xml @@ -0,0 +1,8 @@ + + + #B9B9B9 + #E35100 + #EE9766 + #E7E7E7 + #E35100 + diff --git a/base-filters/src/main/res/values/strings.xml b/base-filters/src/main/res/values/strings.xml new file mode 100644 index 0000000..6f75b53 --- /dev/null +++ b/base-filters/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + от + до + diff --git a/base-filters/src/main/res/values/styles.xml b/base-filters/src/main/res/values/styles.xml new file mode 100644 index 0000000..8565d5b --- /dev/null +++ b/base-filters/src/main/res/values/styles.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/bottom-sheet/.gitignore b/bottom-sheet/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/bottom-sheet/.gitignore @@ -0,0 +1 @@ +/build diff --git a/bottom-sheet/build.gradle b/bottom-sheet/build.gradle new file mode 100644 index 0000000..a30f045 --- /dev/null +++ b/bottom-sheet/build.gradle @@ -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 + } +} diff --git a/bottom-sheet/readme.md b/bottom-sheet/readme.md new file mode 100644 index 0000000..4ac7334 --- /dev/null +++ b/bottom-sheet/readme.md @@ -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 diff --git a/bottom-sheet/src/main/AndroidManifest.xml b/bottom-sheet/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c66f4e2 --- /dev/null +++ b/bottom-sheet/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/bottom-sheet/src/main/java/ru/touchin/roboswag/bottomsheet/BaseBottomSheet.kt b/bottom-sheet/src/main/java/ru/touchin/roboswag/bottomsheet/BaseBottomSheet.kt new file mode 100644 index 0000000..f42b19e --- /dev/null +++ b/bottom-sheet/src/main/java/ru/touchin/roboswag/bottomsheet/BaseBottomSheet.kt @@ -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 + 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 + } + } +} diff --git a/bottom-sheet/src/main/java/ru/touchin/roboswag/bottomsheet/BottomSheetOptions.kt b/bottom-sheet/src/main/java/ru/touchin/roboswag/bottomsheet/BottomSheetOptions.kt new file mode 100644 index 0000000..98a28db --- /dev/null +++ b/bottom-sheet/src/main/java/ru/touchin/roboswag/bottomsheet/BottomSheetOptions.kt @@ -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 +) diff --git a/bottom-sheet/src/main/java/ru/touchin/roboswag/bottomsheet/DefaultBottomSheet.kt b/bottom-sheet/src/main/java/ru/touchin/roboswag/bottomsheet/DefaultBottomSheet.kt new file mode 100644 index 0000000..0dc19d0 --- /dev/null +++ b/bottom-sheet/src/main/java/ru/touchin/roboswag/bottomsheet/DefaultBottomSheet.kt @@ -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() } + } +} diff --git a/bottom-sheet/src/main/res/drawable/bottom_sheet_background_rounded_16.xml b/bottom-sheet/src/main/res/drawable/bottom_sheet_background_rounded_16.xml new file mode 100644 index 0000000..1146eb4 --- /dev/null +++ b/bottom-sheet/src/main/res/drawable/bottom_sheet_background_rounded_16.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/bottom-sheet/src/main/res/layout/default_bottom_sheet.xml b/bottom-sheet/src/main/res/layout/default_bottom_sheet.xml new file mode 100644 index 0000000..792de5b --- /dev/null +++ b/bottom-sheet/src/main/res/layout/default_bottom_sheet.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + diff --git a/bottom-sheet/src/main/res/values/styles.xml b/bottom-sheet/src/main/res/values/styles.xml new file mode 100644 index 0000000..d10936c --- /dev/null +++ b/bottom-sheet/src/main/res/values/styles.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/cart-utils/.gitignore b/cart-utils/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/cart-utils/.gitignore @@ -0,0 +1 @@ +/build diff --git a/cart-utils/build.gradle b/cart-utils/build.gradle new file mode 100644 index 0000000..ff9abdc --- /dev/null +++ b/cart-utils/build.gradle @@ -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) + } + } + } +} diff --git a/cart-utils/src/main/AndroidManifest.xml b/cart-utils/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8a05ba2 --- /dev/null +++ b/cart-utils/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/CartModel.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/CartModel.kt new file mode 100644 index 0000000..baa8ed5 --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/CartModel.kt @@ -0,0 +1,35 @@ +package ru.touchin.roboswag.cart_utils.models + +abstract class CartModel { + + abstract val products: List + + open val promocodeList: List = emptyList() + + open val availableBonuses: Int = 0 + open val usedBonuses: Int = 0 + + val availableProducts: List + 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 copyWith( + products: List = this.products, + promocodeList: List = this.promocodeList, + usedBonuses: Int = this.usedBonuses + ): TCart + + @Suppress("UNCHECKED_CAST") + fun asCart() = this as TCart +} diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/ProductModel.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/ProductModel.kt new file mode 100644 index 0000000..56c2758 --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/ProductModel.kt @@ -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 = emptyList() + open val selectedVariantId: Int? = null + + val selectedVariant get() = variants.find { it.id == selectedVariantId } + + abstract fun copyWith( + countInCart: Int = this.countInCart, + isDeleted: Boolean = this.isDeleted, + selectedVariantId: Int? = this.selectedVariantId + ): TProduct + + @Suppress("UNCHECKED_CAST") + fun asProduct(): TProduct = this as TProduct +} diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/PromocodeModel.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/PromocodeModel.kt new file mode 100644 index 0000000..aab308d --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/PromocodeModel.kt @@ -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 + } +} diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/repositories/IRemoteCartRepository.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/repositories/IRemoteCartRepository.kt new file mode 100644 index 0000000..149a960 --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/repositories/IRemoteCartRepository.kt @@ -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, 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 + +} diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/repositories/LocalCartRepository.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/repositories/LocalCartRepository.kt new file mode 100644 index 0000000..9746e47 --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/repositories/LocalCartRepository.kt @@ -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, 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.() -> Unit) { + _currentCart.update { cart -> + cart.copyWith(products = cart.products.toMutableList().apply(updateAction)) + } + } + + private fun updatePromocodeList(updateAction: MutableList.() -> Unit) { + _currentCart.update { cart -> + cart.copyWith(promocodeList = cart.promocodeList.toMutableList().apply(updateAction)) + } + } + + private fun MutableList.updateProduct(id: Int, updateAction: TProduct.() -> TProduct) { + val index = indexOfFirst { it.id == id } + if (index >= 0) this[index] = updateAction.invoke(this[index]) + } +} diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/requests_qeue/RequestsQueue.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/requests_qeue/RequestsQueue.kt new file mode 100644 index 0000000..d84a95d --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/requests_qeue/RequestsQueue.kt @@ -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 = suspend () -> TResponse + +class RequestsQueue> { + + private val requestChannel = Channel(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 +} diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/update_manager/CartUpdateManager.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/update_manager/CartUpdateManager.kt new file mode 100644 index 0000000..41b5a88 --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/update_manager/CartUpdateManager.kt @@ -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, TProduct : ProductModel>( + private val localCartRepository: LocalCartRepository, + private val remoteCartRepository: IRemoteCartRepository, + 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>() + + @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(products = mergedProducts + newProductsFromRemoteCart) + localCartRepository.updateCart(mergedCart) + } + +} diff --git a/yandex-map/src/main/java/ru/touchin/yandexmap/YandexMapManager.kt b/yandex-map/src/main/java/ru/touchin/yandexmap/YandexMapManager.kt index e3d1249..e7eafc8 100644 --- a/yandex-map/src/main/java/ru/touchin/yandexmap/YandexMapManager.kt +++ b/yandex-map/src/main/java/ru/touchin/yandexmap/YandexMapManager.kt @@ -22,7 +22,7 @@ import com.yandex.runtime.image.ImageProvider import ru.touchin.basemap.AbstractMapManager @Suppress("detekt.TooManyFunctions") -class YandexMapManager( +open class YandexMapManager( mapView: MapView ) : AbstractMapManager(mapView), MapLoadedListener, CameraListener, InputListener, UserLocationObjectListener { @@ -36,7 +36,7 @@ class YandexMapManager( } - private val userLocationLayer by lazy { + val userLocationLayer by lazy { MapKitFactory.getInstance().createUserLocationLayer(mapView.mapWindow).also { it.isVisible = false it.setObjectListener(this)