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 110 public LabeledEditorView(Context context) { 111 super(context); 112 init(context); 113 } 114 115 public LabeledEditorView(Context context, AttributeSet attrs) { 116 super(context, attrs); 117 init(context); 118 } 119 120 public LabeledEditorView(Context context, AttributeSet attrs, int defStyle) { 121 super(context, attrs, defStyle); 122 init(context); 123 } 124 125 public Long getRawContactId() { 126 return mState == null ? null : mState.getRawContactId(); 127 } 128 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 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 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 183 protected void onDetachedFromWindow() { 184 super.onDetachedFromWindow(); 185 mIsAttachedToWindow = false; 186 } 187 188 @Override 189 public void markDeleted() { 190 // Keep around in model, but mark as deleted 191 mEntry.markDeleted(); 192 } 193 194 @Override 195 public void deleteEditor() { 196 markDeleted(); 197 198 // Remove the view 199 EditorAnimator.getInstance().removeEditorView(this); 200 } 201 202 public boolean isReadOnly() { 203 return mReadOnly; 204 } 205 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 */ 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 */ 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 237 public void setDeleteButtonVisible(boolean visible) { 238 if (mIsDeletable) { 239 mDeleteContainer.setVisibility(visible ? View.VISIBLE : View.INVISIBLE); 240 } 241 } 242 243 protected void onOptionalFieldVisibilityChange() { 244 if (mListener != null) { 245 mListener.onRequest(EditorListener.EDITOR_FORM_CHANGED); 246 } 247 } 248 249 @Override 250 public void setEditorListener(EditorListener listener) { 251 mListener = listener; 252 } 253 254 protected EditorListener getEditorListener(){ 255 return mListener; 256 } 257 258 @Override 259 public void setDeletable(boolean deletable) { 260 mIsDeletable = deletable; 261 setupDeleteButton(); 262 } 263 264 @Override 265 public void setEnabled(boolean enabled) { 266 super.setEnabled(enabled); 267 mLabel.setEnabled(!mReadOnly && enabled); 268 mDelete.setEnabled(!mReadOnly && enabled); 269 } 270 271 public Spinner getLabel() { 272 return mLabel; 273 } 274 275 public ImageView getDelete() { 276 return mDelete; 277 } 278 279 protected DataKind getKind() { 280 return mKind; 281 } 282 283 protected ValuesDelta getEntry() { 284 return mEntry; 285 } 286 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 */ 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 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 334 /** {@inheritDoc} */ 335 @Override 336 public void updatePhonetic(String column, String value) { 337 } 338 339 /** {@inheritDoc} */ 340 @Override 341 public String getPhonetic(String column){ 342 return ""; 343 } 344 345 protected void saveValue(String column, String value) { 346 mEntry.put(column, value); 347 } 348 349 /** 350 * Sub classes should call this at the end of {@link #setValues} once they finish changing 351 * isEmpty(). This is needed to fix b/18194655. 352 */ 353 protected final void updateEmptiness() { 354 mWasEmpty = isEmpty(); 355 } 356 357 protected void notifyEditorListener() { 358 if (mListener != null) { 359 mListener.onRequest(EditorListener.FIELD_CHANGED); 360 } 361 362 boolean isEmpty = isEmpty(); 363 if (mWasEmpty != isEmpty) { 364 if (isEmpty) { 365 if (mListener != null) { 366 mListener.onRequest(EditorListener.FIELD_TURNED_EMPTY); 367 } 368 if (mIsDeletable) mDeleteContainer.setVisibility(View.INVISIBLE); 369 } else { 370 if (mListener != null) { 371 mListener.onRequest(EditorListener.FIELD_TURNED_NON_EMPTY); 372 } 373 if (mIsDeletable) mDeleteContainer.setVisibility(View.VISIBLE); 374 } 375 mWasEmpty = isEmpty; 376 377 // Update the label text color 378 if (mEditTypeAdapter != null) { 379 mEditTypeAdapter.notifyDataSetChanged(); 380 } 381 } 382 } 383 384 protected boolean isFieldChanged(String column, String value) { 385 final String dbValue = mEntry.getAsString(column); 386 // nullable fields (e.g. Middle Name) are usually represented as empty columns, 387 // so lets treat null and empty space equivalently here 388 final String dbValueNoNull = dbValue == null ? "" : dbValue; 389 final String valueNoNull = value == null ? "" : value; 390 return !TextUtils.equals(dbValueNoNull, valueNoNull); 391 } 392 393 protected void rebuildValues() { 394 setValues(mKind, mEntry, mState, mReadOnly, mViewIdGenerator); 395 } 396 397 /** 398 * Prepare this editor using the given {@link DataKind} for defining structure and 399 * {@link ValuesDelta} describing the content to edit. When overriding this, be careful 400 * to call {@link #updateEmptiness} at the end. 401 */ 402 @Override 403 public void setValues(DataKind kind, ValuesDelta entry, RawContactDelta state, boolean readOnly, 404 ViewIdGenerator vig) { 405 mKind = kind; 406 mEntry = entry; 407 mState = state; 408 mReadOnly = readOnly; 409 mViewIdGenerator = vig; 410 setId(vig.getId(state, kind, entry, ViewIdGenerator.NO_VIEW_INDEX)); 411 412 if (!entry.isVisible()) { 413 // Hide ourselves entirely if deleted 414 setVisibility(View.GONE); 415 return; 416 } 417 setVisibility(View.VISIBLE); 418 419 // Display label selector if multiple types available 420 final boolean hasTypes = RawContactModifier.hasEditTypes(kind); 421 setupLabelButton(hasTypes); 422 mLabel.setEnabled(!readOnly && isEnabled()); 423 if (mKind.titleRes > 0) { 424 mLabel.setContentDescription(getContext().getResources().getString(mKind.titleRes)); 425 } 426 mType = RawContactModifier.getCurrentType(entry, kind); 427 rebuildLabel(); 428 } 429 430 public ValuesDelta getValues() { 431 return mEntry; 432 } 433 434 /** 435 * Prepare dialog for entering a custom label. The input value is trimmed: white spaces before 436 * and after the input text is removed. 437 * <p> 438 * If the final value is empty, this change request is ignored; 439 * no empty text is allowed in any custom label. 440 */ 441 private Dialog createCustomDialog() { 442 final AlertDialog.Builder builder = new AlertDialog.Builder(getContext()); 443 final LayoutInflater layoutInflater = LayoutInflater.from(builder.getContext()); 444 builder.setTitle(R.string.customLabelPickerTitle); 445 446 final View view = layoutInflater.inflate(R.layout.contact_editor_label_name_dialog, null); 447 final EditText editText = (EditText) view.findViewById(R.id.custom_dialog_content); 448 editText.setInputType(INPUT_TYPE_CUSTOM); 449 editText.setSaveEnabled(true); 450 451 builder.setView(view); 452 editText.requestFocus(); 453 454 builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { 455 @Override 456 public void onClick(DialogInterface dialog, int which) { 457 final String customText = editText.getText().toString().trim(); 458 if (ContactsUtils.isGraphic(customText)) { 459 final List<EditType> allTypes = 460 RawContactModifier.getValidTypes(mState, mKind, null, true, null, true); 461 mType = null; 462 for (EditType editType : allTypes) { 463 if (editType.customColumn != null) { 464 mType = editType; 465 break; 466 } 467 } 468 if (mType == null) return; 469 470 mEntry.put(mKind.typeColumn, mType.rawValue); 471 mEntry.put(mType.customColumn, customText); 472 rebuildLabel(); 473 requestFocusForFirstEditField(); 474 onLabelRebuilt(); 475 } 476 } 477 }); 478 479 builder.setNegativeButton(android.R.string.cancel, null); 480 481 final AlertDialog dialog = builder.create(); 482 dialog.setOnShowListener(new OnShowListener() { 483 @Override 484 public void onShow(DialogInterface dialogInterface) { 485 updateCustomDialogOkButtonState(dialog, editText); 486 } 487 }); 488 editText.addTextChangedListener(new TextWatcher() { 489 @Override 490 public void onTextChanged(CharSequence s, int start, int before, int count) { 491 } 492 493 @Override 494 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 495 } 496 497 @Override 498 public void afterTextChanged(Editable s) { 499 updateCustomDialogOkButtonState(dialog, editText); 500 } 501 }); 502 dialog.getWindow().setSoftInputMode( 503 WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); 504 505 return dialog; 506 } 507 508 /* package */ void updateCustomDialogOkButtonState(AlertDialog dialog, EditText editText) { 509 final Button okButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE); 510 okButton.setEnabled(!TextUtils.isEmpty(editText.getText().toString().trim())); 511 } 512 513 /** 514 * Called after the label has changed (either chosen from the list or entered in the Dialog) 515 */ 516 protected void onLabelRebuilt() { 517 } 518 519 protected void onTypeSelectionChange(int position) { 520 EditType selected = mEditTypeAdapter.getItem(position); 521 // See if the selection has in fact changed 522 if (mEditTypeAdapter.hasCustomSelection() && selected == CUSTOM_SELECTION) { 523 return; 524 } 525 526 if (mType == selected && mType.customColumn == null) { 527 return; 528 } 529 530 if (selected.customColumn != null) { 531 showDialog(DIALOG_ID_CUSTOM); 532 } else { 533 // User picked type, and we're sure it's ok to actually write the entry. 534 mType = selected; 535 mEntry.put(mKind.typeColumn, mType.rawValue); 536 mSelectedLabelIndex = position; 537 rebuildLabel(); 538 requestFocusForFirstEditField(); 539 onLabelRebuilt(); 540 } 541 } 542 543 /* package */ 544 void showDialog(int bundleDialogId) { 545 Bundle bundle = new Bundle(); 546 bundle.putInt(DIALOG_ID_KEY, bundleDialogId); 547 getDialogManager().showDialogInView(this, bundle); 548 } 549 550 private DialogManager getDialogManager() { 551 if (mDialogManager == null) { 552 Context context = getContext(); 553 if (!(context instanceof DialogManager.DialogShowingViewActivity)) { 554 throw new IllegalStateException( 555 "View must be hosted in an Activity that implements " + 556 "DialogManager.DialogShowingViewActivity"); 557 } 558 mDialogManager = ((DialogManager.DialogShowingViewActivity)context).getDialogManager(); 559 } 560 return mDialogManager; 561 } 562 563 @Override 564 public Dialog createDialog(Bundle bundle) { 565 if (bundle == null) throw new IllegalArgumentException("bundle must not be null"); 566 int dialogId = bundle.getInt(DIALOG_ID_KEY); 567 switch (dialogId) { 568 case DIALOG_ID_CUSTOM: 569 return createCustomDialog(); 570 default: 571 throw new IllegalArgumentException("Invalid dialogId: " + dialogId); 572 } 573 } 574 575 protected abstract void requestFocusForFirstEditField(); 576 577 private class EditTypeAdapter extends ArrayAdapter<EditType> { 578 private final LayoutInflater mInflater; 579 private boolean mHasCustomSelection; 580 private int mTextColorHintUnfocused; 581 private int mTextColorDark; 582 private int mSelectedIndex; 583 584 public EditTypeAdapter(Context context) { 585 super(context, 0); 586 mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); 587 mTextColorHintUnfocused = context.getResources().getColor( 588 R.color.editor_disabled_text_color); 589 mTextColorDark = context.getResources().getColor(R.color.primary_text_color); 590 591 592 if (mType != null && mType.customColumn != null) { 593 594 // Use custom label string when present 595 final String customText = mEntry.getAsString(mType.customColumn); 596 if (customText != null) { 597 add(CUSTOM_SELECTION); 598 mHasCustomSelection = true; 599 } 600 } 601 602 addAll(RawContactModifier.getValidTypes(mState, mKind, mType, true, null, false)); 603 } 604 605 public boolean hasCustomSelection() { 606 return mHasCustomSelection; 607 } 608 609 @Override 610 public View getView(int position, View convertView, ViewGroup parent) { 611 final TextView view = createViewFromResource( 612 position, convertView, parent, R.layout.edit_simple_spinner_item); 613 // We don't want any background on this view. The background would obscure 614 // the spinner's background. 615 view.setBackground(null); 616 // The text color should be a very light hint color when unfocused and empty. When 617 // focused and empty, use a less light hint color. When non-empty, use a dark non-hint 618 // color. 619 if (!LabeledEditorView.this.isEmpty()) { 620 view.setTextColor(mTextColorDark); 621 } else { 622 view.setTextColor(mTextColorHintUnfocused); 623 } 624 return view; 625 } 626 627 @Override 628 public View getDropDownView(int position, View convertView, ViewGroup parent) { 629 final CheckedTextView dropDownView = (CheckedTextView) createViewFromResource( 630 position, convertView, parent, android.R.layout.simple_spinner_dropdown_item); 631 dropDownView.setBackground(getContext().getDrawable(R.drawable.drawer_item_background)); 632 dropDownView.setChecked(position == mSelectedIndex); 633 return dropDownView; 634 } 635 636 private TextView createViewFromResource(int position, View convertView, ViewGroup parent, 637 int resource) { 638 TextView textView; 639 640 if (convertView == null) { 641 textView = (TextView) mInflater.inflate(resource, parent, false); 642 textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources().getDimension( 643 R.dimen.editor_form_text_size)); 644 textView.setTextColor(mTextColorDark); 645 } else { 646 textView = (TextView) convertView; 647 } 648 649 EditType type = getItem(position); 650 String text; 651 if (type == CUSTOM_SELECTION) { 652 text = mEntry.getAsString(mType.customColumn); 653 } else { 654 text = getContext().getString(type.labelRes); 655 } 656 textView.setText(text); 657 return textView; 658 } 659 660 public void setSelectedIndex(int selectedIndex) { 661 mSelectedIndex = selectedIndex; 662 } 663 } 664 } 665