LabeledEditorView.java revision 12a93637e406ba7f3da4db8d53cd035c2c1d6e6e
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.EditType;
22import com.android.contacts.model.DataKind;
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;
28
29import android.app.AlertDialog;
30import android.app.Dialog;
31import android.content.Context;
32import android.content.DialogInterface;
33import android.content.Entity;
34import android.os.Bundle;
35import android.os.Handler;
36import android.text.TextUtils;
37import android.util.AttributeSet;
38import android.view.LayoutInflater;
39import android.view.View;
40import android.view.ViewGroup;
41import android.view.inputmethod.EditorInfo;
42import android.widget.AdapterView;
43import android.widget.AdapterView.OnItemSelectedListener;
44import android.widget.ArrayAdapter;
45import android.widget.EditText;
46import android.widget.ImageView;
47import android.widget.LinearLayout;
48import android.widget.Spinner;
49import android.widget.TextView;
50
51import java.util.List;
52
53/**
54 * Base class for editors that handles labels and values. Uses
55 * {@link ValuesDelta} to read any existing {@link Entity} values, and to
56 * correctly write any changes values.
57 */
58public abstract class LabeledEditorView extends LinearLayout implements Editor, DialogShowingView {
59    protected static final String DIALOG_ID_KEY = "dialog_id";
60    private static final int DIALOG_ID_CUSTOM = 1;
61
62    private static final int INPUT_TYPE_CUSTOM = EditorInfo.TYPE_CLASS_TEXT
63            | EditorInfo.TYPE_TEXT_FLAG_CAP_WORDS;
64
65    private TextView mTitle;
66    private Spinner mLabel;
67    private EditTypeAdapter mEditTypeAdapter;
68    private View mDeleteContainer;
69    private ImageView mDelete;
70
71    private DataKind mKind;
72    private ValuesDelta mEntry;
73    private EntityDelta mState;
74    private boolean mReadOnly;
75    private boolean mWasEmpty = true;
76
77    private EditType mType;
78
79    private ViewIdGenerator mViewIdGenerator;
80    private DialogManager mDialogManager = null;
81    private EditorListener mListener;
82    protected int mMinLineItemHeight;
83
84    /**
85     * A marker in the spinner adapter of the currently selected custom type.
86     */
87    public static final EditType CUSTOM_SELECTION = new EditType(0, 0);
88
89    private OnItemSelectedListener mSpinnerListener = new OnItemSelectedListener() {
90
91        @Override
92        public void onItemSelected(
93                AdapterView<?> parent, View view, int position, long id) {
94            onTypeSelectionChange(position);
95        }
96
97        @Override
98        public void onNothingSelected(AdapterView<?> parent) {
99        }
100    };
101
102    public LabeledEditorView(Context context) {
103        super(context);
104        init(context);
105    }
106
107    public LabeledEditorView(Context context, AttributeSet attrs) {
108        super(context, attrs);
109        init(context);
110    }
111
112    public LabeledEditorView(Context context, AttributeSet attrs, int defStyle) {
113        super(context, attrs, defStyle);
114        init(context);
115    }
116
117    private void init(Context context) {
118        mMinLineItemHeight = context.getResources().getDimensionPixelSize(
119                R.dimen.editor_min_line_item_height);
120    }
121
122    /** {@inheritDoc} */
123    @Override
124    protected void onFinishInflate() {
125
126        mTitle = (TextView) findViewById(R.id.title);
127
128        mLabel = (Spinner) findViewById(R.id.spinner);
129        mLabel.setOnItemSelectedListener(mSpinnerListener);
130
131        mDelete = (ImageView) findViewById(R.id.delete_button);
132        mDeleteContainer = findViewById(R.id.delete_button_container);
133        mDeleteContainer.setOnClickListener(new OnClickListener() {
134            @Override
135            public void onClick(View v) {
136                // defer removal of this button so that the pressed state is visible shortly
137                new Handler().post(new Runnable() {
138                    @Override
139                    public void run() {
140                        // Keep around in model, but mark as deleted
141                        mEntry.markDeleted();
142
143                        ((ViewGroup) getParent()).removeView(LabeledEditorView.this);
144
145                        if (mListener != null) {
146                            // Notify listener when present
147                            mListener.onDeleted(LabeledEditorView.this);
148                        }
149                    }
150                });
151            }
152        });
153    }
154
155    public boolean isReadOnly() {
156        return mReadOnly;
157    }
158
159    public int getBaseline(int row) {
160        if (row == 0 && mLabel != null) {
161            return mLabel.getBaseline();
162        }
163        return -1;
164    }
165
166    /**
167     * Creates or removes the type/label button. Doesn't do anything if already correctly configured
168     */
169    private void setupLabelButton(boolean shouldExist) {
170        if (shouldExist) {
171            mLabel.setEnabled(!mReadOnly && isEnabled());
172            mLabel.setVisibility(View.VISIBLE);
173
174            // Since there's a spinner for this editor, use this as the title
175            // instead of the title TextView.
176            mTitle.setVisibility(View.GONE);
177        } else {
178            mLabel.setVisibility(View.GONE);
179        }
180    }
181
182    /**
183     * Creates or removes the remove button. Doesn't do anything if already correctly configured
184     */
185    private void setupDeleteButton(boolean shouldExist) {
186        if (shouldExist) {
187            mDeleteContainer.setVisibility(View.VISIBLE);
188            mDelete.setEnabled(!mReadOnly && isEnabled());
189        } else {
190            mDeleteContainer.setVisibility(View.GONE);
191        }
192    }
193
194    protected void onOptionalFieldVisibilityChange() {
195        if (mListener != null) {
196            mListener.onRequest(EditorListener.EDITOR_FORM_CHANGED);
197        }
198    }
199
200    @Override
201    public void setEditorListener(EditorListener listener) {
202        mListener = listener;
203    }
204
205    @Override
206    public void setDeletable(boolean deletable) {
207        setupDeleteButton(deletable);
208    }
209
210    @Override
211    public void setEnabled(boolean enabled) {
212        super.setEnabled(enabled);
213        mLabel.setEnabled(!mReadOnly && enabled);
214        mDelete.setEnabled(!mReadOnly && enabled);
215    }
216
217    public Spinner getLabel() {
218        return mLabel;
219    }
220
221    public ImageView getDelete() {
222        return mDelete;
223    }
224
225    protected DataKind getKind() {
226        return mKind;
227    }
228
229    protected ValuesDelta getEntry() {
230        return mEntry;
231    }
232
233    protected EditType getType() {
234        return mType;
235    }
236
237    /**
238     * Build the current label state based on selected {@link EditType} and
239     * possible custom label string.
240     */
241    private void rebuildLabel() {
242        mEditTypeAdapter = new EditTypeAdapter(mContext);
243        mLabel.setAdapter(mEditTypeAdapter);
244        if (mEditTypeAdapter.hasCustomSelection()) {
245            mLabel.setSelection(mEditTypeAdapter.getPosition(CUSTOM_SELECTION));
246        } else {
247            mLabel.setSelection(mEditTypeAdapter.getPosition(mType));
248        }
249    }
250
251    @Override
252    public void onFieldChanged(String column, String value) {
253        if (!isFieldChanged(column, value)) {
254            return;
255        }
256
257        // Field changes are saved directly
258        mEntry.put(column, value);
259        if (mListener != null) {
260            mListener.onRequest(EditorListener.FIELD_CHANGED);
261        }
262
263        boolean isEmpty = isEmpty();
264        if (mWasEmpty != isEmpty) {
265            if (isEmpty) {
266                mListener.onRequest(EditorListener.FIELD_TURNED_EMPTY);
267            } else {
268                mListener.onRequest(EditorListener.FIELD_TURNED_NON_EMPTY);
269            }
270            mWasEmpty = isEmpty;
271        }
272    }
273
274    protected boolean isFieldChanged(String column, String value) {
275        final String dbValue = mEntry.getAsString(column);
276        // nullable fields (e.g. Middle Name) are usually represented as empty columns,
277        // so lets treat null and empty space equivalently here
278        final String dbValueNoNull = dbValue == null ? "" : dbValue;
279        final String valueNoNull = value == null ? "" : value;
280        return !TextUtils.equals(dbValueNoNull, valueNoNull);
281    }
282
283    protected void rebuildValues() {
284        setValues(mKind, mEntry, mState, mReadOnly, mViewIdGenerator);
285    }
286
287    /**
288     * Prepare this editor using the given {@link DataKind} for defining
289     * structure and {@link ValuesDelta} describing the content to edit.
290     */
291    @Override
292    public void setValues(DataKind kind, ValuesDelta entry, EntityDelta state, boolean readOnly,
293            ViewIdGenerator vig) {
294        mKind = kind;
295        mEntry = entry;
296        mState = state;
297        mReadOnly = readOnly;
298        mViewIdGenerator = vig;
299        setId(vig.getId(state, kind, entry, ViewIdGenerator.NO_VIEW_INDEX));
300
301        if (!entry.isVisible()) {
302            // Hide ourselves entirely if deleted
303            setVisibility(View.GONE);
304            return;
305        }
306        setVisibility(View.VISIBLE);
307
308        // TODO: handle resources from remote packages
309        String titleString = (kind.titleRes == -1 || kind.titleRes == 0)
310                ? ""
311                : getResources().getString(kind.titleRes);
312
313        // Setup title (may not be shown if there is a Spinner setup later).
314        mTitle.setText(titleString.toUpperCase());
315
316        // Display label selector if multiple types available
317        final boolean hasTypes = EntityModifier.hasEditTypes(kind);
318        setupLabelButton(hasTypes);
319        mLabel.setEnabled(!readOnly && isEnabled());
320        if (hasTypes) {
321            mType = EntityModifier.getCurrentType(entry, kind);
322            rebuildLabel();
323        }
324    }
325
326    public ValuesDelta getValues() {
327        return mEntry;
328    }
329
330    /**
331     * Prepare dialog for entering a custom label. The input value is trimmed: white spaces before
332     * and after the input text is removed.
333     * <p>
334     * If the final value is empty, this change request is ignored;
335     * no empty text is allowed in any custom label.
336     */
337    private Dialog createCustomDialog() {
338        final EditText customType = new EditText(mContext);
339        customType.setId(R.id.custom_dialog_content);
340        customType.setInputType(INPUT_TYPE_CUSTOM);
341        customType.setSaveEnabled(true);
342        customType.requestFocus();
343
344        final AlertDialog.Builder builder = new AlertDialog.Builder(mContext);
345        builder.setTitle(R.string.customLabelPickerTitle);
346        builder.setView(customType);
347
348        builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
349            @Override
350            public void onClick(DialogInterface dialog, int which) {
351                final String customText = customType.getText().toString().trim();
352                if (ContactsUtils.isGraphic(customText)) {
353                    final List<EditType> allTypes =
354                            EntityModifier.getValidTypes(mState, mKind, null);
355                    mType = null;
356                    for (EditType editType : allTypes) {
357                        if (editType.customColumn != null) {
358                            mType = editType;
359                            break;
360                        }
361                    }
362                    if (mType == null) return;
363
364                    mEntry.put(mKind.typeColumn, mType.rawValue);
365                    mEntry.put(mType.customColumn, customText);
366                    rebuildLabel();
367                    requestFocusForFirstEditField();
368                    onLabelRebuilt();
369                }
370            }
371        });
372
373        builder.setNegativeButton(android.R.string.cancel, null);
374
375        return builder.create();
376    }
377
378    /**
379     * Called after the label has changed (either chosen from the list or entered in the Dialog)
380     */
381    protected void onLabelRebuilt() {
382    }
383
384    protected void onTypeSelectionChange(int position) {
385        EditType selected = mEditTypeAdapter.getItem(position);
386        // See if the selection has in fact changed
387        if (mEditTypeAdapter.hasCustomSelection() && selected == CUSTOM_SELECTION) {
388            return;
389        }
390
391        if (mType == selected && mType.customColumn == null) {
392            return;
393        }
394
395        if (selected.customColumn != null) {
396            showDialog(DIALOG_ID_CUSTOM);
397        } else {
398            // User picked type, and we're sure it's ok to actually write the entry.
399            mType = selected;
400            mEntry.put(mKind.typeColumn, mType.rawValue);
401            rebuildLabel();
402            requestFocusForFirstEditField();
403            onLabelRebuilt();
404        }
405    }
406
407    /* package */
408    void showDialog(int bundleDialogId) {
409        Bundle bundle = new Bundle();
410        bundle.putInt(DIALOG_ID_KEY, bundleDialogId);
411        getDialogManager().showDialogInView(this, bundle);
412    }
413
414    private DialogManager getDialogManager() {
415        if (mDialogManager == null) {
416            Context context = getContext();
417            if (!(context instanceof DialogManager.DialogShowingViewActivity)) {
418                throw new IllegalStateException(
419                        "View must be hosted in an Activity that implements " +
420                        "DialogManager.DialogShowingViewActivity");
421            }
422            mDialogManager = ((DialogManager.DialogShowingViewActivity)context).getDialogManager();
423        }
424        return mDialogManager;
425    }
426
427    @Override
428    public Dialog createDialog(Bundle bundle) {
429        if (bundle == null) throw new IllegalArgumentException("bundle must not be null");
430        int dialogId = bundle.getInt(DIALOG_ID_KEY);
431        switch (dialogId) {
432            case DIALOG_ID_CUSTOM:
433                return createCustomDialog();
434            default:
435                throw new IllegalArgumentException("Invalid dialogId: " + dialogId);
436        }
437    }
438
439    protected abstract void requestFocusForFirstEditField();
440
441    private class EditTypeAdapter extends ArrayAdapter<EditType> {
442        private final LayoutInflater mInflater;
443        private boolean mHasCustomSelection;
444        private int mTextSize;
445
446        public EditTypeAdapter(Context context) {
447            super(context, 0);
448            mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
449            mTextSize = getResources().getDimensionPixelSize(
450                    R.dimen.editor_field_spinner_text_size);
451
452            if (mType != null && mType.customColumn != null) {
453
454                // Use custom label string when present
455                final String customText = mEntry.getAsString(mType.customColumn);
456                if (customText != null) {
457                    add(CUSTOM_SELECTION);
458                    mHasCustomSelection = true;
459                }
460            }
461
462            addAll(EntityModifier.getValidTypes(mState, mKind, mType));
463        }
464
465        public boolean hasCustomSelection() {
466            return mHasCustomSelection;
467        }
468
469        @Override
470        public View getView(int position, View convertView, ViewGroup parent) {
471            return createViewFromResource(
472                    position, convertView, parent, android.R.layout.simple_spinner_item);
473        }
474
475        @Override
476        public View getDropDownView(int position, View convertView, ViewGroup parent) {
477            return createViewFromResource(
478                    position, convertView, parent, android.R.layout.simple_spinner_dropdown_item);
479        }
480
481        private View createViewFromResource(int position, View convertView, ViewGroup parent,
482                int resource) {
483            View view;
484            TextView textView;
485
486            if (convertView == null) {
487                view = mInflater.inflate(resource, parent, false);
488            } else {
489                view = convertView;
490            }
491
492            textView = (TextView) view;
493            textView.setTextSize(mTextSize);
494
495            EditType type = getItem(position);
496            String text;
497            if (type == CUSTOM_SELECTION) {
498                text = mEntry.getAsString(mType.customColumn);
499            } else {
500                text = getContext().getString(type.labelRes);
501            }
502            textView.setText(text.toUpperCase());
503            return view;
504        }
505    }
506}
507