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