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