ConversationItemView.java revision 1152c02a415f5b93bdecc5733a7372dca7d9ecd3
1/*
2 * Copyright (C) 2012 Google Inc.
3 * Licensed to The Android Open Source Project.
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 *      http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18package com.android.mail.browse;
19
20import android.animation.Animator;
21import android.animation.Animator.AnimatorListener;
22import android.animation.AnimatorSet;
23import android.animation.ObjectAnimator;
24import android.content.ClipData;
25import android.content.ClipData.Item;
26import android.content.Context;
27import android.content.res.Resources;
28import android.database.Cursor;
29import android.graphics.Bitmap;
30import android.graphics.BitmapFactory;
31import android.graphics.Canvas;
32import android.graphics.Color;
33import android.graphics.LinearGradient;
34import android.graphics.Paint;
35import android.graphics.Point;
36import android.graphics.Rect;
37import android.graphics.Shader;
38import android.graphics.Typeface;
39import android.graphics.drawable.Drawable;
40import android.text.Layout.Alignment;
41import android.text.Spannable;
42import android.text.SpannableString;
43import android.text.SpannableStringBuilder;
44import android.text.StaticLayout;
45import android.text.TextPaint;
46import android.text.TextUtils;
47import android.text.TextUtils.TruncateAt;
48import android.text.format.DateUtils;
49import android.text.style.CharacterStyle;
50import android.text.style.ForegroundColorSpan;
51import android.text.style.StyleSpan;
52import android.util.SparseArray;
53import android.view.HapticFeedbackConstants;
54import android.view.MotionEvent;
55import android.view.View;
56import android.view.ViewConfiguration;
57import android.view.animation.DecelerateInterpolator;
58import android.widget.ListView;
59
60import com.android.mail.R;
61import com.android.mail.browse.ConversationItemViewModel.SenderFragment;
62import com.android.mail.perf.Timer;
63import com.android.mail.providers.Conversation;
64import com.android.mail.providers.Folder;
65import com.android.mail.providers.UIProvider;
66import com.android.mail.providers.UIProvider.ConversationColumns;
67import com.android.mail.ui.AnimatedAdapter;
68import com.android.mail.ui.ConversationSelectionSet;
69import com.android.mail.ui.FolderDisplayer;
70import com.android.mail.ui.SwipeableItemView;
71import com.android.mail.ui.ViewMode;
72import com.android.mail.utils.LogTag;
73import com.android.mail.utils.LogUtils;
74import com.android.mail.utils.Utils;
75import com.google.common.annotations.VisibleForTesting;
76
77public class ConversationItemView extends View implements SwipeableItemView {
78    // Timer.
79    private static int sLayoutCount = 0;
80    private static Timer sTimer; // Create the sTimer here if you need to do
81                                 // perf analysis.
82    private static final int PERF_LAYOUT_ITERATIONS = 50;
83    private static final String PERF_TAG_LAYOUT = "CCHV.layout";
84    private static final String PERF_TAG_CALCULATE_TEXTS_BITMAPS = "CCHV.txtsbmps";
85    private static final String PERF_TAG_CALCULATE_SENDER_SUBJECT = "CCHV.sendersubj";
86    private static final String PERF_TAG_CALCULATE_FOLDERS = "CCHV.folders";
87    private static final String PERF_TAG_CALCULATE_COORDINATES = "CCHV.coordinates";
88    private static final String LOG_TAG = LogTag.getLogTag();
89
90    // Static bitmaps.
91    private static Bitmap CHECKMARK_OFF;
92    private static Bitmap CHECKMARK_ON;
93    private static Bitmap STAR_OFF;
94    private static Bitmap STAR_ON;
95    private static Bitmap ATTACHMENT;
96    private static Bitmap ONLY_TO_ME;
97    private static Bitmap TO_ME_AND_OTHERS;
98    private static Bitmap IMPORTANT_ONLY_TO_ME;
99    private static Bitmap IMPORTANT_TO_ME_AND_OTHERS;
100    private static Bitmap IMPORTANT_TO_OTHERS;
101    private static Bitmap DATE_BACKGROUND;
102    private static Bitmap STATE_REPLIED;
103    private static Bitmap STATE_FORWARDED;
104    private static Bitmap STATE_REPLIED_AND_FORWARDED;
105    private static Bitmap STATE_CALENDAR_INVITE;
106
107    private static String SENDERS_SPLIT_TOKEN;
108
109    // Static colors.
110    private static int DEFAULT_TEXT_COLOR;
111    private static int ACTIVATED_TEXT_COLOR;
112    private static int SUBJECT_TEXT_COLOR_READ;
113    private static int SUBJECT_TEXT_COLOR_UNREAD;
114    private static int SNIPPET_TEXT_COLOR_READ;
115    private static int SNIPPET_TEXT_COLOR_UNREAD;
116    private static int SENDERS_TEXT_COLOR_READ;
117    private static int SENDERS_TEXT_COLOR_UNREAD;
118    private static int DATE_TEXT_COLOR_READ;
119    private static int DATE_TEXT_COLOR_UNREAD;
120    private static int DATE_BACKGROUND_PADDING_LEFT;
121    private static int TOUCH_SLOP;
122    private static int sDateBackgroundHeight;
123    private static int sStandardScaledDimen;
124    private static int sUndoAnimationDuration;
125    protected static CharacterStyle sNormalTextStyle;
126
127    // Static paints.
128    private static TextPaint sPaint = new TextPaint();
129    private static TextPaint sFoldersPaint = new TextPaint();
130
131    // Backgrounds for different states.
132    private final SparseArray<Drawable> mBackgrounds = new SparseArray<Drawable>();
133
134    // Dimensions and coordinates.
135    private int mViewWidth = -1;
136    private int mMode = -1;
137    private int mDateX;
138    private int mPaperclipX;
139    private int mFoldersXEnd;
140    private int mSendersWidth;
141
142    /** Whether we're running under test mode. */
143    private boolean mTesting = false;
144    /** Whether we are on a tablet device or not */
145    private final boolean mTabletDevice;
146
147    @VisibleForTesting
148    ConversationItemViewCoordinates mCoordinates;
149
150    private final Context mContext;
151
152    public ConversationItemViewModel mHeader;
153    private ViewMode mViewMode;
154    private boolean mDownEvent;
155    private boolean mChecked = false;
156    private static int sFadedActivatedColor = -1;
157    private ConversationSelectionSet mSelectedConversationSet;
158    private Folder mDisplayedFolder;
159    private boolean mPriorityMarkersEnabled;
160    private boolean mCheckboxesEnabled;
161    private CheckForTap mPendingCheckForTap;
162    private CheckForLongPress mPendingCheckForLongPress;
163    private boolean mSwipeEnabled;
164    private int mLastTouchX;
165    private int mLastTouchY;
166    private AnimatedAdapter mAdapter;
167    private int mAnimatedHeight = -1;
168    private String mAccount;
169    private Bitmap sDateBackgroundAttachment;
170    private Bitmap sDateBackgroundNoAttachment;
171    private static int sUndoAnimationOffset;
172    private static CharSequence sDraftSingularString;
173    private static CharSequence sDraftPluralString;
174    private static String sDraftCountFormatString;
175    private static ForegroundColorSpan sDraftsStyleSpan;
176    private static Bitmap MORE_FOLDERS;
177
178    static {
179        sPaint.setAntiAlias(true);
180        sFoldersPaint.setAntiAlias(true);
181    }
182
183    /**
184     * Handles displaying folders in a conversation header view.
185     */
186    static class ConversationItemFolderDisplayer extends FolderDisplayer {
187        // Maximum number of folders to be displayed.
188        private static final int MAX_DISPLAYED_FOLDERS_COUNT = 4;
189
190        private int mFoldersCount;
191        private boolean mHasMoreFolders;
192
193        public ConversationItemFolderDisplayer(Context context) {
194            super(context);
195        }
196
197        @Override
198        public void loadConversationFolders(Conversation conv, Folder ignoreFolder) {
199            super.loadConversationFolders(conv, ignoreFolder);
200
201            mFoldersCount = mFoldersSortedSet.size();
202            mHasMoreFolders = mFoldersCount > MAX_DISPLAYED_FOLDERS_COUNT;
203            mFoldersCount = Math.min(mFoldersCount, MAX_DISPLAYED_FOLDERS_COUNT);
204        }
205
206        public boolean hasVisibleFolders() {
207            return mFoldersCount > 0;
208        }
209
210        private int measureFolders(int mode) {
211            int availableSpace = ConversationItemViewCoordinates.getFoldersWidth(mContext, mode);
212            int cellSize = ConversationItemViewCoordinates.getFolderCellWidth(mContext, mode,
213                    mFoldersCount);
214
215            int totalWidth = 0;
216            for (Folder f : mFoldersSortedSet) {
217                final String folderString = f.name;
218                int width = (int) sFoldersPaint.measureText(folderString) + cellSize;
219                if (width % cellSize != 0) {
220                    width += cellSize - (width % cellSize);
221                }
222                totalWidth += width;
223                if (totalWidth > availableSpace) {
224                    break;
225                }
226            }
227
228            return totalWidth;
229        }
230
231        public void drawFolders(Canvas canvas, ConversationItemViewCoordinates coordinates,
232                int foldersXEnd, int mode) {
233            if (mFoldersCount == 0) {
234                return;
235            }
236
237            int xEnd = foldersXEnd;
238            int y = coordinates.foldersY - coordinates.foldersAscent;
239            int height = coordinates.foldersHeight;
240            int topPadding = coordinates.foldersTopPadding;
241            int ascent = coordinates.foldersAscent;
242            sFoldersPaint.setTextSize(coordinates.foldersFontSize);
243
244            // Initialize space and cell size based on the current mode.
245            int availableSpace = ConversationItemViewCoordinates.getFoldersWidth(mContext, mode);
246            int averageWidth = availableSpace / mFoldersCount;
247            int cellSize = ConversationItemViewCoordinates.getFolderCellWidth(mContext, mode,
248                    mFoldersCount);
249
250            // First pass to calculate the starting point.
251            int totalWidth = measureFolders(mode);
252            int xStart = xEnd - Math.min(availableSpace, totalWidth);
253
254            // Second pass to draw folders.
255            for (Folder f : mFoldersSortedSet) {
256                final String folderString = f.name;
257                final int fgColor = f.getForegroundColor(mDefaultFgColor);
258                final int bgColor = f.getBackgroundColor(mDefaultBgColor);
259                int width = cellSize;
260                boolean labelTooLong = false;
261                width = (int) sFoldersPaint.measureText(folderString) + cellSize;
262                if (width % cellSize != 0) {
263                    width += cellSize - (width % cellSize);
264                }
265                if (totalWidth > availableSpace && width > averageWidth) {
266                    width = averageWidth;
267                    labelTooLong = true;
268                }
269
270                // TODO (mindyp): how to we get this?
271                final boolean isMuted = false;
272                // labelValues.folderId ==
273                // sGmail.getFolderMap(mAccount).getFolderIdIgnored();
274
275                // Draw the box.
276                sFoldersPaint.setColor(bgColor);
277                sFoldersPaint.setStyle(isMuted ? Paint.Style.STROKE : Paint.Style.FILL_AND_STROKE);
278                canvas.drawRect(xStart, y + ascent, xStart + width, y + ascent + height,
279                        sFoldersPaint);
280
281                // Draw the text.
282                int padding = getPadding(width, (int) sFoldersPaint.measureText(folderString));
283                if (labelTooLong) {
284                    TextPaint shortPaint = new TextPaint();
285                    shortPaint.setColor(fgColor);
286                    shortPaint.setTextSize(coordinates.foldersFontSize);
287                    padding = cellSize / 2;
288                    int rightBorder = xStart + width - padding;
289                    Shader shader = new LinearGradient(rightBorder - padding, y, rightBorder, y,
290                            fgColor, Utils.getTransparentColor(fgColor), Shader.TileMode.CLAMP);
291                    shortPaint.setShader(shader);
292                    canvas.drawText(folderString, xStart + padding, y + topPadding, shortPaint);
293                } else {
294                    sFoldersPaint.setColor(fgColor);
295                    canvas.drawText(folderString, xStart + padding, y + topPadding, sFoldersPaint);
296                }
297
298                availableSpace -= width;
299                xStart += width;
300                if (availableSpace <= 0 && mHasMoreFolders) {
301                    canvas.drawBitmap(MORE_FOLDERS, xEnd, y + ascent, sFoldersPaint);
302                    return;
303                }
304            }
305        }
306    }
307
308    /**
309     * Helpers function to align an element in the center of a space.
310     */
311    private static int getPadding(int space, int length) {
312        return (space - length) / 2;
313    }
314
315    public ConversationItemView(Context context, String account) {
316        super(context);
317        mContext = context.getApplicationContext();
318        mTabletDevice = Utils.useTabletUI(mContext);
319        mAccount = account;
320        Resources res = mContext.getResources();
321
322        if (CHECKMARK_OFF == null) {
323            // Initialize static bitmaps.
324            CHECKMARK_OFF = BitmapFactory.decodeResource(res,
325                    R.drawable.btn_check_off_normal_holo_light);
326            CHECKMARK_ON = BitmapFactory.decodeResource(res,
327                    R.drawable.btn_check_on_normal_holo_light);
328            STAR_OFF = BitmapFactory.decodeResource(res,
329                    R.drawable.btn_star_off_normal_email_holo_light);
330            STAR_ON = BitmapFactory.decodeResource(res,
331                    R.drawable.btn_star_on_normal_email_holo_light);
332            ONLY_TO_ME = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_double);
333            TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_single);
334            IMPORTANT_ONLY_TO_ME = BitmapFactory.decodeResource(res,
335                    R.drawable.ic_email_caret_double_important_unread);
336            IMPORTANT_TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res,
337                    R.drawable.ic_email_caret_single_important_unread);
338            IMPORTANT_TO_OTHERS = BitmapFactory.decodeResource(res,
339                    R.drawable.ic_email_caret_none_important_unread);
340            ATTACHMENT = BitmapFactory.decodeResource(res, R.drawable.ic_attachment_holo_light);
341            MORE_FOLDERS = BitmapFactory.decodeResource(res, R.drawable.ic_folders_more);
342            DATE_BACKGROUND = BitmapFactory.decodeResource(res, R.drawable.folder_bg_holo_light);
343            STATE_REPLIED =
344                    BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_holo_light);
345            STATE_FORWARDED =
346                    BitmapFactory.decodeResource(res, R.drawable.ic_badge_forward_holo_light);
347            STATE_REPLIED_AND_FORWARDED =
348                    BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_forward_holo_light);
349            STATE_CALENDAR_INVITE =
350                    BitmapFactory.decodeResource(res, R.drawable.ic_badge_invite_holo_light);
351
352            // Initialize colors.
353            DEFAULT_TEXT_COLOR = res.getColor(R.color.default_text_color);
354            ACTIVATED_TEXT_COLOR = res.getColor(android.R.color.white);
355            SUBJECT_TEXT_COLOR_READ = res.getColor(R.color.subject_text_color_read);
356            SUBJECT_TEXT_COLOR_UNREAD = res.getColor(R.color.subject_text_color_unread);
357            SNIPPET_TEXT_COLOR_READ = res.getColor(R.color.snippet_text_color_read);
358            SNIPPET_TEXT_COLOR_UNREAD = res.getColor(R.color.snippet_text_color_unread);
359            SENDERS_TEXT_COLOR_READ = res.getColor(R.color.senders_text_color_read);
360            SENDERS_TEXT_COLOR_UNREAD = res.getColor(R.color.senders_text_color_unread);
361            DATE_TEXT_COLOR_READ = res.getColor(R.color.date_text_color_read);
362            DATE_TEXT_COLOR_UNREAD = res.getColor(R.color.date_text_color_unread);
363            DATE_BACKGROUND_PADDING_LEFT = res
364                    .getDimensionPixelSize(R.dimen.date_background_padding_left);
365            TOUCH_SLOP = res.getDimensionPixelSize(R.dimen.touch_slop);
366            sDateBackgroundHeight = res.getDimensionPixelSize(R.dimen.date_background_height);
367            sStandardScaledDimen = res.getDimensionPixelSize(R.dimen.standard_scaled_dimen);
368            sUndoAnimationDuration = res.getInteger(R.integer.undo_animation_duration);
369            sUndoAnimationOffset = res.getDimensionPixelOffset(R.dimen.undo_animation_offset);
370            // Initialize static color.
371            sNormalTextStyle = new StyleSpan(Typeface.NORMAL);
372            SENDERS_SPLIT_TOKEN = res.getString(R.string.senders_split_token);
373        }
374    }
375
376    public void bind(Cursor cursor, ViewMode viewMode, ConversationSelectionSet set, Folder folder,
377            boolean checkboxesDisabled, boolean swipeEnabled, AnimatedAdapter adapter) {
378        bind(ConversationItemViewModel.forCursor(mAccount, cursor), viewMode, set, folder,
379                checkboxesDisabled, swipeEnabled, adapter);
380    }
381
382    public void bind(Conversation conversation, ViewMode viewMode, ConversationSelectionSet set,
383            Folder folder, boolean checkboxesDisabled, boolean swipeEnabled,
384            AnimatedAdapter adapter) {
385        bind(ConversationItemViewModel.forConversation(mAccount, conversation), viewMode, set,
386                folder, checkboxesDisabled, swipeEnabled, adapter);
387    }
388
389    private void bind(ConversationItemViewModel header, ViewMode viewMode,
390            ConversationSelectionSet set, Folder folder, boolean checkboxesDisabled,
391            boolean swipeEnabled, AnimatedAdapter adapter) {
392        mViewMode = viewMode;
393        mHeader = header;
394        mSelectedConversationSet = set;
395        mDisplayedFolder = folder;
396        mCheckboxesEnabled = !checkboxesDisabled;
397        mSwipeEnabled = swipeEnabled;
398        mAdapter = adapter;
399        setContentDescription(mHeader.getContentDescription(mContext));
400        requestLayout();
401    }
402
403    /**
404     * Get the Conversation object associated with this view.
405     */
406    public Conversation getConversation() {
407        return mHeader.conversation;
408    }
409
410    /**
411     * Sets the mode. Only used for testing.
412     */
413    @VisibleForTesting
414    void setMode(int mode) {
415        mMode = mode;
416        mTesting = true;
417    }
418
419    private static void startTimer(String tag) {
420        if (sTimer != null) {
421            sTimer.start(tag);
422        }
423    }
424
425    private static void pauseTimer(String tag) {
426        if (sTimer != null) {
427            sTimer.pause(tag);
428        }
429    }
430
431    @Override
432    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
433        startTimer(PERF_TAG_LAYOUT);
434
435        super.onLayout(changed, left, top, right, bottom);
436
437        int width = right - left;
438        if (width != mViewWidth) {
439            mViewWidth = width;
440            if (!mTesting) {
441                mMode = ConversationItemViewCoordinates.getMode(mContext, mViewMode);
442            }
443        }
444        mHeader.viewWidth = mViewWidth;
445        Resources res = getResources();
446        mHeader.standardScaledDimen = res.getDimensionPixelOffset(R.dimen.standard_scaled_dimen);
447        if (mHeader.standardScaledDimen != sStandardScaledDimen) {
448            // Large Text has been toggle on/off. Update the static dimens.
449            sStandardScaledDimen = mHeader.standardScaledDimen;
450            ConversationItemViewCoordinates.refreshConversationHeights(mContext);
451            sDateBackgroundHeight = res.getDimensionPixelSize(R.dimen.date_background_height);
452        }
453        mCoordinates = ConversationItemViewCoordinates.forWidth(mContext, mViewWidth, mMode,
454                mHeader.standardScaledDimen);
455        calculateTextsAndBitmaps();
456        calculateCoordinates();
457        mHeader.validate(mContext);
458
459        pauseTimer(PERF_TAG_LAYOUT);
460        if (sTimer != null && ++sLayoutCount >= PERF_LAYOUT_ITERATIONS) {
461            sTimer.dumpResults();
462            sTimer = new Timer();
463            sLayoutCount = 0;
464        }
465    }
466
467    @Override
468    public void setBackgroundResource(int resourceId) {
469        Drawable drawable = mBackgrounds.get(resourceId);
470        if (drawable == null) {
471            drawable = getResources().getDrawable(resourceId);
472            mBackgrounds.put(resourceId, drawable);
473        }
474        if (getBackground() != drawable) {
475            super.setBackgroundDrawable(drawable);
476        }
477    }
478
479    private void calculateTextsAndBitmaps() {
480        startTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS);
481        if (mSelectedConversationSet != null) {
482            mChecked = mSelectedConversationSet.contains(mHeader.conversation);
483        }
484        // Update font color.
485        int fontColor = getFontColor(DEFAULT_TEXT_COLOR);
486        boolean fontChanged = false;
487        if (mHeader.fontColor != fontColor) {
488            fontChanged = true;
489            mHeader.fontColor = fontColor;
490        }
491
492        boolean isUnread = mHeader.unread;
493
494        final boolean checkboxEnabled = mCheckboxesEnabled;
495        if (mHeader.checkboxVisible != checkboxEnabled) {
496            mHeader.checkboxVisible = checkboxEnabled;
497        }
498
499        // Update background.
500        updateBackground(isUnread);
501
502        if (mHeader.isLayoutValid(mContext)) {
503            // Relayout subject if font color has changed.
504            if (fontChanged) {
505                layoutSubjectSpans(isUnread);
506                layoutSubject();
507            }
508            pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS);
509            return;
510        }
511
512        startTimer(PERF_TAG_CALCULATE_FOLDERS);
513
514        // Initialize folder displayer.
515        if (mCoordinates.showFolders) {
516            mHeader.folderDisplayer = new ConversationItemFolderDisplayer(mContext);
517            mHeader.folderDisplayer.loadConversationFolders(mHeader.conversation, mDisplayedFolder);
518        }
519
520        pauseTimer(PERF_TAG_CALCULATE_FOLDERS);
521
522        mHeader.dateText = DateUtils.getRelativeTimeSpanString(mContext,
523                mHeader.conversation.dateMs).toString();
524
525        // Paper clip icon.
526        mHeader.paperclip = null;
527        if (mHeader.conversation.hasAttachments) {
528            mHeader.paperclip = ATTACHMENT;
529        }
530        // Personal level.
531        mHeader.personalLevelBitmap = null;
532        if (mCoordinates.showPersonalLevel) {
533            int personalLevel = mHeader.personalLevel;
534            final boolean isImportant =
535                    mHeader.priority == UIProvider.ConversationPriority.IMPORTANT;
536            // TODO(mindyp): get whether importance indicators are enabled
537            // mPriorityMarkersEnabled =
538            // persistence.getPriorityInboxArrowsEnabled(mContext, mAccount);
539            mPriorityMarkersEnabled = true;
540            boolean useImportantMarkers = isImportant && mPriorityMarkersEnabled;
541
542            if (personalLevel == UIProvider.ConversationPersonalLevel.ONLY_TO_ME) {
543                mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_ONLY_TO_ME
544                        : ONLY_TO_ME;
545            } else if (personalLevel == UIProvider.ConversationPersonalLevel.TO_ME_AND_OTHERS) {
546                mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_TO_ME_AND_OTHERS
547                        : TO_ME_AND_OTHERS;
548            } else if (useImportantMarkers) {
549                mHeader.personalLevelBitmap = IMPORTANT_TO_OTHERS;
550            }
551        }
552
553        startTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT);
554
555        // Subject.
556        layoutSubjectSpans(isUnread);
557
558        mHeader.sendersDisplayText = new SpannableStringBuilder();
559        mHeader.styledSendersString = new SpannableStringBuilder();
560
561        // Parse senders fragments.
562        if (mHeader.conversation.conversationInfo != null) {
563            mHeader.styledSenders = SendersView.format(getContext(),
564                    mHeader.conversation.conversationInfo);
565        } else {
566            mCoordinates.sendersView.formatSenders(mHeader, isUnread, mMode);
567        }
568
569        pauseTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT);
570        pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS);
571    }
572
573    private void layoutSubjectSpans(boolean isUnread) {
574        if (showActivatedText()) {
575            mHeader.subjectTextActivated = createSubject(isUnread, true);
576        }
577        mHeader.subjectText = createSubject(isUnread, false);
578    }
579
580    private SpannableStringBuilder createSubject(boolean isUnread, boolean activated) {
581        final String subject = filterTag(mHeader.conversation.subject);
582        final String snippet = mHeader.conversation.getSnippet();
583        int subjectColor = activated ? ACTIVATED_TEXT_COLOR : isUnread ? SUBJECT_TEXT_COLOR_UNREAD
584                : SUBJECT_TEXT_COLOR_READ;
585        int snippetColor = activated ? ACTIVATED_TEXT_COLOR : isUnread ? SNIPPET_TEXT_COLOR_UNREAD
586                : SNIPPET_TEXT_COLOR_READ;
587        SpannableStringBuilder subjectText = new SpannableStringBuilder(
588                (snippet != null) ? mContext.getString(R.string.subject_and_snippet, subject,
589                        snippet) : subject);
590        if (isUnread) {
591            subjectText.setSpan(new StyleSpan(Typeface.BOLD), 0, subject.length(),
592                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
593        }
594        subjectText.setSpan(new ForegroundColorSpan(subjectColor), 0, subject.length(),
595                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
596        if (snippet != null) {
597            subjectText.setSpan(new ForegroundColorSpan(snippetColor), subject.length() + 1,
598                    subjectText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
599        }
600        return subjectText;
601    }
602
603    private int getFontColor(int defaultColor) {
604        return isActivated() && mTabletDevice ? ACTIVATED_TEXT_COLOR
605                : defaultColor;
606    }
607
608    private boolean showActivatedText() {
609        return mTabletDevice;
610    }
611
612    private void layoutSubject() {
613        if (showActivatedText()) {
614            mHeader.subjectLayoutActivated =
615                    createSubjectLayout(true, mHeader.subjectTextActivated);
616        }
617        mHeader.subjectLayout = createSubjectLayout(false, mHeader.subjectText);
618    }
619
620    private StaticLayout createSubjectLayout(boolean activated,
621            SpannableStringBuilder subjectText) {
622        sPaint.setTextSize(mCoordinates.subjectFontSize);
623        sPaint.setColor(activated ? ACTIVATED_TEXT_COLOR
624                : mHeader.unread ? SUBJECT_TEXT_COLOR_UNREAD : SUBJECT_TEXT_COLOR_READ);
625        StaticLayout subjectLayout = new StaticLayout(subjectText, sPaint,
626                mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true);
627        int lineCount = subjectLayout.getLineCount();
628        if (mCoordinates.subjectLineCount < lineCount) {
629            int end = subjectLayout.getLineEnd(mCoordinates.subjectLineCount - 1);
630            subjectLayout = new StaticLayout(subjectText.subSequence(0, end), sPaint,
631                    mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true);
632        }
633        return subjectLayout;
634    }
635
636    private boolean canFitFragment(int width, int line, int fixedWidth) {
637        if (line == mCoordinates.sendersLineCount) {
638            return width + fixedWidth <= mSendersWidth;
639        } else {
640            return width <= mSendersWidth;
641        }
642    }
643
644    private void calculateCoordinates() {
645        startTimer(PERF_TAG_CALCULATE_COORDINATES);
646
647        sPaint.setTextSize(mCoordinates.dateFontSize);
648        sPaint.setTypeface(Typeface.DEFAULT);
649        mDateX = mCoordinates.dateXEnd - (int) sPaint.measureText(mHeader.dateText);
650
651        mPaperclipX = mDateX - ATTACHMENT.getWidth();
652
653        int cellWidth = mContext.getResources().getDimensionPixelSize(R.dimen.folder_cell_width);
654
655        if (ConversationItemViewCoordinates.isWideMode(mMode)) {
656            // Folders are displayed above the date.
657            mFoldersXEnd = mCoordinates.dateXEnd;
658            // In wide mode, the end of the senders should align with
659            // the start of the subject and is based on a max width.
660            mSendersWidth = mCoordinates.sendersWidth;
661        } else {
662            // In normal mode, the width is based on where the folders or date
663            // (or attachment icon) start.
664            if (mCoordinates.showFolders) {
665                if (mHeader.paperclip != null) {
666                    mFoldersXEnd = mPaperclipX;
667                } else {
668                    mFoldersXEnd = mDateX - cellWidth / 2;
669                }
670                mSendersWidth = mFoldersXEnd - mCoordinates.sendersX - 2 * cellWidth;
671                if (mHeader.folderDisplayer.hasVisibleFolders()) {
672                    mSendersWidth -= ConversationItemViewCoordinates.getFoldersWidth(mContext,
673                            mMode);
674                }
675            } else {
676                int dateAttachmentStart = 0;
677                // Have this end near the paperclip or date, not the folders.
678                if (mHeader.paperclip != null) {
679                    dateAttachmentStart = mPaperclipX;
680                } else {
681                    dateAttachmentStart = mDateX;
682                }
683                mSendersWidth = dateAttachmentStart - mCoordinates.sendersX - cellWidth;
684            }
685        }
686
687        if (mHeader.isLayoutValid(mContext)) {
688            pauseTimer(PERF_TAG_CALCULATE_COORDINATES);
689            return;
690        }
691
692        // Layout subject.
693        layoutSubject();
694
695        // First pass to calculate width of each fragment.
696        int totalWidth = 0;
697        int fixedWidth = 0;
698        sPaint.setTextSize(mCoordinates.sendersFontSize);
699        sPaint.setTypeface(Typeface.DEFAULT);
700        for (SenderFragment senderFragment : mHeader.senderFragments) {
701            CharacterStyle style = senderFragment.style;
702            int start = senderFragment.start;
703            int end = senderFragment.end;
704            style.updateDrawState(sPaint);
705            senderFragment.width = (int) sPaint.measureText(mHeader.sendersText, start, end);
706            boolean isFixed = senderFragment.isFixed;
707            if (isFixed) {
708                fixedWidth += senderFragment.width;
709            }
710            totalWidth += senderFragment.width;
711        }
712
713        // Second pass to layout each fragment.
714        int sendersY = mCoordinates.sendersY - mCoordinates.sendersAscent;
715        if (!ConversationItemViewCoordinates.displaySendersInline(mMode)) {
716            sendersY += totalWidth <= mSendersWidth ? mCoordinates.sendersLineHeight / 2 : 0;
717        }
718        if (mHeader.styledSenders != null) {
719            totalWidth = ellipsizeStyledSenders(fixedWidth, sendersY);
720            mHeader.sendersDisplayLayout = new StaticLayout(mHeader.styledSendersString, sPaint,
721                    mSendersWidth, Alignment.ALIGN_NORMAL, 1, 0, true);
722        } else {
723            totalWidth = ellipsize(fixedWidth, sendersY);
724            mHeader.sendersDisplayLayout = new StaticLayout(mHeader.sendersDisplayText, sPaint,
725                    mSendersWidth, Alignment.ALIGN_NORMAL, 1, 0, true);
726        }
727
728        sPaint.setTextSize(mCoordinates.sendersFontSize);
729        sPaint.setTypeface(Typeface.DEFAULT);
730        if (mSendersWidth < 0) {
731            mSendersWidth = 0;
732        }
733
734        pauseTimer(PERF_TAG_CALCULATE_COORDINATES);
735    }
736
737    // The rules for displaying message info are as follows:
738    // 1) Any DRAFT text is always preceded by ", "
739    // 2) Any COUNT of messages > 1 is always preceded by " "
740    // 3) If there is a COUNT > 1, it is always displayed before DRAFT text
741    private SpannableStringBuilder createMessageInfo() {
742        SpannableStringBuilder messageInfo = new SpannableStringBuilder();
743        if (mHeader.conversation.conversationInfo != null) {
744            int count = mHeader.conversation.conversationInfo.messageCount;
745            int draftCount = mHeader.conversation.conversationInfo.draftCount;
746            if (count > 0 || draftCount <= 0) {
747                messageInfo.append(" ");
748            }
749            if (count > 1) {
750                messageInfo.append(count + "");
751            }
752            if (draftCount > 0) {
753                getDraftResources();
754                messageInfo.append(SENDERS_SPLIT_TOKEN);
755                SpannableStringBuilder draftString = new SpannableStringBuilder();
756                if (draftCount == 1) {
757                    draftString.append(sDraftSingularString);
758                } else {
759                    draftString.append(sDraftPluralString
760                            + String.format(sDraftCountFormatString, draftCount));
761                }
762                draftString.setSpan(CharacterStyle.wrap(sDraftsStyleSpan), 0, draftString.length(),
763                        Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
764                messageInfo.append(draftString);
765            }
766        }
767        return messageInfo;
768    }
769
770    private void getDraftResources() {
771        Resources res = getContext().getResources();
772        if (sDraftSingularString == null) {
773            sDraftSingularString = res.getQuantityText(R.plurals.draft, 1);
774            sDraftPluralString = res.getQuantityText(R.plurals.draft, 2);
775            sDraftCountFormatString = res.getString(R.string.draft_count_format);
776            sDraftsStyleSpan = new ForegroundColorSpan(res.getColor(R.color.drafts));
777        }
778    }
779
780    // The rules for displaying ellipsized senders are as follows:
781    // 1) If there is message info (either a COUNT or DRAFT info to display), it MUST be shown
782    // 2) If senders do not fit, ellipsize the last one that does fit, and stop
783    // appending new senders
784    private int ellipsizeStyledSenders(int fixedWidth, int sendersY) {
785        SpannableStringBuilder builder = new SpannableStringBuilder();
786        int totalWidth = 0;
787        int currentLine = 1;
788        boolean ellipsize = false;
789        SpannableString ellipsizedText;
790        int width;
791        SpannableStringBuilder messageInfoString = createMessageInfo();
792        // Paint the message info string to see if we lose space.
793        float messageInfoWidth = sPaint.measureText(messageInfoString.toString());
794        totalWidth += messageInfoWidth;
795
796        for (SpannableString sender : mHeader.styledSenders) {
797            // No more width available, we'll only show fixed fragments.
798            if (ellipsize) {
799                break;
800            }
801            CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class);
802            // There is only 1 character style span.
803            if (spans.length > 0) {
804                spans[0].updateDrawState(sPaint);
805            }
806            // If there are already senders present in this string, we need to
807            // make sure we prepend the dividing token
808            if (builder.length() > 0) {
809                sender = copyStyles(spans, SENDERS_SPLIT_TOKEN + sender);
810            }
811            // Measure the width of the current sender and make sure we have space
812            width = (int) sPaint.measureText(sender.toString());
813            if (!canFitFragment(totalWidth + width, currentLine, fixedWidth)) {
814                // The text is too long, new line won't help. We have to
815                // ellipsize text.
816                ellipsize = true;
817                width = mSendersWidth - totalWidth;
818                // No more new line, we have to reserve width for fixed
819                // fragments.
820                if (currentLine == mCoordinates.sendersLineCount) {
821                    width -= fixedWidth;
822                }
823                ellipsizedText = copyStyles(spans,
824                        TextUtils.ellipsize(sender, sPaint, width, TruncateAt.END));
825                width = (int) sPaint.measureText(ellipsizedText.toString());
826            } else {
827                ellipsizedText = null;
828            }
829            totalWidth += width;
830
831            final CharSequence fragmentDisplayText;
832            if (ellipsizedText != null) {
833                fragmentDisplayText = ellipsizedText;
834            } else {
835                fragmentDisplayText = sender;
836            }
837            builder.append(fragmentDisplayText);
838        }
839        if (messageInfoString.length() > 0) {
840            builder.append(messageInfoString);
841        }
842        mHeader.styledSendersString = builder;
843        return totalWidth;
844    }
845
846    private SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) {
847        SpannableString s = new SpannableString(newText);
848        if (spans != null && spans.length > 0) {
849            s.setSpan(spans[0], 0, s.length(), 0);
850        }
851        return s;
852    }
853
854    private int ellipsize(int fixedWidth, int sendersY) {
855        int totalWidth = 0;
856        int currentLine = 1;
857        boolean ellipsize = false;
858        for (SenderFragment senderFragment : mHeader.senderFragments) {
859            CharacterStyle style = senderFragment.style;
860            int start = senderFragment.start;
861            int end = senderFragment.end;
862            int width = senderFragment.width;
863            boolean isFixed = senderFragment.isFixed;
864            style.updateDrawState(sPaint);
865
866            // No more width available, we'll only show fixed fragments.
867            if (ellipsize && !isFixed) {
868                senderFragment.shouldDisplay = false;
869                continue;
870            }
871
872            // New line and ellipsize text if needed.
873            senderFragment.ellipsizedText = null;
874            if (isFixed) {
875                fixedWidth -= width;
876            }
877            if (!canFitFragment(totalWidth + width, currentLine, fixedWidth)) {
878                // The text is too long, new line won't help. We have to
879                // ellipsize text.
880                if (totalWidth == 0) {
881                    ellipsize = true;
882                } else {
883                    // New line.
884                    if (currentLine < mCoordinates.sendersLineCount) {
885                        currentLine++;
886                        sendersY += mCoordinates.sendersLineHeight;
887                        totalWidth = 0;
888                        // The text is still too long, we have to ellipsize
889                        // text.
890                        if (totalWidth + width > mSendersWidth) {
891                            ellipsize = true;
892                        }
893                    } else {
894                        ellipsize = true;
895                    }
896                }
897
898                if (ellipsize) {
899                    width = mSendersWidth - totalWidth;
900                    // No more new line, we have to reserve width for fixed
901                    // fragments.
902                    if (currentLine == mCoordinates.sendersLineCount) {
903                        width -= fixedWidth;
904                    }
905                    senderFragment.ellipsizedText = TextUtils.ellipsize(
906                            mHeader.sendersText.substring(start, end), sPaint, width,
907                            TruncateAt.END).toString();
908                    width = (int) sPaint.measureText(senderFragment.ellipsizedText);
909                }
910            }
911            senderFragment.shouldDisplay = true;
912            totalWidth += width;
913
914            final CharSequence fragmentDisplayText;
915            if (senderFragment.ellipsizedText != null) {
916                fragmentDisplayText = senderFragment.ellipsizedText;
917            } else {
918                fragmentDisplayText = mHeader.sendersText.substring(start, end);
919            }
920            final int spanStart = mHeader.sendersDisplayText.length();
921            mHeader.sendersDisplayText.append(fragmentDisplayText);
922            mHeader.sendersDisplayText.setSpan(senderFragment.style, spanStart,
923                    mHeader.sendersDisplayText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
924        }
925        return totalWidth;
926    }
927
928    /**
929     * If the subject contains the tag of a mailing-list (text surrounded with
930     * []), return the subject with that tag ellipsized, e.g.
931     * "[android-gmail-team] Hello" -> "[andr...] Hello"
932     */
933    private String filterTag(String subject) {
934        String result = subject;
935        String formatString = getContext().getResources().getString(R.string.filtered_tag);
936        if (!TextUtils.isEmpty(subject) && subject.charAt(0) == '[') {
937            int end = subject.indexOf(']');
938            if (end > 0) {
939                String tag = subject.substring(1, end);
940                result = String.format(formatString, Utils.ellipsize(tag, 7),
941                        subject.substring(end + 1));
942            }
943        }
944        return result;
945    }
946
947    @Override
948    protected void onDraw(Canvas canvas) {
949        // Check mark.
950        if (mHeader.checkboxVisible) {
951            Bitmap checkmark = mChecked ? CHECKMARK_ON : CHECKMARK_OFF;
952            canvas.drawBitmap(checkmark, mCoordinates.checkmarkX, mCoordinates.checkmarkY, sPaint);
953        }
954
955        // Personal Level.
956        if (mCoordinates.showPersonalLevel && mHeader.personalLevelBitmap != null) {
957            canvas.drawBitmap(mHeader.personalLevelBitmap, mCoordinates.personalLevelX,
958                    mCoordinates.personalLevelY, sPaint);
959        }
960
961        // Senders.
962        boolean isUnread = mHeader.unread;
963        sPaint.setTextSize(mCoordinates.sendersFontSize);
964        sPaint.setTypeface(mCoordinates.sendersView.getTypeface(isUnread));
965        int sendersColor = getFontColor(isUnread ? SENDERS_TEXT_COLOR_UNREAD
966                : SENDERS_TEXT_COLOR_READ);
967        sPaint.setColor(sendersColor);
968        canvas.save();
969        canvas.translate(mCoordinates.sendersX,
970                mCoordinates.sendersY + mHeader.sendersDisplayLayout.getTopPadding());
971        mHeader.sendersDisplayLayout.draw(canvas);
972        canvas.restore();
973
974        // Subject.
975        sPaint.setTextSize(mCoordinates.subjectFontSize);
976        sPaint.setTypeface(Typeface.DEFAULT);
977        canvas.save();
978        if (isActivated() && showActivatedText()) {
979            if (mHeader.subjectLayoutActivated != null) {
980                canvas.translate(mCoordinates.subjectX, mCoordinates.subjectY
981                        + mHeader.subjectLayoutActivated.getTopPadding());
982                mHeader.subjectLayoutActivated.draw(canvas);
983            }
984        } else if (mHeader.subjectLayout != null) {
985            canvas.translate(mCoordinates.subjectX,
986                    mCoordinates.subjectY + mHeader.subjectLayout.getTopPadding());
987            mHeader.subjectLayout.draw(canvas);
988        }
989        canvas.restore();
990
991        // Folders.
992        if (mCoordinates.showFolders) {
993            mHeader.folderDisplayer.drawFolders(canvas, mCoordinates, mFoldersXEnd, mMode);
994        }
995
996        // If this folder has a color (combined view/Email), show it here
997        if (mHeader.conversation.color != 0) {
998            sFoldersPaint.setColor(mHeader.conversation.color);
999            sFoldersPaint.setStyle(Paint.Style.FILL);
1000            int width = ConversationItemViewCoordinates.getColorBlockWidth(mContext);
1001            int height = ConversationItemViewCoordinates.getColorBlockHeight(mContext);
1002            canvas.drawRect(mCoordinates.dateXEnd - width, 0, mCoordinates.dateXEnd,
1003                    height, sFoldersPaint);
1004        }
1005
1006        // Date background: shown when there is an attachment or a visible
1007        // folder.
1008        if (!isActivated()
1009                && (mHeader.conversation.hasAttachments ||
1010                        (mHeader.folderDisplayer != null
1011                            && mHeader.folderDisplayer.hasVisibleFolders()))
1012                && ConversationItemViewCoordinates.showAttachmentBackground(mMode)) {
1013            int leftOffset = (mHeader.conversation.hasAttachments ? mPaperclipX : mDateX)
1014                    - DATE_BACKGROUND_PADDING_LEFT;
1015            int top = mCoordinates.showFolders ? mCoordinates.foldersY : mCoordinates.dateY;
1016            mHeader.dateBackground = getDateBackground(mHeader.conversation.hasAttachments);
1017            canvas.drawBitmap(mHeader.dateBackground, leftOffset, top, sPaint);
1018        } else {
1019            mHeader.dateBackground = null;
1020        }
1021
1022        // Draw the reply state. Draw nothing if neither replied nor forwarded.
1023        if (mCoordinates.showReplyState) {
1024            if (mHeader.hasBeenRepliedTo && mHeader.hasBeenForwarded) {
1025                canvas.drawBitmap(STATE_REPLIED_AND_FORWARDED, mCoordinates.replyStateX,
1026                        mCoordinates.replyStateY, null);
1027            } else if (mHeader.hasBeenRepliedTo) {
1028                canvas.drawBitmap(STATE_REPLIED, mCoordinates.replyStateX,
1029                        mCoordinates.replyStateY, null);
1030            } else if (mHeader.hasBeenForwarded) {
1031                canvas.drawBitmap(STATE_FORWARDED, mCoordinates.replyStateX,
1032                        mCoordinates.replyStateY, null);
1033            } else if (mHeader.isInvite) {
1034                canvas.drawBitmap(STATE_CALENDAR_INVITE, mCoordinates.replyStateX,
1035                        mCoordinates.replyStateY, null);
1036            }
1037        }
1038
1039        // Date.
1040        sPaint.setTextSize(mCoordinates.dateFontSize);
1041        sPaint.setTypeface(Typeface.DEFAULT);
1042        sPaint.setColor(isUnread ? DATE_TEXT_COLOR_UNREAD : DATE_TEXT_COLOR_READ);
1043        drawText(canvas, mHeader.dateText, mDateX, mCoordinates.dateY - mCoordinates.dateAscent,
1044                sPaint);
1045
1046        // Paper clip icon.
1047        if (mHeader.paperclip != null) {
1048            canvas.drawBitmap(mHeader.paperclip, mPaperclipX, mCoordinates.paperclipY, sPaint);
1049        }
1050
1051        if (mHeader.faded) {
1052            int fadedColor = -1;
1053            if (sFadedActivatedColor == -1) {
1054                sFadedActivatedColor = mContext.getResources().getColor(
1055                        R.color.faded_activated_conversation_header);
1056            }
1057            fadedColor = sFadedActivatedColor;
1058            int restoreState = canvas.save();
1059            Rect bounds = canvas.getClipBounds();
1060            canvas.clipRect(bounds.left, bounds.top, bounds.right
1061                    - mContext.getResources().getDimensionPixelSize(R.dimen.triangle_width),
1062                    bounds.bottom);
1063            canvas.drawARGB(Color.alpha(fadedColor), Color.red(fadedColor),
1064                    Color.green(fadedColor), Color.blue(fadedColor));
1065            canvas.restoreToCount(restoreState);
1066        }
1067
1068        // Star.
1069        canvas.drawBitmap(getStarBitmap(), mCoordinates.starX, mCoordinates.starY, sPaint);
1070    }
1071
1072    private Bitmap getStarBitmap() {
1073        return mHeader.conversation.starred ? STAR_ON : STAR_OFF;
1074    }
1075
1076    private Bitmap getDateBackground(boolean hasAttachments) {
1077        int leftOffset = (hasAttachments ? mPaperclipX : mDateX) - DATE_BACKGROUND_PADDING_LEFT;
1078        if (hasAttachments) {
1079            if (sDateBackgroundAttachment == null) {
1080                sDateBackgroundAttachment = Bitmap.createScaledBitmap(DATE_BACKGROUND, mViewWidth
1081                        - leftOffset, sDateBackgroundHeight, false);
1082            }
1083            return sDateBackgroundAttachment;
1084        } else {
1085            if (sDateBackgroundNoAttachment == null) {
1086                sDateBackgroundNoAttachment = Bitmap.createScaledBitmap(DATE_BACKGROUND, mViewWidth
1087                        - leftOffset, sDateBackgroundHeight, false);
1088            }
1089            return sDateBackgroundNoAttachment;
1090        }
1091    }
1092
1093    private void drawText(Canvas canvas, CharSequence s, int x, int y, TextPaint paint) {
1094        canvas.drawText(s, 0, s.length(), x, y, paint);
1095    }
1096
1097    private void updateBackground(boolean isUnread) {
1098        if (isUnread) {
1099            if (mTabletDevice && mViewMode.isListMode()) {
1100                if (mChecked) {
1101                    setBackgroundResource(R.drawable.list_conversation_wide_unread_selected_holo);
1102                } else {
1103                    setBackgroundResource(R.drawable.conversation_wide_unread_selector);
1104                }
1105            } else {
1106                if (mChecked) {
1107                    setCheckedActivatedBackground();
1108                } else {
1109                    setBackgroundResource(R.drawable.conversation_unread_selector);
1110                }
1111            }
1112        } else {
1113            if (mTabletDevice && mViewMode.isListMode()) {
1114                if (mChecked) {
1115                    setBackgroundResource(R.drawable.list_conversation_wide_read_selected_holo);
1116                } else {
1117                    setBackgroundResource(R.drawable.conversation_wide_read_selector);
1118                }
1119            } else {
1120                if (mChecked) {
1121                    setCheckedActivatedBackground();
1122                } else {
1123                    setBackgroundResource(R.drawable.conversation_read_selector);
1124                }
1125            }
1126        }
1127    }
1128
1129    private void setCheckedActivatedBackground() {
1130        if (isActivated() && mTabletDevice) {
1131            setBackgroundResource(R.drawable.list_arrow_selected_holo);
1132        } else {
1133            setBackgroundResource(R.drawable.list_selected_holo);
1134        }
1135    }
1136
1137    /**
1138     * Toggle the check mark on this view and update the conversation
1139     */
1140    public void toggleCheckMark() {
1141        if (mHeader != null && mHeader.conversation != null) {
1142            mChecked = !mChecked;
1143            Conversation conv = mHeader.conversation;
1144            // Set the list position of this item in the conversation
1145            ListView listView = getListView();
1146            conv.position = mChecked && listView != null ? listView.getPositionForView(this)
1147                    : Conversation.NO_POSITION;
1148            if (mSelectedConversationSet != null) {
1149                mSelectedConversationSet.toggle(this, conv);
1150            }
1151            // We update the background after the checked state has changed now
1152            // that
1153            // we have a selected background asset. Setting the background
1154            // usually
1155            // waits for a layout pass, but we don't need a full layout, just an
1156            // update to the background.
1157            requestLayout();
1158        }
1159    }
1160
1161    /**
1162     * Return if the checkbox for this item is checked.
1163     */
1164    public boolean isChecked() {
1165        return mChecked;
1166    }
1167
1168    /**
1169     * Toggle the star on this view and update the conversation.
1170     */
1171    public void toggleStar() {
1172        mHeader.conversation.starred = !mHeader.conversation.starred;
1173        Bitmap starBitmap = getStarBitmap();
1174        postInvalidate(mCoordinates.starX, mCoordinates.starY, mCoordinates.starX
1175                + starBitmap.getWidth(),
1176                mCoordinates.starY + starBitmap.getHeight());
1177        ConversationCursor cursor = (ConversationCursor)mAdapter.getCursor();
1178        cursor.updateBoolean(mContext, mHeader.conversation, ConversationColumns.STARRED,
1179                mHeader.conversation.starred);
1180    }
1181
1182    private boolean isTouchInCheckmark(float x, float y) {
1183        // Everything before senders and include a touch slop.
1184        return mHeader.checkboxVisible && x < mCoordinates.sendersX + TOUCH_SLOP;
1185    }
1186
1187    private boolean isTouchInStar(float x, float y) {
1188        // Everything after the star and include a touch slop.
1189        return x > mCoordinates.starX - TOUCH_SLOP;
1190    }
1191
1192    /**
1193     * Cancel any potential tap handling on this view.
1194     */
1195    @Override
1196    public void cancelTap() {
1197        removeCallbacks(mPendingCheckForTap);
1198        removeCallbacks(mPendingCheckForLongPress);
1199    }
1200
1201    /**
1202     * ConversationItemView is given the first chance to handle touch events.
1203     */
1204    @Override
1205    public boolean onTouchEvent(MotionEvent event) {
1206        mLastTouchX = (int) event.getX();
1207        mLastTouchY = (int) event.getY();
1208        if (!mSwipeEnabled) {
1209            return onTouchEventNoSwipe(event);
1210        }
1211        boolean handled = true;
1212
1213        int x = mLastTouchX;
1214        int y = mLastTouchY;
1215        switch (event.getAction()) {
1216            case MotionEvent.ACTION_DOWN:
1217                mDownEvent = true;
1218                // In order to allow the down event and subsequent move events
1219                // to bubble to the swipe handler, we need to return that all
1220                // down events are handled.
1221                handled = true;
1222                // TODO (mindyp) Debounce
1223                if (mPendingCheckForTap == null) {
1224                    mPendingCheckForTap = new CheckForTap();
1225                }
1226                postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
1227                break;
1228            case MotionEvent.ACTION_CANCEL:
1229                mDownEvent = false;
1230                break;
1231            case MotionEvent.ACTION_UP:
1232                cancelTap();
1233                if (mDownEvent) {
1234                    // ConversationItemView gets the first chance to handle up
1235                    // events if there was a down event and there was no move
1236                    // event in between. In this case, ConversationItemView
1237                    // received the down event, and then an up event in the
1238                    // same location (+/- slop). Treat this as a click on the
1239                    // view or on a specific part of the view.
1240                    if (isTouchInCheckmark(x, y)) {
1241                        // Touch on the check mark
1242                        toggleCheckMark();
1243                        handled = true;
1244                    } else if (isTouchInStar(x, y)) {
1245                        // Touch on the star
1246                        toggleStar();
1247                        handled = true;
1248                    } else {
1249                        ListView list = getListView();
1250                        if (!isChecked() && list != null) {
1251                            int pos = list.getPositionForView(this);
1252                            list.performItemClick(this, pos, mHeader.conversation.id);
1253                        }
1254                    }
1255                    handled = true;
1256                } else {
1257                    // There was no down event that this view was made aware of,
1258                    // therefore it cannot handle it.
1259                    handled = false;
1260                }
1261                break;
1262        }
1263
1264        if (!handled) {
1265            // Let View try to handle it as well.
1266            handled = super.onTouchEvent(event);
1267        }
1268
1269        return handled;
1270    }
1271
1272    private ListView getListView() {
1273        return ((SwipeableConversationItemView) getParent()).getListView();
1274    }
1275
1276    private boolean onTouchEventNoSwipe(MotionEvent event) {
1277        boolean handled = true;
1278
1279        int x = (int) event.getX();
1280        int y = (int) event.getY();
1281        switch (event.getAction()) {
1282            case MotionEvent.ACTION_DOWN:
1283                mDownEvent = true;
1284                // In order to allow the down event and subsequent move events
1285                // to bubble to the swipe handler, we need to return that all
1286                // down events are handled.
1287                handled = isTouchInCheckmark(x, y) || isTouchInStar(x, y);
1288                break;
1289            case MotionEvent.ACTION_CANCEL:
1290                mDownEvent = false;
1291                break;
1292            case MotionEvent.ACTION_UP:
1293                if (mDownEvent) {
1294                    // ConversationItemView gets the first chance to handle up
1295                    // events if there was a down event and there was no move
1296                    // event in between. In this case, ConversationItemView
1297                    // received the down event, and then an up event in the
1298                    // same location (+/- slop). Treat this as a click on the
1299                    // view or on a specific part of the view.
1300                    if (isTouchInCheckmark(x, y)) {
1301                        // Touch on the check mark
1302                        toggleCheckMark();
1303                    } else if (isTouchInStar(x, y)) {
1304                        // Touch on the star
1305                        toggleStar();
1306                    }
1307                    handled = true;
1308                } else {
1309                    // There was no down event that this view was made aware of,
1310                    // therefore it cannot handle it.
1311                    handled = false;
1312                }
1313                break;
1314        }
1315
1316        if (!handled) {
1317            // Let View try to handle it as well.
1318            handled = super.onTouchEvent(event);
1319        }
1320
1321        return handled;
1322    }
1323
1324    /**
1325     * Return if this item should respond to long clicks.
1326     */
1327    @Override
1328    public boolean isLongClickable() {
1329        return true;
1330    }
1331
1332    final class CheckForTap implements Runnable {
1333        @Override
1334        public void run() {
1335            // refreshDrawableState();
1336            final int longPressTimeout = ViewConfiguration.getLongPressTimeout();
1337            final boolean longClickable = isLongClickable();
1338
1339            if (longClickable) {
1340                if (mPendingCheckForLongPress == null) {
1341                    mPendingCheckForLongPress = new CheckForLongPress();
1342                }
1343                postDelayed(mPendingCheckForLongPress, longPressTimeout);
1344            }
1345        }
1346    }
1347
1348    private class CheckForLongPress implements Runnable {
1349        @Override
1350        public void run() {
1351            ConversationItemView.this.toggleSelectionOrBeginDrag();
1352            performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
1353        }
1354    }
1355
1356    /**
1357     * Grow the height of the item and fade it in when bringing a conversation
1358     * back from a destructive action.
1359     * @param listener
1360     */
1361    public void startSwipeUndoAnimation(ViewMode viewMode, final AnimatorListener listener) {
1362        final int start = sUndoAnimationOffset;
1363        final int end = 0;
1364        ObjectAnimator undoAnimator = ObjectAnimator.ofFloat(this, "translationX", start, end);
1365        undoAnimator.setInterpolator(new DecelerateInterpolator(2.0f));
1366        undoAnimator.addListener(listener);
1367        undoAnimator.setDuration(sUndoAnimationDuration);
1368        undoAnimator.start();
1369    }
1370
1371    /**
1372     * Grow the height of the item and fade it in when bringing a conversation
1373     * back from a destructive action.
1374     * @param listener
1375     */
1376    public void startUndoAnimation(ViewMode viewMode, final AnimatorListener listener) {
1377        int minHeight = ConversationItemViewCoordinates.getMinHeight(mContext, viewMode);
1378        setMinimumHeight(minHeight);
1379        final int start = 0;
1380        final int end = minHeight;
1381        ObjectAnimator undoAnimator = ObjectAnimator.ofInt(this, "animatedHeight", start, end);
1382        Animator fadeAnimator = ObjectAnimator.ofFloat(this, "itemAlpha", 0, 1.0f);
1383        mAnimatedHeight = start;
1384        undoAnimator.setInterpolator(new DecelerateInterpolator(2.0f));
1385        undoAnimator.addListener(listener);
1386        undoAnimator.setDuration(sUndoAnimationDuration);
1387        AnimatorSet transitionSet = new AnimatorSet();
1388        transitionSet.playTogether(undoAnimator, fadeAnimator);
1389        transitionSet.start();
1390    }
1391
1392    // Used by animator
1393    @SuppressWarnings("unused")
1394    public void setItemAlpha(float alpha) {
1395        setAlpha(alpha);
1396        invalidate();
1397    }
1398
1399    // Used by animator
1400    @SuppressWarnings("unused")
1401    public void setAnimatedHeight(int height) {
1402        mAnimatedHeight = height;
1403        requestLayout();
1404    }
1405
1406    @Override
1407    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
1408        if (mAnimatedHeight == -1) {
1409            int height = measureHeight(heightMeasureSpec,
1410                    ConversationItemViewCoordinates.getMode(mContext, mViewMode));
1411            setMeasuredDimension(widthMeasureSpec, height);
1412        } else {
1413            setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), mAnimatedHeight);
1414        }
1415    }
1416
1417    /**
1418     * Determine the height of this view.
1419     * @param measureSpec A measureSpec packed into an int
1420     * @param mode The current mode of this view
1421     * @return The height of the view, honoring constraints from measureSpec
1422     */
1423    private int measureHeight(int measureSpec, int mode) {
1424        int result = 0;
1425        int specMode = MeasureSpec.getMode(measureSpec);
1426        int specSize = MeasureSpec.getSize(measureSpec);
1427
1428        if (specMode == MeasureSpec.EXACTLY) {
1429            // We were told how big to be
1430            result = specSize;
1431        } else {
1432            // Measure the text
1433            result = ConversationItemViewCoordinates.getHeight(mContext, mode);
1434            if (specMode == MeasureSpec.AT_MOST) {
1435                // Respect AT_MOST value if that was what is called for by
1436                // measureSpec
1437                result = Math.min(result, specSize);
1438            }
1439        }
1440        return result;
1441    }
1442
1443    /**
1444     * Get the current position of this conversation item in the list.
1445     */
1446    public int getPosition() {
1447        return mHeader != null && mHeader.conversation != null ?
1448                mHeader.conversation.position : -1;
1449    }
1450
1451    /**
1452     * Select the current conversation.
1453     */
1454    private void selectConversation() {
1455        if (!mSelectedConversationSet.containsKey(mHeader.conversation.id)) {
1456            toggleCheckMark();
1457        }
1458    }
1459
1460    @Override
1461    public View getView() {
1462        return this;
1463    }
1464
1465    /**
1466     * With two pane mode and mailboxes in one pane (tablet), add the
1467     * conversation to the selected set and start drag mode. In two pane mode
1468     * when viewing conversations (tablet), toggle selection. In one pane mode
1469     * (phone, and portrait mode on tablet), toggle selection.
1470     */
1471    public void toggleSelectionOrBeginDrag() {
1472        // If we are in one pane mode, or we are looking at conversations, drag
1473        // and drop is meaningless. Toggle checkmark and return early.
1474        if (!Utils.useTabletUI(mContext)
1475                || !mViewMode.isListMode()) {
1476            toggleCheckMark();
1477            return;
1478        }
1479
1480        // Begin drag mode. Keep the conversation selected (NOT toggle
1481        // selection) and start drag.
1482        selectConversation();
1483
1484        // Clip data has form: [conversations_uri, conversationId1,
1485        // maxMessageId1, label1, conversationId2, maxMessageId2, label2, ...]
1486        int count = mSelectedConversationSet.size();
1487        String description = Utils.formatPlural(mContext, R.plurals.move_conversation, count);
1488
1489        final ClipData data = ClipData.newUri(mContext.getContentResolver(), description,
1490                Conversation.MOVE_CONVERSATIONS_URI);
1491        for (Conversation conversation : mSelectedConversationSet.values()) {
1492            data.addItem(new Item(String.valueOf(conversation.position)));
1493        }
1494        // Protect against non-existent views: only happens for monkeys
1495        final int width = this.getWidth();
1496        final int height = this.getHeight();
1497        final boolean isDimensionNegative = (width < 0) || (height < 0);
1498        if (isDimensionNegative) {
1499            LogUtils.e(LOG_TAG, "ConversationItemView: dimension is negative: "
1500                        + "width=%d, height=%d", width, height);
1501            return;
1502        }
1503        // Start drag mode
1504        startDrag(data, new ShadowBuilder(this, count, mLastTouchX, mLastTouchY), null, 0);
1505    }
1506
1507    private class ShadowBuilder extends DragShadowBuilder {
1508        private final Drawable mBackground;
1509
1510        private final View mView;
1511        private final String mDragDesc;
1512        private final int mTouchX;
1513        private final int mTouchY;
1514        private int mDragDescX;
1515        private int mDragDescY;
1516
1517        public ShadowBuilder(View view, int count, int touchX, int touchY) {
1518            super(view);
1519            mView = view;
1520            mBackground = mView.getResources().getDrawable(R.drawable.list_pressed_holo);
1521            mDragDesc = Utils.formatPlural(mView.getContext(), R.plurals.move_conversation, count);
1522            mTouchX = touchX;
1523            mTouchY = touchY;
1524        }
1525
1526        @Override
1527        public void onProvideShadowMetrics(Point shadowSize, Point shadowTouchPoint) {
1528            int width = mView.getWidth();
1529            int height = mView.getHeight();
1530            mDragDescX = mCoordinates.sendersX;
1531            mDragDescY = getPadding(height, mCoordinates.subjectFontSize)
1532                    - mCoordinates.subjectAscent;
1533            shadowSize.set(width, height);
1534            shadowTouchPoint.set(mTouchX, mTouchY);
1535        }
1536
1537        @Override
1538        public void onDrawShadow(Canvas canvas) {
1539            mBackground.setBounds(0, 0, mView.getWidth(), mView.getHeight());
1540            mBackground.draw(canvas);
1541            sPaint.setTextSize(mCoordinates.subjectFontSize);
1542            canvas.drawText(mDragDesc, mDragDescX, mDragDescY, sPaint);
1543        }
1544    }
1545}
1546