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