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 00000000..39c63d74
Binary files /dev/null and b/modules/material-date-time-picker/src/main/res/font/robotomedium.ttf differ
diff --git a/modules/material-date-time-picker/src/main/res/layout-land/mdtp_date_picker_dialog.xml b/modules/material-date-time-picker/src/main/res/layout-land/mdtp_date_picker_dialog.xml
new file mode 100644
index 00000000..394d3b97
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/layout-land/mdtp_date_picker_dialog.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
diff --git a/modules/material-date-time-picker/src/main/res/layout/mdtp_time_header_label.xml b/modules/material-date-time-picker/src/main/res/layout/mdtp_time_header_label.xml
new file mode 100644
index 00000000..e23489ba
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/layout/mdtp_time_header_label.xml
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/material-date-time-picker/src/main/res/layout/mdtp_time_picker_dialog.xml b/modules/material-date-time-picker/src/main/res/layout/mdtp_time_picker_dialog.xml
new file mode 100644
index 00000000..9278aada
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/layout/mdtp_time_picker_dialog.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/material-date-time-picker/src/main/res/layout/mdtp_time_picker_dialog_v2.xml b/modules/material-date-time-picker/src/main/res/layout/mdtp_time_picker_dialog_v2.xml
new file mode 100644
index 00000000..8da06d14
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/layout/mdtp_time_picker_dialog_v2.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/material-date-time-picker/src/main/res/layout/mdtp_time_title_view.xml b/modules/material-date-time-picker/src/main/res/layout/mdtp_time_title_view.xml
new file mode 100644
index 00000000..e4f2adae
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/layout/mdtp_time_title_view.xml
@@ -0,0 +1,30 @@
+
+
+
\ No newline at end of file
diff --git a/modules/material-date-time-picker/src/main/res/layout/mdtp_time_title_view_v2.xml b/modules/material-date-time-picker/src/main/res/layout/mdtp_time_title_view_v2.xml
new file mode 100644
index 00000000..7a560cac
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/layout/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/mdtp_year_label_text_view.xml b/modules/material-date-time-picker/src/main/res/layout/mdtp_year_label_text_view.xml
new file mode 100644
index 00000000..59bd89ca
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/layout/mdtp_year_label_text_view.xml
@@ -0,0 +1,24 @@
+
+
+
diff --git a/modules/material-date-time-picker/src/main/res/values-ar/strings.xml b/modules/material-date-time-picker/src/main/res/values-ar/strings.xml
new file mode 100644
index 00000000..2a75577c
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values-ar/strings.xml
@@ -0,0 +1,33 @@
+
+
+
+
+ "تم"
+ "شريط التمرير الدائري للساعات"
+ "شريط التمرير الدائري للدقائق"
+ "تحديد الساعات"
+ "تحديد الدقائق"
+ "شبكة الشهر مكونة من الأيام"
+ "قائمة الأعوام"
+ "تحديد الشهر واليوم"
+ "تحديد العام"
+ "تم تحديد %1$s"
+ "تم حذف %1$s"
+ MMMM yyyy
+ EEE، dd MMM
+
diff --git a/modules/material-date-time-picker/src/main/res/values-en/strings.xml b/modules/material-date-time-picker/src/main/res/values-en/strings.xml
new file mode 100644
index 00000000..c9d31e9f
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values-en/strings.xml
@@ -0,0 +1,35 @@
+
+
+
+
+ "Done"
+ "Hours circular slider"
+ "Minutes circular slider"
+ "Select hours"
+ "Select minutes"
+ "Month grid of days"
+ "Year list"
+ "Select month and day"
+ "Select year"
+ "%1$s selected"
+ "%1$s deleted"
+ MMMM yyyy
+ EEE, MMM dd
+ Next Month
+ Previous Month
+
diff --git a/modules/material-date-time-picker/src/main/res/values-es/strings.xml b/modules/material-date-time-picker/src/main/res/values-es/strings.xml
new file mode 100644
index 00000000..f706848a
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values-es/strings.xml
@@ -0,0 +1,35 @@
+
+
+
+
+ "Listo"
+ "Control deslizante circular de horas"
+ "Control deslizante circular de minutos"
+ "Seleccionar horas"
+ "Seleccionar minutos"
+ "Cuadrícula mensual de días"
+ "Lista de años"
+ "Seleccionar mes y día"
+ "Seleccionar año"
+ "%1$s seleccionado"
+ "%1$s eliminado"
+ MMMM \'de\' yyyy
+ EEE dd \'de\' MMM
+ Pŕoximo Mes
+ Mes Previo
+
diff --git a/modules/material-date-time-picker/src/main/res/values-fr/strings.xml b/modules/material-date-time-picker/src/main/res/values-fr/strings.xml
new file mode 100644
index 00000000..40ed1740
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values-fr/strings.xml
@@ -0,0 +1,35 @@
+
+
+
+
+ "OK"
+ "Curseur circulaire des heures"
+ "Curseur circulaire des minutes"
+ "Sélectionner les heures"
+ "Sélectionner les minutes"
+ "Calendrier mensuel sous forme de grille"
+ "Liste des années"
+ "Sélectionnez un mois et un jour"
+ "Sélectionnez une année"
+ "%1$s sélectionné"
+ "\"%1$s\" supprimé"
+ MMMM yyyy
+ EEE dd MMM
+ Mois Suivant
+ Mois Précédent
+
diff --git a/modules/material-date-time-picker/src/main/res/values-ja/strings.xml b/modules/material-date-time-picker/src/main/res/values-ja/strings.xml
new file mode 100644
index 00000000..c453938e
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values-ja/strings.xml
@@ -0,0 +1,33 @@
+
+
+
+
+ "完了"
+ "円形スライダー(時)"
+ "円形スライダー(分)"
+ "時間を選択"
+ "分を選択"
+ "日グリッド(月別)"
+ "年リスト"
+ "月と日を選択"
+ "年を選択"
+ "%1$sを選択しました"
+ "%1$sを削除しました"
+ yyyy年M月
+ M月dd日(EEE)
+
diff --git a/modules/material-date-time-picker/src/main/res/values-ko/strings.xml b/modules/material-date-time-picker/src/main/res/values-ko/strings.xml
new file mode 100644
index 00000000..c251673e
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values-ko/strings.xml
@@ -0,0 +1,33 @@
+
+
+
+
+ "완료"
+ "시간 원형 슬라이더"
+ "분 원형 슬라이더"
+ "시간 선택"
+ "분 선택"
+ "월별 바둑판식 날짜 표시"
+ "년"
+ "월/일 선택"
+ "연도 선택"
+ "%1$s이(가) 선택됨"
+ "%1$s 삭제됨"
+ yyyy년 MMMM
+ MMM dd일 (EEE)
+
diff --git a/modules/material-date-time-picker/src/main/res/values-land/dimens.xml b/modules/material-date-time-picker/src/main/res/values-land/dimens.xml
new file mode 100644
index 00000000..439b3d0c
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values-land/dimens.xml
@@ -0,0 +1,36 @@
+
+
+
+
+ 200dip
+ 240dip
+
+ 30dp
+ 100dp
+ 30dp
+
+ 220dp
+ 170dp
+ 308dp
+ 220dp
+ 285dp
+ 16dp
+
+ 220dip
+
diff --git a/modules/material-date-time-picker/src/main/res/values-ru/strings.xml b/modules/material-date-time-picker/src/main/res/values-ru/strings.xml
new file mode 100644
index 00000000..2d0077ef
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values-ru/strings.xml
@@ -0,0 +1,33 @@
+
+
+
+
+ "Готово"
+ "Выбор часов на циферблате"
+ "Выбор минут на циферблате"
+ "Выберите часы"
+ "Выберите минуты"
+ "Окно выбора даты"
+ "Меню выбора года"
+ "Выберите месяц и день"
+ "Выберите год"
+ "Выбран элемент %1$s"
+ "Цифра %1$s удалена"
+ LLLL yyyy
+ ccc, dd MMM
+
diff --git a/modules/material-date-time-picker/src/main/res/values-sw600dp-land/dimens.xml b/modules/material-date-time-picker/src/main/res/values-sw600dp-land/dimens.xml
new file mode 100644
index 00000000..89bd8978
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values-sw600dp-land/dimens.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+ 45dp
+ 14dp
+ 220dp
+ 456dp
+ 16dp
+ 64dp
+ 32dp
+ 16dp
+ 6dip
+ 5dip
+ 96dip
+ 48dip
+ 400dip
+
+
\ No newline at end of file
diff --git a/modules/material-date-time-picker/src/main/res/values-sw600dp/dimens.xml b/modules/material-date-time-picker/src/main/res/values-sw600dp/dimens.xml
new file mode 100644
index 00000000..20d51407
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values-sw600dp/dimens.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+ 400dp
+ 400dp
+ 400dp
+ 360dp
+ 45dp
+ 75dp
+ 20dp
+ 20dp
+ 36dp
+ 45dp
+ 150dp
+ 45dp
+ 96dp
+ 33dp
+ 18dp
+ 18dp
+
+ 18sp
+ 16sp
+ 14sp
+ 12sp
+
+ 76dp
+ 38dp
+ -50dp
+ 16dp
+ 6dip
+ 5dip
+ 120dip
+ 60dip
+ 48dip
+ 24dip
+ 400dip
+ 450dp
+
+
\ No newline at end of file
diff --git a/modules/material-date-time-picker/src/main/res/values-v16/strings.xml b/modules/material-date-time-picker/src/main/res/values-v16/strings.xml
new file mode 100644
index 00000000..55bb88e0
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values-v16/strings.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+ sans-serif-light
+
diff --git a/modules/material-date-time-picker/src/main/res/values-v16/styles.xml b/modules/material-date-time-picker/src/main/res/values-v16/styles.xml
new file mode 100644
index 00000000..0f2bfcae
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values-v16/styles.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/material-date-time-picker/src/main/res/values-w270dp-h560dp/dimens.xml b/modules/material-date-time-picker/src/main/res/values-w270dp-h560dp/dimens.xml
new file mode 100644
index 00000000..89ff8f7a
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values-w270dp-h560dp/dimens.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+ 230dp
+ 190dp
+ 30dp
+ 100dp
+ 30dp
+
\ No newline at end of file
diff --git a/modules/material-date-time-picker/src/main/res/values-w560dp-land/dimens.xml b/modules/material-date-time-picker/src/main/res/values-w560dp-land/dimens.xml
new file mode 100644
index 00000000..682a72ae
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values-w560dp-land/dimens.xml
@@ -0,0 +1,6 @@
+
+
+ 270dp
+ 270dip
+ 270dip
+
diff --git a/modules/material-date-time-picker/src/main/res/values-zh-rHK/strings.xml b/modules/material-date-time-picker/src/main/res/values-zh-rHK/strings.xml
new file mode 100644
index 00000000..f8ad9566
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values-zh-rHK/strings.xml
@@ -0,0 +1,33 @@
+
+
+
+
+ "完成"
+ "小時環形滑桿"
+ "分鐘環形滑桿"
+ "選取小時"
+ "選取分鐘"
+ "日期網格 (按月顯示)"
+ "年份清單"
+ "選取月份和日期"
+ "選取年份"
+ "已選取%1$s"
+ "已刪除 %1$s"
+ yyyy年M月
+ M月dd日EEE
+
diff --git a/modules/material-date-time-picker/src/main/res/values-zh-rTW/strings.xml b/modules/material-date-time-picker/src/main/res/values-zh-rTW/strings.xml
new file mode 100644
index 00000000..c1732440
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values-zh-rTW/strings.xml
@@ -0,0 +1,33 @@
+
+
+
+
+ "完成"
+ "小時數循環滑桿"
+ "分鐘數循環滑桿"
+ "選取小時數"
+ "選取分鐘數"
+ "日期網格 (按月顯示)"
+ "年份清單"
+ "選取月份和日期"
+ "選取年份"
+ "已選取 %1$s"
+ "已刪除 %1$s"
+ yyyy年M月
+ M月dd日EEE
+
diff --git a/modules/material-date-time-picker/src/main/res/values-zh/strings.xml b/modules/material-date-time-picker/src/main/res/values-zh/strings.xml
new file mode 100644
index 00000000..2b7e800b
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values-zh/strings.xml
@@ -0,0 +1,33 @@
+
+
+
+
+ "完成"
+ "小时转盘"
+ "分钟转盘"
+ "选择小时"
+ "选择分钟"
+ "按月份划分的日期网格"
+ "年份列表"
+ "选择月份和日期"
+ "选择年份"
+ "已选择 %1$s"
+ "已删除 %1$s"
+ yyyy年M月
+ M月dd日EEE
+
diff --git a/modules/material-date-time-picker/src/main/res/values/colors.xml b/modules/material-date-time-picker/src/main/res/values/colors.xml
new file mode 100644
index 00000000..5c8a5dcf
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values/colors.xml
@@ -0,0 +1,65 @@
+
+
+
+
+ #ffffff
+ @color/mdtp_white
+ @color/mdtp_background_color
+ #f5f5f5
+ #cccccc
+ @color/mdtp_numbers_text_color
+ #ff212121
+ #cccccc
+ #ff212121
+
+ #7f000000
+ #009688
+ #76ffffff
+ #33999999
+ #00796b
+ #ff212121
+ @color/mdtp_date_picker_text_normal
+ #ccc
+ #767676
+
+
+ @color/mdtp_light_gray
+ @color/mdtp_white
+ #b4b4b4
+ #767676
+ @color/mdtp_date_picker_text_normal_dark_theme
+
+ @color/mdtp_accent_color_dark
+ @android:color/white
+ #ffd1d2d4
+
+ @color/mdtp_accent_color
+ #33969696
+
+
+ #525252
+
+
+ @color/mdtp_accent_color
+ @color/mdtp_accent_color_dark
+ #424242
+ #323232
+ #808080
+ #ffffff
+ #888888
+ #bfbfbf
+
diff --git a/modules/material-date-time-picker/src/main/res/values/dimens.xml b/modules/material-date-time-picker/src/main/res/values/dimens.xml
new file mode 100644
index 00000000..eea275f2
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values/dimens.xml
@@ -0,0 +1,88 @@
+
+
+
+
+ - 0.82
+ - 0.85
+ - 0.16
+ - 0.22
+ - 0.81
+ - 0.60
+ - 0.83
+ - 0.12
+ - 0.11
+ - 0.08
+ - 0.08
+ - 0.11
+
+ 54dp
+ 30dp
+ -12dp
+ -30dp
+ 16dp
+ 14sp
+ 6dip
+ 4dip
+ 96dip
+ 48dip
+ 48dip
+ 24dip
+ 270dip
+ 300dip
+
+ 270dp
+ 290dp
+ @dimen/mdtp_date_picker_component_width
+ -2dp
+ 30dp
+ 9dp
+ 155dp
+ 140dp
+ 252dp
+ 320dp
+ 8dp
+ 16dp
+ 6dp
+ 8dp
+ 42dp
+
+ 56dp
+ 64dp
+ 12sp
+ 16dp
+ 20dp
+ 2dp
+ 5dp
+ 45dp
+ 25dp
+ 70dp
+ 25dp
+ 12dp
+ 12dp
+ 14sp
+ 12sp
+ 64dp
+ 22dp
+
+ 30sp
+ 16sp
+
+ 48dp
+ 14sp
+ 64dp
+ 8dp
+
\ No newline at end of file
diff --git a/modules/material-date-time-picker/src/main/res/values/strings.xml b/modules/material-date-time-picker/src/main/res/values/strings.xml
new file mode 100644
index 00000000..a4393ec5
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values/strings.xml
@@ -0,0 +1,94 @@
+
+
+
+
+ MMMM yyyy
+
+ EEE, MMM dd
+
+
+ Done
+ @android:string/ok
+ @android:string/cancel
+
+ Hours circular slider
+
+ Minutes circular slider
+
+ Seconds circular slider
+
+ Select hours
+
+ Select minutes
+
+ Select seconds
+
+ Date
+ Time
+
+
+ Month grid of days
+
+ Year list
+
+ Select month and day
+
+ Select year
+
+ %1$s selected
+
+ %1$s deleted
+
+ Next Month
+ Previous Month
+
+
+ 00
+
+ :
+
+
+ sans-serif
+
+ sans-serif
+
+
+ sans-serif
+
+ am
+ pm
+
diff --git a/modules/material-date-time-picker/src/main/res/values/styles.xml b/modules/material-date-time-picker/src/main/res/values/styles.xml
new file mode 100644
index 00000000..b8683e42
--- /dev/null
+++ b/modules/material-date-time-picker/src/main/res/values/styles.xml
@@ -0,0 +1,60 @@
+
+
+
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/date/DatePickerDialogTest.java b/modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/date/DatePickerDialogTest.java
new file mode 100644
index 00000000..9898f22b
--- /dev/null
+++ b/modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/date/DatePickerDialogTest.java
@@ -0,0 +1,79 @@
+package com.wdullaer.materialdatetimepicker.date;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.Calendar;
+import java.util.TimeZone;
+
+public class DatePickerDialogTest {
+ // isHighlighted
+ @Test
+ public void isHighlightedShouldReturnFalseIfNoHighlightedDaysAreSet() {
+ DatePickerDialog dpd = DatePickerDialog.newInstance((view, year, monthOfYear, dayOfMonth) -> {
+
+ });
+ Assert.assertFalse(dpd.isHighlighted(1990, 1, 1));
+ }
+
+ @Test
+ public void isHighlightedShouldReturnFalseIfHighlightedDoesNotContainSelection() {
+ DatePickerDialog dpd = DatePickerDialog.newInstance((view, year, monthOfYear, dayOfMonth) -> {
+
+ });
+ Calendar highlighted = Calendar.getInstance();
+ highlighted.set(Calendar.YEAR, 1990);
+ highlighted.set(Calendar.MONTH, 1);
+ highlighted.set(Calendar.DAY_OF_MONTH, 1);
+
+ Calendar[] highlightedDays = {highlighted};
+
+ dpd.setHighlightedDays(highlightedDays);
+
+ Assert.assertFalse(dpd.isHighlighted(1990, 2, 1));
+ }
+
+ @Test
+ public void isHighlightedShouldReturnTrueIfHighlightedDoesContainSelection() {
+ DatePickerDialog dpd = DatePickerDialog.newInstance((view, year, monthOfYear, dayOfMonth) -> {
+
+ });
+ int year = 1990;
+ int month = 1;
+ int day = 1;
+
+ Calendar highlighted = Calendar.getInstance();
+ highlighted.set(Calendar.YEAR, year);
+ highlighted.set(Calendar.MONTH, month);
+ highlighted.set(Calendar.DAY_OF_MONTH, day);
+
+ Calendar[] highlightedDays = {highlighted};
+
+ dpd.setHighlightedDays(highlightedDays);
+
+ Assert.assertTrue(dpd.isHighlighted(year, month, day));
+ }
+
+ @Test
+ public void isHighlightedShouldBehaveCorrectlyInCustomTimezones() {
+ String timeZoneString = "Americas/Los_Angeles";
+ Calendar initial = Calendar.getInstance(TimeZone.getTimeZone(timeZoneString));
+ DatePickerDialog dpd = DatePickerDialog.newInstance((view, year, monthOfYear, dayOfMonth) -> {
+
+ }, initial);
+ int year = 1990;
+ int month = 1;
+ int day = 1;
+
+ Calendar highlighted = Calendar.getInstance(TimeZone.getTimeZone(timeZoneString));
+ highlighted.set(Calendar.YEAR, year);
+ highlighted.set(Calendar.MONTH, month);
+ highlighted.set(Calendar.DAY_OF_MONTH, day);
+
+ Calendar[] highlightedDays = {highlighted};
+
+ dpd.setHighlightedDays(highlightedDays);
+
+ Assert.assertTrue(dpd.isHighlighted(year, month, day));
+ }
+}
diff --git a/modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/date/DefaultDateRangeLimiterPropertyTest.java b/modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/date/DefaultDateRangeLimiterPropertyTest.java
new file mode 100644
index 00000000..59e76bf3
--- /dev/null
+++ b/modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/date/DefaultDateRangeLimiterPropertyTest.java
@@ -0,0 +1,206 @@
+package com.wdullaer.materialdatetimepicker.date;
+
+import com.pholser.junit.quickcheck.Property;
+import com.pholser.junit.quickcheck.generator.InRange;
+import com.pholser.junit.quickcheck.runner.JUnitQuickcheck;
+import com.wdullaer.materialdatetimepicker.Utils;
+
+import org.junit.Assert;
+import org.junit.runner.RunWith;
+
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+
+/**
+ * Property based tests for DefaultDateRangeLimiter (quickcheck FTW!)
+ * Created by wdullaer on 26/04/17.
+ */
+
+@RunWith(JUnitQuickcheck.class)
+public class DefaultDateRangeLimiterPropertyTest {
+ final private DatePickerController controller = new DatePickerController() {
+ @Override
+ public void onYearSelected(int year) {}
+
+ @Override
+ public void onDayOfMonthSelected(int year, int month, int day) {}
+
+ @Override
+ public void registerOnDateChangedListener(DatePickerDialog.OnDateChangedListener listener) {}
+
+ @Override
+ public void unregisterOnDateChangedListener(DatePickerDialog.OnDateChangedListener listener) {}
+
+ @Override
+ public MonthAdapter.CalendarDay getSelectedDay() {
+ return new MonthAdapter.CalendarDay(Calendar.getInstance(), TimeZone.getDefault());
+ }
+
+ @Override
+ public boolean isThemeDark() {
+ return false;
+ }
+
+ @Override
+ public int getAccentColor() {
+ return 0;
+ }
+
+ @Override
+ public boolean isHighlighted(int year, int month, int day) {
+ return false;
+ }
+
+ @Override
+ public int getFirstDayOfWeek() {
+ return 0;
+ }
+
+ @Override
+ public int getMinYear() {
+ return 0;
+ }
+
+ @Override
+ public int getMaxYear() {
+ return 0;
+ }
+
+ @Override
+ public Calendar getStartDate() {
+ return Calendar.getInstance();
+ }
+
+ @Override
+ public Calendar getEndDate() {
+ return Calendar.getInstance();
+ }
+
+ @Override
+ public boolean isOutOfRange(int year, int month, int day) {
+ return false;
+ }
+
+ @Override
+ public void tryVibrate() {}
+
+ @Override
+ public TimeZone getTimeZone() {
+ return TimeZone.getDefault();
+ }
+
+ @Override
+ public Locale getLocale() {
+ return Locale.getDefault();
+ }
+
+ @Override
+ public DatePickerDialog.Version getVersion() {
+ return DatePickerDialog.Version.VERSION_2;
+ }
+
+ @Override
+ public DatePickerDialog.ScrollOrientation getScrollOrientation() {
+ return DatePickerDialog.ScrollOrientation.HORIZONTAL;
+ }
+ };
+
+ private static Calendar[] datesToCalendars(Date[] dates) {
+ Calendar[] output = new Calendar[dates.length];
+ Calendar day = Calendar.getInstance();
+ for (int i = 0; i < dates.length; i++) {
+ Calendar cal = (Calendar) day.clone();
+ cal.setTime(dates[i]);
+ output[i] = cal;
+ }
+ return output;
+ }
+
+ @Property
+ public void setToNearestShouldBeInSelectableDays(
+ @InRange(min = "01/01/1900", max = "12/31/2099", format = "MM/dd/yyyy") Date date,
+ @InRange(min = "01/01/1900", max = "12/31/2099", format = "MM/dd/yyyy") Date[] dates
+ ) {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+
+ Calendar day = Calendar.getInstance();
+ day.setTime(date);
+
+ Calendar[] selectables = datesToCalendars(dates);
+
+ limiter.setSelectableDays(selectables);
+
+ // selectableDays are manipulated a bit by the limiter
+ selectables = limiter.getSelectableDays();
+
+ // selectables == null when the input is empty
+ if (selectables == null) Assert.assertEquals(
+ day.getTimeInMillis(),
+ limiter.setToNearestDate(day).getTimeInMillis()
+ );
+ else Assert.assertTrue(Arrays.asList(selectables).contains(limiter.setToNearestDate(day)));
+ }
+
+ @Property
+ public void setToNearestShouldNeverBeInDisabledDays(
+ @InRange(min = "01/01/1900", max = "12/31/2099", format = "MM/dd/yyyy") Date date,
+ @InRange(min = "01/01/1900", max = "12/31/2099", format = "MM/dd/yyyy") Date[] dates
+ ) {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+
+ Calendar day = Calendar.getInstance();
+ day.setTime(date);
+
+ Calendar[] disableds = datesToCalendars(dates);
+
+ limiter.setDisabledDays(disableds);
+ Assert.assertFalse(Arrays.asList(disableds).contains(limiter.setToNearestDate(day)));
+ }
+
+ @Property
+ public void setToNearestShouldNeverBeBelowMinDate(
+ @InRange(min = "01/01/1900", max = "12/31/2099", format = "MM/dd/yyyy") Date date,
+ @InRange(min = "01/01/1900", max = "12/31/2099", format = "MM/dd/yyyy") Date minDate
+ ) {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+
+ Calendar day = Calendar.getInstance();
+ day.setTime(date);
+
+ Calendar minDay = Calendar.getInstance();
+ minDay.setTime(minDate);
+
+ limiter.setMinDate(minDay);
+ Assert.assertTrue(Utils.trimToMidnight(minDay).getTimeInMillis() <= limiter.setToNearestDate(day).getTimeInMillis());
+ }
+
+ @Property
+ public void setToNearestShouldNeverBeAboveMaxDate(
+ @InRange(min = "01/01/1800", max = "12/31/2099", format = "MM/dd/yyyy") Date date,
+ @InRange(min = "01/01/1800", max = "12/31/2099", format = "MM/dd/yyyy") Date maxDate
+ ) {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+
+ Calendar day = Calendar.getInstance();
+ day.setTime(date);
+
+ Calendar minDay = Calendar.getInstance();
+ minDay.set(Calendar.YEAR, 1800);
+ minDay.set(Calendar.MONTH, Calendar.JANUARY);
+ minDay.set(Calendar.DAY_OF_MONTH, 1);
+ Utils.trimToMidnight(minDay);
+
+ Calendar maxDay = Calendar.getInstance();
+ maxDay.setTime(maxDate);
+
+ limiter.setMinDate(minDay);
+ limiter.setMaxDate(maxDay);
+ Assert.assertTrue(Utils.trimToMidnight(maxDay).getTimeInMillis() >= limiter.setToNearestDate(day).getTimeInMillis());
+ }
+
+ // TODO write property based tests that enable all options as one
+ // TODO ensure generators cover more of the known edge cases in the inputs
+}
diff --git a/modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/date/DefaultDateRangeLimiterTest.java b/modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/date/DefaultDateRangeLimiterTest.java
new file mode 100644
index 00000000..ef7bbd04
--- /dev/null
+++ b/modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/date/DefaultDateRangeLimiterTest.java
@@ -0,0 +1,700 @@
+package com.wdullaer.materialdatetimepicker.date;
+
+import com.wdullaer.materialdatetimepicker.Utils;
+
+import org.junit.Test;
+import org.junit.Assert;
+
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Locale;
+import java.util.TimeZone;
+
+/**
+ * Unit tests for the default DateRangeLimiter implementation
+ * Primarily used to assert that the rounding logic functions properly
+ *
+ * Created by wdullaer on 14/04/17.
+ */
+public class DefaultDateRangeLimiterTest {
+ final private DatePickerController controller = new DatePickerController() {
+ @Override
+ public void onYearSelected(int year) {}
+
+ @Override
+ public void onDayOfMonthSelected(int year, int month, int day) {}
+
+ @Override
+ public void registerOnDateChangedListener(DatePickerDialog.OnDateChangedListener listener) {}
+
+ @Override
+ public void unregisterOnDateChangedListener(DatePickerDialog.OnDateChangedListener listener) {}
+
+ @Override
+ public MonthAdapter.CalendarDay getSelectedDay() {
+ return new MonthAdapter.CalendarDay(Calendar.getInstance(), TimeZone.getDefault());
+ }
+
+ @Override
+ public boolean isThemeDark() {
+ return false;
+ }
+
+ @Override
+ public int getAccentColor() {
+ return 0;
+ }
+
+ @Override
+ public boolean isHighlighted(int year, int month, int day) {
+ return false;
+ }
+
+ @Override
+ public int getFirstDayOfWeek() {
+ return 0;
+ }
+
+ @Override
+ public int getMinYear() {
+ return 0;
+ }
+
+ @Override
+ public int getMaxYear() {
+ return 0;
+ }
+
+ @Override
+ public Calendar getStartDate() {
+ return Calendar.getInstance();
+ }
+
+ @Override
+ public Calendar getEndDate() {
+ return Calendar.getInstance();
+ }
+
+ @Override
+ public boolean isOutOfRange(int year, int month, int day) {
+ return false;
+ }
+
+ @Override
+ public void tryVibrate() {}
+
+ @Override
+ public TimeZone getTimeZone() {
+ return TimeZone.getDefault();
+ }
+
+ @Override
+ public Locale getLocale() {
+ return Locale.getDefault();
+ }
+
+ @Override
+ public DatePickerDialog.Version getVersion() {
+ return DatePickerDialog.Version.VERSION_2;
+ }
+
+ @Override
+ public DatePickerDialog.ScrollOrientation getScrollOrientation() {
+ return DatePickerDialog.ScrollOrientation.HORIZONTAL;
+ }
+ };
+
+ // getters
+ @Test
+ public void getSelectableDaysShouldHaveDatesTrimmedToMidnight() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ Calendar[] days = new Calendar[3];
+ for (int i = 0;i < days.length; i++) {
+ Calendar day = Calendar.getInstance();
+ day.set(Calendar.YEAR, 1999 + i);
+ day.set(Calendar.HOUR_OF_DAY, 2);
+ day.set(Calendar.MINUTE, 10);
+ day.set(Calendar.SECOND, 30);
+ day.set(Calendar.MILLISECOND, 25);
+ days[i] = day;
+ }
+
+ limiter.setSelectableDays(days);
+ Calendar[] selectableDays = limiter.getSelectableDays();
+
+ Assert.assertNotNull(selectableDays);
+ Assert.assertEquals(days.length, selectableDays.length);
+ for (Calendar selectableDay : selectableDays) {
+ Assert.assertEquals(selectableDay.get(Calendar.HOUR_OF_DAY), 0);
+ Assert.assertEquals(selectableDay.get(Calendar.MINUTE), 0);
+ Assert.assertEquals(selectableDay.get(Calendar.SECOND), 0);
+ Assert.assertEquals(selectableDay.get(Calendar.MILLISECOND), 0);
+ }
+ }
+
+ @Test
+ public void getDisabledDaysShouldHaveDatesTrimmedToMidnight() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ Calendar[] days = new Calendar[3];
+ for (int i = 0;i < days.length; i++) {
+ Calendar day = Calendar.getInstance();
+ day.set(Calendar.YEAR, 1999 + i);
+ day.set(Calendar.HOUR_OF_DAY, 2);
+ day.set(Calendar.MINUTE, 10);
+ day.set(Calendar.SECOND, 30);
+ day.set(Calendar.MILLISECOND, 25);
+ days[i] = day;
+ }
+
+ limiter.setDisabledDays(days);
+ Calendar[] disabledDays = limiter.getDisabledDays();
+
+ Assert.assertNotNull(disabledDays);
+ Assert.assertEquals(days.length, disabledDays.length);
+ for (Calendar selectableDay : disabledDays) {
+ Assert.assertEquals(selectableDay.get(Calendar.HOUR_OF_DAY), 0);
+ Assert.assertEquals(selectableDay.get(Calendar.MINUTE), 0);
+ Assert.assertEquals(selectableDay.get(Calendar.SECOND), 0);
+ Assert.assertEquals(selectableDay.get(Calendar.MILLISECOND), 0);
+ }
+ }
+
+ @Test
+ public void getMinDateShouldHaveDateTrimmedToMidnight() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ Calendar day = Calendar.getInstance();
+ day.set(Calendar.YEAR, 1999);
+ day.set(Calendar.HOUR_OF_DAY, 2);
+ day.set(Calendar.MINUTE, 10);
+ day.set(Calendar.SECOND, 30);
+ day.set(Calendar.MILLISECOND, 25);
+
+ limiter.setMinDate(day);
+ Calendar minDate = limiter.getMinDate();
+
+ Assert.assertNotNull(minDate);
+ Assert.assertEquals(minDate.get(Calendar.HOUR_OF_DAY), 0);
+ Assert.assertEquals(minDate.get(Calendar.MINUTE), 0);
+ Assert.assertEquals(minDate.get(Calendar.SECOND), 0);
+ Assert.assertEquals(minDate.get(Calendar.MILLISECOND), 0);
+ }
+
+ @Test
+ public void getMaxDateShouldHaveDateTrimmedToMidnight() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ Calendar day = Calendar.getInstance();
+ day.set(Calendar.YEAR, 1999);
+ day.set(Calendar.HOUR_OF_DAY, 2);
+ day.set(Calendar.MINUTE, 10);
+ day.set(Calendar.SECOND, 30);
+ day.set(Calendar.MILLISECOND, 25);
+
+ limiter.setMaxDate(day);
+ Calendar maxDate = limiter.getMaxDate();
+
+ Assert.assertNotNull(maxDate);
+ Assert.assertEquals(maxDate.get(Calendar.HOUR_OF_DAY), 0);
+ Assert.assertEquals(maxDate.get(Calendar.MINUTE), 0);
+ Assert.assertEquals(maxDate.get(Calendar.SECOND), 0);
+ Assert.assertEquals(maxDate.get(Calendar.MILLISECOND), 0);
+ }
+
+ // getStartDate()
+ @Test
+ public void getStartDateShouldReturnFirstSelectableDay() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ Calendar[] days = new Calendar[3];
+ for (int i = 0; i < days.length; i++) {
+ days[i] = Calendar.getInstance();
+ days[i].set(Calendar.YEAR, 1999 + i);
+ }
+
+ limiter.setSelectableDays(days);
+
+ // selectableDays are manipulated a bit by the limiter
+ days = limiter.getSelectableDays();
+
+ Assert.assertNotNull(days);
+ Assert.assertEquals(limiter.getStartDate().getTimeInMillis(), days[0].getTimeInMillis());
+ }
+
+ @Test
+ public void getStartDateShouldReturnMinDate() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ Calendar minDate = Calendar.getInstance();
+
+ limiter.setMinDate(minDate);
+ minDate = Utils.trimToMidnight(minDate);
+
+ Assert.assertEquals(limiter.getStartDate().getTimeInMillis(), minDate.getTimeInMillis());
+ }
+
+ @Test
+ public void getStartDateShouldReturnMinDateWhenAControllerIsSet() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ limiter.setController(controller);
+ Calendar minDate = Calendar.getInstance();
+
+ limiter.setMinDate(minDate);
+ minDate = Utils.trimToMidnight(minDate);
+
+ Assert.assertEquals(limiter.getStartDate().getTimeInMillis(), minDate.getTimeInMillis());
+ }
+
+ @Test
+ public void getStartDateShouldPreferSelectableOverMinDate() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ Calendar[] days = new Calendar[3];
+ for (int i = 0; i < days.length; i++) {
+ days[i] = Calendar.getInstance();
+ days[i].set(Calendar.YEAR, 1999 + i);
+ }
+ Calendar minDate = Calendar.getInstance();
+
+ limiter.setSelectableDays(days);
+ limiter.setMinDate(minDate);
+
+ // selectableDays are manipulated a bit by the limiter
+ days = limiter.getSelectableDays();
+
+ Assert.assertNotNull(days);
+ Assert.assertEquals(limiter.getStartDate().getTimeInMillis(), days[0].getTimeInMillis());
+ }
+
+ // getEndDate()
+ @Test
+ public void getEndDateShouldReturnLastSelectableDay() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ Calendar[] days = new Calendar[3];
+ for (int i = 0; i < days.length; i++) {
+ days[i] = Calendar.getInstance();
+ days[i].set(Calendar.YEAR, 1999 + i);
+ }
+
+ limiter.setSelectableDays(days);
+
+ // selectableDays are manipulated a bit by the limiter
+ days = limiter.getSelectableDays();
+
+ Assert.assertNotNull(days);
+ Assert.assertEquals(limiter.getEndDate().getTimeInMillis(), days[days.length - 1].getTimeInMillis());
+ }
+
+ @Test
+ public void getEndDateShouldReturnMaxDate() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ Calendar maxDate = Calendar.getInstance();
+
+ limiter.setMaxDate(maxDate);
+ maxDate = Utils.trimToMidnight(maxDate);
+
+ Assert.assertEquals(limiter.getEndDate().getTimeInMillis(), maxDate.getTimeInMillis());
+ }
+
+ @Test
+ public void getEndDateShouldReturnMaxDateWhenAControllerIsSet() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ limiter.setController(controller);
+ Calendar maxDate = Calendar.getInstance();
+
+ limiter.setMaxDate(maxDate);
+ maxDate = Utils.trimToMidnight(maxDate);
+
+ Assert.assertEquals(limiter.getEndDate().getTimeInMillis(), maxDate.getTimeInMillis());
+ }
+
+ @Test
+ public void getEndDateShouldPreferSelectableOverMaxDate() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ Calendar[] days = new Calendar[3];
+ for (int i = 0; i < days.length; i++) {
+ days[i] = Calendar.getInstance();
+ days[i].set(Calendar.YEAR, 1999 + i);
+ }
+ Calendar maxDate = Calendar.getInstance();
+
+ limiter.setSelectableDays(days);
+ limiter.setMinDate(maxDate);
+
+ // selectableDays are manipulated a bit by the limiter
+ days = limiter.getSelectableDays();
+
+ Assert.assertNotNull(days);
+ Assert.assertEquals(limiter.getEndDate().getTimeInMillis(), days[days.length - 1].getTimeInMillis());
+ }
+
+ // isOutOfRange()
+ @Test
+ public void isOutOfRangeShouldReturnTrueForDisabledDates() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ Calendar[] days = new Calendar[1];
+ Calendar day = Calendar.getInstance();
+ day.set(Calendar.YEAR, 1999);
+ days[0] = day;
+
+ limiter.setDisabledDays(days);
+ int year = day.get(Calendar.YEAR);
+ int month = day.get(Calendar.MONTH);
+ int dayNumber = day.get(Calendar.DAY_OF_MONTH);
+
+ Assert.assertTrue(limiter.isOutOfRange(year, month, dayNumber));
+ }
+
+ @Test
+ public void isOutOfRangeShouldReturnFalseForEnabledDates() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ Calendar[] days = new Calendar[1];
+ Calendar day = Calendar.getInstance();
+ day.set(Calendar.YEAR, 1999);
+ days[0] = day;
+
+ limiter.setSelectableDays(days);
+ int year = day.get(Calendar.YEAR);
+ int month = day.get(Calendar.MONTH);
+ int dayNumber = day.get(Calendar.DAY_OF_MONTH);
+
+ Assert.assertFalse(limiter.isOutOfRange(year, month, dayNumber));
+ }
+
+ @Test
+ public void isOutOfRangeShouldReturnTrueIfDateIsBeforeMin() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ Calendar day = Calendar.getInstance();
+ day.set(Calendar.YEAR, 1999);
+
+ limiter.setMinDate(day);
+ day.add(Calendar.DAY_OF_MONTH, -1);
+ int year = day.get(Calendar.YEAR);
+ int month = day.get(Calendar.MONTH);
+ int dayNumber = day.get(Calendar.DAY_OF_MONTH);
+
+ Assert.assertTrue(limiter.isOutOfRange(year, month, dayNumber));
+ }
+
+ @Test
+ public void isOutOfRangeShouldReturnTrueIfDateIsBeforeMinYear() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ int minYear = 1999;
+
+ limiter.setYearRange(minYear, minYear + 1);
+
+ Calendar day = Calendar.getInstance();
+ day.set(Calendar.YEAR, minYear - 1);
+ int year = day.get(Calendar.YEAR);
+ int month = day.get(Calendar.MONTH);
+ int dayNumber = day.get(Calendar.DAY_OF_MONTH);
+
+ Assert.assertTrue(limiter.isOutOfRange(year, month, dayNumber));
+ }
+
+ @Test
+ public void isOutOfRangeShouldReturnTrueIfDateIsAfterMax() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ Calendar day = Calendar.getInstance();
+ day.set(Calendar.YEAR, 1999);
+
+ limiter.setMaxDate(day);
+ day.add(Calendar.DAY_OF_MONTH, 1);
+ int year = day.get(Calendar.YEAR);
+ int month = day.get(Calendar.MONTH);
+ int dayNumber = day.get(Calendar.DAY_OF_MONTH);
+
+ Assert.assertTrue(limiter.isOutOfRange(year, month, dayNumber));
+ }
+
+ @Test
+ public void isOutOfRangeShouldReturnTrueIfDateIsAfterMaxYear() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ int maxYear = 1999;
+
+ limiter.setYearRange(maxYear - 1, maxYear);
+
+ Calendar day = Calendar.getInstance();
+ day.set(Calendar.YEAR, maxYear + 1);
+ int year = day.get(Calendar.YEAR);
+ int month = day.get(Calendar.MONTH);
+ int dayNumber = day.get(Calendar.DAY_OF_MONTH);
+
+ Assert.assertTrue(limiter.isOutOfRange(year, month, dayNumber));
+ }
+
+ @Test
+ public void isOutOfRangeShouldPreferDisabledOverEnabled() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ Calendar[] days = new Calendar[1];
+ Calendar day = Calendar.getInstance();
+ day.set(Calendar.YEAR, 1999);
+ days[0] = day;
+
+ limiter.setSelectableDays(days);
+ limiter.setDisabledDays(days);
+ int year = day.get(Calendar.YEAR);
+ int month = day.get(Calendar.MONTH);
+ int dayNumber = day.get(Calendar.DAY_OF_MONTH);
+
+ Assert.assertTrue(limiter.isOutOfRange(year, month, dayNumber));
+ }
+
+ @Test
+ public void isOutOfRangeShouldWorkWithCustomTimeZones() {
+ final String timeZoneString = "America/Los_Angeles";
+ TimeZone timeZone = TimeZone.getTimeZone(timeZoneString);
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+
+ int year = 1985;
+ int month = 1;
+ int day = 1;
+ Calendar disabledDay = Calendar.getInstance(timeZone);
+ disabledDay.set(Calendar.YEAR, year);
+ disabledDay.set(Calendar.MONTH, month);
+ disabledDay.set(Calendar.DAY_OF_MONTH, day);
+ Calendar[] days = new Calendar[1];
+ days[0] = disabledDay;
+ DatePickerController controller = new DatePickerController() {
+ @Override
+ public void onYearSelected(int year) {}
+
+ @Override
+ public void onDayOfMonthSelected(int year, int month, int day) {}
+
+ @Override
+ public void registerOnDateChangedListener(DatePickerDialog.OnDateChangedListener listener) {}
+
+ @Override
+ public void unregisterOnDateChangedListener(DatePickerDialog.OnDateChangedListener listener) {}
+
+ @Override
+ public MonthAdapter.CalendarDay getSelectedDay() {
+ return null;
+ }
+
+ @Override
+ public boolean isThemeDark() {
+ return false;
+ }
+
+ @Override
+ public int getAccentColor() {
+ return 0;
+ }
+
+ @Override
+ public boolean isHighlighted(int year, int month, int day) {
+ return false;
+ }
+
+ @Override
+ public int getFirstDayOfWeek() {
+ return 0;
+ }
+
+ @Override
+ public int getMinYear() {
+ return 0;
+ }
+
+ @Override
+ public int getMaxYear() {
+ return 0;
+ }
+
+ @Override
+ public Calendar getStartDate() {
+ return null;
+ }
+
+ @Override
+ public Calendar getEndDate() {
+ return null;
+ }
+
+ @Override
+ public boolean isOutOfRange(int year, int month, int day) {
+ return false;
+ }
+
+ @Override
+ public void tryVibrate() {
+
+ }
+
+ @Override
+ public TimeZone getTimeZone() {
+ return TimeZone.getTimeZone(timeZoneString);
+ }
+
+ @Override
+ public Locale getLocale() {
+ return Locale.getDefault();
+ }
+
+ @Override
+ public DatePickerDialog.Version getVersion() {
+ return null;
+ }
+
+ @Override
+ public DatePickerDialog.ScrollOrientation getScrollOrientation() {
+ return null;
+ }
+ };
+
+ limiter.setDisabledDays(days);
+ limiter.setController(controller);
+
+ Assert.assertTrue(limiter.isOutOfRange(year, month, day));
+ }
+
+ // setToNearestDate()
+ @Test
+ public void setToNearestShouldReturnTheInputWhenValid() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ Calendar day = Calendar.getInstance();
+ Calendar expected = (Calendar) day.clone();
+
+ Assert.assertEquals(limiter.setToNearestDate(day).getTimeInMillis(), expected.getTimeInMillis());
+ }
+
+ @Test
+ public void setToNearestShouldRoundDisabledDates() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ Calendar[] days = new Calendar[3];
+ for (int i = 0;i < days.length; i++) {
+ Calendar day = Calendar.getInstance();
+ day.set(Calendar.YEAR, 1999 + i);
+ day.set(Calendar.HOUR_OF_DAY, 2);
+ day.set(Calendar.MINUTE, 10);
+ day.set(Calendar.SECOND, 30);
+ day.set(Calendar.MILLISECOND, 25);
+ days[i] = day;
+ }
+
+ limiter.setDisabledDays(days);
+ Calendar day = (Calendar) days[0].clone();
+
+ Assert.assertNotSame(limiter.setToNearestDate(day).getTimeInMillis(), days[0].getTimeInMillis());
+ }
+
+ @Test
+ public void setToNearestShouldRoundToMinDate() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ Calendar minDate = Calendar.getInstance();
+ minDate.set(Calendar.YEAR, 1999);
+
+ limiter.setMinDate(minDate);
+
+ Calendar day = Calendar.getInstance();
+ day.set(Calendar.YEAR, 1998);
+
+ Assert.assertEquals(
+ limiter.setToNearestDate(day).getTimeInMillis(),
+ Utils.trimToMidnight(minDate).getTimeInMillis()
+ );
+ }
+
+ @Test
+ public void setToNearestShouldRoundToMaxDate() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ Calendar maxDate = Calendar.getInstance();
+ maxDate.set(Calendar.YEAR, 1999);
+
+ limiter.setMaxDate(maxDate);
+
+ Calendar day = Calendar.getInstance();
+ day.set(Calendar.YEAR, 2000);
+
+ Assert.assertEquals(
+ limiter.setToNearestDate(day).getTimeInMillis(),
+ Utils.trimToMidnight(maxDate).getTimeInMillis()
+ );
+ }
+
+ @Test
+ public void setToNearestShouldRoundToASelectableDay() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ Calendar[] days = new Calendar[3];
+ for (int i = 0;i < days.length; i++) {
+ Calendar day = Calendar.getInstance();
+ day.set(Calendar.YEAR, 1999 + i);
+ day.set(Calendar.HOUR_OF_DAY, 2);
+ day.set(Calendar.MINUTE, 10);
+ day.set(Calendar.SECOND, 30);
+ day.set(Calendar.MILLISECOND, 25);
+ days[i] = day;
+ }
+
+ limiter.setSelectableDays(days);
+ Calendar day = Calendar.getInstance();
+
+ // selectableDays are manipulated a bit by the limiter
+ days = limiter.getSelectableDays();
+
+ Assert.assertNotNull(days);
+ Assert.assertTrue(Arrays.asList(days).contains(limiter.setToNearestDate(day)));
+ }
+
+ @Test
+ public void setToNearestShouldRoundToASelectableDayWhenAControllerIsSet() {
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+ limiter.setController(controller);
+ Calendar[] days = new Calendar[3];
+ for (int i = 0;i < days.length; i++) {
+ Calendar day = Calendar.getInstance();
+ day.set(Calendar.YEAR, 1999 + i);
+ day.set(Calendar.HOUR_OF_DAY, 2);
+ day.set(Calendar.MINUTE, 10);
+ day.set(Calendar.SECOND, 30);
+ day.set(Calendar.MILLISECOND, 25);
+ days[i] = day;
+ }
+
+ limiter.setSelectableDays(days);
+ Calendar day = Calendar.getInstance();
+
+ // selectableDays are manipulated a bit by the limiter
+ days = limiter.getSelectableDays();
+
+ Assert.assertNotNull(days);
+ Assert.assertTrue(Arrays.asList(days).contains(limiter.setToNearestDate(day)));
+ }
+
+ @Test
+ public void setToNearestShouldRoundToFirstJanOfMinYearWhenBeforeMin() {
+ // Case with just year range and no other restrictions
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+
+ limiter.setYearRange(1980, 2100);
+ Calendar day = Calendar.getInstance();
+ day.set(Calendar.YEAR, 1970);
+
+ Calendar expectedDay = Calendar.getInstance();
+ expectedDay.set(Calendar.YEAR, 1980);
+ expectedDay.set(Calendar.MONTH, Calendar.JANUARY);
+ expectedDay.set(Calendar.DAY_OF_MONTH, 1);
+
+ Assert.assertEquals(
+ Utils.trimToMidnight(expectedDay).getTimeInMillis(),
+ limiter.setToNearestDate(day).getTimeInMillis()
+ );
+ }
+
+ @Test
+ public void setToNearestShouldReturn31stDecOfMaxYearWhenAfterMax() {
+ // Case with just year range and no other restrictions
+ DefaultDateRangeLimiter limiter = new DefaultDateRangeLimiter();
+
+ limiter.setYearRange(1900, 1950);
+ Calendar day = Calendar.getInstance();
+ day.set(Calendar.YEAR, 1970);
+
+ Calendar expectedDay = Calendar.getInstance();
+ expectedDay.set(Calendar.YEAR, 1950);
+ expectedDay.set(Calendar.MONTH, Calendar.DECEMBER);
+ expectedDay.set(Calendar.DAY_OF_MONTH, 31);
+
+ Assert.assertEquals(
+ Utils.trimToMidnight(expectedDay).getTimeInMillis(),
+ limiter.setToNearestDate(day).getTimeInMillis()
+ );
+ }
+}
\ No newline at end of file
diff --git a/modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/time/DefaultTimepointLimiterTest.java b/modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/time/DefaultTimepointLimiterTest.java
new file mode 100644
index 00000000..d14a7d65
--- /dev/null
+++ b/modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/time/DefaultTimepointLimiterTest.java
@@ -0,0 +1,853 @@
+package com.wdullaer.materialdatetimepicker.time;
+
+import static com.wdullaer.materialdatetimepicker.time.TimePickerDialog.HOUR_INDEX;
+import static com.wdullaer.materialdatetimepicker.time.TimePickerDialog.MINUTE_INDEX;
+
+import org.junit.Test;
+import org.junit.Assert;
+
+/**
+ * Unit tests for the default implementation of TimepointLimiter
+ * Mostly used to assert that the rounding logic works
+ *
+ * Created by wdullaer on 22/06/17.
+ */
+public class DefaultTimepointLimiterTest {
+ @Test
+ public void isAmDisabledShouldReturnTrueWhenMinTimeIsInTheAfternoon() {
+ Timepoint minTime = new Timepoint(13);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setMinTime(minTime);
+
+ Assert.assertTrue(limiter.isAmDisabled());
+ }
+
+ @Test
+ public void isAmDisabledShouldReturnFalseWhenMinTimeIsInTheMorning() {
+ Timepoint minTime = new Timepoint(8);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setMinTime(minTime);
+
+ Assert.assertFalse(limiter.isAmDisabled());
+ }
+
+ @Test
+ public void isAmDisabledShouldReturnTrueWhenMinTimeIsMidday() {
+ Timepoint minTime = new Timepoint(12);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setMinTime(minTime);
+
+ Assert.assertTrue(limiter.isAmDisabled());
+ }
+
+ @Test
+ public void isAmDisabledShouldReturnFalseWhenMaxTimeIsInTheMorning() {
+ Timepoint maxTime = new Timepoint(8);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setMaxTime(maxTime);
+
+ Assert.assertFalse(limiter.isAmDisabled());
+ }
+
+ @Test
+ public void isAmDisabledShouldReturnFalseWhenMaxTimeIsMidday() {
+ Timepoint maxTime = new Timepoint(12);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setMaxTime(maxTime);
+
+ Assert.assertFalse(limiter.isAmDisabled());
+ }
+
+ @Test
+ public void isPmDisabledShouldReturnTrueWhenMaxTimeIsInTheMorning() {
+ Timepoint maxTime = new Timepoint(9);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setMaxTime(maxTime);
+
+ Assert.assertTrue(limiter.isPmDisabled());
+ }
+
+ @Test
+ public void isPmDisabledShouldReturnFalseWhenMinTimeIsInTheAfternoon() {
+ Timepoint minTime = new Timepoint(13);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setMinTime(minTime);
+
+ Assert.assertFalse(limiter.isPmDisabled());
+ }
+
+ @Test
+ public void isPmDisabledShouldReturnFalseWhenMaxTimeIsMidday() {
+ Timepoint maxTime = new Timepoint(12);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setMaxTime(maxTime);
+
+ Assert.assertFalse(limiter.isPmDisabled());
+ }
+
+ @Test
+ public void isAmDisabledShouldReturnTrueIfSelectableDaysAreInTheAfternoon() {
+ Timepoint[] selectableDays = {
+ new Timepoint(13),
+ new Timepoint(22)
+ };
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableDays);
+
+ Assert.assertTrue(limiter.isAmDisabled());
+ }
+
+ @Test
+ public void isAmDisabledShouldReturnFalseIfSelectableDaysHasOneTimeInTheMorning() {
+ Timepoint[] selectableDays = {
+ new Timepoint(4),
+ new Timepoint(13),
+ new Timepoint(22)
+ };
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableDays);
+
+ Assert.assertFalse(limiter.isAmDisabled());
+ }
+
+ @Test
+ public void isPmDisabledShouldReturnTrueIfSelectableDaysAreInTheMorning() {
+ Timepoint[] selectableDays = {
+ new Timepoint(4),
+ new Timepoint(9),
+ new Timepoint(11)
+ };
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableDays);
+
+ Assert.assertTrue(limiter.isPmDisabled());
+ }
+
+ @Test
+ public void isPmDisabledShouldReturnFalseIfSelectableDaysHasOneTimeInTheAfternoon() {
+ Timepoint[] selectableDays = {
+ new Timepoint(4),
+ new Timepoint(22)
+ };
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableDays);
+
+ Assert.assertFalse(limiter.isPmDisabled());
+ }
+
+ @Test
+ public void isPmDisabledShouldReturnFalseIfSelectableDaysContainsMidday() {
+ Timepoint[] selectableDays = {
+ new Timepoint(4),
+ new Timepoint(9),
+ new Timepoint(12)
+ };
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableDays);
+
+ Assert.assertFalse(limiter.isPmDisabled());
+ }
+
+ @Test
+ public void isPmDisabledShouldReturnFalseWithoutConstraints() {
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ Assert.assertFalse(limiter.isPmDisabled());
+ }
+
+ @Test
+ public void isAmDisabledShouldReturnFalseWithoutConstraints() {
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ Assert.assertFalse(limiter.isAmDisabled());
+ }
+
+ @Test
+ public void setMinTimeShouldThrowExceptionWhenBiggerThanMaxTime() {
+ Timepoint maxTime = new Timepoint(2);
+ Timepoint minTime = new Timepoint(3);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setMaxTime(maxTime);
+
+ try {
+ limiter.setMinTime(minTime);
+ Assert.fail("setMinTime() should throw IllegalArgumentException");
+ } catch (IllegalArgumentException e) {
+ Assert.assertNotNull(e);
+ }
+ }
+
+ @Test
+ public void setMaxTimeShouldThrowExceptionWhenSmallerThanMinTime() {
+ Timepoint maxTime = new Timepoint(2);
+ Timepoint minTime = new Timepoint(3);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setMinTime(minTime);
+
+ try {
+ limiter.setMaxTime(maxTime);
+ Assert.fail("setMaxTime() should throw IllegalArgumentException");
+ } catch (IllegalArgumentException e) {
+ Assert.assertNotNull(e);
+ }
+ }
+
+ @Test
+ public void isOutOfRangeShouldReturnTrueIfInputSmallerThanMinTime() {
+ Timepoint minTime = new Timepoint(10);
+ Timepoint input = new Timepoint(2);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setMinTime(minTime);
+
+ Assert.assertTrue(limiter.isOutOfRange(input));
+ }
+
+ @Test
+ public void isOutOfRangeShouldReturnTrueIfInputLargerThanMaxTime() {
+ Timepoint maxTime = new Timepoint(2);
+ Timepoint input = new Timepoint(12);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setMaxTime(maxTime);
+
+ Assert.assertTrue(limiter.isOutOfRange(input));
+ }
+
+ @Test
+ public void isOutOfRangeShouldReturnFalseIfInputIsBetweenMinAndMaxTime() {
+ Timepoint minTime = new Timepoint(1);
+ Timepoint maxTime = new Timepoint(13);
+ Timepoint input = new Timepoint(4);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setMinTime(minTime);
+ limiter.setMaxTime(maxTime);
+
+ Assert.assertFalse(limiter.isOutOfRange(input));
+ }
+
+ @Test
+ public void isOutOfRangeShouldReturnFalseWithoutRestraints() {
+ Timepoint input = new Timepoint(14);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ Assert.assertFalse(limiter.isOutOfRange(input));
+ }
+
+ @Test
+ public void isOutOfRangeShouldReturnTrueIfInputNotSelectable() {
+ Timepoint input = new Timepoint(1);
+ Timepoint[] selectableDays = {
+ new Timepoint(13),
+ new Timepoint(14)
+ };
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableDays);
+
+ Assert.assertTrue(limiter.isOutOfRange(input));
+ }
+
+ @Test
+ public void isOutOfRangeShouldReturnFalseIfInputSelectable() {
+ Timepoint input = new Timepoint(15);
+ Timepoint[] selectableDays = {
+ new Timepoint(4),
+ new Timepoint(10),
+ new Timepoint(15)
+ };
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableDays);
+
+ Assert.assertFalse(limiter.isOutOfRange(input));
+ }
+
+ @Test
+ public void isOutOfRangeWithIndexShouldHandleNull() {
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ Assert.assertFalse(limiter.isOutOfRange(null, HOUR_INDEX, Timepoint.TYPE.SECOND));
+ }
+
+ @Test
+ public void isOutOfRangeMinuteShouldReturnFalseWhenMinTimeEqualsToTheMinute() {
+ Timepoint minTime = new Timepoint(12, 13, 14);
+ Timepoint input = new Timepoint(12, 13);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setMinTime(minTime);
+
+ Assert.assertFalse(limiter.isOutOfRange(input, MINUTE_INDEX, Timepoint.TYPE.SECOND));
+ }
+
+ @Test
+ public void isOutOfRangeMinuteShouldReturnFalseWhenMaxTimeEqualsToTheMinute() {
+ Timepoint maxTime = new Timepoint(12, 13, 14);
+ Timepoint input = new Timepoint(12, 13);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setMaxTime(maxTime);
+
+ Assert.assertFalse(limiter.isOutOfRange(input, MINUTE_INDEX, Timepoint.TYPE.SECOND));
+ }
+
+ @Test
+ public void isOutOfRangeMinuteShouldReturnFalseWhenTimeEqualsSelectableTimeToTheMinute() {
+ Timepoint[] selectableTimes = {
+ new Timepoint(11, 12, 13),
+ new Timepoint(12, 13, 14),
+ new Timepoint(13, 14, 15)
+ };
+ Timepoint input = new Timepoint(12, 13);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableTimes);
+
+ Assert.assertFalse(limiter.isOutOfRange(input, MINUTE_INDEX, Timepoint.TYPE.SECOND));
+ }
+
+ @Test
+ public void isOutOfRangeMinuteShouldReturnFalseWhenTimeEqualsSelectableTimeToTheMinute2() {
+ Timepoint[] selectableTimes = {
+ new Timepoint(11, 12, 13),
+ new Timepoint(12, 13, 14),
+ new Timepoint(13, 14, 15)
+ };
+ Timepoint input = new Timepoint(13, 14, 59);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableTimes);
+
+ Assert.assertFalse(limiter.isOutOfRange(input, MINUTE_INDEX, Timepoint.TYPE.SECOND));
+ }
+
+ @Test
+ public void isOutOfRangeMinuteShouldReturnFalseWhenTimeEqualsSelectableTimeToTheMinute3() {
+ Timepoint[] selectableTimes = {
+ new Timepoint(11, 12, 13),
+ new Timepoint(12, 13, 14),
+ new Timepoint(13, 14, 15)
+ };
+ Timepoint input = new Timepoint(11, 12, 0);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableTimes);
+
+ Assert.assertFalse(limiter.isOutOfRange(input, MINUTE_INDEX, Timepoint.TYPE.SECOND));
+ }
+
+ @Test
+ public void isOutOfRangeMinuteShouldReturnTrueWhenTimeDoesNotEqualSelectableTimeToTheMinute() {
+ Timepoint[] selectableTimes = {
+ new Timepoint(11, 12, 13),
+ new Timepoint(12, 13, 14),
+ new Timepoint(13, 14, 15)
+ };
+ Timepoint input = new Timepoint(11, 11, 0);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableTimes);
+
+ Assert.assertTrue(limiter.isOutOfRange(input, MINUTE_INDEX, Timepoint.TYPE.SECOND));
+ }
+
+ @Test
+ public void isOutOfRangeHourShouldReturnFalseWhenMinTimeEqualsToTheHour() {
+ Timepoint minTime = new Timepoint(12, 13, 14);
+ Timepoint input = new Timepoint(12);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setMinTime(minTime);
+
+ Assert.assertFalse(limiter.isOutOfRange(input, HOUR_INDEX, Timepoint.TYPE.SECOND));
+ }
+
+ @Test
+ public void isOutOfRangeHourShouldReturnFalseWhenMaxTimeEqualsToTheHour() {
+ Timepoint maxTime = new Timepoint(12, 13, 14);
+ Timepoint input = new Timepoint(12);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setMaxTime(maxTime);
+
+ Assert.assertFalse(limiter.isOutOfRange(input, HOUR_INDEX, Timepoint.TYPE.SECOND));
+ }
+
+ @Test
+ public void isOutOfRangeHourShouldReturnFalseWhenTimeEqualsSelectableTimeToTheHour() {
+ Timepoint[] selectableTimes = {
+ new Timepoint(11, 12, 13),
+ new Timepoint(12, 13, 14),
+ new Timepoint(13, 14, 15)
+ };
+ Timepoint input = new Timepoint(12);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableTimes);
+
+ Assert.assertFalse(limiter.isOutOfRange(input, HOUR_INDEX, Timepoint.TYPE.SECOND));
+ }
+
+ @Test
+ public void isOutOfRangeHourShouldReturnFalseWhenTimeEqualsSelectableTimeToTheHour2() {
+ Timepoint[] selectableTimes = {
+ new Timepoint(11, 12, 13),
+ new Timepoint(12, 13, 14),
+ new Timepoint(13, 14, 15)
+ };
+ Timepoint input = new Timepoint(13, 15, 15);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableTimes);
+
+ Assert.assertFalse(limiter.isOutOfRange(input, HOUR_INDEX, Timepoint.TYPE.SECOND));
+ }
+
+ @Test
+ public void isOutOfRangeHourShouldReturnFalseWhenTimeEqualsSelectableTimeToTheHour3() {
+ Timepoint[] selectableTimes = {
+ new Timepoint(11, 12, 13),
+ new Timepoint(12, 13, 14),
+ new Timepoint(13, 14, 15)
+ };
+ Timepoint input = new Timepoint(11);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableTimes);
+
+ Assert.assertFalse(limiter.isOutOfRange(input, HOUR_INDEX, Timepoint.TYPE.SECOND));
+ }
+
+ @Test
+ public void isOutOfRangeShouldWorkWhenSelectableTimesContainsDuplicateEntries() {
+ Timepoint[] selectableTimes = {
+ new Timepoint(11),
+ new Timepoint(12),
+ new Timepoint(12),
+ new Timepoint(13)
+ };
+ Timepoint input = new Timepoint(11, 30);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableTimes);
+
+ Assert.assertTrue(limiter.isOutOfRange(input));
+ }
+
+ @Test
+ public void isOutOfRangeShouldReturnTrueWhenInputIsInDisabledTimes() {
+ Timepoint[] disabledTimes = {
+ new Timepoint(11),
+ new Timepoint(12),
+ new Timepoint(13)
+ };
+ Timepoint input = disabledTimes[0];
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setDisabledTimes(disabledTimes);
+
+ Assert.assertTrue(limiter.isOutOfRange(input));
+ }
+
+ @Test
+ public void isOutOfRangeShouldUseDisabledOverSelectable() {
+ Timepoint[] disabledTimes = {
+ new Timepoint(11),
+ new Timepoint(12),
+ new Timepoint(13)
+ };
+ Timepoint[] selectableTimes = {
+ new Timepoint(12),
+ new Timepoint(14)
+ };
+ Timepoint input = new Timepoint(12);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setDisabledTimes(disabledTimes);
+ limiter.setSelectableTimes(selectableTimes);
+
+ Assert.assertTrue(limiter.isOutOfRange(input));
+ }
+
+ @Test
+ public void isOutOfRangeHourShouldReturnFalseWhenADisabledTimeIsTestedWithSecondResolution() {
+ // If there are only disabledTimes, there will still be other times that are valid
+ Timepoint[] disabledTimes = {
+ new Timepoint(12),
+ new Timepoint(13),
+ new Timepoint(14)
+ };
+ Timepoint input = disabledTimes[0];
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setDisabledTimes(disabledTimes);
+
+ Assert.assertFalse(limiter.isOutOfRange(input, HOUR_INDEX, Timepoint.TYPE.SECOND));
+ }
+
+ @Test
+ public void isOutOfRangeHourShouldReturnFalseWhenADisabledTimeIsTestedWithMinuteResolution() {
+ // If there are only disabledTimes, there will still be other times that are valid
+ Timepoint[] disabledTimes = {
+ new Timepoint(12),
+ new Timepoint(13),
+ new Timepoint(14)
+ };
+ Timepoint input = disabledTimes[0];
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setDisabledTimes(disabledTimes);
+
+ Assert.assertFalse(limiter.isOutOfRange(input, HOUR_INDEX, Timepoint.TYPE.MINUTE));
+ }
+
+ @Test
+ public void isOutOfRangeHourShouldReturnTrueWhenADisabledTimeIsTestedWithHourResolution() {
+ // If there are only disabledTimes, there will still be other times that are valid
+ Timepoint[] disabledTimes = {
+ new Timepoint(12),
+ new Timepoint(13),
+ new Timepoint(14)
+ };
+ Timepoint input = disabledTimes[0];
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setDisabledTimes(disabledTimes);
+
+ Assert.assertTrue(limiter.isOutOfRange(input, HOUR_INDEX, Timepoint.TYPE.HOUR));
+ }
+
+ @Test
+ public void isOutOfRangeMinuteShouldReturnFalseWhenADisabledTimeIsTestedWithSecondResolution() {
+ // If there are only disabledTimes, there will still be other times that are valid
+ Timepoint[] disabledTimes = {
+ new Timepoint(12, 15),
+ new Timepoint(13, 16),
+ new Timepoint(14, 17)
+ };
+ Timepoint input = disabledTimes[0];
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setDisabledTimes(disabledTimes);
+
+ Assert.assertFalse(limiter.isOutOfRange(input, MINUTE_INDEX, Timepoint.TYPE.SECOND));
+ }
+
+ @Test
+ public void isOutOfRangeMinuteShouldReturnFalseWhenADisabledTimeIsTestedWithMinuteResolution() {
+ // If there are only disabledTimes, there will still be other times that are valid
+ Timepoint[] disabledTimes = {
+ new Timepoint(12, 15),
+ new Timepoint(13, 16),
+ new Timepoint(14, 17)
+ };
+ Timepoint input = disabledTimes[0];
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setDisabledTimes(disabledTimes);
+
+ Assert.assertTrue(limiter.isOutOfRange(input, MINUTE_INDEX, Timepoint.TYPE.MINUTE));
+ }
+
+ @Test
+ public void isOutOfRangeHourShouldReturnTrueWhenADisabledTimeCancelsASelectableTime() {
+ Timepoint[] disabledTimes = {
+ new Timepoint(12),
+ new Timepoint(13)
+ };
+ Timepoint[] selectableTimes = {
+ new Timepoint(12),
+ new Timepoint(14),
+ new Timepoint(15)
+ };
+ Timepoint input = new Timepoint(12);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setDisabledTimes(disabledTimes);
+ limiter.setSelectableTimes(selectableTimes);
+
+ Assert.assertTrue(limiter.isOutOfRange(input, HOUR_INDEX, Timepoint.TYPE.SECOND));
+ }
+
+ @Test
+ public void roundToNearestShouldWorkWhenSelectableTimesContainsDuplicateEntries() {
+ Timepoint[] selectableTimes = {
+ new Timepoint(11),
+ new Timepoint(12),
+ new Timepoint(12),
+ new Timepoint(13)
+ };
+ Timepoint input = new Timepoint(12, 29);
+ Timepoint expected = new Timepoint(12);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableTimes);
+
+ Assert.assertEquals(limiter.roundToNearest(input, null, Timepoint.TYPE.SECOND), expected);
+ }
+
+ @Test
+ public void roundToNearestShouldReturnMaxTimeIfBiggerThanMaxTime() {
+ Timepoint maxTime = new Timepoint(8);
+ Timepoint input = new Timepoint(12);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setMaxTime(maxTime);
+
+ Assert.assertEquals(limiter.roundToNearest(input, null, Timepoint.TYPE.SECOND), maxTime);
+ }
+
+ @Test
+ public void roundToNearestShouldReturnMinTimeIfSmallerThanMinTime() {
+ Timepoint minTime = new Timepoint(8);
+ Timepoint input = new Timepoint(7);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setMinTime(minTime);
+
+ Assert.assertEquals(limiter.roundToNearest(input, null, Timepoint.TYPE.SECOND), minTime);
+ }
+
+ @Test
+ public void roundToNearestShouldReturnInputIfNotOutOfRange() {
+ Timepoint input = new Timepoint(12, 13, 14);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ Assert.assertEquals(limiter.roundToNearest(input, null, Timepoint.TYPE.SECOND), input);
+ }
+
+ @Test
+ public void roundToNearestShouldReturnSelectableTime() {
+ Timepoint[] selectableTimes = {
+ new Timepoint(11, 12, 14),
+ new Timepoint(12, 13, 14),
+ new Timepoint(13, 14, 15)
+ };
+ Timepoint input = new Timepoint(11);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableTimes);
+
+ Assert.assertEquals(limiter.roundToNearest(input, null, Timepoint.TYPE.SECOND), selectableTimes[0]);
+ }
+
+ @Test
+ public void roundToNearestShouldNotChangeTheMinutesWhenOptionIsSet() {
+ Timepoint[] selectableTimes = {
+ new Timepoint(11, 12, 13),
+ new Timepoint(12),
+ new Timepoint(12, 13, 14),
+ new Timepoint(15, 16, 17)
+ };
+ Timepoint input = new Timepoint(11, 12, 59);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableTimes);
+
+ Assert.assertEquals(limiter.roundToNearest(input, Timepoint.TYPE.MINUTE, Timepoint.TYPE.SECOND).getHour(), input.getHour());
+ Assert.assertEquals(limiter.roundToNearest(input, Timepoint.TYPE.MINUTE, Timepoint.TYPE.SECOND).getMinute(), input.getMinute());
+ }
+
+ @Test
+ public void roundToNearestShouldNotChangeTheHourWhenOptionIsSet() {
+ Timepoint[] selectableTimes = {
+ new Timepoint(11, 12, 13),
+ new Timepoint(12),
+ new Timepoint(12, 13, 14),
+ new Timepoint(13),
+ new Timepoint(15, 16, 17)
+ };
+ Timepoint input = new Timepoint(12, 59, 59);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableTimes);
+
+ Assert.assertEquals(limiter.roundToNearest(input, Timepoint.TYPE.HOUR, Timepoint.TYPE.SECOND).getHour(), input.getHour());
+ }
+
+ @Test
+ public void roundToNearestShouldNotChangeTheHourWhenOptionIsSet2() {
+ Timepoint[] selectableTimes = {
+ new Timepoint(11, 12, 13),
+ new Timepoint(12),
+ new Timepoint(12, 13, 14),
+ new Timepoint(13),
+ new Timepoint(15, 16, 17)
+ };
+ Timepoint input = new Timepoint(15, 59, 59);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableTimes);
+
+ Assert.assertEquals(limiter.roundToNearest(input, Timepoint.TYPE.HOUR, Timepoint.TYPE.SECOND).getHour(), input.getHour());
+ }
+
+ @Test
+ public void roundToNearestShouldNotChangeAnythingWhenSecondOptionIsSet() {
+ Timepoint[] selectableTimes = {
+ new Timepoint(11, 12, 13),
+ new Timepoint(12),
+ new Timepoint(12, 13, 14),
+ new Timepoint(13),
+ new Timepoint(15, 16, 17)
+ };
+ Timepoint input = new Timepoint(12, 59, 59);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableTimes);
+
+ Assert.assertEquals(limiter.roundToNearest(input, Timepoint.TYPE.SECOND, Timepoint.TYPE.SECOND), input);
+ }
+
+ @Test
+ public void roundToNearestShouldRoundToNearest() {
+ Timepoint[] selectableTimes = {
+ new Timepoint(11, 12, 13),
+ new Timepoint(11, 12, 16),
+ new Timepoint(12)
+ };
+ Timepoint input = new Timepoint(11, 12, 14);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableTimes);
+
+ Assert.assertEquals(limiter.roundToNearest(input, null, Timepoint.TYPE.SECOND), selectableTimes[0]);
+ }
+
+ @Test
+ public void roundToNearestShouldRoundToNearestSelectableThatIsNotDisabled() {
+ Timepoint[] selectableTimes = {
+ new Timepoint(11, 12, 13),
+ new Timepoint(12, 13, 14),
+ new Timepoint(13)
+ };
+ Timepoint[] disabledTimes = {
+ new Timepoint(12, 13, 14)
+ };
+ Timepoint input = new Timepoint(12, 13, 15);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+
+ limiter.setSelectableTimes(selectableTimes);
+ limiter.setDisabledTimes(disabledTimes);
+
+ Assert.assertEquals(limiter.roundToNearest(input, null, Timepoint.TYPE.SECOND), selectableTimes[2]);
+ }
+
+ @Test
+ public void roundToNearestShouldRoundWithSecondIncrementsIfInputIsDisabled() {
+ Timepoint[] disabledTimes = {
+ new Timepoint(11, 12, 13),
+ new Timepoint(12, 13, 14),
+ new Timepoint(13, 14, 15)
+ };
+ Timepoint input = disabledTimes[0];
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+ Timepoint expected = new Timepoint(11, 12, 14);
+
+ limiter.setDisabledTimes(disabledTimes);
+
+ Assert.assertEquals(limiter.roundToNearest(input, null, Timepoint.TYPE.SECOND), expected);
+ }
+
+ @Test
+ public void roundToNearestShouldRoundWithSecondIncrementsIfInputIsDisabled2() {
+ Timepoint[] disabledTimes = {
+ new Timepoint(11, 12, 13),
+ new Timepoint(11, 12, 14),
+ new Timepoint(12, 13, 14),
+ new Timepoint(13, 14, 15)
+ };
+ Timepoint input = disabledTimes[0];
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+ Timepoint expected = new Timepoint(11, 12, 12);
+
+ limiter.setDisabledTimes(disabledTimes);
+
+ Assert.assertEquals(limiter.roundToNearest(input, null, Timepoint.TYPE.SECOND), expected);
+ }
+
+ @Test
+ public void roundToNearestShouldRoundWithSecondIncrementsIfInputIsDisabled3() {
+ Timepoint[] disabledTimes = {
+ new Timepoint(11, 12, 13),
+ new Timepoint(11, 12, 12),
+ new Timepoint(11, 12, 14),
+ new Timepoint(12, 13, 14),
+ new Timepoint(13, 14, 15)
+ };
+ Timepoint input = disabledTimes[0];
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+ Timepoint expected = new Timepoint(11, 12, 15);
+
+ limiter.setDisabledTimes(disabledTimes);
+
+ Assert.assertEquals(limiter.roundToNearest(input, null, Timepoint.TYPE.SECOND), expected);
+ }
+
+ @Test
+ public void roundToNearestShouldRoundWithMinuteIncrementsIfInputIsDisabled() {
+ Timepoint[] disabledTimes = {
+ new Timepoint(11, 12, 13),
+ new Timepoint(12, 13, 14),
+ new Timepoint(13, 14, 15)
+ };
+ Timepoint input = disabledTimes[0];
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+ Timepoint expected = new Timepoint(11, 13, 13);
+
+ limiter.setDisabledTimes(disabledTimes);
+
+ Assert.assertEquals(limiter.roundToNearest(input, null, Timepoint.TYPE.MINUTE), expected);
+ }
+
+ @Test
+ public void roundToNearestShouldRoundWithHourIncrementsIfInputIsDisabled() {
+ Timepoint[] disabledTimes = {
+ new Timepoint(11, 12, 13),
+ new Timepoint(12, 13, 14),
+ new Timepoint(13, 14, 15)
+ };
+ Timepoint input = disabledTimes[0];
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+ Timepoint expected = new Timepoint(10, 12, 13);
+
+ limiter.setDisabledTimes(disabledTimes);
+
+ Assert.assertEquals(limiter.roundToNearest(input, null, Timepoint.TYPE.HOUR), expected);
+ }
+
+ @Test
+ public void roundToNearestIfInputDisablesAtLowerAndUpperTime() {
+ Timepoint[] disabledTimes = new Timepoint[180];
+ for(int l=0; l<=59; l++){ disabledTimes[l] = new Timepoint(0, l); }
+ for(int l=0; l<=59; l++){ disabledTimes[60+l] = new Timepoint(1, l); }
+ for(int l=0; l<=59; l++){ disabledTimes[120+l] = new Timepoint(23, l); }
+
+ Timepoint input = new Timepoint(0, 29, 0);
+ DefaultTimepointLimiter limiter = new DefaultTimepointLimiter();
+ Timepoint expected = new Timepoint(2, 0, 0);
+ Timepoint expected2 = new Timepoint(2, 29, 0);
+
+ limiter.setDisabledTimes(disabledTimes);
+
+ Assert.assertEquals(limiter.roundToNearest(input, null, Timepoint.TYPE.MINUTE), expected);
+ Assert.assertEquals(limiter.roundToNearest(input, null, Timepoint.TYPE.HOUR), expected2);
+ }
+}
\ No newline at end of file
diff --git a/modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/time/TimePickerDialogTest.java b/modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/time/TimePickerDialogTest.java
new file mode 100644
index 00000000..3bad88b4
--- /dev/null
+++ b/modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/time/TimePickerDialogTest.java
@@ -0,0 +1,28 @@
+package com.wdullaer.materialdatetimepicker.time;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+public class TimePickerDialogTest {
+ @Test
+ public void getPickerResolutionShouldReturnSecondIfSecondsAreEnabled() {
+ TimePickerDialog tpd = TimePickerDialog.newInstance(null, false);
+ tpd.enableSeconds(true);
+ Assert.assertEquals(tpd.getPickerResolution(), Timepoint.TYPE.SECOND);
+ }
+
+ @Test
+ public void getPickerResolutionShouldReturnMinuteIfMinutesAreEnabled() {
+ TimePickerDialog tpd = TimePickerDialog.newInstance(null, false);
+ tpd.enableSeconds(false);
+ tpd.enableMinutes(true);
+ Assert.assertEquals(tpd.getPickerResolution(), Timepoint.TYPE.MINUTE);
+ }
+
+ @Test
+ public void getPickerResolutionShouldReturnHourIfMinutesAndSecondsAreDisabled() {
+ TimePickerDialog tpd = TimePickerDialog.newInstance(null, false);
+ tpd.enableMinutes(false);
+ Assert.assertEquals(tpd.getPickerResolution(), Timepoint.TYPE.HOUR);
+ }
+}
diff --git a/modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/time/TimepointTest.java b/modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/time/TimepointTest.java
new file mode 100644
index 00000000..3c25e83f
--- /dev/null
+++ b/modules/material-date-time-picker/src/test/java/com/wdullaer/materialdatetimepicker/time/TimepointTest.java
@@ -0,0 +1,233 @@
+package com.wdullaer.materialdatetimepicker.time;
+
+import org.junit.Test;
+import org.junit.Assert;
+
+import java.util.HashSet;
+
+public class TimepointTest {
+
+ @Test
+ public void timepointsWithSameFieldsShouldHaveSameHashCode() {
+ Timepoint first = new Timepoint(12, 0, 0);
+ Timepoint second = new Timepoint(12, 0, 0);
+ Assert.assertEquals(first.hashCode(), second.hashCode());
+ }
+
+ @Test
+ public void timepointsWithSameFieldsShouldBeEquals() {
+ Timepoint first = new Timepoint(12, 0, 0);
+ Timepoint second = new Timepoint(12, 0, 0);
+ Assert.assertEquals(first, second);
+ }
+
+ @Test
+ public void timepointsWithSameFieldsShouldBeDistinctInHashSet() {
+ HashSet timepoints = new HashSet<>(4);
+ timepoints.add(new Timepoint(12, 0, 0));
+ timepoints.add(new Timepoint(12, 0, 0));
+ timepoints.add(new Timepoint(12, 0, 0));
+ timepoints.add(new Timepoint(12, 0, 0));
+ Assert.assertEquals(timepoints.size(), 1);
+ }
+
+ @Test
+ public void timepointsWithDifferentFieldsShouldNotBeDistinctInHashSet() {
+ HashSet timepoints = new HashSet<>(4);
+ timepoints.add(new Timepoint(12, 1, 0));
+ timepoints.add(new Timepoint(12, 2, 0));
+ timepoints.add(new Timepoint(12, 3, 0));
+ timepoints.add(new Timepoint(12, 4, 0));
+ Assert.assertEquals(timepoints.size(), 4);
+ }
+
+ @Test
+ public void compareToShouldReturnNegativeIfArgumentIsBigger() {
+ Timepoint orig = new Timepoint(12, 11, 10);
+ Timepoint arg = new Timepoint(13, 14, 15);
+ Assert.assertTrue(orig.compareTo(arg) < 0);
+ }
+
+ @Test
+ public void compareToShouldReturnPositiveIfArgumentIsSmaller() {
+ Timepoint orig = new Timepoint(12, 11, 10);
+ Timepoint arg = new Timepoint(10, 14, 15);
+ Assert.assertTrue(orig.compareTo(arg) > 0);
+ }
+
+ @Test
+ public void compareToShouldReturnZeroIfArgumentIsEqual() {
+ Timepoint orig = new Timepoint(12, 11, 10);
+ Timepoint arg = new Timepoint(12, 11, 10);
+ Assert.assertEquals(0, orig.compareTo(arg));
+ }
+
+ @Test
+ public void isAMShouldReturnTrueIfTimepointIsBeforeMidday() {
+ Timepoint timepoint = new Timepoint(11);
+ Assert.assertTrue(timepoint.isAM());
+ }
+
+ @Test
+ public void isAMShouldReturnFalseIfTimepointIsMidday() {
+ Timepoint timepoint = new Timepoint(12);
+ Assert.assertFalse(timepoint.isAM());
+ }
+
+ @Test
+ public void isAMShouldReturnFalseIfTimepointIsAfterMidday() {
+ Timepoint timepoint = new Timepoint(13);
+ Assert.assertFalse(timepoint.isAM());
+ }
+
+ @Test
+ public void isAMShouldReturnTrueIfTimepointIsMidnight() {
+ Timepoint timepoint = new Timepoint(0);
+ Assert.assertTrue(timepoint.isAM());
+ }
+
+ @Test
+ public void isPMShouldReturnFalseIfTimepointIsBeforeMidday() {
+ Timepoint timepoint = new Timepoint(11);
+ Assert.assertFalse(timepoint.isPM());
+ }
+
+ @Test
+ public void isPMShouldReturnTrueIfTimepointIsMidday() {
+ Timepoint timepoint = new Timepoint(12);
+ Assert.assertTrue(timepoint.isPM());
+ }
+
+ @Test
+ public void isPMShouldReturnTrueIfTimepointIsAfterMidday() {
+ Timepoint timepoint = new Timepoint(13);
+ Assert.assertTrue(timepoint.isPM());
+ }
+
+ @Test
+ public void isPMShouldReturnFalseIfTimepointIsMidnight() {
+ Timepoint timepoint = new Timepoint(0);
+ Assert.assertFalse(timepoint.isPM());
+ }
+
+ @Test
+ public void setAMShouldDoNothingIfTimepointIsBeforeMidday() {
+ Timepoint timepoint = new Timepoint(11);
+ timepoint.setAM();
+ Assert.assertEquals(timepoint.getHour(), 11);
+ }
+
+ @Test
+ public void setAMShouldSetToMidnightIfTimepointIsMidday() {
+ Timepoint timepoint = new Timepoint(12);
+ timepoint.setAM();
+ Assert.assertEquals(timepoint.getHour(), 0);
+ }
+
+ @Test
+ public void setAMShouldSetBeforeMiddayIfTimepointIsAfterMidday() {
+ Timepoint timepoint = new Timepoint(13);
+ timepoint.setAM();
+ Assert.assertEquals(timepoint.getHour(), 1);
+ }
+
+ @Test
+ public void setAMShouldDoNothingIfTimepointIsMidnight() {
+ Timepoint timepoint = new Timepoint(0);
+ timepoint.setAM();
+ Assert.assertEquals(timepoint.getHour(), 0);
+ }
+
+ @Test
+ public void setAMShouldNotChangeMinutesOrSeconds() {
+ Timepoint timepoint = new Timepoint(13, 14, 15);
+ timepoint.setAM();
+ Assert.assertEquals(timepoint.getMinute(), 14);
+ Assert.assertEquals(timepoint.getSecond(), 15);
+ }
+
+ @Test
+ public void setPMShouldDoNothingIfTimepointIsAfterMidday() {
+ Timepoint timepoint = new Timepoint(13);
+ timepoint.setPM();
+ Assert.assertEquals(timepoint.getHour(), 13);
+ }
+
+ @Test
+ public void setPMShouldSetToMiddayIfTimepointIsMidnight() {
+ Timepoint timepoint = new Timepoint(0);
+ timepoint.setPM();
+ Assert.assertEquals(timepoint.getHour(), 12);
+ }
+
+ @Test
+ public void setPMShouldSetAfterMiddayIfTimepointIsBeforeMidday() {
+ Timepoint timepoint = new Timepoint(5);
+ timepoint.setPM();
+ Assert.assertEquals(timepoint.getHour(), 17);
+ }
+
+ @Test
+ public void setPMShouldDoNothingIfTimepointIsMidday() {
+ Timepoint timepoint = new Timepoint(12);
+ timepoint.setPM();
+ Assert.assertEquals(timepoint.getHour(), 12);
+ }
+
+ @Test
+ public void setPMShouldNotChangeMinutesOrSeconds() {
+ Timepoint timepoint = new Timepoint(1, 14, 15);
+ timepoint.setPM();
+ Assert.assertEquals(timepoint.getMinute(), 14);
+ Assert.assertEquals(timepoint.getSecond(), 15);
+ }
+
+ @Test
+ public void equalsShouldReturnTrueWhenInputsAreEqualWithSecondsResolution() {
+ Timepoint timepoint1 = new Timepoint(1, 14, 15);
+ Timepoint timepoint2 = new Timepoint(1, 14, 15);
+ Assert.assertTrue(timepoint1.equals(timepoint2, Timepoint.TYPE.SECOND));
+ }
+
+ @Test
+ public void equalsShouldReturnFalseWhenInputsDifferWithSecondsResolution() {
+ Timepoint timepoint1 = new Timepoint(1, 14, 15);
+ Timepoint timepoint2 = new Timepoint(1, 14, 16);
+ Assert.assertFalse(timepoint1.equals(timepoint2, Timepoint.TYPE.SECOND));
+ }
+
+ @Test
+ public void equalsShouldIgnoreSecondsWithMinuteResolution() {
+ Timepoint timepoint1 = new Timepoint(1, 14, 15);
+ Timepoint timepoint2 = new Timepoint(1, 14, 16);
+ Assert.assertTrue(timepoint1.equals(timepoint2, Timepoint.TYPE.MINUTE));
+ }
+
+ @Test
+ public void equalsShouldReturnFalseWhenInputsDifferWithMinuteResolution() {
+ Timepoint timepoint1 = new Timepoint(1, 14, 15);
+ Timepoint timepoint2 = new Timepoint(1, 15, 15);
+ Assert.assertFalse(timepoint1.equals(timepoint2, Timepoint.TYPE.MINUTE));
+ }
+
+ @Test
+ public void equalsShouldIgnoreSecondsWithHourResolution() {
+ Timepoint timepoint1 = new Timepoint(1, 14, 15);
+ Timepoint timepoint2 = new Timepoint(1, 14, 16);
+ Assert.assertTrue(timepoint1.equals(timepoint2, Timepoint.TYPE.HOUR));
+ }
+
+ @Test
+ public void equalsShouldIgnoreMinutesWithHourResolution() {
+ Timepoint timepoint1 = new Timepoint(1, 14, 15);
+ Timepoint timepoint2 = new Timepoint(1, 15, 16);
+ Assert.assertTrue(timepoint1.equals(timepoint2, Timepoint.TYPE.HOUR));
+ }
+
+ @Test
+ public void equalsShouldReturnFalseWhenInputsDifferWithHourResolution() {
+ Timepoint timepoint1 = new Timepoint(1, 14, 15);
+ Timepoint timepoint2 = new Timepoint(2, 14, 15);
+ Assert.assertFalse(timepoint1.equals(timepoint2, Timepoint.TYPE.HOUR));
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 7f2aa6b3..1fadec0c 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -30,6 +30,7 @@ private val modules = listOf(
"apk-signer",
"apk-parser",
"color-picker",
+ "material-date-time-picker",
)
private val libs = listOf(
@@ -453,7 +454,7 @@ pluginManagement {
val console = object {
- val versionInfo = mutableListOf()
+ var versionInfo = mutableListOf()
fun printConcernedSystemPropertiesIfNeeded() {
if (config.isShowConcernedSystemProperties && !systemProperties.isConcernsAlreadyPrinted) {
diff --git a/version.properties b/version.properties
index e26a334b..dfcbfcb5 100644
--- a/version.properties
+++ b/version.properties
@@ -1,5 +1,5 @@
-#Sat May 03 18:51:31 CST 2025
-BUILD_TIME=1746269491585
+#Wed May 07 23:29:39 CST 2025
+BUILD_TIME=1746631779467
COMPILE_SDK_VERSION=35
JAVA_VERSION=23
JAVA_VERSION_MIN_RADICAL=0
@@ -17,6 +17,6 @@ RAPID_OCR_OPENCV_MOBILE_LABEL_VERSION=13
RAPID_OCR_OPENCV_MOBILE_VERSION=4.5.3
TARGET_SDK_VERSION=35
TARGET_SDK_VERSION_INRT=29
-VERSION_BUILD=3185
+VERSION_BUILD=3206
VERSION_NAME=6.6.3 Alpha2
VSCODE_EXT_REQUIRED_VERSION=1.0.8