Home | History | Annotate | Download | only in editor
      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 
     17 package com.android.contacts.editor;
     18 
     19 import android.app.AlertDialog;
     20 import android.app.Dialog;
     21 import android.content.Context;
     22 import android.content.DialogInterface;
     23 import android.content.DialogInterface.OnShowListener;
     24 import android.os.Bundle;
     25 import android.os.Handler;
     26 import android.text.Editable;
     27 import android.text.TextUtils;
     28 import android.text.TextWatcher;
     29 import android.util.AttributeSet;
     30 import android.util.TypedValue;
     31 import android.view.LayoutInflater;
     32 import android.view.View;
     33 import android.view.ViewGroup;
     34 import android.view.WindowManager;
     35 import android.view.inputmethod.EditorInfo;
     36 import android.widget.AdapterView;
     37 import android.widget.AdapterView.OnItemSelectedListener;
     38 import android.widget.ArrayAdapter;
     39 import android.widget.Button;
     40 import android.widget.EditText;
     41 import android.widget.ImageView;
     42 import android.widget.LinearLayout;
     43 import android.widget.Spinner;
     44 import android.widget.TextView;
     45 
     46 import com.android.contacts.R;
     47 import com.android.contacts.common.model.RawContactDelta;
     48 import com.android.contacts.common.ContactsUtils;
     49 import com.android.contacts.common.model.ValuesDelta;
     50 import com.android.contacts.common.model.RawContactModifier;
     51 import com.android.contacts.common.model.account.AccountType.EditType;
     52 import com.android.contacts.common.model.dataitem.DataKind;
     53 import com.android.contacts.util.DialogManager;
     54 import com.android.contacts.util.DialogManager.DialogShowingView;
     55 
     56 import java.util.List;
     57 
     58 /**
     59  * Base class for editors that handles labels and values. Uses
     60  * {@link ValuesDelta} to read any existing {@link RawContact} values, and to
     61  * correctly write any changes values.
     62  */
     63 public abstract class LabeledEditorView extends LinearLayout implements Editor, DialogShowingView {
     64     protected static final String DIALOG_ID_KEY = "dialog_id";
     65     private static final int DIALOG_ID_CUSTOM = 1;
     66 
     67     private static final int INPUT_TYPE_CUSTOM = EditorInfo.TYPE_CLASS_TEXT
     68             | EditorInfo.TYPE_TEXT_FLAG_CAP_WORDS;
     69 
     70     private Spinner mLabel;
     71     private EditTypeAdapter mEditTypeAdapter;
     72     private View mDeleteContainer;
     73     private ImageView mDelete;
     74 
     75     private DataKind mKind;
     76     private ValuesDelta mEntry;
     77     private RawContactDelta mState;
     78     private boolean mReadOnly;
     79     private boolean mWasEmpty = true;
     80     private boolean mIsDeletable = true;
     81     private boolean mIsAttachedToWindow;
     82 
     83     private EditType mType;
     84 
     85     private ViewIdGenerator mViewIdGenerator;
     86     private DialogManager mDialogManager = null;
     87     private EditorListener mListener;
     88     protected int mMinLineItemHeight;
     89 
     90     /**
     91      * A marker in the spinner adapter of the currently selected custom type.
     92      */
     93     public static final EditType CUSTOM_SELECTION = new EditType(0, 0);
     94 
     95     private OnItemSelectedListener mSpinnerListener = new OnItemSelectedListener() {
     96 
     97         @Override
     98         public void onItemSelected(
     99                 AdapterView<?> parent, View view, int position, long id) {
    100             onTypeSelectionChange(position);
    101         }
    102 
    103         @Override
    104         public void onNothingSelected(AdapterView<?> parent) {
    105         }
    106     };
    107 
    108     public LabeledEditorView(Context context) {
    109         super(context);
    110         init(context);
    111     }
    112 
    113     public LabeledEditorView(Context context, AttributeSet attrs) {
    114         super(context, attrs);
    115         init(context);
    116     }
    117 
    118     public LabeledEditorView(Context context, AttributeSet attrs, int defStyle) {
    119         super(context, attrs, defStyle);
    120         init(context);
    121     }
    122 
    123     public Long getRawContactId() {
    124         return mState == null ? null : mState.getRawContactId();
    125     }
    126 
    127     private void init(Context context) {
    128         mMinLineItemHeight = context.getResources().getDimensionPixelSize(
    129                 R.dimen.editor_min_line_item_height);
    130     }
    131 
    132     /** {@inheritDoc} */
    133     @Override
    134     protected void onFinishInflate() {
    135 
    136         mLabel = (Spinner) findViewById(R.id.spinner);
    137         // Turn off the Spinner's own state management. We do this ourselves on rotation
    138         mLabel.setId(View.NO_ID);
    139         mLabel.setOnItemSelectedListener(mSpinnerListener);
    140         ViewSelectedFilter.suppressViewSelectedEvent(mLabel);
    141 
    142         mDelete = (ImageView) findViewById(R.id.delete_button);
    143         mDeleteContainer = findViewById(R.id.delete_button_container);
    144         mDeleteContainer.setOnClickListener(new OnClickListener() {
    145             @Override
    146             public void onClick(View v) {
    147                 // defer removal of this button so that the pressed state is visible shortly
    148                 new Handler().post(new Runnable() {
    149                     @Override
    150                     public void run() {
    151                         // Don't do anything if the view is no longer attached to the window
    152                         // (This check is needed because when this {@link Runnable} is executed,
    153                         // we can't guarantee the view is still valid.
    154                         if (!mIsAttachedToWindow) {
    155                             return;
    156                         }
    157                         // Send the delete request to the listener (which will in turn call
    158                         // deleteEditor() on this view if the deletion is valid - i.e. this is not
    159                         // the last {@link Editor} in the section).
    160                         if (mListener != null) {
    161                             mListener.onDeleteRequested(LabeledEditorView.this);
    162                         }
    163                     }
    164                 });
    165             }
    166         });
    167 
    168         setPadding(getPaddingLeft(), getPaddingTop(), getPaddingRight(),
    169                 (int) getResources().getDimension(R.dimen.editor_padding_between_editor_views));
    170     }
    171 
    172     @Override
    173     protected void onAttachedToWindow() {
    174         super.onAttachedToWindow();
    175         // Keep track of when the view is attached or detached from the window, so we know it's
    176         // safe to remove views (in case the user requests to delete this editor).
    177         mIsAttachedToWindow = true;
    178     }
    179 
    180     @Override
    181     protected void onDetachedFromWindow() {
    182         super.onDetachedFromWindow();
    183         mIsAttachedToWindow = false;
    184     }
    185 
    186     @Override
    187     public void markDeleted() {
    188         // Keep around in model, but mark as deleted
    189         mEntry.markDeleted();
    190     }
    191 
    192     @Override
    193     public void deleteEditor() {
    194         markDeleted();
    195 
    196         // Remove the view
    197         EditorAnimator.getInstance().removeEditorView(this);
    198     }
    199 
    200     public boolean isReadOnly() {
    201         return mReadOnly;
    202     }
    203 
    204     public int getBaseline(int row) {
    205         if (row == 0 && mLabel != null) {
    206             return mLabel.getBaseline();
    207         }
    208         return -1;
    209     }
    210 
    211     /**
    212      * Configures the visibility of the type label button and enables or disables it properly.
    213      */
    214     private void setupLabelButton(boolean shouldExist) {
    215         if (shouldExist) {
    216             mLabel.setEnabled(!mReadOnly && isEnabled());
    217             mLabel.setVisibility(View.VISIBLE);
    218         } else {
    219             mLabel.setVisibility(View.GONE);
    220         }
    221     }
    222 
    223     /**
    224      * Configures the visibility of the "delete" button and enables or disables it properly.
    225      */
    226     private void setupDeleteButton() {
    227         if (mIsDeletable) {
    228             mDeleteContainer.setVisibility(View.VISIBLE);
    229             mDelete.setEnabled(!mReadOnly && isEnabled());
    230         } else {
    231             mDeleteContainer.setVisibility(View.GONE);
    232         }
    233     }
    234 
    235     public void setDeleteButtonVisible(boolean visible) {
    236         if (mIsDeletable) {
    237             mDeleteContainer.setVisibility(visible ? View.VISIBLE : View.INVISIBLE);
    238         }
    239     }
    240 
    241     protected void onOptionalFieldVisibilityChange() {
    242         if (mListener != null) {
    243             mListener.onRequest(EditorListener.EDITOR_FORM_CHANGED);
    244         }
    245     }
    246 
    247     @Override
    248     public void setEditorListener(EditorListener listener) {
    249         mListener = listener;
    250     }
    251 
    252     protected EditorListener getEditorListener(){
    253         return mListener;
    254     }
    255 
    256     @Override
    257     public void setDeletable(boolean deletable) {
    258         mIsDeletable = deletable;
    259         setupDeleteButton();
    260     }
    261 
    262     @Override
    263     public void setEnabled(boolean enabled) {
    264         super.setEnabled(enabled);
    265         mLabel.setEnabled(!mReadOnly && enabled);
    266         mDelete.setEnabled(!mReadOnly && enabled);
    267     }
    268 
    269     public Spinner getLabel() {
    270         return mLabel;
    271     }
    272 
    273     public ImageView getDelete() {
    274         return mDelete;
    275     }
    276 
    277     protected DataKind getKind() {
    278         return mKind;
    279     }
    280 
    281     protected ValuesDelta getEntry() {
    282         return mEntry;
    283     }
    284 
    285     protected EditType getType() {
    286         return mType;
    287     }
    288 
    289     /**
    290      * Build the current label state based on selected {@link EditType} and
    291      * possible custom label string.
    292      */
    293     public void rebuildLabel() {
    294         mEditTypeAdapter = new EditTypeAdapter(getContext());
    295         mLabel.setAdapter(mEditTypeAdapter);
    296         if (mEditTypeAdapter.hasCustomSelection()) {
    297             mLabel.setSelection(mEditTypeAdapter.getPosition(CUSTOM_SELECTION));
    298             mDeleteContainer.setContentDescription(
    299                     getContext().getString(R.string.editor_delete_view_description,
    300                             mEntry.getAsString(mType.customColumn),
    301                             getContext().getString(mKind.titleRes)));
    302         } else {
    303             mLabel.setSelection(mEditTypeAdapter.getPosition(mType));
    304             if (mType != null) {
    305                 mDeleteContainer.setContentDescription(
    306                         getContext().getString(R.string.editor_delete_view_description,
    307                                 getContext().getString(mType.labelRes),
    308                                 getContext().getString(mKind.titleRes)));
    309             } else {
    310                 mDeleteContainer.setContentDescription(
    311                         getContext().getString(R.string.editor_delete_view_description_short,
    312                                 getContext().getString(mKind.titleRes)));
    313             }
    314 
    315         }
    316     }
    317 
    318     @Override
    319     public void onFieldChanged(String column, String value) {
    320         if (!isFieldChanged(column, value)) {
    321             return;
    322         }
    323 
    324         // Field changes are saved directly
    325         saveValue(column, value);
    326 
    327         // Notify listener if applicable
    328         notifyEditorListener();
    329     }
    330 
    331     protected void saveValue(String column, String value) {
    332         mEntry.put(column, value);
    333     }
    334 
    335     /**
    336      * Sub classes should call this at the end of {@link #setValues} once they finish changing
    337      * isEmpty(). This is needed to fix b/18194655.
    338      */
    339     protected final void updateEmptiness() {
    340         mWasEmpty = isEmpty();
    341     }
    342 
    343     protected void notifyEditorListener() {
    344         if (mListener != null) {
    345             mListener.onRequest(EditorListener.FIELD_CHANGED);
    346         }
    347 
    348         boolean isEmpty = isEmpty();
    349         if (mWasEmpty != isEmpty) {
    350             if (isEmpty) {
    351                 if (mListener != null) {
    352                     mListener.onRequest(EditorListener.FIELD_TURNED_EMPTY);
    353                 }
    354                 if (mIsDeletable) mDeleteContainer.setVisibility(View.INVISIBLE);
    355             } else {
    356                 if (mListener != null) {
    357                     mListener.onRequest(EditorListener.FIELD_TURNED_NON_EMPTY);
    358                 }
    359                 if (mIsDeletable) mDeleteContainer.setVisibility(View.VISIBLE);
    360             }
    361             mWasEmpty = isEmpty;
    362 
    363             // Update the label text color
    364             if (mEditTypeAdapter != null) {
    365                 mEditTypeAdapter.notifyDataSetChanged();
    366             }
    367         }
    368     }
    369 
    370     protected boolean isFieldChanged(String column, String value) {
    371         final String dbValue = mEntry.getAsString(column);
    372         // nullable fields (e.g. Middle Name) are usually represented as empty columns,
    373         // so lets treat null and empty space equivalently here
    374         final String dbValueNoNull = dbValue == null ? "" : dbValue;
    375         final String valueNoNull = value == null ? "" : value;
    376         return !TextUtils.equals(dbValueNoNull, valueNoNull);
    377     }
    378 
    379     protected void rebuildValues() {
    380         setValues(mKind, mEntry, mState, mReadOnly, mViewIdGenerator);
    381     }
    382 
    383     /**
    384      * Prepare this editor using the given {@link DataKind} for defining structure and
    385      * {@link ValuesDelta} describing the content to edit. When overriding this, be careful
    386      * to call {@link #updateEmptiness} at the end.
    387      */
    388     @Override
    389     public void setValues(DataKind kind, ValuesDelta entry, RawContactDelta state, boolean readOnly,
    390             ViewIdGenerator vig) {
    391         mKind = kind;
    392         mEntry = entry;
    393         mState = state;
    394         mReadOnly = readOnly;
    395         mViewIdGenerator = vig;
    396         setId(vig.getId(state, kind, entry, ViewIdGenerator.NO_VIEW_INDEX));
    397 
    398         if (!entry.isVisible()) {
    399             // Hide ourselves entirely if deleted
    400             setVisibility(View.GONE);
    401             return;
    402         }
    403         setVisibility(View.VISIBLE);
    404 
    405         // Display label selector if multiple types available
    406         final boolean hasTypes = RawContactModifier.hasEditTypes(kind);
    407         setupLabelButton(hasTypes);
    408         mLabel.setEnabled(!readOnly && isEnabled());
    409         mLabel.setContentDescription(getContext().getResources().getString(mKind.titleRes));
    410 
    411         if (hasTypes) {
    412             mType = RawContactModifier.getCurrentType(entry, kind);
    413             rebuildLabel();
    414         }
    415     }
    416 
    417     public ValuesDelta getValues() {
    418         return mEntry;
    419     }
    420 
    421     /**
    422      * Prepare dialog for entering a custom label. The input value is trimmed: white spaces before
    423      * and after the input text is removed.
    424      * <p>
    425      * If the final value is empty, this change request is ignored;
    426      * no empty text is allowed in any custom label.
    427      */
    428     private Dialog createCustomDialog() {
    429         final AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
    430         final LayoutInflater layoutInflater = LayoutInflater.from(builder.getContext());
    431         builder.setTitle(R.string.customLabelPickerTitle);
    432 
    433         final View view = layoutInflater.inflate(R.layout.contact_editor_label_name_dialog, null);
    434         final EditText editText = (EditText) view.findViewById(R.id.custom_dialog_content);
    435         editText.setInputType(INPUT_TYPE_CUSTOM);
    436         editText.setSaveEnabled(true);
    437 
    438         builder.setView(view);
    439         editText.requestFocus();
    440 
    441         builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
    442             @Override
    443             public void onClick(DialogInterface dialog, int which) {
    444                 final String customText = editText.getText().toString().trim();
    445                 if (ContactsUtils.isGraphic(customText)) {
    446                     final List<EditType> allTypes =
    447                             RawContactModifier.getValidTypes(mState, mKind, null, true, null, true);
    448                     mType = null;
    449                     for (EditType editType : allTypes) {
    450                         if (editType.customColumn != null) {
    451                             mType = editType;
    452                             break;
    453                         }
    454                     }
    455                     if (mType == null) return;
    456 
    457                     mEntry.put(mKind.typeColumn, mType.rawValue);
    458                     mEntry.put(mType.customColumn, customText);
    459                     rebuildLabel();
    460                     requestFocusForFirstEditField();
    461                     onLabelRebuilt();
    462                 }
    463             }
    464         });
    465 
    466         builder.setNegativeButton(android.R.string.cancel, null);
    467 
    468         final AlertDialog dialog = builder.create();
    469         dialog.setOnShowListener(new OnShowListener() {
    470             @Override
    471             public void onShow(DialogInterface dialogInterface) {
    472                 updateCustomDialogOkButtonState(dialog, editText);
    473             }
    474         });
    475         editText.addTextChangedListener(new TextWatcher() {
    476             @Override
    477             public void onTextChanged(CharSequence s, int start, int before, int count) {
    478             }
    479 
    480             @Override
    481             public void beforeTextChanged(CharSequence s, int start, int count, int after) {
    482             }
    483 
    484             @Override
    485             public void afterTextChanged(Editable s) {
    486                 updateCustomDialogOkButtonState(dialog, editText);
    487             }
    488         });
    489         dialog.getWindow().setSoftInputMode(
    490                 WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE);
    491 
    492         return dialog;
    493     }
    494 
    495     /* package */ void updateCustomDialogOkButtonState(AlertDialog dialog, EditText editText) {
    496         final Button okButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
    497         okButton.setEnabled(!TextUtils.isEmpty(editText.getText().toString().trim()));
    498     }
    499 
    500     /**
    501      * Called after the label has changed (either chosen from the list or entered in the Dialog)
    502      */
    503     protected void onLabelRebuilt() {
    504     }
    505 
    506     protected void onTypeSelectionChange(int position) {
    507         EditType selected = mEditTypeAdapter.getItem(position);
    508         // See if the selection has in fact changed
    509         if (mEditTypeAdapter.hasCustomSelection() && selected == CUSTOM_SELECTION) {
    510             return;
    511         }
    512 
    513         if (mType == selected && mType.customColumn == null) {
    514             return;
    515         }
    516 
    517         if (selected.customColumn != null) {
    518             showDialog(DIALOG_ID_CUSTOM);
    519         } else {
    520             // User picked type, and we're sure it's ok to actually write the entry.
    521             mType = selected;
    522             mEntry.put(mKind.typeColumn, mType.rawValue);
    523             rebuildLabel();
    524             requestFocusForFirstEditField();
    525             onLabelRebuilt();
    526         }
    527     }
    528 
    529     /* package */
    530     void showDialog(int bundleDialogId) {
    531         Bundle bundle = new Bundle();
    532         bundle.putInt(DIALOG_ID_KEY, bundleDialogId);
    533         getDialogManager().showDialogInView(this, bundle);
    534     }
    535 
    536     private DialogManager getDialogManager() {
    537         if (mDialogManager == null) {
    538             Context context = getContext();
    539             if (!(context instanceof DialogManager.DialogShowingViewActivity)) {
    540                 throw new IllegalStateException(
    541                         "View must be hosted in an Activity that implements " +
    542                         "DialogManager.DialogShowingViewActivity");
    543             }
    544             mDialogManager = ((DialogManager.DialogShowingViewActivity)context).getDialogManager();
    545         }
    546         return mDialogManager;
    547     }
    548 
    549     @Override
    550     public Dialog createDialog(Bundle bundle) {
    551         if (bundle == null) throw new IllegalArgumentException("bundle must not be null");
    552         int dialogId = bundle.getInt(DIALOG_ID_KEY);
    553         switch (dialogId) {
    554             case DIALOG_ID_CUSTOM:
    555                 return createCustomDialog();
    556             default:
    557                 throw new IllegalArgumentException("Invalid dialogId: " + dialogId);
    558         }
    559     }
    560 
    561     protected abstract void requestFocusForFirstEditField();
    562 
    563     private class EditTypeAdapter extends ArrayAdapter<EditType> {
    564         private final LayoutInflater mInflater;
    565         private boolean mHasCustomSelection;
    566         private int mTextColorHintUnfocused;
    567         private int mTextColorDark;
    568 
    569         public EditTypeAdapter(Context context) {
    570             super(context, 0);
    571             mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    572             mTextColorHintUnfocused = context.getResources().getColor(
    573                     R.color.editor_disabled_text_color);
    574             mTextColorDark = context.getResources().getColor(R.color.primary_text_color);
    575 
    576 
    577             if (mType != null && mType.customColumn != null) {
    578 
    579                 // Use custom label string when present
    580                 final String customText = mEntry.getAsString(mType.customColumn);
    581                 if (customText != null) {
    582                     add(CUSTOM_SELECTION);
    583                     mHasCustomSelection = true;
    584                 }
    585             }
    586 
    587             addAll(RawContactModifier.getValidTypes(mState, mKind, mType, true, null, false));
    588         }
    589 
    590         public boolean hasCustomSelection() {
    591             return mHasCustomSelection;
    592         }
    593 
    594         @Override
    595         public View getView(int position, View convertView, ViewGroup parent) {
    596             final TextView view = createViewFromResource(
    597                     position, convertView, parent, R.layout.edit_simple_spinner_item);
    598             // We don't want any background on this view. The background would obscure
    599             // the spinner's background.
    600             view.setBackground(null);
    601             // The text color should be a very light hint color when unfocused and empty. When
    602             // focused and empty, use a less light hint color. When non-empty, use a dark non-hint
    603             // color.
    604             if (!LabeledEditorView.this.isEmpty()) {
    605                 view.setTextColor(mTextColorDark);
    606             } else {
    607                 view.setTextColor(mTextColorHintUnfocused);
    608             }
    609             return view;
    610         }
    611 
    612         @Override
    613         public View getDropDownView(int position, View convertView, ViewGroup parent) {
    614             return createViewFromResource(
    615                     position, convertView, parent, android.R.layout.simple_spinner_dropdown_item);
    616         }
    617 
    618         private TextView createViewFromResource(int position, View convertView, ViewGroup parent,
    619                 int resource) {
    620             TextView textView;
    621 
    622             if (convertView == null) {
    623                 textView = (TextView) mInflater.inflate(resource, parent, false);
    624                 textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources().getDimension(
    625                         R.dimen.editor_form_text_size));
    626                 textView.setTextColor(mTextColorDark);
    627             } else {
    628                 textView = (TextView) convertView;
    629             }
    630 
    631             EditType type = getItem(position);
    632             String text;
    633             if (type == CUSTOM_SELECTION) {
    634                 text = mEntry.getAsString(mType.customColumn);
    635             } else {
    636                 text = getContext().getString(type.labelRes);
    637             }
    638             textView.setText(text);
    639             return textView;
    640         }
    641     }
    642 }
    643