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