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 
LabeledEditorView(Context context)110     public LabeledEditorView(Context context) {
111         super(context);
112         init(context);
113     }
114 
LabeledEditorView(Context context, AttributeSet attrs)115     public LabeledEditorView(Context context, AttributeSet attrs) {
116         super(context, attrs);
117         init(context);
118     }
119 
LabeledEditorView(Context context, AttributeSet attrs, int defStyle)120     public LabeledEditorView(Context context, AttributeSet attrs, int defStyle) {
121         super(context, attrs, defStyle);
122         init(context);
123     }
124 
getRawContactId()125     public Long getRawContactId() {
126         return mState == null ? null : mState.getRawContactId();
127     }
128 
init(Context context)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
onFinishInflate()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
onAttachedToWindow()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
onDetachedFromWindow()183     protected void onDetachedFromWindow() {
184         super.onDetachedFromWindow();
185         mIsAttachedToWindow = false;
186     }
187 
188     @Override
markDeleted()189     public void markDeleted() {
190         // Keep around in model, but mark as deleted
191         mEntry.markDeleted();
192     }
193 
194     @Override
deleteEditor()195     public void deleteEditor() {
196         markDeleted();
197 
198         // Remove the view
199         EditorAnimator.getInstance().removeEditorView(this);
200     }
201 
isReadOnly()202     public boolean isReadOnly() {
203         return mReadOnly;
204     }
205 
getBaseline(int row)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      */
setupLabelButton(boolean shouldExist)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      */
setupDeleteButton()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 
setDeleteButtonVisible(boolean visible)237     public void setDeleteButtonVisible(boolean visible) {
238         if (mIsDeletable) {
239             mDeleteContainer.setVisibility(visible ? View.VISIBLE : View.INVISIBLE);
240         }
241     }
242 
onOptionalFieldVisibilityChange()243     protected void onOptionalFieldVisibilityChange() {
244         if (mListener != null) {
245             mListener.onRequest(EditorListener.EDITOR_FORM_CHANGED);
246         }
247     }
248 
249     @Override
setEditorListener(EditorListener listener)250     public void setEditorListener(EditorListener listener) {
251         mListener = listener;
252     }
253 
getEditorListener()254     protected EditorListener getEditorListener(){
255         return mListener;
256     }
257 
258     @Override
setDeletable(boolean deletable)259     public void setDeletable(boolean deletable) {
260         mIsDeletable = deletable;
261         setupDeleteButton();
262     }
263 
264     @Override
setEnabled(boolean enabled)265     public void setEnabled(boolean enabled) {
266         super.setEnabled(enabled);
267         mLabel.setEnabled(!mReadOnly && enabled);
268         mDelete.setEnabled(!mReadOnly && enabled);
269     }
270 
getLabel()271     public Spinner getLabel() {
272         return mLabel;
273     }
274 
getDelete()275     public ImageView getDelete() {
276         return mDelete;
277     }
278 
getKind()279     protected DataKind getKind() {
280         return mKind;
281     }
282 
getEntry()283     protected ValuesDelta getEntry() {
284         return mEntry;
285     }
286 
getType()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      */
rebuildLabel()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
onFieldChanged(String column, String value)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
updatePhonetic(String column, String value)336     public void updatePhonetic(String column, String value) {
337     }
338 
339     /** {@inheritDoc} */
340     @Override
getPhonetic(String column)341     public String getPhonetic(String column){
342         return "";
343     }
344 
saveValue(String column, String value)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      */
updateEmptiness()353     protected final void updateEmptiness() {
354         mWasEmpty = isEmpty();
355     }
356 
notifyEditorListener()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 
isFieldChanged(String column, String value)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 
rebuildValues()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
setValues(DataKind kind, ValuesDelta entry, RawContactDelta state, boolean readOnly, ViewIdGenerator vig)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 
getValues()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      */
createCustomDialog()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 
updateCustomDialogOkButtonState(AlertDialog dialog, EditText editText)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      */
onLabelRebuilt()516     protected void onLabelRebuilt() {
517     }
518 
onTypeSelectionChange(int position)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 */
showDialog(int bundleDialogId)544     void showDialog(int bundleDialogId) {
545         Bundle bundle = new Bundle();
546         bundle.putInt(DIALOG_ID_KEY, bundleDialogId);
547         getDialogManager().showDialogInView(this, bundle);
548     }
549 
getDialogManager()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
createDialog(Bundle bundle)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 
requestFocusForFirstEditField()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 
EditTypeAdapter(Context context)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 
hasCustomSelection()605         public boolean hasCustomSelection() {
606             return mHasCustomSelection;
607         }
608 
609         @Override
getView(int position, View convertView, ViewGroup parent)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
getDropDownView(int position, View convertView, ViewGroup parent)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 
createViewFromResource(int position, View convertView, ViewGroup parent, int resource)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 
setSelectedIndex(int selectedIndex)660         public void setSelectedIndex(int selectedIndex) {
661             mSelectedIndex = selectedIndex;
662         }
663     }
664 }
665