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