1/*
2 * Copyright (C) 2013 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License
15 */
16package com.android.deskclock;
17
18import android.content.ContentResolver;
19import android.content.Context;
20import android.content.res.Resources;
21import android.database.ContentObserver;
22import android.graphics.Canvas;
23import android.graphics.Color;
24import android.graphics.Paint;
25import android.graphics.Paint.Align;
26import android.net.Uri;
27import android.os.AsyncTask;
28import android.os.Handler;
29import android.text.format.DateFormat;
30import android.text.format.DateUtils;
31import android.util.AttributeSet;
32import android.view.View;
33
34import com.android.deskclock.provider.Alarm;
35
36import java.text.SimpleDateFormat;
37import java.util.Calendar;
38import java.util.Date;
39import java.util.HashSet;
40import java.util.Iterator;
41import java.util.List;
42import java.util.Locale;
43import java.util.TreeMap;
44
45/**
46 * Renders a tree-like view of the next alarm times over the period of a week.
47 * The timeline begins at the time of the next alarm, and ends a week after that time.
48 * The view is currently only shown in the landscape mode of tablets.
49 */
50public class AlarmTimelineView extends View {
51
52    private static final String TAG = "AlarmTimelineView";
53
54    private static final String FORMAT_12_HOUR = "E h mm a";
55    private static final String FORMAT_24_HOUR = "E H mm";
56
57    private static final int DAYS_IN_WEEK = 7;
58
59    private int mAlarmTimelineColor;
60    private int mAlarmTimelineLength;
61    private int mAlarmTimelineMarginTop;
62    private int mAlarmTimelineMarginBottom;
63    private int mAlarmNodeRadius;
64    private int mAlarmNodeInnerRadius;
65    private int mAlarmNodeInnerRadiusColor;
66    private int mAlarmTextPadding;
67    private int mAlarmTextSize;
68    private int mAlarmMinDistance;
69
70    private Paint mPaint;
71    private ContentResolver mResolver;
72    private SimpleDateFormat mDateFormat;
73    private TreeMap<Date, AlarmTimeNode> mAlarmTimes = new TreeMap<Date, AlarmTimeNode>();
74    private Calendar mCalendar;
75    private AlarmObserver mAlarmObserver = new AlarmObserver(getHandler());
76    private GetAlarmsTask mAlarmsTask = new GetAlarmsTask();
77    private String mNoAlarmsScheduled;
78    private boolean mIsAnimatingOut;
79
80    /**
81     * Observer for any changes to the alarms in the content provider.
82     */
83    private class AlarmObserver extends ContentObserver {
84
85        public AlarmObserver(Handler handler) {
86            super(handler);
87        }
88
89        @Override
90        public void onChange(boolean changed) {
91            if (mAlarmsTask != null) {
92                mAlarmsTask.cancel(true);
93            }
94            mAlarmsTask = new GetAlarmsTask();
95            mAlarmsTask.execute();
96        }
97
98        @Override
99        public void onChange(boolean changed, Uri uri) {
100            onChange(changed);
101        }
102    }
103
104    /**
105     * The data model for one node on the timeline.
106     */
107    private class AlarmTimeNode {
108        public Date date;
109        public boolean isRepeating;
110
111        public AlarmTimeNode(Date date, boolean isRepeating) {
112            this.date = date;
113            this.isRepeating = isRepeating;
114        }
115    }
116
117    /**
118     * Retrieves alarms from the content provider and generates an alarm node tree sorted by date.
119     */
120    private class GetAlarmsTask extends AsyncTask<Void, Void, Void> {
121
122        @Override
123        protected synchronized Void doInBackground(Void... params) {
124            List<Alarm> enabledAlarmList = Alarm.getAlarms(mResolver, Alarm.ENABLED + "=1");
125            final Date currentTime = mCalendar.getTime();
126            mAlarmTimes.clear();
127            for (Alarm alarm : enabledAlarmList) {
128                int hour = alarm.hour;
129                int minutes = alarm.minutes;
130                HashSet<Integer> repeatingDays = alarm.daysOfWeek.getSetDays();
131
132                // If the alarm is not repeating,
133                if (repeatingDays.isEmpty()) {
134                    mCalendar.add(Calendar.DATE, getDaysFromNow(hour, minutes));
135                    mCalendar.set(Calendar.HOUR_OF_DAY, alarm.hour);
136                    mCalendar.set(Calendar.MINUTE, alarm.minutes);
137                    Date date = mCalendar.getTime();
138
139                    if (!mAlarmTimes.containsKey(date)) {
140                        // Add alarm if there is no other alarm with this date.
141                        mAlarmTimes.put(date, new AlarmTimeNode(date, false));
142                    }
143                    mCalendar.setTime(currentTime);
144                    continue;
145                }
146
147                // If the alarm is repeating, iterate through each alarm date.
148                for (int day : alarm.daysOfWeek.getSetDays()) {
149                    mCalendar.add(Calendar.DATE, getDaysFromNow(day, hour, minutes));
150                    mCalendar.set(Calendar.HOUR_OF_DAY, alarm.hour);
151                    mCalendar.set(Calendar.MINUTE, alarm.minutes);
152                    Date date = mCalendar.getTime();
153
154                    if (!mAlarmTimes.containsKey(date)) {
155                        // Add alarm if there is no other alarm with this date.
156                        mAlarmTimes.put(date, new AlarmTimeNode(mCalendar.getTime(), true));
157                    } else {
158                        // If there is another alarm with this date, make it
159                        // repeating.
160                        mAlarmTimes.get(date).isRepeating = true;
161                    }
162                    mCalendar.setTime(currentTime);
163                }
164            }
165            return null;
166        }
167
168        @Override
169        protected void onPostExecute(Void result) {
170            requestLayout();
171            AlarmTimelineView.this.invalidate();
172        }
173
174        // Returns whether this non-repeating alarm is firing today or tomorrow.
175        private int getDaysFromNow(int hour, int minutes) {
176            final int currentHour = mCalendar.get(Calendar.HOUR_OF_DAY);
177            if (hour > currentHour ||
178                    (hour == currentHour && minutes >= mCalendar.get(Calendar.MINUTE)) ) {
179                return 0;
180            }
181            return 1;
182        }
183
184        // Returns the days from now of the next instance of this alarm, given the repeated day.
185        private int getDaysFromNow(int day, int hour, int minute) {
186            final int currentDay = mCalendar.get(Calendar.DAY_OF_WEEK);
187            if (day != currentDay) {
188                if (day < currentDay) {
189                    day += DAYS_IN_WEEK;
190                }
191                return day - currentDay;
192            }
193
194            final int currentHour = mCalendar.get(Calendar.HOUR_OF_DAY);
195            if (hour != currentHour) {
196                return (hour < currentHour) ? DAYS_IN_WEEK : 0;
197            }
198
199            final int currentMinute = mCalendar.get(Calendar.MINUTE);
200            return (minute < currentMinute) ? DAYS_IN_WEEK : 0;
201        }
202    }
203
204    public AlarmTimelineView(Context context) {
205        super(context);
206        init(context);
207    }
208
209    public AlarmTimelineView(Context context, AttributeSet attrs) {
210        super(context, attrs);
211        init(context);
212    }
213
214    private void init(Context context) {
215        mResolver = context.getContentResolver();
216
217        final Resources res = context.getResources();
218
219        mAlarmTimelineColor = res.getColor(R.color.alarm_timeline_color);
220        mAlarmTimelineLength = res.getDimensionPixelOffset(R.dimen.alarm_timeline_length);
221        mAlarmTimelineMarginTop = res.getDimensionPixelOffset(R.dimen.alarm_timeline_margin_top);
222        mAlarmTimelineMarginBottom = res.getDimensionPixelOffset(R.dimen.footer_button_size) +
223                2 * res.getDimensionPixelOffset(R.dimen.footer_button_layout_margin);
224        mAlarmNodeRadius = res.getDimensionPixelOffset(R.dimen.alarm_timeline_radius);
225        mAlarmNodeInnerRadius = res.getDimensionPixelOffset(R.dimen.alarm_timeline_inner_radius);
226        mAlarmNodeInnerRadiusColor = res.getColor(R.color.blackish);
227        mAlarmTextSize = res.getDimensionPixelOffset(R.dimen.alarm_text_font_size);
228        mAlarmTextPadding = res.getDimensionPixelOffset(R.dimen.alarm_text_padding);
229        mAlarmMinDistance = res.getDimensionPixelOffset(R.dimen.alarm_min_distance) +
230                2 * mAlarmNodeRadius;
231        mNoAlarmsScheduled = context.getString(R.string.no_upcoming_alarms);
232
233        mPaint = new Paint();
234        mPaint.setTextSize(mAlarmTextSize);
235        mPaint.setStrokeWidth(res.getDimensionPixelOffset(R.dimen.alarm_timeline_width));
236        mPaint.setAntiAlias(true);
237
238        mCalendar = Calendar.getInstance();
239        final Locale locale = Locale.getDefault();
240        String formatString = DateFormat.is24HourFormat(context) ? FORMAT_24_HOUR : FORMAT_12_HOUR;
241        String format = DateFormat.getBestDateTimePattern(locale, formatString);
242        mDateFormat = new SimpleDateFormat(format, locale);
243
244        mAlarmsTask.execute();
245    }
246
247    @Override
248    public void onAttachedToWindow() {
249        super.onAttachedToWindow();
250        mResolver.registerContentObserver(Alarm.CONTENT_URI, true, mAlarmObserver);
251    }
252
253    @Override
254    public void onDetachedFromWindow() {
255        super.onDetachedFromWindow();
256        mResolver.unregisterContentObserver(mAlarmObserver);
257    }
258
259    @Override
260    public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
261        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
262        int timelineHeight = !mAlarmTimes.isEmpty() ?  mAlarmTimelineLength : 0;
263        setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec),
264                timelineHeight + mAlarmTimelineMarginTop + mAlarmTimelineMarginBottom);
265    }
266
267    @Override
268    public synchronized void onDraw(Canvas canvas) {
269
270        // If the view is in the process of animating out, do not change the text or the timeline.
271        if (mIsAnimatingOut) {
272            return;
273        }
274
275        super.onDraw(canvas);
276
277        final int x = getWidth() / 2;
278        int y = mAlarmTimelineMarginTop;
279
280        mPaint.setColor(mAlarmTimelineColor);
281
282        // If there are no alarms, draw the no alarms text.
283        if (mAlarmTimes == null || mAlarmTimes.isEmpty()) {
284            mPaint.setTextAlign(Align.CENTER);
285            canvas.drawText(mNoAlarmsScheduled, x, y, mPaint);
286            return;
287        }
288
289        // Draw the timeline.
290        canvas.drawLine(x, y, x, y + mAlarmTimelineLength, mPaint);
291
292        final int xLeft = x - mAlarmNodeRadius - mAlarmTextPadding;
293        final int xRight = x + mAlarmNodeRadius + mAlarmTextPadding;
294
295        // Iterate through each of the alarm times chronologically.
296        Iterator<AlarmTimeNode> iter = mAlarmTimes.values().iterator();
297        Date firstDate = null;
298        int prevY = 0;
299        int i=0;
300        final int maxY = mAlarmTimelineLength + mAlarmTimelineMarginTop;
301        while (iter.hasNext()) {
302            AlarmTimeNode node = iter.next();
303            Date date = node.date;
304
305            if (firstDate == null) {
306                // If this is the first alarm, set the node to the top of the timeline.
307                y = mAlarmTimelineMarginTop;
308                firstDate = date;
309            } else {
310                // If this is not the first alarm, set the distance based upon the time from the
311                // first alarm.  If a node already exists at that time, use the minimum distance
312                // required from the last drawn node.
313                y = Math.max(convertToDistance(date, firstDate), prevY + mAlarmMinDistance);
314            }
315
316            if (y > maxY) {
317                // If the y value has somehow exceeded the timeline length, draw node on end of
318                // timeline.  We should never reach this state.
319                Log.wtf("Y-value exceeded timeline length.  Should never happen.");
320                Log.wtf("alarm date=" + node.date.getTime() + ", isRepeating=" + node.isRepeating
321                        + ", y=" + y + ", maxY=" + maxY);
322                y = maxY;
323            }
324
325            // Draw the node.
326            mPaint.setColor(Color.WHITE);
327            canvas.drawCircle(x, y, mAlarmNodeRadius, mPaint);
328
329            // If the node is not repeating, draw an inner circle to make the node "open".
330            if (!node.isRepeating) {
331                mPaint.setColor(mAlarmNodeInnerRadiusColor);
332                canvas.drawCircle(x, y, mAlarmNodeInnerRadius, mPaint);
333            }
334            prevY = y;
335
336            // Draw the alarm text.  Alternate left and right of the timeline.
337            final String timeString = mDateFormat.format(date).toUpperCase();
338            mPaint.setColor(mAlarmTimelineColor);
339            if (i % 2 == 0) {
340                mPaint.setTextAlign(Align.RIGHT);
341                canvas.drawText(timeString, xLeft, y + mAlarmTextSize / 3, mPaint);
342            } else {
343                mPaint.setTextAlign(Align.LEFT);
344                canvas.drawText(timeString, xRight, y + mAlarmTextSize / 3, mPaint);
345            }
346            i++;
347        }
348    }
349
350    // This method is necessary to ensure that the view does not re-draw while it is being
351    // animated out.  The timeline should remain on-screen as is, even though no alarms
352    // are present, as the view moves off-screen.
353    public void setIsAnimatingOut(boolean animatingOut) {
354        mIsAnimatingOut = animatingOut;
355    }
356
357    // Convert the time difference between the date and the first date to a distance along the
358    // timeline.
359    private int convertToDistance(final Date date, final Date firstDate) {
360        if (date == null || firstDate == null) {
361            return 0;
362        }
363        return (int) ((date.getTime() - firstDate.getTime())
364                * mAlarmTimelineLength / DateUtils.WEEK_IN_MILLIS + mAlarmTimelineMarginTop);
365    }
366}
367