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:
Sergey Vlasenko 2024-04-18 16:57:46 +03:00
commit 08d255d73c
204 changed files with 14423 additions and 363 deletions

1
alerts/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

75
alerts/README.md Normal file
View File

@ -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
```

47
alerts/build.gradle Normal file
View File

@ -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'
}
}
}
}

View File

@ -0,0 +1,2 @@
<manifest
package="ru.touchin.roboswag.alerts"/>

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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'

View File

@ -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 {

View File

@ -1,5 +1,9 @@
apply from: "../android-configs/lib-config.gradle"
android {
namespace "ru.touchin.templates.logansquare"
}
dependencies {
implementation project(":utils")
implementation project(":logging")

1
base-filters/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

178
base-filters/README.md Normal file
View File

@ -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`

41
base-filters/build.gradle Normal file
View File

@ -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'
}
}
}
}

View File

@ -0,0 +1,2 @@
<manifest
package="ru.touchin.roboswag.base_filters"/>

View File

@ -0,0 +1,5 @@
package ru.touchin.roboswag.base_filters
enum class SelectionType {
SINGLE_SELECT, MULTI_SELECT
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}

View File

@ -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

View File

@ -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()
}
}
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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
))
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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))
}
}
}
}
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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" />

View File

@ -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" />

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="start_hint">от</string>
<string name="end_hint">до</string>
</resources>

View File

@ -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>

View File

@ -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)

View File

@ -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?
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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>

1
bottom-sheet/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

26
bottom-sheet/build.gradle Normal file
View File

@ -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
}
}

29
bottom-sheet/readme.md Normal file
View File

@ -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

View File

@ -0,0 +1 @@
<manifest package="ru.touchin.roboswag.bottomsheet" />

View File

@ -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
}
}
}

View File

@ -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
)

View File

@ -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() }
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -17,7 +17,7 @@ allprojects {
google()
jcenter()
maven {
url "https://dl.bintray.com/touchin/touchin-tools"
url "https://maven.dev.touchin.ru/"
metadataSources {
artifact()
}

1
cart-utils/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

22
cart-utils/build.gradle Normal file
View File

@ -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)
}
}
}
}

View File

@ -0,0 +1 @@
<manifest package="ru.touchin.roboswag.core.cart_utils" />

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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])
}
}

View File

@ -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
}

View File

@ -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)
}
}

1
client-services/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -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'
}
}
}
}

View File

@ -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>

View File

@ -0,0 +1,5 @@
package ru.touchin.client_services
enum class MobileService {
HUAWEI_SERVICE, GOOGLE_SERVICE
}

View File

@ -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
}

1
code-confirm/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

24
code-confirm/build.gradle Normal file
View File

@ -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)
}
}
}
}

View File

@ -0,0 +1 @@
<manifest package="ru.touchin.code_confirm"/>

View File

@ -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

View File

@ -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
}

View File

@ -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()
}
}

View File

@ -0,0 +1,6 @@
package ru.touchin.code_confirm
abstract class BaseCodeResponse(
open val codeLifetime: Int,
open val codeId: String? = null
)

View File

@ -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))
}
}

View File

@ -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'
}
}
}
}

View File

@ -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) }
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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
}
}

View File

@ -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>

View File

@ -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"/>

View File

@ -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"

View File

@ -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 }

View File

@ -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

View File

@ -1,5 +1,9 @@
apply from: "../android-configs/lib-config.gradle"
android {
namespace "ru.touchin.lifecycle.rx"
}
dependencies {
api project(":utils")
api project(":logging")

View File

@ -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")

View File

@ -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