LabeledEditorView.java revision 60b239c24c05233bbf3f7cb45697252e15ec76cc
1/*
2 * Copyright (C) 2010 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.contacts.editor;
18
19import com.android.contacts.ContactsUtils;
20import com.android.contacts.R;
21import com.android.contacts.model.AccountType.DataKind;
22import com.android.contacts.model.AccountType.EditType;
23import com.android.contacts.model.EntityDelta;
24import com.android.contacts.model.EntityDelta.ValuesDelta;
25import com.android.contacts.model.EntityModifier;
26import com.android.contacts.util.DialogManager;
27import com.android.contacts.util.DialogManager.DialogShowingView;
28import com.android.contacts.util.ThemeUtils;
29
30import android.app.AlertDialog;
31import android.app.Dialog;
32import android.content.Context;
33import android.content.DialogInterface;
34import android.content.Entity;
35import android.content.res.Resources;
36import android.os.Bundle;
37import android.os.Handler;
38import android.text.TextUtils;
39import android.util.AttributeSet;
40import android.view.LayoutInflater;
41import android.view.View;
42import android.view.ViewGroup;
43import android.view.inputmethod.EditorInfo;
44import android.widget.AdapterView;
45import android.widget.AdapterView.OnItemSelectedListener;
46import android.widget.ArrayAdapter;
47import android.widget.EditText;
48import android.widget.ImageButton;
49import android.widget.Spinner;
50import android.widget.TextView;
51
52import java.util.List;
53
54/**
55 * Base class for editors that handles labels and values.
56 * Uses {@link ValuesDelta} to read any existing
57 * {@link Entity} values, and to correctly write any changes values.
58 */
59public abstract class LabeledEditorView extends ViewGroup implements Editor, DialogShowingView {
60    protected static final String DIALOG_ID_KEY = "dialog_id";
61    private static final int DIALOG_ID_CUSTOM = 1;
62
63    private static final int INPUT_TYPE_CUSTOM = EditorInfo.TYPE_CLASS_TEXT
64            | EditorInfo.TYPE_TEXT_FLAG_CAP_WORDS;
65
66    private Spinner mLabel;
67    private EditTypeAdapter mEditTypeAdapter;
68    private ImageButton mDelete;
69
70    private DataKind mKind;
71    private ValuesDelta mEntry;
72    private EntityDelta mState;
73    private boolean mReadOnly;
74
75    private EditType mType;
76
77    private ViewIdGenerator mViewIdGenerator;
78    private DialogManager mDialogManager = null;
79    private EditorListener mListener;
80    protected int mMinLineItemHeight;
81
82    /**
83     * A marker in the spinner adapter of the currently selected custom type.
84     */
85    public static final EditType CUSTOM_SELECTION = new EditType(0, 0);
86
87    private OnItemSelectedListener mSpinnerListener = new OnItemSelectedListener() {
88
89        @Override
90        public void onItemSelected(
91                AdapterView<?> parent, View view, int position, long id) {
92            onTypeSelectionChange(position);
93        }
94
95        @Override
96        public void onNothingSelected(AdapterView<?> parent) {
97        }
98    };
99
100    public LabeledEditorView(Context context) {
101        super(context);
102        init(context);
103    }
104
105    public LabeledEditorView(Context context, AttributeSet attrs) {
106        super(context, attrs);
107        init(context);
108    }
109
110    public LabeledEditorView(Context context, AttributeSet attrs, int defStyle) {
111        super(context, attrs, defStyle);
112        init(context);
113    }
114
115    private void init(Context context) {
116        mMinLineItemHeight = context.getResources().getDimensionPixelSize(
117                R.dimen.editor_min_line_item_height);
118    }
119
120    public boolean isReadOnly() {
121        return mReadOnly;
122    }
123
124    public int getBaseline(int row) {
125        if (row == 0 && mLabel != null) {
126            return mLabel.getBaseline();
127        }
128        return -1;
129    }
130
131    /**
132     * Returns the number of rows in this editor, including the invisible ones.
133     */
134    protected int getLineItemCount() {
135        return 1;
136    }
137
138    protected boolean isLineItemVisible(int row) {
139        return true;
140    }
141
142    protected int getLineItemHeight(int row) {
143        int fieldHeight = 0;
144        int buttonHeight = 0;
145        if (row == 0) {
146            // summarize the EditText heights
147            if (mLabel != null) {
148                fieldHeight = mLabel.getMeasuredHeight();
149            }
150
151            // Ensure there is enough space for the minus button
152            View deleteButton = getDelete();
153            final int deleteHeight = (deleteButton != null) ? deleteButton.getMeasuredHeight() : 0;
154            buttonHeight += deleteHeight;
155        }
156
157        return Math.max(Math.max(buttonHeight, fieldHeight), mMinLineItemHeight);
158    }
159
160    @Override
161    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
162        measureChildren(widthMeasureSpec, heightMeasureSpec);
163
164        int height = 0;
165        height += getPaddingTop() + getPaddingBottom();
166
167        int count = getLineItemCount();
168        for (int i = 0; i < count; i++) {
169            if (isLineItemVisible(i)) {
170                height += getLineItemHeight(i);
171            }
172        }
173
174        setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
175                resolveSize(height, heightMeasureSpec));
176    }
177
178    @Override
179    protected void onLayout(boolean changed, int l, int t, int r, int b) {
180        // Subtract padding from the borders ==> x1 variables
181        int t1 = getPaddingTop();
182        int r1 = getMeasuredWidth() - getPaddingRight();
183        int b1 = getMeasuredHeight() - getPaddingBottom();
184
185        final int r2;
186        if (mDelete != null) {
187            r2 = r1 - mDelete.getMeasuredWidth();
188            // Vertically center the delete button in the first line item
189            int height = mDelete.getMeasuredHeight();
190            int top = t1 + (mMinLineItemHeight - height) / 2;
191            mDelete.layout(
192                    r2, top,
193                    r1, top + height);
194        } else {
195            r2 = r1;
196        }
197
198        if (mLabel != null) {
199            int baseline = getBaseline(0);
200            int y = t1 + baseline - mLabel.getBaseline();
201            mLabel.layout(
202                    r2 - mLabel.getMeasuredWidth(), y,
203                    r2, y + mLabel.getMeasuredHeight());
204        }
205    }
206
207    /**
208     * Creates or removes the type/label button. Doesn't do anything if already correctly configured
209     */
210    private void setupLabelButton(boolean shouldExist) {
211        if (shouldExist && mLabel == null) {
212            mLabel = new Spinner(mContext);
213            final int width =
214                    mContext.getResources().getDimensionPixelSize(R.dimen.editor_type_label_width);
215            mLabel.setLayoutParams(new LayoutParams(width, LayoutParams.WRAP_CONTENT));
216            mLabel.setOnItemSelectedListener(mSpinnerListener);
217            mLabel.setEnabled(!mReadOnly && isEnabled());
218            addView(mLabel);
219        } else if (!shouldExist && mLabel != null) {
220            removeView(mLabel);
221            mLabel = null;
222        }
223    }
224
225    /**
226     * Creates or removes the remove button. Doesn't do anything if already correctly configured
227     */
228    private void setupDeleteButton(boolean shouldExist) {
229        if (shouldExist && mDelete == null) {
230            mDelete = new ImageButton(mContext);
231            mDelete.setImageResource(R.drawable.ic_menu_remove_field_holo_light);
232            mDelete.setBackgroundResource(
233                    ThemeUtils.getSelectableItemBackground(mContext.getTheme()));
234            final Resources resources = mContext.getResources();
235            mDelete.setPadding(
236                    resources.getDimensionPixelOffset(R.dimen.editor_round_button_padding_left),
237                    resources.getDimensionPixelOffset(R.dimen.editor_round_button_padding_top),
238                    resources.getDimensionPixelOffset(R.dimen.editor_round_button_padding_right),
239                    resources.getDimensionPixelOffset(R.dimen.editor_round_button_padding_bottom));
240            mDelete.setContentDescription(
241                    getResources().getText(R.string.description_minus_button));
242            mDelete.setLayoutParams(
243                    new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT));
244            mDelete.setOnClickListener(new OnClickListener() {
245                @Override
246                public void onClick(View v) {
247                    // defer removal of this button so that the pressed state is visible shortly
248                    new Handler().post(new Runnable() {
249                        @Override
250                        public void run() {
251                            // Keep around in model, but mark as deleted
252                            mEntry.markDeleted();
253
254                            ((ViewGroup) getParent()).removeView(LabeledEditorView.this);
255
256                            if (mListener != null) {
257                                // Notify listener when present
258                                mListener.onDeleted(LabeledEditorView.this);
259                            }
260                        }
261                    });
262                }
263            });
264            mDelete.setEnabled(!mReadOnly && isEnabled());
265            addView(mDelete);
266        } else if (!shouldExist && mDelete != null) {
267            removeView(mDelete);
268            mDelete = null;
269        }
270    }
271
272    protected void onOptionalFieldVisibilityChange() {
273        if (mListener != null) {
274            mListener.onRequest(EditorListener.EDITOR_FORM_CHANGED);
275        }
276    }
277
278    @Override
279    public void setEditorListener(EditorListener listener) {
280        mListener = listener;
281    }
282
283    @Override
284    public void setDeletable(boolean deletable) {
285        setupDeleteButton(deletable);
286    }
287
288    @Override
289    public void setEnabled(boolean enabled) {
290        super.setEnabled(enabled);
291        if (mLabel != null) mLabel.setEnabled(!mReadOnly && enabled);
292        if (mDelete != null) mDelete.setEnabled(!mReadOnly && enabled);
293    }
294
295    public Spinner getLabel() {
296        return mLabel;
297    }
298
299    public ImageButton getDelete() {
300        return mDelete;
301    }
302
303    protected DataKind getKind() {
304        return mKind;
305    }
306
307    protected ValuesDelta getEntry() {
308        return mEntry;
309    }
310
311    protected EditType getType() {
312        return mType;
313    }
314
315    /**
316     * Build the current label state based on selected {@link EditType} and
317     * possible custom label string.
318     */
319    private void rebuildLabel() {
320        if (mLabel == null) return;
321        mEditTypeAdapter = new EditTypeAdapter(mContext);
322        mLabel.setAdapter(mEditTypeAdapter);
323        if (mEditTypeAdapter.hasCustomSelection()) {
324            mLabel.setSelection(mEditTypeAdapter.getPosition(CUSTOM_SELECTION));
325        } else {
326            mLabel.setSelection(mEditTypeAdapter.getPosition(mType));
327        }
328    }
329
330    @Override
331    public void onFieldChanged(String column, String value) {
332        if (!isFieldChanged(column, value)) {
333            return;
334        }
335
336        // Field changes are saved directly
337        mEntry.put(column, value);
338        if (mListener != null) {
339            mListener.onRequest(EditorListener.FIELD_CHANGED);
340        }
341    }
342
343    protected boolean isFieldChanged(String column, String value) {
344        final String dbValue = mEntry.getAsString(column);
345        // nullable fields (e.g. Middle Name) are usually represented as empty columns,
346        // so lets treat null and empty space equivalently here
347        final String dbValueNoNull = dbValue == null ? "" : dbValue;
348        final String valueNoNull = value == null ? "" : value;
349        return !TextUtils.equals(dbValueNoNull, valueNoNull);
350    }
351
352    protected void rebuildValues() {
353        setValues(mKind, mEntry, mState, mReadOnly, mViewIdGenerator);
354    }
355
356    /**
357     * Prepare this editor using the given {@link DataKind} for defining
358     * structure and {@link ValuesDelta} describing the content to edit.
359     */
360    @Override
361    public void setValues(DataKind kind, ValuesDelta entry, EntityDelta state, boolean readOnly,
362            ViewIdGenerator vig) {
363        mKind = kind;
364        mEntry = entry;
365        mState = state;
366        mReadOnly = readOnly;
367        mViewIdGenerator = vig;
368        setId(vig.getId(state, kind, entry, ViewIdGenerator.NO_VIEW_INDEX));
369
370        if (!entry.isVisible()) {
371            // Hide ourselves entirely if deleted
372            setVisibility(View.GONE);
373            return;
374        }
375        setVisibility(View.VISIBLE);
376
377        // Display label selector if multiple types available
378        final boolean hasTypes = EntityModifier.hasEditTypes(kind);
379        setupLabelButton(hasTypes);
380        if (mLabel != null) mLabel.setEnabled(!readOnly && isEnabled());
381        if (hasTypes) {
382            mType = EntityModifier.getCurrentType(entry, kind);
383            rebuildLabel();
384        }
385    }
386
387    public ValuesDelta getValues() {
388        return mEntry;
389    }
390
391    /**
392     * Prepare dialog for entering a custom label. The input value is trimmed: white spaces before
393     * and after the input text is removed.
394     * <p>
395     * If the final value is empty, this change request is ignored;
396     * no empty text is allowed in any custom label.
397     */
398    private Dialog createCustomDialog() {
399        final EditText customType = new EditText(mContext);
400        customType.setId(R.id.custom_dialog_content);
401        customType.setInputType(INPUT_TYPE_CUSTOM);
402        customType.setSaveEnabled(true);
403        customType.requestFocus();
404
405        final AlertDialog.Builder builder = new AlertDialog.Builder(mContext);
406        builder.setTitle(R.string.customLabelPickerTitle);
407        builder.setView(customType);
408
409        builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
410            @Override
411            public void onClick(DialogInterface dialog, int which) {
412                final String customText = customType.getText().toString().trim();
413                if (ContactsUtils.isGraphic(customText)) {
414                    final List<EditType> allTypes =
415                            EntityModifier.getValidTypes(mState, mKind, null);
416                    mType = null;
417                    for (EditType editType : allTypes) {
418                        if (editType.customColumn != null) {
419                            mType = editType;
420                            break;
421                        }
422                    }
423                    if (mType == null) return;
424
425                    mEntry.put(mKind.typeColumn, mType.rawValue);
426                    mEntry.put(mType.customColumn, customText);
427                    rebuildLabel();
428                    requestFocusForFirstEditField();
429                    onLabelRebuilt();
430                }
431            }
432        });
433
434        builder.setNegativeButton(android.R.string.cancel, null);
435
436        return builder.create();
437    }
438
439    /**
440     * Called after the label has changed (either chosen from the list or entered in the Dialog)
441     */
442    protected void onLabelRebuilt() {
443    }
444
445    protected void onTypeSelectionChange(int position) {
446        EditType selected = mEditTypeAdapter.getItem(position);
447        // See if the selection has in fact changed
448        if (mEditTypeAdapter.hasCustomSelection() && selected == CUSTOM_SELECTION) {
449            return;
450        }
451
452        if (mType == selected && mType.customColumn == null) {
453            return;
454        }
455
456        if (selected.customColumn != null) {
457            showDialog(DIALOG_ID_CUSTOM);
458        } else {
459            // User picked type, and we're sure it's ok to actually write the entry.
460            mType = selected;
461            mEntry.put(mKind.typeColumn, mType.rawValue);
462            rebuildLabel();
463            requestFocusForFirstEditField();
464            onLabelRebuilt();
465        }
466    }
467
468    /* package */
469    void showDialog(int bundleDialogId) {
470        Bundle bundle = new Bundle();
471        bundle.putInt(DIALOG_ID_KEY, bundleDialogId);
472        getDialogManager().showDialogInView(this, bundle);
473    }
474
475    private DialogManager getDialogManager() {
476        if (mDialogManager == null) {
477            Context context = getContext();
478            if (!(context instanceof DialogManager.DialogShowingViewActivity)) {
479                throw new IllegalStateException(
480                        "View must be hosted in an Activity that implements " +
481                        "DialogManager.DialogShowingViewActivity");
482            }
483            mDialogManager = ((DialogManager.DialogShowingViewActivity)context).getDialogManager();
484        }
485        return mDialogManager;
486    }
487
488    @Override
489    public Dialog createDialog(Bundle bundle) {
490        if (bundle == null) throw new IllegalArgumentException("bundle must not be null");
491        int dialogId = bundle.getInt(DIALOG_ID_KEY);
492        switch (dialogId) {
493            case DIALOG_ID_CUSTOM:
494                return createCustomDialog();
495            default:
496                throw new IllegalArgumentException("Invalid dialogId: " + dialogId);
497        }
498    }
499
500    protected abstract void requestFocusForFirstEditField();
501
502    private class EditTypeAdapter extends ArrayAdapter<EditType> {
503        private final LayoutInflater mInflater;
504        private boolean mHasCustomSelection;
505
506        public EditTypeAdapter(Context context) {
507            super(context, 0);
508            mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
509
510            if (mType != null && mType.customColumn != null) {
511
512                // Use custom label string when present
513                final String customText = mEntry.getAsString(mType.customColumn);
514                if (customText != null) {
515                    add(CUSTOM_SELECTION);
516                    mHasCustomSelection = true;
517                }
518            }
519
520            addAll(EntityModifier.getValidTypes(mState, mKind, mType));
521        }
522
523        public boolean hasCustomSelection() {
524            return mHasCustomSelection;
525        }
526
527        @Override
528        public View getView(int position, View convertView, ViewGroup parent) {
529            return createViewFromResource(
530                    position, convertView, parent, android.R.layout.simple_spinner_item);
531        }
532
533        @Override
534        public View getDropDownView(int position, View convertView, ViewGroup parent) {
535            return createViewFromResource(
536                    position, convertView, parent, android.R.layout.simple_spinner_dropdown_item);
537        }
538
539        private View createViewFromResource(int position, View convertView, ViewGroup parent,
540                int resource) {
541            View view;
542            TextView textView;
543
544            if (convertView == null) {
545                view = mInflater.inflate(resource, parent, false);
546            } else {
547                view = convertView;
548            }
549
550            textView = (TextView) view;
551
552            EditType type = getItem(position);
553            String text;
554            if (type == CUSTOM_SELECTION) {
555                text = mEntry.getAsString(mType.customColumn);
556            } else {
557                text = getContext().getString(type.labelRes);
558            }
559            textView.setText(text);
560            return view;
561        }
562    }
563}
564