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
26package com.android.calculator2;
27
28import android.animation.Animator;
29import android.animation.Animator.AnimatorListener;
30import android.animation.AnimatorListenerAdapter;
31import android.animation.AnimatorSet;
32import android.animation.ObjectAnimator;
33import android.animation.PropertyValuesHolder;
34import android.app.Activity;
35import android.app.AlertDialog;
36import android.content.ClipData;
37import android.content.DialogInterface;
38import android.content.Intent;
39import android.content.res.Resources;
40import android.graphics.Color;
41import android.graphics.Rect;
42import android.net.Uri;
43import android.os.Bundle;
44import android.support.annotation.NonNull;
45import android.support.v4.view.ViewPager;
46import android.text.SpannableString;
47import android.text.SpannableStringBuilder;
48import android.text.Spanned;
49import android.text.style.ForegroundColorSpan;
50import android.text.TextUtils;
51import android.util.Property;
52import android.view.KeyCharacterMap;
53import android.view.KeyEvent;
54import android.view.Menu;
55import android.view.MenuItem;
56import android.view.View;
57import android.view.View.OnKeyListener;
58import android.view.View.OnLongClickListener;
59import android.view.ViewAnimationUtils;
60import android.view.ViewGroupOverlay;
61import android.view.animation.AccelerateDecelerateInterpolator;
62import android.widget.TextView;
63import android.widget.Toolbar;
64
65import com.android.calculator2.CalculatorText.OnTextSizeChangeListener;
66
67import java.io.ByteArrayInputStream;
68import java.io.ByteArrayOutputStream;
69import java.io.IOException;
70import java.io.ObjectInput;
71import java.io.ObjectInputStream;
72import java.io.ObjectOutput;
73import java.io.ObjectOutputStream;
74
75public 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
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
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.
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.
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
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
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     */
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     */
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     */
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.
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     */
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
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
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
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
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.
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.
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.
595    public void onReevaluate()
596    {
597        mResultText.redisplay();
598    }
599
600    @Override
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     */
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
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
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
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
730    private void announceClearedForAccessibility() {
731        mResultText.announceForAccessibility(getResources().getString(R.string.cleared));
732    }
733
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.
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.
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.
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
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
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
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
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
903    private void displayMessage(String s) {
904        AlertDialogFragment.showMessageDialog(this, s, null);
905    }
906
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
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     */
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
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