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 AlertDialog.Builder builder = new AlertDialog.Builder(mContext); 368 builder.setTitle(R.string.customLabelPickerTitle); 369 370 final EditText customType = new EditText(builder.getContext()); 371 customType.setId(R.id.custom_dialog_content); 372 customType.setInputType(INPUT_TYPE_CUSTOM); 373 customType.setSaveEnabled(true); 374 customType.requestFocus(); 375 376 builder.setView(customType); 377 378 builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { 379 @Override 380 public void onClick(DialogInterface dialog, int which) { 381 final String customText = customType.getText().toString().trim(); 382 if (ContactsUtils.isGraphic(customText)) { 383 final List<EditType> allTypes = 384 EntityModifier.getValidTypes(mState, mKind, null); 385 mType = null; 386 for (EditType editType : allTypes) { 387 if (editType.customColumn != null) { 388 mType = editType; 389 break; 390 } 391 } 392 if (mType == null) return; 393 394 mEntry.put(mKind.typeColumn, mType.rawValue); 395 mEntry.put(mType.customColumn, customText); 396 rebuildLabel(); 397 requestFocusForFirstEditField(); 398 onLabelRebuilt(); 399 } 400 } 401 }); 402 403 builder.setNegativeButton(android.R.string.cancel, null); 404 405 return builder.create(); 406 } 407 408 /** 409 * Called after the label has changed (either chosen from the list or entered in the Dialog) 410 */ 411 protected void onLabelRebuilt() { 412 } 413 414 protected void onTypeSelectionChange(int position) { 415 EditType selected = mEditTypeAdapter.getItem(position); 416 // See if the selection has in fact changed 417 if (mEditTypeAdapter.hasCustomSelection() && selected == CUSTOM_SELECTION) { 418 return; 419 } 420 421 if (mType == selected && mType.customColumn == null) { 422 return; 423 } 424 425 if (selected.customColumn != null) { 426 showDialog(DIALOG_ID_CUSTOM); 427 } else { 428 // User picked type, and we're sure it's ok to actually write the entry. 429 mType = selected; 430 mEntry.put(mKind.typeColumn, mType.rawValue); 431 rebuildLabel(); 432 requestFocusForFirstEditField(); 433 onLabelRebuilt(); 434 } 435 } 436 437 /* package */ 438 void showDialog(int bundleDialogId) { 439 Bundle bundle = new Bundle(); 440 bundle.putInt(DIALOG_ID_KEY, bundleDialogId); 441 getDialogManager().showDialogInView(this, bundle); 442 } 443 444 private DialogManager getDialogManager() { 445 if (mDialogManager == null) { 446 Context context = getContext(); 447 if (!(context instanceof DialogManager.DialogShowingViewActivity)) { 448 throw new IllegalStateException( 449 "View must be hosted in an Activity that implements " + 450 "DialogManager.DialogShowingViewActivity"); 451 } 452 mDialogManager = ((DialogManager.DialogShowingViewActivity)context).getDialogManager(); 453 } 454 return mDialogManager; 455 } 456 457 @Override 458 public Dialog createDialog(Bundle bundle) { 459 if (bundle == null) throw new IllegalArgumentException("bundle must not be null"); 460 int dialogId = bundle.getInt(DIALOG_ID_KEY); 461 switch (dialogId) { 462 case DIALOG_ID_CUSTOM: 463 return createCustomDialog(); 464 default: 465 throw new IllegalArgumentException("Invalid dialogId: " + dialogId); 466 } 467 } 468 469 protected abstract void requestFocusForFirstEditField(); 470 471 private class EditTypeAdapter extends ArrayAdapter<EditType> { 472 private final LayoutInflater mInflater; 473 private boolean mHasCustomSelection; 474 private int mTextColor; 475 476 public EditTypeAdapter(Context context) { 477 super(context, 0); 478 mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); 479 mTextColor = context.getResources().getColor(R.color.secondary_text_color); 480 481 if (mType != null && mType.customColumn != null) { 482 483 // Use custom label string when present 484 final String customText = mEntry.getAsString(mType.customColumn); 485 if (customText != null) { 486 add(CUSTOM_SELECTION); 487 mHasCustomSelection = true; 488 } 489 } 490 491 addAll(EntityModifier.getValidTypes(mState, mKind, mType)); 492 } 493 494 public boolean hasCustomSelection() { 495 return mHasCustomSelection; 496 } 497 498 @Override 499 public View getView(int position, View convertView, ViewGroup parent) { 500 return createViewFromResource( 501 position, convertView, parent, android.R.layout.simple_spinner_item); 502 } 503 504 @Override 505 public View getDropDownView(int position, View convertView, ViewGroup parent) { 506 return createViewFromResource( 507 position, convertView, parent, android.R.layout.simple_spinner_dropdown_item); 508 } 509 510 private View createViewFromResource(int position, View convertView, ViewGroup parent, 511 int resource) { 512 TextView textView; 513 514 if (convertView == null) { 515 textView = (TextView) mInflater.inflate(resource, parent, false); 516 textView.setAllCaps(true); 517 textView.setGravity(Gravity.RIGHT | Gravity.CENTER_VERTICAL); 518 textView.setTextAppearance(mContext, android.R.style.TextAppearance_Small); 519 textView.setTextColor(mTextColor); 520 textView.setEllipsize(TruncateAt.MIDDLE); 521 } else { 522 textView = (TextView) convertView; 523 } 524 525 EditType type = getItem(position); 526 String text; 527 if (type == CUSTOM_SELECTION) { 528 text = mEntry.getAsString(mType.customColumn); 529 } else { 530 text = getContext().getString(type.labelRes); 531 } 532 textView.setText(text); 533 return textView; 534 } 535 } 536 } 537