LabeledEditorView.java revision 60b239c24c05233bbf3f7cb45697252e15ec76cc
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.DataKind; 22import com.android.contacts.model.AccountType.EditType; 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; 28import com.android.contacts.util.ThemeUtils; 29 30import android.app.AlertDialog; 31import android.app.Dialog; 32import android.content.Context; 33import android.content.DialogInterface; 34import android.content.Entity; 35import android.content.res.Resources; 36import android.os.Bundle; 37import android.os.Handler; 38import android.text.TextUtils; 39import android.util.AttributeSet; 40import android.view.LayoutInflater; 41import android.view.View; 42import android.view.ViewGroup; 43import android.view.inputmethod.EditorInfo; 44import android.widget.AdapterView; 45import android.widget.AdapterView.OnItemSelectedListener; 46import android.widget.ArrayAdapter; 47import android.widget.EditText; 48import android.widget.ImageButton; 49import android.widget.Spinner; 50import android.widget.TextView; 51 52import java.util.List; 53 54/** 55 * Base class for editors that handles labels and values. 56 * Uses {@link ValuesDelta} to read any existing 57 * {@link Entity} values, and to correctly write any changes values. 58 */ 59public abstract class LabeledEditorView extends ViewGroup implements Editor, DialogShowingView { 60 protected static final String DIALOG_ID_KEY = "dialog_id"; 61 private static final int DIALOG_ID_CUSTOM = 1; 62 63 private static final int INPUT_TYPE_CUSTOM = EditorInfo.TYPE_CLASS_TEXT 64 | EditorInfo.TYPE_TEXT_FLAG_CAP_WORDS; 65 66 private Spinner mLabel; 67 private EditTypeAdapter mEditTypeAdapter; 68 private ImageButton mDelete; 69 70 private DataKind mKind; 71 private ValuesDelta mEntry; 72 private EntityDelta mState; 73 private boolean mReadOnly; 74 75 private EditType mType; 76 77 private ViewIdGenerator mViewIdGenerator; 78 private DialogManager mDialogManager = null; 79 private EditorListener mListener; 80 protected int mMinLineItemHeight; 81 82 /** 83 * A marker in the spinner adapter of the currently selected custom type. 84 */ 85 public static final EditType CUSTOM_SELECTION = new EditType(0, 0); 86 87 private OnItemSelectedListener mSpinnerListener = new OnItemSelectedListener() { 88 89 @Override 90 public void onItemSelected( 91 AdapterView<?> parent, View view, int position, long id) { 92 onTypeSelectionChange(position); 93 } 94 95 @Override 96 public void onNothingSelected(AdapterView<?> parent) { 97 } 98 }; 99 100 public LabeledEditorView(Context context) { 101 super(context); 102 init(context); 103 } 104 105 public LabeledEditorView(Context context, AttributeSet attrs) { 106 super(context, attrs); 107 init(context); 108 } 109 110 public LabeledEditorView(Context context, AttributeSet attrs, int defStyle) { 111 super(context, attrs, defStyle); 112 init(context); 113 } 114 115 private void init(Context context) { 116 mMinLineItemHeight = context.getResources().getDimensionPixelSize( 117 R.dimen.editor_min_line_item_height); 118 } 119 120 public boolean isReadOnly() { 121 return mReadOnly; 122 } 123 124 public int getBaseline(int row) { 125 if (row == 0 && mLabel != null) { 126 return mLabel.getBaseline(); 127 } 128 return -1; 129 } 130 131 /** 132 * Returns the number of rows in this editor, including the invisible ones. 133 */ 134 protected int getLineItemCount() { 135 return 1; 136 } 137 138 protected boolean isLineItemVisible(int row) { 139 return true; 140 } 141 142 protected int getLineItemHeight(int row) { 143 int fieldHeight = 0; 144 int buttonHeight = 0; 145 if (row == 0) { 146 // summarize the EditText heights 147 if (mLabel != null) { 148 fieldHeight = mLabel.getMeasuredHeight(); 149 } 150 151 // Ensure there is enough space for the minus button 152 View deleteButton = getDelete(); 153 final int deleteHeight = (deleteButton != null) ? deleteButton.getMeasuredHeight() : 0; 154 buttonHeight += deleteHeight; 155 } 156 157 return Math.max(Math.max(buttonHeight, fieldHeight), mMinLineItemHeight); 158 } 159 160 @Override 161 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 162 measureChildren(widthMeasureSpec, heightMeasureSpec); 163 164 int height = 0; 165 height += getPaddingTop() + getPaddingBottom(); 166 167 int count = getLineItemCount(); 168 for (int i = 0; i < count; i++) { 169 if (isLineItemVisible(i)) { 170 height += getLineItemHeight(i); 171 } 172 } 173 174 setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec), 175 resolveSize(height, heightMeasureSpec)); 176 } 177 178 @Override 179 protected void onLayout(boolean changed, int l, int t, int r, int b) { 180 // Subtract padding from the borders ==> x1 variables 181 int t1 = getPaddingTop(); 182 int r1 = getMeasuredWidth() - getPaddingRight(); 183 int b1 = getMeasuredHeight() - getPaddingBottom(); 184 185 final int r2; 186 if (mDelete != null) { 187 r2 = r1 - mDelete.getMeasuredWidth(); 188 // Vertically center the delete button in the first line item 189 int height = mDelete.getMeasuredHeight(); 190 int top = t1 + (mMinLineItemHeight - height) / 2; 191 mDelete.layout( 192 r2, top, 193 r1, top + height); 194 } else { 195 r2 = r1; 196 } 197 198 if (mLabel != null) { 199 int baseline = getBaseline(0); 200 int y = t1 + baseline - mLabel.getBaseline(); 201 mLabel.layout( 202 r2 - mLabel.getMeasuredWidth(), y, 203 r2, y + mLabel.getMeasuredHeight()); 204 } 205 } 206 207 /** 208 * Creates or removes the type/label button. Doesn't do anything if already correctly configured 209 */ 210 private void setupLabelButton(boolean shouldExist) { 211 if (shouldExist && mLabel == null) { 212 mLabel = new Spinner(mContext); 213 final int width = 214 mContext.getResources().getDimensionPixelSize(R.dimen.editor_type_label_width); 215 mLabel.setLayoutParams(new LayoutParams(width, LayoutParams.WRAP_CONTENT)); 216 mLabel.setOnItemSelectedListener(mSpinnerListener); 217 mLabel.setEnabled(!mReadOnly && isEnabled()); 218 addView(mLabel); 219 } else if (!shouldExist && mLabel != null) { 220 removeView(mLabel); 221 mLabel = null; 222 } 223 } 224 225 /** 226 * Creates or removes the remove button. Doesn't do anything if already correctly configured 227 */ 228 private void setupDeleteButton(boolean shouldExist) { 229 if (shouldExist && mDelete == null) { 230 mDelete = new ImageButton(mContext); 231 mDelete.setImageResource(R.drawable.ic_menu_remove_field_holo_light); 232 mDelete.setBackgroundResource( 233 ThemeUtils.getSelectableItemBackground(mContext.getTheme())); 234 final Resources resources = mContext.getResources(); 235 mDelete.setPadding( 236 resources.getDimensionPixelOffset(R.dimen.editor_round_button_padding_left), 237 resources.getDimensionPixelOffset(R.dimen.editor_round_button_padding_top), 238 resources.getDimensionPixelOffset(R.dimen.editor_round_button_padding_right), 239 resources.getDimensionPixelOffset(R.dimen.editor_round_button_padding_bottom)); 240 mDelete.setContentDescription( 241 getResources().getText(R.string.description_minus_button)); 242 mDelete.setLayoutParams( 243 new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); 244 mDelete.setOnClickListener(new OnClickListener() { 245 @Override 246 public void onClick(View v) { 247 // defer removal of this button so that the pressed state is visible shortly 248 new Handler().post(new Runnable() { 249 @Override 250 public void run() { 251 // Keep around in model, but mark as deleted 252 mEntry.markDeleted(); 253 254 ((ViewGroup) getParent()).removeView(LabeledEditorView.this); 255 256 if (mListener != null) { 257 // Notify listener when present 258 mListener.onDeleted(LabeledEditorView.this); 259 } 260 } 261 }); 262 } 263 }); 264 mDelete.setEnabled(!mReadOnly && isEnabled()); 265 addView(mDelete); 266 } else if (!shouldExist && mDelete != null) { 267 removeView(mDelete); 268 mDelete = null; 269 } 270 } 271 272 protected void onOptionalFieldVisibilityChange() { 273 if (mListener != null) { 274 mListener.onRequest(EditorListener.EDITOR_FORM_CHANGED); 275 } 276 } 277 278 @Override 279 public void setEditorListener(EditorListener listener) { 280 mListener = listener; 281 } 282 283 @Override 284 public void setDeletable(boolean deletable) { 285 setupDeleteButton(deletable); 286 } 287 288 @Override 289 public void setEnabled(boolean enabled) { 290 super.setEnabled(enabled); 291 if (mLabel != null) mLabel.setEnabled(!mReadOnly && enabled); 292 if (mDelete != null) mDelete.setEnabled(!mReadOnly && enabled); 293 } 294 295 public Spinner getLabel() { 296 return mLabel; 297 } 298 299 public ImageButton getDelete() { 300 return mDelete; 301 } 302 303 protected DataKind getKind() { 304 return mKind; 305 } 306 307 protected ValuesDelta getEntry() { 308 return mEntry; 309 } 310 311 protected EditType getType() { 312 return mType; 313 } 314 315 /** 316 * Build the current label state based on selected {@link EditType} and 317 * possible custom label string. 318 */ 319 private void rebuildLabel() { 320 if (mLabel == null) return; 321 mEditTypeAdapter = new EditTypeAdapter(mContext); 322 mLabel.setAdapter(mEditTypeAdapter); 323 if (mEditTypeAdapter.hasCustomSelection()) { 324 mLabel.setSelection(mEditTypeAdapter.getPosition(CUSTOM_SELECTION)); 325 } else { 326 mLabel.setSelection(mEditTypeAdapter.getPosition(mType)); 327 } 328 } 329 330 @Override 331 public void onFieldChanged(String column, String value) { 332 if (!isFieldChanged(column, value)) { 333 return; 334 } 335 336 // Field changes are saved directly 337 mEntry.put(column, value); 338 if (mListener != null) { 339 mListener.onRequest(EditorListener.FIELD_CHANGED); 340 } 341 } 342 343 protected boolean isFieldChanged(String column, String value) { 344 final String dbValue = mEntry.getAsString(column); 345 // nullable fields (e.g. Middle Name) are usually represented as empty columns, 346 // so lets treat null and empty space equivalently here 347 final String dbValueNoNull = dbValue == null ? "" : dbValue; 348 final String valueNoNull = value == null ? "" : value; 349 return !TextUtils.equals(dbValueNoNull, valueNoNull); 350 } 351 352 protected void rebuildValues() { 353 setValues(mKind, mEntry, mState, mReadOnly, mViewIdGenerator); 354 } 355 356 /** 357 * Prepare this editor using the given {@link DataKind} for defining 358 * structure and {@link ValuesDelta} describing the content to edit. 359 */ 360 @Override 361 public void setValues(DataKind kind, ValuesDelta entry, EntityDelta 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 = EntityModifier.hasEditTypes(kind); 379 setupLabelButton(hasTypes); 380 if (mLabel != null) mLabel.setEnabled(!readOnly && isEnabled()); 381 if (hasTypes) { 382 mType = EntityModifier.getCurrentType(entry, kind); 383 rebuildLabel(); 384 } 385 } 386 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 */ 398 private Dialog createCustomDialog() { 399 final EditText customType = new EditText(mContext); 400 customType.setId(R.id.custom_dialog_content); 401 customType.setInputType(INPUT_TYPE_CUSTOM); 402 customType.setSaveEnabled(true); 403 customType.requestFocus(); 404 405 final AlertDialog.Builder builder = new AlertDialog.Builder(mContext); 406 builder.setTitle(R.string.customLabelPickerTitle); 407 builder.setView(customType); 408 409 builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { 410 @Override 411 public void onClick(DialogInterface dialog, int which) { 412 final String customText = customType.getText().toString().trim(); 413 if (ContactsUtils.isGraphic(customText)) { 414 final List<EditType> allTypes = 415 EntityModifier.getValidTypes(mState, mKind, null); 416 mType = null; 417 for (EditType editType : allTypes) { 418 if (editType.customColumn != null) { 419 mType = editType; 420 break; 421 } 422 } 423 if (mType == null) return; 424 425 mEntry.put(mKind.typeColumn, mType.rawValue); 426 mEntry.put(mType.customColumn, customText); 427 rebuildLabel(); 428 requestFocusForFirstEditField(); 429 onLabelRebuilt(); 430 } 431 } 432 }); 433 434 builder.setNegativeButton(android.R.string.cancel, null); 435 436 return builder.create(); 437 } 438 439 /** 440 * Called after the label has changed (either chosen from the list or entered in the Dialog) 441 */ 442 protected void onLabelRebuilt() { 443 } 444 445 protected void onTypeSelectionChange(int position) { 446 EditType selected = mEditTypeAdapter.getItem(position); 447 // See if the selection has in fact changed 448 if (mEditTypeAdapter.hasCustomSelection() && selected == CUSTOM_SELECTION) { 449 return; 450 } 451 452 if (mType == selected && mType.customColumn == null) { 453 return; 454 } 455 456 if (selected.customColumn != null) { 457 showDialog(DIALOG_ID_CUSTOM); 458 } else { 459 // User picked type, and we're sure it's ok to actually write the entry. 460 mType = selected; 461 mEntry.put(mKind.typeColumn, mType.rawValue); 462 rebuildLabel(); 463 requestFocusForFirstEditField(); 464 onLabelRebuilt(); 465 } 466 } 467 468 /* package */ 469 void showDialog(int bundleDialogId) { 470 Bundle bundle = new Bundle(); 471 bundle.putInt(DIALOG_ID_KEY, bundleDialogId); 472 getDialogManager().showDialogInView(this, bundle); 473 } 474 475 private DialogManager getDialogManager() { 476 if (mDialogManager == null) { 477 Context context = getContext(); 478 if (!(context instanceof DialogManager.DialogShowingViewActivity)) { 479 throw new IllegalStateException( 480 "View must be hosted in an Activity that implements " + 481 "DialogManager.DialogShowingViewActivity"); 482 } 483 mDialogManager = ((DialogManager.DialogShowingViewActivity)context).getDialogManager(); 484 } 485 return mDialogManager; 486 } 487 488 @Override 489 public Dialog createDialog(Bundle bundle) { 490 if (bundle == null) throw new IllegalArgumentException("bundle must not be null"); 491 int dialogId = bundle.getInt(DIALOG_ID_KEY); 492 switch (dialogId) { 493 case DIALOG_ID_CUSTOM: 494 return createCustomDialog(); 495 default: 496 throw new IllegalArgumentException("Invalid dialogId: " + dialogId); 497 } 498 } 499 500 protected abstract void requestFocusForFirstEditField(); 501 502 private class EditTypeAdapter extends ArrayAdapter<EditType> { 503 private final LayoutInflater mInflater; 504 private boolean mHasCustomSelection; 505 506 public EditTypeAdapter(Context context) { 507 super(context, 0); 508 mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); 509 510 if (mType != null && mType.customColumn != null) { 511 512 // Use custom label string when present 513 final String customText = mEntry.getAsString(mType.customColumn); 514 if (customText != null) { 515 add(CUSTOM_SELECTION); 516 mHasCustomSelection = true; 517 } 518 } 519 520 addAll(EntityModifier.getValidTypes(mState, mKind, mType)); 521 } 522 523 public boolean hasCustomSelection() { 524 return mHasCustomSelection; 525 } 526 527 @Override 528 public View getView(int position, View convertView, ViewGroup parent) { 529 return createViewFromResource( 530 position, convertView, parent, android.R.layout.simple_spinner_item); 531 } 532 533 @Override 534 public View getDropDownView(int position, View convertView, ViewGroup parent) { 535 return createViewFromResource( 536 position, convertView, parent, android.R.layout.simple_spinner_dropdown_item); 537 } 538 539 private View createViewFromResource(int position, View convertView, ViewGroup parent, 540 int resource) { 541 View view; 542 TextView textView; 543 544 if (convertView == null) { 545 view = mInflater.inflate(resource, parent, false); 546 } else { 547 view = convertView; 548 } 549 550 textView = (TextView) view; 551 552 EditType type = getItem(position); 553 String text; 554 if (type == CUSTOM_SELECTION) { 555 text = mEntry.getAsString(mType.customColumn); 556 } else { 557 text = getContext().getString(type.labelRes); 558 } 559 textView.setText(text); 560 return view; 561 } 562 } 563} 564