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