/**************************************************************************************** * This program is free software; you can redistribute it and/or modify it under * * the terms of the GNU General Public License as published by the Free Software * * Foundation; either version 3 of the License, or (at your option) any later * * version. * * * * This program is distributed in the hope that it will be useful, but WITHOUT ANY * * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A * * PARTICULAR PURPOSE. See the GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License along with * * this program. If not, see <http://www.gnu.org/licenses/>. * ****************************************************************************************/ package com.ichi2.anki; import android.app.Activity; import android.content.Intent; import android.content.SharedPreferences; import android.content.res.Resources; import android.os.Bundle; import android.support.v4.app.Fragment; import android.support.v7.widget.Toolbar; import android.text.Html; import android.text.method.LinkMovementMethod; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.Button; import android.widget.TextView; import com.afollestad.materialdialogs.MaterialDialog; import com.ichi2.anim.ActivityTransitionAnimation; import com.ichi2.anki.dialogs.CustomStudyDialog; import com.ichi2.async.DeckTask; import com.ichi2.libanki.Collection; import com.ichi2.libanki.Utils; import com.ichi2.themes.StyledProgressDialog; import org.json.JSONException; import org.json.JSONObject; import timber.log.Timber; public class StudyOptionsFragment extends Fragment implements Toolbar.OnMenuItemClickListener { /** * Available options performed by other activities */ private static final int BROWSE_CARDS = 3; private static final int STATISTICS = 4; private static final int DECK_OPTIONS = 5; /** * Constants for selecting which content view to display */ public static final int CONTENT_STUDY_OPTIONS = 0; public static final int CONTENT_CONGRATS = 1; public static final int CONTENT_EMPTY = 2; // Threshold at which the total number of new cards is truncated by libanki private static final int NEW_CARD_COUNT_TRUNCATE_THRESHOLD = 1000; /** * Preferences */ private int mCurrentContentView = CONTENT_STUDY_OPTIONS; /** Alerts to inform the user about different situations */ private MaterialDialog mProgressDialog; /** * UI elements for "Study Options" view */ private View mStudyOptionsView; private View mDeckInfoLayout; private Button mButtonStart; private TextView mTextDeckName; private TextView mTextDeckDescription; private TextView mTextTodayNew; private TextView mTextTodayLrn; private TextView mTextTodayRev; private TextView mTextNewTotal; private TextView mTextTotal; private TextView mTextETA; private TextView mTextCongratsMessage; private Toolbar mToolbar; // Flag to indicate if the fragment should load the deck options immediately after it loads private boolean mLoadWithDeckOptions; private boolean mFragmented; private Thread mFullNewCountThread = null; StudyOptionsListener mListener; public interface StudyOptionsListener { void onRequireDeckListUpdate(); } @Override public void onAttach(Activity activity) { super.onAttach(activity); try { mListener = (StudyOptionsListener) activity; } catch (ClassCastException e) { throw new ClassCastException(activity.toString() + " must implement StudyOptionsListener"); } } /** * Callbacks for UI events */ private View.OnClickListener mButtonClickListener = new View.OnClickListener() { @Override public void onClick(View v) { // long timeLimit = 0; switch (v.getId()) { case R.id.studyoptions_start: Timber.i("StudyOptionsFragment:: start study button pressed"); if (mCurrentContentView != CONTENT_CONGRATS) { openReviewer(); } else { showCustomStudyContextMenu(); } return; default: } } }; private void openFilteredDeckOptions() { openFilteredDeckOptions(false); } /** * Open the FilteredDeckOptions activity to allow the user to modify the parameters of the * filtered deck. * @param defaultConfig If true, signals to the FilteredDeckOptions activity that the filtered * deck has no options associated with it yet and should use a default * set of values. */ private void openFilteredDeckOptions(boolean defaultConfig) { Intent i = new Intent(getActivity(), FilteredDeckOptions.class); i.putExtra("defaultConfig", defaultConfig); getActivity().startActivityForResult(i, DECK_OPTIONS); ActivityTransitionAnimation.slide(getActivity(), ActivityTransitionAnimation.FADE); } /** * Get a new instance of the fragment. * @param withDeckOptions If true, the fragment will load a new activity on top of itself * which shows the current deck's options. Set to true when programmatically * opening a new filtered deck for the first time. */ public static StudyOptionsFragment newInstance(boolean withDeckOptions) { StudyOptionsFragment f = new StudyOptionsFragment(); Bundle args = new Bundle(); args.putBoolean("withDeckOptions", withDeckOptions); f.setArguments(args); return f; } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { if (container == null) { // Currently in a layout without a container, so no reason to create our view. return null; } restorePreferences(); mStudyOptionsView = inflater.inflate(R.layout.studyoptions_fragment, container, false); mFragmented = getActivity().getClass() != StudyOptionsActivity.class; initAllContentViews(); if (getArguments() != null) { mLoadWithDeckOptions = getArguments().getBoolean("withDeckOptions"); } mToolbar = (Toolbar) mStudyOptionsView.findViewById(R.id.studyOptionsToolbar); mToolbar.inflateMenu(R.menu.study_options_fragment); if (mToolbar != null) { configureToolbar(); } refreshInterface(true); return mStudyOptionsView; } @Override public void onDestroy() { super.onDestroy(); if (mFullNewCountThread != null) { mFullNewCountThread.interrupt(); } Timber.d("onDestroy()"); } @Override public void onResume() { super.onResume(); Timber.d("onResume()"); refreshInterface(true); } private void closeStudyOptions(int result) { Activity a = getActivity(); if (!mFragmented && a != null) { a.setResult(result); a.finish(); ActivityTransitionAnimation.slide(a, ActivityTransitionAnimation.RIGHT); } else if (a == null) { // getActivity() can return null if reference to fragment lingers after parent activity has been closed, // which is particularly relevant when using AsyncTasks. Timber.e("closeStudyOptions() failed due to getActivity() returning null"); } } private void openReviewer() { Intent reviewer = new Intent(getActivity(), Reviewer.class); if (mFragmented) { getActivity().startActivityForResult(reviewer, AnkiActivity.REQUEST_REVIEW); } else { // Go to DeckPicker after studying when not tablet reviewer.setFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT); startActivity(reviewer); getActivity().finish(); } animateLeft(); getCol().startTimebox(); } private void animateLeft() { ActivityTransitionAnimation.slide(getActivity(), ActivityTransitionAnimation.LEFT); } private void initAllContentViews() { if (mFragmented) { mStudyOptionsView.findViewById(R.id.studyoptions_gradient).setVisibility(View.VISIBLE); } mDeckInfoLayout = mStudyOptionsView.findViewById(R.id.studyoptions_deckinformation); mTextDeckName = (TextView) mStudyOptionsView.findViewById(R.id.studyoptions_deck_name); mTextDeckDescription = (TextView) mStudyOptionsView.findViewById(R.id.studyoptions_deck_description); // make links clickable mTextDeckDescription.setMovementMethod(LinkMovementMethod.getInstance()); mButtonStart = (Button) mStudyOptionsView.findViewById(R.id.studyoptions_start); mTextCongratsMessage = (TextView) mStudyOptionsView.findViewById(R.id.studyoptions_congrats_message); // Code common to both fragmented and non-fragmented view mTextTodayNew = (TextView) mStudyOptionsView.findViewById(R.id.studyoptions_new); mTextTodayLrn = (TextView) mStudyOptionsView.findViewById(R.id.studyoptions_lrn); mTextTodayRev = (TextView) mStudyOptionsView.findViewById(R.id.studyoptions_rev); mTextNewTotal = (TextView) mStudyOptionsView.findViewById(R.id.studyoptions_total_new); mTextTotal = (TextView) mStudyOptionsView.findViewById(R.id.studyoptions_total); mTextETA = (TextView) mStudyOptionsView.findViewById(R.id.studyoptions_eta); mButtonStart.setOnClickListener(mButtonClickListener); } /** * Show the context menu for the custom study options */ private void showCustomStudyContextMenu() { CustomStudyDialog d = CustomStudyDialog.newInstance(CustomStudyDialog.CONTEXT_MENU_STANDARD, getCol().getDecks().selected()); ((AnkiActivity)getActivity()).showDialogFragment(d); } void setFragmentContentView(View newView) { ViewGroup parent = (ViewGroup) this.getView(); parent.removeAllViews(); parent.addView(newView); } @Override public boolean onMenuItemClick(MenuItem item) { switch (item.getItemId()) { case R.id.action_undo: Timber.i("StudyOptionsFragment:: Undo button pressed"); getCol().undo(); openReviewer(); return true; case R.id.action_deck_options: Timber.i("StudyOptionsFragment:: Deck options button pressed"); if (getCol().getDecks().isDyn(getCol().getDecks().selected())) { openFilteredDeckOptions(); } else { Intent i = new Intent(getActivity(), DeckOptions.class); getActivity().startActivityForResult(i, DECK_OPTIONS); ActivityTransitionAnimation.slide(getActivity(), ActivityTransitionAnimation.FADE); } return true; case R.id.action_custom_study: Timber.i("StudyOptionsFragment:: custom study button pressed"); showCustomStudyContextMenu(); return true; case R.id.action_unbury: Timber.i("StudyOptionsFragment:: unbury button pressed"); getCol().getSched().unburyCardsForDeck(); refreshInterfaceAndDecklist(true); item.setVisible(false); return true; case R.id.action_rebuild: Timber.i("StudyOptionsFragment:: rebuild cram deck button pressed"); mProgressDialog = StyledProgressDialog.show(getActivity(), "", getResources().getString(R.string.rebuild_cram_deck), true); DeckTask.launchDeckTask(DeckTask.TASK_TYPE_REBUILD_CRAM, getDeckTaskListener(true), new DeckTask.TaskData(mFragmented)); return true; case R.id.action_empty: Timber.i("StudyOptionsFragment:: empty cram deck button pressed"); mProgressDialog = StyledProgressDialog.show(getActivity(), "", getResources().getString(R.string.empty_cram_deck), false); DeckTask.launchDeckTask(DeckTask.TASK_TYPE_EMPTY_CRAM, getDeckTaskListener(true), new DeckTask.TaskData(mFragmented)); return true; case R.id.action_rename: ((DeckPicker) getActivity()).renameDeckDialog(getCol().getDecks().selected()); return true; case R.id.action_delete: ((DeckPicker) getActivity()).confirmDeckDeletion(getCol().getDecks().selected()); return true; case R.id.action_export: ((DeckPicker) getActivity()).exportDeck(getCol().getDecks().selected()); return true; default: return false; } } public void configureToolbar() { mToolbar.setOnMenuItemClickListener(this); Menu menu = mToolbar.getMenu(); // Switch on or off rebuild/empty/custom study depending on whether or not filtered deck if (getCol().getDecks().isDyn(getCol().getDecks().selected())) { menu.findItem(R.id.action_rebuild).setVisible(true); menu.findItem(R.id.action_empty).setVisible(true); menu.findItem(R.id.action_custom_study).setVisible(false); } else { menu.findItem(R.id.action_rebuild).setVisible(false); menu.findItem(R.id.action_empty).setVisible(false); menu.findItem(R.id.action_custom_study).setVisible(true); } // Don't show custom study icon if congrats shown if (mCurrentContentView == CONTENT_CONGRATS) { menu.findItem(R.id.action_custom_study).setVisible(false); } // Switch on rename / delete / export if tablet layout if (mFragmented) { menu.findItem(R.id.action_rename).setVisible(true); menu.findItem(R.id.action_delete).setVisible(true); menu.findItem(R.id.action_export).setVisible(true); } else { menu.findItem(R.id.action_rename).setVisible(false); menu.findItem(R.id.action_delete).setVisible(false); menu.findItem(R.id.action_export).setVisible(false); } // Switch on or off unbury depending on if there are cards to unbury menu.findItem(R.id.action_unbury).setVisible(getCol().getSched().haveBuried()); // Switch on or off undo depending on whether undo is available if (!getCol().undoAvailable()) { menu.findItem(R.id.action_undo).setVisible(false); } else { menu.findItem(R.id.action_undo).setVisible(true); Resources res = AnkiDroidApp.getAppResources(); menu.findItem(R.id.action_undo).setTitle(res.getString(R.string.studyoptions_congrats_undo, getCol().undoName(res))); } // Set the back button listener if (!mFragmented) { mToolbar.setNavigationIcon(R.drawable.ic_arrow_back_white_24dp); mToolbar.setNavigationOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { ((AnkiActivity) getActivity()).finishWithAnimation(ActivityTransitionAnimation.RIGHT); } }); } } @Override public void onActivityResult(int requestCode, int resultCode, Intent intent) { super.onActivityResult(requestCode, resultCode, intent); Timber.d("onActivityResult (requestCode = %d, resultCode = %d)", requestCode, resultCode); // rebuild action bar configureToolbar(); // boot back to deck picker if there was an error if (resultCode == DeckPicker.RESULT_DB_ERROR || resultCode == DeckPicker.RESULT_MEDIA_EJECTED) { closeStudyOptions(resultCode); return; } // perform some special actions depending on which activity we're returning from if (requestCode == STATISTICS || requestCode == BROWSE_CARDS) { // select original deck if the statistics or card browser were opened, // which can change the selected deck if (intent.hasExtra("originalDeck")) { getCol().getDecks().select(intent.getLongExtra("originalDeck", 0L)); } } if (requestCode == DECK_OPTIONS) { if (mLoadWithDeckOptions) { mLoadWithDeckOptions = false; try { JSONObject deck = getCol().getDecks().current(); if (deck.getInt("dyn") != 0 && deck.has("empty")) { deck.remove("empty"); } } catch (JSONException e) { throw new RuntimeException(e); } mProgressDialog = StyledProgressDialog.show(getActivity(), "", getResources().getString(R.string.rebuild_cram_deck), true); DeckTask.launchDeckTask(DeckTask.TASK_TYPE_REBUILD_CRAM, getDeckTaskListener(true), new DeckTask.TaskData(mFragmented)); } else { DeckTask.waitToFinish(); refreshInterface(true); } } else if (requestCode == AnkiActivity.REQUEST_REVIEW) { if (resultCode == Reviewer.RESULT_NO_MORE_CARDS) { // If no more cards getting returned while counts > 0 (due to learn ahead limit) then show a snackbar int[] counts = getCol().getSched().counts(); if ((counts[0]+counts[1]+counts[2])>0 && mStudyOptionsView != null) { View rootLayout = mStudyOptionsView.findViewById(R.id.studyoptions_main); UIUtils.showSnackbar(getActivity(), R.string.studyoptions_no_cards_due, false, 0, null, rootLayout); } } } else if (requestCode == STATISTICS && mCurrentContentView == CONTENT_CONGRATS) { mCurrentContentView = CONTENT_STUDY_OPTIONS; setFragmentContentView(mStudyOptionsView); } } private void dismissProgressDialog() { if (mStudyOptionsView != null && mStudyOptionsView.findViewById(R.id.progress_bar) != null) { mStudyOptionsView.findViewById(R.id.progress_bar).setVisibility(View.GONE); } // for rebuilding cram decks if (mProgressDialog != null && mProgressDialog.isShowing()) { try { mProgressDialog.dismiss(); } catch (Exception e) { Timber.e("onPostExecute - Dialog dismiss Exception = " + e.getMessage()); } } } public SharedPreferences restorePreferences() { SharedPreferences preferences = AnkiDroidApp.getSharedPrefs(getActivity().getBaseContext()); return preferences; } private void refreshInterfaceAndDecklist(boolean resetSched) { refreshInterface(resetSched, true); } protected void refreshInterface() { refreshInterface(false, false); } protected void refreshInterface(boolean resetSched) { refreshInterface(resetSched, false); } /** * Rebuild the fragment's interface to reflect the status of the currently selected deck. * * @param resetSched Indicates whether to rebuild the queues as well. Set to true for any * task that modifies queues (e.g., unbury or empty filtered deck). * @param resetDecklist Indicates whether to call back to the parent activity in order to * also refresh the deck list. */ protected void refreshInterface(boolean resetSched, boolean resetDecklist) { Timber.d("Refreshing StudyOptionsFragment"); // Load the deck counts for the deck from Collection asynchronously DeckTask.launchDeckTask(DeckTask.TASK_TYPE_UPDATE_VALUES_FROM_DECK, getDeckTaskListener(resetDecklist), new DeckTask.TaskData(new Object[]{resetSched})); } /** * Returns a listener that rebuilds the interface after execute. * * @param refreshDecklist If true, the listener notifies the parent activity to update its deck list * to reflect the latest values. */ private DeckTask.TaskListener getDeckTaskListener(final boolean refreshDecklist) { return new DeckTask.TaskListener() { @Override public void onPreExecute() { } @Override public void onPostExecute(DeckTask.TaskData result) { dismissProgressDialog(); if (result != null) { // Get the return values back from the AsyncTask Object[] obj = result.getObjArray(); int newCards = (Integer) obj[0]; int lrnCards = (Integer) obj[1]; int revCards = (Integer) obj[2]; int totalNew = (Integer) obj[3]; int totalCards = (Integer) obj[4]; int eta = (Integer) obj[5]; // Don't do anything if the fragment is no longer attached to it's Activity or col has been closed if (getActivity() == null) { Timber.e("StudyOptionsFragment.mRefreshFragmentListener :: can't refresh"); return; } // Reinitialize controls incase changed to filtered deck initAllContentViews(); // Set the deck name String fullName; JSONObject deck = getCol().getDecks().current(); try { // Main deck name fullName = deck.getString("name"); String[] name = fullName.split("::"); StringBuilder nameBuilder = new StringBuilder(); if (name.length > 0) { nameBuilder.append(name[0]); } if (name.length > 1) { nameBuilder.append("\n").append(name[1]); } if (name.length > 3) { nameBuilder.append("..."); } if (name.length > 2) { nameBuilder.append("\n").append(name[name.length - 1]); } mTextDeckName.setText(nameBuilder.toString()); } catch (JSONException e) { throw new RuntimeException(e); } // open cram deck option if deck is opened for the first time if (mLoadWithDeckOptions) { openFilteredDeckOptions(mLoadWithDeckOptions); mLoadWithDeckOptions = false; return; } // Switch between the empty view, the ordinary view, and the "congratulations" view boolean isDynamic = deck.optInt("dyn", 0) != 0; if (totalCards == 0 && !isDynamic) { mCurrentContentView = CONTENT_EMPTY; mDeckInfoLayout.setVisibility(View.VISIBLE); mTextCongratsMessage.setVisibility(View.VISIBLE); mTextCongratsMessage.setText(R.string.studyoptions_empty); mButtonStart.setVisibility(View.GONE); } else if (newCards + lrnCards + revCards == 0) { mCurrentContentView = CONTENT_CONGRATS; if (!isDynamic) { mDeckInfoLayout.setVisibility(View.GONE); mButtonStart.setVisibility(View.VISIBLE); mButtonStart.setText(R.string.custom_study); } else { mButtonStart.setVisibility(View.GONE); } mTextCongratsMessage.setVisibility(View.VISIBLE); mTextCongratsMessage.setText(getCol().getSched().finishedMsg(getActivity())); } else { mCurrentContentView = CONTENT_STUDY_OPTIONS; mDeckInfoLayout.setVisibility(View.VISIBLE); mTextCongratsMessage.setVisibility(View.GONE); mButtonStart.setVisibility(View.VISIBLE); mButtonStart.setText(R.string.studyoptions_start); } // Set deck description String desc; if (isDynamic) { desc = getResources().getString(R.string.dyn_deck_desc); } else { desc = getCol().getDecks().getActualDescription(); } if (desc.length() > 0) { mTextDeckDescription.setText(Html.fromHtml(desc)); mTextDeckDescription.setVisibility(View.VISIBLE); } else { mTextDeckDescription.setVisibility(View.GONE); } // Set new/learn/review card counts mTextTodayNew.setText(String.valueOf(newCards)); mTextTodayLrn.setText(String.valueOf(lrnCards)); mTextTodayRev.setText(String.valueOf(revCards)); // Set the total number of new cards in deck if (totalNew < NEW_CARD_COUNT_TRUNCATE_THRESHOLD) { // if it hasn't been truncated by libanki then just set it usually mTextNewTotal.setText(String.valueOf(totalNew)); } else { // if truncated then make a thread to allow full count to load mTextNewTotal.setText(">1000"); if (mFullNewCountThread != null) { // a thread was previously made -- interrupt it mFullNewCountThread.interrupt(); } mFullNewCountThread = new Thread(new Runnable() { @Override public void run() { Collection collection = getCol(); // TODO: refactor code to not rewrite this query, add to Sched.totalNewForCurrentDeck() StringBuilder sbQuery = new StringBuilder(); sbQuery.append("SELECT count(*) FROM cards WHERE did IN "); sbQuery.append(Utils.ids2str(collection.getDecks().active())); sbQuery.append(" AND queue = 0"); final int fullNewCount = collection.getDb().queryScalar(sbQuery.toString()); if (fullNewCount > 0) { Runnable setNewTotalText = new Runnable() { @Override public void run() { mTextNewTotal.setText(String.valueOf(fullNewCount)); } }; if (!Thread.currentThread().isInterrupted()) { mTextNewTotal.post(setNewTotalText); } } } }); mFullNewCountThread.start(); } // Set total number of cards mTextTotal.setText(String.valueOf(totalCards)); // Set estimated time remaining if (eta != -1) { mTextETA.setText(Integer.toString(eta)); } else { mTextETA.setText("-"); } // Rebuild the options menu configureToolbar(); } // If in fragmented mode, refresh the deck list if (mFragmented && refreshDecklist) { mListener.onRequireDeckListUpdate(); } } @Override public void onProgressUpdate(DeckTask.TaskData... values) { } @Override public void onCancelled() { } }; } private Collection getCol() { return CollectionHelper.getInstance().getCol(getContext()); } }