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