1 /* 2 * Copyright (C) 2015 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.Activity; 35 import android.app.AlertDialog; 36 import android.content.ClipData; 37 import android.content.DialogInterface; 38 import android.content.Intent; 39 import android.content.res.Resources; 40 import android.graphics.Color; 41 import android.graphics.Rect; 42 import android.net.Uri; 43 import android.os.Bundle; 44 import android.support.annotation.NonNull; 45 import android.support.v4.view.ViewPager; 46 import android.text.SpannableString; 47 import android.text.SpannableStringBuilder; 48 import android.text.Spanned; 49 import android.text.style.ForegroundColorSpan; 50 import android.text.TextUtils; 51 import android.util.Property; 52 import android.view.KeyCharacterMap; 53 import android.view.KeyEvent; 54 import android.view.Menu; 55 import android.view.MenuItem; 56 import android.view.View; 57 import android.view.View.OnKeyListener; 58 import android.view.View.OnLongClickListener; 59 import android.view.ViewAnimationUtils; 60 import android.view.ViewGroupOverlay; 61 import android.view.animation.AccelerateDecelerateInterpolator; 62 import android.widget.TextView; 63 import android.widget.Toolbar; 64 65 import com.android.calculator2.CalculatorText.OnTextSizeChangeListener; 66 67 import java.io.ByteArrayInputStream; 68 import java.io.ByteArrayOutputStream; 69 import java.io.IOException; 70 import java.io.ObjectInput; 71 import java.io.ObjectInputStream; 72 import java.io.ObjectOutput; 73 import java.io.ObjectOutputStream; 74 75 public class Calculator extends Activity 76 implements OnTextSizeChangeListener, OnLongClickListener, CalculatorText.OnPasteListener, 77 AlertDialogFragment.OnClickListener { 78 79 /** 80 * Constant for an invalid resource id. 81 */ 82 public static final int INVALID_RES_ID = -1; 83 84 private enum CalculatorState { 85 INPUT, // Result and formula both visible, no evaluation requested, 86 // Though result may be visible on bottom line. 87 EVALUATE, // Both visible, evaluation requested, evaluation/animation incomplete. 88 // Not used for instant result evaluation. 89 INIT, // Very temporary state used as alternative to EVALUATE 90 // during reinitialization. Do not animate on completion. 91 ANIMATE, // Result computed, animation to enlarge result window in progress. 92 RESULT, // Result displayed, formula invisible. 93 // If we are in RESULT state, the formula was evaluated without 94 // error to initial precision. 95 ERROR // Error displayed: Formula visible, result shows error message. 96 // Display similar to INPUT state. 97 } 98 // Normal transition sequence is 99 // INPUT -> EVALUATE -> ANIMATE -> RESULT (or ERROR) -> INPUT 100 // A RESULT -> ERROR transition is possible in rare corner cases, in which 101 // a higher precision evaluation exposes an error. This is possible, since we 102 // initially evaluate assuming we were given a well-defined problem. If we 103 // were actually asked to compute sqrt(<extremely tiny negative number>) we produce 0 104 // unless we are asked for enough precision that we can distinguish the argument from zero. 105 // TODO: Consider further heuristics to reduce the chance of observing this? 106 // It already seems to be observable only in contrived cases. 107 // ANIMATE, ERROR, and RESULT are translated to an INIT state if the application 108 // is restarted in that state. This leads us to recompute and redisplay the result 109 // ASAP. 110 // TODO: Possibly save a bit more information, e.g. its initial display string 111 // or most significant digit position, to speed up restart. 112 113 private final Property<TextView, Integer> TEXT_COLOR = 114 new Property<TextView, Integer>(Integer.class, "textColor") { 115 @Override 116 public Integer get(TextView textView) { 117 return textView.getCurrentTextColor(); 118 } 119 120 @Override 121 public void set(TextView textView, Integer textColor) { 122 textView.setTextColor(textColor); 123 } 124 }; 125 126 // We currently assume that the formula does not change out from under us in 127 // any way. We explicitly handle all input to the formula here. 128 private final OnKeyListener mFormulaOnKeyListener = new OnKeyListener() { 129 @Override 130 public boolean onKey(View view, int keyCode, KeyEvent keyEvent) { 131 stopActionMode(); 132 // Never consume DPAD key events. 133 switch (keyCode) { 134 case KeyEvent.KEYCODE_DPAD_UP: 135 case KeyEvent.KEYCODE_DPAD_DOWN: 136 case KeyEvent.KEYCODE_DPAD_LEFT: 137 case KeyEvent.KEYCODE_DPAD_RIGHT: 138 return false; 139 } 140 // Always cancel unrequested in-progress evaluation, so that we don't have 141 // to worry about subsequent asynchronous completion. 142 // Requested in-progress evaluations are handled below. 143 if (mCurrentState != CalculatorState.EVALUATE) { 144 mEvaluator.cancelAll(true); 145 } 146 // In other cases we go ahead and process the input normally after cancelling: 147 if (keyEvent.getAction() != KeyEvent.ACTION_UP) { 148 return true; 149 } 150 switch (keyCode) { 151 case KeyEvent.KEYCODE_NUMPAD_ENTER: 152 case KeyEvent.KEYCODE_ENTER: 153 case KeyEvent.KEYCODE_DPAD_CENTER: 154 mCurrentButton = mEqualButton; 155 onEquals(); 156 return true; 157 case KeyEvent.KEYCODE_DEL: 158 mCurrentButton = mDeleteButton; 159 onDelete(); 160 return true; 161 default: 162 cancelIfEvaluating(false); 163 final int raw = keyEvent.getKeyCharacterMap() 164 .get(keyCode, keyEvent.getMetaState()); 165 if ((raw & KeyCharacterMap.COMBINING_ACCENT) != 0) { 166 return true; // discard 167 } 168 // Try to discard non-printing characters and the like. 169 // The user will have to explicitly delete other junk that gets past us. 170 if (Character.isIdentifierIgnorable(raw) 171 || Character.isWhitespace(raw)) { 172 return true; 173 } 174 char c = (char) raw; 175 if (c == '=') { 176 mCurrentButton = mEqualButton; 177 onEquals(); 178 } else { 179 addChars(String.valueOf(c), true); 180 redisplayAfterFormulaChange(); 181 } 182 } 183 return false; 184 } 185 }; 186 187 private static final String NAME = Calculator.class.getName(); 188 private static final String KEY_DISPLAY_STATE = NAME + "_display_state"; 189 private static final String KEY_UNPROCESSED_CHARS = NAME + "_unprocessed_chars"; 190 private static final String KEY_EVAL_STATE = NAME + "_eval_state"; 191 // Associated value is a byte array holding both mCalculatorState 192 // and the (much more complex) evaluator state. 193 194 private CalculatorState mCurrentState; 195 private Evaluator mEvaluator; 196 197 private View mDisplayView; 198 private TextView mModeView; 199 private CalculatorText mFormulaText; 200 private CalculatorResult mResultText; 201 202 private ViewPager mPadViewPager; 203 private View mDeleteButton; 204 private View mClearButton; 205 private View mEqualButton; 206 207 private TextView mInverseToggle; 208 private TextView mModeToggle; 209 210 private View[] mInvertibleButtons; 211 private View[] mInverseButtons; 212 213 private View mCurrentButton; 214 private Animator mCurrentAnimator; 215 216 // Characters that were recently entered at the end of the display that have not yet 217 // been added to the underlying expression. 218 private String mUnprocessedChars = null; 219 220 // Color to highlight unprocessed characters from physical keyboard. 221 // TODO: should probably match this to the error color? 222 private ForegroundColorSpan mUnprocessedColorSpan = new ForegroundColorSpan(Color.RED); 223 224 @Override onCreate(Bundle savedInstanceState)225 protected void onCreate(Bundle savedInstanceState) { 226 super.onCreate(savedInstanceState); 227 setContentView(R.layout.activity_calculator); 228 setActionBar((Toolbar) findViewById(R.id.toolbar)); 229 230 // Hide all default options in the ActionBar. 231 getActionBar().setDisplayOptions(0); 232 233 mDisplayView = findViewById(R.id.display); 234 mModeView = (TextView) findViewById(R.id.mode); 235 mFormulaText = (CalculatorText) findViewById(R.id.formula); 236 mResultText = (CalculatorResult) findViewById(R.id.result); 237 238 mPadViewPager = (ViewPager) findViewById(R.id.pad_pager); 239 mDeleteButton = findViewById(R.id.del); 240 mClearButton = findViewById(R.id.clr); 241 mEqualButton = findViewById(R.id.pad_numeric).findViewById(R.id.eq); 242 if (mEqualButton == null || mEqualButton.getVisibility() != View.VISIBLE) { 243 mEqualButton = findViewById(R.id.pad_operator).findViewById(R.id.eq); 244 } 245 246 mInverseToggle = (TextView) findViewById(R.id.toggle_inv); 247 mModeToggle = (TextView) findViewById(R.id.toggle_mode); 248 249 mInvertibleButtons = new View[] { 250 findViewById(R.id.fun_sin), 251 findViewById(R.id.fun_cos), 252 findViewById(R.id.fun_tan), 253 findViewById(R.id.fun_ln), 254 findViewById(R.id.fun_log), 255 findViewById(R.id.op_sqrt) 256 }; 257 mInverseButtons = new View[] { 258 findViewById(R.id.fun_arcsin), 259 findViewById(R.id.fun_arccos), 260 findViewById(R.id.fun_arctan), 261 findViewById(R.id.fun_exp), 262 findViewById(R.id.fun_10pow), 263 findViewById(R.id.op_sqr) 264 }; 265 266 mEvaluator = new Evaluator(this, mResultText); 267 mResultText.setEvaluator(mEvaluator); 268 KeyMaps.setActivity(this); 269 270 if (savedInstanceState != null) { 271 setState(CalculatorState.values()[ 272 savedInstanceState.getInt(KEY_DISPLAY_STATE, 273 CalculatorState.INPUT.ordinal())]); 274 CharSequence unprocessed = savedInstanceState.getCharSequence(KEY_UNPROCESSED_CHARS); 275 if (unprocessed != null) { 276 mUnprocessedChars = unprocessed.toString(); 277 } 278 byte[] state = savedInstanceState.getByteArray(KEY_EVAL_STATE); 279 if (state != null) { 280 try (ObjectInput in = new ObjectInputStream(new ByteArrayInputStream(state))) { 281 mEvaluator.restoreInstanceState(in); 282 } catch (Throwable ignored) { 283 // When in doubt, revert to clean state 284 mCurrentState = CalculatorState.INPUT; 285 mEvaluator.clear(); 286 } 287 } 288 } else { 289 mCurrentState = CalculatorState.INPUT; 290 mEvaluator.clear(); 291 } 292 293 mFormulaText.setOnKeyListener(mFormulaOnKeyListener); 294 mFormulaText.setOnTextSizeChangeListener(this); 295 mFormulaText.setOnPasteListener(this); 296 mDeleteButton.setOnLongClickListener(this); 297 298 onInverseToggled(mInverseToggle.isSelected()); 299 onModeChanged(mEvaluator.getDegreeMode()); 300 301 if (mCurrentState != CalculatorState.INPUT) { 302 // Just reevaluate. 303 redisplayFormula(); 304 setState(CalculatorState.INIT); 305 mEvaluator.requireResult(); 306 } else { 307 redisplayAfterFormulaChange(); 308 } 309 // TODO: We're currently not saving and restoring scroll position. 310 // We probably should. Details may require care to deal with: 311 // - new display size 312 // - slow recomputation if we've scrolled far. 313 } 314 315 @Override onSaveInstanceState(@onNull Bundle outState)316 protected void onSaveInstanceState(@NonNull Bundle outState) { 317 mEvaluator.cancelAll(true); 318 // If there's an animation in progress, cancel it first to ensure our state is up-to-date. 319 if (mCurrentAnimator != null) { 320 mCurrentAnimator.cancel(); 321 } 322 323 super.onSaveInstanceState(outState); 324 outState.putInt(KEY_DISPLAY_STATE, mCurrentState.ordinal()); 325 outState.putCharSequence(KEY_UNPROCESSED_CHARS, mUnprocessedChars); 326 ByteArrayOutputStream byteArrayStream = new ByteArrayOutputStream(); 327 try (ObjectOutput out = new ObjectOutputStream(byteArrayStream)) { 328 mEvaluator.saveInstanceState(out); 329 } catch (IOException e) { 330 // Impossible; No IO involved. 331 throw new AssertionError("Impossible IO exception", e); 332 } 333 outState.putByteArray(KEY_EVAL_STATE, byteArrayStream.toByteArray()); 334 } 335 336 // Set the state, updating delete label and display colors. 337 // This restores display positions on moving to INPUT. 338 // But movement/animation for moving to RESULT has already been done. setState(CalculatorState state)339 private void setState(CalculatorState state) { 340 if (mCurrentState != state) { 341 if (state == CalculatorState.INPUT) { 342 restoreDisplayPositions(); 343 } 344 mCurrentState = state; 345 346 if (mCurrentState == CalculatorState.RESULT) { 347 // No longer do this for ERROR; allow mistakes to be corrected. 348 mDeleteButton.setVisibility(View.GONE); 349 mClearButton.setVisibility(View.VISIBLE); 350 } else { 351 mDeleteButton.setVisibility(View.VISIBLE); 352 mClearButton.setVisibility(View.GONE); 353 } 354 355 if (mCurrentState == CalculatorState.ERROR) { 356 final int errorColor = getColor(R.color.calculator_error_color); 357 mFormulaText.setTextColor(errorColor); 358 mResultText.setTextColor(errorColor); 359 getWindow().setStatusBarColor(errorColor); 360 } else if (mCurrentState != CalculatorState.RESULT) { 361 mFormulaText.setTextColor(getColor(R.color.display_formula_text_color)); 362 mResultText.setTextColor(getColor(R.color.display_result_text_color)); 363 getWindow().setStatusBarColor(getColor(R.color.calculator_accent_color)); 364 } 365 366 invalidateOptionsMenu(); 367 } 368 } 369 370 // Stop any active ActionMode. Return true if there was one. stopActionMode()371 private boolean stopActionMode() { 372 if (mResultText.stopActionMode()) { 373 return true; 374 } 375 if (mFormulaText.stopActionMode()) { 376 return true; 377 } 378 return false; 379 } 380 381 @Override onBackPressed()382 public void onBackPressed() { 383 if (!stopActionMode()) { 384 if (mPadViewPager != null && mPadViewPager.getCurrentItem() != 0) { 385 // Select the previous pad. 386 mPadViewPager.setCurrentItem(mPadViewPager.getCurrentItem() - 1); 387 } else { 388 // If the user is currently looking at the first pad (or the pad is not paged), 389 // allow the system to handle the Back button. 390 super.onBackPressed(); 391 } 392 } 393 } 394 395 @Override onUserInteraction()396 public void onUserInteraction() { 397 super.onUserInteraction(); 398 399 // If there's an animation in progress, end it immediately, so the user interaction can 400 // be handled. 401 if (mCurrentAnimator != null) { 402 mCurrentAnimator.end(); 403 } 404 } 405 406 /** 407 * Invoked whenever the inverse button is toggled to update the UI. 408 * 409 * @param showInverse {@code true} if inverse functions should be shown 410 */ onInverseToggled(boolean showInverse)411 private void onInverseToggled(boolean showInverse) { 412 if (showInverse) { 413 mInverseToggle.setContentDescription(getString(R.string.desc_inv_on)); 414 for (View invertibleButton : mInvertibleButtons) { 415 invertibleButton.setVisibility(View.GONE); 416 } 417 for (View inverseButton : mInverseButtons) { 418 inverseButton.setVisibility(View.VISIBLE); 419 } 420 } else { 421 mInverseToggle.setContentDescription(getString(R.string.desc_inv_off)); 422 for (View invertibleButton : mInvertibleButtons) { 423 invertibleButton.setVisibility(View.VISIBLE); 424 } 425 for (View inverseButton : mInverseButtons) { 426 inverseButton.setVisibility(View.GONE); 427 } 428 } 429 } 430 431 /** 432 * Invoked whenever the deg/rad mode may have changed to update the UI. 433 * 434 * @param degreeMode {@code true} if in degree mode 435 */ onModeChanged(boolean degreeMode)436 private void onModeChanged(boolean degreeMode) { 437 if (degreeMode) { 438 mModeView.setText(R.string.mode_deg); 439 mModeView.setContentDescription(getString(R.string.desc_mode_deg)); 440 441 mModeToggle.setText(R.string.mode_rad); 442 mModeToggle.setContentDescription(getString(R.string.desc_switch_rad)); 443 } else { 444 mModeView.setText(R.string.mode_rad); 445 mModeView.setContentDescription(getString(R.string.desc_mode_rad)); 446 447 mModeToggle.setText(R.string.mode_deg); 448 mModeToggle.setContentDescription(getString(R.string.desc_switch_deg)); 449 } 450 } 451 452 /** 453 * Switch to INPUT from RESULT state in response to input of the specified button_id. 454 * View.NO_ID is treated as an incomplete function id. 455 */ switchToInput(int button_id)456 private void switchToInput(int button_id) { 457 if (KeyMaps.isBinary(button_id) || KeyMaps.isSuffix(button_id)) { 458 mEvaluator.collapse(); 459 } else { 460 announceClearedForAccessibility(); 461 mEvaluator.clear(); 462 } 463 setState(CalculatorState.INPUT); 464 } 465 466 // Add the given button id to input expression. 467 // If appropriate, clear the expression before doing so. addKeyToExpr(int id)468 private void addKeyToExpr(int id) { 469 if (mCurrentState == CalculatorState.ERROR) { 470 setState(CalculatorState.INPUT); 471 } else if (mCurrentState == CalculatorState.RESULT) { 472 switchToInput(id); 473 } 474 if (!mEvaluator.append(id)) { 475 // TODO: Some user visible feedback? 476 } 477 } 478 479 /** 480 * Add the given button id to input expression, assuming it was explicitly 481 * typed/touched. 482 * We perform slightly more aggressive correction than in pasted expressions. 483 */ addExplicitKeyToExpr(int id)484 private void addExplicitKeyToExpr(int id) { 485 if (mCurrentState == CalculatorState.INPUT && id == R.id.op_sub) { 486 mEvaluator.getExpr().removeTrailingAdditiveOperators(); 487 } 488 addKeyToExpr(id); 489 } 490 redisplayAfterFormulaChange()491 private void redisplayAfterFormulaChange() { 492 // TODO: Could do this more incrementally. 493 redisplayFormula(); 494 setState(CalculatorState.INPUT); 495 if (mEvaluator.getExpr().hasInterestingOps()) { 496 mEvaluator.evaluateAndShowResult(); 497 } else { 498 mResultText.clear(); 499 } 500 } 501 onButtonClick(View view)502 public void onButtonClick(View view) { 503 // Any animation is ended before we get here. 504 mCurrentButton = view; 505 stopActionMode(); 506 // See onKey above for the rationale behind some of the behavior below: 507 if (mCurrentState != CalculatorState.EVALUATE) { 508 // Cancel evaluations that were not specifically requested. 509 mEvaluator.cancelAll(true); 510 } 511 final int id = view.getId(); 512 switch (id) { 513 case R.id.eq: 514 onEquals(); 515 break; 516 case R.id.del: 517 onDelete(); 518 break; 519 case R.id.clr: 520 onClear(); 521 break; 522 case R.id.toggle_inv: 523 final boolean selected = !mInverseToggle.isSelected(); 524 mInverseToggle.setSelected(selected); 525 onInverseToggled(selected); 526 if (mCurrentState == CalculatorState.RESULT) { 527 mResultText.redisplay(); // In case we cancelled reevaluation. 528 } 529 break; 530 case R.id.toggle_mode: 531 cancelIfEvaluating(false); 532 final boolean mode = !mEvaluator.getDegreeMode(); 533 if (mCurrentState == CalculatorState.RESULT) { 534 mEvaluator.collapse(); // Capture result evaluated in old mode 535 redisplayFormula(); 536 } 537 // In input mode, we reinterpret already entered trig functions. 538 mEvaluator.setDegreeMode(mode); 539 onModeChanged(mode); 540 setState(CalculatorState.INPUT); 541 mResultText.clear(); 542 if (mEvaluator.getExpr().hasInterestingOps()) { 543 mEvaluator.evaluateAndShowResult(); 544 } 545 break; 546 default: 547 cancelIfEvaluating(false); 548 addExplicitKeyToExpr(id); 549 redisplayAfterFormulaChange(); 550 break; 551 } 552 } 553 redisplayFormula()554 void redisplayFormula() { 555 SpannableStringBuilder formula = mEvaluator.getExpr().toSpannableStringBuilder(this); 556 if (mUnprocessedChars != null) { 557 // Add and highlight characters we couldn't process. 558 formula.append(mUnprocessedChars, mUnprocessedColorSpan, 559 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 560 } 561 mFormulaText.changeTextTo(formula); 562 } 563 564 @Override onLongClick(View view)565 public boolean onLongClick(View view) { 566 mCurrentButton = view; 567 568 if (view.getId() == R.id.del) { 569 onClear(); 570 return true; 571 } 572 return false; 573 } 574 575 // Initial evaluation completed successfully. Initiate display. onEvaluate(int initDisplayPrec, int msd, int leastDigPos, String truncatedWholeNumber)576 public void onEvaluate(int initDisplayPrec, int msd, int leastDigPos, 577 String truncatedWholeNumber) { 578 // Invalidate any options that may depend on the current result. 579 invalidateOptionsMenu(); 580 581 mResultText.displayResult(initDisplayPrec, msd, leastDigPos, truncatedWholeNumber); 582 if (mCurrentState != CalculatorState.INPUT) { // in EVALUATE or INIT state 583 onResult(mCurrentState != CalculatorState.INIT); 584 } 585 } 586 587 // Reset state to reflect evaluator cancellation. Invoked by evaluator. onCancelled()588 public void onCancelled() { 589 // We should be in EVALUATE state. 590 setState(CalculatorState.INPUT); 591 mResultText.clear(); 592 } 593 594 // Reevaluation completed; ask result to redisplay current value. onReevaluate()595 public void onReevaluate() 596 { 597 mResultText.redisplay(); 598 } 599 600 @Override onTextSizeChanged(final TextView textView, float oldSize)601 public void onTextSizeChanged(final TextView textView, float oldSize) { 602 if (mCurrentState != CalculatorState.INPUT) { 603 // Only animate text changes that occur from user input. 604 return; 605 } 606 607 // Calculate the values needed to perform the scale and translation animations, 608 // maintaining the same apparent baseline for the displayed text. 609 final float textScale = oldSize / textView.getTextSize(); 610 final float translationX = (1.0f - textScale) * 611 (textView.getWidth() / 2.0f - textView.getPaddingEnd()); 612 final float translationY = (1.0f - textScale) * 613 (textView.getHeight() / 2.0f - textView.getPaddingBottom()); 614 615 final AnimatorSet animatorSet = new AnimatorSet(); 616 animatorSet.playTogether( 617 ObjectAnimator.ofFloat(textView, View.SCALE_X, textScale, 1.0f), 618 ObjectAnimator.ofFloat(textView, View.SCALE_Y, textScale, 1.0f), 619 ObjectAnimator.ofFloat(textView, View.TRANSLATION_X, translationX, 0.0f), 620 ObjectAnimator.ofFloat(textView, View.TRANSLATION_Y, translationY, 0.0f)); 621 animatorSet.setDuration(getResources().getInteger(android.R.integer.config_mediumAnimTime)); 622 animatorSet.setInterpolator(new AccelerateDecelerateInterpolator()); 623 animatorSet.start(); 624 } 625 626 /** 627 * Cancel any in-progress explicitly requested evaluations. 628 * @param quiet suppress pop-up message. Explicit evaluation can change the expression 629 value, and certainly changes the display, so it seems reasonable to warn. 630 * @return true if there was such an evaluation 631 */ cancelIfEvaluating(boolean quiet)632 private boolean cancelIfEvaluating(boolean quiet) { 633 if (mCurrentState == CalculatorState.EVALUATE) { 634 mEvaluator.cancelAll(quiet); 635 return true; 636 } else { 637 return false; 638 } 639 } 640 onEquals()641 private void onEquals() { 642 // In non-INPUT state assume this was redundant and ignore it. 643 if (mCurrentState == CalculatorState.INPUT && !mEvaluator.getExpr().isEmpty()) { 644 setState(CalculatorState.EVALUATE); 645 mEvaluator.requireResult(); 646 } 647 } 648 onDelete()649 private void onDelete() { 650 // Delete works like backspace; remove the last character or operator from the expression. 651 // Note that we handle keyboard delete exactly like the delete button. For 652 // example the delete button can be used to delete a character from an incomplete 653 // function name typed on a physical keyboard. 654 // This should be impossible in RESULT state. 655 // If there is an in-progress explicit evaluation, just cancel it and return. 656 if (cancelIfEvaluating(false)) return; 657 setState(CalculatorState.INPUT); 658 if (mUnprocessedChars != null) { 659 int len = mUnprocessedChars.length(); 660 if (len > 0) { 661 mUnprocessedChars = mUnprocessedChars.substring(0, len-1); 662 } else { 663 mEvaluator.delete(); 664 } 665 } else { 666 mEvaluator.delete(); 667 } 668 if (mEvaluator.getExpr().isEmpty() 669 && (mUnprocessedChars == null || mUnprocessedChars.isEmpty())) { 670 // Resulting formula won't be announced, since it's empty. 671 announceClearedForAccessibility(); 672 } 673 redisplayAfterFormulaChange(); 674 } 675 reveal(View sourceView, int colorRes, AnimatorListener listener)676 private void reveal(View sourceView, int colorRes, AnimatorListener listener) { 677 final ViewGroupOverlay groupOverlay = 678 (ViewGroupOverlay) getWindow().getDecorView().getOverlay(); 679 680 final Rect displayRect = new Rect(); 681 mDisplayView.getGlobalVisibleRect(displayRect); 682 683 // Make reveal cover the display and status bar. 684 final View revealView = new View(this); 685 revealView.setBottom(displayRect.bottom); 686 revealView.setLeft(displayRect.left); 687 revealView.setRight(displayRect.right); 688 revealView.setBackgroundColor(getResources().getColor(colorRes)); 689 groupOverlay.add(revealView); 690 691 final int[] clearLocation = new int[2]; 692 sourceView.getLocationInWindow(clearLocation); 693 clearLocation[0] += sourceView.getWidth() / 2; 694 clearLocation[1] += sourceView.getHeight() / 2; 695 696 final int revealCenterX = clearLocation[0] - revealView.getLeft(); 697 final int revealCenterY = clearLocation[1] - revealView.getTop(); 698 699 final double x1_2 = Math.pow(revealView.getLeft() - revealCenterX, 2); 700 final double x2_2 = Math.pow(revealView.getRight() - revealCenterX, 2); 701 final double y_2 = Math.pow(revealView.getTop() - revealCenterY, 2); 702 final float revealRadius = (float) Math.max(Math.sqrt(x1_2 + y_2), Math.sqrt(x2_2 + y_2)); 703 704 final Animator revealAnimator = 705 ViewAnimationUtils.createCircularReveal(revealView, 706 revealCenterX, revealCenterY, 0.0f, revealRadius); 707 revealAnimator.setDuration( 708 getResources().getInteger(android.R.integer.config_longAnimTime)); 709 revealAnimator.addListener(listener); 710 711 final Animator alphaAnimator = ObjectAnimator.ofFloat(revealView, View.ALPHA, 0.0f); 712 alphaAnimator.setDuration( 713 getResources().getInteger(android.R.integer.config_mediumAnimTime)); 714 715 final AnimatorSet animatorSet = new AnimatorSet(); 716 animatorSet.play(revealAnimator).before(alphaAnimator); 717 animatorSet.setInterpolator(new AccelerateDecelerateInterpolator()); 718 animatorSet.addListener(new AnimatorListenerAdapter() { 719 @Override 720 public void onAnimationEnd(Animator animator) { 721 groupOverlay.remove(revealView); 722 mCurrentAnimator = null; 723 } 724 }); 725 726 mCurrentAnimator = animatorSet; 727 animatorSet.start(); 728 } 729 announceClearedForAccessibility()730 private void announceClearedForAccessibility() { 731 mResultText.announceForAccessibility(getResources().getString(R.string.cleared)); 732 } 733 onClear()734 private void onClear() { 735 if (mEvaluator.getExpr().isEmpty()) { 736 return; 737 } 738 cancelIfEvaluating(true); 739 announceClearedForAccessibility(); 740 reveal(mCurrentButton, R.color.calculator_accent_color, new AnimatorListenerAdapter() { 741 @Override 742 public void onAnimationEnd(Animator animation) { 743 mUnprocessedChars = null; 744 mResultText.clear(); 745 mEvaluator.clear(); 746 setState(CalculatorState.INPUT); 747 redisplayFormula(); 748 } 749 }); 750 } 751 752 // Evaluation encountered en error. Display the error. onError(final int errorResourceId)753 void onError(final int errorResourceId) { 754 if (mCurrentState == CalculatorState.EVALUATE) { 755 setState(CalculatorState.ANIMATE); 756 mResultText.announceForAccessibility(getResources().getString(errorResourceId)); 757 reveal(mCurrentButton, R.color.calculator_error_color, 758 new AnimatorListenerAdapter() { 759 @Override 760 public void onAnimationEnd(Animator animation) { 761 setState(CalculatorState.ERROR); 762 mResultText.displayError(errorResourceId); 763 } 764 }); 765 } else if (mCurrentState == CalculatorState.INIT) { 766 setState(CalculatorState.ERROR); 767 mResultText.displayError(errorResourceId); 768 } else { 769 mResultText.clear(); 770 } 771 } 772 773 774 // Animate movement of result into the top formula slot. 775 // Result window now remains translated in the top slot while the result is displayed. 776 // (We convert it back to formula use only when the user provides new input.) 777 // Historical note: In the Lollipop version, this invisibly and instantaneously moved 778 // formula and result displays back at the end of the animation. We no longer do that, 779 // so that we can continue to properly support scrolling of the result. 780 // We assume the result already contains the text to be expanded. onResult(boolean animate)781 private void onResult(boolean animate) { 782 // Calculate the textSize that would be used to display the result in the formula. 783 // For scrollable results just use the minimum textSize to maximize the number of digits 784 // that are visible on screen. 785 float textSize = mFormulaText.getMinimumTextSize(); 786 if (!mResultText.isScrollable()) { 787 textSize = mFormulaText.getVariableTextSize(mResultText.getText().toString()); 788 } 789 790 // Scale the result to match the calculated textSize, minimizing the jump-cut transition 791 // when a result is reused in a subsequent expression. 792 final float resultScale = textSize / mResultText.getTextSize(); 793 794 // Set the result's pivot to match its gravity. 795 mResultText.setPivotX(mResultText.getWidth() - mResultText.getPaddingRight()); 796 mResultText.setPivotY(mResultText.getHeight() - mResultText.getPaddingBottom()); 797 798 // Calculate the necessary translations so the result takes the place of the formula and 799 // the formula moves off the top of the screen. 800 final float resultTranslationY = (mFormulaText.getBottom() - mResultText.getBottom()) 801 - (mFormulaText.getPaddingBottom() - mResultText.getPaddingBottom()); 802 final float formulaTranslationY = -mFormulaText.getBottom(); 803 804 // Change the result's textColor to match the formula. 805 final int formulaTextColor = mFormulaText.getCurrentTextColor(); 806 807 if (animate) { 808 mResultText.announceForAccessibility(getResources().getString(R.string.desc_eq)); 809 mResultText.announceForAccessibility(mResultText.getText()); 810 setState(CalculatorState.ANIMATE); 811 final AnimatorSet animatorSet = new AnimatorSet(); 812 animatorSet.playTogether( 813 ObjectAnimator.ofPropertyValuesHolder(mResultText, 814 PropertyValuesHolder.ofFloat(View.SCALE_X, resultScale), 815 PropertyValuesHolder.ofFloat(View.SCALE_Y, resultScale), 816 PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, resultTranslationY)), 817 ObjectAnimator.ofArgb(mResultText, TEXT_COLOR, formulaTextColor), 818 ObjectAnimator.ofFloat(mFormulaText, View.TRANSLATION_Y, formulaTranslationY)); 819 animatorSet.setDuration(getResources().getInteger( 820 android.R.integer.config_longAnimTime)); 821 animatorSet.addListener(new AnimatorListenerAdapter() { 822 @Override 823 public void onAnimationEnd(Animator animation) { 824 setState(CalculatorState.RESULT); 825 mCurrentAnimator = null; 826 } 827 }); 828 829 mCurrentAnimator = animatorSet; 830 animatorSet.start(); 831 } else /* No animation desired; get there fast, e.g. when restarting */ { 832 mResultText.setScaleX(resultScale); 833 mResultText.setScaleY(resultScale); 834 mResultText.setTranslationY(resultTranslationY); 835 mResultText.setTextColor(formulaTextColor); 836 mFormulaText.setTranslationY(formulaTranslationY); 837 setState(CalculatorState.RESULT); 838 } 839 } 840 841 // Restore positions of the formula and result displays back to their original, 842 // pre-animation state. restoreDisplayPositions()843 private void restoreDisplayPositions() { 844 // Clear result. 845 mResultText.setText(""); 846 // Reset all of the values modified during the animation. 847 mResultText.setScaleX(1.0f); 848 mResultText.setScaleY(1.0f); 849 mResultText.setTranslationX(0.0f); 850 mResultText.setTranslationY(0.0f); 851 mFormulaText.setTranslationY(0.0f); 852 853 mFormulaText.requestFocus(); 854 } 855 856 @Override onClick(AlertDialogFragment fragment, int which)857 public void onClick(AlertDialogFragment fragment, int which) { 858 if (which == DialogInterface.BUTTON_POSITIVE) { 859 // Timeout extension request. 860 mEvaluator.setLongTimeOut(); 861 } 862 } 863 864 @Override onCreateOptionsMenu(Menu menu)865 public boolean onCreateOptionsMenu(Menu menu) { 866 super.onCreateOptionsMenu(menu); 867 868 getMenuInflater().inflate(R.menu.activity_calculator, menu); 869 return true; 870 } 871 872 @Override onPrepareOptionsMenu(Menu menu)873 public boolean onPrepareOptionsMenu(Menu menu) { 874 super.onPrepareOptionsMenu(menu); 875 876 // Show the leading option when displaying a result. 877 menu.findItem(R.id.menu_leading).setVisible(mCurrentState == CalculatorState.RESULT); 878 879 // Show the fraction option when displaying a rational result. 880 menu.findItem(R.id.menu_fraction).setVisible(mCurrentState == CalculatorState.RESULT 881 && mEvaluator.getRational() != null); 882 883 return true; 884 } 885 886 @Override onOptionsItemSelected(MenuItem item)887 public boolean onOptionsItemSelected(MenuItem item) { 888 switch (item.getItemId()) { 889 case R.id.menu_leading: 890 displayFull(); 891 return true; 892 case R.id.menu_fraction: 893 displayFraction(); 894 return true; 895 case R.id.menu_licenses: 896 startActivity(new Intent(this, Licenses.class)); 897 return true; 898 default: 899 return super.onOptionsItemSelected(item); 900 } 901 } 902 displayMessage(String s)903 private void displayMessage(String s) { 904 AlertDialogFragment.showMessageDialog(this, s, null); 905 } 906 displayFraction()907 private void displayFraction() { 908 BoundedRational result = mEvaluator.getRational(); 909 displayMessage(KeyMaps.translateResult(result.toNiceString())); 910 } 911 912 // Display full result to currently evaluated precision displayFull()913 private void displayFull() { 914 Resources res = getResources(); 915 String msg = mResultText.getFullText() + " "; 916 if (mResultText.fullTextIsExact()) { 917 msg += res.getString(R.string.exact); 918 } else { 919 msg += res.getString(R.string.approximate); 920 } 921 displayMessage(msg); 922 } 923 924 /** 925 * Add input characters to the end of the expression. 926 * Map them to the appropriate button pushes when possible. Leftover characters 927 * are added to mUnprocessedChars, which is presumed to immediately precede the newly 928 * added characters. 929 * @param moreChars Characters to be added. 930 * @param explicit These characters were explicitly typed by the user, not pasted. 931 */ addChars(String moreChars, boolean explicit)932 private void addChars(String moreChars, boolean explicit) { 933 if (mUnprocessedChars != null) { 934 moreChars = mUnprocessedChars + moreChars; 935 } 936 int current = 0; 937 int len = moreChars.length(); 938 boolean lastWasDigit = false; 939 if (mCurrentState == CalculatorState.RESULT && len != 0) { 940 // Clear display immediately for incomplete function name. 941 switchToInput(KeyMaps.keyForChar(moreChars.charAt(current))); 942 } 943 while (current < len) { 944 char c = moreChars.charAt(current); 945 int k = KeyMaps.keyForChar(c); 946 if (!explicit) { 947 int expEnd; 948 if (lastWasDigit && current != 949 (expEnd = Evaluator.exponentEnd(moreChars, current))) { 950 // Process scientific notation with 'E' when pasting, in spite of ambiguity 951 // with base of natural log. 952 // Otherwise the 10^x key is the user's friend. 953 mEvaluator.addExponent(moreChars, current, expEnd); 954 current = expEnd; 955 lastWasDigit = false; 956 continue; 957 } else { 958 boolean isDigit = KeyMaps.digVal(k) != KeyMaps.NOT_DIGIT; 959 if (current == 0 && (isDigit || k == R.id.dec_point) 960 && mEvaluator.getExpr().hasTrailingConstant()) { 961 // Refuse to concatenate pasted content to trailing constant. 962 // This makes pasting of calculator results more consistent, whether or 963 // not the old calculator instance is still around. 964 addKeyToExpr(R.id.op_mul); 965 } 966 lastWasDigit = (isDigit || lastWasDigit && k == R.id.dec_point); 967 } 968 } 969 if (k != View.NO_ID) { 970 mCurrentButton = findViewById(k); 971 if (explicit) { 972 addExplicitKeyToExpr(k); 973 } else { 974 addKeyToExpr(k); 975 } 976 if (Character.isSurrogate(c)) { 977 current += 2; 978 } else { 979 ++current; 980 } 981 continue; 982 } 983 int f = KeyMaps.funForString(moreChars, current); 984 if (f != View.NO_ID) { 985 mCurrentButton = findViewById(f); 986 if (explicit) { 987 addExplicitKeyToExpr(f); 988 } else { 989 addKeyToExpr(f); 990 } 991 if (f == R.id.op_sqrt) { 992 // Square root entered as function; don't lose the parenthesis. 993 addKeyToExpr(R.id.lparen); 994 } 995 current = moreChars.indexOf('(', current) + 1; 996 continue; 997 } 998 // There are characters left, but we can't convert them to button presses. 999 mUnprocessedChars = moreChars.substring(current); 1000 redisplayAfterFormulaChange(); 1001 return; 1002 } 1003 mUnprocessedChars = null; 1004 redisplayAfterFormulaChange(); 1005 } 1006 1007 @Override onPaste(ClipData clip)1008 public boolean onPaste(ClipData clip) { 1009 final ClipData.Item item = clip.getItemCount() == 0 ? null : clip.getItemAt(0); 1010 if (item == null) { 1011 // nothing to paste, bail early... 1012 return false; 1013 } 1014 1015 // Check if the item is a previously copied result, otherwise paste as raw text. 1016 final Uri uri = item.getUri(); 1017 if (uri != null && mEvaluator.isLastSaved(uri)) { 1018 if (mCurrentState == CalculatorState.ERROR 1019 || mCurrentState == CalculatorState.RESULT) { 1020 setState(CalculatorState.INPUT); 1021 mEvaluator.clear(); 1022 } 1023 mEvaluator.appendSaved(); 1024 redisplayAfterFormulaChange(); 1025 } else { 1026 addChars(item.coerceToText(this).toString(), false); 1027 } 1028 return true; 1029 } 1030 } 1031