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