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