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 */ 16 package com.android.deskclock; 17 18 import android.content.ContentResolver; 19 import android.content.Context; 20 import android.content.res.Resources; 21 import android.database.ContentObserver; 22 import android.graphics.Canvas; 23 import android.graphics.Color; 24 import android.graphics.Paint; 25 import android.graphics.Paint.Align; 26 import android.net.Uri; 27 import android.os.AsyncTask; 28 import android.os.Handler; 29 import android.text.format.DateFormat; 30 import android.text.format.DateUtils; 31 import android.util.AttributeSet; 32 import android.view.View; 33 34 import com.android.deskclock.provider.Alarm; 35 36 import java.text.SimpleDateFormat; 37 import java.util.Calendar; 38 import java.util.Date; 39 import java.util.HashSet; 40 import java.util.Iterator; 41 import java.util.List; 42 import java.util.Locale; 43 import 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 */ 50 public 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