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