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