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 private boolean mHideTypeInitially; 83 private boolean mHasTypes; 84 85 private EditType mType; 86 87 private ViewIdGenerator mViewIdGenerator; 88 private DialogManager mDialogManager = null; 89 private EditorListener mListener; 90 protected int mMinLineItemHeight; 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 143 mDelete = (ImageView) findViewById(R.id.delete_button); 144 mDeleteContainer = findViewById(R.id.delete_button_container); 145 mDeleteContainer.setOnClickListener(new OnClickListener() { 146 @Override 147 public void onClick(View v) { 148 // defer removal of this button so that the pressed state is visible shortly 149 new Handler().post(new Runnable() { 150 @Override 151 public void run() { 152 // Don't do anything if the view is no longer attached to the window 153 // (This check is needed because when this {@link Runnable} is executed, 154 // we can't guarantee the view is still valid. 155 if (!mIsAttachedToWindow) { 156 return; 157 } 158 // Send the delete request to the listener (which will in turn call 159 // deleteEditor() on this view if the deletion is valid - i.e. this is not 160 // the last {@link Editor} in the section). 161 if (mListener != null) { 162 mListener.onDeleteRequested(LabeledEditorView.this); 163 } 164 } 165 }); 166 } 167 }); 168 169 setPadding(getPaddingLeft(), getPaddingTop(), getPaddingRight(), 170 (int) getResources().getDimension(R.dimen.editor_padding_between_editor_views)); 171 } 172 173 @Override 174 protected void onAttachedToWindow() { 175 super.onAttachedToWindow(); 176 // Keep track of when the view is attached or detached from the window, so we know it's 177 // safe to remove views (in case the user requests to delete this editor). 178 mIsAttachedToWindow = true; 179 } 180 181 @Override 182 protected void onDetachedFromWindow() { 183 super.onDetachedFromWindow(); 184 mIsAttachedToWindow = false; 185 } 186 187 @Override 188 public void markDeleted() { 189 // Keep around in model, but mark as deleted 190 mEntry.markDeleted(); 191 } 192 193 @Override 194 public void deleteEditor() { 195 markDeleted(); 196 197 // Remove the view 198 EditorAnimator.getInstance().removeEditorView(this); 199 } 200 201 public boolean isReadOnly() { 202 return mReadOnly; 203 } 204 205 public int getBaseline(int row) { 206 if (row == 0 && mLabel != null) { 207 return mLabel.getBaseline(); 208 } 209 return -1; 210 } 211 212 /** 213 * Configures the visibility of the type label button and enables or disables it properly. 214 */ 215 private void setupLabelButton(boolean shouldExist) { 216 if (shouldExist) { 217 mLabel.setEnabled(!mReadOnly && isEnabled()); 218 mLabel.setVisibility(View.VISIBLE); 219 } else { 220 mLabel.setVisibility(View.GONE); 221 } 222 } 223 224 /** 225 * Configures the visibility of the "delete" button and enables or disables it properly. 226 */ 227 private void setupDeleteButton() { 228 if (mIsDeletable) { 229 mDeleteContainer.setVisibility(View.VISIBLE); 230 mDelete.setEnabled(!mReadOnly && isEnabled()); 231 } else { 232 mDeleteContainer.setVisibility(View.GONE); 233 } 234 } 235 236 public void setDeleteButtonVisible(boolean visible) { 237 if (mIsDeletable) { 238 mDeleteContainer.setVisibility(visible ? View.VISIBLE : View.INVISIBLE); 239 } 240 } 241 242 /** 243 * Whether to hide the type dropdown after values have been set. 244 * By default the drop down is always displayed if there are types to display. 245 */ 246 public void setHideTypeInitially(boolean hideTypeInitially) { 247 mHideTypeInitially = hideTypeInitially; 248 } 249 250 /** 251 * Whether the type drop down is visible. 252 */ 253 public boolean isTypeVisible() { 254 return mLabel == null ? false : mLabel.getVisibility() == View.VISIBLE; 255 } 256 257 /** 258 * Makes the type drop down visible if it is not already so, and there are types to display. 259 */ 260 public void showType() { 261 if (mHasTypes && mLabel != null && mLabel.getVisibility() != View.VISIBLE) { 262 EditorAnimator.getInstance().slideAndFadeIn(mLabel, mLabel.getHeight()); 263 } 264 } 265 266 /** 267 * Hides the type drop down if there are types to display and it is not already hidden. 268 */ 269 public void hideType() { 270 if (mHasTypes && mLabel != null && mLabel.getVisibility() != View.GONE) { 271 EditorAnimator.getInstance().hideEditorView(mLabel); 272 } 273 } 274 275 protected void onOptionalFieldVisibilityChange() { 276 if (mListener != null) { 277 mListener.onRequest(EditorListener.EDITOR_FORM_CHANGED); 278 } 279 } 280 281 @Override 282 public void setEditorListener(EditorListener listener) { 283 mListener = listener; 284 } 285 286 protected EditorListener getEditorListener(){ 287 return mListener; 288 } 289 290 @Override 291 public void setDeletable(boolean deletable) { 292 mIsDeletable = deletable; 293 setupDeleteButton(); 294 } 295 296 @Override 297 public void setEnabled(boolean enabled) { 298 super.setEnabled(enabled); 299 mLabel.setEnabled(!mReadOnly && enabled); 300 mDelete.setEnabled(!mReadOnly && enabled); 301 } 302 303 public Spinner getLabel() { 304 return mLabel; 305 } 306 307 public ImageView getDelete() { 308 return mDelete; 309 } 310 311 protected DataKind getKind() { 312 return mKind; 313 } 314 315 protected ValuesDelta getEntry() { 316 return mEntry; 317 } 318 319 protected EditType getType() { 320 return mType; 321 } 322 323 /** 324 * Build the current label state based on selected {@link EditType} and 325 * possible custom label string. 326 */ 327 public void rebuildLabel() { 328 mEditTypeAdapter = new EditTypeAdapter(getContext()); 329 mLabel.setAdapter(mEditTypeAdapter); 330 if (mEditTypeAdapter.hasCustomSelection()) { 331 mLabel.setSelection(mEditTypeAdapter.getPosition(CUSTOM_SELECTION)); 332 } else { 333 mLabel.setSelection(mEditTypeAdapter.getPosition(mType)); 334 } 335 } 336 337 @Override 338 public void onFieldChanged(String column, String value) { 339 if (!isFieldChanged(column, value)) { 340 return; 341 } 342 343 // Field changes are saved directly 344 saveValue(column, value); 345 346 // Notify listener if applicable 347 notifyEditorListener(); 348 } 349 350 protected void saveValue(String column, String value) { 351 mEntry.put(column, value); 352 } 353 354 /** 355 * Sub classes should call this at the end of {@link #setValues} once they finish changing 356 * isEmpty(). This is needed to fix b/18194655. 357 */ 358 protected final void updateEmptiness() { 359 mWasEmpty = isEmpty(); 360 } 361 362 protected void notifyEditorListener() { 363 if (mListener != null) { 364 mListener.onRequest(EditorListener.FIELD_CHANGED); 365 } 366 367 boolean isEmpty = isEmpty(); 368 if (mWasEmpty != isEmpty) { 369 if (isEmpty) { 370 if (mListener != null) { 371 mListener.onRequest(EditorListener.FIELD_TURNED_EMPTY); 372 } 373 if (mIsDeletable) mDeleteContainer.setVisibility(View.INVISIBLE); 374 } else { 375 if (mListener != null) { 376 mListener.onRequest(EditorListener.FIELD_TURNED_NON_EMPTY); 377 } 378 if (mIsDeletable) mDeleteContainer.setVisibility(View.VISIBLE); 379 } 380 mWasEmpty = isEmpty; 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 mHasTypes = RawContactModifier.hasEditTypes(kind); 421 setupLabelButton(mHasTypes); 422 mLabel.setEnabled(!readOnly && isEnabled()); 423 if (mHasTypes) { 424 mType = RawContactModifier.getCurrentType(entry, kind); 425 rebuildLabel(); 426 if (mHideTypeInitially) { 427 mLabel.setVisibility(View.GONE); 428 } 429 } 430 } 431 432 public ValuesDelta getValues() { 433 return mEntry; 434 } 435 436 /** 437 * Prepare dialog for entering a custom label. The input value is trimmed: white spaces before 438 * and after the input text is removed. 439 * <p> 440 * If the final value is empty, this change request is ignored; 441 * no empty text is allowed in any custom label. 442 */ 443 private Dialog createCustomDialog() { 444 final AlertDialog.Builder builder = new AlertDialog.Builder(getContext()); 445 final LayoutInflater layoutInflater = LayoutInflater.from(builder.getContext()); 446 builder.setTitle(R.string.customLabelPickerTitle); 447 448 final View view = layoutInflater.inflate(R.layout.contact_editor_label_name_dialog, null); 449 final EditText editText = (EditText) view.findViewById(R.id.custom_dialog_content); 450 editText.setInputType(INPUT_TYPE_CUSTOM); 451 editText.setSaveEnabled(true); 452 453 builder.setView(view); 454 editText.requestFocus(); 455 456 builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { 457 @Override 458 public void onClick(DialogInterface dialog, int which) { 459 final String customText = editText.getText().toString().trim(); 460 if (ContactsUtils.isGraphic(customText)) { 461 final List<EditType> allTypes = 462 RawContactModifier.getValidTypes(mState, mKind, null); 463 mType = null; 464 for (EditType editType : allTypes) { 465 if (editType.customColumn != null) { 466 mType = editType; 467 break; 468 } 469 } 470 if (mType == null) return; 471 472 mEntry.put(mKind.typeColumn, mType.rawValue); 473 mEntry.put(mType.customColumn, customText); 474 rebuildLabel(); 475 requestFocusForFirstEditField(); 476 onLabelRebuilt(); 477 } 478 } 479 }); 480 481 builder.setNegativeButton(android.R.string.cancel, null); 482 483 final AlertDialog dialog = builder.create(); 484 dialog.setOnShowListener(new OnShowListener() { 485 @Override 486 public void onShow(DialogInterface dialogInterface) { 487 updateCustomDialogOkButtonState(dialog, editText); 488 } 489 }); 490 editText.addTextChangedListener(new TextWatcher() { 491 @Override 492 public void onTextChanged(CharSequence s, int start, int before, int count) { 493 } 494 495 @Override 496 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 497 } 498 499 @Override 500 public void afterTextChanged(Editable s) { 501 updateCustomDialogOkButtonState(dialog, editText); 502 } 503 }); 504 dialog.getWindow().setSoftInputMode( 505 WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); 506 507 return dialog; 508 } 509 510 /* package */ void updateCustomDialogOkButtonState(AlertDialog dialog, EditText editText) { 511 final Button okButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE); 512 okButton.setEnabled(!TextUtils.isEmpty(editText.getText().toString().trim())); 513 } 514 515 /** 516 * Called after the label has changed (either chosen from the list or entered in the Dialog) 517 */ 518 protected void onLabelRebuilt() { 519 } 520 521 protected void onTypeSelectionChange(int position) { 522 EditType selected = mEditTypeAdapter.getItem(position); 523 // See if the selection has in fact changed 524 if (mEditTypeAdapter.hasCustomSelection() && selected == CUSTOM_SELECTION) { 525 return; 526 } 527 528 if (mType == selected && mType.customColumn == null) { 529 return; 530 } 531 532 if (selected.customColumn != null) { 533 showDialog(DIALOG_ID_CUSTOM); 534 } else { 535 // User picked type, and we're sure it's ok to actually write the entry. 536 mType = selected; 537 mEntry.put(mKind.typeColumn, mType.rawValue); 538 rebuildLabel(); 539 requestFocusForFirstEditField(); 540 onLabelRebuilt(); 541 } 542 } 543 544 /* package */ 545 void showDialog(int bundleDialogId) { 546 Bundle bundle = new Bundle(); 547 bundle.putInt(DIALOG_ID_KEY, bundleDialogId); 548 getDialogManager().showDialogInView(this, bundle); 549 } 550 551 private DialogManager getDialogManager() { 552 if (mDialogManager == null) { 553 Context context = getContext(); 554 if (!(context instanceof DialogManager.DialogShowingViewActivity)) { 555 throw new IllegalStateException( 556 "View must be hosted in an Activity that implements " + 557 "DialogManager.DialogShowingViewActivity"); 558 } 559 mDialogManager = ((DialogManager.DialogShowingViewActivity)context).getDialogManager(); 560 } 561 return mDialogManager; 562 } 563 564 @Override 565 public Dialog createDialog(Bundle bundle) { 566 if (bundle == null) throw new IllegalArgumentException("bundle must not be null"); 567 int dialogId = bundle.getInt(DIALOG_ID_KEY); 568 switch (dialogId) { 569 case DIALOG_ID_CUSTOM: 570 return createCustomDialog(); 571 default: 572 throw new IllegalArgumentException("Invalid dialogId: " + dialogId); 573 } 574 } 575 576 protected abstract void requestFocusForFirstEditField(); 577 578 private class EditTypeAdapter extends ArrayAdapter<EditType> { 579 private final LayoutInflater mInflater; 580 private boolean mHasCustomSelection; 581 private int mTextColorHintUnfocused; 582 private int mTextColorDark; 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)); 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 return createViewFromResource( 630 position, convertView, parent, android.R.layout.simple_spinner_dropdown_item); 631 } 632 633 private TextView createViewFromResource(int position, View convertView, ViewGroup parent, 634 int resource) { 635 TextView textView; 636 637 if (convertView == null) { 638 textView = (TextView) mInflater.inflate(resource, parent, false); 639 textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources().getDimension( 640 R.dimen.editor_form_text_size)); 641 textView.setTextColor(mTextColorDark); 642 } else { 643 textView = (TextView) convertView; 644 } 645 646 EditType type = getItem(position); 647 String text; 648 if (type == CUSTOM_SELECTION) { 649 text = mEntry.getAsString(mType.customColumn); 650 } else { 651 text = getContext().getString(type.labelRes); 652 } 653 textView.setText(text); 654 return textView; 655 } 656 } 657 } 658