Compare commits

...

186 Commits

Author SHA1 Message Date
Kirill Khoroshkov 5a6ea76d33 Merge pull request 'feature/lifecycle_update' (#7) from feature/lifecycle_update into master
Reviewed-on: #7
2023-12-28 17:01:58 +03:00
KirillKhoroshkov 89d8bfdff5 Fixed KeyboardResizeableViewController, DelegationListAdapter and LoadingContentView 2023-12-27 21:41:05 +03:00
KirillKhoroshkov b0c818aadd Replaced 'fun getLifecycle' with 'val lifecycle' 2023-12-27 21:10:17 +03:00
KirillKhoroshkov ee44d8aa89 Replaced 'fun getLifecycle' with 'val lifecycle' 2023-12-27 20:35:48 +03:00
Dmitry Yurchenko 65a8bb31ce Merge pull request 'bottomsheet_utils' (#1) from bottomsheet_utils into master
Reviewed-on: #1
2023-07-26 10:39:53 +03:00
Grigorii b3cb64eb44 Add test project link 2023-04-07 12:08:00 +04:00
Grigorii aa952617ab add bottomsheet readme 2023-04-07 11:59:54 +04:00
Grigorii f0c8e3f1d7 Add bottomseet utils module 2023-04-07 11:39:46 +04:00
Grigorii Leontev 54d2482064 Merge branch 'cart_utils' into 'master'
Cart utils

See merge request touchinstinct/RoboSwag!3
2023-03-23 15:19:45 +00:00
Grigorii c30dc55e08 add new line 2023-03-23 18:56:46 +04:00
Grigorii Leontev e5b996e804 Merge branch 'feature/filters' into 'master'
feature/filters

See merge request touchinstinct/RoboSwag!4
2023-03-22 14:06:34 +00:00
Dmitry Yurchenko a57e298bdb Merge branch 'update_yandex_map_manager' into 'master'
add user location callback and change user location layer modifier

See merge request touchinstinct/RoboSwag!2
2023-03-16 10:20:05 +00:00
Dmitry Yurchenko 0f96243ee4 refactor 2023-03-16 12:51:41 +03:00
Dmitry Yurchenko 0417ca3e67 add user location callback and change user location layer modifier 2023-03-07 19:10:12 +03:00
kostikum 3b35c16cde
Merge pull request #284 from TouchInstinct/kostikum-patch-1
Update build.gradle
2023-02-21 15:15:55 +07:00
kostikum e7a9673608
Update build.gradle 2023-02-21 02:17:26 +07:00
styni b7756fa31d
Merge pull request #282 from TouchInstinct/update_yandex_map_version
Update yandex map version
2023-02-20 11:20:28 +03:00
Dmitry Yurchenko e9426acc2e change yandex map to lite version 2023-02-16 10:33:42 +03:00
Dmitry Yurchenko c431e7fe46 add map tap listeners 2023-02-15 20:47:53 +03:00
Dmitry Yurchenko b7cfd7eec3 update yandex map to 4.2.2 vesion 2023-02-15 17:45:48 +03:00
Grigorii 4dc9e7e478 Fix detekt 2023-01-09 16:45:35 +04:00
Grigorii 724c2ca3b8 Add variants selecting 2023-01-09 16:37:20 +04:00
Grigorii 33a745e5e0 Add bonuses field to cart and products 2023-01-09 16:37:20 +04:00
Grigorii 8a0ed4ca06 Add promocode list and calculate discount 2023-01-09 16:37:20 +04:00
Grigorii 28b362ce00 Add generic cart models and CartUpdateManager 2023-01-09 16:37:20 +04:00
Grigorii ec1d6e5e61 Add cart module and requests queue helper 2023-01-09 16:37:20 +04:00
Grigorii 048c0a43a2
Merge pull request #280 from TouchInstinct/webview_improvements
Webview improvements
2023-01-09 16:36:42 +04:00
Grigorii 06c9ff8a1f Fix review comments 2022-12-28 19:12:58 +04:00
Grigorii 40ecdad81c Refactor RedirectionController 2022-12-28 16:41:08 +04:00
Grigorii 35ad69239c Add custom condition to IgnoredErrorsHolder 2022-12-28 14:27:05 +04:00
Grigorii 49037036b4 Add linear progress bar and refactor WebViewCallback 2022-12-27 15:44:42 +04:00
Grigorii 4ebe674620 Add IgnoredUrlsHolder 2022-12-27 13:22:22 +04:00
Grigorii ea5320e57e Add loading html with css styles 2022-12-26 18:04:29 +04:00
Grigorii f656676c12 Add RedirectionController to BaseWebView 2022-12-26 14:12:51 +04:00
airatmeister 39d045db1f
Merge pull request #279 from TouchInstinct/feature/text_processing
"Replace" template generation, Placeholder generation, Mask generation
2022-12-22 14:45:46 +03:00
airatmeister bcf8bf6cfe "Replace" template generation, Placeholder generation, Mask generation 2022-12-22 14:23:59 +03:00
airatmeister 1cef096d45
Merge pull request #278 from TouchInstinct/INTERNAL-377_Placeholder_generation_from_regex_source_expression
INTERNAL-377: Placeholder generation from regex source expression
2022-12-21 12:17:18 +03:00
airatmeister 1d5e8f0c01 INTERNAL-377: Placeholder generation from regex source expression 2022-12-20 21:04:39 +03:00
airatmeister 393ba8d8cb INTERNAL-377: Placeholder generation from regex source expression 2022-12-20 16:14:37 +03:00
Ganin Alexei 52be4071a4
Merge pull request #267 from TouchInstinct/code_confirm
Code confirm
2022-12-19 19:23:55 +03:00
airatmeister c74c23cf7a INTERNAL-377: Placeholder generation from regex source expression 2022-12-19 16:41:52 +03:00
Alexei Ganin ce3c58ee90 fix issues from Ekaterina Kacharova 2022-12-16 11:29:42 +03:00
airatmeister e57fea6cb0
Merge pull request #275 from TouchInstinct/INTERNAL-375_PCRE_grammar_generation_script
INTERNAL-375: PCRE grammar generation script
2022-12-14 18:40:37 +03:00
airatmeister b35306c65d
Merge pull request #276 from TouchInstinct/INTERNAL-376_Replace_template_generation_from_regex_source_expression
INTERNAL-376: "Replace" template generation from regex source expression
2022-12-14 18:40:07 +03:00
airatmeister 2c5ddac650 Added PCRE.g4 2022-12-14 18:31:54 +03:00
airatmeister c24c931159 INTERNAL-376: "Replace" template generation from regex source expression 2022-12-14 17:51:25 +03:00
airatmeister aecb860397 INTERNAL-375: PCRE grammar generation script 2022-12-14 17:29:46 +03:00
airatmeister 542c918820 INTERNAL-374: Runtime ANTLR integration 2022-12-14 17:23:37 +03:00
airatmeister c4999f82ce Initial commit 2022-12-13 12:24:38 +03:00
Grigorii 48cda1a34b
Merge pull request #266 from TouchInstinct/map_module_update
MapModule update
2022-10-20 13:57:53 +04:00
Grigorii 3f44ad3ba7 Map module: review comments and small fixes 2022-10-20 12:03:14 +04:00
Grigorii 3a3ad0211a
Merge pull request #263 from TouchInstinct/range_filter
Фильтры: Выбор минимального и максимального значения из диапозона
2022-10-17 12:30:50 +04:00
Grigorii e243c12b4f
Merge pull request #262 from TouchInstinct/tag_filters
Фильтры: Добавлен базовый функционал для фильтров в виде тегов
2022-10-17 12:30:05 +04:00
Grigorii 0650ba73a8
Merge pull request #259 from TouchInstinct/new_filters_list
Фильтры: Выбор одного/нескольких из доступных значений списка
2022-10-17 12:29:34 +04:00
Grigorii a475bf787a Small refactor 2022-10-06 20:43:50 +03:00
Grigorii ad39e5ca25
Merge pull request #268 from TouchInstinct/fix/addKeyboardListener
Fix memory leak in Fragment.addKeyboardListener()
2022-10-06 12:30:28 +03:00
Grigorii c71e7b863a Fix memory leak in Fragment.addKeyboardListener() 2022-10-06 12:02:14 +03:00
Grigorii 42b3df50fe Merge branch 'tag_filters' of github.com:TouchInstinct/RoboSwag into range_filter 2022-10-05 13:08:38 +03:00
Grigorii dbe34fb126 TagLayoutView small refactor 2022-10-05 13:07:12 +03:00
Grigorii 00fae760f1 Merge branch 'new_filters_list' of github.com:TouchInstinct/RoboSwag into tag_filters 2022-10-04 17:57:11 +03:00
Grigorii 191e80f2db Small update 2022-10-04 17:43:47 +03:00
AnastasiyaK97 6eeea0351a fix static 2022-09-19 19:29:51 +03:00
Anastasiya97 2d9c4db963 Merge branch 'tag_filters' into range_filter 2022-09-19 18:52:01 +03:00
Anastasiya97 25c74d640c Merge branch 'new_filters_list' into tag_filters 2022-09-19 18:51:23 +03:00
Anastasiya97 554b9c235c Merge branch 'new_filters_list' into range_filter 2022-09-19 18:47:19 +03:00
AnastasiyaK97 c604daf969 fix comment 2022-09-19 18:43:27 +03:00
AnastasiyaK97 3057797b23 fix by PR comments 2022-09-19 15:16:08 +03:00
Anastasiya97 c33b02c8ea Merge branch 'master' into code_confirm 2022-09-16 13:23:02 +03:00
AnastasiyaK97 c50c67e28f Add "code confirm" base realization 2022-09-16 12:09:06 +03:00
Anastasiya97 2577a85af5
Merge pull request #265 from TouchInstinct/alerts
Add alerts module
2022-09-16 11:41:50 +03:00
AnastasiyaK97 dc0c902e83 fix detekt 2022-09-16 11:41:07 +03:00
AnastasiyaK97 5e3636d5e7 small fixes 2022-09-16 10:56:48 +03:00
Anadol b1f7580d69
Update README.md 2022-09-15 19:31:59 +03:00
Anadol 5766535891
Update AlertDialogManager.kt 2022-09-15 19:30:39 +03:00
Anadol 67fa1ed8c6
Update README.md 2022-09-15 19:30:01 +03:00
AnastasiyaK97 c905682e78 update AlertDialogUtils 2022-09-15 18:17:40 +03:00
AnastasiyaK97 c1474b546c update AlertDialogManagers 2022-09-15 18:14:29 +03:00
AnastasiyaK97 613d7241f8 update README 2022-09-15 16:59:41 +03:00
AnastasiyaK97 c8c44ede4f fix by PR comment 2022-09-14 17:34:44 +03:00
AnastasiyaK97 26755f0730 small fixes 2022-09-14 17:32:45 +03:00
AnastasiyaK97 72c9b70d32 add code-confirm module 2022-09-12 16:49:42 +03:00
AnastasiyaK97 5981c8f898 MapModule:
* add base classes for map markers placement, clustering, tap listeners
* add some methods for camera update in MapManagers
2022-09-08 18:23:00 +03:00
Anastasiya97 8b4f2e7059
Merge pull request #261 from TouchInstinct/logging_file
Added ability to save and view logs
2022-08-31 12:54:51 +03:00
AnastasiyaK97 703356b38b small fix 2022-08-31 12:49:26 +03:00
AnastasiyaK97 f9de7f3cda fix errors and small refactor 2022-08-31 12:37:03 +03:00
AnastasiyaK97 0dbbe3f6f0 small fixes and add styles 2022-08-30 16:10:53 +03:00
Anastasiya97 853d32236a Merge branch 'tag_filters' into range_filter
# Conflicts:
#	base-filters/README.md
2022-08-29 21:31:46 +03:00
AnastasiyaK97 81c6d972d7 PR issues 2022-08-29 21:29:49 +03:00
Anastasiya97 5074dfe443 Merge branch 'new_filters_list' into tag_filters
# Conflicts:
#	base-filters/src/main/java/ru/touchin/roboswag/base_filters/select_list_item/adapter/SelectionItemViewHolder.kt
2022-08-29 19:34:41 +03:00
AnastasiyaK97 bf55cc9579 PR issues 2022-08-29 19:29:18 +03:00
tonlirise f86d7d2d65 Minor fix 2022-08-11 10:04:48 +07:00
tonlirise d7cf93a471 Update compose version init 2022-08-10 14:42:57 +07:00
tonlirise 0cea029f36 Move DebugLogsDialog to separate module 2022-08-10 14:28:18 +07:00
tonlirise 2b0fd79f88 Add alerts module 2022-08-10 12:18:46 +07:00
tonlirise f5e87a08e6 PR fix 2022-08-10 10:57:39 +07:00
AnastasiyaK97 d41f8b3111 INTERNAL-301 + INTERNAL-302: Добавлен фильтр для выбора минимального и максимального значения из диапозона 2022-08-05 23:42:43 +03:00
AnastasiyaK97 2dd57ee308 INTERNAL-296 + INTERNAL-297 + INTERNAL-298: Добавлен базовый функционал для фильтров в виде тегов 2022-08-04 14:55:54 +03:00
tonlirise e14273642d Added ability to save and view logs 2022-08-04 12:10:02 +07:00
AnastasiyaK97 d8dc470805 SelectionItemViewHolder вынесен в отдельный файл 2022-08-01 12:42:00 +03:00
AnastasiyaK97 719252a3e1 Добавлена возможность использовать в списке кастомную разметку и тип данных 2022-08-01 11:37:49 +03:00
AnastasiyaK97 d08800af46 Добавлен readme файл 2022-07-29 12:41:30 +03:00
AnastasiyaK97 eb9d4adcf2 INTERNAL-300 + INTERNAL-299: Добвлена настраиваемая вью с выбором одного или нескольких вариантов из списка 2022-07-28 19:54:04 +03:00
AnastasiyaK97 aad4c398e8 add base-filter module 2022-07-27 15:54:02 +03:00
Grigorii b2b9caa4d3
Merge pull request #257 from TouchInstinct/fix/PET-3233
PET-3233: fix KeyboardBehaviorDetector when bottomInset == 0
2022-07-15 12:36:51 +03:00
Grigorii 8765710228 PET-3233: fix KeyboardBehaviorDetector when bottomInset == 0 2022-07-14 19:28:41 +03:00
Grigorii 19e5a361a5
Merge pull request #256 from TouchInstinct/fix/keybord_detector
Fix KeyboardBehaviorDetector
2022-06-03 15:23:19 +03:00
Grigorii 550cdd34b6 Fix startNavigationBarHeight initialization in KeyboardBehaviorDetector after DNKA 2022-06-03 12:27:38 +03:00
Kirill Nayduik 353b7ca6e5
Merge pull request #255 from TouchInstinct/migrate_from_bintray
Migrate from bintray to maven.dev
2022-05-11 13:59:44 +03:00
Kirill Nayduik 6aa11eb5e2 Migrate from bintray to maven.dev 2022-05-11 13:42:16 +03:00
Kirill Nayduik b556995867
Merge pull request #253 from TouchInstinct/date_time_test_fix
Date time test fix
2022-04-18 13:26:50 +03:00
Kirill Nayduik 7a37fb20b7 Add implementation of 'joda-time' for tests to avoid issue https://github.com/dlew/joda-time-android/issues/148 2022-04-18 13:13:35 +03:00
Kirill Nayduik bf68537809
Merge pull request #252 from TouchInstinct/date_time_utils
Date time utils
2022-04-18 12:23:44 +03:00
Grigorii f1189303b0
Merge pull request #251 from TouchInstinct/remove_pretty_printer
Remove pretty-print dependency from mvi module
2022-04-18 12:14:10 +03:00
Kirill Nayduik a3241002c5 Create DateFormatUtilsTest for testing DateFormatUtils 2022-04-18 11:59:21 +03:00
Kirill Nayduik 202e4f8ad1 Create DateFormatUtils class for handling cases with DateTime format 2022-04-18 11:59:08 +03:00
Grigorii 82e1cff525 Remove pretty-print dependency from mvi module 2022-04-18 11:51:21 +03:00
rinstance b7c6d88b0f
Merge pull request #248 from TouchInstinct/add_captcha_module
create captcha module with checking
2022-03-17 16:29:59 +03:00
Rinat Nurmukhametov 05f2adb849 fix for comments 2022-03-17 12:14:23 +03:00
Rinat Nurmukhametov 3681f5c92d change documentation + gradle deps 2022-03-17 12:02:27 +03:00
Rinat Nurmukhametov ccb3f1c4e1 create services module 2022-03-16 18:31:01 +03:00
Rinat Nurmukhametov d201cfb36f change README 2022-03-16 17:01:32 +03:00
Rinat Nurmukhametov d1706bb7c5 Merge branch 'add_captcha_module' of github.com:TouchInstinct/RoboSwag into add_captcha_module 2022-03-16 16:57:13 +03:00
Rinat Nurmukhametov 8d70d2dad1 refactor manager + utils 2022-03-16 16:56:04 +03:00
rinstance d9e891c838
change README 2022-03-15 11:48:11 +03:00
Rinat Nurmukhametov 9a728a9e57 create captcha module with checking 2022-03-15 11:46:24 +03:00
Aleksei Murnikov 1a2ecc7024
Merge pull request #247 from TouchInstinct/add_testing_utils
Add testing utils
2022-03-02 18:02:32 +03:00
Alemoore 7ed7a25bfe add TestableLiveDataDispatcher without android classes 2022-03-02 16:23:24 +03:00
Alemoore 24b380fff0 use interface in RxViewModel 2022-03-02 16:22:56 +03:00
Kirill Nayduik e0b83d80b8
Merge pull request #246 from TouchInstinct/submittable_paging_adapter
SubmittablePagingAdapter
2022-02-28 20:22:46 +03:00
Kirill Nayduik d907193e41 Add subclass of PagingDataAdapter with implementation of updating inner list 2022-02-28 13:22:39 +03:00
Kirill Nayduik 0a20682a87
Merge pull request #245 from TouchInstinct/coroutine_scope_view
Coroutine scope view
2022-02-14 11:03:17 +03:00
Kirill Nayduik f13984d727 Fix code style 2022-02-14 04:18:47 +03:00
Kirill Nayduik cab47a058c Add ViewCoroutineScope to handle scope inside view 2022-02-13 22:58:57 +03:00
Kirill Nayduik 1b994bab0e Change deprecated kotlin-android-extensions to kotlin-parcelize 2022-02-13 21:06:09 +03:00
Kirill Nayduik 902c029a29 Set dagger version up to 2.34 to make it compilable with kotlin 1.5+ 2022-02-13 21:05:39 +03:00
Kirill Nayduik 226f7d164f
Merge pull request #244 from TouchInstinct/fix/stylable_text_view
Make StyleableSubTextView open class
2022-02-02 20:04:31 +03:00
Kirill Nayduik 72d2f3007d Make StyleableSubTextView open class 2022-02-02 19:48:59 +03:00
Kirill Nayduik 248d55aa85
Merge pull request #243 from TouchInstinct/view/dynamic_text
Add ability to change text and subtext dynamically
2022-01-26 20:04:35 +03:00
Kirill Nayduik c844375fc1 Fix codeStyle and optimize text setting 2022-01-26 18:50:12 +03:00
Kirill Nayduik b8f6557195 Add ability to change text and subtext dynamically 2022-01-26 18:20:08 +03:00
Kirill Nayduik cbd94750bf
Merge pull request #242 from TouchInstinct/view/styleable_text
View/styleable text
2022-01-26 17:10:56 +03:00
Kirill Nayduik 3d01b780be Action TextViews bugfix 2022-01-26 16:58:24 +03:00
Kirill Nayduik da50319159
Merge pull request #241 from TouchInstinct/views/text_views
Add EllipsizeSpannableTextView and MultipleActionTextView from Petshop
2022-01-26 14:48:38 +03:00
Kirill Nayduik ee4c4aaa5a Add StyleableSubTextView to support styling substrings of text 2022-01-25 18:38:05 +03:00
Kirill Nayduik dde53450a8 Fix attrs for ActionTextViews 2022-01-25 18:28:02 +03:00
Kirill Nayduik e9249e88a9 Add ability to set style instead of color in ActionTextViews 2022-01-25 18:27:19 +03:00
Kirill Nayduik 3d6d2aa233 Comment typo fix 2022-01-25 15:32:18 +03:00
Kirill Nayduik 66e6d9e563 Add comments for new text view classes 2022-01-25 15:22:12 +03:00
Kirill Nayduik 4eb3683e41 Enable MultipleActionTextView to ellipsize 2022-01-25 15:10:57 +03:00
Kirill Nayduik afca6eab5f Remove redundant variable 2022-01-25 14:41:56 +03:00
Kirill Nayduik a27814fca9 Add EllipsizeSpannableTextView and MultipleActionTextView from Petshop 2022-01-25 13:47:10 +03:00
Kirill Nayduik 80412c01ed
Merge pull request #240 from TouchInstinct/lifecycle/detekt_fix
Fix detekt issues
2022-01-19 13:53:04 +03:00
Kirill Nayduik f58bdd9289 Fix detekt issues 2022-01-19 13:14:47 +03:00
Kirill Nayduik 881c775663
Merge pull request #239 from TouchInstinct/lifecycle_delegates
LifecycleDelegates
2022-01-18 11:39:21 +03:00
Kirill Nayduik 3476324b84 Add delegates that allows to lazily initialize value on certain lifecycle event 2022-01-17 17:55:27 +03:00
Kirill Nayduik cde08fa3f0
Merge pull request #238 from TouchInstinct/keyboard_detector
FragmentKeyboardListenerObserver
2021-12-14 19:09:40 +03:00
Kirill Nayduik 9a4068f337 Add FragmentKeyboardListenerObserver for detecting keyboard events from Fragment 2021-12-14 18:33:57 +03:00
Kirill Nayduik 8566cf0c12
Merge pull request #237 from TouchInstinct/fix/KeyboardBehaviorDetector
Change type of argument for KeyboardBehaviorDetector
2021-12-07 15:49:30 +03:00
Kirill Nayduik e023c57afd Change type of argument for KeyboardBehaviorDetector 2021-12-07 15:30:51 +03:00
Kirill Nayduik 262820c47b
Merge pull request #236 from TouchInstinct/fix/throttle_clickable_span
Fix/throttle clickable span
2021-11-23 11:52:31 +03:00
Kirill Nayduik d83ab89739 Fix naming 2021-11-23 11:34:40 +03:00
Kirill Nayduik a6107e2e7a Invoke onClick action with throttle effect 2021-11-23 11:31:31 +03:00
Rinat Nurmukhametov ad29d57377 delete staples 2021-11-18 03:35:33 +03:00
kostikum c9678f8080
Merge pull request #235 from TouchInstinct/fix/valid_error_logging_for_null_fields
Fix/valid error logging for null fields
2021-11-09 19:06:54 +07:00
Konstantin Kuzhim 0f890cbd1e replace multi-log stacktrace printing with one-statement logging 2021-11-09 18:47:32 +07:00
Konstantin Kuzhim 919445de07 add logging if non-null field is null 2021-11-09 18:46:12 +07:00
rinstance 4bb3d2f3d7
Merge pull request #233 from TouchInstinct/fix/crash_with_null
fix crash with null
2021-10-13 16:02:59 +03:00
Rinat Nurmukhametov 8f76718cc5 add getting args by fragment 2021-10-12 18:54:17 +03:00
Rinat Nurmukhametov 9e8b04e4a0 fix crash with null 2021-10-12 18:11:53 +03:00
rinstance 13440a5e33
Merge pull request #232 from TouchInstinct/fix/mvi_methods
move to method
2021-10-11 16:32:52 +03:00
Rinat Nurmukhametov 9c6979ffa2 move to method 2021-10-11 16:30:00 +03:00
rinstance 8cb33cdd08
Merge pull request #231 from TouchInstinct/add_mvi_bottom_sheet
add MviBottomSheet
2021-10-11 16:19:49 +03:00
Rinat Nurmukhametov 2890cb96ef add MviBottomSheet 2021-10-11 15:57:38 +03:00
rinstance b912ec0ff8
Merge pull request #230 from TouchInstinct/fix/imports
delete imports
2021-10-07 18:25:28 +03:00
Rinat Nurmukhametov 4a5e77a4ef delete imports 2021-10-07 18:24:00 +03:00
rinstance 111bb58463
Merge pull request #229 from TouchInstinct/show_listener_utils
moved the method to extension
2021-10-07 18:12:46 +03:00
Rinat Nurmukhametov 862ab15450 refactor extension 2021-10-07 18:08:42 +03:00
Rinat Nurmukhametov 4a8eeba10a moved the method to extension 2021-10-07 17:35:18 +03:00
rinstance a8df8f70c4
Merge pull request #227 from TouchInstinct/add_top_padding
add top padding to fullscreen bottom sheet
2021-09-30 17:15:53 +03:00
Rinat Nurmukhametov 6e209492ae change bottomSheet on var 2021-09-30 17:13:37 +03:00
Rinat Nurmukhametov e920a7ce34 add top padding to fullsreen bottom sheet 2021-09-30 10:41:52 +03:00
Kirill Nayduik 170f6234a2
Merge pull request #226 from TouchInstinct/fix/nested_features_nav
Fix navigating back inside nested features
2021-09-22 15:02:35 +03:00
Kirill Nayduik aaa65d4e6e Add variables for readability 2021-09-22 04:00:35 +03:00
Kirill Nayduik 7c20ea2ce4 Fix navigating back inside nested features 2021-09-22 03:52:14 +03:00
Kirill Nayduik 4af62593e5
Merge pull request #225 from TouchInstinct/fix/nested_keyboard_detection
Keyboard detector refactor
2021-09-21 17:10:58 +03:00
Kirill Nayduik 89f541f8fc Fix keyboard change detection: separate StatefulKeyboardResizeableFragment and KeyboardResizeableFragment, add ability to handle keyboard detection inside nested fragments 2021-09-21 01:06:56 +03:00
195 changed files with 14378 additions and 384 deletions

1
alerts/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

75
alerts/README.md Normal file
View File

@ -0,0 +1,75 @@
Alerts
=====
### Общее описание
Модуль содержит:
`AlertDialogManager` - служит для демонстрации AlertDialog с использованием View, необходимо вызвать метод `showAlertDialog`, который
в качестве агруметов может принимать:
* `context`,
* `style` - стиль для элементов дефолтного диалога (по умолчанию R.style.AlertDialogDefault),
* `title` - Заголовок диалога,
* `message` - дополнительное сообщение,
* `positiveButtonText` - текст правой кнопки (по умолчанию "ОК"),
* `onPositiveAction` - колбэк при нажатии на правую кнопку,
* `negativeBtnTitle` - текст левой кнопки (по умолчаннию null - в этом случаи не отображается),
* `onNegativeAction` - колбэк при нажатии на левую кнопку,
* `dialogLayout` - id кастомного layout (по умолчанию R.layout.dialog_alert).
---
`ComposableAlertDialog` - служит для демонстрации AlertDialog с использованием Jetpack Compose, необходимо вызвать метод `ShowAlertDialog`, который
в качестве агруметов может принимать:
* `isDialogOpen` - индикатор состояния диалога,
* `title` - Заголовок диалога,
* `message` - дополнительное сообщение,
* `positiveButtonText` - текст правой кнопки,
* `onPositiveAction` - колбэк при нажатии на правую кнопку,
* `negativeBtnTitle` - текст левой кнопки (по умолчаннию null - в этом случаи не отображается),
* `onNegativeAction` - колбэк при нажатии на левую кнопку.
Кастомизация Compose версии происходит по средствам инициализации полей: customTitle, customMessage, customConfirmBtn, customNegativeBtn
### Примеры
View версия (ViewableAlertDialog) ok/cancel диалога:
```kotlin
alertDialogManager.showAlertDialog(
context = activity,
title = "Ой, что-то пошло не так",
message = "Попробуйте ещё раз",
positiveButtonText = "Ещё раз",
onPositiveAction = { retryConnection() },
negativeBtnTitle = "Отмена"
)
```
View версия (ViewableAlertDialog) ok диалога:
```kotlin
alertDialogManager.showOkDialog(
context = dialog?.window?.decorView?.context ?: throw Exception(),
title = "Необходимо изменить настройки",
okButtonText = "Ок",
onOkAction = {
viewModel.dispatchAction(ItemAction.ChangeSettings)
}
)
```
Для катомизации стилей элементов в дефолтной разметке диалога необходимо создать стиль - наследника от `ThemeOverlay.MaterialComponents.MaterialAlertDialog` и переопределить стили:
* `materialAlertDialogTitleTextStyle` - стиль для заголока (наследник от `MaterialAlertDialog.MaterialComponents.Title.Text`),
* `materialAlertDialogBodyTextStyle` - стиль для подзаголовка (наследник от `MaterialAlertDialog.MaterialComponents.Body.Text`),
* `buttonBarPositiveButtonStyle` - стиль для позитивной кнопки (наследник от `Widget.MaterialComponents.Button.TextButton.Dialog`),
* `buttonBarNegativeButtonStyle` - стиль для негативной кнопки (наследник от `Widget.MaterialComponents.Button.TextButton.Dialog`).
Compose версия (ComposableAlertDialog):
```kotlin
val isDialogOpen = remember { mutableStateOf(false)}
....
//Создание диалога
ComposableAlertDialog
.apply { customTitle = { Text(text = "Ой, что-то пошло не так", color = Color.Blue) } }
.ShowAlertDialog(isDialogOpen, message = "Проблемы с сетью", positiveButtonText = "ОК")
....
//Отображение диалога
isDialogOpen.value = true
```

47
alerts/build.gradle Normal file
View File

@ -0,0 +1,47 @@
apply from: "../android-configs/lib-config.gradle"
ext {
composeVersion = '1.1.1'
}
android {
buildFeatures {
viewBinding true
}
}
dependencies {
implementation("androidx.core:core-ktx")
implementation("androidx.constraintlayout:constraintlayout")
implementation("com.google.android.material:material")
implementation project(":kotlin-extensions")
implementation "androidx.compose.runtime:runtime:$composeVersion"
implementation "androidx.compose.ui:ui:$composeVersion"
implementation "androidx.compose.foundation:foundation:$composeVersion"
implementation "androidx.compose.foundation:foundation-layout:$composeVersion"
implementation "androidx.compose.material:material:$composeVersion"
implementation "androidx.compose.runtime:runtime-livedata:$composeVersion"
implementation "androidx.compose.ui:ui-tooling:$composeVersion"
implementation "com.google.android.material:compose-theme-adapter:1.1.9"
constraints {
implementation("androidx.core:core-ktx") {
version {
require '1.0.0'
}
}
implementation("androidx.constraintlayout:constraintlayout") {
version {
require '2.2.0-alpha03'
}
}
implementation("com.google.android.material:material") {
version {
require '1.1.0'
}
}
}
}

View File

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

View File

@ -0,0 +1,54 @@
package ru.touchin.roboswag.composable_dialog
import androidx.compose.material.AlertDialog
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
object ComposableAlertDialog {
var customTitle: @Composable (() -> Unit)? = null
var customMessage: @Composable (() -> Unit)? = null
var customConfirmBtn: @Composable (() -> Unit)? = null
var customNegativeBtn: @Composable (() -> Unit)? = null
@Composable
fun ShowAlertDialog(
isDialogOpen: MutableState<Boolean>,
title: String? = null,
message: String? = null,
positiveButtonText: String? = null,
onPositiveAction: (() -> Unit)? = null,
negativeBtnTitle: String? = null,
onNegativeAction: (() -> Unit)? = null
) {
if (!isDialogOpen.value) return
AlertDialog(
onDismissRequest = { isDialogOpen.value = false },
title = customTitle ?: { Text(title.orEmpty()) },
text = customMessage ?: { Text(message.orEmpty()) },
confirmButton = customConfirmBtn ?: createButton(positiveButtonText.orEmpty()) {
onPositiveAction?.invoke()
isDialogOpen.value = false
},
dismissButton = when {
customNegativeBtn != null -> customNegativeBtn
negativeBtnTitle != null -> createButton(negativeBtnTitle) {
onNegativeAction?.invoke()
isDialogOpen.value = false
}
else -> null
}
)
}
@Composable
private fun createButton(text: String, onClickAction: () -> Unit): @Composable (() -> Unit) =
{
TextButton(onClick = onClickAction) {
Text(text)
}
}
}

View File

@ -0,0 +1,85 @@
package ru.touchin.roboswag.viewable_dialog
import android.content.Context
import android.view.LayoutInflater
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.view.ContextThemeWrapper
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ru.touchin.roboswag.alerts.R
class AlertDialogManager {
@SuppressWarnings("detekt.LongParameterList")
fun showAlertDialog(
context: Context,
style: Int = R.style.AlertDialogDefault,
title: String? = null,
message: String? = null,
positiveButtonText: String = context.getString(R.string.positive_btn),
onPositiveAction: (() -> Unit)? = null,
negativeBtnTitle: String? = null,
onNegativeAction: (() -> Unit)? = null,
dialogLayout: Int = R.layout.dialog_alert,
cancelable: Boolean = true,
onCancelAction: () -> Unit = {}
) {
val styledContext = ContextThemeWrapper(context, style)
MaterialAlertDialogBuilder(styledContext)
.setView(LayoutInflater.from(styledContext).inflate(dialogLayout, null))
.show()
.setupAlertDialog(
title = title,
message = message,
positiveButtonText = positiveButtonText,
onPositiveClick = onPositiveAction,
negativeButtonText = negativeBtnTitle,
onNegativeClick = onNegativeAction,
cancelable = cancelable,
onCancelAction = onCancelAction
)
}
fun showOkDialog(
context: Context,
style: Int = R.style.AlertDialogDefault,
title: String? = null,
message: String? = null,
okButtonText: String = context.getString(R.string.positive_btn),
onOkAction: (() -> Unit)? = null,
cancelable: Boolean = true,
onCancelAction: () -> Unit = {}
) = showAlertDialog(
context = context,
style = style,
title = title,
message = message,
positiveButtonText = okButtonText,
onPositiveAction = onOkAction,
cancelable = cancelable,
onCancelAction = onCancelAction
)
private fun AlertDialog.setupAlertDialog(
title: String? = null,
message: String? = null,
positiveButtonText: String,
onPositiveClick: (() -> Unit)? = null,
negativeButtonText: String? = null,
onNegativeClick: (() -> Unit)? = null,
cancelable: Boolean = true,
onCancelAction: () -> Unit = {}
) {
setCancelable(cancelable)
setOnDismissListener { onCancelAction() }
findViewById<TextView>(R.id.alert_title)?.setTextOrGone(title)
findViewById<TextView>(R.id.alert_message)?.setTextOrGone(message)
findViewById<TextView>(R.id.alert_positive_button)?.let { buttonView ->
setupButton(this, buttonView, positiveButtonText, onPositiveClick)
}
findViewById<TextView>(R.id.alert_negative_button)?.let { buttonView ->
setupButton(this, buttonView, negativeButtonText, onNegativeClick)
}
}
}

View File

@ -0,0 +1,19 @@
package ru.touchin.roboswag.viewable_dialog
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import ru.touchin.extensions.setOnRippleClickListener
fun setupButton(alertDialog: AlertDialog, buttonView: TextView, text: String?, onButtonClick: (() -> Unit)?) {
buttonView.setTextOrGone(text)
buttonView.setOnRippleClickListener {
onButtonClick?.invoke()
alertDialog.dismiss()
}
}
fun TextView.setTextOrGone(text: CharSequence?) {
isVisible = !text.isNullOrEmpty()
setText(text)
}

View File

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="22dp"
android:paddingBottom="8dp">
<TextView
android:id="@+id/alert_title"
style="?attr/materialAlertDialogTitleTextStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Header" />
<TextView
android:id="@+id/alert_message"
style="?attr/materialAlertDialogBodyTextStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/alert_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/alert_title"
tools:text="Text" />
<TextView
android:id="@+id/alert_positive_button"
style="?attr/buttonBarPositiveButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/alert_message"
android:layout_alignParentEnd="true"
android:layout_alignParentRight="true"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/alert_message"
tools:text="OK" />
<TextView
android:id="@+id/alert_negative_button"
style="?attr/buttonBarNegativeButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/alert_message"
android:layout_marginEnd="8dp"
android:layout_toStartOf="@id/alert_positive_button"
android:layout_toLeftOf="@id/alert_positive_button"
app:layout_constraintRight_toLeftOf="@id/alert_positive_button"
app:layout_constraintTop_toBottomOf="@id/alert_message"
tools:text="Cancel" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="positive_btn">OK</string>
<string name="negative_btn">Cancel</string>
</resources>

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AlertDialogDefault" parent="ThemeOverlay.MaterialComponents.MaterialAlertDialog">
<item name="colorSurface">#FFFFFF</item>
<item name="materialAlertDialogTitleTextStyle">@style/MaterialAlertDialog.MaterialComponents.Title.Text.Default</item>
<item name="buttonBarPositiveButtonStyle">@style/MaterialAlertDialog.MaterialComponents.Button.Default</item>
<item name="buttonBarNegativeButtonStyle">@style/MaterialAlertDialog.MaterialComponents.Button.Default</item>
<item name="materialAlertDialogBodyTextStyle">@style/MaterialAlertDialog.MaterialComponents.Body.Text.Default</item>
</style>
<style name="MaterialAlertDialog.MaterialComponents.Title.Text.Default" parent="MaterialAlertDialog.MaterialComponents.Title.Text">
<item name="android:textColor">#383838</item>
<item name="android:textSize">15sp</item>
<item name="android:layout_marginLeft">24dp</item>
<item name="android:layout_marginRight">24dp</item>
<item name="android:paddingBottom">16dp</item>
</style>
<style name="MaterialAlertDialog.MaterialComponents.Body.Text.Default" parent="MaterialAlertDialog.MaterialComponents.Body.Text">
<item name="android:textColor">#383838</item>
<item name="android:textSize">12sp</item>
<item name="android:layout_marginLeft">24dp</item>
<item name="android:layout_marginRight">24dp</item>
<item name="android:paddingBottom">28dp</item>
<item name="android:lineSpacingExtra">8dp</item>
</style>
<style name="MaterialAlertDialog.MaterialComponents.Button.Default" parent="Widget.MaterialComponents.Button.TextButton.Dialog">
<item name="android:textColor">#383838</item>
<item name="android:minWidth">56dp</item>
<item name="android:gravity">center</item>
<item name="android:textAllCaps">true</item>
<item name="android:textSize">14sp</item>
<item name="android:paddingTop">11dp</item>
<item name="android:paddingBottom">9dp</item>
<item name="android:layout_marginTop">8dp</item>
<item name="android:layout_marginRight">8dp</item>
</style>
</resources>

View File

@ -3,5 +3,5 @@ apply plugin: 'com.android.application'
apply from: '../RoboSwag/android-configs/common-config.gradle'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-parcelize'
apply plugin: 'kotlin-kapt'

View File

@ -22,6 +22,7 @@ package ru.touchin.templates.logansquare;
import androidx.annotation.Nullable;
import ru.touchin.roboswag.core.log.Lc;
import ru.touchin.roboswag.core.log.LcGroup;
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 {
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;
}
}

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

@ -0,0 +1 @@
/build

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

@ -0,0 +1,178 @@
# Описание
Модуль содержит реализацию следующих типов фильтров:
1. Выбор одного/нескольких из доступных значений списка
2. Выбор одного/нескольких значений из перечня тегов
3. Выбор минимального и максимального значения из диапозона
# Использование
## 1. Выбор одного/нескольких из доступных значений списка
### Как использовать
``` kotlin
val selectorView = ListSelectionView<DefaultSelectionItem, SelectionItemViewHolder<DefaultSelectionItem>>(context)
.Builder()
.setItems(navArgs.items)
.addItemDecoration((TopDividerItemDecoration(
context = requireContext(),
drawableId = R.drawable.list_divider_1dp,
startMargin = START_MARGIN_DIVIDER_DP.px
)))
.withSelectionType(ListSelectionView.SelectionType.SINGLE_SELECT)
.onResultListener { items ->
viewModel.dispatchAction(SelectItemAction.SelectItem(items))
}
.build()
```
### Конфигурации
* при создании `ListSelectionView<ItemType, HolderType>` необходимо передлать `ItemType` - класс модели данных в списке, `HolderType` - класс viewHolder-а в recyclerView.
Для использования дефолтной реализации необходимо использовать типы `<DefaultSelectionItem, SelectionItemViewHolder<DefaultSelectionItem>>`
* в метод `setItems(List<ItemType>)` необходимо передать список объектов
* метод `addItemDecoration(itemDecoration: RecyclerView.ItemDecoration)` можно использовать для передачи объекта `RecyclerView.ItemDecoration`
* метод `withSelectionType(type: SelectionType)` используется для указания типа выбора:
* `SINGLE_SELECT` - <em>по умолчанию</em> - позволяет выбрать один выариант, при этом будет выбран всегда как минимум один вариант
* `MULTI_SELECT` - позволяет выбрать несколько вариантов из списка, при этом можно полностью выбрать все варианты и убрать выделение со всех вариантов
* метод `showInHolder(HolderFactoryType<ItemType>)` используется для определения кастомного viewHolder для списка с недефолтной разметкой.
``` kotlin
val selectorView = ListSelectionView<TestSelectionItem, TestItemViewHolder>(context)
.Builder()
.showInHolder { parent, clickListener, selectionType ->
TestItemViewHolder(
binding = TestSelectionItemBinding.inflate(LayoutInflater.from(parent.context), parent, false),
onItemSelectAction = clickListener,
selectionType = selectionType
)
}
...
.build()
```
* колбэк `onSelectedItemsListener(listener: OnSelectedItemsListener<ItemType>)` можно использовать для получения списка всех элементов `ItemType` после каждого выбора
* колбэк `onSelectedItemListener(listener: OnSelectedItemListener<ItemType>)` можно использовать для получения элемента списка `ItemType`, по которому произошел клик
* после вызова конфигурационных методов обязательно необходимо вызать метод `build()`
### Кастомизация стиля дефолтной реализации ViewHolder без необходимости создания кастомного layout и viewHolder
#### 1) Определить кастомную тему и стили элементов
1. Стиль для **текста элемента списка** должен быть наследником стиля `Widget.FilterSelection.Item`
``` xml
<style name="Widget.Custom.FilterSelection.Item" parent="@style/Widget.FilterSelection.Item">
<item name="android:textAppearance">@style/Text15sp.Regular.Black</item>
<item name="android:paddingTop">2dp</item>
<item name="android:lineSpacingExtra">3sp</item>
<item name="android:translationY">-1.71sp</item>
</style>
```
2. Стиль для **индикатора выбора** должен быть наследником стиля `Widget.FilterSelection.Radio`
Передайте `selector-drawable` для кастомизации вида индикатора в конце строки
``` xml
<style name="Widget.Custom.FilterSelection.Radio" parent="@style/Widget.FilterSelection.Radio">
<item name="android:button">@drawable/selector_checkbox</item>
</style>
```
3. Создайте **тему**, которая должна быть наследником `Theme.FilterSelection`
``` xml
<style name="Theme.Custom.FilterSelection" parent="@style/Theme.FilterSelection">
<item name="sheetSelection_itemStyle">@style/Widget.Custom.FilterSelection.Item</item>
<item name="sheetSelection_radioStyle">@style/Widget.Custom.FilterSelection.Radio</item>
</style>
```
#### 2) Применить тему при создании view
При создании вью в коде можно указать тему, используя `ContextThemeWrapper`
``` kotlin
val newContext = ContextThemeWrapper(requireContext(), R.style.Theme_Custom_FilterSelection)
val selectorView = ListSelectionView(newContext)
.Builder()
...
.build()
```
## 2. Выбор одного/нескольких значений из перечня тегов
* `TagLayoutView` - view-контейнер для тегов
* `TagView` - view для тега. <em>Кастомная разметка для тега должна содержать в корне `TagView`</em>
### Как использовать
``` kotlin
binding.tagItemLayout
.Builder(getFilterItem())
.setSpacing(16)
.setSelectionType(SelectionType.MULTI_SELECT) // по умолчанию
.isSingleLine(false) // по умолчанию
.onPropertySelectedAction { filterProperty: FilterProperty ->
//Do something
}
.build()
```
### Конфигурации
* метод `setSelectionType(SelectionType)` конфигурирует тип выбора:
* `SINGLE_SELECT` - выбор одного варианта сбрасывает select у всех остальных
* `MULTI_SELECT` - <em>по умолчанию</em> - мультивыбор тегов с учетом исключающих фильтров
* метод `isSingleLine(Boolean)` конфигурирует вид контейнера с тегами: `true` соответствует горизонтальному контейнеру со скроллом
* `setTagLayout(Int)` устанавливает разметку для тега. Если не задано - то используется дефолтная разметка `layout_default_tag.xml`
* `setMaxTagCount(Int)` позволяет ограничить количество отображаемых тегов. По умолчанию ограничения нет.
* `setMoreTagLayout(Int, String)` устанавливает разметку для тега, который отображается для дополнительного тега. Если не указана - то тег не будет создан
* `setSpacing(Int)`, `setSpacingHorizontal(Int)` и `setSpacingVertical(Int)` можно использовать для настройки расстояния между тегами. По умолчанию - 0
* `onMoreValuesAction(FilterMoreAction)` и `onPropertySelectedAction(PropertySelectedAction)` используются для передачи колбэков на клик по тегу типа "Еще" и обычного тега соответственно
* после вызова конфигурационных методов обязательно необходимо вызать метод `build()`
* в Builder необходимо передать объект `filterItem: FilterItem`
## 3. Выбор минимального и максимального значения из диапозона
* `RangeChoiceView` - контейнер для слайдера и редактируемых полей
* `FilterRangeSlider` - слайдер - <em>Можно использовать как отдельный элемент</em>
* `HintInputView` - view для редактируемого поля начала и окончания диапозона
### Как использовать
В разметке
``` xml
<ru.touchin.roboswag.base_filters.range.RangeChoiceView
android:id="@+id/range_values_test"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/FilterRangeChoice" //не забудьте указать стиль
app:layout_constraintTop_toTopOf="parent" />
```
Настройка в коде
``` kotlin
fun setupValues(item: FilterRangeItem) {
binding.rangeValuesTest.setupRangeValues(
rangeFilterItem = item,
onChangeCallback = callback
)
}
fun resetValues() {
binding.rangeValuesTest.resetRangeValue()
}
```
### Конфигурации
Вся конфигурация вьюх осуществляется через стили:
* Для `RangeChoiceView`:
* `filterRange_sliderMargin` - расстояние от слайдера до редактируемых полей
* `filterRange_startHint` - ссылка на строку с текстом подсказки в редактируемом поле для начального значения
* `filterRange_endHint` - ссылка на строку с текстом подсказки в редактируемом поле для конечного значения
* `filterRange_theme` - ссылка на тему
* В теме:
* атрибут `filterRange_sliderStyle` - ссылка на стиль слайдера
* атрибут `filterRange_hintViewStyle` - ссылка на стиль `HintInputView`
* атрибут `filterRange_hintTextStyle` - ссылка на стиль `TextView` внутри `HintInputView`
* атрибут `filterRange_valueEditTextStyle` - ссылка на стиль `EditText` внутри `HintInputView`
* Для `FilterRangeSlider`:
* `trackColorActive`
* `trackColorInactive`
* `trackHeight`
* `thumbElevation`
* `thumbColor`
* `labelBehavior`
* `haloRadius`
* `filterRange_stepTextAppearance`
* `filterRange_activeTickColor`
* `filterRange_inactiveTickColor`
* `filterRange_stepValueMarginTop`
* `filterRange_sliderPointSize`
* `filterRange_pointShape`

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

@ -0,0 +1,41 @@
apply from: "../android-configs/lib-config.gradle"
apply plugin: 'kotlin-parcelize'
android {
buildFeatures {
viewBinding true
}
}
dependencies {
implementation project(":utils")
implementation project(":recyclerview-adapters")
implementation project(":navigation-base")
implementation project(":kotlin-extensions")
implementation("org.jetbrains.kotlin:kotlin-stdlib")
implementation("androidx.core:core-ktx")
implementation("androidx.appcompat:appcompat")
implementation("com.google.android.material:material")
implementation("androidx.constraintlayout:constraintlayout") {
version {
require '2.0.0'
}
}
constraints {
implementation("androidx.appcompat:appcompat") {
version {
require '1.0.0'
}
}
implementation("androidx.core:core-ktx") {
version {
require '1.0.0'
}
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,173 @@
package ru.touchin.roboswag.base_filters.range
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Paint.Cap
import android.util.AttributeSet
import androidx.annotation.StyleRes
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.content.withStyledAttributes
import androidx.core.widget.TextViewCompat
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.slider.RangeSlider
import ru.touchin.roboswag.base_filters.R
import ru.touchin.roboswag.components.utils.getColorSimple
class FilterRangeSlider @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0
) : RangeSlider(context, attrs, defStyleAttr) {
var points: List<Int>? = null
set(value) {
field = value?.sorted()?.filter { it > valueFrom && it < valueTo }
}
private val innerThumbRadius: Int = thumbRadius
private var stepValueMarginTop = 0f
private var inactiveTickColor: Int = context.getColorSimple(R.color.slider_point_inactive)
private var activeTickColor: Int = context.getColorSimple(R.color.slider_point_active)
private var sliderPointSize: Float = 5f
@StyleRes
private var stepTextAppearance: Int = -1
private var shape: Shape = Shape.CIRCLE
private var trackCenterY: Float = -1F
init {
// Set original thumb radius to zero to draw custom one on top
thumbRadius = 0
context.withStyledAttributes(attrs, R.styleable.FilterRangeSlider, defStyleAttr, defStyleRes) {
stepValueMarginTop = getDimension(R.styleable.FilterRangeSlider_filterRange_stepValueMarginTop, stepValueMarginTop)
inactiveTickColor = getColor(R.styleable.FilterRangeSlider_filterRange_inactiveTickColor, inactiveTickColor)
activeTickColor = getColor(R.styleable.FilterRangeSlider_filterRange_activeTickColor, activeTickColor)
sliderPointSize = getDimension(R.styleable.FilterRangeSlider_filterRange_sliderPointSize, sliderPointSize)
stepTextAppearance = getResourceId(R.styleable.FilterRangeSlider_filterRange_stepTextAppearance, -1)
shape = Shape.values()[getInt(R.styleable.FilterRangeSlider_filterRange_pointShape, Shape.CIRCLE.ordinal)]
}
}
private val thumbDrawable = MaterialShapeDrawable().apply {
shadowCompatibilityMode = MaterialShapeDrawable.SHADOW_COMPAT_MODE_ALWAYS
setBounds(0, 0, innerThumbRadius * 2, innerThumbRadius * 2)
elevation = thumbElevation
state = drawableState
fillColor = thumbTintList
shapeAppearanceModel = ShapeAppearanceModel
.builder()
.setAllCorners(shape.value, innerThumbRadius.toFloat())
.build()
}
private val inactiveTicksPaint = getDefaultTickPaint().apply { color = inactiveTickColor }
private val activeTicksPaint = getDefaultTickPaint().apply { color = activeTickColor }
private fun getDefaultTickPaint() = Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
strokeCap = Cap.ROUND
strokeWidth = sliderPointSize
}
// Using TextView as a bridge to get text params
private val stepValuePaint: Paint = AppCompatTextView(context).apply {
stepTextAppearance.takeIf { it != -1 }?.let { TextViewCompat.setTextAppearance(this, it) }
}.let { textView ->
Paint().apply {
isAntiAlias = true
color = textView.currentTextColor
textSize = textView.textSize
typeface = textView.typeface
textAlign = Paint.Align.CENTER
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
trackCenterY = measuredHeight / 2F
val height = trackCenterY + trackHeight / 2F + stepValueMarginTop + stepValuePaint.textSize
setMeasuredDimension(measuredWidth, height.toInt())
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
drawTicks(canvas)
drawThumb(canvas)
drawStepValues(canvas)
}
private fun drawTicks(canvas: Canvas) {
if (points.isNullOrEmpty()) return
val ticksCoordinates = mutableListOf<Float>()
points?.forEach { point ->
ticksCoordinates.add(normalizeValue(point.toFloat()) * trackWidth + trackSidePadding)
ticksCoordinates.add(trackCenterY)
}
val leftPointsSize = points?.count { it < values[0] } ?: 0
val rightPointSize = points?.count { it > values[1] } ?: 0
val activePointSize = (points?.size ?: 0) - leftPointsSize - rightPointSize
// Draw inactive ticks to the left of the smallest thumb.
canvas.drawPoints(ticksCoordinates.toFloatArray(), 0, leftPointsSize * 2, inactiveTicksPaint)
// Draw active ticks between the thumbs.
canvas.drawPoints(
ticksCoordinates.toFloatArray(),
leftPointsSize * 2,
activePointSize * 2,
activeTicksPaint
)
// Draw inactive ticks to the right of the largest thumb.
canvas.drawPoints(
ticksCoordinates.toFloatArray(),
leftPointsSize * 2 + activePointSize * 2,
rightPointSize * 2,
inactiveTicksPaint
)
}
private fun drawThumb(canvas: Canvas) {
for (value in values) {
canvas.save()
canvas.translate(
(trackSidePadding + (normalizeValue(value) * trackWidth).toInt() - innerThumbRadius).toFloat(),
trackCenterY - innerThumbRadius
)
thumbDrawable.draw(canvas)
canvas.restore()
}
}
private fun drawStepValues(canvas: Canvas) {
points?.forEach { point ->
canvas.drawText(
point.toString(),
normalizeValue(point.toFloat()) * trackWidth + trackSidePadding,
trackCenterY + trackHeight / 2F + stepValueMarginTop + stepValuePaint.textSize - 3F,
stepValuePaint
)
}
}
private fun normalizeValue(value: Float) = (value - valueFrom) / (valueTo - valueFrom)
private enum class Shape(val value: Int) {
CIRCLE(CornerFamily.ROUNDED),
CUT(CornerFamily.CUT)
}
}

View File

@ -0,0 +1,39 @@
package ru.touchin.roboswag.base_filters.range
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import ru.touchin.roboswag.base_filters.databinding.ViewHintInputBinding
class HintInputView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr, defStyleRes) {
private val binding = ViewHintInputBinding.inflate(LayoutInflater.from(context), this)
var inputText: String = ""
set(value) {
setText(value)
field = value
}
fun setHint(value: String?) {
binding.startHint.text = value.orEmpty()
}
fun setOnEditorActionListener(listener: TextView.OnEditorActionListener) =
binding.editText.setOnEditorActionListener(listener)
private fun setText(value: String) {
binding.editText.run {
setText(value)
setSelection(text?.length ?: 0)
}
}
}

View File

@ -0,0 +1,168 @@
package ru.touchin.roboswag.base_filters.range
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.inputmethod.EditorInfo
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.withStyledAttributes
import androidx.core.view.updateLayoutParams
import com.google.android.material.slider.RangeSlider
import ru.touchin.roboswag.base_filters.R
import ru.touchin.roboswag.base_filters.databinding.RangeChoiceViewBinding
import ru.touchin.roboswag.base_filters.range.model.FilterRangeItem
import ru.touchin.roboswag.base_filters.range.model.SelectedValues
import kotlin.properties.Delegates
typealias FilterRangeChanged = (FilterRangeItem) -> Unit
class RangeChoiceView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr, defStyleRes) {
private val defaultTheme = R.style.Theme_FilterRangeSlider
private var binding: RangeChoiceViewBinding by Delegates.notNull()
private var valueChangedAction: FilterRangeChanged? = null
private var rangeItem: FilterRangeItem? = null
set(value) {
field = value
binding.fromInput.inputText = value?.selectedValues?.min?.toString()
?: value?.start?.toString().orEmpty()
binding.toInput.inputText = value?.selectedValues?.max?.toString()
?: value?.end?.toString().orEmpty()
binding.rangeSlider.run {
values = listOf(
value?.selectedValues?.min?.toFloat() ?: value?.start?.toFloat(),
value?.selectedValues?.max?.toFloat() ?: value?.end?.toFloat()
)
}
}
init {
context.withStyledAttributes(attrs, R.styleable.FilterRangeChoice, defStyleAttr, defStyleRes) {
val theme = getResourceId(R.styleable.FilterRangeChoice_filterRange_theme, defaultTheme)
val themeContext = ContextThemeWrapper(context, theme)
binding = RangeChoiceViewBinding.inflate(LayoutInflater.from(themeContext), this@RangeChoiceView)
binding.fromInput.setHint(getString(R.styleable.FilterRangeChoice_filterRange_startHint))
binding.toInput.setHint(getString(R.styleable.FilterRangeChoice_filterRange_endHint))
binding.rangeSliderGuideline.updateLayoutParams<MarginLayoutParams> {
topMargin = getDimension(R.styleable.FilterRangeChoice_filterRange_sliderMargin, 0f).toInt()
}
}
}
fun setupRangeValues(
rangeFilterItem: FilterRangeItem,
onChangeCallback: FilterRangeChanged
) {
rangeItem = rangeFilterItem
valueChangedAction = onChangeCallback
with(binding) {
addChangeValuesListener()
setupRangeSlider(rangeFilterItem)
}
}
fun resetRangeValue() {
rangeItem = rangeItem?.resetSelectedValues()
}
private fun addChangeValuesListener() {
binding.fromInput.addChangeValueListener { rangeItem?.setValue(selectedMinValue = it.toIntOrNull()) }
binding.toInput.addChangeValueListener { rangeItem?.setValue(selectedMaxValue = it.toIntOrNull()) }
}
private fun setupRangeSlider(rangeFilterItem: FilterRangeItem) {
with(binding) {
rangeSlider.apply {
valueFrom = rangeFilterItem.start.toFloat()
valueTo = rangeFilterItem.end.toFloat()
points = rangeFilterItem.intermediates
}
rangeSlider.addOnChangeListener { _, _, _ ->
fromInput.inputText = rangeSlider.values[0].toInt().toString()
toInput.inputText = rangeSlider.values[1].toInt().toString()
}
rangeSlider.addOnSliderTouchListener(object : RangeSlider.OnSliderTouchListener {
@SuppressLint("RestrictedApi")
override fun onStartTrackingTouch(slider: RangeSlider) = Unit
@SuppressLint("RestrictedApi")
override fun onStopTrackingTouch(slider: RangeSlider) {
binding.rangeSlider.apply {
when (focusedThumbIndex) {
0 -> {
rangeItem = rangeItem?.setValue(selectedMinValue = from().toInt())
rangeItem?.let { valueChangedAction?.invoke(it) }
}
1 -> {
rangeItem = rangeItem?.setValue(selectedMaxValue = to().toInt())
rangeItem?.let { valueChangedAction?.invoke(it) }
}
}
}
}
})
}
}
private fun HintInputView.addChangeValueListener(updateValue: (String) -> FilterRangeItem?) {
setOnEditorActionListener { view, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
rangeItem = updateValue(view.text.toString().filterNot { it.isWhitespace() })
rangeItem?.let { valueChangedAction?.invoke(it) }
}
false
}
}
private fun RangeSlider.from() = values[0].toInt().toString()
private fun RangeSlider.to() = values[1].toInt().toString()
@SuppressWarnings("detekt.ComplexMethod")
private fun FilterRangeItem.setValue(
selectedMaxValue: Int? = selectedValues?.max,
selectedMinValue: Int? = selectedValues?.min
): FilterRangeItem {
val isMaxValueUpdated = selectedMaxValue != selectedValues?.max
val isMinValueUpdated = selectedMinValue != selectedValues?.min
val isMinValueOutOfRange = selectedMinValue != null && isMinValueUpdated && selectedMinValue > (selectedMaxValue ?: end)
val isMaxValueOutOfRange = selectedMaxValue != null && isMaxValueUpdated && selectedMaxValue < (selectedMinValue ?: start)
val updatedValues = when {
selectedMaxValue == end && selectedMinValue == start -> null
isMinValueOutOfRange -> SelectedValues(
max = selectedMaxValue ?: end,
min = selectedMaxValue ?: end
)
isMaxValueOutOfRange -> SelectedValues(
max = selectedMinValue ?: start,
min = selectedMinValue ?: start
)
else -> SelectedValues(
max = selectedMaxValue?.takeIf { it < end } ?: end,
min = selectedMinValue?.takeIf { it > start } ?: start
)
}
return copyWithSelectedValue(selectedValues = updatedValues)
}
private fun FilterRangeItem.resetSelectedValues() = copyWithSelectedValue(selectedValues = null)
}

View File

@ -0,0 +1,34 @@
package ru.touchin.roboswag.base_filters.range.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
open class FilterRangeItem(
val id: String,
val start: Int,
val end: Int,
val title: String,
val intermediates: List<Int>? = null,
val step: Int? = null,
val selectedValues: SelectedValues? = null
) : Parcelable {
fun isCorrectValues() = end > start
fun copyWithSelectedValue(selectedValues: SelectedValues?) = FilterRangeItem(
id = id,
start = start,
end = end,
title = title,
intermediates = intermediates,
step = step,
selectedValues = selectedValues
)
}
@Parcelize
data class SelectedValues(
val max: Int,
val min: Int
) : Parcelable

View File

@ -0,0 +1,137 @@
package ru.touchin.roboswag.base_filters.select_list_item
import android.content.Context
import android.util.AttributeSet
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import androidx.annotation.StyleRes
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import ru.touchin.roboswag.base_filters.databinding.SelectionItemBinding
import ru.touchin.roboswag.base_filters.select_list_item.adapter.BaseSelectionViewHolder
import ru.touchin.roboswag.base_filters.select_list_item.adapter.HolderFactoryType
import ru.touchin.roboswag.base_filters.select_list_item.adapter.SelectionItemViewHolder
import ru.touchin.roboswag.base_filters.select_list_item.adapter.SheetSelectionAdapter
import ru.touchin.roboswag.base_filters.select_list_item.model.BaseSelectionItem
import ru.touchin.roboswag.base_filters.SelectionType
private typealias OnSelectedItemListener<ItemType> = (item: ItemType) -> Unit
private typealias OnSelectedItemsListener<ItemType> = (items: List<ItemType>) -> Unit
/**
* Base [ListSelectionView] to use in filters screen for choosing single or multi items in list.
*
* @param ItemType Type of model's element in list.
* It must implement [BaseSelectionItem] abstract class.
*
* @param HolderType Type of viewHolder in recyclerView.
* It must extend [BaseSelectionViewHolder] abstract class.
*
**/
class ListSelectionView<ItemType, HolderType> @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr)
where ItemType : BaseSelectionItem,
HolderType : BaseSelectionViewHolder<ItemType> {
enum class SelectionType { SINGLE_SELECT, MULTI_SELECT }
constructor(context: Context, @StyleRes themeResId: Int) : this(ContextThemeWrapper(context, themeResId))
private var mutableItems: List<ItemType> = emptyList()
private var selectionType = SelectionType.SINGLE_SELECT
private var onSelectedItemChanged: OnSelectedItemListener<ItemType>? = null
private var onSelectedItemsChanged: OnSelectedItemsListener<ItemType>? = null
private var factory: HolderFactoryType<ItemType> = getDefaultFactory()
init {
layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
layoutManager = LinearLayoutManager(context)
}
private fun getDefaultFactory(): HolderFactoryType<ItemType> = { parent, clickListener, selectionType ->
SelectionItemViewHolder(
binding = SelectionItemBinding.inflate(LayoutInflater.from(parent.context), parent, false),
onItemSelectAction = clickListener,
selectionType = selectionType
)
}
private val selectionAdapter by lazy {
SheetSelectionAdapter(
onItemSelectAction = onItemSelectedListener,
selectionType = selectionType,
factory = factory
)
}
private val onItemSelectedListener: (item: ItemType) -> Unit = { item ->
onSelectedItemChanged?.invoke(item)
updateAfterSelection(item)
onSelectedItemsChanged?.invoke(mutableItems)
}
fun updateItems(items: List<ItemType>) {
mutableItems = items
updateList()
}
private fun updateList() {
selectionAdapter.submitList(mutableItems)
}
private fun updateAfterSelection(selectedItem: ItemType) {
mutableItems = mutableItems.map { item ->
when {
item.isItemTheSame(selectedItem) -> selectedItem
selectionType == SelectionType.SINGLE_SELECT -> item.copyWithSelection(isSelected = false)
else -> item
}
}
updateList()
}
inner class Builder {
fun setItems(items: List<ItemType>) = apply {
mutableItems = items
}
fun <T> setItems(
source: List<T>,
mapper: (T) -> ItemType
) = setItems(source.map { item -> mapper.invoke(item) })
fun showInHolder(holderFactory: HolderFactoryType<ItemType>) = apply {
factory = holderFactory
}
fun addItemDecoration(itemDecoration: RecyclerView.ItemDecoration) = apply {
this@ListSelectionView.addItemDecoration(itemDecoration)
}
fun onSelectedItemListener(listener: OnSelectedItemListener<ItemType>) = apply {
this@ListSelectionView.onSelectedItemChanged = listener
}
fun onSelectedItemsListener(listener: OnSelectedItemsListener<ItemType>) = apply {
this@ListSelectionView.onSelectedItemsChanged = listener
}
fun withSelectionType(type: SelectionType) = apply {
selectionType = type
}
fun build() = this@ListSelectionView.also {
it.adapter = selectionAdapter
updateList()
}
}
}

View File

@ -0,0 +1,11 @@
package ru.touchin.roboswag.base_filters.select_list_item.adapter
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import ru.touchin.roboswag.base_filters.select_list_item.model.BaseSelectionItem
abstract class BaseSelectionViewHolder<ItemType : BaseSelectionItem>(val view: View)
: RecyclerView.ViewHolder(view) {
abstract fun bind(item: ItemType)
}

View File

@ -0,0 +1,33 @@
package ru.touchin.roboswag.base_filters.select_list_item.adapter
import android.view.View
import ru.touchin.roboswag.base_filters.databinding.SelectionItemBinding
import ru.touchin.roboswag.base_filters.select_list_item.model.BaseSelectionItem
import ru.touchin.roboswag.base_filters.SelectionType
class SelectionItemViewHolder<ItemType : BaseSelectionItem>(
private val binding: SelectionItemBinding,
private val onItemSelectAction: (ItemType) -> Unit,
private val selectionType: SelectionType
) : BaseSelectionViewHolder<ItemType>(binding.root) {
override fun bind(item: ItemType) {
binding.itemTitle.text = item.title
binding.itemRadiobutton.isChecked = item.isSelected
setupCheckListener(item)
}
private fun setupCheckListener(item: ItemType) = with(binding) {
val checkListener = View.OnClickListener {
itemRadiobutton.isChecked = true
onItemSelectAction.invoke(item.copyWithSelection(isSelected = when (selectionType) {
SelectionType.SINGLE_SELECT -> true
else -> !item.isSelected
}))
}
root.setOnClickListener(checkListener)
itemRadiobutton.setOnClickListener(checkListener)
}
}

View File

@ -0,0 +1,30 @@
package ru.touchin.roboswag.base_filters.select_list_item.adapter
import androidx.recyclerview.widget.DiffUtil
import ru.touchin.roboswag.base_filters.select_list_item.model.BaseSelectionItem
import ru.touchin.roboswag.base_filters.SelectionType
import ru.touchin.roboswag.recyclerview_adapters.adapters.DelegationListAdapter
class SheetSelectionAdapter<ItemType : BaseSelectionItem>(
onItemSelectAction: (ItemType) -> Unit,
selectionType: SelectionType,
factory: HolderFactoryType<ItemType>
) : DelegationListAdapter<BaseSelectionItem>(object : DiffUtil.ItemCallback<BaseSelectionItem>() {
override fun areItemsTheSame(oldItem: BaseSelectionItem, newItem: BaseSelectionItem): Boolean =
oldItem.isItemTheSame(newItem)
override fun areContentsTheSame(oldItem: BaseSelectionItem, newItem: BaseSelectionItem): Boolean =
oldItem.isContentTheSame(newItem)
}) {
init {
addDelegate(SheetSelectionDelegate(
onItemSelectAction = onItemSelectAction,
selectionType = selectionType,
factory = factory
))
}
}

View File

@ -0,0 +1,28 @@
package ru.touchin.roboswag.base_filters.select_list_item.adapter
import android.view.ViewGroup
import ru.touchin.roboswag.base_filters.select_list_item.model.BaseSelectionItem
import ru.touchin.roboswag.base_filters.SelectionType
import ru.touchin.roboswag.recyclerview_adapters.adapters.ItemAdapterDelegate
typealias HolderFactoryType<ItemType> = (ViewGroup, (ItemType) -> Unit, SelectionType) -> BaseSelectionViewHolder<ItemType>
class SheetSelectionDelegate<ItemType>(
private val onItemSelectAction: (ItemType) -> Unit,
private val selectionType: SelectionType,
private val factory: HolderFactoryType<ItemType>
) : ItemAdapterDelegate<BaseSelectionViewHolder<ItemType>, ItemType>()
where ItemType : BaseSelectionItem {
override fun onCreateViewHolder(parent: ViewGroup): BaseSelectionViewHolder<ItemType> =
factory.invoke(parent, onItemSelectAction, selectionType)
override fun onBindViewHolder(
holder: BaseSelectionViewHolder<ItemType>,
item: ItemType,
adapterPosition: Int,
collectionPosition: Int,
payloads: MutableList<Any>
) = holder.bind(item)
}

View File

@ -0,0 +1,14 @@
package ru.touchin.roboswag.base_filters.select_list_item.model
abstract class BaseSelectionItem(
open val id: Int,
open val title: String,
open val isSelected: Boolean
) {
abstract fun isItemTheSame(compareItem: BaseSelectionItem): Boolean
abstract fun isContentTheSame(compareItem: BaseSelectionItem): Boolean
abstract fun <ItemType> copyWithSelection(isSelected: Boolean): ItemType
}

View File

@ -0,0 +1,24 @@
package ru.touchin.roboswag.base_filters.select_list_item.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class DefaultSelectionItem(
override val id: Int,
override val title: String,
override val isSelected: Boolean = false
) : BaseSelectionItem(id, title, isSelected), Parcelable {
override fun isItemTheSame(compareItem: BaseSelectionItem): Boolean = when {
compareItem is DefaultSelectionItem && id == compareItem.id -> true
else -> false
}
override fun isContentTheSame(compareItem: BaseSelectionItem): Boolean =
this == compareItem
@Suppress("UNCHECKED_CAST")
override fun <ItemType> copyWithSelection(isSelected: Boolean): ItemType =
this.copy(isSelected = isSelected) as ItemType
}

View File

@ -0,0 +1,171 @@
package ru.touchin.roboswag.base_filters.tags
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import androidx.annotation.LayoutRes
import androidx.core.view.children
import com.google.android.material.chip.ChipGroup
import ru.touchin.roboswag.base_filters.R
import ru.touchin.roboswag.base_filters.SelectionType
import ru.touchin.roboswag.base_filters.databinding.LayoutMultiLineTagGroupBinding
import ru.touchin.roboswag.base_filters.databinding.LayoutSingleLineTagGroupBinding
import ru.touchin.roboswag.base_filters.tags.model.FilterItem
import ru.touchin.roboswag.base_filters.tags.model.FilterProperty
import ru.touchin.roboswag.components.utils.UiUtils
import ru.touchin.roboswag.components.utils.px
import kotlin.properties.Delegates
typealias PropertySelectedAction = (FilterProperty) -> Unit
typealias FilterMoreAction = (FilterItem) -> Unit
class TagLayoutView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private var filterItem: FilterItem by Delegates.notNull()
private var tagsContainer: ChipGroup by Delegates.notNull()
private var propertySelectedAction: PropertySelectedAction? = null
private var moreValuesAction: FilterMoreAction? = null
private var selectionType = SelectionType.MULTI_SELECT
private var isSingleLine = false
private var tagSpacingHorizontalDp: Int = 0
private var tagSpacingVerticalDp: Int = 0
@LayoutRes
private var tagLayout: Int = R.layout.layout_default_tag
private var moreTagText: String = ""
private var maxTagCount = Int.MAX_VALUE
@LayoutRes
private var moreTagLayout: Int = tagLayout
private fun inflateAndGetChipGroup(isSingleLine: Boolean): ChipGroup = when (isSingleLine) {
true -> LayoutSingleLineTagGroupBinding.inflate(LayoutInflater.from(context), this, true).tagGroup
false -> LayoutMultiLineTagGroupBinding.inflate(LayoutInflater.from(context), this, true).tagGroup
}
private fun createTag(property: FilterProperty): TagView {
val tagView = UiUtils.inflate(tagLayout, this)
require(tagView is TagView) { "Layout for tag must contain TagView as root view" }
return tagView.apply {
text = property.title
isChecked = property.isSelected
tagId = property.id
setOnCheckAction { view, isChecked ->
when {
selectionType == SelectionType.SINGLE_SELECT && isChecked -> clearCheck(property.id)
selectionType == SelectionType.MULTI_SELECT && isChecked -> clearExcludedCheck(property)
}
view.isChecked = isChecked
propertySelectedAction?.invoke(property.copyWithSelected(isSelected = isChecked))
}
}
}
private fun createMoreTag(filter: FilterItem): View {
val moreTag = UiUtils.inflate(moreTagLayout, this)
require(moreTag is TextView) { "Layout for more tag must contain TextView as root view" }
return moreTag.apply {
text = moreTagText
setOnClickListener { moreValuesAction?.invoke(filter) }
}
}
private fun clearCheck(selectedId: Int) {
tagsContainer.children.forEach { tagView ->
if (tagView is TagView && tagView.tagId != selectedId) {
tagView.isChecked = false
}
}
}
private fun clearExcludedCheck(property: FilterProperty) {
val excludingIds = property.excludes.map { it.id }
tagsContainer.children.forEach { tagView ->
if (tagView is TagView && tagView.tagId in excludingIds) {
tagView.isChecked = false
}
}
}
inner class Builder(private val filterItem: FilterItem) {
fun onMoreValuesAction(action: FilterMoreAction) = apply {
moreValuesAction = action
}
fun onPropertySelectedAction(action: PropertySelectedAction) = apply {
propertySelectedAction = action
}
fun setMaxTagCount(count: Int) = apply {
maxTagCount = count
}
fun setSpacingHorizontal(horizontalSpacingDp: Int) = apply {
tagSpacingHorizontalDp = horizontalSpacingDp
}
fun setSpacingVertical(verticalSpacingDp: Int) = apply {
tagSpacingVerticalDp = verticalSpacingDp
}
fun setSpacing(value: Int) = apply {
tagSpacingHorizontalDp = value
tagSpacingVerticalDp = value
}
fun setSelectionType(type: SelectionType) = apply {
selectionType = type
}
fun isSingleLine(value: Boolean) = apply {
isSingleLine = value
}
fun setTagLayout(@LayoutRes layoutId: Int) = apply {
tagLayout = layoutId
}
fun setMoreTagLayout(@LayoutRes layoutId: Int, text: String) = apply {
moreTagLayout = layoutId
moreTagText = text
}
fun build() {
this@TagLayoutView.filterItem = filterItem
tagsContainer = inflateAndGetChipGroup(isSingleLine)
with(tagsContainer) {
removeAllViews()
this.isSingleLine = isSingleLine
chipSpacingHorizontal = tagSpacingHorizontalDp.px
chipSpacingVertical = tagSpacingVerticalDp.px
filterItem.properties.take(maxTagCount).forEach { property ->
addView(createTag(property))
}
if (filterItem.properties.size > maxTagCount) {
addView(createMoreTag(filterItem))
}
}
}
}
}

View File

@ -0,0 +1,26 @@
package ru.touchin.roboswag.base_filters.tags
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatCheckBox
class TagView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
): AppCompatCheckBox(context, attrs, defStyleAttr) {
var tagId: Int? = null
private var action: (( view: TagView, isChecked: Boolean) -> Unit)? = null
init {
setOnClickListener {
action?.invoke(this, isChecked)
}
}
fun setOnCheckAction(action: (view: TagView, isChecked: Boolean) -> Unit) {
this.action = action
}
}

View File

@ -0,0 +1,32 @@
package ru.touchin.roboswag.base_filters.tags.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
open class FilterItem(
val id: Int,
val title: String,
val properties: List<FilterProperty>
) : Parcelable
@Parcelize
open class FilterProperty(
val id: Int,
val title: String,
val excludes: List<PropertyExcludingValue>,
val isSelected: Boolean = false
) : Parcelable {
open fun copyWithSelected(isSelected: Boolean) = FilterProperty(
id = id,
title = title,
excludes = excludes,
isSelected = isSelected
)
}
@Parcelize
open class PropertyExcludingValue(
val id: Int
) : Parcelable

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true" android:color="#12B052" />
<item android:state_checked="false" android:color="#1E1E1E" />
</selector>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@android:color/darker_gray"/>
</shape>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@android:color/white"/>
<stroke
android:width="1dp"
android:color="#12B052" />
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true" android:drawable="@drawable/background_chip_checked" />
<item android:state_checked="false" android:drawable="@drawable/background_chip" />
</selector>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="8dp" />
<solid android:color="@android:color/white"/>
<stroke
android:width="1dp"
android:color="@color/slider_point_inactive" />
</shape>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle" >
<size
android:width="1dp" />
<solid
android:color="@color/slider_thumb" />
<padding
android:top="-2sp"
android:bottom="-2sp" />
</shape>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<ru.touchin.roboswag.base_filters.tags.TagView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/background_chip_choice"
android:button="@null"
android:paddingHorizontal="16dp"
android:paddingVertical="6dp"
android:textColor="@color/color_chip_choice"
tools:text="String" />

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.chip.ChipGroup
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/tag_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:chipSpacing="8dp"
app:selectionRequired="false"
app:singleLine="false"
app:singleSelection="true" />

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<HorizontalScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/line_tag_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:scrollbars="none">
<com.google.android.material.chip.ChipGroup
android:id="@+id/tag_group"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:selectionRequired="true" />
</HorizontalScrollView>

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/center_guideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.5" />
<ru.touchin.roboswag.base_filters.range.HintInputView
android:id="@+id/from_input"
style="?attr/filterRange_hintViewStyle"
android:layout_width="0dp"
android:layout_height="42dp"
app:layout_constraintEnd_toStartOf="@+id/center_guideline"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<ru.touchin.roboswag.base_filters.range.HintInputView
android:id="@+id/to_input"
style="?attr/filterRange_hintViewStyle"
android:layout_width="0dp"
android:layout_height="42dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/center_guideline"
app:layout_constraintTop_toTopOf="parent"/>
<ru.touchin.roboswag.base_filters.range.FilterRangeSlider
android:id="@+id/range_slider"
style="?attr/filterRange_sliderStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="@id/range_slider_guideline"
app:layout_constraintBottom_toTopOf="@id/range_slider_guideline"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<View
android:id="@+id/range_slider_guideline"
android:layout_height="0dp"
android:layout_width="wrap_content"
android:orientation="horizontal"
app:layout_constraintTop_toBottomOf="@id/from_input"/>
</merge>

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/item_title"
style="?attr/sheetSelection_itemStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toStartOf="@id/item_radiobutton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
tools:text="Заголовок" />
<androidx.appcompat.widget.AppCompatRadioButton
android:id="@+id/item_radiobutton"
style="?attr/sheetSelection_radioStyle"
android:layout_width="22dp"
android:layout_height="22dp"
android:checked="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<EditText
android:id="@+id/edit_text"
style="?attr/filterRange_valueEditTextStyle"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<TextView
android:id="@+id/start_hint"
style="?attr/filterRange_hintTextStyle"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="@+id/input_text"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/input_text" />
</merge>

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="FilterSelection">
<attr name="sheetSelection_itemStyle" format="reference" />
<attr name="sheetSelection_radioStyle" format="reference" />
</declare-styleable>
<declare-styleable name="FilterRangeSlider">
<attr name="filterRange_activeTickColor" format="color"/>
<attr name="filterRange_inactiveTickColor" format="color"/>
<attr name="filterRange_stepValueMarginTop" format="dimension"/>
<attr name="filterRange_sliderPointSize" format="dimension"/>
<attr name="filterRange_stepTextAppearance" format="reference"/>
<attr name="filterRange_pointShape" format="enum">
<enum name="circle" value="0" />
<enum name="cut" value="1" />
</attr>
</declare-styleable>
<declare-styleable name="FilterRangeChoice">
<attr name="filterRange_sliderStyle" format="reference" />
<attr name="filterRange_hintViewStyle" format="reference" />
<attr name="filterRange_editTextStyle" format="reference" />
<attr name="filterRange_sliderMargin" format="dimension" />
<attr name="filterRange_startHint" format="reference" />
<attr name="filterRange_endHint" format="reference" />
<attr name="filterRange_theme" format="reference" />
</declare-styleable>
<declare-styleable name="FilterEditTextWithHint">
<attr name="filterRange_hintTextStyle" format="reference" />
<attr name="filterRange_valueEditTextStyle" format="reference" />
</declare-styleable>
</resources>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="slider_point_inactive">#B9B9B9</color>
<color name="slider_point_active">#E35100</color>
<color name="slider_track_active_part">#EE9766</color>
<color name="slider_track_inactive_part">#E7E7E7</color>
<color name="slider_thumb">#E35100</color>
</resources>

View File

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

View File

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.FilterSelection" parent="@style/Theme.MaterialComponents.Light.BottomSheetDialog">
<item name="sheetSelection_itemStyle">@style/Widget.FilterSelection.Item</item>
<item name="sheetSelection_radioStyle">@style/Widget.FilterSelection.Radio</item>
</style>
<style name="Widget.FilterSelection.Item" parent="@style/Widget.MaterialComponents.TextView">
<item name="android:layout_margin">16dp</item>
</style>
<style name="Widget.FilterSelection.Radio" parent="@style/Widget.MaterialComponents.CompoundButton.RadioButton">
<item name="android:layout_marginTop">16dp</item>
<item name="android:layout_marginEnd">16dp</item>
</style>
<style name="Widget.FilterRangeSlider.Default" parent="@style/Widget.MaterialComponents.Slider">
<item name="trackColorActive">@color/slider_track_active_part</item>
<item name="trackColorInactive">@color/slider_track_inactive_part</item>
<item name="trackHeight">3dp</item>
<item name="thumbElevation">0dp</item>
<item name="thumbColor">@color/slider_thumb</item>
<item name="labelBehavior">gone</item>
<item name="haloRadius">0dp</item>
<item name="filterRange_activeTickColor">@color/slider_point_active</item>
<item name="filterRange_inactiveTickColor">@color/slider_point_inactive</item>
<item name="filterRange_stepValueMarginTop">12dp</item>
<item name="filterRange_sliderPointSize">5dp</item>
<item name="filterRange_pointShape">circle</item>
<item name="android:layout_marginStart">11dp</item>
<item name="android:layout_marginEnd">11dp</item>
</style>
<style name="Theme.FilterRangeSlider" parent="@style/Theme.MaterialComponents.Light">
<item name="filterRange_sliderStyle">@style/Widget.FilterRangeSlider.Default</item>
<item name="filterRange_hintViewStyle">@style/Widget.HintInputView</item>
<item name="filterRange_hintTextStyle">@style/Widget.HintInputView.HintText</item>
<item name="filterRange_valueEditTextStyle">@style/Widget.HintInputView.EditText</item>
</style>
<style name="FilterRangeChoice">
<item name="filterRange_sliderMargin">23dp</item>
<item name="filterRange_startHint">@string/start_hint</item>
<item name="filterRange_endHint">@string/end_hint</item>
<item name="filterRange_theme">@style/Theme.FilterRangeSlider</item>
</style>
<style name="Widget.HintInputView" parent="@style/Widget.MaterialComponents.TextView">
<item name="android:layout_marginEnd">5dp</item>
<item name="android:layout_marginTop">12dp</item>
<item name="android:layout_marginStart">16dp</item>
</style>
<style name="Widget.HintInputView.HintText" parent="@style/Widget.MaterialComponents.TextView">
<item name="android:layout_marginStart">12dp</item>
<item name="android:elevation">1dp</item>
<item name="android:gravity">center_vertical</item>
</style>
<style name="Widget.HintInputView.EditText" parent="@style/Widget.AppCompat.EditText">
<item name="android:paddingStart">34dp</item>
<item name="android:inputType">number</item>
<item name="android:imeOptions">actionDone</item>
<item name="android:background">@drawable/background_hint_input</item>
<item name="android:textCursorDrawable">@drawable/cursor_background_text_input_view</item>
</style>
</resources>

View File

@ -18,6 +18,10 @@ abstract class AbstractMapManager<TMapView : View, TMap : Any, TLocation : Any>(
abstract fun getCameraTilt(): Float
abstract fun getDefaultDuration(): Float
abstract fun getDefaultZoomStep(): Int
abstract fun moveCamera(
target: TLocation,
zoom: Float = getCameraZoom(),
@ -29,12 +33,17 @@ abstract class AbstractMapManager<TMapView : View, TMap : Any, TLocation : Any>(
target: TLocation,
zoom: Float = getCameraZoom(),
azimuth: Float = getCameraAzimuth(),
tilt: Float = getCameraTilt()
tilt: Float = getCameraTilt(),
animationDuration: Float = getDefaultDuration()
)
abstract fun smoothMoveCamera(targets: List<TLocation>, padding: Int = 0)
abstract fun smoothMoveCamera(targets: List<TLocation>, padding: Int = 0, animationDuration: Float = getDefaultDuration())
abstract fun smoothMoveCamera(targets: List<TLocation>, width: Int, height: Int, padding: Int)
abstract fun smoothMoveCamera(targets: List<TLocation>, width: Int, height: Int, padding: Int, animationDuration: Float = getDefaultDuration())
abstract fun increaseZoom(target: TLocation, zoomIncreaseValue: Int = getDefaultZoomStep())
abstract fun decreaseZoom(target: TLocation, zoomDecreaseValue: Int = getDefaultZoomStep())
abstract fun setMapAllGesturesEnabled(enabled: Boolean)

View File

@ -0,0 +1,12 @@
package ru.touchin.basemap
interface BaseIconGenerator<TPoint, TCluster, TViewIcon> {
fun getClusterIcon(cluster: TCluster): TViewIcon?
fun getClusterItemIcon(clusterItem: TPoint): TViewIcon?
fun getClusterItemView(clusterItem: TPoint): TViewIcon?
fun getClusterView(cluster: TCluster): TViewIcon?
}

View File

@ -0,0 +1,10 @@
package ru.touchin.basemap
interface BaseMapItemRenderer<TPoint, TCluster, TViewIcon> {
var iconGenerator: BaseIconGenerator<TPoint, TCluster, TViewIcon>
fun getClusterItemIcon(item: TPoint): TViewIcon? = iconGenerator.getClusterItemView(item)
fun getClusterIcon(cluster: TCluster): TViewIcon? = iconGenerator.getClusterView(cluster)
}

View File

@ -0,0 +1,13 @@
package ru.touchin.basemap
import android.util.SparseArray
inline fun <K, V> MutableMap<K, V>.getOrPutIfNotNull(key: K, defaultValue: () -> V?): V? =
get(key) ?: defaultValue()?.also { value ->
put(key, value)
}
inline fun <V> SparseArray<V>.getOrPutIfNotNull(key: Int, defaultValue: () -> V?): V? =
get(key) ?: defaultValue()?.also { value ->
put(key, value)
}

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<path
android:pathData="M11,11m-11,0a11,11 0,1 1,22 0a11,11 0,1 1,-22 0"
android:fillColor="#FF5100"/>
</vector>

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

@ -0,0 +1 @@
/build

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

@ -0,0 +1,26 @@
apply from: "../android-configs/lib-config.gradle"
apply plugin: 'kotlin-android'
dependencies {
implementation project(":navigation-base")
implementation 'androidx.core:core-ktx'
implementation 'com.google.android.material:material'
implementation("androidx.core:core-ktx") {
version {
require '1.9.0'
}
}
implementation("com.google.android.material:material") {
version {
require '1.4.0'
}
}
}
android {
buildFeatures {
viewBinding true
}
}

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

@ -0,0 +1,29 @@
# BottomSheet Utils
- `BaseBottomSheet` - класс, содержащий парамерты `BottomSheetOptions`
- `DefaultBottomSheet` - класс с классическим хедером и скруглением, в котором нужно переопределить `createContentView()`
## BottomSheetOptions
- `styleId` - xml-стиль, в котором можно задать скругление
- `canDismiss` - может ли модалка быть срыта по тапу/свайпу/backButton
- `canTouchOutside` - возможность передавать жесты под модалкой
- `isSkipCollapsed` - убирает промежуточное состояние модалки
- `isFullscreen` - модалка откроется на весь экран, даже при маленьком контенте
- `isShiftedWithKeyboard` - модалка будет полностью подниматься при открытии клавиатуры
- `defaultDimAmount` - константное затемнение
- `animatedMaxDimAmount` - максимальное затемнение, при этом будет анимироваться в зависимости от offset
- `fadeAnimationOptions` - позволяет настроить fade анимацию при изменении высоты
- `heightStatesOptions` - позволяет задать 3 состояния высоты модалки
## ContentFadeAnimationOptions
- `foregroundRes` - drawableId, который будет показыватся сверху во время анимации
- `duration` - длительность fade анимации
- `minAlpha` - минимальная прозрачность во время анимации
## HeightStatesOptions
- `collapsedHeightPx` - высота минимального состояния
- `halfExpandedHalfPx` - высота промежуточного состояния
- `canTouchOutsideWhenCollapsed` - могут ли жесты передаватья под модалку в минимальном состоянии
Тестовый проект: https://github.com/duwna/BottomSheets

View File

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

View File

@ -0,0 +1,161 @@
package ru.touchin.roboswag.bottomsheet
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.app.Dialog
import android.content.res.Resources
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.core.content.ContextCompat
import androidx.fragment.app.DialogFragment
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kotlin.math.abs
abstract class BaseBottomSheet : BottomSheetDialogFragment() {
protected abstract val layoutId: Int
protected open val bottomSheetOptions = BottomSheetOptions()
protected val decorView: View
get() = checkNotNull(dialog?.window?.decorView)
protected val bottomSheetView: FrameLayout
get() = decorView.findViewById(com.google.android.material.R.id.design_bottom_sheet)
protected val touchOutsideView: View
get() = decorView.findViewById(com.google.android.material.R.id.touch_outside)
protected val behavior: BottomSheetBehavior<FrameLayout>
get() = BottomSheetBehavior.from(bottomSheetView)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
inflater.inflate(layoutId, container)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
bottomSheetOptions.styleId?.let { setStyle(DialogFragment.STYLE_NORMAL, it) }
}
override fun onStart() {
super.onStart()
bottomSheetOptions.defaultDimAmount?.let { dialog?.window?.setDimAmount(it) }
bottomSheetOptions.animatedMaxDimAmount?.let { setupDimAmountChanges(it) }
if (bottomSheetOptions.isShiftedWithKeyboard) setupShiftWithKeyboard()
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
isCancelable = bottomSheetOptions.canDismiss
return super.onCreateDialog(savedInstanceState)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (bottomSheetOptions.isSkipCollapsed) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
behavior.skipCollapsed = true
}
if (bottomSheetOptions.isFullscreen) {
bottomSheetView.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
}
if (bottomSheetOptions.canTouchOutside) {
setupTouchOutside()
}
bottomSheetOptions.fadeAnimationOptions?.let {
setupFadeAnimationOnHeightChanges(it)
}
bottomSheetOptions.heightStatesOptions?.let {
setupHeightOptions(it)
}
}
private fun setupDimAmountChanges(maxDimAmount: Float) {
behavior.peekHeight = 0
dialog?.window?.setDimAmount(maxDimAmount)
behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) = Unit
override fun onSlide(bottomSheet: View, slideOffset: Float) {
dialog?.window?.setDimAmount(abs(slideOffset) * maxDimAmount)
}
})
}
private fun setupFadeAnimationOnHeightChanges(options: ContentFadeAnimationOptions) {
val foreground = checkNotNull(
ContextCompat.getDrawable(requireContext(), options.foregroundRes)
).apply {
alpha = 0
bottomSheetView.foreground = this
}
bottomSheetView.addOnLayoutChangeListener { _, _, top, _, _, _, oldTop, _, _ ->
if (top != oldTop) showFadeAnimation(foreground, options)
}
}
private fun showFadeAnimation(foreground: Drawable, options: ContentFadeAnimationOptions) {
val maxAlpha = 255
foreground.alpha = maxAlpha
bottomSheetView.alpha = options.minAlpha
ValueAnimator.ofInt(maxAlpha, 0).apply {
duration = options.duration
addUpdateListener {
val value = it.animatedValue as Int
foreground.alpha = value
bottomSheetView.alpha = (1 - value.toFloat() / maxAlpha).coerceAtLeast(options.minAlpha)
}
start()
}
}
private fun setupHeightOptions(options: HeightStatesOptions) = with(behavior) {
isFitToContents = false
peekHeight = options.collapsedHeightPx
halfExpandedRatio = options.halfExpandedHalfPx / Resources.getSystem().displayMetrics.heightPixels.toFloat()
state = BottomSheetBehavior.STATE_COLLAPSED
if (options.canTouchOutsideWhenCollapsed) setupTouchOutsideWhenCollapsed()
}
private fun setupTouchOutsideWhenCollapsed() {
setupTouchOutside()
behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
override fun onStateChanged(bottomSheet: View, newState: Int) {
when (newState) {
BottomSheetBehavior.STATE_COLLAPSED -> setupTouchOutside()
else -> touchOutsideView.setOnTouchListener(null)
}
}
})
}
@Suppress("DEPRECATION")
private fun setupShiftWithKeyboard() {
dialog?.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
}
@SuppressLint("ClickableViewAccessibility")
private fun setupTouchOutside() {
touchOutsideView.setOnTouchListener { _, event ->
requireActivity().dispatchTouchEvent(event)
true
}
}
}

View File

@ -0,0 +1,32 @@
package ru.touchin.roboswag.bottomsheet
import androidx.annotation.DrawableRes
import androidx.annotation.StyleRes
/**
* See explanation in readme
* */
data class BottomSheetOptions(
@StyleRes val styleId: Int? = null,
val canDismiss: Boolean = true,
val canTouchOutside: Boolean = false,
val isSkipCollapsed: Boolean = true,
val isFullscreen: Boolean = false,
val isShiftedWithKeyboard: Boolean = false,
val defaultDimAmount: Float? = null,
val animatedMaxDimAmount: Float? = null,
val fadeAnimationOptions: ContentFadeAnimationOptions? = null,
val heightStatesOptions: HeightStatesOptions? = null
)
data class ContentFadeAnimationOptions(
@DrawableRes val foregroundRes: Int,
val duration: Long,
val minAlpha: Float
)
data class HeightStatesOptions(
val collapsedHeightPx: Int,
val halfExpandedHalfPx: Int,
val canTouchOutsideWhenCollapsed: Boolean = true
)

View File

@ -0,0 +1,40 @@
package ru.touchin.roboswag.bottomsheet
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import ru.touchin.roboswag.bottomsheet.databinding.DefaultBottomSheetBinding
import ru.touchin.roboswag.navigation_base.fragments.viewBinding
abstract class DefaultBottomSheet : BaseBottomSheet() {
abstract fun createContentView(inflater: LayoutInflater): View
final override val layoutId = R.layout.default_bottom_sheet
override val bottomSheetOptions = BottomSheetOptions(
styleId = R.style.RoundedBottomSheetStyle,
fadeAnimationOptions = ContentFadeAnimationOptions(
foregroundRes = R.drawable.bottom_sheet_background_rounded_16,
duration = 150,
minAlpha = 0.5f
)
)
protected val rootBinding by viewBinding(DefaultBottomSheetBinding::bind)
protected val contentView: View get() = rootBinding.linearRoot.getChildAt(1)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
super.onCreateView(inflater, container, savedInstanceState)
.also {
DefaultBottomSheetBinding.bind(checkNotNull(it))
.linearRoot.addView(createContentView(inflater))
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
rootBinding.closeText.setOnClickListener { dismiss() }
}
}

View File

@ -0,0 +1,11 @@
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners
android:topLeftRadius="16dp"
android:topRightRadius="16dp" />
<solid android:color="@android:color/white" />
</shape>

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/linear_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/top_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|center"
tools:ignore="ContentDescription"
tools:src="@android:mipmap/sym_def_app_icon" />
<TextView
android:id="@+id/close_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|left"
tools:text="Закрыть" />
</FrameLayout>
</LinearLayout>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="RoundedBottomSheetStyle" parent="Theme.Design.Light.BottomSheetDialog">
<item name="android:windowIsFloating">false</item>
<item name="bottomSheetStyle">@style/RoundedBackground</item>
</style>
<style name="RoundedBackground" parent="Widget.Design.BottomSheet.Modal">
<item name="android:background">@drawable/bottom_sheet_background_rounded_16</item>
</style>
</resources>

View File

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

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

@ -0,0 +1 @@
/build

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

@ -0,0 +1,22 @@
apply from: "../android-configs/lib-config.gradle"
dependencies {
def coroutinesVersion = '1.6.4'
def junitVersion = '4.13.2'
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
testImplementation("junit:junit")
constraints {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core") {
version {
require(coroutinesVersion)
}
}
testImplementation("junit:junit") {
version {
require(junitVersion)
}
}
}
}

View File

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

View File

@ -0,0 +1,35 @@
package ru.touchin.roboswag.cart_utils.models
abstract class CartModel<TProductModel : ProductModel> {
abstract val products: List<TProductModel>
open val promocodeList: List<PromocodeModel> = emptyList()
open val availableBonuses: Int = 0
open val usedBonuses: Int = 0
val availableProducts: List<TProductModel>
get() = products.filter { it.isAvailable && !it.isDeleted }
val totalPrice: Int
get() = availableProducts.sumOf { it.countInCart * it.price }
val totalBonuses: Int
get() = availableProducts.sumOf { it.countInCart * (it.bonuses ?: 0) }
fun getPriceWithPromocode(): Int = promocodeList
.sortedByDescending { it.discount is PromocodeDiscount.ByPercent }
.fold(initial = totalPrice) { price, promo ->
promo.discount.applyTo(price)
}
abstract fun <TCart> copyWith(
products: List<TProductModel> = this.products,
promocodeList: List<PromocodeModel> = this.promocodeList,
usedBonuses: Int = this.usedBonuses
): TCart
@Suppress("UNCHECKED_CAST")
fun <TCart> asCart() = this as TCart
}

View File

@ -0,0 +1,25 @@
package ru.touchin.roboswag.cart_utils.models
abstract class ProductModel {
abstract val id: Int
abstract val countInCart: Int
abstract val price: Int
abstract val isAvailable: Boolean
abstract val isDeleted: Boolean
open val bonuses: Int? = null
open val variants: List<ProductModel> = emptyList()
open val selectedVariantId: Int? = null
val selectedVariant get() = variants.find { it.id == selectedVariantId }
abstract fun <TProduct> copyWith(
countInCart: Int = this.countInCart,
isDeleted: Boolean = this.isDeleted,
selectedVariantId: Int? = this.selectedVariantId
): TProduct
@Suppress("UNCHECKED_CAST")
fun <TProduct> asProduct(): TProduct = this as TProduct
}

View File

@ -0,0 +1,19 @@
package ru.touchin.roboswag.cart_utils.models
open class PromocodeModel(
val code: String,
val discount: PromocodeDiscount,
)
abstract class PromocodeDiscount {
abstract fun applyTo(totalPrice: Int): Int
class ByValue(private val value: Int) : PromocodeDiscount() {
override fun applyTo(totalPrice: Int): Int = totalPrice - value
}
class ByPercent(private val percent: Int) : PromocodeDiscount() {
override fun applyTo(totalPrice: Int): Int = totalPrice - totalPrice * percent / 100
}
}

View File

@ -0,0 +1,19 @@
package ru.touchin.roboswag.cart_utils.repositories
import ru.touchin.roboswag.cart_utils.models.CartModel
import ru.touchin.roboswag.cart_utils.models.ProductModel
/**
* Interface for server-side cart repository where each request should return updated [CartModel]
*/
interface IRemoteCartRepository<TCart : CartModel<TProduct>, TProduct : ProductModel> {
suspend fun getCart(): TCart
suspend fun addProduct(product: TProduct): TCart
suspend fun removeProduct(id: Int): TCart
suspend fun editProductCount(id: Int, count: Int): TCart
}

View File

@ -0,0 +1,96 @@
package ru.touchin.roboswag.cart_utils.repositories
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import ru.touchin.roboswag.cart_utils.models.CartModel
import ru.touchin.roboswag.cart_utils.models.ProductModel
import ru.touchin.roboswag.cart_utils.models.PromocodeModel
/**
* Class that contains StateFlow of current [CartModel] which can be subscribed in ViewModels
*/
class LocalCartRepository<TCart : CartModel<TProduct>, TProduct : ProductModel>(
initialCart: TCart
) {
private val _currentCart = MutableStateFlow(initialCart)
val currentCart = _currentCart.asStateFlow()
fun updateCart(cart: TCart) {
_currentCart.value = cart
}
fun addProduct(product: TProduct) {
updateCartProducts {
add(product)
}
}
fun removeProduct(id: Int) {
updateCartProducts {
remove(find { it.id == id })
}
}
fun editProductCount(id: Int, count: Int) {
updateCartProducts {
updateProduct(id) { copyWith(countInCart = count) }
}
}
fun markProductDeleted(id: Int) {
updateCartProducts {
updateProduct(id) { copyWith(isDeleted = true) }
}
}
fun restoreDeletedProduct(id: Int) {
updateCartProducts {
updateProduct(id) { copyWith(isDeleted = false) }
}
}
fun applyPromocode(promocode: PromocodeModel) {
updatePromocodeList { add(promocode) }
}
fun removePromocode(code: String) {
updatePromocodeList { removeAt(indexOfFirst { it.code == code }) }
}
fun useBonuses(bonuses: Int) {
require(currentCart.value.availableBonuses >= bonuses) { "Can't use bonuses more than available" }
_currentCart.update { it.copyWith(usedBonuses = bonuses) }
}
fun chooseVariant(productId: Int, variantId: Int?) {
updateCartProducts {
updateProduct(productId) {
if (variantId != null) {
check(variants.any { it.id == variantId }) {
"Product with id=$productId doesn't have variant with id=$variantId"
}
}
copyWith(selectedVariantId = variantId)
}
}
}
private fun updateCartProducts(updateAction: MutableList<TProduct>.() -> Unit) {
_currentCart.update { cart ->
cart.copyWith(products = cart.products.toMutableList().apply(updateAction))
}
}
private fun updatePromocodeList(updateAction: MutableList<PromocodeModel>.() -> Unit) {
_currentCart.update { cart ->
cart.copyWith(promocodeList = cart.promocodeList.toMutableList().apply(updateAction))
}
}
private fun MutableList<TProduct>.updateProduct(id: Int, updateAction: TProduct.() -> TProduct) {
val index = indexOfFirst { it.id == id }
if (index >= 0) this[index] = updateAction.invoke(this[index])
}
}

View File

@ -0,0 +1,39 @@
package ru.touchin.roboswag.cart_utils.requests_qeue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
/**
* Queue for abstract requests which will be executed one after another
*/
typealias Request<TResponse> = suspend () -> TResponse
class RequestsQueue<TRequest : Request<*>> {
private val requestChannel = Channel<TRequest>(capacity = Channel.BUFFERED)
fun initRequestsExecution(
coroutineScope: CoroutineScope,
executeRequestAction: suspend (TRequest) -> Unit,
) {
requestChannel
.consumeAsFlow()
.onEach { executeRequestAction.invoke(it) }
.launchIn(coroutineScope)
}
fun addToQueue(request: TRequest) {
requestChannel.trySend(request)
}
fun clearQueue() {
while (hasPendingRequests()) requestChannel.tryReceive()
}
@OptIn(ExperimentalCoroutinesApi::class)
fun hasPendingRequests() = !requestChannel.isEmpty
}

View File

@ -0,0 +1,110 @@
package ru.touchin.roboswag.cart_utils.update_manager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import ru.touchin.roboswag.cart_utils.models.CartModel
import ru.touchin.roboswag.cart_utils.models.ProductModel
import ru.touchin.roboswag.cart_utils.repositories.IRemoteCartRepository
import ru.touchin.roboswag.cart_utils.repositories.LocalCartRepository
import ru.touchin.roboswag.cart_utils.requests_qeue.Request
import ru.touchin.roboswag.cart_utils.requests_qeue.RequestsQueue
/**
* Combines local and remote cart update actions
*/
open class CartUpdateManager<TCart : CartModel<TProduct>, TProduct : ProductModel>(
private val localCartRepository: LocalCartRepository<TCart, TProduct>,
private val remoteCartRepository: IRemoteCartRepository<TCart, TProduct>,
private val maxRequestAttemptsCount: Int = MAX_REQUEST_CART_ATTEMPTS_COUNT,
private val errorHandler: (Throwable) -> Unit = {},
) {
companion object {
private const val MAX_REQUEST_CART_ATTEMPTS_COUNT = 3
}
private val requestsQueue = RequestsQueue<Request<TCart>>()
@Volatile
var lastRemoteCart: TCart? = null
private set
fun initCartRequestsQueue(
coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
requestsQueue.initRequestsExecution(coroutineScope) { request ->
runCatching {
lastRemoteCart = request.invoke()
if (!requestsQueue.hasPendingRequests()) updateLocalCartWithRemote()
}.onFailure { error ->
errorHandler.invoke(error)
requestsQueue.clearQueue()
tryToGetRemoteCartAgain()
}
}
}
open fun addProduct(product: TProduct, restoreDeleted: Boolean = false) {
with(localCartRepository) {
if (restoreDeleted) restoreDeletedProduct(product.id) else addProduct(product)
}
requestsQueue.addToQueue {
remoteCartRepository.addProduct(product)
}
}
open fun removeProduct(id: Int, markDeleted: Boolean = false) {
with(localCartRepository) {
if (markDeleted) markProductDeleted(id) else removeProduct(id)
}
requestsQueue.addToQueue {
remoteCartRepository.removeProduct(id)
}
}
open fun editProductCount(id: Int, count: Int) {
localCartRepository.editProductCount(id, count)
requestsQueue.addToQueue {
remoteCartRepository.editProductCount(id, count)
}
}
private suspend fun tryToGetRemoteCartAgain() {
repeat(maxRequestAttemptsCount) {
runCatching {
lastRemoteCart = remoteCartRepository.getCart()
updateLocalCartWithRemote()
return
}
}
}
private fun updateLocalCartWithRemote() {
val remoteCart = lastRemoteCart ?: return
val remoteProducts = remoteCart.products
val localProducts = localCartRepository.currentCart.value.products
val newProductsFromRemoteCart = remoteProducts.filter { remoteProduct ->
localProducts.none { it.id == remoteProduct.id }
}
val mergedProducts = localProducts.mapNotNull { localProduct ->
val sameRemoteProduct = remoteProducts.find { it.id == localProduct.id }
when {
sameRemoteProduct != null -> sameRemoteProduct
localProduct.isDeleted -> localProduct
else -> null
}
}
val mergedCart = remoteCart.copyWith<TCart>(products = mergedProducts + newProductsFromRemoteCart)
localCartRepository.updateCart(mergedCart)
}
}

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

@ -0,0 +1 @@
/build

View File

@ -0,0 +1,35 @@
apply from: "../android-configs/lib-config.gradle"
apply plugin: 'com.huawei.agconnect'
dependencies {
implementation "androidx.core:core"
implementation "androidx.annotation:annotation"
implementation "com.google.android.gms:play-services-base"
implementation "com.huawei.hms:base"
constraints {
implementation("androidx.core:core") {
version {
require '1.0.0'
}
}
implementation("androidx.annotation:annotation") {
version {
require '1.1.0'
}
}
implementation("com.google.android.gms:play-services-base") {
version {
require '18.0.1'
}
}
implementation("com.huawei.hms:base") {
version {
require '6.3.0.303'
}
}
}
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="ru.touchin.client_services">
</manifest>

View File

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

View File

@ -0,0 +1,28 @@
package ru.touchin.client_services
import android.content.Context
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.huawei.hms.api.HuaweiApiAvailability
/**
* A class with utils for interacting with Google, Huawei services
*/
class ServicesUtils {
fun getCurrentService(context: Context): MobileService = when {
checkHuaweiServices(context) -> MobileService.HUAWEI_SERVICE
checkGooglePlayServices(context) -> MobileService.GOOGLE_SERVICE
else -> MobileService.GOOGLE_SERVICE
}
private fun checkHuaweiServices(context: Context): Boolean =
HuaweiApiAvailability.getInstance()
.isHuaweiMobileNoticeAvailable(context) == ConnectionResult.SUCCESS
private fun checkGooglePlayServices(context: Context): Boolean =
GoogleApiAvailability.getInstance()
.isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS
}

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

@ -0,0 +1 @@
/build

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

@ -0,0 +1,24 @@
apply from: "../android-configs/lib-config.gradle"
dependencies {
implementation project(":mvi-arch")
implementation project(":lifecycle")
implementation "androidx.lifecycle:lifecycle-extensions"
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx")
def lifecycleVersion = "2.2.0"
constraints {
implementation("androidx.lifecycle:lifecycle-extensions") {
version {
require '2.1.0'
}
}
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx") {
version {
require(lifecycleVersion)
}
}
}
}

View File

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

View File

@ -0,0 +1,22 @@
package ru.touchin.code_confirm
/**
* [CodeConfirmAction] is interface for the action that will call
* the confirmation request with entered code
*/
interface CodeConfirmAction
/**
* [UpdatedCodeInputAction] is interface for the action, that should be called
* after each update of codeInput
* @param code Updated string with code from codeInput
*/
interface UpdatedCodeInputAction {
val code: String?
}
/**
* [GetRefreshCodeAction] is interface for the action that will call
* the request of a repeat code after it's expired
*/
interface GetRefreshCodeAction

View File

@ -0,0 +1,18 @@
package ru.touchin.code_confirm
import ru.touchin.roboswag.mvi_arch.marker.ViewState
abstract class BaseCodeConfirmState(
open var codeLifetime: String,
open var isLoadingState: Boolean,
open var isWrongCode: Boolean,
open var isExpired: Boolean,
open var isRefreshCodeLoading: Boolean = false,
open var needSendCode: Boolean = true
) : ViewState {
val canRequestNewCode: Boolean
get() = isExpired && !isRefreshCodeLoading
abstract fun <T : BaseCodeConfirmState> copyWith(updateBlock: T.() -> Unit): T
}

View File

@ -0,0 +1,134 @@
package ru.touchin.code_confirm
import android.os.CountDownTimer
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import ru.touchin.code_confirm.LifeTimer.Companion.getFormattedCodeLifetimeString
import ru.touchin.lifecycle.extensions.toImmutable
import ru.touchin.lifecycle.livedata.SingleLiveEvent
import ru.touchin.roboswag.mvi_arch.core.MviViewModel
import ru.touchin.roboswag.mvi_arch.marker.ViewAction
@SuppressWarnings("detekt.TooGenericExceptionCaught")
abstract class BaseCodeConfirmViewModel<NavArgs : Parcelable, Action : ViewAction, State : BaseCodeConfirmState>(
initialState: State,
savedStateHandle: SavedStateHandle
) : MviViewModel<NavArgs, Action, State>(initialState, savedStateHandle) {
/** [requireCodeId] uses for auto-filling */
protected open var requireCodeId: String? = null
private var timer: CountDownTimer? = null
private var currentConfirmationCode: String? = null
private val _updateCodeEvent = SingleLiveEvent<String>()
val updateCodeEvent = _updateCodeEvent.toImmutable()
init {
_state.value = currentState.copyWith {
codeLifetime = getFormattedCodeLifetimeString(getTimerDuration().toLong())
}
startTimer(seconds = getTimerDuration())
}
protected abstract fun getTimerDuration(): Int
protected abstract suspend fun requestNewCode(): BaseCodeResponse
protected abstract suspend fun requestCodeConfirmation(code: String)
protected open fun onRefreshCodeRequestError(e: Throwable) {}
protected open fun onCodeConfirmationError(e: Throwable) {}
protected open fun onSuccessCodeConfirmation(code: String) {}
override fun dispatchAction(action: Action) {
super.dispatchAction(action)
when (action) {
is CodeConfirmAction -> {
if (currentState.needSendCode) confirmCode()
}
is GetRefreshCodeAction -> {
getRefreshCode()
}
is UpdatedCodeInputAction -> {
val confirmationCodeChanged = currentConfirmationCode != action.code
_state.value = currentState.copyWith {
isWrongCode = isWrongCode && !confirmationCodeChanged
needSendCode = confirmationCodeChanged
}
currentConfirmationCode = action.code
}
}
}
protected open fun startTimer(seconds: Int) {
timer?.cancel()
timer = LifeTimer(
seconds = seconds,
tickAction = { millis ->
_state.value = currentState.copyWith {
codeLifetime = getFormattedCodeLifetimeString(millis)
isExpired = false
}
},
finishAction = {
_state.value = currentState.copyWith {
isExpired = true
}
}
)
timer?.start()
}
protected open fun getRefreshCode() {
viewModelScope.launch {
try {
_state.value = currentState.copyWith {
isRefreshCodeLoading = true
isWrongCode = false
}
val confirmationData = requestNewCode()
requireCodeId = confirmationData.codeId
startTimer(seconds = confirmationData.codeLifetime)
} catch (throwable: Throwable) {
_state.value = currentState.copyWith { needSendCode = false }
onRefreshCodeRequestError(throwable)
} finally {
_state.value = currentState.copyWith { isRefreshCodeLoading = false }
}
}
}
protected open fun confirmCode() {
currentConfirmationCode?.let { code ->
_state.value = currentState.copyWith { isLoadingState = true }
viewModelScope.launch {
try {
requestCodeConfirmation(code)
onSuccessCodeConfirmation(code)
} catch (throwable: Throwable) {
_state.value = currentState.copyWith { needSendCode = false }
onCodeConfirmationError(throwable)
} finally {
_state.value = currentState.copyWith { isLoadingState = false }
}
}
}
}
protected open fun autofillCode(code: String, codeId: String? = null) {
if (codeId == requireCodeId) {
_updateCodeEvent.setValue(code)
}
}
override fun onCleared() {
super.onCleared()
timer?.cancel()
}
}

View File

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

View File

@ -0,0 +1,35 @@
package ru.touchin.code_confirm
import android.os.CountDownTimer
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.Date
/** [LifeTimer] is extends [CountDownTimer] for countdown in seconds and lifetime text formatting
* @param seconds Lifetime of timer in seconds
* @param tickAction Action will be called on regular interval
* @param finishAction Action will be called on finish */
class LifeTimer(
seconds: Int,
private val tickAction: (Long) -> Unit,
private val finishAction: () -> Unit
) : CountDownTimer(seconds.toLong() * 1000, 1000) {
override fun onTick(millisUntilFinished: Long) {
tickAction.invoke(millisUntilFinished / 1000)
}
override fun onFinish() {
finishAction.invoke()
}
companion object {
private val formatter = SimpleDateFormat("mm:ss", Locale.ROOT)
fun getFormattedCodeLifetimeString(secondsUntilFinished: Long): String =
formatter.format(Date(secondsUntilFinished * 1000L))
}
}

View File

@ -4,6 +4,10 @@ dependencies {
api project(":base-map")
implementation "com.google.android.gms:play-services-maps"
implementation "com.google.maps.android:android-maps-utils"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core"
implementation "androidx.lifecycle:lifecycle-runtime-ktx"
implementation "androidx.core:core-ktx"
constraints {
implementation("com.google.android.gms:play-services-maps") {
@ -11,5 +15,29 @@ dependencies {
require '17.0.0'
}
}
implementation("com.google.maps.android:android-maps-utils") {
version {
require '0.4'
}
}
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core") {
version {
require '1.4.0'
}
}
implementation("androidx.lifecycle:lifecycle-runtime-ktx") {
version {
require '2.4.1'
}
}
implementation("androidx.core:core-ktx") {
version {
require '1.6.0'
}
}
}
}

View File

@ -0,0 +1,44 @@
package ru.touchin.googlemap
import android.content.Context
import android.util.SparseArray
import android.view.LayoutInflater
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.BitmapDescriptorFactory
import com.google.maps.android.clustering.Cluster
import com.google.maps.android.clustering.ClusterItem
import com.google.maps.android.ui.IconGenerator
import ru.touchin.basemap.BaseIconGenerator
import ru.touchin.basemap.getOrPutIfNotNull
open class GoogleIconGenerator<T : ClusterItem>(
private val context: Context
) : IconGenerator(context), BaseIconGenerator<T, Cluster<T>, BitmapDescriptor> {
private val clusterIconsCache = SparseArray<BitmapDescriptor>()
private val clusterItemIconsCache = mutableMapOf<T, BitmapDescriptor>()
fun setDefaultViewAndBackground() {
val defaultLayout = LayoutInflater.from(context).inflate(R.layout.view_google_map_cluster_item, null)
setBackground(ContextCompat.getDrawable(context, R.drawable.default_cluster_background))
setContentView(defaultLayout)
}
override fun getClusterIcon(cluster: Cluster<T>): BitmapDescriptor? {
val clusterSize = cluster.size
return BitmapDescriptorFactory.fromBitmap(makeIcon(clusterSize.toString()))
}
override fun getClusterItemIcon(clusterItem: T): BitmapDescriptor? {
val defaultIcon = context.getDrawable(ru.touchin.basemap.R.drawable.marker_default_icon)
return BitmapDescriptorFactory.fromBitmap(defaultIcon?.toBitmap())
}
override fun getClusterItemView(clusterItem: T): BitmapDescriptor? =
clusterItemIconsCache.getOrPutIfNotNull(clusterItem) { getClusterItemIcon(clusterItem) }
override fun getClusterView(cluster: Cluster<T>): BitmapDescriptor? =
clusterIconsCache.getOrPutIfNotNull(cluster.size) { getClusterIcon(cluster) }
}

View File

@ -0,0 +1,40 @@
package ru.touchin.googlemap
import android.content.Context
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.MarkerOptions
import com.google.maps.android.clustering.Cluster
import com.google.maps.android.clustering.ClusterItem
import com.google.maps.android.clustering.ClusterManager
import com.google.maps.android.clustering.view.DefaultClusterRenderer
import ru.touchin.basemap.BaseIconGenerator
open class GoogleMapItemRenderer<TClusterItem : ClusterItem>(
val context: Context,
googleMap: GoogleMap,
clusterManager: ClusterManager<TClusterItem>,
private val minClusterItemSize: Int = 1
) : DefaultClusterRenderer<TClusterItem>(context, googleMap, clusterManager) {
var iconGenerator: BaseIconGenerator<TClusterItem, Cluster<TClusterItem>, BitmapDescriptor> =
GoogleIconGenerator<TClusterItem>(context).apply { setDefaultViewAndBackground() }
override fun shouldRenderAsCluster(cluster: Cluster<TClusterItem>): Boolean =
cluster.size > minClusterItemSize
override fun onBeforeClusterItemRendered(item: TClusterItem, markerOptions: MarkerOptions) {
markerOptions.icon(getMarkerIcon(item))
}
override fun onBeforeClusterRendered(cluster: Cluster<TClusterItem>, markerOptions: MarkerOptions) {
markerOptions.icon(getClusterIcon(cluster = cluster))
}
private fun getMarkerIcon(item: TClusterItem): BitmapDescriptor? =
iconGenerator.getClusterItemView(item)
private fun getClusterIcon(cluster: Cluster<TClusterItem>): BitmapDescriptor? =
iconGenerator.getClusterView(cluster)
}

View File

@ -13,9 +13,14 @@ import ru.touchin.basemap.AbstractMapManager
@Suppress("detekt.TooManyFunctions")
class GoogleMapManager(mapView: MapView) : AbstractMapManager<MapView, GoogleMap, LatLng>(mapView) {
companion object {
private const val CAMERA_ANIMATION_DURATION = 1f
private const val CAMERA_DEFAULT_STEP = 2
}
override fun initialize(mapListener: AbstractMapListener<MapView, GoogleMap, LatLng>?) {
super.initialize(mapListener)
mapView.getMapAsync(::initMap)
mapView.getMapAsync(this::initMap)
}
override fun initMap(map: GoogleMap) {
@ -77,22 +82,52 @@ class GoogleMapManager(mapView: MapView) : AbstractMapManager<MapView, GoogleMap
override fun getCameraTilt(): Float = map.cameraPosition.tilt
override fun getDefaultDuration(): Float = CAMERA_ANIMATION_DURATION
override fun getDefaultZoomStep(): Int = CAMERA_DEFAULT_STEP
override fun moveCamera(target: LatLng, zoom: Float, azimuth: Float, tilt: Float) {
map.moveCamera(CameraUpdateFactory.newCameraPosition(buildCameraPosition(target, zoom, azimuth, tilt)))
}
override fun smoothMoveCamera(target: LatLng, zoom: Float, azimuth: Float, tilt: Float) {
map.animateCamera(CameraUpdateFactory.newCameraPosition(buildCameraPosition(target, zoom, azimuth, tilt)))
override fun smoothMoveCamera(target: LatLng, zoom: Float, azimuth: Float, tilt: Float, animationDuration: Float) {
map.animateCamera(
CameraUpdateFactory.newCameraPosition(buildCameraPosition(target, zoom, azimuth, tilt)),
animationDuration.toInt(),
null
)
}
override fun smoothMoveCamera(targets: List<LatLng>, padding: Int) {
override fun smoothMoveCamera(targets: List<LatLng>, padding: Int, animationDuration: Float) {
val boundingBox = getBoundingBox(targets)
map.animateCamera(CameraUpdateFactory.newLatLngBounds(boundingBox, padding))
map.animateCamera(
CameraUpdateFactory.newLatLngBounds(boundingBox, padding),
animationDuration.toInt(),
null
)
}
override fun smoothMoveCamera(targets: List<LatLng>, width: Int, height: Int, padding: Int) {
override fun smoothMoveCamera(targets: List<LatLng>, width: Int, height: Int, padding: Int, animationDuration: Float) {
val boundingBox = getBoundingBox(targets)
map.animateCamera(CameraUpdateFactory.newLatLngBounds(boundingBox, width, height, padding))
map.animateCamera(
CameraUpdateFactory.newLatLngBounds(boundingBox, width, height, padding),
animationDuration.toInt(),
null
)
}
override fun increaseZoom(target: LatLng, zoomIncreaseValue: Int) {
smoothMoveCamera(
target = target,
zoom = getCameraZoom() + zoomIncreaseValue
)
}
override fun decreaseZoom(target: LatLng, zoomDecreaseValue: Int) {
smoothMoveCamera(
target = target,
zoom = getCameraZoom() - zoomDecreaseValue
)
}
override fun setMapAllGesturesEnabled(enabled: Boolean) = map.uiSettings.setAllGesturesEnabled(enabled)

View File

@ -0,0 +1,160 @@
package ru.touchin.googlemap
import android.content.Context
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.model.VisibleRegion
import com.google.maps.android.clustering.Cluster
import com.google.maps.android.clustering.ClusterItem
import com.google.maps.android.clustering.ClusterManager
import com.google.maps.android.clustering.algo.Algorithm
import com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm
import com.google.maps.android.clustering.algo.PreCachingAlgorithmDecorator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.sample
@OptIn(FlowPreview::class)
class GooglePlacemarkManager<TClusterItem : ClusterItem>(
context: Context,
private val lifecycleOwner: LifecycleOwner,
private val googleMap: GoogleMap,
clusterItemTapAction: (TClusterItem) -> Boolean,
clusterTapAction: (Cluster<TClusterItem>) -> Boolean,
clusterAlgorithm: Algorithm<TClusterItem> = PreCachingAlgorithmDecorator(NonHierarchicalDistanceBasedAlgorithm())
) : ClusterManager<TClusterItem>(context, googleMap), GoogleMap.OnCameraIdleListener {
private var clusteringJob: Job? = null
private val onVisibilityChangedEvent = MutableSharedFlow<Pair<VisibleRegion?, List<TClusterItem>>>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
private var cameraIdleJob: Job? = null
private val onCameraIdleEvent = MutableSharedFlow<Boolean>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
private val markers = mutableListOf<TClusterItem>()
private var lastVisibleItems = emptyList<TClusterItem>()
var onCameraIdleListener: (() -> Unit)? = null
var clusterRenderer: GoogleMapItemRenderer<TClusterItem>? = null
set(value) {
field = value
setRenderer(value)
}
init {
googleMap.setOnCameraIdleListener(this)
googleMap.setOnMarkerClickListener(this)
setAlgorithm(clusterAlgorithm)
setOnClusterClickListener(clusterTapAction)
setOnClusterItemClickListener(clusterItemTapAction)
}
@Synchronized
override fun addItems(items: Collection<TClusterItem>) {
markers.addAll(items)
onDataChanged()
}
@Synchronized
override fun addItem(clusterItem: TClusterItem) {
markers.add(clusterItem)
onDataChanged()
}
@Synchronized
override fun removeItem(atmClusterItem: TClusterItem) {
markers.remove(atmClusterItem)
onDataChanged()
}
@Synchronized
override fun clearItems() {
markers.clear()
onDataChanged()
}
override fun onCameraIdle() {
onDataChanged()
onCameraIdleEvent.tryEmit(true)
}
@Synchronized
fun setItems(items: Collection<TClusterItem>) {
markers.clear()
markers.addAll(items)
onDataChanged()
}
fun startClustering() {
if (clusteringJob != null || cameraIdleJob != null) return
clusteringJob = lifecycleOwner.lifecycleScope.launchWhenStarted {
onVisibilityChangedEvent
.debounce(CLUSTERING_START_DEBOUNCE_MILLI)
.flowOn(Dispatchers.Default)
.onStart { emit(getData()) }
.mapNotNull { (region, items) -> findItemsInRegion(region, items) }
.sample(CLUSTERING_NEW_LIST_CONSUMING_THROTTLE_MILLIS)
.catch { emit(lastVisibleItems) }
.flowOn(Dispatchers.Main)
.collect { markersToShow ->
lastVisibleItems = markersToShow
super.clearItems()
super.addItems(markersToShow)
cluster()
}
}
listenToCameraIdleEvents()
}
private fun listenToCameraIdleEvents() {
cameraIdleJob = lifecycleOwner.lifecycleScope.launchWhenStarted {
onCameraIdleEvent
.debounce(CAMERA_DEBOUNCE_MILLI)
.flowOn(Dispatchers.Main)
.collect {
onCameraIdleListener?.invoke()
}
}
}
fun stopClustering() {
clusteringJob?.cancel()
cameraIdleJob?.cancel()
}
private fun onDataChanged() {
onVisibilityChangedEvent.tryEmit(getData())
}
private fun getData(): Pair<VisibleRegion?, List<TClusterItem>> =
googleMap.projection.visibleRegion to markers
private fun findItemsInRegion(region: VisibleRegion?, items: List<TClusterItem>): List<TClusterItem>? =
region?.let { items.filter { item -> item.position in region.latLngBounds } }
private companion object {
const val CAMERA_DEBOUNCE_MILLI = 50L
const val CLUSTERING_START_DEBOUNCE_MILLI = 50L
const val CLUSTERING_NEW_LIST_CONSUMING_THROTTLE_MILLIS = 350L
}
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FF5722"/>
</shape>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:minWidth="36dp"
android:minHeight="52dp"
android:padding="6dp"
android:textColor="#FFFFFF"
android:textSize="17sp"/>

View File

@ -0,0 +1,12 @@
package ru.touchin.extensions
import android.content.res.TypedArray
import androidx.annotation.StyleableRes
private const val NOT_FOUND_VALUE = -1
fun TypedArray.getResourceIdOrNull(@StyleableRes index: Int) = getResourceId(index, NOT_FOUND_VALUE)
.takeIf { it != NOT_FOUND_VALUE }
fun TypedArray.getColorOrNull(@StyleableRes index: Int) = getColor(index, NOT_FOUND_VALUE)
.takeIf { it != NOT_FOUND_VALUE }

View File

@ -9,13 +9,15 @@ object ActionThrottler {
// action invoking start user may be in time to click and launch action again
private const val PREVENTION_OF_CLICK_AGAIN_COEFFICIENT = 2
private const val DELAY_MS = PREVENTION_OF_CLICK_AGAIN_COEFFICIENT * RIPPLE_EFFECT_DELAY_MS
const val DEFAULT_THROTTLE_DELAY_MS = 500L
private var lastActionTime = 0L
fun throttleAction(action: () -> Unit): Boolean {
fun throttleAction(throttleDelay: Long = DELAY_MS, action: () -> Unit): Boolean {
val currentTime = SystemClock.elapsedRealtime()
val diff = currentTime - lastActionTime
return if (diff >= DELAY_MS) {
return if (diff >= throttleDelay) {
lastActionTime = currentTime
action.invoke()
true

View File

@ -8,7 +8,7 @@ import androidx.annotation.CallSuper
*/
open class RxViewModel(
private val destroyable: BaseDestroyable = BaseDestroyable(),
private val liveDataDispatcher: BaseLiveDataDispatcher = BaseLiveDataDispatcher(destroyable)
private val liveDataDispatcher: LiveDataDispatcher = BaseLiveDataDispatcher(destroyable)
) : ViewModel(), Destroyable by destroyable, LiveDataDispatcher by liveDataDispatcher {
@CallSuper

View File

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

View File

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

View File

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

View File

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