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 
LabeledEditorView(Context context)108     public LabeledEditorView(Context context) {
109         super(context);
110         init(context);
111     }
112 
LabeledEditorView(Context context, AttributeSet attrs)113     public LabeledEditorView(Context context, AttributeSet attrs) {
114         super(context, attrs);
115         init(context);
116     }
117 
LabeledEditorView(Context context, AttributeSet attrs, int defStyle)118     public LabeledEditorView(Context context, AttributeSet attrs, int defStyle) {
119         super(context, attrs, defStyle);
120         init(context);
121     }
122 
getRawContactId()123     public Long getRawContactId() {
124         return mState == null ? null : mState.getRawContactId();
125     }
126 
init(Context context)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
onFinishInflate()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
onAttachedToWindow()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
onDetachedFromWindow()181     protected void onDetachedFromWindow() {
182         super.onDetachedFromWindow();
183         mIsAttachedToWindow = false;
184     }
185 
186     @Override
markDeleted()187     public void markDeleted() {
188         // Keep around in model, but mark as deleted
189         mEntry.markDeleted();
190     }
191 
192     @Override
deleteEditor()193     public void deleteEditor() {
194         markDeleted();
195 
196         // Remove the view
197         EditorAnimator.getInstance().removeEditorView(this);
198     }
199 
isReadOnly()200     public boolean isReadOnly() {
201         return mReadOnly;
202     }
203 
getBaseline(int row)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      */
setupLabelButton(boolean shouldExist)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      */
setupDeleteButton()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 
setDeleteButtonVisible(boolean visible)235     public void setDeleteButtonVisible(boolean visible) {
236         if (mIsDeletable) {
237             mDeleteContainer.setVisibility(visible ? View.VISIBLE : View.INVISIBLE);
238         }
239     }
240 
onOptionalFieldVisibilityChange()241     protected void onOptionalFieldVisibilityChange() {
242         if (mListener != null) {
243             mListener.onRequest(EditorListener.EDITOR_FORM_CHANGED);
244         }
245     }
246 
247     @Override
setEditorListener(EditorListener listener)248     public void setEditorListener(EditorListener listener) {
249         mListener = listener;
250     }
251 
getEditorListener()252     protected EditorListener getEditorListener(){
253         return mListener;
254     }
255 
256     @Override
setDeletable(boolean deletable)257     public void setDeletable(boolean deletable) {
258         mIsDeletable = deletable;
259         setupDeleteButton();
260     }
261 
262     @Override
setEnabled(boolean enabled)263     public void setEnabled(boolean enabled) {
264         super.setEnabled(enabled);
265         mLabel.setEnabled(!mReadOnly && enabled);
266         mDelete.setEnabled(!mReadOnly && enabled);
267     }
268 
getLabel()269     public Spinner getLabel() {
270         return mLabel;
271     }
272 
getDelete()273     public ImageView getDelete() {
274         return mDelete;
275     }
276 
getKind()277     protected DataKind getKind() {
278         return mKind;
279     }
280 
getEntry()281     protected ValuesDelta getEntry() {
282         return mEntry;
283     }
284 
getType()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      */
rebuildLabel()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
onFieldChanged(String column, String value)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 
saveValue(String column, String value)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      */
updateEmptiness()339     protected final void updateEmptiness() {
340         mWasEmpty = isEmpty();
341     }
342 
notifyEditorListener()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 
isFieldChanged(String column, String value)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 
rebuildValues()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
setValues(DataKind kind, ValuesDelta entry, RawContactDelta state, boolean readOnly, ViewIdGenerator vig)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 
getValues()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      */
createCustomDialog()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 
updateCustomDialogOkButtonState(AlertDialog dialog, EditText editText)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      */
onLabelRebuilt()503     protected void onLabelRebuilt() {
504     }
505 
onTypeSelectionChange(int position)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 */
showDialog(int bundleDialogId)530     void showDialog(int bundleDialogId) {
531         Bundle bundle = new Bundle();
532         bundle.putInt(DIALOG_ID_KEY, bundleDialogId);
533         getDialogManager().showDialogInView(this, bundle);
534     }
535 
getDialogManager()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
createDialog(Bundle bundle)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 
requestFocusForFirstEditField()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 
EditTypeAdapter(Context context)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 
hasCustomSelection()590         public boolean hasCustomSelection() {
591             return mHasCustomSelection;
592         }
593 
594         @Override
getView(int position, View convertView, ViewGroup parent)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
getDropDownView(int position, View convertView, ViewGroup parent)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 
createViewFromResource(int position, View convertView, ViewGroup parent, int resource)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