diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 39a4a706a..c2bdf6a03 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -20,17 +20,21 @@
android:value="uhabits.db" />
+ android:value="5" />
+ android:label="@string/app_name"
+ android:launchMode="singleInstance">
+
+
+
diff --git a/art/ic_launcher.svg b/art/ic_launcher.svg
index 4e6cce6a1..d4d0e2b63 100644
--- a/art/ic_launcher.svg
+++ b/art/ic_launcher.svg
@@ -14,7 +14,7 @@
height="458"
id="svg2"
version="1.1"
- inkscape:version="0.48.4 r9939"
+ inkscape:version="0.91 r13725"
sodipodi:docname="ic_launcher.svg"
inkscape:export-filename="/home/isoron/Android/uHabits/art/ic_launcher.png"
inkscape:export-xdpi="300.06549"
@@ -88,8 +88,8 @@
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1"
- inkscape:cx="158.05316"
- inkscape:cy="229.21828"
+ inkscape:cx="326.25652"
+ inkscape:cy="220.41399"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
@@ -112,7 +112,7 @@
image/svg+xml
-
+
@@ -121,16 +121,16 @@
inkscape:groupmode="layer"
id="layer1"
transform="translate(-143.04724,-297.18109)">
-
+ sodipodi:cx="311" />
+
+
+
diff --git a/assets/migrations/5.sql b/assets/migrations/5.sql
new file mode 100644
index 000000000..bde8462b1
--- /dev/null
+++ b/assets/migrations/5.sql
@@ -0,0 +1,2 @@
+alter table habits add column reminder_hour integer;
+alter table habits add column reminder_min integer;
\ No newline at end of file
diff --git a/res/color/date_picker_selector.xml b/res/color/date_picker_selector.xml
new file mode 100644
index 000000000..63e1e17d8
--- /dev/null
+++ b/res/color/date_picker_selector.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/color/date_picker_year_selector.xml b/res/color/date_picker_year_selector.xml
new file mode 100644
index 000000000..f5cf776fb
--- /dev/null
+++ b/res/color/date_picker_year_selector.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/drawable-hdpi/ic_action_check.png b/res/drawable-hdpi/ic_action_check.png
new file mode 100644
index 000000000..329ea2fc3
Binary files /dev/null and b/res/drawable-hdpi/ic_action_check.png differ
diff --git a/res/drawable-hdpi/ic_action_dismiss.png b/res/drawable-hdpi/ic_action_dismiss.png
new file mode 100644
index 000000000..ea21b1bf2
Binary files /dev/null and b/res/drawable-hdpi/ic_action_dismiss.png differ
diff --git a/res/drawable-hdpi/ic_notification.png b/res/drawable-hdpi/ic_notification.png
new file mode 100644
index 000000000..7732cf993
Binary files /dev/null and b/res/drawable-hdpi/ic_notification.png differ
diff --git a/res/drawable-mdpi/ic_action_check.png b/res/drawable-mdpi/ic_action_check.png
new file mode 100644
index 000000000..700d076ed
Binary files /dev/null and b/res/drawable-mdpi/ic_action_check.png differ
diff --git a/res/drawable-mdpi/ic_action_dismiss.png b/res/drawable-mdpi/ic_action_dismiss.png
new file mode 100644
index 000000000..cfb167994
Binary files /dev/null and b/res/drawable-mdpi/ic_action_dismiss.png differ
diff --git a/res/drawable-mdpi/ic_notification.png b/res/drawable-mdpi/ic_notification.png
new file mode 100644
index 000000000..062788bd1
Binary files /dev/null and b/res/drawable-mdpi/ic_notification.png differ
diff --git a/res/drawable-xhdpi/ic_action_check.png b/res/drawable-xhdpi/ic_action_check.png
new file mode 100644
index 000000000..cf32c2a53
Binary files /dev/null and b/res/drawable-xhdpi/ic_action_check.png differ
diff --git a/res/drawable-xhdpi/ic_action_dismiss.png b/res/drawable-xhdpi/ic_action_dismiss.png
new file mode 100644
index 000000000..0aaf7e2df
Binary files /dev/null and b/res/drawable-xhdpi/ic_action_dismiss.png differ
diff --git a/res/drawable-xhdpi/ic_notification.png b/res/drawable-xhdpi/ic_notification.png
new file mode 100644
index 000000000..99411ad4e
Binary files /dev/null and b/res/drawable-xhdpi/ic_notification.png differ
diff --git a/res/drawable-xxhdpi/ic_action_check.png b/res/drawable-xxhdpi/ic_action_check.png
new file mode 100644
index 000000000..99266ebb9
Binary files /dev/null and b/res/drawable-xxhdpi/ic_action_check.png differ
diff --git a/res/drawable-xxhdpi/ic_action_dismiss.png b/res/drawable-xxhdpi/ic_action_dismiss.png
new file mode 100644
index 000000000..61817c96b
Binary files /dev/null and b/res/drawable-xxhdpi/ic_action_dismiss.png differ
diff --git a/res/drawable-xxhdpi/ic_notification.png b/res/drawable-xxhdpi/ic_notification.png
new file mode 100644
index 000000000..5ec39593a
Binary files /dev/null and b/res/drawable-xxhdpi/ic_notification.png differ
diff --git a/res/drawable/done_background_color.xml b/res/drawable/done_background_color.xml
new file mode 100644
index 000000000..19a386134
--- /dev/null
+++ b/res/drawable/done_background_color.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/drawable/done_background_color_dark.xml b/res/drawable/done_background_color_dark.xml
new file mode 100644
index 000000000..1665eee3f
--- /dev/null
+++ b/res/drawable/done_background_color_dark.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/layout/date_picker_dialog.xml b/res/layout/date_picker_dialog.xml
new file mode 100644
index 000000000..3361cb349
--- /dev/null
+++ b/res/layout/date_picker_dialog.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/layout/date_picker_done_button.xml b/res/layout/date_picker_done_button.xml
new file mode 100644
index 000000000..3794a08ce
--- /dev/null
+++ b/res/layout/date_picker_done_button.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
diff --git a/res/layout/date_picker_header_view.xml b/res/layout/date_picker_header_view.xml
new file mode 100644
index 000000000..5fd73ccc0
--- /dev/null
+++ b/res/layout/date_picker_header_view.xml
@@ -0,0 +1,26 @@
+
+
+
diff --git a/res/layout/date_picker_selected_date.xml b/res/layout/date_picker_selected_date.xml
new file mode 100644
index 000000000..2118ce1e3
--- /dev/null
+++ b/res/layout/date_picker_selected_date.xml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/layout/date_picker_view_animator.xml b/res/layout/date_picker_view_animator.xml
new file mode 100644
index 000000000..04501bdb9
--- /dev/null
+++ b/res/layout/date_picker_view_animator.xml
@@ -0,0 +1,22 @@
+
+
+
\ No newline at end of file
diff --git a/res/layout/edit_habit.xml b/res/layout/edit_habit.xml
index 6687f2817..869562410 100644
--- a/res/layout/edit_habit.xml
+++ b/res/layout/edit_habit.xml
@@ -56,19 +56,22 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
- android:gravity="center">
+ android:gravity="start">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/layout/time_picker_dialog.xml b/res/layout/time_picker_dialog.xml
new file mode 100644
index 000000000..44393dca6
--- /dev/null
+++ b/res/layout/time_picker_dialog.xml
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/layout/year_label_text_view.xml b/res/layout/year_label_text_view.xml
new file mode 100644
index 000000000..33d6b254b
--- /dev/null
+++ b/res/layout/year_label_text_view.xml
@@ -0,0 +1,24 @@
+
+
+
diff --git a/res/values/colors.xml b/res/values/colors.xml
new file mode 100644
index 000000000..5b8ff0762
--- /dev/null
+++ b/res/values/colors.xml
@@ -0,0 +1,50 @@
+
+
+
+
+ #ffffff
+ #f2f2f2
+ #cccccc
+ #8c8c8c
+ #000000
+ #cccccc
+ #8c8c8c
+
+ #7f000000
+ #33b5e5
+ #c1e8f7
+ #33999999
+ #0099cc
+ #ff999999
+
+ #999999
+ #f2f2f2
+ #ffd1d2d4
+
+ #888888
+ #888888
+
+
+ #ff3333
+ #853333
+ #404040
+ #363636
+ #808080
+ #ffffff
+ #888888
+ #bfbfbf
+
\ No newline at end of file
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index efd567356..ea8d92559 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -9,5 +9,45 @@
48dip8dip4dip
+
+
+ 0.82
+ 0.85
+ 0.16
+ 0.19
+ 0.81
+ 0.60
+ 0.83
+ 0.17
+ 0.14
+ 0.11
+ 60sp
+ -30dp
+ 16sp
+ 14sp
+ 6dip
+ 4dip
+ 96dip
+ 48dip
+ 48dip
+ 24dip
+ 270dip
+ 270dp
+ 30dp
+ 155dp
+ 270dp
+ 42dp
+ 50dp
+ 10sp
+ 16dp
+ 45dp
+ 30dp
+ 75dp
+ 30dp
+ 14dp
+ 16sp
+ 16sp
+ 64dp
+ 22dp
\ No newline at end of file
diff --git a/res/values/strings.xml b/res/values/strings.xml
index f7d6663da..d76b5568b 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -20,6 +20,25 @@
Habit changed back.Repetition toggled.
+
+ Done
+ Clear
+ Hours circular slider
+ Minutes circular slider
+ Select hours
+ Select minutes
+ Month grid of days
+ Year list
+ Select month and day
+ Select year
+ %1$s selected
+ %1$s deleted
+ --
+ :
+ sans-serif
+ sans-serif
+ sans-serif
+
diff --git a/res/values/styles.xml b/res/values/styles.xml
index 13a7798a4..d5df99856 100644
--- a/res/values/styles.xml
+++ b/res/values/styles.xml
@@ -16,5 +16,20 @@
+
+
+
+
+
+
+
diff --git a/src/com/android/datetimepicker/AccessibleLinearLayout.java b/src/com/android/datetimepicker/AccessibleLinearLayout.java
new file mode 100644
index 000000000..629f8564a
--- /dev/null
+++ b/src/com/android/datetimepicker/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.android.datetimepicker;
+
+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/src/com/android/datetimepicker/AccessibleTextView.java b/src/com/android/datetimepicker/AccessibleTextView.java
new file mode 100644
index 000000000..98fa74424
--- /dev/null
+++ b/src/com/android/datetimepicker/AccessibleTextView.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.android.datetimepicker;
+
+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.TextView;
+
+/**
+ * Fake Button class, used so TextViews can announce themselves as Buttons, for accessibility.
+ */
+public class AccessibleTextView extends TextView {
+
+ 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/src/com/android/datetimepicker/HapticFeedbackController.java b/src/com/android/datetimepicker/HapticFeedbackController.java
new file mode 100644
index 000000000..b9be63f27
--- /dev/null
+++ b/src/com/android/datetimepicker/HapticFeedbackController.java
@@ -0,0 +1,74 @@
+package com.android.datetimepicker;
+
+import android.app.Service;
+import android.content.Context;
+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 = 5;
+
+ 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() {
+ 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);
+ }
+
+ /**
+ * 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/src/com/android/datetimepicker/Utils.java b/src/com/android/datetimepicker/Utils.java
new file mode 100644
index 000000000..37dd730be
--- /dev/null
+++ b/src/com/android/datetimepicker/Utils.java
@@ -0,0 +1,140 @@
+/*
+ * 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.android.datetimepicker;
+
+import java.util.Calendar;
+
+import android.animation.Keyframe;
+import android.animation.ObjectAnimator;
+import android.animation.PropertyValuesHolder;
+import android.annotation.SuppressLint;
+import android.os.Build;
+import android.text.format.Time;
+import android.view.View;
+
+/**
+ * Utility helper functions for time and date pickers.
+ */
+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 = 51;
+ public static final int SELECTED_ALPHA_THEME_DARK = 102;
+ // Alpha level for fully opaque.
+ public static final int FULL_ALPHA = 255;
+
+
+ static final String SHARED_PREFS_NAME = "com.android.calendar_preferences";
+
+ public static boolean isJellybeanOrLater() {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN;
+ }
+
+ /**
+ * Try to speak the specified text, for accessibility. Only available on JB or later.
+ * @param text Text to announce.
+ */
+ @SuppressLint("NewApi")
+ public static void tryAccessibilityAnnounce(View view, CharSequence text) {
+ if (isJellybeanOrLater() && view != null && text != null) {
+ view.announceForAccessibility(text);
+ }
+ }
+
+ public static int getDaysInMonth(int month, int year) {
+ switch (month) {
+ case Calendar.JANUARY:
+ case Calendar.MARCH:
+ case Calendar.MAY:
+ case Calendar.JULY:
+ case Calendar.AUGUST:
+ case Calendar.OCTOBER:
+ case Calendar.DECEMBER:
+ return 31;
+ case Calendar.APRIL:
+ case Calendar.JUNE:
+ case Calendar.SEPTEMBER:
+ case Calendar.NOVEMBER:
+ return 30;
+ case Calendar.FEBRUARY:
+ return (year % 4 == 0) ? 29 : 28;
+ default:
+ throw new IllegalArgumentException("Invalid Month");
+ }
+ }
+
+ /**
+ * Takes a number of weeks since the epoch and calculates the Julian day of
+ * the Monday for that week.
+ *
+ * This assumes that the week containing the {@link Time#EPOCH_JULIAN_DAY}
+ * is considered week 0. It returns the Julian day for the Monday
+ * {@code week} weeks after the Monday of the week containing the epoch.
+ *
+ * @param week Number of weeks since the epoch
+ * @return The julian day for the Monday of the given week since the epoch
+ */
+ public static int getJulianMondayFromWeeksSinceEpoch(int week) {
+ return MONDAY_BEFORE_JULIAN_EPOCH + week * 7;
+ }
+
+ /**
+ * Returns the week since {@link Time#EPOCH_JULIAN_DAY} (Jan 1, 1970)
+ * adjusted for first day of week.
+ *
+ * This takes a julian day and the week start day and calculates which
+ * week since {@link Time#EPOCH_JULIAN_DAY} that day occurs in, starting
+ * at 0. *Do not* use this to compute the ISO week number for the year.
+ *
+ * @param julianDay The julian day to calculate the week number for
+ * @param firstDayOfWeek Which week day is the first day of the week,
+ * see {@link Time#SUNDAY}
+ * @return Weeks since the epoch
+ */
+ public static int getWeeksSinceEpochFromJulianDay(int julianDay, int firstDayOfWeek) {
+ int diff = Time.THURSDAY - firstDayOfWeek;
+ if (diff < 0) {
+ diff += 7;
+ }
+ int refDay = Time.EPOCH_JULIAN_DAY - diff;
+ return (julianDay - refDay) / 7;
+ }
+
+ /**
+ * 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;
+ }
+}
diff --git a/src/com/android/datetimepicker/date/AccessibleDateAnimator.java b/src/com/android/datetimepicker/date/AccessibleDateAnimator.java
new file mode 100644
index 000000000..fc022cdaf
--- /dev/null
+++ b/src/com/android/datetimepicker/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.android.datetimepicker.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/src/com/android/datetimepicker/date/DatePickerController.java b/src/com/android/datetimepicker/date/DatePickerController.java
new file mode 100644
index 000000000..f89580e46
--- /dev/null
+++ b/src/com/android/datetimepicker/date/DatePickerController.java
@@ -0,0 +1,44 @@
+/*
+ * 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.android.datetimepicker.date;
+
+import com.android.datetimepicker.date.DatePickerDialog.OnDateChangedListener;
+import com.android.datetimepicker.date.MonthAdapter.CalendarDay;
+
+/**
+ * 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(OnDateChangedListener listener);
+
+ void unregisterOnDateChangedListener(OnDateChangedListener listener);
+
+ CalendarDay getSelectedDay();
+
+ int getFirstDayOfWeek();
+
+ int getMinYear();
+
+ int getMaxYear();
+
+ void tryVibrate();
+}
diff --git a/src/com/android/datetimepicker/date/DatePickerDialog.java b/src/com/android/datetimepicker/date/DatePickerDialog.java
new file mode 100644
index 000000000..4f5b41904
--- /dev/null
+++ b/src/com/android/datetimepicker/date/DatePickerDialog.java
@@ -0,0 +1,480 @@
+/*
+ * 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.android.datetimepicker.date;
+
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Locale;
+
+import org.isoron.uhabits.R;
+
+import android.animation.ObjectAnimator;
+import android.app.Activity;
+import android.app.DialogFragment;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.text.format.DateUtils;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.view.Window;
+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 com.android.datetimepicker.HapticFeedbackController;
+import com.android.datetimepicker.Utils;
+import com.android.datetimepicker.date.MonthAdapter.CalendarDay;
+
+/**
+ * Dialog allowing users to select a date.
+ */
+public class DatePickerDialog extends DialogFragment implements
+ OnClickListener, DatePickerController {
+
+ private static final String TAG = "DatePickerDialog";
+
+ 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_YEAR_START = "year_start";
+ private static final String KEY_YEAR_END = "year_end";
+ private static final String KEY_CURRENT_VIEW = "current_view";
+ private static final String KEY_LIST_POSITION_OFFSET = "list_position_offset";
+
+ private static final int DEFAULT_START_YEAR = 1900;
+ private static final int DEFAULT_END_YEAR = 2100;
+
+ 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 DAY_FORMAT = new SimpleDateFormat("dd", Locale.getDefault());
+
+ private final Calendar mCalendar = Calendar.getInstance();
+ private OnDateSetListener mCallBack;
+ private HashSet mListeners = new HashSet();
+
+ private AccessibleDateAnimator mAnimator;
+
+ private TextView mDayOfWeekView;
+ private LinearLayout mMonthAndDayView;
+ private TextView mSelectedMonthTextView;
+ private TextView mSelectedDayTextView;
+ private TextView mYearView;
+ private DayPickerView mDayPickerView;
+ private YearPickerView mYearPickerView;
+ private Button mDoneButton;
+ private Button mClearButton;
+
+ private int mCurrentView = UNINITIALIZED;
+
+ private int mWeekStart = mCalendar.getFirstDayOfWeek();
+ private int mMinYear = DEFAULT_START_YEAR;
+ private int mMaxYear = DEFAULT_END_YEAR;
+
+ 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 dialog, int year, int monthOfYear, int dayOfMonth);
+
+ void onDateCleared(DatePickerDialog dialog);
+ }
+
+ /**
+ * The callback used to notify other date picker components of a change in selected date.
+ */
+ public interface OnDateChangedListener {
+
+ public void onDateChanged();
+ }
+
+
+ public DatePickerDialog() {
+ // Empty constructor required for dialog fragment.
+ }
+
+ /**
+ * @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.
+ */
+ public static DatePickerDialog newInstance(OnDateSetListener callBack, int year,
+ int monthOfYear,
+ int dayOfMonth) {
+ DatePickerDialog ret = new DatePickerDialog();
+ ret.initialize(callBack, year, monthOfYear, dayOfMonth);
+ return ret;
+ }
+
+ public void initialize(OnDateSetListener callBack, int year, int monthOfYear, int dayOfMonth) {
+ mCallBack = callBack;
+ mCalendar.set(Calendar.YEAR, year);
+ mCalendar.set(Calendar.MONTH, monthOfYear);
+ mCalendar.set(Calendar.DAY_OF_MONTH, dayOfMonth);
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ final Activity activity = getActivity();
+ activity.getWindow().setSoftInputMode(
+ WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
+ 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));
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(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_YEAR_START, mMinYear);
+ outState.putInt(KEY_YEAR_END, mMaxYear);
+ 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);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ Log.d(TAG, "onCreateView: ");
+ getDialog().getWindow().requestFeature(Window.FEATURE_NO_TITLE);
+
+ View view = inflater.inflate(R.layout.date_picker_dialog, null);
+
+ mDayOfWeekView = (TextView) view.findViewById(R.id.date_picker_header);
+ mMonthAndDayView = (LinearLayout) view.findViewById(R.id.date_picker_month_and_day);
+ mMonthAndDayView.setOnClickListener(this);
+ mSelectedMonthTextView = (TextView) view.findViewById(R.id.date_picker_month);
+ mSelectedDayTextView = (TextView) view.findViewById(R.id.date_picker_day);
+ mYearView = (TextView) view.findViewById(R.id.date_picker_year);
+ mYearView.setOnClickListener(this);
+
+ int listPosition = -1;
+ int listPositionOffset = 0;
+ int currentView = MONTH_AND_DAY_VIEW;
+ if (savedInstanceState != null) {
+ mWeekStart = savedInstanceState.getInt(KEY_WEEK_START);
+ mMinYear = savedInstanceState.getInt(KEY_YEAR_START);
+ mMaxYear = savedInstanceState.getInt(KEY_YEAR_END);
+ currentView = savedInstanceState.getInt(KEY_CURRENT_VIEW);
+ listPosition = savedInstanceState.getInt(KEY_LIST_POSITION);
+ listPositionOffset = savedInstanceState.getInt(KEY_LIST_POSITION_OFFSET);
+ }
+
+ final Activity activity = getActivity();
+ mDayPickerView = new SimpleDayPickerView(activity, this);
+ mYearPickerView = new YearPickerView(activity, this);
+
+ Resources res = getResources();
+ mDayPickerDescription = res.getString(R.string.day_picker_description);
+ mSelectDay = res.getString(R.string.select_day);
+ mYearPickerDescription = res.getString(R.string.year_picker_description);
+ mSelectYear = res.getString(R.string.select_year);
+
+ mAnimator = (AccessibleDateAnimator) view.findViewById(R.id.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);
+
+ mDoneButton = (Button) view.findViewById(R.id.done);
+ mDoneButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ tryVibrate();
+ if (mCallBack != null) {
+ mCallBack.onDateSet(DatePickerDialog.this, mCalendar.get(Calendar.YEAR),
+ mCalendar.get(Calendar.MONTH), mCalendar.get(Calendar.DAY_OF_MONTH));
+ }
+ dismiss();
+ }
+ });
+
+ mClearButton = (Button) view.findViewById(R.id.clear);
+ mClearButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ tryVibrate();
+ if (mCallBack != null) {
+ mCallBack.onDateCleared(DatePickerDialog.this);
+ }
+ dismiss();
+ }
+ });
+
+ 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 onResume() {
+ super.onResume();
+ mHapticFeedbackController.start();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ mHapticFeedbackController.stop();
+ }
+
+ private void setCurrentView(final int viewIndex) {
+ long millis = mCalendar.getTimeInMillis();
+
+ switch (viewIndex) {
+ case MONTH_AND_DAY_VIEW:
+ ObjectAnimator pulseAnimator = Utils.getPulseAnimator(mMonthAndDayView, 0.9f,
+ 1.05f);
+ if (mDelayAnimation) {
+ pulseAnimator.setStartDelay(ANIMATION_DELAY);
+ mDelayAnimation = false;
+ }
+ mDayPickerView.onDateChanged();
+ if (mCurrentView != viewIndex) {
+ mMonthAndDayView.setSelected(true);
+ mYearView.setSelected(false);
+ mAnimator.setDisplayedChild(MONTH_AND_DAY_VIEW);
+ mCurrentView = viewIndex;
+ }
+ pulseAnimator.start();
+
+ 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:
+ 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();
+
+ CharSequence yearString = YEAR_FORMAT.format(millis);
+ mAnimator.setContentDescription(mYearPickerDescription+": "+yearString);
+ Utils.tryAccessibilityAnnounce(mAnimator, mSelectYear);
+ break;
+ }
+ }
+
+ private void updateDisplay(boolean announce) {
+ if (mDayOfWeekView != null) {
+ mDayOfWeekView.setText(mCalendar.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.LONG,
+ Locale.getDefault()).toUpperCase(Locale.getDefault()));
+ }
+
+ mSelectedMonthTextView.setText(mCalendar.getDisplayName(Calendar.MONTH, Calendar.SHORT,
+ Locale.getDefault()).toUpperCase(Locale.getDefault()));
+ mSelectedDayTextView.setText(DAY_FORMAT.format(mCalendar.getTime()));
+ mYearView.setText(YEAR_FORMAT.format(mCalendar.getTime()));
+
+ // 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);
+ }
+ }
+
+ 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();
+ }
+ }
+
+ public void setYearRange(int startYear, int endYear) {
+ if (endYear <= startYear) {
+ throw new IllegalArgumentException("Year end must be larger than year start");
+ }
+ mMinYear = startYear;
+ mMaxYear = endYear;
+ if (mDayPickerView != null) {
+ mDayPickerView.onChange();
+ }
+ }
+
+ public void setOnDateSetListener(OnDateSetListener listener) {
+ mCallBack = listener;
+ }
+
+ // 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 void adjustDayInMonthIfNeeded(int month, int year) {
+ int day = mCalendar.get(Calendar.DAY_OF_MONTH);
+ int daysInMonth = Utils.getDaysInMonth(month, year);
+ if (day > daysInMonth) {
+ mCalendar.set(Calendar.DAY_OF_MONTH, daysInMonth);
+ }
+ }
+
+ @Override
+ public void onClick(View v) {
+ tryVibrate();
+ if (v.getId() == R.id.date_picker_year) {
+ setCurrentView(YEAR_VIEW);
+ } else if (v.getId() == R.id.date_picker_month_and_day) {
+ setCurrentView(MONTH_AND_DAY_VIEW);
+ }
+ }
+
+ @Override
+ public void onYearSelected(int year) {
+ adjustDayInMonthIfNeeded(mCalendar.get(Calendar.MONTH), year);
+ mCalendar.set(Calendar.YEAR, year);
+ 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);
+ }
+
+ private void updatePickers() {
+ Iterator iterator = mListeners.iterator();
+ while (iterator.hasNext()) {
+ iterator.next().onDateChanged();
+ }
+ }
+
+
+ @Override
+ public CalendarDay getSelectedDay() {
+ return new CalendarDay(mCalendar);
+ }
+
+ @Override
+ public int getMinYear() {
+ return mMinYear;
+ }
+
+ @Override
+ public int getMaxYear() {
+ return mMaxYear;
+ }
+
+ @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() {
+ mHapticFeedbackController.tryVibrate();
+ }
+}
diff --git a/src/com/android/datetimepicker/date/DayPickerView.java b/src/com/android/datetimepicker/date/DayPickerView.java
new file mode 100644
index 000000000..8d39e6f99
--- /dev/null
+++ b/src/com/android/datetimepicker/date/DayPickerView.java
@@ -0,0 +1,507 @@
+/*
+ * 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.android.datetimepicker.date;
+
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Locale;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.widget.AbsListView;
+import android.widget.AbsListView.OnScrollListener;
+import android.widget.ListView;
+
+import com.android.datetimepicker.Utils;
+import com.android.datetimepicker.date.DatePickerDialog.OnDateChangedListener;
+import com.android.datetimepicker.date.MonthAdapter.CalendarDay;
+
+/**
+ * This displays a list of months in a calendar format with selectable days.
+ */
+public abstract class DayPickerView extends ListView implements OnScrollListener,
+ OnDateChangedListener {
+
+ private static final String TAG = "MonthFragment";
+
+ // Affects when the month selection will change while scrolling up
+ protected static final int SCROLL_HYST_WEEKS = 2;
+ // How long the GoTo fling animation should last
+ protected static final int GOTO_SCROLL_DURATION = 250;
+ // How long to wait after receiving an onScrollStateChanged notification
+ // before acting on it
+ protected static final int SCROLL_CHANGE_DELAY = 40;
+ // The number of days to display in each week
+ public static final int DAYS_PER_WEEK = 7;
+ public static int LIST_TOP_OFFSET = -1; // so that the top line will be
+ // under the separator
+ // You can override these numbers to get a different appearance
+ protected int mNumWeeks = 6;
+ protected boolean mShowWeekNumber = false;
+ protected int mDaysPerWeek = 7;
+ private static SimpleDateFormat YEAR_FORMAT = new SimpleDateFormat("yyyy", Locale.getDefault());
+
+ // These affect the scroll speed and feel
+ protected float mFriction = 1.0f;
+
+ protected Context mContext;
+ protected Handler mHandler;
+
+ // highlighted time
+ protected CalendarDay mSelectedDay = new CalendarDay();
+ protected MonthAdapter mAdapter;
+
+ protected CalendarDay mTempDay = new CalendarDay();
+
+ // When the week starts; numbered like Time. (e.g. SUNDAY=0).
+ protected int mFirstDayOfWeek;
+ // The last name announced by accessibility
+ protected CharSequence mPrevMonthName;
+ // which month should be displayed/highlighted [0-11]
+ protected int mCurrentMonthDisplayed;
+ // used for tracking during a scroll
+ protected long mPreviousScrollPosition;
+ // used for tracking what state listview is in
+ protected int mPreviousScrollState = OnScrollListener.SCROLL_STATE_IDLE;
+ // used for tracking what state listview is in
+ protected int mCurrentScrollState = OnScrollListener.SCROLL_STATE_IDLE;
+
+ private DatePickerController mController;
+ private boolean mPerformingScroll;
+
+ public DayPickerView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(context);
+ }
+
+ public DayPickerView(Context context, DatePickerController controller) {
+ super(context);
+ init(context);
+ setController(controller);
+ }
+
+ public void setController(DatePickerController controller) {
+ mController = controller;
+ mController.registerOnDateChangedListener(this);
+ refreshAdapter();
+ onDateChanged();
+ }
+
+ public void init(Context context) {
+ mHandler = new Handler();
+ setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
+ setDrawSelectorOnTop(false);
+
+ mContext = context;
+ setUpListView();
+ }
+
+ public void onChange() {
+ refreshAdapter();
+ }
+
+ /**
+ * 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(getContext(), mController);
+ } else {
+ mAdapter.setSelectedDay(mSelectedDay);
+ }
+ // refresh the view with the new parameters
+ setAdapter(mAdapter);
+ }
+
+ public abstract MonthAdapter createMonthAdapter(Context context,
+ DatePickerController controller);
+
+ /*
+ * Sets all the required fields for the list view. Override this method to
+ * set a different list view behavior.
+ */
+ protected void setUpListView() {
+ // Transparent background on scroll
+ setCacheColorHint(0);
+ // No dividers
+ setDivider(null);
+ // Items are clickable
+ setItemsCanFocus(true);
+ // The thumb gets in the way, so disable it
+ setFastScrollEnabled(false);
+ setVerticalScrollBarEnabled(false);
+ setOnScrollListener(this);
+ setFadingEdgeLength(0);
+ // Make the scrolling behavior nicer
+ setFriction(ViewConfiguration.getScrollFriction() * mFriction);
+ }
+
+ /**
+ * 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 time The time 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(CalendarDay day, boolean animate, boolean setSelected, boolean forceScroll) {
+
+ // Set the selected day
+ if (setSelected) {
+ mSelectedDay.set(day);
+ }
+
+ mTempDay.set(day);
+ final int position = (day.year - mController.getMinYear())
+ * MonthAdapter.MONTHS_IN_YEAR + day.month;
+
+ 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;
+ if (child != null) {
+ selectedPosition = getPositionForView(child);
+ } else {
+ selectedPosition = 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 = OnScrollListener.SCROLL_STATE_FLING;
+ if (animate) {
+ smoothScrollToPositionFromTop(
+ position, LIST_TOP_OFFSET, GOTO_SCROLL_DURATION);
+ return true;
+ } else {
+ postSetSelection(position);
+ }
+ } else if (setSelected) {
+ setMonthDisplayed(mSelectedDay);
+ }
+ return false;
+ }
+
+ public void postSetSelection(final int position) {
+ clearFocus();
+ post(new Runnable() {
+
+ @Override
+ public void run() {
+ setSelection(position);
+ }
+ });
+ onScrollStateChanged(this, OnScrollListener.SCROLL_STATE_IDLE);
+ }
+
+ /**
+ * Updates the title and selected month if the view has moved to a new
+ * month.
+ */
+ @Override
+ public void onScroll(
+ AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
+ MonthView child = (MonthView) view.getChildAt(0);
+ if (child == null) {
+ return;
+ }
+
+ // Figure out where we are
+ long currScroll = view.getFirstVisiblePosition() * child.getHeight() - child.getBottom();
+ mPreviousScrollPosition = currScroll;
+ mPreviousScrollState = mCurrentScrollState;
+ }
+
+ /**
+ * 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(CalendarDay date) {
+ mCurrentMonthDisplayed = date.month;
+ invalidateViews();
+ }
+
+ @Override
+ public void onScrollStateChanged(AbsListView view, int scrollState) {
+ // use a post to prevent re-entering onScrollStateChanged before it
+ // exits
+ mScrollStateChangedRunnable.doScrollStateChange(view, scrollState);
+ }
+
+ protected ScrollStateRunnable mScrollStateChangedRunnable = new ScrollStateRunnable();
+
+ protected class ScrollStateRunnable implements Runnable {
+ private int mNewState;
+
+ /**
+ * Sets up the runnable with a short delay in case the scroll state
+ * immediately changes again.
+ *
+ * @param view The list view that changed state
+ * @param scrollState The new state it changed to
+ */
+ public void doScrollStateChange(AbsListView view, int scrollState) {
+ mHandler.removeCallbacks(this);
+ mNewState = scrollState;
+ mHandler.postDelayed(this, SCROLL_CHANGE_DELAY);
+ }
+
+ @Override
+ public void run() {
+ mCurrentScrollState = mNewState;
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
+ Log.d(TAG,
+ "new scroll state: " + mNewState + " old state: " + mPreviousScrollState);
+ }
+ // Fix the position after a scroll or a fling ends
+ if (mNewState == OnScrollListener.SCROLL_STATE_IDLE
+ && mPreviousScrollState != OnScrollListener.SCROLL_STATE_IDLE
+ && mPreviousScrollState != OnScrollListener.SCROLL_STATE_TOUCH_SCROLL) {
+ mPreviousScrollState = mNewState;
+ int i = 0;
+ View child = getChildAt(i);
+ while (child != null && child.getBottom() <= 0) {
+ child = getChildAt(++i);
+ }
+ if (child == null) {
+ // The view is no longer visible, just return
+ return;
+ }
+ int firstPosition = getFirstVisiblePosition();
+ int lastPosition = getLastVisiblePosition();
+ boolean scroll = firstPosition != 0 && lastPosition != getCount() - 1;
+ final int top = child.getTop();
+ final int bottom = child.getBottom();
+ final int midpoint = getHeight() / 2;
+ if (scroll && top < LIST_TOP_OFFSET) {
+ if (bottom > midpoint) {
+ smoothScrollBy(top, GOTO_SCROLL_DURATION);
+ } else {
+ smoothScrollBy(bottom, GOTO_SCROLL_DURATION);
+ }
+ }
+ } else {
+ mPreviousScrollState = mNewState;
+ }
+ }
+ }
+
+ /**
+ * Gets the position of the view that is most prominently displayed within the list view.
+ */
+ public int getMostVisiblePosition() {
+ final int firstPosition = getFirstVisiblePosition();
+ final int height = getHeight();
+
+ int maxDisplayedHeight = 0;
+ int mostVisibleIndex = 0;
+ int i=0;
+ int bottom = 0;
+ while (bottom < height) {
+ View child = getChildAt(i);
+ if (child == null) {
+ break;
+ }
+ bottom = child.getBottom();
+ int displayedHeight = Math.min(bottom, height) - Math.max(0, child.getTop());
+ if (displayedHeight > maxDisplayedHeight) {
+ mostVisibleIndex = i;
+ maxDisplayedHeight = displayedHeight;
+ }
+ i++;
+ }
+ return firstPosition + mostVisibleIndex;
+ }
+
+ @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 CalendarDay findAccessibilityFocus() {
+ final int childCount = getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ final View child = getChildAt(i);
+ if (child instanceof MonthView) {
+ final 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(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
+ protected void layoutChildren() {
+ final CalendarDay focusedDay = findAccessibilityFocus();
+ super.layoutChildren();
+ if (mPerformingScroll) {
+ mPerformingScroll = false;
+ } else {
+ restoreAccessibilityFocus(focusedDay);
+ }
+ }
+
+ @Override
+ public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
+ super.onInitializeAccessibilityEvent(event);
+ event.setItemCount(-1);
+ }
+
+ private static String getMonthAndYearString(CalendarDay day) {
+ Calendar cal = Calendar.getInstance();
+ cal.set(day.year, day.month, day.day);
+
+ StringBuffer sbuf = new StringBuffer();
+ sbuf.append(cal.getDisplayName(Calendar.MONTH, Calendar.LONG, Locale.getDefault()));
+ sbuf.append(" ");
+ sbuf.append(YEAR_FORMAT.format(cal.getTime()));
+ return sbuf.toString();
+ }
+
+ /**
+ * Necessary for accessibility, to ensure we support "scrolling" forward and backward
+ * in the month list.
+ */
+ @Override
+ public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
+ super.onInitializeAccessibilityNodeInfo(info);
+ info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
+ info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
+ }
+
+ /**
+ * When scroll forward/backward events are received, announce the newly scrolled-to month.
+ */
+ @SuppressLint("NewApi")
+ @Override
+ public boolean performAccessibilityAction(int action, Bundle arguments) {
+ if (action != AccessibilityNodeInfo.ACTION_SCROLL_FORWARD &&
+ action != AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
+ return super.performAccessibilityAction(action, arguments);
+ }
+
+ // Figure out what month is showing.
+ int firstVisiblePosition = getFirstVisiblePosition();
+ int month = firstVisiblePosition % 12;
+ int year = firstVisiblePosition / 12 + mController.getMinYear();
+ CalendarDay day = new CalendarDay(year, month, 1);
+
+ // Scroll either forward or backward one month.
+ if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) {
+ day.month++;
+ if (day.month == 12) {
+ day.month = 0;
+ day.year++;
+ }
+ } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
+ View firstVisibleView = getChildAt(0);
+ // If the view is fully visible, jump one month back. Otherwise, we'll just jump
+ // to the first day of first visible month.
+ if (firstVisibleView != null && firstVisibleView.getTop() >= -1) {
+ // There's an off-by-one somewhere, so the top of the first visible item will
+ // actually be -1 when it's at the exact top.
+ day.month--;
+ if (day.month == -1) {
+ day.month = 11;
+ day.year--;
+ }
+ }
+ }
+
+ // Go to that month.
+ Utils.tryAccessibilityAnnounce(this, getMonthAndYearString(day));
+ goTo(day, true, false, true);
+ mPerformingScroll = true;
+ return true;
+ }
+}
diff --git a/src/com/android/datetimepicker/date/MonthAdapter.java b/src/com/android/datetimepicker/date/MonthAdapter.java
new file mode 100644
index 000000000..8e34b6d89
--- /dev/null
+++ b/src/com/android/datetimepicker/date/MonthAdapter.java
@@ -0,0 +1,224 @@
+/*
+ * 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.android.datetimepicker.date;
+
+import java.util.Calendar;
+import java.util.HashMap;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.text.format.Time;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AbsListView.LayoutParams;
+import android.widget.BaseAdapter;
+
+import com.android.datetimepicker.date.MonthView.OnDayClickListener;
+
+/**
+ * An adapter for a list of {@link MonthView} items.
+ */
+public abstract class MonthAdapter extends BaseAdapter implements OnDayClickListener {
+
+ private static final String TAG = "SimpleMonthAdapter";
+
+ private final Context mContext;
+ private final DatePickerController mController;
+
+ private CalendarDay mSelectedDay;
+
+ protected static int WEEK_7_OVERHANG_HEIGHT = 7;
+ protected static final int MONTHS_IN_YEAR = 12;
+
+ /**
+ * A convenience class to represent a specific date.
+ */
+ public static class CalendarDay {
+ private Calendar calendar;
+ private Time time;
+ int year;
+ int month;
+ int day;
+
+ public CalendarDay() {
+ setTime(System.currentTimeMillis());
+ }
+
+ public CalendarDay(long timeInMillis) {
+ setTime(timeInMillis);
+ }
+
+ public CalendarDay(Calendar calendar) {
+ year = calendar.get(Calendar.YEAR);
+ month = calendar.get(Calendar.MONTH);
+ day = calendar.get(Calendar.DAY_OF_MONTH);
+ }
+
+ public CalendarDay(int year, int month, int day) {
+ 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;
+ }
+
+ public synchronized void setJulianDay(int julianDay) {
+ if (time == null) {
+ time = new Time();
+ }
+ time.setJulianDay(julianDay);
+ setTime(time.toMillis(false));
+ }
+
+ private void setTime(long timeInMillis) {
+ if (calendar == null) {
+ calendar = Calendar.getInstance();
+ }
+ calendar.setTimeInMillis(timeInMillis);
+ month = calendar.get(Calendar.MONTH);
+ year = calendar.get(Calendar.YEAR);
+ day = calendar.get(Calendar.DAY_OF_MONTH);
+ }
+ }
+
+ public MonthAdapter(Context context,
+ DatePickerController controller) {
+ mContext = context;
+ mController = controller;
+ init();
+ setSelectedDay(mController.getSelectedDay());
+ }
+
+ /**
+ * Updates the selected day and related parameters.
+ *
+ * @param day The day to highlight
+ */
+ public void setSelectedDay(CalendarDay day) {
+ mSelectedDay = day;
+ notifyDataSetChanged();
+ }
+
+ public CalendarDay getSelectedDay() {
+ return mSelectedDay;
+ }
+
+ /**
+ * Set up the gesture detector and selected time
+ */
+ protected void init() {
+ mSelectedDay = new CalendarDay(System.currentTimeMillis());
+ }
+
+ @Override
+ public int getCount() {
+ return ((mController.getMaxYear() - mController.getMinYear()) + 1) * MONTHS_IN_YEAR;
+ }
+
+ @Override
+ public Object getItem(int position) {
+ return null;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return true;
+ }
+
+ @SuppressLint("NewApi")
+ @SuppressWarnings("unchecked")
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ MonthView v;
+ HashMap drawingParams = null;
+ if (convertView != null) {
+ v = (MonthView) convertView;
+ // We store the drawing parameters in the view so it can be recycled
+ drawingParams = (HashMap) v.getTag();
+ } else {
+ v = createMonthView(mContext);
+ // Set up the new view
+ LayoutParams params = new LayoutParams(
+ LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
+ v.setLayoutParams(params);
+ v.setClickable(true);
+ v.setOnDayClickListener(this);
+ }
+ if (drawingParams == null) {
+ drawingParams = new HashMap();
+ }
+ drawingParams.clear();
+
+ final int month = position % MONTHS_IN_YEAR;
+ final int year = position / MONTHS_IN_YEAR + mController.getMinYear();
+
+ int selectedDay = -1;
+ if (isSelectedDayInMonth(year, month)) {
+ selectedDay = mSelectedDay.day;
+ }
+
+ // Invokes requestLayout() to ensure that the recycled view is set with the appropriate
+ // height/number of weeks before being displayed.
+ v.reuse();
+
+ drawingParams.put(MonthView.VIEW_PARAMS_SELECTED_DAY, selectedDay);
+ drawingParams.put(MonthView.VIEW_PARAMS_YEAR, year);
+ drawingParams.put(MonthView.VIEW_PARAMS_MONTH, month);
+ drawingParams.put(MonthView.VIEW_PARAMS_WEEK_START, mController.getFirstDayOfWeek());
+ v.setMonthParams(drawingParams);
+ v.invalidate();
+ return v;
+ }
+
+ public abstract MonthView createMonthView(Context context);
+
+ private boolean isSelectedDayInMonth(int year, int month) {
+ return mSelectedDay.year == year && mSelectedDay.month == month;
+ }
+
+
+ @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);
+ }
+}
diff --git a/src/com/android/datetimepicker/date/MonthView.java b/src/com/android/datetimepicker/date/MonthView.java
new file mode 100644
index 000000000..8b5defab2
--- /dev/null
+++ b/src/com/android/datetimepicker/date/MonthView.java
@@ -0,0 +1,689 @@
+/*
+ * 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.android.datetimepicker.date;
+
+import java.security.InvalidParameterException;
+import java.util.Calendar;
+import java.util.Formatter;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+
+import org.isoron.uhabits.R;
+
+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.Bundle;
+import android.support.v4.view.ViewCompat;
+import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
+import android.support.v4.widget.ExploreByTouchHelper;
+import android.text.format.DateFormat;
+import android.text.format.DateUtils;
+import android.text.format.Time;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import com.android.datetimepicker.Utils;
+import com.android.datetimepicker.date.MonthAdapter.CalendarDay;
+
+/**
+ * A calendar-like view displaying a specified month and the appropriate selectable day numbers
+ * within the specified month.
+ */
+public abstract class MonthView extends View {
+ private static final String TAG = "MonthView";
+
+ /**
+ * These params can be passed into the view to control how it appears.
+ * {@link #VIEW_PARAMS_WEEK} is the only required field, though the default
+ * values are unlikely to fit most layouts correctly.
+ */
+ /**
+ * This sets the height of this week in pixels
+ */
+ public static final String VIEW_PARAMS_HEIGHT = "height";
+ /**
+ * This specifies the position (or weeks since the epoch) of this week,
+ * calculated using {@link Utils#getWeeksSinceEpochFromJulianDay}
+ */
+ public static final String VIEW_PARAMS_MONTH = "month";
+ /**
+ * This specifies the position (or weeks since the epoch) of this week,
+ * calculated using {@link Utils#getWeeksSinceEpochFromJulianDay}
+ */
+ public static final String VIEW_PARAMS_YEAR = "year";
+ /**
+ * This sets one of the days in this view as selected {@link Time#SUNDAY}
+ * through {@link Time#SATURDAY}.
+ */
+ public static final String VIEW_PARAMS_SELECTED_DAY = "selected_day";
+ /**
+ * Which day the week should start on. {@link Time#SUNDAY} through
+ * {@link Time#SATURDAY}.
+ */
+ public static final String VIEW_PARAMS_WEEK_START = "week_start";
+ /**
+ * How many days to display at a time. Days will be displayed starting with
+ * {@link #mWeekStart}.
+ */
+ public static final String VIEW_PARAMS_NUM_DAYS = "num_days";
+ /**
+ * Which month is currently in focus, as defined by {@link Time#month}
+ * [0-11].
+ */
+ public static final String VIEW_PARAMS_FOCUS_MONTH = "focus_month";
+ /**
+ * If this month should display week numbers. false if 0, true otherwise.
+ */
+ public static final String VIEW_PARAMS_SHOW_WK_NUM = "show_wk_num";
+
+ protected static int DEFAULT_HEIGHT = 32;
+ protected static int MIN_HEIGHT = 10;
+ 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_SHOW_WK_NUM = 0;
+ protected static final int DEFAULT_FOCUS_MONTH = -1;
+ protected static final int DEFAULT_NUM_ROWS = 6;
+ protected static final int MAX_NUM_ROWS = 6;
+
+ private static final int SELECTED_CIRCLE_ALPHA = 60;
+
+ 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 DAY_SELECTED_CIRCLE_SIZE;
+
+ // used for scaling to the device density
+ protected static float mScale = 0;
+
+ // affects the padding on the sides of this view
+ protected int mPadding = 0;
+
+ private String mDayOfWeekTypeface;
+ private String mMonthTitleTypeface;
+
+ protected Paint mMonthNumPaint;
+ protected Paint mMonthTitlePaint;
+ protected Paint mMonthTitleBGPaint;
+ protected Paint mSelectedCirclePaint;
+ protected Paint mMonthDayLabelPaint;
+
+ private final Formatter mFormatter;
+ private final StringBuilder mStringBuilder;
+
+ // The Julian day of the first day displayed by this item
+ protected int mFirstJulianDay = -1;
+ // The month of the first day in this week
+ protected int mFirstMonth = -1;
+ // The month of the last day in this week
+ protected int mLastMonth = -1;
+
+ 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;
+ // The left edge of the selected day
+ protected int mSelectedLeft = -1;
+ // The right edge of the selected day
+ protected int mSelectedRight = -1;
+
+ private final Calendar mCalendar;
+ private final Calendar mDayLabelCalendar;
+ private final MonthViewTouchHelper mTouchHelper;
+
+ private int mNumRows = DEFAULT_NUM_ROWS;
+
+ // Optional listener for handling day click actions
+ private OnDayClickListener mOnDayClickListener;
+ // Whether to prevent setting the accessibility delegate
+ private boolean mLockAccessibilityDelegate;
+
+ protected int mDayTextColor;
+ protected int mTodayNumberColor;
+ protected int mMonthTitleColor;
+ protected int mMonthTitleBGColor;
+
+ public MonthView(Context context) {
+ super(context);
+
+ Resources res = context.getResources();
+
+ mDayLabelCalendar = Calendar.getInstance();
+ mCalendar = Calendar.getInstance();
+
+ mDayOfWeekTypeface = res.getString(R.string.day_of_week_label_typeface);
+ mMonthTitleTypeface = res.getString(R.string.sans_serif);
+
+ mDayTextColor = res.getColor(R.color.date_picker_text_normal);
+ mTodayNumberColor = res.getColor(R.color.blue);
+ mMonthTitleColor = res.getColor(R.color.white);
+ mMonthTitleBGColor = res.getColor(R.color.circle_background);
+
+ mStringBuilder = new StringBuilder(50);
+ mFormatter = new Formatter(mStringBuilder, Locale.getDefault());
+
+ MINI_DAY_NUMBER_TEXT_SIZE = res.getDimensionPixelSize(R.dimen.day_number_size);
+ MONTH_LABEL_TEXT_SIZE = res.getDimensionPixelSize(R.dimen.month_label_size);
+ MONTH_DAY_LABEL_TEXT_SIZE = res.getDimensionPixelSize(R.dimen.month_day_label_text_size);
+ MONTH_HEADER_SIZE = res.getDimensionPixelOffset(R.dimen.month_list_item_header_height);
+ DAY_SELECTED_CIRCLE_SIZE = res
+ .getDimensionPixelSize(R.dimen.day_number_select_circle_radius);
+
+ mRowHeight = (res.getDimensionPixelOffset(R.dimen.date_picker_view_animator_height)
+ - MONTH_HEADER_SIZE) / MAX_NUM_ROWS;
+
+ // Set up accessibility components.
+ mTouchHelper = new MonthViewTouchHelper(this);
+ ViewCompat.setAccessibilityDelegate(this, mTouchHelper);
+ ViewCompat.setImportantForAccessibility(this, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES);
+ mLockAccessibilityDelegate = true;
+
+ // Sets up any standard paints that will be used
+ initView();
+ }
+
+ @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(MotionEvent event) {
+ // First right-of-refusal goes the touch exploration helper.
+ if (mTouchHelper.dispatchHoverEvent(event)) {
+ return true;
+ }
+ return super.dispatchHoverEvent(event);
+ }
+
+ @Override
+ public boolean onTouchEvent(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();
+ 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);
+
+ mMonthTitleBGPaint = new Paint();
+ mMonthTitleBGPaint.setFakeBoldText(true);
+ mMonthTitleBGPaint.setAntiAlias(true);
+ mMonthTitleBGPaint.setColor(mMonthTitleBGColor);
+ mMonthTitleBGPaint.setTextAlign(Align.CENTER);
+ mMonthTitleBGPaint.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(mDayTextColor);
+ mMonthDayLabelPaint.setTypeface(Typeface.create(mDayOfWeekTypeface, Typeface.NORMAL));
+ 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. See
+ * {@link #VIEW_PARAMS_HEIGHT} for more info on parameters.
+ *
+ * @param params A map of the new parameters, see
+ * {@link #VIEW_PARAMS_HEIGHT}
+ */
+ public void setMonthParams(HashMap params) {
+ if (!params.containsKey(VIEW_PARAMS_MONTH) && !params.containsKey(VIEW_PARAMS_YEAR)) {
+ throw new InvalidParameterException("You must specify month and year for this view");
+ }
+ setTag(params);
+ // We keep the current value for any params not present
+ if (params.containsKey(VIEW_PARAMS_HEIGHT)) {
+ mRowHeight = params.get(VIEW_PARAMS_HEIGHT);
+ if (mRowHeight < MIN_HEIGHT) {
+ mRowHeight = MIN_HEIGHT;
+ }
+ }
+ if (params.containsKey(VIEW_PARAMS_SELECTED_DAY)) {
+ mSelectedDay = params.get(VIEW_PARAMS_SELECTED_DAY);
+ }
+
+ // Allocate space for caching the day numbers and focus values
+ mMonth = params.get(VIEW_PARAMS_MONTH);
+ mYear = params.get(VIEW_PARAMS_YEAR);
+
+ // Figure out what day today is
+ final Time today = new Time(Time.getCurrentTimezone());
+ today.setToNow();
+ 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 (params.containsKey(VIEW_PARAMS_WEEK_START)) {
+ mWeekStart = params.get(VIEW_PARAMS_WEEK_START);
+ } else {
+ mWeekStart = mCalendar.getFirstDayOfWeek();
+ }
+
+ mNumCells = Utils.getDaysInMonth(mMonth, mYear);
+ 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();
+ }
+
+ public void reuse() {
+ mNumRows = DEFAULT_NUM_ROWS;
+ requestLayout();
+ }
+
+ 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, Time today) {
+ return mYear == today.year &&
+ mMonth == today.month &&
+ day == today.monthDay;
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), mRowHeight * mNumRows
+ + MONTH_HEADER_SIZE);
+ }
+
+ @Override
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+ mWidth = w;
+
+ // Invalidate cached accessibility information.
+ mTouchHelper.invalidateRoot();
+ }
+
+ private String getMonthAndYearString() {
+ int flags = DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR
+ | DateUtils.FORMAT_NO_MONTH_DAY;
+ mStringBuilder.setLength(0);
+ long millis = mCalendar.getTimeInMillis();
+ return DateUtils.formatDateRange(getContext(), mFormatter, millis, millis, flags,
+ Time.getCurrentTimezone()).toString();
+ }
+
+ private void drawMonthTitle(Canvas canvas) {
+ int x = (mWidth + 2 * mPadding) / 2;
+ int y = (MONTH_HEADER_SIZE - MONTH_DAY_LABEL_TEXT_SIZE) / 2 + (MONTH_LABEL_TEXT_SIZE / 3);
+ canvas.drawText(getMonthAndYearString(), x, y, mMonthTitlePaint);
+ }
+
+ private void drawMonthDayLabels(Canvas canvas) {
+ int y = MONTH_HEADER_SIZE - (MONTH_DAY_LABEL_TEXT_SIZE / 2);
+ int dayWidthHalf = (mWidth - mPadding * 2) / (mNumDays * 2);
+
+ for (int i = 0; i < mNumDays; i++) {
+ int calendarDay = (i + mWeekStart) % mNumDays;
+ int x = (2 * i + 1) * dayWidthHalf + mPadding;
+ mDayLabelCalendar.set(Calendar.DAY_OF_WEEK, calendarDay);
+ canvas.drawText(mDayLabelCalendar.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.SHORT,
+ Locale.getDefault()).toUpperCase(Locale.getDefault()), 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)
+ + MONTH_HEADER_SIZE;
+ int dayWidthHalf = (mWidth - mPadding * 2) / (mNumDays * 2);
+ int j = findDayOffset();
+ for (int dayNumber = 1; dayNumber <= mNumCells; dayNumber++) {
+ int x = (2 * j + 1) * dayWidthHalf + mPadding;
+
+ int yRelativeToDay = (mRowHeight + MINI_DAY_NUMBER_TEXT_SIZE) / 2 - DAY_SEPARATOR_WIDTH;
+
+ int startX = x - dayWidthHalf;
+ int stopX = x + dayWidthHalf;
+ int startY = y - yRelativeToDay;
+ 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);
+
+ private 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) {
+ int dayStart = mPadding;
+ if (x < dayStart || x > mWidth - mPadding) {
+ return -1;
+ }
+ // Selection is (x - start) / (pixels/day) == (x -s) * day / pixels
+ int row = (int) (y - MONTH_HEADER_SIZE) / mRowHeight;
+ int column = (int) ((x - dayStart) * mNumDays / (mWidth - dayStart - mPadding));
+
+ int day = column - findDayOffset() + 1;
+ day += row * mNumDays;
+ if (day < 1 || day > mNumCells) {
+ return -1;
+ }
+ return day;
+ }
+
+ /**
+ * Called when the user clicks on a day. Handles callbacks to the
+ * {@link OnDayClickListener} if one is set.
+ *
+ * @param day The day that was clicked
+ */
+ private void onDayClick(int day) {
+ if (mOnDayClickListener != null) {
+ mOnDayClickListener.onDayClick(this, new CalendarDay(mYear, mMonth, day));
+ }
+
+ // This is a no-op if accessibility is turned off.
+ mTouchHelper.sendEventForVirtualView(day, AccessibilityEvent.TYPE_VIEW_CLICKED);
+ }
+
+ /**
+ * @return The date that has accessibility focus, or {@code null} if no date
+ * has focus
+ */
+ public CalendarDay getAccessibilityFocus() {
+ final int day = mTouchHelper.getFocusedVirtualView();
+ if (day >= 0) {
+ return new CalendarDay(mYear, mMonth, day);
+ }
+ 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.
+ */
+ private 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();
+
+ public MonthViewTouchHelper(View host) {
+ super(host);
+ }
+
+ public void setFocusedVirtualView(int virtualViewId) {
+ getAccessibilityNodeProvider(MonthView.this).performAction(
+ virtualViewId, AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS, null);
+ }
+
+ public void clearFocusedVirtualView() {
+ final int focusedVirtualView = getFocusedVirtualView();
+ 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, AccessibilityEvent event) {
+ event.setContentDescription(getItemDescription(virtualViewId));
+ }
+
+ @Override
+ protected void onPopulateNodeForVirtualView(int virtualViewId,
+ AccessibilityNodeInfoCompat node) {
+ getItemBounds(virtualViewId, mTempRect);
+
+ node.setContentDescription(getItemDescription(virtualViewId));
+ node.setBoundsInParent(mTempRect);
+ node.addAction(AccessibilityNodeInfo.ACTION_CLICK);
+
+ 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
+ */
+ private void getItemBounds(int day, Rect rect) {
+ final int offsetX = mPadding;
+ final int offsetY = MONTH_HEADER_SIZE;
+ final int cellHeight = mRowHeight;
+ final int cellWidth = ((mWidth - (2 * mPadding)) / 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
+ */
+ private CharSequence getItemDescription(int day) {
+ mTempCalendar.set(mYear, mMonth, day);
+ final CharSequence date = DateFormat.format(DATE_FORMAT,
+ mTempCalendar.getTimeInMillis());
+
+ if (day == mSelectedDay) {
+ return getContext().getString(R.string.item_is_selected, date);
+ }
+
+ return date;
+ }
+ }
+
+ /**
+ * Handles callbacks when the user clicks on a time object.
+ */
+ public interface OnDayClickListener {
+ public void onDayClick(MonthView view, CalendarDay day);
+ }
+}
diff --git a/src/com/android/datetimepicker/date/SimpleDayPickerView.java b/src/com/android/datetimepicker/date/SimpleDayPickerView.java
new file mode 100644
index 000000000..658c8a284
--- /dev/null
+++ b/src/com/android/datetimepicker/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.android.datetimepicker.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(Context context, DatePickerController controller) {
+ return new SimpleMonthAdapter(context, controller);
+ }
+
+}
diff --git a/src/com/android/datetimepicker/date/SimpleMonthAdapter.java b/src/com/android/datetimepicker/date/SimpleMonthAdapter.java
new file mode 100644
index 000000000..b49b135cd
--- /dev/null
+++ b/src/com/android/datetimepicker/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.android.datetimepicker.date;
+
+import android.content.Context;
+
+/**
+ * An adapter for a list of {@link SimpleMonthView} items.
+ */
+public class SimpleMonthAdapter extends MonthAdapter {
+
+ public SimpleMonthAdapter(Context context, DatePickerController controller) {
+ super(context, controller);
+ }
+
+ @Override
+ public MonthView createMonthView(Context context) {
+ return new SimpleMonthView(context);
+ }
+}
diff --git a/src/com/android/datetimepicker/date/SimpleMonthView.java b/src/com/android/datetimepicker/date/SimpleMonthView.java
new file mode 100644
index 000000000..6939fa8ed
--- /dev/null
+++ b/src/com/android/datetimepicker/date/SimpleMonthView.java
@@ -0,0 +1,43 @@
+/*
+ * 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.android.datetimepicker.date;
+
+import android.content.Context;
+import android.graphics.Canvas;
+
+public class SimpleMonthView extends MonthView {
+
+ public SimpleMonthView(Context context) {
+ super(context);
+ }
+
+ @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 (mHasToday && mToday == day) {
+ mMonthNumPaint.setColor(mTodayNumberColor);
+ } else {
+ mMonthNumPaint.setColor(mDayTextColor);
+ }
+ canvas.drawText(String.format("%d", day), x, y, mMonthNumPaint);
+ }
+}
diff --git a/src/com/android/datetimepicker/date/TextViewWithCircularIndicator.java b/src/com/android/datetimepicker/date/TextViewWithCircularIndicator.java
new file mode 100644
index 000000000..64d6404e7
--- /dev/null
+++ b/src/com/android/datetimepicker/date/TextViewWithCircularIndicator.java
@@ -0,0 +1,88 @@
+/*
+ * 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.android.datetimepicker.date;
+
+import org.isoron.uhabits.R;
+
+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.util.AttributeSet;
+import android.widget.TextView;
+
+/**
+ * A text view which, when pressed or activated, displays a blue circle around the text.
+ */
+public class TextViewWithCircularIndicator extends TextView {
+
+ private static final int SELECTED_CIRCLE_ALPHA = 60;
+
+ Paint mCirclePaint = new Paint();
+
+ private final int mRadius;
+ private final int mCircleColor;
+ private final String mItemIsSelectedText;
+
+ private boolean mDrawCircle;
+
+ public TextViewWithCircularIndicator(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ Resources res = context.getResources();
+ mCircleColor = res.getColor(R.color.blue);
+ mRadius = res.getDimensionPixelOffset(R.dimen.month_select_circle_radius);
+ mItemIsSelectedText = context.getResources().getString(R.string.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 drawIndicator(boolean drawCircle) {
+ mDrawCircle = drawCircle;
+ }
+
+ @Override
+ public void onDraw(Canvas canvas) {
+ super.onDraw(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);
+ }
+ }
+
+ @Override
+ public CharSequence getContentDescription() {
+ CharSequence itemText = getText();
+ if (mDrawCircle) {
+ return String.format(mItemIsSelectedText, itemText);
+ } else {
+ return itemText;
+ }
+ }
+}
diff --git a/src/com/android/datetimepicker/date/YearPickerView.java b/src/com/android/datetimepicker/date/YearPickerView.java
new file mode 100644
index 000000000..5abc4ca1a
--- /dev/null
+++ b/src/com/android/datetimepicker/date/YearPickerView.java
@@ -0,0 +1,162 @@
+/*
+ * 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.android.datetimepicker.date;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.isoron.uhabits.R;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.drawable.StateListDrawable;
+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.ArrayAdapter;
+import android.widget.ListView;
+import android.widget.TextView;
+
+import com.android.datetimepicker.date.DatePickerDialog.OnDateChangedListener;
+
+/**
+ * Displays a selectable list of years.
+ */
+public class YearPickerView extends ListView implements OnItemClickListener, OnDateChangedListener {
+ private static final String TAG = "YearPickerView";
+
+ private final DatePickerController mController;
+ private YearAdapter mAdapter;
+ private int mViewSize;
+ private int mChildSize;
+ private TextViewWithCircularIndicator mSelectedView;
+
+ /**
+ * @param context
+ */
+ 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 = res.getDimensionPixelOffset(R.dimen.date_picker_view_animator_height);
+ mChildSize = res.getDimensionPixelOffset(R.dimen.year_label_height);
+ setVerticalFadingEdgeEnabled(true);
+ setFadingEdgeLength(mChildSize / 3);
+ init(context);
+ setOnItemClickListener(this);
+ setSelector(new StateListDrawable());
+ setDividerHeight(0);
+ onDateChanged();
+ }
+
+ private void init(Context context) {
+ ArrayList years = new ArrayList();
+ for (int year = mController.getMinYear(); year <= mController.getMaxYear(); year++) {
+ years.add(String.format("%d", year));
+ }
+ mAdapter = new YearAdapter(context, R.layout.year_label_text_view, years);
+ 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 class YearAdapter extends ArrayAdapter {
+
+ public YearAdapter(Context context, int resource, List objects) {
+ super(context, resource, objects);
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ TextViewWithCircularIndicator v = (TextViewWithCircularIndicator)
+ super.getView(position, convertView, parent);
+ v.requestLayout();
+ int year = getYearFromTextView(v);
+ boolean selected = mController.getSelectedDay().year == year;
+ v.drawIndicator(selected);
+ 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(new Runnable() {
+
+ @Override
+ public void run() {
+ 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/src/com/android/datetimepicker/time/AmPmCirclesView.java b/src/com/android/datetimepicker/time/AmPmCirclesView.java
new file mode 100644
index 000000000..993ea0c91
--- /dev/null
+++ b/src/com/android/datetimepicker/time/AmPmCirclesView.java
@@ -0,0 +1,212 @@
+/*
+ * 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.android.datetimepicker.time;
+
+import java.text.DateFormatSymbols;
+
+import org.isoron.uhabits.R;
+
+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.Typeface;
+import android.util.Log;
+import android.view.View;
+
+import com.android.datetimepicker.Utils;
+
+/**
+ * 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 mUnselectedColor;
+ private int mAmPmTextColor;
+ private int mSelectedColor;
+ private float mCircleRadiusMultiplier;
+ private float mAmPmCircleRadiusMultiplier;
+ private String mAmText;
+ private String mPmText;
+ 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, int amOrPm) {
+ if (mIsInitialized) {
+ Log.e(TAG, "AmPmCirclesView may only be initialized once.");
+ return;
+ }
+
+ Resources res = context.getResources();
+ mUnselectedColor = res.getColor(R.color.white);
+ mSelectedColor = res.getColor(R.color.blue);
+ mAmPmTextColor = res.getColor(R.color.ampm_text_color);
+ mSelectedAlpha = SELECTED_ALPHA;
+ String typefaceFamily = res.getString(R.string.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.circle_radius_multiplier));
+ mAmPmCircleRadiusMultiplier =
+ Float.parseFloat(res.getString(R.string.ampm_circle_radius_multiplier));
+ String[] amPmTexts = new DateFormatSymbols().getAmPmStrings();
+ mAmText = amPmTexts[0];
+ mPmText = amPmTexts[1];
+
+ setAmOrPm(amOrPm);
+ mAmOrPmPressed = -1;
+
+ mIsInitialized = true;
+ }
+
+ /* package */ void setTheme(Context context, boolean themeDark) {
+ Resources res = context.getResources();
+ if (themeDark) {
+ mUnselectedColor = res.getColor(R.color.dark_gray);
+ mSelectedColor = res.getColor(R.color.red);
+ mAmPmTextColor = res.getColor(R.color.white);
+ mSelectedAlpha = SELECTED_ALPHA_THEME_DARK;
+ } else {
+ mUnselectedColor = res.getColor(R.color.white);
+ mSelectedColor = res.getColor(R.color.blue);
+ mAmPmTextColor = res.getColor(R.color.ampm_text_color);
+ mSelectedAlpha = SELECTED_ALPHA;
+ }
+ }
+
+ 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) {
+ return AM;
+ }
+
+ int distanceToPmCenter =
+ (int) Math.sqrt((xCoord - mPmXCenter)*(xCoord - mPmXCenter) + squaredYDistance);
+ if (distanceToPmCenter <= mAmPmCircleRadius) {
+ 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);
+ 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 pmColor = mUnselectedColor;
+ int pmAlpha = 255;
+ if (mAmOrPm == AM) {
+ amColor = mSelectedColor;
+ amAlpha = mSelectedAlpha;
+ } else if (mAmOrPm == PM) {
+ pmColor = mSelectedColor;
+ pmAlpha = mSelectedAlpha;
+ }
+ if (mAmOrPmPressed == AM) {
+ amColor = mSelectedColor;
+ amAlpha = mSelectedAlpha;
+ } else if (mAmOrPmPressed == PM) {
+ pmColor = mSelectedColor;
+ pmAlpha = mSelectedAlpha;
+ }
+
+ // 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(mAmPmTextColor);
+ int textYCenter = mAmPmYCenter - (int) (mPaint.descent() + mPaint.ascent()) / 2;
+ canvas.drawText(mAmText, mAmXCenter, textYCenter, mPaint);
+ canvas.drawText(mPmText, mPmXCenter, textYCenter, mPaint);
+ }
+}
diff --git a/src/com/android/datetimepicker/time/CircleView.java b/src/com/android/datetimepicker/time/CircleView.java
new file mode 100644
index 000000000..bd0ccc99c
--- /dev/null
+++ b/src/com/android/datetimepicker/time/CircleView.java
@@ -0,0 +1,122 @@
+/*
+ * 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.android.datetimepicker.time;
+
+import org.isoron.uhabits.R;
+
+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;
+
+/**
+ * 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);
+
+ Resources res = context.getResources();
+ mCircleColor = res.getColor(R.color.white);
+ mDotColor = res.getColor(R.color.numbers_text_color);
+ mPaint.setAntiAlias(true);
+
+ mIsInitialized = false;
+ }
+
+ public void initialize(Context context, boolean is24HourMode) {
+ if (mIsInitialized) {
+ Log.e(TAG, "CircleView may only be initialized once.");
+ return;
+ }
+
+ Resources res = context.getResources();
+ mIs24HourMode = is24HourMode;
+ if (is24HourMode) {
+ mCircleRadiusMultiplier = Float.parseFloat(
+ res.getString(R.string.circle_radius_multiplier_24HourMode));
+ } else {
+ mCircleRadiusMultiplier = Float.parseFloat(
+ res.getString(R.string.circle_radius_multiplier));
+ mAmPmCircleRadiusMultiplier =
+ Float.parseFloat(res.getString(R.string.ampm_circle_radius_multiplier));
+ }
+
+ mIsInitialized = true;
+ }
+
+ /* package */ void setTheme(Context context, boolean dark) {
+ Resources res = context.getResources();
+ if (dark) {
+ mCircleColor = res.getColor(R.color.dark_gray);
+ mDotColor = res.getColor(R.color.light_gray);
+ } else {
+ mCircleColor = res.getColor(R.color.white);
+ mDotColor = res.getColor(R.color.numbers_text_color);
+ }
+ }
+
+
+ @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 / 2;
+ }
+
+ 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, 2, mPaint);
+ }
+}
diff --git a/src/com/android/datetimepicker/time/RadialPickerLayout.java b/src/com/android/datetimepicker/time/RadialPickerLayout.java
new file mode 100644
index 000000000..3ada25315
--- /dev/null
+++ b/src/com/android/datetimepicker/time/RadialPickerLayout.java
@@ -0,0 +1,830 @@
+/*
+ * 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.android.datetimepicker.time;
+
+import org.isoron.uhabits.R;
+
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.os.Handler;
+import android.text.format.DateUtils;
+import android.text.format.Time;
+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.android.datetimepicker.HapticFeedbackController;
+
+/**
+ * The primary layout to hold the circular picker, and the am/pm buttons. This view well 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 HOUR_INDEX = TimePickerDialog.HOUR_INDEX;
+ private static final int MINUTE_INDEX = TimePickerDialog.MINUTE_INDEX;
+ private static final int AMPM_INDEX = TimePickerDialog.AMPM_INDEX;
+ private static final int ENABLE_PICKER_INDEX = TimePickerDialog.ENABLE_PICKER_INDEX;
+ private static final int AM = TimePickerDialog.AM;
+ private static final int PM = TimePickerDialog.PM;
+
+ private int mLastValueSelected;
+
+ private HapticFeedbackController mHapticFeedbackController;
+ private OnValueSelectedListener mListener;
+ private boolean mTimeInitialized;
+ private int mCurrentHoursOfDay;
+ private int mCurrentMinutes;
+ private boolean mIs24HourMode;
+ private boolean mHideAmPm;
+ private int mCurrentItemShowing;
+
+ private CircleView mCircleView;
+ private AmPmCirclesView mAmPmCirclesView;
+ private RadialTextsView mHourRadialTextsView;
+ private RadialTextsView mMinuteRadialTextsView;
+ private RadialSelectorView mHourRadialSelectorView;
+ private RadialSelectorView mMinuteRadialSelectorView;
+ 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(int pickerIndex, int newValue, boolean autoAdvance);
+ }
+
+ 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);
+
+ mHourRadialTextsView = new RadialTextsView(context);
+ addView(mHourRadialTextsView);
+ mMinuteRadialTextsView = new RadialTextsView(context);
+ addView(mMinuteRadialTextsView);
+
+ mHourRadialSelectorView = new RadialSelectorView(context);
+ addView(mHourRadialSelectorView);
+ mMinuteRadialSelectorView = new RadialSelectorView(context);
+ addView(mMinuteRadialSelectorView);
+
+ // Prepare mapping to snap touchable degrees to selectable degrees.
+ preparePrefer30sMap();
+
+ mLastValueSelected = -1;
+
+ mInputEnabled = true;
+ mGrayBox = new View(context);
+ mGrayBox.setLayoutParams(new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
+ mGrayBox.setBackgroundColor(getResources().getColor(R.color.transparent_black));
+ mGrayBox.setVisibility(View.INVISIBLE);
+ addView(mGrayBox);
+
+ mAccessibilityManager = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
+
+ mTimeInitialized = false;
+ }
+
+ /**
+ * Measure the view to end up as a square, based on the minimum of the height and width.
+ */
+ @Override
+ public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ int measuredWidth = MeasureSpec.getSize(widthMeasureSpec);
+ int widthMode = MeasureSpec.getMode(widthMeasureSpec);
+ int measuredHeight = MeasureSpec.getSize(heightMeasureSpec);
+ int heightMode = MeasureSpec.getMode(heightMeasureSpec);
+ int minDimension = Math.min(measuredWidth, measuredHeight);
+
+ super.onMeasure(MeasureSpec.makeMeasureSpec(minDimension, widthMode),
+ MeasureSpec.makeMeasureSpec(minDimension, heightMode));
+ }
+
+ public void setOnValueSelectedListener(OnValueSelectedListener listener) {
+ mListener = listener;
+ }
+
+ /**
+ * Initialize the Layout with starting values.
+ * @param context
+ * @param initialHoursOfDay
+ * @param initialMinutes
+ * @param is24HourMode
+ */
+ public void initialize(Context context, HapticFeedbackController hapticFeedbackController,
+ int initialHoursOfDay, int initialMinutes, boolean is24HourMode) {
+ if (mTimeInitialized) {
+ Log.e(TAG, "Time has already been initialized.");
+ return;
+ }
+
+ mHapticFeedbackController = hapticFeedbackController;
+ mIs24HourMode = is24HourMode;
+ mHideAmPm = mAccessibilityManager.isTouchExplorationEnabled()? true : mIs24HourMode;
+
+ // Initialize the circle and AM/PM circles if applicable.
+ mCircleView.initialize(context, mHideAmPm);
+ mCircleView.invalidate();
+ if (!mHideAmPm) {
+ mAmPmCirclesView.initialize(context, initialHoursOfDay < 12? AM : PM);
+ mAmPmCirclesView.invalidate();
+ }
+
+ // Initialize the hours and minutes numbers.
+ Resources res = context.getResources();
+ 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};
+ String[] hoursTexts = new String[12];
+ String[] innerHoursTexts = new String[12];
+ String[] minutesTexts = new String[12];
+ for (int i = 0; i < 12; i++) {
+ hoursTexts[i] = is24HourMode?
+ String.format("%02d", hours_24[i]) : String.format("%d", hours[i]);
+ innerHoursTexts[i] = String.format("%d", hours[i]);
+ minutesTexts[i] = String.format("%02d", minutes[i]);
+ }
+ mHourRadialTextsView.initialize(res,
+ hoursTexts, (is24HourMode? innerHoursTexts : null), mHideAmPm, true);
+ mHourRadialTextsView.invalidate();
+ mMinuteRadialTextsView.initialize(res, minutesTexts, null, mHideAmPm, false);
+ mMinuteRadialTextsView.invalidate();
+
+ // Initialize the currently-selected hour and minute.
+ setValueForItem(HOUR_INDEX, initialHoursOfDay);
+ setValueForItem(MINUTE_INDEX, initialMinutes);
+ int hourDegrees = (initialHoursOfDay % 12) * HOUR_VALUE_TO_DEGREES_STEP_SIZE;
+ mHourRadialSelectorView.initialize(context, mHideAmPm, is24HourMode, true,
+ hourDegrees, isHourInnerCircle(initialHoursOfDay));
+ int minuteDegrees = initialMinutes * MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
+ mMinuteRadialSelectorView.initialize(context, mHideAmPm, false, false,
+ minuteDegrees, false);
+
+ mTimeInitialized = true;
+ }
+
+ /* package */ void setTheme(Context context, boolean themeDark) {
+ mCircleView.setTheme(context, themeDark);
+ mAmPmCirclesView.setTheme(context, themeDark);
+ mHourRadialTextsView.setTheme(context, themeDark);
+ mMinuteRadialTextsView.setTheme(context, themeDark);
+ mHourRadialSelectorView.setTheme(context, themeDark);
+ mMinuteRadialSelectorView.setTheme(context, themeDark);
+ }
+
+ public void setTime(int hours, int minutes) {
+ setItem(HOUR_INDEX, hours);
+ setItem(MINUTE_INDEX, minutes);
+ }
+
+ /**
+ * Set either the hour or the minute. Will set the internal value, and set the selection.
+ */
+ private void setItem(int index, int value) {
+ if (index == HOUR_INDEX) {
+ setValueForItem(HOUR_INDEX, value);
+ int hourDegrees = (value % 12) * HOUR_VALUE_TO_DEGREES_STEP_SIZE;
+ mHourRadialSelectorView.setSelection(hourDegrees, isHourInnerCircle(value), false);
+ mHourRadialSelectorView.invalidate();
+ } else if (index == MINUTE_INDEX) {
+ setValueForItem(MINUTE_INDEX, value);
+ int minuteDegrees = value * MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
+ mMinuteRadialSelectorView.setSelection(minuteDegrees, false, false);
+ mMinuteRadialSelectorView.invalidate();
+ }
+ }
+
+ /**
+ * 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.
+ return mIs24HourMode && (hourOfDay <= 12 && hourOfDay != 0);
+ }
+
+ public int getHours() {
+ return mCurrentHoursOfDay;
+ }
+
+ public int getMinutes() {
+ return mCurrentMinutes;
+ }
+
+ /**
+ * If the hours are showing, return the current hour. If the minutes are showing, return the
+ * current minute.
+ */
+ private int getCurrentlyShowingValue() {
+ int currentIndex = getCurrentItemShowing();
+ if (currentIndex == HOUR_INDEX) {
+ return mCurrentHoursOfDay;
+ } else if (currentIndex == MINUTE_INDEX) {
+ return mCurrentMinutes;
+ } else {
+ return -1;
+ }
+ }
+
+ public int getIsCurrentlyAmOrPm() {
+ if (mCurrentHoursOfDay < 12) {
+ return AM;
+ } else if (mCurrentHoursOfDay < 24) {
+ return PM;
+ }
+ return -1;
+ }
+
+ /**
+ * Set the internal value for the hour, minute, or AM/PM.
+ */
+ private void setValueForItem(int index, int value) {
+ if (index == HOUR_INDEX) {
+ mCurrentHoursOfDay = value;
+ } else if (index == MINUTE_INDEX){
+ mCurrentMinutes = value;
+ } else if (index == AMPM_INDEX) {
+ if (value == AM) {
+ mCurrentHoursOfDay = mCurrentHoursOfDay % 12;
+ } else if (value == PM) {
+ mCurrentHoursOfDay = (mCurrentHoursOfDay % 12) + 12;
+ }
+ }
+ }
+
+ /**
+ * Set the internal value as either AM or PM, and update the AM/PM circle displays.
+ * @param amOrPm
+ */
+ public void setAmOrPm(int amOrPm) {
+ mAmPmCirclesView.setAmOrPm(amOrPm);
+ mAmPmCirclesView.invalidate();
+ setValueForItem(AMPM_INDEX, amOrPm);
+ }
+
+ /**
+ * 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 forceAboveOrBelow 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;
+ }
+
+ /**
+ * For the currently showing view (either hours or minutes), re-calculate the position for the
+ * selector, and redraw it at that position. The input degrees will be snapped to a selectable
+ * value.
+ * @param degrees Degrees which should be selected.
+ * @param isInnerCircle Whether the selection should be in the inner circle; will be ignored
+ * if there is no inner circle.
+ * @param forceToVisibleValue Even if the currently-showing circle allows for fine-grained
+ * selection (i.e. minutes), force the selection to one of the visibly-showing values.
+ * @param forceDrawDot The dot in the circle will generally only be shown when the selection
+ * is on non-visible values, but use this to force the dot to be shown.
+ * @return The value that was selected, i.e. 0-23 for hours, 0-59 for minutes.
+ */
+ private int reselectSelector(int degrees, boolean isInnerCircle,
+ boolean forceToVisibleValue, boolean forceDrawDot) {
+ if (degrees == -1) {
+ return -1;
+ }
+ int currentShowing = getCurrentItemShowing();
+
+ int stepSize;
+ boolean allowFineGrained = !forceToVisibleValue && (currentShowing == MINUTE_INDEX);
+ if (allowFineGrained) {
+ degrees = snapPrefer30s(degrees);
+ } else {
+ degrees = snapOnly30s(degrees, 0);
+ }
+
+ RadialSelectorView radialSelectorView;
+ if (currentShowing == HOUR_INDEX) {
+ radialSelectorView = mHourRadialSelectorView;
+ stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE;
+ } else {
+ radialSelectorView = mMinuteRadialSelectorView;
+ stepSize = MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
+ }
+ radialSelectorView.setSelection(degrees, isInnerCircle, forceDrawDot);
+ radialSelectorView.invalidate();
+
+
+ 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) {
+ degrees = 0;
+ }
+
+ int value = degrees / stepSize;
+ if (currentShowing == HOUR_INDEX && mIs24HourMode && !isInnerCircle && degrees != 0) {
+ value += 12;
+ }
+ return value;
+ }
+
+ /**
+ * 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) {
+ int currentItem = getCurrentItemShowing();
+ if (currentItem == HOUR_INDEX) {
+ return mHourRadialSelectorView.getDegreesFromCoords(
+ pointX, pointY, forceLegal, isInnerCircle);
+ } else if (currentItem == MINUTE_INDEX) {
+ return mMinuteRadialSelectorView.getDegreesFromCoords(
+ pointX, pointY, forceLegal, isInnerCircle);
+ } else {
+ return -1;
+ }
+ }
+
+ /**
+ * Get the item (hours or minutes) that is currently showing.
+ */
+ public int getCurrentItemShowing() {
+ if (mCurrentItemShowing != HOUR_INDEX && mCurrentItemShowing != MINUTE_INDEX) {
+ Log.e(TAG, "Current item showing was unfortunately set to "+mCurrentItemShowing);
+ return -1;
+ }
+ return mCurrentItemShowing;
+ }
+
+ /**
+ * Set either 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) {
+ Log.e(TAG, "TimePicker does not support view at index "+index);
+ return;
+ }
+
+ int lastIndex = getCurrentItemShowing();
+ mCurrentItemShowing = index;
+
+ if (animate && (index != lastIndex)) {
+ ObjectAnimator[] anims = new ObjectAnimator[4];
+ if (index == MINUTE_INDEX) {
+ anims[0] = mHourRadialTextsView.getDisappearAnimator();
+ anims[1] = mHourRadialSelectorView.getDisappearAnimator();
+ anims[2] = mMinuteRadialTextsView.getReappearAnimator();
+ anims[3] = mMinuteRadialSelectorView.getReappearAnimator();
+ } else if (index == HOUR_INDEX){
+ anims[0] = mHourRadialTextsView.getReappearAnimator();
+ anims[1] = mHourRadialSelectorView.getReappearAnimator();
+ anims[2] = mMinuteRadialTextsView.getDisappearAnimator();
+ anims[3] = mMinuteRadialSelectorView.getDisappearAnimator();
+ }
+
+ if (mTransition != null && mTransition.isRunning()) {
+ mTransition.end();
+ }
+ mTransition = new AnimatorSet();
+ mTransition.playTogether(anims);
+ mTransition.start();
+ } else {
+ int hourAlpha = (index == HOUR_INDEX) ? 255 : 0;
+ int minuteAlpha = (index == MINUTE_INDEX) ? 255 : 0;
+ mHourRadialTextsView.setAlpha(hourAlpha);
+ mHourRadialSelectorView.setAlpha(hourAlpha);
+ mMinuteRadialTextsView.setAlpha(minuteAlpha);
+ mMinuteRadialSelectorView.setAlpha(minuteAlpha);
+ }
+
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ final float eventX = event.getX();
+ final float eventY = event.getY();
+ int degrees;
+ int 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 = -1;
+ mDoingMove = false;
+ mDoingTouch = true;
+ // If we're showing the AM/PM, check to see if the user is touching it.
+ if (!mHideAmPm) {
+ 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.
+ mHapticFeedbackController.tryVibrate();
+ mDownDegrees = -1;
+ mHandler.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ 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);
+ 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.
+ mHapticFeedbackController.tryVibrate();
+ mHandler.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ mDoingMove = true;
+ int value = reselectSelector(mDownDegrees, isInnerCircle[0],
+ false, true);
+ mLastValueSelected = value;
+ mListener.onValueSelected(getCurrentItemShowing(), value, false);
+ }
+ }, 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 = reselectSelector(degrees, isInnerCircle[0], false, true);
+ if (value != mLastValueSelected) {
+ mHapticFeedbackController.tryVibrate();
+ mLastValueSelected = value;
+ mListener.onValueSelected(getCurrentItemShowing(), value, false);
+ }
+ }
+ 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.onValueSelected(ENABLE_PICKER_INDEX, 1, false);
+ 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) {
+ mListener.onValueSelected(AMPM_INDEX, mIsTouchingAmOrPm, false);
+ setValueForItem(AMPM_INDEX, isTouchingAmOrPm);
+ }
+ }
+ 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 = reselectSelector(degrees, isInnerCircle[0], !mDoingMove, false);
+ if (getCurrentItemShowing() == HOUR_INDEX && !mIs24HourMode) {
+ int amOrPm = getIsCurrentlyAmOrPm();
+ if (amOrPm == AM && value == 12) {
+ value = 0;
+ } else if (amOrPm == PM && value != 12) {
+ value += 12;
+ }
+ }
+ setValueForItem(getCurrentItemShowing(), value);
+ mListener.onValueSelected(getCurrentItemShowing(), value, true);
+ }
+ }
+ 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(AccessibilityNodeInfo info) {
+ super.onInitializeAccessibilityNodeInfo(info);
+ 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();
+ Time time = new Time();
+ time.hour = getHours();
+ time.minute = getMinutes();
+ long millis = time.normalize(true);
+ 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.
+ */
+ @SuppressLint("NewApi")
+ @Override
+ public boolean performAccessibilityAction(int action, Bundle arguments) {
+ if (super.performAccessibilityAction(action, arguments)) {
+ return true;
+ }
+
+ int changeMultiplier = 0;
+ if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) {
+ changeMultiplier = 1;
+ } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_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;
+ }
+
+ 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;
+ }
+ setItem(currentItemShowing, value);
+ mListener.onValueSelected(currentItemShowing, value, false);
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/com/android/datetimepicker/time/RadialSelectorView.java b/src/com/android/datetimepicker/time/RadialSelectorView.java
new file mode 100644
index 000000000..1ccf511c1
--- /dev/null
+++ b/src/com/android/datetimepicker/time/RadialSelectorView.java
@@ -0,0 +1,399 @@
+/*
+ * 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.android.datetimepicker.time;
+
+import org.isoron.uhabits.R;
+
+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.android.datetimepicker.Utils;
+
+/**
+ * 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 is24HourMode Whether the selector is in 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, boolean is24HourMode, 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 blue = res.getColor(R.color.blue);
+ mPaint.setColor(blue);
+ mPaint.setAntiAlias(true);
+ mSelectionAlpha = SELECTED_ALPHA;
+
+ // Calculate values for the circle radius size.
+ mIs24HourMode = is24HourMode;
+ if (is24HourMode) {
+ mCircleRadiusMultiplier = Float.parseFloat(
+ res.getString(R.string.circle_radius_multiplier_24HourMode));
+ } else {
+ mCircleRadiusMultiplier = Float.parseFloat(
+ res.getString(R.string.circle_radius_multiplier));
+ mAmPmCircleRadiusMultiplier =
+ Float.parseFloat(res.getString(R.string.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.numbers_radius_multiplier_inner));
+ mOuterNumbersRadiusMultiplier =
+ Float.parseFloat(res.getString(R.string.numbers_radius_multiplier_outer));
+ } else {
+ mNumbersRadiusMultiplier =
+ Float.parseFloat(res.getString(R.string.numbers_radius_multiplier_normal));
+ }
+ mSelectionRadiusMultiplier =
+ Float.parseFloat(res.getString(R.string.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();
+
+ setSelection(selectionDegrees, isInnerCircle, false);
+ mIsInitialized = true;
+ }
+
+ /* package */ void setTheme(Context context, boolean themeDark) {
+ Resources res = context.getResources();
+ int color;
+ if (themeDark) {
+ color = res.getColor(R.color.red);
+ mSelectionAlpha = SELECTED_ALPHA_THEME_DARK;
+ } else {
+ color = res.getColor(R.color.blue);
+ mSelectionAlpha = SELECTED_ALPHA;
+ }
+ mPaint.setColor(color);
+ }
+
+ /**
+ * 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.
+ */
+ 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 / 2;
+ }
+
+ 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(1);
+ 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 class InvalidateUpdateListener implements AnimatorUpdateListener {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ RadialSelectorView.this.invalidate();
+ }
+ }
+}
diff --git a/src/com/android/datetimepicker/time/RadialTextsView.java b/src/com/android/datetimepicker/time/RadialTextsView.java
new file mode 100644
index 000000000..e6a99a657
--- /dev/null
+++ b/src/com/android/datetimepicker/time/RadialTextsView.java
@@ -0,0 +1,359 @@
+/*
+ * 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.android.datetimepicker.time;
+
+import org.isoron.uhabits.R;
+
+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.Paint.Align;
+import android.graphics.Typeface;
+import android.util.Log;
+import android.view.View;
+
+/**
+ * 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 boolean mDrawValuesReady;
+ private boolean mIsInitialized;
+
+ 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(Resources res, String[] texts, String[] innerTexts,
+ boolean is24HourMode, boolean disappearsOut) {
+ if (mIsInitialized) {
+ Log.e(TAG, "This RadialTextsView may only be initialized once.");
+ return;
+ }
+
+ // Set up the paint.
+ int numbersTextColor = res.getColor(R.color.numbers_text_color);
+ mPaint.setColor(numbersTextColor);
+ String typefaceFamily = res.getString(R.string.radial_numbers_typeface);
+ mTypefaceLight = Typeface.create(typefaceFamily, Typeface.NORMAL);
+ String typefaceFamilyRegular = res.getString(R.string.sans_serif);
+ mTypefaceRegular = Typeface.create(typefaceFamilyRegular, Typeface.NORMAL);
+ mPaint.setAntiAlias(true);
+ mPaint.setTextAlign(Align.CENTER);
+
+ mTexts = texts;
+ mInnerTexts = innerTexts;
+ mIs24HourMode = is24HourMode;
+ mHasInnerCircle = (innerTexts != null);
+
+ // Calculate the radius for the main circle.
+ if (is24HourMode) {
+ mCircleRadiusMultiplier = Float.parseFloat(
+ res.getString(R.string.circle_radius_multiplier_24HourMode));
+ } else {
+ mCircleRadiusMultiplier = Float.parseFloat(
+ res.getString(R.string.circle_radius_multiplier));
+ mAmPmCircleRadiusMultiplier =
+ Float.parseFloat(res.getString(R.string.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.numbers_radius_multiplier_outer));
+ mTextSizeMultiplier = Float.parseFloat(
+ res.getString(R.string.text_size_multiplier_outer));
+ mInnerNumbersRadiusMultiplier = Float.parseFloat(
+ res.getString(R.string.numbers_radius_multiplier_inner));
+ mInnerTextSizeMultiplier = Float.parseFloat(
+ res.getString(R.string.text_size_multiplier_inner));
+
+ mInnerTextGridHeights = new float[7];
+ mInnerTextGridWidths = new float[7];
+ } else {
+ mNumbersRadiusMultiplier = Float.parseFloat(
+ res.getString(R.string.numbers_radius_multiplier_normal));
+ mTextSizeMultiplier = Float.parseFloat(
+ res.getString(R.string.text_size_multiplier_normal));
+ }
+
+ mAnimationRadiusMultiplier = 1;
+ mTransitionMidRadiusMultiplier = 1f + (0.05f * (disappearsOut? -1 : 1));
+ mTransitionEndRadiusMultiplier = 1f + (0.3f * (disappearsOut? 1 : -1));
+ mInvalidateUpdateListener = new InvalidateUpdateListener();
+
+ mTextGridValuesDirty = true;
+ mIsInitialized = true;
+ }
+
+ /* package */ void setTheme(Context context, boolean themeDark) {
+ Resources res = context.getResources();
+ int textColor;
+ if (themeDark) {
+ textColor = res.getColor(R.color.white);
+ } else {
+ textColor = res.getColor(R.color.numbers_text_color);
+ }
+ mPaint.setColor(textColor);
+ }
+
+ /**
+ * Allows for smoother animation.
+ */
+ @Override
+ public boolean hasOverlappingRendering() {
+ return false;
+ }
+
+ /**
+ * Used by the animation to move the numbers in and out.
+ */
+ 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 / 2;
+ }
+
+ 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);
+ // 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;
+ }
+
+ /**
+ * Draw the 12 text values at the positions specified by the textGrid parameters.
+ */
+ private void drawTexts(Canvas canvas, float textSize, Typeface typeface, String[] texts,
+ float[] textGridWidths, float[] textGridHeights) {
+ mPaint.setTextSize(textSize);
+ mPaint.setTypeface(typeface);
+ canvas.drawText(texts[0], textGridWidths[3], textGridHeights[0], mPaint);
+ canvas.drawText(texts[1], textGridWidths[4], textGridHeights[1], mPaint);
+ canvas.drawText(texts[2], textGridWidths[5], textGridHeights[2], mPaint);
+ canvas.drawText(texts[3], textGridWidths[6], textGridHeights[3], mPaint);
+ canvas.drawText(texts[4], textGridWidths[5], textGridHeights[4], mPaint);
+ canvas.drawText(texts[5], textGridWidths[4], textGridHeights[5], mPaint);
+ canvas.drawText(texts[6], textGridWidths[3], textGridHeights[6], mPaint);
+ canvas.drawText(texts[7], textGridWidths[2], textGridHeights[5], mPaint);
+ canvas.drawText(texts[8], textGridWidths[1], textGridHeights[4], mPaint);
+ canvas.drawText(texts[9], textGridWidths[0], textGridHeights[3], mPaint);
+ canvas.drawText(texts[10], textGridWidths[1], textGridHeights[2], mPaint);
+ canvas.drawText(texts[11], textGridWidths[2], textGridHeights[1], mPaint);
+ }
+
+ /**
+ * Render the animations for appearing and disappearing.
+ */
+ private void renderAnimations() {
+ Keyframe kf0, kf1, kf2, kf3;
+ float midwayPoint = 0.2f;
+ int duration = 500;
+
+ // Set up animator for disappearing.
+ 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);
+
+ mDisappearAnimator = ObjectAnimator.ofPropertyValuesHolder(
+ this, radiusDisappear, fadeOut).setDuration(duration);
+ mDisappearAnimator.addUpdateListener(mInvalidateUpdateListener);
+
+
+ // Set up animator for reappearing.
+ 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);
+
+ mReappearAnimator = ObjectAnimator.ofPropertyValuesHolder(
+ this, radiusReappear, fadeIn).setDuration(totalDuration);
+ mReappearAnimator.addUpdateListener(mInvalidateUpdateListener);
+ }
+
+ public ObjectAnimator getDisappearAnimator() {
+ if (!mIsInitialized || !mDrawValuesReady || mDisappearAnimator == null) {
+ Log.e(TAG, "RadialTextView was not ready for animation.");
+ return null;
+ }
+
+ return mDisappearAnimator;
+ }
+
+ public ObjectAnimator getReappearAnimator() {
+ if (!mIsInitialized || !mDrawValuesReady || mReappearAnimator == null) {
+ Log.e(TAG, "RadialTextView was not ready for animation.");
+ return null;
+ }
+
+ return mReappearAnimator;
+ }
+
+ private class InvalidateUpdateListener implements AnimatorUpdateListener {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ RadialTextsView.this.invalidate();
+ }
+ }
+}
diff --git a/src/com/android/datetimepicker/time/TimePickerDialog.java b/src/com/android/datetimepicker/time/TimePickerDialog.java
new file mode 100644
index 000000000..eff74c130
--- /dev/null
+++ b/src/com/android/datetimepicker/time/TimePickerDialog.java
@@ -0,0 +1,1003 @@
+/*
+ * 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.android.datetimepicker.time;
+
+import java.text.DateFormatSymbols;
+import java.util.ArrayList;
+import java.util.Locale;
+
+import org.isoron.uhabits.R;
+
+import android.animation.ObjectAnimator;
+import android.app.ActionBar.LayoutParams;
+import android.app.DialogFragment;
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.KeyCharacterMap;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.View.OnKeyListener;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import com.android.datetimepicker.HapticFeedbackController;
+import com.android.datetimepicker.Utils;
+import com.android.datetimepicker.time.RadialPickerLayout.OnValueSelectedListener;
+
+/**
+ * Dialog to set a time.
+ */
+public class TimePickerDialog extends DialogFragment implements OnValueSelectedListener{
+ private static final String TAG = "TimePickerDialog";
+
+ private static final String KEY_HOUR_OF_DAY = "hour_of_day";
+ private static final String KEY_MINUTE = "minute";
+ private static final String KEY_IS_24_HOUR_VIEW = "is_24_hour_view";
+ private static final String KEY_CURRENT_ITEM_SHOWING = "current_item_showing";
+ private static final String KEY_IN_KB_MODE = "in_kb_mode";
+ private static final String KEY_TYPED_TIMES = "typed_times";
+ private static final String KEY_DARK_THEME = "dark_theme";
+
+ public static final int HOUR_INDEX = 0;
+ public static final int MINUTE_INDEX = 1;
+ // NOT a real index for the purpose of what's showing.
+ public static final int AMPM_INDEX = 2;
+ // Also NOT a real index, just used for keyboard mode.
+ public static final int ENABLE_PICKER_INDEX = 3;
+ public static final int AM = 0;
+ public static final int PM = 1;
+
+ // Delay before starting the pulse animation, in ms.
+ private static final int PULSE_ANIMATOR_DELAY = 300;
+
+ private OnTimeSetListener mCallback;
+
+ private HapticFeedbackController mHapticFeedbackController;
+
+ private TextView mDoneButton;
+ private TextView mClearButton;
+ private TextView mHourView;
+ private TextView mHourSpaceView;
+ private TextView mMinuteView;
+ private TextView mMinuteSpaceView;
+ private TextView mAmPmTextView;
+ private View mAmPmHitspace;
+ private RadialPickerLayout mTimePicker;
+
+ private int mSelectedColor;
+ private int mUnselectedColor;
+ private String mAmText;
+ private String mPmText;
+
+ private boolean mAllowAutoAdvance;
+ private int mInitialHourOfDay;
+ private int mInitialMinute;
+ private boolean mIs24HourMode;
+ private boolean mThemeDark;
+
+ // For hardware IME input.
+ private char mPlaceholderText;
+ private String mDoublePlaceholderText;
+ private String mDeletedKeyFormat;
+ private boolean mInKbMode;
+ private ArrayList mTypedTimes;
+ private Node mLegalTimesTree;
+ private int mAmKeyCode;
+ private int mPmKeyCode;
+
+ // Accessibility strings.
+ private String mHourPickerDescription;
+ private String mSelectHours;
+ private String mMinutePickerDescription;
+ private String mSelectMinutes;
+
+ /**
+ * 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.
+ */
+ void onTimeSet(RadialPickerLayout view, int hourOfDay, int minute);
+
+ void onTimeCleared(RadialPickerLayout view);
+ }
+
+ public TimePickerDialog() {
+ // Empty constructor required for dialog fragment.
+ }
+
+ public TimePickerDialog(Context context, int theme, OnTimeSetListener callback,
+ int hourOfDay, int minute, boolean is24HourMode) {
+ // Empty constructor required for dialog fragment.
+ }
+
+ public static TimePickerDialog newInstance(OnTimeSetListener callback,
+ int hourOfDay, int minute, boolean is24HourMode) {
+ TimePickerDialog ret = new TimePickerDialog();
+ ret.initialize(callback, hourOfDay, minute, is24HourMode);
+ return ret;
+ }
+
+ public void initialize(OnTimeSetListener callback,
+ int hourOfDay, int minute, boolean is24HourMode) {
+ mCallback = callback;
+
+ mInitialHourOfDay = hourOfDay;
+ mInitialMinute = minute;
+ mIs24HourMode = is24HourMode;
+ mInKbMode = false;
+ mThemeDark = false;
+ }
+
+ /**
+ * Set a dark or light theme. NOTE: this will only take effect for the next onCreateView.
+ */
+ public void setThemeDark(boolean dark) {
+ mThemeDark = dark;
+ }
+
+ public boolean isThemeDark() {
+ return mThemeDark;
+ }
+
+ public void setOnTimeSetListener(OnTimeSetListener callback) {
+ mCallback = callback;
+ }
+
+ public void setStartTime(int hourOfDay, int minute) {
+ mInitialHourOfDay = hourOfDay;
+ mInitialMinute = minute;
+ mInKbMode = false;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (savedInstanceState != null && savedInstanceState.containsKey(KEY_HOUR_OF_DAY)
+ && savedInstanceState.containsKey(KEY_MINUTE)
+ && savedInstanceState.containsKey(KEY_IS_24_HOUR_VIEW)) {
+ mInitialHourOfDay = savedInstanceState.getInt(KEY_HOUR_OF_DAY);
+ mInitialMinute = savedInstanceState.getInt(KEY_MINUTE);
+ mIs24HourMode = savedInstanceState.getBoolean(KEY_IS_24_HOUR_VIEW);
+ mInKbMode = savedInstanceState.getBoolean(KEY_IN_KB_MODE);
+ mThemeDark = savedInstanceState.getBoolean(KEY_DARK_THEME);
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ getDialog().getWindow().requestFeature(Window.FEATURE_NO_TITLE);
+
+ View view = inflater.inflate(R.layout.time_picker_dialog, null);
+ KeyboardListener keyboardListener = new KeyboardListener();
+ view.findViewById(R.id.time_picker_dialog).setOnKeyListener(keyboardListener);
+
+ Resources res = getResources();
+ mHourPickerDescription = res.getString(R.string.hour_picker_description);
+ mSelectHours = res.getString(R.string.select_hours);
+ mMinutePickerDescription = res.getString(R.string.minute_picker_description);
+ mSelectMinutes = res.getString(R.string.select_minutes);
+ mSelectedColor = res.getColor(mThemeDark? R.color.red : R.color.blue);
+ mUnselectedColor = res.getColor(mThemeDark? R.color.white : R.color.numbers_text_color);
+
+ mHourView = (TextView) view.findViewById(R.id.hours);
+ mHourView.setOnKeyListener(keyboardListener);
+ mHourSpaceView = (TextView) view.findViewById(R.id.hour_space);
+ mMinuteSpaceView = (TextView) view.findViewById(R.id.minutes_space);
+ mMinuteView = (TextView) view.findViewById(R.id.minutes);
+ mMinuteView.setOnKeyListener(keyboardListener);
+ mAmPmTextView = (TextView) view.findViewById(R.id.ampm_label);
+ mAmPmTextView.setOnKeyListener(keyboardListener);
+ String[] amPmTexts = new DateFormatSymbols().getAmPmStrings();
+ mAmText = amPmTexts[0];
+ mPmText = amPmTexts[1];
+
+ mHapticFeedbackController = new HapticFeedbackController(getActivity());
+
+ mTimePicker = (RadialPickerLayout) view.findViewById(R.id.time_picker);
+ mTimePicker.setOnValueSelectedListener(this);
+ mTimePicker.setOnKeyListener(keyboardListener);
+ mTimePicker.initialize(getActivity(), mHapticFeedbackController, mInitialHourOfDay,
+ mInitialMinute, 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(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ setCurrentItemShowing(HOUR_INDEX, true, false, true);
+ tryVibrate();
+ }
+ });
+ mMinuteView.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ setCurrentItemShowing(MINUTE_INDEX, true, false, true);
+ tryVibrate();
+ }
+ });
+
+ mDoneButton = (TextView) view.findViewById(R.id.done_button);
+ mDoneButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (mInKbMode && isTypedTimeFullyLegal()) {
+ finishKbMode(false);
+ } else {
+ tryVibrate();
+ }
+ if (mCallback != null) {
+ mCallback.onTimeSet(mTimePicker,
+ mTimePicker.getHours(), mTimePicker.getMinutes());
+ }
+ dismiss();
+ }
+ });
+ mDoneButton.setOnKeyListener(keyboardListener);
+
+ mClearButton = (TextView) view.findViewById(R.id.clear_button);
+ mClearButton.setOnClickListener(new OnClickListener()
+ {
+ @Override
+ public void onClick(View v)
+ {
+ if(mCallback != null) {
+ mCallback.onTimeCleared(mTimePicker);
+ }
+ dismiss();
+ }
+ });
+ mClearButton.setOnKeyListener(keyboardListener);
+
+ // Enable or disable the AM/PM view.
+ mAmPmHitspace = view.findViewById(R.id.ampm_hitspace);
+ if (mIs24HourMode) {
+ mAmPmTextView.setVisibility(View.GONE);
+
+ RelativeLayout.LayoutParams paramsSeparator = new RelativeLayout.LayoutParams(
+ LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
+ paramsSeparator.addRule(RelativeLayout.CENTER_IN_PARENT);
+ TextView separatorView = (TextView) view.findViewById(R.id.separator);
+ separatorView.setLayoutParams(paramsSeparator);
+ } else {
+ mAmPmTextView.setVisibility(View.VISIBLE);
+ updateAmPmDisplay(mInitialHourOfDay < 12? AM : PM);
+ mAmPmHitspace.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ tryVibrate();
+ int amOrPm = mTimePicker.getIsCurrentlyAmOrPm();
+ if (amOrPm == AM) {
+ amOrPm = PM;
+ } else if (amOrPm == PM){
+ amOrPm = AM;
+ }
+ updateAmPmDisplay(amOrPm);
+ mTimePicker.setAmOrPm(amOrPm);
+ }
+ });
+ }
+
+ mAllowAutoAdvance = true;
+ setHour(mInitialHourOfDay, true);
+ setMinute(mInitialMinute);
+
+ // Set up for keyboard mode.
+ mDoublePlaceholderText = res.getString(R.string.time_placeholder);
+ mDeletedKeyFormat = res.getString(R.string.deleted_key);
+ mPlaceholderText = mDoublePlaceholderText.charAt(0);
+ mAmKeyCode = mPmKeyCode = -1;
+ generateLegalTimesTree();
+ if (mInKbMode) {
+ mTypedTimes = savedInstanceState.getIntegerArrayList(KEY_TYPED_TIMES);
+ tryStartingKbMode(-1);
+ mHourView.invalidate();
+ } else if (mTypedTimes == null) {
+ mTypedTimes = new ArrayList();
+ }
+
+ // Set the theme at the end so that the initialize()s above don't counteract the theme.
+ mTimePicker.setTheme(getActivity().getApplicationContext(), mThemeDark);
+ // Prepare some colors to use.
+ int white = res.getColor(R.color.white);
+ int circleBackground = res.getColor(R.color.circle_background);
+ int line = res.getColor(R.color.line_background);
+ int timeDisplay = res.getColor(R.color.numbers_text_color);
+ ColorStateList doneTextColor = res.getColorStateList(R.color.done_text_color);
+ int doneBackground = R.drawable.done_background_color;
+
+ int darkGray = res.getColor(R.color.dark_gray);
+ int lightGray = res.getColor(R.color.light_gray);
+ int darkLine = res.getColor(R.color.line_dark);
+ ColorStateList darkDoneTextColor = res.getColorStateList(R.color.done_text_color_dark);
+ int darkDoneBackground = R.drawable.done_background_color_dark;
+
+ // Set the colors for each view based on the theme.
+ view.findViewById(R.id.time_display_background).setBackgroundColor(mThemeDark? darkGray : white);
+ view.findViewById(R.id.time_display).setBackgroundColor(mThemeDark? darkGray : white);
+ ((TextView) view.findViewById(R.id.separator)).setTextColor(mThemeDark? white : timeDisplay);
+ ((TextView) view.findViewById(R.id.ampm_label)).setTextColor(mThemeDark? white : timeDisplay);
+ view.findViewById(R.id.line).setBackgroundColor(mThemeDark? darkLine : line);
+ mDoneButton.setTextColor(mThemeDark? darkDoneTextColor : doneTextColor);
+ mTimePicker.setBackgroundColor(mThemeDark? lightGray : circleBackground);
+ mDoneButton.setBackgroundResource(mThemeDark? darkDoneBackground : doneBackground);
+ return view;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ mHapticFeedbackController.start();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ mHapticFeedbackController.stop();
+ }
+
+ public void tryVibrate() {
+ mHapticFeedbackController.tryVibrate();
+ }
+
+ private void updateAmPmDisplay(int amOrPm) {
+ if (amOrPm == AM) {
+ mAmPmTextView.setText(mAmText);
+ Utils.tryAccessibilityAnnounce(mTimePicker, mAmText);
+ mAmPmHitspace.setContentDescription(mAmText);
+ } else if (amOrPm == PM){
+ mAmPmTextView.setText(mPmText);
+ Utils.tryAccessibilityAnnounce(mTimePicker, mPmText);
+ mAmPmHitspace.setContentDescription(mPmText);
+ } else {
+ mAmPmTextView.setText(mDoublePlaceholderText);
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ if (mTimePicker != null) {
+ outState.putInt(KEY_HOUR_OF_DAY, mTimePicker.getHours());
+ outState.putInt(KEY_MINUTE, mTimePicker.getMinutes());
+ 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.putBoolean(KEY_DARK_THEME, mThemeDark);
+ }
+ }
+
+ /**
+ * Called by the picker for updating the header display.
+ */
+ @Override
+ public void onValueSelected(int pickerIndex, int newValue, boolean autoAdvance) {
+ if (pickerIndex == HOUR_INDEX) {
+ setHour(newValue, false);
+ String announcement = String.format("%d", newValue);
+ if (mAllowAutoAdvance && autoAdvance) {
+ setCurrentItemShowing(MINUTE_INDEX, true, true, false);
+ announcement += ". " + mSelectMinutes;
+ } else {
+ mTimePicker.setContentDescription(mHourPickerDescription + ": " + newValue);
+ }
+
+ Utils.tryAccessibilityAnnounce(mTimePicker, announcement);
+ } else if (pickerIndex == MINUTE_INDEX){
+ setMinute(newValue);
+ mTimePicker.setContentDescription(mMinutePickerDescription + ": " + newValue);
+ } else if (pickerIndex == AMPM_INDEX) {
+ updateAmPmDisplay(newValue);
+ } else if (pickerIndex == ENABLE_PICKER_INDEX) {
+ if (!isTypedTimeFullyLegal()) {
+ mTypedTimes.clear();
+ }
+ finishKbMode(true);
+ }
+ }
+
+ 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(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(Locale.getDefault(), "%02d", value);
+ Utils.tryAccessibilityAnnounce(mTimePicker, text);
+ mMinuteView.setText(text);
+ mMinuteSpaceView.setText(text);
+ }
+
+ // Show either Hours or Minutes.
+ private void setCurrentItemShowing(int index, boolean animateCircle, boolean delayLabelAnimate,
+ boolean announce) {
+ mTimePicker.setCurrentItemShowing(index, animateCircle);
+
+ TextView labelToAnimate;
+ if (index == HOUR_INDEX) {
+ int hours = mTimePicker.getHours();
+ if (!mIs24HourMode) {
+ hours = hours % 12;
+ }
+ mTimePicker.setContentDescription(mHourPickerDescription + ": " + hours);
+ if (announce) {
+ Utils.tryAccessibilityAnnounce(mTimePicker, mSelectHours);
+ }
+ labelToAnimate = mHourView;
+ } else {
+ int minutes = mTimePicker.getMinutes();
+ mTimePicker.setContentDescription(mMinutePickerDescription + ": " + minutes);
+ if (announce) {
+ Utils.tryAccessibilityAnnounce(mTimePicker, mSelectMinutes);
+ }
+ labelToAnimate = mMinuteView;
+ }
+
+ int hourColor = (index == HOUR_INDEX)? mSelectedColor : mUnselectedColor;
+ int minuteColor = (index == MINUTE_INDEX)? mSelectedColor : mUnselectedColor;
+ mHourView.setTextColor(hourColor);
+ mMinuteView.setTextColor(minuteColor);
+
+ 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_ESCAPE || keyCode == KeyEvent.KEYCODE_BACK) {
+ dismiss();
+ return true;
+ } else 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(mTimePicker,
+ mTimePicker.getHours(), mTimePicker.getMinutes());
+ }
+ 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("%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;
+ mDoneButton.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.
+ if ((mIs24HourMode && mTypedTimes.size() == 4) ||
+ (!mIs24HourMode && isTypedTimeFullyLegal())) {
+ return false;
+ }
+
+ mTypedTimes.add(keyCode);
+ if (!isTypedTimeLegalSoFar()) {
+ deleteLastTypedKey();
+ return false;
+ }
+
+ int val = getValFromKeyCode(keyCode);
+ Utils.tryAccessibilityAnnounce(mTimePicker, String.format("%d", val));
+ // Automatically fill in 0's if AM or PM was legally entered.
+ if (isTypedTimeFullyLegal()) {
+ if (!mIs24HourMode && mTypedTimes.size() <= 3) {
+ mTypedTimes.add(mTypedTimes.size() - 1, KeyEvent.KEYCODE_0);
+ mTypedTimes.add(mTypedTimes.size() - 1, KeyEvent.KEYCODE_0);
+ }
+ mDoneButton.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.
+ int[] values = getEnteredTime(null);
+ return (values[0] >= 0 && values[1] >= 0 && values[1] < 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()) {
+ mDoneButton.setEnabled(false);
+ }
+ return deleted;
+ }
+
+ /**
+ * Get out of keyboard mode. If there is nothing in typedTimes, revert to TimePicker's time.
+ * @param changeDisplays If true, update the displays with the relevant time.
+ */
+ private void finishKbMode(boolean updateDisplays) {
+ mInKbMode = false;
+ if (!mTypedTimes.isEmpty()) {
+ int values[] = getEnteredTime(null);
+ mTimePicker.setTime(values[0], values[1]);
+ if (!mIs24HourMode) {
+ mTimePicker.setAmOrPm(values[2]);
+ }
+ mTypedTimes.clear();
+ }
+ if (updateDisplays) {
+ updateDisplay(false);
+ mTimePicker.trySettingInputEnabled(true);
+ }
+ }
+
+ /**
+ * Update the hours, minutes, 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();
+ setHour(hour, true);
+ setMinute(minute);
+ if (!mIs24HourMode) {
+ updateAmPmDisplay(hour < 12? AM : PM);
+ }
+ setCurrentItemShowing(mTimePicker.getCurrentItemShowing(), true, true, true);
+ mDoneButton.setEnabled(true);
+ } else {
+ Boolean[] enteredZeros = {false, false};
+ int[] values = getEnteredTime(enteredZeros);
+ String hourFormat = enteredZeros[0]? "%02d" : "%2d";
+ String minuteFormat = (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);
+ mHourView.setText(hourStr);
+ mHourSpaceView.setText(hourStr);
+ mHourView.setTextColor(mUnselectedColor);
+ mMinuteView.setText(minuteStr);
+ mMinuteSpaceView.setText(minuteStr);
+ mMinuteView.setTextColor(mUnselectedColor);
+ if (!mIs24HourMode) {
+ updateAmPmDisplay(values[2]);
+ }
+ }
+ }
+
+ 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 and minutes 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.
+ */
+ private int[] getEnteredTime(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;
+ for (int i = startIndex; i <= mTypedTimes.size(); i++) {
+ int val = getValFromKeyCode(mTypedTimes.get(mTypedTimes.size() - i));
+ if (i == startIndex) {
+ minute = val;
+ } else if (i == startIndex+1) {
+ minute += 10*val;
+ if (enteredZeros != null && val == 0) {
+ enteredZeros[1] = true;
+ }
+ } else if (i == startIndex+2) {
+ hour = val;
+ } else if (i == startIndex+3) {
+ hour += 10*val;
+ if (enteredZeros != null && val == 0) {
+ enteredZeros[0] = true;
+ }
+ }
+ }
+
+ int[] ret = {hour, minute, amOrPm};
+ return ret;
+ }
+
+ /**
+ * 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(Locale.getDefault()).charAt(i);
+ pmChar = mPmText.toLowerCase(Locale.getDefault()).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();
+ 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);
+
+ // 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));
+
+ // 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. E.g. 10:49am, 12:40pm.
+ fourthDigit.addChild(ampm);
+
+ // 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, 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. E.g. 1:39am, 1:50pm.
+ thirdDigit.addChild(ampm);
+
+ // 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);
+ }
+ }
+
+ /**
+ * 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 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 i = 0; i < mLegalKeys.length; i++) {
+ if (mLegalKeys[i] == 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;
+ }
+ }
+}
diff --git a/src/org/isoron/uhabits/AlarmReceiver.java b/src/org/isoron/uhabits/AlarmReceiver.java
new file mode 100644
index 000000000..0c3c9ff02
--- /dev/null
+++ b/src/org/isoron/uhabits/AlarmReceiver.java
@@ -0,0 +1,49 @@
+package org.isoron.uhabits;
+
+import android.app.Activity;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.media.RingtoneManager;
+import android.net.Uri;
+import android.support.v4.app.NotificationCompat;
+
+public class AlarmReceiver extends BroadcastReceiver
+{
+ static int k = 1;
+
+ @Override
+ public void onReceive(Context context, Intent intent)
+ {
+ createNotification(context, intent.getData(), intent.getDataString());
+ }
+
+
+ private void createNotification(Context context, Uri data, String text)
+ {
+ Intent resultIntent = new Intent(context, MainActivity.class);
+ resultIntent.setData(data);
+
+ PendingIntent notificationIntent = PendingIntent.getActivity(context, 0, resultIntent, 0);
+
+ Uri soundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION);
+
+ Notification notification =
+ new NotificationCompat.Builder(context)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle("Habit Reminder")
+ .setContentText(text)
+ .setContentIntent(notificationIntent)
+ .setSound(soundUri)
+ .build();
+
+ notification.flags = Notification.FLAG_AUTO_CANCEL;
+
+ NotificationManager notificationManager = (NotificationManager) context.getSystemService(Activity.NOTIFICATION_SERVICE);
+ notificationManager.notify(k++, notification);
+ }
+
+}
diff --git a/src/org/isoron/uhabits/MainActivity.java b/src/org/isoron/uhabits/MainActivity.java
index e9478843e..76832e360 100644
--- a/src/org/isoron/uhabits/MainActivity.java
+++ b/src/org/isoron/uhabits/MainActivity.java
@@ -4,10 +4,19 @@ import java.util.LinkedList;
import org.isoron.helpers.Command;
import org.isoron.uhabits.dialogs.ShowHabitsFragment;
-import org.isoron.uhabits.models.Habit;
import android.app.Activity;
+import android.app.AlarmManager;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.media.RingtoneManager;
+import android.net.Uri;
import android.os.Bundle;
+import android.os.SystemClock;
+import android.support.v4.app.NotificationCompat;
import android.view.Menu;
import android.view.MenuItem;
import android.widget.Toast;
@@ -38,6 +47,24 @@ public class MainActivity extends Activity
undoList = new LinkedList();
redoList = new LinkedList();
+
+// startAlarm("http://hello-world.com/", 5);
+// startAlarm("http://ola-mundo.com.br/", 10);
+ }
+
+ private void startAlarm(String data, int interval)
+ {
+ Intent alarmIntent = new Intent(MainActivity.this, AlarmReceiver.class);
+ alarmIntent.setData(Uri.parse(data));
+
+ PendingIntent pendingIntent = PendingIntent.getBroadcast(MainActivity.this, 0, alarmIntent, 0);
+
+ AlarmManager manager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
+ manager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP,
+ SystemClock.elapsedRealtime() +
+ interval * 1000, pendingIntent);
+
+ Toast.makeText(this, "Alarm Set", Toast.LENGTH_SHORT).show();
}
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
diff --git a/src/org/isoron/uhabits/dialogs/EditHabitFragment.java b/src/org/isoron/uhabits/dialogs/EditHabitFragment.java
index 44d319a83..4ced5eee2 100644
--- a/src/org/isoron/uhabits/dialogs/EditHabitFragment.java
+++ b/src/org/isoron/uhabits/dialogs/EditHabitFragment.java
@@ -1,5 +1,9 @@
package org.isoron.uhabits.dialogs;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+
import org.isoron.helpers.Command;
import org.isoron.helpers.DialogHelper.OnSavedListener;
import org.isoron.uhabits.R;
@@ -21,6 +25,9 @@ import android.widget.TextView;
import com.android.colorpicker.ColorPickerDialog;
import com.android.colorpicker.ColorPickerSwatch;
+import com.android.datetimepicker.time.RadialPickerLayout;
+import com.android.datetimepicker.time.TimePickerDialog;
+import com.android.datetimepicker.time.TimePickerDialog.OnTimeSetListener;
public class EditHabitFragment extends DialogFragment implements OnClickListener
{
@@ -31,7 +38,7 @@ public class EditHabitFragment extends DialogFragment implements OnClickListener
private OnSavedListener onSavedListener;
private Habit originalHabit, modifiedHabit;
- private TextView tvName, tvDescription, tvFreqNum, tvFreqDen;
+ private TextView tvName, tvDescription, tvFreqNum, tvFreqDen, tvInputReminder;
static class SolidColorMatrix extends ColorMatrix
{
@@ -80,12 +87,14 @@ public class EditHabitFragment extends DialogFragment implements OnClickListener
tvDescription = (TextView) view.findViewById(R.id.input_description);
tvFreqNum = (TextView) view.findViewById(R.id.input_freq_num);
tvFreqDen = (TextView) view.findViewById(R.id.input_freq_den);
+ tvInputReminder = (TextView) view.findViewById(R.id.input_reminder_time);
Button buttonSave = (Button) view.findViewById(R.id.button_save);
Button buttonDiscard = (Button) view.findViewById(R.id.button_discard);
buttonSave.setOnClickListener(this);
buttonDiscard.setOnClickListener(this);
+ tvInputReminder.setOnClickListener(this);
ImageButton buttonPickColor = (ImageButton) view.findViewById(R.id.button_pick_color);
@@ -112,6 +121,7 @@ public class EditHabitFragment extends DialogFragment implements OnClickListener
}
changeColor(modifiedHabit.color);
+ updateReminder();
buttonPickColor.setOnClickListener(new OnClickListener()
{
@@ -146,6 +156,21 @@ public class EditHabitFragment extends DialogFragment implements OnClickListener
tvName.setBackgroundDrawable(background);
tvName.setTextColor(color);
}
+
+ private void updateReminder()
+ {
+ if(modifiedHabit.reminder_hour != null)
+ {
+ tvInputReminder.setTextColor(Color.BLACK);
+ tvInputReminder.setText(String.format("%02d:%02d", modifiedHabit.reminder_hour,
+ modifiedHabit.reminder_min));
+ }
+ else
+ {
+ tvInputReminder.setTextColor(Color.GRAY);
+ tvInputReminder.setText("Off");
+ }
+ }
public void setOnSavedListener(OnSavedListener onSavedListener)
{
@@ -160,6 +185,31 @@ public class EditHabitFragment extends DialogFragment implements OnClickListener
public void onClick(View v)
{
int id = v.getId();
+
+ /* Due date spinner */
+ if(id == R.id.input_reminder_time)
+ {
+ TimePickerDialog timePicker = TimePickerDialog.newInstance(new OnTimeSetListener()
+ {
+
+ @Override
+ public void onTimeSet(RadialPickerLayout view, int hour, int minute)
+ {
+ modifiedHabit.reminder_hour = hour;
+ modifiedHabit.reminder_min = minute;
+ updateReminder();
+ }
+
+ @Override
+ public void onTimeCleared(RadialPickerLayout view)
+ {
+ modifiedHabit.reminder_hour = null;
+ modifiedHabit.reminder_min = null;
+ updateReminder();
+ }
+ }, 8, 0, true);
+ timePicker.show(getFragmentManager(), "timePicker");
+ }
/* Save button */
if(id == R.id.button_save)
diff --git a/src/org/isoron/uhabits/models/Habit.java b/src/org/isoron/uhabits/models/Habit.java
index 62c3a9ee9..35c33417e 100644
--- a/src/org/isoron/uhabits/models/Habit.java
+++ b/src/org/isoron/uhabits/models/Habit.java
@@ -53,6 +53,12 @@ public class Habit extends Model
@Column(name = "position")
public Integer position;
+
+ @Column(name = "reminder_hour")
+ public Integer reminder_hour;
+
+ @Column(name = "reminder_min")
+ public Integer reminder_min;
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
* Commands *
@@ -188,6 +194,8 @@ public class Habit extends Model
this.freq_den = model.freq_den;
this.color = model.color;
this.position = model.position;
+ this.reminder_hour = model.reminder_hour;
+ this.reminder_min = model.reminder_min;
}
public Habit()