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