1 /* 2 * Copyright (C) 2016 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 // TODO: Copy & more general paste in formula? Note that this requires 18 // great care: Currently the text version of a displayed formula 19 // is not directly useful for re-evaluating the formula later, since 20 // it contains ellipses representing subexpressions evaluated with 21 // a different degree mode. Rather than supporting copy from the 22 // formula window, we may eventually want to support generation of a 23 // more useful text version in a separate window. It's not clear 24 // this is worth the added (code and user) complexity. 25 26 package com.android.calculator2; 27 28 import android.animation.Animator; 29 import android.animation.Animator.AnimatorListener; 30 import android.animation.AnimatorListenerAdapter; 31 import android.animation.AnimatorSet; 32 import android.animation.ObjectAnimator; 33 import android.animation.PropertyValuesHolder; 34 import android.app.ActionBar; 35 import android.app.Activity; 36 import android.app.Fragment; 37 import android.app.FragmentManager; 38 import android.app.FragmentTransaction; 39 import android.content.ClipData; 40 import android.content.DialogInterface; 41 import android.content.Intent; 42 import android.content.res.Resources; 43 import android.graphics.Color; 44 import android.graphics.Rect; 45 import android.net.Uri; 46 import android.os.Bundle; 47 import android.support.annotation.NonNull; 48 import android.support.annotation.StringRes; 49 import android.support.v4.content.ContextCompat; 50 import android.support.v4.view.ViewPager; 51 import android.text.Editable; 52 import android.text.SpannableStringBuilder; 53 import android.text.Spanned; 54 import android.text.TextUtils; 55 import android.text.TextWatcher; 56 import android.text.style.ForegroundColorSpan; 57 import android.util.Log; 58 import android.util.Property; 59 import android.view.ActionMode; 60 import android.view.KeyCharacterMap; 61 import android.view.KeyEvent; 62 import android.view.Menu; 63 import android.view.MenuItem; 64 import android.view.MotionEvent; 65 import android.view.View; 66 import android.view.View.OnLongClickListener; 67 import android.view.ViewAnimationUtils; 68 import android.view.ViewGroupOverlay; 69 import android.view.ViewTreeObserver; 70 import android.view.animation.AccelerateDecelerateInterpolator; 71 import android.widget.HorizontalScrollView; 72 import android.widget.TextView; 73 import android.widget.Toolbar; 74 75 import com.android.calculator2.CalculatorFormula.OnTextSizeChangeListener; 76 77 import java.io.ByteArrayInputStream; 78 import java.io.ByteArrayOutputStream; 79 import java.io.IOException; 80 import java.io.ObjectInput; 81 import java.io.ObjectInputStream; 82 import java.io.ObjectOutput; 83 import java.io.ObjectOutputStream; 84 import java.text.DecimalFormatSymbols; 85 86 import static com.android.calculator2.CalculatorFormula.OnFormulaContextMenuClickListener; 87 88 public class Calculator extends Activity 89 implements OnTextSizeChangeListener, OnLongClickListener, 90 AlertDialogFragment.OnClickListener, Evaluator.EvaluationListener /* for main result */, 91 DragLayout.CloseCallback, DragLayout.DragCallback { 92 93 private static final String TAG = "Calculator"; 94 /** 95 * Constant for an invalid resource id. 96 */ 97 public static final int INVALID_RES_ID = -1; 98 99 private enum CalculatorState { 100 INPUT, // Result and formula both visible, no evaluation requested, 101 // Though result may be visible on bottom line. 102 EVALUATE, // Both visible, evaluation requested, evaluation/animation incomplete. 103 // Not used for instant result evaluation. 104 INIT, // Very temporary state used as alternative to EVALUATE 105 // during reinitialization. Do not animate on completion. 106 INIT_FOR_RESULT, // Identical to INIT, but evaluation is known to terminate 107 // with result, and current expression has been copied to history. 108 ANIMATE, // Result computed, animation to enlarge result window in progress. 109 RESULT, // Result displayed, formula invisible. 110 // If we are in RESULT state, the formula was evaluated without 111 // error to initial precision. 112 // The current formula is now also the last history entry. 113 ERROR // Error displayed: Formula visible, result shows error message. 114 // Display similar to INPUT state. 115 } 116 // Normal transition sequence is 117 // INPUT -> EVALUATE -> ANIMATE -> RESULT (or ERROR) -> INPUT 118 // A RESULT -> ERROR transition is possible in rare corner cases, in which 119 // a higher precision evaluation exposes an error. This is possible, since we 120 // initially evaluate assuming we were given a well-defined problem. If we 121 // were actually asked to compute sqrt(<extremely tiny negative number>) we produce 0 122 // unless we are asked for enough precision that we can distinguish the argument from zero. 123 // ERROR and RESULT are translated to INIT or INIT_FOR_RESULT state if the application 124 // is restarted in that state. This leads us to recompute and redisplay the result 125 // ASAP. We avoid saving the ANIMATE state or activating history in that state. 126 // In INIT_FOR_RESULT, and RESULT state, a copy of the current 127 // expression has been saved in the history db; in the other non-ANIMATE states, 128 // it has not. 129 // TODO: Possibly save a bit more information, e.g. its initial display string 130 // or most significant digit position, to speed up restart. 131 132 private final Property<TextView, Integer> TEXT_COLOR = 133 new Property<TextView, Integer>(Integer.class, "textColor") { 134 @Override 135 public Integer get(TextView textView) { 136 return textView.getCurrentTextColor(); 137 } 138 139 @Override 140 public void set(TextView textView, Integer textColor) { 141 textView.setTextColor(textColor); 142 } 143 }; 144 145 private static final String NAME = "Calculator"; 146 private static final String KEY_DISPLAY_STATE = NAME + "_display_state"; 147 private static final String KEY_UNPROCESSED_CHARS = NAME + "_unprocessed_chars"; 148 /** 149 * Associated value is a byte array holding the evaluator state. 150 */ 151 private static final String KEY_EVAL_STATE = NAME + "_eval_state"; 152 private static final String KEY_INVERSE_MODE = NAME + "_inverse_mode"; 153 /** 154 * Associated value is an boolean holding the visibility state of the toolbar. 155 */ 156 private static final String KEY_SHOW_TOOLBAR = NAME + "_show_toolbar"; 157 158 private final ViewTreeObserver.OnPreDrawListener mPreDrawListener = 159 new ViewTreeObserver.OnPreDrawListener() { 160 @Override 161 public boolean onPreDraw() { 162 mFormulaContainer.scrollTo(mFormulaText.getRight(), 0); 163 final ViewTreeObserver observer = mFormulaContainer.getViewTreeObserver(); 164 if (observer.isAlive()) { 165 observer.removeOnPreDrawListener(this); 166 } 167 return false; 168 } 169 }; 170 171 private final Evaluator.Callback mEvaluatorCallback = new Evaluator.Callback() { 172 @Override 173 public void onMemoryStateChanged() { 174 mFormulaText.onMemoryStateChanged(); 175 } 176 177 @Override 178 public void showMessageDialog(@StringRes int title, @StringRes int message, 179 @StringRes int positiveButtonLabel, String tag) { 180 AlertDialogFragment.showMessageDialog(Calculator.this, title, message, 181 positiveButtonLabel, tag); 182 183 } 184 }; 185 186 private final OnDisplayMemoryOperationsListener mOnDisplayMemoryOperationsListener = 187 new OnDisplayMemoryOperationsListener() { 188 @Override 189 public boolean shouldDisplayMemory() { 190 return mEvaluator.getMemoryIndex() != 0; 191 } 192 }; 193 194 private final OnFormulaContextMenuClickListener mOnFormulaContextMenuClickListener = 195 new OnFormulaContextMenuClickListener() { 196 @Override 197 public boolean onPaste(ClipData clip) { 198 final ClipData.Item item = clip.getItemCount() == 0 ? null : clip.getItemAt(0); 199 if (item == null) { 200 // nothing to paste, bail early... 201 return false; 202 } 203 204 // Check if the item is a previously copied result, otherwise paste as raw text. 205 final Uri uri = item.getUri(); 206 if (uri != null && mEvaluator.isLastSaved(uri)) { 207 clearIfNotInputState(); 208 mEvaluator.appendExpr(mEvaluator.getSavedIndex()); 209 redisplayAfterFormulaChange(); 210 } else { 211 addChars(item.coerceToText(Calculator.this).toString(), false); 212 } 213 return true; 214 } 215 216 @Override 217 public void onMemoryRecall() { 218 clearIfNotInputState(); 219 long memoryIndex = mEvaluator.getMemoryIndex(); 220 if (memoryIndex != 0) { 221 mEvaluator.appendExpr(mEvaluator.getMemoryIndex()); 222 redisplayAfterFormulaChange(); 223 } 224 } 225 }; 226 227 228 private final TextWatcher mFormulaTextWatcher = new TextWatcher() { 229 @Override 230 public void beforeTextChanged(CharSequence charSequence, int start, int count, int after) { 231 } 232 233 @Override 234 public void onTextChanged(CharSequence charSequence, int start, int count, int after) { 235 } 236 237 @Override 238 public void afterTextChanged(Editable editable) { 239 final ViewTreeObserver observer = mFormulaContainer.getViewTreeObserver(); 240 if (observer.isAlive()) { 241 observer.removeOnPreDrawListener(mPreDrawListener); 242 observer.addOnPreDrawListener(mPreDrawListener); 243 } 244 } 245 }; 246 247 private CalculatorState mCurrentState; 248 private Evaluator mEvaluator; 249 250 private CalculatorDisplay mDisplayView; 251 private TextView mModeView; 252 private CalculatorFormula mFormulaText; 253 private CalculatorResult mResultText; 254 private HorizontalScrollView mFormulaContainer; 255 private DragLayout mDragLayout; 256 257 private ViewPager mPadViewPager; 258 private View mDeleteButton; 259 private View mClearButton; 260 private View mEqualButton; 261 private View mMainCalculator; 262 263 private TextView mInverseToggle; 264 private TextView mModeToggle; 265 266 private View[] mInvertibleButtons; 267 private View[] mInverseButtons; 268 269 private View mCurrentButton; 270 private Animator mCurrentAnimator; 271 272 // Characters that were recently entered at the end of the display that have not yet 273 // been added to the underlying expression. 274 private String mUnprocessedChars = null; 275 276 // Color to highlight unprocessed characters from physical keyboard. 277 // TODO: should probably match this to the error color? 278 private ForegroundColorSpan mUnprocessedColorSpan = new ForegroundColorSpan(Color.RED); 279 280 // Whether the display is one line. 281 private boolean mIsOneLine; 282 283 /** 284 * Map the old saved state to a new state reflecting requested result reevaluation. 285 */ mapFromSaved(CalculatorState savedState)286 private CalculatorState mapFromSaved(CalculatorState savedState) { 287 switch (savedState) { 288 case RESULT: 289 case INIT_FOR_RESULT: 290 // Evaluation is expected to terminate normally. 291 return CalculatorState.INIT_FOR_RESULT; 292 case ERROR: 293 case INIT: 294 return CalculatorState.INIT; 295 case EVALUATE: 296 case INPUT: 297 return savedState; 298 default: // Includes ANIMATE state. 299 throw new AssertionError("Impossible saved state"); 300 } 301 } 302 303 /** 304 * Restore Evaluator state and mCurrentState from savedInstanceState. 305 * Return true if the toolbar should be visible. 306 */ restoreInstanceState(Bundle savedInstanceState)307 private void restoreInstanceState(Bundle savedInstanceState) { 308 final CalculatorState savedState = CalculatorState.values()[ 309 savedInstanceState.getInt(KEY_DISPLAY_STATE, 310 CalculatorState.INPUT.ordinal())]; 311 setState(savedState); 312 CharSequence unprocessed = savedInstanceState.getCharSequence(KEY_UNPROCESSED_CHARS); 313 if (unprocessed != null) { 314 mUnprocessedChars = unprocessed.toString(); 315 } 316 byte[] state = savedInstanceState.getByteArray(KEY_EVAL_STATE); 317 if (state != null) { 318 try (ObjectInput in = new ObjectInputStream(new ByteArrayInputStream(state))) { 319 mEvaluator.restoreInstanceState(in); 320 } catch (Throwable ignored) { 321 // When in doubt, revert to clean state 322 mCurrentState = CalculatorState.INPUT; 323 mEvaluator.clearMain(); 324 } 325 } 326 if (savedInstanceState.getBoolean(KEY_SHOW_TOOLBAR, true)) { 327 showAndMaybeHideToolbar(); 328 } else { 329 mDisplayView.hideToolbar(); 330 } 331 onInverseToggled(savedInstanceState.getBoolean(KEY_INVERSE_MODE)); 332 // TODO: We're currently not saving and restoring scroll position. 333 // We probably should. Details may require care to deal with: 334 // - new display size 335 // - slow recomputation if we've scrolled far. 336 } 337 restoreDisplay()338 private void restoreDisplay() { 339 onModeChanged(mEvaluator.getDegreeMode(Evaluator.MAIN_INDEX)); 340 if (mCurrentState != CalculatorState.RESULT 341 && mCurrentState != CalculatorState.INIT_FOR_RESULT) { 342 redisplayFormula(); 343 } 344 if (mCurrentState == CalculatorState.INPUT) { 345 // This resultText will explicitly call evaluateAndNotify when ready. 346 mResultText.setShouldEvaluateResult(CalculatorResult.SHOULD_EVALUATE, this); 347 } else { 348 // Just reevaluate. 349 setState(mapFromSaved(mCurrentState)); 350 // Request evaluation when we know display width. 351 mResultText.setShouldEvaluateResult(CalculatorResult.SHOULD_REQUIRE, this); 352 } 353 } 354 355 @Override onCreate(Bundle savedInstanceState)356 protected void onCreate(Bundle savedInstanceState) { 357 super.onCreate(savedInstanceState); 358 359 setContentView(R.layout.activity_calculator_main); 360 setActionBar((Toolbar) findViewById(R.id.toolbar)); 361 362 // Hide all default options in the ActionBar. 363 getActionBar().setDisplayOptions(0); 364 365 // Ensure the toolbar stays visible while the options menu is displayed. 366 getActionBar().addOnMenuVisibilityListener(new ActionBar.OnMenuVisibilityListener() { 367 @Override 368 public void onMenuVisibilityChanged(boolean isVisible) { 369 mDisplayView.setForceToolbarVisible(isVisible); 370 } 371 }); 372 373 mMainCalculator = findViewById(R.id.main_calculator); 374 mDisplayView = (CalculatorDisplay) findViewById(R.id.display); 375 mModeView = (TextView) findViewById(R.id.mode); 376 mFormulaText = (CalculatorFormula) findViewById(R.id.formula); 377 mResultText = (CalculatorResult) findViewById(R.id.result); 378 mFormulaContainer = (HorizontalScrollView) findViewById(R.id.formula_container); 379 mEvaluator = Evaluator.getInstance(this); 380 mEvaluator.setCallback(mEvaluatorCallback); 381 mResultText.setEvaluator(mEvaluator, Evaluator.MAIN_INDEX); 382 KeyMaps.setActivity(this); 383 384 mPadViewPager = (ViewPager) findViewById(R.id.pad_pager); 385 mDeleteButton = findViewById(R.id.del); 386 mClearButton = findViewById(R.id.clr); 387 final View numberPad = findViewById(R.id.pad_numeric); 388 mEqualButton = numberPad.findViewById(R.id.eq); 389 if (mEqualButton == null || mEqualButton.getVisibility() != View.VISIBLE) { 390 mEqualButton = findViewById(R.id.pad_operator).findViewById(R.id.eq); 391 } 392 final TextView decimalPointButton = (TextView) numberPad.findViewById(R.id.dec_point); 393 decimalPointButton.setText(getDecimalSeparator()); 394 395 mInverseToggle = (TextView) findViewById(R.id.toggle_inv); 396 mModeToggle = (TextView) findViewById(R.id.toggle_mode); 397 398 mIsOneLine = mResultText.getVisibility() == View.INVISIBLE; 399 400 mInvertibleButtons = new View[] { 401 findViewById(R.id.fun_sin), 402 findViewById(R.id.fun_cos), 403 findViewById(R.id.fun_tan), 404 findViewById(R.id.fun_ln), 405 findViewById(R.id.fun_log), 406 findViewById(R.id.op_sqrt) 407 }; 408 mInverseButtons = new View[] { 409 findViewById(R.id.fun_arcsin), 410 findViewById(R.id.fun_arccos), 411 findViewById(R.id.fun_arctan), 412 findViewById(R.id.fun_exp), 413 findViewById(R.id.fun_10pow), 414 findViewById(R.id.op_sqr) 415 }; 416 417 mDragLayout = (DragLayout) findViewById(R.id.drag_layout); 418 mDragLayout.removeDragCallback(this); 419 mDragLayout.addDragCallback(this); 420 mDragLayout.setCloseCallback(this); 421 422 mFormulaText.setOnContextMenuClickListener(mOnFormulaContextMenuClickListener); 423 mFormulaText.setOnDisplayMemoryOperationsListener(mOnDisplayMemoryOperationsListener); 424 425 mFormulaText.setOnTextSizeChangeListener(this); 426 mFormulaText.addTextChangedListener(mFormulaTextWatcher); 427 mDeleteButton.setOnLongClickListener(this); 428 429 if (savedInstanceState != null) { 430 restoreInstanceState(savedInstanceState); 431 } else { 432 mCurrentState = CalculatorState.INPUT; 433 mEvaluator.clearMain(); 434 showAndMaybeHideToolbar(); 435 onInverseToggled(false); 436 } 437 restoreDisplay(); 438 } 439 440 @Override onResume()441 protected void onResume() { 442 super.onResume(); 443 if (mDisplayView.isToolbarVisible()) { 444 showAndMaybeHideToolbar(); 445 } 446 // If HistoryFragment is showing, hide the main Calculator elements from accessibility. 447 // This is because Talkback does not use visibility as a cue for RelativeLayout elements, 448 // and RelativeLayout is the base class of DragLayout. 449 // If we did not do this, it would be possible to traverse to main Calculator elements from 450 // HistoryFragment. 451 mMainCalculator.setImportantForAccessibility( 452 mDragLayout.isOpen() ? View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS 453 : View.IMPORTANT_FOR_ACCESSIBILITY_AUTO); 454 } 455 456 @Override onSaveInstanceState(@onNull Bundle outState)457 protected void onSaveInstanceState(@NonNull Bundle outState) { 458 mEvaluator.cancelAll(true); 459 // If there's an animation in progress, cancel it first to ensure our state is up-to-date. 460 if (mCurrentAnimator != null) { 461 mCurrentAnimator.cancel(); 462 } 463 464 super.onSaveInstanceState(outState); 465 outState.putInt(KEY_DISPLAY_STATE, mCurrentState.ordinal()); 466 outState.putCharSequence(KEY_UNPROCESSED_CHARS, mUnprocessedChars); 467 ByteArrayOutputStream byteArrayStream = new ByteArrayOutputStream(); 468 try (ObjectOutput out = new ObjectOutputStream(byteArrayStream)) { 469 mEvaluator.saveInstanceState(out); 470 } catch (IOException e) { 471 // Impossible; No IO involved. 472 throw new AssertionError("Impossible IO exception", e); 473 } 474 outState.putByteArray(KEY_EVAL_STATE, byteArrayStream.toByteArray()); 475 outState.putBoolean(KEY_INVERSE_MODE, mInverseToggle.isSelected()); 476 outState.putBoolean(KEY_SHOW_TOOLBAR, mDisplayView.isToolbarVisible()); 477 // We must wait for asynchronous writes to complete, since outState may contain 478 // references to expressions being written. 479 mEvaluator.waitForWrites(); 480 } 481 482 // Set the state, updating delete label and display colors. 483 // This restores display positions on moving to INPUT. 484 // But movement/animation for moving to RESULT has already been done. setState(CalculatorState state)485 private void setState(CalculatorState state) { 486 if (mCurrentState != state) { 487 if (state == CalculatorState.INPUT) { 488 // We'll explicitly request evaluation from now on. 489 mResultText.setShouldEvaluateResult(CalculatorResult.SHOULD_NOT_EVALUATE, null); 490 restoreDisplayPositions(); 491 } 492 mCurrentState = state; 493 494 if (mCurrentState == CalculatorState.RESULT) { 495 // No longer do this for ERROR; allow mistakes to be corrected. 496 mDeleteButton.setVisibility(View.GONE); 497 mClearButton.setVisibility(View.VISIBLE); 498 } else { 499 mDeleteButton.setVisibility(View.VISIBLE); 500 mClearButton.setVisibility(View.GONE); 501 } 502 503 if (mIsOneLine) { 504 if (mCurrentState == CalculatorState.RESULT 505 || mCurrentState == CalculatorState.EVALUATE 506 || mCurrentState == CalculatorState.ANIMATE) { 507 mFormulaText.setVisibility(View.VISIBLE); 508 mResultText.setVisibility(View.VISIBLE); 509 } else if (mCurrentState == CalculatorState.ERROR) { 510 mFormulaText.setVisibility(View.INVISIBLE); 511 mResultText.setVisibility(View.VISIBLE); 512 } else { 513 mFormulaText.setVisibility(View.VISIBLE); 514 mResultText.setVisibility(View.INVISIBLE); 515 } 516 } 517 518 if (mCurrentState == CalculatorState.ERROR) { 519 final int errorColor = 520 ContextCompat.getColor(this, R.color.calculator_error_color); 521 mFormulaText.setTextColor(errorColor); 522 mResultText.setTextColor(errorColor); 523 getWindow().setStatusBarColor(errorColor); 524 } else if (mCurrentState != CalculatorState.RESULT) { 525 mFormulaText.setTextColor( 526 ContextCompat.getColor(this, R.color.display_formula_text_color)); 527 mResultText.setTextColor( 528 ContextCompat.getColor(this, R.color.display_result_text_color)); 529 getWindow().setStatusBarColor( 530 ContextCompat.getColor(this, R.color.calculator_statusbar_color)); 531 } 532 533 invalidateOptionsMenu(); 534 } 535 } 536 isResultLayout()537 public boolean isResultLayout() { 538 if (mCurrentState == CalculatorState.ANIMATE) { 539 throw new AssertionError("impossible state"); 540 } 541 // Note that ERROR has INPUT, not RESULT layout. 542 return mCurrentState == CalculatorState.INIT_FOR_RESULT 543 || mCurrentState == CalculatorState.RESULT; 544 } 545 isOneLine()546 public boolean isOneLine() { 547 return mIsOneLine; 548 } 549 550 @Override onDestroy()551 protected void onDestroy() { 552 mDragLayout.removeDragCallback(this); 553 super.onDestroy(); 554 } 555 556 /** 557 * Destroy the evaluator and close the underlying database. 558 */ destroyEvaluator()559 public void destroyEvaluator() { 560 mEvaluator.destroyEvaluator(); 561 } 562 563 @Override onActionModeStarted(ActionMode mode)564 public void onActionModeStarted(ActionMode mode) { 565 super.onActionModeStarted(mode); 566 if (mode.getTag() == CalculatorFormula.TAG_ACTION_MODE) { 567 mFormulaContainer.scrollTo(mFormulaText.getRight(), 0); 568 } 569 } 570 571 /** 572 * Stop any active ActionMode or ContextMenu for copy/paste actions. 573 * Return true if there was one. 574 */ stopActionModeOrContextMenu()575 private boolean stopActionModeOrContextMenu() { 576 return mResultText.stopActionModeOrContextMenu() 577 || mFormulaText.stopActionModeOrContextMenu(); 578 } 579 580 @Override onUserInteraction()581 public void onUserInteraction() { 582 super.onUserInteraction(); 583 584 // If there's an animation in progress, end it immediately, so the user interaction can 585 // be handled. 586 if (mCurrentAnimator != null) { 587 mCurrentAnimator.end(); 588 } 589 } 590 591 @Override dispatchTouchEvent(MotionEvent e)592 public boolean dispatchTouchEvent(MotionEvent e) { 593 if (e.getActionMasked() == MotionEvent.ACTION_DOWN) { 594 stopActionModeOrContextMenu(); 595 596 final HistoryFragment historyFragment = getHistoryFragment(); 597 if (mDragLayout.isOpen() && historyFragment != null) { 598 historyFragment.stopActionModeOrContextMenu(); 599 } 600 } 601 return super.dispatchTouchEvent(e); 602 } 603 604 @Override onBackPressed()605 public void onBackPressed() { 606 if (!stopActionModeOrContextMenu()) { 607 final HistoryFragment historyFragment = getHistoryFragment(); 608 if (mDragLayout.isOpen() && historyFragment != null) { 609 if (!historyFragment.stopActionModeOrContextMenu()) { 610 removeHistoryFragment(); 611 } 612 return; 613 } 614 if (mPadViewPager != null && mPadViewPager.getCurrentItem() != 0) { 615 // Select the previous pad. 616 mPadViewPager.setCurrentItem(mPadViewPager.getCurrentItem() - 1); 617 } else { 618 // If the user is currently looking at the first pad (or the pad is not paged), 619 // allow the system to handle the Back button. 620 super.onBackPressed(); 621 } 622 } 623 } 624 625 @Override onKeyUp(int keyCode, KeyEvent event)626 public boolean onKeyUp(int keyCode, KeyEvent event) { 627 // Allow the system to handle special key codes (e.g. "BACK" or "DPAD"). 628 switch (keyCode) { 629 case KeyEvent.KEYCODE_BACK: 630 case KeyEvent.KEYCODE_ESCAPE: 631 case KeyEvent.KEYCODE_DPAD_UP: 632 case KeyEvent.KEYCODE_DPAD_DOWN: 633 case KeyEvent.KEYCODE_DPAD_LEFT: 634 case KeyEvent.KEYCODE_DPAD_RIGHT: 635 return super.onKeyUp(keyCode, event); 636 } 637 638 // Stop the action mode or context menu if it's showing. 639 stopActionModeOrContextMenu(); 640 641 // Always cancel unrequested in-progress evaluation of the main expression, so that 642 // we don't have to worry about subsequent asynchronous completion. 643 // Requested in-progress evaluations are handled below. 644 cancelUnrequested(); 645 646 switch (keyCode) { 647 case KeyEvent.KEYCODE_NUMPAD_ENTER: 648 case KeyEvent.KEYCODE_ENTER: 649 case KeyEvent.KEYCODE_DPAD_CENTER: 650 mCurrentButton = mEqualButton; 651 onEquals(); 652 return true; 653 case KeyEvent.KEYCODE_DEL: 654 mCurrentButton = mDeleteButton; 655 onDelete(); 656 return true; 657 case KeyEvent.KEYCODE_CLEAR: 658 mCurrentButton = mClearButton; 659 onClear(); 660 return true; 661 default: 662 cancelIfEvaluating(false); 663 final int raw = event.getKeyCharacterMap().get(keyCode, event.getMetaState()); 664 if ((raw & KeyCharacterMap.COMBINING_ACCENT) != 0) { 665 return true; // discard 666 } 667 // Try to discard non-printing characters and the like. 668 // The user will have to explicitly delete other junk that gets past us. 669 if (Character.isIdentifierIgnorable(raw) || Character.isWhitespace(raw)) { 670 return true; 671 } 672 char c = (char) raw; 673 if (c == '=') { 674 mCurrentButton = mEqualButton; 675 onEquals(); 676 } else { 677 addChars(String.valueOf(c), true); 678 redisplayAfterFormulaChange(); 679 } 680 return true; 681 } 682 } 683 684 /** 685 * Invoked whenever the inverse button is toggled to update the UI. 686 * 687 * @param showInverse {@code true} if inverse functions should be shown 688 */ onInverseToggled(boolean showInverse)689 private void onInverseToggled(boolean showInverse) { 690 mInverseToggle.setSelected(showInverse); 691 if (showInverse) { 692 mInverseToggle.setContentDescription(getString(R.string.desc_inv_on)); 693 for (View invertibleButton : mInvertibleButtons) { 694 invertibleButton.setVisibility(View.GONE); 695 } 696 for (View inverseButton : mInverseButtons) { 697 inverseButton.setVisibility(View.VISIBLE); 698 } 699 } else { 700 mInverseToggle.setContentDescription(getString(R.string.desc_inv_off)); 701 for (View invertibleButton : mInvertibleButtons) { 702 invertibleButton.setVisibility(View.VISIBLE); 703 } 704 for (View inverseButton : mInverseButtons) { 705 inverseButton.setVisibility(View.GONE); 706 } 707 } 708 } 709 710 /** 711 * Invoked whenever the deg/rad mode may have changed to update the UI. Note that the mode has 712 * not necessarily actually changed where this is invoked. 713 * 714 * @param degreeMode {@code true} if in degree mode 715 */ onModeChanged(boolean degreeMode)716 private void onModeChanged(boolean degreeMode) { 717 if (degreeMode) { 718 mModeView.setText(R.string.mode_deg); 719 mModeView.setContentDescription(getString(R.string.desc_mode_deg)); 720 721 mModeToggle.setText(R.string.mode_rad); 722 mModeToggle.setContentDescription(getString(R.string.desc_switch_rad)); 723 } else { 724 mModeView.setText(R.string.mode_rad); 725 mModeView.setContentDescription(getString(R.string.desc_mode_rad)); 726 727 mModeToggle.setText(R.string.mode_deg); 728 mModeToggle.setContentDescription(getString(R.string.desc_switch_deg)); 729 } 730 } 731 removeHistoryFragment()732 private void removeHistoryFragment() { 733 final FragmentManager manager = getFragmentManager(); 734 if (manager != null && !manager.isDestroyed()) { 735 manager.popBackStack(HistoryFragment.TAG, FragmentManager.POP_BACK_STACK_INCLUSIVE); 736 } 737 738 // When HistoryFragment is hidden, the main Calculator is important for accessibility again. 739 mMainCalculator.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO); 740 } 741 742 /** 743 * Switch to INPUT from RESULT state in response to input of the specified button_id. 744 * View.NO_ID is treated as an incomplete function id. 745 */ switchToInput(int button_id)746 private void switchToInput(int button_id) { 747 if (KeyMaps.isBinary(button_id) || KeyMaps.isSuffix(button_id)) { 748 mEvaluator.collapse(mEvaluator.getMaxIndex() /* Most recent history entry */); 749 } else { 750 announceClearedForAccessibility(); 751 mEvaluator.clearMain(); 752 } 753 setState(CalculatorState.INPUT); 754 } 755 756 // Add the given button id to input expression. 757 // If appropriate, clear the expression before doing so. addKeyToExpr(int id)758 private void addKeyToExpr(int id) { 759 if (mCurrentState == CalculatorState.ERROR) { 760 setState(CalculatorState.INPUT); 761 } else if (mCurrentState == CalculatorState.RESULT) { 762 switchToInput(id); 763 } 764 if (!mEvaluator.append(id)) { 765 // TODO: Some user visible feedback? 766 } 767 } 768 769 /** 770 * Add the given button id to input expression, assuming it was explicitly 771 * typed/touched. 772 * We perform slightly more aggressive correction than in pasted expressions. 773 */ addExplicitKeyToExpr(int id)774 private void addExplicitKeyToExpr(int id) { 775 if (mCurrentState == CalculatorState.INPUT && id == R.id.op_sub) { 776 mEvaluator.getExpr(Evaluator.MAIN_INDEX).removeTrailingAdditiveOperators(); 777 } 778 addKeyToExpr(id); 779 } 780 evaluateInstantIfNecessary()781 public void evaluateInstantIfNecessary() { 782 if (mCurrentState == CalculatorState.INPUT 783 && mEvaluator.getExpr(Evaluator.MAIN_INDEX).hasInterestingOps()) { 784 mEvaluator.evaluateAndNotify(Evaluator.MAIN_INDEX, this, mResultText); 785 } 786 } 787 redisplayAfterFormulaChange()788 private void redisplayAfterFormulaChange() { 789 // TODO: Could do this more incrementally. 790 redisplayFormula(); 791 setState(CalculatorState.INPUT); 792 mResultText.clear(); 793 if (haveUnprocessed()) { 794 // Force reevaluation when text is deleted, even if expression is unchanged. 795 mEvaluator.touch(); 796 } else { 797 evaluateInstantIfNecessary(); 798 } 799 } 800 801 /** 802 * Show the toolbar. 803 * Automatically hide it again if it's not relevant to current formula. 804 */ showAndMaybeHideToolbar()805 private void showAndMaybeHideToolbar() { 806 final boolean shouldBeVisible = 807 mCurrentState == CalculatorState.INPUT && mEvaluator.hasTrigFuncs(); 808 mDisplayView.showToolbar(!shouldBeVisible); 809 } 810 811 /** 812 * Display or hide the toolbar depending on calculator state. 813 */ showOrHideToolbar()814 private void showOrHideToolbar() { 815 final boolean shouldBeVisible = 816 mCurrentState == CalculatorState.INPUT && mEvaluator.hasTrigFuncs(); 817 if (shouldBeVisible) { 818 mDisplayView.showToolbar(false); 819 } else { 820 mDisplayView.hideToolbar(); 821 } 822 } 823 onButtonClick(View view)824 public void onButtonClick(View view) { 825 // Any animation is ended before we get here. 826 mCurrentButton = view; 827 stopActionModeOrContextMenu(); 828 829 // See onKey above for the rationale behind some of the behavior below: 830 cancelUnrequested(); 831 832 final int id = view.getId(); 833 switch (id) { 834 case R.id.eq: 835 onEquals(); 836 break; 837 case R.id.del: 838 onDelete(); 839 break; 840 case R.id.clr: 841 onClear(); 842 return; // Toolbar visibility adjusted at end of animation. 843 case R.id.toggle_inv: 844 final boolean selected = !mInverseToggle.isSelected(); 845 mInverseToggle.setSelected(selected); 846 onInverseToggled(selected); 847 if (mCurrentState == CalculatorState.RESULT) { 848 mResultText.redisplay(); // In case we cancelled reevaluation. 849 } 850 break; 851 case R.id.toggle_mode: 852 cancelIfEvaluating(false); 853 final boolean mode = !mEvaluator.getDegreeMode(Evaluator.MAIN_INDEX); 854 if (mCurrentState == CalculatorState.RESULT 855 && mEvaluator.getExpr(Evaluator.MAIN_INDEX).hasTrigFuncs()) { 856 // Capture current result evaluated in old mode. 857 mEvaluator.collapse(mEvaluator.getMaxIndex()); 858 redisplayFormula(); 859 } 860 // In input mode, we reinterpret already entered trig functions. 861 mEvaluator.setDegreeMode(mode); 862 onModeChanged(mode); 863 // Show the toolbar to highlight the mode change. 864 showAndMaybeHideToolbar(); 865 setState(CalculatorState.INPUT); 866 mResultText.clear(); 867 if (!haveUnprocessed()) { 868 evaluateInstantIfNecessary(); 869 } 870 return; 871 default: 872 cancelIfEvaluating(false); 873 if (haveUnprocessed()) { 874 // For consistency, append as uninterpreted characters. 875 // This may actually be useful for a left parenthesis. 876 addChars(KeyMaps.toString(this, id), true); 877 } else { 878 addExplicitKeyToExpr(id); 879 redisplayAfterFormulaChange(); 880 } 881 break; 882 } 883 showOrHideToolbar(); 884 } 885 redisplayFormula()886 void redisplayFormula() { 887 SpannableStringBuilder formula 888 = mEvaluator.getExpr(Evaluator.MAIN_INDEX).toSpannableStringBuilder(this); 889 if (mUnprocessedChars != null) { 890 // Add and highlight characters we couldn't process. 891 formula.append(mUnprocessedChars, mUnprocessedColorSpan, 892 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 893 } 894 mFormulaText.changeTextTo(formula); 895 mFormulaText.setContentDescription(TextUtils.isEmpty(formula) 896 ? getString(R.string.desc_formula) : null); 897 } 898 899 @Override onLongClick(View view)900 public boolean onLongClick(View view) { 901 mCurrentButton = view; 902 903 if (view.getId() == R.id.del) { 904 onClear(); 905 return true; 906 } 907 return false; 908 } 909 910 // Initial evaluation completed successfully. Initiate display. onEvaluate(long index, int initDisplayPrec, int msd, int leastDigPos, String truncatedWholeNumber)911 public void onEvaluate(long index, int initDisplayPrec, int msd, int leastDigPos, 912 String truncatedWholeNumber) { 913 if (index != Evaluator.MAIN_INDEX) { 914 throw new AssertionError("Unexpected evaluation result index\n"); 915 } 916 917 // Invalidate any options that may depend on the current result. 918 invalidateOptionsMenu(); 919 920 mResultText.onEvaluate(index, initDisplayPrec, msd, leastDigPos, truncatedWholeNumber); 921 if (mCurrentState != CalculatorState.INPUT) { 922 // In EVALUATE, INIT, RESULT, or INIT_FOR_RESULT state. 923 onResult(mCurrentState == CalculatorState.EVALUATE /* animate */, 924 mCurrentState == CalculatorState.INIT_FOR_RESULT 925 || mCurrentState == CalculatorState.RESULT /* previously preserved */); 926 } 927 } 928 929 // Reset state to reflect evaluator cancellation. Invoked by evaluator. onCancelled(long index)930 public void onCancelled(long index) { 931 // Index is Evaluator.MAIN_INDEX. We should be in EVALUATE state. 932 setState(CalculatorState.INPUT); 933 mResultText.onCancelled(index); 934 } 935 936 // Reevaluation completed; ask result to redisplay current value. onReevaluate(long index)937 public void onReevaluate(long index) { 938 // Index is Evaluator.MAIN_INDEX. 939 mResultText.onReevaluate(index); 940 } 941 942 @Override onTextSizeChanged(final TextView textView, float oldSize)943 public void onTextSizeChanged(final TextView textView, float oldSize) { 944 if (mCurrentState != CalculatorState.INPUT) { 945 // Only animate text changes that occur from user input. 946 return; 947 } 948 949 // Calculate the values needed to perform the scale and translation animations, 950 // maintaining the same apparent baseline for the displayed text. 951 final float textScale = oldSize / textView.getTextSize(); 952 final float translationX = (1.0f - textScale) * 953 (textView.getWidth() / 2.0f - textView.getPaddingEnd()); 954 final float translationY = (1.0f - textScale) * 955 (textView.getHeight() / 2.0f - textView.getPaddingBottom()); 956 957 final AnimatorSet animatorSet = new AnimatorSet(); 958 animatorSet.playTogether( 959 ObjectAnimator.ofFloat(textView, View.SCALE_X, textScale, 1.0f), 960 ObjectAnimator.ofFloat(textView, View.SCALE_Y, textScale, 1.0f), 961 ObjectAnimator.ofFloat(textView, View.TRANSLATION_X, translationX, 0.0f), 962 ObjectAnimator.ofFloat(textView, View.TRANSLATION_Y, translationY, 0.0f)); 963 animatorSet.setDuration(getResources().getInteger(android.R.integer.config_mediumAnimTime)); 964 animatorSet.setInterpolator(new AccelerateDecelerateInterpolator()); 965 animatorSet.start(); 966 } 967 968 /** 969 * Cancel any in-progress explicitly requested evaluations. 970 * @param quiet suppress pop-up message. Explicit evaluation can change the expression 971 value, and certainly changes the display, so it seems reasonable to warn. 972 * @return true if there was such an evaluation 973 */ cancelIfEvaluating(boolean quiet)974 private boolean cancelIfEvaluating(boolean quiet) { 975 if (mCurrentState == CalculatorState.EVALUATE) { 976 mEvaluator.cancel(Evaluator.MAIN_INDEX, quiet); 977 return true; 978 } else { 979 return false; 980 } 981 } 982 983 cancelUnrequested()984 private void cancelUnrequested() { 985 if (mCurrentState == CalculatorState.INPUT) { 986 mEvaluator.cancel(Evaluator.MAIN_INDEX, true); 987 } 988 } 989 haveUnprocessed()990 private boolean haveUnprocessed() { 991 return mUnprocessedChars != null && !mUnprocessedChars.isEmpty(); 992 } 993 onEquals()994 private void onEquals() { 995 // Ignore if in non-INPUT state, or if there are no operators. 996 if (mCurrentState == CalculatorState.INPUT) { 997 if (haveUnprocessed()) { 998 setState(CalculatorState.EVALUATE); 999 onError(Evaluator.MAIN_INDEX, R.string.error_syntax); 1000 } else if (mEvaluator.getExpr(Evaluator.MAIN_INDEX).hasInterestingOps()) { 1001 setState(CalculatorState.EVALUATE); 1002 mEvaluator.requireResult(Evaluator.MAIN_INDEX, this, mResultText); 1003 } 1004 } 1005 } 1006 onDelete()1007 private void onDelete() { 1008 // Delete works like backspace; remove the last character or operator from the expression. 1009 // Note that we handle keyboard delete exactly like the delete button. For 1010 // example the delete button can be used to delete a character from an incomplete 1011 // function name typed on a physical keyboard. 1012 // This should be impossible in RESULT state. 1013 // If there is an in-progress explicit evaluation, just cancel it and return. 1014 if (cancelIfEvaluating(false)) return; 1015 setState(CalculatorState.INPUT); 1016 if (haveUnprocessed()) { 1017 mUnprocessedChars = mUnprocessedChars.substring(0, mUnprocessedChars.length() - 1); 1018 } else { 1019 mEvaluator.delete(); 1020 } 1021 if (mEvaluator.getExpr(Evaluator.MAIN_INDEX).isEmpty() && !haveUnprocessed()) { 1022 // Resulting formula won't be announced, since it's empty. 1023 announceClearedForAccessibility(); 1024 } 1025 redisplayAfterFormulaChange(); 1026 } 1027 reveal(View sourceView, int colorRes, AnimatorListener listener)1028 private void reveal(View sourceView, int colorRes, AnimatorListener listener) { 1029 final ViewGroupOverlay groupOverlay = 1030 (ViewGroupOverlay) getWindow().getDecorView().getOverlay(); 1031 1032 final Rect displayRect = new Rect(); 1033 mDisplayView.getGlobalVisibleRect(displayRect); 1034 1035 // Make reveal cover the display and status bar. 1036 final View revealView = new View(this); 1037 revealView.setBottom(displayRect.bottom); 1038 revealView.setLeft(displayRect.left); 1039 revealView.setRight(displayRect.right); 1040 revealView.setBackgroundColor(ContextCompat.getColor(this, colorRes)); 1041 groupOverlay.add(revealView); 1042 1043 final int[] clearLocation = new int[2]; 1044 sourceView.getLocationInWindow(clearLocation); 1045 clearLocation[0] += sourceView.getWidth() / 2; 1046 clearLocation[1] += sourceView.getHeight() / 2; 1047 1048 final int revealCenterX = clearLocation[0] - revealView.getLeft(); 1049 final int revealCenterY = clearLocation[1] - revealView.getTop(); 1050 1051 final double x1_2 = Math.pow(revealView.getLeft() - revealCenterX, 2); 1052 final double x2_2 = Math.pow(revealView.getRight() - revealCenterX, 2); 1053 final double y_2 = Math.pow(revealView.getTop() - revealCenterY, 2); 1054 final float revealRadius = (float) Math.max(Math.sqrt(x1_2 + y_2), Math.sqrt(x2_2 + y_2)); 1055 1056 final Animator revealAnimator = 1057 ViewAnimationUtils.createCircularReveal(revealView, 1058 revealCenterX, revealCenterY, 0.0f, revealRadius); 1059 revealAnimator.setDuration( 1060 getResources().getInteger(android.R.integer.config_longAnimTime)); 1061 revealAnimator.addListener(listener); 1062 1063 final Animator alphaAnimator = ObjectAnimator.ofFloat(revealView, View.ALPHA, 0.0f); 1064 alphaAnimator.setDuration( 1065 getResources().getInteger(android.R.integer.config_mediumAnimTime)); 1066 1067 final AnimatorSet animatorSet = new AnimatorSet(); 1068 animatorSet.play(revealAnimator).before(alphaAnimator); 1069 animatorSet.setInterpolator(new AccelerateDecelerateInterpolator()); 1070 animatorSet.addListener(new AnimatorListenerAdapter() { 1071 @Override 1072 public void onAnimationEnd(Animator animator) { 1073 groupOverlay.remove(revealView); 1074 mCurrentAnimator = null; 1075 } 1076 }); 1077 1078 mCurrentAnimator = animatorSet; 1079 animatorSet.start(); 1080 } 1081 announceClearedForAccessibility()1082 private void announceClearedForAccessibility() { 1083 mResultText.announceForAccessibility(getResources().getString(R.string.cleared)); 1084 } 1085 onClearAnimationEnd()1086 public void onClearAnimationEnd() { 1087 mUnprocessedChars = null; 1088 mResultText.clear(); 1089 mEvaluator.clearMain(); 1090 setState(CalculatorState.INPUT); 1091 redisplayFormula(); 1092 } 1093 onClear()1094 private void onClear() { 1095 if (mEvaluator.getExpr(Evaluator.MAIN_INDEX).isEmpty() && !haveUnprocessed()) { 1096 return; 1097 } 1098 cancelIfEvaluating(true); 1099 announceClearedForAccessibility(); 1100 reveal(mCurrentButton, R.color.calculator_primary_color, new AnimatorListenerAdapter() { 1101 @Override 1102 public void onAnimationEnd(Animator animation) { 1103 onClearAnimationEnd(); 1104 showOrHideToolbar(); 1105 } 1106 }); 1107 } 1108 1109 // Evaluation encountered en error. Display the error. 1110 @Override onError(final long index, final int errorResourceId)1111 public void onError(final long index, final int errorResourceId) { 1112 if (index != Evaluator.MAIN_INDEX) { 1113 throw new AssertionError("Unexpected error source"); 1114 } 1115 if (mCurrentState == CalculatorState.EVALUATE) { 1116 setState(CalculatorState.ANIMATE); 1117 mResultText.announceForAccessibility(getResources().getString(errorResourceId)); 1118 reveal(mCurrentButton, R.color.calculator_error_color, 1119 new AnimatorListenerAdapter() { 1120 @Override 1121 public void onAnimationEnd(Animator animation) { 1122 setState(CalculatorState.ERROR); 1123 mResultText.onError(index, errorResourceId); 1124 } 1125 }); 1126 } else if (mCurrentState == CalculatorState.INIT 1127 || mCurrentState == CalculatorState.INIT_FOR_RESULT /* very unlikely */) { 1128 setState(CalculatorState.ERROR); 1129 mResultText.onError(index, errorResourceId); 1130 } else { 1131 mResultText.clear(); 1132 } 1133 } 1134 1135 // Animate movement of result into the top formula slot. 1136 // Result window now remains translated in the top slot while the result is displayed. 1137 // (We convert it back to formula use only when the user provides new input.) 1138 // Historical note: In the Lollipop version, this invisibly and instantaneously moved 1139 // formula and result displays back at the end of the animation. We no longer do that, 1140 // so that we can continue to properly support scrolling of the result. 1141 // We assume the result already contains the text to be expanded. onResult(boolean animate, boolean resultWasPreserved)1142 private void onResult(boolean animate, boolean resultWasPreserved) { 1143 // Calculate the textSize that would be used to display the result in the formula. 1144 // For scrollable results just use the minimum textSize to maximize the number of digits 1145 // that are visible on screen. 1146 float textSize = mFormulaText.getMinimumTextSize(); 1147 if (!mResultText.isScrollable()) { 1148 textSize = mFormulaText.getVariableTextSize(mResultText.getText().toString()); 1149 } 1150 1151 // Scale the result to match the calculated textSize, minimizing the jump-cut transition 1152 // when a result is reused in a subsequent expression. 1153 final float resultScale = textSize / mResultText.getTextSize(); 1154 1155 // Set the result's pivot to match its gravity. 1156 mResultText.setPivotX(mResultText.getWidth() - mResultText.getPaddingRight()); 1157 mResultText.setPivotY(mResultText.getHeight() - mResultText.getPaddingBottom()); 1158 1159 // Calculate the necessary translations so the result takes the place of the formula and 1160 // the formula moves off the top of the screen. 1161 final float resultTranslationY = (mFormulaContainer.getBottom() - mResultText.getBottom()) 1162 - (mFormulaText.getPaddingBottom() - mResultText.getPaddingBottom()); 1163 float formulaTranslationY = -mFormulaContainer.getBottom(); 1164 if (mIsOneLine) { 1165 // Position the result text. 1166 mResultText.setY(mResultText.getBottom()); 1167 formulaTranslationY = -(findViewById(R.id.toolbar).getBottom() 1168 + mFormulaContainer.getBottom()); 1169 } 1170 1171 // Change the result's textColor to match the formula. 1172 final int formulaTextColor = mFormulaText.getCurrentTextColor(); 1173 1174 if (resultWasPreserved) { 1175 // Result was previously addded to history. 1176 mEvaluator.represerve(); 1177 } else { 1178 // Add current result to history. 1179 mEvaluator.preserve(Evaluator.MAIN_INDEX, true); 1180 } 1181 1182 if (animate) { 1183 mResultText.announceForAccessibility(getResources().getString(R.string.desc_eq)); 1184 mResultText.announceForAccessibility(mResultText.getText()); 1185 setState(CalculatorState.ANIMATE); 1186 final AnimatorSet animatorSet = new AnimatorSet(); 1187 animatorSet.playTogether( 1188 ObjectAnimator.ofPropertyValuesHolder(mResultText, 1189 PropertyValuesHolder.ofFloat(View.SCALE_X, resultScale), 1190 PropertyValuesHolder.ofFloat(View.SCALE_Y, resultScale), 1191 PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, resultTranslationY)), 1192 ObjectAnimator.ofArgb(mResultText, TEXT_COLOR, formulaTextColor), 1193 ObjectAnimator.ofFloat(mFormulaContainer, View.TRANSLATION_Y, 1194 formulaTranslationY)); 1195 animatorSet.setDuration(getResources().getInteger( 1196 android.R.integer.config_longAnimTime)); 1197 animatorSet.addListener(new AnimatorListenerAdapter() { 1198 @Override 1199 public void onAnimationEnd(Animator animation) { 1200 setState(CalculatorState.RESULT); 1201 mCurrentAnimator = null; 1202 } 1203 }); 1204 1205 mCurrentAnimator = animatorSet; 1206 animatorSet.start(); 1207 } else /* No animation desired; get there fast when restarting */ { 1208 mResultText.setScaleX(resultScale); 1209 mResultText.setScaleY(resultScale); 1210 mResultText.setTranslationY(resultTranslationY); 1211 mResultText.setTextColor(formulaTextColor); 1212 mFormulaContainer.setTranslationY(formulaTranslationY); 1213 setState(CalculatorState.RESULT); 1214 } 1215 } 1216 1217 // Restore positions of the formula and result displays back to their original, 1218 // pre-animation state. restoreDisplayPositions()1219 private void restoreDisplayPositions() { 1220 // Clear result. 1221 mResultText.setText(""); 1222 // Reset all of the values modified during the animation. 1223 mResultText.setScaleX(1.0f); 1224 mResultText.setScaleY(1.0f); 1225 mResultText.setTranslationX(0.0f); 1226 mResultText.setTranslationY(0.0f); 1227 mFormulaContainer.setTranslationY(0.0f); 1228 1229 mFormulaText.requestFocus(); 1230 } 1231 1232 @Override onClick(AlertDialogFragment fragment, int which)1233 public void onClick(AlertDialogFragment fragment, int which) { 1234 if (which == DialogInterface.BUTTON_POSITIVE) { 1235 if (HistoryFragment.CLEAR_DIALOG_TAG.equals(fragment.getTag())) { 1236 // TODO: Try to preserve the current, saved, and memory expressions. How should we 1237 // handle expressions to which they refer? 1238 mEvaluator.clearEverything(); 1239 // TODO: It's not clear what we should really do here. This is an initial hack. 1240 // May want to make onClearAnimationEnd() private if/when we fix this. 1241 onClearAnimationEnd(); 1242 mEvaluatorCallback.onMemoryStateChanged(); 1243 onBackPressed(); 1244 } else if (Evaluator.TIMEOUT_DIALOG_TAG.equals(fragment.getTag())) { 1245 // Timeout extension request. 1246 mEvaluator.setLongTimeout(); 1247 } else { 1248 Log.e(TAG, "Unknown AlertDialogFragment click:" + fragment.getTag()); 1249 } 1250 } 1251 } 1252 1253 @Override onCreateOptionsMenu(Menu menu)1254 public boolean onCreateOptionsMenu(Menu menu) { 1255 super.onCreateOptionsMenu(menu); 1256 1257 getMenuInflater().inflate(R.menu.activity_calculator, menu); 1258 return true; 1259 } 1260 1261 @Override onPrepareOptionsMenu(Menu menu)1262 public boolean onPrepareOptionsMenu(Menu menu) { 1263 super.onPrepareOptionsMenu(menu); 1264 1265 // Show the leading option when displaying a result. 1266 menu.findItem(R.id.menu_leading).setVisible(mCurrentState == CalculatorState.RESULT); 1267 1268 // Show the fraction option when displaying a rational result. 1269 boolean visible = mCurrentState == CalculatorState.RESULT; 1270 final UnifiedReal mainResult = mEvaluator.getResult(Evaluator.MAIN_INDEX); 1271 // mainResult should never be null, but it happens. Check as a workaround to protect 1272 // against crashes until we find the root cause (b/34763650). 1273 visible &= mainResult != null && mainResult.exactlyDisplayable(); 1274 menu.findItem(R.id.menu_fraction).setVisible(visible); 1275 1276 return true; 1277 } 1278 1279 @Override onOptionsItemSelected(MenuItem item)1280 public boolean onOptionsItemSelected(MenuItem item) { 1281 switch (item.getItemId()) { 1282 case R.id.menu_history: 1283 showHistoryFragment(); 1284 return true; 1285 case R.id.menu_leading: 1286 displayFull(); 1287 return true; 1288 case R.id.menu_fraction: 1289 displayFraction(); 1290 return true; 1291 case R.id.menu_licenses: 1292 startActivity(new Intent(this, Licenses.class)); 1293 return true; 1294 default: 1295 return super.onOptionsItemSelected(item); 1296 } 1297 } 1298 1299 /* Begin override CloseCallback method. */ 1300 1301 @Override onClose()1302 public void onClose() { 1303 removeHistoryFragment(); 1304 } 1305 1306 /* End override CloseCallback method. */ 1307 1308 /* Begin override DragCallback methods */ 1309 onStartDraggingOpen()1310 public void onStartDraggingOpen() { 1311 mDisplayView.hideToolbar(); 1312 showHistoryFragment(); 1313 } 1314 1315 @Override onInstanceStateRestored(boolean isOpen)1316 public void onInstanceStateRestored(boolean isOpen) { 1317 } 1318 1319 @Override whileDragging(float yFraction)1320 public void whileDragging(float yFraction) { 1321 } 1322 1323 @Override shouldCaptureView(View view, int x, int y)1324 public boolean shouldCaptureView(View view, int x, int y) { 1325 return view.getId() == R.id.history_frame 1326 && (mDragLayout.isMoving() || mDragLayout.isViewUnder(view, x, y)); 1327 } 1328 1329 @Override getDisplayHeight()1330 public int getDisplayHeight() { 1331 return mDisplayView.getMeasuredHeight(); 1332 } 1333 1334 /* End override DragCallback methods */ 1335 1336 /** 1337 * Change evaluation state to one that's friendly to the history fragment. 1338 * Return false if that was not easily possible. 1339 */ prepareForHistory()1340 private boolean prepareForHistory() { 1341 if (mCurrentState == CalculatorState.ANIMATE) { 1342 throw new AssertionError("onUserInteraction should have ended animation"); 1343 } else if (mCurrentState == CalculatorState.EVALUATE) { 1344 // Cancel current evaluation 1345 cancelIfEvaluating(true /* quiet */ ); 1346 setState(CalculatorState.INPUT); 1347 return true; 1348 } else if (mCurrentState == CalculatorState.INIT) { 1349 // Easiest to just refuse. Otherwise we can see a state change 1350 // while in history mode, which causes all sorts of problems. 1351 // TODO: Consider other alternatives. If we're just doing the decimal conversion 1352 // at the end of an evaluation, we could treat this as RESULT state. 1353 return false; 1354 } 1355 // We should be in INPUT, INIT_FOR_RESULT, RESULT, or ERROR state. 1356 return true; 1357 } 1358 getHistoryFragment()1359 private HistoryFragment getHistoryFragment() { 1360 final FragmentManager manager = getFragmentManager(); 1361 if (manager == null || manager.isDestroyed()) { 1362 return null; 1363 } 1364 final Fragment fragment = manager.findFragmentByTag(HistoryFragment.TAG); 1365 return fragment == null || fragment.isRemoving() ? null : (HistoryFragment) fragment; 1366 } 1367 showHistoryFragment()1368 private void showHistoryFragment() { 1369 final FragmentManager manager = getFragmentManager(); 1370 if (manager == null || manager.isDestroyed()) { 1371 return; 1372 } 1373 1374 if (getHistoryFragment() != null || !prepareForHistory()) { 1375 return; 1376 } 1377 1378 stopActionModeOrContextMenu(); 1379 manager.beginTransaction() 1380 .replace(R.id.history_frame, new HistoryFragment(), HistoryFragment.TAG) 1381 .setTransition(FragmentTransaction.TRANSIT_NONE) 1382 .addToBackStack(HistoryFragment.TAG) 1383 .commit(); 1384 1385 // When HistoryFragment is visible, hide all descendants of the main Calculator view. 1386 mMainCalculator.setImportantForAccessibility( 1387 View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); 1388 // TODO: pass current scroll position of result 1389 } 1390 displayMessage(String title, String message)1391 private void displayMessage(String title, String message) { 1392 AlertDialogFragment.showMessageDialog(this, title, message, null, null /* tag */); 1393 } 1394 displayFraction()1395 private void displayFraction() { 1396 UnifiedReal result = mEvaluator.getResult(Evaluator.MAIN_INDEX); 1397 displayMessage(getString(R.string.menu_fraction), 1398 KeyMaps.translateResult(result.toNiceString())); 1399 } 1400 1401 // Display full result to currently evaluated precision displayFull()1402 private void displayFull() { 1403 Resources res = getResources(); 1404 String msg = mResultText.getFullText(true /* withSeparators */) + " "; 1405 if (mResultText.fullTextIsExact()) { 1406 msg += res.getString(R.string.exact); 1407 } else { 1408 msg += res.getString(R.string.approximate); 1409 } 1410 displayMessage(getString(R.string.menu_leading), msg); 1411 } 1412 1413 /** 1414 * Add input characters to the end of the expression. 1415 * Map them to the appropriate button pushes when possible. Leftover characters 1416 * are added to mUnprocessedChars, which is presumed to immediately precede the newly 1417 * added characters. 1418 * @param moreChars characters to be added 1419 * @param explicit these characters were explicitly typed by the user, not pasted 1420 */ addChars(String moreChars, boolean explicit)1421 private void addChars(String moreChars, boolean explicit) { 1422 if (mUnprocessedChars != null) { 1423 moreChars = mUnprocessedChars + moreChars; 1424 } 1425 int current = 0; 1426 int len = moreChars.length(); 1427 boolean lastWasDigit = false; 1428 if (mCurrentState == CalculatorState.RESULT && len != 0) { 1429 // Clear display immediately for incomplete function name. 1430 switchToInput(KeyMaps.keyForChar(moreChars.charAt(current))); 1431 } 1432 char groupingSeparator = KeyMaps.translateResult(",").charAt(0); 1433 while (current < len) { 1434 char c = moreChars.charAt(current); 1435 if (Character.isSpaceChar(c) || c == groupingSeparator) { 1436 ++current; 1437 continue; 1438 } 1439 int k = KeyMaps.keyForChar(c); 1440 if (!explicit) { 1441 int expEnd; 1442 if (lastWasDigit && current != 1443 (expEnd = Evaluator.exponentEnd(moreChars, current))) { 1444 // Process scientific notation with 'E' when pasting, in spite of ambiguity 1445 // with base of natural log. 1446 // Otherwise the 10^x key is the user's friend. 1447 mEvaluator.addExponent(moreChars, current, expEnd); 1448 current = expEnd; 1449 lastWasDigit = false; 1450 continue; 1451 } else { 1452 boolean isDigit = KeyMaps.digVal(k) != KeyMaps.NOT_DIGIT; 1453 if (current == 0 && (isDigit || k == R.id.dec_point) 1454 && mEvaluator.getExpr(Evaluator.MAIN_INDEX).hasTrailingConstant()) { 1455 // Refuse to concatenate pasted content to trailing constant. 1456 // This makes pasting of calculator results more consistent, whether or 1457 // not the old calculator instance is still around. 1458 addKeyToExpr(R.id.op_mul); 1459 } 1460 lastWasDigit = (isDigit || lastWasDigit && k == R.id.dec_point); 1461 } 1462 } 1463 if (k != View.NO_ID) { 1464 mCurrentButton = findViewById(k); 1465 if (explicit) { 1466 addExplicitKeyToExpr(k); 1467 } else { 1468 addKeyToExpr(k); 1469 } 1470 if (Character.isSurrogate(c)) { 1471 current += 2; 1472 } else { 1473 ++current; 1474 } 1475 continue; 1476 } 1477 int f = KeyMaps.funForString(moreChars, current); 1478 if (f != View.NO_ID) { 1479 mCurrentButton = findViewById(f); 1480 if (explicit) { 1481 addExplicitKeyToExpr(f); 1482 } else { 1483 addKeyToExpr(f); 1484 } 1485 if (f == R.id.op_sqrt) { 1486 // Square root entered as function; don't lose the parenthesis. 1487 addKeyToExpr(R.id.lparen); 1488 } 1489 current = moreChars.indexOf('(', current) + 1; 1490 continue; 1491 } 1492 // There are characters left, but we can't convert them to button presses. 1493 mUnprocessedChars = moreChars.substring(current); 1494 redisplayAfterFormulaChange(); 1495 showOrHideToolbar(); 1496 return; 1497 } 1498 mUnprocessedChars = null; 1499 redisplayAfterFormulaChange(); 1500 showOrHideToolbar(); 1501 } 1502 clearIfNotInputState()1503 private void clearIfNotInputState() { 1504 if (mCurrentState == CalculatorState.ERROR 1505 || mCurrentState == CalculatorState.RESULT) { 1506 setState(CalculatorState.INPUT); 1507 mEvaluator.clearMain(); 1508 } 1509 } 1510 1511 /** 1512 * Since we only support LTR format, using the RTL comma does not make sense. 1513 */ getDecimalSeparator()1514 private String getDecimalSeparator() { 1515 final char defaultSeparator = DecimalFormatSymbols.getInstance().getDecimalSeparator(); 1516 final char rtlComma = '\u066b'; 1517 return defaultSeparator == rtlComma ? "," : String.valueOf(defaultSeparator); 1518 } 1519 1520 /** 1521 * Clean up animation for context menu. 1522 */ 1523 @Override onContextMenuClosed(Menu menu)1524 public void onContextMenuClosed(Menu menu) { 1525 stopActionModeOrContextMenu(); 1526 } 1527 1528 public interface OnDisplayMemoryOperationsListener { shouldDisplayMemory()1529 boolean shouldDisplayMemory(); 1530 } 1531 } 1532