diff options
author | The Android Open Source Project <initial-contribution@android.com> | 2009-03-03 19:32:18 -0800 |
---|---|---|
committer | The Android Open Source Project <initial-contribution@android.com> | 2009-03-03 19:32:18 -0800 |
commit | 146de36083f6ce8b7e8a1f974d3990594a36bfec (patch) | |
tree | 26291db8f35326f89276b7f51dda5b5b4e78f070 /src/com | |
parent | 2cb8df4a54d65554c34faa79d8b2a46a86ff7b52 (diff) | |
download | android_packages_apps_Calendar-146de36083f6ce8b7e8a1f974d3990594a36bfec.tar.gz android_packages_apps_Calendar-146de36083f6ce8b7e8a1f974d3990594a36bfec.tar.bz2 android_packages_apps_Calendar-146de36083f6ce8b7e8a1f974d3990594a36bfec.zip |
auto import from //depot/cupcake/@135843
Diffstat (limited to 'src/com')
32 files changed, 12506 insertions, 0 deletions
diff --git a/src/com/android/calendar/AgendaActivity.java b/src/com/android/calendar/AgendaActivity.java new file mode 100644 index 00000000..fd159e62 --- /dev/null +++ b/src/com/android/calendar/AgendaActivity.java @@ -0,0 +1,428 @@ +/* + * Copyright (C) 2007 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.calendar; + +import android.app.Activity; +import android.content.AsyncQueryHandler; +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.database.ContentObserver; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.preference.PreferenceManager; +import android.provider.Calendar; +import android.provider.Calendar.Attendees; +import android.provider.Calendar.Calendars; +import android.provider.Calendar.Events; +import android.provider.Calendar.Instances; +import android.text.format.Time; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.Window; +import android.widget.AdapterView; +import android.widget.ListView; +import android.widget.ViewSwitcher; +import dalvik.system.VMRuntime; + +public class AgendaActivity extends Activity implements ViewSwitcher.ViewFactory, Navigator { + + protected static final String BUNDLE_KEY_RESTORE_TIME = "key_restore_time"; + + static final String[] PROJECTION = new String[] { + Instances._ID, // 0 + Instances.TITLE, // 1 + Instances.EVENT_LOCATION, // 2 + Instances.ALL_DAY, // 3 + Instances.HAS_ALARM, // 4 + Instances.COLOR, // 5 + Instances.RRULE, // 6 + Instances.BEGIN, // 7 + Instances.END, // 8 + Instances.EVENT_ID, // 9 + Instances.START_DAY, // 10 Julian start day + Instances.END_DAY, // 11 Julian end day + Instances.SELF_ATTENDEE_STATUS, // 12 + }; + + public static final int INDEX_TITLE = 1; + public static final int INDEX_EVENT_LOCATION = 2; + public static final int INDEX_ALL_DAY = 3; + public static final int INDEX_HAS_ALARM = 4; + public static final int INDEX_COLOR = 5; + public static final int INDEX_RRULE = 6; + public static final int INDEX_BEGIN = 7; + public static final int INDEX_END = 8; + public static final int INDEX_EVENT_ID = 9; + public static final int INDEX_START_DAY = 10; + public static final int INDEX_END_DAY = 11; + public static final int INDEX_SELF_ATTENDEE_STATUS = 12; + + public static final String AGENDA_SORT_ORDER = "startDay ASC, begin ASC, title ASC"; + + private static final long INITIAL_HEAP_SIZE = 4*1024*1024; + + private ContentResolver mContentResolver; + + private ViewSwitcher mViewSwitcher; + + private QueryHandler mQueryHandler; + private DeleteEventHelper mDeleteEventHelper; + private Time mTime; + + /** + * This records the start time parameter for the last query sent to the + * AsyncQueryHandler so that we don't send it duplicate query requests. + */ + private Time mLastQueryTime = new Time(); + + private class QueryHandler extends AsyncQueryHandler { + public QueryHandler(ContentResolver cr) { + super(cr); + } + + @Override + protected void onQueryComplete(int token, Object cookie, Cursor cursor) { + + // Only set mCursor if the Activity is not finishing. Otherwise close the cursor. + if (!isFinishing()) { + AgendaListView next = (AgendaListView) mViewSwitcher.getNextView(); + next.setCursor(cursor); + mViewSwitcher.showNext(); + selectTime(); + } else { + cursor.close(); + } + } + } + + private class AgendaListView extends ListView { + private Cursor mCursor; + private AgendaByDayAdapter mDayAdapter; + private AgendaAdapter mAdapter; + + public AgendaListView(Context context) { + super(context, null); + setOnItemClickListener(mOnItemClickListener); + setChoiceMode(ListView.CHOICE_MODE_SINGLE); + mAdapter = new AgendaAdapter(AgendaActivity.this, R.layout.agenda_item); + mDayAdapter = new AgendaByDayAdapter(AgendaActivity.this, mAdapter); + } + + public void setCursor(Cursor cursor) { + if (mCursor != null) { + mCursor.close(); + } + mCursor = cursor; + mDayAdapter.calculateDays(cursor); + mAdapter.changeCursor(cursor); + setAdapter(mDayAdapter); + } + + public Cursor getCursor() { + return mCursor; + } + + public AgendaByDayAdapter getDayAdapter() { + return mDayAdapter; + } + + @Override protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + if (mCursor != null) { + mCursor.close(); + } + } + + private OnItemClickListener mOnItemClickListener = new OnItemClickListener() { + public void onItemClick(AdapterView a, View v, int position, long id) { + if (id != -1) { + // Switch to the EventInfo view + mCursor.moveToPosition(mDayAdapter.getCursorPosition(position)); + long eventId = mCursor.getLong(INDEX_EVENT_ID); + Uri uri = ContentUris.withAppendedId(Events.CONTENT_URI, eventId); + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + intent.putExtra(Calendar.EVENT_BEGIN_TIME, mCursor.getLong(INDEX_BEGIN)); + intent.putExtra(Calendar.EVENT_END_TIME, mCursor.getLong(INDEX_END)); + startActivity(intent); + } + } + }; + } + + private BroadcastReceiver mIntentReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (action.equals(Intent.ACTION_TIME_CHANGED) + || action.equals(Intent.ACTION_DATE_CHANGED) + || action.equals(Intent.ACTION_TIMEZONE_CHANGED)) { + clearLastQueryTime(); + renewCursor(); + } + } + }; + + private ContentObserver mObserver = new ContentObserver(new Handler()) { + @Override + public boolean deliverSelfNotifications() { + return true; + } + + @Override + public void onChange(boolean selfChange) { + clearLastQueryTime(); + renewCursor(); + } + }; + + @Override + protected void onCreate(Bundle icicle) { + super.onCreate(icicle); + + // Eliminate extra GCs during startup by setting the initial heap size to 4MB. + // TODO: We should restore the old heap size once the activity reaches the idle state + long oldHeapSize = VMRuntime.getRuntime().setMinimumHeapSize(INITIAL_HEAP_SIZE); + + getWindow().requestFeature(Window.FEATURE_NO_TITLE); + setContentView(R.layout.agenda_activity); + + mContentResolver = getContentResolver(); + mQueryHandler = new QueryHandler(mContentResolver); + + // Preserve the same month and event selection if this activity is + // being restored due to an orientation change + mTime = new Time(); + if (icicle != null) { + mTime.set(icicle.getLong(BUNDLE_KEY_RESTORE_TIME)); + } else { + mTime.set(Utils.timeFromIntent(getIntent())); + } + setTitle(Utils.formatMonthYear(mTime)); + + mViewSwitcher = (ViewSwitcher) findViewById(R.id.switcher); + mViewSwitcher.setFactory(this); + + // Record Agenda View as the (new) default detailed view. + String activityString = CalendarApplication.ACTIVITY_NAMES[CalendarApplication.AGENDA_VIEW_ID]; + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(CalendarPreferenceActivity.KEY_DETAILED_VIEW, activityString); + + // Record Agenda View as the (new) start view + editor.putString(CalendarPreferenceActivity.KEY_START_VIEW, activityString); + editor.commit(); + + mDeleteEventHelper = new DeleteEventHelper(this, false /* don't exit when done */); + } + + @Override + protected void onResume() { + super.onResume(); + + clearLastQueryTime(); + renewCursor(); + + // Register for Intent broadcasts + IntentFilter filter = new IntentFilter(); + filter.addAction(Intent.ACTION_TIME_CHANGED); + filter.addAction(Intent.ACTION_DATE_CHANGED); + filter.addAction(Intent.ACTION_TIMEZONE_CHANGED); + registerReceiver(mIntentReceiver, filter); + + mContentResolver.registerContentObserver(Events.CONTENT_URI, true, mObserver); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putLong(BUNDLE_KEY_RESTORE_TIME, getSelectedTime()); + } + + @Override + protected void onPause() { + super.onPause(); + + mContentResolver.unregisterContentObserver(mObserver); + unregisterReceiver(mIntentReceiver); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuHelper.onPrepareOptionsMenu(this, menu); + return super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuHelper.onCreateOptionsMenu(menu); + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + MenuHelper.onOptionsItemSelected(this, item, this); + return super.onOptionsItemSelected(item); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + switch (keyCode) { + case KeyEvent.KEYCODE_DEL: { + // Delete the currently selected event (if any) + AgendaListView current = (AgendaListView) mViewSwitcher.getCurrentView(); + Cursor cursor = current.getCursor(); + if (cursor != null) { + int position = current.getSelectedItemPosition(); + position = current.getDayAdapter().getCursorPosition(position); + if (position >= 0) { + cursor.moveToPosition(position); + long begin = cursor.getLong(INDEX_BEGIN); + long end = cursor.getLong(INDEX_END); + long eventId = cursor.getLong(INDEX_EVENT_ID); + mDeleteEventHelper.delete(begin, end, eventId, -1); + } + } + } + break; + + case KeyEvent.KEYCODE_BACK: + finish(); + return true; + } + return super.onKeyDown(keyCode, event); + } + + /** + * Clears the cached value for the last query time so that renewCursor() + * will force a requery of the Calendar events. + */ + private void clearLastQueryTime() { + mLastQueryTime.year = 0; + mLastQueryTime.month = 0; + } + + private void renewCursor() { + // Avoid batching up repeated queries for the same month. This can + // happen if the user scrolls with the trackball too fast. + if (mLastQueryTime.month == mTime.month && mLastQueryTime.year == mTime.year) { + return; + } + + // Query all instances for the current month + Time time = new Time(); + time.year = mTime.year; + time.month = mTime.month; + long start = time.normalize(true); + + time.month++; + long end = time.normalize(true); + + StringBuilder path = new StringBuilder(); + path.append(start); + path.append('/'); + path.append(end); + + // Respect the preference to show/hide declined events + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + boolean hideDeclined = prefs.getBoolean(CalendarPreferenceActivity.KEY_HIDE_DECLINED, + false); + + Uri uri = Uri.withAppendedPath(Instances.CONTENT_URI, path.toString()); + + String selection; + if (hideDeclined) { + selection = Calendars.SELECTED + "=1 AND " + + Instances.SELF_ATTENDEE_STATUS + "!=" + Attendees.ATTENDEE_STATUS_DECLINED; + } else { + selection = Calendars.SELECTED + "=1"; + } + + // Cancel any previous queries that haven't started yet. This + // isn't likely to happen since we already avoid sending + // a duplicate query for the same month as the previous query. + // But if the user quickly wiggles the trackball back and forth, + // he could generate a stream of queries. + mQueryHandler.cancelOperation(0); + + mLastQueryTime.month = mTime.month; + mLastQueryTime.year = mTime.year; + mQueryHandler.startQuery(0, null, uri, PROJECTION, selection, null, + AGENDA_SORT_ORDER); + } + + private void selectTime() { + // Selects the first event of the day + AgendaListView current = (AgendaListView) mViewSwitcher.getCurrentView(); + if (current.getCursor() == null) { + return; + } + + int position = current.getDayAdapter().findDayPositionNearestTime(mTime); + current.setSelection(position); + } + + /* ViewSwitcher.ViewFactory interface methods */ + public View makeView() { + AgendaListView agendaListView = new AgendaListView(this); + return agendaListView; + } + + /* Navigator interface methods */ + public void goToToday() { + Time now = new Time(); + now.set(System.currentTimeMillis()); + goTo(now); + } + + public void goTo(Time time) { + if (mTime.year == time.year && mTime.month == time.month) { + mTime = time; + selectTime(); + } else { + mTime = time; + } + } + + public long getSelectedTime() { + // Update the current time based on the selected event + AgendaListView current = (AgendaListView) mViewSwitcher.getCurrentView(); + int position = current.getSelectedItemPosition(); + position = current.getDayAdapter().getCursorPosition(position); + Cursor cursor = current.getCursor(); + if (position >= 0 && position < cursor.getCount()) { + cursor.moveToPosition(position); + mTime.set(cursor.getLong(INDEX_BEGIN)); + } + + return mTime.toMillis(true); + } + + public boolean getAllDay() { + return false; + } +} + diff --git a/src/com/android/calendar/AgendaAdapter.java b/src/com/android/calendar/AgendaAdapter.java new file mode 100644 index 00000000..6aac218a --- /dev/null +++ b/src/com/android/calendar/AgendaAdapter.java @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2007 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.calendar; + +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.provider.Calendar.Attendees; +import android.provider.Calendar.Reminders; +import android.text.format.DateFormat; +import android.text.format.DateUtils; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ResourceCursorAdapter; +import android.widget.TextView; + +import java.util.ArrayList; + +public class AgendaAdapter extends ResourceCursorAdapter { + + static final String[] REMINDERS_PROJECTION = new String[] { + Reminders._ID, // 0 + Reminders.MINUTES, // 1 + }; + static final int REMINDERS_INDEX_MINUTES = 1; + static final String REMINDERS_WHERE = Reminders.EVENT_ID + "=%d AND (" + + Reminders.METHOD + "=" + Reminders.METHOD_ALERT + " OR " + Reminders.METHOD + "=" + + Reminders.METHOD_DEFAULT + ")"; + + private Resources mResources; + private static ArrayList<Integer> sReminderValues; + private static String[] sReminderLabels; + + public AgendaAdapter(Context context, int resource) { + super(context, resource, null); + mResources = context.getResources(); + } + + @Override + public void bindView(View view, Context context, Cursor cursor) { + // Fade text if event was declined. + int selfAttendeeStatus = cursor.getInt(AgendaActivity.INDEX_SELF_ATTENDEE_STATUS); + boolean declined = (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_DECLINED); + + View stripe = view.findViewById(R.id.vertical_stripe); + int color = cursor.getInt(AgendaActivity.INDEX_COLOR); + ((FrameLayout) view).setForeground(declined ? + mResources.getDrawable(R.drawable.agenda_item_declined) : null); + + stripe.setBackgroundColor(color); + + // What + TextView title = (TextView) view.findViewById(R.id.title); + String titleString = cursor.getString(AgendaActivity.INDEX_TITLE); + if (titleString == null || titleString.length() == 0) { + titleString = mResources.getString(R.string.no_title_label); + } + title.setText(titleString); + title.setTextColor(color); + + // When + TextView when = (TextView) view.findViewById(R.id.when); + long begin = cursor.getLong(AgendaActivity.INDEX_BEGIN); + long end = cursor.getLong(AgendaActivity.INDEX_END); + boolean allDay = cursor.getInt(AgendaActivity.INDEX_ALL_DAY) != 0; + int flags; + String whenString; + if (allDay) { + flags = DateUtils.FORMAT_UTC; + } else { + flags = DateUtils.FORMAT_SHOW_TIME; + } + if (DateFormat.is24HourFormat(context)) { + flags |= DateUtils.FORMAT_24HOUR; + } + whenString = DateUtils.formatDateRange(context, begin, end, flags); + when.setText(whenString); + + String rrule = cursor.getString(AgendaActivity.INDEX_RRULE); + if (rrule != null) { + when.setCompoundDrawablesWithIntrinsicBounds(null, null, + context.getResources().getDrawable(R.drawable.ic_repeat_dark), null); + when.setCompoundDrawablePadding(5); + } else { + when.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null); + } + + /* + // Repeating info + View repeatContainer = view.findViewById(R.id.repeat_icon); + String rrule = cursor.getString(AgendaActivity.INDEX_RRULE); + if (rrule != null) { + repeatContainer.setVisibility(View.VISIBLE); + } else { + repeatContainer.setVisibility(View.GONE); + } + */ + + /* + // Reminder + boolean hasAlarm = cursor.getInt(AgendaActivity.INDEX_HAS_ALARM) != 0; + if (hasAlarm) { + updateReminder(view, context, begin, cursor.getLong(AgendaActivity.INDEX_EVENT_ID)); + } + */ + + // Where + TextView where = (TextView) view.findViewById(R.id.where); + String whereString = cursor.getString(AgendaActivity.INDEX_EVENT_LOCATION); + if (whereString != null && whereString.length() > 0) { + where.setVisibility(View.VISIBLE); + where.setText(whereString); + } else { + where.setVisibility(View.GONE); + } + } + + /* + public static void updateReminder(View view, Context context, long begin, long eventId) { + ContentResolver cr = context.getContentResolver(); + Uri uri = Reminders.CONTENT_URI; + String where = String.format(REMINDERS_WHERE, eventId); + + Cursor remindersCursor = cr.query(uri, REMINDERS_PROJECTION, where, null, null); + if (remindersCursor != null) { + LayoutInflater inflater = + (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + LinearLayout parent = (LinearLayout) view.findViewById(R.id.reminders_container); + parent.removeAllViews(); + while (remindersCursor.moveToNext()) { + int alarm = remindersCursor.getInt(REMINDERS_INDEX_MINUTES); + String before = EditEvent.constructReminderLabel(context, alarm, true); + LinearLayout reminderItem = (LinearLayout) + inflater.inflate(R.layout.agenda_reminder_item, null); + TextView reminderItemText = (TextView) reminderItem.findViewById(R.id.reminder); + reminderItemText.setText(before); + parent.addView(reminderItem); + } + } + remindersCursor.close(); + } + */ +} + diff --git a/src/com/android/calendar/AgendaByDayAdapter.java b/src/com/android/calendar/AgendaByDayAdapter.java new file mode 100644 index 00000000..d774efbe --- /dev/null +++ b/src/com/android/calendar/AgendaByDayAdapter.java @@ -0,0 +1,364 @@ +/* + * Copyright (C) 2008 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.calendar; + +import android.content.Context; +import android.database.Cursor; +import android.text.format.DateUtils; +import android.text.format.Time; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Iterator; +import java.util.LinkedList; + +public class AgendaByDayAdapter extends BaseAdapter { + private static final int TYPE_DAY = 0; + private static final int TYPE_MEETING = 1; + private static final int TYPE_LAST = 2; + + private final Context mContext; + private final AgendaAdapter mAgendaAdapter; + private final LayoutInflater mInflater; + private ArrayList<RowInfo> mRowInfo; + private int mTodayJulianDay; + private Time mTime = new Time(); + + public AgendaByDayAdapter(Context context, AgendaAdapter agendaAdapter) { + mContext = context; + mAgendaAdapter = agendaAdapter; + mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + } + + public int getCount() { + if (mRowInfo != null) { + return mRowInfo.size(); + } + return mAgendaAdapter.getCount(); + } + + public Object getItem(int position) { + if (mRowInfo != null) { + RowInfo row = mRowInfo.get(position); + if (row.mType == TYPE_DAY) { + return row; + } else { + return mAgendaAdapter.getItem(row.mData); + } + } + return mAgendaAdapter.getItem(position); + } + + public long getItemId(int position) { + if (mRowInfo != null) { + RowInfo row = mRowInfo.get(position); + if (row.mType == TYPE_DAY) { + return position; + } else { + return mAgendaAdapter.getItemId(row.mData); + } + } + return mAgendaAdapter.getItemId(position); + } + + @Override + public int getViewTypeCount() { + return TYPE_LAST; + } + + @Override + public int getItemViewType(int position) { + return mRowInfo != null && mRowInfo.size() > position ? + mRowInfo.get(position).mType : TYPE_DAY; + } + + private static class ViewHolder { + TextView dateView; + TextView dayOfWeekView; + } + + public View getView(int position, View convertView, ViewGroup parent) { + if ((mRowInfo == null) || (position > mRowInfo.size())) { + // If we have no row info, mAgendaAdapter returns the view. + return mAgendaAdapter.getView(position, convertView, parent); + } + + RowInfo row = mRowInfo.get(position); + if (row.mType == TYPE_DAY) { + ViewHolder holder; + View agendaDayView; + if ((convertView == null) || (convertView.getTag() == null)) { + // Create a new AgendaView with a ViewHolder for fast access to + // views w/o calling findViewById() + holder = new ViewHolder(); + agendaDayView = mInflater.inflate(R.layout.agenda_day, parent, false); + holder.dateView = (TextView) agendaDayView.findViewById(R.id.date); + holder.dayOfWeekView = (TextView) agendaDayView.findViewById(R.id.day_of_week); + agendaDayView.setTag(holder); + } else { + agendaDayView = convertView; + holder = (ViewHolder) convertView.getTag(); + } + + // Re-use the member variable "mTime" which is set to the local timezone. + Time date = mTime; + long millis = date.setJulianDay(row.mData); + int flags = DateUtils.FORMAT_NUMERIC_DATE; + holder.dateView.setText(DateUtils.formatDateRange(mContext, millis, millis, flags)); + + if (row.mData == mTodayJulianDay) { + holder.dayOfWeekView.setText(R.string.agenda_today); + } else { + int weekDay = date.weekDay + Calendar.SUNDAY; + holder.dayOfWeekView.setText(DateUtils.getDayOfWeekString(weekDay, + DateUtils.LENGTH_LONG)); + } + return agendaDayView; + } else if (row.mType == TYPE_MEETING) { + return mAgendaAdapter.getView(row.mData, convertView, parent); + } else { + // Error + throw new IllegalStateException("Unknown event type:" + row.mType); + } + } + + public void clearDayHeaderInfo() { + mRowInfo = null; + } + + public void calculateDays(Cursor cursor) { + ArrayList<RowInfo> rowInfo = new ArrayList<RowInfo>(); + int prevStartDay = -1; + Time time = new Time(); + long now = System.currentTimeMillis(); + time.set(now); + mTodayJulianDay = Time.getJulianDay(now, time.gmtoff); + LinkedList<MultipleDayInfo> multipleDayList = new LinkedList<MultipleDayInfo>(); + for (int position = 0; cursor.moveToNext(); position++) { + boolean allDay = cursor.getInt(AgendaActivity.INDEX_ALL_DAY) != 0; + int startDay = cursor.getInt(AgendaActivity.INDEX_START_DAY); + + if (startDay != prevStartDay) { + // Check if we skipped over any empty days + if (prevStartDay == -1) { + rowInfo.add(new RowInfo(TYPE_DAY, startDay)); + } else { + // If there are any multiple-day events that span the empty + // range of days, then create day headers and events for + // those multiple-day events. + boolean dayHeaderAdded = false; + for (int currentDay = prevStartDay + 1; currentDay <= startDay; currentDay++) { + dayHeaderAdded = false; + Iterator<MultipleDayInfo> iter = multipleDayList.iterator(); + while (iter.hasNext()) { + MultipleDayInfo info = iter.next(); + // If this event has ended then remove it from the + // list. + if (info.mEndDay < currentDay) { + iter.remove(); + continue; + } + + // If this is the first event for the day, then + // insert a day header. + if (!dayHeaderAdded) { + rowInfo.add(new RowInfo(TYPE_DAY, currentDay)); + dayHeaderAdded = true; + } + rowInfo.add(new RowInfo(TYPE_MEETING, info.mPosition)); + } + } + + // If the day header was not added for the start day, then + // add it now. + if (!dayHeaderAdded) { + rowInfo.add(new RowInfo(TYPE_DAY, startDay)); + } + } + prevStartDay = startDay; + } + + // Add in the event for this cursor position + rowInfo.add(new RowInfo(TYPE_MEETING, position)); + + // If this event spans multiple days, then add it to the multipleDay + // list. + int endDay = cursor.getInt(AgendaActivity.INDEX_END_DAY); + if (endDay > startDay) { + multipleDayList.add(new MultipleDayInfo(position, endDay)); + } + } + + // There are no more cursor events but we might still have multiple-day + // events left. So create day headers and events for those. + if (prevStartDay > 0) { + // Get the Julian day for the last day of this month. To do that, + // we set the date to one less than the first day of the next month, + // and then normalize. + time.setJulianDay(prevStartDay); + time.month += 1; + time.monthDay = 0; // monthDay starts with 1, so this is the previous day + long millis = time.normalize(true /* ignore isDst */); + int lastDayOfMonth = Time.getJulianDay(millis, time.gmtoff); + + for (int currentDay = prevStartDay + 1; currentDay <= lastDayOfMonth; currentDay++) { + boolean dayHeaderAdded = false; + Iterator<MultipleDayInfo> iter = multipleDayList.iterator(); + while (iter.hasNext()) { + MultipleDayInfo info = iter.next(); + // If this event has ended then remove it from the + // list. + if (info.mEndDay < currentDay) { + iter.remove(); + continue; + } + + // If this is the first event for the day, then + // insert a day header. + if (!dayHeaderAdded) { + rowInfo.add(new RowInfo(TYPE_DAY, currentDay)); + dayHeaderAdded = true; + } + rowInfo.add(new RowInfo(TYPE_MEETING, info.mPosition)); + } + } + } + mRowInfo = rowInfo; + } + + private static class RowInfo { + // mType is either a day header (TYPE_DAY) or an event (TYPE_MEETING) + final int mType; + + // If mType is TYPE_DAY, then mData is the Julian day. Otherwise + // mType is TYPE_MEETING and mData is the cursor position. + final int mData; + + RowInfo(int type, int data) { + mType = type; + mData = data; + } + } + + private static class MultipleDayInfo { + final int mPosition; + final int mEndDay; + + MultipleDayInfo(int position, int endDay) { + mPosition = position; + mEndDay = endDay; + } + } + + /** + * Searches for the day that matches the given Time object and returns the + * list position of that day. If there are no events for that day, then it + * finds the nearest day (before or after) that has events and returns the + * list position for that day. + * + * @param time the date to search for + * @return the cursor position of the first event for that date, or zero + * if no match was found + */ + public int findDayPositionNearestTime(Time time) { + if (mRowInfo == null) { + return 0; + } + long millis = time.toMillis(false /* use isDst */); + int julianDay = Time.getJulianDay(millis, time.gmtoff); + int minDistance = 1000; // some big number + int minIndex = 0; + int len = mRowInfo.size(); + for (int index = 0; index < len; index++) { + RowInfo row = mRowInfo.get(index); + if (row.mType == TYPE_DAY) { + int distance = Math.abs(julianDay - row.mData); + if (distance == 0) { + return index; + } + if (distance < minDistance) { + minDistance = distance; + minIndex = index; + } + } + } + + // We didn't find an exact match so take the nearest day that had + // events. + return minIndex; + } + + /** + * Finds the Julian day containing the event at the given position. + * + * @param position the list position of an event + * @return the Julian day containing that event + */ + public int findJulianDayFromPosition(int position) { + if (mRowInfo == null || position < 0) { + return 0; + } + + int len = mRowInfo.size(); + if (position >= len) return 0; // no row info at this position + + for (int index = position; index >= 0; index--) { + RowInfo row = mRowInfo.get(index); + if (row.mType == TYPE_DAY) { + return row.mData; + } + } + return 0; + } + + /** + * Converts a list position to a cursor position. The list contains + * day headers as well as events. The cursor contains only events. + * + * @param listPos the list position of an event + * @return the corresponding cursor position of that event + */ + public int getCursorPosition(int listPos) { + if (mRowInfo != null && listPos >= 0) { + RowInfo row = mRowInfo.get(listPos); + if (row.mType == TYPE_MEETING) { + return row.mData; + } + } + return listPos; + } + + @Override + public boolean areAllItemsEnabled() { + return false; + } + + @Override + public boolean isEnabled(int position) { + if (mRowInfo != null && position < mRowInfo.size()) { + RowInfo row = mRowInfo.get(position); + return row.mType == TYPE_MEETING; + } + return true; + } +} + diff --git a/src/com/android/calendar/AlertActivity.java b/src/com/android/calendar/AlertActivity.java new file mode 100644 index 00000000..913bd85c --- /dev/null +++ b/src/com/android/calendar/AlertActivity.java @@ -0,0 +1,284 @@ +/* + * Copyright (C) 2007 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.calendar; + +import static android.provider.Calendar.EVENT_BEGIN_TIME; +import static android.provider.Calendar.EVENT_END_TIME; + +import android.app.Activity; +import android.app.AlarmManager; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.AsyncQueryHandler; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +import android.content.Intent; +import android.content.res.TypedArray; +import android.database.Cursor; +import android.graphics.PixelFormat; +import android.net.Uri; +import android.os.Bundle; +import android.provider.Calendar; +import android.provider.Calendar.CalendarAlerts; +import android.provider.Calendar.Events; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.view.View.OnClickListener; +import android.widget.AdapterView; +import android.widget.Button; +import android.widget.ListView; +import android.widget.AdapterView.OnItemClickListener; + +/** + * The alert panel that pops up when there is a calendar event alarm. + * This activity is started by an intent that specifies an event id. + */ +public class AlertActivity extends Activity { + + // The default snooze delay: 5 minutes + public static final long SNOOZE_DELAY = 5 * 60 * 1000L; + + private static final String[] PROJECTION = new String[] { + CalendarAlerts._ID, // 0 + CalendarAlerts.TITLE, // 1 + CalendarAlerts.EVENT_LOCATION, // 2 + CalendarAlerts.ALL_DAY, // 3 + CalendarAlerts.BEGIN, // 4 + CalendarAlerts.END, // 5 + CalendarAlerts.EVENT_ID, // 6 + CalendarAlerts.COLOR, // 7 + CalendarAlerts.RRULE, // 8 + CalendarAlerts.HAS_ALARM, // 9 + CalendarAlerts.STATE, // 10 + CalendarAlerts.ALARM_TIME, // 11 + }; + + public static final int INDEX_TITLE = 1; + public static final int INDEX_EVENT_LOCATION = 2; + public static final int INDEX_ALL_DAY = 3; + public static final int INDEX_BEGIN = 4; + public static final int INDEX_END = 5; + public static final int INDEX_EVENT_ID = 6; + public static final int INDEX_COLOR = 7; + public static final int INDEX_RRULE = 8; + public static final int INDEX_HAS_ALARM = 9; + public static final int INDEX_STATE = 10; + public static final int INDEX_ALARM_TIME = 11; + + // We use one notification id for all events so that we don't clutter + // the notification screen. It doesn't matter what the id is, as long + // as it is used consistently everywhere. + public static final int NOTIFICATION_ID = 0; + + private ContentResolver mResolver; + private AlertAdapter mAdapter; + private QueryHandler mQueryHandler; + private Cursor mCursor; + private ListView mListView; + private Button mSnoozeAllButton; + private Button mDismissAllButton; + + private class QueryHandler extends AsyncQueryHandler { + public QueryHandler(ContentResolver cr) { + super(cr); + } + + @Override + protected void onQueryComplete(int token, Object cookie, Cursor cursor) { + // Only set mCursor if the Activity is not finishing. Otherwise close the cursor. + if (!isFinishing()) { + mCursor = cursor; + mAdapter.changeCursor(cursor); + + // The results are in, enable the buttons + mSnoozeAllButton.setEnabled(true); + mDismissAllButton.setEnabled(true); + } else { + cursor.close(); + } + } + + } + + private OnItemClickListener mViewListener = new OnItemClickListener() { + + public void onItemClick(AdapterView parent, View view, int position, + long i) { + AlertActivity alertActivity = AlertActivity.this; + Cursor cursor = alertActivity.getItemForView(view); + + long id = cursor.getInt(AlertActivity.INDEX_EVENT_ID); + long startMillis = cursor.getLong(AlertActivity.INDEX_BEGIN); + long endMillis = cursor.getLong(AlertActivity.INDEX_END); + + Uri uri = ContentUris.withAppendedId(Events.CONTENT_URI, id); + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + intent.setClass(alertActivity, EventInfoActivity.class); + intent.putExtra(EVENT_BEGIN_TIME, startMillis); + intent.putExtra(EVENT_END_TIME, endMillis); + + // Mark this alarm as DISMISSED + cursor.updateInt(INDEX_STATE, CalendarAlerts.DISMISSED); + cursor.commitUpdates(); + + startActivity(intent); + alertActivity.finish(); + } + }; + + @Override + protected void onCreate(Bundle icicle) { + super.onCreate(icicle); + + requestWindowFeature(Window.FEATURE_NO_TITLE); + setContentView(R.layout.alert_activity); + + WindowManager.LayoutParams lp = getWindow().getAttributes(); + lp.width = ViewGroup.LayoutParams.FILL_PARENT; + lp.height = ViewGroup.LayoutParams.FILL_PARENT; + + // Get the dim amount from the theme + TypedArray a = obtainStyledAttributes(com.android.internal.R.styleable.Theme); + lp.dimAmount = a.getFloat(android.R.styleable.Theme_backgroundDimAmount, 0.5f); + a.recycle(); + + getWindow().setAttributes(lp); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND); + + mResolver = getContentResolver(); + mQueryHandler = new QueryHandler(mResolver); + mAdapter = new AlertAdapter(this, R.layout.alert_item); + + mListView = (ListView) findViewById(R.id.alert_container); + mListView.setItemsCanFocus(true); + mListView.setAdapter(mAdapter); + mListView.setOnItemClickListener(mViewListener); + + mSnoozeAllButton = (Button) findViewById(R.id.snooze_all); + mSnoozeAllButton.setOnClickListener(mSnoozeAllListener); + mDismissAllButton = (Button) findViewById(R.id.dismiss_all); + mDismissAllButton.setOnClickListener(mDismissAllListener); + + // Disable the buttons, since they need mCursor, which is created asynchronously + mSnoozeAllButton.setEnabled(false); + mDismissAllButton.setEnabled(false); + } + + @Override + protected void onResume() { + super.onResume(); + + // If the cursor is null, start the async handler. If it is not null just requery. + if (mCursor == null) { + Uri uri = CalendarAlerts.CONTENT_URI_BY_INSTANCE; + String selection = CalendarAlerts.STATE + "=" + CalendarAlerts.FIRED; + mQueryHandler.startQuery(0, null, uri, PROJECTION, selection, + null /* selection args */, CalendarAlerts.DEFAULT_SORT_ORDER); + } else { + mCursor.requery(); + } + } + + @Override + protected void onStop() { + super.onStop(); + AlertReceiver.updateAlertNotification(this); + + if (mCursor != null) { + mCursor.deactivate(); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (mCursor != null) { + mCursor.close(); + } + } + + private OnClickListener mSnoozeAllListener = new OnClickListener() { + public void onClick(View v) { + NotificationManager nm = + (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + nm.cancel(NOTIFICATION_ID); + mCursor.moveToPosition(-1); + while (mCursor.moveToNext()) { + long eventId = mCursor.getLong(INDEX_EVENT_ID); + long begin = mCursor.getLong(INDEX_BEGIN); + long end = mCursor.getLong(INDEX_END); + long alarmTime = mCursor.getLong(INDEX_ALARM_TIME); + + // Mark this alarm as DISMISSED + mCursor.updateInt(INDEX_STATE, CalendarAlerts.DISMISSED); + + // Create a new alarm entry in the CalendarAlerts table + long now = System.currentTimeMillis(); + alarmTime = now + SNOOZE_DELAY; + + // Set the "minutes" to zero to indicate this is a snoozed + // alarm. There is code in AlertService.java that checks + // this field. + Uri uri = CalendarAlerts.insert(mResolver, eventId, + begin, end, alarmTime, 0 /* minutes */); + + // Set a new alarm to go off after the snooze delay. + Intent intent = new Intent(Calendar.EVENT_REMINDER_ACTION); + intent.setData(uri); + intent.putExtra(Calendar.EVENT_BEGIN_TIME, begin); + intent.putExtra(Calendar.EVENT_END_TIME, end); + + PendingIntent sender = PendingIntent.getBroadcast(AlertActivity.this, + 0, intent, PendingIntent.FLAG_CANCEL_CURRENT); + Object service = getSystemService(Context.ALARM_SERVICE); + AlarmManager alarmManager = (AlarmManager) service; + alarmManager.set(AlarmManager.RTC_WAKEUP, alarmTime, sender); + } + mCursor.commitUpdates(); + finish(); + } + }; + + private OnClickListener mDismissAllListener = new OnClickListener() { + public void onClick(View v) { + NotificationManager nm = + (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + nm.cancel(NOTIFICATION_ID); + mCursor.moveToPosition(-1); + while (mCursor.moveToNext()) { + mCursor.updateInt(INDEX_STATE, CalendarAlerts.DISMISSED); + } + mCursor.commitUpdates(); + finish(); + } + }; + + public boolean isEmpty() { + return (mCursor.getCount() == 0); + } + + public Cursor getItemForView(View view) { + int index = mListView.getPositionForView(view); + if (index < 0) { + return null; + } + return (Cursor) mListView.getAdapter().getItem(index); + } +} diff --git a/src/com/android/calendar/AlertAdapter.java b/src/com/android/calendar/AlertAdapter.java new file mode 100644 index 00000000..b182ccd7 --- /dev/null +++ b/src/com/android/calendar/AlertAdapter.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2008 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.calendar; + +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.PorterDuff; +import android.text.format.DateFormat; +import android.text.format.DateUtils; +import android.view.View; +import android.widget.ImageView; +import android.widget.ResourceCursorAdapter; +import android.widget.TextView; + +public class AlertAdapter extends ResourceCursorAdapter { + + public AlertAdapter(Context context, int resource) { + super(context, resource, null); + } + + @Override + public void bindView(View view, Context context, Cursor cursor) { + TextView textView; + + ImageView stripe = (ImageView) view.findViewById(R.id.vertical_stripe); + int color = cursor.getInt(AlertActivity.INDEX_COLOR) & 0xbbffffff; + stripe.getBackground().setColorFilter(color, PorterDuff.Mode.SRC_IN); + + // Repeating info + View repeatContainer = view.findViewById(R.id.repeat_icon); + String rrule = cursor.getString(AlertActivity.INDEX_RRULE); + if (rrule != null) { + repeatContainer.setVisibility(View.VISIBLE); + } else { + repeatContainer.setVisibility(View.GONE); + } + + /* + // Reminder + boolean hasAlarm = cursor.getInt(AlertActivity.INDEX_HAS_ALARM) != 0; + if (hasAlarm) { + AgendaAdapter.updateReminder(view, context, cursor.getLong(AlertActivity.INDEX_BEGIN), + cursor.getLong(AlertActivity.INDEX_EVENT_ID)); + } + */ + + String eventName = cursor.getString(AlertActivity.INDEX_TITLE); + String location = cursor.getString(AlertActivity.INDEX_EVENT_LOCATION); + long startMillis = cursor.getLong(AlertActivity.INDEX_BEGIN); + long endMillis = cursor.getLong(AlertActivity.INDEX_END); + boolean allDay = cursor.getInt(AlertActivity.INDEX_ALL_DAY) != 0; + + updateView(context, view, eventName, location, startMillis, endMillis, allDay); + } + + public static void updateView(Context context, View view, String eventName, String location, + long startMillis, long endMillis, boolean allDay) { + + Resources res = context.getResources(); + TextView textView; + + // What + if (eventName == null || eventName.length() == 0) { + eventName = res.getString(R.string.no_title_label); + } + textView = (TextView) view.findViewById(R.id.event_title); + textView.setText(eventName); + + // When + String when; + int flags; + if (allDay) { + flags = DateUtils.FORMAT_UTC | DateUtils.FORMAT_SHOW_WEEKDAY | + DateUtils.FORMAT_SHOW_DATE; + } else { + flags = DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_SHOW_DATE; + } + if (DateFormat.is24HourFormat(context)) { + flags |= DateUtils.FORMAT_24HOUR; + } + when = DateUtils.formatDateRange(context, startMillis, endMillis, flags); + textView = (TextView) view.findViewById(R.id.when); + textView.setText(when); + + // Where + textView = (TextView) view.findViewById(R.id.where); + if (location == null || location.length() == 0) { + textView.setVisibility(View.GONE); + } else { + textView.setText(location); + } + } +} diff --git a/src/com/android/calendar/AlertReceiver.java b/src/com/android/calendar/AlertReceiver.java new file mode 100644 index 00000000..20f065ce --- /dev/null +++ b/src/com/android/calendar/AlertReceiver.java @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2007 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.calendar; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.database.Cursor; +import android.net.Uri; +import android.os.PowerManager; +import android.preference.PreferenceManager; +import android.provider.Calendar.CalendarAlerts; + +/** + * Receives android.intent.action.EVENT_REMINDER intents and handles + * event reminders. The intent URI specifies an alert id in the + * CalendarAlerts database table. This class also receives the + * BOOT_COMPLETED intent so that it can add a status bar notification + * if there are Calendar event alarms that have not been dismissed. + * It also receives the TIME_CHANGED action so that it can fire off + * snoozed alarms that have become ready. The real work is done in + * the AlertService class. + */ +public class AlertReceiver extends BroadcastReceiver { + private static final String[] ALERT_PROJECTION = new String[] { + CalendarAlerts.TITLE, // 0 + CalendarAlerts.EVENT_LOCATION, // 1 + }; + private static final int ALERT_INDEX_TITLE = 0; + private static final int ALERT_INDEX_EVENT_LOCATION = 1; + + private static final String DELETE_ACTION = "delete"; + + private static final String[] PROJECTION = new String[] { + CalendarAlerts._ID, // 0 + CalendarAlerts.STATE, // 1 + }; + + public static final int INDEX_STATE = 1; + + static final Object mStartingServiceSync = new Object(); + static PowerManager.WakeLock mStartingService; + + @Override + public void onReceive(Context context, Intent intent) { + if (DELETE_ACTION.equals(intent.getAction())) { + + /* The user has clicked the "Clear All Notifications" + * buttons so dismiss all Calendar alerts. + */ + dismissAllEvents(context); + } else { + Intent i = new Intent(); + i.setClass(context, AlertService.class); + i.putExtras(intent); + i.putExtra("action", intent.getAction()); + Uri uri = intent.getData(); + + // This intent might be a BOOT_COMPLETED so it might not have a Uri. + if (uri != null) { + i.putExtra("uri", uri.toString()); + + // Record the received time in the CalendarAlerts table. + // This is useful for finding bugs that cause alarms to be + // missed or delayed. + ContentResolver cr = context.getContentResolver(); + ContentValues values = new ContentValues(); + long currentTime = System.currentTimeMillis(); + values.put(CalendarAlerts.RECEIVED_TIME, currentTime); + cr.update(uri, values, null /* where */, null /* args */); + } + beginStartingService(context, i); + } + } + + private void dismissAllEvents(Context context) { + Uri uri = CalendarAlerts.CONTENT_URI_BY_INSTANCE; + String selection = CalendarAlerts.STATE + "=" + CalendarAlerts.FIRED; + ContentResolver resolver = context.getContentResolver(); + Cursor cursor = resolver.query(uri, PROJECTION, selection, null, null); + if (cursor != null) { + cursor.moveToPosition(-1); + while (cursor.moveToNext()) { + cursor.updateInt(INDEX_STATE, CalendarAlerts.DISMISSED); + } + cursor.commitUpdates(); + cursor.close(); + } + } + + /** + * Start the service to process the current event notifications, acquiring + * the wake lock before returning to ensure that the service will run. + */ + public static void beginStartingService(Context context, Intent intent) { + synchronized (mStartingServiceSync) { + if (mStartingService == null) { + PowerManager pm = + (PowerManager)context.getSystemService(Context.POWER_SERVICE); + mStartingService = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, + "StartingAlertService"); + mStartingService.setReferenceCounted(false); + } + mStartingService.acquire(); + context.startService(intent); + } + } + + /** + * Called back by the service when it has finished processing notifications, + * releasing the wake lock if the service is now stopping. + */ + public static void finishStartingService(Service service, int startId) { + synchronized (mStartingServiceSync) { + if (mStartingService != null) { + if (service.stopSelfResult(startId)) { + mStartingService.release(); + } + } + } + } + + public static void updateAlertNotification(Context context) { + // This can be called regularly to synchronize the alert notification + // with the contents of the CalendarAlerts table. + + ContentResolver cr = context.getContentResolver(); + + if (cr == null) { + return; + } + + String selection = CalendarAlerts.STATE + "=" + CalendarAlerts.FIRED; + Cursor alertCursor = CalendarAlerts.query(cr, ALERT_PROJECTION, selection, null); + + NotificationManager nm = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + if (alertCursor == null) { + nm.cancel(AlertActivity.NOTIFICATION_ID); + return; + } + + if (!alertCursor.moveToFirst()) { + alertCursor.close(); + nm.cancel(AlertActivity.NOTIFICATION_ID); + return; + } + + // Check the settings to see if alerts are disabled + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + String reminderType = prefs.getString(CalendarPreferenceActivity.KEY_ALERTS_TYPE, + CalendarPreferenceActivity.ALERT_TYPE_STATUS_BAR); + if (reminderType.equals(CalendarPreferenceActivity.ALERT_TYPE_OFF)) { + return; + } + + String title = alertCursor.getString(ALERT_INDEX_TITLE); + String location = alertCursor.getString(ALERT_INDEX_EVENT_LOCATION); + + Notification notification = AlertReceiver.makeNewAlertNotification(context, title, + location, alertCursor.getCount()); + alertCursor.close(); + + nm.notify(0, notification); + } + + public static Notification makeNewAlertNotification(Context context, String title, + String location, int numReminders) { + Resources res = context.getResources(); + + // Create an intent triggered by clicking on the status icon. + Intent clickIntent = new Intent(); + clickIntent.setClass(context, AlertActivity.class); + clickIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + // Create an intent triggered by clicking on the "Clear All Notifications" button + Intent deleteIntent = new Intent(); + deleteIntent.setClass(context, AlertReceiver.class); + deleteIntent.setAction(DELETE_ACTION); + + if (title == null || title.length() == 0) { + title = res.getString(R.string.no_title_label); + } + + String helperString; + if (numReminders > 1) { + String format; + if (numReminders == 2) { + format = res.getString(R.string.alert_missed_events_single); + } else { + format = res.getString(R.string.alert_missed_events_multiple); + } + helperString = String.format(format, numReminders - 1); + } else { + helperString = location; + } + + Notification notification = new Notification( + R.drawable.stat_notify_calendar, + null, + System.currentTimeMillis()); + notification.setLatestEventInfo(context, + title, + helperString, + PendingIntent.getActivity(context, 0, clickIntent, 0)); + notification.deleteIntent = PendingIntent.getBroadcast(context, 0, deleteIntent, 0); + + return notification; + } +} + diff --git a/src/com/android/calendar/AlertService.java b/src/com/android/calendar/AlertService.java new file mode 100644 index 00000000..eb705046 --- /dev/null +++ b/src/com/android/calendar/AlertService.java @@ -0,0 +1,437 @@ +/* + * Copyright (C) 2008 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.calendar; + +import android.app.AlarmManager; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.Service; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; +import android.os.Looper; +import android.os.Message; +import android.os.Process; +import android.preference.PreferenceManager; +import android.provider.Calendar; +import android.provider.Calendar.Attendees; +import android.provider.Calendar.CalendarAlerts; +import android.provider.Calendar.Instances; +import android.provider.Calendar.Reminders; +import android.text.TextUtils; +import android.text.format.DateUtils; +import android.text.format.Time; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; + +/** + * This service is used to handle calendar event reminders. + */ +public class AlertService extends Service { + private static final String TAG = "AlertService"; + + private volatile Looper mServiceLooper; + private volatile ServiceHandler mServiceHandler; + + private static final String[] ALERT_PROJECTION = new String[] { + CalendarAlerts._ID, // 0 + CalendarAlerts.EVENT_ID, // 1 + CalendarAlerts.STATE, // 2 + CalendarAlerts.TITLE, // 3 + CalendarAlerts.EVENT_LOCATION, // 4 + CalendarAlerts.SELF_ATTENDEE_STATUS, // 5 + CalendarAlerts.ALL_DAY, // 6 + CalendarAlerts.ALARM_TIME, // 7 + CalendarAlerts.MINUTES, // 8 + CalendarAlerts.BEGIN, // 9 + }; + + // We just need a simple projection that returns any column + private static final String[] ALERT_PROJECTION_SMALL = new String[] { + CalendarAlerts._ID, // 0 + }; + + private static final int ALERT_INDEX_ID = 0; + private static final int ALERT_INDEX_EVENT_ID = 1; + private static final int ALERT_INDEX_STATE = 2; + private static final int ALERT_INDEX_TITLE = 3; + private static final int ALERT_INDEX_EVENT_LOCATION = 4; + private static final int ALERT_INDEX_SELF_ATTENDEE_STATUS = 5; + private static final int ALERT_INDEX_ALL_DAY = 6; + private static final int ALERT_INDEX_ALARM_TIME = 7; + private static final int ALERT_INDEX_MINUTES = 8; + private static final int ALERT_INDEX_BEGIN = 9; + + private String[] INSTANCE_PROJECTION = { Instances.BEGIN, Instances.END }; + private static final int INSTANCES_INDEX_BEGIN = 0; + private static final int INSTANCES_INDEX_END = 1; + + // We just need a simple projection that returns any column + private static final String[] REMINDER_PROJECTION_SMALL = new String[] { + Reminders._ID, // 0 + }; + + private void processMessage(Message msg) { + Bundle bundle = (Bundle) msg.obj; + + // On reboot, update the notification bar with the contents of the + // CalendarAlerts table. + String action = bundle.getString("action"); + if (action.equals(Intent.ACTION_BOOT_COMPLETED) + || action.equals(Intent.ACTION_TIME_CHANGED)) { + doTimeChanged(); + return; + } + + // The Uri specifies an entry in the CalendarAlerts table + Uri alertUri = Uri.parse(bundle.getString("uri")); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "uri: " + alertUri); + } + + ContentResolver cr = getContentResolver(); + Cursor alertCursor = cr.query(alertUri, ALERT_PROJECTION, + null /* selection */, null, null /* sort order */); + + long alertId, eventId, instanceId, alarmTime; + int minutes; + String eventName; + String location; + boolean allDay; + boolean declined = false; + try { + if (alertCursor == null || !alertCursor.moveToFirst()) { + // This can happen if the event was deleted. + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "alert not found"); + } + return; + } + alertId = alertCursor.getLong(ALERT_INDEX_ID); + eventId = alertCursor.getLong(ALERT_INDEX_EVENT_ID); + minutes = alertCursor.getInt(ALERT_INDEX_MINUTES); + eventName = alertCursor.getString(ALERT_INDEX_TITLE); + location = alertCursor.getString(ALERT_INDEX_EVENT_LOCATION); + allDay = alertCursor.getInt(ALERT_INDEX_ALL_DAY) != 0; + alarmTime = alertCursor.getLong(ALERT_INDEX_ALARM_TIME); + declined = alertCursor.getInt(ALERT_INDEX_SELF_ATTENDEE_STATUS) == + Attendees.ATTENDEE_STATUS_DECLINED; + + // If the event was declined, then mark the alarm DISMISSED, + // otherwise, mark the alarm FIRED. + int newState = CalendarAlerts.FIRED; + if (declined) { + newState = CalendarAlerts.DISMISSED; + } + alertCursor.updateInt(ALERT_INDEX_STATE, newState); + alertCursor.commitUpdates(); + } finally { + if (alertCursor != null) { + alertCursor.close(); + } + } + + // Do not show an alert if the event was declined + if (declined) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "event declined, alert cancelled"); + } + return; + } + + long beginTime = bundle.getLong(Calendar.EVENT_BEGIN_TIME, 0); + long endTime = bundle.getLong(Calendar.EVENT_END_TIME, 0); + + // Check if this alarm is still valid. The time of the event may + // have been changed, or the reminder may have been changed since + // this alarm was set. First, search for an instance in the Instances + // that has the same event id and the same begin and end time. + // Then check for a reminder in the Reminders table to ensure that + // the reminder minutes is consistent with this alarm. + String selection = Instances.EVENT_ID + "=" + eventId; + Cursor instanceCursor = Instances.query(cr, INSTANCE_PROJECTION, + beginTime, endTime, selection, Instances.DEFAULT_SORT_ORDER); + long instanceBegin = 0, instanceEnd = 0; + try { + if (instanceCursor == null || !instanceCursor.moveToFirst()) { + // Delete this alarm from the CalendarAlerts table + cr.delete(alertUri, null /* selection */, null /* selection args */); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "instance not found, alert cancelled"); + } + return; + } + instanceBegin = instanceCursor.getLong(INSTANCES_INDEX_BEGIN); + instanceEnd = instanceCursor.getLong(INSTANCES_INDEX_END); + } finally { + if (instanceCursor != null) { + instanceCursor.close(); + } + } + + // Check that a reminder for this event exists with the same number + // of minutes. But snoozed alarms have minutes = 0, so don't do this + // check for snoozed alarms. + if (minutes > 0) { + selection = Reminders.EVENT_ID + "=" + eventId + + " AND " + Reminders.MINUTES + "=" + minutes; + Cursor reminderCursor = cr.query(Reminders.CONTENT_URI, REMINDER_PROJECTION_SMALL, + selection, null /* selection args */, null /* sort order */); + try { + if (reminderCursor == null || reminderCursor.getCount() == 0) { + // Delete this alarm from the CalendarAlerts table + cr.delete(alertUri, null /* selection */, null /* selection args */); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "reminder not found, alert cancelled"); + } + return; + } + } finally { + if (reminderCursor != null) { + reminderCursor.close(); + } + } + } + + // If the event time was changed and the event has already ended, + // then don't sound the alarm. + if (alarmTime > instanceEnd) { + // Delete this alarm from the CalendarAlerts table + cr.delete(alertUri, null /* selection */, null /* selection args */); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "event ended, alert cancelled"); + } + return; + } + + // If minutes > 0, then this is a normal alarm (not a snoozed alarm) + // so check for duplicate alarms. A duplicate alarm can occur when + // the start time of an event is changed to an earlier time. The + // later alarm (that was first scheduled for the later event time) + // should be discarded. + long computedAlarmTime = instanceBegin - minutes * DateUtils.MINUTE_IN_MILLIS; + if (minutes > 0 && computedAlarmTime != alarmTime) { + // If the event time was changed to a later time, then the computed + // alarm time is in the future and we shouldn't sound this alarm. + if (computedAlarmTime > alarmTime) { + // Delete this alarm from the CalendarAlerts table + cr.delete(alertUri, null /* selection */, null /* selection args */); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "event postponed, alert cancelled"); + } + return; + } + + // Check for another alarm in the CalendarAlerts table that has the + // same event id and the same "minutes". This can occur + // if the event start time was changed to an earlier time and the + // alarm for the later time goes off. To avoid discarding alarms + // for repeating events (that have the same event id), we check + // that the other alarm fired recently (within an hour of this one). + long recently = alarmTime - 60 * DateUtils.MINUTE_IN_MILLIS; + selection = CalendarAlerts.EVENT_ID + "=" + eventId + + " AND " + CalendarAlerts.TABLE_NAME + "." + CalendarAlerts._ID + + "!=" + alertId + + " AND " + CalendarAlerts.MINUTES + "=" + minutes + + " AND " + CalendarAlerts.ALARM_TIME + ">" + recently + + " AND " + CalendarAlerts.ALARM_TIME + "<=" + alarmTime; + alertCursor = CalendarAlerts.query(cr, ALERT_PROJECTION_SMALL, selection, null); + if (alertCursor != null) { + try { + if (alertCursor.getCount() > 0) { + // Delete this alarm from the CalendarAlerts table + cr.delete(alertUri, null /* selection */, null /* selection args */); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "duplicate alarm, alert cancelled"); + } + return; + } + } finally { + alertCursor.close(); + } + } + } + + // Find all the alerts that have fired but have not been dismissed + selection = CalendarAlerts.STATE + "=" + CalendarAlerts.FIRED; + alertCursor = CalendarAlerts.query(cr, ALERT_PROJECTION, selection, null); + + if (alertCursor == null || alertCursor.getCount() == 0) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "no fired alarms found"); + } + return; + } + + int numReminders = alertCursor.getCount(); + try { + while (alertCursor.moveToNext()) { + long otherEventId = alertCursor.getLong(ALERT_INDEX_EVENT_ID); + long otherAlertId = alertCursor.getLong(ALERT_INDEX_ID); + int otherAlarmState = alertCursor.getInt(ALERT_INDEX_STATE); + long otherBeginTime = alertCursor.getLong(ALERT_INDEX_BEGIN); + if (otherEventId == eventId && otherAlertId != alertId + && otherAlarmState == CalendarAlerts.FIRED + && otherBeginTime == beginTime) { + // This event already has an alert that fired and has not + // been dismissed. This can happen if an event has + // multiple reminders. Do not count this as a separate + // reminder. But we do want to sound the alarm and vibrate + // the phone, if necessary. + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "multiple alarms for this event"); + } + numReminders -= 1; + } + } + } finally { + alertCursor.close(); + } + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "creating new alarm notification, numReminders: " + numReminders); + } + Notification notification = AlertReceiver.makeNewAlertNotification(this, eventName, + location, numReminders); + + // Generate either a pop-up dialog, status bar notification, or + // neither. Pop-up dialog and status bar notification may include a + // sound, an alert, or both. A status bar notification also includes + // a toast. + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + String reminderType = prefs.getString(CalendarPreferenceActivity.KEY_ALERTS_TYPE, + CalendarPreferenceActivity.ALERT_TYPE_STATUS_BAR); + + if (reminderType.equals(CalendarPreferenceActivity.ALERT_TYPE_OFF)) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "alert preference is OFF"); + } + return; + } + + NotificationManager nm = + (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + boolean reminderVibrate = + prefs.getBoolean(CalendarPreferenceActivity.KEY_ALERTS_VIBRATE, false); + String reminderRingtone = + prefs.getString(CalendarPreferenceActivity.KEY_ALERTS_RINGTONE, null); + + // Possibly generate a vibration + if (reminderVibrate) { + notification.defaults |= Notification.DEFAULT_VIBRATE; + } + + // Possibly generate a sound. If 'Silent' is chosen, the ringtone string will be empty. + notification.sound = TextUtils.isEmpty(reminderRingtone) ? null : Uri + .parse(reminderRingtone); + + if (reminderType.equals(CalendarPreferenceActivity.ALERT_TYPE_ALERTS)) { + Intent alertIntent = new Intent(); + alertIntent.setClass(this, AlertActivity.class); + alertIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(alertIntent); + } else { + LayoutInflater inflater; + inflater = (LayoutInflater) getSystemService(Context.LAYOUT_INFLATER_SERVICE); + View view = inflater.inflate(R.layout.alert_toast, null); + + AlertAdapter.updateView(this, view, eventName, location, beginTime, endTime, allDay); + } + + // Record the notify time in the CalendarAlerts table. + // This is used for debugging missed alarms. + ContentValues values = new ContentValues(); + long currentTime = System.currentTimeMillis(); + values.put(CalendarAlerts.NOTIFY_TIME, currentTime); + cr.update(alertUri, values, null /* where */, null /* args */); + + // The notification time should be pretty close to the reminder time + // that the user set for this event. If the notification is late, then + // that's a bug and we should log an error. + if (currentTime > alarmTime + DateUtils.MINUTE_IN_MILLIS) { + long minutesLate = (currentTime - alarmTime) / DateUtils.MINUTE_IN_MILLIS; + int flags = DateUtils.FORMAT_SHOW_YEAR | DateUtils.FORMAT_SHOW_TIME; + String alarmTimeStr = DateUtils.formatDateTime(this, alarmTime, flags); + String currentTimeStr = DateUtils.formatDateTime(this, currentTime, flags); + Log.w(TAG, "Calendar reminder alarm for event id " + eventId + + " is " + minutesLate + " minute(s) late;" + + " expected alarm at: " + alarmTimeStr + + " but got it at: " + currentTimeStr); + } + + nm.notify(0, notification); + } + + private void doTimeChanged() { + ContentResolver cr = getContentResolver(); + Object service = getSystemService(Context.ALARM_SERVICE); + AlarmManager manager = (AlarmManager) service; + CalendarAlerts.rescheduleMissedAlarms(cr, this, manager); + AlertReceiver.updateAlertNotification(this); + } + + private final class ServiceHandler extends Handler { + public ServiceHandler(Looper looper) { + super(looper); + } + + @Override + public void handleMessage(Message msg) { + processMessage(msg); + // NOTE: We MUST not call stopSelf() directly, since we need to + // make sure the wake lock acquired by AlertReceiver is released. + AlertReceiver.finishStartingService(AlertService.this, msg.arg1); + } + }; + + @Override + public void onCreate() { + HandlerThread thread = new HandlerThread("AlertService", + Process.THREAD_PRIORITY_BACKGROUND); + thread.start(); + + mServiceLooper = thread.getLooper(); + mServiceHandler = new ServiceHandler(mServiceLooper); + } + + @Override + public void onStart(Intent intent, int startId) { + Message msg = mServiceHandler.obtainMessage(); + msg.arg1 = startId; + msg.obj = intent.getExtras(); + mServiceHandler.sendMessage(msg); + } + + @Override + public void onDestroy() { + mServiceLooper.quit(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } +} diff --git a/src/com/android/calendar/CalendarActivity.java b/src/com/android/calendar/CalendarActivity.java new file mode 100644 index 00000000..639d59c7 --- /dev/null +++ b/src/com/android/calendar/CalendarActivity.java @@ -0,0 +1,360 @@ +/* + * Copyright (C) 2007 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.calendar; + +import dalvik.system.VMRuntime; + +import android.accounts.AccountMonitor; +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.database.ContentObserver; +import android.os.Bundle; +import android.os.Handler; +import android.provider.Calendar; +import android.text.format.Time; +import android.view.GestureDetector; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.widget.ProgressBar; +import android.widget.ViewSwitcher; + +/** + * This is the base class for Day and Week Activities. + */ +public class CalendarActivity extends Activity implements Navigator { + + private static final long INITIAL_HEAP_SIZE = 4*1024*1024; + + protected static final String BUNDLE_KEY_RESTORE_TIME = "key_restore_time"; + + private ContentResolver mContentResolver; + + private AccountMonitor mAccountMonitor; + + protected ProgressBar mProgressBar; + protected ViewSwitcher mViewSwitcher; + protected Animation mInAnimationForward; + protected Animation mOutAnimationForward; + protected Animation mInAnimationBackward; + protected Animation mOutAnimationBackward; + EventLoader mEventLoader; + + Time mSelectedDay = new Time(); + + /* package */ GestureDetector mGestureDetector; + + /** + * Listens for intent broadcasts + */ + private BroadcastReceiver mIntentReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (action.equals(Intent.ACTION_TIME_CHANGED) + || action.equals(Intent.ACTION_DATE_CHANGED) + || action.equals(Intent.ACTION_TIMEZONE_CHANGED)) { + eventsChanged(); + } + } + }; + + // Create an observer so that we can update the views whenever a + // Calendar event changes. + private ContentObserver mObserver = new ContentObserver(new Handler()) + { + @Override + public boolean deliverSelfNotifications() { + return true; + } + + @Override + public void onChange(boolean selfChange) { + eventsChanged(); + } + }; + + @Override + protected void onCreate(Bundle icicle) { + super.onCreate(icicle); + + // Eliminate extra GCs during startup by setting the initial heap size to 4MB. + // TODO: We should restore the old heap size once the activity reaches the idle state + long oldHeapSize = VMRuntime.getRuntime().setMinimumHeapSize(INITIAL_HEAP_SIZE); + + setDefaultKeyMode(DEFAULT_KEYS_SHORTCUT); + mContentResolver = getContentResolver(); + + mInAnimationForward = AnimationUtils.loadAnimation(this, R.anim.slide_left_in); + mOutAnimationForward = AnimationUtils.loadAnimation(this, R.anim.slide_left_out); + mInAnimationBackward = AnimationUtils.loadAnimation(this, R.anim.slide_right_in); + mOutAnimationBackward = AnimationUtils.loadAnimation(this, R.anim.slide_right_out); + + mGestureDetector = new GestureDetector(this, new CalendarGestureListener()); + mEventLoader = new EventLoader(this); + } + + @Override + protected void onRestoreInstanceState(Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + + CalendarView view = (CalendarView) mViewSwitcher.getCurrentView(); + Time time = new Time(); + time.set(savedInstanceState.getLong(BUNDLE_KEY_RESTORE_TIME)); + view.setSelectedDay(time); + } + + @Override + protected void onResume() { + super.onResume(); + mEventLoader.startBackgroundThread(); + eventsChanged(); + + // Register for Intent broadcasts + IntentFilter filter = new IntentFilter(); + + filter.addAction(Intent.ACTION_TIME_CHANGED); + filter.addAction(Intent.ACTION_DATE_CHANGED); + filter.addAction(Intent.ACTION_TIMEZONE_CHANGED); + registerReceiver(mIntentReceiver, filter); + + mContentResolver.registerContentObserver(Calendar.Events.CONTENT_URI, + true, mObserver); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putLong(BUNDLE_KEY_RESTORE_TIME, getSelectedTimeInMillis()); + } + + @Override + protected void onDestroy() { + if (mAccountMonitor != null) { + mAccountMonitor.close(); + } + super.onDestroy(); + } + + @Override + protected void onPause() { + super.onPause(); + mContentResolver.unregisterContentObserver(mObserver); + unregisterReceiver(mIntentReceiver); + + CalendarView view = (CalendarView) mViewSwitcher.getCurrentView(); + view.cleanup(); + view = (CalendarView) mViewSwitcher.getNextView(); + view.cleanup(); + mEventLoader.stopBackgroundThread(); + } + + void startProgressSpinner() { + // start the progress spinner + mProgressBar.setVisibility(View.VISIBLE); + } + + void stopProgressSpinner() { + // stop the progress spinner + mProgressBar.setVisibility(View.GONE); + } + + /* Navigator interface methods */ + public void goTo(Time time) { + CalendarView current = (CalendarView) mViewSwitcher.getCurrentView(); + + if (current.getSelectedTime().before(time)) { + mViewSwitcher.setInAnimation(mInAnimationForward); + mViewSwitcher.setOutAnimation(mOutAnimationForward); + } else { + mViewSwitcher.setInAnimation(mInAnimationBackward); + mViewSwitcher.setOutAnimation(mOutAnimationBackward); + } + + CalendarView next = (CalendarView) mViewSwitcher.getNextView(); + next.setSelectedDay(time); + next.reloadEvents(); + mViewSwitcher.showNext(); + next.requestFocus(); + } + + /** + * Returns the selected time in milliseconds. The milliseconds are measured + * in UTC milliseconds from the epoch and uniquely specifies any selectable + * time. + * + * @return the selected time in milliseconds + */ + public long getSelectedTimeInMillis() { + CalendarView view = (CalendarView) mViewSwitcher.getCurrentView(); + return view.getSelectedTimeInMillis(); + } + + public long getSelectedTime() { + return getSelectedTimeInMillis(); + } + + public void goToToday() { + mSelectedDay.set(System.currentTimeMillis()); + CalendarView view = (CalendarView) mViewSwitcher.getCurrentView(); + view.setSelectedDay(mSelectedDay); + view.reloadEvents(); + } + + public boolean getAllDay() { + CalendarView view = (CalendarView) mViewSwitcher.getCurrentView(); + return view.mSelectionAllDay; + } + + void eventsChanged() { + CalendarView view = (CalendarView) mViewSwitcher.getCurrentView(); + view.clearCachedEvents(); + view.reloadEvents(); + } + + Event getSelectedEvent() { + CalendarView view = (CalendarView) mViewSwitcher.getCurrentView(); + return view.getSelectedEvent(); + } + + boolean isEventSelected() { + CalendarView view = (CalendarView) mViewSwitcher.getCurrentView(); + return view.isEventSelected(); + } + + Event getNewEvent() { + CalendarView view = (CalendarView) mViewSwitcher.getCurrentView(); + return view.getNewEvent(); + } + + public CalendarView getNextView() { + return (CalendarView) mViewSwitcher.getNextView(); + } + + public View switchViews(boolean forward, float xOffSet, float width) { + long offset = 0; + if (xOffSet != 0) { + + // The user might have scrolled the view to the left or right + // in which case we just want to animate the bit left over + // instead of animating all of it. So calculate how much + // it's been moved already and animate the remaining portion + double progress = ((width - (Math.abs(xOffSet))) / width); + long duration = mInAnimationForward.getDuration(); + offset = -1 * (long) (duration - (duration * progress)); + } + if (forward) { + mInAnimationForward.setStartOffset(offset); + mOutAnimationForward.setStartOffset(offset); + mViewSwitcher.setInAnimation(mInAnimationForward); + mViewSwitcher.setOutAnimation(mOutAnimationForward); + } else { + mInAnimationBackward.setStartOffset(offset); + mOutAnimationBackward.setStartOffset(offset); + mViewSwitcher.setInAnimation(mInAnimationBackward); + mViewSwitcher.setOutAnimation(mOutAnimationBackward); + } + CalendarView view = (CalendarView) mViewSwitcher.getCurrentView(); + view.cleanup(); + mViewSwitcher.showNext(); + view = (CalendarView) mViewSwitcher.getCurrentView(); + view.requestFocus(); + view.reloadEvents(); + return view; + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuHelper.onPrepareOptionsMenu(this, menu); + return super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + if (! MenuHelper.onCreateOptionsMenu(menu)) { + return false; + } + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (MenuHelper.onOptionsItemSelected(this, item, this)) { + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + if (mGestureDetector.onTouchEvent(ev)) { + return true; + } + return super.onTouchEvent(ev); + } + + class CalendarGestureListener extends GestureDetector.SimpleOnGestureListener { + @Override + public boolean onSingleTapUp(MotionEvent ev) { + CalendarView view = (CalendarView) mViewSwitcher.getCurrentView(); + view.doSingleTapUp(ev); + return true; + } + + @Override + public void onShowPress(MotionEvent ev) { + CalendarView view = (CalendarView) mViewSwitcher.getCurrentView(); + view.doShowPress(ev); + } + + @Override + public void onLongPress(MotionEvent ev) { + CalendarView view = (CalendarView) mViewSwitcher.getCurrentView(); + view.doLongPress(ev); + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + CalendarView view = (CalendarView) mViewSwitcher.getCurrentView(); + view.doScroll(e1, e2, distanceX, distanceY); + return true; + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + CalendarView view = (CalendarView) mViewSwitcher.getCurrentView(); + view.doFling(e1, e2, velocityX, velocityY); + return true; + } + + @Override + public boolean onDown(MotionEvent ev) { + CalendarView view = (CalendarView) mViewSwitcher.getCurrentView(); + view.doDown(ev); + return true; + } + } +} + diff --git a/src/com/android/calendar/CalendarApplication.java b/src/com/android/calendar/CalendarApplication.java new file mode 100644 index 00000000..a3dad951 --- /dev/null +++ b/src/com/android/calendar/CalendarApplication.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2007 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.calendar; + +import android.app.Application; +import android.preference.PreferenceManager; +import android.util.Log; + +public class CalendarApplication extends Application { + + // TODO: get rid of this global member. + public Event currentEvent = null; + + /** + * The Screen class defines a node in a linked list. This list contains + * the screens that were visited, with the more recently visited screens + * coming earlier in the list. The "next" pointer of the head node + * points to the first element in the list (the most recently visited + * screen). + */ + /* package */ class Screen { + public int id; + public Screen next; + public Screen previous; + + public Screen(int id) { + this.id = id; + next = this; + previous = this; + } + + // Adds the given node to the list after this one + public void insert(Screen node) { + node.next = next; + node.previous = this; + next.previous = node; + next = node; + } + + // Removes this node from the list it is in. + public void unlink() { + next.previous = previous; + previous.next = next; + } + } + + public static final int MONTH_VIEW_ID = 0; + public static final int WEEK_VIEW_ID = 1; + public static final int DAY_VIEW_ID = 2; + public static final int AGENDA_VIEW_ID = 3; + + public static final String[] ACTIVITY_NAMES = new String[] { + MonthActivity.class.getName(), + WeekActivity.class.getName(), + DayActivity.class.getName(), + AgendaActivity.class.getName(), + }; + + @Override + public void onCreate() { + super.onCreate(); + + /* + * Ensure the default values are set for any receiver, activity, + * service, etc. of Calendar + */ + PreferenceManager.setDefaultValues(this, R.xml.preferences, false); + } + +} diff --git a/src/com/android/calendar/CalendarData.java b/src/com/android/calendar/CalendarData.java new file mode 100644 index 00000000..49f3056f --- /dev/null +++ b/src/com/android/calendar/CalendarData.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2006 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.calendar; + +public final class CalendarData { + static final String[] sDateStrings = { "0", "1", "2", "3", "4", "5", "6", + "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", + "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", + "29", "30", "31" }; + + static final String[] sMonthNumStrings = { "1", "2", "3", "4", "5", "6", + "7", "8", "9", "10", "11", "12" }; + + static final String[] s12Hours = { "12 AM", "1 AM", "2 AM", "3 AM", "4 AM", + "5 AM", "6 AM", "7 AM", "8 AM", "9 AM", "10 AM", "11 AM", "Noon", + "1 PM", "2 PM", "3 PM", "4 PM", "5 PM", "6 PM", "7 PM", "8 PM", + "9 PM", "10 PM", "11 PM", "12 AM" }; + + static final String[] s12AmPm = { "AM", "AM", "AM", "AM", "AM", + "AM", "AM", "AM", "AM", "AM", "AM", "AM", "PM", + "PM", "PM", "PM", "PM", "PM", "PM", "PM", "PM", + "PM", "PM", "PM", "AM" }; + + static final String[] s12HoursNoAmPm = { "12", "1", "2", "3", "4", + "5", "6", "7", "8", "9", "10", "11", "12", + "1", "2", "3", "4", "5", "6", "7", "8", + "9", "10", "11", "12" }; + + static final String[] s24Hours = { "00", "01", "02", "03", "04", "05", + "06", "07", "08", "09", "10", "11", "12", "13", "14", "15", "16", + "17", "18", "19", "20", "21", "22", "23", "00" }; + + static final String[] sMinutes = { + ":00", ":01", ":02", ":03", ":04", ":05", ":06", ":07", ":08", ":09", + ":10", ":11", ":12", ":13", ":14", ":15", ":16", ":17", ":18", ":19", + ":20", ":21", ":22", ":23", ":24", ":25", ":26", ":27", ":28", ":29", + ":30", ":31", ":32", ":33", ":34", ":35", ":36", ":37", ":38", ":39", + ":40", ":41", ":42", ":43", ":44", ":45", ":46", ":47", ":48", ":49", + ":50", ":51", ":52", ":53", ":54", ":55", ":56", ":57", ":58", ":59" + }; +} diff --git a/src/com/android/calendar/CalendarPreferenceActivity.java b/src/com/android/calendar/CalendarPreferenceActivity.java new file mode 100644 index 00000000..78d60651 --- /dev/null +++ b/src/com/android/calendar/CalendarPreferenceActivity.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2007 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.calendar; + +import android.content.SharedPreferences; +import android.content.SharedPreferences.OnSharedPreferenceChangeListener; +import android.os.Bundle; +import android.preference.ListPreference; +import android.preference.PreferenceActivity; +import android.preference.PreferenceScreen; +import android.preference.CheckBoxPreference; +import android.preference.RingtonePreference; + +public class CalendarPreferenceActivity extends PreferenceActivity implements OnSharedPreferenceChangeListener { + // Preference keys + static final String KEY_HIDE_DECLINED = "preferences_hide_declined"; + static final String KEY_ALERTS_TYPE = "preferences_alerts_type"; + static final String KEY_ALERTS_VIBRATE = "preferences_alerts_vibrate"; + static final String KEY_ALERTS_RINGTONE = "preferences_alerts_ringtone"; + static final String KEY_DEFAULT_REMINDER = "preferences_default_reminder"; + static final String KEY_START_VIEW = "startView"; + static final String KEY_DETAILED_VIEW = "preferredDetailedView"; + + // These must be in sync with the array preferences_alert_type_values + static final String ALERT_TYPE_ALERTS = "0"; + static final String ALERT_TYPE_STATUS_BAR = "1"; + static final String ALERT_TYPE_OFF = "2"; + + // Default preference values + static final String DEFAULT_START_VIEW = + CalendarApplication.ACTIVITY_NAMES[CalendarApplication.MONTH_VIEW_ID]; + static final String DEFAULT_DETAILED_VIEW = + CalendarApplication.ACTIVITY_NAMES[CalendarApplication.DAY_VIEW_ID]; + + ListPreference mAlertType; + CheckBoxPreference mVibrate; + RingtonePreference mRingtone; + + @Override + protected void onCreate(Bundle icicle) { + super.onCreate(icicle); + + // Load the preferences from an XML resource + addPreferencesFromResource(R.xml.preferences); + + PreferenceScreen preferenceScreen = getPreferenceScreen(); + preferenceScreen.getSharedPreferences().registerOnSharedPreferenceChangeListener(this); + mAlertType = (ListPreference) preferenceScreen.findPreference(KEY_ALERTS_TYPE); + mVibrate = (CheckBoxPreference) preferenceScreen.findPreference(KEY_ALERTS_VIBRATE); + mRingtone = (RingtonePreference) preferenceScreen.findPreference(KEY_ALERTS_RINGTONE); + + updateChildPreferences(); + } + + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if (key.equals(KEY_ALERTS_TYPE)) { + updateChildPreferences(); + } + } + + private void updateChildPreferences() { + if (mAlertType.getValue().equals(ALERT_TYPE_OFF)) { + mVibrate.setChecked(false); + mVibrate.setEnabled(false); + mRingtone.setEnabled(false); + } else { + mVibrate.setEnabled(true); + mRingtone.setEnabled(true); + } + } +} diff --git a/src/com/android/calendar/CalendarView.java b/src/com/android/calendar/CalendarView.java new file mode 100644 index 00000000..a50c44be --- /dev/null +++ b/src/com/android/calendar/CalendarView.java @@ -0,0 +1,2970 @@ +/* + * Copyright (C) 2007 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.calendar; + +import static android.provider.Calendar.EVENT_BEGIN_TIME; +import static android.provider.Calendar.EVENT_END_TIME; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.PorterDuff; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Typeface; +import android.graphics.Paint.Style; +import android.graphics.Path.Direction; +import android.net.Uri; +import android.os.Handler; +import android.provider.Calendar.Attendees; +import android.provider.Calendar.Calendars; +import android.provider.Calendar.Events; +import android.text.format.DateFormat; +import android.text.format.DateUtils; +import android.text.format.Time; +import android.text.TextUtils; +import android.util.Log; +import android.view.ContextMenu; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.ContextMenu.ContextMenuInfo; +import android.widget.ImageView; +import android.widget.PopupWindow; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.Calendar; + +/** + * This is the base class for a set of classes that implement views (day view + * and week view to start with) that share some common code. + */ +public class CalendarView extends View + implements View.OnCreateContextMenuListener, View.OnClickListener { + + private boolean mOnFlingCalled; + + protected CalendarApplication mCalendarApp; + protected CalendarActivity mParentActivity; + + private static final String[] CALENDARS_PROJECTION = new String[] { + Calendars._ID, // 0 + Calendars.ACCESS_LEVEL, // 1 + }; + private static final int CALENDARS_INDEX_ACCESS_LEVEL = 1; + private static final String CALENDARS_WHERE = Calendars._ID + "=%d"; + + private static final String[] ATTENDEES_PROJECTION = new String[] { + Attendees._ID, // 0 + Attendees.ATTENDEE_RELATIONSHIP, // 1 + }; + private static final int ATTENDEES_INDEX_RELATIONSHIP = 1; + private static final String ATTENDEES_WHERE = Attendees.EVENT_ID + "=%d"; + + private static final float SMALL_ROUND_RADIUS = 3.0F; + + private static final int FROM_NONE = 0; + private static final int FROM_ABOVE = 1; + private static final int FROM_BELOW = 2; + private static final int FROM_LEFT = 4; + private static final int FROM_RIGHT = 8; + + private static final int HORIZONTAL_SCROLL_THRESHOLD = 50; + + private ContinueScroll mContinueScroll = new ContinueScroll(); + + // Make this visible within the package for more informative debugging + Time mBaseDate; + + private Typeface mBold = Typeface.DEFAULT_BOLD; + private int mFirstJulianDay; + private int mLastJulianDay; + + private int mMonthLength; + private int mFirstDate; + private int[] mEarliestStartHour; // indexed by the week day offset + private boolean[] mHasAllDayEvent; // indexed by the week day offset + + private String mDetailedView = CalendarPreferenceActivity.DEFAULT_DETAILED_VIEW; + + /** + * This variable helps to avoid unnecessarily reloading events by keeping + * track of the start millis parameter used for the most recent loading + * of events. If the next reload matches this, then the events are not + * reloaded. To force a reload, set this to zero (this is set to zero + * in the method clearCachedEvents()). + */ + private long mLastReloadMillis; + + private ArrayList<Event> mEvents = new ArrayList<Event>(); + private int mSelectionDay; // Julian day + private int mSelectionHour; + + /* package private so that CalendarActivity can read it when creating new + * events + */ + boolean mSelectionAllDay; + + private int mCellWidth; + private boolean mLaunchNewView; + + // Pre-allocate these objects and re-use them + private Rect mRect = new Rect(); + private RectF mRectF = new RectF(); + private Rect mSrcRect = new Rect(); + private Rect mDestRect = new Rect(); + private Paint mPaint = new Paint(); + private Paint mEventTextPaint = new Paint(); + private Paint mSelectionPaint = new Paint(); + private Path mPath = new Path(); + + protected boolean mDrawTextInEventRect; + private int mStartDay; + + private PopupWindow mPopup; + private View mPopupView; + + // The number of milliseconds to show the popup window + private static final int POPUP_DISMISS_DELAY = 3000; + private DismissPopup mDismissPopup = new DismissPopup(); + + // For drawing to an off-screen Canvas + private Bitmap mBitmap; + private Canvas mCanvas; + private boolean mRedrawScreen = true; + private boolean mRemeasure = true; + + private final EventLoader mEventLoader; + protected final EventGeometry mEventGeometry; + + private static final int DAY_GAP = 1; + private static final int HOUR_GAP = 1; + private static final int SINGLE_ALLDAY_HEIGHT = 20; + private static final int MAX_ALLDAY_HEIGHT = 72; + private static final int ALLDAY_TOP_MARGIN = 3; + private static final int MAX_ALLDAY_EVENT_HEIGHT = 18; + + /* The extra space to leave above the text in all-day events */ + private static final int ALL_DAY_TEXT_TOP_MARGIN = 0; + + /* The extra space to leave above the text in normal events */ + private static final int NORMAL_TEXT_TOP_MARGIN = 2; + + private static final int HOURS_LEFT_MARGIN = 2; + private static final int HOURS_RIGHT_MARGIN = 4; + private static final int HOURS_MARGIN = HOURS_LEFT_MARGIN + HOURS_RIGHT_MARGIN; + + /* package */ static final int MINUTES_PER_HOUR = 60; + /* package */ static final int MINUTES_PER_DAY = MINUTES_PER_HOUR * 24; + /* package */ static final int MILLIS_PER_MINUTE = 60 * 1000; + /* package */ static final int MILLIS_PER_HOUR = (3600 * 1000); + /* package */ static final int MILLIS_PER_DAY = MILLIS_PER_HOUR * 24; + + private static final int NORMAL_FONT_SIZE = 12; + private static final int EVENT_TEXT_FONT_SIZE = 12; + private static final int HOURS_FONT_SIZE = 12; + private static final int AMPM_FONT_SIZE = 9; + private static final int MIN_CELL_WIDTH_FOR_TEXT = 10; + private static final int MAX_EVENT_TEXT_LEN = 500; + private static final float MIN_EVENT_HEIGHT = 15.0F; // in pixels + + private static int mSelectionColor; + private static int mPressedColor; + private static int mSelectedEventTextColor; + private static int mEventTextColor; + + private int mViewStartX; + private int mViewStartY; + private int mMaxViewStartY; + private int mBitmapHeight; + private int mViewHeight; + private int mViewWidth; + private int mGridAreaHeight; + private int mCellHeight; + private int mScrollStartY; + private int mPreviousDirection; + private int mPreviousDistanceX; + + private int mHoursTextHeight; + private int mEventTextAscent; + private int mEventTextHeight; + private int mAllDayHeight; + private int mBannerPlusMargin; + private int mMaxAllDayEvents; + + protected int mNumDays = 7; + private int mNumHours = 10; + private int mHoursWidth; + private int mDateStrWidth; + private int mFirstCell; + private int mFirstHour = -1; + private int mFirstHourOffset; + private String[] mHourStrs; + private String[] mDayStrs; + private String[] mDayStrs2Letter; + private boolean mIs24HourFormat; + + private float[] mCharWidths = new float[MAX_EVENT_TEXT_LEN]; + private ArrayList<Event> mSelectedEvents = new ArrayList<Event>(); + private boolean mComputeSelectedEvents; + private Event mSelectedEvent; + private Event mPrevSelectedEvent; + private Rect mPrevBox = new Rect(); + protected final Resources mResources; + private String mAmString; + private String mPmString; + private DeleteEventHelper mDeleteEventHelper; + + private ContextMenuHandler mContextMenuHandler = new ContextMenuHandler(); + + /** + * The initial state of the touch mode when we enter this view. + */ + private static final int TOUCH_MODE_INITIAL_STATE = 0; + + /** + * Indicates we just received the touch event and we are waiting to see if + * it is a tap or a scroll gesture. + */ + private static final int TOUCH_MODE_DOWN = 1; + + /** + * Indicates the touch gesture is a vertical scroll + */ + private static final int TOUCH_MODE_VSCROLL = 0x20; + + /** + * Indicates the touch gesture is a horizontal scroll + */ + private static final int TOUCH_MODE_HSCROLL = 0x40; + + private int mTouchMode = TOUCH_MODE_INITIAL_STATE; + + /** + * The selection modes are HIDDEN, PRESSED, SELECTED, and LONGPRESS. + */ + private static final int SELECTION_HIDDEN = 0; + private static final int SELECTION_PRESSED = 1; + private static final int SELECTION_SELECTED = 2; + private static final int SELECTION_LONGPRESS = 3; + + private int mSelectionMode = SELECTION_HIDDEN; + + private boolean mScrolling = false; + + private String mDateRange; + private TextView mTitleTextView; + + public CalendarView(CalendarActivity activity) { + super(activity); + mResources = activity.getResources(); + mEventLoader = activity.mEventLoader; + mEventGeometry = new EventGeometry(); + mEventGeometry.setMinEventHeight(MIN_EVENT_HEIGHT); + mEventGeometry.setHourGap(HOUR_GAP); + mParentActivity = activity; + mCalendarApp = (CalendarApplication) mParentActivity.getApplication(); + mDeleteEventHelper = new DeleteEventHelper(activity, false /* don't exit when done */); + + init(activity); + } + + private void init(Context context) { + setFocusable(true); + + // Allow focus in touch mode so that we can do keyboard shortcuts + // even after we've entered touch mode. + setFocusableInTouchMode(true); + setClickable(true); + setOnCreateContextMenuListener(this); + + mStartDay = Calendar.getInstance().getFirstDayOfWeek(); + if (mStartDay == Calendar.SATURDAY) { + mStartDay = Time.SATURDAY; + } else if (mStartDay == Calendar.MONDAY) { + mStartDay = Time.MONDAY; + } else { + mStartDay = Time.SUNDAY; + } + + mSelectionColor = mResources.getColor(R.color.selection); + mPressedColor = mResources.getColor(R.color.pressed); + mSelectedEventTextColor = mResources.getColor(R.color.calendar_event_selected_text_color); + mEventTextColor = mResources.getColor(R.color.calendar_event_text_color); + mEventTextPaint.setColor(mEventTextColor); + mEventTextPaint.setTextSize(EVENT_TEXT_FONT_SIZE); + mEventTextPaint.setTextAlign(Paint.Align.LEFT); + mEventTextPaint.setAntiAlias(true); + + int gridLineColor = mResources.getColor(R.color.calendar_grid_line_highlight_color); + Paint p = mSelectionPaint; + p.setColor(gridLineColor); + p.setStyle(Style.STROKE); + p.setStrokeWidth(2.0f); + p.setAntiAlias(false); + + p = mPaint; + p.setAntiAlias(true); + + // Allocate space for 2 weeks worth of weekday names so that we can + // easily start the week display at any week day. + mDayStrs = new String[14]; + + // Also create an array of 2-letter abbreviations. + mDayStrs2Letter = new String[14]; + + for (int i = Calendar.SUNDAY; i <= Calendar.SATURDAY; i++) { + int index = i - Calendar.SUNDAY; + // e.g. Tue for Tuesday + mDayStrs[index] = DateUtils.getDayOfWeekString(i, DateUtils.LENGTH_MEDIUM); + mDayStrs[index + 7] = mDayStrs[index]; + // e.g. Tu for Tuesday + mDayStrs2Letter[index] = DateUtils.getDayOfWeekString(i, DateUtils.LENGTH_SHORT); + mDayStrs2Letter[index + 7] = mDayStrs2Letter[index]; + } + + // Figure out how much space we need for the 3-letter abbrev names + // in the worst case. + p.setTextSize(NORMAL_FONT_SIZE); + p.setTypeface(mBold); + String[] dateStrs = {" 28", " 30"}; + mDateStrWidth = computeMaxStringWidth(0, dateStrs, p); + mDateStrWidth += computeMaxStringWidth(0, mDayStrs, p); + + p.setTextSize(HOURS_FONT_SIZE); + p.setTypeface(null); + mIs24HourFormat = DateFormat.is24HourFormat(context); + mHourStrs = mIs24HourFormat ? CalendarData.s24Hours : CalendarData.s12HoursNoAmPm; + mHoursWidth = computeMaxStringWidth(0, mHourStrs, p); + + mAmString = DateUtils.getAMPMString(Calendar.AM); + mPmString = DateUtils.getAMPMString(Calendar.PM); + String[] ampm = {mAmString, mPmString}; + p.setTextSize(AMPM_FONT_SIZE); + mHoursWidth = computeMaxStringWidth(mHoursWidth, ampm, p); + mHoursWidth += HOURS_MARGIN; + + LayoutInflater inflater; + inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + mPopupView = inflater.inflate(R.layout.bubble_event, null); + mPopupView.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.FILL_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + mPopup = new PopupWindow(context); + mPopup.setContentView(mPopupView); + Resources.Theme dialogTheme = getResources().newTheme(); + dialogTheme.applyStyle(android.R.style.Theme_Dialog, true); + TypedArray ta = dialogTheme.obtainStyledAttributes(new int[] { + android.R.attr.windowBackground }); + mPopup.setBackgroundDrawable(ta.getDrawable(0)); + ta.recycle(); + + // Enable touching the popup window + mPopupView.setOnClickListener(this); + + mBaseDate = new Time(); + long millis = System.currentTimeMillis(); + mBaseDate.set(millis); + + mEarliestStartHour = new int[mNumDays]; + mHasAllDayEvent = new boolean[mNumDays]; + + mNumHours = context.getResources().getInteger(R.integer.number_of_hours); + mTitleTextView = (TextView) mParentActivity.findViewById(R.id.title); + } + + /** + * This is called when the popup window is pressed. + */ + public void onClick(View v) { + if (v == mPopupView) { + // Pretend it was a trackball click because that will always + // jump to the "View event" screen. + switchViews(true /* trackball */); + } + } + + /** + * Returns the start of the selected time in milliseconds since the epoch. + * + * @return selected time in UTC milliseconds since the epoch. + */ + long getSelectedTimeInMillis() { + Time time = new Time(mBaseDate); + time.setJulianDay(mSelectionDay); + time.hour = mSelectionHour; + + // We ignore the "isDst" field because we want normalize() to figure + // out the correct DST value and not adjust the selected time based + // on the current setting of DST. + return time.normalize(true /* ignore isDst */); + } + + Time getSelectedTime() { + Time time = new Time(mBaseDate); + time.setJulianDay(mSelectionDay); + time.hour = mSelectionHour; + + // We ignore the "isDst" field because we want normalize() to figure + // out the correct DST value and not adjust the selected time based + // on the current setting of DST. + time.normalize(true /* ignore isDst */); + return time; + } + + /** + * Returns the start of the selected time in minutes since midnight, + * local time. The derived class must ensure that this is consistent + * with the return value from getSelectedTimeInMillis(). + */ + int getSelectedMinutesSinceMidnight() { + return mSelectionHour * MINUTES_PER_HOUR; + } + + public void setSelectedDay(Time time) { + mBaseDate.set(time); + mSelectionHour = mBaseDate.hour; + mSelectedEvent = null; + mPrevSelectedEvent = null; + long millis = mBaseDate.toMillis(false /* use isDst */); + mSelectionDay = Time.getJulianDay(millis, mBaseDate.gmtoff); + mSelectedEvents.clear(); + mComputeSelectedEvents = true; + + // Force a recalculation of the first visible hour + mFirstHour = -1; + recalc(); + mTitleTextView.setText(mDateRange); + + // Force a redraw of the selection box. + mSelectionMode = SELECTION_SELECTED; + mRedrawScreen = true; + mRemeasure = true; + invalidate(); + } + + public Time getSelectedDay() { + Time time = new Time(mBaseDate); + time.setJulianDay(mSelectionDay); + time.hour = mSelectionHour; + + // We ignore the "isDst" field because we want normalize() to figure + // out the correct DST value and not adjust the selected time based + // on the current setting of DST. + time.normalize(true /* ignore isDst */); + return time; + } + + private void recalc() { + // Set the base date to the beginning of the week if we are displaying + // 7 days at a time. + if (mNumDays == 7) { + int dayOfWeek = mBaseDate.weekDay; + int diff = dayOfWeek - mStartDay; + if (diff != 0) { + if (diff < 0) { + diff += 7; + } + mBaseDate.monthDay -= diff; + mBaseDate.normalize(true /* ignore isDst */); + } + } + + final long start = mBaseDate.toMillis(false /* use isDst */); + long end = start; + mFirstJulianDay = Time.getJulianDay(start, mBaseDate.gmtoff); + mLastJulianDay = mFirstJulianDay + mNumDays - 1; + + mMonthLength = mBaseDate.getActualMaximum(Time.MONTH_DAY); + mFirstDate = mBaseDate.monthDay; + + int flags = DateUtils.FORMAT_SHOW_YEAR; + if (DateFormat.is24HourFormat(mContext)) { + flags |= DateUtils.FORMAT_24HOUR; + } + if (mNumDays > 1) { + mBaseDate.monthDay += mNumDays - 1; + end = mBaseDate.toMillis(true /* ignore isDst */); + mBaseDate.monthDay -= mNumDays - 1; + flags |= DateUtils.FORMAT_NO_MONTH_DAY; + } else { + flags |= DateUtils.FORMAT_SHOW_WEEKDAY + | DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_ABBREV_MONTH; + } + + mDateRange = DateUtils.formatDateRange(mParentActivity, start, end, flags); + // Do not set the title here because this is called when executing + // initNextView() to prepare the Day view when sliding the finger + // horizontally but we don't always want to change the title. And + // if we change the title here and then change it back in the caller + // then we get an annoying flicker. + } + + void setDetailedView(String detailedView) { + mDetailedView = detailedView; + } + + @Override + protected void onSizeChanged(int width, int height, int oldw, int oldh) { + mViewWidth = width; + mViewHeight = height; + int gridAreaWidth = width - mHoursWidth; + mCellWidth = (gridAreaWidth - (mNumDays * DAY_GAP)) / mNumDays; + + Paint p = new Paint(); + p.setTextSize(NORMAL_FONT_SIZE); + int bannerTextHeight = (int) Math.abs(p.ascent()); + + p.setTextSize(HOURS_FONT_SIZE); + mHoursTextHeight = (int) Math.abs(p.ascent()); + + p.setTextSize(EVENT_TEXT_FONT_SIZE); + float ascent = -p.ascent(); + mEventTextAscent = (int) Math.ceil(ascent); + float totalHeight = ascent + p.descent(); + mEventTextHeight = (int) Math.ceil(totalHeight); + + if (mNumDays > 1) { + mBannerPlusMargin = bannerTextHeight + 14; + } else { + mBannerPlusMargin = 0; + } + + remeasure(width, height); + } + + // Measures the space needed for various parts of the view after + // loading new events. This can change if there are all-day events. + private void remeasure(int width, int height) { + + // First, clear the array of earliest start times, and the array + // indicating presence of an all-day event. + for (int day = 0; day < mNumDays; day++) { + mEarliestStartHour[day] = 25; // some big number + mHasAllDayEvent[day] = false; + } + + // Compute the space needed for the all-day events, if any. + // Make a pass over all the events, and keep track of the maximum + // number of all-day events in any one day. Also, keep track of + // the earliest event in each day. + int maxAllDayEvents = 0; + ArrayList<Event> events = mEvents; + int len = events.size(); + for (int ii = 0; ii < len; ii++) { + Event event = events.get(ii); + if (event.startDay > mLastJulianDay || event.endDay < mFirstJulianDay) + continue; + if (event.allDay) { + int max = event.getColumn() + 1; + if (maxAllDayEvents < max) { + maxAllDayEvents = max; + } + int daynum = event.startDay - mFirstJulianDay; + int durationDays = event.endDay - event.startDay + 1; + if (daynum < 0) { + durationDays += daynum; + daynum = 0; + } + if (daynum + durationDays > mNumDays) { + durationDays = mNumDays - daynum; + } + for (int day = daynum; durationDays > 0; day++, durationDays--) { + mHasAllDayEvent[day] = true; + } + } else { + int daynum = event.startDay - mFirstJulianDay; + int hour = event.startTime / 60; + if (daynum >= 0 && hour < mEarliestStartHour[daynum]) { + mEarliestStartHour[daynum] = hour; + } + + // Also check the end hour in case the event spans more than + // one day. + daynum = event.endDay - mFirstJulianDay; + hour = event.endTime / 60; + if (daynum < mNumDays && hour < mEarliestStartHour[daynum]) { + mEarliestStartHour[daynum] = hour; + } + } + } + mMaxAllDayEvents = maxAllDayEvents; + + mFirstCell = mBannerPlusMargin; + int allDayHeight = 0; + if (maxAllDayEvents > 0) { + // If there is at most one all-day event per day, then use less + // space (but more than the space for a single event). + if (maxAllDayEvents == 1) { + allDayHeight = SINGLE_ALLDAY_HEIGHT; + } else { + // Allow the all-day area to grow in height depending on the + // number of all-day events we need to show, up to a limit. + allDayHeight = maxAllDayEvents * MAX_ALLDAY_EVENT_HEIGHT; + if (allDayHeight > MAX_ALLDAY_HEIGHT) { + allDayHeight = MAX_ALLDAY_HEIGHT; + } + } + mFirstCell = mBannerPlusMargin + allDayHeight + ALLDAY_TOP_MARGIN; + } else { + mSelectionAllDay = false; + } + mAllDayHeight = allDayHeight; + + mGridAreaHeight = height - mFirstCell; + mCellHeight = (mGridAreaHeight - ((mNumHours + 1) * HOUR_GAP)) / mNumHours; + int usedGridAreaHeight = (mCellHeight + HOUR_GAP) * mNumHours + HOUR_GAP; + int bottomSpace = mGridAreaHeight - usedGridAreaHeight; + mEventGeometry.setHourHeight(mCellHeight); + + // Create an off-screen bitmap that we can draw into. + mBitmapHeight = HOUR_GAP + 24 * (mCellHeight + HOUR_GAP) + bottomSpace; + if ((mBitmap == null || mBitmap.getHeight() < mBitmapHeight) && width > 0 && + mBitmapHeight > 0) { + if (mBitmap != null) { + mBitmap.recycle(); + } + mBitmap = Bitmap.createBitmap(width, mBitmapHeight, Bitmap.Config.RGB_565); + mCanvas = new Canvas(mBitmap); + } + mMaxViewStartY = mBitmapHeight - mGridAreaHeight; + + if (mFirstHour == -1) { + initFirstHour(); + mFirstHourOffset = 0; + } + + // When we change the base date, the number of all-day events may + // change and that changes the cell height. When we switch dates, + // we use the mFirstHourOffset from the previous view, but that may + // be too large for the new view if the cell height is smaller. + if (mFirstHourOffset >= mCellHeight + HOUR_GAP) { + mFirstHourOffset = mCellHeight + HOUR_GAP - 1; + } + mViewStartY = mFirstHour * (mCellHeight + HOUR_GAP) - mFirstHourOffset; + + int eventAreaWidth = mNumDays * (mCellWidth + DAY_GAP); + mPopup.dismiss(); + mPopup.setWidth(eventAreaWidth - 20); + mPopup.setHeight(WindowManager.LayoutParams.WRAP_CONTENT); + } + + /** + * Initialize the state for another view. The given view is one that has + * its own bitmap and will use an animation to replace the current view. + * The current view and new view are either both Week views or both Day + * views. They differ in their base date. + * + * @param view the view to initialize. + */ + private void initView(CalendarView view) { + view.mSelectionHour = mSelectionHour; + view.mSelectedEvents.clear(); + view.mComputeSelectedEvents = true; + view.mFirstHour = mFirstHour; + view.mFirstHourOffset = mFirstHourOffset; + view.remeasure(getWidth(), getHeight()); + + view.mSelectedEvent = null; + view.mPrevSelectedEvent = null; + view.mStartDay = mStartDay; + if (view.mEvents.size() > 0) { + view.mSelectionAllDay = mSelectionAllDay; + } else { + view.mSelectionAllDay = false; + } + + // Redraw the screen so that the selection box will be redrawn. We may + // have scrolled to a different part of the day in some other view + // so the selection box in this view may no longer be visible. + view.mRedrawScreen = true; + view.recalc(); + } + + /** + * Switch to another view based on what was selected (an event or a free + * slot) and how it was selected (by touch or by trackball). + * + * @param trackBallSelection true if the selection was made using the + * trackball. + */ + private void switchViews(boolean trackBallSelection) { + Event selectedEvent = mSelectedEvent; + + mPopup.dismiss(); + if (mNumDays > 1) { + // This is the Week view. + // With touch, we always switch to Day/Agenda View + // With track ball, if we selected a free slot, then create an event. + // If we selected a specific event, switch to EventInfo view. + if (trackBallSelection) { + if (selectedEvent == null) { + // Switch to the EditEvent view + long startMillis = getSelectedTimeInMillis(); + long endMillis = startMillis + DateUtils.HOUR_IN_MILLIS; + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setClassName(mContext, EditEvent.class.getName()); + intent.putExtra(EVENT_BEGIN_TIME, startMillis); + intent.putExtra(EVENT_END_TIME, endMillis); + mParentActivity.startActivity(intent); + } else { + // Switch to the EventInfo view + Intent intent = new Intent(Intent.ACTION_VIEW); + Uri eventUri = ContentUris.withAppendedId(Events.CONTENT_URI, + selectedEvent.id); + intent.setData(eventUri); + intent.setClassName(mContext, EventInfoActivity.class.getName()); + intent.putExtra(EVENT_BEGIN_TIME, selectedEvent.startMillis); + intent.putExtra(EVENT_END_TIME, selectedEvent.endMillis); + mParentActivity.startActivity(intent); + } + } else { + // This was a touch selection. If the touch selected a single + // unambiguous event, then view that event. Otherwise go to + // Day/Agenda view. + if (mSelectedEvents.size() == 1) { + // Switch to the EventInfo view + Intent intent = new Intent(Intent.ACTION_VIEW); + Uri eventUri = ContentUris.withAppendedId(Events.CONTENT_URI, + selectedEvent.id); + intent.setData(eventUri); + intent.setClassName(mContext, EventInfoActivity.class.getName()); + intent.putExtra(EVENT_BEGIN_TIME, selectedEvent.startMillis); + intent.putExtra(EVENT_END_TIME, selectedEvent.endMillis); + mParentActivity.startActivity(intent); + } else { + // Switch to the Day/Agenda view. + long millis = getSelectedTimeInMillis(); + MenuHelper.switchTo(mParentActivity, mDetailedView, millis); + mParentActivity.finish(); + } + } + } else { + // This is the Day view. + // If we selected a free slot, then create an event. + // If we selected an event, then go to the EventInfo view. + if (selectedEvent == null) { + // Switch to the EditEvent view + long startMillis = getSelectedTimeInMillis(); + long endMillis = startMillis + DateUtils.HOUR_IN_MILLIS; + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setClassName(mContext, EditEvent.class.getName()); + intent.putExtra(EVENT_BEGIN_TIME, startMillis); + intent.putExtra(EVENT_END_TIME, endMillis); + mParentActivity.startActivity(intent); + } else { + // Switch to the EventInfo view + Intent intent = new Intent(Intent.ACTION_VIEW); + Uri eventUri = ContentUris.withAppendedId(Events.CONTENT_URI, selectedEvent.id); + intent.setData(eventUri); + intent.setClassName(mContext, EventInfoActivity.class.getName()); + intent.putExtra(EVENT_BEGIN_TIME, selectedEvent.startMillis); + intent.putExtra(EVENT_END_TIME, selectedEvent.endMillis); + mParentActivity.startActivity(intent); + } + } + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + mScrolling = false; + long duration = event.getEventTime() - event.getDownTime(); + + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_CENTER: + if (mSelectionMode == SELECTION_HIDDEN) { + // Don't do anything unless the selection is visible. + break; + } + + if (mSelectionMode == SELECTION_PRESSED) { + // This was the first press when there was nothing selected. + // Change the selection from the "pressed" state to the + // the "selected" state. We treat short-press and + // long-press the same here because nothing was selected. + mSelectionMode = SELECTION_SELECTED; + mRedrawScreen = true; + invalidate(); + break; + } + + // Check the duration to determine if this was a short press + if (duration < ViewConfiguration.getLongPressTimeout()) { + switchViews(true /* trackball */); + } else { + mSelectionMode = SELECTION_LONGPRESS; + mRedrawScreen = true; + invalidate(); + performLongClick(); + } + break; + } + return super.onKeyUp(keyCode, event); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (mSelectionMode == SELECTION_HIDDEN) { + if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT + || keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == KeyEvent.KEYCODE_DPAD_UP + || keyCode == KeyEvent.KEYCODE_DPAD_DOWN) { + // Display the selection box but don't move or select it + // on this key press. + mSelectionMode = SELECTION_SELECTED; + mRedrawScreen = true; + invalidate(); + return true; + } else if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER) { + // Display the selection box but don't select it + // on this key press. + mSelectionMode = SELECTION_PRESSED; + mRedrawScreen = true; + invalidate(); + return true; + } + } + + mSelectionMode = SELECTION_SELECTED; + mScrolling = false; + boolean redraw; + int selectionDay = mSelectionDay; + + switch (keyCode) { + case KeyEvent.KEYCODE_DEL: + // Delete the selected event, if any + Event selectedEvent = mSelectedEvent; + if (selectedEvent == null) { + return false; + } + mPopup.dismiss(); + + long begin = selectedEvent.startMillis; + long end = selectedEvent.endMillis; + long id = selectedEvent.id; + mDeleteEventHelper.delete(begin, end, id, -1); + return true; + case KeyEvent.KEYCODE_ENTER: + switchViews(true /* trackball or keyboard */); + return true; + case KeyEvent.KEYCODE_BACK: + mPopup.dismiss(); + mParentActivity.finish(); + return true; + case KeyEvent.KEYCODE_DPAD_LEFT: + if (mSelectedEvent != null) { + mSelectedEvent = mSelectedEvent.nextLeft; + } + if (mSelectedEvent == null) { + selectionDay -= 1; + } + redraw = true; + break; + + case KeyEvent.KEYCODE_DPAD_RIGHT: + if (mSelectedEvent != null) { + mSelectedEvent = mSelectedEvent.nextRight; + } + if (mSelectedEvent == null) { + selectionDay += 1; + } + redraw = true; + break; + + case KeyEvent.KEYCODE_DPAD_UP: + if (mSelectedEvent != null) { + mSelectedEvent = mSelectedEvent.nextUp; + } + if (mSelectedEvent == null) { + if (!mSelectionAllDay) { + mSelectionHour -= 1; + adjustHourSelection(); + mSelectedEvents.clear(); + mComputeSelectedEvents = true; + } + } + redraw = true; + break; + + case KeyEvent.KEYCODE_DPAD_DOWN: + if (mSelectedEvent != null) { + mSelectedEvent = mSelectedEvent.nextDown; + } + if (mSelectedEvent == null) { + if (mSelectionAllDay) { + mSelectionAllDay = false; + } else { + mSelectionHour++; + adjustHourSelection(); + mSelectedEvents.clear(); + mComputeSelectedEvents = true; + } + } + redraw = true; + break; + + default: + return super.onKeyDown(keyCode, event); + } + + if ((selectionDay < mFirstJulianDay) || (selectionDay > mLastJulianDay)) { + boolean forward; + CalendarView view = mParentActivity.getNextView(); + Time date = view.mBaseDate; + date.set(mBaseDate); + if (selectionDay < mFirstJulianDay) { + date.monthDay -= mNumDays; + forward = false; + } else { + date.monthDay += mNumDays; + forward = true; + } + date.normalize(true /* ignore isDst */); + view.mSelectionDay = selectionDay; + + initView(view); + mTitleTextView.setText(view.mDateRange); + mParentActivity.switchViews(forward, 0, 0); + return true; + } + mSelectionDay = selectionDay; + mSelectedEvents.clear(); + mComputeSelectedEvents = true; + + if (redraw) { + mRedrawScreen = true; + invalidate(); + return true; + } + + return super.onKeyDown(keyCode, event); + } + + // This is called after scrolling stops to move the selected hour + // to the visible part of the screen. + private void resetSelectedHour() { + if (mSelectionHour < mFirstHour + 1) { + mSelectionHour = mFirstHour + 1; + mSelectedEvent = null; + mSelectedEvents.clear(); + mComputeSelectedEvents = true; + } else if (mSelectionHour > mFirstHour + mNumHours - 3) { + mSelectionHour = mFirstHour + mNumHours - 3; + mSelectedEvent = null; + mSelectedEvents.clear(); + mComputeSelectedEvents = true; + } + } + + private void initFirstHour() { + mFirstHour = mSelectionHour - mNumHours / 2; + if (mFirstHour < 0) { + mFirstHour = 0; + } else if (mFirstHour + mNumHours > 24) { + mFirstHour = 24 - mNumHours; + } + } + + /** + * Recomputes the first full hour that is visible on screen after the + * screen is scrolled. + */ + private void computeFirstHour() { + // Compute the first full hour that is visible on screen + mFirstHour = (mViewStartY + mCellHeight + HOUR_GAP - 1) / (mCellHeight + HOUR_GAP); + mFirstHourOffset = mFirstHour * (mCellHeight + HOUR_GAP) - mViewStartY; + } + + private void adjustHourSelection() { + if (mSelectionHour < 0) { + mSelectionHour = 0; + if (mMaxAllDayEvents > 0) { + mPrevSelectedEvent = null; + mSelectionAllDay = true; + } + } + + if (mSelectionHour > 23) { + mSelectionHour = 23; + } + + // If the selected hour is at least 2 time slots from the top and + // bottom of the screen, then don't scroll the view. + if (mSelectionHour < mFirstHour + 1) { + // If there are all-days events for the selected day but there + // are no more normal events earlier in the day, then jump to + // the all-day event area. + // Exception 1: allow the user to scroll to 8am with the trackball + // before jumping to the all-day event area. + // Exception 2: if 12am is on screen, then allow the user to select + // 12am before going up to the all-day event area. + int daynum = mSelectionDay - mFirstJulianDay; + if (mMaxAllDayEvents > 0 && mEarliestStartHour[daynum] > mSelectionHour + && mFirstHour > 0 && mFirstHour < 8) { + mPrevSelectedEvent = null; + mSelectionAllDay = true; + mSelectionHour = mFirstHour + 1; + return; + } + + if (mFirstHour > 0) { + mFirstHour -= 1; + mViewStartY -= (mCellHeight + HOUR_GAP); + if (mViewStartY < 0) { + mViewStartY = 0; + } + return; + } + } + + if (mSelectionHour > mFirstHour + mNumHours - 3) { + if (mFirstHour < 24 - mNumHours) { + mFirstHour += 1; + mViewStartY += (mCellHeight + HOUR_GAP); + if (mViewStartY > mBitmapHeight - mGridAreaHeight) { + mViewStartY = mBitmapHeight - mGridAreaHeight; + } + return; + } else if (mFirstHour == 24 - mNumHours && mFirstHourOffset > 0) { + mViewStartY = mBitmapHeight - mGridAreaHeight; + } + } + } + + void clearCachedEvents() { + mLastReloadMillis = 0; + } + + private Runnable mCancelCallback = new Runnable() { + public void run() { + clearCachedEvents(); + } + }; + + void reloadEvents() { + // Protect against this being called before this view has been + // initialized. + if (mParentActivity == null) { + return; + } + + mSelectedEvent = null; + mPrevSelectedEvent = null; + mSelectedEvents.clear(); + + // The start date is the beginning of the week at 12am + Time weekStart = new Time(); + weekStart.set(mBaseDate); + weekStart.hour = 0; + weekStart.minute = 0; + weekStart.second = 0; + long millis = weekStart.normalize(true /* ignore isDst */); + + // Avoid reloading events unnecessarily. + if (millis == mLastReloadMillis) { + return; + } + mLastReloadMillis = millis; + + // load events in the background + mParentActivity.startProgressSpinner(); + final ArrayList<Event> events = new ArrayList<Event>(); + mEventLoader.loadEventsInBackground(mNumDays, events, millis, new Runnable() { + public void run() { + mEvents = events; + mRemeasure = true; + mRedrawScreen = true; + mComputeSelectedEvents = true; + recalc(); + mParentActivity.stopProgressSpinner(); + invalidate(); + } + }, mCancelCallback); + } + + @Override + protected void onDraw(Canvas canvas) { + if (mRemeasure) { + remeasure(getWidth(), getHeight()); + mRemeasure = false; + } + + if (mRedrawScreen && mCanvas != null) { + doDraw(mCanvas); + mRedrawScreen = false; + } + + if ((mTouchMode & TOUCH_MODE_HSCROLL) != 0) { + canvas.save(); + if (mViewStartX > 0) { + canvas.translate(mViewWidth - mViewStartX, 0); + } else { + canvas.translate(-(mViewWidth + mViewStartX), 0); + } + CalendarView nextView = mParentActivity.getNextView(); + + // Prevent infinite recursive calls to onDraw(). + nextView.mTouchMode = TOUCH_MODE_INITIAL_STATE; + + nextView.onDraw(canvas); + canvas.restore(); + canvas.save(); + canvas.translate(-mViewStartX, 0); + } + + if (mBitmap != null) { + drawCalendarView(canvas); + } + + // Draw the fixed areas (that don't scroll) directly to the canvas. + drawAfterScroll(canvas); + mComputeSelectedEvents = false; + + if ((mTouchMode & TOUCH_MODE_HSCROLL) != 0) { + canvas.restore(); + } + } + + private void drawCalendarView(Canvas canvas) { + + // Copy the scrollable region from the big bitmap to the canvas. + Rect src = mSrcRect; + Rect dest = mDestRect; + + src.top = mViewStartY; + src.bottom = mViewStartY + mGridAreaHeight; + src.left = 0; + src.right = mViewWidth; + + dest.top = mFirstCell; + dest.bottom = mViewHeight; + dest.left = 0; + dest.right = mViewWidth; + canvas.save(); + canvas.clipRect(dest); + canvas.drawColor(0, PorterDuff.Mode.CLEAR); + canvas.drawBitmap(mBitmap, src, dest, null); + canvas.restore(); + } + + private void drawAfterScroll(Canvas canvas) { + Paint p = mPaint; + Rect r = mRect; + + if (mMaxAllDayEvents != 0) { + drawAllDayEvents(mFirstJulianDay, mNumDays, r, canvas, p); + drawUpperLeftCorner(r, canvas, p); + } + + if (mNumDays > 1) { + drawDayHeaderLoop(r, canvas, p); + } + + // Draw the AM and PM indicators if we're in 12 hour mode + if (!mIs24HourFormat) { + drawAmPm(canvas, p); + } + + // Update the popup window showing the event details, but only if + // we are not scrolling and we have focus. + if (!mScrolling && isFocused()) { + updateEventDetails(); + } + } + + // This isn't really the upper-left corner. It's the square area just + // below the upper-left corner, above the hours and to the left of the + // all-day area. + private void drawUpperLeftCorner(Rect r, Canvas canvas, Paint p) { + p.setColor(mResources.getColor(R.color.calendar_hour_background)); + r.top = mBannerPlusMargin; + r.bottom = r.top + mAllDayHeight + ALLDAY_TOP_MARGIN; + r.left = 0; + r.right = mHoursWidth; + canvas.drawRect(r, p); + } + + private void drawDayHeaderLoop(Rect r, Canvas canvas, Paint p) { + // Draw the horizontal day background banner + p.setColor(mResources.getColor(R.color.calendar_date_banner_background)); + r.top = 0; + r.bottom = mBannerPlusMargin; + r.left = 0; + r.right = mHoursWidth + mNumDays * (mCellWidth + DAY_GAP); + canvas.drawRect(r, p); + + // Fill the extra space on the right side with the default background + r.left = r.right; + r.right = mViewWidth; + p.setColor(mResources.getColor(R.color.calendar_grid_area_background)); + canvas.drawRect(r, p); + + // Draw a highlight on the selected day (if any), but only if we are + // displaying more than one day. + if (mSelectionMode != SELECTION_HIDDEN) { + if (mNumDays > 1) { + p.setColor(mResources.getColor(R.color.calendar_date_selected)); + r.top = 0; + r.bottom = mBannerPlusMargin; + int daynum = mSelectionDay - mFirstJulianDay; + r.left = mHoursWidth + daynum * (mCellWidth + DAY_GAP); + r.right = r.left + mCellWidth; + canvas.drawRect(r, p); + } + } + + p.setTextSize(NORMAL_FONT_SIZE); + p.setTextAlign(Paint.Align.CENTER); + int x = mHoursWidth; + int deltaX = mCellWidth + DAY_GAP; + int cell = mFirstJulianDay; + + String[] dayNames; + if (mDateStrWidth < mCellWidth) { + dayNames = mDayStrs; + } else { + dayNames = mDayStrs2Letter; + } + + for (int day = 0; day < mNumDays; day++, cell++) { + drawDayHeader(dayNames[day + mStartDay], day, cell, x, canvas, p); + x += deltaX; + } + } + + private void drawAmPm(Canvas canvas, Paint p) { + p.setColor(mResources.getColor(R.color.calendar_ampm_label)); + p.setTextSize(AMPM_FONT_SIZE); + p.setTypeface(mBold); + p.setAntiAlias(true); + mPaint.setTextAlign(Paint.Align.RIGHT); + String text = mAmString; + if (mFirstHour >= 12) { + text = mPmString; + } + int y = mFirstCell + mFirstHourOffset + 2 * mHoursTextHeight + HOUR_GAP; + int right = mHoursWidth - HOURS_RIGHT_MARGIN; + canvas.drawText(text, right, y, p); + + if (mFirstHour < 12 && mFirstHour + mNumHours > 12) { + // Also draw the "PM" + text = mPmString; + y = mFirstCell + mFirstHourOffset + (12 - mFirstHour) * (mCellHeight + HOUR_GAP) + + 2 * mHoursTextHeight + HOUR_GAP; + canvas.drawText(text, right, y, p); + } + } + + private void doDraw(Canvas canvas) { + Paint p = mPaint; + Rect r = mRect; + + drawGridBackground(r, canvas, p); + drawHours(r, canvas, p); + + // Draw each day + int x = mHoursWidth; + int deltaX = mCellWidth + DAY_GAP; + int cell = mFirstJulianDay; + for (int day = 0; day < mNumDays; day++, cell++) { + drawEvents(cell, x, HOUR_GAP, canvas, p); + x += deltaX; + } + } + + private void drawHours(Rect r, Canvas canvas, Paint p) { + // Draw the background for the hour labels + p.setColor(mResources.getColor(R.color.calendar_hour_background)); + r.top = 0; + r.bottom = 24 * (mCellHeight + HOUR_GAP) + HOUR_GAP; + r.left = 0; + r.right = mHoursWidth; + canvas.drawRect(r, p); + + // Fill the bottom left corner with the default grid background + r.top = r.bottom; + r.bottom = mBitmapHeight; + p.setColor(mResources.getColor(R.color.calendar_grid_area_background)); + canvas.drawRect(r, p); + + // Draw a highlight on the selected hour (if needed) + if (mSelectionMode != SELECTION_HIDDEN && !mSelectionAllDay) { + p.setColor(mResources.getColor(R.color.calendar_hour_selected)); + r.top = mSelectionHour * (mCellHeight + HOUR_GAP); + r.bottom = r.top + mCellHeight + 2 * HOUR_GAP; + r.left = 0; + r.right = mHoursWidth; + canvas.drawRect(r, p); + + // Also draw the highlight on the grid + p.setColor(mResources.getColor(R.color.calendar_grid_area_selected)); + int daynum = mSelectionDay - mFirstJulianDay; + r.left = mHoursWidth + daynum * (mCellWidth + DAY_GAP); + r.right = r.left + mCellWidth; + canvas.drawRect(r, p); + + // Draw a border around the highlighted grid hour. + Path path = mPath; + r.top += HOUR_GAP; + r.bottom -= HOUR_GAP; + path.reset(); + path.addRect(r.left, r.top, r.right, r.bottom, Direction.CW); + canvas.drawPath(path, mSelectionPaint); + saveSelectionPosition(r.left, r.top, r.right, r.bottom); + } + + p.setColor(mResources.getColor(R.color.calendar_hour_label)); + p.setTextSize(HOURS_FONT_SIZE); + p.setTypeface(mBold); + p.setTextAlign(Paint.Align.RIGHT); + p.setAntiAlias(true); + + int right = mHoursWidth - HOURS_RIGHT_MARGIN; + int y = HOUR_GAP + mHoursTextHeight; + + for (int i = 0; i < 24; i++) { + String time = mHourStrs[i]; + canvas.drawText(time, right, y, p); + y += mCellHeight + HOUR_GAP; + } + } + + private void drawDayHeader(String dateStr, int day, int cell, int x, Canvas canvas, Paint p) { + float xCenter = x + mCellWidth / 2.0f; + + p.setTypeface(mBold); + p.setAntiAlias(true); + + boolean isWeekend = false; + if ((mStartDay == Time.SUNDAY && (day == 0 || day == 6)) + || (mStartDay == Time.MONDAY && (day == 5 || day == 6)) + || (mStartDay == Time.SATURDAY && (day == 0 || day == 1))) { + isWeekend = true; + } + + if (isWeekend) { + p.setColor(mResources.getColor(R.color.week_weekend)); + } else { + p.setColor(mResources.getColor(R.color.calendar_date_banner_text_color)); + } + + int dateNum = mFirstDate + day; + if (dateNum > mMonthLength) { + dateNum -= mMonthLength; + } + + // Add a leading zero if the date is a single digit + if (dateNum < 10) { + dateStr += " 0" + dateNum; + } else { + dateStr += " " + dateNum; + } + + float y = mBannerPlusMargin - 7; + canvas.drawText(dateStr, xCenter, y, p); + } + + private void drawGridBackground(Rect r, Canvas canvas, Paint p) { + Paint.Style savedStyle = p.getStyle(); + + // Clear the background + p.setColor(mResources.getColor(R.color.calendar_grid_area_background)); + r.top = 0; + r.bottom = mBitmapHeight; + r.left = 0; + r.right = mViewWidth; + canvas.drawRect(r, p); + + // Draw the horizontal grid lines + p.setColor(mResources.getColor(R.color.calendar_grid_line_horizontal_color)); + p.setStyle(Style.STROKE); + p.setStrokeWidth(0); + p.setAntiAlias(false); + float startX = mHoursWidth; + float stopX = mHoursWidth + (mCellWidth + DAY_GAP) * mNumDays; + float y = 0; + float deltaY = mCellHeight + HOUR_GAP; + for (int hour = 0; hour <= 24; hour++) { + canvas.drawLine(startX, y, stopX, y, p); + y += deltaY; + } + + // Draw the vertical grid lines + p.setColor(mResources.getColor(R.color.calendar_grid_line_vertical_color)); + float startY = 0; + float stopY = HOUR_GAP + 24 * (mCellHeight + HOUR_GAP); + float deltaX = mCellWidth + DAY_GAP; + float x = mHoursWidth + mCellWidth; + for (int day = 0; day < mNumDays; day++) { + canvas.drawLine(x, startY, x, stopY, p); + x += deltaX; + } + + // Restore the saved style. + p.setStyle(savedStyle); + p.setAntiAlias(true); + } + + Event getSelectedEvent() { + if (mSelectedEvent == null) { + // There is no event at the selected hour, so create a new event. + return getNewEvent(mSelectionDay, getSelectedTimeInMillis(), + getSelectedMinutesSinceMidnight()); + } + return mSelectedEvent; + } + + boolean isEventSelected() { + return (mSelectedEvent != null); + } + + Event getNewEvent() { + return getNewEvent(mSelectionDay, getSelectedTimeInMillis(), + getSelectedMinutesSinceMidnight()); + } + + static Event getNewEvent(int julianDay, long utcMillis, + int minutesSinceMidnight) { + Event event = Event.newInstance(); + event.startDay = julianDay; + event.endDay = julianDay; + event.startMillis = utcMillis; + event.endMillis = event.startMillis + MILLIS_PER_HOUR; + event.startTime = minutesSinceMidnight; + event.endTime = event.startTime + MINUTES_PER_HOUR; + return event; + } + + private int computeMaxStringWidth(int currentMax, String[] strings, Paint p) { + float maxWidthF = 0.0f; + + int len = strings.length; + for (int i = 0; i < len; i++) { + float width = p.measureText(strings[i]); + maxWidthF = Math.max(width, maxWidthF); + } + int maxWidth = (int) (maxWidthF + 0.5); + if (maxWidth < currentMax) { + maxWidth = currentMax; + } + return maxWidth; + } + + private void saveSelectionPosition(float left, float top, float right, float bottom) { + mPrevBox.left = (int) left; + mPrevBox.right = (int) right; + mPrevBox.top = (int) top; + mPrevBox.bottom = (int) bottom; + } + + private Rect getCurrentSelectionPosition() { + Rect box = new Rect(); + box.top = mSelectionHour * (mCellHeight + HOUR_GAP); + box.bottom = box.top + mCellHeight + HOUR_GAP; + int daynum = mSelectionDay - mFirstJulianDay; + box.left = mHoursWidth + daynum * (mCellWidth + DAY_GAP); + box.right = box.left + mCellWidth + DAY_GAP; + return box; + } + + private void drawAllDayEvents(int firstDay, int numDays, + Rect r, Canvas canvas, Paint p) { + p.setTextSize(NORMAL_FONT_SIZE); + p.setTextAlign(Paint.Align.LEFT); + Paint eventTextPaint = mEventTextPaint; + + // Draw the background for the all-day events area + r.top = mBannerPlusMargin; + r.bottom = r.top + mAllDayHeight + ALLDAY_TOP_MARGIN; + r.left = mHoursWidth; + r.right = r.left + mNumDays * (mCellWidth + DAY_GAP); + p.setColor(mResources.getColor(R.color.calendar_all_day_background)); + canvas.drawRect(r, p); + + // Fill the extra space on the right side with the default background + r.left = r.right; + r.right = mViewWidth; + p.setColor(mResources.getColor(R.color.calendar_grid_area_background)); + canvas.drawRect(r, p); + + // Draw the vertical grid lines + p.setColor(mResources.getColor(R.color.calendar_grid_line_vertical_color)); + p.setStyle(Style.STROKE); + p.setStrokeWidth(0); + p.setAntiAlias(false); + float startY = r.top; + float stopY = r.bottom; + float deltaX = mCellWidth + DAY_GAP; + float x = mHoursWidth + mCellWidth; + for (int day = 0; day <= mNumDays; day++) { + canvas.drawLine(x, startY, x, stopY, p); + x += deltaX; + } + p.setAntiAlias(true); + p.setStyle(Style.FILL); + + int y = mBannerPlusMargin + ALLDAY_TOP_MARGIN; + float left = mHoursWidth; + int lastDay = firstDay + numDays - 1; + ArrayList<Event> events = mEvents; + int numEvents = events.size(); + float drawHeight = mAllDayHeight; + float numRectangles = mMaxAllDayEvents; + for (int i = 0; i < numEvents; i++) { + Event event = events.get(i); + if (!event.allDay) + continue; + int startDay = event.startDay; + int endDay = event.endDay; + if (startDay > lastDay || endDay < firstDay) + continue; + if (startDay < firstDay) + startDay = firstDay; + if (endDay > lastDay) + endDay = lastDay; + int startIndex = startDay - firstDay; + int endIndex = endDay - firstDay; + float height = drawHeight / numRectangles; + + // Prevent a single event from getting too big + if (height > MAX_ALLDAY_EVENT_HEIGHT) { + height = MAX_ALLDAY_EVENT_HEIGHT; + } + + // Leave a one-pixel space between the vertical day lines and the + // event rectangle. + event.left = left + startIndex * (mCellWidth + DAY_GAP) + 2; + event.right = left + endIndex * (mCellWidth + DAY_GAP) + mCellWidth - 1; + event.top = y + height * event.getColumn(); + + // Multiply the height by 0.9 to leave a little gap between events + event.bottom = event.top + height * 0.9f; + + RectF rf = drawAllDayEventRect(event, canvas, p, eventTextPaint); + drawEventText(event, rf, canvas, eventTextPaint, ALL_DAY_TEXT_TOP_MARGIN); + + // Check if this all-day event intersects the selected day + if (mSelectionAllDay && mComputeSelectedEvents) { + if (startDay <= mSelectionDay && endDay >= mSelectionDay) { + mSelectedEvents.add(event); + } + } + } + + if (mSelectionAllDay) { + // Compute the neighbors for the list of all-day events that + // intersect the selected day. + computeAllDayNeighbors(); + if (mSelectedEvent != null) { + Event event = mSelectedEvent; + RectF rf = drawAllDayEventRect(event, canvas, p, eventTextPaint); + drawEventText(event, rf, canvas, eventTextPaint, ALL_DAY_TEXT_TOP_MARGIN); + } + + // Draw the highlight on the selected all-day area + float top = mBannerPlusMargin + 1; + float bottom = top + mAllDayHeight + ALLDAY_TOP_MARGIN - 1; + int daynum = mSelectionDay - mFirstJulianDay; + left = mHoursWidth + daynum * (mCellWidth + DAY_GAP) + 1; + float right = left + mCellWidth + DAY_GAP - 1; + if (mNumDays == 1) { + // The Day view doesn't have a vertical line on the right. + right -= 1; + } + Path path = mPath; + path.reset(); + path.addRect(left, top, right, bottom, Direction.CW); + canvas.drawPath(path, mSelectionPaint); + + // Set the selection position to zero so that when we move down + // to the normal event area, we will highlight the topmost event. + saveSelectionPosition(0f, 0f, 0f, 0f); + } + } + + private void computeAllDayNeighbors() { + int len = mSelectedEvents.size(); + if (len == 0 || mSelectedEvent != null) { + return; + } + + // First, clear all the links + for (int ii = 0; ii < len; ii++) { + Event ev = mSelectedEvents.get(ii); + ev.nextUp = null; + ev.nextDown = null; + ev.nextLeft = null; + ev.nextRight = null; + } + + // For each event in the selected event list "mSelectedEvents", find + // its neighbors in the up and down directions. This could be done + // more efficiently by sorting on the Event.getColumn() field, but + // the list is expected to be very small. + + // Find the event in the same row as the previously selected all-day + // event, if any. + int startPosition = -1; + if (mPrevSelectedEvent != null && mPrevSelectedEvent.allDay) { + startPosition = mPrevSelectedEvent.getColumn(); + } + int maxPosition = -1; + Event startEvent = null; + Event maxPositionEvent = null; + for (int ii = 0; ii < len; ii++) { + Event ev = mSelectedEvents.get(ii); + int position = ev.getColumn(); + if (position == startPosition) { + startEvent = ev; + } else if (position > maxPosition) { + maxPositionEvent = ev; + maxPosition = position; + } + for (int jj = 0; jj < len; jj++) { + if (jj == ii) { + continue; + } + Event neighbor = mSelectedEvents.get(jj); + int neighborPosition = neighbor.getColumn(); + if (neighborPosition == position - 1) { + ev.nextUp = neighbor; + } else if (neighborPosition == position + 1) { + ev.nextDown = neighbor; + } + } + } + if (startEvent != null) { + mSelectedEvent = startEvent; + } else { + mSelectedEvent = maxPositionEvent; + } + } + + RectF drawAllDayEventRect(Event event, Canvas canvas, Paint p, Paint eventTextPaint) { + // If this event is selected, then use the selection color + if (mSelectedEvent == event) { + // Also, remember the last selected event that we drew + mPrevSelectedEvent = event; + p.setColor(mSelectionColor); + eventTextPaint.setColor(mSelectedEventTextColor); + } else { + // Use the normal color for all-day events + p.setColor(event.color); + eventTextPaint.setColor(mEventTextColor); + } + + RectF rf = mRectF; + rf.top = event.top; + rf.bottom = event.bottom; + rf.left = event.left; + rf.right = event.right; + canvas.drawRoundRect(rf, SMALL_ROUND_RADIUS, SMALL_ROUND_RADIUS, p); + + rf.left += 2; + rf.right -= 2; + return rf; + } + + private void drawEvents(int date, int left, int top, Canvas canvas, Paint p) { + Paint eventTextPaint = mEventTextPaint; + int cellWidth = mCellWidth; + int cellHeight = mCellHeight; + + // Use the selected hour as the selection region + Rect selectionArea = mRect; + selectionArea.top = top + mSelectionHour * (cellHeight + HOUR_GAP); + selectionArea.bottom = selectionArea.top + cellHeight; + selectionArea.left = left; + selectionArea.right = selectionArea.left + cellWidth; + + ArrayList<Event> events = mEvents; + int numEvents = events.size(); + EventGeometry geometry = mEventGeometry; + + for (int i = 0; i < numEvents; i++) { + Event event = events.get(i); + if (!geometry.computeEventRect(date, left, top, cellWidth, event)) { + continue; + } + + if (date == mSelectionDay && !mSelectionAllDay && mComputeSelectedEvents + && geometry.eventIntersectsSelection(event, selectionArea)) { + mSelectedEvents.add(event); + } + + RectF rf = drawEventRect(event, canvas, p, eventTextPaint); + drawEventText(event, rf, canvas, eventTextPaint, NORMAL_TEXT_TOP_MARGIN); + } + + if (date == mSelectionDay && !mSelectionAllDay && isFocused() + && mSelectionMode != SELECTION_HIDDEN) { + computeNeighbors(); + if (mSelectedEvent != null) { + RectF rf = drawEventRect(mSelectedEvent, canvas, p, eventTextPaint); + drawEventText(mSelectedEvent, rf, canvas, eventTextPaint, NORMAL_TEXT_TOP_MARGIN); + } + } + } + + // Computes the "nearest" neighbor event in four directions (left, right, + // up, down) for each of the events in the mSelectedEvents array. + private void computeNeighbors() { + int len = mSelectedEvents.size(); + if (len == 0 || mSelectedEvent != null) { + return; + } + + // First, clear all the links + for (int ii = 0; ii < len; ii++) { + Event ev = mSelectedEvents.get(ii); + ev.nextUp = null; + ev.nextDown = null; + ev.nextLeft = null; + ev.nextRight = null; + } + + Event startEvent = mSelectedEvents.get(0); + int startEventDistance1 = 100000; // any large number + int startEventDistance2 = 100000; // any large number + int prevLocation = FROM_NONE; + int prevTop; + int prevBottom; + int prevLeft; + int prevRight; + int prevCenter = 0; + Rect box = getCurrentSelectionPosition(); + if (mPrevSelectedEvent != null) { + prevTop = (int) mPrevSelectedEvent.top; + prevBottom = (int) mPrevSelectedEvent.bottom; + prevLeft = (int) mPrevSelectedEvent.left; + prevRight = (int) mPrevSelectedEvent.right; + // Check if the previously selected event intersects the previous + // selection box. (The previously selected event may be from a + // much older selection box.) + if (prevTop >= mPrevBox.bottom || prevBottom <= mPrevBox.top + || prevRight <= mPrevBox.left || prevLeft >= mPrevBox.right) { + mPrevSelectedEvent = null; + prevTop = mPrevBox.top; + prevBottom = mPrevBox.bottom; + prevLeft = mPrevBox.left; + prevRight = mPrevBox.right; + } else { + // Clip the top and bottom to the previous selection box. + if (prevTop < mPrevBox.top) { + prevTop = mPrevBox.top; + } + if (prevBottom > mPrevBox.bottom) { + prevBottom = mPrevBox.bottom; + } + } + } else { + // Just use the previously drawn selection box + prevTop = mPrevBox.top; + prevBottom = mPrevBox.bottom; + prevLeft = mPrevBox.left; + prevRight = mPrevBox.right; + } + + // Figure out where we came from and compute the center of that area. + if (prevLeft >= box.right) { + // The previously selected event was to the right of us. + prevLocation = FROM_RIGHT; + prevCenter = (prevTop + prevBottom) / 2; + } else if (prevRight <= box.left) { + // The previously selected event was to the left of us. + prevLocation = FROM_LEFT; + prevCenter = (prevTop + prevBottom) / 2; + } else if (prevBottom <= box.top) { + // The previously selected event was above us. + prevLocation = FROM_ABOVE; + prevCenter = (prevLeft + prevRight) / 2; + } else if (prevTop >= box.bottom) { + // The previously selected event was below us. + prevLocation = FROM_BELOW; + prevCenter = (prevLeft + prevRight) / 2; + } + + // For each event in the selected event list "mSelectedEvents", search + // all the other events in that list for the nearest neighbor in 4 + // directions. + for (int ii = 0; ii < len; ii++) { + Event ev = mSelectedEvents.get(ii); + + int startTime = ev.startTime; + int endTime = ev.endTime; + int left = (int) ev.left; + int right = (int) ev.right; + int top = (int) ev.top; + if (top < box.top) { + top = box.top; + } + int bottom = (int) ev.bottom; + if (bottom > box.bottom) { + bottom = box.bottom; + } + if (false) { + int flags = DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_ABBREV_ALL + | DateUtils.FORMAT_CAP_NOON_MIDNIGHT; + if (DateFormat.is24HourFormat(mContext)) { + flags |= DateUtils.FORMAT_24HOUR; + } + String timeRange = DateUtils.formatDateRange(mParentActivity, + ev.startMillis, ev.endMillis, flags); + Log.i("Cal", "left: " + left + " right: " + right + " top: " + top + + " bottom: " + bottom + " ev: " + timeRange + " " + ev.title); + } + int upDistanceMin = 10000; // any large number + int downDistanceMin = 10000; // any large number + int leftDistanceMin = 10000; // any large number + int rightDistanceMin = 10000; // any large number + Event upEvent = null; + Event downEvent = null; + Event leftEvent = null; + Event rightEvent = null; + + // Pick the starting event closest to the previously selected event, + // if any. distance1 takes precedence over distance2. + int distance1 = 0; + int distance2 = 0; + if (prevLocation == FROM_ABOVE) { + if (left >= prevCenter) { + distance1 = left - prevCenter; + } else if (right <= prevCenter) { + distance1 = prevCenter - right; + } + distance2 = top - prevBottom; + } else if (prevLocation == FROM_BELOW) { + if (left >= prevCenter) { + distance1 = left - prevCenter; + } else if (right <= prevCenter) { + distance1 = prevCenter - right; + } + distance2 = prevTop - bottom; + } else if (prevLocation == FROM_LEFT) { + if (bottom <= prevCenter) { + distance1 = prevCenter - bottom; + } else if (top >= prevCenter) { + distance1 = top - prevCenter; + } + distance2 = left - prevRight; + } else if (prevLocation == FROM_RIGHT) { + if (bottom <= prevCenter) { + distance1 = prevCenter - bottom; + } else if (top >= prevCenter) { + distance1 = top - prevCenter; + } + distance2 = prevLeft - right; + } + if (distance1 < startEventDistance1 + || (distance1 == startEventDistance1 && distance2 < startEventDistance2)) { + startEvent = ev; + startEventDistance1 = distance1; + startEventDistance2 = distance2; + } + + // For each neighbor, figure out if it is above or below or left + // or right of me and compute the distance. + for (int jj = 0; jj < len; jj++) { + if (jj == ii) { + continue; + } + Event neighbor = mSelectedEvents.get(jj); + int neighborLeft = (int) neighbor.left; + int neighborRight = (int) neighbor.right; + if (neighbor.endTime <= startTime) { + // This neighbor is entirely above me. + // If we overlap the same column, then compute the distance. + if (neighborLeft < right && neighborRight > left) { + int distance = startTime - neighbor.endTime; + if (distance < upDistanceMin) { + upDistanceMin = distance; + upEvent = neighbor; + } else if (distance == upDistanceMin) { + int center = (left + right) / 2; + int currentDistance = 0; + int currentLeft = (int) upEvent.left; + int currentRight = (int) upEvent.right; + if (currentRight <= center) { + currentDistance = center - currentRight; + } else if (currentLeft >= center) { + currentDistance = currentLeft - center; + } + + int neighborDistance = 0; + if (neighborRight <= center) { + neighborDistance = center - neighborRight; + } else if (neighborLeft >= center) { + neighborDistance = neighborLeft - center; + } + if (neighborDistance < currentDistance) { + upDistanceMin = distance; + upEvent = neighbor; + } + } + } + } else if (neighbor.startTime >= endTime) { + // This neighbor is entirely below me. + // If we overlap the same column, then compute the distance. + if (neighborLeft < right && neighborRight > left) { + int distance = neighbor.startTime - endTime; + if (distance < downDistanceMin) { + downDistanceMin = distance; + downEvent = neighbor; + } else if (distance == downDistanceMin) { + int center = (left + right) / 2; + int currentDistance = 0; + int currentLeft = (int) downEvent.left; + int currentRight = (int) downEvent.right; + if (currentRight <= center) { + currentDistance = center - currentRight; + } else if (currentLeft >= center) { + currentDistance = currentLeft - center; + } + + int neighborDistance = 0; + if (neighborRight <= center) { + neighborDistance = center - neighborRight; + } else if (neighborLeft >= center) { + neighborDistance = neighborLeft - center; + } + if (neighborDistance < currentDistance) { + downDistanceMin = distance; + downEvent = neighbor; + } + } + } + } + + if (neighborLeft >= right) { + // This neighbor is entirely to the right of me. + // Take the closest neighbor in the y direction. + int center = (top + bottom) / 2; + int distance = 0; + int neighborBottom = (int) neighbor.bottom; + int neighborTop = (int) neighbor.top; + if (neighborBottom <= center) { + distance = center - neighborBottom; + } else if (neighborTop >= center) { + distance = neighborTop - center; + } + if (distance < rightDistanceMin) { + rightDistanceMin = distance; + rightEvent = neighbor; + } else if (distance == rightDistanceMin) { + // Pick the closest in the x direction + int neighborDistance = neighborLeft - right; + int currentDistance = (int) rightEvent.left - right; + if (neighborDistance < currentDistance) { + rightDistanceMin = distance; + rightEvent = neighbor; + } + } + } else if (neighborRight <= left) { + // This neighbor is entirely to the left of me. + // Take the closest neighbor in the y direction. + int center = (top + bottom) / 2; + int distance = 0; + int neighborBottom = (int) neighbor.bottom; + int neighborTop = (int) neighbor.top; + if (neighborBottom <= center) { + distance = center - neighborBottom; + } else if (neighborTop >= center) { + distance = neighborTop - center; + } + if (distance < leftDistanceMin) { + leftDistanceMin = distance; + leftEvent = neighbor; + } else if (distance == leftDistanceMin) { + // Pick the closest in the x direction + int neighborDistance = left - neighborRight; + int currentDistance = left - (int) leftEvent.right; + if (neighborDistance < currentDistance) { + leftDistanceMin = distance; + leftEvent = neighbor; + } + } + } + } + ev.nextUp = upEvent; + ev.nextDown = downEvent; + ev.nextLeft = leftEvent; + ev.nextRight = rightEvent; + } + mSelectedEvent = startEvent; + } + + + private RectF drawEventRect(Event event, Canvas canvas, Paint p, Paint eventTextPaint) { + + int color = event.color; + + // Fade visible boxes if event was declined. + boolean declined = (event.selfAttendeeStatus == Attendees.ATTENDEE_STATUS_DECLINED); + if (declined) { + int alpha = color & 0xff000000; + color &= 0x00ffffff; + int red = (color & 0x00ff0000) >> 16; + int green = (color & 0x0000ff00) >> 8; + int blue = (color & 0x0000ff); + color = ((red >> 1) << 16) | ((green >> 1) << 8) | (blue >> 1); + color += 0x7F7F7F + alpha; + } + + // If this event is selected, then use the selection color + if (mSelectedEvent == event) { + if (mSelectionMode == SELECTION_PRESSED) { + // Also, remember the last selected event that we drew + mPrevSelectedEvent = event; + // box = mBoxPressed; + p.setColor(mPressedColor); // FIXME:pressed + eventTextPaint.setColor(mSelectedEventTextColor); + } else if (mSelectionMode == SELECTION_SELECTED) { + // Also, remember the last selected event that we drew + mPrevSelectedEvent = event; + // box = mBoxSelected; + p.setColor(mSelectionColor); + eventTextPaint.setColor(mSelectedEventTextColor); + } else if (mSelectionMode == SELECTION_LONGPRESS) { + // box = mBoxLongPressed; + p.setColor(mPressedColor); // FIXME: longpressed (maybe -- this doesn't seem to work) + eventTextPaint.setColor(mSelectedEventTextColor); + } else { + p.setColor(color); + eventTextPaint.setColor(mEventTextColor); + } + } else { + p.setColor(color); + eventTextPaint.setColor(mEventTextColor); + } + + + RectF rf = mRectF; + rf.top = event.top; + rf.bottom = event.bottom; + rf.left = event.left; + rf.right = event.right - 1; + + canvas.drawRoundRect(rf, SMALL_ROUND_RADIUS, SMALL_ROUND_RADIUS, p); + + rf.left += 2; + rf.right -= 2; + + return rf; + } + + private void drawEventText(Event event, RectF rf, Canvas canvas, Paint p, int topMargin) { + if (!mDrawTextInEventRect) { + return; + } + + float width = rf.right - rf.left; + float height = rf.bottom - rf.top; + + // Leave one pixel extra space between lines + int lineHeight = mEventTextHeight + 1; + + // If the rectangle is too small for text, then return + if (width < MIN_CELL_WIDTH_FOR_TEXT || height <= lineHeight) { + return; + } + + // Truncate the event title to a known (large enough) limit + String text = event.getTitleAndLocation(); + int len = text.length(); + if (len > MAX_EVENT_TEXT_LEN) { + text = text.substring(0, MAX_EVENT_TEXT_LEN); + len = MAX_EVENT_TEXT_LEN; + } + + // Figure out how much space the event title will take, and create a + // String fragment that will fit in the rectangle. Use multiple lines, + // if available. + p.getTextWidths(text, mCharWidths); + String fragment = text; + float top = rf.top + mEventTextAscent + topMargin; + int start = 0; + + // Leave one pixel extra space at the bottom + while (start < len && height >= (lineHeight + 1)) { + boolean lastLine = (height < 2 * lineHeight + 1); + // Skip leading spaces at the beginning of each line + do { + char c = text.charAt(start); + if (c != ' ') break; + start += 1; + } while (start < len); + + float sum = 0; + int end = start; + for (int ii = start; ii < len; ii++) { + char c = text.charAt(ii); + + // If we found the end of a word, then remember the ending + // position. + if (c == ' ') { + end = ii; + } + sum += mCharWidths[ii]; + // If adding this character would exceed the width and this + // isn't the last line, then break the line at the previous + // word. If there was no previous word, then break this word. + if (sum > width) { + if (end > start && !lastLine) { + // There was a previous word on this line. + fragment = text.substring(start, end); + start = end; + break; + } + + // This is the only word and it is too long to fit on + // the line (or this is the last line), so take as many + // characters of this word as will fit. + fragment = text.substring(start, ii); + start = ii; + break; + } + } + + // If sum <= width, then we can fit the rest of the text on + // this line. + if (sum <= width) { + fragment = text.substring(start, len); + start = len; + } + + canvas.drawText(fragment, rf.left + 1, top, p); + + top += lineHeight; + height -= lineHeight; + } + } + + private void updateEventDetails() { + if (mSelectedEvent == null || mSelectionMode == SELECTION_HIDDEN + || mSelectionMode == SELECTION_LONGPRESS) { + mPopup.dismiss(); + return; + } + + // Remove any outstanding callbacks to dismiss the popup. + getHandler().removeCallbacks(mDismissPopup); + + Event event = mSelectedEvent; + TextView titleView = (TextView) mPopupView.findViewById(R.id.event_title); + titleView.setText(event.title); + + ImageView imageView = (ImageView) mPopupView.findViewById(R.id.reminder_icon); + imageView.setVisibility(event.hasAlarm ? View.VISIBLE : View.GONE); + + imageView = (ImageView) mPopupView.findViewById(R.id.repeat_icon); + imageView.setVisibility(event.isRepeating ? View.VISIBLE : View.GONE); + + int flags; + if (event.allDay) { + flags = DateUtils.FORMAT_UTC | DateUtils.FORMAT_SHOW_DATE | + DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_ABBREV_ALL; + } else { + flags = DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_SHOW_DATE + | DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_ABBREV_ALL + | DateUtils.FORMAT_CAP_NOON_MIDNIGHT; + } + if (DateFormat.is24HourFormat(mContext)) { + flags |= DateUtils.FORMAT_24HOUR; + } + String timeRange = DateUtils.formatDateRange(mParentActivity, + event.startMillis, event.endMillis, flags); + TextView timeView = (TextView) mPopupView.findViewById(R.id.time); + timeView.setText(timeRange); + + TextView whereView = (TextView) mPopupView.findViewById(R.id.where); + final boolean empty = TextUtils.isEmpty(event.location); + whereView.setVisibility(empty ? View.GONE : View.VISIBLE); + if (!empty) whereView.setText(event.location); + + mPopup.showAtLocation(this, Gravity.BOTTOM | Gravity.LEFT, mHoursWidth, 5); + postDelayed(mDismissPopup, POPUP_DISMISS_DELAY); + } + + // The following routines are called from the parent activity when certain + // touch events occur. + + void doDown(MotionEvent ev) { + mTouchMode = TOUCH_MODE_DOWN; + mViewStartX = 0; + mOnFlingCalled = false; + mLaunchNewView = false; + getHandler().removeCallbacks(mContinueScroll); + } + + void doSingleTapUp(MotionEvent ev) { + mSelectionMode = SELECTION_SELECTED; + mRedrawScreen = true; + invalidate(); + if (mLaunchNewView) { + mLaunchNewView = false; + switchViews(false /* not the trackball */); + } + } + + void doShowPress(MotionEvent ev) { + int x = (int) ev.getX(); + int y = (int) ev.getY(); + Event selectedEvent = mSelectedEvent; + int selectedDay = mSelectionDay; + int selectedHour = mSelectionHour; + + boolean validPosition = setSelectionFromPosition(x, y); + if (!validPosition) { + return; + } + + mSelectionMode = SELECTION_PRESSED; + mRedrawScreen = true; + invalidate(); + + // If the tap is on an already selected event or hour slot, + // then launch a new view. Otherwise, just select the event. + if (selectedEvent != null && selectedEvent == mSelectedEvent) { + // Launch the "View event" view when the finger lifts up, + // unless the finger moves before lifting up. + mLaunchNewView = true; + } else if (selectedEvent == null && selectedDay == mSelectionDay + && selectedHour == mSelectionHour) { + // Launch the Day/Agenda view when the finger lifts up, + // unless the finger moves before lifting up. + mLaunchNewView = true; + } + } + + void doLongPress(MotionEvent ev) { + mLaunchNewView = false; + mSelectionMode = SELECTION_LONGPRESS; + mRedrawScreen = true; + invalidate(); + performLongClick(); + } + + void doScroll(MotionEvent e1, MotionEvent e2, float deltaX, float deltaY) { + mLaunchNewView = false; + // Use the distance from the current point to the initial touch instead + // of deltaX and deltaY to avoid accumulating floating-point rounding + // errors. Also, we don't need floats, we can use ints. + int distanceX = (int) e1.getX() - (int) e2.getX(); + int distanceY = (int) e1.getY() - (int) e2.getY(); + + // If we haven't figured out the predominant scroll direction yet, + // then do it now. + if (mTouchMode == TOUCH_MODE_DOWN) { + int absDistanceX = Math.abs(distanceX); + int absDistanceY = Math.abs(distanceY); + mScrollStartY = mViewStartY; + mPreviousDistanceX = 0; + mPreviousDirection = 0; + + // If the x distance is at least twice the y distance, then lock + // the scroll horizontally. Otherwise scroll vertically. + if (absDistanceX >= 2 * absDistanceY) { + mTouchMode = TOUCH_MODE_HSCROLL; + mViewStartX = distanceX; + initNextView(-mViewStartX); + } else { + mTouchMode = TOUCH_MODE_VSCROLL; + } + } else if ((mTouchMode & TOUCH_MODE_HSCROLL) != 0) { + // We are already scrolling horizontally, so check if we + // changed the direction of scrolling so that the other week + // is now visible. + mViewStartX = distanceX; + if (distanceX != 0) { + int direction = (distanceX > 0) ? 1 : -1; + if (direction != mPreviousDirection) { + // The user has switched the direction of scrolling + // so re-init the next view + initNextView(-mViewStartX); + mPreviousDirection = direction; + } + } + + // If we have moved at least the HORIZONTAL_SCROLL_THRESHOLD, + // then change the title to the new day (or week), but only + // if we haven't already changed the title. + if (distanceX >= HORIZONTAL_SCROLL_THRESHOLD) { + if (mPreviousDistanceX < HORIZONTAL_SCROLL_THRESHOLD) { + CalendarView view = mParentActivity.getNextView(); + mTitleTextView.setText(view.mDateRange); + } + } else if (distanceX <= -HORIZONTAL_SCROLL_THRESHOLD) { + if (mPreviousDistanceX > -HORIZONTAL_SCROLL_THRESHOLD) { + CalendarView view = mParentActivity.getNextView(); + mTitleTextView.setText(view.mDateRange); + } + } else { + if (mPreviousDistanceX >= HORIZONTAL_SCROLL_THRESHOLD + || mPreviousDistanceX <= -HORIZONTAL_SCROLL_THRESHOLD) { + mTitleTextView.setText(mDateRange); + } + } + mPreviousDistanceX = distanceX; + } + + if ((mTouchMode & TOUCH_MODE_VSCROLL) != 0) { + mViewStartY = mScrollStartY + distanceY; + if (mViewStartY < 0) { + mViewStartY = 0; + } else if (mViewStartY > mMaxViewStartY) { + mViewStartY = mMaxViewStartY; + } + computeFirstHour(); + } + + mScrolling = true; + + if (mSelectionMode != SELECTION_HIDDEN) { + mSelectionMode = SELECTION_HIDDEN; + mRedrawScreen = true; + } + invalidate(); + } + + void doFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + mTouchMode = TOUCH_MODE_INITIAL_STATE; + mSelectionMode = SELECTION_HIDDEN; + mOnFlingCalled = true; + int deltaX = (int) e2.getX() - (int) e1.getX(); + int distanceX = Math.abs(deltaX); + int deltaY = (int) e2.getY() - (int) e1.getY(); + int distanceY = Math.abs(deltaY); + + if ((distanceX >= HORIZONTAL_SCROLL_THRESHOLD) && (distanceX > distanceY)) { + boolean switchForward = initNextView(deltaX); + CalendarView view = mParentActivity.getNextView(); + mTitleTextView.setText(view.mDateRange); + mParentActivity.switchViews(switchForward, mViewStartX, mViewWidth); + mViewStartX = 0; + return; + } + + // Continue scrolling vertically + mContinueScroll.init((int) velocityY / 20); + post(mContinueScroll); + } + + private boolean initNextView(int deltaX) { + // Change the view to the previous day or week + CalendarView view = mParentActivity.getNextView(); + Time date = view.mBaseDate; + date.set(mBaseDate); + boolean switchForward; + if (deltaX > 0) { + date.monthDay -= mNumDays; + view.mSelectionDay = mSelectionDay - mNumDays; + switchForward = false; + } else { + date.monthDay += mNumDays; + view.mSelectionDay = mSelectionDay + mNumDays; + switchForward = true; + } + date.normalize(true /* ignore isDst */); + initView(view); + view.setFrame(getLeft(), getTop(), getRight(), getBottom()); + view.reloadEvents(); + return switchForward; + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + int action = ev.getAction(); + + switch (action) { + case MotionEvent.ACTION_DOWN: + mParentActivity.mGestureDetector.onTouchEvent(ev); + return true; + + case MotionEvent.ACTION_MOVE: + mParentActivity.mGestureDetector.onTouchEvent(ev); + return true; + + case MotionEvent.ACTION_UP: + mParentActivity.mGestureDetector.onTouchEvent(ev); + if (mOnFlingCalled) { + return true; + } + if ((mTouchMode & TOUCH_MODE_HSCROLL) != 0) { + mTouchMode = TOUCH_MODE_INITIAL_STATE; + if (Math.abs(mViewStartX) > HORIZONTAL_SCROLL_THRESHOLD) { + // The user has gone beyond the threshold so switch views + mParentActivity.switchViews(mViewStartX > 0, mViewStartX, mViewWidth); + } else { + // Not beyond the threshold so invalidate which will cause + // the view to snap back. Also call recalc() to ensure + // that we have the correct starting date and title. + recalc(); + mTitleTextView.setText(mDateRange); + invalidate(); + } + mViewStartX = 0; + } + + // If we were scrolling, then reset the selected hour so that it + // is visible. + if (mScrolling) { + mScrolling = false; + resetSelectedHour(); + mRedrawScreen = true; + invalidate(); + } + return true; + + // This case isn't expected to happen. + case MotionEvent.ACTION_CANCEL: + mParentActivity.mGestureDetector.onTouchEvent(ev); + mScrolling = false; + resetSelectedHour(); + return true; + + default: + if (mParentActivity.mGestureDetector.onTouchEvent(ev)) { + return true; + } + return super.onTouchEvent(ev); + } + } + + public void onCreateContextMenu(ContextMenu menu, View view, ContextMenuInfo menuInfo) { + MenuItem item; + + // If the trackball is held down, then the context menu pops up and + // we never get onKeyUp() for the long-press. So check for it here + // and change the selection to the long-press state. + if (mSelectionMode != SELECTION_LONGPRESS) { + mSelectionMode = SELECTION_LONGPRESS; + mRedrawScreen = true; + invalidate(); + } + + final long startMillis = getSelectedTimeInMillis(); + int flags = DateUtils.FORMAT_SHOW_TIME + | DateUtils.FORMAT_CAP_NOON_MIDNIGHT + | DateUtils.FORMAT_SHOW_WEEKDAY; + final String title = DateUtils.formatDateTime(mParentActivity, startMillis, flags); + menu.setHeaderTitle(title); + + int numSelectedEvents = mSelectedEvents.size(); + if (mNumDays == 1) { + // Day view. + + // If there is a selected event, then allow it to be viewed and + // edited. + if (numSelectedEvents >= 1) { + item = menu.add(0, MenuHelper.MENU_EVENT_VIEW, 0, R.string.event_view); + item.setOnMenuItemClickListener(mContextMenuHandler); + item.setIcon(android.R.drawable.ic_menu_info_details); + + if (isEventEditable(mContext, mSelectedEvent)) { + item = menu.add(0, MenuHelper.MENU_EVENT_EDIT, 0, R.string.event_edit); + item.setOnMenuItemClickListener(mContextMenuHandler); + item.setIcon(android.R.drawable.ic_menu_edit); + item.setAlphabeticShortcut('e'); + + item = menu.add(0, MenuHelper.MENU_EVENT_DELETE, 0, R.string.event_delete); + item.setOnMenuItemClickListener(mContextMenuHandler); + item.setIcon(android.R.drawable.ic_menu_delete); + } + + item = menu.add(0, MenuHelper.MENU_EVENT_CREATE, 0, R.string.event_create); + item.setOnMenuItemClickListener(mContextMenuHandler); + item.setIcon(android.R.drawable.ic_menu_add); + item.setAlphabeticShortcut('n'); + } else { + // Otherwise, if the user long-pressed on a blank hour, allow + // them to create an event. They can also do this by tapping. + item = menu.add(0, MenuHelper.MENU_EVENT_CREATE, 0, R.string.event_create); + item.setOnMenuItemClickListener(mContextMenuHandler); + item.setIcon(android.R.drawable.ic_menu_add); + item.setAlphabeticShortcut('n'); + } + } else { + // Week view. + + // If there is a selected event, then allow it to be viewed and + // edited. + if (numSelectedEvents >= 1) { + item = menu.add(0, MenuHelper.MENU_EVENT_VIEW, 0, R.string.event_view); + item.setOnMenuItemClickListener(mContextMenuHandler); + item.setIcon(android.R.drawable.ic_menu_info_details); + + if (isEventEditable(mContext, mSelectedEvent)) { + item = menu.add(0, MenuHelper.MENU_EVENT_EDIT, 0, R.string.event_edit); + item.setOnMenuItemClickListener(mContextMenuHandler); + item.setIcon(android.R.drawable.ic_menu_edit); + item.setAlphabeticShortcut('e'); + + item = menu.add(0, MenuHelper.MENU_EVENT_DELETE, 0, R.string.event_delete); + item.setOnMenuItemClickListener(mContextMenuHandler); + item.setIcon(android.R.drawable.ic_menu_delete); + } + + item = menu.add(0, MenuHelper.MENU_EVENT_CREATE, 0, R.string.event_create); + item.setOnMenuItemClickListener(mContextMenuHandler); + item.setIcon(android.R.drawable.ic_menu_add); + item.setAlphabeticShortcut('n'); + + item = menu.add(0, MenuHelper.MENU_DAY, 0, R.string.show_day_view); + item.setOnMenuItemClickListener(mContextMenuHandler); + item.setIcon(android.R.drawable.ic_menu_day); + item.setAlphabeticShortcut('d'); + + item = menu.add(0, MenuHelper.MENU_AGENDA, 0, R.string.show_agenda_view); + item.setOnMenuItemClickListener(mContextMenuHandler); + item.setIcon(android.R.drawable.ic_menu_agenda); + item.setAlphabeticShortcut('a'); + } else { + // No events are selected + item = menu.add(0, MenuHelper.MENU_EVENT_CREATE, 0, R.string.event_create); + item.setOnMenuItemClickListener(mContextMenuHandler); + item.setIcon(android.R.drawable.ic_menu_add); + item.setAlphabeticShortcut('n'); + + item = menu.add(0, MenuHelper.MENU_DAY, 0, R.string.show_day_view); + item.setOnMenuItemClickListener(mContextMenuHandler); + item.setIcon(android.R.drawable.ic_menu_day); + item.setAlphabeticShortcut('d'); + + item = menu.add(0, MenuHelper.MENU_AGENDA, 0, R.string.show_agenda_view); + item.setOnMenuItemClickListener(mContextMenuHandler); + item.setIcon(android.R.drawable.ic_menu_agenda); + item.setAlphabeticShortcut('a'); + } + } + + mPopup.dismiss(); + } + + private class ContextMenuHandler implements MenuItem.OnMenuItemClickListener { + public boolean onMenuItemClick(MenuItem item) { + switch (item.getItemId()) { + case MenuHelper.MENU_EVENT_VIEW: { + if (mSelectedEvent != null) { + long id = mSelectedEvent.id; + Uri eventUri = ContentUris.withAppendedId(Events.CONTENT_URI, id); + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(eventUri); + intent.setClassName(mContext, EventInfoActivity.class.getName()); + intent.putExtra(EVENT_BEGIN_TIME, mSelectedEvent.startMillis); + intent.putExtra(EVENT_END_TIME, mSelectedEvent.endMillis); + mParentActivity.startActivity(intent); + } + break; + } + case MenuHelper.MENU_EVENT_EDIT: { + if (mSelectedEvent != null) { + long id = mSelectedEvent.id; + Uri eventUri = ContentUris.withAppendedId(Events.CONTENT_URI, id); + Intent intent = new Intent(Intent.ACTION_EDIT); + intent.setData(eventUri); + intent.setClassName(mContext, EditEvent.class.getName()); + intent.putExtra(EVENT_BEGIN_TIME, mSelectedEvent.startMillis); + intent.putExtra(EVENT_END_TIME, mSelectedEvent.endMillis); + mParentActivity.startActivity(intent); + } + break; + } + case MenuHelper.MENU_DAY: { + long startMillis = getSelectedTimeInMillis(); + MenuHelper.switchTo(mParentActivity, DayActivity.class.getName(), startMillis); + mParentActivity.finish(); + break; + } + case MenuHelper.MENU_AGENDA: { + long startMillis = getSelectedTimeInMillis(); + MenuHelper.switchTo(mParentActivity, AgendaActivity.class.getName(), startMillis); + mParentActivity.finish(); + break; + } + case MenuHelper.MENU_EVENT_CREATE: { + long startMillis = getSelectedTimeInMillis(); + long endMillis = startMillis + DateUtils.HOUR_IN_MILLIS; + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setClassName(mContext, EditEvent.class.getName()); + intent.putExtra(EVENT_BEGIN_TIME, startMillis); + intent.putExtra(EVENT_END_TIME, endMillis); + intent.putExtra(EditEvent.EVENT_ALL_DAY, mSelectionAllDay); + mParentActivity.startActivity(intent); + break; + } + case MenuHelper.MENU_EVENT_DELETE: { + if (mSelectedEvent != null) { + Event selectedEvent = mSelectedEvent; + long begin = selectedEvent.startMillis; + long end = selectedEvent.endMillis; + long id = selectedEvent.id; + mDeleteEventHelper.delete(begin, end, id, -1); + } + break; + } + default: { + return false; + } + } + return true; + } + } + + private static boolean isEventEditable(Context context, Event e) { + ContentResolver cr = context.getContentResolver(); + + int visibility = Calendars.NO_ACCESS; + int relationship = Attendees.RELATIONSHIP_ORGANIZER; + + // Get the calendar id for this event + Cursor cursor = cr.query(ContentUris.withAppendedId(Events.CONTENT_URI, e.id), + new String[] { Events.CALENDAR_ID }, + null /* selection */, + null /* selectionArgs */, + null /* sort */); + if ((cursor == null) || (cursor.getCount() == 0)) { + return false; + } + cursor.moveToFirst(); + long calId = cursor.getLong(0); + cursor.deactivate(); + + Uri uri = Calendars.CONTENT_URI; + String where = String.format(CALENDARS_WHERE, calId); + cursor = cr.query(uri, CALENDARS_PROJECTION, where, null, null); + + if (cursor != null) { + cursor.moveToFirst(); + visibility = cursor.getInt(CALENDARS_INDEX_ACCESS_LEVEL); + cursor.close(); + } + + // Attendees cursor + uri = Attendees.CONTENT_URI; + where = String.format(ATTENDEES_WHERE, e.id); + Cursor attendeesCursor = cr.query(uri, ATTENDEES_PROJECTION, where, null, null); + if (attendeesCursor != null) { + if (attendeesCursor.moveToFirst()) { + relationship = attendeesCursor.getInt(ATTENDEES_INDEX_RELATIONSHIP); + } + attendeesCursor.close(); + } + + return visibility >= Calendars.CONTRIBUTOR_ACCESS && + relationship >= Attendees.RELATIONSHIP_ORGANIZER; + } + + /** + * Sets mSelectionDay and mSelectionHour based on the (x,y) touch position. + * If the touch position is not within the displayed grid, then this + * method returns false. + * + * @param x the x position of the touch + * @param y the y position of the touch + * @return true if the touch position is valid + */ + private boolean setSelectionFromPosition(int x, int y) { + if (x < mHoursWidth) { + return false; + } + + int day = (x - mHoursWidth) / (mCellWidth + DAY_GAP); + if (day >= mNumDays) { + day = mNumDays - 1; + } + day += mFirstJulianDay; + int hour; + if (y < mFirstCell + mFirstHourOffset) { + mSelectionAllDay = true; + } else { + hour = (y - mFirstCell - mFirstHourOffset) / (mCellHeight + HOUR_GAP); + hour += mFirstHour; + mSelectionHour = hour; + mSelectionAllDay = false; + } + mSelectionDay = day; + findSelectedEvent(x, y); +// Log.i("Cal", "setSelectionFromPosition( " + x + ", " + y + " ) day: " + day +// + " hour: " + hour +// + " mFirstCell: " + mFirstCell + " mFirstHourOffset: " + mFirstHourOffset); +// if (mSelectedEvent != null) { +// Log.i("Cal", " num events: " + mSelectedEvents.size() + " event: " + mSelectedEvent.title); +// for (Event ev : mSelectedEvents) { +// int flags = DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_ABBREV_ALL +// | DateUtils.FORMAT_CAP_NOON_MIDNIGHT; +// String timeRange = formatDateRange(mParentActivity, +// ev.startMillis, ev.endMillis, flags); +// +// Log.i("Cal", " " + timeRange + " " + ev.title); +// } +// } + return true; + } + + private void findSelectedEvent(int x, int y) { + int date = mSelectionDay; + int cellWidth = mCellWidth; + ArrayList<Event> events = mEvents; + int numEvents = events.size(); + int left = mHoursWidth + (mSelectionDay - mFirstJulianDay) * (cellWidth + DAY_GAP); + int top = 0; + mSelectedEvent = null; + + mSelectedEvents.clear(); + if (mSelectionAllDay) { + float yDistance; + float minYdistance = 10000.0f; // any large number + Event closestEvent = null; + float drawHeight = mAllDayHeight; + int yOffset = mBannerPlusMargin + ALLDAY_TOP_MARGIN; + for (int i = 0; i < numEvents; i++) { + Event event = events.get(i); + if (!event.allDay) { + continue; + } + + if (event.startDay <= mSelectionDay && event.endDay >= mSelectionDay) { + float numRectangles = event.getMaxColumns(); + float height = drawHeight / numRectangles; + if (height > MAX_ALLDAY_EVENT_HEIGHT) { + height = MAX_ALLDAY_EVENT_HEIGHT; + } + float eventTop = yOffset + height * event.getColumn(); + float eventBottom = eventTop + height; + if (eventTop < y && eventBottom > y) { + // If the touch is inside the event rectangle, then + // add the event. + mSelectedEvents.add(event); + closestEvent = event; + break; + } else { + // Find the closest event + if (eventTop >= y) { + yDistance = eventTop - y; + } else { + yDistance = y - eventBottom; + } + if (yDistance < minYdistance) { + minYdistance = yDistance; + closestEvent = event; + } + } + } + } + mSelectedEvent = closestEvent; + return; + } + + // Adjust y for the scrollable bitmap + y += mViewStartY - mFirstCell; + + // Use a region around (x,y) for the selection region + Rect region = mRect; + region.left = x - 10; + region.right = x + 10; + region.top = y - 10; + region.bottom = y + 10; + + EventGeometry geometry = mEventGeometry; + + for (int i = 0; i < numEvents; i++) { + Event event = events.get(i); + // Compute the event rectangle. + if (!geometry.computeEventRect(date, left, top, cellWidth, event)) { + continue; + } + + // If the event intersects the selection region, then add it to + // mSelectedEvents. + if (geometry.eventIntersectsSelection(event, region)) { + mSelectedEvents.add(event); + } + } + + // If there are any events in the selected region, then assign the + // closest one to mSelectedEvent. + if (mSelectedEvents.size() > 0) { + int len = mSelectedEvents.size(); + Event closestEvent = null; + float minDist = mViewWidth + mViewHeight; // some large distance + for (int index = 0; index < len; index++) { + Event ev = mSelectedEvents.get(index); + float dist = geometry.pointToEvent(x, y, ev); + if (dist < minDist) { + minDist = dist; + closestEvent = ev; + } + } + mSelectedEvent = closestEvent; + + // Keep the selected hour and day consistent with the selected + // event. They could be different if we touched on an empty hour + // slot very close to an event in the previous hour slot. In + // that case we will select the nearby event. + int startDay = mSelectedEvent.startDay; + int endDay = mSelectedEvent.endDay; + if (mSelectionDay < startDay) { + mSelectionDay = startDay; + } else if (mSelectionDay > endDay) { + mSelectionDay = endDay; + } + + int startHour = mSelectedEvent.startTime / 60; + int endHour; + if (mSelectedEvent.startTime < mSelectedEvent.endTime) { + endHour = (mSelectedEvent.endTime - 1) / 60; + } else { + endHour = mSelectedEvent.endTime / 60; + } + + if (mSelectionHour < startHour) { + mSelectionHour = startHour; + } else if (mSelectionHour > endHour) { + mSelectionHour = endHour; + } + } + } + + // Encapsulates the code to continue the scrolling after the + // finger is lifted. Instead of stopping the scroll immediately, + // the scroll continues to "free spin" and gradually slows down. + private class ContinueScroll implements Runnable { + int mSignDeltaY; + int mAbsDeltaY; + float mFloatDeltaY; + long mFreeSpinTime; + private static final float FRICTION_COEF = 0.7F; + private static final long FREE_SPIN_MILLIS = 180; + private static final int MAX_DELTA = 60; + private static final int SCROLL_REPEAT_INTERVAL = 30; + + public void init(int deltaY) { + mSignDeltaY = 0; + if (deltaY > 0) { + mSignDeltaY = 1; + } else if (deltaY < 0) { + mSignDeltaY = -1; + } + mAbsDeltaY = Math.abs(deltaY); + + // Limit the maximum speed + if (mAbsDeltaY > MAX_DELTA) { + mAbsDeltaY = MAX_DELTA; + } + mFloatDeltaY = mAbsDeltaY; + mFreeSpinTime = System.currentTimeMillis() + FREE_SPIN_MILLIS; +// Log.i("Cal", "init scroll: mAbsDeltaY: " + mAbsDeltaY +// + " mViewStartY: " + mViewStartY); + } + + public void run() { + long time = System.currentTimeMillis(); + + // Start out with a frictionless "free spin" + if (time > mFreeSpinTime) { + // If the delta is small, then apply a fixed deceleration. + // Otherwise + if (mAbsDeltaY <= 10) { + mAbsDeltaY -= 2; + } else { + mFloatDeltaY *= FRICTION_COEF; + mAbsDeltaY = (int) mFloatDeltaY; + } + + if (mAbsDeltaY < 0) { + mAbsDeltaY = 0; + } + } + + if (mSignDeltaY == 1) { + mViewStartY -= mAbsDeltaY; + } else { + mViewStartY += mAbsDeltaY; + } +// Log.i("Cal", " scroll: mAbsDeltaY: " + mAbsDeltaY +// + " mViewStartY: " + mViewStartY); + + if (mViewStartY < 0) { + mViewStartY = 0; + mAbsDeltaY = 0; + } else if (mViewStartY > mMaxViewStartY) { + mViewStartY = mMaxViewStartY; + mAbsDeltaY = 0; + } + + computeFirstHour(); + + if (mAbsDeltaY > 0) { + postDelayed(this, SCROLL_REPEAT_INTERVAL); + } else { + // Done scrolling. + mScrolling = false; + resetSelectedHour(); + mRedrawScreen = true; + } + + invalidate(); + } + } + + /** + * Cleanup the pop-up. + */ + public void cleanup() { + // Protect against null-pointer exceptions + if (mPopup != null) { + mPopup.dismiss(); + } + Handler handler = getHandler(); + if (handler != null) { + handler.removeCallbacks(mDismissPopup); + } + } + + @Override protected void onDetachedFromWindow() { + cleanup(); + if (mBitmap != null) { + mBitmap.recycle(); + mBitmap = null; + } + super.onDetachedFromWindow(); + } + + class DismissPopup implements Runnable { + public void run() { + // Protect against null-pointer exceptions + if (mPopup != null) { + mPopup.dismiss(); + } + } + } +} + diff --git a/src/com/android/calendar/DayActivity.java b/src/com/android/calendar/DayActivity.java new file mode 100644 index 00000000..51f965b8 --- /dev/null +++ b/src/com/android/calendar/DayActivity.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2006 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.calendar; + +import android.content.SharedPreferences; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.view.View; +import android.view.ViewGroup.LayoutParams; +import android.widget.ProgressBar; +import android.widget.ViewSwitcher; + +public class DayActivity extends CalendarActivity implements ViewSwitcher.ViewFactory { + /** + * The view id used for all the views we create. It's OK to have all child + * views have the same ID. This ID is used to pick which view receives + * focus when a view hierarchy is saved / restore + */ + private static final int VIEW_ID = 1; + + @Override + protected void onCreate(Bundle icicle) { + super.onCreate(icicle); + setContentView(R.layout.day_activity); + + mSelectedDay = Utils.timeFromIntent(getIntent()); + mViewSwitcher = (ViewSwitcher) findViewById(R.id.switcher); + mViewSwitcher.setFactory(this); + mViewSwitcher.getCurrentView().requestFocus(); + mProgressBar = (ProgressBar) findViewById(R.id.progress_circular); + + // Record Day View as the (new) default detailed view. + String activityString = CalendarApplication.ACTIVITY_NAMES[CalendarApplication.DAY_VIEW_ID]; + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(CalendarPreferenceActivity.KEY_DETAILED_VIEW, activityString); + + // Record Day View as the (new) start view + editor.putString(CalendarPreferenceActivity.KEY_START_VIEW, activityString); + editor.commit(); + } + + public View makeView() { + DayView view = new DayView(this); + view.setId(VIEW_ID); + view.setLayoutParams(new ViewSwitcher.LayoutParams( + LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT)); + view.setSelectedDay(mSelectedDay); + return view; + } + + @Override + protected void onPause() { + super.onPause(); + CalendarView view = (CalendarView) mViewSwitcher.getCurrentView(); + mSelectedDay = view.getSelectedDay(); + } +} diff --git a/src/com/android/calendar/DayView.java b/src/com/android/calendar/DayView.java new file mode 100644 index 00000000..a24b51b5 --- /dev/null +++ b/src/com/android/calendar/DayView.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2006 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.calendar; + + +public class DayView extends CalendarView { + private static final int CELL_MARGIN = 10; + + public DayView(CalendarActivity activity) { + super(activity); + init(); + } + + private void init() { + mDrawTextInEventRect = true; + mNumDays = 1; + mEventGeometry.setCellMargin(CELL_MARGIN); + } +} diff --git a/src/com/android/calendar/DeleteEventHelper.java b/src/com/android/calendar/DeleteEventHelper.java new file mode 100644 index 00000000..90cd3ff5 --- /dev/null +++ b/src/com/android/calendar/DeleteEventHelper.java @@ -0,0 +1,344 @@ +/* + * Copyright (C) 2008 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.calendar; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.DialogInterface; +import android.database.Cursor; +import android.net.Uri; +import android.pim.EventRecurrence; +import android.provider.Calendar; +import android.provider.Calendar.Events; +import android.text.format.Time; +import android.widget.Button; + +/** + * A helper class for deleting events. If a normal event is selected for + * deletion, then this pops up a confirmation dialog. If the user confirms, + * then the normal event is deleted. + * + * <p> + * If a repeating event is selected for deletion, then this pops up dialog + * asking if the user wants to delete just this one instance, or all the + * events in the series, or this event plus all following events. The user + * may also cancel the delete. + * </p> + * + * <p> + * To use this class, create an instance, passing in the parent activity + * and a boolean that determines if the parent activity should exit if the + * event is deleted. Then to use the instance, call one of the + * {@link delete()} methods on this class. + * + * An instance of this class may be created once and reused (by calling + * {@link #delete()} multiple times). + */ +public class DeleteEventHelper { + + private static final String TAG = "DeleteEventHelper"; + private final Activity mParent; + private final ContentResolver mContentResolver; + + private long mStartMillis; + private long mEndMillis; + private Cursor mCursor; + + /** + * If true, then call finish() on the parent activity when done. + */ + private boolean mExitWhenDone; + + /** + * These are the corresponding indices into the array of strings + * "R.array.delete_repeating_labels" in the resource file. + */ + static final int DELETE_SELECTED = 0; + static final int DELETE_ALL_FOLLOWING = 1; + static final int DELETE_ALL = 2; + + private int mWhichDelete; + private AlertDialog mAlertDialog; + + private static final String[] EVENT_PROJECTION = new String[] { + Events._ID, + Events.TITLE, + Events.ALL_DAY, + Events.CALENDAR_ID, + Events.RRULE, + Events.DTSTART, + Events._SYNC_ID, + Events.EVENT_TIMEZONE, + }; + + private int mEventIndexId; + private int mEventIndexRrule; + private String mSyncId; + + public DeleteEventHelper(Activity parent, boolean exitWhenDone) { + mParent = parent; + mContentResolver = mParent.getContentResolver(); + mExitWhenDone = exitWhenDone; + } + + public void setExitWhenDone(boolean exitWhenDone) { + mExitWhenDone = exitWhenDone; + } + + /** + * This callback is used when a normal event is deleted. + */ + private DialogInterface.OnClickListener mDeleteNormalDialogListener = + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int button) { + long id = mCursor.getInt(mEventIndexId); + Uri uri = ContentUris.withAppendedId(Calendar.Events.CONTENT_URI, id); + mContentResolver.delete(uri, null /* where */, null /* selectionArgs */); + if (mExitWhenDone) { + mParent.finish(); + } + } + }; + + /** + * This callback is used when a list item for a repeating event is selected + */ + private DialogInterface.OnClickListener mDeleteListListener = + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int button) { + mWhichDelete = button; + + // Enable the "ok" button now that the user has selected which + // events in the series to delete. + Button ok = mAlertDialog.getButton(DialogInterface.BUTTON_POSITIVE); + ok.setEnabled(true); + } + }; + + /** + * This callback is used when a repeating event is deleted. + */ + private DialogInterface.OnClickListener mDeleteRepeatingDialogListener = + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int button) { + if (mWhichDelete != -1) { + deleteRepeatingEvent(mWhichDelete); + } + } + }; + + /** + * Does the required processing for deleting an event, which includes + * first popping up a dialog asking for confirmation (if the event is + * a normal event) or a dialog asking which events to delete (if the + * event is a repeating event). The "which" parameter is used to check + * the initial selection and is only used for repeating events. Set + * "which" to -1 to have nothing selected initially. + * + * @param begin the begin time of the event, in UTC milliseconds + * @param end the end time of the event, in UTC milliseconds + * @param eventId the event id + * @param which one of the values {@link DELETE_SELECTED}, + * {@link DELETE_ALL_FOLLOWING}, {@link DELETE_ALL}, or -1 + */ + public void delete(long begin, long end, long eventId, int which) { + Uri uri = ContentUris.withAppendedId(Calendar.Events.CONTENT_URI, eventId); + Cursor cursor = mParent.managedQuery(uri, EVENT_PROJECTION, null, null); + if (cursor == null) { + return; + } + cursor.moveToFirst(); + delete(begin, end, cursor, which); + } + + /** + * Does the required processing for deleting an event. This method + * takes a {@link Cursor} object as a parameter, which must point to + * a row in the Events table containing the required database fields. + * The required fields for a normal event are: + * + * <ul> + * <li> Events._ID </li> + * <li> Events.TITLE </li> + * <li> Events.RRULE </li> + * </ul> + * + * The required fields for a repeating event include the above plus the + * following fields: + * + * <ul> + * <li> Events.ALL_DAY </li> + * <li> Events.CALENDAR_ID </li> + * <li> Events.DTSTART </li> + * <li> Events._SYNC_ID </li> + * <li> Events.EVENT_TIMEZONE </li> + * </ul> + * + * @param begin the begin time of the event, in UTC milliseconds + * @param end the end time of the event, in UTC milliseconds + * @param cursor the database cursor containing the required fields + * @param which one of the values {@link DELETE_SELECTED}, + * {@link DELETE_ALL_FOLLOWING}, {@link DELETE_ALL}, or -1 + */ + public void delete(long begin, long end, Cursor cursor, int which) { + mWhichDelete = which; + mStartMillis = begin; + mEndMillis = end; + mCursor = cursor; + mEventIndexId = mCursor.getColumnIndexOrThrow(Events._ID); + mEventIndexRrule = mCursor.getColumnIndexOrThrow(Events.RRULE); + int eventIndexSyncId = mCursor.getColumnIndexOrThrow(Events._SYNC_ID); + mSyncId = mCursor.getString(eventIndexSyncId); + + // If this is a repeating event, then pop up a dialog asking the + // user if they want to delete all of the repeating events or + // just some of them. + String rRule = mCursor.getString(mEventIndexRrule); + if (rRule == null) { + // This is a normal event. Pop up a confirmation dialog. + new AlertDialog.Builder(mParent) + .setTitle(R.string.delete_title) + .setMessage(R.string.delete_this_event_title) + .setIcon(android.R.drawable.ic_dialog_alert) + .setPositiveButton(android.R.string.ok, mDeleteNormalDialogListener) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } else { + // This is a repeating event. Pop up a dialog asking which events + // to delete. + int labelsArrayId = R.array.delete_repeating_labels; + if (mSyncId == null) { + labelsArrayId = R.array.delete_repeating_labels_no_selected; + } + AlertDialog dialog = new AlertDialog.Builder(mParent) + .setTitle(R.string.delete_title) + .setIcon(android.R.drawable.ic_dialog_alert) + .setSingleChoiceItems(labelsArrayId, which, mDeleteListListener) + .setPositiveButton(android.R.string.ok, mDeleteRepeatingDialogListener) + .setNegativeButton(android.R.string.cancel, null) + .show(); + mAlertDialog = dialog; + + // Disable the "Ok" button until the user selects which events to + // delete. + Button ok = dialog.getButton(DialogInterface.BUTTON_POSITIVE); + ok.setEnabled(false); + } + } + + private void deleteRepeatingEvent(int which) { + int indexDtstart = mCursor.getColumnIndexOrThrow(Events.DTSTART); + int indexAllDay = mCursor.getColumnIndexOrThrow(Events.ALL_DAY); + int indexTitle = mCursor.getColumnIndexOrThrow(Events.TITLE); + int indexTimezone = mCursor.getColumnIndexOrThrow(Events.EVENT_TIMEZONE); + int indexCalendarId = mCursor.getColumnIndexOrThrow(Events.CALENDAR_ID); + + String rRule = mCursor.getString(mEventIndexRrule); + boolean allDay = mCursor.getInt(indexAllDay) != 0; + long dtstart = mCursor.getLong(indexDtstart); + long id = mCursor.getInt(mEventIndexId); + + // If the repeating event has not been given a sync id from the server + // yet, then we can't delete a single instance of this event. (This is + // a deficiency in the CalendarProvider and sync code.) We checked for + // that when creating the list of items in the dialog and we removed + // the first element ("DELETE_SELECTED") from the dialog in that case. + // The "which" value is a 0-based index into the list of items, where + // the "DELETE_SELECTED" item is at index 0. + if (mSyncId == null) { + which += 1; + } + + switch (which) { + case DELETE_SELECTED: + { + // If we are deleting the first event in the series, then + // instead of creating a recurrence exception, just change + // the start time of the recurrence. + if (dtstart == mStartMillis) { + // TODO + } + + // Create a recurrence exception by creating a new event + // with the status "cancelled". + ContentValues values = new ContentValues(); + + // The title might not be necessary, but it makes it easier + // to find this entry in the database when there is a problem. + String title = mCursor.getString(indexTitle); + values.put(Events.TITLE, title); + + String timezone = mCursor.getString(indexTimezone); + int calendarId = mCursor.getInt(indexCalendarId); + values.put(Events.EVENT_TIMEZONE, timezone); + values.put(Events.ALL_DAY, allDay ? 1 : 0); + values.put(Events.CALENDAR_ID, calendarId); + values.put(Events.DTSTART, mStartMillis); + values.put(Events.DTEND, mEndMillis); + values.put(Events.ORIGINAL_EVENT, mSyncId); + values.put(Events.ORIGINAL_INSTANCE_TIME, mStartMillis); + values.put(Events.STATUS, Events.STATUS_CANCELED); + + mContentResolver.insert(Events.CONTENT_URI, values); + break; + } + case DELETE_ALL: { + Uri uri = ContentUris.withAppendedId(Calendar.Events.CONTENT_URI, id); + mContentResolver.delete(uri, null /* where */, null /* selectionArgs */); + break; + } + case DELETE_ALL_FOLLOWING: { + // If we are deleting the first event in the series and all + // following events, then delete them all. + if (dtstart == mStartMillis) { + Uri uri = ContentUris.withAppendedId(Calendar.Events.CONTENT_URI, id); + mContentResolver.delete(uri, null /* where */, null /* selectionArgs */); + break; + } + + // Modify the repeating event to end just before this event time + EventRecurrence eventRecurrence = new EventRecurrence(); + eventRecurrence.parse(rRule); + Time date = new Time(); + if (allDay) { + date.timezone = Time.TIMEZONE_UTC; + } + date.set(mStartMillis); + date.second--; + date.normalize(false); + + // Google calendar seems to require the UNTIL string to be + // in UTC. + date.switchTimezone(Time.TIMEZONE_UTC); + eventRecurrence.until = date.format2445(); + + ContentValues values = new ContentValues(); + values.put(Events.DTSTART, dtstart); + values.put(Events.RRULE, eventRecurrence.toString()); + Uri uri = ContentUris.withAppendedId(Calendar.Events.CONTENT_URI, id); + mContentResolver.update(uri, values, null, null); + break; + } + } + if (mExitWhenDone) { + mParent.finish(); + } + } +} diff --git a/src/com/android/calendar/EditEvent.java b/src/com/android/calendar/EditEvent.java new file mode 100644 index 00000000..f020a7cc --- /dev/null +++ b/src/com/android/calendar/EditEvent.java @@ -0,0 +1,1690 @@ +/* + * Copyright (C) 2008 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.calendar; + +import static android.provider.Calendar.EVENT_BEGIN_TIME; +import static android.provider.Calendar.EVENT_END_TIME; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.DatePickerDialog; +import android.app.ProgressDialog; +import android.app.TimePickerDialog; +import android.app.DatePickerDialog.OnDateSetListener; +import android.app.TimePickerDialog.OnTimeSetListener; +import android.content.AsyncQueryHandler; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.DialogInterface.OnCancelListener; +import android.content.DialogInterface.OnClickListener; +import android.content.res.Resources; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.pim.EventRecurrence; +import android.preference.PreferenceManager; +import android.provider.Calendar.Calendars; +import android.provider.Calendar.Events; +import android.provider.Calendar.Reminders; +import android.text.TextUtils; +import android.text.format.DateFormat; +import android.text.format.DateUtils; +import android.text.format.Time; +import android.util.Log; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.Window; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.DatePicker; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.ResourceCursorAdapter; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.TimePicker; +import android.widget.Toast; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.TimeZone; + +public class EditEvent extends Activity implements View.OnClickListener, + DialogInterface.OnCancelListener, DialogInterface.OnClickListener { + /** + * This is the symbolic name for the key used to pass in the boolean + * for creating all-day events that is part of the extra data of the intent. + * This is used only for creating new events and is set to true if + * the default for the new event should be an all-day event. + */ + public static final String EVENT_ALL_DAY = "allDay"; + + private static final int MAX_REMINDERS = 5; + + private static final int MENU_GROUP_REMINDER = 1; + private static final int MENU_GROUP_SHOW_OPTIONS = 2; + private static final int MENU_GROUP_HIDE_OPTIONS = 3; + + private static final int MENU_ADD_REMINDER = 1; + private static final int MENU_SHOW_EXTRA_OPTIONS = 2; + private static final int MENU_HIDE_EXTRA_OPTIONS = 3; + + private static final String[] EVENT_PROJECTION = new String[] { + Events._ID, // 0 + Events.TITLE, // 1 + Events.DESCRIPTION, // 2 + Events.EVENT_LOCATION, // 3 + Events.ALL_DAY, // 4 + Events.HAS_ALARM, // 5 + Events.CALENDAR_ID, // 6 + Events.DTSTART, // 7 + Events.DURATION, // 8 + Events.EVENT_TIMEZONE, // 9 + Events.RRULE, // 10 + Events._SYNC_ID, // 11 + Events.TRANSPARENCY, // 12 + Events.VISIBILITY, // 13 + }; + private static final int EVENT_INDEX_ID = 0; + private static final int EVENT_INDEX_TITLE = 1; + private static final int EVENT_INDEX_DESCRIPTION = 2; + private static final int EVENT_INDEX_EVENT_LOCATION = 3; + private static final int EVENT_INDEX_ALL_DAY = 4; + private static final int EVENT_INDEX_HAS_ALARM = 5; + private static final int EVENT_INDEX_CALENDAR_ID = 6; + private static final int EVENT_INDEX_DTSTART = 7; + private static final int EVENT_INDEX_DURATION = 8; + private static final int EVENT_INDEX_TIMEZONE = 9; + private static final int EVENT_INDEX_RRULE = 10; + private static final int EVENT_INDEX_SYNC_ID = 11; + private static final int EVENT_INDEX_TRANSPARENCY = 12; + private static final int EVENT_INDEX_VISIBILITY = 13; + + private static final String[] CALENDARS_PROJECTION = new String[] { + Calendars._ID, // 0 + Calendars.DISPLAY_NAME, // 1 + Calendars.TIMEZONE, // 2 + }; + private static final int CALENDARS_INDEX_DISPLAY_NAME = 1; + private static final int CALENDARS_INDEX_TIMEZONE = 2; + private static final String CALENDARS_WHERE = Calendars.ACCESS_LEVEL + ">=" + + Calendars.CONTRIBUTOR_ACCESS + " AND " + Calendars.SYNC_EVENTS + "=1"; + + private static final String[] REMINDERS_PROJECTION = new String[] { + Reminders._ID, // 0 + Reminders.MINUTES, // 1 + }; + private static final int REMINDERS_INDEX_MINUTES = 1; + private static final String REMINDERS_WHERE = Reminders.EVENT_ID + "=%d AND (" + + Reminders.METHOD + "=" + Reminders.METHOD_ALERT + " OR " + Reminders.METHOD + "=" + + Reminders.METHOD_DEFAULT + ")"; + + private static final int DOES_NOT_REPEAT = 0; + private static final int REPEATS_DAILY = 1; + private static final int REPEATS_EVERY_WEEKDAY = 2; + private static final int REPEATS_WEEKLY_ON_DAY = 3; + private static final int REPEATS_MONTHLY_ON_DAY_COUNT = 4; + private static final int REPEATS_MONTHLY_ON_DAY = 5; + private static final int REPEATS_YEARLY = 6; + private static final int REPEATS_CUSTOM = 7; + + private static final int MODIFY_UNINITIALIZED = 0; + private static final int MODIFY_SELECTED = 1; + private static final int MODIFY_ALL = 2; + private static final int MODIFY_ALL_FOLLOWING = 3; + + private static final int DAY_IN_SECONDS = 24 * 60 * 60; + + private int mFirstDayOfWeek; // cached in onCreate + private Uri mUri; + private Cursor mEventCursor; + private Cursor mCalendarsCursor; + + private Button mStartDateButton; + private Button mEndDateButton; + private Button mStartTimeButton; + private Button mEndTimeButton; + private Button mSaveButton; + private Button mDeleteButton; + private Button mDiscardButton; + private CheckBox mAllDayCheckBox; + private Spinner mCalendarsSpinner; + private Spinner mRepeatsSpinner; + private Spinner mAvailabilitySpinner; + private Spinner mVisibilitySpinner; + private TextView mTitleTextView; + private TextView mLocationTextView; + private TextView mDescriptionTextView; + private View mRemindersSeparator; + private LinearLayout mRemindersContainer; + private LinearLayout mExtraOptions; + private ArrayList<Integer> mOriginalMinutes = new ArrayList<Integer>(); + private ArrayList<LinearLayout> mReminderItems = new ArrayList<LinearLayout>(0); + + private EventRecurrence mEventRecurrence = new EventRecurrence(); + private String mRrule; + private boolean mCalendarsQueryComplete; + private boolean mSaveAfterQueryComplete; + private ProgressDialog mLoadingCalendarsDialog; + private AlertDialog mNoCalendarsDialog; + private ContentValues mInitialValues; + + /** + * If the repeating event is created on the phone and it hasn't been + * synced yet to the web server, then there is a bug where you can't + * delete or change an instance of the repeating event. This case + * can be detected with mSyncId. If mSyncId == null, then the repeating + * event has not been synced to the phone, in which case we won't allow + * the user to change one instance. + */ + private String mSyncId; + + private ArrayList<Integer> mRecurrenceIndexes = new ArrayList<Integer> (0); + private ArrayList<Integer> mReminderValues; + private ArrayList<String> mReminderLabels; + + private Time mStartTime; + private Time mEndTime; + private int mModification = MODIFY_UNINITIALIZED; + private int mDefaultReminderMinutes; + + private DeleteEventHelper mDeleteEventHelper; + private QueryHandler mQueryHandler; + + /* This class is used to update the time buttons. */ + private class TimeListener implements OnTimeSetListener { + private View mView; + + public TimeListener(View view) { + mView = view; + } + + public void onTimeSet(TimePicker view, int hourOfDay, int minute) { + // Cache the member variables locally to avoid inner class overhead. + Time startTime = mStartTime; + Time endTime = mEndTime; + + // Cache the start and end millis so that we limit the number + // of calls to normalize() and toMillis(), which are fairly + // expensive. + long startMillis; + long endMillis; + if (mView == mStartTimeButton) { + // The start time was changed. + int hourDuration = endTime.hour - startTime.hour; + int minuteDuration = endTime.minute - startTime.minute; + + startTime.hour = hourOfDay; + startTime.minute = minute; + startMillis = startTime.normalize(true); + + // Also update the end time to keep the duration constant. + endTime.hour = hourOfDay + hourDuration; + endTime.minute = minute + minuteDuration; + endMillis = endTime.normalize(true); + } else { + // The end time was changed. + startMillis = startTime.toMillis(true); + endTime.hour = hourOfDay; + endTime.minute = minute; + endMillis = endTime.normalize(true); + + // Do not allow an event to have an end time before the start time. + if (endTime.before(startTime)) { + endTime.set(startTime); + endMillis = startMillis; + } + } + + setDate(mEndDateButton, endMillis); + setTime(mStartTimeButton, startMillis); + setTime(mEndTimeButton, endMillis); + } + } + + private class TimeClickListener implements View.OnClickListener { + private Time mTime; + + public TimeClickListener(Time time) { + mTime = time; + } + + public void onClick(View v) { + new TimePickerDialog(EditEvent.this, new TimeListener(v), + mTime.hour, mTime.minute, + DateFormat.is24HourFormat(EditEvent.this)).show(); + } + } + + private class DateListener implements OnDateSetListener { + View mView; + + public DateListener(View view) { + mView = view; + } + + public void onDateSet(DatePicker view, int year, int month, int monthDay) { + // Cache the member variables locally to avoid inner class overhead. + Time startTime = mStartTime; + Time endTime = mEndTime; + + // Cache the start and end millis so that we limit the number + // of calls to normalize() and toMillis(), which are fairly + // expensive. + long startMillis; + long endMillis; + if (mView == mStartDateButton) { + // The start date was changed. + int yearDuration = endTime.year - startTime.year; + int monthDuration = endTime.month - startTime.month; + int monthDayDuration = endTime.monthDay - startTime.monthDay; + + startTime.year = year; + startTime.month = month; + startTime.monthDay = monthDay; + startMillis = startTime.normalize(true); + + // Also update the end date to keep the duration constant. + endTime.year = year + yearDuration; + endTime.month = month + monthDuration; + endTime.monthDay = monthDay + monthDayDuration; + endMillis = endTime.normalize(true); + + // If the start date has changed then update the repeats. + populateRepeats(); + } else { + // The end date was changed. + startMillis = startTime.toMillis(true); + endTime.year = year; + endTime.month = month; + endTime.monthDay = monthDay; + endMillis = endTime.normalize(true); + + // Do not allow an event to have an end time before the start time. + if (endTime.before(startTime)) { + endTime.set(startTime); + endMillis = startMillis; + } + } + + setDate(mStartDateButton, startMillis); + setDate(mEndDateButton, endMillis); + setTime(mEndTimeButton, endMillis); // In case end time had to be reset + } + } + + private class DateClickListener implements View.OnClickListener { + private Time mTime; + + public DateClickListener(Time time) { + mTime = time; + } + + public void onClick(View v) { + new DatePickerDialog(EditEvent.this, new DateListener(v), mTime.year, + mTime.month, mTime.monthDay).show(); + } + } + + private class CalendarsAdapter extends ResourceCursorAdapter { + public CalendarsAdapter(Context context, Cursor c) { + super(context, R.layout.calendars_item, c); + setDropDownViewResource(R.layout.calendars_dropdown_item); + } + + @Override + public void bindView(View view, Context context, Cursor cursor) { + TextView name = (TextView) view.findViewById(R.id.calendar_name); + name.setText(cursor.getString(CALENDARS_INDEX_DISPLAY_NAME)); + } + } + + // This is called if the user clicks on one of the buttons: "Save", + // "Discard", or "Delete". This is also called if the user clicks + // on the "remove reminder" button. + public void onClick(View v) { + if (v == mSaveButton) { + if (save()) { + finish(); + } + return; + } + + if (v == mDeleteButton) { + long begin = mStartTime.toMillis(false /* use isDst */); + long end = mEndTime.toMillis(false /* use isDst */); + int which = -1; + switch (mModification) { + case MODIFY_SELECTED: + which = DeleteEventHelper.DELETE_SELECTED; + break; + case MODIFY_ALL_FOLLOWING: + which = DeleteEventHelper.DELETE_ALL_FOLLOWING; + break; + case MODIFY_ALL: + which = DeleteEventHelper.DELETE_ALL; + break; + } + mDeleteEventHelper.delete(begin, end, mEventCursor, which); + return; + } + + if (v == mDiscardButton) { + finish(); + return; + } + + // This must be a click on one of the "remove reminder" buttons + LinearLayout reminderItem = (LinearLayout) v.getParent(); + LinearLayout parent = (LinearLayout) reminderItem.getParent(); + parent.removeView(reminderItem); + mReminderItems.remove(reminderItem); + updateRemindersVisibility(); + } + + // This is called if the user cancels a popup dialog. There are two + // dialogs: the "Loading calendars" dialog, and the "No calendars" + // dialog. The "Loading calendars" dialog is shown if there is a delay + // in loading the calendars (needed when creating an event) and the user + // tries to save the event before the calendars have finished loading. + // The "No calendars" dialog is shown if there are no syncable calendars. + public void onCancel(DialogInterface dialog) { + if (dialog == mLoadingCalendarsDialog) { + mSaveAfterQueryComplete = false; + } else if (dialog == mNoCalendarsDialog) { + finish(); + } + } + + // This is called if the user clicks on a dialog button. + public void onClick(DialogInterface dialog, int which) { + if (dialog == mNoCalendarsDialog) { + finish(); + } + } + + private class QueryHandler extends AsyncQueryHandler { + public QueryHandler(ContentResolver cr) { + super(cr); + } + + @Override + protected void onQueryComplete(int token, Object cookie, Cursor cursor) { + // If the Activity is finishing, then close the cursor. + // Otherwise, use the new cursor in the adapter. + if (isFinishing()) { + stopManagingCursor(cursor); + cursor.close(); + } else { + mCalendarsCursor = cursor; + startManagingCursor(cursor); + + // Stop the spinner + getWindow().setFeatureInt(Window.FEATURE_INDETERMINATE_PROGRESS, + Window.PROGRESS_VISIBILITY_OFF); + + // If there are no syncable calendars, then we cannot allow + // creating a new event. + if (cursor.getCount() == 0) { + // Cancel the "loading calendars" dialog if it exists + if (mSaveAfterQueryComplete) { + mLoadingCalendarsDialog.cancel(); + } + + // Create an error message for the user that, when clicked, + // will exit this activity without saving the event. + AlertDialog.Builder builder = new AlertDialog.Builder(EditEvent.this); + builder.setTitle(R.string.no_syncable_calendars) + .setIcon(android.R.drawable.ic_dialog_alert) + .setMessage(R.string.no_calendars_found) + .setPositiveButton(android.R.string.ok, EditEvent.this) + .setOnCancelListener(EditEvent.this); + mNoCalendarsDialog = builder.show(); + return; + } + + // populate the calendars spinner + CalendarsAdapter adapter = new CalendarsAdapter(EditEvent.this, mCalendarsCursor); + mCalendarsSpinner.setAdapter(adapter); + mCalendarsQueryComplete = true; + if (mSaveAfterQueryComplete) { + mLoadingCalendarsDialog.cancel(); + save(); + finish(); + } + } + } + } + + @Override + protected void onCreate(Bundle icicle) { + super.onCreate(icicle); + requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); + setContentView(R.layout.edit_event); + + mFirstDayOfWeek = Calendar.getInstance().getFirstDayOfWeek(); + + mStartTime = new Time(); + mEndTime = new Time(); + + Intent intent = getIntent(); + mUri = intent.getData(); + + if (mUri != null) { + mEventCursor = managedQuery(mUri, EVENT_PROJECTION, null, null); + if (mEventCursor == null || mEventCursor.getCount() == 0) { + // The cursor is empty. This can happen if the event was deleted. + finish(); + return; + } + } + + long begin = intent.getLongExtra(EVENT_BEGIN_TIME, 0); + long end = intent.getLongExtra(EVENT_END_TIME, 0); + + boolean allDay = false; + if (mEventCursor != null) { + // The event already exists so fetch the all-day status + mEventCursor.moveToFirst(); + allDay = mEventCursor.getInt(EVENT_INDEX_ALL_DAY) != 0; + String rrule = mEventCursor.getString(EVENT_INDEX_RRULE); + String timezone = mEventCursor.getString(EVENT_INDEX_TIMEZONE); + long calendarId = mEventCursor.getInt(EVENT_INDEX_CALENDAR_ID); + + // Remember the initial values + mInitialValues = new ContentValues(); + mInitialValues.put(EVENT_BEGIN_TIME, begin); + mInitialValues.put(EVENT_END_TIME, end); + mInitialValues.put(Events.ALL_DAY, allDay ? 1 : 0); + mInitialValues.put(Events.RRULE, rrule); + mInitialValues.put(Events.EVENT_TIMEZONE, timezone); + mInitialValues.put(Events.CALENDAR_ID, calendarId); + } else { + // We are creating a new event, so set the default from the + // intent (if specified). + allDay = intent.getBooleanExtra(EVENT_ALL_DAY, false); + + // Start the spinner + getWindow().setFeatureInt(Window.FEATURE_INDETERMINATE_PROGRESS, + Window.PROGRESS_VISIBILITY_ON); + + // Start a query in the background to read the list of calendars + mQueryHandler = new QueryHandler(getContentResolver()); + mQueryHandler.startQuery(0, null, Calendars.CONTENT_URI, CALENDARS_PROJECTION, + CALENDARS_WHERE, null /* selection args */, null /* sort order */); + } + + // If the event is all-day, read the times in UTC timezone + if (begin != 0) { + if (allDay) { + String tz = mStartTime.timezone; + mStartTime.timezone = Time.TIMEZONE_UTC; + mStartTime.set(begin); + mStartTime.timezone = tz; + + // Calling normalize to calculate isDst + mStartTime.normalize(true); + } else { + mStartTime.set(begin); + } + } + + if (end != 0) { + if (allDay) { + String tz = mStartTime.timezone; + mEndTime.timezone = Time.TIMEZONE_UTC; + mEndTime.set(end); + mEndTime.timezone = tz; + + // Calling normalize to calculate isDst + mEndTime.normalize(true); + } else { + mEndTime.set(end); + } + } + + // cache all the widgets + mTitleTextView = (TextView) findViewById(R.id.title); + mLocationTextView = (TextView) findViewById(R.id.location); + mDescriptionTextView = (TextView) findViewById(R.id.description); + mStartDateButton = (Button) findViewById(R.id.start_date); + mEndDateButton = (Button) findViewById(R.id.end_date); + mStartTimeButton = (Button) findViewById(R.id.start_time); + mEndTimeButton = (Button) findViewById(R.id.end_time); + mAllDayCheckBox = (CheckBox) findViewById(R.id.is_all_day); + mCalendarsSpinner = (Spinner) findViewById(R.id.calendars); + mRepeatsSpinner = (Spinner) findViewById(R.id.repeats); + mAvailabilitySpinner = (Spinner) findViewById(R.id.availability); + mVisibilitySpinner = (Spinner) findViewById(R.id.visibility); + mRemindersSeparator = findViewById(R.id.reminders_separator); + mRemindersContainer = (LinearLayout) findViewById(R.id.reminders_container); + mExtraOptions = (LinearLayout) findViewById(R.id.extra_options_container); + + mAllDayCheckBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + if (isChecked) { + if (mEndTime.hour == 0 && mEndTime.minute == 0) { + mEndTime.monthDay--; + long endMillis = mEndTime.normalize(true); + + // Do not allow an event to have an end time before the start time. + if (mEndTime.before(mStartTime)) { + mEndTime.set(mStartTime); + endMillis = mEndTime.normalize(true); + } + setDate(mEndDateButton, endMillis); + setTime(mEndTimeButton, endMillis); + } + + mStartTimeButton.setVisibility(View.GONE); + mEndTimeButton.setVisibility(View.GONE); + } else { + if (mEndTime.hour == 0 && mEndTime.minute == 0) { + mEndTime.monthDay++; + long endMillis = mEndTime.normalize(true); + setDate(mEndDateButton, endMillis); + setTime(mEndTimeButton, endMillis); + } + + mStartTimeButton.setVisibility(View.VISIBLE); + mEndTimeButton.setVisibility(View.VISIBLE); + } + } + }); + + if (allDay) { + mAllDayCheckBox.setChecked(true); + } else { + mAllDayCheckBox.setChecked(false); + } + + mSaveButton = (Button) findViewById(R.id.save); + mSaveButton.setOnClickListener(this); + + mDeleteButton = (Button) findViewById(R.id.delete); + mDeleteButton.setOnClickListener(this); + + mDiscardButton = (Button) findViewById(R.id.discard); + mDiscardButton.setOnClickListener(this); + + // Initialize the reminder values array. + Resources r = getResources(); + String[] strings = r.getStringArray(R.array.reminder_minutes_values); + int size = strings.length; + ArrayList<Integer> list = new ArrayList<Integer>(size); + for (int i = 0 ; i < size ; i++) { + list.add(Integer.parseInt(strings[i])); + } + mReminderValues = list; + String[] labels = r.getStringArray(R.array.reminder_minutes_labels); + mReminderLabels = new ArrayList<String>(Arrays.asList(labels)); + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + String durationString = + prefs.getString(CalendarPreferenceActivity.KEY_DEFAULT_REMINDER, "0"); + mDefaultReminderMinutes = Integer.parseInt(durationString); + + // Reminders cursor + boolean hasAlarm = (mEventCursor != null) + && (mEventCursor.getInt(EVENT_INDEX_HAS_ALARM) != 0); + if (hasAlarm) { + Uri uri = Reminders.CONTENT_URI; + long eventId = mEventCursor.getLong(EVENT_INDEX_ID); + String where = String.format(REMINDERS_WHERE, eventId); + ContentResolver cr = getContentResolver(); + Cursor reminderCursor = cr.query(uri, REMINDERS_PROJECTION, where, null, null); + try { + // First pass: collect all the custom reminder minutes (e.g., + // a reminder of 8 minutes) into a global list. + while (reminderCursor.moveToNext()) { + int minutes = reminderCursor.getInt(REMINDERS_INDEX_MINUTES); + EditEvent.addMinutesToList(this, mReminderValues, mReminderLabels, minutes); + } + + // Second pass: create the reminder spinners + reminderCursor.moveToPosition(-1); + while (reminderCursor.moveToNext()) { + int minutes = reminderCursor.getInt(REMINDERS_INDEX_MINUTES); + mOriginalMinutes.add(minutes); + EditEvent.addReminder(this, this, mReminderItems, mReminderValues, + mReminderLabels, minutes); + } + } finally { + reminderCursor.close(); + } + } + updateRemindersVisibility(); + + mDeleteEventHelper = new DeleteEventHelper(this, true /* exit when done */); + + if (mEventCursor == null) { + // Allow the intent to specify the fields in the event. + // This will allow other apps to create events easily. + initFromIntent(intent); + } + } + + private void initFromIntent(Intent intent) { + String title = intent.getStringExtra(Events.TITLE); + if (title != null) { + mTitleTextView.setText(title); + } + + String location = intent.getStringExtra(Events.EVENT_LOCATION); + if (location != null) { + mLocationTextView.setText(location); + } + + String description = intent.getStringExtra(Events.DESCRIPTION); + if (description != null) { + mDescriptionTextView.setText(description); + } + + int availability = intent.getIntExtra(Events.TRANSPARENCY, -1); + if (availability != -1) { + mAvailabilitySpinner.setSelection(availability); + } + + int visibility = intent.getIntExtra(Events.VISIBILITY, -1); + if (visibility != -1) { + mVisibilitySpinner.setSelection(visibility); + } + + String rrule = intent.getStringExtra(Events.RRULE); + if (rrule != null) { + mRrule = rrule; + mEventRecurrence.parse(rrule); + } + } + + @Override + protected void onResume() { + super.onResume(); + + if (mUri != null) { + if (mEventCursor == null || mEventCursor.getCount() == 0) { + // The cursor is empty. This can happen if the event was deleted. + finish(); + return; + } + } + + if (mEventCursor != null) { + Cursor cursor = mEventCursor; + cursor.moveToFirst(); + + mRrule = cursor.getString(EVENT_INDEX_RRULE); + String title = cursor.getString(EVENT_INDEX_TITLE); + String description = cursor.getString(EVENT_INDEX_DESCRIPTION); + String location = cursor.getString(EVENT_INDEX_EVENT_LOCATION); + int availability = cursor.getInt(EVENT_INDEX_TRANSPARENCY); + int visibility = cursor.getInt(EVENT_INDEX_VISIBILITY); + if (visibility > 0) { + // For now we the array contains the values 0, 2, and 3. We subtract one to match. + visibility--; + } + + if (!TextUtils.isEmpty(mRrule) && mModification == MODIFY_UNINITIALIZED) { + // If this event has not been synced, then don't allow deleting + // or changing a single instance. + mSyncId = cursor.getString(EVENT_INDEX_SYNC_ID); + mEventRecurrence.parse(mRrule); + + // If we haven't synced this repeating event yet, then don't + // allow the user to change just one instance. + int itemIndex = 0; + CharSequence[] items; + if (mSyncId == null) { + items = new CharSequence[2]; + } else { + items = new CharSequence[3]; + items[itemIndex++] = getText(R.string.modify_event); + } + items[itemIndex++] = getText(R.string.modify_all); + items[itemIndex++] = getText(R.string.modify_all_following); + + // Display the modification dialog. + new AlertDialog.Builder(this) + .setOnCancelListener(new OnCancelListener() { + public void onCancel(DialogInterface dialog) { + finish(); + } + }) + .setTitle(R.string.edit_event_label) + .setItems(items, new OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + if (which == 0) { + mModification = + (mSyncId == null) ? MODIFY_ALL : MODIFY_SELECTED; + } else if (which == 1) { + mModification = + (mSyncId == null) ? MODIFY_ALL_FOLLOWING : MODIFY_ALL; + } else if (which == 2) { + mModification = MODIFY_ALL_FOLLOWING; + } + + // If we are modifying all the events in a + // series then disable and ignore the date. + if (mModification == MODIFY_ALL) { + mStartDateButton.setEnabled(false); + mEndDateButton.setEnabled(false); + } else if (mModification == MODIFY_SELECTED) { + mRepeatsSpinner.setEnabled(false); + } else { + // We could allow changing the Rrule for + // all following instances but we'll + // keep it simple for now. + mRepeatsSpinner.setEnabled(false); + } + } + }) + .show(); + } + + mTitleTextView.setText(title); + mLocationTextView.setText(location); + mDescriptionTextView.setText(description); + mAvailabilitySpinner.setSelection(availability); + mVisibilitySpinner.setSelection(visibility); + + // This is an existing event so hide the calendar spinner + // since we can't change the calendar. + View calendarSeparator = findViewById(R.id.calendar_separator); + View calendarLabel = findViewById(R.id.calendar_label); + calendarSeparator.setVisibility(View.GONE); + calendarLabel.setVisibility(View.GONE); + mCalendarsSpinner.setVisibility(View.GONE); + } else if (Time.isEpoch(mStartTime) && Time.isEpoch(mEndTime)) { + mStartTime.setToNow(); + + // Round the time to the nearest half hour. + mStartTime.second = 0; + int minute = mStartTime.minute; + if (minute > 0 && minute <= 30) { + mStartTime.minute = 30; + } else { + mStartTime.minute = 0; + mStartTime.hour += 1; + } + + long startMillis = mStartTime.normalize(true /* ignore isDst */); + mEndTime.set(startMillis + DateUtils.HOUR_IN_MILLIS); + } else { + // New event - set the default reminder + if (mDefaultReminderMinutes != 0) { + addReminder(this, this, mReminderItems, mReminderValues, + mReminderLabels, mDefaultReminderMinutes); + } + + // Hide delete button + mDeleteButton.setVisibility(View.GONE); + } + + updateRemindersVisibility(); + populateWhen(); + populateRepeats(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuItem item; + item = menu.add(MENU_GROUP_REMINDER, MENU_ADD_REMINDER, 0, + R.string.add_new_reminder); + item.setIcon(R.drawable.ic_menu_reminder); + item.setAlphabeticShortcut('r'); + + item = menu.add(MENU_GROUP_SHOW_OPTIONS, MENU_SHOW_EXTRA_OPTIONS, 0, + R.string.edit_event_show_extra_options); + item.setIcon(R.drawable.ic_menu_show_list); + item = menu.add(MENU_GROUP_HIDE_OPTIONS, MENU_HIDE_EXTRA_OPTIONS, 0, + R.string.edit_event_hide_extra_options); + item.setIcon(R.drawable.ic_menu_show_list); + + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + if (mReminderItems.size() < MAX_REMINDERS) { + menu.setGroupVisible(MENU_GROUP_REMINDER, true); + menu.setGroupEnabled(MENU_GROUP_REMINDER, true); + } else { + menu.setGroupVisible(MENU_GROUP_REMINDER, false); + menu.setGroupEnabled(MENU_GROUP_REMINDER, false); + } + + if (mExtraOptions.getVisibility() == View.VISIBLE) { + menu.setGroupVisible(MENU_GROUP_SHOW_OPTIONS, false); + menu.setGroupVisible(MENU_GROUP_HIDE_OPTIONS, true); + } else { + menu.setGroupVisible(MENU_GROUP_SHOW_OPTIONS, true); + menu.setGroupVisible(MENU_GROUP_HIDE_OPTIONS, false); + } + + return super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case MENU_ADD_REMINDER: + // TODO: when adding a new reminder, make it different from the + // last one in the list (if any). + if (mDefaultReminderMinutes == 0) { + addReminder(this, this, mReminderItems, mReminderValues, + mReminderLabels, 10 /* minutes */); + } else { + addReminder(this, this, mReminderItems, mReminderValues, + mReminderLabels, mDefaultReminderMinutes); + } + updateRemindersVisibility(); + return true; + case MENU_SHOW_EXTRA_OPTIONS: + mExtraOptions.setVisibility(View.VISIBLE); + return true; + case MENU_HIDE_EXTRA_OPTIONS: + mExtraOptions.setVisibility(View.GONE); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + switch (keyCode) { + case KeyEvent.KEYCODE_BACK: + // If we are creating a new event, do not create it if the + // title, location and description are all empty, in order to + // prevent accidental "no subject" event creations. + if (mUri != null || !isEmpty()) { + if (!save()) { + // We cannot exit this activity because the calendars + // are still loading. + return true; + } + } + break; + } + + return super.onKeyDown(keyCode, event); + } + + private void populateWhen() { + long startMillis = mStartTime.toMillis(false /* use isDst */); + long endMillis = mEndTime.toMillis(false /* use isDst */); + setDate(mStartDateButton, startMillis); + setDate(mEndDateButton, endMillis); + + setTime(mStartTimeButton, startMillis); + setTime(mEndTimeButton, endMillis); + + mStartDateButton.setOnClickListener(new DateClickListener(mStartTime)); + mEndDateButton.setOnClickListener(new DateClickListener(mEndTime)); + + mStartTimeButton.setOnClickListener(new TimeClickListener(mStartTime)); + mEndTimeButton.setOnClickListener(new TimeClickListener(mEndTime)); + } + + private void populateRepeats() { + Time time = mStartTime; + Resources r = getResources(); + int resource = android.R.layout.simple_spinner_item; + + String[] days = r.getStringArray(R.array.day_labels); + String[] ordinals = r.getStringArray(R.array.ordinal_labels); + + // Only display "Custom" in the spinner if the device does not support the + // recurrence functionality of the event. Only display every weekday if + // the event starts on a weekday. + boolean isCustomRecurrence = isCustomRecurrence(); + boolean isWeekdayEvent = isWeekdayEvent(); + + ArrayList<String> repeatArray = new ArrayList<String>(0); + ArrayList<Integer> recurrenceIndexes = new ArrayList<Integer>(0); + + repeatArray.add(r.getString(R.string.does_not_repeat)); + recurrenceIndexes.add(DOES_NOT_REPEAT); + + repeatArray.add(r.getString(R.string.daily)); + recurrenceIndexes.add(REPEATS_DAILY); + + if (isWeekdayEvent) { + repeatArray.add(r.getString(R.string.every_weekday)); + recurrenceIndexes.add(REPEATS_EVERY_WEEKDAY); + } + + String format = r.getString(R.string.weekly); + repeatArray.add(String.format(format, time.format("%A"))); + recurrenceIndexes.add(REPEATS_WEEKLY_ON_DAY); + + // Calculate whether this is the 1st, 2nd, 3rd, 4th, or last appearance of the given day. + int dayNumber = (time.monthDay - 1) / 7; + format = r.getString(R.string.monthly_on_day_count); + repeatArray.add(String.format(format, ordinals[dayNumber], days[time.weekDay])); + recurrenceIndexes.add(REPEATS_MONTHLY_ON_DAY_COUNT); + + format = r.getString(R.string.monthly_on_day); + repeatArray.add(String.format(format, time.monthDay)); + recurrenceIndexes.add(REPEATS_MONTHLY_ON_DAY); + + long when = time.toMillis(false); + format = r.getString(R.string.yearly); + int flags = 0; + if (DateFormat.is24HourFormat(this)) { + flags |= DateUtils.FORMAT_24HOUR; + } + repeatArray.add(String.format(format, DateUtils.formatDateTime(this, when, flags))); + recurrenceIndexes.add(REPEATS_YEARLY); + + if (isCustomRecurrence) { + repeatArray.add(r.getString(R.string.custom)); + recurrenceIndexes.add(REPEATS_CUSTOM); + } + mRecurrenceIndexes = recurrenceIndexes; + + int position = recurrenceIndexes.indexOf(DOES_NOT_REPEAT); + if (mRrule != null) { + if (isCustomRecurrence) { + position = recurrenceIndexes.indexOf(REPEATS_CUSTOM); + } else { + switch (mEventRecurrence.freq) { + case EventRecurrence.DAILY: + position = recurrenceIndexes.indexOf(REPEATS_DAILY); + break; + case EventRecurrence.WEEKLY: + if (mEventRecurrence.repeatsOnEveryWeekDay()) { + position = recurrenceIndexes.indexOf(REPEATS_EVERY_WEEKDAY); + } else { + position = recurrenceIndexes.indexOf(REPEATS_WEEKLY_ON_DAY); + } + break; + case EventRecurrence.MONTHLY: + if (mEventRecurrence.repeatsMonthlyOnDayCount()) { + position = recurrenceIndexes.indexOf(REPEATS_MONTHLY_ON_DAY_COUNT); + } else { + position = recurrenceIndexes.indexOf(REPEATS_MONTHLY_ON_DAY); + } + break; + case EventRecurrence.YEARLY: + position = recurrenceIndexes.indexOf(REPEATS_YEARLY); + break; + } + } + } + ArrayAdapter<String> adapter = new ArrayAdapter<String>(this, resource, repeatArray); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + mRepeatsSpinner.setAdapter(adapter); + mRepeatsSpinner.setSelection(position); + } + + // Adds a reminder to the displayed list of reminders. + // Returns true if successfully added reminder, false if no reminders can + // be added. + static boolean addReminder(Activity activity, View.OnClickListener listener, + ArrayList<LinearLayout> items, ArrayList<Integer> values, + ArrayList<String> labels, int minutes) { + + if (items.size() >= MAX_REMINDERS) { + return false; + } + + LayoutInflater inflater = activity.getLayoutInflater(); + LinearLayout parent = (LinearLayout) activity.findViewById(R.id.reminder_items_container); + LinearLayout reminderItem = (LinearLayout) inflater.inflate(R.layout.edit_reminder_item, null); + parent.addView(reminderItem); + + Spinner spinner = (Spinner) reminderItem.findViewById(R.id.reminder_value); + Resources res = activity.getResources(); + spinner.setPrompt(res.getString(R.string.reminders_label)); + int resource = android.R.layout.simple_spinner_item; + ArrayAdapter<String> adapter = new ArrayAdapter<String>(activity, resource, labels); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinner.setAdapter(adapter); + + ImageButton reminderRemoveButton; + reminderRemoveButton = (ImageButton) reminderItem.findViewById(R.id.reminder_remove); + reminderRemoveButton.setOnClickListener(listener); + + int index = findMinutesInReminderList(values, minutes); + spinner.setSelection(index); + items.add(reminderItem); + + return true; + } + + static void addMinutesToList(Context context, ArrayList<Integer> values, + ArrayList<String> labels, int minutes) { + int index = values.indexOf(minutes); + if (index != -1) { + return; + } + + // The requested "minutes" does not exist in the list, so insert it + // into the list. + + String label = constructReminderLabel(context, minutes, false); + int len = values.size(); + for (int i = 0; i < len; i++) { + if (minutes < values.get(i)) { + values.add(i, minutes); + labels.add(i, label); + return; + } + } + + values.add(minutes); + labels.add(len, label); + } + + /** + * Finds the index of the given "minutes" in the "values" list. + * + * @param values the list of minutes corresponding to the spinner choices + * @param minutes the minutes to search for in the values list + * @return the index of "minutes" in the "values" list + */ + private static int findMinutesInReminderList(ArrayList<Integer> values, int minutes) { + int index = values.indexOf(minutes); + if (index == -1) { + // This should never happen. + Log.e("Cal", "Cannot find minutes (" + minutes + ") in list"); + return 0; + } + return index; + } + + // Constructs a label given an arbitrary number of minutes. For example, + // if the given minutes is 63, then this returns the string "63 minutes". + // As another example, if the given minutes is 120, then this returns + // "2 hours". + static String constructReminderLabel(Context context, int minutes, boolean abbrev) { + Resources resources = context.getResources(); + int value, resId; + + if (minutes % 60 != 0) { + value = minutes; + if (abbrev) { + resId = R.plurals.Nmins; + } else { + resId = R.plurals.Nminutes; + } + } else if (minutes % (24 * 60) != 0) { + value = minutes / 60; + resId = R.plurals.Nhours; + } else { + value = minutes / ( 24 * 60); + resId = R.plurals.Ndays; + } + + String format = resources.getQuantityString(resId, value); + return String.format(format, value); + } + + private void updateRemindersVisibility() { + if (mReminderItems.size() == 0) { + mRemindersSeparator.setVisibility(View.GONE); + mRemindersContainer.setVisibility(View.GONE); + } else { + mRemindersSeparator.setVisibility(View.VISIBLE); + mRemindersContainer.setVisibility(View.VISIBLE); + } + } + + private void setDate(TextView view, long millis) { + int flags = DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR | + DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_ABBREV_MONTH | + DateUtils.FORMAT_ABBREV_WEEKDAY; + view.setText(DateUtils.formatDateTime(this, millis, flags)); + } + + private void setTime(TextView view, long millis) { + int flags = DateUtils.FORMAT_SHOW_TIME; + if (DateFormat.is24HourFormat(this)) { + flags |= DateUtils.FORMAT_24HOUR; + } + view.setText(DateUtils.formatDateTime(this, millis, flags)); + } + + // Saves the event. Returns true if it is okay to exit this activity. + private boolean save() { + // If we are creating a new event, then make sure we wait until the + // query to fetch the list of calendars has finished. + if (mEventCursor == null) { + if (!mCalendarsQueryComplete) { + // Wait for the calendars query to finish. + if (mLoadingCalendarsDialog == null) { + // Create the progress dialog + mLoadingCalendarsDialog = ProgressDialog.show(this, + getText(R.string.loading_calendars_title), + getText(R.string.loading_calendars_message), + true, true, this); + mSaveAfterQueryComplete = true; + } + return false; + } + + // Avoid creating a new event if the calendars cursor is empty. This + // shouldn't ever happen since the setup wizard should ensure the user + // has a calendar. + if (mCalendarsCursor == null || mCalendarsCursor.getCount() == 0) { + Log.w("Cal", "The calendars table does not contain any calendars." + + " New event was not created."); + return true; + } + Toast.makeText(this, R.string.creating_event, Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(this, R.string.saving_event, Toast.LENGTH_SHORT).show(); + } + + ContentResolver cr = getContentResolver(); + ContentValues values = getContentValuesFromUi(); + Uri uri = mUri; + + // For recurring events, we must make sure that we use duration rather + // than dtend. + if (uri == null) { + // Create new event with new contents + addRecurrenceRule(values); + uri = cr.insert(Events.CONTENT_URI, values); + + } else if (mRrule == null) { + // Modify contents of a non-repeating event + addRecurrenceRule(values); + checkTimeDependentFields(values); + cr.update(uri, values, null, null); + + } else if (mInitialValues.getAsString(Events.RRULE) == null) { + // This event was changed from a non-repeating event to a + // repeating event. + addRecurrenceRule(values); + values.remove(Events.DTEND); + cr.update(uri, values, null, null); + + } else if (mModification == MODIFY_SELECTED) { + // Modify contents of the current instance of repeating event + + // Create a recurrence exception + long begin = mInitialValues.getAsLong(EVENT_BEGIN_TIME); + values.put(Events.ORIGINAL_EVENT, mEventCursor.getString(EVENT_INDEX_SYNC_ID)); + values.put(Events.ORIGINAL_INSTANCE_TIME, begin); + boolean allDay = mInitialValues.getAsInteger(Events.ALL_DAY) != 0; + values.put(Events.ORIGINAL_ALL_DAY, allDay ? 1 : 0); + + uri = cr.insert(Events.CONTENT_URI, values); + + } else if (mModification == MODIFY_ALL_FOLLOWING) { + // Modify this instance and all future instances of repeating event + addRecurrenceRule(values); + + if (mRrule == null) { + // We've changed a recurring event to a non-recurring event. + // If the event we are editing is the first in the series, + // then delete the whole series. Otherwise, update the series + // to end at the new start time. + if (isFirstEventInSeries()) { + cr.delete(uri, null, null); + } else { + // Update the current repeating event to end at the new + // start time. + updatePastEvents(cr, uri); + } + uri = cr.insert(Events.CONTENT_URI, values); + } else { + if (isFirstEventInSeries()) { + checkTimeDependentFields(values); + values.remove(Events.DTEND); + cr.update(uri, values, null, null); + } else { + // Update the current repeating event to end at the new + // start time. + updatePastEvents(cr, uri); + + // Create a new event with the user-modified fields + values.remove(Events.DTEND); + uri = cr.insert(Events.CONTENT_URI, values); + } + } + + } else if (mModification == MODIFY_ALL) { + + // Modify all instances of repeating event + addRecurrenceRule(values); + + if (mRrule == null) { + // We've changed a recurring event to a non-recurring event. + // Delete the whole series and replace it with a new + // non-recurring event. + cr.delete(uri, null, null); + uri = cr.insert(Events.CONTENT_URI, values); + } else { + checkTimeDependentFields(values); + values.remove(Events.DTEND); + cr.update(uri, values, null, null); + } + } + + if (uri != null) { + long eventId = ContentUris.parseId(uri); + ArrayList<Integer> reminderMinutes = reminderItemsToMinutes(mReminderItems, + mReminderValues); + saveReminders(cr, eventId, reminderMinutes, mOriginalMinutes); + } + return true; + } + + private boolean isFirstEventInSeries() { + int dtStart = mEventCursor.getColumnIndexOrThrow(Events.DTSTART); + long start = mEventCursor.getLong(dtStart); + return start == mStartTime.toMillis(true); + } + + private void updatePastEvents(ContentResolver cr, Uri uri) { + long oldStartMillis = mEventCursor.getLong(EVENT_INDEX_DTSTART); + String oldDuration = mEventCursor.getString(EVENT_INDEX_DURATION); + boolean allDay = mEventCursor.getInt(EVENT_INDEX_ALL_DAY) != 0; + String oldRrule = mEventCursor.getString(EVENT_INDEX_RRULE); + mEventRecurrence.parse(oldRrule); + + Time untilTime = new Time(); + long begin = mInitialValues.getAsLong(EVENT_BEGIN_TIME); + ContentValues oldValues = new ContentValues(); + + // The "until" time must be in UTC time in order for Google calendar + // to display it properly. For all-day events, the "until" time string + // must include just the date field, and not the time field. The + // repeating events repeat up to and including the "until" time. + untilTime.timezone = Time.TIMEZONE_UTC; + + // Subtract one second from the old begin time to get the new + // "until" time. + untilTime.set(begin - 1000); // subtract one second (1000 millis) + if (allDay) { + untilTime.hour = 0; + untilTime.minute = 0; + untilTime.second = 0; + untilTime.allDay = true; + untilTime.normalize(false); + + // For all-day events, the duration must be in days, not seconds. + // Otherwise, Google Calendar will (mistakenly) change this event + // into a non-all-day event. + int len = oldDuration.length(); + if (oldDuration.charAt(0) == 'P' && oldDuration.charAt(len - 1) == 'S') { + int seconds = Integer.parseInt(oldDuration.substring(1, len - 1)); + int days = (seconds + DAY_IN_SECONDS - 1) / DAY_IN_SECONDS; + oldDuration = "P" + days + "D"; + } + } + mEventRecurrence.until = untilTime.format2445(); + + oldValues.put(Events.DTSTART, oldStartMillis); + oldValues.put(Events.DURATION, oldDuration); + oldValues.put(Events.RRULE, mEventRecurrence.toString()); + cr.update(uri, oldValues, null, null); + } + + private void checkTimeDependentFields(ContentValues values) { + long oldBegin = mInitialValues.getAsLong(EVENT_BEGIN_TIME); + long oldEnd = mInitialValues.getAsLong(EVENT_END_TIME); + boolean oldAllDay = mInitialValues.getAsInteger(Events.ALL_DAY) != 0; + String oldRrule = mInitialValues.getAsString(Events.RRULE); + String oldTimezone = mInitialValues.getAsString(Events.EVENT_TIMEZONE); + + long newBegin = values.getAsLong(Events.DTSTART); + long newEnd = values.getAsLong(Events.DTEND); + boolean newAllDay = values.getAsInteger(Events.ALL_DAY) != 0; + String newRrule = values.getAsString(Events.RRULE); + String newTimezone = values.getAsString(Events.EVENT_TIMEZONE); + + // If none of the time-dependent fields changed, then remove them. + if (oldBegin == newBegin && oldEnd == newEnd && oldAllDay == newAllDay + && TextUtils.equals(oldRrule, newRrule) + && TextUtils.equals(oldTimezone, newTimezone)) { + values.remove(Events.DTSTART); + values.remove(Events.DTEND); + values.remove(Events.DURATION); + values.remove(Events.ALL_DAY); + values.remove(Events.RRULE); + values.remove(Events.EVENT_TIMEZONE); + return; + } + + if (oldRrule == null || newRrule == null) { + return; + } + + // If we are modifying all events then we need to set DTSTART to the + // start time of the first event in the series, not the current + // date and time. If the start time of the event was changed + // (from, say, 3pm to 4pm), then we want to add the time difference + // to the start time of the first event in the series (the DTSTART + // value). If we are modifying one instance or all following instances, + // then we leave the DTSTART field alone. + if (mModification == MODIFY_ALL) { + long oldStartMillis = mEventCursor.getLong(EVENT_INDEX_DTSTART); + if (oldBegin != newBegin) { + // The user changed the start time of this event + long offset = newBegin - oldBegin; + oldStartMillis += offset; + } + values.put(Events.DTSTART, oldStartMillis); + } + } + + static ArrayList<Integer> reminderItemsToMinutes(ArrayList<LinearLayout> reminderItems, + ArrayList<Integer> reminderValues) { + int len = reminderItems.size(); + ArrayList<Integer> reminderMinutes = new ArrayList<Integer>(len); + for (int index = 0; index < len; index++) { + LinearLayout layout = reminderItems.get(index); + Spinner spinner = (Spinner) layout.findViewById(R.id.reminder_value); + int minutes = reminderValues.get(spinner.getSelectedItemPosition()); + reminderMinutes.add(minutes); + } + return reminderMinutes; + } + + /** + * Saves the reminders, if they changed. Returns true if the database + * was updated. + * + * @param cr the ContentResolver + * @param eventId the id of the event whose reminders are being updated + * @param reminderMinutes the array of reminders set by the user + * @param originalMinutes the original array of reminders + * @return true if the database was updated + */ + static boolean saveReminders(ContentResolver cr, long eventId, + ArrayList<Integer> reminderMinutes, ArrayList<Integer> originalMinutes) { + // If the reminders have not changed, then don't update the database + if (reminderMinutes.equals(originalMinutes)) { + return false; + } + + // Delete all the existing reminders for this event + String where = Reminders.EVENT_ID + "=?"; + String[] args = new String[] { Long.toString(eventId) }; + cr.delete(Reminders.CONTENT_URI, where, args); + + // Update the "hasAlarm" field for the event + ContentValues values = new ContentValues(); + int len = reminderMinutes.size(); + values.put(Events.HAS_ALARM, (len > 0) ? 1 : 0); + Uri uri = ContentUris.withAppendedId(Events.CONTENT_URI, eventId); + cr.update(uri, values, null /* where */, null /* selection args */); + + // Insert the new reminders, if any + for (int i = 0; i < len; i++) { + int minutes = reminderMinutes.get(i); + + values.clear(); + values.put(Reminders.MINUTES, minutes); + values.put(Reminders.METHOD, Reminders.METHOD_ALERT); + values.put(Reminders.EVENT_ID, eventId); + cr.insert(Reminders.CONTENT_URI, values); + } + return true; + } + + private void addRecurrenceRule(ContentValues values) { + updateRecurrenceRule(); + + if (mRrule == null) { + return; + } + + values.put(Events.RRULE, mRrule); + long end = mEndTime.toMillis(true /* ignore dst */); + long start = mStartTime.toMillis(true /* ignore dst */); + String duration; + + boolean isAllDay = mAllDayCheckBox.isChecked(); + if (isAllDay) { + long days = (end - start + DateUtils.DAY_IN_MILLIS - 1) / DateUtils.DAY_IN_MILLIS; + duration = "P" + days + "D"; + } else { + long seconds = (end - start) / DateUtils.SECOND_IN_MILLIS; + duration = "P" + seconds + "S"; + } + values.put(Events.DURATION, duration); + } + + private void updateRecurrenceRule() { + int position = mRepeatsSpinner.getSelectedItemPosition(); + int selection = mRecurrenceIndexes.get(position); + + if (selection == DOES_NOT_REPEAT) { + mRrule = null; + return; + } else if (selection == REPEATS_CUSTOM) { + // Keep custom recurrence as before. + return; + } else if (selection == REPEATS_DAILY) { + mEventRecurrence.freq = EventRecurrence.DAILY; + } else if (selection == REPEATS_EVERY_WEEKDAY) { + mEventRecurrence.freq = EventRecurrence.WEEKLY; + int dayCount = 5; + int[] byday = new int[dayCount]; + int[] bydayNum = new int[dayCount]; + + byday[0] = EventRecurrence.MO; + byday[1] = EventRecurrence.TU; + byday[2] = EventRecurrence.WE; + byday[3] = EventRecurrence.TH; + byday[4] = EventRecurrence.FR; + for (int day = 0; day < dayCount; day++) { + bydayNum[day] = 0; + } + + mEventRecurrence.byday = byday; + mEventRecurrence.bydayNum = bydayNum; + mEventRecurrence.bydayCount = dayCount; + } else if (selection == REPEATS_WEEKLY_ON_DAY) { + mEventRecurrence.freq = EventRecurrence.WEEKLY; + int[] days = new int[1]; + int dayCount = 1; + int[] dayNum = new int[dayCount]; + + days[0] = EventRecurrence.timeDay2Day(mStartTime.weekDay); + // not sure why this needs to be zero, but set it for now. + dayNum[0] = 0; + + mEventRecurrence.byday = days; + mEventRecurrence.bydayNum = dayNum; + mEventRecurrence.bydayCount = dayCount; + } else if (selection == REPEATS_MONTHLY_ON_DAY) { + mEventRecurrence.freq = EventRecurrence.MONTHLY; + mEventRecurrence.bydayCount = 0; + mEventRecurrence.bymonthdayCount = 1; + int[] bymonthday = new int[1]; + bymonthday[0] = mStartTime.monthDay; + mEventRecurrence.bymonthday = bymonthday; + } else if (selection == REPEATS_MONTHLY_ON_DAY_COUNT) { + mEventRecurrence.freq = EventRecurrence.MONTHLY; + mEventRecurrence.bydayCount = 1; + mEventRecurrence.bymonthdayCount = 0; + + int[] byday = new int[1]; + int[] bydayNum = new int[1]; + // Compute the week number (for example, the "2nd" Monday) + int dayCount = 1 + ((mStartTime.monthDay - 1) / 7); + if (dayCount == 5) { + dayCount = -1; + } + bydayNum[0] = dayCount; + byday[0] = EventRecurrence.timeDay2Day(mStartTime.weekDay); + mEventRecurrence.byday = byday; + mEventRecurrence.bydayNum = bydayNum; + } else if (selection == REPEATS_YEARLY) { + mEventRecurrence.freq = EventRecurrence.YEARLY; + } + + // Set the week start day. + mEventRecurrence.wkst = EventRecurrence.calendarDay2Day(mFirstDayOfWeek); + mRrule = mEventRecurrence.toString(); + } + + private ContentValues getContentValuesFromUi() { + String title = mTitleTextView.getText().toString(); + boolean isAllDay = mAllDayCheckBox.isChecked(); + String location = mLocationTextView.getText().toString(); + String description = mDescriptionTextView.getText().toString(); + + ContentValues values = new ContentValues(); + + String timezone = null; + long startMillis; + long endMillis; + long calendarId; + if (isAllDay) { + // Reset start and end time, increment the monthDay by 1, and set + // the timezone to UTC, as required for all-day events. + timezone = Time.TIMEZONE_UTC; + mStartTime.hour = 0; + mStartTime.minute = 0; + mStartTime.second = 0; + mStartTime.timezone = timezone; + startMillis = mStartTime.normalize(true); + + mEndTime.hour = 0; + mEndTime.minute = 0; + mEndTime.second = 0; + mEndTime.monthDay++; + mEndTime.timezone = timezone; + endMillis = mEndTime.normalize(true); + + if (mEventCursor == null) { + // This is a new event + calendarId = mCalendarsSpinner.getSelectedItemId(); + } else { + calendarId = mInitialValues.getAsLong(Events.CALENDAR_ID); + } + } else { + startMillis = mStartTime.toMillis(true); + endMillis = mEndTime.toMillis(true); + if (mEventCursor != null) { + // This is an existing event + timezone = mEventCursor.getString(EVENT_INDEX_TIMEZONE); + + // The timezone might be null if we are changing an existing + // all-day event to a non-all-day event. We need to assign + // a timezone to the non-all-day event. + if (TextUtils.isEmpty(timezone)) { + timezone = TimeZone.getDefault().getID(); + } + calendarId = mInitialValues.getAsLong(Events.CALENDAR_ID); + } else { + // This is a new event + calendarId = mCalendarsSpinner.getSelectedItemId(); + + // The timezone for a new event is the currently displayed + // timezone, NOT the timezone of the containing calendar. + timezone = TimeZone.getDefault().getID(); + } + } + + values.put(Events.CALENDAR_ID, calendarId); + values.put(Events.EVENT_TIMEZONE, timezone); + values.put(Events.TITLE, title); + values.put(Events.ALL_DAY, isAllDay ? 1 : 0); + values.put(Events.DTSTART, startMillis); + values.put(Events.DTEND, endMillis); + values.put(Events.DESCRIPTION, description); + values.put(Events.EVENT_LOCATION, location); + values.put(Events.TRANSPARENCY, mAvailabilitySpinner.getSelectedItemPosition()); + + int visibility = mVisibilitySpinner.getSelectedItemPosition(); + if (visibility > 0) { + // For now we the array contains the values 0, 2, and 3. We add one to match. + visibility++; + } + values.put(Events.VISIBILITY, visibility); + + return values; + } + + private boolean isEmpty() { + String title = mTitleTextView.getText().toString(); + if (title.length() > 0) { + return false; + } + + String location = mLocationTextView.getText().toString(); + if (location.length() > 0) { + return false; + } + + String description = mDescriptionTextView.getText().toString(); + if (description.length() > 0) { + return false; + } + + return true; + } + + private boolean isCustomRecurrence() { + + if (mEventRecurrence.until != null || mEventRecurrence.interval != 0) { + return true; + } + + if (mEventRecurrence.freq == 0) { + return false; + } + + switch (mEventRecurrence.freq) { + case EventRecurrence.DAILY: + return false; + case EventRecurrence.WEEKLY: + if (mEventRecurrence.repeatsOnEveryWeekDay() && isWeekdayEvent()) { + return false; + } else if (mEventRecurrence.bydayCount == 1) { + return false; + } + break; + case EventRecurrence.MONTHLY: + if (mEventRecurrence.repeatsMonthlyOnDayCount()) { + return false; + } else if (mEventRecurrence.bydayCount == 0 && mEventRecurrence.bymonthdayCount == 1) { + return false; + } + break; + case EventRecurrence.YEARLY: + return false; + } + + return true; + } + + private boolean isWeekdayEvent() { + if (mStartTime.weekDay != Time.SUNDAY && mStartTime.weekDay != Time.SATURDAY) { + return true; + } + return false; + } +} diff --git a/src/com/android/calendar/EditResponseHelper.java b/src/com/android/calendar/EditResponseHelper.java new file mode 100644 index 00000000..378ef458 --- /dev/null +++ b/src/com/android/calendar/EditResponseHelper.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2009 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.calendar; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.widget.Button; + +/** + * A helper class for editing the response to an invitation when the invitation + * is a repeating event. + */ +public class EditResponseHelper implements DialogInterface.OnClickListener { + private final Activity mParent; + private int mWhichEvents = -1; + private AlertDialog mAlertDialog; + + /** + * This callback is passed in to this object when this object is created + * and is invoked when the "Ok" button is selected. + */ + private DialogInterface.OnClickListener mDialogListener; + + public EditResponseHelper(Activity parent) { + mParent = parent; + } + + public void setOnClickListener(DialogInterface.OnClickListener listener) { + mDialogListener = listener; + } + + public int getWhichEvents() { + return mWhichEvents; + } + + public void onClick(DialogInterface dialog, int which) { + } + + /** + * This callback is used when a list item is selected + */ + private DialogInterface.OnClickListener mListListener = + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + mWhichEvents = which; + + // Enable the "ok" button now that the user has selected which + // events in the series to delete. + Button ok = mAlertDialog.getButton(DialogInterface.BUTTON_POSITIVE); + ok.setEnabled(true); + } + }; + + public void showDialog(int whichEvents) { + // We need to have a non-null listener, otherwise we get null when + // we try to fetch the "Ok" button. + if (mDialogListener == null) { + mDialogListener = this; + } + AlertDialog dialog = new AlertDialog.Builder(mParent) + .setTitle(R.string.change_response_title) + .setIcon(android.R.drawable.ic_dialog_alert) + .setSingleChoiceItems(R.array.change_response_labels, whichEvents, + mListListener) + .setPositiveButton(android.R.string.ok, mDialogListener) + .setNegativeButton(android.R.string.cancel, null) + .show(); + mAlertDialog = dialog; + + if (whichEvents == -1) { + // Disable the "Ok" button until the user selects which events to + // delete. + Button ok = dialog.getButton(DialogInterface.BUTTON_POSITIVE); + ok.setEnabled(false); + } + } +} diff --git a/src/com/android/calendar/Event.java b/src/com/android/calendar/Event.java new file mode 100644 index 00000000..c15e5b54 --- /dev/null +++ b/src/com/android/calendar/Event.java @@ -0,0 +1,625 @@ +/* + * Copyright (C) 2007 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.calendar; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.database.Cursor; +import android.os.Debug; +import android.preference.PreferenceManager; +import android.provider.Calendar.Attendees; +import android.provider.Calendar.Instances; +import android.text.TextUtils; +import android.text.format.DateUtils; +import android.text.format.Time; +import android.util.Log; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.concurrent.atomic.AtomicInteger; + +// TODO: should Event be Parcelable so it can be passed via Intents? +public class Event implements Comparable, Cloneable { + + private static final boolean PROFILE = false; + + private static final String[] PROJECTION = new String[] { + Instances.TITLE, // 0 + Instances.EVENT_LOCATION, // 1 + Instances.ALL_DAY, // 2 + Instances.COLOR, // 3 + Instances.EVENT_TIMEZONE, // 4 + Instances.EVENT_ID, // 5 + Instances.BEGIN, // 6 + Instances.END, // 7 + Instances._ID, // 8 + Instances.START_DAY, // 9 + Instances.END_DAY, // 10 + Instances.START_MINUTE, // 11 + Instances.END_MINUTE, // 12 + Instances.HAS_ALARM, // 13 + Instances.RRULE, // 14 + Instances.RDATE, // 15 + Instances.SELF_ATTENDEE_STATUS, // 16 + }; + + // The indices for the projection array above. + private static final int PROJECTION_TITLE_INDEX = 0; + private static final int PROJECTION_LOCATION_INDEX = 1; + private static final int PROJECTION_ALL_DAY_INDEX = 2; + private static final int PROJECTION_COLOR_INDEX = 3; + private static final int PROJECTION_TIMEZONE_INDEX = 4; + private static final int PROJECTION_EVENT_ID_INDEX = 5; + private static final int PROJECTION_BEGIN_INDEX = 6; + private static final int PROJECTION_END_INDEX = 7; + private static final int PROJECTION_START_DAY_INDEX = 9; + private static final int PROJECTION_END_DAY_INDEX = 10; + private static final int PROJECTION_START_MINUTE_INDEX = 11; + private static final int PROJECTION_END_MINUTE_INDEX = 12; + private static final int PROJECTION_HAS_ALARM_INDEX = 13; + private static final int PROJECTION_RRULE_INDEX = 14; + private static final int PROJECTION_RDATE_INDEX = 15; + private static final int PROJECTION_SELF_ATTENDEE_STATUS_INDEX = 16; + + public long id; + public int color; + public CharSequence title; + public CharSequence location; + public boolean allDay; + + public int startDay; // start Julian day + public int endDay; // end Julian day + public int startTime; // Start and end time are in minutes since midnight + public int endTime; + + public long startMillis; // UTC milliseconds since the epoch + public long endMillis; // UTC milliseconds since the epoch + private int mColumn; + private int mMaxColumns; + + public boolean hasAlarm; + public boolean isRepeating; + + public int selfAttendeeStatus; + + // The coordinates of the event rectangle drawn on the screen. + public float left; + public float right; + public float top; + public float bottom; + + // These 4 fields are used for navigating among events within the selected + // hour in the Day and Week view. + public Event nextRight; + public Event nextLeft; + public Event nextUp; + public Event nextDown; + + private static final int MIDNIGHT_IN_MINUTES = 24 * 60; + + @Override + public final Object clone() { + Event e = new Event(); + + e.title = title; + e.color = color; + e.location = location; + e.allDay = allDay; + e.startDay = startDay; + e.endDay = endDay; + e.startTime = startTime; + e.endTime = endTime; + e.startMillis = startMillis; + e.endMillis = endMillis; + e.hasAlarm = hasAlarm; + e.isRepeating = isRepeating; + e.selfAttendeeStatus = selfAttendeeStatus; + + return e; + } + + public final void copyTo(Event dest) { + dest.id = id; + dest.title = title; + dest.color = color; + dest.location = location; + dest.allDay = allDay; + dest.startDay = startDay; + dest.endDay = endDay; + dest.startTime = startTime; + dest.endTime = endTime; + dest.startMillis = startMillis; + dest.endMillis = endMillis; + dest.hasAlarm = hasAlarm; + dest.isRepeating = isRepeating; + dest.selfAttendeeStatus = selfAttendeeStatus; + } + + public static final Event newInstance() { + Event e = new Event(); + + e.id = 0; + e.title = null; + e.color = 0; + e.location = null; + e.allDay = false; + e.startDay = 0; + e.endDay = 0; + e.startTime = 0; + e.endTime = 0; + e.startMillis = 0; + e.endMillis = 0; + e.hasAlarm = false; + e.isRepeating = false; + e.selfAttendeeStatus = Attendees.ATTENDEE_STATUS_NONE; + + return e; + } + + /** + * Compares this event to the given event. This is just used for checking + * if two events differ. It's not used for sorting anymore. + */ + public final int compareTo(Object obj) { + Event e = (Event) obj; + + // The earlier start day and time comes first + if (startDay < e.startDay) return -1; + if (startDay > e.startDay) return 1; + if (startTime < e.startTime) return -1; + if (startTime > e.startTime) return 1; + + // The later end time comes first (in order to put long strips on + // the left). + if (endDay < e.endDay) return 1; + if (endDay > e.endDay) return -1; + if (endTime < e.endTime) return 1; + if (endTime > e.endTime) return -1; + + // Sort all-day events before normal events. + if (allDay && !e.allDay) return -1; + if (!allDay && e.allDay) return 1; + + // If two events have the same time range, then sort them in + // alphabetical order based on their titles. + int cmp = compareStrings(title, e.title); + if (cmp != 0) { + return cmp; + } + + // If the titles are the same then compare the other fields + // so that we can use this function to check for differences + // between events. + cmp = compareStrings(location, e.location); + if (cmp != 0) { + return cmp; + } + return 0; + } + + /** + * Compare string a with string b, but if either string is null, + * then treat it (the null) as if it were the empty string (""). + * + * @param a the first string + * @param b the second string + * @return the result of comparing a with b after replacing null + * strings with "". + */ + private int compareStrings(CharSequence a, CharSequence b) { + String aStr, bStr; + if (a != null) { + aStr = a.toString(); + } else { + aStr = ""; + } + if (b != null) { + bStr = b.toString(); + } else { + bStr = ""; + } + return aStr.compareTo(bStr); + } + + /** + * Loads <i>days</i> days worth of instances starting at <i>start</i>. + */ + public static void loadEvents(Context context, ArrayList<Event> events, + long start, int days, int requestId, AtomicInteger sequenceNumber) { + + if (PROFILE) { + Debug.startMethodTracing("loadEvents"); + } + + Cursor c = null; + + events.clear(); + try { + Time local = new Time(); + int count; + + local.set(start); + int startDay = Time.getJulianDay(start, local.gmtoff); + int endDay = startDay + days; + + local.monthDay += days; + long end = local.normalize(true /* ignore isDst */); + + // Widen the time range that we query by one day on each end + // so that we can catch all-day events. All-day events are + // stored starting at midnight in UTC but should be included + // in the list of events starting at midnight local time. + // This may fetch more events than we actually want, so we + // filter them out below. + // + // The sort order is: events with an earlier start time occur + // first and if the start times are the same, then events with + // a later end time occur first. The later end time is ordered + // first so that long rectangles in the calendar views appear on + // the left side. If the start and end times of two events are + // the same then we sort alphabetically on the title. This isn't + // required for correctness, it just adds a nice touch. + + String orderBy = Instances.SORT_CALENDAR_VIEW; + + // Respect the preference to show/hide declined events + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + boolean hideDeclined = prefs.getBoolean(CalendarPreferenceActivity.KEY_HIDE_DECLINED, + false); + + String where = null; + if (hideDeclined) { + where = Instances.SELF_ATTENDEE_STATUS + "!=" + Attendees.ATTENDEE_STATUS_DECLINED; + } + + c = Instances.query(context.getContentResolver(), PROJECTION, + start - DateUtils.DAY_IN_MILLIS, end + DateUtils.DAY_IN_MILLIS, where, orderBy); + + if (c == null) { + Log.e("Cal", "loadEvents() returned null cursor!"); + return; + } + + // Check if we should return early because there are more recent + // load requests waiting. + if (requestId != sequenceNumber.get()) { + return; + } + + count = c.getCount(); + + if (count == 0) { + return; + } + + Resources res = context.getResources(); + while (c.moveToNext()) { + Event e = new Event(); + + e.id = c.getLong(PROJECTION_EVENT_ID_INDEX); + e.title = c.getString(PROJECTION_TITLE_INDEX); + e.location = c.getString(PROJECTION_LOCATION_INDEX); + e.allDay = c.getInt(PROJECTION_ALL_DAY_INDEX) != 0; + String timezone = c.getString(PROJECTION_TIMEZONE_INDEX); + + if (e.title == null || e.title.length() == 0) { + e.title = res.getString(R.string.no_title_label); + } + + if (!c.isNull(PROJECTION_COLOR_INDEX)) { + // Read the color from the database + e.color = c.getInt(PROJECTION_COLOR_INDEX); + } else { + e.color = res.getColor(R.color.event_center); + } + + long eStart = c.getLong(PROJECTION_BEGIN_INDEX); + long eEnd = c.getLong(PROJECTION_END_INDEX); + + e.startMillis = eStart; + e.startTime = c.getInt(PROJECTION_START_MINUTE_INDEX); + e.startDay = c.getInt(PROJECTION_START_DAY_INDEX); + + e.endMillis = eEnd; + e.endTime = c.getInt(PROJECTION_END_MINUTE_INDEX); + e.endDay = c.getInt(PROJECTION_END_DAY_INDEX); + + if (e.startDay > endDay || e.endDay < startDay) { + continue; + } + + e.hasAlarm = c.getInt(PROJECTION_HAS_ALARM_INDEX) != 0; + + // Check if this is a repeating event + String rrule = c.getString(PROJECTION_RRULE_INDEX); + String rdate = c.getString(PROJECTION_RDATE_INDEX); + if (!TextUtils.isEmpty(rrule) || !TextUtils.isEmpty(rdate)) { + e.isRepeating = true; + } else { + e.isRepeating = false; + } + + e.selfAttendeeStatus = c.getInt(PROJECTION_SELF_ATTENDEE_STATUS_INDEX); + + events.add(e); + } + + computePositions(events); + } finally { + if (c != null) { + c.close(); + } + if (PROFILE) { + Debug.stopMethodTracing(); + } + } + } + + /** + * Computes a position for each event. Each event is displayed + * as a non-overlapping rectangle. For normal events, these rectangles + * are displayed in separate columns in the week view and day view. For + * all-day events, these rectangles are displayed in separate rows along + * the top. In both cases, each event is assigned two numbers: N, and + * Max, that specify that this event is the Nth event of Max number of + * events that are displayed in a group. The width and position of each + * rectangle depend on the maximum number of rectangles that occur at + * the same time. + * + * @param eventsList the list of events, sorted into increasing time order + */ + static void computePositions(ArrayList<Event> eventsList) { + if (eventsList == null) + return; + + // Compute the column positions separately for the all-day events + doComputePositions(eventsList, false); + doComputePositions(eventsList, true); + } + + private static void doComputePositions(ArrayList<Event> eventsList, + boolean doAllDayEvents) { + ArrayList<Event> activeList = new ArrayList<Event>(); + ArrayList<Event> groupList = new ArrayList<Event>(); + + long colMask = 0; + int maxCols = 0; + for (Event event : eventsList) { + // Process all-day events separately + if (event.allDay != doAllDayEvents) + continue; + + long start = event.getStartMillis(); + if (false && event.allDay) { + Event e = event; + Log.i("Cal", "event start,end day: " + e.startDay + "," + e.endDay + + " start,end time: " + e.startTime + "," + e.endTime + + " start,end millis: " + e.getStartMillis() + "," + e.getEndMillis() + + " " + e.title); + } + + // Remove the inactive events. An event on the active list + // becomes inactive when its end time is less than or equal to + // the current event's start time. + Iterator<Event> iter = activeList.iterator(); + while (iter.hasNext()) { + Event active = iter.next(); + if (active.getEndMillis() <= start) { + if (false && event.allDay) { + Event e = active; + Log.i("Cal", " removing: start,end day: " + e.startDay + "," + e.endDay + + " start,end time: " + e.startTime + "," + e.endTime + + " start,end millis: " + e.getStartMillis() + "," + e.getEndMillis() + + " " + e.title); + } + colMask &= ~(1L << active.getColumn()); + iter.remove(); + } + } + + // If the active list is empty, then reset the max columns, clear + // the column bit mask, and empty the groupList. + if (activeList.isEmpty()) { + for (Event ev : groupList) { + ev.setMaxColumns(maxCols); + } + maxCols = 0; + colMask = 0; + groupList.clear(); + } + + // Find the first empty column. Empty columns are represented by + // zero bits in the column mask "colMask". + int col = findFirstZeroBit(colMask); + if (col == 64) + col = 63; + colMask |= (1L << col); + event.setColumn(col); + activeList.add(event); + groupList.add(event); + int len = activeList.size(); + if (maxCols < len) + maxCols = len; + } + for (Event ev : groupList) { + ev.setMaxColumns(maxCols); + } + } + + public static int findFirstZeroBit(long val) { + for (int ii = 0; ii < 64; ++ii) { + if ((val & (1L << ii)) == 0) + return ii; + } + return 64; + } + + /** + * Returns a darker version of the given color. It does this by dividing + * each of the red, green, and blue components by 2. The alpha value is + * preserved. + */ + private static final int getDarkerColor(int color) { + int darker = (color >> 1) & 0x007f7f7f; + int alpha = color & 0xff000000; + return alpha | darker; + } + + // For testing. This method can be removed at any time. + private static ArrayList<Event> createTestEventList() { + ArrayList<Event> evList = new ArrayList<Event>(); + createTestEvent(evList, 1, 5, 10); + createTestEvent(evList, 2, 5, 10); + createTestEvent(evList, 3, 15, 20); + createTestEvent(evList, 4, 20, 25); + createTestEvent(evList, 5, 30, 70); + createTestEvent(evList, 6, 32, 40); + createTestEvent(evList, 7, 32, 40); + createTestEvent(evList, 8, 34, 38); + createTestEvent(evList, 9, 34, 38); + createTestEvent(evList, 10, 42, 50); + createTestEvent(evList, 11, 45, 60); + createTestEvent(evList, 12, 55, 90); + createTestEvent(evList, 13, 65, 75); + + createTestEvent(evList, 21, 105, 130); + createTestEvent(evList, 22, 110, 120); + createTestEvent(evList, 23, 115, 130); + createTestEvent(evList, 24, 125, 140); + createTestEvent(evList, 25, 127, 135); + + createTestEvent(evList, 31, 150, 160); + createTestEvent(evList, 32, 152, 162); + createTestEvent(evList, 33, 153, 163); + createTestEvent(evList, 34, 155, 170); + createTestEvent(evList, 35, 158, 175); + createTestEvent(evList, 36, 165, 180); + + return evList; + } + + // For testing. This method can be removed at any time. + private static Event createTestEvent(ArrayList<Event> evList, int id, + int startMinute, int endMinute) { + Event ev = new Event(); + ev.title = "ev" + id; + ev.startDay = 1; + ev.endDay = 1; + ev.setStartMillis(startMinute); + ev.setEndMillis(endMinute); + evList.add(ev); + return ev; + } + + public final void dump() { + Log.e("Cal", "+-----------------------------------------+"); + Log.e("Cal", "+ id = " + id); + Log.e("Cal", "+ color = " + color); + Log.e("Cal", "+ title = " + title); + Log.e("Cal", "+ location = " + location); + Log.e("Cal", "+ allDay = " + allDay); + Log.e("Cal", "+ startDay = " + startDay); + Log.e("Cal", "+ endDay = " + endDay); + Log.e("Cal", "+ startTime = " + startTime); + Log.e("Cal", "+ endTime = " + endTime); + } + + public final boolean intersects(int julianDay, int startMinute, + int endMinute) { + if (endDay < julianDay) { + return false; + } + + if (startDay > julianDay) { + return false; + } + + if (endDay == julianDay) { + if (endTime < startMinute) { + return false; + } + // An event that ends at the start minute should not be considered + // as intersecting the given time span, but don't exclude + // zero-length (or very short) events. + if (endTime == startMinute + && (startTime != endTime || startDay != endDay)) { + return false; + } + } + + if (startDay == julianDay && startTime > endMinute) { + return false; + } + + return true; + } + + /** + * Returns the event title and location separated by a comma. If the + * location is already part of the title (at the end of the title), then + * just the title is returned. + * + * @return the event title and location as a String + */ + public String getTitleAndLocation() { + String text = title.toString(); + + // Append the location to the title, unless the title ends with the + // location (for example, "meeting in building 42" ends with the + // location). + if (location != null) { + String locationString = location.toString(); + if (!text.endsWith(locationString)) { + text += ", " + locationString; + } + } + return text; + } + + public void setColumn(int column) { + mColumn = column; + } + + public int getColumn() { + return mColumn; + } + + public void setMaxColumns(int maxColumns) { + mMaxColumns = maxColumns; + } + + public int getMaxColumns() { + return mMaxColumns; + } + + public void setStartMillis(long startMillis) { + this.startMillis = startMillis; + } + + public long getStartMillis() { + return startMillis; + } + + public void setEndMillis(long endMillis) { + this.endMillis = endMillis; + } + + public long getEndMillis() { + return endMillis; + } +} diff --git a/src/com/android/calendar/EventGeometry.java b/src/com/android/calendar/EventGeometry.java new file mode 100644 index 00000000..ebfa6a3e --- /dev/null +++ b/src/com/android/calendar/EventGeometry.java @@ -0,0 +1,221 @@ +/* + * Copyright (C) 2008 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.calendar; + +import android.graphics.Rect; + +public class EventGeometry { + // This is the space from the grid line to the event rectangle. + private int mCellMargin = 0; + + private float mMinuteHeight; + + private float mHourGap; + private float mMinEventHeight; + + void setCellMargin(int cellMargin) { + mCellMargin = cellMargin; + } + + void setHourGap(float gap) { + mHourGap = gap; + } + + void setMinEventHeight(float height) { + mMinEventHeight = height; + } + + void setHourHeight(float height) { + mMinuteHeight = height / 60.0f; + } + + // Computes the rectangle coordinates of the given event on the screen. + // Returns true if the rectangle is visible on the screen. + boolean computeEventRect(int date, int left, int top, int cellWidth, Event event) { + if (event.allDay) { + return false; + } + + float cellMinuteHeight = mMinuteHeight; + int startDay = event.startDay; + int endDay = event.endDay; + + if (startDay > date || endDay < date) { + return false; + } + + int startTime = event.startTime; + int endTime = event.endTime; + + // If the event started on a previous day, then show it starting + // at the beginning of this day. + if (startDay < date) { + startTime = 0; + } + + // If the event ends on a future day, then show it extending to + // the end of this day. + if (endDay > date) { + endTime = CalendarView.MINUTES_PER_DAY; + } + + int col = event.getColumn(); + int maxCols = event.getMaxColumns(); + int startHour = startTime / 60; + int endHour = endTime / 60; + + // If the end point aligns on a cell boundary then count it as + // ending in the previous cell so that we don't cross the border + // between hours. + if (endHour * 60 == endTime) + endHour -= 1; + + event.top = top; + event.top += (int) (startTime * cellMinuteHeight); + event.top += startHour * mHourGap; + + event.bottom = top; + event.bottom += (int) (endTime * cellMinuteHeight); + event.bottom += endHour * mHourGap; + + // Make the rectangle be at least mMinEventHeight pixels high + if (event.bottom < event.top + mMinEventHeight) { + event.bottom = event.top + mMinEventHeight; + } + + float colWidth = (float) (cellWidth - 2 * mCellMargin) / (float) maxCols; + event.left = left + mCellMargin + col * colWidth; + event.right = event.left + colWidth; + return true; + } + + // Computes the busy bits. For each interval containing "interval" minutes, + // the busy bit for that interval is set to 1 if the given event overlaps + // that interval. + void computeBusyBits(int firstDate, int numDays, byte[][] busyBits, Event event, int interval) { + if (event.allDay) { + return; + } + + int endDate = firstDate + numDays; + int startDay = event.startDay; + int endDay = event.endDay; + if (startDay >= endDate || endDay < firstDate) { + return; + } + + int startTime = event.startTime; + + int day = startDay; + + // If the event started on a previous day, then show it starting + // at the beginning of this day. + if (day < firstDate) { + day = firstDate; + startTime = 0; + } + + if (endDay >= endDate) { + endDay = endDate - 1; + } + + int dayIndex = day - firstDate; + while (day <= endDay) { + int endTime = event.endTime; + // If the event ends on a future day, then show it extending to + // the end of this day. + if (endDay > day) { + endTime = CalendarView.MINUTES_PER_DAY; + } + + int startInterval = startTime / interval; + int endInterval = (endTime + interval - 1) / interval; + + for (int ii = startInterval; ii < endInterval; ii++) { + busyBits[dayIndex][ii] = 1; + } + day += 1; + dayIndex += 1; + startTime = 0; + } + } + + /** + * Returns true if this event intersects the selection region. + */ + boolean eventIntersectsSelection(Event event, Rect selection) { + if (event.left < selection.right && event.right >= selection.left + && event.top < selection.bottom && event.bottom >= selection.top) { + return true; + } + return false; + } + + /** + * Computes the distance from the given point to the given event. + */ + float pointToEvent(float x, float y, Event event) { + float left = event.left; + float right = event.right; + float top = event.top; + float bottom = event.bottom; + + if (x >= left) { + if (x <= right) { + if (y >= top) { + if (y <= bottom) { + // x,y is inside the event rectangle + return 0f; + } + // x,y is below the event rectangle + return y - bottom; + } + // x,y is above the event rectangle + return top - y; + } + + // x > right + float dx = x - right; + if (y < top) { + // the upper right corner + float dy = top - y; + return (float) Math.sqrt(dx * dx + dy * dy); + } + if (y > bottom) { + // the lower right corner + float dy = y - bottom; + return (float) Math.sqrt(dx * dx + dy * dy); + } + // x,y is to the right of the event rectangle + return dx; + } + // x < left + float dx = left - x; + if (y < top) { + // the upper left corner + float dy = top - y; + return (float) Math.sqrt(dx * dx + dy * dy); + } + if (y > bottom) { + // the lower left corner + float dy = y - bottom; + return (float) Math.sqrt(dx * dx + dy * dy); + } + // x,y is to the left of the event rectangle + return dx; + } +} diff --git a/src/com/android/calendar/EventInfoActivity.java b/src/com/android/calendar/EventInfoActivity.java new file mode 100644 index 00000000..0b31b015 --- /dev/null +++ b/src/com/android/calendar/EventInfoActivity.java @@ -0,0 +1,724 @@ +/* + * Copyright (C) 2007 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.calendar; + +import static android.provider.Calendar.EVENT_BEGIN_TIME; +import static android.provider.Calendar.EVENT_END_TIME; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.PorterDuff; +import android.net.Uri; +import android.os.Bundle; +import android.pim.EventRecurrence; +import android.preference.PreferenceManager; +import android.provider.Calendar; +import android.provider.Calendar.Attendees; +import android.provider.Calendar.Calendars; +import android.provider.Calendar.Events; +import android.provider.Calendar.Reminders; +import android.text.format.DateFormat; +import android.text.format.DateUtils; +import android.text.format.Time; +import android.util.Log; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import java.util.ArrayList; +import java.util.Arrays; + +public class EventInfoActivity extends Activity implements View.OnClickListener, + AdapterView.OnItemSelectedListener { + private static final int MAX_REMINDERS = 5; + + /** + * These are the corresponding indices into the array of strings + * "R.array.change_response_labels" in the resource file. + */ + static final int UPDATE_SINGLE = 0; + static final int UPDATE_ALL = 1; + + private static final String[] EVENT_PROJECTION = new String[] { + Events._ID, // 0 do not remove; used in DeleteEventHelper + Events.TITLE, // 1 do not remove; used in DeleteEventHelper + Events.RRULE, // 2 do not remove; used in DeleteEventHelper + Events.ALL_DAY, // 3 do not remove; used in DeleteEventHelper + Events.CALENDAR_ID, // 4 do not remove; used in DeleteEventHelper + Events.DTSTART, // 5 do not remove; used in DeleteEventHelper + Events._SYNC_ID, // 6 do not remove; used in DeleteEventHelper + Events.EVENT_TIMEZONE, // 7 do not remove; used in DeleteEventHelper + Events.DESCRIPTION, // 8 + Events.EVENT_LOCATION, // 9 + Events.HAS_ALARM, // 10 + Events.ACCESS_LEVEL, // 11 + Events.COLOR, // 12 + }; + private static final int EVENT_INDEX_ID = 0; + private static final int EVENT_INDEX_TITLE = 1; + private static final int EVENT_INDEX_RRULE = 2; + private static final int EVENT_INDEX_ALL_DAY = 3; + private static final int EVENT_INDEX_CALENDAR_ID = 4; + private static final int EVENT_INDEX_SYNC_ID = 6; + private static final int EVENT_INDEX_EVENT_TIMEZONE = 7; + private static final int EVENT_INDEX_DESCRIPTION = 8; + private static final int EVENT_INDEX_EVENT_LOCATION = 9; + private static final int EVENT_INDEX_HAS_ALARM = 10; + private static final int EVENT_INDEX_ACCESS_LEVEL = 11; + private static final int EVENT_INDEX_COLOR = 12; + + private static final String[] ATTENDEES_PROJECTION = new String[] { + Attendees._ID, // 0 + Attendees.ATTENDEE_RELATIONSHIP, // 1 + Attendees.ATTENDEE_STATUS, // 2 + }; + private static final int ATTENDEES_INDEX_ID = 0; + private static final int ATTENDEES_INDEX_RELATIONSHIP = 1; + private static final int ATTENDEES_INDEX_STATUS = 2; + private static final String ATTENDEES_WHERE = Attendees.EVENT_ID + "=%d"; + + static final String[] CALENDARS_PROJECTION = new String[] { + Calendars._ID, // 0 + Calendars.DISPLAY_NAME, // 1 + }; + static final int CALENDARS_INDEX_DISPLAY_NAME = 1; + static final String CALENDARS_WHERE = Calendars._ID + "=%d"; + + private static final String[] REMINDERS_PROJECTION = new String[] { + Reminders._ID, // 0 + Reminders.MINUTES, // 1 + }; + private static final int REMINDERS_INDEX_MINUTES = 1; + private static final String REMINDERS_WHERE = Reminders.EVENT_ID + "=%d AND (" + + Reminders.METHOD + "=" + Reminders.METHOD_ALERT + " OR " + Reminders.METHOD + "=" + + Reminders.METHOD_DEFAULT + ")"; + + private static final int MENU_GROUP_REMINDER = 1; + private static final int MENU_GROUP_EDIT = 2; + private static final int MENU_GROUP_DELETE = 3; + + private static final int MENU_ADD_REMINDER = 1; + private static final int MENU_EDIT = 2; + private static final int MENU_DELETE = 3; + + private static final int ATTENDEE_NO_RESPONSE = -1; + private static final int[] ATTENDEE_VALUES = { + ATTENDEE_NO_RESPONSE, + Attendees.ATTENDEE_STATUS_ACCEPTED, + Attendees.ATTENDEE_STATUS_TENTATIVE, + Attendees.ATTENDEE_STATUS_DECLINED, + }; + + private LinearLayout mRemindersContainer; + + private Uri mUri; + private long mEventId; + private Cursor mEventCursor; + private Cursor mAttendeesCursor; + private Cursor mCalendarsCursor; + + private long mStartMillis; + private long mEndMillis; + private int mVisibility = Calendars.NO_ACCESS; + private int mRelationship = Attendees.RELATIONSHIP_ORGANIZER; + + private ArrayList<Integer> mOriginalMinutes = new ArrayList<Integer>(); + private ArrayList<LinearLayout> mReminderItems = new ArrayList<LinearLayout>(0); + private ArrayList<Integer> mReminderValues; + private ArrayList<String> mReminderLabels; + private int mDefaultReminderMinutes; + + private DeleteEventHelper mDeleteEventHelper; + private EditResponseHelper mEditResponseHelper; + + private int mResponseOffset; + private int mOriginalAttendeeResponse; + private boolean mIsRepeating; + + // This is called when one of the "remove reminder" buttons is selected. + public void onClick(View v) { + LinearLayout reminderItem = (LinearLayout) v.getParent(); + LinearLayout parent = (LinearLayout) reminderItem.getParent(); + parent.removeView(reminderItem); + mReminderItems.remove(reminderItem); + updateRemindersVisibility(); + } + + public void onItemSelected(AdapterView parent, View v, int position, long id) { + // If they selected the "No response" option, then don't display the + // dialog asking which events to change. + if (id == 0 && mResponseOffset == 0) { + return; + } + + // If this is not a repeating event, then don't display the dialog + // asking which events to change. + if (!mIsRepeating) { + return; + } + + // If the selection is the same as the original, then don't display the + // dialog asking which events to change. + int index = findResponseIndexFor(mOriginalAttendeeResponse); + if (position == index + mResponseOffset) { + return; + } + + // This is a repeating event. We need to ask the user if they mean to + // change just this one instance or all instances. + mEditResponseHelper.showDialog(mEditResponseHelper.getWhichEvents()); + } + + public void onNothingSelected(AdapterView parent) { + } + + @Override + protected void onCreate(Bundle icicle) { + super.onCreate(icicle); + + // Event cursor + Intent intent = getIntent(); + mUri = intent.getData(); + ContentResolver cr = getContentResolver(); + mStartMillis = intent.getLongExtra(EVENT_BEGIN_TIME, 0); + mEndMillis = intent.getLongExtra(EVENT_END_TIME, 0); + mEventCursor = managedQuery(mUri, EVENT_PROJECTION, null, null); + if (initEventCursor()) { + // The cursor is empty. This can happen if the event was deleted. + finish(); + return; + } + + setContentView(R.layout.event_info_activity); + + // Attendees cursor + Uri uri = Attendees.CONTENT_URI; + String where = String.format(ATTENDEES_WHERE, mEventId); + mAttendeesCursor = managedQuery(uri, ATTENDEES_PROJECTION, where, null); + initAttendeesCursor(); + + // Calendars cursor + uri = Calendars.CONTENT_URI; + where = String.format(CALENDARS_WHERE, mEventCursor.getLong(EVENT_INDEX_CALENDAR_ID)); + mCalendarsCursor = managedQuery(uri, CALENDARS_PROJECTION, where, null); + initCalendarsCursor(); + + Resources res = getResources(); + + if (mVisibility >= Calendars.CONTRIBUTOR_ACCESS && + mRelationship == Attendees.RELATIONSHIP_ATTENDEE) { + setTitle(res.getString(R.string.event_info_title_invite)); + } else { + setTitle(res.getString(R.string.event_info_title)); + } + + // Initialize the reminder values array. + Resources r = getResources(); + String[] strings = r.getStringArray(R.array.reminder_minutes_values); + int size = strings.length; + ArrayList<Integer> list = new ArrayList<Integer>(size); + for (int i = 0 ; i < size ; i++) { + list.add(Integer.parseInt(strings[i])); + } + mReminderValues = list; + String[] labels = r.getStringArray(R.array.reminder_minutes_labels); + mReminderLabels = new ArrayList<String>(Arrays.asList(labels)); + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + String durationString = + prefs.getString(CalendarPreferenceActivity.KEY_DEFAULT_REMINDER, "0"); + mDefaultReminderMinutes = Integer.parseInt(durationString); + + mRemindersContainer = (LinearLayout) findViewById(R.id.reminders_container); + + // Reminders cursor + boolean hasAlarm = mEventCursor.getInt(EVENT_INDEX_HAS_ALARM) != 0; + if (hasAlarm) { + uri = Reminders.CONTENT_URI; + where = String.format(REMINDERS_WHERE, mEventId); + Cursor reminderCursor = cr.query(uri, REMINDERS_PROJECTION, where, null, null); + try { + // First pass: collect all the custom reminder minutes (e.g., + // a reminder of 8 minutes) into a global list. + while (reminderCursor.moveToNext()) { + int minutes = reminderCursor.getInt(REMINDERS_INDEX_MINUTES); + EditEvent.addMinutesToList(this, mReminderValues, mReminderLabels, minutes); + } + + // Second pass: create the reminder spinners + reminderCursor.moveToPosition(-1); + while (reminderCursor.moveToNext()) { + int minutes = reminderCursor.getInt(REMINDERS_INDEX_MINUTES); + mOriginalMinutes.add(minutes); + EditEvent.addReminder(this, this, mReminderItems, mReminderValues, + mReminderLabels, minutes); + } + } finally { + reminderCursor.close(); + } + } + + updateView(); + updateRemindersVisibility(); + + mDeleteEventHelper = new DeleteEventHelper(this, true /* exit when done */); + mEditResponseHelper = new EditResponseHelper(this); + } + + @Override + protected void onResume() { + super.onResume(); + if (initEventCursor()) { + // The cursor is empty. This can happen if the event was deleted. + finish(); + return; + } + initAttendeesCursor(); + initCalendarsCursor(); + } + + /** + * Initializes the event cursor, which is expected to point to the first + * (and only) result from a query. + * @return true if the cursor is empty. + */ + private boolean initEventCursor() { + if ((mEventCursor == null) || (mEventCursor.getCount() == 0)) { + return true; + } + mEventCursor.moveToFirst(); + mVisibility = mEventCursor.getInt(EVENT_INDEX_ACCESS_LEVEL); + mEventId = mEventCursor.getInt(EVENT_INDEX_ID); + String rRule = mEventCursor.getString(EVENT_INDEX_RRULE); + mIsRepeating = (rRule != null); + return false; + } + + private void initAttendeesCursor() { + if (mAttendeesCursor != null) { + if (mAttendeesCursor.moveToFirst()) { + mRelationship = mAttendeesCursor.getInt(ATTENDEES_INDEX_RELATIONSHIP); + } + } + } + + private void initCalendarsCursor() { + if (mCalendarsCursor != null) { + mCalendarsCursor.moveToFirst(); + } + } + + @Override + public void onPause() { + super.onPause(); + if (!isFinishing()) { + return; + } + ContentResolver cr = getContentResolver(); + ArrayList<Integer> reminderMinutes = EditEvent.reminderItemsToMinutes(mReminderItems, + mReminderValues); + boolean changed = EditEvent.saveReminders(cr, mEventId, reminderMinutes, mOriginalMinutes); + changed |= saveResponse(cr); + if (changed) { + Toast.makeText(this, R.string.saving_event, Toast.LENGTH_SHORT).show(); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuItem item; + item = menu.add(MENU_GROUP_REMINDER, MENU_ADD_REMINDER, 0, + R.string.add_new_reminder); + item.setIcon(R.drawable.ic_menu_reminder); + item.setAlphabeticShortcut('r'); + + item = menu.add(MENU_GROUP_EDIT, MENU_EDIT, 0, R.string.edit_event_label); + item.setIcon(android.R.drawable.ic_menu_edit); + item.setAlphabeticShortcut('e'); + + item = menu.add(MENU_GROUP_DELETE, MENU_DELETE, 0, R.string.delete_event_label); + item.setIcon(android.R.drawable.ic_menu_delete); + + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + // Cannot add reminders to a shared calendar with only free/busy + // permissions + if (mVisibility >= Calendars.READ_ACCESS && mReminderItems.size() < MAX_REMINDERS) { + menu.setGroupVisible(MENU_GROUP_REMINDER, true); + menu.setGroupEnabled(MENU_GROUP_REMINDER, true); + } else { + menu.setGroupVisible(MENU_GROUP_REMINDER, false); + menu.setGroupEnabled(MENU_GROUP_REMINDER, false); + } + + if (mVisibility >= Calendars.CONTRIBUTOR_ACCESS && + mRelationship >= Attendees.RELATIONSHIP_ORGANIZER) { + menu.setGroupVisible(MENU_GROUP_EDIT, true); + menu.setGroupEnabled(MENU_GROUP_EDIT, true); + menu.setGroupVisible(MENU_GROUP_DELETE, true); + menu.setGroupEnabled(MENU_GROUP_DELETE, true); + } else { + menu.setGroupVisible(MENU_GROUP_EDIT, false); + menu.setGroupEnabled(MENU_GROUP_EDIT, false); + menu.setGroupVisible(MENU_GROUP_DELETE, false); + menu.setGroupEnabled(MENU_GROUP_DELETE, false); + } + + return super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + switch (item.getItemId()) { + case MENU_ADD_REMINDER: + // TODO: when adding a new reminder, make it different from the + // last one in the list (if any). + if (mDefaultReminderMinutes == 0) { + EditEvent.addReminder(this, this, mReminderItems, + mReminderValues, mReminderLabels, 10 /* minutes */); + } else { + EditEvent.addReminder(this, this, mReminderItems, + mReminderValues, mReminderLabels, mDefaultReminderMinutes); + } + updateRemindersVisibility(); + break; + case MENU_EDIT: + doEdit(); + break; + case MENU_DELETE: + doDelete(); + break; + } + return true; + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_DEL) { + doDelete(); + return true; + } + return super.onKeyDown(keyCode, event); + } + + private void updateRemindersVisibility() { + if (mReminderItems.size() == 0) { + mRemindersContainer.setVisibility(View.GONE); + } else { + mRemindersContainer.setVisibility(View.VISIBLE); + } + } + + /** + * Saves the response to an invitation if the user changed the response. + * Returns true if the database was updated. + * + * @param cr the ContentResolver + * @return true if the database was changed + */ + private boolean saveResponse(ContentResolver cr) { + if (mAttendeesCursor == null || mEventCursor == null) { + return false; + } + Spinner spinner = (Spinner) findViewById(R.id.response_value); + int position = spinner.getSelectedItemPosition() - mResponseOffset; + if (position <= 0) { + return false; + } + + int status = ATTENDEE_VALUES[position]; + + // If the status has not changed, then don't update the database + if (status == mOriginalAttendeeResponse) { + return false; + } + + long attendeeId = mAttendeesCursor.getInt(ATTENDEES_INDEX_ID); + if (!mIsRepeating) { + // This is a non-repeating event + updateResponse(cr, mEventId, attendeeId, status); + return true; + } + + // This is a repeating event + int whichEvents = mEditResponseHelper.getWhichEvents(); + switch (whichEvents) { + case -1: + return false; + case UPDATE_SINGLE: + createExceptionResponse(cr, mEventId, attendeeId, status); + return true; + case UPDATE_ALL: + updateResponse(cr, mEventId, attendeeId, status); + return true; + default: + Log.e("Calendar", "Unexpected choice for updating invitation response"); + break; + } + return false; + } + + private void updateResponse(ContentResolver cr, long eventId, long attendeeId, int status) { + // Update the "selfAttendeeStatus" field for the event + ContentValues values = new ContentValues(); + + // Will need to add email when MULTIPLE_ATTENDEES_PER_EVENT supported. + values.put(Attendees.ATTENDEE_STATUS, status); + values.put(Attendees.EVENT_ID, eventId); + + Uri uri = ContentUris.withAppendedId(Attendees.CONTENT_URI, attendeeId); + cr.update(uri, values, null /* where */, null /* selection args */); + } + + private void createExceptionResponse(ContentResolver cr, long eventId, + long attendeeId, int status) { + // Fetch information about the repeating event. + Uri uri = ContentUris.withAppendedId(Events.CONTENT_URI, eventId); + Cursor cursor = cr.query(uri, EVENT_PROJECTION, null, null, null); + if (cursor == null) { + return; + } + + try { + cursor.moveToFirst(); + ContentValues values = new ContentValues(); + + String title = cursor.getString(EVENT_INDEX_TITLE); + String timezone = cursor.getString(EVENT_INDEX_EVENT_TIMEZONE); + int calendarId = cursor.getInt(EVENT_INDEX_CALENDAR_ID); + boolean allDay = cursor.getInt(EVENT_INDEX_ALL_DAY) != 0; + String syncId = cursor.getString(EVENT_INDEX_SYNC_ID); + + values.put(Events.TITLE, title); + values.put(Events.EVENT_TIMEZONE, timezone); + values.put(Events.ALL_DAY, allDay ? 1 : 0); + values.put(Events.CALENDAR_ID, calendarId); + values.put(Events.DTSTART, mStartMillis); + values.put(Events.DTEND, mEndMillis); + values.put(Events.ORIGINAL_EVENT, syncId); + values.put(Events.ORIGINAL_INSTANCE_TIME, mStartMillis); + values.put(Events.ORIGINAL_ALL_DAY, allDay ? 1 : 0); + values.put(Events.STATUS, Events.STATUS_CONFIRMED); + values.put(Events.SELF_ATTENDEE_STATUS, status); + + // Create a recurrence exception + Uri newUri = cr.insert(Events.CONTENT_URI, values); + } finally { + cursor.close(); + } + } + + private int findResponseIndexFor(int response) { + int size = ATTENDEE_VALUES.length; + for (int index = 0; index < size; index++) { + if (ATTENDEE_VALUES[index] == response) { + return index; + } + } + return 0; + } + + private void doEdit() { + Uri uri = ContentUris.withAppendedId(Events.CONTENT_URI, mEventId); + Intent intent = new Intent(Intent.ACTION_EDIT, uri); + intent.putExtra(Calendar.EVENT_BEGIN_TIME, mStartMillis); + intent.putExtra(Calendar.EVENT_END_TIME, mEndMillis); + intent.setClass(EventInfoActivity.this, EditEvent.class); + startActivity(intent); + finish(); + } + + private void doDelete() { + mDeleteEventHelper.delete(mStartMillis, mEndMillis, mEventCursor, -1); + } + + private void updateView() { + if (mEventCursor == null) { + return; + } + Resources res = getResources(); + ContentResolver cr = getContentResolver(); + + String eventName = mEventCursor.getString(EVENT_INDEX_TITLE); + if (eventName == null || eventName.length() == 0) { + eventName = res.getString(R.string.no_title_label); + } + + boolean allDay = mEventCursor.getInt(EVENT_INDEX_ALL_DAY) != 0; + String location = mEventCursor.getString(EVENT_INDEX_EVENT_LOCATION); + String description = mEventCursor.getString(EVENT_INDEX_DESCRIPTION); + String rRule = mEventCursor.getString(EVENT_INDEX_RRULE); + boolean hasAlarm = mEventCursor.getInt(EVENT_INDEX_HAS_ALARM) != 0; + String eventTimezone = mEventCursor.getString(EVENT_INDEX_EVENT_TIMEZONE); + int color = mEventCursor.getInt(EVENT_INDEX_COLOR) & 0xbbffffff; + + View strip = (View) findViewById(R.id.strip); + strip.getBackground().setColorFilter(color, PorterDuff.Mode.SRC_IN); + + TextView title = (TextView) findViewById(R.id.title); + title.setTextColor(color); + + View divider = (View) findViewById(R.id.divider); + divider.getBackground().setColorFilter(color, PorterDuff.Mode.SRC_IN); + + // What + if (eventName != null) { + setTextCommon(R.id.title, eventName); + } + + // When + String when; + int flags; + if (allDay) { + flags = DateUtils.FORMAT_UTC | DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_SHOW_DATE; + } else { + flags = DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_SHOW_DATE; + if (DateFormat.is24HourFormat(this)) { + flags |= DateUtils.FORMAT_24HOUR; + } + } + when = DateUtils.formatDateRange(this, mStartMillis, mEndMillis, flags); + setTextCommon(R.id.when, when); + + // Show the event timezone if it is different from the local timezone + Time time = new Time(); + String localTimezone = time.timezone; + if (allDay) { + localTimezone = Time.TIMEZONE_UTC; + } + if (eventTimezone != null && !localTimezone.equals(eventTimezone) && !allDay) { + setTextCommon(R.id.timezone, localTimezone); + } else { + setVisibilityCommon(R.id.timezone_container, View.GONE); + } + + // Repeat + if (rRule != null) { + EventRecurrence eventRecurrence = new EventRecurrence(); + eventRecurrence.parse(rRule); + Time date = new Time(); + if (allDay) { + date.timezone = Time.TIMEZONE_UTC; + } + date.set(mStartMillis); + eventRecurrence.setStartDate(date); + String repeatString = eventRecurrence.getRepeatString(); + setTextCommon(R.id.repeat, repeatString); + } else { + setVisibilityCommon(R.id.repeat_container, View.GONE); + } + + // Where + if (location == null || location.length() == 0) { + setVisibilityCommon(R.id.where, View.GONE); + } else { + setTextCommon(R.id.where, location); + } + + // Description + if (description == null || description.length() == 0) { + setVisibilityCommon(R.id.description, View.GONE); + } else { + setTextCommon(R.id.description, description); + } + + // Calendar + if (mCalendarsCursor != null) { + mCalendarsCursor.moveToFirst(); + String calendarName = mCalendarsCursor.getString(CALENDARS_INDEX_DISPLAY_NAME); + setTextCommon(R.id.calendar, calendarName); + } else { + setVisibilityCommon(R.id.calendar_container, View.GONE); + } + + // Response + updateResponse(); + } + + void updateResponse() { + if (mVisibility < Calendars.CONTRIBUTOR_ACCESS || + mRelationship != Attendees.RELATIONSHIP_ATTENDEE) { + setVisibilityCommon(R.id.response_container, View.GONE); + return; + } + + setVisibilityCommon(R.id.response_container, View.VISIBLE); + + Spinner spinner = (Spinner) findViewById(R.id.response_value); + + mOriginalAttendeeResponse = ATTENDEE_NO_RESPONSE; + if (mAttendeesCursor != null) { + mOriginalAttendeeResponse = mAttendeesCursor.getInt(ATTENDEES_INDEX_STATUS); + } + mResponseOffset = 0; + + /* If the user has previously responded to this event + * we should not allow them to select no response again. + * Switch the entries to a set of entries without the + * no response option. + */ + if ((mOriginalAttendeeResponse != Attendees.ATTENDEE_STATUS_INVITED) + && (mOriginalAttendeeResponse != ATTENDEE_NO_RESPONSE) + && (mOriginalAttendeeResponse != Attendees.ATTENDEE_STATUS_NONE)) { + CharSequence[] entries; + entries = getResources().getTextArray(R.array.response_labels2); + mResponseOffset = -1; + ArrayAdapter<CharSequence> adapter = + new ArrayAdapter<CharSequence>(this, + android.R.layout.simple_spinner_item, entries); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinner.setAdapter(adapter); + } + + int index = findResponseIndexFor(mOriginalAttendeeResponse); + spinner.setSelection(index + mResponseOffset); + spinner.setOnItemSelectedListener(this); + } + + private void setTextCommon(int id, CharSequence text) { + TextView textView = (TextView) findViewById(id); + if (textView == null) + return; + textView.setText(text); + } + + private void setVisibilityCommon(int id, int visibility) { + View v = findViewById(id); + if (v != null) { + v.setVisibility(visibility); + } + return; + } +} diff --git a/src/com/android/calendar/EventLoader.java b/src/com/android/calendar/EventLoader.java new file mode 100644 index 00000000..eb8a82d7 --- /dev/null +++ b/src/com/android/calendar/EventLoader.java @@ -0,0 +1,256 @@ +/* + * Copyright (C) 2008 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.calendar; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.os.Handler; +import android.os.Process; +import android.provider.Calendar.BusyBits; +import android.util.Log; + +import java.util.ArrayList; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicInteger; + +public class EventLoader { + + private Context mContext; + private Handler mHandler = new Handler(); + private AtomicInteger mSequenceNumber = new AtomicInteger(); + + private LinkedBlockingQueue<LoadRequest> mLoaderQueue; + private LoaderThread mLoaderThread; + private ContentResolver mResolver; + + private static interface LoadRequest { + public void processRequest(EventLoader eventLoader); + public void skipRequest(EventLoader eventLoader); + } + + private static class ShutdownRequest implements LoadRequest { + public void processRequest(EventLoader eventLoader) { + } + + public void skipRequest(EventLoader eventLoader) { + } + } + + private static class LoadBusyBitsRequest implements LoadRequest { + public int startDay; + public int numDays; + public int[] busybits; + public int[] allDayCounts; + public Runnable uiCallback; + + public LoadBusyBitsRequest(int startDay, int numDays, int[] busybits, int[] allDayCounts, + final Runnable uiCallback) { + this.startDay = startDay; + this.numDays = numDays; + this.busybits = busybits; + this.allDayCounts = allDayCounts; + this.uiCallback = uiCallback; + } + + public void processRequest(EventLoader eventLoader) { + final Handler handler = eventLoader.mHandler; + ContentResolver cr = eventLoader.mResolver; + + // Clear the busy bits and all-day counts + for (int dayIndex = 0; dayIndex < numDays; dayIndex++) { + busybits[dayIndex] = 0; + allDayCounts[dayIndex] = 0; + } + + Cursor cursor = BusyBits.query(cr, startDay, numDays); + try { + int dayColumnIndex = cursor.getColumnIndexOrThrow(BusyBits.DAY); + int busybitColumnIndex = cursor.getColumnIndexOrThrow(BusyBits.BUSYBITS); + int allDayCountColumnIndex = cursor.getColumnIndexOrThrow(BusyBits.ALL_DAY_COUNT); + + while (cursor.moveToNext()) { + int day = cursor.getInt(dayColumnIndex); + int dayIndex = day - startDay; + busybits[dayIndex] = cursor.getInt(busybitColumnIndex); + allDayCounts[dayIndex] = cursor.getInt(allDayCountColumnIndex); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + handler.post(uiCallback); + } + + public void skipRequest(EventLoader eventLoader) { + } + } + + private static class LoadEventsRequest implements LoadRequest { + + public int id; + public long startMillis; + public int numDays; + public ArrayList<Event> events; + public Runnable successCallback; + public Runnable cancelCallback; + + public LoadEventsRequest(int id, long startMillis, int numDays, ArrayList<Event> events, + final Runnable successCallback, final Runnable cancelCallback) { + this.id = id; + this.startMillis = startMillis; + this.numDays = numDays; + this.events = events; + this.successCallback = successCallback; + this.cancelCallback = cancelCallback; + } + + public void processRequest(EventLoader eventLoader) { + Event.loadEvents(eventLoader.mContext, events, startMillis, + numDays, id, eventLoader.mSequenceNumber); + + // Check if we are still the most recent request. + if (id == eventLoader.mSequenceNumber.get()) { + eventLoader.mHandler.post(successCallback); + } else { + eventLoader.mHandler.post(cancelCallback); + } + } + + public void skipRequest(EventLoader eventLoader) { + eventLoader.mHandler.post(cancelCallback); + } + } + + private static class LoaderThread extends Thread { + LinkedBlockingQueue<LoadRequest> mQueue; + EventLoader mEventLoader; + + public LoaderThread(LinkedBlockingQueue<LoadRequest> queue, EventLoader eventLoader) { + mQueue = queue; + mEventLoader = eventLoader; + } + + public void shutdown() { + try { + mQueue.put(new ShutdownRequest()); + } catch (InterruptedException ex) { + // The put() method fails with InterruptedException if the + // queue is full. This should never happen because the queue + // has no limit. + Log.e("Cal", "LoaderThread.shutdown() interrupted!"); + } + } + + @Override + public void run() { + Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); + while (true) { + try { + // Wait for the next request + LoadRequest request = mQueue.take(); + + // If there are a bunch of requests already waiting, then + // skip all but the most recent request. + while (!mQueue.isEmpty()) { + // Let the request know that it was skipped + request.skipRequest(mEventLoader); + + // Skip to the next request + request = mQueue.take(); + } + + if (request instanceof ShutdownRequest) { + return; + } + request.processRequest(mEventLoader); + } catch (InterruptedException ex) { + Log.e("Cal", "background LoaderThread interrupted!"); + } + } + } + } + + public EventLoader(Context context) { + mContext = context; + mLoaderQueue = new LinkedBlockingQueue<LoadRequest>(); + mResolver = context.getContentResolver(); + } + + /** + * Call this from the activity's onResume() + */ + public void startBackgroundThread() { + mLoaderThread = new LoaderThread(mLoaderQueue, this); + mLoaderThread.start(); + } + + /** + * Call this from the activity's onPause() + */ + public void stopBackgroundThread() { + mLoaderThread.shutdown(); + } + + /** + * Loads "numDays" days worth of events, starting at start, into events. + * Posts uiCallback to the {@link Handler} for this view, which will run in the UI thread. + * Reuses an existing background thread, if events were already being loaded in the background. + * NOTE: events and uiCallback are not used if an existing background thread gets reused -- + * the ones that were passed in on the call that results in the background thread getting + * created are used, and the most recent call's worth of data is loaded into events and posted + * via the uiCallback. + */ + void loadEventsInBackground(final int numDays, final ArrayList<Event> events, + long start, final Runnable successCallback, final Runnable cancelCallback) { + + // Increment the sequence number for requests. We don't care if the + // sequence numbers wrap around because we test for equality with the + // latest one. + int id = mSequenceNumber.incrementAndGet(); + + // Send the load request to the background thread + LoadEventsRequest request = new LoadEventsRequest(id, start, numDays, + events, successCallback, cancelCallback); + + try { + mLoaderQueue.put(request); + } catch (InterruptedException ex) { + // The put() method fails with InterruptedException if the + // queue is full. This should never happen because the queue + // has no limit. + Log.e("Cal", "loadEventsInBackground() interrupted!"); + } + } + + void loadBusyBitsInBackground(int startDay, int numDays, int[] busybits, int[] allDayCounts, + final Runnable uiCallback) { + // Send the load request to the background thread + LoadBusyBitsRequest request = new LoadBusyBitsRequest(startDay, numDays, busybits, + allDayCounts, uiCallback); + + try { + mLoaderQueue.put(request); + } catch (InterruptedException ex) { + // The put() method fails with InterruptedException if the + // queue is full. This should never happen because the queue + // has no limit. + Log.e("Cal", "loadBusyBitsInBackground() interrupted!"); + } + } +} diff --git a/src/com/android/calendar/IcsImportActivity.java b/src/com/android/calendar/IcsImportActivity.java new file mode 100644 index 00000000..c4f5fdea --- /dev/null +++ b/src/com/android/calendar/IcsImportActivity.java @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2007 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.calendar; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.pim.ICalendar; +import android.provider.Calendar; +import android.util.Config; +import android.util.Log; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.Spinner; +import android.widget.TextView; + +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; + +public class IcsImportActivity extends Activity { + + private static final String TAG = "Calendar"; + + // TODO: consolidate this code with the EventActivity + private static class CalendarInfo { + public final long id; + public final String name; + + public CalendarInfo(long id, String name) { + this.id = id; + this.name = name; + } + + @Override + public String toString() { + return name; + } + } + + private View mView; + private Button mImportButton; + private Button mCancelButton; + private Spinner mCalendars; + private ImageView mCalendarIcon; + private TextView mNumEvents; + + private ICalendar.Component mCalendar = null; + + private View.OnClickListener mImportListener = new View.OnClickListener() { + public void onClick(View v) { + importCalendar(); + finish(); + } + }; + + private View.OnClickListener mCancelListener = new View.OnClickListener() { + public void onClick(View v) { + finish(); + } + }; + + @Override + protected void onCreate(Bundle icicle) { + super.onCreate(icicle); + setContentView(R.layout.ics_import_activity); + mView = findViewById(R.id.import_ics); + + mCalendarIcon = (ImageView) findViewById(R.id.calendar_icon); + mCalendars = (Spinner) findViewById(R.id.calendars); + populateCalendars(); + + mImportButton = (Button) findViewById(R.id.import_button); + mImportButton.setOnClickListener(mImportListener); + mCancelButton = (Button) findViewById(R.id.cancel_button); + mCancelButton.setOnClickListener(mCancelListener); + + mNumEvents = (TextView) findViewById(R.id.num_events); + + Intent intent = getIntent(); + String data = intent.getStringExtra("ics"); + if (data == null) { + Uri content = intent.getData(); + if (content != null) { + InputStream is = null; + try { + is = getContentResolver().openInputStream(content); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buf = new byte[8096]; + int bytesRead = -1; + int pos = 0; + while ((bytesRead = is.read(buf)) != -1) { + baos.write(buf, pos, bytesRead); + pos += bytesRead; + } + data = new String(baos.toByteArray(), "UTF-8"); + } catch (FileNotFoundException fnfe) { + Log.w(TAG, "Could not open data.", fnfe); + } catch (IOException ioe) { + Log.w(TAG, "Could not read data.", ioe); + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException ioe) { + Log.w(TAG, "Could not close InputStream.", ioe); + } + } + } + } + } + if (data == null) { + Log.w(TAG, "No iCalendar data to parse."); + finish(); + return; + } + parseCalendar(data); + } + + private void populateCalendars() { + ContentResolver cr = getContentResolver(); + Cursor c = cr.query(Calendar.Calendars.CONTENT_URI, + new String[] { Calendar.Calendars._ID, + Calendar.Calendars.DISPLAY_NAME, + Calendar.Calendars.SELECTED, + Calendar.Calendars.ACCESS_LEVEL }, + Calendar.Calendars.SELECTED + "=1 AND " + + Calendar.Calendars.ACCESS_LEVEL + ">=" + + Calendar.Calendars.CONTRIBUTOR_ACCESS, + null, null /* sort order */); + + ArrayList<CalendarInfo> items = new ArrayList<CalendarInfo>(); + try { + // TODO: write a custom adapter that wraps the cursor? + int idColumn = c.getColumnIndex(Calendar.Calendars._ID); + int nameColumn = c.getColumnIndex(Calendar.Calendars.DISPLAY_NAME); + while (c.moveToNext()) { + long id = c.getLong(idColumn); + String name = c.getString(nameColumn); + items.add(new CalendarInfo(id, name)); + } + } finally { + c.deactivate(); + } + + mCalendars.setAdapter(new ArrayAdapter<CalendarInfo>(this, + android.R.layout.simple_spinner_item, items)); + } + + private void parseCalendar(String data) { + mCalendar = null; + try { + mCalendar = ICalendar.parseCalendar(data); + } catch (ICalendar.FormatException fe) { + if (Config.LOGD) { + Log.d(TAG, "Could not parse iCalendar.", fe); + // TODO: show an error message. + finish(); + return; + } + } + if (mCalendar.getComponents() == null) { + Log.d(TAG, "No events in iCalendar."); + finish(); + return; + } + int numEvents = 0; + for (ICalendar.Component component : mCalendar.getComponents()) { + if ("VEVENT".equals(component.getName())) { + // TODO: display a list of the events (start time, title) in + // the UI? + ++numEvents; + } + } + // TODO: special-case a single-event calendar. switch to the + // EventActivity, once the EventActivity supports displaying data that + // is passed in via the extras. + // OR, we could flip things around, where the EventActivity handles ICS + // import by default, and delegates to the IcsImportActivity if it finds + // that there are more than one event in the iCalendar. that would + // avoid an extra activity launch for the expected common case of + // importing a single event. + mNumEvents.setText(Integer.toString(numEvents)); + } + + private void importCalendar() { + + ContentResolver cr = getContentResolver(); + + int numImported = 0; + ContentValues values = new ContentValues(); + + for (ICalendar.Component component : mCalendar.getComponents()) { + if ("VEVENT".equals(component.getName())) { + CalendarInfo calInfo = + (CalendarInfo) mCalendars.getSelectedItem(); + if (Calendar.Events.insertVEvent(cr, component, calInfo.id, + Calendar.Events.STATUS_CONFIRMED, values) != null) { + ++numImported; + } + } + } + // TODO: display how many were imported. + } +} diff --git a/src/com/android/calendar/LaunchActivity.java b/src/com/android/calendar/LaunchActivity.java new file mode 100644 index 00000000..4dabc5c7 --- /dev/null +++ b/src/com/android/calendar/LaunchActivity.java @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2007 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.calendar; + +import com.google.android.googlelogin.GoogleLoginServiceConstants; +import com.google.android.googlelogin.GoogleLoginServiceHelper; + +import android.app.Activity; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.provider.Gmail; + +public class LaunchActivity extends Activity { + + // An arbitrary constant to pass to the GoogleLoginHelperService + private static final int GET_ACCOUNT_REQUEST = 1; + + @Override + protected void onCreate(Bundle icicle) { + super.onCreate(icicle); + + // Our UI is not something intended for the user to see. We just + // stick around until we can figure out what to do next based on + // the current state of the system. + setVisible(false); + + // Only try looking for an account if this is the first launch. + if (icicle == null) { + // This will request a Gmail account and if none are present, it will + // invoke SetupWizard to login or create one. The result is returned + // through onActivityResult(). + Bundle bundle = new Bundle(); + bundle.putCharSequence("optional_message", getText(R.string.calendar_plug)); + GoogleLoginServiceHelper.getCredentials( + this, + GET_ACCOUNT_REQUEST, + bundle, + GoogleLoginServiceConstants.PREFER_HOSTED, + Gmail.GMAIL_AUTH_SERVICE, + true); + } + } + + private void onAccountsLoaded(String account) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + String startActivity = prefs.getString(CalendarPreferenceActivity.KEY_START_VIEW, + CalendarPreferenceActivity.DEFAULT_START_VIEW); + + // Get the data for from this intent, if any + Intent myIntent = getIntent(); + Uri myData = myIntent.getData(); + + // Set up the intent for the start activity + Intent intent = new Intent(); + if (myData != null) { + intent.setData(myData); + } + intent.setClassName(this, startActivity); + startActivity(intent); + finish(); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent intent) { + super.onActivityResult(requestCode, resultCode, intent); + if (requestCode == GET_ACCOUNT_REQUEST) { + if (resultCode == RESULT_OK) { + if (intent != null) { + Bundle extras = intent.getExtras(); + if (extras != null) { + final String account; + account = extras.getString(GoogleLoginServiceConstants.AUTH_ACCOUNT_KEY); + onAccountsLoaded(account); + } + } + } else { + finish(); + } + } + } +} diff --git a/src/com/android/calendar/MenuHelper.java b/src/com/android/calendar/MenuHelper.java new file mode 100644 index 00000000..f23cbf97 --- /dev/null +++ b/src/com/android/calendar/MenuHelper.java @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2008 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.calendar; + +import static android.provider.Calendar.EVENT_BEGIN_TIME; +import static android.provider.Calendar.EVENT_END_TIME; +import android.app.Activity; +import android.content.Intent; +import android.text.format.DateUtils; +import android.view.Menu; +import android.view.MenuItem; + +public class MenuHelper { + private static final int MENU_GROUP_AGENDA = 1; + private static final int MENU_GROUP_DAY = 2; + private static final int MENU_GROUP_WEEK = 3; + private static final int MENU_GROUP_MONTH = 4; + private static final int MENU_GROUP_EVENT_CREATE = 5; + private static final int MENU_GROUP_TODAY = 6; + private static final int MENU_GROUP_SELECT_CALENDARS = 7; + private static final int MENU_GROUP_PREFERENCES = 8; + + public static final int MENU_GOTO_TODAY = 1; + public static final int MENU_AGENDA = 2; + public static final int MENU_DAY = 3; + public static final int MENU_WEEK = 4; + public static final int MENU_EVENT_VIEW = 5; + public static final int MENU_EVENT_CREATE = 6; + public static final int MENU_EVENT_EDIT = 7; + public static final int MENU_EVENT_DELETE = 8; + public static final int MENU_MONTH = 9; + public static final int MENU_SELECT_CALENDARS = 10; + public static final int MENU_PREFERENCES = 11; + + public static void onPrepareOptionsMenu(Activity activity, Menu menu) { + + if (activity instanceof AgendaActivity) { + menu.setGroupVisible(MENU_GROUP_AGENDA, false); + menu.setGroupEnabled(MENU_GROUP_AGENDA, false); + } else { + menu.setGroupVisible(MENU_GROUP_AGENDA, true); + menu.setGroupEnabled(MENU_GROUP_AGENDA, true); + } + + if (activity instanceof DayActivity) { + menu.setGroupVisible(MENU_GROUP_DAY, false); + menu.setGroupEnabled(MENU_GROUP_DAY, false); + } else { + menu.setGroupVisible(MENU_GROUP_DAY, true); + menu.setGroupEnabled(MENU_GROUP_DAY, true); + } + + if (activity instanceof WeekActivity) { + menu.setGroupVisible(MENU_GROUP_WEEK, false); + menu.setGroupEnabled(MENU_GROUP_WEEK, false); + } else { + menu.setGroupVisible(MENU_GROUP_WEEK, true); + menu.setGroupEnabled(MENU_GROUP_WEEK, true); + } + + if (activity instanceof MonthActivity) { + menu.setGroupVisible(MENU_GROUP_MONTH, false); + menu.setGroupEnabled(MENU_GROUP_MONTH, false); + } else { + menu.setGroupVisible(MENU_GROUP_MONTH, true); + menu.setGroupEnabled(MENU_GROUP_MONTH, true); + } + + if (activity instanceof EventInfoActivity) { + menu.setGroupVisible(MENU_GROUP_TODAY, false); + menu.setGroupEnabled(MENU_GROUP_TODAY, false); + } else { + menu.setGroupVisible(MENU_GROUP_TODAY, true); + menu.setGroupEnabled(MENU_GROUP_TODAY, true); + } + } + + public static boolean onCreateOptionsMenu(Menu menu) { + + MenuItem item; + item = menu.add(MENU_GROUP_AGENDA, MENU_AGENDA, 0, R.string.agenda_view); + item.setIcon(android.R.drawable.ic_menu_agenda); + item.setAlphabeticShortcut('a'); + + item = menu.add(MENU_GROUP_DAY, MENU_DAY, 0, R.string.day_view); + item.setIcon(android.R.drawable.ic_menu_day); + item.setAlphabeticShortcut('d'); + + item = menu.add(MENU_GROUP_WEEK, MENU_WEEK, 0, R.string.week_view); + item.setIcon(android.R.drawable.ic_menu_week); + item.setAlphabeticShortcut('w'); + + item = menu.add(MENU_GROUP_MONTH, MENU_MONTH, 0, R.string.month_view); + item.setIcon(android.R.drawable.ic_menu_month); + item.setAlphabeticShortcut('m'); + + item = menu.add(MENU_GROUP_EVENT_CREATE, MENU_EVENT_CREATE, 0, R.string.event_create); + item.setIcon(android.R.drawable.ic_menu_add); + item.setAlphabeticShortcut('n'); + + item = menu.add(MENU_GROUP_TODAY, MENU_GOTO_TODAY, 0, R.string.goto_today); + item.setIcon(android.R.drawable.ic_menu_today); + item.setAlphabeticShortcut('t'); + + item = menu.add(MENU_GROUP_SELECT_CALENDARS, MENU_SELECT_CALENDARS, + 0, R.string.menu_select_calendars); + item.setIcon(android.R.drawable.ic_menu_manage); + + item = menu.add(MENU_GROUP_PREFERENCES, MENU_PREFERENCES, 0, R.string.menu_preferences); + item.setIcon(android.R.drawable.ic_menu_preferences); + item.setAlphabeticShortcut('p'); + + return true; + } + + public static boolean onOptionsItemSelected(Activity activity, MenuItem item, Navigator nav) { + switch (item.getItemId()) { + case MENU_SELECT_CALENDARS: { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setClass(activity, SelectCalendarsActivity.class); + activity.startActivity(intent); + return true; + } + case MENU_GOTO_TODAY: + nav.goToToday(); + return true; + case MENU_PREFERENCES: + switchTo(activity, CalendarPreferenceActivity.class.getName(), nav.getSelectedTime()); + return true; + case MENU_AGENDA: + switchTo(activity, AgendaActivity.class.getName(), nav.getSelectedTime()); + activity.finish(); + return true; + case MENU_DAY: + switchTo(activity, DayActivity.class.getName(), nav.getSelectedTime()); + activity.finish(); + return true; + case MENU_WEEK: + switchTo(activity, WeekActivity.class.getName(), nav.getSelectedTime()); + activity.finish(); + return true; + case MENU_MONTH: + switchTo(activity, MonthActivity.class.getName(), nav.getSelectedTime()); + activity.finish(); + return true; + case MENU_EVENT_CREATE: { + long startMillis = nav.getSelectedTime(); + long endMillis = startMillis + DateUtils.HOUR_IN_MILLIS; + Intent intent = new Intent(Intent.ACTION_EDIT); + intent.setClassName(activity, EditEvent.class.getName()); + intent.putExtra(EVENT_BEGIN_TIME, startMillis); + intent.putExtra(EVENT_END_TIME, endMillis); + intent.putExtra(EditEvent.EVENT_ALL_DAY, nav.getAllDay()); + activity.startActivity(intent); + return true; + } + } + return false; + } + + /* package */ static void switchTo(Activity activity, String className, long startMillis) { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setClassName(activity, className); + intent.putExtra(EVENT_BEGIN_TIME, startMillis); + activity.startActivity(intent); + } +} diff --git a/src/com/android/calendar/MonthActivity.java b/src/com/android/calendar/MonthActivity.java new file mode 100644 index 00000000..89b07eca --- /dev/null +++ b/src/com/android/calendar/MonthActivity.java @@ -0,0 +1,341 @@ +/* + * Copyright (C) 2006 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.calendar; + +import static android.provider.Calendar.EVENT_BEGIN_TIME; +import dalvik.system.VMRuntime; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.database.ContentObserver; +import android.os.Bundle; +import android.os.Handler; +import android.preference.PreferenceManager; +import android.provider.Calendar.Events; +import android.text.format.DateUtils; +import android.text.format.Time; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.view.animation.Animation.AnimationListener; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.ViewSwitcher; +import android.widget.Gallery.LayoutParams; + +import java.util.Calendar; + +public class MonthActivity extends Activity implements ViewSwitcher.ViewFactory, + Navigator, AnimationListener { + private static final int INITIAL_HEAP_SIZE = 4 * 1024 * 1024; + private Animation mInAnimationPast; + private Animation mInAnimationFuture; + private Animation mOutAnimationPast; + private Animation mOutAnimationFuture; + private ViewSwitcher mSwitcher; + private Time mTime; + + private ContentResolver mContentResolver; + EventLoader mEventLoader; + private int mStartDay; + + private ProgressBar mProgressBar; + + protected void startProgressSpinner() { + // start the progress spinner + mProgressBar.setVisibility(View.VISIBLE); + } + + protected void stopProgressSpinner() { + // stop the progress spinner + mProgressBar.setVisibility(View.GONE); + } + + /* ViewSwitcher.ViewFactory interface methods */ + public View makeView() { + MonthView mv = new MonthView(this, this); + mv.setLayoutParams(new ViewSwitcher.LayoutParams( + LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT)); + mv.setSelectedTime(mTime); + return mv; + } + + /* Navigator interface methods */ + public void goTo(Time time) { + TextView title = (TextView) findViewById(R.id.title); + title.setText(Utils.formatMonthYear(time)); + + MonthView current = (MonthView) mSwitcher.getCurrentView(); + current.dismissPopup(); + + Time currentTime = current.getTime(); + + // Compute a month number that is monotonically increasing for any + // two adjacent months. + // This is faster than calling getSelectedTime() because we avoid + // a call to Time#normalize(). + int currentMonth = currentTime.month + currentTime.year * 12; + int nextMonth = time.month + time.year * 12; + if (nextMonth < currentMonth) { + mSwitcher.setInAnimation(mInAnimationPast); + mSwitcher.setOutAnimation(mOutAnimationPast); + } else { + mSwitcher.setInAnimation(mInAnimationFuture); + mSwitcher.setOutAnimation(mOutAnimationFuture); + } + + MonthView next = (MonthView) mSwitcher.getNextView(); + next.setSelectionMode(current.getSelectionMode()); + next.setSelectedTime(time); + next.reloadEvents(); + next.animationStarted(); + mSwitcher.showNext(); + next.requestFocus(); + mTime = time; + } + + public void goToToday() { + Time now = new Time(); + now.set(System.currentTimeMillis()); + + TextView title = (TextView) findViewById(R.id.title); + title.setText(Utils.formatMonthYear(now)); + mTime = now; + + MonthView view = (MonthView) mSwitcher.getCurrentView(); + view.setSelectedTime(now); + view.reloadEvents(); + } + + public long getSelectedTime() { + MonthView mv = (MonthView) mSwitcher.getCurrentView(); + return mv.getSelectedTimeInMillis(); + } + + public boolean getAllDay() { + return false; + } + + int getStartDay() { + return mStartDay; + } + + void eventsChanged() { + MonthView view = (MonthView) mSwitcher.getCurrentView(); + view.reloadEvents(); + } + + /** + * Listens for intent broadcasts + */ + private BroadcastReceiver mIntentReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (action.equals(Intent.ACTION_TIME_CHANGED) + || action.equals(Intent.ACTION_DATE_CHANGED) + || action.equals(Intent.ACTION_TIMEZONE_CHANGED)) { + eventsChanged(); + } + } + }; + + // Create an observer so that we can update the views whenever a + // Calendar event changes. + private ContentObserver mObserver = new ContentObserver(new Handler()) + { + @Override + public boolean deliverSelfNotifications() { + return true; + } + + @Override + public void onChange(boolean selfChange) { + eventsChanged(); + } + }; + + public void onAnimationStart(Animation animation) { + } + + // Notifies the MonthView when an animation has finished. + public void onAnimationEnd(Animation animation) { + MonthView monthView = (MonthView) mSwitcher.getCurrentView(); + monthView.animationFinished(); + } + + public void onAnimationRepeat(Animation animation) { + } + + @Override + protected void onCreate(Bundle icicle) { + super.onCreate(icicle); + + // Eliminate extra GCs during startup by setting the initial heap size to 4MB. + // TODO: We should restore the old heap size once the activity reaches the idle state + long oldHeapSize = VMRuntime.getRuntime().setMinimumHeapSize(INITIAL_HEAP_SIZE); + + setContentView(R.layout.month_activity); + mContentResolver = getContentResolver(); + + long time; + if (icicle != null) { + time = icicle.getLong(EVENT_BEGIN_TIME); + } else { + time = Utils.timeFromIntentInMillis(getIntent()); + } + + mTime = new Time(); + mTime.set(time); + mTime.normalize(true); + + // Get first day of week based on locale and populate the day headers + mStartDay = Calendar.getInstance().getFirstDayOfWeek(); + int diff = mStartDay - Calendar.SUNDAY - 1; + + String dayString = DateUtils.getDayOfWeekString((Calendar.SUNDAY + diff) % 7 + 1, + DateUtils.LENGTH_MEDIUM); + ((TextView) findViewById(R.id.day0)).setText(dayString); + dayString = DateUtils.getDayOfWeekString((Calendar.MONDAY + diff) % 7 + 1, + DateUtils.LENGTH_MEDIUM); + ((TextView) findViewById(R.id.day1)).setText(dayString); + dayString = DateUtils.getDayOfWeekString((Calendar.TUESDAY + diff) % 7 + 1, + DateUtils.LENGTH_MEDIUM); + ((TextView) findViewById(R.id.day2)).setText(dayString); + dayString = DateUtils.getDayOfWeekString((Calendar.WEDNESDAY + diff) % 7 + 1, + DateUtils.LENGTH_MEDIUM); + ((TextView) findViewById(R.id.day3)).setText(dayString); + dayString = DateUtils.getDayOfWeekString((Calendar.THURSDAY + diff) % 7 + 1, + DateUtils.LENGTH_MEDIUM); + ((TextView) findViewById(R.id.day4)).setText(dayString); + dayString = DateUtils.getDayOfWeekString((Calendar.FRIDAY + diff) % 7 + 1, + DateUtils.LENGTH_MEDIUM); + ((TextView) findViewById(R.id.day5)).setText(dayString); + dayString = DateUtils.getDayOfWeekString((Calendar.SATURDAY + diff) % 7 + 1, + DateUtils.LENGTH_MEDIUM); + ((TextView) findViewById(R.id.day6)).setText(dayString); + + // Set the initial title + TextView title = (TextView) findViewById(R.id.title); + title.setText(Utils.formatMonthYear(mTime)); + + mEventLoader = new EventLoader(this); + mProgressBar = (ProgressBar) findViewById(R.id.progress_circular); + + mSwitcher = (ViewSwitcher) findViewById(R.id.switcher); + mSwitcher.setFactory(this); + mSwitcher.getCurrentView().requestFocus(); + + mInAnimationPast = AnimationUtils.loadAnimation(this, R.anim.slide_down_in); + mOutAnimationPast = AnimationUtils.loadAnimation(this, R.anim.slide_down_out); + mInAnimationFuture = AnimationUtils.loadAnimation(this, R.anim.slide_up_in); + mOutAnimationFuture = AnimationUtils.loadAnimation(this, R.anim.slide_up_out); + + mInAnimationPast.setAnimationListener(this); + mInAnimationFuture.setAnimationListener(this); + } + + @Override + protected void onPause() { + super.onPause(); + if (isFinishing()) { + mEventLoader.stopBackgroundThread(); + } + mContentResolver.unregisterContentObserver(mObserver); + unregisterReceiver(mIntentReceiver); + + MonthView view = (MonthView) mSwitcher.getCurrentView(); + view.dismissPopup(); + view = (MonthView) mSwitcher.getNextView(); + view.dismissPopup(); + mEventLoader.stopBackgroundThread(); + } + + @Override + protected void onResume() { + super.onResume(); + mEventLoader.startBackgroundThread(); + eventsChanged(); + + MonthView view1 = (MonthView) mSwitcher.getCurrentView(); + MonthView view2 = (MonthView) mSwitcher.getNextView(); + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + String str = prefs.getString(CalendarPreferenceActivity.KEY_DETAILED_VIEW, + CalendarPreferenceActivity.DEFAULT_DETAILED_VIEW); + view1.setDetailedView(str); + view2.setDetailedView(str); + + // Record Month View as the (new) start view + String activityString = CalendarApplication.ACTIVITY_NAMES[CalendarApplication.MONTH_VIEW_ID]; + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(CalendarPreferenceActivity.KEY_START_VIEW, activityString); + editor.commit(); + + // Register for Intent broadcasts + IntentFilter filter = new IntentFilter(); + + filter.addAction(Intent.ACTION_TIME_CHANGED); + filter.addAction(Intent.ACTION_DATE_CHANGED); + filter.addAction(Intent.ACTION_TIMEZONE_CHANGED); + registerReceiver(mIntentReceiver, filter); + + mContentResolver.registerContentObserver(Events.CONTENT_URI, + true, mObserver); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putLong(EVENT_BEGIN_TIME, mTime.toMillis(true)); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + switch (keyCode) { + case KeyEvent.KEYCODE_BACK: + finish(); + return true; + } + return super.onKeyDown(keyCode, event); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuHelper.onPrepareOptionsMenu(this, menu); + return super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuHelper.onCreateOptionsMenu(menu); + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + MenuHelper.onOptionsItemSelected(this, item, this); + return super.onOptionsItemSelected(item); + } +} diff --git a/src/com/android/calendar/MonthView.java b/src/com/android/calendar/MonthView.java new file mode 100644 index 00000000..f6db3b80 --- /dev/null +++ b/src/com/android/calendar/MonthView.java @@ -0,0 +1,1366 @@ +/* + * Copyright (C) 2006 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.calendar; + +import static android.provider.Calendar.EVENT_BEGIN_TIME; +import static android.provider.Calendar.EVENT_END_TIME; + +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.os.Handler; +import android.os.SystemClock; +import android.provider.Calendar.BusyBits; +import android.text.format.DateFormat; +import android.text.format.DateUtils; +import android.text.format.Time; +import android.util.DayOfMonthCursor; +import android.util.Log; +import android.util.SparseArray; +import android.view.ContextMenu; +import android.view.GestureDetector; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ContextMenu.ContextMenuInfo; +import android.widget.PopupWindow; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.Calendar; + +public class MonthView extends View implements View.OnCreateContextMenuListener { + + private static final boolean PROFILE_LOAD_TIME = false; + private static final boolean DEBUG_BUSYBITS = false; + + private static final int WEEK_GAP = 0; + private static final int MONTH_DAY_GAP = 1; + private static final float HOUR_GAP = 0.5f; + + private static final int MONTH_DAY_TEXT_SIZE = 20; + private static final int WEEK_BANNER_HEIGHT = 17; + private static final int WEEK_TEXT_SIZE = 15; + private static final int WEEK_TEXT_PADDING = 3; + private static final int BUSYBIT_WIDTH = 10; + private static final int BUSYBIT_RIGHT_MARGIN = 3; + private static final int BUSYBIT_TOP_BOTTOM_MARGIN = 7; + + private static final int HORIZONTAL_FLING_THRESHOLD = 50; + + private int mCellHeight; + private int mBorder; + private boolean mLaunchDayView; + + private GestureDetector mGestureDetector; + + private String mDetailedView = CalendarPreferenceActivity.DEFAULT_DETAILED_VIEW; + + private Time mToday; + private Time mViewCalendar; + private Time mSavedTime = new Time(); // the time when we entered this view + + // This Time object is used to set the time for the other Month view. + private Time mOtherViewCalendar = new Time(); + + // This Time object is used for temporary calculations and is allocated + // once to avoid extra garbage collection + private Time mTempTime = new Time(); + + private DayOfMonthCursor mCursor; + + private Drawable mBoxSelected; + private Drawable mBoxPressed; + private Drawable mBoxLongPressed; + private Drawable mDnaEmpty; + private Drawable mDnaTop; + private Drawable mDnaMiddle; + private Drawable mDnaBottom; + private int mCellWidth; + + private Resources mResources; + private MonthActivity mParentActivity; + private Navigator mNavigator; + private final EventGeometry mEventGeometry; + + // Pre-allocate and reuse + private Rect mRect = new Rect(); + + // The number of hours represented by one busy bit + private static final int HOURS_PER_BUSY_SLOT = 4; + + // The number of database intervals represented by one busy bit (slot) + private static final int INTERVALS_PER_BUSY_SLOT = 4 * 60 / BusyBits.MINUTES_PER_BUSY_INTERVAL; + + // The bit mask for coalescing the raw busy bits from the database + // (1 bit per hour) into the busy bits per slot (4-hour slots). + private static final int BUSY_SLOT_MASK = (1 << INTERVALS_PER_BUSY_SLOT) - 1; + + // The number of slots in a day + private static final int SLOTS_PER_DAY = 24 / HOURS_PER_BUSY_SLOT; + + // There is one "busy" bit for each slot of time. + private byte[][] mBusyBits = new byte[31][SLOTS_PER_DAY]; + + // Raw busy bits from database + private int[] mRawBusyBits = new int[31]; + private int[] mAllDayCounts = new int[31]; + + private PopupWindow mPopup; + private View mPopupView; + private static final int POPUP_HEIGHT = 100; + private int mPreviousPopupHeight; + private static final int POPUP_DISMISS_DELAY = 3000; + private DismissPopup mDismissPopup = new DismissPopup(); + + // For drawing to an off-screen Canvas + private Bitmap mBitmap; + private Canvas mCanvas; + private boolean mRedrawScreen = true; + private Rect mBitmapRect = new Rect(); + private boolean mAnimating; + + // These booleans disable features that were taken out of the spec. + private boolean mShowWeekNumbers = false; + private boolean mShowToast = false; + + // Bitmap caches. + // These improve performance by minimizing calls to NinePatchDrawable.draw() for common + // drawables for events and day backgrounds. + // mEventBitmapCache is indexed by an integer constructed from the bits in the busyBits + // field. It is not expected to be larger than 12 bits (if so, we should switch to using a Map). + // mDayBitmapCache is indexed by a unique integer constructed from the width/height. + private SparseArray<Bitmap> mEventBitmapCache = new SparseArray<Bitmap>(1<<SLOTS_PER_DAY); + private SparseArray<Bitmap> mDayBitmapCache = new SparseArray<Bitmap>(4); + + private ContextMenuHandler mContextMenuHandler = new ContextMenuHandler(); + + /** + * The selection modes are HIDDEN, PRESSED, SELECTED, and LONGPRESS. + */ + private static final int SELECTION_HIDDEN = 0; + private static final int SELECTION_PRESSED = 1; + private static final int SELECTION_SELECTED = 2; + private static final int SELECTION_LONGPRESS = 3; + + // Modulo used to pack (width,height) into a unique integer + private static final int MODULO_SHIFT = 16; + + private int mSelectionMode = SELECTION_HIDDEN; + + /** + * The first Julian day of the current month. + */ + private int mFirstJulianDay; + + private final EventLoader mEventLoader; + + private ArrayList<Event> mEvents = new ArrayList<Event>(); + + private Drawable mTodayBackground; + private Drawable mDayBackground; + + // Cached colors + private int mMonthOtherMonthColor; + private int mMonthWeekBannerColor; + private int mMonthOtherMonthBannerColor; + private int mMonthOtherMonthDayNumberColor; + private int mMonthDayNumberColor; + private int mMonthTodayNumberColor; + + public MonthView(MonthActivity activity, Navigator navigator) { + super(activity); + mEventLoader = activity.mEventLoader; + mNavigator = navigator; + mEventGeometry = new EventGeometry(); + mEventGeometry.setMinEventHeight(1.0f); + mEventGeometry.setHourGap(HOUR_GAP); + init(activity); + } + + private void init(MonthActivity activity) { + setFocusable(true); + setClickable(true); + setOnCreateContextMenuListener(this); + mParentActivity = activity; + mViewCalendar = new Time(); + long now = System.currentTimeMillis(); + mViewCalendar.set(now); + mViewCalendar.monthDay = 1; + long millis = mViewCalendar.normalize(true /* ignore DST */); + mFirstJulianDay = Time.getJulianDay(millis, mViewCalendar.gmtoff); + mViewCalendar.set(now); + + mCursor = new DayOfMonthCursor(mViewCalendar.year, mViewCalendar.month, + mViewCalendar.monthDay, mParentActivity.getStartDay()); + mToday = new Time(); + mToday.set(System.currentTimeMillis()); + + mResources = activity.getResources(); + mBoxSelected = mResources.getDrawable(R.drawable.month_view_selected); + mBoxPressed = mResources.getDrawable(R.drawable.month_view_pressed); + mBoxLongPressed = mResources.getDrawable(R.drawable.month_view_longpress); + + mDnaEmpty = mResources.getDrawable(R.drawable.dna_empty); + mDnaTop = mResources.getDrawable(R.drawable.dna_1_of_6); + mDnaMiddle = mResources.getDrawable(R.drawable.dna_2345_of_6); + mDnaBottom = mResources.getDrawable(R.drawable.dna_6_of_6); + mTodayBackground = mResources.getDrawable(R.drawable.month_view_today_background); + mDayBackground = mResources.getDrawable(R.drawable.month_view_background); + + // Cache color lookups + Resources res = getResources(); + mMonthOtherMonthColor = res.getColor(R.color.month_other_month); + mMonthWeekBannerColor = res.getColor(R.color.month_week_banner); + mMonthOtherMonthBannerColor = res.getColor(R.color.month_other_month_banner); + mMonthOtherMonthDayNumberColor = res.getColor(R.color.month_other_month_day_number); + mMonthDayNumberColor = res.getColor(R.color.month_day_number); + mMonthTodayNumberColor = res.getColor(R.color.month_today_number); + + if (mShowToast) { + LayoutInflater inflater; + inflater = (LayoutInflater) activity.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + mPopupView = inflater.inflate(R.layout.month_bubble, null); + mPopup = new PopupWindow(activity); + mPopup.setContentView(mPopupView); + Resources.Theme dialogTheme = getResources().newTheme(); + dialogTheme.applyStyle(android.R.style.Theme_Dialog, true); + TypedArray ta = dialogTheme.obtainStyledAttributes(new int[] { + android.R.attr.windowBackground }); + mPopup.setBackgroundDrawable(ta.getDrawable(0)); + ta.recycle(); + } + + mGestureDetector = new GestureDetector(getContext(), new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, + float velocityY) { + // The user might do a slow "fling" after touching the screen + // and we don't want the long-press to pop up a context menu. + // Setting mLaunchDayView to false prevents the long-press. + mLaunchDayView = false; + mSelectionMode = SELECTION_HIDDEN; + + int distanceX = Math.abs((int) e2.getX() - (int) e1.getX()); + int distanceY = Math.abs((int) e2.getY() - (int) e1.getY()); + if (distanceY < HORIZONTAL_FLING_THRESHOLD || distanceY < distanceX) { + return false; + } + + // Switch to a different month + Time time = mOtherViewCalendar; + time.set(mViewCalendar); + if (velocityY < 0) { + time.month += 1; + } else { + time.month -= 1; + } + time.normalize(true); + mParentActivity.goTo(time); + + return true; + } + + @Override + public boolean onDown(MotionEvent e) { + mLaunchDayView = false; + return true; + } + + @Override + public void onShowPress(MotionEvent e) { + int x = (int) e.getX(); + int y = (int) e.getY(); + int row = (y - WEEK_GAP) / (WEEK_GAP + mCellHeight); + int col = (x - mBorder) / (MONTH_DAY_GAP + mCellWidth); + if (row > 5) { + row = 5; + } + if (col > 6) { + col = 6; + } + + // Launch the Day/Agenda view when the finger lifts up, + // unless the finger moves before lifting up. + mLaunchDayView = true; + + // Highlight the selected day. + mCursor.setSelectedRowColumn(row, col); + mSelectionMode = SELECTION_PRESSED; + mRedrawScreen = true; + invalidate(); + } + + @Override + public void onLongPress(MotionEvent e) { + // If mLaunchDayView is true, then we haven't done any scrolling + // after touching the screen, so allow long-press to proceed + // with popping up the context menu. + if (mLaunchDayView) { + mLaunchDayView = false; + mSelectionMode = SELECTION_LONGPRESS; + mRedrawScreen = true; + invalidate(); + performLongClick(); + } + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, + float distanceX, float distanceY) { + // If the user moves his finger after touching, then do not + // launch the Day view when he lifts his finger. Also, turn + // off the selection. + mLaunchDayView = false; + + if (mSelectionMode != SELECTION_HIDDEN) { + mSelectionMode = SELECTION_HIDDEN; + mRedrawScreen = true; + invalidate(); + } + return true; + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + if (mLaunchDayView) { + mSelectionMode = SELECTION_SELECTED; + mRedrawScreen = true; + invalidate(); + mLaunchDayView = false; + int x = (int) e.getX(); + int y = (int) e.getY(); + long millis = getSelectedMillisFor(x, y); + Utils.startActivity(getContext(), mDetailedView, millis); + mParentActivity.finish(); + } + + return true; + } + }); + } + + public void onCreateContextMenu(ContextMenu menu, View view, ContextMenuInfo menuInfo) { + MenuItem item; + + final long startMillis = getSelectedTimeInMillis(); + final int flags = DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_SHOW_DATE + | DateUtils.FORMAT_ABBREV_MONTH; + + final String title = DateUtils.formatDateTime(mParentActivity, startMillis, flags); + menu.setHeaderTitle(title); + + item = menu.add(0, MenuHelper.MENU_DAY, 0, R.string.show_day_view); + item.setOnMenuItemClickListener(mContextMenuHandler); + item.setIcon(android.R.drawable.ic_menu_day); + item.setAlphabeticShortcut('d'); + + item = menu.add(0, MenuHelper.MENU_AGENDA, 0, R.string.show_agenda_view); + item.setOnMenuItemClickListener(mContextMenuHandler); + item.setIcon(android.R.drawable.ic_menu_agenda); + item.setAlphabeticShortcut('a'); + + item = menu.add(0, MenuHelper.MENU_EVENT_CREATE, 0, R.string.event_create); + item.setOnMenuItemClickListener(mContextMenuHandler); + item.setIcon(android.R.drawable.ic_menu_add); + item.setAlphabeticShortcut('n'); + } + + private class ContextMenuHandler implements MenuItem.OnMenuItemClickListener { + public boolean onMenuItemClick(MenuItem item) { + switch (item.getItemId()) { + case MenuHelper.MENU_DAY: { + long startMillis = getSelectedTimeInMillis(); + MenuHelper.switchTo(mParentActivity, DayActivity.class.getName(), startMillis); + mParentActivity.finish(); + break; + } + case MenuHelper.MENU_AGENDA: { + long startMillis = getSelectedTimeInMillis(); + MenuHelper.switchTo(mParentActivity, AgendaActivity.class.getName(), startMillis); + mParentActivity.finish(); + break; + } + case MenuHelper.MENU_EVENT_CREATE: { + long startMillis = getSelectedTimeInMillis(); + long endMillis = startMillis + DateUtils.HOUR_IN_MILLIS; + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setClassName(mContext, EditEvent.class.getName()); + intent.putExtra(EVENT_BEGIN_TIME, startMillis); + intent.putExtra(EVENT_END_TIME, endMillis); + mParentActivity.startActivity(intent); + break; + } + default: { + return false; + } + } + return true; + } + } + + void reloadEvents() { + // Get the date for the beginning of the month + Time monthStart = mTempTime; + monthStart.set(mViewCalendar); + monthStart.monthDay = 1; + monthStart.hour = 0; + monthStart.minute = 0; + monthStart.second = 0; + long millis = monthStart.normalize(true /* ignore isDst */); + int startDay = Time.getJulianDay(millis, monthStart.gmtoff); + + // Load the busy-bits in the background + mParentActivity.startProgressSpinner(); + final long startMillis; + if (PROFILE_LOAD_TIME) { + startMillis = SystemClock.uptimeMillis(); + } else { + // To avoid a compiler error that this variable might not be initialized. + startMillis = 0; + } + mEventLoader.loadBusyBitsInBackground(startDay, 31, mRawBusyBits, mAllDayCounts, + new Runnable() { + public void run() { + convertBusyBits(); + if (PROFILE_LOAD_TIME) { + long endMillis = SystemClock.uptimeMillis(); + long elapsed = endMillis - startMillis; + Log.i("Cal", (mViewCalendar.month+1) + "/" + mViewCalendar.year + " Month view load busybits: " + elapsed); + } + mRedrawScreen = true; + mParentActivity.stopProgressSpinner(); + invalidate(); + } + }); + } + + void animationStarted() { + mAnimating = true; + } + + void animationFinished() { + mAnimating = false; + mRedrawScreen = true; + invalidate(); + } + + @Override + protected void onSizeChanged(int width, int height, int oldw, int oldh) { + drawingCalc(width, height); + // If the size changed, then we should rebuild the bitmaps... + clearBitmapCache(); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + // No need to hang onto the bitmaps... + clearBitmapCache(); + if (mBitmap != null) { + mBitmap.recycle(); + } + } + + @Override + protected void onDraw(Canvas canvas) { + if (mRedrawScreen) { + if (mCanvas == null) { + drawingCalc(getWidth(), getHeight()); + } + + // If we are zero-sized, the canvas will remain null so check again + if (mCanvas != null) { + // Clear the background + final Canvas bitmapCanvas = mCanvas; + bitmapCanvas.drawColor(0, PorterDuff.Mode.CLEAR); + doDraw(bitmapCanvas); + mRedrawScreen = false; + } + } + + // If we are zero-sized, the bitmap will be null so guard against this + if (mBitmap != null) { + canvas.drawBitmap(mBitmap, mBitmapRect, mBitmapRect, null); + } + } + + private void doDraw(Canvas canvas) { + boolean isLandscape = getResources().getConfiguration().orientation + == Configuration.ORIENTATION_LANDSCAPE; + + Paint p = new Paint(); + Rect r = mRect; + int columnDay1 = mCursor.getColumnOf(1); + + // Get the Julian day for the date at row 0, column 0. + int day = mFirstJulianDay - columnDay1; + + int weekNum = 0; + Calendar calendar = null; + if (mShowWeekNumbers) { + calendar = Calendar.getInstance(); + boolean noPrevMonth = (columnDay1 == 0); + + // Compute the week number for the first row. + weekNum = getWeekOfYear(0, 0, noPrevMonth, calendar); + } + + for (int row = 0; row < 6; row++) { + for (int column = 0; column < 7; column++) { + drawBox(day, weekNum, row, column, canvas, p, r, isLandscape); + day += 1; + } + + if (mShowWeekNumbers) { + weekNum += 1; + if (weekNum >= 53) { + boolean inCurrentMonth = (day - mFirstJulianDay < 31); + weekNum = getWeekOfYear(row + 1, 0, inCurrentMonth, calendar); + } + } + } + + drawGrid(canvas, p); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (mGestureDetector.onTouchEvent(event)) { + return true; + } + + return super.onTouchEvent(event); + } + + private long getSelectedMillisFor(int x, int y) { + int row = (y - WEEK_GAP) / (WEEK_GAP + mCellHeight); + int column = (x - mBorder) / (MONTH_DAY_GAP + mCellWidth); + if (column > 6) { + column = 6; + } + + DayOfMonthCursor c = mCursor; + Time time = mTempTime; + time.set(mViewCalendar); + + // Compute the day number from the row and column. If the row and + // column are in a different month from the current one, then the + // monthDay might be negative or it might be greater than the number + // of days in this month, but that is okay because the normalize() + // method will adjust the month (and year) if necessary. + time.monthDay = 7 * row + column - c.getOffset() + 1; + return time.normalize(true); + } + + /** + * Create a bitmap at the origin and draw the drawable to it using the bounds specified by rect. + * + * @param drawable the drawable we wish to render + * @param width the width of the resulting bitmap + * @param height the height of the resulting bitmap + * @return a new bitmap + */ + private Bitmap createBitmap(Drawable drawable, int width, int height) { + // Create a bitmap with the same format as mBitmap (should be Bitmap.Config.ARGB_8888) + Bitmap bitmap = Bitmap.createBitmap(width, height, mBitmap.getConfig()); + + // Draw the drawable into the bitmap at the origin. + Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, width, height); + drawable.draw(canvas); + return bitmap; + } + + /** + * Clears the bitmap cache. Generally only needed when the screen size changed. + */ + private void clearBitmapCache() { + recycleAndClearBitmapCache(mEventBitmapCache); + recycleAndClearBitmapCache(mDayBitmapCache); + } + + private void recycleAndClearBitmapCache(SparseArray<Bitmap> bitmapCache) { + int size = bitmapCache.size(); + for(int i = 0; i < size; i++) { + bitmapCache.valueAt(i).recycle(); + } + bitmapCache.clear(); + + } + + /** + * Draw the grid lines for the calendar + * @param canvas The canvas to draw on. + * @param p The paint used for drawing. + */ + private void drawGrid(Canvas canvas, Paint p) { + p.setColor(mMonthOtherMonthColor); + p.setAntiAlias(false); + + final int width = getMeasuredWidth(); + final int height = getMeasuredHeight(); + + for (int row = 0; row < 6; row++) { + int y = WEEK_GAP + row * (WEEK_GAP + mCellHeight) - 1; + canvas.drawLine(0, y, width, y, p); + } + for (int column = 1; column < 7; column++) { + int x = mBorder + column * (MONTH_DAY_GAP + mCellWidth) - 1; + canvas.drawLine(x, WEEK_GAP, x, height, p); + } + } + + /** + * Draw a single box onto the canvas. + * @param day The Julian day. + * @param weekNum The week number. + * @param row The row of the box (0-5). + * @param column The column of the box (0-6). + * @param canvas The canvas to draw on. + * @param p The paint used for drawing. + * @param r The rectangle used for each box. + * @param isLandscape Is the current orientation landscape. + */ + private void drawBox(int day, int weekNum, int row, int column, Canvas canvas, Paint p, + Rect r, boolean isLandscape) { + + // Only draw the selection if we are in the press state or if we have + // moved the cursor with key input. + boolean drawSelection = false; + if (mSelectionMode != SELECTION_HIDDEN) { + drawSelection = mCursor.isSelected(row, column); + } + + boolean withinCurrentMonth = mCursor.isWithinCurrentMonth(row, column); + boolean isToday = false; + int dayOfBox = mCursor.getDayAt(row, column); + if (dayOfBox == mToday.monthDay && mCursor.getYear() == mToday.year + && mCursor.getMonth() == mToday.month) { + isToday = true; + } + + int y = WEEK_GAP + row*(WEEK_GAP + mCellHeight); + int x = mBorder + column*(MONTH_DAY_GAP + mCellWidth); + + r.left = x; + r.top = y; + r.right = x + mCellWidth; + r.bottom = y + mCellHeight; + + + // Adjust the left column, right column, and bottom row to leave + // no border. + if (column == 0) { + r.left = -1; + } else if (column == 6) { + r.right += mBorder + 2; + } + + if (row == 5) { + r.bottom = getMeasuredHeight(); + } + + // Draw the cell contents (excluding monthDay number) + if (!withinCurrentMonth) { + boolean firstDayOfNextmonth = isFirstDayOfNextMonth(row, column); + + // Adjust cell boundaries to compensate for the different border + // style. + r.top--; + if (column != 0) { + r.left--; + } + } else if (drawSelection) { + if (mSelectionMode == SELECTION_SELECTED) { + mBoxSelected.setBounds(r); + mBoxSelected.draw(canvas); + } else if (mSelectionMode == SELECTION_PRESSED) { + mBoxPressed.setBounds(r); + mBoxPressed.draw(canvas); + } else { + mBoxLongPressed.setBounds(r); + mBoxLongPressed.draw(canvas); + } + + drawEvents(day, canvas, r, p); + if (!mAnimating) { + updateEventDetails(day); + } + } else { + // Today gets a different background + if (isToday) { + // We could cache this for a little bit more performance, but it's not on the + // performance radar... + Drawable background = mTodayBackground; + background.setBounds(r); + background.draw(canvas); + } else { + // Use the bitmap cache to draw the day background + int width = r.right - r.left; + int height = r.bottom - r.top; + // Compute a unique id that depends on width and height. + int id = (height << MODULO_SHIFT) | width; + Bitmap bitmap = mDayBitmapCache.get(id); + if (bitmap == null) { + bitmap = createBitmap(mDayBackground, width, height); + mDayBitmapCache.put(id, bitmap); + } + canvas.drawBitmap(bitmap, r.left, r.top, p); + } + drawEvents(day, canvas, r, p); + } + + // Draw week number + if (mShowWeekNumbers && column == 0) { + // Draw the banner + p.setStyle(Paint.Style.FILL); + p.setColor(mMonthWeekBannerColor); + int right = r.right; + r.right = right - BUSYBIT_WIDTH - BUSYBIT_RIGHT_MARGIN; + if (isLandscape) { + int bottom = r.bottom; + r.bottom = r.top + WEEK_BANNER_HEIGHT; + r.left++; + canvas.drawRect(r, p); + r.bottom = bottom; + r.left--; + } else { + int top = r.top; + r.top = r.bottom - WEEK_BANNER_HEIGHT; + r.left++; + canvas.drawRect(r, p); + r.top = top; + r.left--; + } + r.right = right; + + // Draw the number + p.setColor(mMonthOtherMonthBannerColor); + p.setAntiAlias(true); + p.setTypeface(null); + p.setTextSize(WEEK_TEXT_SIZE); + p.setTextAlign(Paint.Align.LEFT); + + int textX = r.left + WEEK_TEXT_PADDING; + int textY; + if (isLandscape) { + textY = r.top + WEEK_BANNER_HEIGHT - WEEK_TEXT_PADDING; + } else { + textY = r.bottom - WEEK_TEXT_PADDING; + } + + canvas.drawText(String.valueOf(weekNum), textX, textY, p); + } + + // Draw the monthDay number + p.setStyle(Paint.Style.FILL); + p.setAntiAlias(true); + p.setTypeface(null); + p.setTextSize(MONTH_DAY_TEXT_SIZE); + + if (!withinCurrentMonth) { + p.setColor(mMonthOtherMonthDayNumberColor); + } else if (drawSelection || !isToday) { + p.setColor(mMonthDayNumberColor); + } else { + p.setColor(mMonthTodayNumberColor); + } + + p.setTextAlign(Paint.Align.CENTER); + int right = r.right - BUSYBIT_WIDTH - BUSYBIT_RIGHT_MARGIN; + int textX = r.left + (right - r.left) / 2; // center of text + int textY = r.bottom - BUSYBIT_TOP_BOTTOM_MARGIN - 2; // bottom of text + canvas.drawText(String.valueOf(mCursor.getDayAt(row, column)), textX, textY, p); + } + + /** + * Converts the busy bits from the database that use 1-hour intervals to + * the 4-hour time slots needed in this view. Also, we map all-day + * events to the first two 4-hour time slots (that is, an all-day event + * will look like the first 8 hours from 12am to 8am are busy). This + * looks better than setting just the first 4-hour time slot because that + * is barely visible in landscape mode. + */ + private void convertBusyBits() { + if (DEBUG_BUSYBITS) { + Log.i("Cal", "convertBusyBits() SLOTS_PER_DAY: " + SLOTS_PER_DAY + + " BUSY_SLOT_MASK: " + BUSY_SLOT_MASK + + " INTERVALS_PER_BUSY_SLOT: " + INTERVALS_PER_BUSY_SLOT); + for (int day = 0; day < 31; day++) { + int bits = mRawBusyBits[day]; + String bitString = String.format("0x%06x", bits); + String valString = ""; + for (int slot = 0; slot < SLOTS_PER_DAY; slot++) { + int val = bits & BUSY_SLOT_MASK; + bits = bits >>> INTERVALS_PER_BUSY_SLOT; + valString += " " + val; + } + Log.i("Cal", "[" + day + "] " + bitString + " " + valString + + " allday: " + mAllDayCounts[day]); + } + } + for (int day = 0; day < 31; day++) { + int bits = mRawBusyBits[day]; + for (int slot = 0; slot < SLOTS_PER_DAY; slot++) { + int val = bits & BUSY_SLOT_MASK; + bits = bits >>> INTERVALS_PER_BUSY_SLOT; + if (val == 0) { + mBusyBits[day][slot] = 0; + } else { + mBusyBits[day][slot] = 1; + } + } + if (mAllDayCounts[day] > 0) { + mBusyBits[day][0] = 1; + mBusyBits[day][1] = 1; + } + } + } + + /** + * Create a bitmap at the origin for the given set of busyBits. + * + * @param busyBits an array of bits with elements set to 1 if we have an event for that slot + * @param rect the size of the resulting + * @return a new bitmap + */ + private Bitmap createEventBitmap(byte[] busyBits, Rect rect) { + // Compute the size of the smallest bitmap, excluding margins. + final int left = 0; + final int right = BUSYBIT_WIDTH; + final int top = 0; + final int bottom = (rect.bottom - rect.top) - 2 * BUSYBIT_TOP_BOTTOM_MARGIN; + final int height = bottom - top; + final int width = right - left; + + final Drawable dnaEmpty = mDnaEmpty; + final Drawable dnaTop = mDnaTop; + final Drawable dnaMiddle = mDnaMiddle; + final Drawable dnaBottom = mDnaBottom; + final float slotHeight = (float) height / SLOTS_PER_DAY; + + // Create a bitmap with the same format as mBitmap (should be Bitmap.Config.ARGB_8888) + Bitmap bitmap = Bitmap.createBitmap(width, height, mBitmap.getConfig()); + + // Create a canvas for drawing and draw background (dnaEmpty) + Canvas canvas = new Canvas(bitmap); + dnaEmpty.setBounds(left, top, right, bottom); + dnaEmpty.draw(canvas); + + // The first busy bit is a drawable that is round at the top + if (busyBits[0] == 1) { + float rectBottom = top + slotHeight; + dnaTop.setBounds(left, top, right, (int) rectBottom); + dnaTop.draw(canvas); + } + + // The last busy bit is a drawable that is round on the bottom + int lastIndex = busyBits.length - 1; + if (busyBits[lastIndex] == 1) { + float rectTop = bottom - slotHeight; + dnaBottom.setBounds(left, (int) rectTop, right, bottom); + dnaBottom.draw(canvas); + } + + // Draw all intermediate pieces. We could further optimize this to + // draw runs of bits, but it probably won't yield much more performance. + float rectTop = top + slotHeight; + for (int index = 1; index < lastIndex; index++) { + float rectBottom = rectTop + slotHeight; + if (busyBits[index] == 1) { + dnaMiddle.setBounds(left, (int) rectTop, right, (int) rectBottom); + dnaMiddle.draw(canvas); + } + rectTop = rectBottom; + } + return bitmap; + } + + private void drawEvents(int date, Canvas canvas, Rect rect, Paint p) { + // These are the coordinates of the upper left corner where we'll draw the event bitmap + int top = rect.top + BUSYBIT_TOP_BOTTOM_MARGIN; + int right = rect.right - BUSYBIT_RIGHT_MARGIN; + int left = right - BUSYBIT_WIDTH; + + // Display the busy bits. Draw a rectangle for each run of 1-bits. + int day = date - mFirstJulianDay; + byte[] busyBits = mBusyBits[day]; + int lastIndex = busyBits.length - 1; + + // Cache index is simply all of the bits combined into an integer + int cacheIndex = 0; + for (int i = 0 ; i <= lastIndex; i++) cacheIndex |= busyBits[i] << i; + Bitmap bitmap = mEventBitmapCache.get(cacheIndex); + if (bitmap == null) { + // Create a bitmap that we'll reuse for all events with the same + // combination of busyBits. + bitmap = createEventBitmap(busyBits, rect); + mEventBitmapCache.put(cacheIndex, bitmap); + } + canvas.drawBitmap(bitmap, left, top, p); + } + + private boolean isFirstDayOfNextMonth(int row, int column) { + if (column == 0) { + column = 6; + row--; + } else { + column--; + } + return mCursor.isWithinCurrentMonth(row, column); + } + + private int getWeekOfYear(int row, int column, boolean isWithinCurrentMonth, + Calendar calendar) { + calendar.set(Calendar.DAY_OF_MONTH, mCursor.getDayAt(row, column)); + if (isWithinCurrentMonth) { + calendar.set(Calendar.MONTH, mCursor.getMonth()); + calendar.set(Calendar.YEAR, mCursor.getYear()); + } else { + int month = mCursor.getMonth(); + int year = mCursor.getYear(); + if (row < 2) { + // Previous month + if (month == 0) { + year--; + month = 11; + } else { + month--; + } + } else { + // Next month + if (month == 11) { + year++; + month = 0; + } else { + month++; + } + } + calendar.set(Calendar.MONTH, month); + calendar.set(Calendar.YEAR, year); + } + + return calendar.get(Calendar.WEEK_OF_YEAR); + } + + void setDetailedView(String detailedView) { + mDetailedView = detailedView; + } + + void setSelectedTime(Time time) { + // Save the selected time so that we can restore it later when we switch views. + mSavedTime.set(time); + + mViewCalendar.set(time); + mViewCalendar.monthDay = 1; + long millis = mViewCalendar.normalize(true /* ignore DST */); + mFirstJulianDay = Time.getJulianDay(millis, mViewCalendar.gmtoff); + mViewCalendar.set(time); + + mCursor = new DayOfMonthCursor(time.year, time.month, time.monthDay, + mCursor.getWeekStartDay()); + + mRedrawScreen = true; + invalidate(); + } + + public long getSelectedTimeInMillis() { + Time time = mTempTime; + time.set(mViewCalendar); + + time.month += mCursor.getSelectedMonthOffset(); + time.monthDay = mCursor.getSelectedDayOfMonth(); + + // Restore the saved hour:minute:second offset from when we entered + // this view. + time.second = mSavedTime.second; + time.minute = mSavedTime.minute; + time.hour = mSavedTime.hour; + return time.normalize(true); + } + + Time getTime() { + return mViewCalendar; + } + + public int getSelectionMode() { + return mSelectionMode; + } + + public void setSelectionMode(int selectionMode) { + mSelectionMode = selectionMode; + } + + private void drawingCalc(int width, int height) { + mCellHeight = (height - (6 * WEEK_GAP)) / 6; + mEventGeometry.setHourHeight((mCellHeight - 25.0f * HOUR_GAP) / 24.0f); + mCellWidth = (width - (6 * MONTH_DAY_GAP)) / 7; + mBorder = (width - 6 * (mCellWidth + MONTH_DAY_GAP) - mCellWidth) / 2; + + if (mShowToast) { + mPopup.dismiss(); + mPopup.setWidth(width - 20); + mPopup.setHeight(POPUP_HEIGHT); + } + + if (((mBitmap == null) + || mBitmap.isRecycled() + || (mBitmap.getHeight() != height) + || (mBitmap.getWidth() != width)) + && (width > 0) && (height > 0)) { + if (mBitmap != null) { + mBitmap.recycle(); + } + mBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + mCanvas = new Canvas(mBitmap); + } + + mBitmapRect.top = 0; + mBitmapRect.bottom = height; + mBitmapRect.left = 0; + mBitmapRect.right = width; + } + + private void updateEventDetails(int date) { + if (!mShowToast) { + return; + } + + getHandler().removeCallbacks(mDismissPopup); + ArrayList<Event> events = mEvents; + int numEvents = events.size(); + if (numEvents == 0) { + mPopup.dismiss(); + return; + } + + int eventIndex = 0; + for (int i = 0; i < numEvents; i++) { + Event event = events.get(i); + + if (event.startDay > date || event.endDay < date) { + continue; + } + + // If we have all the event that we can display, then just count + // the extra ones. + if (eventIndex >= 4) { + eventIndex += 1; + continue; + } + + int flags; + boolean showEndTime = false; + if (event.allDay) { + int numDays = event.endDay - event.startDay; + if (numDays == 0) { + flags = DateUtils.FORMAT_UTC | DateUtils.FORMAT_SHOW_DATE + | DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_ABBREV_ALL; + } else { + showEndTime = true; + flags = DateUtils.FORMAT_UTC | DateUtils.FORMAT_SHOW_DATE + | DateUtils.FORMAT_ABBREV_ALL; + } + } else { + flags = DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_CAP_NOON_MIDNIGHT; + if (DateFormat.is24HourFormat(mContext)) { + flags |= DateUtils.FORMAT_24HOUR; + } + } + + String timeRange; + if (showEndTime) { + timeRange = DateUtils.formatDateRange(mParentActivity, + event.startMillis, event.endMillis, flags); + } else { + timeRange = DateUtils.formatDateRange(mParentActivity, + event.startMillis, event.startMillis, flags); + } + + TextView timeView = null; + TextView titleView = null; + switch (eventIndex) { + case 0: + timeView = (TextView) mPopupView.findViewById(R.id.time0); + titleView = (TextView) mPopupView.findViewById(R.id.event_title0); + break; + case 1: + timeView = (TextView) mPopupView.findViewById(R.id.time1); + titleView = (TextView) mPopupView.findViewById(R.id.event_title1); + break; + case 2: + timeView = (TextView) mPopupView.findViewById(R.id.time2); + titleView = (TextView) mPopupView.findViewById(R.id.event_title2); + break; + case 3: + timeView = (TextView) mPopupView.findViewById(R.id.time3); + titleView = (TextView) mPopupView.findViewById(R.id.event_title3); + break; + } + + timeView.setText(timeRange); + titleView.setText(event.title); + eventIndex += 1; + } + if (eventIndex == 0) { + // We didn't find any events for this day + mPopup.dismiss(); + return; + } + + // Hide the items that have no event information + View view; + switch (eventIndex) { + case 1: + view = mPopupView.findViewById(R.id.item_layout1); + view.setVisibility(View.GONE); + view = mPopupView.findViewById(R.id.item_layout2); + view.setVisibility(View.GONE); + view = mPopupView.findViewById(R.id.item_layout3); + view.setVisibility(View.GONE); + view = mPopupView.findViewById(R.id.plus_more); + view.setVisibility(View.GONE); + break; + case 2: + view = mPopupView.findViewById(R.id.item_layout1); + view.setVisibility(View.VISIBLE); + view = mPopupView.findViewById(R.id.item_layout2); + view.setVisibility(View.GONE); + view = mPopupView.findViewById(R.id.item_layout3); + view.setVisibility(View.GONE); + view = mPopupView.findViewById(R.id.plus_more); + view.setVisibility(View.GONE); + break; + case 3: + view = mPopupView.findViewById(R.id.item_layout1); + view.setVisibility(View.VISIBLE); + view = mPopupView.findViewById(R.id.item_layout2); + view.setVisibility(View.VISIBLE); + view = mPopupView.findViewById(R.id.item_layout3); + view.setVisibility(View.GONE); + view = mPopupView.findViewById(R.id.plus_more); + view.setVisibility(View.GONE); + break; + case 4: + view = mPopupView.findViewById(R.id.item_layout1); + view.setVisibility(View.VISIBLE); + view = mPopupView.findViewById(R.id.item_layout2); + view.setVisibility(View.VISIBLE); + view = mPopupView.findViewById(R.id.item_layout3); + view.setVisibility(View.VISIBLE); + view = mPopupView.findViewById(R.id.plus_more); + view.setVisibility(View.GONE); + break; + default: + view = mPopupView.findViewById(R.id.item_layout1); + view.setVisibility(View.VISIBLE); + view = mPopupView.findViewById(R.id.item_layout2); + view.setVisibility(View.VISIBLE); + view = mPopupView.findViewById(R.id.item_layout3); + view.setVisibility(View.VISIBLE); + TextView tv = (TextView) mPopupView.findViewById(R.id.plus_more); + tv.setVisibility(View.VISIBLE); + String format = mResources.getString(R.string.plus_N_more); + String plusMore = String.format(format, eventIndex - 4); + tv.setText(plusMore); + break; + } + + if (eventIndex > 5) { + eventIndex = 5; + } + int popupHeight = 20 * eventIndex + 15; + mPopup.setHeight(popupHeight); + + if (mPreviousPopupHeight != popupHeight) { + mPreviousPopupHeight = popupHeight; + mPopup.dismiss(); + } + mPopup.showAtLocation(this, Gravity.BOTTOM | Gravity.LEFT, 0, 0); + postDelayed(mDismissPopup, POPUP_DISMISS_DELAY); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + long duration = event.getEventTime() - event.getDownTime(); + + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_CENTER: + if (mSelectionMode == SELECTION_HIDDEN) { + // Don't do anything unless the selection is visible. + break; + } + + if (mSelectionMode == SELECTION_PRESSED) { + // This was the first press when there was nothing selected. + // Change the selection from the "pressed" state to the + // the "selected" state. We treat short-press and + // long-press the same here because nothing was selected. + mSelectionMode = SELECTION_SELECTED; + mRedrawScreen = true; + invalidate(); + break; + } + + // Check the duration to determine if this was a short press + if (duration < ViewConfiguration.getLongPressTimeout()) { + long millis = getSelectedTimeInMillis(); + Utils.startActivity(getContext(), mDetailedView, millis); + mParentActivity.finish(); + } else { + mSelectionMode = SELECTION_LONGPRESS; + mRedrawScreen = true; + invalidate(); + performLongClick(); + } + } + return super.onKeyUp(keyCode, event); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (mSelectionMode == SELECTION_HIDDEN) { + if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT + || keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == KeyEvent.KEYCODE_DPAD_UP + || keyCode == KeyEvent.KEYCODE_DPAD_DOWN) { + // Display the selection box but don't move or select it + // on this key press. + mSelectionMode = SELECTION_SELECTED; + mRedrawScreen = true; + invalidate(); + return true; + } else if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER) { + // Display the selection box but don't select it + // on this key press. + mSelectionMode = SELECTION_PRESSED; + mRedrawScreen = true; + invalidate(); + return true; + } + } + + mSelectionMode = SELECTION_SELECTED; + boolean redraw = false; + Time other = null; + + switch (keyCode) { + case KeyEvent.KEYCODE_ENTER: + long millis = getSelectedTimeInMillis(); + Utils.startActivity(getContext(), mDetailedView, millis); + mParentActivity.finish(); + return true; + case KeyEvent.KEYCODE_DPAD_UP: + if (mCursor.up()) { + other = mOtherViewCalendar; + other.set(mViewCalendar); + other.month -= 1; + other.monthDay = mCursor.getSelectedDayOfMonth(); + + // restore the calendar cursor for the animation + mCursor.down(); + } + redraw = true; + break; + + case KeyEvent.KEYCODE_DPAD_DOWN: + if (mCursor.down()) { + other = mOtherViewCalendar; + other.set(mViewCalendar); + other.month += 1; + other.monthDay = mCursor.getSelectedDayOfMonth(); + + // restore the calendar cursor for the animation + mCursor.up(); + } + redraw = true; + break; + + case KeyEvent.KEYCODE_DPAD_LEFT: + if (mCursor.left()) { + other = mOtherViewCalendar; + other.set(mViewCalendar); + other.month -= 1; + other.monthDay = mCursor.getSelectedDayOfMonth(); + + // restore the calendar cursor for the animation + mCursor.right(); + } + redraw = true; + break; + + case KeyEvent.KEYCODE_DPAD_RIGHT: + if (mCursor.right()) { + other = mOtherViewCalendar; + other.set(mViewCalendar); + other.month += 1; + other.monthDay = mCursor.getSelectedDayOfMonth(); + + // restore the calendar cursor for the animation + mCursor.left(); + } + redraw = true; + break; + } + + if (other != null) { + other.normalize(true /* ignore DST */); + mNavigator.goTo(other); + } else if (redraw) { + mRedrawScreen = true; + invalidate(); + } + + return redraw; + } + + class DismissPopup implements Runnable { + public void run() { + mPopup.dismiss(); + } + } + + // This is called when the activity is paused so that the popup can + // be dismissed. + void dismissPopup() { + if (!mShowToast) { + return; + } + + // Protect against null-pointer exceptions + if (mPopup != null) { + mPopup.dismiss(); + } + + Handler handler = getHandler(); + if (handler != null) { + handler.removeCallbacks(mDismissPopup); + } + } +} diff --git a/src/com/android/calendar/Navigator.java b/src/com/android/calendar/Navigator.java new file mode 100644 index 00000000..e05a6033 --- /dev/null +++ b/src/com/android/calendar/Navigator.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2008 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.calendar; + +import android.text.format.Time; + +public interface Navigator { + /** + * Returns the time in millis of the selected event in this view. + * @return the selected time in UTC milliseconds. + */ + long getSelectedTime(); + + /** + * Changes the view to include the given time. + * @param time the desired time to view. + */ + void goTo(Time time); + + /** + * Changes the view to include today's date. + */ + void goToToday(); + + /** + * This is called when the user wants to create a new event and returns + * true if the new event should default to an all-day event. + * @return true if the new event should be an all-day event. + */ + boolean getAllDay(); +} diff --git a/src/com/android/calendar/SelectCalendarsActivity.java b/src/com/android/calendar/SelectCalendarsActivity.java new file mode 100644 index 00000000..44943eb8 --- /dev/null +++ b/src/com/android/calendar/SelectCalendarsActivity.java @@ -0,0 +1,279 @@ +/* + * Copyright (C) 2007 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.calendar; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.AsyncQueryHandler; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.DialogInterface; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.Calendar.Calendars; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.Window; +import android.view.MenuItem.OnMenuItemClickListener; +import android.widget.AdapterView; +import android.widget.CheckBox; +import android.widget.ListView; + + +public class SelectCalendarsActivity extends Activity implements ListView.OnItemClickListener { + + private static final String TAG = "Calendar"; + private View mView = null; + private Cursor mCursor = null; + private QueryHandler mQueryHandler; + private SelectCalendarsAdapter mAdapter; + private static final String[] PROJECTION = new String[] { + Calendars._ID, + Calendars.DISPLAY_NAME, + Calendars.COLOR, + Calendars.SELECTED, + Calendars.SYNC_EVENTS + }; + + @Override + protected void onCreate(Bundle icicle) { + super.onCreate(icicle); + requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); + setContentView(R.layout.calendars_activity); + getWindow().setFeatureInt(Window.FEATURE_INDETERMINATE_PROGRESS, + Window.PROGRESS_INDETERMINATE_ON); + mQueryHandler = new QueryHandler(getContentResolver()); + mView = findViewById(R.id.calendars); + ListView items = (ListView) mView.findViewById(R.id.items); + Context context = mView.getContext(); + mCursor = managedQuery(Calendars.CONTENT_URI, PROJECTION, + Calendars.SYNC_EVENTS + "=1", + null /* selectionArgs */, + Calendars.DEFAULT_SORT_ORDER); + + mAdapter = new SelectCalendarsAdapter(context, mCursor); + items.setAdapter(mAdapter); + items.setOnItemClickListener(this); + + // Start a background sync to get the list of calendars from the server. + startCalendarSync(); + } + + @Override + public void onPause() { + super.onPause(); + } + + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + CheckBox box = (CheckBox) view.findViewById(R.id.checkbox); + box.toggle(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + MenuItem item; + item = menu.add(0, 0, 0, R.string.add_calendars) + .setOnMenuItemClickListener(new ChangeCalendarAction(false /* not remove */)); + item.setIcon(android.R.drawable.ic_menu_add); + + item = menu.add(0, 0, 0, R.string.remove_calendars) + .setOnMenuItemClickListener(new ChangeCalendarAction(true /* remove */)); + item.setIcon(android.R.drawable.ic_menu_delete); + return true; + } + + /** + * ChangeCalendarAction is used both for adding and removing calendars. + * The constructor takes a boolean argument that is false if adding + * calendars and true if removing calendars. The user selects calendars + * to be added or removed from a pop-up list. + */ + public class ChangeCalendarAction implements OnMenuItemClickListener, + DialogInterface.OnClickListener, DialogInterface.OnMultiChoiceClickListener { + + int mNumItems; + long[] mCalendarIds; + boolean[] mIsChecked; + ContentResolver mContentResolver; + boolean mRemove; + + public ChangeCalendarAction(boolean remove) { + mContentResolver = SelectCalendarsActivity.this.getContentResolver(); + mRemove = remove; + } + + /* + * This is called when the user selects a calendar from either the + * "Add calendars" or "Remove calendars" popup dialog. + */ + public void onClick(DialogInterface dialog, int position, boolean isChecked) { + mIsChecked[position] = isChecked; + } + + /* + * This is called when the user presses the OK or Cancel button on the + * "Add calendars" or "Remove calendars" popup dialog. + */ + public void onClick(DialogInterface dialog, int which) { + // If the user cancelled the dialog, then do nothing. + if (which == DialogInterface.BUTTON2) { + return; + } + + boolean changesFound = false; + for (int position = 0; position < mNumItems; position++) { + // If this calendar wasn't selected, then skip it. + if (!mIsChecked[position]) { + continue; + } + changesFound = true; + + long id = mCalendarIds[position]; + Uri uri = ContentUris.withAppendedId(Calendars.CONTENT_URI, id); + ContentValues values = new ContentValues(); + int selected = 1; + if (mRemove) { + selected = 0; + } + values.put(Calendars.SELECTED, selected); + values.put(Calendars.SYNC_EVENTS, selected); + mContentResolver.update(uri, values, null, null); + } + + // If there were any changes, then update the list of calendars + // that are synced. + if (changesFound) { + mCursor.requery(); + } + } + + public boolean onMenuItemClick(MenuItem item) { + AlertDialog.Builder builder = new AlertDialog.Builder(SelectCalendarsActivity.this); + String selection; + if (mRemove) { + builder.setTitle(R.string.remove_calendars) + .setIcon(android.R.drawable.ic_dialog_alert); + selection = Calendars.SYNC_EVENTS + "=1"; + } else { + builder.setTitle(R.string.add_calendars); + selection = Calendars.SYNC_EVENTS + "=0"; + } + ContentResolver cr = getContentResolver(); + Cursor cursor = cr.query(Calendars.CONTENT_URI, PROJECTION, + selection, null /* selectionArgs */, + Calendars.DEFAULT_SORT_ORDER); + if (cursor == null) { + Log.w(TAG, "Cannot get cursor for calendars"); + return true; + } + + int count = cursor.getCount(); + mNumItems = count; + CharSequence[] calendarNames = new CharSequence[count]; + mCalendarIds = new long[count]; + mIsChecked = new boolean[count]; + try { + int pos = 0; + while (cursor.moveToNext()) { + mCalendarIds[pos] = cursor.getLong(0); + calendarNames[pos] = cursor.getString(1); + pos += 1; + } + } finally { + cursor.close(); + } + + builder.setMultiChoiceItems(calendarNames, null, this) + .setPositiveButton(android.R.string.ok, this) + .setNegativeButton(android.R.string.cancel, this) + .show(); + return true; + } + } + + private class QueryHandler extends AsyncQueryHandler { + public QueryHandler(ContentResolver cr) { + super(cr); + } + + @Override + protected void onQueryComplete(int token, Object cookie, Cursor cursor) { + getWindow().setFeatureInt(Window.FEATURE_INDETERMINATE_PROGRESS, + Window.PROGRESS_VISIBILITY_OFF); + + // If the Activity is finishing, then close the cursor. + // Otherwise, use the new cursor in the adapter. + if (isFinishing()) { + stopManagingCursor(cursor); + cursor.close(); + } else { + if (cursor.getCount() == 0) { + // There are no calendars. This might happen if we lost + // the wireless connection (in airplane mode, for example). + // Leave the current list of calendars alone and pop up + // a dialog explaining that the connection is down. + // But allow the user to add and remove calendars. + return; + } + if (mCursor != null) { + stopManagingCursor(mCursor); + } + mCursor = cursor; + startManagingCursor(cursor); + mAdapter.changeCursor(cursor); + } + } + } + + // This class implements the menu option "Refresh list from server". + // (No longer used.) + public class RefreshAction implements Runnable { + public void run() { + startCalendarSync(); + } + } + + // startCalendarSync() checks the server for an updated list of Calendars + // (in the background) using an AsyncQueryHandler. + // + // Calendars are never removed from the phone due to a server sync. + // But if a Calendar is added on the web (and it is selected and not + // hidden) then it will be added to the list of calendars on the phone + // (when this asynchronous query finishes). When a new calendar from the + // web is added to the phone, then the events for that calendar are also + // downloaded from the web. + // + // This sync is done automatically in the background when the + // SelectCalendars activity is started. + private void startCalendarSync() { + getWindow().setFeatureInt(Window.FEATURE_INDETERMINATE_PROGRESS, + Window.PROGRESS_VISIBILITY_ON); + + // TODO: make sure the user has login info. + + Uri uri = Calendars.LIVE_CONTENT_URI; + mQueryHandler.startQuery(0, null, uri, PROJECTION, + Calendars.SYNC_EVENTS + "=1", + null, Calendars.DEFAULT_SORT_ORDER); + } +} diff --git a/src/com/android/calendar/SelectCalendarsAdapter.java b/src/com/android/calendar/SelectCalendarsAdapter.java new file mode 100644 index 00000000..fb50662a --- /dev/null +++ b/src/com/android/calendar/SelectCalendarsAdapter.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2007 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.calendar; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.net.Uri; +import android.provider.Calendar.Calendars; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.CursorAdapter; +import android.widget.TextView; + +public class SelectCalendarsAdapter extends CursorAdapter { + + private static final int CLEAR_ALPHA_MASK = 0x00FFFFFF; + private static final int HIGH_ALPHA = 255 << 24; + private static final int MED_ALPHA = 180 << 24; + private static final int LOW_ALPHA = 150 << 24; + + /* The corner should be rounded on the top right and bottom right */ + private static final float[] CORNERS = new float[] {0, 0, 5, 5, 5, 5, 0, 0}; + + private static final String TAG = "Calendar"; + + private final LayoutInflater mInflater; + private final ContentResolver mResolver; + private final ContentValues mValues = new ContentValues(); + + private class CheckBoxListener implements CheckBox.OnCheckedChangeListener { + private final long mCalendarId; + + private CheckBoxListener(long calendarId) { + mCalendarId = calendarId; + } + + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + Uri uri = ContentUris.withAppendedId(Calendars.CONTENT_URI, mCalendarId); + mValues.clear(); + int checked = isChecked ? 1 : 0; + mValues.put(Calendars.SELECTED, checked); + mResolver.update(uri, mValues, null, null); + } + } + + public SelectCalendarsAdapter(Context context, Cursor cursor) { + super(context, cursor); + mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + mResolver = context.getContentResolver(); + } + + @Override + public View newView(Context context, Cursor cursor, ViewGroup parent) { + return mInflater.inflate(R.layout.calendar_item, parent, false); + } + + @Override + public void bindView(View view, Context context, Cursor cursor) { + int idColumn = cursor.getColumnIndexOrThrow(Calendars._ID); + int nameColumn = cursor.getColumnIndexOrThrow(Calendars.DISPLAY_NAME); + int selectedColumn = cursor.getColumnIndexOrThrow(Calendars.SELECTED); + int colorColumn = cursor.getColumnIndexOrThrow(Calendars.COLOR); + view.findViewById(R.id.color).setBackgroundDrawable(getColorChip(cursor.getInt(colorColumn))); + setText(view, R.id.calendar, cursor.getString(nameColumn)); + CheckBox box = (CheckBox) view.findViewById(R.id.checkbox); + long id = cursor.getLong(idColumn); + boolean checked = cursor.getInt(selectedColumn) != 0; + box.setOnCheckedChangeListener(null); + box.setChecked(checked); + box.setOnCheckedChangeListener(new CheckBoxListener(id)); + } + + private static void setText(View view, int id, String text) { + if (TextUtils.isEmpty(text)) { + return; + } + TextView textView = (TextView) view.findViewById(id); + textView.setText(text); + } + + private Drawable getColorChip(int color) { + + /* + * We want the color chip to have a nice gradient using + * the color of the calendar. To do this we use a GradientDrawable. + * The color supplied has an alpha of FF so we first do: + * color & 0x00FFFFFF + * to clear the alpha. Then we add our alpha to it. + * We use 3 colors to get a step effect where it starts off very + * light and quickly becomes dark and then a slow transition to + * be even darker. + */ + color &= CLEAR_ALPHA_MASK; + int startColor = color | HIGH_ALPHA; + int middleColor = color | MED_ALPHA; + int endColor = color | LOW_ALPHA; + int[] colors = new int[] {startColor, middleColor, endColor}; + GradientDrawable d = new GradientDrawable(GradientDrawable.Orientation.LEFT_RIGHT, colors); + d.setCornerRadii(CORNERS); + return d; + } +} diff --git a/src/com/android/calendar/Utils.java b/src/com/android/calendar/Utils.java new file mode 100644 index 00000000..53c584cb --- /dev/null +++ b/src/com/android/calendar/Utils.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2006 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.calendar; + +import static android.provider.Calendar.EVENT_BEGIN_TIME; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.text.format.Time; +import android.view.animation.AlphaAnimation; +import android.widget.ViewFlipper; + +public class Utils { + public static void startActivity(Context context, String className, long time) { + Intent intent = new Intent(Intent.ACTION_VIEW); + + intent.setClassName(context, className); + intent.putExtra(EVENT_BEGIN_TIME, time); + + context.startActivity(intent); + } + + public static final Time timeFromIntent(Intent intent) { + Time time = new Time(); + time.set(timeFromIntentInMillis(intent)); + return time; + } + + /** + * If the given intent specifies a time (in milliseconds since the epoch), + * then that time is returned. Otherwise, the current time is returned. + */ + public static final long timeFromIntentInMillis(Intent intent) { + // If the time was specified, then use that. Otherwise, use the current time. + long millis = intent.getLongExtra(EVENT_BEGIN_TIME, -1); + if (millis == -1) { + millis = System.currentTimeMillis(); + } + return millis; + } + + public static final void applyAlphaAnimation(ViewFlipper v) { + AlphaAnimation in = new AlphaAnimation(0.0f, 1.0f); + + in.setStartOffset(0); + in.setDuration(500); + + AlphaAnimation out = new AlphaAnimation(1.0f, 0.0f); + + out.setStartOffset(0); + out.setDuration(500); + + v.setInAnimation(in); + v.setOutAnimation(out); + } + + /** + * Formats the given Time object so that it gives the month and year + * (for example, "September 2007"). + * + * @param time the time to format + * @return the string containing the weekday and the date + */ + public static String formatMonthYear(Time time) { + Resources res = Resources.getSystem(); + return time.format(res.getString(com.android.internal.R.string.month_year)); + } + + // TODO: replace this with the correct i18n way to do this + public static final String englishNthDay[] = { + "", "1st", "2nd", "3rd", "4th", "5th", "6th", "7th", "8th", "9th", + "10th", "11th", "12th", "13th", "14th", "15th", "16th", "17th", "18th", "19th", + "20th", "21st", "22nd", "23rd", "24th", "25th", "26th", "27th", "28th", "29th", + "30th", "31st" + }; + + public static String formatNth(int nth) { + return "the " + englishNthDay[nth]; + } + + /** + * Sets the time to the beginning of the day (midnight) by clearing the + * hour, minute, and second fields. + */ + static void setTimeToStartOfDay(Time time) { + time.second = 0; + time.minute = 0; + time.hour = 0; + } +} diff --git a/src/com/android/calendar/WeekActivity.java b/src/com/android/calendar/WeekActivity.java new file mode 100644 index 00000000..0e06b518 --- /dev/null +++ b/src/com/android/calendar/WeekActivity.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2006 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.calendar; + +import android.content.SharedPreferences; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.view.View; +import android.view.ViewGroup.LayoutParams; +import android.widget.ProgressBar; +import android.widget.ViewSwitcher; + +public class WeekActivity extends CalendarActivity implements ViewSwitcher.ViewFactory { + /** + * The view id used for all the views we create. It's OK to have all child + * views have the same ID. This ID is used to pick which view receives + * focus when a view hierarchy is saved / restore + */ + private static final int VIEW_ID = 1; + + @Override + protected void onCreate(Bundle icicle) { + super.onCreate(icicle); + + setContentView(R.layout.week_activity); + + mSelectedDay = Utils.timeFromIntent(getIntent()); + mViewSwitcher = (ViewSwitcher) findViewById(R.id.switcher); + mViewSwitcher.setFactory(this); + mViewSwitcher.getCurrentView().requestFocus(); + mProgressBar = (ProgressBar) findViewById(R.id.progress_circular); + } + + public View makeView() { + WeekView wv = new WeekView(this); + wv.setId(VIEW_ID); + wv.setLayoutParams(new ViewSwitcher.LayoutParams( + LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT)); + wv.setSelectedDay(mSelectedDay); + return wv; + } + + @Override + protected void onResume() { + super.onResume(); + + CalendarView view1 = (CalendarView) mViewSwitcher.getCurrentView(); + CalendarView view2 = (CalendarView) mViewSwitcher.getNextView(); + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + + String str = prefs.getString(CalendarPreferenceActivity.KEY_DETAILED_VIEW, + CalendarPreferenceActivity.DEFAULT_DETAILED_VIEW); + view1.setDetailedView(str); + view2.setDetailedView(str); + + // Record Week View as the (new) start view + String activityString = CalendarApplication.ACTIVITY_NAMES[CalendarApplication.WEEK_VIEW_ID]; + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(CalendarPreferenceActivity.KEY_START_VIEW, activityString); + editor.commit(); + } + + @Override + protected void onPause() { + super.onPause(); + CalendarView view = (CalendarView) mViewSwitcher.getCurrentView(); + mSelectedDay = view.getSelectedDay(); + } +} diff --git a/src/com/android/calendar/WeekView.java b/src/com/android/calendar/WeekView.java new file mode 100644 index 00000000..b0b32445 --- /dev/null +++ b/src/com/android/calendar/WeekView.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2006 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.calendar; + + +public class WeekView extends CalendarView { + private static final int CELL_MARGIN = 0; + + public WeekView(CalendarActivity activity) { + super(activity); + init(); + } + + private void init() { + mDrawTextInEventRect = false; + mNumDays = 7; + mEventGeometry.setCellMargin(CELL_MARGIN); + } +} |