From 7a3891a14e26323bfc49d34a2c08c5b85a3c8c66 Mon Sep 17 00:00:00 2001 From: SuperMonster003 Date: Wed, 7 May 2025 23:47:22 +0800 Subject: [PATCH] =?UTF-8?q?6.6.3=20-=20Alpha2=20-=20=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=8C=96=20Material=20Date=20Time=20Picker=20(=E7=89=88?= =?UTF-8?q?=E6=9C=AC=204.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changelog/lang_zh-Hans.json | 7 +- app/build.gradle.kts | 9 +- modules/material-date-time-picker/.gitignore | 1 + .../material-date-time-picker/build.gradle | 57 + .../date/DefaultDateRangeLimiterTest.java | 152 ++ .../time/DefaultTimepointLimiterTest.java | 118 + .../time/TimepointTest.java | 28 + .../src/main/AndroidManifest.xml | 22 + .../AccessibleLinearLayout.java | 46 + .../AccessibleTextView.java | 45 + .../GravitySnapHelper.java | 283 +++ .../HapticFeedbackController.java | 89 + .../materialdatetimepicker/Utils.java | 157 ++ .../VerticalTextView.java | 57 + .../date/AccessibleDateAnimator.java | 53 + .../date/DatePickerController.java | 66 + .../date/DatePickerDialog.java | 1145 ++++++++++ .../date/DateRangeLimiter.java | 86 + .../date/DayOfWeek.java | 4 + .../date/DayPickerGroup.java | 188 ++ .../date/DayPickerView.java | 372 ++++ .../date/DefaultDateRangeLimiter.java | 265 +++ .../date/MonthAdapter.java | 219 ++ .../date/MonthView.java | 769 +++++++ .../date/SimpleDayPickerView.java | 40 + .../date/SimpleMonthAdapter.java | 34 + .../date/SimpleMonthView.java | 60 + .../date/TextViewWithCircularIndicator.java | 116 + .../date/YearPickerView.java | 176 ++ .../time/AmPmCirclesView.java | 234 ++ .../time/CircleView.java | 112 + .../time/DefaultTimepointLimiter.java | 306 +++ .../time/RadialPickerLayout.java | 1026 +++++++++ .../time/RadialSelectorView.java | 398 ++++ .../time/RadialTextsView.java | 407 ++++ .../time/TimePickerController.java | 61 + .../time/TimePickerDialog.java | 1936 +++++++++++++++++ .../time/Timepoint.java | 199 ++ .../time/TimepointLimiter.java | 70 + .../res/color/mdtp_date_picker_selector.xml | 23 + .../color/mdtp_date_picker_year_selector.xml | 23 + .../main/res/color/mdtp_done_text_color.xml | 21 + .../res/color/mdtp_done_text_color_dark.xml | 21 + .../mdtp_done_background_color.xml | 25 + .../mdtp_done_background_color.xml | 25 + .../mdtp_done_background_color_dark.xml | 25 + .../mdtp_done_background_color.xml | 25 + .../mdtp_material_button_background.xml | 9 + .../mdtp_material_button_selected.xml | 13 + .../mdtp_month_arrow_background.xml | 3 + .../drawable/mdtp_done_background_color.xml | 25 + .../mdtp_done_background_color_dark.xml | 25 + .../mdtp_ic_chevron_left_black_24dp.xml | 9 + .../mdtp_ic_chevron_right_black_24dp.xml | 9 + .../mdtp_material_button_background.xml | 6 + .../mdtp_material_button_selected.xml | 13 + .../drawable/mdtp_month_arrow_background.xml | 4 + .../src/main/res/font/robotomedium.ttf | Bin 0 -> 162588 bytes .../layout-land/mdtp_date_picker_dialog.xml | 45 + .../mdtp_date_picker_dialog_v2.xml | 37 + .../mdtp_date_picker_header_view_v2.xml | 29 + .../layout-land/mdtp_time_header_label.xml | 193 ++ .../layout-land/mdtp_time_picker_dialog.xml | 52 + .../mdtp_time_picker_dialog_v2.xml | 55 + .../layout-land/mdtp_time_title_view_v2.xml | 30 + .../res/layout-ldrtl/mdtp_daypicker_group.xml | 25 + .../mdtp_date_picker_dialog.xml | 43 + .../mdtp_date_picker_dialog.xml | 38 + .../mdtp_date_picker_dialog.xml | 38 + .../res/layout/mdtp_date_picker_dialog.xml | 37 + .../res/layout/mdtp_date_picker_dialog_v2.xml | 38 + .../layout/mdtp_date_picker_header_view.xml | 27 + .../mdtp_date_picker_header_view_v2.xml | 28 + .../layout/mdtp_date_picker_selected_date.xml | 79 + .../mdtp_date_picker_selected_date_v2.xml | 65 + .../layout/mdtp_date_picker_view_animator.xml | 28 + .../mdtp_date_picker_view_animator_v2.xml | 26 + .../main/res/layout/mdtp_daypicker_group.xml | 25 + .../src/main/res/layout/mdtp_done_button.xml | 43 + .../res/layout/mdtp_time_header_label.xml | 184 ++ .../res/layout/mdtp_time_picker_dialog.xml | 51 + .../res/layout/mdtp_time_picker_dialog_v2.xml | 51 + .../main/res/layout/mdtp_time_title_view.xml | 30 + .../res/layout/mdtp_time_title_view_v2.xml | 30 + .../res/layout/mdtp_year_label_text_view.xml | 24 + .../src/main/res/values-ar/strings.xml | 33 + .../src/main/res/values-en/strings.xml | 35 + .../src/main/res/values-es/strings.xml | 35 + .../src/main/res/values-fr/strings.xml | 35 + .../src/main/res/values-ja/strings.xml | 33 + .../src/main/res/values-ko/strings.xml | 33 + .../src/main/res/values-land/dimens.xml | 36 + .../src/main/res/values-ru/strings.xml | 33 + .../main/res/values-sw600dp-land/dimens.xml | 36 + .../src/main/res/values-sw600dp/dimens.xml | 57 + .../src/main/res/values-v16/strings.xml | 20 + .../src/main/res/values-v16/styles.xml | 30 + .../main/res/values-w270dp-h560dp/dimens.xml | 29 + .../main/res/values-w560dp-land/dimens.xml | 6 + .../src/main/res/values-zh-rHK/strings.xml | 33 + .../src/main/res/values-zh-rTW/strings.xml | 33 + .../src/main/res/values-zh/strings.xml | 33 + .../src/main/res/values/colors.xml | 65 + .../src/main/res/values/dimens.xml | 88 + .../src/main/res/values/strings.xml | 94 + .../src/main/res/values/styles.xml | 60 + .../date/DatePickerDialogTest.java | 79 + .../DefaultDateRangeLimiterPropertyTest.java | 206 ++ .../date/DefaultDateRangeLimiterTest.java | 700 ++++++ .../time/DefaultTimepointLimiterTest.java | 853 ++++++++ .../time/TimePickerDialogTest.java | 28 + .../time/TimepointTest.java | 233 ++ settings.gradle.kts | 3 +- version.properties | 6 +- 114 files changed, 14024 insertions(+), 9 deletions(-) create mode 100644 modules/material-date-time-picker/.gitignore create mode 100644 modules/material-date-time-picker/build.gradle create mode 100644 modules/material-date-time-picker/src/androidTest/java/com/wdullaer/materialdatetimepicker/date/DefaultDateRangeLimiterTest.java create mode 100644 modules/material-date-time-picker/src/androidTest/java/com/wdullaer/materialdatetimepicker/time/DefaultTimepointLimiterTest.java create mode 100644 modules/material-date-time-picker/src/androidTest/java/com/wdullaer/materialdatetimepicker/time/TimepointTest.java create mode 100644 modules/material-date-time-picker/src/main/AndroidManifest.xml create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/AccessibleLinearLayout.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/AccessibleTextView.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/GravitySnapHelper.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/HapticFeedbackController.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/Utils.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/VerticalTextView.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/AccessibleDateAnimator.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DatePickerController.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DatePickerDialog.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DateRangeLimiter.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DayOfWeek.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DayPickerGroup.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DayPickerView.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DefaultDateRangeLimiter.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/MonthAdapter.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/MonthView.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/SimpleDayPickerView.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/SimpleMonthAdapter.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/SimpleMonthView.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/TextViewWithCircularIndicator.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/YearPickerView.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/AmPmCirclesView.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/CircleView.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/DefaultTimepointLimiter.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/RadialPickerLayout.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/RadialSelectorView.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/RadialTextsView.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/TimePickerController.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/TimePickerDialog.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/Timepoint.java create mode 100644 modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/TimepointLimiter.java create mode 100644 modules/material-date-time-picker/src/main/res/color/mdtp_date_picker_selector.xml create mode 100644 modules/material-date-time-picker/src/main/res/color/mdtp_date_picker_year_selector.xml create mode 100644 modules/material-date-time-picker/src/main/res/color/mdtp_done_text_color.xml create mode 100644 modules/material-date-time-picker/src/main/res/color/mdtp_done_text_color_dark.xml create mode 100644 modules/material-date-time-picker/src/main/res/drawable-land-v19/mdtp_done_background_color.xml create mode 100644 modules/material-date-time-picker/src/main/res/drawable-land/mdtp_done_background_color.xml create mode 100644 modules/material-date-time-picker/src/main/res/drawable-land/mdtp_done_background_color_dark.xml create mode 100644 modules/material-date-time-picker/src/main/res/drawable-v19/mdtp_done_background_color.xml create mode 100644 modules/material-date-time-picker/src/main/res/drawable-v21/mdtp_material_button_background.xml create mode 100644 modules/material-date-time-picker/src/main/res/drawable-v21/mdtp_material_button_selected.xml create mode 100644 modules/material-date-time-picker/src/main/res/drawable-v21/mdtp_month_arrow_background.xml create mode 100644 modules/material-date-time-picker/src/main/res/drawable/mdtp_done_background_color.xml create mode 100644 modules/material-date-time-picker/src/main/res/drawable/mdtp_done_background_color_dark.xml create mode 100644 modules/material-date-time-picker/src/main/res/drawable/mdtp_ic_chevron_left_black_24dp.xml create mode 100644 modules/material-date-time-picker/src/main/res/drawable/mdtp_ic_chevron_right_black_24dp.xml create mode 100644 modules/material-date-time-picker/src/main/res/drawable/mdtp_material_button_background.xml create mode 100644 modules/material-date-time-picker/src/main/res/drawable/mdtp_material_button_selected.xml create mode 100644 modules/material-date-time-picker/src/main/res/drawable/mdtp_month_arrow_background.xml create mode 100644 modules/material-date-time-picker/src/main/res/font/robotomedium.ttf create mode 100644 modules/material-date-time-picker/src/main/res/layout-land/mdtp_date_picker_dialog.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout-land/mdtp_date_picker_dialog_v2.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout-land/mdtp_date_picker_header_view_v2.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout-land/mdtp_time_header_label.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout-land/mdtp_time_picker_dialog.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout-land/mdtp_time_picker_dialog_v2.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout-land/mdtp_time_title_view_v2.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout-ldrtl/mdtp_daypicker_group.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout-sw600dp-land/mdtp_date_picker_dialog.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout-sw600dp/mdtp_date_picker_dialog.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout-w270dp-h560dp/mdtp_date_picker_dialog.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_dialog.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_dialog_v2.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_header_view.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_header_view_v2.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_selected_date.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_selected_date_v2.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_view_animator.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_view_animator_v2.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout/mdtp_daypicker_group.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout/mdtp_done_button.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout/mdtp_time_header_label.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout/mdtp_time_picker_dialog.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout/mdtp_time_picker_dialog_v2.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout/mdtp_time_title_view.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout/mdtp_time_title_view_v2.xml create mode 100644 modules/material-date-time-picker/src/main/res/layout/mdtp_year_label_text_view.xml create mode 100644 modules/material-date-time-picker/src/main/res/values-ar/strings.xml create mode 100644 modules/material-date-time-picker/src/main/res/values-en/strings.xml create mode 100644 modules/material-date-time-picker/src/main/res/values-es/strings.xml create mode 100644 modules/material-date-time-picker/src/main/res/values-fr/strings.xml create mode 100644 modules/material-date-time-picker/src/main/res/values-ja/strings.xml create mode 100644 modules/material-date-time-picker/src/main/res/values-ko/strings.xml create mode 100644 modules/material-date-time-picker/src/main/res/values-land/dimens.xml create mode 100644 modules/material-date-time-picker/src/main/res/values-ru/strings.xml create mode 100644 modules/material-date-time-picker/src/main/res/values-sw600dp-land/dimens.xml create mode 100644 modules/material-date-time-picker/src/main/res/values-sw600dp/dimens.xml create mode 100644 modules/material-date-time-picker/src/main/res/values-v16/strings.xml create mode 100644 modules/material-date-time-picker/src/main/res/values-v16/styles.xml create mode 100644 modules/material-date-time-picker/src/main/res/values-w270dp-h560dp/dimens.xml create mode 100644 modules/material-date-time-picker/src/main/res/values-w560dp-land/dimens.xml create mode 100644 modules/material-date-time-picker/src/main/res/values-zh-rHK/strings.xml create mode 100644 modules/material-date-time-picker/src/main/res/values-zh-rTW/strings.xml create mode 100644 modules/material-date-time-picker/src/main/res/values-zh/strings.xml create mode 100644 modules/material-date-time-picker/src/main/res/values/colors.xml create mode 100644 modules/material-date-time-picker/src/main/res/values/dimens.xml create mode 100644 modules/material-date-time-picker/src/main/res/values/strings.xml create mode 100644 modules/material-date-time-picker/src/main/res/values/styles.xml create mode 100644 modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/date/DatePickerDialogTest.java create mode 100644 modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/date/DefaultDateRangeLimiterPropertyTest.java create mode 100644 modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/date/DefaultDateRangeLimiterTest.java create mode 100644 modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/time/DefaultTimepointLimiterTest.java create mode 100644 modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/time/TimePickerDialogTest.java create mode 100644 modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/time/TimepointTest.java diff --git a/.changelog/lang_zh-Hans.json b/.changelog/lang_zh-Hans.json index c12d24df..eeac034e 100644 --- a/.changelog/lang_zh-Hans.json +++ b/.changelog/lang_zh-Hans.json @@ -1,7 +1,7 @@ { "$data": { "v6.6.3": { - "released_date": "2025/05/03", + "released_date": "2025/05/07", "feature": [ "版本历史功能, 可查看发行版本历史更新记录 (多语言) 与统计数据", "timers.keepAlive 方法 (已全局化), 用于保持脚本活跃状态", @@ -25,9 +25,12 @@ "使用异步加载方式一定程度提升文件管理器列表滑动流畅性" ], "dependency": [ + "本地化 Material Date Time Picker 版本 4.2.3", "附加 Jsoup 版本 1.19.1", + "附加 Material Progressbar 版本 1.4.2", "附加 Flexmark Java HTML to Markdown 版本 0.64.8", - "升级 Gradle 版本 8.14-rc-1 -> 8.14-rc-2" + "升级 Gradle 版本 8.14-rc-1 -> 8.14-rc-2", + "升级 Androidx Room 版本 2.7.0 -> 2.7.1" ] }, "v6.6.2": { diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f852cc35..32f794d1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -212,9 +212,9 @@ dependencies /* Unclassified */ { implementation(files("$rootDir/libs/tiny-sign-0.9.jar")) // Room - implementation("androidx.room:room-runtime:2.7.0") - implementation("androidx.room:room-ktx:2.7.0") - ksp("androidx.room:room-compiler:2.7.0") + implementation("androidx.room:room-runtime:2.7.1") + implementation("androidx.room:room-ktx:2.7.1") + ksp("androidx.room:room-compiler:2.7.1") // ApkSig // implementation("com.android.tools.build:apksig:8.7.3") @@ -231,6 +231,9 @@ dependencies /* Unclassified */ { // Jsoup implementation("org.jsoup:jsoup:1.19.1") + + // Material Date Time Picker + implementation(project(":modules:material-date-time-picker")) } dependencies /* MIME */ { diff --git a/modules/material-date-time-picker/.gitignore b/modules/material-date-time-picker/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/modules/material-date-time-picker/.gitignore @@ -0,0 +1 @@ +/build diff --git a/modules/material-date-time-picker/build.gradle b/modules/material-date-time-picker/build.gradle new file mode 100644 index 00000000..a399d6b0 --- /dev/null +++ b/modules/material-date-time-picker/build.gradle @@ -0,0 +1,57 @@ +plugins { + id 'com.android.library' + id 'kotlin-android' +} + +android { + namespace = 'com.wdullaer.materialdatetimepicker' + + compileSdk = project.ext.compileSdk + + defaultConfig { + minSdk = project.ext.minSdk + targetSdk = project.ext.targetSdk + + versionName '4.2.3' + versionCode 54 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(project.ext.javaVersion) + targetCompatibility = JavaVersion.toVersion(project.ext.javaVersion) + } + + kotlinOptions { + jvmTarget = project.ext.javaVersion + } + + lintOptions { + abortOnError false + } +} + +repositories { + mavenCentral() + google() +} + +dependencies { + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'androidx.recyclerview:recyclerview:1.4.0' + + testImplementation 'junit:junit:4.13.2' + testImplementation 'com.pholser:junit-quickcheck-core:0.9.2' + testImplementation 'com.pholser:junit-quickcheck-generators:0.9.1' + + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test:runner:1.6.2' + androidTestImplementation 'androidx.test:rules:1.6.1' +} diff --git a/modules/material-date-time-picker/src/androidTest/java/com/wdullaer/materialdatetimepicker/date/DefaultDateRangeLimiterTest.java b/modules/material-date-time-picker/src/androidTest/java/com/wdullaer/materialdatetimepicker/date/DefaultDateRangeLimiterTest.java new file mode 100644 index 00000000..63464d83 --- /dev/null +++ b/modules/material-date-time-picker/src/androidTest/java/com/wdullaer/materialdatetimepicker/date/DefaultDateRangeLimiterTest.java @@ -0,0 +1,152 @@ +package com.wdullaer.materialdatetimepicker.date; + +import android.os.Parcel; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Calendar; + +import static org.junit.Assert.*; + +/** + * Unit tests for DefaultDateRangeLimiter which need to run on an android device + * Mostly used to test Parcelable serialisation logic + * Created by wdullaer on 2/11/17. + */ +@RunWith(AndroidJUnit4.class) +public class DefaultDateRangeLimiterTest { + @Test + public void shouldCorrectlySaveAndRestoreAParcelWithAYearRange() { + int minYear = 1985; + int maxYear = 2015; + + DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter(); + limiter.setYearRange(minYear, maxYear); + + Parcel parcel = Parcel.obtain(); + limiter.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + DefaultDateRangeLimiter clonedLimiter = DefaultDateRangeLimiter.CREATOR.createFromParcel(parcel); + + assertEquals(clonedLimiter.getMinYear(), minYear); + assertEquals(clonedLimiter.getMaxYear(), maxYear); + } + + @Test + public void shouldCorrectlySaveAndRestoreAParcelWithAMinDate() { + Calendar minDate = Calendar.getInstance(); + minDate.set(Calendar.YEAR, 1980); + + DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter(); + limiter.setMinDate(minDate); + + Parcel parcel = Parcel.obtain(); + limiter.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + DefaultDateRangeLimiter clonedLimiter = DefaultDateRangeLimiter.CREATOR.createFromParcel(parcel); + + assertEquals(clonedLimiter.getMinDate(), limiter.getMinDate()); + } + + @Test + public void shouldCorrectlySaveAndRestoreAParcelWithAMaxDate() { + Calendar maxDate = Calendar.getInstance(); + + DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter(); + limiter.setMaxDate(maxDate); + + Parcel parcel = Parcel.obtain(); + limiter.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + DefaultDateRangeLimiter clonedLimiter = DefaultDateRangeLimiter.CREATOR.createFromParcel(parcel); + + assertEquals(clonedLimiter.getMaxDate(), limiter.getMaxDate()); + } + + @Test + public void shouldCorrectlySaveAndRestoreAParcelWithSelectableDays() { + Calendar day1 = Calendar.getInstance(); + day1.set(Calendar.YEAR, 1985); + Calendar day2 = Calendar.getInstance(); + Calendar[] selectableDays = { + day1, + day2 + }; + + DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter(); + limiter.setSelectableDays(selectableDays); + + Parcel parcel = Parcel.obtain(); + limiter.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + DefaultDateRangeLimiter clonedLimiter = DefaultDateRangeLimiter.CREATOR.createFromParcel(parcel); + + assertArrayEquals(clonedLimiter.getSelectableDays(), limiter.getSelectableDays()); + } + + @Test + public void shouldCorrectlySaveAndRestoreAParcelWithDisabledDays() { + Calendar day1 = Calendar.getInstance(); + day1.set(Calendar.YEAR, 1985); + Calendar day2 = Calendar.getInstance(); + Calendar[] disabledDays = { + day1, + day2 + }; + + DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter(); + limiter.setDisabledDays(disabledDays); + + Parcel parcel = Parcel.obtain(); + limiter.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + DefaultDateRangeLimiter clonedLimiter = DefaultDateRangeLimiter.CREATOR.createFromParcel(parcel); + + assertArrayEquals(clonedLimiter.getDisabledDays(), limiter.getDisabledDays()); + } + + @Test + public void shouldCorrectlySaveAndRestoreAParcel() { + int minYear = 1970; + int maxYear = 2020; + + Calendar minDate = Calendar.getInstance(); + minDate.set(Calendar.YEAR, 1985); + Calendar maxDate = Calendar.getInstance(); + maxDate.set(Calendar.YEAR, 2019); + + Calendar day1 = Calendar.getInstance(); + day1.set(Calendar.MONTH, Calendar.JANUARY); + Calendar day2 = Calendar.getInstance(); + day2.set(Calendar.MONTH, Calendar.AUGUST); + Calendar[] disabledDays = { + day1, + day2 + }; + + Calendar[] selectableDays = { + Calendar.getInstance() + }; + + DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter(); + limiter.setYearRange(minYear, maxYear); + limiter.setMinDate(minDate); + limiter.setMaxDate(maxDate); + limiter.setDisabledDays(disabledDays); + limiter.setSelectableDays(selectableDays); + + Parcel parcel = Parcel.obtain(); + limiter.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + DefaultDateRangeLimiter clonedLimiter = DefaultDateRangeLimiter.CREATOR.createFromParcel(parcel); + + assertEquals(clonedLimiter.getMinYear(), limiter.getMinYear()); + assertEquals(clonedLimiter.getMaxYear(), limiter.getMaxYear()); + assertEquals(clonedLimiter.getMinDate(), limiter.getMinDate()); + assertEquals(clonedLimiter.getMaxDate(), limiter.getMaxDate()); + assertArrayEquals(clonedLimiter.getDisabledDays(), limiter.getDisabledDays()); + assertArrayEquals(clonedLimiter.getSelectableDays(), limiter.getSelectableDays()); + } +} \ No newline at end of file diff --git a/modules/material-date-time-picker/src/androidTest/java/com/wdullaer/materialdatetimepicker/time/DefaultTimepointLimiterTest.java b/modules/material-date-time-picker/src/androidTest/java/com/wdullaer/materialdatetimepicker/time/DefaultTimepointLimiterTest.java new file mode 100644 index 00000000..7daed2a4 --- /dev/null +++ b/modules/material-date-time-picker/src/androidTest/java/com/wdullaer/materialdatetimepicker/time/DefaultTimepointLimiterTest.java @@ -0,0 +1,118 @@ +package com.wdullaer.materialdatetimepicker.time; + +import android.os.Parcel; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Unit tests for DefaultTimepointLimiter which need to run on an android device + * Mostly used to test Parcelable serialisation logic + * Created by wdullaer on 1/11/17. + */ +@RunWith(AndroidJUnit4.class) +public class DefaultTimepointLimiterTest { + @Test + public void shouldCorrectlySaveAndRestoreAParcelWithMinTime() { + Timepoint minTime = new Timepoint(1, 2, 3); + + DefaultTimepointLimiter limiter = new DefaultTimepointLimiter(); + limiter.setMinTime(minTime); + + Parcel limiterParcel = Parcel.obtain(); + limiter.writeToParcel(limiterParcel, 0); + limiterParcel.setDataPosition(0); + + DefaultTimepointLimiter clonedLimiter = DefaultTimepointLimiter.CREATOR.createFromParcel(limiterParcel); + + assertEquals(clonedLimiter.getMinTime(), minTime); + } + + @Test + public void shouldCorrectlySaveAndRestoreAParcelWithMaxTime() { + Timepoint maxTime = new Timepoint(1, 2, 3); + + DefaultTimepointLimiter limiter = new DefaultTimepointLimiter(); + limiter.setMaxTime(maxTime); + + Parcel limiterParcel = Parcel.obtain(); + limiter.writeToParcel(limiterParcel, 0); + limiterParcel.setDataPosition(0); + + DefaultTimepointLimiter clonedLimiter = DefaultTimepointLimiter.CREATOR.createFromParcel(limiterParcel); + + assertEquals(clonedLimiter.getMaxTime(), maxTime); + } + + @Test + public void shouldCorrectlySaveAndRestoreAParcelWithSelectableTimes() { + Timepoint[] disabledTimes = { + new Timepoint(1, 2, 3), + new Timepoint(10, 11, 12) + }; + + DefaultTimepointLimiter limiter = new DefaultTimepointLimiter(); + limiter.setDisabledTimes(disabledTimes); + + Parcel limiterParcel = Parcel.obtain(); + limiter.writeToParcel(limiterParcel, 0); + limiterParcel.setDataPosition(0); + + DefaultTimepointLimiter clonedLimiter = DefaultTimepointLimiter.CREATOR.createFromParcel(limiterParcel); + + assertArrayEquals(clonedLimiter.getDisabledTimes(), disabledTimes); + } + + @Test + public void shouldCorrectlySaveAndRestoreAParcelWithDisabledTimes() { + Timepoint[] selectableTimes = { + new Timepoint(1, 2, 3), + new Timepoint(10, 11, 12) + }; + + DefaultTimepointLimiter limiter = new DefaultTimepointLimiter(); + limiter.setSelectableTimes(selectableTimes); + + Parcel limiterParcel = Parcel.obtain(); + limiter.writeToParcel(limiterParcel, 0); + limiterParcel.setDataPosition(0); + + DefaultTimepointLimiter clonedLimiter = DefaultTimepointLimiter.CREATOR.createFromParcel(limiterParcel); + + assertArrayEquals(clonedLimiter.getSelectableTimes(), selectableTimes); + } + + @Test + public void shouldCorrectlySaveAndRestoreAParcel() { + Timepoint minTime = new Timepoint(1, 2, 3); + Timepoint maxTime = new Timepoint(12, 13, 14); + Timepoint[] disabledTimes = { + new Timepoint(2, 3, 4), + new Timepoint(3, 4, 5) + }; + Timepoint[] selectableTimes = { + new Timepoint(2, 3, 4), + new Timepoint(10, 11, 12) + }; + + DefaultTimepointLimiter limiter = new DefaultTimepointLimiter(); + limiter.setMinTime(minTime); + limiter.setMaxTime(maxTime); + limiter.setDisabledTimes(disabledTimes); + limiter.setSelectableTimes(selectableTimes); + + Parcel limiterParcel = Parcel.obtain(); + limiter.writeToParcel(limiterParcel, 0); + limiterParcel.setDataPosition(0); + + DefaultTimepointLimiter clonedLimiter = DefaultTimepointLimiter.CREATOR.createFromParcel(limiterParcel); + + assertEquals(clonedLimiter.getMinTime(), minTime); + assertEquals(clonedLimiter.getMaxTime(), maxTime); + assertArrayEquals(clonedLimiter.getDisabledTimes(), disabledTimes); + assertArrayEquals(clonedLimiter.getSelectableTimes(), selectableTimes); + } +} \ No newline at end of file diff --git a/modules/material-date-time-picker/src/androidTest/java/com/wdullaer/materialdatetimepicker/time/TimepointTest.java b/modules/material-date-time-picker/src/androidTest/java/com/wdullaer/materialdatetimepicker/time/TimepointTest.java new file mode 100644 index 00000000..43cc7d8d --- /dev/null +++ b/modules/material-date-time-picker/src/androidTest/java/com/wdullaer/materialdatetimepicker/time/TimepointTest.java @@ -0,0 +1,28 @@ +package com.wdullaer.materialdatetimepicker.time; + +import android.os.Parcel; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import static org.junit.Assert.*; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Test for Timepoint which need to run on an actual device + * Created by wdullaer on 1/11/17. + */ +@RunWith(AndroidJUnit4.class) +public class TimepointTest { + @Test + public void shouldCorrectlySaveAndRestoreAParcel() { + Timepoint input = new Timepoint(1, 2, 3); + Parcel timepointParcel = Parcel.obtain(); + input.writeToParcel(timepointParcel, 0); + timepointParcel.setDataPosition(0); + + Timepoint output = Timepoint.CREATOR.createFromParcel(timepointParcel); + assertEquals(input.getHour(), output.getHour()); + assertEquals(input.getMinute(), output.getMinute()); + assertEquals(input.getSecond(), output.getSecond()); + } +} \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/AndroidManifest.xml b/modules/material-date-time-picker/src/main/AndroidManifest.xml new file mode 100644 index 00000000..c2ef964e --- /dev/null +++ b/modules/material-date-time-picker/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/AccessibleLinearLayout.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/AccessibleLinearLayout.java new file mode 100644 index 00000000..8adf3d4b --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/AccessibleLinearLayout.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import android.widget.Button; +import android.widget.LinearLayout; + +/** + * Fake Button class, used so TextViews can announce themselves as Buttons, for accessibility. + */ +public class AccessibleLinearLayout extends LinearLayout { + + public AccessibleLinearLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void onInitializeAccessibilityEvent(AccessibilityEvent event) { + super.onInitializeAccessibilityEvent(event); + event.setClassName(Button.class.getName()); + } + + @Override + public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(info); + info.setClassName(Button.class.getName()); + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/AccessibleTextView.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/AccessibleTextView.java new file mode 100644 index 00000000..73ab8169 --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/AccessibleTextView.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import android.widget.Button; + +/** + * Fake Button class, used so TextViews can announce themselves as Buttons, for accessibility. + */ +public class AccessibleTextView extends androidx.appcompat.widget.AppCompatTextView { + + public AccessibleTextView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void onInitializeAccessibilityEvent(AccessibilityEvent event) { + super.onInitializeAccessibilityEvent(event); + event.setClassName(Button.class.getName()); + } + + @Override + public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(info); + info.setClassName(Button.class.getName()); + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/GravitySnapHelper.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/GravitySnapHelper.java new file mode 100644 index 00000000..8da8ef78 --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/GravitySnapHelper.java @@ -0,0 +1,283 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * Copyright (C) 2017 Wouter Dullaert + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific languag`e governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker; + +import android.os.Build; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.LinearSnapHelper; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.OrientationHelper; +import androidx.recyclerview.widget.RecyclerView; +import android.view.Gravity; +import android.view.View; + +/** + * Enables snapping better snapping in a RecyclerView + * Based on the code of Ruben Sousa + * Created by wdullaer on 3/04/17. + */ +public class GravitySnapHelper extends LinearSnapHelper { + + private OrientationHelper verticalHelper; + private OrientationHelper horizontalHelper; + private int gravity; + private boolean isRtlHorizontal; + private GravitySnapHelper.SnapListener listener; + private boolean snapping; + private RecyclerView.OnScrollListener mScrollListener = new RecyclerView.OnScrollListener() { + @Override + public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { + super.onScrollStateChanged(recyclerView, newState); + if (newState == RecyclerView.SCROLL_STATE_SETTLING) { + snapping = false; + } + if (newState == RecyclerView.SCROLL_STATE_IDLE && listener != null) { + int position = getSnappedPosition(recyclerView); + if (position != RecyclerView.NO_POSITION) { + listener.onSnap(position); + } + snapping = false; + } + } + }; + + @SuppressWarnings("unused") + public GravitySnapHelper(int gravity) { + this(gravity, null); + } + + public GravitySnapHelper(int gravity, SnapListener snapListener) { + if (gravity != Gravity.START && gravity != Gravity.END + && gravity != Gravity.BOTTOM && gravity != Gravity.TOP) { + throw new IllegalArgumentException("Invalid gravity value. Use START " + + "| END | BOTTOM | TOP constants"); + } + this.gravity = gravity; + this.listener = snapListener; + } + + @Override + public void attachToRecyclerView(@Nullable RecyclerView recyclerView) + throws IllegalStateException { + if (recyclerView != null) { + if ((gravity == Gravity.START || gravity == Gravity.END) + && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + isRtlHorizontal + = recyclerView.getContext().getResources().getConfiguration() + .getLayoutDirection() == View.LAYOUT_DIRECTION_RTL; + } + if (listener != null) { + recyclerView.addOnScrollListener(mScrollListener); + } + } + super.attachToRecyclerView(recyclerView); + } + + @Override + public int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager, + @NonNull View targetView) { + int[] out = new int[2]; + + if (layoutManager.canScrollHorizontally()) { + if (gravity == Gravity.START) { + out[0] = distanceToStart(targetView, getHorizontalHelper(layoutManager), false); + } else { // END + out[0] = distanceToEnd(targetView, getHorizontalHelper(layoutManager), false); + } + } else { + out[0] = 0; + } + + if (layoutManager.canScrollVertically()) { + if (gravity == Gravity.TOP) { + out[1] = distanceToStart(targetView, getVerticalHelper(layoutManager), false); + } else { // BOTTOM + out[1] = distanceToEnd(targetView, getVerticalHelper(layoutManager), false); + } + } else { + out[1] = 0; + } + + return out; + } + + @Override + public View findSnapView(RecyclerView.LayoutManager layoutManager) { + View snapView = null; + if (layoutManager instanceof LinearLayoutManager) { + switch (gravity) { + case Gravity.START: + snapView = findStartView(layoutManager, getHorizontalHelper(layoutManager)); + break; + case Gravity.END: + snapView = findEndView(layoutManager, getHorizontalHelper(layoutManager)); + break; + case Gravity.TOP: + snapView = findStartView(layoutManager, getVerticalHelper(layoutManager)); + break; + case Gravity.BOTTOM: + snapView = findEndView(layoutManager, getVerticalHelper(layoutManager)); + break; + } + } + snapping = snapView != null; + return snapView; + } + + private int distanceToStart(View targetView, OrientationHelper helper, boolean fromEnd) { + if (isRtlHorizontal && !fromEnd) { + return distanceToEnd(targetView, helper, true); + } + + return helper.getDecoratedStart(targetView) - helper.getStartAfterPadding(); + } + + private int distanceToEnd(View targetView, OrientationHelper helper, boolean fromStart) { + if (isRtlHorizontal && !fromStart) { + return distanceToStart(targetView, helper, true); + } + + return helper.getDecoratedEnd(targetView) - helper.getEndAfterPadding(); + } + + /** + * Returns the first view that we should snap to. + * + * @param layoutManager the recyclerview's layout manager + * @param helper orientation helper to calculate view sizes + * @return the first view in the LayoutManager to snap to + */ + private View findStartView(RecyclerView.LayoutManager layoutManager, + OrientationHelper helper) { + + if (layoutManager instanceof LinearLayoutManager) { + int firstChild = ((LinearLayoutManager) layoutManager).findFirstVisibleItemPosition(); + + if (firstChild == RecyclerView.NO_POSITION) { + return null; + } + + View child = layoutManager.findViewByPosition(firstChild); + + float visibleWidth; + + // We should return the child if it's visible width + // is greater than 0.5 of it's total width. + // In a RTL configuration, we need to check the start point and in LTR the end point + if (isRtlHorizontal) { + visibleWidth = (float) (helper.getTotalSpace() - helper.getDecoratedStart(child)) + / helper.getDecoratedMeasurement(child); + } else { + visibleWidth = (float) helper.getDecoratedEnd(child) + / helper.getDecoratedMeasurement(child); + } + + // If we're at the end of the list, we shouldn't snap + // to avoid having the last item not completely visible. + boolean endOfList = ((LinearLayoutManager) layoutManager) + .findLastCompletelyVisibleItemPosition() + == layoutManager.getItemCount() - 1; + + if (visibleWidth > 0.5f && !endOfList) { + return child; + } else if (endOfList) { + return null; + } else { + // If the child wasn't returned, we need to return + // the next view close to the start. + return layoutManager.findViewByPosition(firstChild + 1); + } + } + + return null; + } + + private View findEndView(RecyclerView.LayoutManager layoutManager, + OrientationHelper helper) { + + if (layoutManager instanceof LinearLayoutManager) { + int lastChild = ((LinearLayoutManager) layoutManager).findLastVisibleItemPosition(); + + if (lastChild == RecyclerView.NO_POSITION) { + return null; + } + + View child = layoutManager.findViewByPosition(lastChild); + + float visibleWidth; + + if (isRtlHorizontal) { + visibleWidth = (float) helper.getDecoratedEnd(child) + / helper.getDecoratedMeasurement(child); + } else { + visibleWidth = (float) (helper.getTotalSpace() - helper.getDecoratedStart(child)) + / helper.getDecoratedMeasurement(child); + } + + // If we're at the start of the list, we shouldn't snap + // to avoid having the first item not completely visible. + boolean startOfList = ((LinearLayoutManager) layoutManager) + .findFirstCompletelyVisibleItemPosition() == 0; + + if (visibleWidth > 0.5f && !startOfList) { + return child; + } else if (startOfList) { + return null; + } else { + // If the child wasn't returned, we need to return the previous view + return layoutManager.findViewByPosition(lastChild - 1); + } + } + return null; + } + + private int getSnappedPosition(RecyclerView recyclerView) { + RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager(); + + if (layoutManager instanceof LinearLayoutManager) { + if (gravity == Gravity.START || gravity == Gravity.TOP) { + return ((LinearLayoutManager) layoutManager).findFirstCompletelyVisibleItemPosition(); + } else if (gravity == Gravity.END || gravity == Gravity.BOTTOM) { + return ((LinearLayoutManager) layoutManager).findLastCompletelyVisibleItemPosition(); + } + } + + return RecyclerView.NO_POSITION; + } + + private OrientationHelper getVerticalHelper(RecyclerView.LayoutManager layoutManager) { + if (verticalHelper == null) { + verticalHelper = OrientationHelper.createVerticalHelper(layoutManager); + } + return verticalHelper; + } + + private OrientationHelper getHorizontalHelper(RecyclerView.LayoutManager layoutManager) { + if (horizontalHelper == null) { + horizontalHelper = OrientationHelper.createHorizontalHelper(layoutManager); + } + return horizontalHelper; + } + + public interface SnapListener { + void onSnap(int position); + } + +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/HapticFeedbackController.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/HapticFeedbackController.java new file mode 100644 index 00000000..01ef461e --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/HapticFeedbackController.java @@ -0,0 +1,89 @@ +package com.wdullaer.materialdatetimepicker; + +import android.app.Service; +import android.content.Context; +import android.content.pm.PackageManager; +import android.database.ContentObserver; +import android.net.Uri; +import android.os.SystemClock; +import android.os.Vibrator; +import android.provider.Settings; + +/** + * A simple utility class to handle haptic feedback. + */ +public class HapticFeedbackController { + private static final int VIBRATE_DELAY_MS = 125; + private static final int VIBRATE_LENGTH_MS = 50; + + private static boolean checkGlobalSetting(Context context) { + return Settings.System.getInt(context.getContentResolver(), + Settings.System.HAPTIC_FEEDBACK_ENABLED, 0) == 1; + } + + private final Context mContext; + private final ContentObserver mContentObserver; + + private Vibrator mVibrator; + private boolean mIsGloballyEnabled; + private long mLastVibrate; + + public HapticFeedbackController(Context context) { + mContext = context; + mContentObserver = new ContentObserver(null) { + @Override + public void onChange(boolean selfChange) { + mIsGloballyEnabled = checkGlobalSetting(mContext); + } + }; + } + + /** + * Call to setup the controller. + */ + public void start() { + if (hasVibratePermission(mContext)) { + mVibrator = (Vibrator) mContext.getSystemService(Service.VIBRATOR_SERVICE); + } + + // Setup a listener for changes in haptic feedback settings + mIsGloballyEnabled = checkGlobalSetting(mContext); + Uri uri = Settings.System.getUriFor(Settings.System.HAPTIC_FEEDBACK_ENABLED); + mContext.getContentResolver().registerContentObserver(uri, false, mContentObserver); + } + + /** + * Method to verify that vibrate permission has been granted. + * + * Allows users of the library to disabled vibrate support if desired. + * @return true if Vibrate permission has been granted + */ + private boolean hasVibratePermission(Context context) { + PackageManager pm = context.getPackageManager(); + int hasPerm = pm.checkPermission(android.Manifest.permission.VIBRATE, context.getPackageName()); + return hasPerm == PackageManager.PERMISSION_GRANTED; + } + + /** + * Call this when you don't need the controller anymore. + */ + public void stop() { + mVibrator = null; + mContext.getContentResolver().unregisterContentObserver(mContentObserver); + } + + /** + * Try to vibrate. To prevent this becoming a single continuous vibration, nothing will + * happen if we have vibrated very recently. + */ + public void tryVibrate() { + if (mVibrator != null && mIsGloballyEnabled) { + long now = SystemClock.uptimeMillis(); + // We want to try to vibrate each individual tick discretely. + if (now - mLastVibrate >= VIBRATE_DELAY_MS) { + mVibrator.vibrate(VIBRATE_LENGTH_MS); + mLastVibrate = now; + } + } + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/Utils.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/Utils.java new file mode 100644 index 00000000..6fc4d1cc --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/Utils.java @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker; + +import android.animation.Keyframe; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.os.Build; +import androidx.annotation.AttrRes; +import androidx.core.content.ContextCompat; +import android.util.TypedValue; +import android.view.View; + +import java.util.Calendar; + +/** + * Utility helper functions for time and date pickers. + */ +@SuppressWarnings("WeakerAccess") +public class Utils { + + //public static final int MONDAY_BEFORE_JULIAN_EPOCH = Time.EPOCH_JULIAN_DAY - 3; + public static final int PULSE_ANIMATOR_DURATION = 544; + + // Alpha level for time picker selection. + public static final int SELECTED_ALPHA = 255; + public static final int SELECTED_ALPHA_THEME_DARK = 255; + // Alpha level for fully opaque. + public static final int FULL_ALPHA = 255; + + /** + * Try to speak the specified text, for accessibility. Only available on JB or later. + * @param text Text to announce. + */ + public static void tryAccessibilityAnnounce(View view, CharSequence text) { + if (view != null && text != null) { + view.announceForAccessibility(text); + } + } + + /** + * Render an animator to pulsate a view in place. + * @param labelToAnimate the view to pulsate. + * @return The animator object. Use .start() to begin. + */ + public static ObjectAnimator getPulseAnimator(View labelToAnimate, float decreaseRatio, + float increaseRatio) { + Keyframe k0 = Keyframe.ofFloat(0f, 1f); + Keyframe k1 = Keyframe.ofFloat(0.275f, decreaseRatio); + Keyframe k2 = Keyframe.ofFloat(0.69f, increaseRatio); + Keyframe k3 = Keyframe.ofFloat(1f, 1f); + + PropertyValuesHolder scaleX = PropertyValuesHolder.ofKeyframe("scaleX", k0, k1, k2, k3); + PropertyValuesHolder scaleY = PropertyValuesHolder.ofKeyframe("scaleY", k0, k1, k2, k3); + ObjectAnimator pulseAnimator = + ObjectAnimator.ofPropertyValuesHolder(labelToAnimate, scaleX, scaleY); + pulseAnimator.setDuration(PULSE_ANIMATOR_DURATION); + + return pulseAnimator; + } + + /** + * Convert Dp to Pixel + */ + @SuppressWarnings("unused") + public static int dpToPx(float dp, Resources resources){ + float px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.getDisplayMetrics()); + return (int) px; + } + + public static int darkenColor(int color) { + float[] hsv = new float[3]; + Color.colorToHSV(color, hsv); + hsv[2] = hsv[2] * 0.8f; // value component + return Color.HSVToColor(hsv); + } + + /** + * Gets the colorAccent from the current context, if possible/available + * @param context The context to use as reference for the color + * @return the accent color of the current context + */ + public static int getAccentColorFromThemeIfAvailable(Context context) { + TypedValue typedValue = new TypedValue(); + // First, try the android:colorAccent + if (Build.VERSION.SDK_INT >= 21) { + context.getTheme().resolveAttribute(android.R.attr.colorAccent, typedValue, true); + return typedValue.data; + } + // Next, try colorAccent from support lib + int colorAccentResId = context.getResources().getIdentifier("colorAccent", "attr", context.getPackageName()); + if (colorAccentResId != 0 && context.getTheme().resolveAttribute(colorAccentResId, typedValue, true)) { + return typedValue.data; + } + // Return the value in mdtp_accent_color + return ContextCompat.getColor(context, R.color.mdtp_accent_color); + } + + /** + * Gets dialog type (Light/Dark) from current theme + * @param context The context to use as reference for the boolean + * @param current Default value to return if cannot resolve the attribute + * @return true if dark mode, false if light. + */ + public static boolean isDarkTheme(Context context, boolean current) { + return resolveBoolean(context, R.attr.mdtp_theme_dark, current); + } + + /** + * Gets the required boolean value from the current context, if possible/available + * @param context The context to use as reference for the boolean + * @param attr Attribute id to resolve + * @param fallback Default value to return if no value is specified in theme + * @return the boolean value from current theme + */ + private static boolean resolveBoolean(Context context, @AttrRes int attr, boolean fallback) { + TypedArray a = context.getTheme().obtainStyledAttributes(new int[]{attr}); + try { + return a.getBoolean(0, fallback); + } finally { + a.recycle(); + } + } + + /** + * Trims off all time information, effectively setting it to midnight + * Makes it easier to compare at just the day level + * + * @param calendar The Calendar object to trim + * @return The trimmed Calendar object + */ + public static Calendar trimToMidnight(Calendar calendar) { + calendar.set(Calendar.HOUR_OF_DAY, 0); + calendar.set(Calendar.MINUTE, 0); + calendar.set(Calendar.SECOND, 0); + calendar.set(Calendar.MILLISECOND, 0); + return calendar; + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/VerticalTextView.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/VerticalTextView.java new file mode 100644 index 00000000..6a62b33a --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/VerticalTextView.java @@ -0,0 +1,57 @@ +package com.wdullaer.materialdatetimepicker; + +import android.content.Context; +import android.graphics.Canvas; +import android.text.TextPaint; +import android.util.AttributeSet; +import android.view.Gravity; + +/** + * TextView that renders it's contents vertically. (Just using rotate doesn't work because onMeasure + * happens before the View is rotated causing incorrect View boundaries) + * Created by wdullaer on 28/03/16. + */ +public class VerticalTextView extends androidx.appcompat.widget.AppCompatTextView { + final boolean topDown; + + public VerticalTextView(Context context, AttributeSet attrs){ + super(context, attrs); + final int gravity = getGravity(); + if (Gravity.isVertical(gravity) && (gravity&Gravity.VERTICAL_GRAVITY_MASK) == Gravity.BOTTOM) { + setGravity((gravity&Gravity.HORIZONTAL_GRAVITY_MASK) | Gravity.TOP); + topDown = false; + } else { + topDown = true; + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){ + //noinspection SuspiciousNameCombination + super.onMeasure(heightMeasureSpec, widthMeasureSpec); + setMeasuredDimension(getMeasuredHeight(), getMeasuredWidth()); + } + + @Override + protected void onDraw(Canvas canvas){ + TextPaint textPaint = getPaint(); + textPaint.setColor(getCurrentTextColor()); + textPaint.drawableState = getDrawableState(); + + canvas.save(); + + if (topDown){ + canvas.translate(getWidth(), 0); + canvas.rotate(90); + } else { + canvas.translate(0, getHeight()); + canvas.rotate(-90); + } + + + canvas.translate(getCompoundPaddingLeft(), getExtendedPaddingTop()); + + getLayout().draw(canvas); + canvas.restore(); + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/AccessibleDateAnimator.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/AccessibleDateAnimator.java new file mode 100644 index 00000000..96b4f950 --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/AccessibleDateAnimator.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker.date; + +import android.content.Context; +import android.text.format.DateUtils; +import android.util.AttributeSet; +import android.view.accessibility.AccessibilityEvent; +import android.widget.ViewAnimator; + +public class AccessibleDateAnimator extends ViewAnimator { + private long mDateMillis; + + public AccessibleDateAnimator(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public void setDateMillis(long dateMillis) { + mDateMillis = dateMillis; + } + + /** + * Announce the currently-selected date when launched. + */ + @Override + public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { + if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { + // Clear the event's current text so that only the current date will be spoken. + event.getText().clear(); + int flags = DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR | + DateUtils.FORMAT_SHOW_WEEKDAY; + + String dateString = DateUtils.formatDateTime(getContext(), mDateMillis, flags); + event.getText().add(dateString); + return true; + } + return super.dispatchPopulateAccessibilityEvent(event); + } +} \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DatePickerController.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DatePickerController.java new file mode 100644 index 00000000..161f7ba1 --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DatePickerController.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker.date; + +import java.util.Calendar; +import java.util.Locale; +import java.util.TimeZone; + +/** + * Controller class to communicate among the various components of the date picker dialog. + */ +public interface DatePickerController { + + void onYearSelected(int year); + + void onDayOfMonthSelected(int year, int month, int day); + + void registerOnDateChangedListener(DatePickerDialog.OnDateChangedListener listener); + + @SuppressWarnings("unused") + void unregisterOnDateChangedListener(DatePickerDialog.OnDateChangedListener listener); + + MonthAdapter.CalendarDay getSelectedDay(); + + boolean isThemeDark(); + + int getAccentColor(); + + boolean isHighlighted(int year, int month, int day); + + int getFirstDayOfWeek(); + + int getMinYear(); + + int getMaxYear(); + + Calendar getStartDate(); + + Calendar getEndDate(); + + boolean isOutOfRange(int year, int month, int day); + + void tryVibrate(); + + TimeZone getTimeZone(); + + Locale getLocale(); + + DatePickerDialog.Version getVersion(); + + DatePickerDialog.ScrollOrientation getScrollOrientation(); +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DatePickerDialog.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DatePickerDialog.java new file mode 100644 index 00000000..040822aa --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DatePickerDialog.java @@ -0,0 +1,1145 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker.date; + +import android.animation.ObjectAnimator; +import android.app.Activity; +import android.content.DialogInterface; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.graphics.Color; +import android.os.Build; +import android.os.Bundle; +import android.text.format.DateFormat; +import android.text.format.DateUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.TextView; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AppCompatDialogFragment; +import androidx.core.content.ContextCompat; +import androidx.core.content.res.ResourcesCompat; +import com.wdullaer.materialdatetimepicker.HapticFeedbackController; +import com.wdullaer.materialdatetimepicker.R; +import com.wdullaer.materialdatetimepicker.Utils; + +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Calendar; +import java.util.HashSet; +import java.util.Locale; +import java.util.TimeZone; + +/** + * Dialog allowing users to select a date. + */ +public class DatePickerDialog extends AppCompatDialogFragment implements + OnClickListener, DatePickerController { + + public enum Version { + VERSION_1, + VERSION_2 + } + + public enum ScrollOrientation { + HORIZONTAL, + VERTICAL + } + + private static final int UNINITIALIZED = -1; + private static final int MONTH_AND_DAY_VIEW = 0; + private static final int YEAR_VIEW = 1; + + private static final String KEY_SELECTED_YEAR = "year"; + private static final String KEY_SELECTED_MONTH = "month"; + private static final String KEY_SELECTED_DAY = "day"; + private static final String KEY_LIST_POSITION = "list_position"; + private static final String KEY_WEEK_START = "week_start"; + private static final String KEY_CURRENT_VIEW = "current_view"; + private static final String KEY_LIST_POSITION_OFFSET = "list_position_offset"; + private static final String KEY_HIGHLIGHTED_DAYS = "highlighted_days"; + private static final String KEY_THEME_DARK = "theme_dark"; + private static final String KEY_THEME_DARK_CHANGED = "theme_dark_changed"; + private static final String KEY_ACCENT = "accent"; + private static final String KEY_VIBRATE = "vibrate"; + private static final String KEY_DISMISS = "dismiss"; + private static final String KEY_AUTO_DISMISS = "auto_dismiss"; + private static final String KEY_DEFAULT_VIEW = "default_view"; + private static final String KEY_TITLE = "title"; + private static final String KEY_OK_RESID = "ok_resid"; + private static final String KEY_OK_STRING = "ok_string"; + private static final String KEY_OK_COLOR = "ok_color"; + private static final String KEY_CANCEL_RESID = "cancel_resid"; + private static final String KEY_CANCEL_STRING = "cancel_string"; + private static final String KEY_CANCEL_COLOR = "cancel_color"; + private static final String KEY_VERSION = "version"; + private static final String KEY_TIMEZONE = "timezone"; + private static final String KEY_DATERANGELIMITER = "daterangelimiter"; + private static final String KEY_SCROLL_ORIENTATION = "scrollorientation"; + private static final String KEY_LOCALE = "locale"; + + private static final int ANIMATION_DURATION = 300; + private static final int ANIMATION_DELAY = 500; + + private static SimpleDateFormat YEAR_FORMAT = new SimpleDateFormat("yyyy", Locale.getDefault()); + private static SimpleDateFormat MONTH_FORMAT = new SimpleDateFormat("MMM", Locale.getDefault()); + private static SimpleDateFormat DAY_FORMAT = new SimpleDateFormat("dd", Locale.getDefault()); + private static SimpleDateFormat VERSION_2_FORMAT; + + private Calendar mCalendar = Utils.trimToMidnight(Calendar.getInstance(getTimeZone())); + private OnDateSetListener mCallBack; + private HashSet mListeners = new HashSet<>(); + private DialogInterface.OnCancelListener mOnCancelListener; + private DialogInterface.OnDismissListener mOnDismissListener; + + private AccessibleDateAnimator mAnimator; + + private TextView mDatePickerHeaderView; + private LinearLayout mMonthAndDayView; + private TextView mSelectedMonthTextView; + private TextView mSelectedDayTextView; + private TextView mYearView; + private DayPickerGroup mDayPickerView; + private YearPickerView mYearPickerView; + + private int mCurrentView = UNINITIALIZED; + + private int mWeekStart = mCalendar.getFirstDayOfWeek(); + private String mTitle; + private HashSet highlightedDays = new HashSet<>(); + private boolean mThemeDark = false; + private boolean mThemeDarkChanged = false; + private Integer mAccentColor = null; + private boolean mVibrate = true; + private boolean mDismissOnPause = false; + private boolean mAutoDismiss = false; + private int mDefaultView = MONTH_AND_DAY_VIEW; + private int mOkResid = R.string.mdtp_ok; + private String mOkString; + private Integer mOkColor = null; + private int mCancelResid = R.string.mdtp_cancel; + private String mCancelString; + private Integer mCancelColor = null; + private Version mVersion; + private ScrollOrientation mScrollOrientation; + private TimeZone mTimezone; + private Locale mLocale = Locale.getDefault(); + private DefaultDateRangeLimiter mDefaultLimiter = new DefaultDateRangeLimiter(); + private DateRangeLimiter mDateRangeLimiter = mDefaultLimiter; + + private HapticFeedbackController mHapticFeedbackController; + + private boolean mDelayAnimation = true; + + // Accessibility strings. + private String mDayPickerDescription; + private String mSelectDay; + private String mYearPickerDescription; + private String mSelectYear; + + /** + * The callback used to indicate the user is done filling in the date. + */ + public interface OnDateSetListener { + + /** + * @param view The view associated with this listener. + * @param year The year that was set. + * @param monthOfYear The month that was set (0-11) for compatibility + * with {@link java.util.Calendar}. + * @param dayOfMonth The day of the month that was set. + */ + void onDateSet(DatePickerDialog view, int year, int monthOfYear, int dayOfMonth); + } + + /** + * The callback used to notify other date picker components of a change in selected date. + */ + protected interface OnDateChangedListener { + void onDateChanged(); + } + + + public DatePickerDialog() { + // Empty constructor required for dialog fragment. + } + + /** + * Create a new DatePickerDialog instance with a specific initial selection. + * @param callBack How the parent is notified that the date is set. + * @param year The initial year of the dialog. + * @param monthOfYear The initial month of the dialog. + * @param dayOfMonth The initial day of the dialog. + * @return a new DatePickerDialog instance. + */ + public static DatePickerDialog newInstance(OnDateSetListener callBack, int year, int monthOfYear, int dayOfMonth) { + DatePickerDialog ret = new DatePickerDialog(); + ret.initialize(callBack, year, monthOfYear, dayOfMonth); + return ret; + } + + /** + * Create a new DatePickerDialog instance initialised to the current system date. + * @param callback How the parent is notified that the date is set. + * @return a new DatePickerDialog instance + */ + @SuppressWarnings({"unused", "WeakerAccess"}) + public static DatePickerDialog newInstance(OnDateSetListener callback) { + Calendar now = Calendar.getInstance(); + return DatePickerDialog.newInstance(callback, now); + } + + /** + * Create a new DatePickerDialog instance with a specific initial selection. + * @param callback How the parent is notified that the date is set. + * @param initialSelection A Calendar object containing the original selection of the picker. + * (Time is ignored by trimming the Calendar to midnight in the current + * TimeZone of the Calendar object) + * @return a new DatePickerDialog instance + */ + @SuppressWarnings({"unused", "WeakerAccess"}) + public static DatePickerDialog newInstance(OnDateSetListener callback, Calendar initialSelection) { + DatePickerDialog ret = new DatePickerDialog(); + ret.initialize(callback, initialSelection); + return ret; + } + + public void initialize(OnDateSetListener callBack, Calendar initialSelection) { + mCallBack = callBack; + mCalendar = Utils.trimToMidnight((Calendar) initialSelection.clone()); + mScrollOrientation = null; + //noinspection deprecation + setTimeZone(mCalendar.getTimeZone()); + + mVersion = Build.VERSION.SDK_INT < Build.VERSION_CODES.M ? Version.VERSION_1 : Version.VERSION_2; + } + + public void initialize(OnDateSetListener callBack, int year, int monthOfYear, int dayOfMonth) { + Calendar cal = Calendar.getInstance(getTimeZone()); + cal.set(Calendar.YEAR, year); + cal.set(Calendar.MONTH, monthOfYear); + cal.set(Calendar.DAY_OF_MONTH, dayOfMonth); + this.initialize(callBack, cal); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final Activity activity = requireActivity(); + activity.getWindow().setSoftInputMode( + WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); + setStyle(AppCompatDialogFragment.STYLE_NO_TITLE, 0); + mCurrentView = UNINITIALIZED; + if (savedInstanceState != null) { + mCalendar.set(Calendar.YEAR, savedInstanceState.getInt(KEY_SELECTED_YEAR)); + mCalendar.set(Calendar.MONTH, savedInstanceState.getInt(KEY_SELECTED_MONTH)); + mCalendar.set(Calendar.DAY_OF_MONTH, savedInstanceState.getInt(KEY_SELECTED_DAY)); + mDefaultView = savedInstanceState.getInt(KEY_DEFAULT_VIEW); + } + if (Build.VERSION.SDK_INT < 18) { + VERSION_2_FORMAT = new SimpleDateFormat(activity.getResources().getString(R.string.mdtp_date_v2_daymonthyear), mLocale); + } else { + VERSION_2_FORMAT = new SimpleDateFormat(DateFormat.getBestDateTimePattern(mLocale, "EEEMMMdd"), mLocale); + } + VERSION_2_FORMAT.setTimeZone(getTimeZone()); + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putInt(KEY_SELECTED_YEAR, mCalendar.get(Calendar.YEAR)); + outState.putInt(KEY_SELECTED_MONTH, mCalendar.get(Calendar.MONTH)); + outState.putInt(KEY_SELECTED_DAY, mCalendar.get(Calendar.DAY_OF_MONTH)); + outState.putInt(KEY_WEEK_START, mWeekStart); + outState.putInt(KEY_CURRENT_VIEW, mCurrentView); + int listPosition = -1; + if (mCurrentView == MONTH_AND_DAY_VIEW) { + listPosition = mDayPickerView.getMostVisiblePosition(); + } else if (mCurrentView == YEAR_VIEW) { + listPosition = mYearPickerView.getFirstVisiblePosition(); + outState.putInt(KEY_LIST_POSITION_OFFSET, mYearPickerView.getFirstPositionOffset()); + } + outState.putInt(KEY_LIST_POSITION, listPosition); + outState.putSerializable(KEY_HIGHLIGHTED_DAYS, highlightedDays); + outState.putBoolean(KEY_THEME_DARK, mThemeDark); + outState.putBoolean(KEY_THEME_DARK_CHANGED, mThemeDarkChanged); + if (mAccentColor != null) outState.putInt(KEY_ACCENT, mAccentColor); + outState.putBoolean(KEY_VIBRATE, mVibrate); + outState.putBoolean(KEY_DISMISS, mDismissOnPause); + outState.putBoolean(KEY_AUTO_DISMISS, mAutoDismiss); + outState.putInt(KEY_DEFAULT_VIEW, mDefaultView); + outState.putString(KEY_TITLE, mTitle); + outState.putInt(KEY_OK_RESID, mOkResid); + outState.putString(KEY_OK_STRING, mOkString); + if (mOkColor != null) outState.putInt(KEY_OK_COLOR, mOkColor); + outState.putInt(KEY_CANCEL_RESID, mCancelResid); + outState.putString(KEY_CANCEL_STRING, mCancelString); + if (mCancelColor != null) outState.putInt(KEY_CANCEL_COLOR, mCancelColor); + outState.putSerializable(KEY_VERSION, mVersion); + outState.putSerializable(KEY_SCROLL_ORIENTATION, mScrollOrientation); + outState.putSerializable(KEY_TIMEZONE, mTimezone); + outState.putParcelable(KEY_DATERANGELIMITER, mDateRangeLimiter); + outState.putSerializable(KEY_LOCALE, mLocale); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + int listPosition = -1; + int listPositionOffset = 0; + int currentView = mDefaultView; + if (mScrollOrientation == null) { + mScrollOrientation = mVersion == Version.VERSION_1 + ? ScrollOrientation.VERTICAL + : ScrollOrientation.HORIZONTAL; + } + if (savedInstanceState != null) { + mWeekStart = savedInstanceState.getInt(KEY_WEEK_START); + currentView = savedInstanceState.getInt(KEY_CURRENT_VIEW); + listPosition = savedInstanceState.getInt(KEY_LIST_POSITION); + listPositionOffset = savedInstanceState.getInt(KEY_LIST_POSITION_OFFSET); + //noinspection unchecked + highlightedDays = (HashSet) savedInstanceState.getSerializable(KEY_HIGHLIGHTED_DAYS); + mThemeDark = savedInstanceState.getBoolean(KEY_THEME_DARK); + mThemeDarkChanged = savedInstanceState.getBoolean(KEY_THEME_DARK_CHANGED); + if (savedInstanceState.containsKey(KEY_ACCENT)) mAccentColor = savedInstanceState.getInt(KEY_ACCENT); + mVibrate = savedInstanceState.getBoolean(KEY_VIBRATE); + mDismissOnPause = savedInstanceState.getBoolean(KEY_DISMISS); + mAutoDismiss = savedInstanceState.getBoolean(KEY_AUTO_DISMISS); + mTitle = savedInstanceState.getString(KEY_TITLE); + mOkResid = savedInstanceState.getInt(KEY_OK_RESID); + mOkString = savedInstanceState.getString(KEY_OK_STRING); + if (savedInstanceState.containsKey(KEY_OK_COLOR)) mOkColor = savedInstanceState.getInt(KEY_OK_COLOR); + mCancelResid = savedInstanceState.getInt(KEY_CANCEL_RESID); + mCancelString = savedInstanceState.getString(KEY_CANCEL_STRING); + if (savedInstanceState.containsKey(KEY_CANCEL_COLOR)) mCancelColor = savedInstanceState.getInt(KEY_CANCEL_COLOR); + mVersion = (Version) savedInstanceState.getSerializable(KEY_VERSION); + mScrollOrientation = (ScrollOrientation) savedInstanceState.getSerializable(KEY_SCROLL_ORIENTATION); + mTimezone = (TimeZone) savedInstanceState.getSerializable(KEY_TIMEZONE); + mDateRangeLimiter = savedInstanceState.getParcelable(KEY_DATERANGELIMITER); + + /* + We need to update some variables when setting the locale, so use the setter rather + than a plain assignment + */ + setLocale((Locale) savedInstanceState.getSerializable(KEY_LOCALE)); + + /* + If the user supplied a custom limiter, we need to create a new default one to prevent + null pointer exceptions on the configuration methods + If the user did not supply a custom limiter we need to ensure both mDefaultLimiter + and mDateRangeLimiter are the same reference, so that the config methods actually + affect the behaviour of the picker (in the unlikely event the user reconfigures + the picker when it is shown) + */ + if (mDateRangeLimiter instanceof DefaultDateRangeLimiter) { + mDefaultLimiter = (DefaultDateRangeLimiter) mDateRangeLimiter; + } else { + mDefaultLimiter = new DefaultDateRangeLimiter(); + } + } + + mDefaultLimiter.setController(this); + + int viewRes = mVersion == Version.VERSION_1 ? R.layout.mdtp_date_picker_dialog : R.layout.mdtp_date_picker_dialog_v2; + View view = inflater.inflate(viewRes, container, false); + // All options have been set at this point: round the initial selection if necessary + mCalendar = mDateRangeLimiter.setToNearestDate(mCalendar); + + mDatePickerHeaderView = view.findViewById(R.id.mdtp_date_picker_header); + mMonthAndDayView = view.findViewById(R.id.mdtp_date_picker_month_and_day); + mMonthAndDayView.setOnClickListener(this); + mSelectedMonthTextView = view.findViewById(R.id.mdtp_date_picker_month); + mSelectedDayTextView = view.findViewById(R.id.mdtp_date_picker_day); + mYearView = view.findViewById(R.id.mdtp_date_picker_year); + mYearView.setOnClickListener(this); + + final Activity activity = requireActivity(); + mDayPickerView = new DayPickerGroup(activity, this); + mYearPickerView = new YearPickerView(activity, this); + + // if theme mode has not been set by java code, check if it is specified in Style.xml + if (!mThemeDarkChanged) { + mThemeDark = Utils.isDarkTheme(activity, mThemeDark); + } + + Resources res = getResources(); + mDayPickerDescription = res.getString(R.string.mdtp_day_picker_description); + mSelectDay = res.getString(R.string.mdtp_select_day); + mYearPickerDescription = res.getString(R.string.mdtp_year_picker_description); + mSelectYear = res.getString(R.string.mdtp_select_year); + + int bgColorResource = mThemeDark ? R.color.mdtp_date_picker_view_animator_dark_theme : R.color.mdtp_date_picker_view_animator; + int bgColor = ContextCompat.getColor(activity, bgColorResource); + view.setBackgroundColor(bgColor); + + mAnimator = view.findViewById(R.id.mdtp_animator); + mAnimator.addView(mDayPickerView); + mAnimator.addView(mYearPickerView); + mAnimator.setDateMillis(mCalendar.getTimeInMillis()); + // TODO: Replace with animation decided upon by the design team. + Animation animation = new AlphaAnimation(0.0f, 1.0f); + animation.setDuration(ANIMATION_DURATION); + mAnimator.setInAnimation(animation); + // TODO: Replace with animation decided upon by the design team. + Animation animation2 = new AlphaAnimation(1.0f, 0.0f); + animation2.setDuration(ANIMATION_DURATION); + mAnimator.setOutAnimation(animation2); + + Button okButton = view.findViewById(R.id.mdtp_ok); + okButton.setOnClickListener(v -> { + tryVibrate(); + notifyOnDateListener(); + dismiss(); + }); + okButton.setTypeface(ResourcesCompat.getFont(activity, R.font.robotomedium)); + if (mOkString != null) okButton.setText(mOkString); + else okButton.setText(mOkResid); + + Button cancelButton = view.findViewById(R.id.mdtp_cancel); + cancelButton.setOnClickListener(v -> { + tryVibrate(); + if (getDialog() != null) getDialog().cancel(); + }); + cancelButton.setTypeface(ResourcesCompat.getFont(activity, R.font.robotomedium)); + if (mCancelString != null) cancelButton.setText(mCancelString); + else cancelButton.setText(mCancelResid); + cancelButton.setVisibility(isCancelable() ? View.VISIBLE : View.GONE); + + // If an accent color has not been set manually, get it from the context + if (mAccentColor == null) { + mAccentColor = Utils.getAccentColorFromThemeIfAvailable(getActivity()); + } + if (mDatePickerHeaderView != null) mDatePickerHeaderView.setBackgroundColor(Utils.darkenColor(mAccentColor)); + view.findViewById(R.id.mdtp_day_picker_selected_date_layout).setBackgroundColor(mAccentColor); + + // Buttons can have a different color + if (mOkColor == null) { + mOkColor = mAccentColor; + } + okButton.setTextColor(mOkColor); + + if (mCancelColor == null) { + mCancelColor = mAccentColor; + } + cancelButton.setTextColor(mCancelColor); + + if (getDialog() == null) { + view.findViewById(R.id.mdtp_done_background).setVisibility(View.GONE); + } + + updateDisplay(false); + setCurrentView(currentView); + + if (listPosition != -1) { + if (currentView == MONTH_AND_DAY_VIEW) { + mDayPickerView.postSetSelection(listPosition); + } else if (currentView == YEAR_VIEW) { + mYearPickerView.postSetSelectionFromTop(listPosition, listPositionOffset); + } + } + + mHapticFeedbackController = new HapticFeedbackController(activity); + return view; + } + + @Override + public void onConfigurationChanged(final Configuration newConfig) { + super.onConfigurationChanged(newConfig); + ViewGroup viewGroup = (ViewGroup) getView(); + if (viewGroup != null) { + viewGroup.removeAllViewsInLayout(); + View view = onCreateView(requireActivity().getLayoutInflater(), viewGroup, null); + viewGroup.addView(view); + } + } + + @Override + public void onResume() { + super.onResume(); + mHapticFeedbackController.start(); + } + + @Override + public void onPause() { + super.onPause(); + mHapticFeedbackController.stop(); + if (mDismissOnPause) dismiss(); + } + + @Override + public void onCancel(DialogInterface dialog) { + super.onCancel(dialog); + if (mOnCancelListener != null) mOnCancelListener.onCancel(dialog); + } + + @Override + public void onDismiss(DialogInterface dialog) { + super.onDismiss(dialog); + if (mOnDismissListener != null) mOnDismissListener.onDismiss(dialog); + } + + private void setCurrentView(final int viewIndex) { + long millis = mCalendar.getTimeInMillis(); + + switch (viewIndex) { + case MONTH_AND_DAY_VIEW: + if (mVersion == Version.VERSION_1) { + ObjectAnimator pulseAnimator = Utils.getPulseAnimator(mMonthAndDayView, 0.9f, + 1.05f); + if (mDelayAnimation) { + pulseAnimator.setStartDelay(ANIMATION_DELAY); + mDelayAnimation = false; + } + if (mCurrentView != viewIndex) { + mMonthAndDayView.setSelected(true); + mYearView.setSelected(false); + mAnimator.setDisplayedChild(MONTH_AND_DAY_VIEW); + mCurrentView = viewIndex; + } + mDayPickerView.onDateChanged(); + pulseAnimator.start(); + } else { + if (mCurrentView != viewIndex) { + mMonthAndDayView.setSelected(true); + mYearView.setSelected(false); + mAnimator.setDisplayedChild(MONTH_AND_DAY_VIEW); + mCurrentView = viewIndex; + } + mDayPickerView.onDateChanged(); + } + + int flags = DateUtils.FORMAT_SHOW_DATE; + String dayString = DateUtils.formatDateTime(getActivity(), millis, flags); + mAnimator.setContentDescription(mDayPickerDescription + ": " + dayString); + Utils.tryAccessibilityAnnounce(mAnimator, mSelectDay); + break; + case YEAR_VIEW: + if (mVersion == Version.VERSION_1) { + ObjectAnimator pulseAnimator = Utils.getPulseAnimator(mYearView, 0.85f, 1.1f); + if (mDelayAnimation) { + pulseAnimator.setStartDelay(ANIMATION_DELAY); + mDelayAnimation = false; + } + mYearPickerView.onDateChanged(); + if (mCurrentView != viewIndex) { + mMonthAndDayView.setSelected(false); + mYearView.setSelected(true); + mAnimator.setDisplayedChild(YEAR_VIEW); + mCurrentView = viewIndex; + } + pulseAnimator.start(); + } else { + mYearPickerView.onDateChanged(); + if (mCurrentView != viewIndex) { + mMonthAndDayView.setSelected(false); + mYearView.setSelected(true); + mAnimator.setDisplayedChild(YEAR_VIEW); + mCurrentView = viewIndex; + } + } + + CharSequence yearString = YEAR_FORMAT.format(millis); + mAnimator.setContentDescription(mYearPickerDescription + ": " + yearString); + Utils.tryAccessibilityAnnounce(mAnimator, mSelectYear); + break; + } + } + + private void updateDisplay(boolean announce) { + mYearView.setText(YEAR_FORMAT.format(mCalendar.getTime())); + + if (mVersion == Version.VERSION_1) { + if (mDatePickerHeaderView != null) { + if (mTitle != null) + mDatePickerHeaderView.setText(mTitle); + else { + mDatePickerHeaderView.setText(mCalendar.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.LONG, + mLocale)); + } + } + mSelectedMonthTextView.setText(MONTH_FORMAT.format(mCalendar.getTime())); + mSelectedDayTextView.setText(DAY_FORMAT.format(mCalendar.getTime())); + } + + if (mVersion == Version.VERSION_2) { + mSelectedDayTextView.setText(VERSION_2_FORMAT.format(mCalendar.getTime())); + if (mTitle != null) + mDatePickerHeaderView.setText(mTitle.toUpperCase(mLocale)); + else + mDatePickerHeaderView.setVisibility(View.GONE); + } + + // Accessibility. + long millis = mCalendar.getTimeInMillis(); + mAnimator.setDateMillis(millis); + int flags = DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_NO_YEAR; + String monthAndDayText = DateUtils.formatDateTime(getActivity(), millis, flags); + mMonthAndDayView.setContentDescription(monthAndDayText); + + if (announce) { + flags = DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR; + String fullDateText = DateUtils.formatDateTime(getActivity(), millis, flags); + Utils.tryAccessibilityAnnounce(mAnimator, fullDateText); + } + } + + /** + * Set whether the device should vibrate when touching fields + * + * @param vibrate true if the device should vibrate when touching a field + */ + public void vibrate(boolean vibrate) { + mVibrate = vibrate; + } + + /** + * Set whether the picker should dismiss itself when being paused or whether it should try to survive an orientation change + * + * @param dismissOnPause true if the dialog should dismiss itself when it's pausing + */ + public void dismissOnPause(boolean dismissOnPause) { + mDismissOnPause = dismissOnPause; + } + + /** + * Set whether the picker should dismiss itself when a day is selected + * + * @param autoDismiss true if the dialog should dismiss itself when a day is selected + */ + @SuppressWarnings("unused") + public void autoDismiss(boolean autoDismiss) { + mAutoDismiss = autoDismiss; + } + + /** + * Set whether the dark theme should be used + * + * @param themeDark true if the dark theme should be used, false if the default theme should be used + */ + public void setThemeDark(boolean themeDark) { + mThemeDark = themeDark; + mThemeDarkChanged = true; + } + + /** + * Returns true when the dark theme should be used + * + * @return true if the dark theme should be used, false if the default theme should be used + */ + @Override + public boolean isThemeDark() { + return mThemeDark; + } + + /** + * Set the accent color of this dialog + * + * @param color the accent color you want + */ + @SuppressWarnings("unused") + public void setAccentColor(String color) { + mAccentColor = Color.parseColor(color); + } + + /** + * Set the accent color of this dialog + * + * @param color the accent color you want + */ + public void setAccentColor(@ColorInt int color) { + mAccentColor = Color.argb(255, Color.red(color), Color.green(color), Color.blue(color)); + } + + /** + * Set the text color of the OK button + * + * @param color the color you want + */ + @SuppressWarnings("unused") + public void setOkColor(String color) { + mOkColor = Color.parseColor(color); + } + + /** + * Set the text color of the OK button + * + * @param color the color you want + */ + @SuppressWarnings("unused") + public void setOkColor(@ColorInt int color) { + mOkColor = Color.argb(255, Color.red(color), Color.green(color), Color.blue(color)); + } + + /** + * Set the text color of the Cancel button + * + * @param color the color you want + */ + @SuppressWarnings("unused") + public void setCancelColor(String color) { + mCancelColor = Color.parseColor(color); + } + + /** + * Set the text color of the Cancel button + * + * @param color the color you want + */ + @SuppressWarnings("unused") + public void setCancelColor(@ColorInt int color) { + mCancelColor = Color.argb(255, Color.red(color), Color.green(color), Color.blue(color)); + } + + /** + * Get the accent color of this dialog + * + * @return accent color + */ + @Override + public int getAccentColor() { + return mAccentColor; + } + + /** + * Set whether the year picker of the month and day picker is shown first + * + * @param yearPicker boolean + */ + public void showYearPickerFirst(boolean yearPicker) { + mDefaultView = yearPicker ? YEAR_VIEW : MONTH_AND_DAY_VIEW; + } + + @SuppressWarnings("unused") + public void setFirstDayOfWeek(int startOfWeek) { + if (startOfWeek < Calendar.SUNDAY || startOfWeek > Calendar.SATURDAY) { + throw new IllegalArgumentException("Value must be between Calendar.SUNDAY and " + + "Calendar.SATURDAY"); + } + mWeekStart = startOfWeek; + if (mDayPickerView != null) { + mDayPickerView.onChange(); + } + } + + @SuppressWarnings("unused") + public void setYearRange(int startYear, int endYear) { + mDefaultLimiter.setYearRange(startYear, endYear); + + if (mDayPickerView != null) { + mDayPickerView.onChange(); + } + } + + /** + * Sets the minimal date supported by this DatePicker. Dates before (but not including) the + * specified date will be disallowed from being selected. + * + * @param calendar a Calendar object set to the year, month, day desired as the mindate. + */ + @SuppressWarnings("unused") + public void setMinDate(Calendar calendar) { + mDefaultLimiter.setMinDate(calendar); + + if (mDayPickerView != null) { + mDayPickerView.onChange(); + } + } + + /** + * @return The minimal date supported by this DatePicker. Null if it has not been set. + */ + @SuppressWarnings("unused") + public Calendar getMinDate() { + return mDefaultLimiter.getMinDate(); + } + + /** + * Sets the minimal date supported by this DatePicker. Dates after (but not including) the + * specified date will be disallowed from being selected. + * + * @param calendar a Calendar object set to the year, month, day desired as the maxdate. + */ + @SuppressWarnings("unused") + public void setMaxDate(Calendar calendar) { + mDefaultLimiter.setMaxDate(calendar); + + if (mDayPickerView != null) { + mDayPickerView.onChange(); + } + } + + /** + * @return The maximal date supported by this DatePicker. Null if it has not been set. + */ + @SuppressWarnings("unused") + public Calendar getMaxDate() { + return mDefaultLimiter.getMaxDate(); + } + + /** + * Sets an array of dates which should be highlighted when the picker is drawn + * + * @param highlightedDays an Array of Calendar objects containing the dates to be highlighted + */ + @SuppressWarnings("unused") + public void setHighlightedDays(Calendar[] highlightedDays) { + for (Calendar highlightedDay : highlightedDays) { + this.highlightedDays.add(Utils.trimToMidnight((Calendar) highlightedDay.clone())); + } + if (mDayPickerView != null) mDayPickerView.onChange(); + } + + /** + * @return The list of dates, as Calendar Objects, which should be highlighted. null is no dates should be highlighted + */ + @SuppressWarnings("unused") + public Calendar[] getHighlightedDays() { + if (highlightedDays.isEmpty()) return null; + Calendar[] output = highlightedDays.toArray(new Calendar[0]); + Arrays.sort(output); + return output; + } + + @Override + public boolean isHighlighted(int year, int month, int day) { + Calendar date = Calendar.getInstance(getTimeZone()); + date.set(Calendar.YEAR, year); + date.set(Calendar.MONTH, month); + date.set(Calendar.DAY_OF_MONTH, day); + Utils.trimToMidnight(date); + return highlightedDays.contains(date); + } + + /** + * Sets a list of days which are the only valid selections. + * Setting this value will take precedence over using setMinDate() and setMaxDate() + * + * @param selectableDays an Array of Calendar Objects containing the selectable dates + */ + @SuppressWarnings("unused") + public void setSelectableDays(Calendar[] selectableDays) { + mDefaultLimiter.setSelectableDays(selectableDays); + if (mDayPickerView != null) mDayPickerView.onChange(); + } + + /** + * @return an Array of Calendar objects containing the list with selectable items. null if no restriction is set + */ + @SuppressWarnings("unused") + public Calendar[] getSelectableDays() { + return mDefaultLimiter.getSelectableDays(); + } + + /** + * Sets a list of days that are not selectable in the picker + * Setting this value will take precedence over using setMinDate() and setMaxDate(), but stacks with setSelectableDays() + * + * @param disabledDays an Array of Calendar Objects containing the disabled dates + */ + @SuppressWarnings("unused") + public void setDisabledDays(Calendar[] disabledDays) { + mDefaultLimiter.setDisabledDays(disabledDays); + if (mDayPickerView != null) mDayPickerView.onChange(); + } + + /** + * @return an Array of Calendar objects containing the list of days that are not selectable. null if no restriction is set + */ + @SuppressWarnings("unused") + public Calendar[] getDisabledDays() { + return mDefaultLimiter.getDisabledDays(); + } + + /** + * Provide a DateRangeLimiter for full control over which dates are enabled and disabled in the picker + * @param dateRangeLimiter An implementation of the DateRangeLimiter interface + */ + @SuppressWarnings("unused") + public void setDateRangeLimiter(DateRangeLimiter dateRangeLimiter) { + mDateRangeLimiter = dateRangeLimiter; + } + + /** + * Set a title to be displayed instead of the weekday + * + * @param title String - The title to be displayed + */ + public void setTitle(String title) { + mTitle = title; + } + + /** + * Set the label for the Ok button (max 12 characters) + * + * @param okString A literal String to be used as the Ok button label + */ + @SuppressWarnings("unused") + public void setOkText(String okString) { + mOkString = okString; + } + + /** + * Set the label for the Ok button (max 12 characters) + * + * @param okResid A resource ID to be used as the Ok button label + */ + @SuppressWarnings("unused") + public void setOkText(@StringRes int okResid) { + mOkString = null; + mOkResid = okResid; + } + + /** + * Set the label for the Cancel button (max 12 characters) + * + * @param cancelString A literal String to be used as the Cancel button label + */ + @SuppressWarnings("unused") + public void setCancelText(String cancelString) { + mCancelString = cancelString; + } + + /** + * Set the label for the Cancel button (max 12 characters) + * + * @param cancelResid A resource ID to be used as the Cancel button label + */ + @SuppressWarnings("unused") + public void setCancelText(@StringRes int cancelResid) { + mCancelString = null; + mCancelResid = cancelResid; + } + + /** + * Set which layout version the picker should use + * + * @param version The version to use + */ + public void setVersion(Version version) { + mVersion = version; + } + + /** + * Get the layout version the Dialog is using + * + * @return Version + */ + public Version getVersion() { + return mVersion; + } + + /** + * Set which way the user needs to swipe to switch months in the MonthView + * @param orientation The orientation to use + */ + public void setScrollOrientation(ScrollOrientation orientation) { + mScrollOrientation = orientation; + } + + /** + * Get which way the user needs to swipe to switch months in the MonthView + * @return SwipeOrientation + */ + public ScrollOrientation getScrollOrientation() { + return mScrollOrientation; + } + + /** + * Set which timezone the picker should use + * + * This has been deprecated in favor of setting the TimeZone using the constructor that + * takes a Calendar object + * @param timeZone The timezone to use + */ + @SuppressWarnings("DeprecatedIsStillUsed") + @Deprecated + public void setTimeZone(TimeZone timeZone) { + mTimezone = timeZone; + mCalendar.setTimeZone(timeZone); + YEAR_FORMAT.setTimeZone(timeZone); + MONTH_FORMAT.setTimeZone(timeZone); + DAY_FORMAT.setTimeZone(timeZone); + } + + /** + * Set a custom locale to be used when generating various strings in the picker + * @param locale Locale + */ + @SuppressWarnings("WeakerAccess") + public void setLocale(Locale locale) { + mLocale = locale; + mWeekStart = Calendar.getInstance(mTimezone, mLocale).getFirstDayOfWeek(); + YEAR_FORMAT = new SimpleDateFormat("yyyy", locale); + MONTH_FORMAT = new SimpleDateFormat("MMM", locale); + DAY_FORMAT = new SimpleDateFormat("dd", locale); + } + + /** + * Return the current locale (default or other) + * @return Locale + */ + @Override + public Locale getLocale() { + return mLocale; + } + + @SuppressWarnings("unused") + public void setOnDateSetListener(OnDateSetListener listener) { + mCallBack = listener; + } + + @SuppressWarnings("unused") + public void setOnCancelListener(DialogInterface.OnCancelListener onCancelListener) { + mOnCancelListener = onCancelListener; + } + + @SuppressWarnings("unused") + public void setOnDismissListener(DialogInterface.OnDismissListener onDismissListener) { + mOnDismissListener = onDismissListener; + } + + /** + * Get a reference to the callback + * @return OnDateSetListener the callback + */ + @SuppressWarnings("unused") + public OnDateSetListener getOnDateSetListener() { + return mCallBack; + } + + // If the newly selected month / year does not contain the currently selected day number, + // change the selected day number to the last day of the selected month or year. + // e.g. Switching from Mar to Apr when Mar 31 is selected -> Apr 30 + // e.g. Switching from 2012 to 2013 when Feb 29, 2012 is selected -> Feb 28, 2013 + private Calendar adjustDayInMonthIfNeeded(Calendar calendar) { + int day = calendar.get(Calendar.DAY_OF_MONTH); + int daysInMonth = calendar.getActualMaximum(Calendar.DAY_OF_MONTH); + if (day > daysInMonth) { + calendar.set(Calendar.DAY_OF_MONTH, daysInMonth); + } + return mDateRangeLimiter.setToNearestDate(calendar); + } + + @Override + public void onClick(View v) { + tryVibrate(); + if (v.getId() == R.id.mdtp_date_picker_year) { + setCurrentView(YEAR_VIEW); + } else if (v.getId() == R.id.mdtp_date_picker_month_and_day) { + setCurrentView(MONTH_AND_DAY_VIEW); + } + } + + @Override + public void onYearSelected(int year) { + mCalendar.set(Calendar.YEAR, year); + mCalendar = adjustDayInMonthIfNeeded(mCalendar); + updatePickers(); + setCurrentView(MONTH_AND_DAY_VIEW); + updateDisplay(true); + } + + @Override + public void onDayOfMonthSelected(int year, int month, int day) { + mCalendar.set(Calendar.YEAR, year); + mCalendar.set(Calendar.MONTH, month); + mCalendar.set(Calendar.DAY_OF_MONTH, day); + updatePickers(); + updateDisplay(true); + if (mAutoDismiss) { + notifyOnDateListener(); + dismiss(); + } + } + + private void updatePickers() { + for (OnDateChangedListener listener : mListeners) listener.onDateChanged(); + } + + + @Override + public MonthAdapter.CalendarDay getSelectedDay() { + return new MonthAdapter.CalendarDay(mCalendar, getTimeZone()); + } + + @Override + public Calendar getStartDate() { + return mDateRangeLimiter.getStartDate(); + } + + @Override + public Calendar getEndDate() { + return mDateRangeLimiter.getEndDate(); + } + + @Override + public int getMinYear() { + return mDateRangeLimiter.getMinYear(); + } + + @Override + public int getMaxYear() { + return mDateRangeLimiter.getMaxYear(); + } + + + @Override + public boolean isOutOfRange(int year, int month, int day) { + return mDateRangeLimiter.isOutOfRange(year, month, day); + } + + @Override + public int getFirstDayOfWeek() { + return mWeekStart; + } + + @Override + public void registerOnDateChangedListener(OnDateChangedListener listener) { + mListeners.add(listener); + } + + @Override + public void unregisterOnDateChangedListener(OnDateChangedListener listener) { + mListeners.remove(listener); + } + + @Override + public void tryVibrate() { + if (mVibrate) mHapticFeedbackController.tryVibrate(); + } + + @Override public TimeZone getTimeZone() { + return mTimezone == null ? TimeZone.getDefault() : mTimezone; + } + + public void notifyOnDateListener() { + if (mCallBack != null) { + mCallBack.onDateSet(DatePickerDialog.this, mCalendar.get(Calendar.YEAR), + mCalendar.get(Calendar.MONTH), mCalendar.get(Calendar.DAY_OF_MONTH)); + } + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DateRangeLimiter.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DateRangeLimiter.java new file mode 100644 index 00000000..53763e8a --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DateRangeLimiter.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2017 Wouter Dullaert + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker.date; + +import android.os.Parcelable; +import androidx.annotation.NonNull; + +import java.util.Calendar; + +@SuppressWarnings("WeakerAccess") +public interface DateRangeLimiter extends Parcelable { + /** + * getMinYear returns the minimum selectable year of the picker. + * This method should match getStartDate() + * It is recommended to keep the default implementation + * This method will be removed from this interface at the next semver major + * @return the minimum selectable year of the picker + */ + default int getMinYear() { + return getStartDate().get(Calendar.YEAR); + } + + /** + * getMaxYear returns the maximum selectable year of the picker + * This method should semantically match getEndDate() + * It is recommended to keep the default implementation. + * This method will be removed from this interface at the next semver major + * @return the maximum selectable year of the picker + */ + default int getMaxYear() { + return getEndDate().get(Calendar.YEAR); + } + + /** + * getStartDate returns the minimum selectable date of the picker + * It is called in various places, including the hot loop when rendering. + * It is highly recommended to keep this method as simple as possible + * @return the minimum selectable date of the picker + */ + @NonNull Calendar getStartDate(); + + /** + * getEndDate returns the maximum selectable date of the picker + * It is called in various places, including the hot loop when rendering. + * It is highly recommended to keep this method as simple as possible + * @return the maximum selectable date of the picker + */ + @NonNull Calendar getEndDate(); + + /** + * isOutOfRange is called for each date when it is about to be rendered + * Returning true from this function will cause that particular day to be non selectable + * Since this code is called in the inner loop when rendering, it is highly recommended to + * keep the logic as simple as possible + * @param year the year of the date + * @param month the month of the date + * @param day the day of the month of the date + * @return true if the date should be disabled, false otherwise + */ + boolean isOutOfRange(int year, int month, int day); + + /** + * setToNearestDate rounds a Date to the nearest selectable value. + * It is called each time the user makes a year selection: the newly resulting date might not be + * valid according to the constraints set by the limiter. + * This method is not called when the user selects a day, since the picker prevents the + * selection of values which satisfy `isOutOfRange` + * @param day a date with the current user selection + * @return the date after rounding to a selectable value + */ + @NonNull Calendar setToNearestDate(@NonNull Calendar day); +} \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DayOfWeek.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DayOfWeek.java new file mode 100644 index 00000000..a902b959 --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DayOfWeek.java @@ -0,0 +1,4 @@ +package com.wdullaer.materialdatetimepicker.date; + +public enum DayOfWeek { +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DayPickerGroup.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DayPickerGroup.java new file mode 100644 index 00000000..ab7e7026 --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DayPickerGroup.java @@ -0,0 +1,188 @@ +package com.wdullaer.materialdatetimepicker.date; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.core.view.ViewCompat; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; + +import com.wdullaer.materialdatetimepicker.R; +import com.wdullaer.materialdatetimepicker.Utils; + +public class DayPickerGroup extends ViewGroup + implements View.OnClickListener, DayPickerView.OnPageListener { + private ImageButton prevButton; + private ImageButton nextButton; + private DayPickerView dayPickerView; + private DatePickerController controller; + + public DayPickerGroup(Context context) { + super(context); + init(); + } + + public DayPickerGroup(Context context, @NonNull DatePickerController controller) { + super(context); + this.controller = controller; + init(); + } + + public DayPickerGroup(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + public DayPickerGroup(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + dayPickerView = new SimpleDayPickerView(getContext(), controller); + addView(dayPickerView); + + final LayoutInflater inflater = LayoutInflater.from(getContext()); + final ViewGroup content = (ViewGroup) inflater.inflate(R.layout.mdtp_daypicker_group, this, false); + + // Transfer all children from the content to this + while (content.getChildCount() > 0) { + final View view = content.getChildAt(0); + content.removeViewAt(0); + addView(view); + } + + prevButton = findViewById(R.id.mdtp_previous_month_arrow); + nextButton = findViewById(R.id.mdtp_next_month_arrow); + + if (controller.getVersion() == DatePickerDialog.Version.VERSION_1) { + int size = Utils.dpToPx(16f, getResources()); + prevButton.setMinimumHeight(size); + prevButton.setMinimumWidth(size); + nextButton.setMinimumHeight(size); + nextButton.setMinimumWidth(size); + } + + if (controller.isThemeDark()) { + int color = ContextCompat.getColor(getContext(), R.color.mdtp_date_picker_text_normal_dark_theme); + prevButton.setColorFilter(color); + nextButton.setColorFilter(color); + } + + prevButton.setOnClickListener(this); + nextButton.setOnClickListener(this); + + dayPickerView.setOnPageListener(this); + } + + private void updateButtonVisibility(int position) { + final boolean isHorizontal = controller.getScrollOrientation() == DatePickerDialog.ScrollOrientation.HORIZONTAL; + final boolean hasPrev = position > 0; + final boolean hasNext = position < (dayPickerView.getCount() - 1); + prevButton.setVisibility(isHorizontal && hasPrev ? View.VISIBLE : View.INVISIBLE); + nextButton.setVisibility(isHorizontal && hasNext ? View.VISIBLE : View.INVISIBLE); + } + + public void onChange() { + dayPickerView.onChange(); + } + + public void onDateChanged() { + dayPickerView.onDateChanged(); + } + + public void postSetSelection(int position) { + dayPickerView.postSetSelection(position); + } + + public int getMostVisiblePosition() { + return dayPickerView.getMostVisiblePosition(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + measureChild(dayPickerView, widthMeasureSpec, heightMeasureSpec); + + final int measuredWidthAndState = dayPickerView.getMeasuredWidthAndState(); + final int measuredHeightAndState = dayPickerView.getMeasuredHeightAndState(); + setMeasuredDimension(measuredWidthAndState, measuredHeightAndState); + + final int pagerWidth = dayPickerView.getMeasuredWidth(); + final int pagerHeight = dayPickerView.getMeasuredHeight(); + final int buttonWidthSpec = MeasureSpec.makeMeasureSpec(pagerWidth, MeasureSpec.AT_MOST); + final int buttonHeightSpec = MeasureSpec.makeMeasureSpec(pagerHeight, MeasureSpec.AT_MOST); + prevButton.measure(buttonWidthSpec, buttonHeightSpec); + nextButton.measure(buttonWidthSpec, buttonHeightSpec); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + final ImageButton leftButton; + final ImageButton rightButton; + if (ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_RTL) { + leftButton = nextButton; + rightButton = prevButton; + } else { + leftButton = prevButton; + rightButton = nextButton; + } + + final int topMargin = controller.getVersion() == DatePickerDialog.Version.VERSION_1 + ? 0 + : getContext().getResources().getDimensionPixelSize(R.dimen.mdtp_date_picker_view_animator_padding_v2); + final int width = right - left; + final int height = bottom - top; + dayPickerView.layout(0, topMargin, width, height); + + final SimpleMonthView monthView = (SimpleMonthView) dayPickerView.getChildAt(0); + final int monthHeight = monthView.getMonthHeight(); + final int cellWidth = monthView.getCellWidth(); + final int edgePadding = monthView.getEdgePadding(); + + // Vertically center the previous/next buttons within the month + // header, horizontally center within the day cell. + final int leftDW = leftButton.getMeasuredWidth(); + final int leftDH = leftButton.getMeasuredHeight(); + final int leftIconTop = topMargin + monthView.getPaddingTop() + (monthHeight - leftDH) / 2; + final int leftIconLeft = edgePadding + (cellWidth - leftDW) / 2; + leftButton.layout(leftIconLeft, leftIconTop, leftIconLeft + leftDW, leftIconTop + leftDH); + + final int rightDW = rightButton.getMeasuredWidth(); + final int rightDH = rightButton.getMeasuredHeight(); + final int rightIconTop = topMargin + monthView.getPaddingTop() + (monthHeight - rightDH) / 2; + final int rightIconRight = width - edgePadding - (cellWidth - rightDW) / 2 - 2; + rightButton.layout(rightIconRight - rightDW, rightIconTop, + rightIconRight, rightIconTop + rightDH); + } + + @Override + public void onPageChanged(int position) { + updateButtonVisibility(position); + dayPickerView.accessibilityAnnouncePageChanged(); + } + + @Override + public void onClick(@NonNull View v) { + int offset; + if (nextButton == v) { + offset = 1; + } else if (prevButton == v) { + offset = -1; + } else { + return; + } + int position = dayPickerView.getMostVisiblePosition() + offset; + + // updateButtonVisibility only triggers when a scroll is completed. So a user might + // click the button when the animation is still ongoing potentially pushing the target + // position outside of the bounds of the dayPickerView + if (position >= 0 && position < dayPickerView.getCount()) { + dayPickerView.smoothScrollToPosition(position); + updateButtonVisibility(position); + } + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DayPickerView.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DayPickerView.java new file mode 100644 index 00000000..885b9297 --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DayPickerView.java @@ -0,0 +1,372 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker.date; + +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.util.Log; +import android.view.Gravity; +import android.view.View; +import android.view.accessibility.AccessibilityEvent; + +import com.wdullaer.materialdatetimepicker.GravitySnapHelper; +import com.wdullaer.materialdatetimepicker.Utils; +import com.wdullaer.materialdatetimepicker.date.DatePickerDialog.OnDateChangedListener; + +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Locale; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +/** + * This displays a list of months in a calendar format with selectable days. + */ +public abstract class DayPickerView extends RecyclerView implements OnDateChangedListener { + + private static final String TAG = "MonthFragment"; + + protected Context mContext; + + // highlighted time + protected MonthAdapter.CalendarDay mSelectedDay; + protected MonthAdapter mAdapter; + + protected MonthAdapter.CalendarDay mTempDay; + + // which month should be displayed/highlighted [0-11] + protected int mCurrentMonthDisplayed; + // used for tracking what state listview is in + protected int mPreviousScrollState = RecyclerView.SCROLL_STATE_IDLE; + + private OnPageListener pageListener; + private DatePickerController mController; + + public interface OnPageListener { + /** + * Called when the visible page of the DayPickerView has changed + * @param position the new position visible in the DayPickerView + */ + void onPageChanged(int position); + } + + public DayPickerView(Context context, AttributeSet attrs) { + super(context, attrs); + DatePickerDialog.ScrollOrientation scrollOrientation = Build.VERSION.SDK_INT < Build.VERSION_CODES.M + ? DatePickerDialog.ScrollOrientation.VERTICAL + : DatePickerDialog.ScrollOrientation.HORIZONTAL; + init(context, scrollOrientation); + } + + public DayPickerView(Context context, DatePickerController controller) { + super(context); + init(context, controller.getScrollOrientation()); + setController(controller); + } + + protected void setController(DatePickerController controller) { + mController = controller; + mController.registerOnDateChangedListener(this); + mSelectedDay = new MonthAdapter.CalendarDay(mController.getTimeZone()); + mTempDay = new MonthAdapter.CalendarDay(mController.getTimeZone()); + refreshAdapter(); + } + + public void init(Context context, DatePickerDialog.ScrollOrientation scrollOrientation) { + @RecyclerView.Orientation + int layoutOrientation = scrollOrientation == DatePickerDialog.ScrollOrientation.VERTICAL + ? RecyclerView.VERTICAL + : RecyclerView.HORIZONTAL; + LinearLayoutManager linearLayoutManager = new LinearLayoutManager(context, layoutOrientation, false); + setLayoutManager(linearLayoutManager); + setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); + setVerticalScrollBarEnabled(false); + setHorizontalScrollBarEnabled(false); + setClipChildren(false); + + mContext = context; + setUpRecyclerView(scrollOrientation); + } + + /** + * Sets all the required fields for the list view. Override this method to + * set a different list view behavior. + */ + protected void setUpRecyclerView(DatePickerDialog.ScrollOrientation scrollOrientation) { + setVerticalScrollBarEnabled(false); + setFadingEdgeLength(0); + int gravity = scrollOrientation == DatePickerDialog.ScrollOrientation.VERTICAL + ? Gravity.TOP + : Gravity.START; + GravitySnapHelper helper = new GravitySnapHelper(gravity, position -> { + // Leverage the fact that the SnapHelper figures out which position is shown and + // pass this on to our PageListener after the snap has happened + if (pageListener != null) pageListener.onPageChanged(position); + }); + helper.attachToRecyclerView(this); + } + + public void onChange() { + refreshAdapter(); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + super.onLayout(changed, l, t, r, b); + final MonthAdapter.CalendarDay focusedDay = findAccessibilityFocus(); + restoreAccessibilityFocus(focusedDay); + } + + /** + * Creates a new adapter if necessary and sets up its parameters. Override + * this method to provide a custom adapter. + */ + protected void refreshAdapter() { + if (mAdapter == null) { + mAdapter = createMonthAdapter(mController); + } else { + mAdapter.setSelectedDay(mSelectedDay); + if (pageListener != null) pageListener.onPageChanged(getMostVisiblePosition()); + } + // refresh the view with the new parameters + setAdapter(mAdapter); + } + + public abstract MonthAdapter createMonthAdapter(DatePickerController controller); + + public void setOnPageListener(@Nullable OnPageListener pageListener) { + this.pageListener = pageListener; + } + + @Nullable + @SuppressWarnings("unused") + public OnPageListener getOnPageListener() { + return pageListener; + } + + /** + * This moves to the specified time in the view. If the time is not already + * in range it will move the list so that the first of the month containing + * the time is at the top of the view. If the new time is already in view + * the list will not be scrolled unless forceScroll is true. This time may + * optionally be highlighted as selected as well. + * + * @param day The day to move to + * @param animate Whether to scroll to the given time or just redraw at the + * new location + * @param setSelected Whether to set the given time as selected + * @param forceScroll Whether to recenter even if the time is already + * visible + * @return Whether or not the view animated to the new location + */ + public boolean goTo(MonthAdapter.CalendarDay day, boolean animate, boolean setSelected, boolean forceScroll) { + + // Set the selected day + if (setSelected) { + mSelectedDay.set(day); + } + + mTempDay.set(day); + int minMonth = mController.getStartDate().get(Calendar.MONTH); + final int position = (day.year - mController.getMinYear()) + * MonthAdapter.MONTHS_IN_YEAR + day.month - minMonth; + + View child; + int i = 0; + int top = 0; + // Find a child that's completely in the view + do { + child = getChildAt(i++); + if (child == null) { + break; + } + top = child.getTop(); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "child at " + (i - 1) + " has top " + top); + } + } while (top < 0); + + // Compute the first and last position visible + int selectedPosition = child != null ? getChildAdapterPosition(child) : 0; + + if (setSelected) { + mAdapter.setSelectedDay(mSelectedDay); + } + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "GoTo position " + position); + } + // Check if the selected day is now outside of our visible range + // and if so scroll to the month that contains it + if (position != selectedPosition || forceScroll) { + setMonthDisplayed(mTempDay); + mPreviousScrollState = RecyclerView.SCROLL_STATE_DRAGGING; + if (animate) { + smoothScrollToPosition(position); + if (pageListener != null) pageListener.onPageChanged(position); + return true; + } else { + postSetSelection(position); + } + } else if (setSelected) { + setMonthDisplayed(mSelectedDay); + } + return false; + } + + public void postSetSelection(final int position) { + clearFocus(); + post(() -> { + ((LinearLayoutManager) getLayoutManager()).scrollToPositionWithOffset(position, 0); + + // Set initial accessibility focus to selected day + restoreAccessibilityFocus(mSelectedDay); + + if (pageListener != null) pageListener.onPageChanged(position); + }); + } + + /** + * Sets the month displayed at the top of this view based on time. Override + * to add custom events when the title is changed. + */ + protected void setMonthDisplayed(MonthAdapter.CalendarDay date) { + mCurrentMonthDisplayed = date.month; + } + + /** + * Gets the position of the view that is most prominently displayed within the list. + */ + public int getMostVisiblePosition() { + return getChildAdapterPosition(getMostVisibleMonth()); + } + + public @Nullable MonthView getMostVisibleMonth() { + boolean verticalScroll = mController.getScrollOrientation() == DatePickerDialog.ScrollOrientation.VERTICAL; + final int maxSize = verticalScroll ? getHeight() : getWidth(); + int maxDisplayedSize = 0; + int i = 0; + int size = 0; + MonthView mostVisibleMonth = null; + + while (size < maxSize) { + View child = getChildAt(i); + if (child == null) { + break; + } + size = verticalScroll ? child.getBottom() : child.getRight(); + int endPosition = verticalScroll ? child.getTop() : child.getLeft(); + int displayedSize = Math.min(size, maxSize) - Math.max(0, endPosition); + if (displayedSize > maxDisplayedSize) { + mostVisibleMonth = (MonthView) child; + maxDisplayedSize = displayedSize; + } + i++; + } + return mostVisibleMonth; + } + + public int getCount() { + return mAdapter.getItemCount(); + } + + /** + * This should only be called when the DayPickerView is visible, or when it has already been + * requested to be visible + */ + @Override + public void onDateChanged() { + goTo(mController.getSelectedDay(), false, true, true); + } + + /** + * Attempts to return the date that has accessibility focus. + * + * @return The date that has accessibility focus, or {@code null} if no date + * has focus. + */ + private MonthAdapter.CalendarDay findAccessibilityFocus() { + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + if (child instanceof MonthView) { + final MonthAdapter.CalendarDay focus = ((MonthView) child).getAccessibilityFocus(); + if (focus != null) { + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.JELLY_BEAN_MR1) { + // Clear focus to avoid ListView bug in Jelly Bean MR1. + ((MonthView) child).clearAccessibilityFocus(); + } + return focus; + } + } + } + + return null; + } + + /** + * Attempts to restore accessibility focus to a given date. No-op if + * {@code day} is {@code null}. + * + * @param day The date that should receive accessibility focus + * @return {@code true} if focus was restored + */ + private boolean restoreAccessibilityFocus(MonthAdapter.CalendarDay day) { + if (day == null) { + return false; + } + + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + if (child instanceof MonthView) { + if (((MonthView) child).restoreAccessibilityFocus(day)) { + return true; + } + } + } + + return false; + } + + @Override + public void onInitializeAccessibilityEvent(@NonNull AccessibilityEvent event) { + super.onInitializeAccessibilityEvent(event); + event.setItemCount(-1); + } + + void accessibilityAnnouncePageChanged() { + MonthView mv = getMostVisibleMonth(); + if (mv != null) { + String monthYear = getMonthAndYearString(mv.mMonth, mv.mYear, mController.getLocale()); + Utils.tryAccessibilityAnnounce(this, monthYear); + } else { + Log.w("DayPickerView", "Tried to announce before layout was initialized"); + } + } + + private static String getMonthAndYearString(int month, int year, Locale locale) { + Calendar calendar = Calendar.getInstance(); + calendar.set(Calendar.MONTH, month); + calendar.set(Calendar.YEAR, year); + return new SimpleDateFormat("MMMM yyyy", locale).format(calendar.getTime()); + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DefaultDateRangeLimiter.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DefaultDateRangeLimiter.java new file mode 100644 index 00000000..5f4ef466 --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/DefaultDateRangeLimiter.java @@ -0,0 +1,265 @@ +/* + * Copyright (C) 2017 Wouter Dullaert + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker.date; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.wdullaer.materialdatetimepicker.Utils; + +import java.util.Calendar; +import java.util.HashSet; +import java.util.TimeZone; +import java.util.TreeSet; + +class DefaultDateRangeLimiter implements DateRangeLimiter { + private static final int DEFAULT_START_YEAR = 1900; + private static final int DEFAULT_END_YEAR = 2100; + + private transient DatePickerController mController; + private int mMinYear = DEFAULT_START_YEAR; + private int mMaxYear = DEFAULT_END_YEAR; + private Calendar mMinDate; + private Calendar mMaxDate; + private TreeSet selectableDays = new TreeSet<>(); + private HashSet disabledDays = new HashSet<>(); + + DefaultDateRangeLimiter() {} + + @SuppressWarnings({"unchecked", "WeakerAccess"}) + public DefaultDateRangeLimiter(Parcel in) { + mMinYear = in.readInt(); + mMaxYear = in.readInt(); + mMinDate = (Calendar) in.readSerializable(); + mMaxDate = (Calendar) in.readSerializable(); + selectableDays = (TreeSet) in.readSerializable(); + disabledDays = (HashSet) in.readSerializable(); + } + + @Override + public void writeToParcel(Parcel out, int flags) { + out.writeInt(mMinYear); + out.writeInt(mMaxYear); + out.writeSerializable(mMinDate); + out.writeSerializable(mMaxDate); + out.writeSerializable(selectableDays); + out.writeSerializable(disabledDays); + } + + @Override + public int describeContents() { + return 0; + } + + @SuppressWarnings("WeakerAccess") + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + public DefaultDateRangeLimiter createFromParcel(Parcel in) { + return new DefaultDateRangeLimiter(in); + } + + public DefaultDateRangeLimiter[] newArray(int size) { + return new DefaultDateRangeLimiter[size]; + } + }; + + void setSelectableDays(@NonNull Calendar[] days) { + for (Calendar selectableDay : days) { + this.selectableDays.add(Utils.trimToMidnight((Calendar) selectableDay.clone())); + } + } + + void setDisabledDays(@NonNull Calendar[] days) { + for (Calendar disabledDay : days) { + this.disabledDays.add(Utils.trimToMidnight((Calendar) disabledDay.clone())); + } + } + + void setMinDate(@NonNull Calendar calendar) { + mMinDate = Utils.trimToMidnight((Calendar) calendar.clone()); + } + + void setMaxDate(@NonNull Calendar calendar) { + mMaxDate = Utils.trimToMidnight((Calendar) calendar.clone()); + } + + void setController(@NonNull DatePickerController controller) { + mController = controller; + } + + void setYearRange(int startYear, int endYear) { + if (endYear < startYear) { + throw new IllegalArgumentException("Year end must be larger than or equal to year start"); + } + + mMinYear = startYear; + mMaxYear = endYear; + } + + @Nullable Calendar getMinDate() { + return mMinDate; + } + + @Nullable Calendar getMaxDate() { + return mMaxDate; + } + + @Nullable Calendar[] getSelectableDays() { + return selectableDays.isEmpty() ? null : selectableDays.toArray(new Calendar[0]); + } + + @Nullable Calendar[] getDisabledDays() { + return disabledDays.isEmpty() ? null : disabledDays.toArray(new Calendar[0]); + } + + @Override + public int getMinYear() { + if (!selectableDays.isEmpty()) return selectableDays.first().get(Calendar.YEAR); + // Ensure no years can be selected outside of the given minimum date + return mMinDate != null && mMinDate.get(Calendar.YEAR) > mMinYear ? mMinDate.get(Calendar.YEAR) : mMinYear; + } + + @Override + public int getMaxYear() { + if (!selectableDays.isEmpty()) return selectableDays.last().get(Calendar.YEAR); + // Ensure no years can be selected outside of the given maximum date + return mMaxDate != null && mMaxDate.get(Calendar.YEAR) < mMaxYear ? mMaxDate.get(Calendar.YEAR) : mMaxYear; + } + + @Override + public @NonNull Calendar getStartDate() { + if (!selectableDays.isEmpty()) return (Calendar) selectableDays.first().clone(); + if (mMinDate != null) return (Calendar) mMinDate.clone(); + TimeZone timeZone = mController == null ? TimeZone.getDefault() : mController.getTimeZone(); + Calendar output = Calendar.getInstance(timeZone); + output.set(Calendar.YEAR, mMinYear); + output.set(Calendar.DAY_OF_MONTH, 1); + output.set(Calendar.MONTH, Calendar.JANUARY); + return output; + } + + @Override + public @NonNull Calendar getEndDate() { + if (!selectableDays.isEmpty()) return (Calendar) selectableDays.last().clone(); + if (mMaxDate != null) return (Calendar) mMaxDate.clone(); + TimeZone timeZone = mController == null ? TimeZone.getDefault() : mController.getTimeZone(); + Calendar output = Calendar.getInstance(timeZone); + output.set(Calendar.YEAR, mMaxYear); + output.set(Calendar.DAY_OF_MONTH, 31); + output.set(Calendar.MONTH, Calendar.DECEMBER); + return output; + } + + /** + * @return true if the specified year/month/day are within the selectable days or the range set by minDate and maxDate. + * If one or either have not been set, they are considered as Integer.MIN_VALUE and + * Integer.MAX_VALUE. + */ + @Override + public boolean isOutOfRange(int year, int month, int day) { + TimeZone timezone = mController == null ? TimeZone.getDefault() : mController.getTimeZone(); + Calendar date = Calendar.getInstance(timezone); + date.set(Calendar.YEAR, year); + date.set(Calendar.MONTH, month); + date.set(Calendar.DAY_OF_MONTH, day); + return isOutOfRange(date); + } + + private boolean isOutOfRange(@NonNull Calendar calendar) { + Utils.trimToMidnight(calendar); + return isDisabled(calendar) || !isSelectable(calendar); + } + + private boolean isDisabled(@NonNull Calendar c) { + return disabledDays.contains(Utils.trimToMidnight(c)) || isBeforeMin(c) || isAfterMax(c); + } + + private boolean isSelectable(@NonNull Calendar c) { + return selectableDays.isEmpty() || selectableDays.contains(Utils.trimToMidnight(c)); + } + + private boolean isBeforeMin(@NonNull Calendar calendar) { + return mMinDate != null && calendar.before(mMinDate) || calendar.get(Calendar.YEAR) < mMinYear; + } + + private boolean isAfterMax(@NonNull Calendar calendar) { + return mMaxDate != null && calendar.after(mMaxDate) || calendar.get(Calendar.YEAR) > mMaxYear; + } + + @Override + public @NonNull Calendar setToNearestDate(@NonNull Calendar calendar) { + if (!selectableDays.isEmpty()) { + Calendar newCalendar = null; + Calendar higher = selectableDays.ceiling(calendar); + Calendar lower = selectableDays.lower(calendar); + + if (higher == null && lower != null) newCalendar = lower; + else if (lower == null && higher != null) newCalendar = higher; + + if (newCalendar != null || higher == null) { + newCalendar = newCalendar == null ? calendar : newCalendar; + TimeZone timeZone = mController == null ? TimeZone.getDefault() : mController.getTimeZone(); + newCalendar.setTimeZone(timeZone); + return (Calendar) newCalendar.clone(); + } + + long highDistance = Math.abs(higher.getTimeInMillis() - calendar.getTimeInMillis()); + long lowDistance = Math.abs(calendar.getTimeInMillis() - lower.getTimeInMillis()); + + if (lowDistance < highDistance) return (Calendar) lower.clone(); + else return (Calendar) higher.clone(); + } + + if (!disabledDays.isEmpty()) { + Calendar forwardDate = isBeforeMin(calendar) ? getStartDate() : (Calendar) calendar.clone(); + Calendar backwardDate = isAfterMax(calendar) ? getEndDate() : (Calendar) calendar.clone(); + while (isDisabled(forwardDate) && isDisabled(backwardDate)) { + forwardDate.add(Calendar.DAY_OF_MONTH, 1); + backwardDate.add(Calendar.DAY_OF_MONTH, -1); + } + if (!isDisabled(backwardDate)) { + return backwardDate; + } + if (!isDisabled(forwardDate)) { + return forwardDate; + } + } + + TimeZone timezone = mController == null ? TimeZone.getDefault() : mController.getTimeZone(); + if (isBeforeMin(calendar)) { + if (mMinDate != null) return (Calendar) mMinDate.clone(); + Calendar output = Calendar.getInstance(timezone); + output.set(Calendar.YEAR, mMinYear); + output.set(Calendar.MONTH, Calendar.JANUARY); + output.set(Calendar.DAY_OF_MONTH, 1); + return Utils.trimToMidnight(output); + } + + if (isAfterMax(calendar)) { + if (mMaxDate != null) return (Calendar) mMaxDate.clone(); + Calendar output = Calendar.getInstance(timezone); + output.set(Calendar.YEAR, mMaxYear); + output.set(Calendar.MONTH, Calendar.DECEMBER); + output.set(Calendar.DAY_OF_MONTH, 31); + return Utils.trimToMidnight(output); + } + + return calendar; + } +} \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/MonthAdapter.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/MonthAdapter.java new file mode 100644 index 00000000..6d41fa75 --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/MonthAdapter.java @@ -0,0 +1,219 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker.date; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import android.view.ViewGroup; +import android.widget.AbsListView.LayoutParams; + +import com.wdullaer.materialdatetimepicker.date.MonthAdapter.MonthViewHolder; +import com.wdullaer.materialdatetimepicker.date.MonthView.OnDayClickListener; + +import java.util.Calendar; +import java.util.TimeZone; + +/** + * An adapter for a list of {@link MonthView} items. + */ +@SuppressWarnings("WeakerAccess") +public abstract class MonthAdapter extends RecyclerView.Adapter implements OnDayClickListener { + + protected final DatePickerController mController; + + private CalendarDay mSelectedDay; + + protected static final int MONTHS_IN_YEAR = 12; + + /** + * A convenience class to represent a specific date. + */ + public static class CalendarDay { + private Calendar calendar; + int year; + int month; + int day; + TimeZone mTimeZone; + + public CalendarDay(TimeZone timeZone) { + mTimeZone = timeZone; + setTime(System.currentTimeMillis()); + } + + public CalendarDay(long timeInMillis, TimeZone timeZone) { + mTimeZone = timeZone; + setTime(timeInMillis); + } + + public CalendarDay(Calendar calendar, TimeZone timeZone) { + mTimeZone = timeZone; + year = calendar.get(Calendar.YEAR); + month = calendar.get(Calendar.MONTH); + day = calendar.get(Calendar.DAY_OF_MONTH); + } + + @SuppressWarnings("unused") + public CalendarDay(int year, int month, int day) { + setDay(year, month, day); + } + + public CalendarDay(int year, int month, int day, TimeZone timezone) { + mTimeZone = timezone; + setDay(year, month, day); + } + + public void set(CalendarDay date) { + year = date.year; + month = date.month; + day = date.day; + } + + public void setDay(int year, int month, int day) { + this.year = year; + this.month = month; + this.day = day; + } + + private void setTime(long timeInMillis) { + if (calendar == null) { + calendar = Calendar.getInstance(mTimeZone); + } + calendar.setTimeInMillis(timeInMillis); + month = calendar.get(Calendar.MONTH); + year = calendar.get(Calendar.YEAR); + day = calendar.get(Calendar.DAY_OF_MONTH); + } + + public int getYear() { + return year; + } + + public int getMonth() { + return month; + } + + public int getDay() { + return day; + } + } + + public MonthAdapter(DatePickerController controller) { + mController = controller; + init(); + setSelectedDay(mController.getSelectedDay()); + setHasStableIds(true); + } + + /** + * Updates the selected day and related parameters. + * + * @param day The day to highlight + */ + public void setSelectedDay(CalendarDay day) { + mSelectedDay = day; + notifyDataSetChanged(); + } + + @SuppressWarnings("unused") + public CalendarDay getSelectedDay() { + return mSelectedDay; + } + + /** + * Set up the gesture detector and selected time + */ + protected void init() { + mSelectedDay = new CalendarDay(System.currentTimeMillis(), mController.getTimeZone()); + } + + @Override + @NonNull + public MonthViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + + MonthView v = createMonthView(parent.getContext()); + // Set up the new view + LayoutParams params = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + v.setLayoutParams(params); + v.setClickable(true); + v.setOnDayClickListener(this); + + return new MonthViewHolder(v); + } + + @Override public void onBindViewHolder(@NonNull MonthViewHolder holder, int position) { + holder.bind(position, mController, mSelectedDay); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override public int getItemCount() { + Calendar endDate = mController.getEndDate(); + Calendar startDate = mController.getStartDate(); + int endMonth = endDate.get(Calendar.YEAR) * MONTHS_IN_YEAR + endDate.get(Calendar.MONTH); + int startMonth = startDate.get(Calendar.YEAR) * MONTHS_IN_YEAR + startDate.get(Calendar.MONTH); + return endMonth - startMonth + 1; + } + + public abstract MonthView createMonthView(Context context); + + @Override + public void onDayClick(MonthView view, CalendarDay day) { + if (day != null) { + onDayTapped(day); + } + } + + /** + * Maintains the same hour/min/sec but moves the day to the tapped day. + * + * @param day The day that was tapped + */ + protected void onDayTapped(CalendarDay day) { + mController.tryVibrate(); + mController.onDayOfMonthSelected(day.year, day.month, day.day); + setSelectedDay(day); + } + + static class MonthViewHolder extends RecyclerView.ViewHolder { + + public MonthViewHolder(MonthView itemView) { + super(itemView); + + } + + void bind(int position, DatePickerController mController, CalendarDay selectedCalendarDay) { + final int month = (position + mController.getStartDate().get(Calendar.MONTH)) % MONTHS_IN_YEAR; + final int year = (position + mController.getStartDate().get(Calendar.MONTH)) / MONTHS_IN_YEAR + mController.getMinYear(); + + int selectedDay = -1; + if (isSelectedDayInMonth(selectedCalendarDay, year, month)) { + selectedDay = selectedCalendarDay.day; + } + + ((MonthView) itemView).setMonthParams(selectedDay, year, month, mController.getFirstDayOfWeek()); + this.itemView.invalidate(); + } + + private boolean isSelectedDayInMonth(CalendarDay selectedDay, int year, int month) { + return selectedDay.year == year && selectedDay.month == month; + } + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/MonthView.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/MonthView.java new file mode 100644 index 00000000..70325d9b --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/MonthView.java @@ -0,0 +1,769 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker.date; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Paint.Align; +import android.graphics.Paint.Style; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.os.Build; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.core.view.ViewCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import androidx.customview.widget.ExploreByTouchHelper; +import android.text.format.DateFormat; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; + +import com.wdullaer.materialdatetimepicker.R; +import com.wdullaer.materialdatetimepicker.date.MonthAdapter.CalendarDay; + +import java.security.InvalidParameterException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.List; +import java.util.Locale; + +/** + * A calendar-like view displaying a specified month and the appropriate selectable day numbers + * within the specified month. + */ +public abstract class MonthView extends View { + + protected static int DEFAULT_HEIGHT = 32; + protected static final int DEFAULT_SELECTED_DAY = -1; + protected static final int DEFAULT_WEEK_START = Calendar.SUNDAY; + protected static final int DEFAULT_NUM_DAYS = 7; + protected static final int DEFAULT_NUM_ROWS = 6; + protected static final int MAX_NUM_ROWS = 6; + + private static final int SELECTED_CIRCLE_ALPHA = 255; + + protected static int DAY_SEPARATOR_WIDTH = 1; + protected static int MINI_DAY_NUMBER_TEXT_SIZE; + protected static int MONTH_LABEL_TEXT_SIZE; + protected static int MONTH_DAY_LABEL_TEXT_SIZE; + protected static int MONTH_HEADER_SIZE; + protected static int MONTH_HEADER_SIZE_V2; + protected static int DAY_SELECTED_CIRCLE_SIZE; + protected static int DAY_HIGHLIGHT_CIRCLE_SIZE; + protected static int DAY_HIGHLIGHT_CIRCLE_MARGIN; + + protected DatePickerController mController; + + // affects the padding on the sides of this view + protected int mEdgePadding = 0; + + private String mDayOfWeekTypeface; + private String mMonthTitleTypeface; + + protected Paint mMonthNumPaint; + protected Paint mMonthTitlePaint; + protected Paint mSelectedCirclePaint; + protected Paint mMonthDayLabelPaint; + + private final StringBuilder mStringBuilder; + + protected int mMonth; + + protected int mYear; + // Quick reference to the width of this view, matches parent + protected int mWidth; + // The height this view should draw at in pixels, set by height param + protected int mRowHeight = DEFAULT_HEIGHT; + // If this view contains the today + protected boolean mHasToday = false; + // Which day is selected [0-6] or -1 if no day is selected + protected int mSelectedDay = -1; + // Which day is today [0-6] or -1 if no day is today + protected int mToday = DEFAULT_SELECTED_DAY; + // Which day of the week to start on [0-6] + protected int mWeekStart = DEFAULT_WEEK_START; + // How many days to display + protected int mNumDays = DEFAULT_NUM_DAYS; + // The number of days + a spot for week number if it is displayed + protected int mNumCells = mNumDays; + + private final Calendar mCalendar; + protected final Calendar mDayLabelCalendar; + private final MonthViewTouchHelper mTouchHelper; + + protected int mNumRows = DEFAULT_NUM_ROWS; + + // Optional listener for handling day click actions + protected OnDayClickListener mOnDayClickListener; + + // Whether to prevent setting the accessibility delegate + private boolean mLockAccessibilityDelegate; + + protected int mDayTextColor; + protected int mSelectedDayTextColor; + protected int mMonthDayTextColor; + protected int mTodayNumberColor; + protected int mHighlightedDayTextColor; + protected int mDisabledDayTextColor; + protected int mMonthTitleColor; + + private SimpleDateFormat weekDayLabelFormatter; + + public MonthView(Context context) { + this(context, null, null); + } + + public MonthView(Context context, AttributeSet attr, DatePickerController controller) { + super(context, attr); + mController = controller; + Resources res = context.getResources(); + + mDayLabelCalendar = Calendar.getInstance(mController.getTimeZone(), mController.getLocale()); + mCalendar = Calendar.getInstance(mController.getTimeZone(), mController.getLocale()); + + mDayOfWeekTypeface = res.getString(R.string.mdtp_day_of_week_label_typeface); + mMonthTitleTypeface = res.getString(R.string.mdtp_sans_serif); + + boolean darkTheme = mController != null && mController.isThemeDark(); + if (darkTheme) { + mDayTextColor = ContextCompat.getColor(context, R.color.mdtp_date_picker_text_normal_dark_theme); + mMonthDayTextColor = ContextCompat.getColor(context, R.color.mdtp_date_picker_month_day_dark_theme); + mDisabledDayTextColor = ContextCompat.getColor(context, R.color.mdtp_date_picker_text_disabled_dark_theme); + mHighlightedDayTextColor = ContextCompat.getColor(context, R.color.mdtp_date_picker_text_highlighted_dark_theme); + } else { + mDayTextColor = ContextCompat.getColor(context, R.color.mdtp_date_picker_text_normal); + mMonthDayTextColor = ContextCompat.getColor(context, R.color.mdtp_date_picker_month_day); + mDisabledDayTextColor = ContextCompat.getColor(context, R.color.mdtp_date_picker_text_disabled); + mHighlightedDayTextColor = ContextCompat.getColor(context, R.color.mdtp_date_picker_text_highlighted); + } + mSelectedDayTextColor = ContextCompat.getColor(context, R.color.mdtp_white); + mTodayNumberColor = mController.getAccentColor(); + mMonthTitleColor = ContextCompat.getColor(context, R.color.mdtp_white); + + mStringBuilder = new StringBuilder(50); + + MINI_DAY_NUMBER_TEXT_SIZE = res.getDimensionPixelSize(R.dimen.mdtp_day_number_size); + MONTH_LABEL_TEXT_SIZE = res.getDimensionPixelSize(R.dimen.mdtp_month_label_size); + MONTH_DAY_LABEL_TEXT_SIZE = res.getDimensionPixelSize(R.dimen.mdtp_month_day_label_text_size); + MONTH_HEADER_SIZE = res.getDimensionPixelOffset(R.dimen.mdtp_month_list_item_header_height); + MONTH_HEADER_SIZE_V2 = res.getDimensionPixelOffset(R.dimen.mdtp_month_list_item_header_height_v2); + DAY_SELECTED_CIRCLE_SIZE = mController.getVersion() == DatePickerDialog.Version.VERSION_1 + ? res.getDimensionPixelSize(R.dimen.mdtp_day_number_select_circle_radius) + : res.getDimensionPixelSize(R.dimen.mdtp_day_number_select_circle_radius_v2); + DAY_HIGHLIGHT_CIRCLE_SIZE = res + .getDimensionPixelSize(R.dimen.mdtp_day_highlight_circle_radius); + DAY_HIGHLIGHT_CIRCLE_MARGIN = res + .getDimensionPixelSize(R.dimen.mdtp_day_highlight_circle_margin); + + if (mController.getVersion() == DatePickerDialog.Version.VERSION_1) { + mRowHeight = (res.getDimensionPixelOffset(R.dimen.mdtp_date_picker_view_animator_height) + - getMonthHeaderSize()) / MAX_NUM_ROWS; + } else { + mRowHeight = (res.getDimensionPixelOffset(R.dimen.mdtp_date_picker_view_animator_height_v2) + - getMonthHeaderSize() - MONTH_DAY_LABEL_TEXT_SIZE * 2) / MAX_NUM_ROWS; + } + + mEdgePadding = mController.getVersion() == DatePickerDialog.Version.VERSION_1 + ? 0 + : context.getResources().getDimensionPixelSize(R.dimen.mdtp_date_picker_view_animator_padding_v2); + + // Set up accessibility components. + mTouchHelper = getMonthViewTouchHelper(); + ViewCompat.setAccessibilityDelegate(this, mTouchHelper); + ViewCompat.setImportantForAccessibility(this, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES); + mLockAccessibilityDelegate = true; + + // Sets up any standard paints that will be used + initView(); + } + + protected MonthViewTouchHelper getMonthViewTouchHelper() { + return new MonthViewTouchHelper(this); + } + + @Override + public void setAccessibilityDelegate(AccessibilityDelegate delegate) { + // Workaround for a JB MR1 issue where accessibility delegates on + // top-level ListView items are overwritten. + if (!mLockAccessibilityDelegate) { + super.setAccessibilityDelegate(delegate); + } + } + + public void setOnDayClickListener(OnDayClickListener listener) { + mOnDayClickListener = listener; + } + + @Override + public boolean dispatchHoverEvent(@NonNull MotionEvent event) { + // First right-of-refusal goes the touch exploration helper. + return mTouchHelper.dispatchHoverEvent(event) || super.dispatchHoverEvent(event); + } + + @Override + public boolean onTouchEvent(@NonNull MotionEvent event) { + switch (event.getAction()) { + case MotionEvent.ACTION_UP: + final int day = getDayFromLocation(event.getX(), event.getY()); + if (day >= 0) { + onDayClick(day); + } + break; + } + return true; + } + + /** + * Sets up the text and style properties for painting. Override this if you + * want to use a different paint. + */ + protected void initView() { + mMonthTitlePaint = new Paint(); + if (mController.getVersion() == DatePickerDialog.Version.VERSION_1) + mMonthTitlePaint.setFakeBoldText(true); + mMonthTitlePaint.setAntiAlias(true); + mMonthTitlePaint.setTextSize(MONTH_LABEL_TEXT_SIZE); + mMonthTitlePaint.setTypeface(Typeface.create(mMonthTitleTypeface, Typeface.BOLD)); + mMonthTitlePaint.setColor(mDayTextColor); + mMonthTitlePaint.setTextAlign(Align.CENTER); + mMonthTitlePaint.setStyle(Style.FILL); + + mSelectedCirclePaint = new Paint(); + mSelectedCirclePaint.setFakeBoldText(true); + mSelectedCirclePaint.setAntiAlias(true); + mSelectedCirclePaint.setColor(mTodayNumberColor); + mSelectedCirclePaint.setTextAlign(Align.CENTER); + mSelectedCirclePaint.setStyle(Style.FILL); + mSelectedCirclePaint.setAlpha(SELECTED_CIRCLE_ALPHA); + + mMonthDayLabelPaint = new Paint(); + mMonthDayLabelPaint.setAntiAlias(true); + mMonthDayLabelPaint.setTextSize(MONTH_DAY_LABEL_TEXT_SIZE); + mMonthDayLabelPaint.setColor(mMonthDayTextColor); + mMonthTitlePaint.setTypeface(Typeface.create(mDayOfWeekTypeface, Typeface.BOLD)); + mMonthDayLabelPaint.setStyle(Style.FILL); + mMonthDayLabelPaint.setTextAlign(Align.CENTER); + mMonthDayLabelPaint.setFakeBoldText(true); + + mMonthNumPaint = new Paint(); + mMonthNumPaint.setAntiAlias(true); + mMonthNumPaint.setTextSize(MINI_DAY_NUMBER_TEXT_SIZE); + mMonthNumPaint.setStyle(Style.FILL); + mMonthNumPaint.setTextAlign(Align.CENTER); + mMonthNumPaint.setFakeBoldText(false); + } + + @Override + protected void onDraw(Canvas canvas) { + drawMonthTitle(canvas); + drawMonthDayLabels(canvas); + drawMonthNums(canvas); + } + + private int mDayOfWeekStart = 0; + + /** + * Sets all the parameters for displaying this week. The only required + * parameter is the week number. Other parameters have a default value and + * will only update if a new value is included, except for focus month, + * which will always default to no focus month if no value is passed in. + */ + public void setMonthParams(int selectedDay, int year, int month, int weekStart) { + if (month == -1 && year == -1) { + throw new InvalidParameterException("You must specify month and year for this view"); + } + + mSelectedDay = selectedDay; + + // Allocate space for caching the day numbers and focus values + mMonth = month; + mYear = year; + + // Figure out what day today is + //final Time today = new Time(Time.getCurrentTimezone()); + //today.setToNow(); + final Calendar today = Calendar.getInstance(mController.getTimeZone(), mController.getLocale()); + mHasToday = false; + mToday = -1; + + mCalendar.set(Calendar.MONTH, mMonth); + mCalendar.set(Calendar.YEAR, mYear); + mCalendar.set(Calendar.DAY_OF_MONTH, 1); + mDayOfWeekStart = mCalendar.get(Calendar.DAY_OF_WEEK); + + if (weekStart != -1) { + mWeekStart = weekStart; + } else { + mWeekStart = mCalendar.getFirstDayOfWeek(); + } + + mNumCells = mCalendar.getActualMaximum(Calendar.DAY_OF_MONTH); + for (int i = 0; i < mNumCells; i++) { + final int day = i + 1; + if (sameDay(day, today)) { + mHasToday = true; + mToday = day; + } + } + mNumRows = calculateNumRows(); + + // Invalidate cached accessibility information. + mTouchHelper.invalidateRoot(); + } + + @SuppressWarnings("unused") + public void setSelectedDay(int day) { + mSelectedDay = day; + } + + private int calculateNumRows() { + int offset = findDayOffset(); + int dividend = (offset + mNumCells) / mNumDays; + int remainder = (offset + mNumCells) % mNumDays; + return (dividend + (remainder > 0 ? 1 : 0)); + } + + private boolean sameDay(int day, Calendar today) { + return mYear == today.get(Calendar.YEAR) && + mMonth == today.get(Calendar.MONTH) && + day == today.get(Calendar.DAY_OF_MONTH); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), mRowHeight * mNumRows + getMonthHeaderSize()); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + mWidth = w; + + // Invalidate cached accessibility information. + mTouchHelper.invalidateRoot(); + } + + public int getMonth() { + return mMonth; + } + + public int getYear() { + return mYear; + } + + /** + * @return The height in pixels of a row of day labels + */ + public int getMonthHeight() { + int scaleFactor = mController.getVersion() == DatePickerDialog.Version.VERSION_1 ? 2 : 3; + return getMonthHeaderSize() - MONTH_DAY_LABEL_TEXT_SIZE * scaleFactor; + } + + /** + * @return The width in pixels of a day label + */ + public int getCellWidth() { + return (mWidth - mEdgePadding * 2) / mNumDays; + } + + /** + * @return The left / right padding used when calculating day number positions + */ + public int getEdgePadding() { + return mEdgePadding; + } + + /** + * A wrapper to the MonthHeaderSize to allow override it in children + */ + protected int getMonthHeaderSize() { + return mController.getVersion() == DatePickerDialog.Version.VERSION_1 + ? MONTH_HEADER_SIZE + : MONTH_HEADER_SIZE_V2; + } + + @NonNull + private String getMonthAndYearString() { + Locale locale = mController.getLocale(); + String pattern = "MMMM yyyy"; + + if (Build.VERSION.SDK_INT < 18) pattern = getContext().getResources().getString(R.string.mdtp_date_v1_monthyear); + else pattern = DateFormat.getBestDateTimePattern(locale, pattern); + + SimpleDateFormat formatter = new SimpleDateFormat(pattern, locale); + formatter.setTimeZone(mController.getTimeZone()); + formatter.applyLocalizedPattern(pattern); + mStringBuilder.setLength(0); + return formatter.format(mCalendar.getTime()); + } + + protected void drawMonthTitle(Canvas canvas) { + int x = mWidth / 2; + int y = mController.getVersion() == DatePickerDialog.Version.VERSION_1 + ? (getMonthHeaderSize() - MONTH_DAY_LABEL_TEXT_SIZE) / 2 + : getMonthHeaderSize() / 2 - MONTH_DAY_LABEL_TEXT_SIZE; + canvas.drawText(getMonthAndYearString(), x, y, mMonthTitlePaint); + } + + protected void drawMonthDayLabels(Canvas canvas) { + int y = getMonthHeaderSize() - (MONTH_DAY_LABEL_TEXT_SIZE / 2); + int dayWidthHalf = (mWidth - mEdgePadding * 2) / (mNumDays * 2); + + for (int i = 0; i < mNumDays; i++) { + int x = (2 * i + 1) * dayWidthHalf + mEdgePadding; + + int calendarDay = (i + mWeekStart) % mNumDays; + mDayLabelCalendar.set(Calendar.DAY_OF_WEEK, calendarDay); + String weekString = getWeekDayLabel(mDayLabelCalendar); + canvas.drawText(weekString, x, y, mMonthDayLabelPaint); + } + } + + /** + * Draws the week and month day numbers for this week. Override this method + * if you need different placement. + * + * @param canvas The canvas to draw on + */ + protected void drawMonthNums(Canvas canvas) { + int y = (((mRowHeight + MINI_DAY_NUMBER_TEXT_SIZE) / 2) - DAY_SEPARATOR_WIDTH) + + getMonthHeaderSize(); + // TODO: look at the calculations used by the framework picker to properly align this with the buttons + final int dayWidthHalf = (mWidth - mEdgePadding * 2) / (mNumDays * 2); + int j = findDayOffset(); + for (int dayNumber = 1; dayNumber <= mNumCells; dayNumber++) { + final int x = (2 * j + 1) * dayWidthHalf + mEdgePadding; + + int yRelativeToDay = (mRowHeight + MINI_DAY_NUMBER_TEXT_SIZE) / 2 - DAY_SEPARATOR_WIDTH; + + final int startX = x - dayWidthHalf; + final int stopX = x + dayWidthHalf; + final int startY = y - yRelativeToDay; + final int stopY = startY + mRowHeight; + + drawMonthDay(canvas, mYear, mMonth, dayNumber, x, y, startX, stopX, startY, stopY); + + j++; + if (j == mNumDays) { + j = 0; + y += mRowHeight; + } + } + } + + /** + * This method should draw the month day. Implemented by sub-classes to allow customization. + * + * @param canvas The canvas to draw on + * @param year The year of this month day + * @param month The month of this month day + * @param day The day number of this month day + * @param x The default x position to draw the day number + * @param y The default y position to draw the day number + * @param startX The left boundary of the day number rect + * @param stopX The right boundary of the day number rect + * @param startY The top boundary of the day number rect + * @param stopY The bottom boundary of the day number rect + */ + public abstract void drawMonthDay(Canvas canvas, int year, int month, int day, + int x, int y, int startX, int stopX, int startY, int stopY); + + protected int findDayOffset() { + return (mDayOfWeekStart < mWeekStart ? (mDayOfWeekStart + mNumDays) : mDayOfWeekStart) + - mWeekStart; + } + + + /** + * Calculates the day that the given x position is in, accounting for week + * number. Returns the day or -1 if the position wasn't in a day. + * + * @param x The x position of the touch event + * @return The day number, or -1 if the position wasn't in a day + */ + public int getDayFromLocation(float x, float y) { + final int day = getInternalDayFromLocation(x, y); + if (day < 1 || day > mNumCells) { + return -1; + } + return day; + } + + /** + * Calculates the day that the given x position is in, accounting for week + * number. + * + * @param x The x position of the touch event + * @return The day number + */ + protected int getInternalDayFromLocation(float x, float y) { + int dayStart = mEdgePadding; + if (x < dayStart || x > mWidth - mEdgePadding) { + return -1; + } + // Selection is (x - start) / (pixels/day) == (x -s) * day / pixels + int row = (int) (y - getMonthHeaderSize()) / mRowHeight; + int column = (int) ((x - dayStart) * mNumDays / (mWidth - dayStart - mEdgePadding)); + + int day = column - findDayOffset() + 1; + day += row * mNumDays; + return day; + } + + /** + * Called when the user clicks on a day. Handles callbacks to the + * {@link OnDayClickListener} if one is set. + *

+ * If the day is out of the range set by minDate and/or maxDate, this is a no-op. + * + * @param day The day that was clicked + */ + private void onDayClick(int day) { + // If the min / max date are set, only process the click if it's a valid selection. + if (mController.isOutOfRange(mYear, mMonth, day)) { + return; + } + + + if (mOnDayClickListener != null) { + mOnDayClickListener.onDayClick(this, new CalendarDay(mYear, mMonth, day, mController.getTimeZone())); + } + + // This is a no-op if accessibility is turned off. + mTouchHelper.sendEventForVirtualView(day, AccessibilityEvent.TYPE_VIEW_CLICKED); + } + + /** + * @param year as an int + * @param month as an int + * @param day as an int + * @return true if the given date should be highlighted + */ + protected boolean isHighlighted(int year, int month, int day) { + return mController.isHighlighted(year, month, day); + } + + /** + * Return a 1 or 2 letter String for use as a weekday label + * + * @param day The day for which to generate a label + * @return The weekday label + */ + private String getWeekDayLabel(Calendar day) { + Locale locale = mController.getLocale(); + + // Localised short version of the string is not available on API < 18 + if (Build.VERSION.SDK_INT < 18) { + String dayName = new SimpleDateFormat("E", locale).format(day.getTime()); + String dayLabel = dayName.toUpperCase(locale).substring(0, 1); + + // Chinese labels should be fetched right to left + if (locale.equals(Locale.CHINA) || locale.equals(Locale.CHINESE) || locale.equals(Locale.SIMPLIFIED_CHINESE) || locale.equals(Locale.TRADITIONAL_CHINESE)) { + int len = dayName.length(); + dayLabel = dayName.substring(len - 1, len); + } + + // Most hebrew labels should select the second to last character + if (locale.getLanguage().equals("he") || locale.getLanguage().equals("iw")) { + if (mDayLabelCalendar.get(Calendar.DAY_OF_WEEK) != Calendar.SATURDAY) { + int len = dayName.length(); + dayLabel = dayName.substring(len - 2, len - 1); + } else { + // I know this is duplication, but it makes the code easier to grok by + // having all hebrew code in the same block + dayLabel = dayName.toUpperCase(locale).substring(0, 1); + } + } + + // Catalan labels should be two digits in lowercase + if (locale.getLanguage().equals("ca")) + dayLabel = dayName.toLowerCase().substring(0, 2); + + // Correct single character label in Spanish is X + if (locale.getLanguage().equals("es") && day.get(Calendar.DAY_OF_WEEK) == Calendar.WEDNESDAY) + dayLabel = "X"; + + return dayLabel; + } + // Getting the short label is a one liner on API >= 18 + if (weekDayLabelFormatter == null) { + weekDayLabelFormatter = new SimpleDateFormat("EEEEE", locale); + } + return weekDayLabelFormatter.format(day.getTime()); + } + + /** + * @return The date that has accessibility focus, or {@code null} if no date + * has focus + */ + public CalendarDay getAccessibilityFocus() { + final int day = mTouchHelper.getAccessibilityFocusedVirtualViewId(); + if (day >= 0) { + return new CalendarDay(mYear, mMonth, day, mController.getTimeZone()); + } + return null; + } + + /** + * Clears accessibility focus within the view. No-op if the view does not + * contain accessibility focus. + */ + public void clearAccessibilityFocus() { + mTouchHelper.clearFocusedVirtualView(); + } + + /** + * Attempts to restore accessibility focus to the specified date. + * + * @param day The date which should receive focus + * @return {@code false} if the date is not valid for this month view, or + * {@code true} if the date received focus + */ + public boolean restoreAccessibilityFocus(CalendarDay day) { + if ((day.year != mYear) || (day.month != mMonth) || (day.day > mNumCells)) { + return false; + } + mTouchHelper.setFocusedVirtualView(day.day); + return true; + } + + /** + * Provides a virtual view hierarchy for interfacing with an accessibility + * service. + */ + protected class MonthViewTouchHelper extends ExploreByTouchHelper { + private static final String DATE_FORMAT = "dd MMMM yyyy"; + + private final Rect mTempRect = new Rect(); + private final Calendar mTempCalendar = Calendar.getInstance(mController.getTimeZone()); + + MonthViewTouchHelper(View host) { + super(host); + } + + void setFocusedVirtualView(int virtualViewId) { + getAccessibilityNodeProvider(MonthView.this).performAction( + virtualViewId, AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS, null); + } + + void clearFocusedVirtualView() { + final int focusedVirtualView = getAccessibilityFocusedVirtualViewId(); + if (focusedVirtualView != ExploreByTouchHelper.INVALID_ID) { + getAccessibilityNodeProvider(MonthView.this).performAction( + focusedVirtualView, + AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS, + null); + } + } + + @Override + protected int getVirtualViewAt(float x, float y) { + final int day = getDayFromLocation(x, y); + if (day >= 0) { + return day; + } + return ExploreByTouchHelper.INVALID_ID; + } + + @Override + protected void getVisibleVirtualViews(List virtualViewIds) { + for (int day = 1; day <= mNumCells; day++) { + virtualViewIds.add(day); + } + } + + @Override + protected void onPopulateEventForVirtualView(int virtualViewId, @NonNull AccessibilityEvent event) { + event.setContentDescription(getItemDescription(virtualViewId)); + } + + @Override + protected void onPopulateNodeForVirtualView(int virtualViewId, + @NonNull AccessibilityNodeInfoCompat node) { + getItemBounds(virtualViewId, mTempRect); + + node.setContentDescription(getItemDescription(virtualViewId)); + node.setBoundsInParent(mTempRect); + node.addAction(AccessibilityNodeInfo.ACTION_CLICK); + + // Flag non-selectable dates as disabled + node.setEnabled(!mController.isOutOfRange(mYear, mMonth, virtualViewId)); + + if (virtualViewId == mSelectedDay) { + node.setSelected(true); + } + + } + + @Override + protected boolean onPerformActionForVirtualView(int virtualViewId, int action, + Bundle arguments) { + switch (action) { + case AccessibilityNodeInfo.ACTION_CLICK: + onDayClick(virtualViewId); + return true; + } + + return false; + } + + /** + * Calculates the bounding rectangle of a given time object. + * + * @param day The day to calculate bounds for + * @param rect The rectangle in which to store the bounds + */ + void getItemBounds(int day, Rect rect) { + final int offsetX = mEdgePadding; + final int offsetY = getMonthHeaderSize(); + final int cellHeight = mRowHeight; + final int cellWidth = ((mWidth - (2 * mEdgePadding)) / mNumDays); + final int index = ((day - 1) + findDayOffset()); + final int row = (index / mNumDays); + final int column = (index % mNumDays); + final int x = (offsetX + (column * cellWidth)); + final int y = (offsetY + (row * cellHeight)); + + rect.set(x, y, (x + cellWidth), (y + cellHeight)); + } + + /** + * Generates a description for a given time object. Since this + * description will be spoken, the components are ordered by descending + * specificity as DAY MONTH YEAR. + * + * @param day The day to generate a description for + * @return A description of the time object + */ + CharSequence getItemDescription(int day) { + mTempCalendar.set(mYear, mMonth, day); + return DateFormat.format(DATE_FORMAT, mTempCalendar.getTimeInMillis()); + } + } + + /** + * Handles callbacks when the user clicks on a time object. + */ + public interface OnDayClickListener { + void onDayClick(MonthView view, CalendarDay day); + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/SimpleDayPickerView.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/SimpleDayPickerView.java new file mode 100644 index 00000000..da033e35 --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/SimpleDayPickerView.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker.date; + +import android.content.Context; +import android.util.AttributeSet; + +/** + * A DayPickerView customized for {@link SimpleMonthAdapter} + */ +public class SimpleDayPickerView extends DayPickerView { + + public SimpleDayPickerView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SimpleDayPickerView(Context context, DatePickerController controller) { + super(context, controller); + } + + @Override + public MonthAdapter createMonthAdapter(DatePickerController controller) { + return new SimpleMonthAdapter(controller); + } + +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/SimpleMonthAdapter.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/SimpleMonthAdapter.java new file mode 100644 index 00000000..aaa4f628 --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/SimpleMonthAdapter.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker.date; + +import android.content.Context; + +/** + * An adapter for a list of {@link SimpleMonthView} items. + */ +public class SimpleMonthAdapter extends MonthAdapter { + + public SimpleMonthAdapter(DatePickerController controller) { + super(controller); + } + + @Override + public MonthView createMonthView(Context context) { + return new SimpleMonthView(context, null, mController); + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/SimpleMonthView.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/SimpleMonthView.java new file mode 100644 index 00000000..cb886a8e --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/SimpleMonthView.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker.date; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Typeface; +import android.util.AttributeSet; + +public class SimpleMonthView extends MonthView { + + public SimpleMonthView(Context context, AttributeSet attr, DatePickerController controller) { + super(context, attr, controller); + } + + @Override + public void drawMonthDay(Canvas canvas, int year, int month, int day, + int x, int y, int startX, int stopX, int startY, int stopY) { + if (mSelectedDay == day) { + canvas.drawCircle(x, y - (MINI_DAY_NUMBER_TEXT_SIZE / 3), DAY_SELECTED_CIRCLE_SIZE, + mSelectedCirclePaint); + } + + if (isHighlighted(year, month, day) && mSelectedDay != day) { + canvas.drawCircle(x, y + MINI_DAY_NUMBER_TEXT_SIZE - DAY_HIGHLIGHT_CIRCLE_MARGIN, + DAY_HIGHLIGHT_CIRCLE_SIZE, mSelectedCirclePaint); + mMonthNumPaint.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)); + } else { + mMonthNumPaint.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)); + } + + // gray out the day number if it's outside the range. + if (mController.isOutOfRange(year, month, day)) { + mMonthNumPaint.setColor(mDisabledDayTextColor); + } else if (mSelectedDay == day) { + mMonthNumPaint.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)); + mMonthNumPaint.setColor(mSelectedDayTextColor); + } else if (mHasToday && mToday == day) { + mMonthNumPaint.setColor(mTodayNumberColor); + } else { + mMonthNumPaint.setColor(isHighlighted(year, month, day) ? mHighlightedDayTextColor : mDayTextColor); + } + + canvas.drawText(String.format(mController.getLocale(), "%d", day), x, y, mMonthNumPaint); + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/TextViewWithCircularIndicator.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/TextViewWithCircularIndicator.java new file mode 100644 index 00000000..36ca105e --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/TextViewWithCircularIndicator.java @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker.date; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Paint.Align; +import android.graphics.Paint.Style; +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import android.util.AttributeSet; + +import com.wdullaer.materialdatetimepicker.R; + +/** + * A text view which, when pressed or activated, displays a colored circle around the text. + */ +public class TextViewWithCircularIndicator extends androidx.appcompat.widget.AppCompatTextView { + + private static final int SELECTED_CIRCLE_ALPHA = 255; + + Paint mCirclePaint = new Paint(); + + private int mCircleColor; + private final String mItemIsSelectedText; + + private boolean mDrawCircle; + + public TextViewWithCircularIndicator(Context context, AttributeSet attrs) { + super(context, attrs); + mCircleColor = ContextCompat.getColor(context, R.color.mdtp_accent_color); + mItemIsSelectedText = context.getResources().getString(R.string.mdtp_item_is_selected); + + init(); + } + + private void init() { + mCirclePaint.setFakeBoldText(true); + mCirclePaint.setAntiAlias(true); + mCirclePaint.setColor(mCircleColor); + mCirclePaint.setTextAlign(Align.CENTER); + mCirclePaint.setStyle(Style.FILL); + mCirclePaint.setAlpha(SELECTED_CIRCLE_ALPHA); + } + + public void setAccentColor(int color, boolean darkMode) { + mCircleColor = color; + mCirclePaint.setColor(mCircleColor); + setTextColor(createTextColor(color, darkMode)); + } + + /** + * Programmatically set the color state list (see mdtp_date_picker_year_selector) + * @param accentColor pressed state text color + * @param darkMode current theme mode + * @return ColorStateList with pressed state + */ + private ColorStateList createTextColor(int accentColor, boolean darkMode) { + int[][] states = new int[][]{ + new int[]{android.R.attr.state_pressed}, // pressed + new int[]{android.R.attr.state_selected}, // selected + new int[]{} + }; + int[] colors = new int[]{ + accentColor, + Color.WHITE, + darkMode ? Color.WHITE : Color.BLACK + }; + return new ColorStateList(states, colors); + } + + public void drawIndicator(boolean drawCircle) { + mDrawCircle = drawCircle; + } + + @Override + public void onDraw(@NonNull Canvas canvas) { + if (mDrawCircle) { + final int width = getWidth(); + final int height = getHeight(); + int radius = Math.min(width, height) / 2; + canvas.drawCircle(width / 2, height / 2, radius, mCirclePaint); + } + setSelected(mDrawCircle); + super.onDraw(canvas); + } + + @SuppressLint("GetContentDescriptionOverride") + @Override + public CharSequence getContentDescription() { + CharSequence itemText = getText(); + if (mDrawCircle) { + return String.format(mItemIsSelectedText, itemText); + } else { + return itemText; + } + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/YearPickerView.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/YearPickerView.java new file mode 100644 index 00000000..04c7fdca --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/date/YearPickerView.java @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker.date; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.drawable.StateListDrawable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.accessibility.AccessibilityEvent; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.BaseAdapter; +import android.widget.ListView; +import android.widget.TextView; + +import com.wdullaer.materialdatetimepicker.R; +import com.wdullaer.materialdatetimepicker.date.DatePickerDialog.OnDateChangedListener; + +/** + * Displays a selectable list of years. + */ +public class YearPickerView extends ListView implements OnItemClickListener, OnDateChangedListener { + private final DatePickerController mController; + private YearAdapter mAdapter; + private int mViewSize; + private int mChildSize; + private TextViewWithCircularIndicator mSelectedView; + + public YearPickerView(Context context, DatePickerController controller) { + super(context); + mController = controller; + mController.registerOnDateChangedListener(this); + ViewGroup.LayoutParams frame = new ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, + LayoutParams.WRAP_CONTENT); + setLayoutParams(frame); + Resources res = context.getResources(); + mViewSize = mController.getVersion() == DatePickerDialog.Version.VERSION_1 + ? res.getDimensionPixelOffset(R.dimen.mdtp_date_picker_view_animator_height) + : res.getDimensionPixelOffset(R.dimen.mdtp_date_picker_view_animator_height_v2); + mChildSize = res.getDimensionPixelOffset(R.dimen.mdtp_year_label_height); + setVerticalFadingEdgeEnabled(true); + setFadingEdgeLength(mChildSize / 3); + init(); + setOnItemClickListener(this); + setSelector(new StateListDrawable()); + setDividerHeight(0); + onDateChanged(); + } + + private void init() { + mAdapter = new YearAdapter(mController.getMinYear(), mController.getMaxYear()); + setAdapter(mAdapter); + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + mController.tryVibrate(); + TextViewWithCircularIndicator clickedView = (TextViewWithCircularIndicator) view; + if (clickedView != null) { + if (clickedView != mSelectedView) { + if (mSelectedView != null) { + mSelectedView.drawIndicator(false); + mSelectedView.requestLayout(); + } + clickedView.drawIndicator(true); + clickedView.requestLayout(); + mSelectedView = clickedView; + } + mController.onYearSelected(getYearFromTextView(clickedView)); + mAdapter.notifyDataSetChanged(); + } + } + + private static int getYearFromTextView(TextView view) { + return Integer.valueOf(view.getText().toString()); + } + + private final class YearAdapter extends BaseAdapter { + private final int mMinYear; + private final int mMaxYear; + + YearAdapter(int minYear, int maxYear) { + if (minYear > maxYear) { + throw new IllegalArgumentException("minYear > maxYear"); + } + mMinYear = minYear; + mMaxYear = maxYear; + } + + @Override + public int getCount() { + return mMaxYear - mMinYear + 1; + } + + @Override + public Object getItem(int position) { + return mMinYear + position; + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + TextViewWithCircularIndicator v; + if (convertView != null) { + v = (TextViewWithCircularIndicator) convertView; + } else { + v = (TextViewWithCircularIndicator) LayoutInflater.from(parent.getContext()) + .inflate(R.layout.mdtp_year_label_text_view, parent, false); + v.setAccentColor(mController.getAccentColor(), mController.isThemeDark()); + } + int year = mMinYear + position; + boolean selected = mController.getSelectedDay().year == year; + v.setText(String.format(mController.getLocale(),"%d", year)); + v.drawIndicator(selected); + v.requestLayout(); + if (selected) { + mSelectedView = v; + } + return v; + } + } + + public void postSetSelectionCentered(final int position) { + postSetSelectionFromTop(position, mViewSize / 2 - mChildSize / 2); + } + + public void postSetSelectionFromTop(final int position, final int offset) { + post(() -> { + setSelectionFromTop(position, offset); + requestLayout(); + }); + } + + public int getFirstPositionOffset() { + final View firstChild = getChildAt(0); + if (firstChild == null) { + return 0; + } + return firstChild.getTop(); + } + + @Override + public void onDateChanged() { + mAdapter.notifyDataSetChanged(); + postSetSelectionCentered(mController.getSelectedDay().year - mController.getMinYear()); + } + + @Override + public void onInitializeAccessibilityEvent(AccessibilityEvent event) { + super.onInitializeAccessibilityEvent(event); + if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_SCROLLED) { + event.setFromIndex(0); + event.setToIndex(0); + } + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/AmPmCirclesView.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/AmPmCirclesView.java new file mode 100644 index 00000000..f87e1111 --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/AmPmCirclesView.java @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker.time; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Typeface; +import android.graphics.Paint.Align; +import androidx.core.content.ContextCompat; +import android.util.Log; +import android.view.View; + +import com.wdullaer.materialdatetimepicker.R; +import com.wdullaer.materialdatetimepicker.Utils; + +import java.text.DateFormatSymbols; +import java.util.Locale; + +/** + * Draw the two smaller AM and PM circles next to where the larger circle will be. + */ +public class AmPmCirclesView extends View { + private static final String TAG = "AmPmCirclesView"; + + // Alpha level for selected circle. + private static final int SELECTED_ALPHA = Utils.SELECTED_ALPHA; + private static final int SELECTED_ALPHA_THEME_DARK = Utils.SELECTED_ALPHA_THEME_DARK; + + private final Paint mPaint = new Paint(); + private int mSelectedAlpha; + private int mTouchedColor; + private int mUnselectedColor; + private int mAmPmTextColor; + private int mAmPmSelectedTextColor; + private int mAmPmDisabledTextColor; + private int mSelectedColor; + private float mCircleRadiusMultiplier; + private float mAmPmCircleRadiusMultiplier; + private String mAmText; + private String mPmText; + private boolean mAmDisabled; + private boolean mPmDisabled; + private boolean mIsInitialized; + + private static final int AM = TimePickerDialog.AM; + private static final int PM = TimePickerDialog.PM; + + private boolean mDrawValuesReady; + private int mAmPmCircleRadius; + private int mAmXCenter; + private int mPmXCenter; + private int mAmPmYCenter; + private int mAmOrPm; + private int mAmOrPmPressed; + + public AmPmCirclesView(Context context) { + super(context); + mIsInitialized = false; + } + + public void initialize(Context context, Locale locale, TimePickerController controller, int amOrPm) { + if (mIsInitialized) { + Log.e(TAG, "AmPmCirclesView may only be initialized once."); + return; + } + + Resources res = context.getResources(); + + if (controller.isThemeDark()) { + mUnselectedColor = ContextCompat.getColor(context, R.color.mdtp_circle_background_dark_theme); + mAmPmTextColor = ContextCompat.getColor(context, R.color.mdtp_white); + mAmPmDisabledTextColor = ContextCompat.getColor(context, R.color.mdtp_date_picker_text_disabled_dark_theme); + mSelectedAlpha = SELECTED_ALPHA_THEME_DARK; + } else { + mUnselectedColor = ContextCompat.getColor(context, R.color.mdtp_white); + mAmPmTextColor = ContextCompat.getColor(context, R.color.mdtp_ampm_text_color); + mAmPmDisabledTextColor = ContextCompat.getColor(context, R.color.mdtp_date_picker_text_disabled); + mSelectedAlpha = SELECTED_ALPHA; + } + + mSelectedColor = controller.getAccentColor(); + mTouchedColor = Utils.darkenColor(mSelectedColor); + mAmPmSelectedTextColor = ContextCompat.getColor(context, R.color.mdtp_white); + + String typefaceFamily = res.getString(R.string.mdtp_sans_serif); + Typeface tf = Typeface.create(typefaceFamily, Typeface.NORMAL); + mPaint.setTypeface(tf); + mPaint.setAntiAlias(true); + mPaint.setTextAlign(Align.CENTER); + + mCircleRadiusMultiplier = + Float.parseFloat(res.getString(R.string.mdtp_circle_radius_multiplier)); + mAmPmCircleRadiusMultiplier = + Float.parseFloat(res.getString(R.string.mdtp_ampm_circle_radius_multiplier)); + String[] amPmTexts = new DateFormatSymbols(locale).getAmPmStrings(); + mAmText = amPmTexts[0]; + mPmText = amPmTexts[1]; + + mAmDisabled = controller.isAmDisabled(); + mPmDisabled = controller.isPmDisabled(); + + setAmOrPm(amOrPm); + mAmOrPmPressed = -1; + + mIsInitialized = true; + } + + public void setAmOrPm(int amOrPm) { + mAmOrPm = amOrPm; + } + + public void setAmOrPmPressed(int amOrPmPressed) { + mAmOrPmPressed = amOrPmPressed; + } + + /** + * Calculate whether the coordinates are touching the AM or PM circle. + */ + public int getIsTouchingAmOrPm(float xCoord, float yCoord) { + if (!mDrawValuesReady) { + return -1; + } + + int squaredYDistance = (int) ((yCoord - mAmPmYCenter)*(yCoord - mAmPmYCenter)); + + int distanceToAmCenter = + (int) Math.sqrt((xCoord - mAmXCenter)*(xCoord - mAmXCenter) + squaredYDistance); + if (distanceToAmCenter <= mAmPmCircleRadius && !mAmDisabled) { + return AM; + } + + int distanceToPmCenter = + (int) Math.sqrt((xCoord - mPmXCenter)*(xCoord - mPmXCenter) + squaredYDistance); + if (distanceToPmCenter <= mAmPmCircleRadius && !mPmDisabled) { + return PM; + } + + // Neither was close enough. + return -1; + } + + @Override + public void onDraw(Canvas canvas) { + int viewWidth = getWidth(); + if (viewWidth == 0 || !mIsInitialized) { + return; + } + + if (!mDrawValuesReady) { + int layoutXCenter = getWidth() / 2; + int layoutYCenter = getHeight() / 2; + int circleRadius = + (int) (Math.min(layoutXCenter, layoutYCenter) * mCircleRadiusMultiplier); + mAmPmCircleRadius = (int) (circleRadius * mAmPmCircleRadiusMultiplier); + layoutYCenter += mAmPmCircleRadius*0.75; + int textSize = mAmPmCircleRadius * 3 / 4; + mPaint.setTextSize(textSize); + + // Line up the vertical center of the AM/PM circles with the bottom of the main circle. + mAmPmYCenter = layoutYCenter - mAmPmCircleRadius / 2 + circleRadius; + // Line up the horizontal edges of the AM/PM circles with the horizontal edges + // of the main circle. + mAmXCenter = layoutXCenter - circleRadius + mAmPmCircleRadius; + mPmXCenter = layoutXCenter + circleRadius - mAmPmCircleRadius; + + mDrawValuesReady = true; + } + + // We'll need to draw either a lighter blue (for selection), a darker blue (for touching) + // or white (for not selected). + int amColor = mUnselectedColor; + int amAlpha = 255; + int amTextColor = mAmPmTextColor; + int pmColor = mUnselectedColor; + int pmAlpha = 255; + int pmTextColor = mAmPmTextColor; + + if (mAmOrPm == AM) { + amColor = mSelectedColor; + amAlpha = mSelectedAlpha; + amTextColor = mAmPmSelectedTextColor; + } else if (mAmOrPm == PM) { + pmColor = mSelectedColor; + pmAlpha = mSelectedAlpha; + pmTextColor = mAmPmSelectedTextColor; + } + if (mAmOrPmPressed == AM) { + amColor = mTouchedColor; + amAlpha = mSelectedAlpha; + } else if (mAmOrPmPressed == PM) { + pmColor = mTouchedColor; + pmAlpha = mSelectedAlpha; + } + if (mAmDisabled) { + amColor = mUnselectedColor; + amTextColor = mAmPmDisabledTextColor; + } + if (mPmDisabled) { + pmColor = mUnselectedColor; + pmTextColor = mAmPmDisabledTextColor; + } + + // Draw the two circles. + mPaint.setColor(amColor); + mPaint.setAlpha(amAlpha); + canvas.drawCircle(mAmXCenter, mAmPmYCenter, mAmPmCircleRadius, mPaint); + mPaint.setColor(pmColor); + mPaint.setAlpha(pmAlpha); + canvas.drawCircle(mPmXCenter, mAmPmYCenter, mAmPmCircleRadius, mPaint); + + // Draw the AM/PM texts on top. + mPaint.setColor(amTextColor); + int textYCenter = mAmPmYCenter - (int) (mPaint.descent() + mPaint.ascent()) / 2; + canvas.drawText(mAmText, mAmXCenter, textYCenter, mPaint); + mPaint.setColor(pmTextColor); + canvas.drawText(mPmText, mPmXCenter, textYCenter, mPaint); + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/CircleView.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/CircleView.java new file mode 100644 index 00000000..46ff501a --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/CircleView.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker.time; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import androidx.core.content.ContextCompat; +import android.util.Log; +import android.view.View; + +import com.wdullaer.materialdatetimepicker.R; + +/** + * Draws a simple white circle on which the numbers will be drawn. + */ +public class CircleView extends View { + private static final String TAG = "CircleView"; + + private final Paint mPaint = new Paint(); + private boolean mIs24HourMode; + private int mCircleColor; + private int mDotColor; + private float mCircleRadiusMultiplier; + private float mAmPmCircleRadiusMultiplier; + private boolean mIsInitialized; + + private boolean mDrawValuesReady; + private int mXCenter; + private int mYCenter; + private int mCircleRadius; + + public CircleView(Context context) { + super(context); + + mIsInitialized = false; + } + + public void initialize(Context context, TimePickerController controller) { + if (mIsInitialized) { + Log.e(TAG, "CircleView may only be initialized once."); + return; + } + + Resources res = context.getResources(); + + int colorRes = controller.isThemeDark() ? R.color.mdtp_circle_background_dark_theme : R.color.mdtp_circle_color; + mCircleColor = ContextCompat.getColor(context, colorRes); + mDotColor = controller.getAccentColor(); + mPaint.setAntiAlias(true); + + mIs24HourMode = controller.is24HourMode(); + if (mIs24HourMode || controller.getVersion() != TimePickerDialog.Version.VERSION_1) { + mCircleRadiusMultiplier = Float.parseFloat( + res.getString(R.string.mdtp_circle_radius_multiplier_24HourMode)); + } else { + mCircleRadiusMultiplier = Float.parseFloat( + res.getString(R.string.mdtp_circle_radius_multiplier)); + mAmPmCircleRadiusMultiplier = + Float.parseFloat(res.getString(R.string.mdtp_ampm_circle_radius_multiplier)); + } + + mIsInitialized = true; + } + + @Override + public void onDraw(Canvas canvas) { + int viewWidth = getWidth(); + if (viewWidth == 0 || !mIsInitialized) { + return; + } + + if (!mDrawValuesReady) { + mXCenter = getWidth() / 2; + mYCenter = getHeight() / 2; + mCircleRadius = (int) (Math.min(mXCenter, mYCenter) * mCircleRadiusMultiplier); + + if (!mIs24HourMode) { + // We'll need to draw the AM/PM circles, so the main circle will need to have + // a slightly higher center. To keep the entire view centered vertically, we'll + // have to push it up by half the radius of the AM/PM circles. + int amPmCircleRadius = (int) (mCircleRadius * mAmPmCircleRadiusMultiplier); + mYCenter -= amPmCircleRadius*0.75; + } + + mDrawValuesReady = true; + } + + // Draw the white circle. + mPaint.setColor(mCircleColor); + canvas.drawCircle(mXCenter, mYCenter, mCircleRadius, mPaint); + + // Draw a small black circle in the center. + mPaint.setColor(mDotColor); + canvas.drawCircle(mXCenter, mYCenter, 8, mPaint); + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/DefaultTimepointLimiter.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/DefaultTimepointLimiter.java new file mode 100644 index 00000000..39d6cbfa --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/DefaultTimepointLimiter.java @@ -0,0 +1,306 @@ +package com.wdullaer.materialdatetimepicker.time; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Arrays; +import java.util.TreeSet; + +import static com.wdullaer.materialdatetimepicker.time.TimePickerDialog.HOUR_INDEX; +import static com.wdullaer.materialdatetimepicker.time.TimePickerDialog.MINUTE_INDEX; + +/** + * An implementation of TimepointLimiter which implements the most common ways to restrict Timepoints + * in a TimePickerDialog + * Created by wdullaer on 20/06/17. + */ + +class DefaultTimepointLimiter implements TimepointLimiter { + private TreeSet mSelectableTimes = new TreeSet<>(); + private TreeSet mDisabledTimes = new TreeSet<>(); + private TreeSet exclusiveSelectableTimes = new TreeSet<>(); + private Timepoint mMinTime; + private Timepoint mMaxTime; + + DefaultTimepointLimiter() {} + + @SuppressWarnings("WeakerAccess") + public DefaultTimepointLimiter(Parcel in) { + mMinTime = in.readParcelable(Timepoint.class.getClassLoader()); + mMaxTime = in.readParcelable(Timepoint.class.getClassLoader()); + mSelectableTimes.addAll(Arrays.asList(in.createTypedArray(Timepoint.CREATOR))); + mDisabledTimes.addAll(Arrays.asList(in.createTypedArray(Timepoint.CREATOR))); + exclusiveSelectableTimes = getExclusiveSelectableTimes(mSelectableTimes, mDisabledTimes); + } + + @Override + public void writeToParcel(Parcel out, int flags) { + out.writeParcelable(mMinTime, flags); + out.writeParcelable(mMaxTime, flags); + out.writeTypedArray(mSelectableTimes.toArray(new Timepoint[mSelectableTimes.size()]), flags); + out.writeTypedArray(mDisabledTimes.toArray(new Timepoint[mDisabledTimes.size()]), flags); + } + + @Override + public int describeContents() { + return 0; + } + + @SuppressWarnings("WeakerAccess") + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + public DefaultTimepointLimiter createFromParcel(Parcel in) { + return new DefaultTimepointLimiter(in); + } + + public DefaultTimepointLimiter[] newArray(int size) { + return new DefaultTimepointLimiter[size]; + } + }; + + void setMinTime(@NonNull Timepoint minTime) { + if(mMaxTime != null && minTime.compareTo(mMaxTime) > 0) + throw new IllegalArgumentException("Minimum time must be smaller than the maximum time"); + mMinTime = minTime; + } + + void setMaxTime(@NonNull Timepoint maxTime) { + if(mMinTime != null && maxTime.compareTo(mMinTime) < 0) + throw new IllegalArgumentException("Maximum time must be greater than the minimum time"); + mMaxTime = maxTime; + } + + void setSelectableTimes(@NonNull Timepoint[] selectableTimes) { + mSelectableTimes.addAll(Arrays.asList(selectableTimes)); + exclusiveSelectableTimes = getExclusiveSelectableTimes(mSelectableTimes, mDisabledTimes); + } + + void setDisabledTimes(@NonNull Timepoint[] disabledTimes) { + mDisabledTimes.addAll(Arrays.asList(disabledTimes)); + exclusiveSelectableTimes = getExclusiveSelectableTimes(mSelectableTimes, mDisabledTimes); + } + + @Nullable Timepoint getMinTime() { + return mMinTime; + } + + @Nullable Timepoint getMaxTime() { + return mMaxTime; + } + + @NonNull Timepoint[] getSelectableTimes() { + return mSelectableTimes.toArray(new Timepoint[mSelectableTimes.size()]); + } + + @NonNull Timepoint[] getDisabledTimes() { + return mDisabledTimes.toArray(new Timepoint[mDisabledTimes.size()]); + } + + @NonNull private TreeSet getExclusiveSelectableTimes(@NonNull TreeSet selectable, @NonNull TreeSet disabled) { + TreeSet output = new TreeSet<>(selectable); + output.removeAll(disabled); + return output; + } + + @Override + public boolean isOutOfRange(@Nullable Timepoint current, int index, @NonNull Timepoint.TYPE resolution) { + if (current == null) return false; + + if (index == HOUR_INDEX) { + if (mMinTime != null && mMinTime.getHour() > current.getHour()) return true; + + if (mMaxTime != null && mMaxTime.getHour()+1 <= current.getHour()) return true; + + if (!exclusiveSelectableTimes.isEmpty()) { + Timepoint ceil = exclusiveSelectableTimes.ceiling(current); + Timepoint floor = exclusiveSelectableTimes.floor(current); + return !(current.equals(ceil, Timepoint.TYPE.HOUR) || current.equals(floor, Timepoint.TYPE.HOUR)); + } + + if (!mDisabledTimes.isEmpty() && resolution == Timepoint.TYPE.HOUR) { + Timepoint ceil = mDisabledTimes.ceiling(current); + Timepoint floor = mDisabledTimes.floor(current); + return current.equals(ceil, Timepoint.TYPE.HOUR) || current.equals(floor, Timepoint.TYPE.HOUR); + } + + return false; + } + else if (index == MINUTE_INDEX) { + if (mMinTime != null) { + Timepoint roundedMin = new Timepoint(mMinTime.getHour(), mMinTime.getMinute()); + if (roundedMin.compareTo(current) > 0) return true; + } + + if (mMaxTime != null) { + Timepoint roundedMax = new Timepoint(mMaxTime.getHour(), mMaxTime.getMinute(), 59); + if (roundedMax.compareTo(current) < 0) return true; + } + + if (!exclusiveSelectableTimes.isEmpty()) { + Timepoint ceil = exclusiveSelectableTimes.ceiling(current); + Timepoint floor = exclusiveSelectableTimes.floor(current); + return !(current.equals(ceil, Timepoint.TYPE.MINUTE) || current.equals(floor, Timepoint.TYPE.MINUTE)); + } + + if (!mDisabledTimes.isEmpty() && resolution == Timepoint.TYPE.MINUTE) { + Timepoint ceil = mDisabledTimes.ceiling(current); + Timepoint floor = mDisabledTimes.floor(current); + boolean ceilExclude = current.equals(ceil, Timepoint.TYPE.MINUTE); + boolean floorExclude = current.equals(floor, Timepoint.TYPE.MINUTE); + return ceilExclude || floorExclude; + } + + return false; + } + else return isOutOfRange(current); + } + + public boolean isOutOfRange(@NonNull Timepoint current) { + if (mMinTime != null && mMinTime.compareTo(current) > 0) return true; + + if (mMaxTime != null && mMaxTime.compareTo(current) < 0) return true; + + if (!exclusiveSelectableTimes.isEmpty()) return !exclusiveSelectableTimes.contains(current); + + return mDisabledTimes.contains(current); + } + + @SuppressWarnings("SimplifiableIfStatement") + @Override + public boolean isAmDisabled() { + Timepoint midday = new Timepoint(12); + + if (mMinTime != null && mMinTime.compareTo(midday) >= 0) return true; + + if (!exclusiveSelectableTimes.isEmpty()) return exclusiveSelectableTimes.first().compareTo(midday) >= 0; + + return false; + } + + @SuppressWarnings("SimplifiableIfStatement") + @Override + public boolean isPmDisabled() { + Timepoint midday = new Timepoint(12); + + if (mMaxTime != null && mMaxTime.compareTo(midday) < 0) return true; + + if (!exclusiveSelectableTimes.isEmpty()) return exclusiveSelectableTimes.last().compareTo(midday) < 0; + + return false; + } + + @Override + public @NonNull Timepoint roundToNearest(@NonNull Timepoint time,@Nullable Timepoint.TYPE type, @NonNull Timepoint.TYPE resolution) { + if (mMinTime != null && mMinTime.compareTo(time) > 0) return mMinTime; + + if (mMaxTime != null && mMaxTime.compareTo(time) < 0) return mMaxTime; + + // type == SECOND: cannot change anything, return input + if (type == Timepoint.TYPE.SECOND) return time; + + if (!exclusiveSelectableTimes.isEmpty()) { + Timepoint floor = exclusiveSelectableTimes.floor(time); + Timepoint ceil = exclusiveSelectableTimes.ceiling(time); + + if (floor == null || ceil == null) { + Timepoint t = floor == null ? ceil : floor; + if (type == null) return t; + if (t.getHour() != time.getHour()) return time; + if (type == Timepoint.TYPE.MINUTE && t.getMinute() != time.getMinute()) return time; + return t; + } + + if (type == Timepoint.TYPE.HOUR) { + if (floor.getHour() != time.getHour() && ceil.getHour() == time.getHour()) return ceil; + if (floor.getHour() == time.getHour() && ceil.getHour() != time.getHour()) return floor; + if (floor.getHour() != time.getHour() && ceil.getHour() != time.getHour()) return time; + } + + if (type == Timepoint.TYPE.MINUTE) { + if (floor.getHour() != time.getHour() && ceil.getHour() != time.getHour()) return time; + if (floor.getHour() != time.getHour() && ceil.getHour() == time.getHour()) { + return ceil.getMinute() == time.getMinute() ? ceil : time; + } + if (floor.getHour() == time.getHour() && ceil.getHour() != time.getHour()) { + return floor.getMinute() == time.getMinute() ? floor : time; + } + if (floor.getMinute() != time.getMinute() && ceil.getMinute() == time.getMinute()) return ceil; + if (floor.getMinute() == time.getMinute() && ceil.getMinute() != time.getMinute()) return floor; + if (floor.getMinute() != time.getMinute() && ceil.getMinute() != time.getMinute()) return time; + } + + int floorDist = Math.abs(time.compareTo(floor)); + int ceilDist = Math.abs(time.compareTo(ceil)); + + return floorDist < ceilDist ? floor : ceil; + } + + if (!mDisabledTimes.isEmpty()) { + // if type matches resolution: cannot change anything, return input + if (type != null && type == resolution) return time; + + if (resolution == Timepoint.TYPE.SECOND) { + if (!mDisabledTimes.contains(time)) return time; + return searchValidTimePoint(time, type, resolution); + } + + if (resolution == Timepoint.TYPE.MINUTE) { + Timepoint ceil = mDisabledTimes.ceiling(time); + Timepoint floor = mDisabledTimes.floor(time); + boolean ceilDisabled = time.equals(ceil, Timepoint.TYPE.MINUTE); + boolean floorDisabled = time.equals(floor, Timepoint.TYPE.MINUTE); + + if (ceilDisabled || floorDisabled) return searchValidTimePoint(time, type, resolution); + return time; + } + + if (resolution == Timepoint.TYPE.HOUR) { + Timepoint ceil = mDisabledTimes.ceiling(time); + Timepoint floor = mDisabledTimes.floor(time); + boolean ceilDisabled = time.equals(ceil, Timepoint.TYPE.HOUR); + boolean floorDisabled = time.equals(floor, Timepoint.TYPE.HOUR); + + if (ceilDisabled || floorDisabled) return searchValidTimePoint(time, type, resolution); + return time; + } + } + + return time; + } + + private Timepoint searchValidTimePoint(@NonNull Timepoint time, @Nullable Timepoint.TYPE type, @NonNull Timepoint.TYPE resolution) { + Timepoint forward = new Timepoint(time); + Timepoint backward = new Timepoint(time); + int iteration = 0; + int resolutionMultiplier = 1; + if (resolution == Timepoint.TYPE.MINUTE) resolutionMultiplier = 60; + if (resolution == Timepoint.TYPE.SECOND) resolutionMultiplier = 3600; + + while (iteration < 24 * resolutionMultiplier) { + iteration++; + forward.add(resolution, 1); + backward.add(resolution, -1); + + if (type == null || forward.get(type) == time.get(type)) { + Timepoint forwardCeil = mDisabledTimes.ceiling(forward); + Timepoint forwardFloor = mDisabledTimes.floor(forward); + if (!forward.equals(forwardCeil, resolution) && !forward.equals(forwardFloor, resolution)) + return forward; + } + + if (type == null || backward.get(type) == time.get(type)) { + Timepoint backwardCeil = mDisabledTimes.ceiling(backward); + Timepoint backwardFloor = mDisabledTimes.floor(backward); + if (!backward.equals(backwardCeil, resolution) && !backward.equals(backwardFloor, resolution)) + return backward; + } + + if (type != null && backward.get(type) != time.get(type) && forward.get(type) != time.get(type)) + break; + } + // If this step is reached, the user has disabled all timepoints + return time; + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/RadialPickerLayout.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/RadialPickerLayout.java new file mode 100644 index 00000000..6c6dc889 --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/RadialPickerLayout.java @@ -0,0 +1,1026 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker.time; + +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import android.text.format.DateUtils; +import android.util.AttributeSet; +import android.util.Log; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnTouchListener; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityManager; +import android.view.accessibility.AccessibilityNodeInfo; +import android.widget.FrameLayout; + +import com.wdullaer.materialdatetimepicker.R; + +import java.util.Calendar; +import java.util.Locale; + +/** + * The primary layout to hold the circular picker, and the am/pm buttons. This view will measure + * itself to end up as a square. It also handles touches to be passed in to views that need to know + * when they'd been touched. + */ +public class RadialPickerLayout extends FrameLayout implements OnTouchListener { + private static final String TAG = "RadialPickerLayout"; + + private final int TOUCH_SLOP; + private final int TAP_TIMEOUT; + + private static final int VISIBLE_DEGREES_STEP_SIZE = 30; + private static final int HOUR_VALUE_TO_DEGREES_STEP_SIZE = VISIBLE_DEGREES_STEP_SIZE; + private static final int MINUTE_VALUE_TO_DEGREES_STEP_SIZE = 6; + private static final int SECOND_VALUE_TO_DEGREES_STEP_SIZE = 6; + private static final int HOUR_INDEX = TimePickerDialog.HOUR_INDEX; + private static final int MINUTE_INDEX = TimePickerDialog.MINUTE_INDEX; + private static final int SECOND_INDEX = TimePickerDialog.SECOND_INDEX; + private static final int AM = TimePickerDialog.AM; + private static final int PM = TimePickerDialog.PM; + + private Timepoint mLastValueSelected; + + private TimePickerController mController; + private OnValueSelectedListener mListener; + private boolean mTimeInitialized; + private Timepoint mCurrentTime; + private boolean mIs24HourMode; + private int mCurrentItemShowing; + + private CircleView mCircleView; + private AmPmCirclesView mAmPmCirclesView; + private RadialTextsView mHourRadialTextsView; + private RadialTextsView mMinuteRadialTextsView; + private RadialTextsView mSecondRadialTextsView; + private RadialSelectorView mHourRadialSelectorView; + private RadialSelectorView mMinuteRadialSelectorView; + private RadialSelectorView mSecondRadialSelectorView; + private View mGrayBox; + + private int[] mSnapPrefer30sMap; + private boolean mInputEnabled; + private int mIsTouchingAmOrPm = -1; + private boolean mDoingMove; + private boolean mDoingTouch; + private int mDownDegrees; + private float mDownX; + private float mDownY; + private AccessibilityManager mAccessibilityManager; + + private AnimatorSet mTransition; + private Handler mHandler = new Handler(); + + public interface OnValueSelectedListener { + void onValueSelected(Timepoint newTime); + void enablePicker(); + void advancePicker(int index); + } + + public RadialPickerLayout(Context context, AttributeSet attrs) { + super(context, attrs); + + setOnTouchListener(this); + ViewConfiguration vc = ViewConfiguration.get(context); + TOUCH_SLOP = vc.getScaledTouchSlop(); + TAP_TIMEOUT = ViewConfiguration.getTapTimeout(); + mDoingMove = false; + + mCircleView = new CircleView(context); + addView(mCircleView); + + mAmPmCirclesView = new AmPmCirclesView(context); + addView(mAmPmCirclesView); + + mHourRadialSelectorView = new RadialSelectorView(context); + addView(mHourRadialSelectorView); + mMinuteRadialSelectorView = new RadialSelectorView(context); + addView(mMinuteRadialSelectorView); + mSecondRadialSelectorView = new RadialSelectorView(context); + addView(mSecondRadialSelectorView); + + mHourRadialTextsView = new RadialTextsView(context); + addView(mHourRadialTextsView); + mMinuteRadialTextsView = new RadialTextsView(context); + addView(mMinuteRadialTextsView); + mSecondRadialTextsView = new RadialTextsView(context); + addView(mSecondRadialTextsView); + + // Prepare mapping to snap touchable degrees to selectable degrees. + preparePrefer30sMap(); + + mLastValueSelected = null; + + mInputEnabled = true; + + mGrayBox = new View(context); + mGrayBox.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + mGrayBox.setBackgroundColor(ContextCompat.getColor(context, R.color.mdtp_transparent_black)); + mGrayBox.setVisibility(View.INVISIBLE); + addView(mGrayBox); + + mAccessibilityManager = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); + + mTimeInitialized = false; + } + + public void setOnValueSelectedListener(OnValueSelectedListener listener) { + mListener = listener; + } + + /** + * Initialize the Layout with starting values. + * @param context A context needed to inflate resources + * @param locale A Locale to be used when generating strings + * @param initialTime The initial selection of the Timepicker + * @param is24HourMode Indicates whether we should render in 24hour mode or with AM/PM selectors + */ + public void initialize(Context context, Locale locale, TimePickerController timePickerController, + Timepoint initialTime, boolean is24HourMode) { + if (mTimeInitialized) { + Log.e(TAG, "Time has already been initialized."); + return; + } + + mController = timePickerController; + mIs24HourMode = mAccessibilityManager.isTouchExplorationEnabled() || is24HourMode; + + // Initialize the circle and AM/PM circles if applicable. + mCircleView.initialize(context, mController); + mCircleView.invalidate(); + if (!mIs24HourMode && mController.getVersion() == TimePickerDialog.Version.VERSION_1) { + mAmPmCirclesView.initialize(context, locale, mController, initialTime.isAM() ? AM : PM); + mAmPmCirclesView.invalidate(); + } + + // Create the selection validators + RadialTextsView.SelectionValidator secondValidator = selection -> { + Timepoint newTime = new Timepoint(mCurrentTime.getHour(), mCurrentTime.getMinute(), selection); + return !mController.isOutOfRange(newTime, SECOND_INDEX); + }; + RadialTextsView.SelectionValidator minuteValidator = selection -> { + Timepoint newTime = new Timepoint(mCurrentTime.getHour(), selection, mCurrentTime.getSecond()); + return !mController.isOutOfRange(newTime, MINUTE_INDEX); + }; + RadialTextsView.SelectionValidator hourValidator = selection -> { + Timepoint newTime = new Timepoint(selection, mCurrentTime.getMinute(), mCurrentTime.getSecond()); + if(!mIs24HourMode && getIsCurrentlyAmOrPm() == PM) newTime.setPM(); + if(!mIs24HourMode && getIsCurrentlyAmOrPm() == AM) newTime.setAM(); + return !mController.isOutOfRange(newTime, HOUR_INDEX); + }; + + // Initialize the hours and minutes numbers. + int[] hours = {12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}; + int[] hours_24 = {0, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23}; + int[] minutes = {0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}; + int[] seconds = {0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}; + String[] hoursTexts = new String[12]; + String[] innerHoursTexts = new String[12]; + String[] minutesTexts = new String[12]; + String[] secondsTexts = new String[12]; + for (int i = 0; i < 12; i++) { + hoursTexts[i] = is24HourMode? + String.format(locale, "%02d", hours_24[i]) : String.format(locale, "%d", hours[i]); + innerHoursTexts[i] = String.format(locale, "%d", hours[i]); + minutesTexts[i] = String.format(locale, "%02d", minutes[i]); + secondsTexts[i] = String.format(locale, "%02d", seconds[i]); + } + // The version 2 layout has the hours > 12 on the inner circle rather than the outer circle + // Inner circle and outer circle should be swapped (see #411) + if (mController.getVersion() == TimePickerDialog.Version.VERSION_2) { + String[] temp = hoursTexts; + hoursTexts = innerHoursTexts; + innerHoursTexts = temp; + } + + mHourRadialTextsView.initialize(context, + hoursTexts, (is24HourMode ? innerHoursTexts : null), mController, hourValidator, true); + mHourRadialTextsView.setSelection(is24HourMode ? initialTime.getHour() : hours[initialTime.getHour() % 12]); + mHourRadialTextsView.invalidate(); + mMinuteRadialTextsView.initialize(context, minutesTexts, null, mController, minuteValidator, false); + mMinuteRadialTextsView.setSelection(initialTime.getMinute()); + mMinuteRadialTextsView.invalidate(); + mSecondRadialTextsView.initialize(context, secondsTexts, null, mController, secondValidator, false); + mSecondRadialTextsView.setSelection(initialTime.getSecond()); + mSecondRadialTextsView.invalidate(); + + // Initialize the currently-selected hour and minute. + mCurrentTime = initialTime; + int hourDegrees = (initialTime.getHour() % 12) * HOUR_VALUE_TO_DEGREES_STEP_SIZE; + mHourRadialSelectorView.initialize(context, mController, is24HourMode, true, + hourDegrees, isHourInnerCircle(initialTime.getHour())); + int minuteDegrees = initialTime.getMinute() * MINUTE_VALUE_TO_DEGREES_STEP_SIZE; + mMinuteRadialSelectorView.initialize(context, mController, false, false, + minuteDegrees, false); + int secondDegrees = initialTime.getSecond() * SECOND_VALUE_TO_DEGREES_STEP_SIZE; + mSecondRadialSelectorView.initialize(context, mController, false, false, + secondDegrees, false); + + mTimeInitialized = true; + } + + public void setTime(Timepoint time) { + setItem(HOUR_INDEX, time); + } + + /** + * Set either the hour, the minute or the second. Will set the internal value, and set the selection. + */ + private void setItem(int index, Timepoint time) { + time = roundToValidTime(time, index); + mCurrentTime = time; + reselectSelector(time, false, index); + } + + /** + * Check if a given hour appears in the outer circle or the inner circle + * @return true if the hour is in the inner circle, false if it's in the outer circle. + */ + private boolean isHourInnerCircle(int hourOfDay) { + // We'll have the 00 hours on the outside circle. + boolean isMorning = hourOfDay <= 12 && hourOfDay != 0; + // In the version 2 layout the circles are swapped + if (mController.getVersion() != TimePickerDialog.Version.VERSION_1) isMorning = !isMorning; + return mIs24HourMode && isMorning; + } + + public int getHours() { + return mCurrentTime.getHour(); + } + + public int getMinutes() { + return mCurrentTime.getMinute(); + } + + public int getSeconds() { + return mCurrentTime.getSecond(); + } + + public Timepoint getTime() { + return mCurrentTime; + } + + /** + * If the hours are showing, return the current hour. If the minutes are showing, return the + * current minute. + */ + private int getCurrentlyShowingValue() { + int currentIndex = getCurrentItemShowing(); + switch(currentIndex) { + case HOUR_INDEX: + return mCurrentTime.getHour(); + case MINUTE_INDEX: + return mCurrentTime.getMinute(); + case SECOND_INDEX: + return mCurrentTime.getSecond(); + default: + return -1; + } + } + + public int getIsCurrentlyAmOrPm() { + if (mCurrentTime.isAM()) { + return AM; + } else if (mCurrentTime.isPM()) { + return PM; + } + return -1; + } + + /** + * Set the internal value as either AM or PM, and update the AM/PM circle displays. + * @param amOrPm Integer representing AM of PM (use the supplied constants) + */ + public void setAmOrPm(int amOrPm) { + mAmPmCirclesView.setAmOrPm(amOrPm); + mAmPmCirclesView.invalidate(); + Timepoint newSelection = new Timepoint(mCurrentTime); + if(amOrPm == AM) newSelection.setAM(); + else if(amOrPm == PM) newSelection.setPM(); + newSelection = roundToValidTime(newSelection, HOUR_INDEX); + reselectSelector(newSelection, false, HOUR_INDEX); + mCurrentTime = newSelection; + mListener.onValueSelected(newSelection); + } + + /** + * Split up the 360 degrees of the circle among the 60 selectable values. Assigns a larger + * selectable area to each of the 12 visible values, such that the ratio of space apportioned + * to a visible value : space apportioned to a non-visible value will be 14 : 4. + * E.g. the output of 30 degrees should have a higher range of input associated with it than + * the output of 24 degrees, because 30 degrees corresponds to a visible number on the clock + * circle (5 on the minutes, 1 or 13 on the hours). + */ + private void preparePrefer30sMap() { + // We'll split up the visible output and the non-visible output such that each visible + // output will correspond to a range of 14 associated input degrees, and each non-visible + // output will correspond to a range of 4 associate input degrees, so visible numbers + // are more than 3 times easier to get than non-visible numbers: + // {354-359,0-7}:0, {8-11}:6, {12-15}:12, {16-19}:18, {20-23}:24, {24-37}:30, etc. + // + // If an output of 30 degrees should correspond to a range of 14 associated degrees, then + // we'll need any input between 24 - 37 to snap to 30. Working out from there, 20-23 should + // snap to 24, while 38-41 should snap to 36. This is somewhat counter-intuitive, that you + // can be touching 36 degrees but have the selection snapped to 30 degrees; however, this + // inconsistency isn't noticeable at such fine-grained degrees, and it affords us the + // ability to aggressively prefer the visible values by a factor of more than 3:1, which + // greatly contributes to the selectability of these values. + + // Our input will be 0 through 360. + mSnapPrefer30sMap = new int[361]; + + // The first output is 0, and each following output will increment by 6 {0, 6, 12, ...}. + int snappedOutputDegrees = 0; + // Count of how many inputs we've designated to the specified output. + int count = 1; + // How many input we expect for a specified output. This will be 14 for output divisible + // by 30, and 4 for the remaining output. We'll special case the outputs of 0 and 360, so + // the caller can decide which they need. + int expectedCount = 8; + // Iterate through the input. + for (int degrees = 0; degrees < 361; degrees++) { + // Save the input-output mapping. + mSnapPrefer30sMap[degrees] = snappedOutputDegrees; + // If this is the last input for the specified output, calculate the next output and + // the next expected count. + if (count == expectedCount) { + snappedOutputDegrees += 6; + if (snappedOutputDegrees == 360) { + expectedCount = 7; + } else if (snappedOutputDegrees % 30 == 0) { + expectedCount = 14; + } else { + expectedCount = 4; + } + count = 1; + } else { + count++; + } + } + } + + /** + * Returns mapping of any input degrees (0 to 360) to one of 60 selectable output degrees, + * where the degrees corresponding to visible numbers (i.e. those divisible by 30) will be + * weighted heavier than the degrees corresponding to non-visible numbers. + * See {@link #preparePrefer30sMap()} documentation for the rationale and generation of the + * mapping. + */ + private int snapPrefer30s(int degrees) { + if (mSnapPrefer30sMap == null) { + return -1; + } + return mSnapPrefer30sMap[degrees]; + } + + /** + * Returns mapping of any input degrees (0 to 360) to one of 12 visible output degrees (all + * multiples of 30), where the input will be "snapped" to the closest visible degrees. + * @param degrees The input degrees + * @param forceHigherOrLower The output may be forced to either the higher or lower step, or may + * be allowed to snap to whichever is closer. Use 1 to force strictly higher, -1 to force + * strictly lower, and 0 to snap to the closer one. + * @return output degrees, will be a multiple of 30 + */ + private static int snapOnly30s(int degrees, int forceHigherOrLower) { + int stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE; + int floor = (degrees / stepSize) * stepSize; + int ceiling = floor + stepSize; + if (forceHigherOrLower == 1) { + degrees = ceiling; + } else if (forceHigherOrLower == -1) { + if (degrees == floor) { + floor -= stepSize; + } + degrees = floor; + } else { + if ((degrees - floor) < (ceiling - degrees)) { + degrees = floor; + } else { + degrees = ceiling; + } + } + return degrees; + } + + /** + * Snap the input to a selectable value + * @param newSelection Timepoint - Time which should be rounded + * @param currentItemShowing int - The index of the current view + * @return Timepoint - the rounded value + */ + private Timepoint roundToValidTime(Timepoint newSelection, int currentItemShowing) { + switch(currentItemShowing) { + case HOUR_INDEX: + return mController.roundToNearest(newSelection, null); + case MINUTE_INDEX: + return mController.roundToNearest(newSelection, Timepoint.TYPE.HOUR); + default: + return mController.roundToNearest(newSelection, Timepoint.TYPE.MINUTE); + } + } + + /** + * For the currently showing view (either hours, minutes or seconds), re-calculate the position + * for the selector, and redraw it at that position. The text representing the currently + * selected value will be redrawn if required. + * @param newSelection Timpoint - Time which should be selected. + * @param forceDrawDot The dot in the circle will generally only be shown when the selection + * @param index The picker to use as a reference. Will be getCurrentItemShow() except when AM/PM is changed + * is on non-visible values, but use this to force the dot to be shown. + */ + private void reselectSelector(Timepoint newSelection, boolean forceDrawDot, int index) { + switch(index) { + case HOUR_INDEX: + // The selection might have changed, recalculate the degrees and innerCircle values + int hour = newSelection.getHour(); + boolean isInnerCircle = isHourInnerCircle(hour); + int degrees = (hour%12)*360/12; + if(!mIs24HourMode) hour = hour%12; + if(!mIs24HourMode && hour == 0) hour += 12; + + mHourRadialSelectorView.setSelection(degrees, isInnerCircle, forceDrawDot); + mHourRadialTextsView.setSelection(hour); + // If we rounded the minutes, reposition the minuteSelector too. + if(newSelection.getMinute() != mCurrentTime.getMinute()) { + int minDegrees = newSelection.getMinute() * (360 / 60); + mMinuteRadialSelectorView.setSelection(minDegrees, isInnerCircle, forceDrawDot); + mMinuteRadialTextsView.setSelection(newSelection.getMinute()); + } + // If we rounded the seconds, reposition the secondSelector too. + if(newSelection.getSecond() != mCurrentTime.getSecond()) { + int secDegrees = newSelection.getSecond() * (360 / 60); + mSecondRadialSelectorView.setSelection(secDegrees, isInnerCircle, forceDrawDot); + mSecondRadialTextsView.setSelection(newSelection.getSecond()); + } + break; + case MINUTE_INDEX: + // The selection might have changed, recalculate the degrees + degrees = newSelection.getMinute() * (360 / 60); + + mMinuteRadialSelectorView.setSelection(degrees, false, forceDrawDot); + mMinuteRadialTextsView.setSelection(newSelection.getMinute()); + // If we rounded the seconds, reposition the secondSelector too. + if(newSelection.getSecond() != mCurrentTime.getSecond()) { + int secDegrees = newSelection.getSecond()* (360 / 60); + mSecondRadialSelectorView.setSelection(secDegrees, false, forceDrawDot); + mSecondRadialTextsView.setSelection(newSelection.getSecond()); + } + break; + case SECOND_INDEX: + // The selection might have changed, recalculate the degrees + degrees = newSelection.getSecond() * (360 / 60); + mSecondRadialSelectorView.setSelection(degrees, false, forceDrawDot); + mSecondRadialTextsView.setSelection(newSelection.getSecond()); + } + + // Invalidate the currently showing picker to force a redraw + switch(getCurrentItemShowing()) { + case HOUR_INDEX: + mHourRadialSelectorView.invalidate(); + mHourRadialTextsView.invalidate(); + break; + case MINUTE_INDEX: + mMinuteRadialSelectorView.invalidate(); + mMinuteRadialTextsView.invalidate(); + break; + case SECOND_INDEX: + mSecondRadialSelectorView.invalidate(); + mSecondRadialTextsView.invalidate(); + } + } + + private Timepoint getTimeFromDegrees(int degrees, boolean isInnerCircle, boolean forceToVisibleValue) { + if (degrees == -1) { + return null; + } + int currentShowing = getCurrentItemShowing(); + + int stepSize; + boolean allowFineGrained = !forceToVisibleValue && + (currentShowing == MINUTE_INDEX || currentShowing == SECOND_INDEX); + if (allowFineGrained) { + degrees = snapPrefer30s(degrees); + } else { + degrees = snapOnly30s(degrees, 0); + } + + switch (currentShowing) { + case HOUR_INDEX: + stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE; + break; + case MINUTE_INDEX: + stepSize = MINUTE_VALUE_TO_DEGREES_STEP_SIZE; + break; + default: + stepSize = SECOND_VALUE_TO_DEGREES_STEP_SIZE; + } + + // TODO: simplify this logic. Just appending a swap of the values at the end for the v2 + // TODO: layout makes this code rather hard to read + if (currentShowing == HOUR_INDEX) { + if (mIs24HourMode) { + if (degrees == 0 && isInnerCircle) { + degrees = 360; + } else if (degrees == 360 && !isInnerCircle) { + degrees = 0; + } + } else if (degrees == 0) { + degrees = 360; + } + } else if (degrees == 360 && (currentShowing == MINUTE_INDEX || currentShowing == SECOND_INDEX)) { + degrees = 0; + } + + int value = degrees / stepSize; + + if (currentShowing == HOUR_INDEX && mIs24HourMode && !isInnerCircle && degrees != 0) { + value += 12; + } + + if (currentShowing == HOUR_INDEX + && mController.getVersion() != TimePickerDialog.Version.VERSION_1 + && mIs24HourMode) { + value = (value + 12) % 24; + } + + Timepoint newSelection; + switch(currentShowing) { + case HOUR_INDEX: + int hour = value; + if(!mIs24HourMode && getIsCurrentlyAmOrPm() == PM && degrees != 360) hour += 12; + if(!mIs24HourMode && getIsCurrentlyAmOrPm() == AM && degrees == 360) hour = 0; + newSelection = new Timepoint(hour, mCurrentTime.getMinute(), mCurrentTime.getSecond()); + break; + case MINUTE_INDEX: + newSelection = new Timepoint(mCurrentTime.getHour(), value, mCurrentTime.getSecond()); + break; + case SECOND_INDEX: + newSelection = new Timepoint(mCurrentTime.getHour(), mCurrentTime.getMinute(), value); + break; + default: + newSelection = mCurrentTime; + } + + return newSelection; + } + + /** + * Calculate the degrees within the circle that corresponds to the specified coordinates, if + * the coordinates are within the range that will trigger a selection. + * @param pointX The x coordinate. + * @param pointY The y coordinate. + * @param forceLegal Force the selection to be legal, regardless of how far the coordinates are + * from the actual numbers. + * @param isInnerCircle If the selection may be in the inner circle, pass in a size-1 boolean + * array here, inside which the value will be true if the selection is in the inner circle, + * and false if in the outer circle. + * @return Degrees from 0 to 360, if the selection was within the legal range. -1 if not. + */ + private int getDegreesFromCoords(float pointX, float pointY, boolean forceLegal, + final Boolean[] isInnerCircle) { + switch(getCurrentItemShowing()) { + case HOUR_INDEX: + return mHourRadialSelectorView.getDegreesFromCoords( + pointX, pointY, forceLegal, isInnerCircle); + case MINUTE_INDEX: + return mMinuteRadialSelectorView.getDegreesFromCoords( + pointX, pointY, forceLegal, isInnerCircle); + case SECOND_INDEX: + return mSecondRadialSelectorView.getDegreesFromCoords( + pointX, pointY, forceLegal, isInnerCircle); + default: + return -1; + } + } + + /** + * Get the item (hours, minutes or seconds) that is currently showing. + */ + public int getCurrentItemShowing() { + if (mCurrentItemShowing != HOUR_INDEX && mCurrentItemShowing != MINUTE_INDEX && mCurrentItemShowing != SECOND_INDEX) { + Log.e(TAG, "Current item showing was unfortunately set to " + mCurrentItemShowing); + return -1; + } + return mCurrentItemShowing; + } + + /** + * Set either seconds, minutes or hours as showing. + * @param animate True to animate the transition, false to show with no animation. + */ + public void setCurrentItemShowing(int index, boolean animate) { + if (index != HOUR_INDEX && index != MINUTE_INDEX && index != SECOND_INDEX) { + Log.e(TAG, "TimePicker does not support view at index "+index); + return; + } + + int lastIndex = getCurrentItemShowing(); + mCurrentItemShowing = index; + reselectSelector(getTime(), true, index); + + if (animate && (index != lastIndex)) { + ObjectAnimator[] anims = new ObjectAnimator[4]; + if (index == MINUTE_INDEX && lastIndex == HOUR_INDEX) { + anims[0] = mHourRadialTextsView.getDisappearAnimator(); + anims[1] = mHourRadialSelectorView.getDisappearAnimator(); + anims[2] = mMinuteRadialTextsView.getReappearAnimator(); + anims[3] = mMinuteRadialSelectorView.getReappearAnimator(); + } else if (index == HOUR_INDEX && lastIndex == MINUTE_INDEX){ + anims[0] = mHourRadialTextsView.getReappearAnimator(); + anims[1] = mHourRadialSelectorView.getReappearAnimator(); + anims[2] = mMinuteRadialTextsView.getDisappearAnimator(); + anims[3] = mMinuteRadialSelectorView.getDisappearAnimator(); + } else if (index == MINUTE_INDEX && lastIndex == SECOND_INDEX) { + anims[0] = mSecondRadialTextsView.getDisappearAnimator(); + anims[1] = mSecondRadialSelectorView.getDisappearAnimator(); + anims[2] = mMinuteRadialTextsView.getReappearAnimator(); + anims[3] = mMinuteRadialSelectorView.getReappearAnimator(); + } else if (index == HOUR_INDEX && lastIndex == SECOND_INDEX) { + anims[0] = mSecondRadialTextsView.getDisappearAnimator(); + anims[1] = mSecondRadialSelectorView.getDisappearAnimator(); + anims[2] = mHourRadialTextsView.getReappearAnimator(); + anims[3] = mHourRadialSelectorView.getReappearAnimator(); + } else if (index == SECOND_INDEX && lastIndex == MINUTE_INDEX) { + anims[0] = mSecondRadialTextsView.getReappearAnimator(); + anims[1] = mSecondRadialSelectorView.getReappearAnimator(); + anims[2] = mMinuteRadialTextsView.getDisappearAnimator(); + anims[3] = mMinuteRadialSelectorView.getDisappearAnimator(); + } else if (index == SECOND_INDEX && lastIndex == HOUR_INDEX) { + anims[0] = mSecondRadialTextsView.getReappearAnimator(); + anims[1] = mSecondRadialSelectorView.getReappearAnimator(); + anims[2] = mHourRadialTextsView.getDisappearAnimator(); + anims[3] = mHourRadialSelectorView.getDisappearAnimator(); + } + + if (anims[0] != null && anims[1] != null && anims[2] != null && + anims[3] != null) { + if (mTransition != null && mTransition.isRunning()) { + mTransition.end(); + } + mTransition = new AnimatorSet(); + mTransition.playTogether(anims); + mTransition.start(); + } else { + transitionWithoutAnimation(index); + } + } else { + transitionWithoutAnimation(index); + } + } + + private void transitionWithoutAnimation(int index) { + int hourAlpha = (index == HOUR_INDEX) ? 1 : 0; + int minuteAlpha = (index == MINUTE_INDEX) ? 1 : 0; + int secondAlpha = (index == SECOND_INDEX) ? 1 : 0; + mHourRadialTextsView.setAlpha(hourAlpha); + mHourRadialSelectorView.setAlpha(hourAlpha); + mMinuteRadialTextsView.setAlpha(minuteAlpha); + mMinuteRadialSelectorView.setAlpha(minuteAlpha); + mSecondRadialTextsView.setAlpha(secondAlpha); + mSecondRadialSelectorView.setAlpha(secondAlpha); + } + + @Override + public boolean onTouch(View v, MotionEvent event) { + final float eventX = event.getX(); + final float eventY = event.getY(); + int degrees; + Timepoint value; + final Boolean[] isInnerCircle = new Boolean[1]; + isInnerCircle[0] = false; + + switch(event.getAction()) { + case MotionEvent.ACTION_DOWN: + if (!mInputEnabled) { + return true; + } + + mDownX = eventX; + mDownY = eventY; + + mLastValueSelected = null; + mDoingMove = false; + mDoingTouch = true; + // If we're showing the AM/PM, check to see if the user is touching it. + if (!mIs24HourMode && mController.getVersion() == TimePickerDialog.Version.VERSION_1) { + mIsTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY); + } else { + mIsTouchingAmOrPm = -1; + } + if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) { + // If the touch is on AM or PM, set it as "touched" after the TAP_TIMEOUT + // in case the user moves their finger quickly. + mController.tryVibrate(); + mDownDegrees = -1; + mHandler.postDelayed(() -> { + mAmPmCirclesView.setAmOrPmPressed(mIsTouchingAmOrPm); + mAmPmCirclesView.invalidate(); + }, TAP_TIMEOUT); + } else { + // If we're in accessibility mode, force the touch to be legal. Otherwise, + // it will only register within the given touch target zone. + boolean forceLegal = mAccessibilityManager.isTouchExplorationEnabled(); + // Calculate the degrees that is currently being touched. + mDownDegrees = getDegreesFromCoords(eventX, eventY, forceLegal, isInnerCircle); + Timepoint selectedTime = getTimeFromDegrees(mDownDegrees, isInnerCircle[0], false); + if(mController.isOutOfRange(selectedTime, getCurrentItemShowing())) mDownDegrees = -1; + if (mDownDegrees != -1) { + // If it's a legal touch, set that number as "selected" after the + // TAP_TIMEOUT in case the user moves their finger quickly. + mController.tryVibrate(); + mHandler.postDelayed(() -> { + mDoingMove = true; + mLastValueSelected = getTimeFromDegrees(mDownDegrees, isInnerCircle[0], + false); + mLastValueSelected = roundToValidTime(mLastValueSelected, getCurrentItemShowing()); + // Redraw + reselectSelector(mLastValueSelected, true, getCurrentItemShowing()); + mListener.onValueSelected(mLastValueSelected); + }, TAP_TIMEOUT); + } + } + return true; + case MotionEvent.ACTION_MOVE: + if (!mInputEnabled) { + // We shouldn't be in this state, because input is disabled. + Log.e(TAG, "Input was disabled, but received ACTION_MOVE."); + return true; + } + + float dY = Math.abs(eventY - mDownY); + float dX = Math.abs(eventX - mDownX); + + if (!mDoingMove && dX <= TOUCH_SLOP && dY <= TOUCH_SLOP) { + // Hasn't registered down yet, just slight, accidental movement of finger. + break; + } + + // If we're in the middle of touching down on AM or PM, check if we still are. + // If so, no-op. If not, remove its pressed state. Either way, no need to check + // for touches on the other circle. + if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) { + mHandler.removeCallbacksAndMessages(null); + int isTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY); + if (isTouchingAmOrPm != mIsTouchingAmOrPm) { + mAmPmCirclesView.setAmOrPmPressed(-1); + mAmPmCirclesView.invalidate(); + mIsTouchingAmOrPm = -1; + } + break; + } + + if (mDownDegrees == -1) { + // Original down was illegal, so no movement will register. + break; + } + + // We're doing a move along the circle, so move the selection as appropriate. + mDoingMove = true; + mHandler.removeCallbacksAndMessages(null); + degrees = getDegreesFromCoords(eventX, eventY, true, isInnerCircle); + if (degrees != -1) { + value = roundToValidTime( + getTimeFromDegrees(degrees, isInnerCircle[0], false), + getCurrentItemShowing() + ); + reselectSelector(value, true, getCurrentItemShowing()); + if (value != null && (mLastValueSelected == null || !mLastValueSelected.equals(value))) { + mController.tryVibrate(); + mLastValueSelected = value; + mListener.onValueSelected(value); + } + } + return true; + case MotionEvent.ACTION_UP: + if (!mInputEnabled) { + // If our touch input was disabled, tell the listener to re-enable us. + Log.d(TAG, "Input was disabled, but received ACTION_UP."); + mListener.enablePicker(); + return true; + } + + mHandler.removeCallbacksAndMessages(null); + mDoingTouch = false; + + // If we're touching AM or PM, set it as selected, and tell the listener. + if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) { + int isTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY); + mAmPmCirclesView.setAmOrPmPressed(-1); + mAmPmCirclesView.invalidate(); + + if (isTouchingAmOrPm == mIsTouchingAmOrPm) { + mAmPmCirclesView.setAmOrPm(isTouchingAmOrPm); + if (getIsCurrentlyAmOrPm() != isTouchingAmOrPm) { + Timepoint newSelection = new Timepoint(mCurrentTime); + if(mIsTouchingAmOrPm == AM) newSelection.setAM(); + else if(mIsTouchingAmOrPm == PM) newSelection.setPM(); + newSelection = roundToValidTime(newSelection, HOUR_INDEX); + reselectSelector(newSelection, false, HOUR_INDEX); + mCurrentTime = newSelection; + mListener.onValueSelected(newSelection); + + } + } + mIsTouchingAmOrPm = -1; + break; + } + + // If we have a legal degrees selected, set the value and tell the listener. + if (mDownDegrees != -1) { + degrees = getDegreesFromCoords(eventX, eventY, mDoingMove, isInnerCircle); + if (degrees != -1) { + value = getTimeFromDegrees(degrees, isInnerCircle[0], !mDoingMove); + value = roundToValidTime(value, getCurrentItemShowing()); + reselectSelector(value, false, getCurrentItemShowing()); + mCurrentTime = value; + mListener.onValueSelected(value); + mListener.advancePicker(getCurrentItemShowing()); + } + } + mDoingMove = false; + return true; + default: + break; + } + return false; + } + + /** + * Set touch input as enabled or disabled, for use with keyboard mode. + */ + public boolean trySettingInputEnabled(boolean inputEnabled) { + if (mDoingTouch && !inputEnabled) { + // If we're trying to disable input, but we're in the middle of a touch event, + // we'll allow the touch event to continue before disabling input. + return false; + } + + mInputEnabled = inputEnabled; + mGrayBox.setVisibility(inputEnabled? View.INVISIBLE : View.VISIBLE); + return true; + } + + /** + * Necessary for accessibility, to ensure we support "scrolling" forward and backward + * in the circle. + */ + @Override + public void onInitializeAccessibilityNodeInfo(@NonNull AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(info); + if (Build.VERSION.SDK_INT >= 21) { + info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD); + info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD); + } + else { + info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD); + info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD); + } + } + + /** + * Announce the currently-selected time when launched. + */ + @Override + public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { + if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { + // Clear the event's current text so that only the current time will be spoken. + event.getText().clear(); + Calendar time = Calendar.getInstance(); + time.set(Calendar.HOUR, getHours()); + time.set(Calendar.MINUTE, getMinutes()); + time.set(Calendar.SECOND, getSeconds()); + long millis = time.getTimeInMillis(); + int flags = DateUtils.FORMAT_SHOW_TIME; + if (mIs24HourMode) { + flags |= DateUtils.FORMAT_24HOUR; + } + String timeString = DateUtils.formatDateTime(getContext(), millis, flags); + event.getText().add(timeString); + return true; + } + return super.dispatchPopulateAccessibilityEvent(event); + } + + /** + * When scroll forward/backward events are received, jump the time to the higher/lower + * discrete, visible value on the circle. + */ + @Override + public boolean performAccessibilityAction(int action, Bundle arguments) { + if (super.performAccessibilityAction(action, arguments)) { + return true; + } + + int changeMultiplier = 0; + int forward; + int backward; + if (Build.VERSION.SDK_INT >= 16) { + forward = AccessibilityNodeInfo.ACTION_SCROLL_FORWARD; + backward = AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD; + } else { + forward = AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD; + backward = AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD; + } + if (action == forward) { + changeMultiplier = 1; + } else if (action == backward) { + changeMultiplier = -1; + } + if (changeMultiplier != 0) { + int value = getCurrentlyShowingValue(); + int stepSize = 0; + int currentItemShowing = getCurrentItemShowing(); + if (currentItemShowing == HOUR_INDEX) { + stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE; + value %= 12; + } else if (currentItemShowing == MINUTE_INDEX) { + stepSize = MINUTE_VALUE_TO_DEGREES_STEP_SIZE; + } else if (currentItemShowing == SECOND_INDEX) { + stepSize = SECOND_VALUE_TO_DEGREES_STEP_SIZE; + } + + int degrees = value * stepSize; + degrees = snapOnly30s(degrees, changeMultiplier); + value = degrees / stepSize; + int maxValue = 0; + int minValue = 0; + if (currentItemShowing == HOUR_INDEX) { + if (mIs24HourMode) { + maxValue = 23; + } else { + maxValue = 12; + minValue = 1; + } + } else { + maxValue = 55; + } + if (value > maxValue) { + // If we scrolled forward past the highest number, wrap around to the lowest. + value = minValue; + } else if (value < minValue) { + // If we scrolled backward past the lowest number, wrap around to the highest. + value = maxValue; + } + + Timepoint newSelection; + switch(currentItemShowing) { + case HOUR_INDEX: + newSelection = new Timepoint( + value, + mCurrentTime.getMinute(), + mCurrentTime.getSecond() + ); + break; + case MINUTE_INDEX: + newSelection = new Timepoint( + mCurrentTime.getHour(), + value, + mCurrentTime.getSecond() + ); + break; + case SECOND_INDEX: + newSelection = new Timepoint( + mCurrentTime.getHour(), + mCurrentTime.getMinute(), + value + ); + break; + default: + newSelection = mCurrentTime; + } + + setItem(currentItemShowing, newSelection); + mListener.onValueSelected(newSelection); + return true; + } + + return false; + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/RadialSelectorView.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/RadialSelectorView.java new file mode 100644 index 00000000..bf8866ed --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/RadialSelectorView.java @@ -0,0 +1,398 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker.time; + +import android.animation.Keyframe; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; +import android.animation.ValueAnimator; +import android.animation.ValueAnimator.AnimatorUpdateListener; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.util.Log; +import android.view.View; + +import com.wdullaer.materialdatetimepicker.R; +import com.wdullaer.materialdatetimepicker.Utils; + +import java.lang.ref.WeakReference; + +/** + * View to show what number is selected. This will draw a blue circle over the number, with a blue + * line coming from the center of the main circle to the edge of the blue selection. + */ +public class RadialSelectorView extends View { + private static final String TAG = "RadialSelectorView"; + + // Alpha level for selected circle. + private static final int SELECTED_ALPHA = Utils.SELECTED_ALPHA; + private static final int SELECTED_ALPHA_THEME_DARK = Utils.SELECTED_ALPHA_THEME_DARK; + // Alpha level for the line. + private static final int FULL_ALPHA = Utils.FULL_ALPHA; + + private final Paint mPaint = new Paint(); + + private boolean mIsInitialized; + private boolean mDrawValuesReady; + + private float mCircleRadiusMultiplier; + private float mAmPmCircleRadiusMultiplier; + private float mInnerNumbersRadiusMultiplier; + private float mOuterNumbersRadiusMultiplier; + private float mNumbersRadiusMultiplier; + private float mSelectionRadiusMultiplier; + private float mAnimationRadiusMultiplier; + private boolean mIs24HourMode; + private boolean mHasInnerCircle; + private int mSelectionAlpha; + + private int mXCenter; + private int mYCenter; + private int mCircleRadius; + private float mTransitionMidRadiusMultiplier; + private float mTransitionEndRadiusMultiplier; + private int mLineLength; + private int mSelectionRadius; + private InvalidateUpdateListener mInvalidateUpdateListener; + + private int mSelectionDegrees; + private double mSelectionRadians; + private boolean mForceDrawDot; + + public RadialSelectorView(Context context) { + super(context); + mIsInitialized = false; + } + + /** + * Initialize this selector with the state of the picker. + * @param context Current context. + * @param controller Structure containing the accentColor and the 24-hour mode, which will tell us + * whether the circle's center is moved up slightly to make room for the AM/PM circles. + * @param hasInnerCircle Whether we have both an inner and an outer circle of numbers + * that may be selected. Should be true for 24-hour mode in the hours circle. + * @param disappearsOut Whether the numbers' animation will have them disappearing out + * or disappearing in. + * @param selectionDegrees The initial degrees to be selected. + * @param isInnerCircle Whether the initial selection is in the inner or outer circle. + * Will be ignored when hasInnerCircle is false. + */ + public void initialize(Context context, TimePickerController controller, boolean hasInnerCircle, + boolean disappearsOut, int selectionDegrees, boolean isInnerCircle) { + if (mIsInitialized) { + Log.e(TAG, "This RadialSelectorView may only be initialized once."); + return; + } + + Resources res = context.getResources(); + + int accentColor = controller.getAccentColor(); + mPaint.setColor(accentColor); + mPaint.setAntiAlias(true); + + mSelectionAlpha = controller.isThemeDark() ? SELECTED_ALPHA_THEME_DARK : SELECTED_ALPHA; + + // Calculate values for the circle radius size. + mIs24HourMode = controller.is24HourMode(); + if (mIs24HourMode || controller.getVersion() != TimePickerDialog.Version.VERSION_1) { + mCircleRadiusMultiplier = Float.parseFloat( + res.getString(R.string.mdtp_circle_radius_multiplier_24HourMode)); + } else { + mCircleRadiusMultiplier = Float.parseFloat( + res.getString(R.string.mdtp_circle_radius_multiplier)); + mAmPmCircleRadiusMultiplier = + Float.parseFloat(res.getString(R.string.mdtp_ampm_circle_radius_multiplier)); + } + + // Calculate values for the radius size(s) of the numbers circle(s). + mHasInnerCircle = hasInnerCircle; + if (hasInnerCircle) { + mInnerNumbersRadiusMultiplier = + Float.parseFloat(res.getString(R.string.mdtp_numbers_radius_multiplier_inner)); + mOuterNumbersRadiusMultiplier = + Float.parseFloat(res.getString(R.string.mdtp_numbers_radius_multiplier_outer)); + } else { + mNumbersRadiusMultiplier = + Float.parseFloat(res.getString(R.string.mdtp_numbers_radius_multiplier_normal)); + } + mSelectionRadiusMultiplier = + Float.parseFloat(res.getString(R.string.mdtp_selection_radius_multiplier)); + + // Calculate values for the transition mid-way states. + mAnimationRadiusMultiplier = 1; + mTransitionMidRadiusMultiplier = 1f + (0.05f * (disappearsOut? -1 : 1)); + mTransitionEndRadiusMultiplier = 1f + (0.3f * (disappearsOut? 1 : -1)); + mInvalidateUpdateListener = new InvalidateUpdateListener(this); + + setSelection(selectionDegrees, isInnerCircle, false); + mIsInitialized = true; + } + + /** + * Set the selection. + * @param selectionDegrees The degrees to be selected. + * @param isInnerCircle Whether the selection should be in the inner circle or outer. Will be + * ignored if hasInnerCircle was initialized to false. + * @param forceDrawDot Whether to force the dot in the center of the selection circle to be + * drawn. If false, the dot will be drawn only when the degrees is not a multiple of 30, i.e. + * the selection is not on a visible number. + */ + public void setSelection(int selectionDegrees, boolean isInnerCircle, boolean forceDrawDot) { + mSelectionDegrees = selectionDegrees; + mSelectionRadians = selectionDegrees * Math.PI / 180; + mForceDrawDot = forceDrawDot; + + if (mHasInnerCircle) { + if (isInnerCircle) { + mNumbersRadiusMultiplier = mInnerNumbersRadiusMultiplier; + } else { + mNumbersRadiusMultiplier = mOuterNumbersRadiusMultiplier; + } + } + } + + /** + * Allows for smoother animations. + */ + @Override + public boolean hasOverlappingRendering() { + return false; + } + + /** + * Set the multiplier for the radius. Will be used during animations to move in/out. + */ + @SuppressWarnings("unused") + public void setAnimationRadiusMultiplier(float animationRadiusMultiplier) { + mAnimationRadiusMultiplier = animationRadiusMultiplier; + } + + public int getDegreesFromCoords(float pointX, float pointY, boolean forceLegal, + final Boolean[] isInnerCircle) { + if (!mDrawValuesReady) { + return -1; + } + + double hypotenuse = Math.sqrt( + (pointY - mYCenter)*(pointY - mYCenter) + + (pointX - mXCenter)*(pointX - mXCenter)); + // Check if we're outside the range + if (mHasInnerCircle) { + if (forceLegal) { + // If we're told to force the coordinates to be legal, we'll set the isInnerCircle + // boolean based based off whichever number the coordinates are closer to. + int innerNumberRadius = (int) (mCircleRadius * mInnerNumbersRadiusMultiplier); + int distanceToInnerNumber = (int) Math.abs(hypotenuse - innerNumberRadius); + int outerNumberRadius = (int) (mCircleRadius * mOuterNumbersRadiusMultiplier); + int distanceToOuterNumber = (int) Math.abs(hypotenuse - outerNumberRadius); + + isInnerCircle[0] = (distanceToInnerNumber <= distanceToOuterNumber); + } else { + // Otherwise, if we're close enough to either number (with the space between the + // two allotted equally), set the isInnerCircle boolean as the closer one. + // appropriately, but otherwise return -1. + int minAllowedHypotenuseForInnerNumber = + (int) (mCircleRadius * mInnerNumbersRadiusMultiplier) - mSelectionRadius; + int maxAllowedHypotenuseForOuterNumber = + (int) (mCircleRadius * mOuterNumbersRadiusMultiplier) + mSelectionRadius; + int halfwayHypotenusePoint = (int) (mCircleRadius * + ((mOuterNumbersRadiusMultiplier + mInnerNumbersRadiusMultiplier) / 2)); + + if (hypotenuse >= minAllowedHypotenuseForInnerNumber && + hypotenuse <= halfwayHypotenusePoint) { + isInnerCircle[0] = true; + } else if (hypotenuse <= maxAllowedHypotenuseForOuterNumber && + hypotenuse >= halfwayHypotenusePoint) { + isInnerCircle[0] = false; + } else { + return -1; + } + } + } else { + // If there's just one circle, we'll need to return -1 if: + // we're not told to force the coordinates to be legal, and + // the coordinates' distance to the number is within the allowed distance. + if (!forceLegal) { + int distanceToNumber = (int) Math.abs(hypotenuse - mLineLength); + // The max allowed distance will be defined as the distance from the center of the + // number to the edge of the circle. + int maxAllowedDistance = (int) (mCircleRadius * (1 - mNumbersRadiusMultiplier)); + if (distanceToNumber > maxAllowedDistance) { + return -1; + } + } + } + + + float opposite = Math.abs(pointY - mYCenter); + double radians = Math.asin(opposite / hypotenuse); + int degrees = (int) (radians * 180 / Math.PI); + + // Now we have to translate to the correct quadrant. + boolean rightSide = (pointX > mXCenter); + boolean topSide = (pointY < mYCenter); + if (rightSide && topSide) { + degrees = 90 - degrees; + } else if (rightSide && !topSide) { + degrees = 90 + degrees; + } else if (!rightSide && !topSide) { + degrees = 270 - degrees; + } else if (!rightSide && topSide) { + degrees = 270 + degrees; + } + return degrees; + } + + @Override + public void onDraw(Canvas canvas) { + int viewWidth = getWidth(); + if (viewWidth == 0 || !mIsInitialized) { + return; + } + + if (!mDrawValuesReady) { + mXCenter = getWidth() / 2; + mYCenter = getHeight() / 2; + mCircleRadius = (int) (Math.min(mXCenter, mYCenter) * mCircleRadiusMultiplier); + + if (!mIs24HourMode) { + // We'll need to draw the AM/PM circles, so the main circle will need to have + // a slightly higher center. To keep the entire view centered vertically, we'll + // have to push it up by half the radius of the AM/PM circles. + int amPmCircleRadius = (int) (mCircleRadius * mAmPmCircleRadiusMultiplier); + mYCenter -= amPmCircleRadius *0.75; + } + + mSelectionRadius = (int) (mCircleRadius * mSelectionRadiusMultiplier); + + mDrawValuesReady = true; + } + + // Calculate the current radius at which to place the selection circle. + mLineLength = (int) (mCircleRadius * mNumbersRadiusMultiplier * mAnimationRadiusMultiplier); + int pointX = mXCenter + (int) (mLineLength * Math.sin(mSelectionRadians)); + int pointY = mYCenter - (int) (mLineLength * Math.cos(mSelectionRadians)); + + // Draw the selection circle. + mPaint.setAlpha(mSelectionAlpha); + canvas.drawCircle(pointX, pointY, mSelectionRadius, mPaint); + + if (mForceDrawDot | mSelectionDegrees % 30 != 0) { + // We're not on a direct tick (or we've been told to draw the dot anyway). + mPaint.setAlpha(FULL_ALPHA); + canvas.drawCircle(pointX, pointY, (mSelectionRadius * 2 / 7), mPaint); + } else { + // We're not drawing the dot, so shorten the line to only go as far as the edge of the + // selection circle. + int lineLength = mLineLength; + lineLength -= mSelectionRadius; + pointX = mXCenter + (int) (lineLength * Math.sin(mSelectionRadians)); + pointY = mYCenter - (int) (lineLength * Math.cos(mSelectionRadians)); + } + + // Draw the line from the center of the circle. + mPaint.setAlpha(255); + mPaint.setStrokeWidth(3); + canvas.drawLine(mXCenter, mYCenter, pointX, pointY, mPaint); + } + + public ObjectAnimator getDisappearAnimator() { + if (!mIsInitialized || !mDrawValuesReady) { + Log.e(TAG, "RadialSelectorView was not ready for animation."); + return null; + } + + Keyframe kf0, kf1, kf2; + float midwayPoint = 0.2f; + int duration = 500; + + kf0 = Keyframe.ofFloat(0f, 1); + kf1 = Keyframe.ofFloat(midwayPoint, mTransitionMidRadiusMultiplier); + kf2 = Keyframe.ofFloat(1f, mTransitionEndRadiusMultiplier); + PropertyValuesHolder radiusDisappear = PropertyValuesHolder.ofKeyframe( + "animationRadiusMultiplier", kf0, kf1, kf2); + + kf0 = Keyframe.ofFloat(0f, 1f); + kf1 = Keyframe.ofFloat(1f, 0f); + PropertyValuesHolder fadeOut = PropertyValuesHolder.ofKeyframe("alpha", kf0, kf1); + + ObjectAnimator disappearAnimator = ObjectAnimator.ofPropertyValuesHolder( + this, radiusDisappear, fadeOut).setDuration(duration); + disappearAnimator.addUpdateListener(mInvalidateUpdateListener); + + return disappearAnimator; + } + + public ObjectAnimator getReappearAnimator() { + if (!mIsInitialized || !mDrawValuesReady) { + Log.e(TAG, "RadialSelectorView was not ready for animation."); + return null; + } + + Keyframe kf0, kf1, kf2, kf3; + float midwayPoint = 0.2f; + int duration = 500; + + // The time points are half of what they would normally be, because this animation is + // staggered against the disappear so they happen seamlessly. The reappear starts + // halfway into the disappear. + float delayMultiplier = 0.25f; + float transitionDurationMultiplier = 1f; + float totalDurationMultiplier = transitionDurationMultiplier + delayMultiplier; + int totalDuration = (int) (duration * totalDurationMultiplier); + float delayPoint = (delayMultiplier * duration) / totalDuration; + midwayPoint = 1 - (midwayPoint * (1 - delayPoint)); + + kf0 = Keyframe.ofFloat(0f, mTransitionEndRadiusMultiplier); + kf1 = Keyframe.ofFloat(delayPoint, mTransitionEndRadiusMultiplier); + kf2 = Keyframe.ofFloat(midwayPoint, mTransitionMidRadiusMultiplier); + kf3 = Keyframe.ofFloat(1f, 1); + PropertyValuesHolder radiusReappear = PropertyValuesHolder.ofKeyframe( + "animationRadiusMultiplier", kf0, kf1, kf2, kf3); + + kf0 = Keyframe.ofFloat(0f, 0f); + kf1 = Keyframe.ofFloat(delayPoint, 0f); + kf2 = Keyframe.ofFloat(1f, 1f); + PropertyValuesHolder fadeIn = PropertyValuesHolder.ofKeyframe("alpha", kf0, kf1, kf2); + + ObjectAnimator reappearAnimator = ObjectAnimator.ofPropertyValuesHolder( + this, radiusReappear, fadeIn).setDuration(totalDuration); + reappearAnimator.addUpdateListener(mInvalidateUpdateListener); + return reappearAnimator; + } + + /** + * We'll need to invalidate during the animation. + */ + private static class InvalidateUpdateListener implements AnimatorUpdateListener { + private final WeakReference selectorRef; + + InvalidateUpdateListener(RadialSelectorView selectorView) { + this.selectorRef = new WeakReference<>(selectorView); + } + + @Override + public void onAnimationUpdate(ValueAnimator animation) { + RadialSelectorView selectorView = selectorRef.get(); + if (selectorView != null) { + selectorView.invalidate(); + } + } + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/RadialTextsView.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/RadialTextsView.java new file mode 100644 index 00000000..85d1f541 --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/RadialTextsView.java @@ -0,0 +1,407 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wdullaer.materialdatetimepicker.time; + +import android.animation.Keyframe; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; +import android.animation.ValueAnimator; +import android.animation.ValueAnimator.AnimatorUpdateListener; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Typeface; +import android.graphics.Paint.Align; +import androidx.core.content.ContextCompat; +import android.util.Log; +import android.view.View; + +import com.wdullaer.materialdatetimepicker.R; + +/** + * A view to show a series of numbers in a circular pattern. + */ +public class RadialTextsView extends View { + private final static String TAG = "RadialTextsView"; + + private final Paint mPaint = new Paint(); + private final Paint mSelectedPaint = new Paint(); + private final Paint mInactivePaint = new Paint(); + + private boolean mDrawValuesReady; + private boolean mIsInitialized; + + private int selection = -1; + + private SelectionValidator mValidator; + + private Typeface mTypefaceLight; + private Typeface mTypefaceRegular; + private String[] mTexts; + private String[] mInnerTexts; + private boolean mIs24HourMode; + private boolean mHasInnerCircle; + private float mCircleRadiusMultiplier; + private float mAmPmCircleRadiusMultiplier; + private float mNumbersRadiusMultiplier; + private float mInnerNumbersRadiusMultiplier; + private float mTextSizeMultiplier; + private float mInnerTextSizeMultiplier; + + private int mXCenter; + private int mYCenter; + private float mCircleRadius; + private boolean mTextGridValuesDirty; + private float mTextSize; + private float mInnerTextSize; + private float[] mTextGridHeights; + private float[] mTextGridWidths; + private float[] mInnerTextGridHeights; + private float[] mInnerTextGridWidths; + + private float mAnimationRadiusMultiplier; + private float mTransitionMidRadiusMultiplier; + private float mTransitionEndRadiusMultiplier; + ObjectAnimator mDisappearAnimator; + ObjectAnimator mReappearAnimator; + private InvalidateUpdateListener mInvalidateUpdateListener; + + public RadialTextsView(Context context) { + super(context); + mIsInitialized = false; + } + + public void initialize(Context context, String[] texts, String[] innerTexts, + TimePickerController controller, SelectionValidator validator, boolean disappearsOut) { + if (mIsInitialized) { + Log.e(TAG, "This RadialTextsView may only be initialized once."); + return; + } + Resources res = context.getResources(); + + // Set up the paint. + int textColorRes = controller.isThemeDark() ? R.color.mdtp_white : R.color.mdtp_numbers_text_color; + mPaint.setColor(ContextCompat.getColor(context, textColorRes)); + String typefaceFamily = res.getString(R.string.mdtp_radial_numbers_typeface); + mTypefaceLight = Typeface.create(typefaceFamily, Typeface.NORMAL); + String typefaceFamilyRegular = res.getString(R.string.mdtp_sans_serif); + mTypefaceRegular = Typeface.create(typefaceFamilyRegular, Typeface.NORMAL); + mPaint.setAntiAlias(true); + mPaint.setTextAlign(Align.CENTER); + + // Set up the selected paint + int selectedTextColor = ContextCompat.getColor(context, R.color.mdtp_white); + mSelectedPaint.setColor(selectedTextColor); + mSelectedPaint.setAntiAlias(true); + mSelectedPaint.setTextAlign(Align.CENTER); + + // Set up the inactive paint + int inactiveColorRes = controller.isThemeDark() ? R.color.mdtp_date_picker_text_disabled_dark_theme + : R.color.mdtp_date_picker_text_disabled; + mInactivePaint.setColor(ContextCompat.getColor(context, inactiveColorRes)); + mInactivePaint.setAntiAlias(true); + mInactivePaint.setTextAlign(Align.CENTER); + + mTexts = texts; + mInnerTexts = innerTexts; + mIs24HourMode = controller.is24HourMode(); + mHasInnerCircle = (innerTexts != null); + + // Calculate the radius for the main circle. + if (mIs24HourMode || controller.getVersion() != TimePickerDialog.Version.VERSION_1) { + mCircleRadiusMultiplier = Float.parseFloat( + res.getString(R.string.mdtp_circle_radius_multiplier_24HourMode)); + } else { + mCircleRadiusMultiplier = Float.parseFloat( + res.getString(R.string.mdtp_circle_radius_multiplier)); + mAmPmCircleRadiusMultiplier = + Float.parseFloat(res.getString(R.string.mdtp_ampm_circle_radius_multiplier)); + } + + // Initialize the widths and heights of the grid, and calculate the values for the numbers. + mTextGridHeights = new float[7]; + mTextGridWidths = new float[7]; + if (mHasInnerCircle) { + mNumbersRadiusMultiplier = Float.parseFloat( + res.getString(R.string.mdtp_numbers_radius_multiplier_outer)); + mInnerNumbersRadiusMultiplier = Float.parseFloat( + res.getString(R.string.mdtp_numbers_radius_multiplier_inner)); + + // Version 2 layout draws outer circle bigger than inner + if (controller.getVersion() == TimePickerDialog.Version.VERSION_1) { + mTextSizeMultiplier = Float.parseFloat( + res.getString(R.string.mdtp_text_size_multiplier_outer)); + mInnerTextSizeMultiplier = Float.parseFloat( + res.getString(R.string.mdtp_text_size_multiplier_inner)); + } else { + mTextSizeMultiplier = Float.parseFloat( + res.getString(R.string.mdtp_text_size_multiplier_outer_v2)); + mInnerTextSizeMultiplier = Float.parseFloat( + res.getString(R.string.mdtp_text_size_multiplier_inner_v2)); + } + + mInnerTextGridHeights = new float[7]; + mInnerTextGridWidths = new float[7]; + } else { + mNumbersRadiusMultiplier = Float.parseFloat( + res.getString(R.string.mdtp_numbers_radius_multiplier_normal)); + mTextSizeMultiplier = Float.parseFloat( + res.getString(R.string.mdtp_text_size_multiplier_normal)); + } + + mAnimationRadiusMultiplier = 1; + mTransitionMidRadiusMultiplier = 1f + (0.05f * (disappearsOut? -1 : 1)); + mTransitionEndRadiusMultiplier = 1f + (0.3f * (disappearsOut? 1 : -1)); + mInvalidateUpdateListener = new InvalidateUpdateListener(); + + mValidator = validator; + + mTextGridValuesDirty = true; + mIsInitialized = true; + } + + /** + * Set the value of the selected text. Depending on the theme this will be rendered differently + * @param selection The text which is currently selected + */ + protected void setSelection(int selection) { + this.selection = selection; + } + + /** + * Allows for smoother animation. + */ + @Override + public boolean hasOverlappingRendering() { + return false; + } + + /** + * Used by the animation to move the numbers in and out. + */ + @SuppressWarnings("unused") + public void setAnimationRadiusMultiplier(float animationRadiusMultiplier) { + mAnimationRadiusMultiplier = animationRadiusMultiplier; + mTextGridValuesDirty = true; + } + + @Override + public void onDraw(Canvas canvas) { + int viewWidth = getWidth(); + if (viewWidth == 0 || !mIsInitialized) { + return; + } + + if (!mDrawValuesReady) { + mXCenter = getWidth() / 2; + mYCenter = getHeight() / 2; + mCircleRadius = Math.min(mXCenter, mYCenter) * mCircleRadiusMultiplier; + if (!mIs24HourMode) { + // We'll need to draw the AM/PM circles, so the main circle will need to have + // a slightly higher center. To keep the entire view centered vertically, we'll + // have to push it up by half the radius of the AM/PM circles. + float amPmCircleRadius = mCircleRadius * mAmPmCircleRadiusMultiplier; + mYCenter -= amPmCircleRadius *0.75; + } + + mTextSize = mCircleRadius * mTextSizeMultiplier; + if (mHasInnerCircle) { + mInnerTextSize = mCircleRadius * mInnerTextSizeMultiplier; + } + + // Because the text positions will be static, pre-render the animations. + renderAnimations(); + + mTextGridValuesDirty = true; + mDrawValuesReady = true; + } + + // Calculate the text positions, but only if they've changed since the last onDraw. + if (mTextGridValuesDirty) { + float numbersRadius = + mCircleRadius * mNumbersRadiusMultiplier * mAnimationRadiusMultiplier; + + // Calculate the positions for the 12 numbers in the main circle. + calculateGridSizes(numbersRadius, mXCenter, mYCenter, + mTextSize, mTextGridHeights, mTextGridWidths); + if (mHasInnerCircle) { + // If we have an inner circle, calculate those positions too. + float innerNumbersRadius = + mCircleRadius * mInnerNumbersRadiusMultiplier * mAnimationRadiusMultiplier; + calculateGridSizes(innerNumbersRadius, mXCenter, mYCenter, + mInnerTextSize, mInnerTextGridHeights, mInnerTextGridWidths); + } + mTextGridValuesDirty = false; + } + + // Draw the texts in the pre-calculated positions. + drawTexts(canvas, mTextSize, mTypefaceLight, mTexts, mTextGridWidths, mTextGridHeights); + if (mHasInnerCircle) { + drawTexts(canvas, mInnerTextSize, mTypefaceRegular, mInnerTexts, + mInnerTextGridWidths, mInnerTextGridHeights); + } + } + + /** + * Using the trigonometric Unit Circle, calculate the positions that the text will need to be + * drawn at based on the specified circle radius. Place the values in the textGridHeights and + * textGridWidths parameters. + */ + private void calculateGridSizes(float numbersRadius, float xCenter, float yCenter, + float textSize, float[] textGridHeights, float[] textGridWidths) { + /* + * The numbers need to be drawn in a 7x7 grid, representing the points on the Unit Circle. + */ + float offset1 = numbersRadius; + // cos(30) = a / r => r * cos(30) = a => r * √3/2 = a + float offset2 = numbersRadius * ((float) Math.sqrt(3)) / 2f; + // sin(30) = o / r => r * sin(30) = o => r / 2 = a + float offset3 = numbersRadius / 2f; + mPaint.setTextSize(textSize); + mSelectedPaint.setTextSize(textSize); + mInactivePaint.setTextSize(textSize); + // We'll need yTextBase to be slightly lower to account for the text's baseline. + yCenter -= (mPaint.descent() + mPaint.ascent()) / 2; + + textGridHeights[0] = yCenter - offset1; + textGridWidths[0] = xCenter - offset1; + textGridHeights[1] = yCenter - offset2; + textGridWidths[1] = xCenter - offset2; + textGridHeights[2] = yCenter - offset3; + textGridWidths[2] = xCenter - offset3; + textGridHeights[3] = yCenter; + textGridWidths[3] = xCenter; + textGridHeights[4] = yCenter + offset3; + textGridWidths[4] = xCenter + offset3; + textGridHeights[5] = yCenter + offset2; + textGridWidths[5] = xCenter + offset2; + textGridHeights[6] = yCenter + offset1; + textGridWidths[6] = xCenter + offset1; + } + + private Paint[] assignTextColors(String[] texts) { + Paint[] paints = new Paint[texts.length]; + for(int i=0;i mTypedTimes; + private Node mLegalTimesTree; + private int mAmKeyCode; + private int mPmKeyCode; + + // Accessibility strings. + private String mHourPickerDescription; + private String mSelectHours; + private String mMinutePickerDescription; + private String mSelectMinutes; + private String mSecondPickerDescription; + private String mSelectSeconds; + + /** + * The callback interface used to indicate the user is done filling in + * the time (they clicked on the 'Set' button). + */ + public interface OnTimeSetListener { + + /** + * @param view The view associated with this listener. + * @param hourOfDay The hour that was set. + * @param minute The minute that was set. + * @param second The second that was set + */ + void onTimeSet(TimePickerDialog view, int hourOfDay, int minute, int second); + } + + public TimePickerDialog() { + // Empty constructor required for dialog fragment. + } + + /** + * Create a new TimePickerDialog instance with a given intial selection + * + * @param callback How the parent is notified that the time is set. + * @param hourOfDay The initial hour of the dialog. + * @param minute The initial minute of the dialog. + * @param second The initial second of the dialog. + * @param is24HourMode True to render 24 hour mode, false to render AM / PM selectors. + * @return a new TimePickerDialog instance. + */ + @SuppressWarnings({"SameParameterValue", "WeakerAccess"}) + public static TimePickerDialog newInstance(OnTimeSetListener callback, + int hourOfDay, int minute, int second, boolean is24HourMode) { + TimePickerDialog ret = new TimePickerDialog(); + ret.initialize(callback, hourOfDay, minute, second, is24HourMode); + return ret; + } + + /** + * Create a new TimePickerDialog instance with a given initial selection + * + * @param callback How the parent is notified that the time is set. + * @param hourOfDay The initial hour of the dialog. + * @param minute The initial minute of the dialog. + * @param is24HourMode True to render 24 hour mode, false to render AM / PM selectors. + * @return a new TimePickerDialog instance. + */ + public static TimePickerDialog newInstance(OnTimeSetListener callback, + int hourOfDay, int minute, boolean is24HourMode) { + return TimePickerDialog.newInstance(callback, hourOfDay, minute, 0, is24HourMode); + } + + /** + * Create a new TimePickerDialog instance initialized to the current system time + * + * @param callback How the parent is notified that the time is set. + * @param is24HourMode True to render 24 hour mode, false to render AM / PM selectors. + * @return a new TimePickerDialog instance. + */ + @SuppressWarnings({"unused", "SameParameterValue", "WeakerAccess"}) + public static TimePickerDialog newInstance(OnTimeSetListener callback, boolean is24HourMode) { + Calendar now = Calendar.getInstance(); + return TimePickerDialog.newInstance(callback, now.get(Calendar.HOUR_OF_DAY), now.get(Calendar.MINUTE), is24HourMode); + } + + public void initialize(OnTimeSetListener callback, + int hourOfDay, int minute, int second, boolean is24HourMode) { + mCallback = callback; + + mInitialTime = new Timepoint(hourOfDay, minute, second); + mIs24HourMode = is24HourMode; + mInKbMode = false; + mTitle = ""; + mThemeDark = false; + mThemeDarkChanged = false; + mVibrate = true; + mDismissOnPause = false; + mEnableSeconds = false; + mEnableMinutes = true; + mOkResid = R.string.mdtp_ok; + mCancelResid = R.string.mdtp_cancel; + mVersion = Build.VERSION.SDK_INT < Build.VERSION_CODES.M ? Version.VERSION_1 : Version.VERSION_2; + // Throw away the current TimePicker, which might contain old state if the dialog instance is reused + mTimePicker = null; + } + + /** + * Set a title. NOTE: this will only take effect with the next onCreateView + */ + public void setTitle(String title) { + mTitle = title; + } + + @SuppressWarnings("unused") + public String getTitle() { + return mTitle; + } + + /** + * Set a dark or light theme. NOTE: this will only take effect for the next onCreateView. + */ + public void setThemeDark(boolean dark) { + mThemeDark = dark; + mThemeDarkChanged = true; + } + + /** + * Set the accent color of this dialog + * + * @param color the accent color you want + */ + @SuppressWarnings("unused") + public void setAccentColor(String color) { + mAccentColor = Color.parseColor(color); + } + + /** + * Set the accent color of this dialog + * + * @param color the accent color you want + */ + public void setAccentColor(@ColorInt int color) { + mAccentColor = Color.argb(255, Color.red(color), Color.green(color), Color.blue(color)); + } + + /** + * Set the text color of the OK button + * + * @param color the color you want + */ + @SuppressWarnings("unused") + public void setOkColor(String color) { + mOkColor = Color.parseColor(color); + } + + /** + * Set the text color of the OK button + * + * @param color the color you want + */ + @SuppressWarnings("unused") + public void setOkColor(@ColorInt int color) { + mOkColor = Color.argb(255, Color.red(color), Color.green(color), Color.blue(color)); + } + + /** + * Set the text color of the Cancel button + * + * @param color the color you want + */ + @SuppressWarnings("unused") + public void setCancelColor(String color) { + mCancelColor = Color.parseColor(color); + } + + /** + * Set the text color of the Cancel button + * + * @param color the color you want + */ + @SuppressWarnings("unused") + public void setCancelColor(@ColorInt int color) { + mCancelColor = Color.argb(255, Color.red(color), Color.green(color), Color.blue(color)); + } + + @Override + public boolean isThemeDark() { + return mThemeDark; + } + + @Override + public boolean is24HourMode() { + return mIs24HourMode; + } + + @Override + public int getAccentColor() { + return mAccentColor; + } + + /** + * Set whether the device should vibrate when touching fields + * + * @param vibrate true if the device should vibrate when touching a field + */ + public void vibrate(boolean vibrate) { + mVibrate = vibrate; + } + + /** + * Set whether the picker should dismiss itself when it's pausing or whether it should try to survive an orientation change + * + * @param dismissOnPause true if the picker should dismiss itself + */ + public void dismissOnPause(boolean dismissOnPause) { + mDismissOnPause = dismissOnPause; + } + + /** + * Set whether an additional picker for seconds should be shown + * Will enable minutes picker as well if seconds picker should be shown + * + * @param enableSeconds true if the seconds picker should be shown + */ + public void enableSeconds(boolean enableSeconds) { + if (enableSeconds) mEnableMinutes = true; + mEnableSeconds = enableSeconds; + } + + /** + * Set whether the picker for minutes should be shown + * Will disable seconds if minutes are disbled + * + * @param enableMinutes true if minutes picker should be shown + */ + @SuppressWarnings({"unused", "WeakerAccess"}) + public void enableMinutes(boolean enableMinutes) { + if (!enableMinutes) mEnableSeconds = false; + mEnableMinutes = enableMinutes; + } + + @SuppressWarnings("unused") + public void setMinTime(int hour, int minute, int second) { + setMinTime(new Timepoint(hour, minute, second)); + } + + @SuppressWarnings("WeakerAccess") + public void setMinTime(Timepoint minTime) { + mDefaultLimiter.setMinTime(minTime); + } + + @SuppressWarnings("unused") + public void setMaxTime(int hour, int minute, int second) { + setMaxTime(new Timepoint(hour, minute, second)); + } + + @SuppressWarnings("WeakerAccess") + public void setMaxTime(Timepoint maxTime) { + mDefaultLimiter.setMaxTime(maxTime); + } + + /** + * Pass in an array of Timepoints which are the only possible selections. + * Try to specify Timepoints only up to the resolution of your picker (i.e. do not add seconds + * if the resolution of the picker is minutes) + * + * @param selectableTimes Array of Timepoints which are the only valid selections in the picker + */ + @SuppressWarnings("WeakerAccess") + public void setSelectableTimes(Timepoint[] selectableTimes) { + mDefaultLimiter.setSelectableTimes(selectableTimes); + } + + /** + * Pass in an array of Timepoints that cannot be selected. These take precedence over + * {@link TimePickerDialog#setSelectableTimes(Timepoint[])} + * Be careful when using this without selectableTimes: rounding to a valid Timepoint is a + * very expensive operation if a lot of consecutive Timepoints are disabled + * Try to specify Timepoints only up to the resolution of your picker (i.e. do not add seconds + * if the resolution of the picker is minutes) + * + * @param disabledTimes Array of Timepoints which are disabled in the resulting picker + */ + public void setDisabledTimes(Timepoint[] disabledTimes) { + mDefaultLimiter.setDisabledTimes(disabledTimes); + } + + /** + * Set the interval for selectable times in the TimePickerDialog + * This is a convenience wrapper around {@link TimePickerDialog#setSelectableTimes(Timepoint[])} + * The interval for all three time components can be set independently + * If you are not using the seconds / minutes picker, set the respective item to 60 for + * better performance. + * + * @param hourInterval The interval between 2 selectable hours ([1,24]) + * @param minuteInterval The interval between 2 selectable minutes ([1,60]) + * @param secondInterval The interval between 2 selectable seconds ([1,60]) + */ + public void setTimeInterval(@IntRange(from = 1, to = 24) int hourInterval, + @IntRange(from = 1, to = 60) int minuteInterval, + @IntRange(from = 1, to = 60) int secondInterval) { + List timepoints = new ArrayList<>(); + + int hour = 0; + while (hour < 24) { + int minute = 0; + while (minute < 60) { + int second = 0; + while (second < 60) { + timepoints.add(new Timepoint(hour, minute, second)); + second += secondInterval; + } + minute += minuteInterval; + } + hour += hourInterval; + } + setSelectableTimes(timepoints.toArray(new Timepoint[timepoints.size()])); + } + + /** + * Set the interval for selectable times in the TimePickerDialog + * This is a convenience wrapper around setSelectableTimes + * The interval for all three time components can be set independently + * If you are not using the seconds / minutes picker, set the respective item to 60 for + * better performance. + * + * @param hourInterval The interval between 2 selectable hours ([1,24]) + * @param minuteInterval The interval between 2 selectable minutes ([1,60]) + */ + @SuppressWarnings({"SameParameterValue", "WeakerAccess"}) + public void setTimeInterval(@IntRange(from = 1, to = 24) int hourInterval, + @IntRange(from = 1, to = 60) int minuteInterval) { + setTimeInterval(hourInterval, minuteInterval, 60); + } + + /** + * Set the interval for selectable times in the TimePickerDialog + * This is a convenience wrapper around setSelectableTimes + * The interval for all three time components can be set independently + * If you are not using the seconds / minutes picker, set the respective item to 60 for + * better performance. + * + * @param hourInterval The interval between 2 selectable hours ([1,24]) + */ + @SuppressWarnings("unused") + public void setTimeInterval(@IntRange(from = 1, to = 24) int hourInterval) { + setTimeInterval(hourInterval, 60); + } + + public void setOnTimeSetListener(OnTimeSetListener callback) { + mCallback = callback; + } + + public void setOnCancelListener(DialogInterface.OnCancelListener onCancelListener) { + mOnCancelListener = onCancelListener; + } + + @SuppressWarnings("unused") + public void setOnDismissListener(DialogInterface.OnDismissListener onDismissListener) { + mOnDismissListener = onDismissListener; + } + + /** + * Set the time that will be shown when the picker opens for the first time + * Overrides the value given in newInstance() + * + * @param hourOfDay the hour of the day + * @param minute the minute of the hour + * @param second the second of the minute + * @deprecated in favor of {@link #setInitialSelection(int, int, int)} + */ + @Deprecated + public void setStartTime(int hourOfDay, int minute, int second) { + mInitialTime = roundToNearest(new Timepoint(hourOfDay, minute, second)); + mInKbMode = false; + } + + /** + * Set the time that will be shown when the picker opens for the first time + * Overrides the value given in newInstance + * + * @param hourOfDay the hour of the day + * @param minute the minute of the hour + * @deprecated in favor of {@link #setInitialSelection(int, int)} + */ + @SuppressWarnings({"unused", "deprecation"}) + @Deprecated + public void setStartTime(int hourOfDay, int minute) { + setStartTime(hourOfDay, minute, 0); + } + + /** + * Set the time that will be shown when the picker opens for the first time + * Overrides the value given in newInstance() + * + * @param hourOfDay the hour of the day + * @param minute the minute of the hour + * @param second the second of the minute + */ + @SuppressWarnings("WeakerAccess") + public void setInitialSelection(int hourOfDay, int minute, int second) { + setInitialSelection(new Timepoint(hourOfDay, minute, second)); + } + + /** + * Set the time that will be shown when the picker opens for the first time + * Overrides the value given in newInstance + * + * @param hourOfDay the hour of the day + * @param minute the minute of the hour + */ + @SuppressWarnings({"unused", "WeakerAccess"}) + public void setInitialSelection(int hourOfDay, int minute) { + setInitialSelection(hourOfDay, minute, 0); + } + + /** + * Set the time that will be shown when the picker opens for the first time + * Overrides the value given in newInstance() + * + * @param time the Timepoint selected when the Dialog opens + */ + @SuppressWarnings("WeakerAccess") + public void setInitialSelection(Timepoint time) { + mInitialTime = roundToNearest(time); + mInKbMode = false; + } + + /** + * Set the label for the Ok button (max 12 characters) + * + * @param okString A literal String to be used as the Ok button label + */ + @SuppressWarnings("unused") + public void setOkText(String okString) { + mOkString = okString; + } + + /** + * Set the label for the Ok button (max 12 characters) + * + * @param okResid A resource ID to be used as the Ok button label + */ + @SuppressWarnings("unused") + public void setOkText(@StringRes int okResid) { + mOkString = null; + mOkResid = okResid; + } + + /** + * Set the label for the Cancel button (max 12 characters) + * + * @param cancelString A literal String to be used as the Cancel button label + */ + @SuppressWarnings("unused") + public void setCancelText(String cancelString) { + mCancelString = cancelString; + } + + /** + * Set the label for the Cancel button (max 12 characters) + * + * @param cancelResid A resource ID to be used as the Cancel button label + */ + @SuppressWarnings("unused") + public void setCancelText(@StringRes int cancelResid) { + mCancelString = null; + mCancelResid = cancelResid; + } + + /** + * Set which layout version the picker should use + * + * @param version The version to use + */ + public void setVersion(Version version) { + mVersion = version; + } + + /** + * Pass in a custom implementation of TimeLimiter + * Disables setSelectableTimes, setDisabledTimes, setTimeInterval, setMinTime and setMaxTime + * + * @param limiter A custom implementation of TimeLimiter + */ + @SuppressWarnings("unused") + public void setTimepointLimiter(TimepointLimiter limiter) { + mLimiter = limiter; + } + + @Override + public Version getVersion() { + return mVersion; + } + + /** + * Get a reference to the OnTimeSetListener callback + * + * @return OnTimeSetListener the callback + */ + @SuppressWarnings("unused") + public OnTimeSetListener getOnTimeSetListener() { + return mCallback; + } + + /** + * Set the Locale which will be used to generate various strings throughout the picker + * + * @param locale Locale + */ + @SuppressWarnings("unused") + public void setLocale(Locale locale) { + mLocale = locale; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setStyle(AppCompatDialogFragment.STYLE_NO_TITLE, 0); + if (savedInstanceState != null && savedInstanceState.containsKey(KEY_INITIAL_TIME) + && savedInstanceState.containsKey(KEY_IS_24_HOUR_VIEW)) { + mInitialTime = savedInstanceState.getParcelable(KEY_INITIAL_TIME); + mIs24HourMode = savedInstanceState.getBoolean(KEY_IS_24_HOUR_VIEW); + mInKbMode = savedInstanceState.getBoolean(KEY_IN_KB_MODE); + mTitle = savedInstanceState.getString(KEY_TITLE); + mThemeDark = savedInstanceState.getBoolean(KEY_THEME_DARK); + mThemeDarkChanged = savedInstanceState.getBoolean(KEY_THEME_DARK_CHANGED); + if (savedInstanceState.containsKey(KEY_ACCENT)) mAccentColor = savedInstanceState.getInt(KEY_ACCENT); + mVibrate = savedInstanceState.getBoolean(KEY_VIBRATE); + mDismissOnPause = savedInstanceState.getBoolean(KEY_DISMISS); + mEnableSeconds = savedInstanceState.getBoolean(KEY_ENABLE_SECONDS); + mEnableMinutes = savedInstanceState.getBoolean(KEY_ENABLE_MINUTES); + mOkResid = savedInstanceState.getInt(KEY_OK_RESID); + mOkString = savedInstanceState.getString(KEY_OK_STRING); + if (savedInstanceState.containsKey(KEY_OK_COLOR)) mOkColor = savedInstanceState.getInt(KEY_OK_COLOR); + if (mOkColor == Integer.MAX_VALUE) mOkColor = null; + mCancelResid = savedInstanceState.getInt(KEY_CANCEL_RESID); + mCancelString = savedInstanceState.getString(KEY_CANCEL_STRING); + if (savedInstanceState.containsKey(KEY_CANCEL_COLOR)) mCancelColor = savedInstanceState.getInt(KEY_CANCEL_COLOR); + mVersion = (Version) savedInstanceState.getSerializable(KEY_VERSION); + mLimiter = savedInstanceState.getParcelable(KEY_TIMEPOINTLIMITER); + mLocale = (Locale) savedInstanceState.getSerializable(KEY_LOCALE); + + /* + If the user supplied a custom limiter, we need to create a new default one to prevent + null pointer exceptions on the configuration methods + If the user did not supply a custom limiter we need to ensure both mDefaultLimiter + and mLimiter are the same reference, so that the config methods actually + affect the behaviour of the picker (in the unlikely event the user reconfigures + the picker when it is shown) + */ + mDefaultLimiter = mLimiter instanceof DefaultTimepointLimiter + ? (DefaultTimepointLimiter) mLimiter + : new DefaultTimepointLimiter(); + } + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + int viewRes = mVersion == Version.VERSION_1 ? R.layout.mdtp_time_picker_dialog : R.layout.mdtp_time_picker_dialog_v2; + View view = inflater.inflate(viewRes, container, false); + KeyboardListener keyboardListener = new KeyboardListener(); + view.findViewById(R.id.mdtp_time_picker_dialog).setOnKeyListener(keyboardListener); + + // If an accent color has not been set manually, get it from the context + if (mAccentColor == null) { + mAccentColor = Utils.getAccentColorFromThemeIfAvailable(getActivity()); + } + + // if theme mode has not been set by java code, check if it is specified in Style.xml + if (!mThemeDarkChanged) { + mThemeDark = Utils.isDarkTheme(getActivity(), mThemeDark); + } + + Resources res = getResources(); + Context context = requireActivity(); + mHourPickerDescription = res.getString(R.string.mdtp_hour_picker_description); + mSelectHours = res.getString(R.string.mdtp_select_hours); + mMinutePickerDescription = res.getString(R.string.mdtp_minute_picker_description); + mSelectMinutes = res.getString(R.string.mdtp_select_minutes); + mSecondPickerDescription = res.getString(R.string.mdtp_second_picker_description); + mSelectSeconds = res.getString(R.string.mdtp_select_seconds); + mSelectedColor = ContextCompat.getColor(context, R.color.mdtp_white); + mUnselectedColor = ContextCompat.getColor(context, R.color.mdtp_accent_color_focused); + + mHourView = view.findViewById(R.id.mdtp_hours); + mHourView.setOnKeyListener(keyboardListener); + mHourSpaceView = view.findViewById(R.id.mdtp_hour_space); + mMinuteSpaceView = view.findViewById(R.id.mdtp_minutes_space); + mMinuteView = view.findViewById(R.id.mdtp_minutes); + mMinuteView.setOnKeyListener(keyboardListener); + mSecondSpaceView = view.findViewById(R.id.mdtp_seconds_space); + mSecondView = view.findViewById(R.id.mdtp_seconds); + mSecondView.setOnKeyListener(keyboardListener); + mAmTextView = view.findViewById(R.id.mdtp_am_label); + mAmTextView.setOnKeyListener(keyboardListener); + mPmTextView = view.findViewById(R.id.mdtp_pm_label); + mPmTextView.setOnKeyListener(keyboardListener); + mAmPmLayout = view.findViewById(R.id.mdtp_ampm_layout); + String[] amPmTexts = new DateFormatSymbols(mLocale).getAmPmStrings(); + mAmText = amPmTexts[0]; + mPmText = amPmTexts[1]; + + mHapticFeedbackController = new HapticFeedbackController(getActivity()); + + if (mTimePicker != null) { + mInitialTime = new Timepoint(mTimePicker.getHours(), mTimePicker.getMinutes(), mTimePicker.getSeconds()); + } + + mInitialTime = roundToNearest(mInitialTime); + + mTimePicker = view.findViewById(R.id.mdtp_time_picker); + mTimePicker.setOnValueSelectedListener(this); + mTimePicker.setOnKeyListener(keyboardListener); + mTimePicker.initialize(getActivity(), mLocale, this, mInitialTime, mIs24HourMode); + + int currentItemShowing = HOUR_INDEX; + if (savedInstanceState != null && + savedInstanceState.containsKey(KEY_CURRENT_ITEM_SHOWING)) { + currentItemShowing = savedInstanceState.getInt(KEY_CURRENT_ITEM_SHOWING); + } + setCurrentItemShowing(currentItemShowing, false, true, true); + mTimePicker.invalidate(); + + mHourView.setOnClickListener(v -> { + setCurrentItemShowing(HOUR_INDEX, true, false, true); + tryVibrate(); + }); + mMinuteView.setOnClickListener(v -> { + setCurrentItemShowing(MINUTE_INDEX, true, false, true); + tryVibrate(); + }); + mSecondView.setOnClickListener(view1 -> { + setCurrentItemShowing(SECOND_INDEX, true, false, true); + tryVibrate(); + }); + + mOkButton = view.findViewById(R.id.mdtp_ok); + mOkButton.setOnClickListener(v -> { + if (mInKbMode && isTypedTimeFullyLegal()) { + finishKbMode(false); + } else { + tryVibrate(); + } + notifyOnDateListener(); + dismiss(); + }); + mOkButton.setOnKeyListener(keyboardListener); + mOkButton.setTypeface(ResourcesCompat.getFont(context, R.font.robotomedium)); + if (mOkString != null) mOkButton.setText(mOkString); + else mOkButton.setText(mOkResid); + + mCancelButton = view.findViewById(R.id.mdtp_cancel); + mCancelButton.setOnClickListener(v -> { + tryVibrate(); + if (getDialog() != null) getDialog().cancel(); + }); + mCancelButton.setTypeface(ResourcesCompat.getFont(context, R.font.robotomedium)); + if (mCancelString != null) mCancelButton.setText(mCancelString); + else mCancelButton.setText(mCancelResid); + mCancelButton.setVisibility(isCancelable() ? View.VISIBLE : View.GONE); + + // Enable or disable the AM/PM view. + if (mIs24HourMode) { + mAmPmLayout.setVisibility(View.GONE); + } else { + OnClickListener listener = v -> { + // Don't do anything if either AM or PM are disabled + if (isAmDisabled() || isPmDisabled()) return; + + tryVibrate(); + int amOrPm = mTimePicker.getIsCurrentlyAmOrPm(); + if (amOrPm == AM) { + amOrPm = PM; + } else if (amOrPm == PM) { + amOrPm = AM; + } + mTimePicker.setAmOrPm(amOrPm); + }; + mAmTextView.setVisibility(View.GONE); + mPmTextView.setVisibility(View.VISIBLE); + mAmPmLayout.setOnClickListener(listener); + if (mVersion == Version.VERSION_2) { + mAmTextView.setText(mAmText); + mPmTextView.setText(mPmText); + mAmTextView.setVisibility(View.VISIBLE); + } + updateAmPmDisplay(mInitialTime.isAM() ? AM : PM); + + } + + // Disable seconds picker + if (!mEnableSeconds) { + mSecondView.setVisibility(View.GONE); + view.findViewById(R.id.mdtp_separator_seconds).setVisibility(View.GONE); + } + + // Disable minutes picker + if (!mEnableMinutes) { + mMinuteSpaceView.setVisibility(View.GONE); + view.findViewById(R.id.mdtp_separator).setVisibility(View.GONE); + } + + // Center stuff depending on what's visible + boolean isLandscape = getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; + // Landscape layout is radically different + if (isLandscape) { + if (!mEnableMinutes && !mEnableSeconds) { + // Just the hour + // Put the hour above the center + RelativeLayout.LayoutParams paramsHour = new RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT + ); + paramsHour.addRule(RelativeLayout.ABOVE, R.id.mdtp_center_view); + paramsHour.addRule(RelativeLayout.CENTER_HORIZONTAL); + mHourSpaceView.setLayoutParams(paramsHour); + if (mIs24HourMode) { + // Hour + Am/Pm indicator + // Put the am / pm indicator next to the hour + RelativeLayout.LayoutParams paramsAmPm = new RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT + ); + paramsAmPm.addRule(RelativeLayout.RIGHT_OF, R.id.mdtp_hour_space); + mAmPmLayout.setLayoutParams(paramsAmPm); + } + } else if (!mEnableSeconds && mIs24HourMode) { + // Hour + Minutes + // Put the separator above the center + RelativeLayout.LayoutParams paramsSeparator = new RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT + ); + paramsSeparator.addRule(RelativeLayout.CENTER_HORIZONTAL); + paramsSeparator.addRule(RelativeLayout.ABOVE, R.id.mdtp_center_view); + TextView separatorView = view.findViewById(R.id.mdtp_separator); + separatorView.setLayoutParams(paramsSeparator); + } else if (!mEnableSeconds) { + // Hour + Minutes + Am/Pm indicator + // Put separator above the center + RelativeLayout.LayoutParams paramsSeparator = new RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT + ); + paramsSeparator.addRule(RelativeLayout.CENTER_HORIZONTAL); + paramsSeparator.addRule(RelativeLayout.ABOVE, R.id.mdtp_center_view); + TextView separatorView = view.findViewById(R.id.mdtp_separator); + separatorView.setLayoutParams(paramsSeparator); + // Put the am/pm indicator below the separator + RelativeLayout.LayoutParams paramsAmPm = new RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT + ); + paramsAmPm.addRule(RelativeLayout.CENTER_IN_PARENT); + paramsAmPm.addRule(RelativeLayout.BELOW, R.id.mdtp_center_view); + mAmPmLayout.setLayoutParams(paramsAmPm); + } else if (mIs24HourMode) { + // Hour + Minutes + Seconds + // Put the separator above the center + RelativeLayout.LayoutParams paramsSeparator = new RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT + ); + paramsSeparator.addRule(RelativeLayout.CENTER_HORIZONTAL); + paramsSeparator.addRule(RelativeLayout.ABOVE, R.id.mdtp_seconds_space); + TextView separatorView = view.findViewById(R.id.mdtp_separator); + separatorView.setLayoutParams(paramsSeparator); + // Center the seconds + RelativeLayout.LayoutParams paramsSeconds = new RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT + ); + paramsSeconds.addRule(RelativeLayout.CENTER_IN_PARENT); + mSecondSpaceView.setLayoutParams(paramsSeconds); + } else { + // Hour + Minutes + Seconds + Am/Pm Indicator + // Put the seconds on the center + RelativeLayout.LayoutParams paramsSeconds = new RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT + ); + paramsSeconds.addRule(RelativeLayout.CENTER_IN_PARENT); + mSecondSpaceView.setLayoutParams(paramsSeconds); + // Put the separator above the seconds + RelativeLayout.LayoutParams paramsSeparator = new RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT + ); + paramsSeparator.addRule(RelativeLayout.CENTER_HORIZONTAL); + paramsSeparator.addRule(RelativeLayout.ABOVE, R.id.mdtp_seconds_space); + TextView separatorView = view.findViewById(R.id.mdtp_separator); + separatorView.setLayoutParams(paramsSeparator); + // Put the Am/Pm indicator below the seconds + RelativeLayout.LayoutParams paramsAmPm = new RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT + ); + paramsAmPm.addRule(RelativeLayout.CENTER_HORIZONTAL); + paramsAmPm.addRule(RelativeLayout.BELOW, R.id.mdtp_seconds_space); + mAmPmLayout.setLayoutParams(paramsAmPm); + } + } else if (mIs24HourMode && !mEnableSeconds && mEnableMinutes) { + // center first separator + RelativeLayout.LayoutParams paramsSeparator = new RelativeLayout.LayoutParams( + LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT + ); + paramsSeparator.addRule(RelativeLayout.CENTER_IN_PARENT); + TextView separatorView = view.findViewById(R.id.mdtp_separator); + separatorView.setLayoutParams(paramsSeparator); + } else if (!mEnableMinutes && !mEnableSeconds) { + // center the hour + RelativeLayout.LayoutParams paramsHour = new RelativeLayout.LayoutParams( + LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT + ); + paramsHour.addRule(RelativeLayout.CENTER_IN_PARENT); + mHourSpaceView.setLayoutParams(paramsHour); + + if (!mIs24HourMode) { + RelativeLayout.LayoutParams paramsAmPm = new RelativeLayout.LayoutParams( + LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT + ); + paramsAmPm.addRule(RelativeLayout.RIGHT_OF, R.id.mdtp_hour_space); + paramsAmPm.addRule(RelativeLayout.ALIGN_BASELINE, R.id.mdtp_hour_space); + mAmPmLayout.setLayoutParams(paramsAmPm); + } + } else if (mEnableSeconds) { + // link separator to minutes + final View separator = view.findViewById(R.id.mdtp_separator); + RelativeLayout.LayoutParams paramsSeparator = new RelativeLayout.LayoutParams( + LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT + ); + paramsSeparator.addRule(RelativeLayout.LEFT_OF, R.id.mdtp_minutes_space); + paramsSeparator.addRule(RelativeLayout.CENTER_VERTICAL, RelativeLayout.TRUE); + separator.setLayoutParams(paramsSeparator); + + if (!mIs24HourMode) { + // center minutes + RelativeLayout.LayoutParams paramsMinutes = new RelativeLayout.LayoutParams( + LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT + ); + paramsMinutes.addRule(RelativeLayout.CENTER_IN_PARENT); + mMinuteSpaceView.setLayoutParams(paramsMinutes); + } else { + // move minutes to right of center + RelativeLayout.LayoutParams paramsMinutes = new RelativeLayout.LayoutParams( + LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT + ); + paramsMinutes.addRule(RelativeLayout.RIGHT_OF, R.id.mdtp_center_view); + mMinuteSpaceView.setLayoutParams(paramsMinutes); + } + } + + mAllowAutoAdvance = true; + setHour(mInitialTime.getHour(), true); + setMinute(mInitialTime.getMinute()); + setSecond(mInitialTime.getSecond()); + + // Set up for keyboard mode. + mDoublePlaceholderText = res.getString(R.string.mdtp_time_placeholder); + mDeletedKeyFormat = res.getString(R.string.mdtp_deleted_key); + mPlaceholderText = mDoublePlaceholderText.charAt(0); + mAmKeyCode = mPmKeyCode = -1; + generateLegalTimesTree(); + if (mInKbMode && savedInstanceState != null) { + mTypedTimes = savedInstanceState.getIntegerArrayList(KEY_TYPED_TIMES); + tryStartingKbMode(-1); + mHourView.invalidate(); + } else if (mTypedTimes == null) { + mTypedTimes = new ArrayList<>(); + } + + // Set the title (if any) + TextView timePickerHeader = view.findViewById(R.id.mdtp_time_picker_header); + if (!mTitle.isEmpty()) { + timePickerHeader.setVisibility(TextView.VISIBLE); + timePickerHeader.setText(mTitle); + } + + // Set the theme at the end so that the initialize()s above don't counteract the theme. + timePickerHeader.setBackgroundColor(Utils.darkenColor(mAccentColor)); + view.findViewById(R.id.mdtp_time_display_background).setBackgroundColor(mAccentColor); + view.findViewById(R.id.mdtp_time_display).setBackgroundColor(mAccentColor); + + // Button text can have a different color + if (mOkColor == null) mOkColor = mAccentColor; + mOkButton.setTextColor(mOkColor); + if (mCancelColor == null) mCancelColor = mAccentColor; + mCancelButton.setTextColor(mCancelColor); + + if (getDialog() == null) { + view.findViewById(R.id.mdtp_done_background).setVisibility(View.GONE); + } + + int circleBackground = ContextCompat.getColor(context, R.color.mdtp_circle_background); + int backgroundColor = ContextCompat.getColor(context, R.color.mdtp_background_color); + int darkBackgroundColor = ContextCompat.getColor(context, R.color.mdtp_light_gray); + int lightGray = ContextCompat.getColor(context, R.color.mdtp_light_gray); + + mTimePicker.setBackgroundColor(mThemeDark ? lightGray : circleBackground); + view.findViewById(R.id.mdtp_time_picker_dialog).setBackgroundColor(mThemeDark ? darkBackgroundColor : backgroundColor); + return view; + } + + @Override + public void onConfigurationChanged(final Configuration newConfig) { + super.onConfigurationChanged(newConfig); + ViewGroup viewGroup = (ViewGroup) getView(); + if (viewGroup != null) { + viewGroup.removeAllViewsInLayout(); + View view = onCreateView(requireActivity().getLayoutInflater(), viewGroup, null); + viewGroup.addView(view); + } + } + + @Override + public void onResume() { + super.onResume(); + mHapticFeedbackController.start(); + } + + @Override + public void onPause() { + super.onPause(); + mHapticFeedbackController.stop(); + if (mDismissOnPause) dismiss(); + } + + @Override + public void onCancel(DialogInterface dialog) { + super.onCancel(dialog); + if (mOnCancelListener != null) mOnCancelListener.onCancel(dialog); + } + + @Override + public void onDismiss(DialogInterface dialog) { + super.onDismiss(dialog); + if (mOnDismissListener != null) mOnDismissListener.onDismiss(dialog); + } + + @Override + public void tryVibrate() { + if (mVibrate) mHapticFeedbackController.tryVibrate(); + } + + private void updateAmPmDisplay(int amOrPm) { + if (mVersion == Version.VERSION_2) { + if (amOrPm == AM) { + mAmTextView.setTextColor(mSelectedColor); + mPmTextView.setTextColor(mUnselectedColor); + Utils.tryAccessibilityAnnounce(mTimePicker, mAmText); + } else { + mAmTextView.setTextColor(mUnselectedColor); + mPmTextView.setTextColor(mSelectedColor); + Utils.tryAccessibilityAnnounce(mTimePicker, mPmText); + } + } else { + if (amOrPm == AM) { + mPmTextView.setText(mAmText); + Utils.tryAccessibilityAnnounce(mTimePicker, mAmText); + mPmTextView.setContentDescription(mAmText); + } else if (amOrPm == PM) { + mPmTextView.setText(mPmText); + Utils.tryAccessibilityAnnounce(mTimePicker, mPmText); + mPmTextView.setContentDescription(mPmText); + } else { + mPmTextView.setText(mDoublePlaceholderText); + } + } + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + if (mTimePicker != null) { + outState.putParcelable(KEY_INITIAL_TIME, mTimePicker.getTime()); + outState.putBoolean(KEY_IS_24_HOUR_VIEW, mIs24HourMode); + outState.putInt(KEY_CURRENT_ITEM_SHOWING, mTimePicker.getCurrentItemShowing()); + outState.putBoolean(KEY_IN_KB_MODE, mInKbMode); + if (mInKbMode) { + outState.putIntegerArrayList(KEY_TYPED_TIMES, mTypedTimes); + } + outState.putString(KEY_TITLE, mTitle); + outState.putBoolean(KEY_THEME_DARK, mThemeDark); + outState.putBoolean(KEY_THEME_DARK_CHANGED, mThemeDarkChanged); + if (mAccentColor != null) outState.putInt(KEY_ACCENT, mAccentColor); + outState.putBoolean(KEY_VIBRATE, mVibrate); + outState.putBoolean(KEY_DISMISS, mDismissOnPause); + outState.putBoolean(KEY_ENABLE_SECONDS, mEnableSeconds); + outState.putBoolean(KEY_ENABLE_MINUTES, mEnableMinutes); + outState.putInt(KEY_OK_RESID, mOkResid); + outState.putString(KEY_OK_STRING, mOkString); + if (mOkColor != null) outState.putInt(KEY_OK_COLOR, mOkColor); + outState.putInt(KEY_CANCEL_RESID, mCancelResid); + outState.putString(KEY_CANCEL_STRING, mCancelString); + if (mCancelColor != null) outState.putInt(KEY_CANCEL_COLOR, mCancelColor); + outState.putSerializable(KEY_VERSION, mVersion); + outState.putParcelable(KEY_TIMEPOINTLIMITER, mLimiter); + outState.putSerializable(KEY_LOCALE, mLocale); + } + } + + /** + * Called by the picker for updating the header display. + */ + @Override + public void onValueSelected(Timepoint newValue) { + setHour(newValue.getHour(), false); + mTimePicker.setContentDescription(mHourPickerDescription + ": " + newValue.getHour()); + setMinute(newValue.getMinute()); + mTimePicker.setContentDescription(mMinutePickerDescription + ": " + newValue.getMinute()); + setSecond(newValue.getSecond()); + mTimePicker.setContentDescription(mSecondPickerDescription + ": " + newValue.getSecond()); + if (!mIs24HourMode) updateAmPmDisplay(newValue.isAM() ? AM : PM); + } + + @Override + public void advancePicker(int index) { + if (!mAllowAutoAdvance) return; + if (index == HOUR_INDEX && mEnableMinutes) { + setCurrentItemShowing(MINUTE_INDEX, true, true, false); + + String announcement = mSelectHours + ". " + mTimePicker.getMinutes(); + Utils.tryAccessibilityAnnounce(mTimePicker, announcement); + } else if (index == MINUTE_INDEX && mEnableSeconds) { + setCurrentItemShowing(SECOND_INDEX, true, true, false); + + String announcement = mSelectMinutes + ". " + mTimePicker.getSeconds(); + Utils.tryAccessibilityAnnounce(mTimePicker, announcement); + } + } + + @Override + public void enablePicker() { + if (!isTypedTimeFullyLegal()) mTypedTimes.clear(); + finishKbMode(true); + } + + public boolean isOutOfRange(Timepoint current) { + return isOutOfRange(current, SECOND_INDEX); + } + + @Override + public boolean isOutOfRange(Timepoint current, int index) { + return mLimiter.isOutOfRange(current, index, getPickerResolution()); + } + + @Override + public boolean isAmDisabled() { + return mLimiter.isAmDisabled(); + } + + @Override + public boolean isPmDisabled() { + return mLimiter.isPmDisabled(); + } + + /** + * Round a given Timepoint to the nearest valid Timepoint + * + * @param time Timepoint - The timepoint to round + * @return Timepoint - The nearest valid Timepoint + */ + private Timepoint roundToNearest(@NonNull Timepoint time) { + return roundToNearest(time, null); + } + + @Override + public Timepoint roundToNearest(@NonNull Timepoint time, @Nullable Timepoint.TYPE type) { + return mLimiter.roundToNearest(time, type, getPickerResolution()); + } + + /** + * Get the configured resolution of the current picker in terms of Timepoint components + * + * @return Timepoint.TYPE (hour, minute or second) + */ + @NonNull + Timepoint.TYPE getPickerResolution() { + if (mEnableSeconds) return Timepoint.TYPE.SECOND; + if (mEnableMinutes) return Timepoint.TYPE.MINUTE; + return Timepoint.TYPE.HOUR; + } + + private void setHour(int value, boolean announce) { + String format; + if (mIs24HourMode) { + format = "%02d"; + } else { + format = "%d"; + value = value % 12; + if (value == 0) { + value = 12; + } + } + + CharSequence text = String.format(mLocale, format, value); + mHourView.setText(text); + mHourSpaceView.setText(text); + if (announce) { + Utils.tryAccessibilityAnnounce(mTimePicker, text); + } + } + + private void setMinute(int value) { + if (value == 60) { + value = 0; + } + CharSequence text = String.format(mLocale, "%02d", value); + Utils.tryAccessibilityAnnounce(mTimePicker, text); + mMinuteView.setText(text); + mMinuteSpaceView.setText(text); + } + + private void setSecond(int value) { + if (value == 60) { + value = 0; + } + CharSequence text = String.format(mLocale, "%02d", value); + Utils.tryAccessibilityAnnounce(mTimePicker, text); + mSecondView.setText(text); + mSecondSpaceView.setText(text); + } + + // Show either Hours or Minutes. + private void setCurrentItemShowing(int index, boolean animateCircle, boolean delayLabelAnimate, + boolean announce) { + mTimePicker.setCurrentItemShowing(index, animateCircle); + + TextView labelToAnimate; + switch (index) { + case HOUR_INDEX: + int hours = mTimePicker.getHours(); + if (!mIs24HourMode) { + hours = hours % 12; + } + mTimePicker.setContentDescription(mHourPickerDescription + ": " + hours); + if (announce) { + Utils.tryAccessibilityAnnounce(mTimePicker, mSelectHours); + } + labelToAnimate = mHourView; + break; + case MINUTE_INDEX: + int minutes = mTimePicker.getMinutes(); + mTimePicker.setContentDescription(mMinutePickerDescription + ": " + minutes); + if (announce) { + Utils.tryAccessibilityAnnounce(mTimePicker, mSelectMinutes); + } + labelToAnimate = mMinuteView; + break; + default: + int seconds = mTimePicker.getSeconds(); + mTimePicker.setContentDescription(mSecondPickerDescription + ": " + seconds); + if (announce) { + Utils.tryAccessibilityAnnounce(mTimePicker, mSelectSeconds); + } + labelToAnimate = mSecondView; + } + + int hourColor = (index == HOUR_INDEX) ? mSelectedColor : mUnselectedColor; + int minuteColor = (index == MINUTE_INDEX) ? mSelectedColor : mUnselectedColor; + int secondColor = (index == SECOND_INDEX) ? mSelectedColor : mUnselectedColor; + mHourView.setTextColor(hourColor); + mMinuteView.setTextColor(minuteColor); + mSecondView.setTextColor(secondColor); + + ObjectAnimator pulseAnimator = Utils.getPulseAnimator(labelToAnimate, 0.85f, 1.1f); + if (delayLabelAnimate) { + pulseAnimator.setStartDelay(PULSE_ANIMATOR_DELAY); + } + pulseAnimator.start(); + } + + /** + * For keyboard mode, processes key events. + * + * @param keyCode the pressed key. + * @return true if the key was successfully processed, false otherwise. + */ + private boolean processKeyUp(int keyCode) { + if (keyCode == KeyEvent.KEYCODE_TAB) { + if (mInKbMode) { + if (isTypedTimeFullyLegal()) { + finishKbMode(true); + } + return true; + } + } else if (keyCode == KeyEvent.KEYCODE_ENTER) { + if (mInKbMode) { + if (!isTypedTimeFullyLegal()) { + return true; + } + finishKbMode(false); + } + if (mCallback != null) { + mCallback.onTimeSet(this, + mTimePicker.getHours(), mTimePicker.getMinutes(), mTimePicker.getSeconds()); + } + dismiss(); + return true; + } else if (keyCode == KeyEvent.KEYCODE_DEL) { + if (mInKbMode) { + if (!mTypedTimes.isEmpty()) { + int deleted = deleteLastTypedKey(); + String deletedKeyStr; + if (deleted == getAmOrPmKeyCode(AM)) { + deletedKeyStr = mAmText; + } else if (deleted == getAmOrPmKeyCode(PM)) { + deletedKeyStr = mPmText; + } else { + deletedKeyStr = String.format(mLocale, "%d", getValFromKeyCode(deleted)); + } + Utils.tryAccessibilityAnnounce(mTimePicker, + String.format(mDeletedKeyFormat, deletedKeyStr)); + updateDisplay(true); + } + } + } else if (keyCode == KeyEvent.KEYCODE_0 || keyCode == KeyEvent.KEYCODE_1 + || keyCode == KeyEvent.KEYCODE_2 || keyCode == KeyEvent.KEYCODE_3 + || keyCode == KeyEvent.KEYCODE_4 || keyCode == KeyEvent.KEYCODE_5 + || keyCode == KeyEvent.KEYCODE_6 || keyCode == KeyEvent.KEYCODE_7 + || keyCode == KeyEvent.KEYCODE_8 || keyCode == KeyEvent.KEYCODE_9 + || (!mIs24HourMode && + (keyCode == getAmOrPmKeyCode(AM) || keyCode == getAmOrPmKeyCode(PM)))) { + if (!mInKbMode) { + if (mTimePicker == null) { + // Something's wrong, because time picker should definitely not be null. + Log.e(TAG, "Unable to initiate keyboard mode, TimePicker was null."); + return true; + } + mTypedTimes.clear(); + tryStartingKbMode(keyCode); + return true; + } + // We're already in keyboard mode. + if (addKeyIfLegal(keyCode)) { + updateDisplay(false); + } + return true; + } + return false; + } + + /** + * Try to start keyboard mode with the specified key, as long as the timepicker is not in the + * middle of a touch-event. + * + * @param keyCode The key to use as the first press. Keyboard mode will not be started if the + * key is not legal to start with. Or, pass in -1 to get into keyboard mode without a starting + * key. + */ + private void tryStartingKbMode(int keyCode) { + if (mTimePicker.trySettingInputEnabled(false) && + (keyCode == -1 || addKeyIfLegal(keyCode))) { + mInKbMode = true; + mOkButton.setEnabled(false); + updateDisplay(false); + } + } + + private boolean addKeyIfLegal(int keyCode) { + // If we're in 24hour mode, we'll need to check if the input is full. If in AM/PM mode, + // we'll need to see if AM/PM have been typed. + int textSize = 6; + if (mEnableMinutes && !mEnableSeconds) textSize = 4; + if (!mEnableMinutes && !mEnableSeconds) textSize = 2; + if ((mIs24HourMode && mTypedTimes.size() == textSize) || + (!mIs24HourMode && isTypedTimeFullyLegal())) { + return false; + } + + mTypedTimes.add(keyCode); + if (!isTypedTimeLegalSoFar()) { + deleteLastTypedKey(); + return false; + } + + int val = getValFromKeyCode(keyCode); + Utils.tryAccessibilityAnnounce(mTimePicker, String.format(mLocale, "%d", val)); + // Automatically fill in 0's if AM or PM was legally entered. + if (isTypedTimeFullyLegal()) { + if (!mIs24HourMode && mTypedTimes.size() <= (textSize - 1)) { + mTypedTimes.add(mTypedTimes.size() - 1, KeyEvent.KEYCODE_0); + mTypedTimes.add(mTypedTimes.size() - 1, KeyEvent.KEYCODE_0); + } + mOkButton.setEnabled(true); + } + + return true; + } + + /** + * Traverse the tree to see if the keys that have been typed so far are legal as is, + * or may become legal as more keys are typed (excluding backspace). + */ + private boolean isTypedTimeLegalSoFar() { + Node node = mLegalTimesTree; + for (int keyCode : mTypedTimes) { + node = node.canReach(keyCode); + if (node == null) { + return false; + } + } + return true; + } + + /** + * Check if the time that has been typed so far is completely legal, as is. + */ + private boolean isTypedTimeFullyLegal() { + if (mIs24HourMode) { + // For 24-hour mode, the time is legal if the hours and minutes are each legal. Note: + // getEnteredTime() will ONLY call isTypedTimeFullyLegal() when NOT in 24hour mode. + Boolean[] enteredZeros = {false, false, false}; + int[] values = getEnteredTime(enteredZeros); + return (values[0] >= 0 && values[1] >= 0 && values[1] < 60 && values[2] >= 0 && values[2] < 60); + } else { + // For AM/PM mode, the time is legal if it contains an AM or PM, as those can only be + // legally added at specific times based on the tree's algorithm. + return (mTypedTimes.contains(getAmOrPmKeyCode(AM)) || + mTypedTimes.contains(getAmOrPmKeyCode(PM))); + } + } + + private int deleteLastTypedKey() { + int deleted = mTypedTimes.remove(mTypedTimes.size() - 1); + if (!isTypedTimeFullyLegal()) { + mOkButton.setEnabled(false); + } + return deleted; + } + + /** + * Get out of keyboard mode. If there is nothing in typedTimes, revert to TimePicker's time. + * + * @param updateDisplays If true, update the displays with the relevant time. + */ + private void finishKbMode(boolean updateDisplays) { + mInKbMode = false; + if (!mTypedTimes.isEmpty()) { + Boolean[] enteredZeros = {false, false, false}; + int[] values = getEnteredTime(enteredZeros); + mTimePicker.setTime(new Timepoint(values[0], values[1], values[2])); + if (!mIs24HourMode) { + mTimePicker.setAmOrPm(values[3]); + } + mTypedTimes.clear(); + } + if (updateDisplays) { + updateDisplay(false); + mTimePicker.trySettingInputEnabled(true); + } + } + + /** + * Update the hours, minutes, seconds and AM/PM displays with the typed times. If the typedTimes + * is empty, either show an empty display (filled with the placeholder text), or update from the + * timepicker's values. + * + * @param allowEmptyDisplay if true, then if the typedTimes is empty, use the placeholder text. + * Otherwise, revert to the timepicker's values. + */ + private void updateDisplay(boolean allowEmptyDisplay) { + if (!allowEmptyDisplay && mTypedTimes.isEmpty()) { + int hour = mTimePicker.getHours(); + int minute = mTimePicker.getMinutes(); + int second = mTimePicker.getSeconds(); + setHour(hour, true); + setMinute(minute); + setSecond(second); + if (!mIs24HourMode) { + updateAmPmDisplay(hour < 12 ? AM : PM); + } + setCurrentItemShowing(mTimePicker.getCurrentItemShowing(), true, true, true); + mOkButton.setEnabled(true); + } else { + Boolean[] enteredZeros = {false, false, false}; + int[] values = getEnteredTime(enteredZeros); + String hourFormat = enteredZeros[0] ? "%02d" : "%2d"; + String minuteFormat = (enteredZeros[1]) ? "%02d" : "%2d"; + String secondFormat = (enteredZeros[1]) ? "%02d" : "%2d"; + String hourStr = (values[0] == -1) ? mDoublePlaceholderText : + String.format(hourFormat, values[0]).replace(' ', mPlaceholderText); + String minuteStr = (values[1] == -1) ? mDoublePlaceholderText : + String.format(minuteFormat, values[1]).replace(' ', mPlaceholderText); + String secondStr = (values[2] == -1) ? mDoublePlaceholderText : + String.format(secondFormat, values[1]).replace(' ', mPlaceholderText); + mHourView.setText(hourStr); + mHourSpaceView.setText(hourStr); + mHourView.setTextColor(mUnselectedColor); + mMinuteView.setText(minuteStr); + mMinuteSpaceView.setText(minuteStr); + mMinuteView.setTextColor(mUnselectedColor); + mSecondView.setText(secondStr); + mSecondSpaceView.setText(secondStr); + mSecondView.setTextColor(mUnselectedColor); + if (!mIs24HourMode) { + updateAmPmDisplay(values[3]); + } + } + } + + private static int getValFromKeyCode(int keyCode) { + switch (keyCode) { + case KeyEvent.KEYCODE_0: + return 0; + case KeyEvent.KEYCODE_1: + return 1; + case KeyEvent.KEYCODE_2: + return 2; + case KeyEvent.KEYCODE_3: + return 3; + case KeyEvent.KEYCODE_4: + return 4; + case KeyEvent.KEYCODE_5: + return 5; + case KeyEvent.KEYCODE_6: + return 6; + case KeyEvent.KEYCODE_7: + return 7; + case KeyEvent.KEYCODE_8: + return 8; + case KeyEvent.KEYCODE_9: + return 9; + default: + return -1; + } + } + + /** + * Get the currently-entered time, as integer values of the hours, minutes and seconds typed. + * + * @param enteredZeros A size-2 boolean array, which the caller should initialize, and which + * may then be used for the caller to know whether zeros had been explicitly entered as either + * hours of minutes. This is helpful for deciding whether to show the dashes, or actual 0's. + * @return A size-3 int array. The first value will be the hours, the second value will be the + * minutes, and the third will be either TimePickerDialog.AM or TimePickerDialog.PM. + */ + @NonNull + private int[] getEnteredTime(@NonNull Boolean[] enteredZeros) { + int amOrPm = -1; + int startIndex = 1; + if (!mIs24HourMode && isTypedTimeFullyLegal()) { + int keyCode = mTypedTimes.get(mTypedTimes.size() - 1); + if (keyCode == getAmOrPmKeyCode(AM)) { + amOrPm = AM; + } else if (keyCode == getAmOrPmKeyCode(PM)) { + amOrPm = PM; + } + startIndex = 2; + } + int minute = -1; + int hour = -1; + int second = 0; + int shift = mEnableSeconds ? 2 : 0; + for (int i = startIndex; i <= mTypedTimes.size(); i++) { + int val = getValFromKeyCode(mTypedTimes.get(mTypedTimes.size() - i)); + if (mEnableSeconds) { + if (i == startIndex) { + second = val; + } else if (i == startIndex + 1) { + second += 10 * val; + if (val == 0) enteredZeros[2] = true; + } + } + if (mEnableMinutes) { + if (i == startIndex + shift) { + minute = val; + } else if (i == startIndex + shift + 1) { + minute += 10 * val; + if (val == 0) enteredZeros[1] = true; + } else if (i == startIndex + shift + 2) { + hour = val; + } else if (i == startIndex + shift + 3) { + hour += 10 * val; + if (val == 0) enteredZeros[0] = true; + } + } else { + if (i == startIndex + shift) { + hour = val; + } else if (i == startIndex + shift + 1) { + hour += 10 * val; + if (val == 0) enteredZeros[0] = true; + } + } + } + + return new int[]{hour, minute, second, amOrPm}; + } + + /** + * Get the keycode value for AM and PM in the current language. + */ + private int getAmOrPmKeyCode(int amOrPm) { + // Cache the codes. + if (mAmKeyCode == -1 || mPmKeyCode == -1) { + // Find the first character in the AM/PM text that is unique. + KeyCharacterMap kcm = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD); + char amChar; + char pmChar; + for (int i = 0; i < Math.max(mAmText.length(), mPmText.length()); i++) { + amChar = mAmText.toLowerCase(mLocale).charAt(i); + pmChar = mPmText.toLowerCase(mLocale).charAt(i); + if (amChar != pmChar) { + KeyEvent[] events = kcm.getEvents(new char[]{amChar, pmChar}); + // There should be 4 events: a down and up for both AM and PM. + if (events != null && events.length == 4) { + mAmKeyCode = events[0].getKeyCode(); + mPmKeyCode = events[2].getKeyCode(); + } else { + Log.e(TAG, "Unable to find keycodes for AM and PM."); + } + break; + } + } + } + if (amOrPm == AM) { + return mAmKeyCode; + } else if (amOrPm == PM) { + return mPmKeyCode; + } + + return -1; + } + + /** + * Create a tree for deciding what keys can legally be typed. + */ + private void generateLegalTimesTree() { + // Create a quick cache of numbers to their keycodes. + int k0 = KeyEvent.KEYCODE_0; + int k1 = KeyEvent.KEYCODE_1; + int k2 = KeyEvent.KEYCODE_2; + int k3 = KeyEvent.KEYCODE_3; + int k4 = KeyEvent.KEYCODE_4; + int k5 = KeyEvent.KEYCODE_5; + int k6 = KeyEvent.KEYCODE_6; + int k7 = KeyEvent.KEYCODE_7; + int k8 = KeyEvent.KEYCODE_8; + int k9 = KeyEvent.KEYCODE_9; + + // The root of the tree doesn't contain any numbers. + mLegalTimesTree = new Node(); + + // In case we're only allowing hours + if (!mEnableMinutes && mIs24HourMode) { + // The first digit may be 0-1 + Node firstDigit = new Node(k0, k1); + mLegalTimesTree.addChild(firstDigit); + + // When the first digit is 0-1, the second digit may be 0-9 + Node secondDigit = new Node(k0, k1, k2, k3, k4, k5, k6, k7, k8, k9); + firstDigit.addChild(secondDigit); + + // The first digit may be 2 + firstDigit = new Node(k2); + mLegalTimesTree.addChild(firstDigit); + + // When the first digit is 2, the second digit may be 0-3 + secondDigit = new Node(k0, k1, k2, k3); + firstDigit.addChild(secondDigit); + return; + } + // noinspection ConstantConditions + if (!mEnableMinutes && !mIs24HourMode) { + // We'll need to use the AM/PM node a lot. + // Set up AM and PM to respond to "a" and "p". + Node ampm = new Node(getAmOrPmKeyCode(AM), getAmOrPmKeyCode(PM)); + + // The first digit may be 1 + Node firstDigit = new Node(k1); + mLegalTimesTree.addChild(firstDigit); + + // If the first digit is 1, the second one may be am/pm 1pm + firstDigit.addChild(ampm); + // If the first digit is 1, the second digit may be 0-2 + Node secondDigit = new Node(k0, k1, k2); + firstDigit.addChild(secondDigit); + secondDigit.addChild(ampm); + + // The first digit may be 2-9 + firstDigit = new Node(k2, k3, k4, k5, k6, k7, k8, k9); + mLegalTimesTree.addChild(firstDigit); + firstDigit.addChild(ampm); + return; + } + + // In case minutes are allowed + if (mIs24HourMode) { + // We'll be re-using these nodes, so we'll save them. + Node minuteFirstDigit = new Node(k0, k1, k2, k3, k4, k5); + Node minuteSecondDigit = new Node(k0, k1, k2, k3, k4, k5, k6, k7, k8, k9); + // The first digit must be followed by the second digit. + minuteFirstDigit.addChild(minuteSecondDigit); + + if (mEnableSeconds) { + Node secondsFirstDigit = new Node(k0, k1, k2, k3, k4, k5); + Node secondsSecondDigit = new Node(k0, k1, k2, k3, k4, k5, k6, k7, k8, k9); + secondsFirstDigit.addChild(secondsSecondDigit); + + // Minutes can be followed by seconds. + minuteSecondDigit.addChild(secondsFirstDigit); + } + + // The first digit may be 0-1. + Node firstDigit = new Node(k0, k1); + mLegalTimesTree.addChild(firstDigit); + + // When the first digit is 0-1, the second digit may be 0-5. + Node secondDigit = new Node(k0, k1, k2, k3, k4, k5); + firstDigit.addChild(secondDigit); + // We may now be followed by the first minute digit. E.g. 00:09, 15:58. + secondDigit.addChild(minuteFirstDigit); + + // When the first digit is 0-1, and the second digit is 0-5, the third digit may be 6-9. + Node thirdDigit = new Node(k6, k7, k8, k9); + // The time must now be finished. E.g. 0:55, 1:08. + secondDigit.addChild(thirdDigit); + + // When the first digit is 0-1, the second digit may be 6-9. + secondDigit = new Node(k6, k7, k8, k9); + firstDigit.addChild(secondDigit); + // We must now be followed by the first minute digit. E.g. 06:50, 18:20. + secondDigit.addChild(minuteFirstDigit); + + // The first digit may be 2. + firstDigit = new Node(k2); + mLegalTimesTree.addChild(firstDigit); + + // When the first digit is 2, the second digit may be 0-3. + secondDigit = new Node(k0, k1, k2, k3); + firstDigit.addChild(secondDigit); + // We must now be followed by the first minute digit. E.g. 20:50, 23:09. + secondDigit.addChild(minuteFirstDigit); + + // When the first digit is 2, the second digit may be 4-5. + secondDigit = new Node(k4, k5); + firstDigit.addChild(secondDigit); + // We must now be followd by the last minute digit. E.g. 2:40, 2:53. + secondDigit.addChild(minuteSecondDigit); + + // The first digit may be 3-9. + firstDigit = new Node(k3, k4, k5, k6, k7, k8, k9); + mLegalTimesTree.addChild(firstDigit); + // We must now be followed by the first minute digit. E.g. 3:57, 8:12. + firstDigit.addChild(minuteFirstDigit); + } else { + // We'll need to use the AM/PM node a lot. + // Set up AM and PM to respond to "a" and "p". + Node ampm = new Node(getAmOrPmKeyCode(AM), getAmOrPmKeyCode(PM)); + + // Seconds will be used a few times as well, if enabled. + Node secondsFirstDigit = new Node(k0, k1, k2, k3, k4, k5); + Node secondsSecondDigit = new Node(k0, k1, k2, k3, k4, k5, k6, k7, k8, k9); + secondsSecondDigit.addChild(ampm); + secondsFirstDigit.addChild(secondsSecondDigit); + + // The first hour digit may be 1. + Node firstDigit = new Node(k1); + mLegalTimesTree.addChild(firstDigit); + // We'll allow quick input of on-the-hour times. E.g. 1pm. + firstDigit.addChild(ampm); + + // When the first digit is 1, the second digit may be 0-2. + Node secondDigit = new Node(k0, k1, k2); + firstDigit.addChild(secondDigit); + // Also for quick input of on-the-hour times. E.g. 10pm, 12am. + secondDigit.addChild(ampm); + + // When the first digit is 1, and the second digit is 0-2, the third digit may be 0-5. + Node thirdDigit = new Node(k0, k1, k2, k3, k4, k5); + secondDigit.addChild(thirdDigit); + // The time may be finished now. E.g. 1:02pm, 1:25am. + thirdDigit.addChild(ampm); + + // When the first digit is 1, the second digit is 0-2, and the third digit is 0-5, + // the fourth digit may be 0-9. + Node fourthDigit = new Node(k0, k1, k2, k3, k4, k5, k6, k7, k8, k9); + thirdDigit.addChild(fourthDigit); + // The time must be finished now, when seconds are disabled. E.g. 10:49am, 12:40pm. + fourthDigit.addChild(ampm); + + // When the first digit is 1, the second digit is 0-2, and the third digit is 0-5, + // and fourth digit is 0-9, we may add seconds if enabled. + if (mEnableSeconds) { + // The time must be finished now. E.g. 10:49:01am, 12:40:59pm. + fourthDigit.addChild(secondsFirstDigit); + } + + // When the first digit is 1, and the second digit is 0-2, the third digit may be 6-9. + thirdDigit = new Node(k6, k7, k8, k9); + secondDigit.addChild(thirdDigit); + // The time must be finished now. E.g. 1:08am, 1:26pm. + thirdDigit.addChild(ampm); + + // When the first digit is 1, and the second digit is 0-2, and the third digit is 6-9, + // we may add seconds is enabled. + if (mEnableSeconds) { + // The time must be finished now. E.g. 1:08:01am, 1:26:59pm. + thirdDigit.addChild(secondsFirstDigit); + } + + // When the first digit is 1, the second digit may be 3-5. + secondDigit = new Node(k3, k4, k5); + firstDigit.addChild(secondDigit); + + // When the first digit is 1, and the second digit is 3-5, the third digit may be 0-9. + thirdDigit = new Node(k0, k1, k2, k3, k4, k5, k6, k7, k8, k9); + secondDigit.addChild(thirdDigit); + // The time must be finished now if seconds are disabled. E.g. 1:39am, 1:50pm. + thirdDigit.addChild(ampm); + + // When the first digit is 1, and the second digit is 3-5, and the third digit is 0-9, + // we may add seconds if enabled. + if (mEnableSeconds) { + // The time must be finished now. E.g. 1:39:01am, 1:50:59pm. + thirdDigit.addChild(secondsFirstDigit); + } + + // The hour digit may be 2-9. + firstDigit = new Node(k2, k3, k4, k5, k6, k7, k8, k9); + mLegalTimesTree.addChild(firstDigit); + // We'll allow quick input of on-the-hour-times. E.g. 2am, 5pm. + firstDigit.addChild(ampm); + + // When the first digit is 2-9, the second digit may be 0-5. + secondDigit = new Node(k0, k1, k2, k3, k4, k5); + firstDigit.addChild(secondDigit); + + // When the first digit is 2-9, and the second digit is 0-5, the third digit may be 0-9. + thirdDigit = new Node(k0, k1, k2, k3, k4, k5, k6, k7, k8, k9); + secondDigit.addChild(thirdDigit); + // The time must be finished now. E.g. 2:57am, 9:30pm. + thirdDigit.addChild(ampm); + + // When the first digit is 2-9, and the second digit is 0-5, and third digit is 0-9, we + // may add seconds if enabled. + if (mEnableSeconds) { + // The time must be finished now. E.g. 2:57:01am, 9:30:59pm. + thirdDigit.addChild(secondsFirstDigit); + } + } + } + + /** + * Simple node class to be used for traversal to check for legal times. + * mLegalKeys represents the keys that can be typed to get to the node. + * mChildren are the children that can be reached from this node. + */ + private static class Node { + private int[] mLegalKeys; + private ArrayList mChildren; + + public Node(int... legalKeys) { + mLegalKeys = legalKeys; + mChildren = new ArrayList<>(); + } + + public void addChild(Node child) { + mChildren.add(child); + } + + public boolean containsKey(int key) { + for (int legalKey : mLegalKeys) { + if (legalKey == key) return true; + } + return false; + } + + public Node canReach(int key) { + if (mChildren == null) { + return null; + } + for (Node child : mChildren) { + if (child.containsKey(key)) { + return child; + } + } + return null; + } + } + + private class KeyboardListener implements OnKeyListener { + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + if (event.getAction() == KeyEvent.ACTION_UP) { + return processKeyUp(keyCode); + } + return false; + } + } + + public void notifyOnDateListener() { + if (mCallback != null) { + mCallback.onTimeSet(this, mTimePicker.getHours(), mTimePicker.getMinutes(), mTimePicker.getSeconds()); + } + } + + public Timepoint getSelectedTime() { + return mTimePicker.getTime(); + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/Timepoint.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/Timepoint.java new file mode 100644 index 00000000..3ffff635 --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/Timepoint.java @@ -0,0 +1,199 @@ +package com.wdullaer.materialdatetimepicker.time; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import static com.wdullaer.materialdatetimepicker.time.Timepoint.TYPE.HOUR; +import static com.wdullaer.materialdatetimepicker.time.Timepoint.TYPE.MINUTE; + +/** + * Simple utility class that represents a time in the day up to second precision + * The time input is expected to use 24 hour mode. + * Fields are modulo'd into their correct ranges. + * It does not handle timezones. + * + * Created by wdullaer on 13/10/15. + */ +@SuppressWarnings("WeakerAccess") +public class Timepoint implements Parcelable, Comparable { + private int hour; + private int minute; + private int second; + + public enum TYPE { + HOUR, + MINUTE, + SECOND + } + + public Timepoint(Timepoint time) { + this(time.hour, time.minute, time.second); + } + + public Timepoint(@IntRange(from=0, to=23) int hour, + @IntRange(from=0, to=59) int minute, + @IntRange(from=0, to=59) int second) { + this.hour = hour % 24; + this.minute = minute % 60; + this.second = second % 60; + } + + public Timepoint(@IntRange(from=0, to=23) int hour, + @IntRange(from=0, to=59) int minute) { + this(hour, minute, 0); + } + + public Timepoint(@IntRange(from=0, to=23) int hour) { + this(hour, 0); + } + + public Timepoint(Parcel in) { + hour = in.readInt(); + minute = in.readInt(); + second = in.readInt(); + } + + @IntRange(from=0, to=23) + public int getHour() { + return hour; + } + + @IntRange(from=0, to=59) + public int getMinute() { + return minute; + } + + @IntRange(from=0, to=59) + public int getSecond() { + return second; + } + + public boolean isAM() { + return hour < 12; + } + + public boolean isPM() { + return !isAM(); + } + + public void setAM() { + if(hour >= 12) hour = hour % 12; + } + + public void setPM() { + if(hour < 12) hour = (hour + 12) % 24; + } + + public void add(TYPE type, int value) { + if (type == MINUTE) value *= 60; + if (type == HOUR) value *= 3600; + value += toSeconds(); + + switch (type) { + case SECOND: + int secondVal = (value % 3600) % 60; + if(secondVal<0){ + second = 60+secondVal; + add(MINUTE, -1); + }else { + second = secondVal; + } + case MINUTE: + int minuteVal = (value % 3600) / 60; + if(minuteVal<0){ + minute = 60+minuteVal; + add(HOUR, -1); + }else { + minute = minuteVal; + } + case HOUR: + int hourVal = (value / 3600) % 24; + if(hourVal<0){ + hour = 24+hourVal; + }else{ + hour=hourVal; + } + } + } + + public int get(@NonNull TYPE type) { + switch (type) { + case SECOND: + return getSecond(); + case MINUTE: + return getMinute(); + case HOUR: + default: // Makes the compiler happy + return getHour(); + } + } + + public int toSeconds() { + return 3600 * hour + 60 * minute + second; + } + + @Override + public int hashCode() { + return toSeconds(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Timepoint timepoint = (Timepoint) o; + + return hashCode() == timepoint.hashCode(); + } + + public boolean equals(@Nullable Timepoint time, @NonNull TYPE resolution) { + if (time == null) return false; + boolean output = true; + switch (resolution) { + case SECOND: + output = output && time.getSecond() == getSecond(); + case MINUTE: + output = output && time.getMinute() == getMinute(); + case HOUR: + output = output && time.getHour() == getHour(); + } + return output; + } + + @Override + public int compareTo(@NonNull Timepoint t) { + return hashCode() - t.hashCode(); + } + + @Override + public void writeToParcel(Parcel out, int flags) { + out.writeInt(hour); + out.writeInt(minute); + out.writeInt(second); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + public Timepoint createFromParcel(Parcel in) { + return new Timepoint(in); + } + + public Timepoint[] newArray(int size) { + return new Timepoint[size]; + } + }; + + @Override + public String toString() { + return "" + hour + "h " + minute + "m " + second + "s"; + } +} diff --git a/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/TimepointLimiter.java b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/TimepointLimiter.java new file mode 100644 index 00000000..3709fa01 --- /dev/null +++ b/modules/material-date-time-picker/src/main/java/com/wdullaer/materialdatetimepicker/time/TimepointLimiter.java @@ -0,0 +1,70 @@ +package com.wdullaer.materialdatetimepicker.time; + +import android.os.Parcelable; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +@SuppressWarnings("WeakerAccess") +public interface TimepointLimiter extends Parcelable { + /** + * isOutOfRange indicates whether a particular timepoint is selectable or not + * It is called multiple times in the rendering path, so it should be fast + * + * The index parameter indicates which picker is currently visible. This is necessary because + * you typically only want to compare with a resolution up to the visible component. (The + * implementation should ensure that 8 is selectable, if any valid timepoint with 8 as the hour + * is selectable, when the hour picker is showing) + * + * Similarly the overall resolution of the picker is passed in, because it can impact the + * comparisons an implementation does (especially when comparing with a disabled list) + * + * The scope of this method is most likely too broad, which makes it hard to reason about. It is + * one of the main reasons the DefaultTimeLimiter implementation of this contains extensive + * example and generate tests. The default implementation should cover 90% of use cases, but if + * I ever notice that a substantial amount of people are trying to implement this themselves, it + * might need to be redesigned. + * + * @param point A timepoint to validate + * @param index The currently showing picker (hour, minute, second) + * @param resolution The overall resolution of the picker + * @return whether the Timepoint is out of range or selectable + */ + boolean isOutOfRange(@Nullable Timepoint point, int index, @NonNull Timepoint.TYPE resolution); + + /** + * isAmDisabled ndicates whether any times before midday are selectable + * This method is called when the picker is initialized or when the user clicks / taps the AM or + * PM buttons. + * This means that it's result can't be updated when the picker is already being rendered + * @return true if the AM selector should be disabled + */ + boolean isAmDisabled(); + + /** + * isPmDisabled ndicates whether any times after midday are selectable + * This method is called when the picker is initialized or when the user clicks / taps the AM or + * PM buttons. + * This means that it's result can't be updated when the picker is already being rendered + * @return true if the PM selector should be disabled + */ + boolean isPmDisabled(); + + /** + * roundToNearest returns the nearest selectable timepoint given a particular input + * It is called whenever the user touches the screen, which means it can get called very + * frequently if the user performs a drag operation + * + * Both the currently showing picker and the overall resolution are passed in, for similar + * reasons as in isOutOfRange + * + * @param time the proposed selection + * @param type the currently showing picker (hour, minute, second) + * @param resolution the overall resolution of the picker + * @return a selectable timepoint + */ + @NonNull Timepoint roundToNearest( + @NonNull Timepoint time, + @Nullable Timepoint.TYPE type, + @NonNull Timepoint.TYPE resolution + ); +} \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/color/mdtp_date_picker_selector.xml b/modules/material-date-time-picker/src/main/res/color/mdtp_date_picker_selector.xml new file mode 100644 index 00000000..15606d21 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/color/mdtp_date_picker_selector.xml @@ -0,0 +1,23 @@ + + + + + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/color/mdtp_date_picker_year_selector.xml b/modules/material-date-time-picker/src/main/res/color/mdtp_date_picker_year_selector.xml new file mode 100644 index 00000000..eb2ce133 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/color/mdtp_date_picker_year_selector.xml @@ -0,0 +1,23 @@ + + + + + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/color/mdtp_done_text_color.xml b/modules/material-date-time-picker/src/main/res/color/mdtp_done_text_color.xml new file mode 100644 index 00000000..7e99f971 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/color/mdtp_done_text_color.xml @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/color/mdtp_done_text_color_dark.xml b/modules/material-date-time-picker/src/main/res/color/mdtp_done_text_color_dark.xml new file mode 100644 index 00000000..1c16f236 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/color/mdtp_done_text_color_dark.xml @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/drawable-land-v19/mdtp_done_background_color.xml b/modules/material-date-time-picker/src/main/res/drawable-land-v19/mdtp_done_background_color.xml new file mode 100644 index 00000000..696bd065 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/drawable-land-v19/mdtp_done_background_color.xml @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/drawable-land/mdtp_done_background_color.xml b/modules/material-date-time-picker/src/main/res/drawable-land/mdtp_done_background_color.xml new file mode 100644 index 00000000..48c51ffa --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/drawable-land/mdtp_done_background_color.xml @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/drawable-land/mdtp_done_background_color_dark.xml b/modules/material-date-time-picker/src/main/res/drawable-land/mdtp_done_background_color_dark.xml new file mode 100644 index 00000000..1f426a06 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/drawable-land/mdtp_done_background_color_dark.xml @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/drawable-v19/mdtp_done_background_color.xml b/modules/material-date-time-picker/src/main/res/drawable-v19/mdtp_done_background_color.xml new file mode 100644 index 00000000..3ef7e41a --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/drawable-v19/mdtp_done_background_color.xml @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/drawable-v21/mdtp_material_button_background.xml b/modules/material-date-time-picker/src/main/res/drawable-v21/mdtp_material_button_background.xml new file mode 100644 index 00000000..fbc19976 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/drawable-v21/mdtp_material_button_background.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/drawable-v21/mdtp_material_button_selected.xml b/modules/material-date-time-picker/src/main/res/drawable-v21/mdtp_material_button_selected.xml new file mode 100644 index 00000000..9eaadcf7 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/drawable-v21/mdtp_material_button_selected.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/drawable-v21/mdtp_month_arrow_background.xml b/modules/material-date-time-picker/src/main/res/drawable-v21/mdtp_month_arrow_background.xml new file mode 100644 index 00000000..3163c5c0 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/drawable-v21/mdtp_month_arrow_background.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/drawable/mdtp_done_background_color.xml b/modules/material-date-time-picker/src/main/res/drawable/mdtp_done_background_color.xml new file mode 100644 index 00000000..f301fcc9 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/drawable/mdtp_done_background_color.xml @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/drawable/mdtp_done_background_color_dark.xml b/modules/material-date-time-picker/src/main/res/drawable/mdtp_done_background_color_dark.xml new file mode 100644 index 00000000..680ec82f --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/drawable/mdtp_done_background_color_dark.xml @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/drawable/mdtp_ic_chevron_left_black_24dp.xml b/modules/material-date-time-picker/src/main/res/drawable/mdtp_ic_chevron_left_black_24dp.xml new file mode 100644 index 00000000..e6bb3ca9 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/drawable/mdtp_ic_chevron_left_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/modules/material-date-time-picker/src/main/res/drawable/mdtp_ic_chevron_right_black_24dp.xml b/modules/material-date-time-picker/src/main/res/drawable/mdtp_ic_chevron_right_black_24dp.xml new file mode 100644 index 00000000..24835127 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/drawable/mdtp_ic_chevron_right_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/modules/material-date-time-picker/src/main/res/drawable/mdtp_material_button_background.xml b/modules/material-date-time-picker/src/main/res/drawable/mdtp_material_button_background.xml new file mode 100644 index 00000000..f70c39c0 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/drawable/mdtp_material_button_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/drawable/mdtp_material_button_selected.xml b/modules/material-date-time-picker/src/main/res/drawable/mdtp_material_button_selected.xml new file mode 100644 index 00000000..1733e2d3 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/drawable/mdtp_material_button_selected.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/drawable/mdtp_month_arrow_background.xml b/modules/material-date-time-picker/src/main/res/drawable/mdtp_month_arrow_background.xml new file mode 100644 index 00000000..bbf8441b --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/drawable/mdtp_month_arrow_background.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/font/robotomedium.ttf b/modules/material-date-time-picker/src/main/res/font/robotomedium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..39c63d7461796094c0b8889ee8fe2706d344a99a GIT binary patch literal 162588 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 cASb?` zy=9AL-ai$OY1(rBquG5S8ue;OXpwPJFsEh7RIAQps6^reUVt7#7x&3mY5DE6+!b-N z62`#U|#>nESj%^R1pI)TY`GE_w@~AsDR;H~UOrhZ=3wFFyWH)CXyW%@Gp0*K;p$id5 z6$hgTtXC#H22~IkU{yPg5?_cZmy(~s9kF|UDo^u59N8&l5}mlK{|~)(_eLa0DDgKf zn{%M^sO`H#4LiA(@tr09vjfGk`_)tSQg^?&mI?4!G=+j|R+g;}y((uL|B}FWEoBFl za5)!>{K7pf?7Lu}Ix5l&QV!zE#A10wBi04pn5OB=Gdud{J#tFgfBet9iOEaq)>)D= zi3NY5;iFi&FJ#Cx8u@COeD3VW?~FAZSB>1#Z)&ak8+(p*q1Wt& zxznKce(O#1lwTF4`}CDF#&;VateG?3bftU}H?KUxK@i-v9xc~?(G;Lt!&poa(FExWO4)5Y|-2AVR_yB<9frl|wqK!&R_-z=v+(O-i#$#adSEIHSM=dC~vUt`@J)9_sJ-Q29G& zYV;-?KqCeTM? zkE0hx!<7GnaiSx&We1Djr!$Wabpp1w(6%jD*ahMfnM0vrOk<+-d~M6O#?{q59XN77 z?I7iK>$0dpUY>AvmXd6&!441UJSG1tDuv9sDlCw0V?HTHYW}xVbyg-+4+N6sfmA0P z5)3bNyv`CLDL7a+F)vS1>Q8HcXuMdBmZ*}DP$jluL)2;7r!=CesM85C+J>^=#ZTTq z_O{hb5d*AhAiB2Dt~d+<53+k;_OtCRV5)QB=a)jG)p3t)0_y?4F`L%ZsTmS6TJW$k z^R@Tp-7Y8Xr->U1*n$TRZzu*rDH?6D4um43-s&2uL!=D>d*-HdOs3go)s$Xk5z5jn(W}gbq>j-lPQE-I~!D zL8Zt@Su7MOR%bD5FRHGiHKCSdO;~sO)pn`-k-W2Qb0Qy8R8Y@QNm_Mb4@a<2INY0i zJYF#{!vNyHW<}I!-GaCs<_bx>X@{M^-z??M-rX#(L*JJDJZPSDd{K=&vD(no&704k zGFD|L`y{q+VDw=ZPmIiei*G1d#t2%agQdb2yv_V!(ih6> zyjh=S&K*5+4hvmlnyLKyd)2l1vuGv7e#^0whc=!(2HwUgiTG_NGjD@%yiMiJ;^El3 z(%R$da@?9wO{&$3S{)zx|Jm0;KSyy5DB|mg5zkM`1M;#R1{^$}^M3NECADfV9Wj-; zepPNK8%@%KK~u0I`l4BXe5|6Mpw7ch(GG@oa1Ua@zdQ)ujv~EZWHwO8sFS^Dps->>ue~glS|vijT}%~wRQr2<4lbvTTj(u51u+Jsj{)%l)tRpMI0qw@tHNU8vuNrtitf zLd_~Qzi&IH?V^2&zjSNU8FL3EcBWTsm{bd1OADD4@><-`95<^VZIdO`95BOd`ZEMj@hB%QwaA`RL{{v6NKmqd>9wH^Tdft-WFiGuFF0QLz8YqdE94w(tPFd^4J3tzK0%Yr;0v@mr|q zZ^%K7p+wOyFTN@>Zz|LC5h&7pJ?j@Qcli!YyFRPMdr{p0{`%KrR%p8aMlAyzY%V$V2l{%HG4 z@K!XEV`WN>cTpqby^9ohNo3# z2J~&U{o!}h0*ADM_b^>qOn=7zZA5^^)|9p8?+0mhu%YJJtB4U($1-HYt*O@Liks~% z55z%Z&EF|d5AAHb?yZ=pwn44h4I0$0)qpMisz|X_8a1jC+X%l#jqisziVji;(UZ=y zZ$O|b0p0=X-i%dU)gi1}%7U3la8S?XW0T4JWgHjv`z`L_vbPItv3jDm@D`XF>%d_` z{yh(dm9?w+dm!eBhoE)$WKG+T(mWT>ExLFhGxNa3)rZ9A+LVsJ>Jabf+br!$$v^%G zr2dae-lqD%oa~bKRwgDZ7aQ=X2PODT3vrxu26m{I?4zDMF-y%Avy-beuxg#=3r|OO zS+XB=3EX1ey-=hKt1LgA!8uFzG~abCyd{Oc_Hx$-AP&G!(}rTQ3auQYw@trZ2*et- zaqJ-d`df?8^4fJP?7q;YFF*F{(!P&9jb&{NGg@D|WIwp$p$qRX{qf*n*M3v_zP|t+ z=xll+H5K0>LRJRa$jX-Ee8E5Px9EV?W?;c)z*-fvmEJZY__3}KTMCgSC}XHL6hotO zY-lWZ3%z2!FrMA9c}Cqm;pWD`bHEuUvBsLqmsU@nJELdPg1PfnoIJf^;k?BIy3d}M zKWfnNN5_+rjy*Y^G+^R{X$xkqJa=)^j|&zKPhPz6r!|*Ot(?1%jU9D0IpykKnaRnS zydO%SA5LQQd*Zhc)KTn-4)C;gfXyW&PCV?vgF7@!b~Yz)oD1K^aSKm(cJp_G*CS$= z3|R<0iPlkXz>#1KHnX;2Toy05`jZk_H==q}nhoER*%jy*g*2DSYFjJVqHD z0`76Ay?F6Lc`x|pzYs1mz7yL)2|;xKED0wy$P`&$^LyS@z0MGUUc@m%KD6|Zik=r$ zRnJ5ChN^{kTxC|3e;3ApHD2+8!RA<8{en!S`GrWGT%q8ePh%Gb5feth*Prp{#5vn%DDVIHq-4KenKpXv^dm=) zcivhnp1S?|mOAAv95r_KC_43Y2ji9IjR*EvvbW;!GCgFH%>8P~i`c(g7&xp_CP`{Gj}NXo>niwobS zz!rtG7BM>}**YI~7xqw9eMq0j8Tj`IgRK{x>!ifTr_`Yr<2j9&+C;|3_l%FPWG1ky zZLzDLi+=IJ=!zlpWdGPITx$M01HN~O&nU|%i$do_@ilB%mLUx1TB6ir!wiavqi%@v z+lQ&&hq4b(C-ypfWE8XK9=WWu9WLY})615Ueaz=)!w`zMv$X~%6@zUlb@IR$y)55m zvV1end^jvRxH2mpgcFH6S5aTuH5doTaDG5%K29KZ*{z&Bc4GC0ohv)F>(r-j$MzlQ z=B3JC8`daqR<6JoKI5p{vM+Cr&_;9;O*nEyIe7Y%a^NUywQT+E^r5?MZ(5SrGA$)J zWqR}Osher3?HiSBWrvczWgFFRxkNi$xS(uS1}d8~F5>?O!tlGVdj?g&~I2uSCkoZX zo%F4y)H)w?p}5-g2rtRrwv=4TL5;iM>#F{ktGTX2XZ>~l(AmgR{A&<~$Z)x2!Slf- zBY7si$6A4p2)UZ=79Vhh_3mFf$;~Yf`{4klyQh~&5GI|Fpiu7qz}5t{{kQr_F8@~1 z|HImMz(-Maeea!_-AxGTjS?UQ5_*7;1OcTplHHr{f9~w;%pm%H@Avy2A2!)!vwQBn=brjM=eSEOX9eH=U~H|*&U~x< zYd2k+Rkw5f&hJRyzr=)RUt-dZe7D?k=7do-iVWHKW@J$8x+ZmIypd9`8pFxVw1>!D zgcL``7BY5{w<;IhLdVg?T}t}a9`%sBD49LYV)9HJOV*j&$RJc(Wl+Z zbwCZ6Zav9LNm2sX3-$`u0#ty(i6xo+M54I0`{uD!Zy)z!6pZ2jcQ zO`7y=fKTs=)L{;KviZUG19sPw4)Z$GKzhHT_Kj>mlYX!VkP*Mv&tBLr!UA3;2!k7o zl~%L1eA{xojAyWEpRsYdSUC2py0l7C?FV5Aw^w~UmM|$ROHGp4 zi%?wg2=6Yal8v;viYBGRJ!CH%zsoOvi0l{rPV76%G$~B(F_OVFfl`d50#aOLSW4xp zwGV8YnYFrq_jzoDv}yvg_o-7lxpvCPcPEV)(7Ww0P?;)aN!#oPAb}>RMTG=1*~j|r z5Sa*~l0}ka$-wa!*jJOaK>v1mn7V*cx7a<0L8h-(<%3X#0T@?9fD^;I3XAukRBKW1 zA7CX(m0ns0saZOGZxK~O9LxXKw3Ih0)H<+fO4i1iOZQ~W+;O08^%}3w=%ftn76;3* zN5;5*W0FeE@1HqwSbTD7>0XRZVkmOW4MYq&PShF(N+4JSQ}zbJ#D#GLqfkbPr7TJ; z2?3JC!uf*}m^OR~f4>M3ykhV?LxB45_f&tAkrp2!T7*A;xjMzN1H{K@42usSwIZ8& zFfQ0h^^em2FW8V!ykXS}i^f2njFD8iEG~6^=%kveuNH}h6wy&@!jHgb;8$e1K;W3f znALa@I|kRr!`fPnx-Mc?!@uU4cn4=+E)Kq(8?~-$eYpJ)&Au?K^A<>`LDE(h7XdU})vWoa{RZ zg`&0~?RpE}4J22nh`+&6+t1l|(QDBO)<^v9MQIiO<{PcWc$+9*CTamumplec(CJhxL zUV_XS%1RK*gMy+8NrWn5WM^AfW+2b^3BNAak#D-wgQeo`J7LoD7mwJu!mKsMVP&7< zeNXWe)<>E}_q-`O^gmQ)Kx0<1K?gx*Mi4H7 zAH2m-hVo>z*aru&rmQ+Yxdd)v^(FkIc;KhlK$I-jUdE4z2YzH3tHpr?l2Kv3-X3dz z3_TeQBu{TONJH`nzJM2=9q-|eSAzx%1&KgsocMwk8Pt3NuDdB2UWiH)>8i(jojEXv zzz+k?k{S=B2-T28rY1QfLhWIZ4BAw3+AAk8NMb;VV*w@J`~I|!+ZLW!#=_K7S#7s0 zUc+2()@RS!&IF=ZI$D-THtRr>|ndvkZMtej)~MB@vSP0by|$sHP(moRc3JfVQe6G61B80zIo0!djPGy3zn@Td)PJuK?}40GyR~S(F+u zV;7JHpgJBUj_P~iUspniB7x2P{SOwxALl&H$$2(m;k!AD{4l4GqIIB*f3-u?36w}tCQ)KcI2{&b5S@fFKWOzGC4-mbSY zJGJ;kQQrFI@xg6xbRSkLp=|3Vvm5W&pHT2bREaiqDt4STt3|snn!eQyZG2^0gS>~1 zY3Nv73iZ_0ZD&;J^*4wv>^&S*JXZ0XfCWFM$GSWYSbn=eWB@pjl^?bQY#AflMCc$e zVc|rzicQp+S4rGktV&`PLeEsmqc2^n452Sat6Zo8*I`~2_~oX+6K;8&ch{`jMaZE< zZ%~SA(UZNe1jNo$en(!fjz1G#TVzTks-``d8v@-J9$W+y_#1>Kes<5R_05dz&mZ4A z+O=KB5l`KxZVZ|F`Q^v%hQ2Fi%pUN1!+MuoE2_>ace3x;&T^R^6NA{vL1F8gw$5I? z_JjHz`?Rkex@iCOnG$zDm_2uG)!5BdYJO6!%q6L7y;{wsU*=3KIt;ZNwm#sgnvSx_ z>z6@f(WzAsBMMQ{v*!h&0SfB8b&YLnrO8xL>dYE-pJld7-5 zQj>vm8>76AzTJctD*-92#8bG7Xaq0gG0t;B7DRwI-&a5ppuA}Dg@SVhg~T<&)!7At zvSTRPXyR>Q3(<*?|GMbp_&YKctmgtAqV=mtG}@KNk9iWcWAJ%_m?>}S((Jw zfFAIz%D*d#5yWO)e3yR;AVJZ^7nZS<)!n6T5r?-d>FnMT{3*%YDX`;H;Rg`f z0hmb(qTp4SNukPhD_+m{{lDP|{(+fPQg0szjv%Lh_Nm)BQ%CDCg1`7BzzCe{+~1A? zV)BK3^8qi8069VX8fc7AXvzVatzmH`g{@EKeV9N_R5XgGwzz@GjP&>@sY7DZ^pcJ`tZC!!S1m672>F`8?fG}k>! z*|j0}52{O3@^3&v4fR3WKp2877#UOGL0M5ix~Zq@4G#)vN)Es(!ux=Z9Dr~0!=pDW zDE+T2sabC*pA0Q8lF6YJjyNEI9f=O$7PB0XwWiJu`IskFM;a1q)9~$2hCF@xHoV)i zyc^rk&$17AYo#i0!Lr>sZbD+mf-dT@;BV`w(Io=$y0Z4>f?0lga|&N!_o134%kIP8 zGyFfYsCB4IJ;g4AnxGRtd%L&SMB0xb5+KTS9NR_Q4WalBra5XPEe)u{d=$*P6~ zcw!zJK8{d~$;nhqg~~I#?~!fwst1&;I^I3m<&q|%kh;Lfr-MJs|4Bt9Jof=ABpxbZ zpf-~?#>Bs7xc}YMaB=@NYQ^Q&8B36^KM@@f7~w&qv>InW;8R?FzzZ((Eb{Ic!eeB~~@_jvH>{Mpj?V~6%} z-30eb$S6K^mV^Hp3BPHf$~3K^$ zig_YM(Ivc+ZIxOusWMSsP#E}lEbrM5%V}D~gbt|{`=O%*RlARX(k4a<=yM{ZMPgzi zp_~&TElen9iGtJ3hLN`HMd`jca`uMU>#ZD z&A(^V`gqVckKJwj7LID4Rlj-t^JD*-H{;oD{`BdLabsr896M^3{L|ZG!`Mf|q=e@z zaZl?mC~JK<6 zEeW}x3kh8aL)n`yAJwjteXfC@gXbR4uF<|==}H@w)%#9_@Ao^9+ZX)#mZ!cm0w>c^ zwQ2R8k)Fe_=qUX|RJ#`3w7-l8u<{*z1#{8AJsPn77_!)$$ixEeh}J0Z;}9N7je!X# zeZsHu!}3M@jdNR;SFRY7{R4Vs4cNf%Zd-OF#GQB`4B^U#vv2-ZIrMa313s63Jm3UB zvHS85hl}k$YGZkKcw^~n>{3zNFg46nc`jTWeV2qJ(`ADw`_rEY4}k;)O8QLIKv;qS z(j@7QYQ;cceyuA*Lc=2_gq*NI&Yj^&(zGu=dcQ$Rp!3a;v70s>-}8FMg7MYXurK5p zuH@`bgOAGjcFAYCj?Z|!K_hUQHUj>~APxIdwi z-2Gd1-`QI`y48Q`l=m;iZMhLb1*8vJdkRRgGOEWosBDESjK#{r;))Z+VMf)D#Xc1Y zG%|@CipZ1!r{iy|43>|WDS*(U+#TE7}2mw3&>WXM2yoTlz?kxFfPI84U?fTS^ z#{aVPHh=iki)vl(p1m}*|L_?fmYA@%2w_{xGbX^TTMi$lJBd0;$LkJ;6KbvI_aJr1 zb`dH@KN(9oudXUpmR|tP5d`ot76YMdbe+P%gnKCeXC39BE*~8{Whth-ns3VbJKy-a zH@%{!5{N*1No6s-mu{-l)cehXpV3WE!$_l;7c!Zl7uE1kAyol#m%Y@gj&Siq)LIyR zGjWpx;A#?)CX^r+lA3BSnYH1r!wVK{Pz!^p7pt56To=r+!CB7QO`6~)6E1(Tup9BA zCV3}zP`|H&QE*=^!j1(CFGSPUY%%*}OT8+A2~{(cs=3qE-2S zB=NeaKAOaXJ*^8piwU9cj*4Lz#H|D(GcTMBoSGDlOiXeT`)biees39n@TI-?s(kz&E8t6!Zap9AuniTN z<59{vDcAlpY~V!UR0qmfgX1E=d3ysL#n38(%orn;mhBLhwx4L>=>S*&opN!rrQB@( zL-xML(;C@-&Ro4Zvv$LVwTb%4$_+LXwI05!eTpy{)di98J#wzbfNsiKZmoV5c*fllK4k`suN44dHhp)AGhSu=+J|iB`6QO{0 ziMSY$MyyTeNfXk#O_&&5yL@7;8r^F+N{`<@cI@`?7ZYpMtWdsA9Z+40x5Ny`>hO4D zRit0AyC(O5nBQ(n;>J))UsPq$1+1LP=Cy59zij#NhMl^O{G&>jif=~FFFUy7M+5P` z>B^6CTl=4g7N8>xUO=58s#g+8{Yp}H_n&rKXqFLtdqFlTlDIa5P=4m`Iwm5+hg%JNs@Pa$7|Hcjorc8sXCChjvI~Hc zRB=h1;qJGb4QnlPZrjnUf%5YrdsJM5@qOD>Zpw}8KK{CmHX38X13GHJ$B1c3$#UwYHYapSiNQUz)VcOF>isR z7T=23^#LO5fMYkl%a40g&4vJtSHxm%|2zOPYs57&5e5Fjx#FF@w0Qwr?D5?xi}+Sx zwt(D{+Gy}C$8P>jq6N7H$?3`JN)FptAvsqeo{K0C2#=8;HM@062U|j`YThaqMmB zCSO4<(avD@sPO*mq40ub$j(^XXw^rv{h)QhztfZ1n!K_l&%e*JPivq11sUn?HV6M%GB{ zK;!y&o9I9Tb9dbF*_15W&Z?j9OsOK#gXRgdn|jbNLcnwO zFnqp~GLE!@|2c08jcrPpieRB&L3^0XJ<+9%^Y9>gOJi+K@!t8N`NVr$)}_Zp%x4Z^ zVp#>$g4jZ81i{bh71K2F-qWi^}r%k9K3jZP)c#Y!2wK;ghgvd+AkR0Lo?PhdH$@7>D$~JxGQTu zlizo{Pj6qmY>PZ{^OA+zlsq|$uU+}9K~s6#Ha71Cdm}Fk-QbF?%a){F%E#}m+o!l& zN*|K+dlM_|gq23xI;l1jw#nkKSg^{%+}2sk8=wvM6TszgS~K~?rewD-tqQsVVOfWb zYA)k#3HUw&D@u}^OkLoX`MqbZ-}tYJi)Br6Np&KRY+=81$1{GRAfLlVMY0OS^nPoO z7m&g13^8+?at)aF5}?^BvKtz6h-nl$WqL+K98Ga#ql%L!gS3UIkqW>k^jpd<~RPtah|1JH?nAkvMI z3&sq%%!4jOv!GuFo%!MW^F1H%2SqQO?Zb+#?Y@r{d2cPh^--_o{A!=GWKFiodjPI) z1}`MWHcl<3b)^OJHq;Y(vDA`TToT=vG;RlZ$)=Xz`9ej*f^hML(vBv61NT`(BK3ek zRuNH7{DYH1ea$0N!FvQ`iid3@yXAVp`3fX8*^u1(*ooVBPJYxUlt-nHo>#BloDmb1 zs;(E~mAyqbZeT^8BYn+MmQQsbUAq(5=lQ3w%4K|v(jRg^3^?OnYJ!I1PYA*pP-Tf& zfkngUYf}@n;+%v;bGe_RxX5e40W%4Ctk2y466QADB~Q&CVX%-n#JCO06pDryIA5w# zs3i5f@U%y}aZ{dN+LZn@<9pU*Sm(6K?bZyNo!)1BgUj}l->F?!RVaIS%K3+_coqA$ z5yyt|K^;bC_I2HDIeYM+g=|0ZWi;=KP8d~TfuL)VFUbInFNJ*9y;Tdk%V(I@QRR% zdX!7tTw;6}ucknJK!k{T19MQyD~{A6r9}8R!WtOTMsY-ji#-dAjKD?k1W?en8A;br z-H%C#bzHpsal>KFm-lPd;n2U%$7R#9X)&8n3)R)jhn69U$`lv@QLKt zD|fEloZmkewd&bS7`O7KyjXk5+3pD&4jtMz_G~2eAxOhI^P}!3!8TWoc)4GfzMCa* zv1#&%8tF^De>2c1wZPt*ku4%&6uAqHr}9NSY_lmM4N3>yVn;H_`~sSsU_j1>0)v4`Z8gan#A6U0(ulI1fz ztxdhx8(-_zJiXCjrKZI&BH14;=@GY`STm?NNqswbR;zRT(%=Q4Hq$y2$jl~#lMrMsTs7Rt5};2=W#`HSezI91l7JMb0Z zrsJ`&=f()dt?Lc1Q4t`J;ThQhWM>8p4In!+peDYkwg6!6kufrQ0cIl+W0FF+Y(~Ny zv|GkRpnsAkfb10NM({@|`~3Ee7xrgA-+E?uS7n#ehYd+*mwDOoH^)fQaLg9j$7->1 z_t22L37&i%SK-UEXTN+>`r@TCXK3HI<^3Ub6EQMR7fj0@3#cDm5F)GomqKp`$A5gcO_gHCU&xI$@LY4K1p(dwwy)T5eu+vG%pi11xK&yc^ zQJ#qC06_Z?)0U_v@@Wj5D6~y{0#&er6XU>>EM(x=Vr)n8iqXMJ>B@sfl{xbaaMsBk zV;ja6maExw-)@n6v<;AHpXO^fL<)1KDuF*VmZg0`ptmOtz)6R}?(zG# zN^{)`6(^%#DzYfzprb)nMRD>N9fV^y5HCV%Cz@pM?H7%?uDAljO7b4^f6A|Lr zWTja$Ngxkx9FQ!6kwWU+CVRqYq|&7}H*;?u`>=lwlY`JwX+p;E-eZ(z{49SG_>|v8 zKU4X~r!2;GWz^0OKRI=59qnmLK1aR>ieu3mw6O3)HK}ExRG9gMU={HQ;$;(?N5u(Y zi(Uj4#2zdUqgqLUu?ssyV-)ZSx?QKFNL5Zgx-%$k*wiJgd|E{|vB|`w($#A{e#FLJ zRhnHp*lpyY?S-bUYV%sF+6|6Y>6O~7Y{}Arsr*vHnisRl4rmGKmI-d4zF|#Nr`9*j zY1oV+eXQ$AeS1kz8LmG;tV}`-t1C$7A45iy!_#^z6&16_7Au+j&jab#7vAEibGsqc za(y;EebnTsqejn^M=#2q|8OcR%jEozS)AnlZri~_hgW}?O}ktJQFcrku?3;K4!l~g zZV>#b)9B?~80jV&U(KvUrY6N;uSUzFis&Iq6oh=6PhjzY8-YJiBXIHBhv8OD1 zO=^7%bWDPs?E^YOV4(&48~$GhFPN-oI(R*p9UMe)&3HK}A~*=0E#ZH#;p>Mjln4Jl zg1de>dx-(pKbpJmt^0))`I+D1fAC|szLS>X#NOnw_UTG3+>JO}R}*TQ^iV-fPFYHe zg(I!+j$lX<)_K4c$=4RLgC-=Q=7x$WCuN5<^s~zBbXj zhWtihM1*4ajh*a6P%U{P`oguk| z)6ZwWc?X!JdyfvEU9*JM@(v?~Z>X%O`sRx(UvvI&%HZyUE+LqIYSWfOjZ3??PZ*#Ks$55E z8|F9a2RE}{WXB-ERVF>HUhR1Q zlC_HQ?_K=In3`qs6|df?&xEqFlRfX5R_M6Bu6uy{u@odNuWLV6xTnY)CV`)TC4(f1 zw+&T&w7fiGm--CAe_muVkSILn6?3?;hL}@*g2hoG>wrRD^b(7p5=WC6sTK>9v}WZWr!n&pHg0TovyC5YTT(e zdm;7c+_;rIb?t7*sLQu*A*~&?XZ6Q!*Sej1Rv$UDeCe-`vzDL1&56x>YDcgUw|lS} zY|3v71deVs>t?1{p@>${asjh3tx$;+ZGBJ*3J1)HGvbg;?igJxv3eokaYRax5=Ui2 zB(W%Xgs`o$fQK#_o{Q4;$DGZc$Fj0!jCJ!2Ww3IGkBt9~w|IDT+_W(>qunF5PR2Cv z=n3vlG2lUDQx60YU(i>e+x-+GF?6CDDe5OQzp_GPP4X+#B8QsXOB;>lu-g6x%7X6C zE5}?lNNohV^15ZGUF0xc#>0BFZ0r@kSjqG_!*Z6rRy?$0W| z`(Pq}aMyi?*De&;S+QLjm43{H~}j0g>F^iumj*wL~yh=<#BAeG|9!v ztbRO^-_CL0bWd`*PWk;?LxX zWz)RMPyt648yK_5Elqaw#%_*WR&F8X=NB#b3P$*}1<(q|@VVsCcT|J4K8-Ak^crk{H*iE|8@J7Vx8iSA}`zS4db^aj9Z>!pThwL!Q!djC-2MqBnwcp1Ph$Vg_7LNrq@p$(kie9h4kOU)Gh(#uwF5QdHTM zFv6@|MK=d^lMs)<|cODG{aAbm5v(bM?66;RFKG%PH_Cm5b*0am4~_5`pB0R$hX3Sgi`dfn z402x>qR4(2uARnH2qhq^M7c@Icqxxv8?t#W|AV<$=JH+d6HiI6m-_iv_9d?{cXVlK z3sHcM`A%7^7P{~sw@R3mI$v3?n9k`hWv^-JP&gn$`-h!Ae=qecUFbu69zL0G zaPGu+fwtD(=5UsM{`U{}+CV0_1XAa4!cj7Ux*^Fzg*by?os#Wg*Z;;9BE>BJg}84r zY9|7HR{-~&X6=xE?t&f@8oZUD(y!PW;x_~s3H1q_1 z@3H$b68$qZ4Gq2g_24gNz}QkAxzBQ4L(61SI%pCvbr>l?)A@vlL+~FcSv=}EP>Y7J zRAyN0F>lJ!<|BgNuq};L6Co3fxF(L-SO6evhTWLJR?r$WE5ogP>T>1Q)RrMn)y)hV zV*-=Wvk=2F@`I((R1MH9jR2Bc+Rj_(_p+YNSEtgn?U1#G)o7*CYtplKOccIMn?VG#5L5o=y|0DZ3zZ}87UpwQ@x97gQGjHu<=ChT5et!G~{@GS$|CP9Z z9jberSZgGZ37Kk$R(A<&Pw$FgS+lr-`UVJrsGCaOdl4YIKbbR7rhFcS^o=AFInhSx zEEcOHo5+BIlux0;_Va`my5c4gCJsFz5EUXbF9^~^<3O=2eZfLZ=?m6fZ#7seg-1IDLo7i?dzvYl zd{ID#V3s535nQ==YD_Ris9}o$pU@qX1%3fcsxD`t&)5^UGH1?%2lHl0(e9hlfUzw2 z(mm-#3;EvoIp`}1nmfW8sO|_wlqufUS`E;mIJh->W>6TndM;g$p{urFQM-)0T~JF_ zn9czab#oXN$qHb;X#i3%&=jc%Y@(_H;2wuFCDL21TsdA!KlhCPdG`i?{QTLRw@%ic zwRy#Rg%>U!J!4w>xaren&Y#@Rf1h7xC7G=r>%vwfMb${@mDxNl`u&AVPrSEj^{(C7 z^ESewKy{{46;Y`Gfm|AkDONO(gX9=uDkf=WK_uvBff)~!$q-YH_tuPP5ka>v;wHYJ zmYwH0GiL5epQJ#`O$Q*WDb{4gH0m*Buc3+yU^sas zwS)bUPa@rNk;-ckJjqY zlt{N|UKl>zEZK3;!u=u|5O60i%qdOoRo59HJz4hD-RK!ZuQPO_a|RqAc6KdW>zd_u zvD*BIq)1yX@Ncl{qR>m7Kxw#bigta3%+~1=!b5*z2{n56<3BvGVBG`DLSH%4u4VtX z8-^S$i-DVt|3ZZumOMFU(%<|W)`x$+H>H14lH}xV**5y; zcDY5qx+!u&Ck3wK9kr+l*CC)B`kHj5sunCkp{WeyM}X@n*rFiuWWk&GqDB}zDMyI5 z0#L6(#t%KM!e#$ZY86hP4&!$*1?gVY^!1A=98)HyT8u)|CkZGdV%;zMUe>-`a(3J1 z2cDkII5+(1=nn1sbxPZ{cVm~9Ee1dRZT}ZH<&XyBiq|@@bWf{y`_*pWs!sEIyQOKh z8#S+<_3`^S)fu>-Avjg52c|Dt2pIi|db`id{0+3SJH(NCZ9c2Ch%3LU7w za+gOEsytbzBoTVy6Z6~pYJ6njZ?Vm<{6t|dkiV`~US|N+Szzzij@zid*jvZZ3SZPv zhXrm6&xo8Q5arQ1!dGYR@i{pIFj%52AIG+$hm28z{lYC`Nim=>3={^Ek7%lF5MoKE zO}E{JfvqzKnTv+@x8goQhUOz89*GJ=Tt_7=hkqFsx+A+WtJI=lK73^EX?ZlC$$xuv z#_qTCF8k86U-ltlzuIg2Wf7HPS9F?mwIyiMT}JW5u&*pl#C`N8;zBq9_Cyp)d8{U@ zki);(#INKam{CR!#0j2rR|YDz0toSNO)$Rovt?>Di4W7s5Hd=CqRVnYhHg~=gDO(g zXv}%k_X3$wM`1?oP4undD-Bn>`m*7BpZMnaqLE24-$Z;-vwRWW$nt%Uer+^1^5S%b zYoY|_@Qyh-Y*P+Hl@>Bsud@>-4!5F#|C2oOs$cObF-k}d?~o(U^GI)otf@`Dh<$MC zC~C_-crOgz&LplC6pD~DBlH1!8(!q=bND984ZCK_3|*(_|Cs-kE5ds5&cD z1S6Ahj}{`nG_=F3CPZ0wUKn>en!yrp_QsPCWHi8ugieq1Mv2fjh1x2Y1Erw?bqOhp zh)_td4s>t;keN_Ii`K$96*+kgliB*x!6t7!t=ZUxFIOTa z>8k6%!2T#fDcyId1h1E+M!|J09}Psr%^d!7QQn4aF2v;9)%Sho@2;AUHQul-pY%n( zV|0%EsIp1$9DH~mEtzY%krp}P)sx0KLQi^wy25{)k;69UEDpQo`U)xAS@KxuJU6RF z{0&E5zNu7$58uENC9EZy63c2zb+5QO1nGsGgVpN7!d?5(QV%P8I z^c&c{XAbKZapg0>srHWU9x3Jb+%bnN)@%0S_0JPmU4LQ`)84CYSghbTv3Qt0h8I7M zwaj6yp1zo>FJ2xi#upH7?x3u+4^Q$U{Bx|cnJ3WTRMNOcO-I^9-y?0D3(L6+NT0mJC@3n6KE>;ay|m<`*PJ$8e3kH@Z2 z&!*_q=P31aa&q@u7C@Q?`q;Y$NoeI*u?Ct+9H4boHF@h6+I4bKR;W{GxVNPBEZ(|_ zLVa&d)V)NPK@+3ee@v=qW{b8>P=ibe3Gwmz(R&3OKGqPjYEe&3PEoSbinb^U6(biV zoufvfc2o7Ehr(X-i$tH1F-7|h4PhG|7A_jJ!Y|U|;aa(@DHaZtDx7zTy*>Kw$TP#Q zGQV+G7mojY@ZHgEdS~W`5!xysQ=Z$edC%Q_`B$!jt6a1C92nJk%)7I+V(adzmtz=Ov61jWQSIJ~>?R6d_fjiWEZ*RE zN)meSo(pNku{0wyN{yEQPy)za~ zlV)Ccu1{Q2F7xVOvhQf@lBi6Fw}b0)L)_>Hc;sPPG!AGR@^-v|N_~mCNEN^F+M6LZ zQOJvyB8H)*I%P2z0Jq#olxy}@oE#Ubp&(_iOMc_&?O$AZJqO%7eW+LSCpX*or|ZDp z;Um~brC0Y_eoW%)-h7)?=>RF5_akPL(CoPwV7|je6@(4r2Ml9ICsy1KUD@iWoqg{W z-Cgu(QMt34SQPvgNDnaugaaIdY?GJ$RH=X{c(&yHD1c(*bs@o!KS#sLY%ReZE(UUW z8atT#zfgY%Dgqh9{}glW_HV9NH87#tcro5??b;mXr|i6k8FwzOow%9l}4(7VPUKsDviboxK_v|RW$5!bJW!HEd#0b{c zp>e&|bSED}iqM@bfK$}_TTq!7@`n~1P!Eq;S93HRe3nQLf%H%yP~@%(Sns1E;%Qix zNw47s0*_pP_K_B)-9Q7cpkKVX{3p{u$+>fLSTW_J-_27cm5;WsUoR}$mBRP-qwMFa zELw}5rAt7unpYTk#95koM!v z?B0bOcb_i(hBG#(N4LR9%aLI>cd}6y=d9-67^ziJnm|+{So`&@Mmeg&wg?xr**wnz5nRcyirH6 zYO`Zk=g+@(LfY+qYxux{!;P=#>U$G5ZH%LUxSPY&aP4kF^-}RnyNTA3M zLg|^6hW0}m?k$SB5!V+PBJ)*5=H%t8s9!|zD>(!DRZNgJ^6P({ynFXk90%!BN|TFizJj!$s`YDK3C>-9rq6JpnhW)q+i`4&S< zss+Sfg738af=!>%>_xXFdC+f~U9;pPzh%sK$L}m27`Mgq*YEIS_jrV~b;HtSVXg@N z_45z-^TqOBfTWrqVMSK)7f;sAy>{-zPfus8zQ=Rdv&v`OBXN=FE~A&t0qD&*+alFp z)aJgzf{Vj?<8(&UTZD<7h{kWCX(s{{Qj}VTrDi}26!##(PV&_N8>l`3LLLJ+Z$!bV zVXGm6)re40vIID8QM!e~sPhc0rrK#O@g!7iagT{NZcP|dzB;BU(;o}53_d;Igu%M1 z{hGVO)QZ68|@pNy5HktFM4v3x!pZp|Ol0N8dHeRIFFZF2OOys4E|D58 z3A55$0&PlDBBSo0EI5Wk&}*=_Vpjoe5Ic-qHmT_tnQdi#SsAgv^D}P^$8N9SKcWp{ zch+i~)JJakqJ_TWYzN0MIoBr)%{wF;fS`^7*z-_m>4~Ow>3c5Lr9b)KuFDprnfo-t z#q8IkqXiNnuyn!*u&hRgc$StFN)1@FrNv>=E-j4?i7nvI>3sT&vl?^&j?5!*QYWR+ z@CZETrJb33j_1TCVP()!h|Q6WTv(fZUfA&2M4w^(_$({49Fp+ngzPIQ4hgc)gcJv! zUkE$YA*hA57oah*1;$I&>Zn`?7K=d0lt0FoloVVHOhHuA`R{g@G&-xV!NGf$FJJ?I`cayNRc^!SO~GC6iPL*|jd~%mf~^#GOQd6pWE zHBOOEo#PMChxq>KBT^3QGCYwzEjKPrX49AQZ`3cp@@GHt;=x~k_Tiq1!zGz_W$zEp zI3V?7vBaMnf!zHNrx*>1y$z;HIF%wqtg7xz$PmS1t+*aYcRHU1v-Qw2U#xyz^Nr*3ym zTsT6OeD3k`ENy)+_ex~S$4k@QW4#CO>)t7K8kWHq%CE8Qv?B;eI7j2mwH+yv5NJ0S-$vf~M{84vHHCLZ>t#Se`~h{lM$tNG*N$sR&Ih7p@l1IwZm zylhZ*sf*dA(9)+=u~PV>l4VQLD=S>gu0Y+!5ROMgO2chx?fFq(jRu?X=zo<;6A&xM z2L_dCqMP7a0t?l>EE~aEIcx%csY1{hbfn)gJ3zyzn z=V>H&UgFj3@89%J^D)J1f3R$~(tSX!g%$PM$g_^vB1`h`GQnv`7B4w~+h&b^CmzLq2=gz4u{0_nY}Xk;F6#OM@Zy8Gm);lSxn zybEIag*}c$pr!voWGdBxMLOdgP9$Grq;7=^-$`DbUQQ~|I)ShF10Y?0Hh1_C>8}r# zvu~x0+$GRhqIZ*00KP^r(P6?cTePwzuH!8rWp>d~12@YEUV$e-2Q^l43K$wFNn*Fd z!qPijsFM(xKRULWKY#W$UzE!Rck9NEu-J%C9Vw+s+#A`0{8C4s!G_}sj`NfgSOV~ zA4_0HH@8WhV_`u;wOezkut%{llVqaU2*&h@zCsM)>EWX&_LB;rY*MfiK-xnD$uo#QK#+c4?>UYtZQ*RERi(b!~^PH zM9Cm?4bPK;f5wTbMg4@O9)~KQpzPA9<`2qFxtN`jNK*^TCaPt;P5ek!SGq_;4qvnU ztoFt1+KJiqE@syw_2%t)P9Yg|1y8{UY4puOWS`d5Yn>m%v9FbBz%vt7 zhzPqXe|-6r{eJQG@Zq<={7uQZ(6x8(uKkAp$%gf%@A}8Insc;5Sl<~BO4R#e2rpGj z-j-Gk&g3=s{cE{v*FL)Z*mQ4YC+4C4=cZRZ zaFt#D=v22J$JMWus|Oo)Y*nX47xs{Kd8>BojtvhNZ#Cyw+3>zIvFoRX@(N$XH!iPrJ2xMRot3z1>Qb725iHopC zFhmOWoEmMgZ9IKcb{sM6e}_LyB*$O?HRRZ4p~80-o~$jw`VZSbzX7{-rUR*xoOu~RKaLQMmfie}z+BlW%0zs#E)S6S3O0+FJ4vA931_-srMr5O>e)PklOa!YpoUnxaI z0F;{QUqNX>Zw3zg4N7TRJ4^}!G#Jf3B9o*mIUjyK?#D$Lr-$4f*>2dB%%LmzY}P-T z#a)eB<(l2+;OLI&hqo49e~_ZOgJ9<@w!Z=G7ivSZArVv3Sp%P9ca}g>wM432{7aJY zTz!UlRX~DuEG*?jj%`N z4MIQtKwEiHXM(5#rcm3AD+pN$m=*F=P)GT_KgOF@5()t`n$b)eQBgke@vQzS{MOu3 z3FBA+NBGs)puS_+3^oRz;vR30;KLnh!1?AE9b)a&;TP|boP@#b?(v6NwU+j))0Pc= z6R+Kb*RIxIs}EDgYZFovlEU48wXDVt+aul_x@?-w=S6XV4v*S*<6R5EYD%`%wl%kP zvh}y6+p_B>X17YrPD{+LqHVh#3p98Att%|2x45UAUP3?IG#<%|J<-aoi$ao$8$an`tslwgC zBaVz5npB~D61zUKbJtPBlFOG(YA~vE*O5b$J0ePiAXzh+7n91336&Eiwt$jyoDcHK2*(*2dRiZ zYsxq0j`HUpvJUcUiPd})5!W1ve_EHtmkC}{>$IE}yrlNY>H$DDcT+Y5u$Fus56+*_ zy?!-%V7r3eee-kI{NQ)%elFdSSiQRZitDz-57uX85a;n^pt_3;)W}z4pe))^coDh? z)zeWFmoVp{{6%ugn}^CTii1ri;eRKg2(9oxC86wgH`kI-cKZ%hbuG|SP|$s-AyvMI zUlf3BH}JYzM$P153R_)Yf{rOsBSbG-9Hj{~L^ir5TZTxJh^64V!}tjmra7|ql~NN@ z*+REe?hjUMRQjM<s^ld<%9uNF|DI0{`kTM#C?L9Y4+iRM%R;tRo;4Alkb znI)Ra!nAdWHdRTHxYZ%#NKq4u2ox74q(-JF6$bYo(zpFkOb@%6Z@?#~e|kE3ER7GV zSE>Kt_OVjPAIsBwj7`B5G5ZgLr||Fj3&(~dG(;?Be9u1Zpg9QJUD*kT4bxzl53z-2 z*$VBUpMf{s0_WX8Y(WMIKx}Z5QrX?kEvY$L&s~;=_sxVE-O)Y*?`!TNt?$hL^oDXr z9+ATyJk?;k>^1V`SMk%z8gLC&T?BeMmw5_9Mbug+)*Hw}ag#x;Du)W5hoA>@=QH9F z;2;BOC6+{!WP)s>VTe?MQV%iI)_N<8#T?UTIc(}IiSIZhx?J*Ixm^>OJs&BG^Dl-Kl@$csLJo{-0DhaZ5mbC1rSPG>Xd>o>N2E7AK8A`;POcB)l zjFOl*d~w)7x@ye}rj*-SyqRpzn2@_g3s- zfIc74f^--3{o^{)d3)*e%_r!?*xy8|n4AYdzv)5|;@(WDjjq=}DXlsFDeo`M8puks za(pbRH%?LY2H?12g5KZm^Pm*^z&RB=>2I5T)7CRKi$8kPPY}>Ffm4@wfFhQLk^Upk9CSFVu_4fEol;!+!p+h#ST> zNiFzBQAr_2zX)*)2BD0ua*Ee&RQJbWCL!Ifx4n z=p6Ktk0COxNHTPYHGFSu-9N=A?V~+xf<=HX)Vj3GwO*2-v%P)WGx^Vs1K8IB=mHty z@ew_cE0KKAH&rY|X9-=TqHbKI|HV`c-9w@N8HSOgk?IkshC~(eKHmfkt}fddGIdnH zO)DpAH6+p!o_oF9(fLJomdxriWjRmr6qUe6=!1CCw*pmD38>%*Z4zCCF|AtMDV?Fk zpfLT3?xkrFBUMra^beL9;;bh8I~1>hnITAx(HsQKLJ1~x8%;EMmNfoX4B|u5K#RGd z|2S+0G2oi)i0~k1glGw$f|1uSDIDSUZ*P3KhGT4tH*}4cCEXF*RQ{er5ycYH!HgS@=u39`Ii6fJj>oZ`x{btNBN7>{D(7PQo(6g z8e}owE4y~=UXR`Bh6>p)U_r&g_nW6Gi4};eVDH6wVjY&$q-a8?pRKVw5KKZ}k>He+ zLi7&UB=Qx#i>B0qalEuivBrEEY!X~6G^Q)#eZ4j#S!+=ll7uF#6q-&-jVFJc{HO3R zu|tvQR;O){^2LG0=?m`i%Rg?f*?8rI5?T9}EIT~8X*(V;w#VxgD^&|gd5zU(O6?0Q z{~2Y;_=cO$NlR~~AH1J;e$3ZPf?3w~fjzd3A24%lf85M+e7)mi+{{3Dk`TG#;{6kR zqjQO#4Isf_*fiNtxPTN{Fd`(oXheYk7K*YBz@*p7NBDcvWLI|BFS(bXB~IpEN8bf+ z|BHBj)J~8fQ9a_J#S$;q!L(#0gBAW$ zI`N}kzax)1al+l$T)_jBhVm13q=2otYB$A%EM|&em6vx#wToe(G|a~b4!_wYKwb_z z5{xFh7!-g`gLptykH}6S8qFq1&65Xj9N5$CP8+)M*$W0QpzMm*66~xDznZV0v<1C7 zsu#%j;I8BT4Mv)3B&^y$QDqcidrNf)EW9T2Ede@<$oIN0flN#H1tO7bbbif7%)?_d z#h3^~TwJGD{xhd;#f|%pu%~Rw#%k42b`)TrGdJ|&9@c_qe^8A1@h6S|t^NpA&dOcv zAZ3~CrYw`7LEkj}7732i{X-PUiv&MLc^E>6YOtw-EL!ovR^WC_cdb*I9sRxZZYJ&% zrGCE0lh{S{GI6&je<>pG9(w(q1iGv^<|;>&5P5OPLjAT|8eizts71B8qeTC_Q97yVl4Pl4QrML~^^2%5FSA6@bEg!wJg!!yx zJ}lOS9+`VSs95JaZkGnGTrxj9lC@@Ksu#GteP8KaENUI&tNH$E$!ET;cdBH0d0??m zKD`EC{4BDhb45Hb2C^(#o3aoc#0@FXgaz7IC^ES3*^i?)12`m2%Jn?XTDTVz>rG-9WYPmxp)0s${DgPuKz$A~cL zyUYCNFnP+1ZGDR|=fP{gyI=QPGiGa_X|;afgV+V1;b+praxYaXa%}O^3a3!o$9eY| zt7lH=Hb7byl0IP`**XhQ(f3H$6TQ`N5C79VX-H>_!}w20MsnM!kU^P26Snr+oI#iy zM~j08Y}gNEX&8kDxVjz>g0LgDaPrEcgC$G_TI%C=4`V8;#AEL<8~^PO%u|&3F8*8^ z=FWmA@xIi_J*OD|_HUlUzdp`}uyml$;aBkcN*zeL2pf9fdg%AqLze8T$Q%sabKX$2G zsoOi#4$O+$S^v!F>Aw14X`*x$CMkzvr^C>XF`ubVw?!IS0wucXW2h1Yv{cDP>AY7= zItgJ953bCrFu z%VDUjGxr!5wCk$fqG0}oc2Ogw(Oy$(f$>cLH$n+%DMIpETbBd!bMZKvOe{G5*X-)GE5}_uEWl5TYMf6#<_-Yzq zE3glcT+%R)+^wOB-K{GoY>{ zjd2`>(dpMuMA%HuiM|SN#?oR|sEU}tr55(nt}AdHur$;l@svz1laU1sb3w1b!opyC zpeH{dI{$1xC*`>p$qN~u@%az@?)|y9GF>zKj$82V!g=Tixsf+7aR2zJU;7-L+I7;z zo=c_!WXo)w`6#6x4yQ2ns`sn`F1!b1crnXDnz(^*abGp_4op+440f80eUG=QOSaLJ%5uD+ya6kUw>@~M^Va00i{Ja@aquAiL?KU@Ixw|R6|Dmk zEM>if)}qP1!44Y}EsKzFhJ9wThryASvQB987-|ZLem5vQfyEEFf;_|R{$b7INi6&U ztIWU6STuK>yh;jx(PHG~S?64`J5h4X9yM~IOg9A31V=WgD}dU%G}Wo;K2*yV^w{wh zsqfW&P9I@fkeD@NSkz$c=NMoGnQs6PXn;3?NMd4(6QRO<1g114J^=+u)byr=PP#Pw zA-^T)b~WxY&@ROk`LkHOq~x6Ad-yjK7R*m~fBoePSqjfBB+Z%7==&yJ&egr_lFvwv zIU`3cqN=MddA~R&qOyag;7nBuYEy9V@l>l%OeWlQy|Tk1uk><3@iM(Y0^>79uQK$% zH9`M_#1GX%+V5~chUr3;5XPu*Q`NpB0>Ho2l!TqAoFF_4*H1 zT7*;KUz_|9L{<&c7PC7<-Dj-XbyW++YH^}a_nRE>dH|s5TN6kq*xp3a-^wgEk^bSTw%CHiQ2iDKn{AhcsEK z^{`w4rrbNUcAd|LpQYpb&*nK;*x5VT`yCJ3Nm&z@ZJROawR2D9VN)ASJgiJzc0$_B z&orv=0anz29}v?TV=)cDPvjAN^d2m9$ z`o)dj_4%_IAN2UHanskvG_1I1+OX+Z%SgUWDTLFZwKzO$!EjZ&iefFg>EOL^%vV$$ z(OQh-ureWLy3lfQ(+sU8)`C7#K;99M!cZ{XCkyy6yO{Yr)W~AwQjp4}hS|uCqd`q5ia<1w z4`hdSi%`Xze$b36BVV_v#3&%H1X!V6Q`Bi?wkA>|9Xye zM)tkIqNE$Shg$aByXD|_y+7-{C3o7f!&p^KV01d*bYie7&F|6fj>X*-;1qpTUZ}mL zc``ZZhLnJVXEOg$z)W7UwkmI-ZH(xpX%dJ)lF5<$uh8JFJ6lPj5s&l}d#i4{J|qhx zD#+5%&n6uIjL9SUN`CJTOu-VZdE8}M_FVVwN1wOYld*;f6Y`rJ&{M&Hre0Aabxmsi%wrr zwy|nq(XUNghj7aDYa`UN{=|}!5HUC5p^4%s%|pBBya^9&Z%t#6i*U_zt5MvVm(SuVs|pi(D0M`~HGOHWy;`GAD?(9bgYoKSXv!^Y!v*kO0Mmc6CT z3wM9n`DiyBkPn-Nm34+4dk~S;7+ZbR9L8a5wUt?fn{I1RYR~Y<$lfry&4oM}3VUpE z2|z?@W~jku1sc(GDAK_X_=8o_N*1~E#;h7MHmo^ya_xkvvYh;9LMW3a-w$8S3hZQo zyIGO7?LT?*mqU5BefOm4BbT1r)2`jz^MmO;-p+d<|AHt%QA{%lHP!2iIJe^b5b0acj}RwDLXX z44=DXw^XP8m_GAbuFGZ>pBG*JVs7ojRS&-(z49n%jDf!I0NyW%9K2nh%mt>lBwxI< z|12H?MXeBf#FRgy07?yrAa_CY0T6%mGP+lrv)lX)WfFhG!{vV#E+dQIC&>-fiV9WKJZ}XqqgloBu*LcoP7xq7m5%GmYW@N+Y&bk=bm1 zP@cnvQ}yIV1)HmhfLE5)u0jraDcrw`aY?Ji&IMq z?6r{g)DKcY*Wu*KC4_Y!W5SY!(V#Ij0h##myEAsV-r??bFs6>OfYDP}BJ%}DxHo1j z*v^vn9bL1Lt>~3GxyzIz{qCJ~x2?Fj`>7xM9T?Mo)QBG6e?FA#|31nP=^OjEKv((0 zCnGqGKyhU=RPYI~E7$_jeHuU+j%n9P;Su1`WVU$d*FSgWm-om`T4XCLSdDNX%UX9G zU#~%jBjgj+mwctx(s|4^4i-Mr%g@(HgOqpWifCH7{HCqR`I_`MlchoSOWMz-+j{pF zKT~)O`CS{lDq9^JcFwa>`7;M8bmbW!W7juw+|d6z)J6LwN^1cuij`c0M204^y4 zo)F*X4%CwMeV%4~PQO>?140<#HS%N5A)3AfdMi;T7(L&1A-D=ebh5;&SLMJ0m=>fK zsD0cE7Px3+c%~t~qM_7AdS5G_(-wd0E+6KWFVHLcl;Jg%zh?5+W`MaAGqBH3Tr&-e z3i3HntKFzNWRNX{=_8UDAtGTB+fK7p(n_kGLDZQPOuU zT-&D2n)!QoE?U>7&FV$Fym4>OTlCI53+LsriPPm1)7?4svs<*8)|#F0{qVR=>jw2( zwVu(E4VaTRa^zc!o6XJ}F(PliGIefV-YZS#?A-ZEVO(={O!LB8jXSq*g;FgYTeL8L zlK*JZsnhe#o4(MQdTF6U{31C+*@;^Uli+C6WEb5igasn^$>=MZ!RkJE3!%daZ^5&kYt{2j#$8+o$|5koYSMXcpxmY{$C)`JJiPJK$GK7}n~%kg*5NW+!U zjx<0|AgEb7e^3^VE04B1oK{URm?^K4KX$y0CR<;FD6OLCXF@eoI1B*_(JBVZi{&E6 zA$T}DXhF6-Fh4op&zjD4wMbu}36I^w8}yasejI>Akm*{+0pOYw!a-G1DKZasa+c6? zIa7{1ruR!b?$|YXfqqI4WLI~0^NkJgk79S_K6dT zu~Du>7n+nq)2E)+{HsRI1T(Z4$qz_?P^u%~C|iD?J^rA;cQvsO%A{^sdFuD;)E@SK<4Ts|2RL?RGA2lO=#*0*HP zkP6aAT9DC^4iWzG6gLkuAiV<9@k%+obhUmx>iiCj6KJ7&atI)e#u3P41>hRSk@08{ zNLh+`d2Uiht#{?{ERY0pBBSCuEdVD5uQu{Wl8_3;D;2mx(Wm*P_Y=naK{ECCh|Rl;)WDHU4=46T1t)z`sJAROiR~=+z#5(4W1Q z$&1syTPsykj&EMKS&v|U(ieVYH}q(Bqwu?R>(=SO=&u@s2d_shx;i++vdDScq9qzR zZ;4i9Di@3eJaMupxRjs0i!(EEaAqVBtXekS2~KJQHi;+9p%g_zBqf@FX4-Uk*uyAC zzBHPS*fRrhM1DPClVQr`QEkW&BR?}vsd_j{dpk1@S4WfB4z2ayCY8Q-debAe_>#B8 z_`8Q6_gZ=Hm(8$OZ>v{}_ZOB2?EXOU!r^`FUFOmsBDPw3;3TTBy69#=U7Sm*R1=)Y zC3RNE@b9p}yE@==g19u;e5;@aYz^s#Rz0&O?E*v{_l#FuHC}Pac&*4^tMJ#VJ{a`V zkhmR395T1Ei8msSio-3ZCj^jzVWfgkOA}~NAf%xV1fu~q{Q(h*tVLl^(v@JcDu+O1 zp8-k{@B`ok5V|;mV3k;O42Ms^EkHCZ%$<&eJ*4`CQI;MHbJrj!MeS_`Q0hViw}P@P z^krbVf_mJMxPvS}Dt!T3@hvNvxiTQLSL^(^@9T%l{iHvb;aRTVU3qt=?)|RZ!PR{q zcVRV#UGjdGTk#L2cy8kF`i?^t2QYW)3*PGKo!DCeVXBKNcW>UZ+53%Lj=u1tua;Tv>9CHR9qvZvc%9R-$N^RTuhqGk$J_bC%b@TtqDNF!Q21nNkrr*ME##*anlvgohTGDU zAsktx8W3O)wFz?vbJ`6!NTzyb5VH4&M#e{`M}oqKS$_sS(K1F;3MW7su)y2+!060_ zB9ap%#Ydp1e}!R4aDlP|D25;>X6yP9y-H7-RV3+G19t7&wL64W_70g>df z{SDS%4&AdW@oX8Ez~m*fmK3qL_;X3Swk??j#zvH#_irf$JWByx|4|Dz=z1`x3wQ;8 z!mFU@{O$zKh;SKlGp3l)4TfBn9VsP*B_t$NO~_7Yo6tL9RKl!;z@A2K-+p)5BV{k3 zZew;asccx;gtAr3W|wvJ)KV?l4t!2a&kn7cmYvo%t#{g}v{`A((_DU*t<*3#A|epRLVFXGa*%9fz#|17i6Ci|RGOHT2y1#)qH^VGM(FA4 z>te2*Vi(b4S7F{!b7Q=}AU(a>*Se>svPXT`a=`xWd&9lgL*#a^^=!3&!&#Eh5)oI^ zj`Ok=TAa;d=9kVQ49Z$$IF~*n(3pK|E1gevX3u`r?PRxCx}EImeJmIL)0+021563! z)~z0BpGoZp3>n8Syw#73_|ZA@G@^M|IZAkyDBIQU0_J6$W28vL;-jJnpp>EUEDYq=LRuzj^rmP$e1-{F2B z_ThTrJh1wa!LLT?w>DhkT|)teM!U?kL?uK2xbPhQ$zScv48H%4*J-K5D4~sx=CgJzjwwuOy^~8d5E3wN|Qj9b(j_!st}- z3i^T9EbKKi-JNA~O9|SQ0*_7#$x&d@Kq;u3A%R@61Sz*sXebXcCcV~5DxCJGaCx&# zx;rIIt7!g~T#MDrcL2mFXM!1?Y9)(8eYEsS42U(gNFLS;tx4XOTO@7SG-uYEc@1mU zX!P8WY*u2=f`z-^(tmp=59;FcZR*u;?R`8$J~2ZrH+JP)i<-@OXZ6_3Y$$|HIz3m- zn>~NWjwQ|J?0oALN8`>NT5?tK9#_I-sTFM9%N-A4qfL~$Y0QWUfV@GqQ0%41IV=S8 zSO9_rldKRk1ADeb%i#7){OjHw(4L*inP{oTlVzb)%jhZ{lc5j_Qiz{R{^mco`t=g1FAu-^}VnZF@*x4v^w>!nlV#x2hdtk57^BaXEk za^X?O5$Kws@C2nvJA6Gwh!X%%@e0!sUPZ=NDlR(J@7Bc|uC$3ai5Qkh@IZQt>F&ek zKy(lDcwa%L>_wi2nnAJLbNI&|~-X868y7y?C(?dUj)TNMntQfn0m%Xt@PS5@1$c{E| z96h?l*?ZWqe(ibzD(rtUwDQ$zXV8FPitaMd-fz>U>P?##d0EIL z5D6M+zD0Xbc%KiPE9_q!0RS+d($27bWbP#U6p5@L%E(%W3Pw3a48kgQlo5&cG08kG z5CO)@Z`0~e8~MuYdGki<;fuyEK@Mo3{wf=%FP2N|bJ=w7PwXST3Htylw=As%k68h) zOf;;*eQkY>Vtn+=&#GtAuaZU@Oi3bCrcns4hR9&?PK!2%KuL-*hfo9nEI!mMk&Hla zFvE=r@r%JkkQkn-MjiNI#k~c~|9N@dk1s1;Y2xbzyY%JjrpTqozBpzhW2=@iCHGa& z^7{JwBc>=7r|36W+0oE#x)%NIIOBK?u|t=OuE_K8eVX(b%*4g6DUiIRF#kKLS`B~G zvFLB-OOAYu{Yok$ucp6YZ~Ok{W9x5(dz$}Ecl=Ec$J-ctMGA&xh5m*@KhE)7|b`q*3_+ESdUN z`eMC=N~%eQa|~7_+4u=UqMuf3W8Prve&MmKdTc5Y36yTmFrx2pJLF1>uzF~LMas4I zOe}T$mJ^(I2d{7U@CkrGtvso#F~XPO2i#nb%G$R0&hs}%w&wR0+_PQ z`=`?Q@k}|#yH9Oacwdfy)H(LpI{Bph>H1^dv3TAldAM>Lz7>Ts{6V3t%iPnsK`=V8l$=DJd0~f zBpHw;*RbJcWo4$O0pOgj>$84YzU22#dHVHbKhK{1(^8hQb02 z(xlU|V_O^>kG;FRR7#^3r;Y*1iuUR)<+o8`AzG@Z$-b;pf;+Ab`$e%)T(HgU04Ib! zHpXE_{bo_Q9z#8hX3O{IHEIx1x!fZ8h}NKM$%@st=D7DqeiX7%z4z|1=!3=HCki`& zF*RCwgf&!xgcxQ-)P<4WC!zb6n@dF`nIn%&;o={_)ruv+XSRqVBK;5op`}2DMfDc^ zkU;lgtGb8JYyotn7+1nwMM6NR8B3Ru6^%jpVBp+#P2XtMzR}B7m8I2d$w_SzW?UJP&?-f4RQC+t zr?lel+a%#VuwjKXOS!EcgB3c9JHc%BGYAkMYA*f?u7(_1E83b4B$U`6;TH%L*cvsd z$RZ6A##rUH_lH@-VIxYMq4$x)=8x$2k^cE~wu|-uss4^r*H7Q7zxVWYQ2Dx2q_RXE zDh-knKx<#kmTJEaf8DyGP2L^&?JWHEd;Z%bzu*4g-GO^cL0-!Z;IRYN_F9@Vh!-q^ zq~h1*LC7^GGCL&_Z?ypa4U5lUC`87T$Vj%WfX&rs9oJ{D|J=HJ6LBhO-U{+p>`T3( zUI9!9>v>aphkfWE`WOMM-p}ASl1iyApS3^y8rkh-sh>PkuCLYx1#n3fQ7wdv3glv> z(glL1!U%+4ajO~}1u7jQC2i7|v~eS=zj32HlcG%6A>2`uqCu=1bAn=_LI_c2!!he~ zOFEs$XFac+VjW&6^mlv^}rWdF;T6x*N}a0Or%&Vj!YR-^d3SBK zGIkMzsLu#nMiY0OiPiggI8Cxj7huMRz-MM-M8(617lE)?fv|vbTns2;v|%Ras|QY2 zcc4CW?SU-{u3htB*AcFX_YYj>_Y2$_wGW9Nv@@Zz|$VMS7Fy7t5Op zBP(aWHhKo$;#VA(@7bcMV9WoyJ`w}y3NLQoelkD1xwCxc9yZQ*nW+oM(q(#3Jn1uj zpZ)R+B0dgyx3~YjD)X(o4kVS>js+$P>sUz6$Jr$TIt+;RJ0Bl_kY`Q6O=7tJ*%%OG0Fb^I5F<{- zfE){y3a1dfLkIXAP?Ul^-AG#mFi-Lk`}0H4WbDr@0dM+BlHne)RM@AxN5GJ5rVAUB z1!lOR;g&Q=6>00lsmO&0jHazZ5~Lrwvlxkh>Y2mUd_ECPYcgyr5apXci*eTcI9q)7EXcd`8#Q40~!OARrrWvc=Z@ z&Y>lK(^CA#5AQ4EHN6+PP(IfjGOFL zbO<;Qq$-q^MR_9#Zal>b@)*+H$fix%+mN>SdBqyvpcH2~Cm$Y7o}>lK*{)r^yX15! zlUVxROE0t?@FFX_HRo=@ybI%QIwtk&-GAsm9roYc-@WxSSN5Fxjt=#}LY!&d7P(D+ zm%f8LHmNgs;N;d#d$y=wtA2ydQ#!AFulL)1Ug-J!vke>9DBtC+#UI8F%bC6X^zj_Z zE7QlgZaVhBhfxZYDhpK@o|B%3rSAo)oAjd87c~NgNF$^Ps2nu|HM{3ai=?IOh!(bN zIZ-o6O5jI$MT`Sp;qbw0UH)31zc%HsE%<9&{@R(pcIU5s_-jA@I*7jx=dYvq>qP!K zmA}s9uP^i0h5U6fm})(PsW!#kHU+cSrZ-k9Y11a-WzuZC%&Lu-pr-X`r9ncN!|kHp z7%X*$R5OwXnoM(#L{wYbVwQs3b|n8Rc%97;N!lxCy@Lb(HQ}uQaxZoaq!Hs?5m}>Jnh)u(^X4dagd^mCg6X)i>YIk@Q;>fT8diGKCVFl(YS;WX(J1?QU+IYK|=IF=J5Q zGc3m2oke?lpz?5#_49{4C)BCiv+%Fc{GTgnB>%@Q^_@KF#qqtSO?pv(S1$K_>y8N_ zO`qAonm1|FrpYLJVLAHzHa**Q=w7pO;VJg)>CvN4>&Kn(tjxHbJI5i(t#JR&4((N? z#W?=&VJ%~RrJ!wvvicW=GaeO2Kb&EFgM8Yz*tCxr6K300LNPK3SX$|$VEAD zWuIj&foHK%JP+J9u&hD^S7DNsxS$C88~1%6CzT@Cw31Pz8KQ8F;y~GL%5$J=b0H4G zxJ#JLW8vQ?-zj~mC6vR34GSBqp=DEA^muE{^3C!YxlX0pwR(0>KYjXiOI4Ls)!DwV zT46Qi{9kqIlt^39V%EA>XJ*vMuFt#*ZiIb= zOSoK(CPE9>ge`hhQpf=U$b-ZU5K;c{Ub-h&_BK}BjtY;yQg1;cL`_`%1vM60a~x#X zxmvtoTY*0~7`RScz>nMW7#Bh7xg}pjv590S&8hPICK%+cybuIW(T7@$@jK)`S;l(O zrY$EP4>-ed#%2Kt(9JwGEsNtsCgDzM7UzP*=bbsj&&@kLIP7!%@$F6rpU<4LeCgaF zduE;;<5@OoP17c;r!A3FH$Pr7e@tQN`}+O#XWXsc*bp;uapA_?BeU6+sh^N8?ABYd zrP4W2;VNeAo3AV)KV8y*ZDtaX9Bz|^%`QC3h4t{IWrnk*qq?tYTB~?Uy=mw42PgaH zgnt^0^mjY~@qw?i9O*s4VFcGBWU}Ux1jr~Nigb@0k4Jx9CdqZYdO?2RyatUUL7K{K zI8K;4)3b|(I^&(`&IZmLr=us4!$7l`A}pcgo`U@xKZJoor@Oh&H#@{|`ZkEniR=~W z>`4qFhHM*T?&4Z9?1{f`giWVt(rD( zvAkEaTC*$0SA3~Ylk|m^%T{){2TYwhAhS;03>MmQXtV0&;#v=DRy~F9?y`IzNq!-~ z$gX^MF4$yfcdmF>x~qXJ$7Sp;$l9Gt7{T#_!9fgc3@y&FG1{cxS{!F6g2aMu@`a`$ z=?HmLSwv@@E|+|N?~0Xsceicar1kZH<_%W5Hs!WyjA9Ob5yx1gcAbop6Xe)ZHG8*h zoL(n@=lYIUzfdIS46Hd^>iNHJ#I|N!1P2o9M)=CJLqm(l7f&zVpmoHgzpl*D_p~Jq-O_)I^rGajs_03rzShNzlpomvI83gPo5 z?`og0imMKFtygVoN?e6*UF%kynOdr{`s>oAx31Uk)u?h@iR|7DpDAtZPz`dP!49LP z-v5tx$mt&G3>?UejiELXh2?3*%{6OL0aUt(Fp)1B>b)IaeCv(*;GnBPt=X2c-v5Ai8gv4loy`$K~AIODy?KeZj`BzE!U-UHYg8 zD~5BMqW-GB>RfJHivtd8AKSx&;gn668c4WpJW8`1=}8{TAPfoO5S!tkLqofrWVZ25mV7BU_p*}l-Pao(|9P}@DPnj2Cf+h$z4ZSH zgW%K&hpb1^<5MswoZzw~sNTOYs9Z*@9fS1M2gb~D>=P(-?g-K5)#_lj(^&~Np8s=9 zKN9C^)MTl18_4X7PR>dlhEJP6uj_u=A;RF*)#?&he1cGe-C~$!z>JA|3I`E?!;Sl3 z(^?>}ae*VYMbBwL{6q3x)43AMR+I*M*>#g5K>a! zVGSp0Z0Kf+(JwO1NE(H(I)Ux|?nc+{y+{4-+F62c83JxgsAu0ObPQ@=Um<{Mu!HOdn_eT;r< z{(?!@9WHCSKT{NQ_iM*&*s#wy~*ioyEZREspaF~j65|F4?@ z9kT~(;CwABiE*MT{Dzd=gX2ty%nU0xflb}Ul;!d={o&hl=dfl@{gk?7(c|2DTlH6! zQ-u#%kLOtL0Gu4wqrAu0)5hiz6i3D-KLXccPVNyYW(QXi+ibQxII1jX0ZtC~Dat~i zFQu0rB)2O3M5*gN!0xku^wUSxB?bCj-}5)&`GLsd`0u#$RCJ-|`+0}BV~jtqyI$O* zl=q%NF{E$wa`Y^Uw(AHeX$~kUjXDvfY}t?8SR=MFj{8V6l_Lx;bi4}kVDB(lm z(*25Clyf-~31-3&GiP+>;~QTvC5tE1MKW%2^n?7VW zR+V^_Rbhb*Ss<%?>_~r<6wf|zsP@Ty1mOfXFY$Yt|CD?3+kwDu>o_^VLEaR$69{Av zJRFEj%?4Dg@jSiYr!iU){S=Cy@<#^F3`EVL>w&jvSTFjhbYNxr*}83HoR9?)@+)35 z)R#Qod+5s)>@kj84`;WrX4IKt6R~Cm(GnIZ6bCJq$*0O-6Yt)|Q2bR&(H*5fek?8} zX^zk^EZX>EE|7CrG_Bo$GJi!`9_{&2JN=(~2U>PkmlS@#=lqeqyI<^SKfhq9;iNGV%c~+h4E*EQztY0e`kTmGlJ!?%E(gvLjR6^Ggp6G zUE=l5M|{Bp?=<5U5f`xp`f2&6tU^54#u9iO6NQEkmMazh;5iDs@$JU3jGs|&joHqK zpQ+zI#2bOh{2usrag_Xx2>jES(dX4=d<){=c!^f*F%rxu65qjh+OamQ{@s%L=C}1X zOXAK7-&0zGAw4VCDSTBKbrLRD#>oVM2cWLC!2{sQ5&AYi0dY`5v4@Mq;97np81%^E zVSq=RkdM7mxTRtJ8SG&{U-JHSNx%Ej#K{8?o~+|qF7Mv?sZ!9TW9PxNqE`1SEgP`%aGgKg5bm=s~e?i)CWCO{K?Dz~WBcO zUgPzXi*{k86u`Rmp#d}WPXdFx-z*DpL3_$p(0wclZmrTR(z9uD%@{tWji z9HeoMwQ-KfTtm-!2lq<39b{tsAIQaF%#5-xZeTT%yDTTH7G%_z1ADR3!m%M=76Ph3 zFhod*eAKz1pJ#p#+wE0ogY#YAi%uU$d0fhOG}VZ~aSmdHoCQM7%pNKK(M zUsBpFlX$GnBp!uWNJV~0Kkx2v|JX&{GigAt5u=xVd*q+9i+-MQk4b$-vg(a;TJ@dV zW90`YH@EBAI=fD*$JR z!46~e86JJEau-5LmU4<>MFs(j%VN$@DN_r!8)+HI3Y0O5d{bNOy{Z3lolG69_Sfyd% zVUWu=l>S&#GgmBnolbeChl=jAal)euWm(9su1 zlXjPtMpKf-`u9bT&}nZrGEqy(kJu{j$KLmd9LX)^MpzLHW@=6QJ@}oH-#v&G;VkIs zcph$n_*0@>h80J#%bs`j{;ZfhMUM2k^kM8x`6k^%LrA+GJRwkOZ0y-@QUR|vVD%LX zL+p?(f=ml^7*+$s7$aheOz&)xJhiZmJj}aB*?jcq5~XgzBBHA;i~g4LkjobW?>{&a zkKHDYBqBulDVOvyV!`-G*8i-YkhLa#m&fBznW$vaG%Qt^8 zevC44q{`~&=H+c#zj4p*O~E^J-{1K<_CE`Yl9fo<27Hjt;aMy|uo|MFh80|~Js1L? zE3;gNEFV0@Mn=kYs;-=f8S7$34LgiQrNjVRY&!Q3iirh(w+A%KF(i*_N+=gg;Y

  • 2a-ytx-(Y1%*Q`;e(V?9Smc22g?_xG~I&%zq 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≀A#j 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%0AHUW;|^l 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 XDz@2YO+x)8BOh4hv + + + + + + + + + + + + + + + + diff --git a/modules/material-date-time-picker/src/main/res/layout-land/mdtp_date_picker_dialog_v2.xml b/modules/material-date-time-picker/src/main/res/layout-land/mdtp_date_picker_dialog_v2.xml new file mode 100644 index 00000000..dc84a223 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/layout-land/mdtp_date_picker_dialog_v2.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + diff --git a/modules/material-date-time-picker/src/main/res/layout-land/mdtp_date_picker_header_view_v2.xml b/modules/material-date-time-picker/src/main/res/layout-land/mdtp_date_picker_header_view_v2.xml new file mode 100644 index 00000000..cfb4eae5 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/layout-land/mdtp_date_picker_header_view_v2.xml @@ -0,0 +1,29 @@ + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/layout-land/mdtp_time_header_label.xml b/modules/material-date-time-picker/src/main/res/layout-land/mdtp_time_header_label.xml new file mode 100644 index 00000000..18e1fe1f --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/layout-land/mdtp_time_header_label.xml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/material-date-time-picker/src/main/res/layout-land/mdtp_time_picker_dialog.xml b/modules/material-date-time-picker/src/main/res/layout-land/mdtp_time_picker_dialog.xml new file mode 100644 index 00000000..c7fdad62 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/layout-land/mdtp_time_picker_dialog.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + diff --git a/modules/material-date-time-picker/src/main/res/layout-land/mdtp_time_picker_dialog_v2.xml b/modules/material-date-time-picker/src/main/res/layout-land/mdtp_time_picker_dialog_v2.xml new file mode 100644 index 00000000..6bad2f47 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/layout-land/mdtp_time_picker_dialog_v2.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + diff --git a/modules/material-date-time-picker/src/main/res/layout-land/mdtp_time_title_view_v2.xml b/modules/material-date-time-picker/src/main/res/layout-land/mdtp_time_title_view_v2.xml new file mode 100644 index 00000000..a854c807 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/layout-land/mdtp_time_title_view_v2.xml @@ -0,0 +1,30 @@ + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/layout-ldrtl/mdtp_daypicker_group.xml b/modules/material-date-time-picker/src/main/res/layout-ldrtl/mdtp_daypicker_group.xml new file mode 100644 index 00000000..b61079f9 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/layout-ldrtl/mdtp_daypicker_group.xml @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/layout-sw600dp-land/mdtp_date_picker_dialog.xml b/modules/material-date-time-picker/src/main/res/layout-sw600dp-land/mdtp_date_picker_dialog.xml new file mode 100644 index 00000000..9264cbab --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/layout-sw600dp-land/mdtp_date_picker_dialog.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/layout-sw600dp/mdtp_date_picker_dialog.xml b/modules/material-date-time-picker/src/main/res/layout-sw600dp/mdtp_date_picker_dialog.xml new file mode 100644 index 00000000..687a48e1 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/layout-sw600dp/mdtp_date_picker_dialog.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/layout-w270dp-h560dp/mdtp_date_picker_dialog.xml b/modules/material-date-time-picker/src/main/res/layout-w270dp-h560dp/mdtp_date_picker_dialog.xml new file mode 100644 index 00000000..12e91ce4 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/layout-w270dp-h560dp/mdtp_date_picker_dialog.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_dialog.xml b/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_dialog.xml new file mode 100644 index 00000000..d4aa58b0 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_dialog.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + diff --git a/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_dialog_v2.xml b/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_dialog_v2.xml new file mode 100644 index 00000000..87b543c0 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_dialog_v2.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + diff --git a/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_header_view.xml b/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_header_view.xml new file mode 100644 index 00000000..777d3765 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_header_view.xml @@ -0,0 +1,27 @@ + + + diff --git a/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_header_view_v2.xml b/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_header_view_v2.xml new file mode 100644 index 00000000..1ae8aeb4 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_header_view_v2.xml @@ -0,0 +1,28 @@ + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_selected_date.xml b/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_selected_date.xml new file mode 100644 index 00000000..270c2d9a --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_selected_date.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + diff --git a/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_selected_date_v2.xml b/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_selected_date_v2.xml new file mode 100644 index 00000000..38299d30 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_selected_date_v2.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + diff --git a/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_view_animator.xml b/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_view_animator.xml new file mode 100644 index 00000000..52cb94a4 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_view_animator.xml @@ -0,0 +1,28 @@ + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_view_animator_v2.xml b/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_view_animator_v2.xml new file mode 100644 index 00000000..83b94717 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/layout/mdtp_date_picker_view_animator_v2.xml @@ -0,0 +1,26 @@ + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/layout/mdtp_daypicker_group.xml b/modules/material-date-time-picker/src/main/res/layout/mdtp_daypicker_group.xml new file mode 100644 index 00000000..2b30fbe0 --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/layout/mdtp_daypicker_group.xml @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/modules/material-date-time-picker/src/main/res/layout/mdtp_done_button.xml b/modules/material-date-time-picker/src/main/res/layout/mdtp_done_button.xml new file mode 100644 index 00000000..17dad5ff --- /dev/null +++ b/modules/material-date-time-picker/src/main/res/layout/mdtp_done_button.xml @@ -0,0 +1,43 @@ + + + + +