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