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 androidx.annotation.NonNull; 48 import androidx.annotation.StringRes; 49 import androidx.core.content.ContextCompat; 50 import androidx.viewpager.widget.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 // Note that ERROR has INPUT, not RESULT layout. 539 return mCurrentState == CalculatorState.INIT_FOR_RESULT 540 || mCurrentState == CalculatorState.RESULT; 541 } 542 isOneLine()543 public boolean isOneLine() { 544 return mIsOneLine; 545 } 546 547 @Override onDestroy()548 protected void onDestroy() { 549 mDragLayout.removeDragCallback(this); 550 super.onDestroy(); 551 } 552 553 /** 554 * Destroy the evaluator and close the underlying database. 555 */ destroyEvaluator()556 public void destroyEvaluator() { 557 mEvaluator.destroyEvaluator(); 558 } 559 560 @Override onActionModeStarted(ActionMode mode)561 public void onActionModeStarted(ActionMode mode) { 562 super.onActionModeStarted(mode); 563 if (mode.getTag() == CalculatorFormula.TAG_ACTION_MODE) { 564 mFormulaContainer.scrollTo(mFormulaText.getRight(), 0); 565 } 566 } 567 568 /** 569 * Stop any active ActionMode or ContextMenu for copy/paste actions. 570 * Return true if there was one. 571 */ stopActionModeOrContextMenu()572 private boolean stopActionModeOrContextMenu() { 573 return mResultText.stopActionModeOrContextMenu() 574 || mFormulaText.stopActionModeOrContextMenu(); 575 } 576 577 @Override onUserInteraction()578 public void onUserInteraction() { 579 super.onUserInteraction(); 580 581 // If there's an animation in progress, end it immediately, so the user interaction can 582 // be handled. 583 if (mCurrentAnimator != null) { 584 mCurrentAnimator.end(); 585 } 586 } 587 588 @Override dispatchTouchEvent(MotionEvent e)589 public boolean dispatchTouchEvent(MotionEvent e) { 590 if (e.getActionMasked() == MotionEvent.ACTION_DOWN) { 591 stopActionModeOrContextMenu(); 592 593 final HistoryFragment historyFragment = getHistoryFragment(); 594 if (mDragLayout.isOpen() && historyFragment != null) { 595 historyFragment.stopActionModeOrContextMenu(); 596 } 597 } 598 return super.dispatchTouchEvent(e); 599 } 600 601 @Override onBackPressed()602 public void onBackPressed() { 603 if (!stopActionModeOrContextMenu()) { 604 final HistoryFragment historyFragment = getHistoryFragment(); 605 if (mDragLayout.isOpen() && historyFragment != null) { 606 if (!historyFragment.stopActionModeOrContextMenu()) { 607 removeHistoryFragment(); 608 } 609 return; 610 } 611 if (mPadViewPager != null && mPadViewPager.getCurrentItem() != 0) { 612 // Select the previous pad. 613 mPadViewPager.setCurrentItem(mPadViewPager.getCurrentItem() - 1); 614 } else { 615 // If the user is currently looking at the first pad (or the pad is not paged), 616 // allow the system to handle the Back button. 617 super.onBackPressed(); 618 } 619 } 620 } 621 622 @Override onKeyUp(int keyCode, KeyEvent event)623 public boolean onKeyUp(int keyCode, KeyEvent event) { 624 // Allow the system to handle special key codes (e.g. "BACK" or "DPAD"). 625 switch (keyCode) { 626 case KeyEvent.KEYCODE_BACK: 627 case KeyEvent.KEYCODE_ESCAPE: 628 case KeyEvent.KEYCODE_DPAD_UP: 629 case KeyEvent.KEYCODE_DPAD_DOWN: 630 case KeyEvent.KEYCODE_DPAD_LEFT: 631 case KeyEvent.KEYCODE_DPAD_RIGHT: 632 return super.onKeyUp(keyCode, event); 633 } 634 635 // Stop the action mode or context menu if it's showing. 636 stopActionModeOrContextMenu(); 637 638 // Always cancel unrequested in-progress evaluation of the main expression, so that 639 // we don't have to worry about subsequent asynchronous completion. 640 // Requested in-progress evaluations are handled below. 641 cancelUnrequested(); 642 643 switch (keyCode) { 644 case KeyEvent.KEYCODE_NUMPAD_ENTER: 645 case KeyEvent.KEYCODE_ENTER: 646 case KeyEvent.KEYCODE_DPAD_CENTER: 647 mCurrentButton = mEqualButton; 648 onEquals(); 649 return true; 650 case KeyEvent.KEYCODE_DEL: 651 mCurrentButton = mDeleteButton; 652 onDelete(); 653 return true; 654 case KeyEvent.KEYCODE_CLEAR: 655 mCurrentButton = mClearButton; 656 onClear(); 657 return true; 658 default: 659 cancelIfEvaluating(false); 660 final int raw = event.getKeyCharacterMap().get(keyCode, event.getMetaState()); 661 if ((raw & KeyCharacterMap.COMBINING_ACCENT) != 0) { 662 return true; // discard 663 } 664 // Try to discard non-printing characters and the like. 665 // The user will have to explicitly delete other junk that gets past us. 666 if (Character.isIdentifierIgnorable(raw) || Character.isWhitespace(raw)) { 667 return true; 668 } 669 char c = (char) raw; 670 if (c == '=') { 671 mCurrentButton = mEqualButton; 672 onEquals(); 673 } else { 674 addChars(String.valueOf(c), true); 675 redisplayAfterFormulaChange(); 676 } 677 return true; 678 } 679 } 680 681 /** 682 * Invoked whenever the inverse button is toggled to update the UI. 683 * 684 * @param showInverse {@code true} if inverse functions should be shown 685 */ onInverseToggled(boolean showInverse)686 private void onInverseToggled(boolean showInverse) { 687 mInverseToggle.setSelected(showInverse); 688 if (showInverse) { 689 mInverseToggle.setContentDescription(getString(R.string.desc_inv_on)); 690 for (View invertibleButton : mInvertibleButtons) { 691 invertibleButton.setVisibility(View.GONE); 692 } 693 for (View inverseButton : mInverseButtons) { 694 inverseButton.setVisibility(View.VISIBLE); 695 } 696 } else { 697 mInverseToggle.setContentDescription(getString(R.string.desc_inv_off)); 698 for (View invertibleButton : mInvertibleButtons) { 699 invertibleButton.setVisibility(View.VISIBLE); 700 } 701 for (View inverseButton : mInverseButtons) { 702 inverseButton.setVisibility(View.GONE); 703 } 704 } 705 } 706 707 /** 708 * Invoked whenever the deg/rad mode may have changed to update the UI. Note that the mode has 709 * not necessarily actually changed where this is invoked. 710 * 711 * @param degreeMode {@code true} if in degree mode 712 */ onModeChanged(boolean degreeMode)713 private void onModeChanged(boolean degreeMode) { 714 if (degreeMode) { 715 mModeView.setText(R.string.mode_deg); 716 mModeView.setContentDescription(getString(R.string.desc_mode_deg)); 717 718 mModeToggle.setText(R.string.mode_rad); 719 mModeToggle.setContentDescription(getString(R.string.desc_switch_rad)); 720 } else { 721 mModeView.setText(R.string.mode_rad); 722 mModeView.setContentDescription(getString(R.string.desc_mode_rad)); 723 724 mModeToggle.setText(R.string.mode_deg); 725 mModeToggle.setContentDescription(getString(R.string.desc_switch_deg)); 726 } 727 } 728 removeHistoryFragment()729 private void removeHistoryFragment() { 730 final FragmentManager manager = getFragmentManager(); 731 if (manager != null && !manager.isDestroyed()) { 732 manager.popBackStack(HistoryFragment.TAG, FragmentManager.POP_BACK_STACK_INCLUSIVE); 733 } 734 735 // When HistoryFragment is hidden, the main Calculator is important for accessibility again. 736 mMainCalculator.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO); 737 } 738 739 /** 740 * Switch to INPUT from RESULT state in response to input of the specified button_id. 741 * View.NO_ID is treated as an incomplete function id. 742 */ switchToInput(int button_id)743 private void switchToInput(int button_id) { 744 if (KeyMaps.isBinary(button_id) || KeyMaps.isSuffix(button_id)) { 745 mEvaluator.collapse(mEvaluator.getMaxIndex() /* Most recent history entry */); 746 } else { 747 announceClearedForAccessibility(); 748 mEvaluator.clearMain(); 749 } 750 setState(CalculatorState.INPUT); 751 } 752 753 // Add the given button id to input expression. 754 // If appropriate, clear the expression before doing so. addKeyToExpr(int id)755 private void addKeyToExpr(int id) { 756 if (mCurrentState == CalculatorState.ERROR) { 757 setState(CalculatorState.INPUT); 758 } else if (mCurrentState == CalculatorState.RESULT) { 759 switchToInput(id); 760 } 761 if (!mEvaluator.append(id)) { 762 // TODO: Some user visible feedback? 763 } 764 } 765 766 /** 767 * Add the given button id to input expression, assuming it was explicitly 768 * typed/touched. 769 * We perform slightly more aggressive correction than in pasted expressions. 770 */ addExplicitKeyToExpr(int id)771 private void addExplicitKeyToExpr(int id) { 772 if (mCurrentState == CalculatorState.INPUT && id == R.id.op_sub) { 773 mEvaluator.getExpr(Evaluator.MAIN_INDEX).removeTrailingAdditiveOperators(); 774 } 775 addKeyToExpr(id); 776 } 777 evaluateInstantIfNecessary()778 public void evaluateInstantIfNecessary() { 779 if (mCurrentState == CalculatorState.INPUT 780 && mEvaluator.getExpr(Evaluator.MAIN_INDEX).hasInterestingOps()) { 781 mEvaluator.evaluateAndNotify(Evaluator.MAIN_INDEX, this, mResultText); 782 } 783 } 784 redisplayAfterFormulaChange()785 private void redisplayAfterFormulaChange() { 786 // TODO: Could do this more incrementally. 787 redisplayFormula(); 788 setState(CalculatorState.INPUT); 789 mResultText.clear(); 790 if (haveUnprocessed()) { 791 // Force reevaluation when text is deleted, even if expression is unchanged. 792 mEvaluator.touch(); 793 } else { 794 evaluateInstantIfNecessary(); 795 } 796 } 797 798 /** 799 * Show the toolbar. 800 * Automatically hide it again if it's not relevant to current formula. 801 */ showAndMaybeHideToolbar()802 private void showAndMaybeHideToolbar() { 803 final boolean shouldBeVisible = 804 mCurrentState == CalculatorState.INPUT && mEvaluator.hasTrigFuncs(); 805 mDisplayView.showToolbar(!shouldBeVisible); 806 } 807 808 /** 809 * Display or hide the toolbar depending on calculator state. 810 */ showOrHideToolbar()811 private void showOrHideToolbar() { 812 final boolean shouldBeVisible = 813 mCurrentState == CalculatorState.INPUT && mEvaluator.hasTrigFuncs(); 814 if (shouldBeVisible) { 815 mDisplayView.showToolbar(false); 816 } else { 817 mDisplayView.hideToolbar(); 818 } 819 } 820 onButtonClick(View view)821 public void onButtonClick(View view) { 822 // Any animation is ended before we get here. 823 mCurrentButton = view; 824 stopActionModeOrContextMenu(); 825 826 // See onKey above for the rationale behind some of the behavior below: 827 cancelUnrequested(); 828 829 final int id = view.getId(); 830 switch (id) { 831 case R.id.eq: 832 onEquals(); 833 break; 834 case R.id.del: 835 onDelete(); 836 break; 837 case R.id.clr: 838 onClear(); 839 return; // Toolbar visibility adjusted at end of animation. 840 case R.id.toggle_inv: 841 final boolean selected = !mInverseToggle.isSelected(); 842 mInverseToggle.setSelected(selected); 843 onInverseToggled(selected); 844 if (mCurrentState == CalculatorState.RESULT) { 845 mResultText.redisplay(); // In case we cancelled reevaluation. 846 } 847 break; 848 case R.id.toggle_mode: 849 cancelIfEvaluating(false); 850 final boolean mode = !mEvaluator.getDegreeMode(Evaluator.MAIN_INDEX); 851 if (mCurrentState == CalculatorState.RESULT 852 && mEvaluator.getExpr(Evaluator.MAIN_INDEX).hasTrigFuncs()) { 853 // Capture current result evaluated in old mode. 854 mEvaluator.collapse(mEvaluator.getMaxIndex()); 855 redisplayFormula(); 856 } 857 // In input mode, we reinterpret already entered trig functions. 858 mEvaluator.setDegreeMode(mode); 859 onModeChanged(mode); 860 // Show the toolbar to highlight the mode change. 861 showAndMaybeHideToolbar(); 862 setState(CalculatorState.INPUT); 863 mResultText.clear(); 864 if (!haveUnprocessed()) { 865 evaluateInstantIfNecessary(); 866 } 867 return; 868 default: 869 cancelIfEvaluating(false); 870 if (haveUnprocessed()) { 871 // For consistency, append as uninterpreted characters. 872 // This may actually be useful for a left parenthesis. 873 addChars(KeyMaps.toString(this, id), true); 874 } else { 875 addExplicitKeyToExpr(id); 876 redisplayAfterFormulaChange(); 877 } 878 break; 879 } 880 showOrHideToolbar(); 881 } 882 redisplayFormula()883 void redisplayFormula() { 884 SpannableStringBuilder formula 885 = mEvaluator.getExpr(Evaluator.MAIN_INDEX).toSpannableStringBuilder(this); 886 if (mUnprocessedChars != null) { 887 // Add and highlight characters we couldn't process. 888 formula.append(mUnprocessedChars, mUnprocessedColorSpan, 889 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 890 } 891 mFormulaText.changeTextTo(formula); 892 mFormulaText.setContentDescription(TextUtils.isEmpty(formula) 893 ? getString(R.string.desc_formula) : null); 894 } 895 896 @Override onLongClick(View view)897 public boolean onLongClick(View view) { 898 mCurrentButton = view; 899 900 if (view.getId() == R.id.del) { 901 onClear(); 902 return true; 903 } 904 return false; 905 } 906 907 // Initial evaluation completed successfully. Initiate display. onEvaluate(long index, int initDisplayPrec, int msd, int leastDigPos, String truncatedWholeNumber)908 public void onEvaluate(long index, int initDisplayPrec, int msd, int leastDigPos, 909 String truncatedWholeNumber) { 910 if (index != Evaluator.MAIN_INDEX) { 911 throw new AssertionError("Unexpected evaluation result index\n"); 912 } 913 914 // Invalidate any options that may depend on the current result. 915 invalidateOptionsMenu(); 916 917 mResultText.onEvaluate(index, initDisplayPrec, msd, leastDigPos, truncatedWholeNumber); 918 if (mCurrentState != CalculatorState.INPUT) { 919 // In EVALUATE, INIT, RESULT, or INIT_FOR_RESULT state. 920 onResult(mCurrentState == CalculatorState.EVALUATE /* animate */, 921 mCurrentState == CalculatorState.INIT_FOR_RESULT 922 || mCurrentState == CalculatorState.RESULT /* previously preserved */); 923 } 924 } 925 926 // Reset state to reflect evaluator cancellation. Invoked by evaluator. onCancelled(long index)927 public void onCancelled(long index) { 928 // Index is Evaluator.MAIN_INDEX. We should be in EVALUATE state. 929 setState(CalculatorState.INPUT); 930 mResultText.onCancelled(index); 931 } 932 933 // Reevaluation completed; ask result to redisplay current value. onReevaluate(long index)934 public void onReevaluate(long index) { 935 // Index is Evaluator.MAIN_INDEX. 936 mResultText.onReevaluate(index); 937 } 938 939 @Override onTextSizeChanged(final TextView textView, float oldSize)940 public void onTextSizeChanged(final TextView textView, float oldSize) { 941 if (mCurrentState != CalculatorState.INPUT) { 942 // Only animate text changes that occur from user input. 943 return; 944 } 945 946 // Calculate the values needed to perform the scale and translation animations, 947 // maintaining the same apparent baseline for the displayed text. 948 final float textScale = oldSize / textView.getTextSize(); 949 final float translationX = (1.0f - textScale) * 950 (textView.getWidth() / 2.0f - textView.getPaddingEnd()); 951 final float translationY = (1.0f - textScale) * 952 (textView.getHeight() / 2.0f - textView.getPaddingBottom()); 953 954 final AnimatorSet animatorSet = new AnimatorSet(); 955 animatorSet.playTogether( 956 ObjectAnimator.ofFloat(textView, View.SCALE_X, textScale, 1.0f), 957 ObjectAnimator.ofFloat(textView, View.SCALE_Y, textScale, 1.0f), 958 ObjectAnimator.ofFloat(textView, View.TRANSLATION_X, translationX, 0.0f), 959 ObjectAnimator.ofFloat(textView, View.TRANSLATION_Y, translationY, 0.0f)); 960 animatorSet.setDuration(getResources().getInteger(android.R.integer.config_mediumAnimTime)); 961 animatorSet.setInterpolator(new AccelerateDecelerateInterpolator()); 962 animatorSet.start(); 963 } 964 965 /** 966 * Cancel any in-progress explicitly requested evaluations. 967 * @param quiet suppress pop-up message. Explicit evaluation can change the expression 968 value, and certainly changes the display, so it seems reasonable to warn. 969 * @return true if there was such an evaluation 970 */ cancelIfEvaluating(boolean quiet)971 private boolean cancelIfEvaluating(boolean quiet) { 972 if (mCurrentState == CalculatorState.EVALUATE) { 973 mEvaluator.cancel(Evaluator.MAIN_INDEX, quiet); 974 return true; 975 } else { 976 return false; 977 } 978 } 979 980 cancelUnrequested()981 private void cancelUnrequested() { 982 if (mCurrentState == CalculatorState.INPUT) { 983 mEvaluator.cancel(Evaluator.MAIN_INDEX, true); 984 } 985 } 986 haveUnprocessed()987 private boolean haveUnprocessed() { 988 return mUnprocessedChars != null && !mUnprocessedChars.isEmpty(); 989 } 990 onEquals()991 private void onEquals() { 992 // Ignore if in non-INPUT state, or if there are no operators. 993 if (mCurrentState == CalculatorState.INPUT) { 994 if (haveUnprocessed()) { 995 setState(CalculatorState.EVALUATE); 996 onError(Evaluator.MAIN_INDEX, R.string.error_syntax); 997 } else if (mEvaluator.getExpr(Evaluator.MAIN_INDEX).hasInterestingOps()) { 998 setState(CalculatorState.EVALUATE); 999 mEvaluator.requireResult(Evaluator.MAIN_INDEX, this, mResultText); 1000 } 1001 } 1002 } 1003 onDelete()1004 private void onDelete() { 1005 // Delete works like backspace; remove the last character or operator from the expression. 1006 // Note that we handle keyboard delete exactly like the delete button. For 1007 // example the delete button can be used to delete a character from an incomplete 1008 // function name typed on a physical keyboard. 1009 // This should be impossible in RESULT state. 1010 // If there is an in-progress explicit evaluation, just cancel it and return. 1011 if (cancelIfEvaluating(false)) return; 1012 setState(CalculatorState.INPUT); 1013 if (haveUnprocessed()) { 1014 mUnprocessedChars = mUnprocessedChars.substring(0, mUnprocessedChars.length() - 1); 1015 } else { 1016 mEvaluator.delete(); 1017 } 1018 if (mEvaluator.getExpr(Evaluator.MAIN_INDEX).isEmpty() && !haveUnprocessed()) { 1019 // Resulting formula won't be announced, since it's empty. 1020 announceClearedForAccessibility(); 1021 } 1022 redisplayAfterFormulaChange(); 1023 } 1024 reveal(View sourceView, int colorRes, AnimatorListener listener)1025 private void reveal(View sourceView, int colorRes, AnimatorListener listener) { 1026 final ViewGroupOverlay groupOverlay = 1027 (ViewGroupOverlay) getWindow().getDecorView().getOverlay(); 1028 1029 final Rect displayRect = new Rect(); 1030 mDisplayView.getGlobalVisibleRect(displayRect); 1031 1032 // Make reveal cover the display and status bar. 1033 final View revealView = new View(this); 1034 revealView.setBottom(displayRect.bottom); 1035 revealView.setLeft(displayRect.left); 1036 revealView.setRight(displayRect.right); 1037 revealView.setBackgroundColor(ContextCompat.getColor(this, colorRes)); 1038 groupOverlay.add(revealView); 1039 1040 final int[] clearLocation = new int[2]; 1041 sourceView.getLocationInWindow(clearLocation); 1042 clearLocation[0] += sourceView.getWidth() / 2; 1043 clearLocation[1] += sourceView.getHeight() / 2; 1044 1045 final int revealCenterX = clearLocation[0] - revealView.getLeft(); 1046 final int revealCenterY = clearLocation[1] - revealView.getTop(); 1047 1048 final double x1_2 = Math.pow(revealView.getLeft() - revealCenterX, 2); 1049 final double x2_2 = Math.pow(revealView.getRight() - revealCenterX, 2); 1050 final double y_2 = Math.pow(revealView.getTop() - revealCenterY, 2); 1051 final float revealRadius = (float) Math.max(Math.sqrt(x1_2 + y_2), Math.sqrt(x2_2 + y_2)); 1052 1053 final Animator revealAnimator = 1054 ViewAnimationUtils.createCircularReveal(revealView, 1055 revealCenterX, revealCenterY, 0.0f, revealRadius); 1056 revealAnimator.setDuration( 1057 getResources().getInteger(android.R.integer.config_longAnimTime)); 1058 revealAnimator.addListener(listener); 1059 1060 final Animator alphaAnimator = ObjectAnimator.ofFloat(revealView, View.ALPHA, 0.0f); 1061 alphaAnimator.setDuration( 1062 getResources().getInteger(android.R.integer.config_mediumAnimTime)); 1063 1064 final AnimatorSet animatorSet = new AnimatorSet(); 1065 animatorSet.play(revealAnimator).before(alphaAnimator); 1066 animatorSet.setInterpolator(new AccelerateDecelerateInterpolator()); 1067 animatorSet.addListener(new AnimatorListenerAdapter() { 1068 @Override 1069 public void onAnimationEnd(Animator animator) { 1070 groupOverlay.remove(revealView); 1071 mCurrentAnimator = null; 1072 } 1073 }); 1074 1075 mCurrentAnimator = animatorSet; 1076 animatorSet.start(); 1077 } 1078 announceClearedForAccessibility()1079 private void announceClearedForAccessibility() { 1080 mResultText.announceForAccessibility(getResources().getString(R.string.cleared)); 1081 } 1082 onClearAnimationEnd()1083 public void onClearAnimationEnd() { 1084 mUnprocessedChars = null; 1085 mResultText.clear(); 1086 mEvaluator.clearMain(); 1087 setState(CalculatorState.INPUT); 1088 redisplayFormula(); 1089 } 1090 onClear()1091 private void onClear() { 1092 if (mEvaluator.getExpr(Evaluator.MAIN_INDEX).isEmpty() && !haveUnprocessed()) { 1093 return; 1094 } 1095 cancelIfEvaluating(true); 1096 announceClearedForAccessibility(); 1097 reveal(mCurrentButton, R.color.calculator_primary_color, new AnimatorListenerAdapter() { 1098 @Override 1099 public void onAnimationEnd(Animator animation) { 1100 onClearAnimationEnd(); 1101 showOrHideToolbar(); 1102 } 1103 }); 1104 } 1105 1106 // Evaluation encountered en error. Display the error. 1107 @Override onError(final long index, final int errorResourceId)1108 public void onError(final long index, final int errorResourceId) { 1109 if (index != Evaluator.MAIN_INDEX) { 1110 throw new AssertionError("Unexpected error source"); 1111 } 1112 if (mCurrentState == CalculatorState.EVALUATE) { 1113 setState(CalculatorState.ANIMATE); 1114 mResultText.announceForAccessibility(getResources().getString(errorResourceId)); 1115 reveal(mCurrentButton, R.color.calculator_error_color, 1116 new AnimatorListenerAdapter() { 1117 @Override 1118 public void onAnimationEnd(Animator animation) { 1119 setState(CalculatorState.ERROR); 1120 mResultText.onError(index, errorResourceId); 1121 } 1122 }); 1123 } else if (mCurrentState == CalculatorState.INIT 1124 || mCurrentState == CalculatorState.INIT_FOR_RESULT /* very unlikely */) { 1125 setState(CalculatorState.ERROR); 1126 mResultText.onError(index, errorResourceId); 1127 } else { 1128 mResultText.clear(); 1129 } 1130 } 1131 1132 // Animate movement of result into the top formula slot. 1133 // Result window now remains translated in the top slot while the result is displayed. 1134 // (We convert it back to formula use only when the user provides new input.) 1135 // Historical note: In the Lollipop version, this invisibly and instantaneously moved 1136 // formula and result displays back at the end of the animation. We no longer do that, 1137 // so that we can continue to properly support scrolling of the result. 1138 // We assume the result already contains the text to be expanded. onResult(boolean animate, boolean resultWasPreserved)1139 private void onResult(boolean animate, boolean resultWasPreserved) { 1140 // Calculate the textSize that would be used to display the result in the formula. 1141 // For scrollable results just use the minimum textSize to maximize the number of digits 1142 // that are visible on screen. 1143 float textSize = mFormulaText.getMinimumTextSize(); 1144 if (!mResultText.isScrollable()) { 1145 textSize = mFormulaText.getVariableTextSize(mResultText.getText().toString()); 1146 } 1147 1148 // Scale the result to match the calculated textSize, minimizing the jump-cut transition 1149 // when a result is reused in a subsequent expression. 1150 final float resultScale = textSize / mResultText.getTextSize(); 1151 1152 // Set the result's pivot to match its gravity. 1153 mResultText.setPivotX(mResultText.getWidth() - mResultText.getPaddingRight()); 1154 mResultText.setPivotY(mResultText.getHeight() - mResultText.getPaddingBottom()); 1155 1156 // Calculate the necessary translations so the result takes the place of the formula and 1157 // the formula moves off the top of the screen. 1158 final float resultTranslationY = (mFormulaContainer.getBottom() - mResultText.getBottom()) 1159 - (mFormulaText.getPaddingBottom() - mResultText.getPaddingBottom()); 1160 float formulaTranslationY = -mFormulaContainer.getBottom(); 1161 if (mIsOneLine) { 1162 // Position the result text. 1163 mResultText.setY(mResultText.getBottom()); 1164 formulaTranslationY = -(findViewById(R.id.toolbar).getBottom() 1165 + mFormulaContainer.getBottom()); 1166 } 1167 1168 // Change the result's textColor to match the formula. 1169 final int formulaTextColor = mFormulaText.getCurrentTextColor(); 1170 1171 if (resultWasPreserved) { 1172 // Result was previously addded to history. 1173 mEvaluator.represerve(); 1174 } else { 1175 // Add current result to history. 1176 mEvaluator.preserve(Evaluator.MAIN_INDEX, true); 1177 } 1178 1179 if (animate) { 1180 mResultText.announceForAccessibility(getResources().getString(R.string.desc_eq)); 1181 mResultText.announceForAccessibility(mResultText.getText()); 1182 setState(CalculatorState.ANIMATE); 1183 final AnimatorSet animatorSet = new AnimatorSet(); 1184 animatorSet.playTogether( 1185 ObjectAnimator.ofPropertyValuesHolder(mResultText, 1186 PropertyValuesHolder.ofFloat(View.SCALE_X, resultScale), 1187 PropertyValuesHolder.ofFloat(View.SCALE_Y, resultScale), 1188 PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, resultTranslationY)), 1189 ObjectAnimator.ofArgb(mResultText, TEXT_COLOR, formulaTextColor), 1190 ObjectAnimator.ofFloat(mFormulaContainer, View.TRANSLATION_Y, 1191 formulaTranslationY)); 1192 animatorSet.setDuration(getResources().getInteger( 1193 android.R.integer.config_longAnimTime)); 1194 animatorSet.addListener(new AnimatorListenerAdapter() { 1195 @Override 1196 public void onAnimationEnd(Animator animation) { 1197 setState(CalculatorState.RESULT); 1198 mCurrentAnimator = null; 1199 } 1200 }); 1201 1202 mCurrentAnimator = animatorSet; 1203 animatorSet.start(); 1204 } else /* No animation desired; get there fast when restarting */ { 1205 mResultText.setScaleX(resultScale); 1206 mResultText.setScaleY(resultScale); 1207 mResultText.setTranslationY(resultTranslationY); 1208 mResultText.setTextColor(formulaTextColor); 1209 mFormulaContainer.setTranslationY(formulaTranslationY); 1210 setState(CalculatorState.RESULT); 1211 } 1212 } 1213 1214 // Restore positions of the formula and result displays back to their original, 1215 // pre-animation state. restoreDisplayPositions()1216 private void restoreDisplayPositions() { 1217 // Clear result. 1218 mResultText.setText(""); 1219 // Reset all of the values modified during the animation. 1220 mResultText.setScaleX(1.0f); 1221 mResultText.setScaleY(1.0f); 1222 mResultText.setTranslationX(0.0f); 1223 mResultText.setTranslationY(0.0f); 1224 mFormulaContainer.setTranslationY(0.0f); 1225 1226 mFormulaText.requestFocus(); 1227 } 1228 1229 @Override onClick(AlertDialogFragment fragment, int which)1230 public void onClick(AlertDialogFragment fragment, int which) { 1231 if (which == DialogInterface.BUTTON_POSITIVE) { 1232 if (HistoryFragment.CLEAR_DIALOG_TAG.equals(fragment.getTag())) { 1233 // TODO: Try to preserve the current, saved, and memory expressions. How should we 1234 // handle expressions to which they refer? 1235 mEvaluator.clearEverything(); 1236 // TODO: It's not clear what we should really do here. This is an initial hack. 1237 // May want to make onClearAnimationEnd() private if/when we fix this. 1238 onClearAnimationEnd(); 1239 mEvaluatorCallback.onMemoryStateChanged(); 1240 onBackPressed(); 1241 } else if (Evaluator.TIMEOUT_DIALOG_TAG.equals(fragment.getTag())) { 1242 // Timeout extension request. 1243 mEvaluator.setLongTimeout(); 1244 } else { 1245 Log.e(TAG, "Unknown AlertDialogFragment click:" + fragment.getTag()); 1246 } 1247 } 1248 } 1249 1250 @Override onCreateOptionsMenu(Menu menu)1251 public boolean onCreateOptionsMenu(Menu menu) { 1252 super.onCreateOptionsMenu(menu); 1253 1254 getMenuInflater().inflate(R.menu.activity_calculator, menu); 1255 return true; 1256 } 1257 1258 @Override onPrepareOptionsMenu(Menu menu)1259 public boolean onPrepareOptionsMenu(Menu menu) { 1260 super.onPrepareOptionsMenu(menu); 1261 1262 // Show the leading option when displaying a result. 1263 menu.findItem(R.id.menu_leading).setVisible(mCurrentState == CalculatorState.RESULT); 1264 1265 // Show the fraction option when displaying a rational result. 1266 boolean visible = mCurrentState == CalculatorState.RESULT; 1267 final UnifiedReal mainResult = mEvaluator.getResult(Evaluator.MAIN_INDEX); 1268 // mainResult should never be null, but it happens. Check as a workaround to protect 1269 // against crashes until we find the root cause (b/34763650). 1270 visible &= mainResult != null && mainResult.exactlyDisplayable(); 1271 menu.findItem(R.id.menu_fraction).setVisible(visible); 1272 1273 return true; 1274 } 1275 1276 @Override onOptionsItemSelected(MenuItem item)1277 public boolean onOptionsItemSelected(MenuItem item) { 1278 switch (item.getItemId()) { 1279 case R.id.menu_history: 1280 showHistoryFragment(); 1281 return true; 1282 case R.id.menu_leading: 1283 displayFull(); 1284 return true; 1285 case R.id.menu_fraction: 1286 displayFraction(); 1287 return true; 1288 case R.id.menu_licenses: 1289 startActivity(new Intent(this, Licenses.class)); 1290 return true; 1291 default: 1292 return super.onOptionsItemSelected(item); 1293 } 1294 } 1295 1296 /* Begin override CloseCallback method. */ 1297 1298 @Override onClose()1299 public void onClose() { 1300 removeHistoryFragment(); 1301 } 1302 1303 /* End override CloseCallback method. */ 1304 1305 /* Begin override DragCallback methods */ 1306 onStartDraggingOpen()1307 public void onStartDraggingOpen() { 1308 mDisplayView.hideToolbar(); 1309 showHistoryFragment(); 1310 } 1311 1312 @Override onInstanceStateRestored(boolean isOpen)1313 public void onInstanceStateRestored(boolean isOpen) { 1314 } 1315 1316 @Override whileDragging(float yFraction)1317 public void whileDragging(float yFraction) { 1318 } 1319 1320 @Override shouldCaptureView(View view, int x, int y)1321 public boolean shouldCaptureView(View view, int x, int y) { 1322 return view.getId() == R.id.history_frame 1323 && (mDragLayout.isMoving() || mDragLayout.isViewUnder(view, x, y)); 1324 } 1325 1326 @Override getDisplayHeight()1327 public int getDisplayHeight() { 1328 return mDisplayView.getMeasuredHeight(); 1329 } 1330 1331 /* End override DragCallback methods */ 1332 1333 /** 1334 * Change evaluation state to one that's friendly to the history fragment. 1335 * Return false if that was not easily possible. 1336 */ prepareForHistory()1337 private boolean prepareForHistory() { 1338 if (mCurrentState == CalculatorState.ANIMATE) { 1339 // End the current animation and signal that preparation has failed. 1340 // onUserInteraction is unreliable and onAnimationEnd() is asynchronous, so we 1341 // aren't guaranteed to be out of the ANIMATE state by the time prepareForHistory is 1342 // called. 1343 if (mCurrentAnimator != null) { 1344 mCurrentAnimator.end(); 1345 } 1346 return false; 1347 } else if (mCurrentState == CalculatorState.EVALUATE) { 1348 // Cancel current evaluation 1349 cancelIfEvaluating(true /* quiet */ ); 1350 setState(CalculatorState.INPUT); 1351 return true; 1352 } else if (mCurrentState == CalculatorState.INIT) { 1353 // Easiest to just refuse. Otherwise we can see a state change 1354 // while in history mode, which causes all sorts of problems. 1355 // TODO: Consider other alternatives. If we're just doing the decimal conversion 1356 // at the end of an evaluation, we could treat this as RESULT state. 1357 return false; 1358 } 1359 // We should be in INPUT, INIT_FOR_RESULT, RESULT, or ERROR state. 1360 return true; 1361 } 1362 getHistoryFragment()1363 private HistoryFragment getHistoryFragment() { 1364 final FragmentManager manager = getFragmentManager(); 1365 if (manager == null || manager.isDestroyed()) { 1366 return null; 1367 } 1368 final Fragment fragment = manager.findFragmentByTag(HistoryFragment.TAG); 1369 return fragment == null || fragment.isRemoving() ? null : (HistoryFragment) fragment; 1370 } 1371 showHistoryFragment()1372 private void showHistoryFragment() { 1373 if (getHistoryFragment() != null) { 1374 // If the fragment already exists, do nothing. 1375 return; 1376 } 1377 1378 final FragmentManager manager = getFragmentManager(); 1379 if (manager == null || manager.isDestroyed() || !prepareForHistory()) { 1380 // If the history fragment can not be shown, close the draglayout. 1381 mDragLayout.setClosed(); 1382 return; 1383 } 1384 1385 stopActionModeOrContextMenu(); 1386 manager.beginTransaction() 1387 .replace(R.id.history_frame, new HistoryFragment(), HistoryFragment.TAG) 1388 .setTransition(FragmentTransaction.TRANSIT_NONE) 1389 .addToBackStack(HistoryFragment.TAG) 1390 .commit(); 1391 1392 // When HistoryFragment is visible, hide all descendants of the main Calculator view. 1393 mMainCalculator.setImportantForAccessibility( 1394 View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); 1395 // TODO: pass current scroll position of result 1396 } 1397 displayMessage(String title, String message)1398 private void displayMessage(String title, String message) { 1399 AlertDialogFragment.showMessageDialog(this, title, message, null, null /* tag */); 1400 } 1401 displayFraction()1402 private void displayFraction() { 1403 UnifiedReal result = mEvaluator.getResult(Evaluator.MAIN_INDEX); 1404 displayMessage(getString(R.string.menu_fraction), 1405 KeyMaps.translateResult(result.toNiceString())); 1406 } 1407 1408 // Display full result to currently evaluated precision displayFull()1409 private void displayFull() { 1410 Resources res = getResources(); 1411 String msg = mResultText.getFullText(true /* withSeparators */) + " "; 1412 if (mResultText.fullTextIsExact()) { 1413 msg += res.getString(R.string.exact); 1414 } else { 1415 msg += res.getString(R.string.approximate); 1416 } 1417 displayMessage(getString(R.string.menu_leading), msg); 1418 } 1419 1420 /** 1421 * Add input characters to the end of the expression. 1422 * Map them to the appropriate button pushes when possible. Leftover characters 1423 * are added to mUnprocessedChars, which is presumed to immediately precede the newly 1424 * added characters. 1425 * @param moreChars characters to be added 1426 * @param explicit these characters were explicitly typed by the user, not pasted 1427 */ addChars(String moreChars, boolean explicit)1428 private void addChars(String moreChars, boolean explicit) { 1429 if (mUnprocessedChars != null) { 1430 moreChars = mUnprocessedChars + moreChars; 1431 } 1432 int current = 0; 1433 int len = moreChars.length(); 1434 boolean lastWasDigit = false; 1435 if (mCurrentState == CalculatorState.RESULT && len != 0) { 1436 // Clear display immediately for incomplete function name. 1437 switchToInput(KeyMaps.keyForChar(moreChars.charAt(current))); 1438 } 1439 char groupingSeparator = KeyMaps.translateResult(",").charAt(0); 1440 while (current < len) { 1441 char c = moreChars.charAt(current); 1442 if (Character.isSpaceChar(c) || c == groupingSeparator) { 1443 ++current; 1444 continue; 1445 } 1446 int k = KeyMaps.keyForChar(c); 1447 if (!explicit) { 1448 int expEnd; 1449 if (lastWasDigit && current != 1450 (expEnd = Evaluator.exponentEnd(moreChars, current))) { 1451 // Process scientific notation with 'E' when pasting, in spite of ambiguity 1452 // with base of natural log. 1453 // Otherwise the 10^x key is the user's friend. 1454 mEvaluator.addExponent(moreChars, current, expEnd); 1455 current = expEnd; 1456 lastWasDigit = false; 1457 continue; 1458 } else { 1459 boolean isDigit = KeyMaps.digVal(k) != KeyMaps.NOT_DIGIT; 1460 if (current == 0 && (isDigit || k == R.id.dec_point) 1461 && mEvaluator.getExpr(Evaluator.MAIN_INDEX).hasTrailingConstant()) { 1462 // Refuse to concatenate pasted content to trailing constant. 1463 // This makes pasting of calculator results more consistent, whether or 1464 // not the old calculator instance is still around. 1465 addKeyToExpr(R.id.op_mul); 1466 } 1467 lastWasDigit = (isDigit || lastWasDigit && k == R.id.dec_point); 1468 } 1469 } 1470 if (k != View.NO_ID) { 1471 mCurrentButton = findViewById(k); 1472 if (explicit) { 1473 addExplicitKeyToExpr(k); 1474 } else { 1475 addKeyToExpr(k); 1476 } 1477 if (Character.isSurrogate(c)) { 1478 current += 2; 1479 } else { 1480 ++current; 1481 } 1482 continue; 1483 } 1484 int f = KeyMaps.funForString(moreChars, current); 1485 if (f != View.NO_ID) { 1486 mCurrentButton = findViewById(f); 1487 if (explicit) { 1488 addExplicitKeyToExpr(f); 1489 } else { 1490 addKeyToExpr(f); 1491 } 1492 if (f == R.id.op_sqrt) { 1493 // Square root entered as function; don't lose the parenthesis. 1494 addKeyToExpr(R.id.lparen); 1495 } 1496 current = moreChars.indexOf('(', current) + 1; 1497 continue; 1498 } 1499 // There are characters left, but we can't convert them to button presses. 1500 mUnprocessedChars = moreChars.substring(current); 1501 redisplayAfterFormulaChange(); 1502 showOrHideToolbar(); 1503 return; 1504 } 1505 mUnprocessedChars = null; 1506 redisplayAfterFormulaChange(); 1507 showOrHideToolbar(); 1508 } 1509 clearIfNotInputState()1510 private void clearIfNotInputState() { 1511 if (mCurrentState == CalculatorState.ERROR 1512 || mCurrentState == CalculatorState.RESULT) { 1513 setState(CalculatorState.INPUT); 1514 mEvaluator.clearMain(); 1515 } 1516 } 1517 1518 /** 1519 * Since we only support LTR format, using the RTL comma does not make sense. 1520 */ getDecimalSeparator()1521 private String getDecimalSeparator() { 1522 final char defaultSeparator = DecimalFormatSymbols.getInstance().getDecimalSeparator(); 1523 final char rtlComma = '\u066b'; 1524 return defaultSeparator == rtlComma ? "," : String.valueOf(defaultSeparator); 1525 } 1526 1527 /** 1528 * Clean up animation for context menu. 1529 */ 1530 @Override onContextMenuClosed(Menu menu)1531 public void onContextMenuClosed(Menu menu) { 1532 stopActionModeOrContextMenu(); 1533 } 1534 1535 public interface OnDisplayMemoryOperationsListener { shouldDisplayMemory()1536 boolean shouldDisplayMemory(); 1537 } 1538 } 1539