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