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