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