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