diff --git a/alerts/.gitignore b/alerts/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/alerts/.gitignore @@ -0,0 +1 @@ +/build diff --git a/alerts/README.md b/alerts/README.md new file mode 100644 index 0000000..61cca56 --- /dev/null +++ b/alerts/README.md @@ -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 +``` diff --git a/alerts/build.gradle b/alerts/build.gradle new file mode 100644 index 0000000..6c08de1 --- /dev/null +++ b/alerts/build.gradle @@ -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' + } + } + } +} diff --git a/alerts/src/main/AndroidManifest.xml b/alerts/src/main/AndroidManifest.xml new file mode 100644 index 0000000..80ea08a --- /dev/null +++ b/alerts/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + diff --git a/alerts/src/main/java/ru/touchin/roboswag/composable_dialog/ComposableAlertDialog.kt b/alerts/src/main/java/ru/touchin/roboswag/composable_dialog/ComposableAlertDialog.kt new file mode 100644 index 0000000..e8d6c3f --- /dev/null +++ b/alerts/src/main/java/ru/touchin/roboswag/composable_dialog/ComposableAlertDialog.kt @@ -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, + 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) + } + } + +} diff --git a/alerts/src/main/java/ru/touchin/roboswag/viewable_dialog/AlertDialogManager.kt b/alerts/src/main/java/ru/touchin/roboswag/viewable_dialog/AlertDialogManager.kt new file mode 100644 index 0000000..b6df3fd --- /dev/null +++ b/alerts/src/main/java/ru/touchin/roboswag/viewable_dialog/AlertDialogManager.kt @@ -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(R.id.alert_title)?.setTextOrGone(title) + findViewById(R.id.alert_message)?.setTextOrGone(message) + findViewById(R.id.alert_positive_button)?.let { buttonView -> + setupButton(this, buttonView, positiveButtonText, onPositiveClick) + } + findViewById(R.id.alert_negative_button)?.let { buttonView -> + setupButton(this, buttonView, negativeButtonText, onNegativeClick) + } + } +} diff --git a/alerts/src/main/java/ru/touchin/roboswag/viewable_dialog/AlertDialogUtils.kt b/alerts/src/main/java/ru/touchin/roboswag/viewable_dialog/AlertDialogUtils.kt new file mode 100644 index 0000000..07cd8d4 --- /dev/null +++ b/alerts/src/main/java/ru/touchin/roboswag/viewable_dialog/AlertDialogUtils.kt @@ -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) +} diff --git a/alerts/src/main/res/layout/dialog_alert.xml b/alerts/src/main/res/layout/dialog_alert.xml new file mode 100644 index 0000000..6aec6ce --- /dev/null +++ b/alerts/src/main/res/layout/dialog_alert.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + diff --git a/alerts/src/main/res/values/strings.xml b/alerts/src/main/res/values/strings.xml new file mode 100644 index 0000000..319d46c --- /dev/null +++ b/alerts/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + OK + Cancel + diff --git a/alerts/src/main/res/values/styles.xml b/alerts/src/main/res/values/styles.xml new file mode 100644 index 0000000..7c8d2be --- /dev/null +++ b/alerts/src/main/res/values/styles.xml @@ -0,0 +1,39 @@ + + + + + + + + + + diff --git a/android-configs/app-config.gradle b/android-configs/app-config.gradle index cf6759d..52f152f 100644 --- a/android-configs/app-config.gradle +++ b/android-configs/app-config.gradle @@ -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' diff --git a/base-filters/.gitignore b/base-filters/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/base-filters/.gitignore @@ -0,0 +1 @@ +/build diff --git a/base-filters/README.md b/base-filters/README.md new file mode 100644 index 0000000..95bf577 --- /dev/null +++ b/base-filters/README.md @@ -0,0 +1,178 @@ +# Описание + +Модуль содержит реализацию следующих типов фильтров: + +1. Выбор одного/нескольких из доступных значений списка +2. Выбор одного/нескольких значений из перечня тегов +3. Выбор минимального и максимального значения из диапозона + +# Использование + +## 1. Выбор одного/нескольких из доступных значений списка + +### Как использовать +``` kotlin +val selectorView = ListSelectionView>(context) + .Builder() + .setItems(navArgs.items) + .addItemDecoration((TopDividerItemDecoration( + context = requireContext(), + drawableId = R.drawable.list_divider_1dp, + startMargin = START_MARGIN_DIVIDER_DP.px + ))) + .withSelectionType(ListSelectionView.SelectionType.SINGLE_SELECT) + .onResultListener { items -> + viewModel.dispatchAction(SelectItemAction.SelectItem(items)) + } + .build() +``` +### Конфигурации +* при создании `ListSelectionView` необходимо передлать `ItemType` - класс модели данных в списке, `HolderType` - класс viewHolder-а в recyclerView. +Для использования дефолтной реализации необходимо использовать типы `>` +* в метод `setItems(List)` необходимо передать список объектов +* метод `addItemDecoration(itemDecoration: RecyclerView.ItemDecoration)` можно использовать для передачи объекта `RecyclerView.ItemDecoration` +* метод `withSelectionType(type: SelectionType)` используется для указания типа выбора: + * `SINGLE_SELECT` - по умолчанию - позволяет выбрать один выариант, при этом будет выбран всегда как минимум один вариант + * `MULTI_SELECT` - позволяет выбрать несколько вариантов из списка, при этом можно полностью выбрать все варианты и убрать выделение со всех вариантов +* метод `showInHolder(HolderFactoryType)` используется для определения кастомного viewHolder для списка с недефолтной разметкой. +``` kotlin +val selectorView = ListSelectionView(context) + .Builder() + .showInHolder { parent, clickListener, selectionType -> + TestItemViewHolder( + binding = TestSelectionItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), + onItemSelectAction = clickListener, + selectionType = selectionType + ) + } + ... + .build() +``` +* колбэк `onSelectedItemsListener(listener: OnSelectedItemsListener)` можно использовать для получения списка всех элементов `ItemType` после каждого выбора +* колбэк `onSelectedItemListener(listener: OnSelectedItemListener)` можно использовать для получения элемента списка `ItemType`, по которому произошел клик +* после вызова конфигурационных методов обязательно необходимо вызать метод `build()` + +### Кастомизация стиля дефолтной реализации ViewHolder без необходимости создания кастомного layout и viewHolder + +#### 1) Определить кастомную тему и стили элементов +1. Стиль для **текста элемента списка** должен быть наследником стиля `Widget.FilterSelection.Item` +``` xml + +``` +2. Стиль для **индикатора выбора** должен быть наследником стиля `Widget.FilterSelection.Radio` +Передайте `selector-drawable` для кастомизации вида индикатора в конце строки +``` xml + +``` +3. Создайте **тему**, которая должна быть наследником `Theme.FilterSelection` +``` xml + +``` +#### 2) Применить тему при создании view +При создании вью в коде можно указать тему, используя `ContextThemeWrapper` +``` kotlin +val newContext = ContextThemeWrapper(requireContext(), R.style.Theme_Custom_FilterSelection) +val selectorView = ListSelectionView(newContext) + .Builder() + ... + .build() +``` + +## 2. Выбор одного/нескольких значений из перечня тегов + +* `TagLayoutView` - view-контейнер для тегов +* `TagView` - view для тега. Кастомная разметка для тега должна содержать в корне `TagView` + +### Как использовать +``` kotlin +binding.tagItemLayout + .Builder(getFilterItem()) + .setSpacing(16) + .setSelectionType(SelectionType.MULTI_SELECT) // по умолчанию + .isSingleLine(false) // по умолчанию + .onPropertySelectedAction { filterProperty: FilterProperty -> + //Do something + } + .build() +``` +### Конфигурации +* метод `setSelectionType(SelectionType)` конфигурирует тип выбора: + * `SINGLE_SELECT` - выбор одного варианта сбрасывает select у всех остальных + * `MULTI_SELECT` - по умолчанию - мультивыбор тегов с учетом исключающих фильтров +* метод `isSingleLine(Boolean)` конфигурирует вид контейнера с тегами: `true` соответствует горизонтальному контейнеру со скроллом +* `setTagLayout(Int)` устанавливает разметку для тега. Если не задано - то используется дефолтная разметка `layout_default_tag.xml` +* `setMaxTagCount(Int)` позволяет ограничить количество отображаемых тегов. По умолчанию ограничения нет. +* `setMoreTagLayout(Int, String)` устанавливает разметку для тега, который отображается для дополнительного тега. Если не указана - то тег не будет создан +* `setSpacing(Int)`, `setSpacingHorizontal(Int)` и `setSpacingVertical(Int)` можно использовать для настройки расстояния между тегами. По умолчанию - 0 +* `onMoreValuesAction(FilterMoreAction)` и `onPropertySelectedAction(PropertySelectedAction)` используются для передачи колбэков на клик по тегу типа "Еще" и обычного тега соответственно +* после вызова конфигурационных методов обязательно необходимо вызать метод `build()` +* в Builder необходимо передать объект `filterItem: FilterItem` + + +## 3. Выбор минимального и максимального значения из диапозона + +* `RangeChoiceView` - контейнер для слайдера и редактируемых полей +* `FilterRangeSlider` - слайдер - Можно использовать как отдельный элемент +* `HintInputView` - view для редактируемого поля начала и окончания диапозона + +### Как использовать + +В разметке +``` xml + +``` + +Настройка в коде +``` kotlin +fun setupValues(item: FilterRangeItem) { + binding.rangeValuesTest.setupRangeValues( + rangeFilterItem = item, + onChangeCallback = callback + ) + } + + fun resetValues() { + binding.rangeValuesTest.resetRangeValue() + } +``` +### Конфигурации +Вся конфигурация вьюх осуществляется через стили: +* Для `RangeChoiceView`: + * `filterRange_sliderMargin` - расстояние от слайдера до редактируемых полей + * `filterRange_startHint` - ссылка на строку с текстом подсказки в редактируемом поле для начального значения + * `filterRange_endHint` - ссылка на строку с текстом подсказки в редактируемом поле для конечного значения + * `filterRange_theme` - ссылка на тему +* В теме: + * атрибут `filterRange_sliderStyle` - ссылка на стиль слайдера + * атрибут `filterRange_hintViewStyle` - ссылка на стиль `HintInputView` + * атрибут `filterRange_hintTextStyle` - ссылка на стиль `TextView` внутри `HintInputView` + * атрибут `filterRange_valueEditTextStyle` - ссылка на стиль `EditText` внутри `HintInputView` +* Для `FilterRangeSlider`: + * `trackColorActive` + * `trackColorInactive` + * `trackHeight` + * `thumbElevation` + * `thumbColor` + * `labelBehavior` + * `haloRadius` + * `filterRange_stepTextAppearance` + * `filterRange_activeTickColor` + * `filterRange_inactiveTickColor` + * `filterRange_stepValueMarginTop` + * `filterRange_sliderPointSize` + * `filterRange_pointShape` diff --git a/base-filters/build.gradle b/base-filters/build.gradle new file mode 100644 index 0000000..6c57558 --- /dev/null +++ b/base-filters/build.gradle @@ -0,0 +1,41 @@ +apply from: "../android-configs/lib-config.gradle" +apply plugin: 'kotlin-parcelize' + +android { + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(":utils") + implementation project(":recyclerview-adapters") + implementation project(":navigation-base") + implementation project(":kotlin-extensions") + + implementation("org.jetbrains.kotlin:kotlin-stdlib") + + implementation("androidx.core:core-ktx") + + implementation("androidx.appcompat:appcompat") + implementation("com.google.android.material:material") + implementation("androidx.constraintlayout:constraintlayout") { + version { + require '2.0.0' + } + } + + constraints { + implementation("androidx.appcompat:appcompat") { + version { + require '1.0.0' + } + } + + implementation("androidx.core:core-ktx") { + version { + require '1.0.0' + } + } + } +} diff --git a/base-filters/src/main/AndroidManifest.xml b/base-filters/src/main/AndroidManifest.xml new file mode 100644 index 0000000..14e5b35 --- /dev/null +++ b/base-filters/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/SelectionType.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/SelectionType.kt new file mode 100644 index 0000000..31f1f0e --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/SelectionType.kt @@ -0,0 +1,5 @@ +package ru.touchin.roboswag.base_filters + +enum class SelectionType { + SINGLE_SELECT, MULTI_SELECT +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/FilterRangeSlider.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/FilterRangeSlider.kt new file mode 100644 index 0000000..0fdfc1d --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/FilterRangeSlider.kt @@ -0,0 +1,173 @@ +package ru.touchin.roboswag.base_filters.range + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Paint.Cap +import android.util.AttributeSet +import androidx.annotation.StyleRes +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.content.withStyledAttributes +import androidx.core.widget.TextViewCompat +import com.google.android.material.shape.CornerFamily +import com.google.android.material.shape.MaterialShapeDrawable +import com.google.android.material.shape.ShapeAppearanceModel +import com.google.android.material.slider.RangeSlider +import ru.touchin.roboswag.base_filters.R +import ru.touchin.roboswag.components.utils.getColorSimple + +class FilterRangeSlider @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0 +) : RangeSlider(context, attrs, defStyleAttr) { + + var points: List? = null + set(value) { + field = value?.sorted()?.filter { it > valueFrom && it < valueTo } + } + + private val innerThumbRadius: Int = thumbRadius + + private var stepValueMarginTop = 0f + private var inactiveTickColor: Int = context.getColorSimple(R.color.slider_point_inactive) + private var activeTickColor: Int = context.getColorSimple(R.color.slider_point_active) + private var sliderPointSize: Float = 5f + + @StyleRes + private var stepTextAppearance: Int = -1 + private var shape: Shape = Shape.CIRCLE + + private var trackCenterY: Float = -1F + + init { + // Set original thumb radius to zero to draw custom one on top + thumbRadius = 0 + + context.withStyledAttributes(attrs, R.styleable.FilterRangeSlider, defStyleAttr, defStyleRes) { + stepValueMarginTop = getDimension(R.styleable.FilterRangeSlider_filterRange_stepValueMarginTop, stepValueMarginTop) + inactiveTickColor = getColor(R.styleable.FilterRangeSlider_filterRange_inactiveTickColor, inactiveTickColor) + activeTickColor = getColor(R.styleable.FilterRangeSlider_filterRange_activeTickColor, activeTickColor) + sliderPointSize = getDimension(R.styleable.FilterRangeSlider_filterRange_sliderPointSize, sliderPointSize) + stepTextAppearance = getResourceId(R.styleable.FilterRangeSlider_filterRange_stepTextAppearance, -1) + shape = Shape.values()[getInt(R.styleable.FilterRangeSlider_filterRange_pointShape, Shape.CIRCLE.ordinal)] + } + } + + private val thumbDrawable = MaterialShapeDrawable().apply { + shadowCompatibilityMode = MaterialShapeDrawable.SHADOW_COMPAT_MODE_ALWAYS + setBounds(0, 0, innerThumbRadius * 2, innerThumbRadius * 2) + elevation = thumbElevation + state = drawableState + fillColor = thumbTintList + shapeAppearanceModel = ShapeAppearanceModel + .builder() + .setAllCorners(shape.value, innerThumbRadius.toFloat()) + .build() + } + + private val inactiveTicksPaint = getDefaultTickPaint().apply { color = inactiveTickColor } + + private val activeTicksPaint = getDefaultTickPaint().apply { color = activeTickColor } + + private fun getDefaultTickPaint() = Paint().apply { + isAntiAlias = true + style = Paint.Style.STROKE + strokeCap = Cap.ROUND + strokeWidth = sliderPointSize + } + + // Using TextView as a bridge to get text params + private val stepValuePaint: Paint = AppCompatTextView(context).apply { + stepTextAppearance.takeIf { it != -1 }?.let { TextViewCompat.setTextAppearance(this, it) } + }.let { textView -> + Paint().apply { + isAntiAlias = true + color = textView.currentTextColor + textSize = textView.textSize + typeface = textView.typeface + textAlign = Paint.Align.CENTER + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + + trackCenterY = measuredHeight / 2F + + val height = trackCenterY + trackHeight / 2F + stepValueMarginTop + stepValuePaint.textSize + setMeasuredDimension(measuredWidth, height.toInt()) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + drawTicks(canvas) + drawThumb(canvas) + drawStepValues(canvas) + } + + private fun drawTicks(canvas: Canvas) { + if (points.isNullOrEmpty()) return + + val ticksCoordinates = mutableListOf() + points?.forEach { point -> + ticksCoordinates.add(normalizeValue(point.toFloat()) * trackWidth + trackSidePadding) + ticksCoordinates.add(trackCenterY) + } + + val leftPointsSize = points?.count { it < values[0] } ?: 0 + val rightPointSize = points?.count { it > values[1] } ?: 0 + val activePointSize = (points?.size ?: 0) - leftPointsSize - rightPointSize + + // Draw inactive ticks to the left of the smallest thumb. + canvas.drawPoints(ticksCoordinates.toFloatArray(), 0, leftPointsSize * 2, inactiveTicksPaint) + + // Draw active ticks between the thumbs. + canvas.drawPoints( + ticksCoordinates.toFloatArray(), + leftPointsSize * 2, + activePointSize * 2, + activeTicksPaint + ) + + // Draw inactive ticks to the right of the largest thumb. + canvas.drawPoints( + ticksCoordinates.toFloatArray(), + leftPointsSize * 2 + activePointSize * 2, + rightPointSize * 2, + inactiveTicksPaint + ) + } + + private fun drawThumb(canvas: Canvas) { + for (value in values) { + canvas.save() + canvas.translate( + (trackSidePadding + (normalizeValue(value) * trackWidth).toInt() - innerThumbRadius).toFloat(), + trackCenterY - innerThumbRadius + ) + thumbDrawable.draw(canvas) + canvas.restore() + } + } + + private fun drawStepValues(canvas: Canvas) { + points?.forEach { point -> + canvas.drawText( + point.toString(), + normalizeValue(point.toFloat()) * trackWidth + trackSidePadding, + trackCenterY + trackHeight / 2F + stepValueMarginTop + stepValuePaint.textSize - 3F, + stepValuePaint + ) + } + } + + private fun normalizeValue(value: Float) = (value - valueFrom) / (valueTo - valueFrom) + + private enum class Shape(val value: Int) { + CIRCLE(CornerFamily.ROUNDED), + CUT(CornerFamily.CUT) + } +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/HintInputView.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/HintInputView.kt new file mode 100644 index 0000000..9f860a6 --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/HintInputView.kt @@ -0,0 +1,39 @@ +package ru.touchin.roboswag.base_filters.range + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import ru.touchin.roboswag.base_filters.databinding.ViewHintInputBinding + +class HintInputView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr, defStyleRes) { + + private val binding = ViewHintInputBinding.inflate(LayoutInflater.from(context), this) + + var inputText: String = "" + set(value) { + setText(value) + field = value + } + + fun setHint(value: String?) { + binding.startHint.text = value.orEmpty() + } + + fun setOnEditorActionListener(listener: TextView.OnEditorActionListener) = + binding.editText.setOnEditorActionListener(listener) + + private fun setText(value: String) { + binding.editText.run { + setText(value) + setSelection(text?.length ?: 0) + } + } + +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/RangeChoiceView.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/RangeChoiceView.kt new file mode 100644 index 0000000..c03143a --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/RangeChoiceView.kt @@ -0,0 +1,168 @@ +package ru.touchin.roboswag.base_filters.range + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.inputmethod.EditorInfo +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.withStyledAttributes +import androidx.core.view.updateLayoutParams +import com.google.android.material.slider.RangeSlider +import ru.touchin.roboswag.base_filters.R +import ru.touchin.roboswag.base_filters.databinding.RangeChoiceViewBinding +import ru.touchin.roboswag.base_filters.range.model.FilterRangeItem +import ru.touchin.roboswag.base_filters.range.model.SelectedValues +import kotlin.properties.Delegates + +typealias FilterRangeChanged = (FilterRangeItem) -> Unit + +class RangeChoiceView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr, defStyleRes) { + + private val defaultTheme = R.style.Theme_FilterRangeSlider + private var binding: RangeChoiceViewBinding by Delegates.notNull() + + private var valueChangedAction: FilterRangeChanged? = null + + private var rangeItem: FilterRangeItem? = null + set(value) { + field = value + + binding.fromInput.inputText = value?.selectedValues?.min?.toString() + ?: value?.start?.toString().orEmpty() + binding.toInput.inputText = value?.selectedValues?.max?.toString() + ?: value?.end?.toString().orEmpty() + + binding.rangeSlider.run { + values = listOf( + value?.selectedValues?.min?.toFloat() ?: value?.start?.toFloat(), + value?.selectedValues?.max?.toFloat() ?: value?.end?.toFloat() + ) + } + } + + init { + context.withStyledAttributes(attrs, R.styleable.FilterRangeChoice, defStyleAttr, defStyleRes) { + val theme = getResourceId(R.styleable.FilterRangeChoice_filterRange_theme, defaultTheme) + val themeContext = ContextThemeWrapper(context, theme) + binding = RangeChoiceViewBinding.inflate(LayoutInflater.from(themeContext), this@RangeChoiceView) + + binding.fromInput.setHint(getString(R.styleable.FilterRangeChoice_filterRange_startHint)) + binding.toInput.setHint(getString(R.styleable.FilterRangeChoice_filterRange_endHint)) + binding.rangeSliderGuideline.updateLayoutParams { + topMargin = getDimension(R.styleable.FilterRangeChoice_filterRange_sliderMargin, 0f).toInt() + } + } + } + + fun setupRangeValues( + rangeFilterItem: FilterRangeItem, + onChangeCallback: FilterRangeChanged + ) { + rangeItem = rangeFilterItem + valueChangedAction = onChangeCallback + + with(binding) { + addChangeValuesListener() + setupRangeSlider(rangeFilterItem) + } + } + + fun resetRangeValue() { + rangeItem = rangeItem?.resetSelectedValues() + } + + private fun addChangeValuesListener() { + binding.fromInput.addChangeValueListener { rangeItem?.setValue(selectedMinValue = it.toIntOrNull()) } + binding.toInput.addChangeValueListener { rangeItem?.setValue(selectedMaxValue = it.toIntOrNull()) } + } + + private fun setupRangeSlider(rangeFilterItem: FilterRangeItem) { + with(binding) { + rangeSlider.apply { + valueFrom = rangeFilterItem.start.toFloat() + valueTo = rangeFilterItem.end.toFloat() + points = rangeFilterItem.intermediates + } + + rangeSlider.addOnChangeListener { _, _, _ -> + fromInput.inputText = rangeSlider.values[0].toInt().toString() + toInput.inputText = rangeSlider.values[1].toInt().toString() + } + + rangeSlider.addOnSliderTouchListener(object : RangeSlider.OnSliderTouchListener { + @SuppressLint("RestrictedApi") + override fun onStartTrackingTouch(slider: RangeSlider) = Unit + + @SuppressLint("RestrictedApi") + override fun onStopTrackingTouch(slider: RangeSlider) { + binding.rangeSlider.apply { + when (focusedThumbIndex) { + 0 -> { + rangeItem = rangeItem?.setValue(selectedMinValue = from().toInt()) + rangeItem?.let { valueChangedAction?.invoke(it) } + } + 1 -> { + rangeItem = rangeItem?.setValue(selectedMaxValue = to().toInt()) + rangeItem?.let { valueChangedAction?.invoke(it) } + } + } + } + } + }) + } + } + + private fun HintInputView.addChangeValueListener(updateValue: (String) -> FilterRangeItem?) { + setOnEditorActionListener { view, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + rangeItem = updateValue(view.text.toString().filterNot { it.isWhitespace() }) + rangeItem?.let { valueChangedAction?.invoke(it) } + } + false + } + } + + private fun RangeSlider.from() = values[0].toInt().toString() + + private fun RangeSlider.to() = values[1].toInt().toString() + + @SuppressWarnings("detekt.ComplexMethod") + private fun FilterRangeItem.setValue( + selectedMaxValue: Int? = selectedValues?.max, + selectedMinValue: Int? = selectedValues?.min + ): FilterRangeItem { + val isMaxValueUpdated = selectedMaxValue != selectedValues?.max + val isMinValueUpdated = selectedMinValue != selectedValues?.min + + val isMinValueOutOfRange = selectedMinValue != null && isMinValueUpdated && selectedMinValue > (selectedMaxValue ?: end) + val isMaxValueOutOfRange = selectedMaxValue != null && isMaxValueUpdated && selectedMaxValue < (selectedMinValue ?: start) + + val updatedValues = when { + selectedMaxValue == end && selectedMinValue == start -> null + isMinValueOutOfRange -> SelectedValues( + max = selectedMaxValue ?: end, + min = selectedMaxValue ?: end + ) + isMaxValueOutOfRange -> SelectedValues( + max = selectedMinValue ?: start, + min = selectedMinValue ?: start + ) + else -> SelectedValues( + max = selectedMaxValue?.takeIf { it < end } ?: end, + min = selectedMinValue?.takeIf { it > start } ?: start + ) + } + + return copyWithSelectedValue(selectedValues = updatedValues) + } + + private fun FilterRangeItem.resetSelectedValues() = copyWithSelectedValue(selectedValues = null) + +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/model/FilterRangeItem.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/model/FilterRangeItem.kt new file mode 100644 index 0000000..1c452de --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/range/model/FilterRangeItem.kt @@ -0,0 +1,34 @@ +package ru.touchin.roboswag.base_filters.range.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +open class FilterRangeItem( + val id: String, + val start: Int, + val end: Int, + val title: String, + val intermediates: List? = null, + val step: Int? = null, + val selectedValues: SelectedValues? = null +) : Parcelable { + + fun isCorrectValues() = end > start + + fun copyWithSelectedValue(selectedValues: SelectedValues?) = FilterRangeItem( + id = id, + start = start, + end = end, + title = title, + intermediates = intermediates, + step = step, + selectedValues = selectedValues + ) +} + +@Parcelize +data class SelectedValues( + val max: Int, + val min: Int +) : Parcelable diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/ListSelectionView.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/ListSelectionView.kt new file mode 100644 index 0000000..3e9e903 --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/ListSelectionView.kt @@ -0,0 +1,137 @@ +package ru.touchin.roboswag.base_filters.select_list_item + +import android.content.Context +import android.util.AttributeSet +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import androidx.annotation.StyleRes +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import ru.touchin.roboswag.base_filters.databinding.SelectionItemBinding +import ru.touchin.roboswag.base_filters.select_list_item.adapter.BaseSelectionViewHolder +import ru.touchin.roboswag.base_filters.select_list_item.adapter.HolderFactoryType +import ru.touchin.roboswag.base_filters.select_list_item.adapter.SelectionItemViewHolder +import ru.touchin.roboswag.base_filters.select_list_item.adapter.SheetSelectionAdapter +import ru.touchin.roboswag.base_filters.select_list_item.model.BaseSelectionItem +import ru.touchin.roboswag.base_filters.SelectionType + +private typealias OnSelectedItemListener = (item: ItemType) -> Unit +private typealias OnSelectedItemsListener = (items: List) -> Unit + +/** + * Base [ListSelectionView] to use in filters screen for choosing single or multi items in list. + * + * @param ItemType Type of model's element in list. + * It must implement [BaseSelectionItem] abstract class. + * + * @param HolderType Type of viewHolder in recyclerView. + * It must extend [BaseSelectionViewHolder] abstract class. + * + **/ + +class ListSelectionView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : RecyclerView(context, attrs, defStyleAttr) + where ItemType : BaseSelectionItem, + HolderType : BaseSelectionViewHolder { + + enum class SelectionType { SINGLE_SELECT, MULTI_SELECT } + + constructor(context: Context, @StyleRes themeResId: Int) : this(ContextThemeWrapper(context, themeResId)) + + private var mutableItems: List = emptyList() + private var selectionType = SelectionType.SINGLE_SELECT + + private var onSelectedItemChanged: OnSelectedItemListener? = null + private var onSelectedItemsChanged: OnSelectedItemsListener? = null + private var factory: HolderFactoryType = getDefaultFactory() + + init { + layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + layoutManager = LinearLayoutManager(context) + } + + private fun getDefaultFactory(): HolderFactoryType = { parent, clickListener, selectionType -> + SelectionItemViewHolder( + binding = SelectionItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), + onItemSelectAction = clickListener, + selectionType = selectionType + ) + } + + private val selectionAdapter by lazy { + SheetSelectionAdapter( + onItemSelectAction = onItemSelectedListener, + selectionType = selectionType, + factory = factory + ) + } + + private val onItemSelectedListener: (item: ItemType) -> Unit = { item -> + onSelectedItemChanged?.invoke(item) + updateAfterSelection(item) + onSelectedItemsChanged?.invoke(mutableItems) + } + + fun updateItems(items: List) { + mutableItems = items + updateList() + } + + private fun updateList() { + selectionAdapter.submitList(mutableItems) + } + + private fun updateAfterSelection(selectedItem: ItemType) { + mutableItems = mutableItems.map { item -> + when { + item.isItemTheSame(selectedItem) -> selectedItem + selectionType == SelectionType.SINGLE_SELECT -> item.copyWithSelection(isSelected = false) + else -> item + } + } + updateList() + } + + inner class Builder { + + fun setItems(items: List) = apply { + mutableItems = items + } + + fun setItems( + source: List, + mapper: (T) -> ItemType + ) = setItems(source.map { item -> mapper.invoke(item) }) + + fun showInHolder(holderFactory: HolderFactoryType) = apply { + factory = holderFactory + } + + fun addItemDecoration(itemDecoration: RecyclerView.ItemDecoration) = apply { + this@ListSelectionView.addItemDecoration(itemDecoration) + } + + fun onSelectedItemListener(listener: OnSelectedItemListener) = apply { + this@ListSelectionView.onSelectedItemChanged = listener + } + + fun onSelectedItemsListener(listener: OnSelectedItemsListener) = apply { + this@ListSelectionView.onSelectedItemsChanged = listener + } + + fun withSelectionType(type: SelectionType) = apply { + selectionType = type + } + + fun build() = this@ListSelectionView.also { + it.adapter = selectionAdapter + updateList() + } + } +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/BaseSelectionViewHolder.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/BaseSelectionViewHolder.kt new file mode 100644 index 0000000..847bfc2 --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/BaseSelectionViewHolder.kt @@ -0,0 +1,11 @@ +package ru.touchin.roboswag.base_filters.select_list_item.adapter + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import ru.touchin.roboswag.base_filters.select_list_item.model.BaseSelectionItem + +abstract class BaseSelectionViewHolder(val view: View) + : RecyclerView.ViewHolder(view) { + + abstract fun bind(item: ItemType) +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/SelectionItemViewHolder.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/SelectionItemViewHolder.kt new file mode 100644 index 0000000..f08dd4b --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/SelectionItemViewHolder.kt @@ -0,0 +1,33 @@ +package ru.touchin.roboswag.base_filters.select_list_item.adapter + +import android.view.View +import ru.touchin.roboswag.base_filters.databinding.SelectionItemBinding +import ru.touchin.roboswag.base_filters.select_list_item.model.BaseSelectionItem +import ru.touchin.roboswag.base_filters.SelectionType + +class SelectionItemViewHolder( + private val binding: SelectionItemBinding, + private val onItemSelectAction: (ItemType) -> Unit, + private val selectionType: SelectionType +) : BaseSelectionViewHolder(binding.root) { + + override fun bind(item: ItemType) { + binding.itemTitle.text = item.title + binding.itemRadiobutton.isChecked = item.isSelected + + setupCheckListener(item) + } + + private fun setupCheckListener(item: ItemType) = with(binding) { + val checkListener = View.OnClickListener { + itemRadiobutton.isChecked = true + onItemSelectAction.invoke(item.copyWithSelection(isSelected = when (selectionType) { + SelectionType.SINGLE_SELECT -> true + else -> !item.isSelected + })) + } + root.setOnClickListener(checkListener) + itemRadiobutton.setOnClickListener(checkListener) + } + +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/SheetSelectionAdapter.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/SheetSelectionAdapter.kt new file mode 100644 index 0000000..9424309 --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/SheetSelectionAdapter.kt @@ -0,0 +1,30 @@ +package ru.touchin.roboswag.base_filters.select_list_item.adapter + +import androidx.recyclerview.widget.DiffUtil +import ru.touchin.roboswag.base_filters.select_list_item.model.BaseSelectionItem +import ru.touchin.roboswag.base_filters.SelectionType +import ru.touchin.roboswag.recyclerview_adapters.adapters.DelegationListAdapter + +class SheetSelectionAdapter( + onItemSelectAction: (ItemType) -> Unit, + selectionType: SelectionType, + factory: HolderFactoryType +) : DelegationListAdapter(object : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: BaseSelectionItem, newItem: BaseSelectionItem): Boolean = + oldItem.isItemTheSame(newItem) + + override fun areContentsTheSame(oldItem: BaseSelectionItem, newItem: BaseSelectionItem): Boolean = + oldItem.isContentTheSame(newItem) + +}) { + + init { + addDelegate(SheetSelectionDelegate( + onItemSelectAction = onItemSelectAction, + selectionType = selectionType, + factory = factory + )) + } + +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/SheetSelectionDelegate.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/SheetSelectionDelegate.kt new file mode 100644 index 0000000..39021ab --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/SheetSelectionDelegate.kt @@ -0,0 +1,28 @@ +package ru.touchin.roboswag.base_filters.select_list_item.adapter + +import android.view.ViewGroup +import ru.touchin.roboswag.base_filters.select_list_item.model.BaseSelectionItem +import ru.touchin.roboswag.base_filters.SelectionType +import ru.touchin.roboswag.recyclerview_adapters.adapters.ItemAdapterDelegate + +typealias HolderFactoryType = (ViewGroup, (ItemType) -> Unit, SelectionType) -> BaseSelectionViewHolder + +class SheetSelectionDelegate( + private val onItemSelectAction: (ItemType) -> Unit, + private val selectionType: SelectionType, + private val factory: HolderFactoryType +) : ItemAdapterDelegate, ItemType>() + where ItemType : BaseSelectionItem { + + override fun onCreateViewHolder(parent: ViewGroup): BaseSelectionViewHolder = + factory.invoke(parent, onItemSelectAction, selectionType) + + override fun onBindViewHolder( + holder: BaseSelectionViewHolder, + item: ItemType, + adapterPosition: Int, + collectionPosition: Int, + payloads: MutableList + ) = holder.bind(item) + +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/model/BaseSelectionItem.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/model/BaseSelectionItem.kt new file mode 100644 index 0000000..8f59990 --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/model/BaseSelectionItem.kt @@ -0,0 +1,14 @@ +package ru.touchin.roboswag.base_filters.select_list_item.model + +abstract class BaseSelectionItem( + open val id: Int, + open val title: String, + open val isSelected: Boolean +) { + + abstract fun isItemTheSame(compareItem: BaseSelectionItem): Boolean + + abstract fun isContentTheSame(compareItem: BaseSelectionItem): Boolean + + abstract fun copyWithSelection(isSelected: Boolean): ItemType +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/model/DefaultSelectionItem.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/model/DefaultSelectionItem.kt new file mode 100644 index 0000000..603961f --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/model/DefaultSelectionItem.kt @@ -0,0 +1,24 @@ +package ru.touchin.roboswag.base_filters.select_list_item.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class DefaultSelectionItem( + override val id: Int, + override val title: String, + override val isSelected: Boolean = false +) : BaseSelectionItem(id, title, isSelected), Parcelable { + + override fun isItemTheSame(compareItem: BaseSelectionItem): Boolean = when { + compareItem is DefaultSelectionItem && id == compareItem.id -> true + else -> false + } + + override fun isContentTheSame(compareItem: BaseSelectionItem): Boolean = + this == compareItem + + @Suppress("UNCHECKED_CAST") + override fun copyWithSelection(isSelected: Boolean): ItemType = + this.copy(isSelected = isSelected) as ItemType +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/tags/TagLayoutView.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/tags/TagLayoutView.kt new file mode 100644 index 0000000..2c20c82 --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/tags/TagLayoutView.kt @@ -0,0 +1,171 @@ +package ru.touchin.roboswag.base_filters.tags + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import android.widget.TextView +import androidx.annotation.LayoutRes +import androidx.core.view.children +import com.google.android.material.chip.ChipGroup +import ru.touchin.roboswag.base_filters.R +import ru.touchin.roboswag.base_filters.SelectionType +import ru.touchin.roboswag.base_filters.databinding.LayoutMultiLineTagGroupBinding +import ru.touchin.roboswag.base_filters.databinding.LayoutSingleLineTagGroupBinding +import ru.touchin.roboswag.base_filters.tags.model.FilterItem +import ru.touchin.roboswag.base_filters.tags.model.FilterProperty +import ru.touchin.roboswag.components.utils.UiUtils +import ru.touchin.roboswag.components.utils.px +import kotlin.properties.Delegates + +typealias PropertySelectedAction = (FilterProperty) -> Unit +typealias FilterMoreAction = (FilterItem) -> Unit + +class TagLayoutView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private var filterItem: FilterItem by Delegates.notNull() + + private var tagsContainer: ChipGroup by Delegates.notNull() + + private var propertySelectedAction: PropertySelectedAction? = null + private var moreValuesAction: FilterMoreAction? = null + + private var selectionType = SelectionType.MULTI_SELECT + private var isSingleLine = false + private var tagSpacingHorizontalDp: Int = 0 + private var tagSpacingVerticalDp: Int = 0 + + @LayoutRes + private var tagLayout: Int = R.layout.layout_default_tag + + private var moreTagText: String = "" + private var maxTagCount = Int.MAX_VALUE + + @LayoutRes + private var moreTagLayout: Int = tagLayout + + private fun inflateAndGetChipGroup(isSingleLine: Boolean): ChipGroup = when (isSingleLine) { + true -> LayoutSingleLineTagGroupBinding.inflate(LayoutInflater.from(context), this, true).tagGroup + false -> LayoutMultiLineTagGroupBinding.inflate(LayoutInflater.from(context), this, true).tagGroup + } + + private fun createTag(property: FilterProperty): TagView { + val tagView = UiUtils.inflate(tagLayout, this) + require(tagView is TagView) { "Layout for tag must contain TagView as root view" } + + return tagView.apply { + text = property.title + isChecked = property.isSelected + tagId = property.id + + setOnCheckAction { view, isChecked -> + when { + selectionType == SelectionType.SINGLE_SELECT && isChecked -> clearCheck(property.id) + selectionType == SelectionType.MULTI_SELECT && isChecked -> clearExcludedCheck(property) + } + view.isChecked = isChecked + propertySelectedAction?.invoke(property.copyWithSelected(isSelected = isChecked)) + } + } + } + + private fun createMoreTag(filter: FilterItem): View { + val moreTag = UiUtils.inflate(moreTagLayout, this) + require(moreTag is TextView) { "Layout for more tag must contain TextView as root view" } + + return moreTag.apply { + text = moreTagText + setOnClickListener { moreValuesAction?.invoke(filter) } + } + } + + private fun clearCheck(selectedId: Int) { + tagsContainer.children.forEach { tagView -> + if (tagView is TagView && tagView.tagId != selectedId) { + tagView.isChecked = false + } + } + } + + private fun clearExcludedCheck(property: FilterProperty) { + val excludingIds = property.excludes.map { it.id } + + tagsContainer.children.forEach { tagView -> + if (tagView is TagView && tagView.tagId in excludingIds) { + tagView.isChecked = false + } + } + } + + inner class Builder(private val filterItem: FilterItem) { + + fun onMoreValuesAction(action: FilterMoreAction) = apply { + moreValuesAction = action + } + + fun onPropertySelectedAction(action: PropertySelectedAction) = apply { + propertySelectedAction = action + } + + fun setMaxTagCount(count: Int) = apply { + maxTagCount = count + } + + fun setSpacingHorizontal(horizontalSpacingDp: Int) = apply { + tagSpacingHorizontalDp = horizontalSpacingDp + } + + fun setSpacingVertical(verticalSpacingDp: Int) = apply { + tagSpacingVerticalDp = verticalSpacingDp + } + + fun setSpacing(value: Int) = apply { + tagSpacingHorizontalDp = value + tagSpacingVerticalDp = value + } + + fun setSelectionType(type: SelectionType) = apply { + selectionType = type + } + + fun isSingleLine(value: Boolean) = apply { + isSingleLine = value + } + + fun setTagLayout(@LayoutRes layoutId: Int) = apply { + tagLayout = layoutId + } + + fun setMoreTagLayout(@LayoutRes layoutId: Int, text: String) = apply { + moreTagLayout = layoutId + moreTagText = text + } + + fun build() { + this@TagLayoutView.filterItem = filterItem + tagsContainer = inflateAndGetChipGroup(isSingleLine) + + with(tagsContainer) { + removeAllViews() + + this.isSingleLine = isSingleLine + + chipSpacingHorizontal = tagSpacingHorizontalDp.px + chipSpacingVertical = tagSpacingVerticalDp.px + + filterItem.properties.take(maxTagCount).forEach { property -> + addView(createTag(property)) + } + + if (filterItem.properties.size > maxTagCount) { + addView(createMoreTag(filterItem)) + } + } + } + } +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/tags/TagView.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/tags/TagView.kt new file mode 100644 index 0000000..4a4ee35 --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/tags/TagView.kt @@ -0,0 +1,26 @@ +package ru.touchin.roboswag.base_filters.tags + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatCheckBox + +class TagView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +): AppCompatCheckBox(context, attrs, defStyleAttr) { + + var tagId: Int? = null + + private var action: (( view: TagView, isChecked: Boolean) -> Unit)? = null + + init { + setOnClickListener { + action?.invoke(this, isChecked) + } + } + + fun setOnCheckAction(action: (view: TagView, isChecked: Boolean) -> Unit) { + this.action = action + } +} diff --git a/base-filters/src/main/java/ru/touchin/roboswag/base_filters/tags/model/FilterProperty.kt b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/tags/model/FilterProperty.kt new file mode 100644 index 0000000..c45742d --- /dev/null +++ b/base-filters/src/main/java/ru/touchin/roboswag/base_filters/tags/model/FilterProperty.kt @@ -0,0 +1,32 @@ +package ru.touchin.roboswag.base_filters.tags.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +open class FilterItem( + val id: Int, + val title: String, + val properties: List +) : Parcelable + +@Parcelize +open class FilterProperty( + val id: Int, + val title: String, + val excludes: List, + val isSelected: Boolean = false +) : Parcelable { + + open fun copyWithSelected(isSelected: Boolean) = FilterProperty( + id = id, + title = title, + excludes = excludes, + isSelected = isSelected + ) +} + +@Parcelize +open class PropertyExcludingValue( + val id: Int +) : Parcelable diff --git a/base-filters/src/main/res/color/color_chip_choice.xml b/base-filters/src/main/res/color/color_chip_choice.xml new file mode 100644 index 0000000..d19976d --- /dev/null +++ b/base-filters/src/main/res/color/color_chip_choice.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/base-filters/src/main/res/drawable/background_chip.xml b/base-filters/src/main/res/drawable/background_chip.xml new file mode 100644 index 0000000..177d7bd --- /dev/null +++ b/base-filters/src/main/res/drawable/background_chip.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/base-filters/src/main/res/drawable/background_chip_checked.xml b/base-filters/src/main/res/drawable/background_chip_checked.xml new file mode 100644 index 0000000..9e00394 --- /dev/null +++ b/base-filters/src/main/res/drawable/background_chip_checked.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/base-filters/src/main/res/drawable/background_chip_choice.xml b/base-filters/src/main/res/drawable/background_chip_choice.xml new file mode 100644 index 0000000..cef4af3 --- /dev/null +++ b/base-filters/src/main/res/drawable/background_chip_choice.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/base-filters/src/main/res/drawable/background_hint_input.xml b/base-filters/src/main/res/drawable/background_hint_input.xml new file mode 100644 index 0000000..354e22c --- /dev/null +++ b/base-filters/src/main/res/drawable/background_hint_input.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/base-filters/src/main/res/drawable/cursor_background_text_input_view.xml b/base-filters/src/main/res/drawable/cursor_background_text_input_view.xml new file mode 100644 index 0000000..4633444 --- /dev/null +++ b/base-filters/src/main/res/drawable/cursor_background_text_input_view.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/base-filters/src/main/res/layout/layout_default_tag.xml b/base-filters/src/main/res/layout/layout_default_tag.xml new file mode 100644 index 0000000..a5236c7 --- /dev/null +++ b/base-filters/src/main/res/layout/layout_default_tag.xml @@ -0,0 +1,12 @@ + + diff --git a/base-filters/src/main/res/layout/layout_multi_line_tag_group.xml b/base-filters/src/main/res/layout/layout_multi_line_tag_group.xml new file mode 100644 index 0000000..27118fd --- /dev/null +++ b/base-filters/src/main/res/layout/layout_multi_line_tag_group.xml @@ -0,0 +1,11 @@ + + diff --git a/base-filters/src/main/res/layout/layout_single_line_tag_group.xml b/base-filters/src/main/res/layout/layout_single_line_tag_group.xml new file mode 100644 index 0000000..3a32ef3 --- /dev/null +++ b/base-filters/src/main/res/layout/layout_single_line_tag_group.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/base-filters/src/main/res/layout/range_choice_view.xml b/base-filters/src/main/res/layout/range_choice_view.xml new file mode 100644 index 0000000..6d749ef --- /dev/null +++ b/base-filters/src/main/res/layout/range_choice_view.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + diff --git a/base-filters/src/main/res/layout/selection_item.xml b/base-filters/src/main/res/layout/selection_item.xml new file mode 100644 index 0000000..72bb7eb --- /dev/null +++ b/base-filters/src/main/res/layout/selection_item.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/base-filters/src/main/res/layout/view_hint_input.xml b/base-filters/src/main/res/layout/view_hint_input.xml new file mode 100644 index 0000000..1f66eb4 --- /dev/null +++ b/base-filters/src/main/res/layout/view_hint_input.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/base-filters/src/main/res/values/attrs.xml b/base-filters/src/main/res/values/attrs.xml new file mode 100644 index 0000000..32e1c69 --- /dev/null +++ b/base-filters/src/main/res/values/attrs.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/base-filters/src/main/res/values/colors.xml b/base-filters/src/main/res/values/colors.xml new file mode 100644 index 0000000..aed3bb2 --- /dev/null +++ b/base-filters/src/main/res/values/colors.xml @@ -0,0 +1,8 @@ + + + #B9B9B9 + #E35100 + #EE9766 + #E7E7E7 + #E35100 + diff --git a/base-filters/src/main/res/values/strings.xml b/base-filters/src/main/res/values/strings.xml new file mode 100644 index 0000000..6f75b53 --- /dev/null +++ b/base-filters/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + от + до + diff --git a/base-filters/src/main/res/values/styles.xml b/base-filters/src/main/res/values/styles.xml new file mode 100644 index 0000000..8565d5b --- /dev/null +++ b/base-filters/src/main/res/values/styles.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/base-map/src/main/java/ru/touchin/basemap/AbstractMapManager.kt b/base-map/src/main/java/ru/touchin/basemap/AbstractMapManager.kt index bf47586..4bb7693 100644 --- a/base-map/src/main/java/ru/touchin/basemap/AbstractMapManager.kt +++ b/base-map/src/main/java/ru/touchin/basemap/AbstractMapManager.kt @@ -18,6 +18,10 @@ abstract class AbstractMapManager( 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( target: TLocation, zoom: Float = getCameraZoom(), azimuth: Float = getCameraAzimuth(), - tilt: Float = getCameraTilt() + tilt: Float = getCameraTilt(), + animationDuration: Float = getDefaultDuration() ) - abstract fun smoothMoveCamera(targets: List, padding: Int = 0) + abstract fun smoothMoveCamera(targets: List, padding: Int = 0, animationDuration: Float = getDefaultDuration()) - abstract fun smoothMoveCamera(targets: List, width: Int, height: Int, padding: Int) + abstract fun smoothMoveCamera(targets: List, 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) diff --git a/base-map/src/main/java/ru/touchin/basemap/BaseIconGenerator.kt b/base-map/src/main/java/ru/touchin/basemap/BaseIconGenerator.kt new file mode 100644 index 0000000..4154efa --- /dev/null +++ b/base-map/src/main/java/ru/touchin/basemap/BaseIconGenerator.kt @@ -0,0 +1,12 @@ +package ru.touchin.basemap + +interface BaseIconGenerator { + + fun getClusterIcon(cluster: TCluster): TViewIcon? + + fun getClusterItemIcon(clusterItem: TPoint): TViewIcon? + + fun getClusterItemView(clusterItem: TPoint): TViewIcon? + + fun getClusterView(cluster: TCluster): TViewIcon? +} diff --git a/base-map/src/main/java/ru/touchin/basemap/BaseMapItemRenderer.kt b/base-map/src/main/java/ru/touchin/basemap/BaseMapItemRenderer.kt new file mode 100644 index 0000000..d94238d --- /dev/null +++ b/base-map/src/main/java/ru/touchin/basemap/BaseMapItemRenderer.kt @@ -0,0 +1,10 @@ +package ru.touchin.basemap + +interface BaseMapItemRenderer { + + var iconGenerator: BaseIconGenerator + + fun getClusterItemIcon(item: TPoint): TViewIcon? = iconGenerator.getClusterItemView(item) + + fun getClusterIcon(cluster: TCluster): TViewIcon? = iconGenerator.getClusterView(cluster) +} diff --git a/base-map/src/main/java/ru/touchin/basemap/MapExtension.kt b/base-map/src/main/java/ru/touchin/basemap/MapExtension.kt new file mode 100644 index 0000000..0bde807 --- /dev/null +++ b/base-map/src/main/java/ru/touchin/basemap/MapExtension.kt @@ -0,0 +1,13 @@ +package ru.touchin.basemap + +import android.util.SparseArray + +inline fun MutableMap.getOrPutIfNotNull(key: K, defaultValue: () -> V?): V? = + get(key) ?: defaultValue()?.also { value -> + put(key, value) + } + +inline fun SparseArray.getOrPutIfNotNull(key: Int, defaultValue: () -> V?): V? = + get(key) ?: defaultValue()?.also { value -> + put(key, value) + } diff --git a/base-map/src/main/res/drawable/marker_default_icon.xml b/base-map/src/main/res/drawable/marker_default_icon.xml new file mode 100644 index 0000000..53d3bac --- /dev/null +++ b/base-map/src/main/res/drawable/marker_default_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/bottom-sheet/.gitignore b/bottom-sheet/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/bottom-sheet/.gitignore @@ -0,0 +1 @@ +/build diff --git a/bottom-sheet/build.gradle b/bottom-sheet/build.gradle new file mode 100644 index 0000000..a30f045 --- /dev/null +++ b/bottom-sheet/build.gradle @@ -0,0 +1,26 @@ +apply from: "../android-configs/lib-config.gradle" +apply plugin: 'kotlin-android' + +dependencies { + implementation project(":navigation-base") + + implementation 'androidx.core:core-ktx' + implementation 'com.google.android.material:material' + + implementation("androidx.core:core-ktx") { + version { + require '1.9.0' + } + } + implementation("com.google.android.material:material") { + version { + require '1.4.0' + } + } +} + +android { + buildFeatures { + viewBinding true + } +} diff --git a/bottom-sheet/readme.md b/bottom-sheet/readme.md new file mode 100644 index 0000000..4ac7334 --- /dev/null +++ b/bottom-sheet/readme.md @@ -0,0 +1,29 @@ +# BottomSheet Utils + +- `BaseBottomSheet` - класс, содержащий парамерты `BottomSheetOptions` + +- `DefaultBottomSheet` - класс с классическим хедером и скруглением, в котором нужно переопределить `createContentView()` + +## BottomSheetOptions +- `styleId` - xml-стиль, в котором можно задать скругление +- `canDismiss` - может ли модалка быть срыта по тапу/свайпу/backButton +- `canTouchOutside` - возможность передавать жесты под модалкой +- `isSkipCollapsed` - убирает промежуточное состояние модалки +- `isFullscreen` - модалка откроется на весь экран, даже при маленьком контенте +- `isShiftedWithKeyboard` - модалка будет полностью подниматься при открытии клавиатуры +- `defaultDimAmount` - константное затемнение +- `animatedMaxDimAmount` - максимальное затемнение, при этом будет анимироваться в зависимости от offset +- `fadeAnimationOptions` - позволяет настроить fade анимацию при изменении высоты +- `heightStatesOptions` - позволяет задать 3 состояния высоты модалки + +## ContentFadeAnimationOptions +- `foregroundRes` - drawableId, который будет показыватся сверху во время анимации +- `duration` - длительность fade анимации +- `minAlpha` - минимальная прозрачность во время анимации + +## HeightStatesOptions +- `collapsedHeightPx` - высота минимального состояния +- `halfExpandedHalfPx` - высота промежуточного состояния +- `canTouchOutsideWhenCollapsed` - могут ли жесты передаватья под модалку в минимальном состоянии + +Тестовый проект: https://github.com/duwna/BottomSheets diff --git a/bottom-sheet/src/main/AndroidManifest.xml b/bottom-sheet/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c66f4e2 --- /dev/null +++ b/bottom-sheet/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/bottom-sheet/src/main/java/ru/touchin/roboswag/bottomsheet/BaseBottomSheet.kt b/bottom-sheet/src/main/java/ru/touchin/roboswag/bottomsheet/BaseBottomSheet.kt new file mode 100644 index 0000000..f42b19e --- /dev/null +++ b/bottom-sheet/src/main/java/ru/touchin/roboswag/bottomsheet/BaseBottomSheet.kt @@ -0,0 +1,161 @@ +package ru.touchin.roboswag.bottomsheet + +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.app.Dialog +import android.content.res.Resources +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.FrameLayout +import androidx.core.content.ContextCompat +import androidx.fragment.app.DialogFragment +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import kotlin.math.abs + +abstract class BaseBottomSheet : BottomSheetDialogFragment() { + + protected abstract val layoutId: Int + + protected open val bottomSheetOptions = BottomSheetOptions() + + protected val decorView: View + get() = checkNotNull(dialog?.window?.decorView) + + protected val bottomSheetView: FrameLayout + get() = decorView.findViewById(com.google.android.material.R.id.design_bottom_sheet) + + protected val touchOutsideView: View + get() = decorView.findViewById(com.google.android.material.R.id.touch_outside) + + protected val behavior: BottomSheetBehavior + get() = BottomSheetBehavior.from(bottomSheetView) + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = + inflater.inflate(layoutId, container) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + bottomSheetOptions.styleId?.let { setStyle(DialogFragment.STYLE_NORMAL, it) } + } + + override fun onStart() { + super.onStart() + bottomSheetOptions.defaultDimAmount?.let { dialog?.window?.setDimAmount(it) } + bottomSheetOptions.animatedMaxDimAmount?.let { setupDimAmountChanges(it) } + + if (bottomSheetOptions.isShiftedWithKeyboard) setupShiftWithKeyboard() + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + isCancelable = bottomSheetOptions.canDismiss + return super.onCreateDialog(savedInstanceState) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + if (bottomSheetOptions.isSkipCollapsed) { + behavior.state = BottomSheetBehavior.STATE_EXPANDED + behavior.skipCollapsed = true + } + + if (bottomSheetOptions.isFullscreen) { + bottomSheetView.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + } + + if (bottomSheetOptions.canTouchOutside) { + setupTouchOutside() + } + + bottomSheetOptions.fadeAnimationOptions?.let { + setupFadeAnimationOnHeightChanges(it) + } + + bottomSheetOptions.heightStatesOptions?.let { + setupHeightOptions(it) + } + } + + private fun setupDimAmountChanges(maxDimAmount: Float) { + behavior.peekHeight = 0 + dialog?.window?.setDimAmount(maxDimAmount) + + behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) = Unit + + override fun onSlide(bottomSheet: View, slideOffset: Float) { + dialog?.window?.setDimAmount(abs(slideOffset) * maxDimAmount) + } + }) + } + + private fun setupFadeAnimationOnHeightChanges(options: ContentFadeAnimationOptions) { + val foreground = checkNotNull( + ContextCompat.getDrawable(requireContext(), options.foregroundRes) + ).apply { + alpha = 0 + bottomSheetView.foreground = this + } + + bottomSheetView.addOnLayoutChangeListener { _, _, top, _, _, _, oldTop, _, _ -> + if (top != oldTop) showFadeAnimation(foreground, options) + } + } + + private fun showFadeAnimation(foreground: Drawable, options: ContentFadeAnimationOptions) { + val maxAlpha = 255 + foreground.alpha = maxAlpha + bottomSheetView.alpha = options.minAlpha + + ValueAnimator.ofInt(maxAlpha, 0).apply { + duration = options.duration + addUpdateListener { + val value = it.animatedValue as Int + foreground.alpha = value + bottomSheetView.alpha = (1 - value.toFloat() / maxAlpha).coerceAtLeast(options.minAlpha) + } + start() + } + } + + private fun setupHeightOptions(options: HeightStatesOptions) = with(behavior) { + isFitToContents = false + peekHeight = options.collapsedHeightPx + halfExpandedRatio = options.halfExpandedHalfPx / Resources.getSystem().displayMetrics.heightPixels.toFloat() + state = BottomSheetBehavior.STATE_COLLAPSED + if (options.canTouchOutsideWhenCollapsed) setupTouchOutsideWhenCollapsed() + } + + private fun setupTouchOutsideWhenCollapsed() { + setupTouchOutside() + + behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { + override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit + + override fun onStateChanged(bottomSheet: View, newState: Int) { + when (newState) { + BottomSheetBehavior.STATE_COLLAPSED -> setupTouchOutside() + else -> touchOutsideView.setOnTouchListener(null) + } + } + }) + } + + @Suppress("DEPRECATION") + private fun setupShiftWithKeyboard() { + dialog?.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + } + + @SuppressLint("ClickableViewAccessibility") + private fun setupTouchOutside() { + touchOutsideView.setOnTouchListener { _, event -> + requireActivity().dispatchTouchEvent(event) + true + } + } +} diff --git a/bottom-sheet/src/main/java/ru/touchin/roboswag/bottomsheet/BottomSheetOptions.kt b/bottom-sheet/src/main/java/ru/touchin/roboswag/bottomsheet/BottomSheetOptions.kt new file mode 100644 index 0000000..98a28db --- /dev/null +++ b/bottom-sheet/src/main/java/ru/touchin/roboswag/bottomsheet/BottomSheetOptions.kt @@ -0,0 +1,32 @@ +package ru.touchin.roboswag.bottomsheet + +import androidx.annotation.DrawableRes +import androidx.annotation.StyleRes + +/** + * See explanation in readme + * */ +data class BottomSheetOptions( + @StyleRes val styleId: Int? = null, + val canDismiss: Boolean = true, + val canTouchOutside: Boolean = false, + val isSkipCollapsed: Boolean = true, + val isFullscreen: Boolean = false, + val isShiftedWithKeyboard: Boolean = false, + val defaultDimAmount: Float? = null, + val animatedMaxDimAmount: Float? = null, + val fadeAnimationOptions: ContentFadeAnimationOptions? = null, + val heightStatesOptions: HeightStatesOptions? = null +) + +data class ContentFadeAnimationOptions( + @DrawableRes val foregroundRes: Int, + val duration: Long, + val minAlpha: Float +) + +data class HeightStatesOptions( + val collapsedHeightPx: Int, + val halfExpandedHalfPx: Int, + val canTouchOutsideWhenCollapsed: Boolean = true +) diff --git a/bottom-sheet/src/main/java/ru/touchin/roboswag/bottomsheet/DefaultBottomSheet.kt b/bottom-sheet/src/main/java/ru/touchin/roboswag/bottomsheet/DefaultBottomSheet.kt new file mode 100644 index 0000000..0dc19d0 --- /dev/null +++ b/bottom-sheet/src/main/java/ru/touchin/roboswag/bottomsheet/DefaultBottomSheet.kt @@ -0,0 +1,40 @@ +package ru.touchin.roboswag.bottomsheet + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import ru.touchin.roboswag.bottomsheet.databinding.DefaultBottomSheetBinding +import ru.touchin.roboswag.navigation_base.fragments.viewBinding + +abstract class DefaultBottomSheet : BaseBottomSheet() { + + abstract fun createContentView(inflater: LayoutInflater): View + + final override val layoutId = R.layout.default_bottom_sheet + + override val bottomSheetOptions = BottomSheetOptions( + styleId = R.style.RoundedBottomSheetStyle, + fadeAnimationOptions = ContentFadeAnimationOptions( + foregroundRes = R.drawable.bottom_sheet_background_rounded_16, + duration = 150, + minAlpha = 0.5f + ) + ) + + protected val rootBinding by viewBinding(DefaultBottomSheetBinding::bind) + + protected val contentView: View get() = rootBinding.linearRoot.getChildAt(1) + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = + super.onCreateView(inflater, container, savedInstanceState) + .also { + DefaultBottomSheetBinding.bind(checkNotNull(it)) + .linearRoot.addView(createContentView(inflater)) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + rootBinding.closeText.setOnClickListener { dismiss() } + } +} diff --git a/bottom-sheet/src/main/res/drawable/bottom_sheet_background_rounded_16.xml b/bottom-sheet/src/main/res/drawable/bottom_sheet_background_rounded_16.xml new file mode 100644 index 0000000..1146eb4 --- /dev/null +++ b/bottom-sheet/src/main/res/drawable/bottom_sheet_background_rounded_16.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/bottom-sheet/src/main/res/layout/default_bottom_sheet.xml b/bottom-sheet/src/main/res/layout/default_bottom_sheet.xml new file mode 100644 index 0000000..792de5b --- /dev/null +++ b/bottom-sheet/src/main/res/layout/default_bottom_sheet.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + diff --git a/bottom-sheet/src/main/res/values/styles.xml b/bottom-sheet/src/main/res/values/styles.xml new file mode 100644 index 0000000..d10936c --- /dev/null +++ b/bottom-sheet/src/main/res/values/styles.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/build.gradle b/build.gradle index 7a61b19..e56cb52 100644 --- a/build.gradle +++ b/build.gradle @@ -17,7 +17,7 @@ allprojects { google() jcenter() maven { - url "https://dl.bintray.com/touchin/touchin-tools" + url "https://maven.dev.touchin.ru/" metadataSources { artifact() } diff --git a/cart-utils/.gitignore b/cart-utils/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/cart-utils/.gitignore @@ -0,0 +1 @@ +/build diff --git a/cart-utils/build.gradle b/cart-utils/build.gradle new file mode 100644 index 0000000..ff9abdc --- /dev/null +++ b/cart-utils/build.gradle @@ -0,0 +1,22 @@ +apply from: "../android-configs/lib-config.gradle" + +dependencies { + def coroutinesVersion = '1.6.4' + def junitVersion = '4.13.2' + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core") + testImplementation("junit:junit") + + constraints { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core") { + version { + require(coroutinesVersion) + } + } + testImplementation("junit:junit") { + version { + require(junitVersion) + } + } + } +} diff --git a/cart-utils/src/main/AndroidManifest.xml b/cart-utils/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8a05ba2 --- /dev/null +++ b/cart-utils/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/CartModel.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/CartModel.kt new file mode 100644 index 0000000..baa8ed5 --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/CartModel.kt @@ -0,0 +1,35 @@ +package ru.touchin.roboswag.cart_utils.models + +abstract class CartModel { + + abstract val products: List + + open val promocodeList: List = emptyList() + + open val availableBonuses: Int = 0 + open val usedBonuses: Int = 0 + + val availableProducts: List + get() = products.filter { it.isAvailable && !it.isDeleted } + + val totalPrice: Int + get() = availableProducts.sumOf { it.countInCart * it.price } + + val totalBonuses: Int + get() = availableProducts.sumOf { it.countInCart * (it.bonuses ?: 0) } + + fun getPriceWithPromocode(): Int = promocodeList + .sortedByDescending { it.discount is PromocodeDiscount.ByPercent } + .fold(initial = totalPrice) { price, promo -> + promo.discount.applyTo(price) + } + + abstract fun copyWith( + products: List = this.products, + promocodeList: List = this.promocodeList, + usedBonuses: Int = this.usedBonuses + ): TCart + + @Suppress("UNCHECKED_CAST") + fun asCart() = this as TCart +} diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/ProductModel.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/ProductModel.kt new file mode 100644 index 0000000..56c2758 --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/ProductModel.kt @@ -0,0 +1,25 @@ +package ru.touchin.roboswag.cart_utils.models + +abstract class ProductModel { + abstract val id: Int + abstract val countInCart: Int + abstract val price: Int + abstract val isAvailable: Boolean + abstract val isDeleted: Boolean + + open val bonuses: Int? = null + + open val variants: List = emptyList() + open val selectedVariantId: Int? = null + + val selectedVariant get() = variants.find { it.id == selectedVariantId } + + abstract fun copyWith( + countInCart: Int = this.countInCart, + isDeleted: Boolean = this.isDeleted, + selectedVariantId: Int? = this.selectedVariantId + ): TProduct + + @Suppress("UNCHECKED_CAST") + fun asProduct(): TProduct = this as TProduct +} diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/PromocodeModel.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/PromocodeModel.kt new file mode 100644 index 0000000..aab308d --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/PromocodeModel.kt @@ -0,0 +1,19 @@ +package ru.touchin.roboswag.cart_utils.models + +open class PromocodeModel( + val code: String, + val discount: PromocodeDiscount, +) + +abstract class PromocodeDiscount { + + abstract fun applyTo(totalPrice: Int): Int + + class ByValue(private val value: Int) : PromocodeDiscount() { + override fun applyTo(totalPrice: Int): Int = totalPrice - value + } + + class ByPercent(private val percent: Int) : PromocodeDiscount() { + override fun applyTo(totalPrice: Int): Int = totalPrice - totalPrice * percent / 100 + } +} diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/repositories/IRemoteCartRepository.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/repositories/IRemoteCartRepository.kt new file mode 100644 index 0000000..149a960 --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/repositories/IRemoteCartRepository.kt @@ -0,0 +1,19 @@ +package ru.touchin.roboswag.cart_utils.repositories + +import ru.touchin.roboswag.cart_utils.models.CartModel +import ru.touchin.roboswag.cart_utils.models.ProductModel + +/** + * Interface for server-side cart repository where each request should return updated [CartModel] + */ +interface IRemoteCartRepository, TProduct : ProductModel> { + + suspend fun getCart(): TCart + + suspend fun addProduct(product: TProduct): TCart + + suspend fun removeProduct(id: Int): TCart + + suspend fun editProductCount(id: Int, count: Int): TCart + +} diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/repositories/LocalCartRepository.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/repositories/LocalCartRepository.kt new file mode 100644 index 0000000..9746e47 --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/repositories/LocalCartRepository.kt @@ -0,0 +1,96 @@ +package ru.touchin.roboswag.cart_utils.repositories + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import ru.touchin.roboswag.cart_utils.models.CartModel +import ru.touchin.roboswag.cart_utils.models.ProductModel +import ru.touchin.roboswag.cart_utils.models.PromocodeModel + +/** + * Class that contains StateFlow of current [CartModel] which can be subscribed in ViewModels + */ +class LocalCartRepository, TProduct : ProductModel>( + initialCart: TCart +) { + + private val _currentCart = MutableStateFlow(initialCart) + val currentCart = _currentCart.asStateFlow() + + fun updateCart(cart: TCart) { + _currentCart.value = cart + } + + fun addProduct(product: TProduct) { + updateCartProducts { + add(product) + } + } + + fun removeProduct(id: Int) { + updateCartProducts { + remove(find { it.id == id }) + } + } + + fun editProductCount(id: Int, count: Int) { + updateCartProducts { + updateProduct(id) { copyWith(countInCart = count) } + } + } + + fun markProductDeleted(id: Int) { + updateCartProducts { + updateProduct(id) { copyWith(isDeleted = true) } + } + } + + fun restoreDeletedProduct(id: Int) { + updateCartProducts { + updateProduct(id) { copyWith(isDeleted = false) } + } + } + + fun applyPromocode(promocode: PromocodeModel) { + updatePromocodeList { add(promocode) } + } + + fun removePromocode(code: String) { + updatePromocodeList { removeAt(indexOfFirst { it.code == code }) } + } + + fun useBonuses(bonuses: Int) { + require(currentCart.value.availableBonuses >= bonuses) { "Can't use bonuses more than available" } + _currentCart.update { it.copyWith(usedBonuses = bonuses) } + } + + fun chooseVariant(productId: Int, variantId: Int?) { + updateCartProducts { + updateProduct(productId) { + if (variantId != null) { + check(variants.any { it.id == variantId }) { + "Product with id=$productId doesn't have variant with id=$variantId" + } + } + copyWith(selectedVariantId = variantId) + } + } + } + + private fun updateCartProducts(updateAction: MutableList.() -> Unit) { + _currentCart.update { cart -> + cart.copyWith(products = cart.products.toMutableList().apply(updateAction)) + } + } + + private fun updatePromocodeList(updateAction: MutableList.() -> Unit) { + _currentCart.update { cart -> + cart.copyWith(promocodeList = cart.promocodeList.toMutableList().apply(updateAction)) + } + } + + private fun MutableList.updateProduct(id: Int, updateAction: TProduct.() -> TProduct) { + val index = indexOfFirst { it.id == id } + if (index >= 0) this[index] = updateAction.invoke(this[index]) + } +} diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/requests_qeue/RequestsQueue.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/requests_qeue/RequestsQueue.kt new file mode 100644 index 0000000..d84a95d --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/requests_qeue/RequestsQueue.kt @@ -0,0 +1,39 @@ +package ru.touchin.roboswag.cart_utils.requests_qeue + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * Queue for abstract requests which will be executed one after another + */ +typealias Request = suspend () -> TResponse + +class RequestsQueue> { + + private val requestChannel = Channel(capacity = Channel.BUFFERED) + + fun initRequestsExecution( + coroutineScope: CoroutineScope, + executeRequestAction: suspend (TRequest) -> Unit, + ) { + requestChannel + .consumeAsFlow() + .onEach { executeRequestAction.invoke(it) } + .launchIn(coroutineScope) + } + + fun addToQueue(request: TRequest) { + requestChannel.trySend(request) + } + + fun clearQueue() { + while (hasPendingRequests()) requestChannel.tryReceive() + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun hasPendingRequests() = !requestChannel.isEmpty +} diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/update_manager/CartUpdateManager.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/update_manager/CartUpdateManager.kt new file mode 100644 index 0000000..41b5a88 --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/update_manager/CartUpdateManager.kt @@ -0,0 +1,110 @@ +package ru.touchin.roboswag.cart_utils.update_manager + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import ru.touchin.roboswag.cart_utils.models.CartModel +import ru.touchin.roboswag.cart_utils.models.ProductModel +import ru.touchin.roboswag.cart_utils.repositories.IRemoteCartRepository +import ru.touchin.roboswag.cart_utils.repositories.LocalCartRepository +import ru.touchin.roboswag.cart_utils.requests_qeue.Request +import ru.touchin.roboswag.cart_utils.requests_qeue.RequestsQueue + +/** + * Combines local and remote cart update actions + */ +open class CartUpdateManager, TProduct : ProductModel>( + private val localCartRepository: LocalCartRepository, + private val remoteCartRepository: IRemoteCartRepository, + private val maxRequestAttemptsCount: Int = MAX_REQUEST_CART_ATTEMPTS_COUNT, + private val errorHandler: (Throwable) -> Unit = {}, +) { + + companion object { + private const val MAX_REQUEST_CART_ATTEMPTS_COUNT = 3 + } + + private val requestsQueue = RequestsQueue>() + + @Volatile + var lastRemoteCart: TCart? = null + private set + + fun initCartRequestsQueue( + coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + ) { + requestsQueue.initRequestsExecution(coroutineScope) { request -> + runCatching { + lastRemoteCart = request.invoke() + if (!requestsQueue.hasPendingRequests()) updateLocalCartWithRemote() + }.onFailure { error -> + errorHandler.invoke(error) + requestsQueue.clearQueue() + tryToGetRemoteCartAgain() + } + } + } + + open fun addProduct(product: TProduct, restoreDeleted: Boolean = false) { + with(localCartRepository) { + if (restoreDeleted) restoreDeletedProduct(product.id) else addProduct(product) + } + + requestsQueue.addToQueue { + remoteCartRepository.addProduct(product) + } + } + + open fun removeProduct(id: Int, markDeleted: Boolean = false) { + with(localCartRepository) { + if (markDeleted) markProductDeleted(id) else removeProduct(id) + } + + requestsQueue.addToQueue { + remoteCartRepository.removeProduct(id) + } + } + + open fun editProductCount(id: Int, count: Int) { + localCartRepository.editProductCount(id, count) + + requestsQueue.addToQueue { + remoteCartRepository.editProductCount(id, count) + } + } + + private suspend fun tryToGetRemoteCartAgain() { + repeat(maxRequestAttemptsCount) { + runCatching { + lastRemoteCart = remoteCartRepository.getCart() + updateLocalCartWithRemote() + + return + } + } + } + + private fun updateLocalCartWithRemote() { + val remoteCart = lastRemoteCart ?: return + val remoteProducts = remoteCart.products + val localProducts = localCartRepository.currentCart.value.products + + val newProductsFromRemoteCart = remoteProducts.filter { remoteProduct -> + localProducts.none { it.id == remoteProduct.id } + } + + val mergedProducts = localProducts.mapNotNull { localProduct -> + val sameRemoteProduct = remoteProducts.find { it.id == localProduct.id } + + when { + sameRemoteProduct != null -> sameRemoteProduct + localProduct.isDeleted -> localProduct + else -> null + } + } + + val mergedCart = remoteCart.copyWith(products = mergedProducts + newProductsFromRemoteCart) + localCartRepository.updateCart(mergedCart) + } + +} diff --git a/client-services/.gitignore b/client-services/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/client-services/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/client-services/build.gradle b/client-services/build.gradle new file mode 100644 index 0000000..6799856 --- /dev/null +++ b/client-services/build.gradle @@ -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' + } + } + } +} diff --git a/client-services/src/main/AndroidManifest.xml b/client-services/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4390a88 --- /dev/null +++ b/client-services/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/client-services/src/main/java/ru/touchin/client_services/MobileService.kt b/client-services/src/main/java/ru/touchin/client_services/MobileService.kt new file mode 100644 index 0000000..6514e3a --- /dev/null +++ b/client-services/src/main/java/ru/touchin/client_services/MobileService.kt @@ -0,0 +1,5 @@ +package ru.touchin.client_services + +enum class MobileService { + HUAWEI_SERVICE, GOOGLE_SERVICE +} diff --git a/client-services/src/main/java/ru/touchin/client_services/ServicesUtils.kt b/client-services/src/main/java/ru/touchin/client_services/ServicesUtils.kt new file mode 100644 index 0000000..1cc5b2a --- /dev/null +++ b/client-services/src/main/java/ru/touchin/client_services/ServicesUtils.kt @@ -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 + +} diff --git a/code-confirm/.gitignore b/code-confirm/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/code-confirm/.gitignore @@ -0,0 +1 @@ +/build diff --git a/code-confirm/build.gradle b/code-confirm/build.gradle new file mode 100644 index 0000000..d135f88 --- /dev/null +++ b/code-confirm/build.gradle @@ -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) + } + } + } +} diff --git a/code-confirm/src/main/AndroidManifest.xml b/code-confirm/src/main/AndroidManifest.xml new file mode 100644 index 0000000..749fd3b --- /dev/null +++ b/code-confirm/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeConfirmAction.kt b/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeConfirmAction.kt new file mode 100644 index 0000000..745bef2 --- /dev/null +++ b/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeConfirmAction.kt @@ -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 diff --git a/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeConfirmState.kt b/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeConfirmState.kt new file mode 100644 index 0000000..3a916d8 --- /dev/null +++ b/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeConfirmState.kt @@ -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 copyWith(updateBlock: T.() -> Unit): T +} diff --git a/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeConfirmViewModel.kt b/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeConfirmViewModel.kt new file mode 100644 index 0000000..1d78e32 --- /dev/null +++ b/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeConfirmViewModel.kt @@ -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( + initialState: State, + savedStateHandle: SavedStateHandle +) : MviViewModel(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() + 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() + } + +} diff --git a/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeResponse.kt b/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeResponse.kt new file mode 100644 index 0000000..a300d4e --- /dev/null +++ b/code-confirm/src/main/java/ru/touchin/code_confirm/BaseCodeResponse.kt @@ -0,0 +1,6 @@ +package ru.touchin.code_confirm + +abstract class BaseCodeResponse( + open val codeLifetime: Int, + open val codeId: String? = null +) diff --git a/code-confirm/src/main/java/ru/touchin/code_confirm/LifeTimer.kt b/code-confirm/src/main/java/ru/touchin/code_confirm/LifeTimer.kt new file mode 100644 index 0000000..9478076 --- /dev/null +++ b/code-confirm/src/main/java/ru/touchin/code_confirm/LifeTimer.kt @@ -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)) + + } + +} diff --git a/google-map/build.gradle b/google-map/build.gradle index 18e0af5..48a9fe3 100644 --- a/google-map/build.gradle +++ b/google-map/build.gradle @@ -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' + } + } } } diff --git a/google-map/src/main/java/ru/touchin/googlemap/GoogleIconGenerator.kt b/google-map/src/main/java/ru/touchin/googlemap/GoogleIconGenerator.kt new file mode 100644 index 0000000..15d4842 --- /dev/null +++ b/google-map/src/main/java/ru/touchin/googlemap/GoogleIconGenerator.kt @@ -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( + private val context: Context +) : IconGenerator(context), BaseIconGenerator, BitmapDescriptor> { + + private val clusterIconsCache = SparseArray() + private val clusterItemIconsCache = mutableMapOf() + + 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): 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): BitmapDescriptor? = + clusterIconsCache.getOrPutIfNotNull(cluster.size) { getClusterIcon(cluster) } +} diff --git a/google-map/src/main/java/ru/touchin/googlemap/GoogleMapItemRenderer.kt b/google-map/src/main/java/ru/touchin/googlemap/GoogleMapItemRenderer.kt new file mode 100644 index 0000000..cdf2a74 --- /dev/null +++ b/google-map/src/main/java/ru/touchin/googlemap/GoogleMapItemRenderer.kt @@ -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( + val context: Context, + googleMap: GoogleMap, + clusterManager: ClusterManager, + private val minClusterItemSize: Int = 1 +) : DefaultClusterRenderer(context, googleMap, clusterManager) { + + var iconGenerator: BaseIconGenerator, BitmapDescriptor> = + GoogleIconGenerator(context).apply { setDefaultViewAndBackground() } + + override fun shouldRenderAsCluster(cluster: Cluster): Boolean = + cluster.size > minClusterItemSize + + override fun onBeforeClusterItemRendered(item: TClusterItem, markerOptions: MarkerOptions) { + markerOptions.icon(getMarkerIcon(item)) + } + + override fun onBeforeClusterRendered(cluster: Cluster, markerOptions: MarkerOptions) { + markerOptions.icon(getClusterIcon(cluster = cluster)) + } + + private fun getMarkerIcon(item: TClusterItem): BitmapDescriptor? = + iconGenerator.getClusterItemView(item) + + private fun getClusterIcon(cluster: Cluster): BitmapDescriptor? = + iconGenerator.getClusterView(cluster) + +} diff --git a/google-map/src/main/java/ru/touchin/googlemap/GoogleMapManager.kt b/google-map/src/main/java/ru/touchin/googlemap/GoogleMapManager.kt index 503f1f2..5712eb5 100644 --- a/google-map/src/main/java/ru/touchin/googlemap/GoogleMapManager.kt +++ b/google-map/src/main/java/ru/touchin/googlemap/GoogleMapManager.kt @@ -13,9 +13,14 @@ import ru.touchin.basemap.AbstractMapManager @Suppress("detekt.TooManyFunctions") class GoogleMapManager(mapView: MapView) : AbstractMapManager(mapView) { + companion object { + private const val CAMERA_ANIMATION_DURATION = 1f + private const val CAMERA_DEFAULT_STEP = 2 + } + override fun initialize(mapListener: AbstractMapListener?) { super.initialize(mapListener) - mapView.getMapAsync(::initMap) + mapView.getMapAsync(this::initMap) } override fun initMap(map: GoogleMap) { @@ -77,22 +82,52 @@ class GoogleMapManager(mapView: MapView) : AbstractMapManager, padding: Int) { + override fun smoothMoveCamera(targets: List, 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, width: Int, height: Int, padding: Int) { + override fun smoothMoveCamera(targets: List, 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) diff --git a/google-map/src/main/java/ru/touchin/googlemap/GooglePlacemarkManager.kt b/google-map/src/main/java/ru/touchin/googlemap/GooglePlacemarkManager.kt new file mode 100644 index 0000000..b91e5c0 --- /dev/null +++ b/google-map/src/main/java/ru/touchin/googlemap/GooglePlacemarkManager.kt @@ -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( + context: Context, + private val lifecycleOwner: LifecycleOwner, + private val googleMap: GoogleMap, + clusterItemTapAction: (TClusterItem) -> Boolean, + clusterTapAction: (Cluster) -> Boolean, + clusterAlgorithm: Algorithm = PreCachingAlgorithmDecorator(NonHierarchicalDistanceBasedAlgorithm()) +) : ClusterManager(context, googleMap), GoogleMap.OnCameraIdleListener { + + private var clusteringJob: Job? = null + private val onVisibilityChangedEvent = MutableSharedFlow>>( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + private var cameraIdleJob: Job? = null + private val onCameraIdleEvent = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + private val markers = mutableListOf() + private var lastVisibleItems = emptyList() + + var onCameraIdleListener: (() -> Unit)? = null + + var clusterRenderer: GoogleMapItemRenderer? = 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) { + 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) { + 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> = + googleMap.projection.visibleRegion to markers + + private fun findItemsInRegion(region: VisibleRegion?, items: List): List? = + 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 + } + +} diff --git a/google-map/src/main/res/drawable/default_cluster_background.xml b/google-map/src/main/res/drawable/default_cluster_background.xml new file mode 100644 index 0000000..9dda8f3 --- /dev/null +++ b/google-map/src/main/res/drawable/default_cluster_background.xml @@ -0,0 +1,6 @@ + + + + diff --git a/google-map/src/main/res/layout/view_google_map_cluster_item.xml b/google-map/src/main/res/layout/view_google_map_cluster_item.xml new file mode 100644 index 0000000..2f92a85 --- /dev/null +++ b/google-map/src/main/res/layout/view_google_map_cluster_item.xml @@ -0,0 +1,12 @@ + + diff --git a/kotlin-extensions/src/main/java/ru/touchin/extensions/TypedArray.kt b/kotlin-extensions/src/main/java/ru/touchin/extensions/TypedArray.kt new file mode 100644 index 0000000..6b013cb --- /dev/null +++ b/kotlin-extensions/src/main/java/ru/touchin/extensions/TypedArray.kt @@ -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 } diff --git a/kotlin-extensions/src/main/java/ru/touchin/utils/ActionThrottler.kt b/kotlin-extensions/src/main/java/ru/touchin/utils/ActionThrottler.kt index 8721e60..da3e1b5 100644 --- a/kotlin-extensions/src/main/java/ru/touchin/utils/ActionThrottler.kt +++ b/kotlin-extensions/src/main/java/ru/touchin/utils/ActionThrottler.kt @@ -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 diff --git a/lifecycle/src/main/java/ru/touchin/lifecycle/OnLifecycle.kt b/lifecycle/src/main/java/ru/touchin/lifecycle/OnLifecycle.kt new file mode 100644 index 0000000..bd29666 --- /dev/null +++ b/lifecycle/src/main/java/ru/touchin/lifecycle/OnLifecycle.kt @@ -0,0 +1,37 @@ +package ru.touchin.lifecycle + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import java.lang.IllegalStateException +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +/** + * Delegate that allows to lazily initialize value on certain lifecycle event + * @param initializeEvent is event when value should be initialize + * @param initializer callback that handles value initialization + */ + +class OnLifecycle( + private val lifecycleOwner: R, + private val initializeEvent: Lifecycle.Event, + private val initializer: (R) -> T +) : ReadOnlyProperty { + + private var value: T? = null + + init { + lifecycleOwner.lifecycle.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (initializeEvent == event && value == null) { + value = initializer.invoke(lifecycleOwner) + } + } + }) + } + + override fun getValue(thisRef: R, property: KProperty<*>) = value + ?: throw IllegalStateException("Can't get access to value before $initializeEvent. Current is ${thisRef.lifecycle.currentState}") + +} diff --git a/lifecycle/src/main/java/ru/touchin/lifecycle/extensions/LifecycleOwner.kt b/lifecycle/src/main/java/ru/touchin/lifecycle/extensions/LifecycleOwner.kt new file mode 100644 index 0000000..c70329c --- /dev/null +++ b/lifecycle/src/main/java/ru/touchin/lifecycle/extensions/LifecycleOwner.kt @@ -0,0 +1,23 @@ +package ru.touchin.lifecycle.extensions + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import ru.touchin.lifecycle.OnLifecycle +import kotlin.properties.ReadOnlyProperty + +fun R.onCreateEvent( + initializer: (R) -> T +): ReadOnlyProperty = OnLifecycle(this, Lifecycle.Event.ON_CREATE, initializer) + +fun R.onStartEvent( + initializer: (R) -> T +): ReadOnlyProperty = OnLifecycle(this, Lifecycle.Event.ON_START, initializer) + +fun R.onResumeEvent( + initializer: (R) -> T +): ReadOnlyProperty = OnLifecycle(this, Lifecycle.Event.ON_RESUME, initializer) + +fun R.onLifecycle( + initializeEvent: Lifecycle.Event, + initializer: (R) -> T +): ReadOnlyProperty = OnLifecycle(this, initializeEvent, initializer) diff --git a/lifecycle/src/main/java/ru/touchin/lifecycle/scope/ViewCoroutineScope.kt b/lifecycle/src/main/java/ru/touchin/lifecycle/scope/ViewCoroutineScope.kt new file mode 100644 index 0000000..42e16c5 --- /dev/null +++ b/lifecycle/src/main/java/ru/touchin/lifecycle/scope/ViewCoroutineScope.kt @@ -0,0 +1,35 @@ +package ru.touchin.lifecycle.scope + +import android.view.View +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import ru.touchin.lifecycle.R + +val View.viewScope: CoroutineScope + get() { + val storedScope = getTag(R.string.view_coroutine_scope) as? CoroutineScope + if (storedScope != null) return storedScope + + val newScope = ViewCoroutineScope() + if (isAttachedToWindow) { + addOnAttachStateChangeListener(newScope) + setTag(R.string.view_coroutine_scope, newScope) + } else { + newScope.cancel() + } + + return newScope + } + +private class ViewCoroutineScope : CoroutineScope, View.OnAttachStateChangeListener { + override val coroutineContext = SupervisorJob() + Dispatchers.Main + + override fun onViewAttachedToWindow(view: View) = Unit + + override fun onViewDetachedFromWindow(view: View) { + coroutineContext.cancel() + view.setTag(R.string.view_coroutine_scope, null) + } +} diff --git a/lifecycle/src/main/res/values/strings.xml b/lifecycle/src/main/res/values/strings.xml new file mode 100644 index 0000000..4fbdb47 --- /dev/null +++ b/lifecycle/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + view_coroutine_scope + \ No newline at end of file diff --git a/logging_reader/.gitignore b/logging_reader/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/logging_reader/.gitignore @@ -0,0 +1 @@ +/build diff --git a/logging_reader/README.md b/logging_reader/README.md new file mode 100644 index 0000000..ecbff5b --- /dev/null +++ b/logging_reader/README.md @@ -0,0 +1,14 @@ +logging_reader +===== + +### Общее описание + +Модуль служит для получания на устройстве файла с логами разного приоритета, их анализа и возможности отправить файл через стандартный шеринг. + + +### Пример + +Для вызова диалогового окна, позволяющего сохранять, читать и отправлять логи, достаточно вызвать: +```kotlin +DebugLogsDialogFragment().show(parentFragmentManager, null) +``` \ No newline at end of file diff --git a/logging_reader/build.gradle b/logging_reader/build.gradle new file mode 100644 index 0000000..751955d --- /dev/null +++ b/logging_reader/build.gradle @@ -0,0 +1,29 @@ +apply from: "../android-configs/lib-config.gradle" + +dependencies { + implementation project(":navigation-base") + + implementation "androidx.recyclerview:recyclerview" + implementation "androidx.constraintlayout:constraintlayout" + implementation "androidx.fragment:fragment-ktx" + + constraints { + implementation("androidx.recyclerview:recyclerview") { + version { + require '1.1.0' + } + } + + implementation("androidx.constraintlayout:constraintlayout"){ + version { + require '2.2.0-alpha03' + } + } + + implementation("androidx.fragment:fragment-ktx") { + version { + require '1.2.1' + } + } + } +} diff --git a/logging_reader/src/main/AndroidManifest.xml b/logging_reader/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a9ba76b --- /dev/null +++ b/logging_reader/src/main/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/logging_reader/src/main/java/ru/touchin/roboswag/loggging_reader/DebugLogsDialogFragment.kt b/logging_reader/src/main/java/ru/touchin/roboswag/loggging_reader/DebugLogsDialogFragment.kt new file mode 100644 index 0000000..a9923c4 --- /dev/null +++ b/logging_reader/src/main/java/ru/touchin/roboswag/loggging_reader/DebugLogsDialogFragment.kt @@ -0,0 +1,87 @@ +package ru.touchin.roboswag.loggging_reader + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import androidx.core.content.FileProvider +import androidx.fragment.app.DialogFragment +import ru.touchin.roboswag.logging_reader.BuildConfig +import ru.touchin.roboswag.logging_reader.R +import ru.touchin.roboswag.logging_reader.databinding.DialogFragmentDebugLogsBinding +import ru.touchin.roboswag.navigation_base.fragments.viewBinding +import java.io.File + +class DebugLogsDialogFragment : DialogFragment() { + + private val logItemsList: MutableList = mutableListOf() + private val binding: DialogFragmentDebugLogsBinding by viewBinding(DialogFragmentDebugLogsBinding::bind) + + override fun getTheme(): Int = R.style.DialogFullscreenTheme + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.dialog_fragment_debug_logs, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initSpinner() + + binding.logsRecycler.adapter = LogItemAdapter(requireContext(), logItemsList) + updateRecycler() + + binding.updateBtn.setOnClickListener { updateRecycler() } + binding.shareBtn.setOnClickListener { onShareButtonClick() } + } + + private fun initSpinner() { + val priorityTitle = LogFileManager.Priority.values().map { it.title } + val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, priorityTitle) + adapter.setDropDownViewResource(android.R.layout.simple_dropdown_item_1line) + + binding.priorityFilter.adapter = adapter + binding.priorityFilter.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>, view: View, position: Int, id: Long) { + val priority = LogFileManager.Priority.values()[position] + LogFileManager(requireContext()).saveLogcatToFile(priority.tag) + binding.logsRecycler.postDelayed({ updateRecycler() }, 500) + } + + override fun onNothingSelected(parent: AdapterView<*>) {} + } + } + + private fun updateRecycler() { + binding.logsRecycler.adapter?.notifyItemRangeRemoved(0, logItemsList.size) + logItemsList.clear() + val files = LogFileManager(requireContext()).getLogDirectory().listFiles() + files?.firstOrNull()?.let { firstFile -> + File(firstFile.absolutePath) + .useLines { lines -> lines.forEach { logItemsList.add(it) } } + } + binding.logsRecycler.adapter?.notifyItemRangeInserted(0, logItemsList.size) + } + + private fun onShareButtonClick() { + val files = LogFileManager(requireContext()).getLogDirectory().listFiles() + + files?.firstOrNull()?.let { firstFile -> + val uri = FileProvider.getUriForFile( + requireContext(), + BuildConfig.LIBRARY_PACKAGE_NAME + LogFileManager.fileProviderName, + firstFile + ) + + val intent = Intent(Intent.ACTION_SEND).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + type = "*/*" + putExtra(Intent.EXTRA_STREAM, uri) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + startActivity(intent) + } + + } +} diff --git a/logging_reader/src/main/java/ru/touchin/roboswag/loggging_reader/LogFileManager.kt b/logging_reader/src/main/java/ru/touchin/roboswag/loggging_reader/LogFileManager.kt new file mode 100644 index 0000000..d8b598a --- /dev/null +++ b/logging_reader/src/main/java/ru/touchin/roboswag/loggging_reader/LogFileManager.kt @@ -0,0 +1,60 @@ +package ru.touchin.roboswag.loggging_reader + +import android.content.Context +import java.io.File +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class LogFileManager(private val context: Context) { + + enum class Priority(val title: String, val tag: String) { + VERBOSE("VERBOSE", "*:V"), + DEBUG("DEBUG", "*:D"), + INFO("INFO", "*:I"), + WARNING("WARNING", "*:W"), + ERROR("ERROR", "*:E"), + ASSERT("ASSERT", "*:A") + } + + companion object { + private const val logDirecroryName = "log" + const val fileProviderName = ".fileprovider" + } + + fun getLogDirectory(): File { + val appDirectory = context.getExternalFilesDir(null) + return File(appDirectory.toString() + "/$logDirecroryName") + } + + fun saveLogcatToFile(priorityTag: String) { + val logDirectory = initLogDirectory(context) + + val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH_mm_ss_SSS", Locale.getDefault()) + val logFile = File(logDirectory, "logcat_${sdf.format(Date())}.txt") + + try { + Runtime.getRuntime().exec("logcat $priorityTag -f $logFile") + } catch (e: IOException) { + e.printStackTrace() + } + } + + private fun initLogDirectory(context: Context): File { + val appDirectory = context.getExternalFilesDir(null) + if (appDirectory != null && !appDirectory.exists()) { + appDirectory.mkdir() + } + + val logDirectory = File(appDirectory.toString() + "/$logDirecroryName") + if (!logDirectory.exists()) { + logDirectory.mkdir() + } + + for (file in logDirectory.listFiles()) { + file.delete() + } + return logDirectory + } +} diff --git a/logging_reader/src/main/java/ru/touchin/roboswag/loggging_reader/LogItemAdapter.kt b/logging_reader/src/main/java/ru/touchin/roboswag/loggging_reader/LogItemAdapter.kt new file mode 100644 index 0000000..cff6838 --- /dev/null +++ b/logging_reader/src/main/java/ru/touchin/roboswag/loggging_reader/LogItemAdapter.kt @@ -0,0 +1,31 @@ +package ru.touchin.roboswag.loggging_reader + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import ru.touchin.roboswag.logging_reader.databinding.LogItemBinding + +class LogItemAdapter(private val context: Context, private val logItemList: MutableList) + : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = LogItemViewHolder( + binding = LogItemBinding.inflate(LayoutInflater.from(context), parent, false) + ) + + override fun onBindViewHolder(holder: LogItemViewHolder, position: Int) { + val logItem = logItemList[position] + holder.bind(logItem) + } + + override fun getItemCount(): Int { + return logItemList.size + } + + class LogItemViewHolder(private val binding: LogItemBinding) : RecyclerView.ViewHolder(binding.root) { + + fun bind(logItem: String) { + binding.logDescription.text = logItem + } + } +} diff --git a/logging_reader/src/main/res/layout/dialog_fragment_debug_logs.xml b/logging_reader/src/main/res/layout/dialog_fragment_debug_logs.xml new file mode 100644 index 0000000..16e995d --- /dev/null +++ b/logging_reader/src/main/res/layout/dialog_fragment_debug_logs.xml @@ -0,0 +1,54 @@ + + + +