/* * Copyright (C) 2011 The original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express * or implied. See the License for the specific language governing permissions and limitations under * the License. */ package com.zapta.apps.maniana.controller; import static com.zapta.apps.maniana.util.Assertions.check; import java.io.InputStream; import java.util.Date; import javax.annotation.Nullable; import android.app.Activity; import android.content.Intent; import android.media.AudioManager; import android.net.Uri; import android.view.View; import android.widget.AdapterView; import android.widget.AdapterView.OnItemClickListener; import android.widget.TextView; import com.zapta.apps.maniana.R; import com.zapta.apps.maniana.annotations.MainActivityScope; import com.zapta.apps.maniana.backup.RestoreBackupDialog; import com.zapta.apps.maniana.backup.RestoreBackupDialog.Action; import com.zapta.apps.maniana.backup.RestoreBackupDialog.RestoreBackupDialogListener; import com.zapta.apps.maniana.editors.ItemTextEditor; import com.zapta.apps.maniana.editors.ItemTimePicker; import com.zapta.apps.maniana.editors.ItemTimePicker.ItemTimePickerListener; import com.zapta.apps.maniana.editors.ItemVoiceEditor; import com.zapta.apps.maniana.help.HelpUtil; import com.zapta.apps.maniana.help.PopupMessageActivity; import com.zapta.apps.maniana.help.PopupMessageActivity.MessageKind; import com.zapta.apps.maniana.main.MainActivityResumeAction; import com.zapta.apps.maniana.main.MainActivityState; import com.zapta.apps.maniana.menus.ItemMenuEntry; import com.zapta.apps.maniana.menus.MainMenuEntry; import com.zapta.apps.maniana.model.AppModel; import com.zapta.apps.maniana.model.ItemColor; import com.zapta.apps.maniana.model.ItemModel; import com.zapta.apps.maniana.model.ItemModelReadOnly; import com.zapta.apps.maniana.model.OrganizePageSummary; import com.zapta.apps.maniana.model.PageKind; import com.zapta.apps.maniana.model.PushScope; import com.zapta.apps.maniana.notifications.NotificationUtil; import com.zapta.apps.maniana.persistence.ModelDeserialization; import com.zapta.apps.maniana.persistence.ModelPersistence; import com.zapta.apps.maniana.persistence.PersistenceMetadata; import com.zapta.apps.maniana.services.MidnightTicker; import com.zapta.apps.maniana.services.ShakeImpl; import com.zapta.apps.maniana.services.Shaker; import com.zapta.apps.maniana.services.Shaker.ShakerListener; import com.zapta.apps.maniana.settings.ItemColorsSet; import com.zapta.apps.maniana.settings.PreferenceKind; import com.zapta.apps.maniana.settings.SettingsActivity; import com.zapta.apps.maniana.settings.ShakerAction; import com.zapta.apps.maniana.util.CalendarUtil; import com.zapta.apps.maniana.util.FileUtil; import com.zapta.apps.maniana.util.FileUtil.FileReadResult; import com.zapta.apps.maniana.util.IdGenerator; import com.zapta.apps.maniana.util.LogUtil; import com.zapta.apps.maniana.view.AppView; import com.zapta.apps.maniana.view.AppView.ItemAnimationType; import com.zapta.apps.maniana.widget.BaseWidgetProvider; /** * The controller class. Contains main app logic. Interacts with the model (data) and view * (display). * * @author Tal Dayan */ @MainActivityScope public class Controller implements ShakerListener { // Adding a task with this exact text turns on the debug mode. // Debug mode exposes few commands that may be useful for developers. When enabled, // debug mode commands are available via the 'Debug' entry in the main menu. // Note that debug commands can change any time as the developers needs change. // Also, we don't bother to translate debug mode strings. They are always in // English. private static final String DEBUG_MODE_TASK_CODE = "#d#"; private static final int VOICE_RECOGNITION_REQUEST_CODE = 1001; /** The app context. Provide access to the model, view and services. */ private final MainActivityState mMainActivityState; private final ItemMenuCache mItemMenuCache; /** Used to detect first app resume to trigger the startup animation. */ private int mOnAppResumeCount = 0; /** Indicates when the resume operation should populate the model with new user data. */ private boolean mPopulateNewUserSampleDataOnResume = false; /** * Used to determine if the resume is from own sub activity (e.g. voice or help) as opposed to * app re-entry. */ private boolean mInSubActivity = false; @Nullable private Shaker mOptionalShaker = null; /** * Preallocated temp object. Used to reduce object alloctation. */ private final OrganizePageSummary mTempSummary = new OrganizePageSummary(); public Controller(MainActivityState mainActivityState) { mMainActivityState = mainActivityState; mItemMenuCache = new ItemMenuCache(mainActivityState); } /** Called by the view when user clicks on item's text area */ public void onItemTextClick(PageKind pageKind, int itemIndex) { mMainActivityState.services().maybePlayStockSound(AudioManager.FX_KEY_CLICK, false); showItemMenu(pageKind, itemIndex); } /** Called the view when user clicks on item's color swatch area */ public final void onItemColorClick(final PageKind pageKind, final int itemIndex) { mMainActivityState.services().maybePlayStockSound(AudioManager.FX_KEYPRESS_SPACEBAR, false); final ItemModel item = mMainActivityState.model().getItemForMutation(pageKind, itemIndex); final ItemColorsSet itemColorsSet = mMainActivityState.prefTracker().getItemColorsPreference(); final ItemColor newItemColor = itemColorsSet.colorAfter(item.getColor()); if (newItemColor != item.getColor()) { item.setColor(newItemColor); mMainActivityState.view().updatePage(pageKind); } else { // No color change. Give a novice user a hing. if (mMainActivityState.prefTracker().getVerboseMessagesEnabledPreference()) { mMainActivityState.services().toast(R.string.item_colors_hint); } } } /** Called by the view when user clicks on item's arrow/lock area */ public final void onItemArrowClick(final PageKind pageKind, final int itemIndex) { // If item locked, show item menu, allowing to unlock it. if (mMainActivityState.model().getItemReadOnly(pageKind, itemIndex).isLocked()) { showItemMenu(pageKind, itemIndex); return; } // Here when item is not locked. Animate and move to other page and do the actual // move in the model at the end of the animation. mMainActivityState.services().maybePlayStockSound(AudioManager.FX_KEYPRESS_RETURN, false); mMainActivityState.view().startItemAnimation(pageKind, itemIndex, AppView.ItemAnimationType.MOVING_ITEM_TO_OTHER_PAGE, 0, new Runnable() { @Override public void run() { moveItemToOtherPage(pageKind, itemIndex); } }); } /** * Move a model item to the other page. * * @param pageKind the source page. * @param itemIndex item index in the source page. */ private final void moveItemToOtherPage(PageKind pageKind, int itemIndex) { // Remove item from current page. final ItemModel item = mMainActivityState.model().removeItem(pageKind, itemIndex); // Insert at the beginning of the other page. final PageKind otherPageKind = pageKind.otherPageKind(); mMainActivityState.model().insertItem(otherPageKind, 0, item); mMainActivityState.model().clearAllUndo(); maybeAutoSortPage(otherPageKind, false, false); mMainActivityState.view().updatePages(); // The item is inserted at the top of the other page. Scroll there so it is visible // if the user flips to the other page. // NOTE(tal): This must be done after updateAlLPages(), otherwise it is ignored. mMainActivityState.view().scrollToItem(otherPageKind, 0); } /** Called by the view when the user drag an item within the page */ public final void onItemMoveInPage(final PageKind pageKind, final int sourceItemIndex, final int destinationItemIndex) { final ItemModel itemModel = mMainActivityState.model() .removeItem(pageKind, sourceItemIndex); // NOTE(tal): if source index < destination index, the item removal above affect the index // of the destination by 1. Despite that, we don't compensate for it as this acieve a more // intuitive behavior and allow to move an item to the end of the list. mMainActivityState.model().insertItem(pageKind, destinationItemIndex, itemModel); mMainActivityState.view().updatePage(pageKind); mMainActivityState.view().getRootView().post(new Runnable() { @Override public void run() { maybeAutosortPageWithItemOfInterest(pageKind, destinationItemIndex); } }); } /** Called when the activity is paused */ public final void onMainActivityPause() { if (mOptionalShaker != null) { mOptionalShaker.pause(); } // Close any leftover dialogs. This provides a more intuitive user experience. mMainActivityState.popupsTracker().closeAllLeftOvers(); flushModelChanges(false); } /** If model is dirty then persist and update widgets. */ private final void flushModelChanges(boolean alwaysUpdateAllWidgets) { // If state is dirty persist data so we don't lose it if the app will not resumed. final boolean modelWasDirty = mMainActivityState.model().isDirty(); if (modelWasDirty) { final PersistenceMetadata metadata = new PersistenceMetadata(mMainActivityState .services().getAppVersionCode(), mMainActivityState.services() .getAppVersionName()); // NOTE(tal): this clears the dirty bit. ModelPersistence.writeModelFile(mMainActivityState, mMainActivityState.model(), metadata); check(!mMainActivityState.model().isDirty()); onBackupDataChange(); } if (modelWasDirty || alwaysUpdateAllWidgets) { updateAllWidgets(); } } /** Called when the main activity is resumed, including after app creation. */ public final void onMainActivityResume(MainActivityResumeAction resumeAction, @Nullable Intent resumeIntent) { // This may leave undo items in case we cleanup completed tasks. maybeHandleDateChange(); NotificationUtil.clearPendingItemsNotification(mMainActivityState.context()); // Keep the midnight ticker going, just in case. MidnightTicker.scheduleMidnightTicker(mMainActivityState.context()); ++mOnAppResumeCount; // We suppress the population of new user sample tasks if the first resume is with certain // actions. // It seems to be more intuitive this way. if (mPopulateNewUserSampleDataOnResume) { if (resumeAction != MainActivityResumeAction.RESTORE_FROM_BABKUP_FILE) { populateModelWithSampleTasks(mMainActivityState.model()); startPopupMessageSubActivity(MessageKind.NEW_USER); } mMainActivityState.model().setLastPushDateStamp( mMainActivityState.dateTracker().getDateStampString()); mMainActivityState.model().setDirty(); mPopulateNewUserSampleDataOnResume = false; } // Typically we reset the view to default position (both pages are scrolled // to the top, Today page is shown) when the app is resumed. We preserve the page and // scroll only when it is resumed from a sub activity (e.g. settings or startup message) // with no resume action. final boolean preserveView = mInSubActivity && resumeAction.isNone(); if (!preserveView) { // Force both pages to be scrolled to top. More intuitive this way. mMainActivityState.view().scrollToItem(PageKind.TOMOROW, 0); mMainActivityState.view().scrollToItem(PageKind.TODAY, 0); // Force showing today's page. This is done with animation or immediately // depending on settings. In case of an actual resume action, we skip // the animation to save user's time. final boolean isAppStartup = (mOnAppResumeCount == 1); final boolean doAnimation = isAppStartup && resumeAction.allowsAnimations() && mMainActivityState.prefTracker().getStartupAnimationPreference(); if (doAnimation) { // Show initial animation mMainActivityState.view().setCurrentPage(PageKind.TOMOROW, -1); mMainActivityState.view().getRootView().postDelayed(new Runnable() { @Override public void run() { mMainActivityState.view().setCurrentPage(PageKind.TODAY, 800); } }, 500); } else { // No animation. Jump directly to Today. mMainActivityState.view().setCurrentPage( resumeAction.isForceTomorowPage() ? PageKind.TOMOROW : PageKind.TODAY, 0); } } // Reset the in sub activity tracking . mInSubActivity = false; maybeAutoSortPages(true, true); // Dispatch optional resume action by simulating user clicks. By the logic // above, if resumeAction is not NONE, here the TODAY page is already displayed and // is scrolled all the way to the top. switch (resumeAction) { case ADD_NEW_ITEM_BY_TEXT: onAddItemByTextButton(PageKind.TODAY); break; case ADD_NEW_ITEM_BY_VOICE: onAddItemByVoiceButton(PageKind.TODAY); break; case RESTORE_FROM_BABKUP_FILE: onRestoreBackupFromFileClick(resumeIntent); break; case NONE: case SHOW_TODAY_PAGE: case FORCE_TODAY_PAGE: case FORCE_TOMORROW_PAGE: default: // Do nothing } resumeShaker(); } /** * Called when main activity is resumed. It updates the shaker state based on current settings. */ private final void resumeShaker() { if (mMainActivityState.prefTracker().getShakerEnabledPreference()) { final boolean installShaker = (mOptionalShaker == null); if (installShaker) { mOptionalShaker = new ShakeImpl(mMainActivityState.context(), this); } final boolean shakerSupported = mOptionalShaker.resume(mMainActivityState.prefTracker() .getShakerSensitivityPreference()); if (installShaker && !shakerSupported) { mMainActivityState.services().toast( mMainActivityState.str(R.string.shaking_service_not_available)); } } else { if (mOptionalShaker != null) { mOptionalShaker.pause(); mOptionalShaker = null; } } } /** Populate given model with new user's sample tasks. */ private final void populateModelWithSampleTasks(AppModel model) { // Today's page final long ts = System.currentTimeMillis(); model.appendItem(PageKind.TODAY, new ItemModel(ts, IdGenerator.getFreshId(), mMainActivityState.str(R.string.sample_tast_text_11), false, false, 0, ItemColor.NONE)); model.appendItem(PageKind.TODAY, new ItemModel(ts, IdGenerator.getFreshId(), mMainActivityState.str(R.string.sample_tast_text_12), false, false, 0, ItemColor.NONE)); model.appendItem(PageKind.TODAY, new ItemModel(ts, IdGenerator.getFreshId(), mMainActivityState.str(R.string.sample_tast_text_13), false, false, 0, ItemColor.NONE)); model.appendItem(PageKind.TODAY, new ItemModel(ts, IdGenerator.getFreshId(), mMainActivityState.str(R.string.sample_tast_text_14), false, false, 0, ItemColor.RED)); model.appendItem(PageKind.TODAY, new ItemModel(ts, IdGenerator.getFreshId(), mMainActivityState.str(R.string.sample_tast_text_15), false, false, 0, ItemColor.BLUE)); model.appendItem(PageKind.TODAY, new ItemModel(ts, IdGenerator.getFreshId(), mMainActivityState.str(R.string.sample_tast_text_16), false, false, 0, ItemColor.NONE)); // Tommorow's page model.appendItem(PageKind.TOMOROW, new ItemModel(ts, IdGenerator.getFreshId(), mMainActivityState.str(R.string.sample_tast_text_21), false, false, 0, ItemColor.NONE)); } /** Update date and if needed push model items from Tomorow to Today. */ private void maybeHandleDateChange() { // Sample and cache the current date. mMainActivityState.dateTracker().updateDate(); // TODO: filter out redundant view date changes? (do not update unless date changed) mMainActivityState.view().onDateChange(); // A quick check for the normal case where the last push was today. final String modelPushDateStamp = mMainActivityState.model().getLastPushDateStamp(); final String trackerTodayDateStamp = mMainActivityState.dateTracker().getDateStampString(); if (trackerTodayDateStamp.equals(modelPushDateStamp)) { return; } // Determine if to expire all locks final PushScope pushScope = mMainActivityState.dateTracker().computePushScope( modelPushDateStamp, mMainActivityState.prefTracker().reader().getLockExpierationPeriodPreference()); if (pushScope == PushScope.NONE) { // Not expected because of the quick check above LogUtil.error("*** Unexpected condition, pushScope=NONE," + " modelTimestamp=%s, trackerDateStamp=%s", modelPushDateStamp, trackerTodayDateStamp); } else { final boolean expireAllLocks = (pushScope == PushScope.ALL); final boolean deleteCompletedItems = mMainActivityState.prefTracker().reader() .getAutoDailyCleanupPreference(); LogUtil.info("Model push scope: %s, auto_cleanup=%s", pushScope, deleteCompletedItems); mMainActivityState.model().pushToToday(expireAllLocks, deleteCompletedItems); // Not bothering to test if anything changed. Always updating. This happens only once a // day. mMainActivityState.model().clearAllUndo(); mMainActivityState.view().updatePages(); } mMainActivityState.model().setLastPushDateStamp( mMainActivityState.dateTracker().getDateStampString()); } /** Show the popup menu for a given item. Item is assumed to be already visible. */ private final void showItemMenu(PageKind pageKind, int itemIndex) { final ItemModelReadOnly item = mMainActivityState.model().getItemReadOnly(pageKind, itemIndex); // Done vs ToDo based on item isCompleted status. final ItemMenuEntry doneAction = item.isCompleted() ? mItemMenuCache.getToDoAction() : mItemMenuCache.getDoneAction(); // Edit. final ItemMenuEntry editAction = mItemMenuCache.getEditAction(); // Set or modify date and time. final ItemMenuEntry scheduleAction = mItemMenuCache.getScheduleAction(); // Lock vs Unlock based on item isLocked status. final ItemMenuEntry lockAction = item.isLocked() ? mItemMenuCache.getUnlockAction() : mItemMenuCache.getLockAction(); // Delete. final ItemMenuEntry deleteAction = mItemMenuCache.getDeleteAction(); // Action list final ItemMenuEntry actions[] = { doneAction, editAction, scheduleAction, lockAction, deleteAction }; mMainActivityState.view().setItemViewHighlight(pageKind, itemIndex, true); mMainActivityState.view().showItemMenu(pageKind, itemIndex, actions, ItemMenuCache.DISMISS_WITH_NO_SELECTION_ID); } /** Called when the user made a selection from an item popup menu. */ public void onItemMenuSelection(final PageKind pageKind, final int itemIndex, int actionId) { // In case of dismissal with no selection we don't clear the undo buffer. if (actionId != ItemMenuCache.DISMISS_WITH_NO_SELECTION_ID) { clearPageUndo(pageKind); } // Clear the item-highlighted state when the menu was shown. mMainActivityState.view().setItemViewHighlight(pageKind, itemIndex, false); // Handle the action switch (actionId) { case ItemMenuCache.DISMISS_WITH_NO_SELECTION_ID: { return; } case ItemMenuCache.DONE_ACTION_ID: { mMainActivityState.services().maybePlayApplauseSoundClip(AudioManager.FX_KEY_CLICK, false); final ItemModel item = mMainActivityState.model().getItemForMutation(pageKind, itemIndex); item.setIsCompleted(true); // NOTE(tal): we assume that the color flag is not needed once an item is completed. // This is a usability heuristic. Not required otherwise. // // NOTE(tal): NONE may or may not be in the user selected task color set. It does not // matter, we set to NONE regardless. item.setColor(ItemColor.NONE); mMainActivityState.view().updatePage(pageKind); maybeAutosortPageWithItemOfInterest(pageKind, itemIndex); return; } case ItemMenuCache.TODO_ACTION_ID: { mMainActivityState.services().maybePlayStockSound(AudioManager.FX_KEY_CLICK, false); final ItemModel item = mMainActivityState.model().getItemForMutation(pageKind, itemIndex); item.setIsCompleted(false); // mApp.view().updateSingleItemView(pageKind, itemIndex); mMainActivityState.view().updatePage(pageKind); maybeAutosortPageWithItemOfInterest(pageKind, itemIndex); return; } // Edit case ItemMenuCache.EDIT_ACTION_ID: { mMainActivityState.services().maybePlayStockSound(AudioManager.FX_KEY_CLICK, false); final ItemModel item = mMainActivityState.model().getItemForMutation(pageKind, itemIndex); ItemTextEditor.startEditor(mMainActivityState, mMainActivityState.str(R.string.editor_title_Edit_Task), item.getText(), item.getColor(), new ItemTextEditor.ItemEditorListener() { @Override public void onDismiss(String finalString, ItemColor finalColor) { // NOTE: at this point, finalString is also cleaned of leading or // trailing if (finalString.length() == 0) { startItemDeletionWithAnination(pageKind, itemIndex); if (mMainActivityState.prefTracker() .getVerboseMessagesEnabledPreference()) { mMainActivityState.services().toast( R.string.editor_Empty_task_deleted); } } else { item.setText(finalString); item.setColor(finalColor); mMainActivityState.model().setDirty(); mMainActivityState.view().updatePage(pageKind); // Highlight the modified item for a short time, to provide // the user with an indication of the modified item. briefItemHighlight(pageKind, itemIndex, 700); } } }); return; } case ItemMenuCache.SCHEDULE_ACTION_ID: { mMainActivityState.services().maybePlayStockSound(AudioManager.FX_KEY_CLICK, false); final ItemModel item = mMainActivityState.model().getItemForMutation(pageKind, itemIndex); ItemTimePicker.startEditor(mMainActivityState, new Date(), new ItemTimePickerListener() { @Override public void onDismiss(Date finalDate) { item.setScheduledTime(finalDate.getTime()); // FIXME: reschedule the alarm to earliest Tomorrow item's scheduled time MidnightTicker.scheduleMidnightTicker(mMainActivityState.context()); mMainActivityState.model().setDirty(); mMainActivityState.view().updatePage(pageKind); // Highlight the modified item for a short time, to provide // the user with an indication of the modified item. briefItemHighlight(pageKind, itemIndex, 700); } }); return; } case ItemMenuCache.LOCK_ACTION_ID: case ItemMenuCache.UNLOCK_ACTION_ID: { mMainActivityState.services().maybePlayStockSound(AudioManager.FX_KEY_CLICK, false); final ItemModel item = mMainActivityState.model().getItemForMutation(pageKind, itemIndex); item.setIsLocked(actionId == ItemMenuCache.LOCK_ACTION_ID); // mApp.view().updateSingleItemView(pageKind, itemIndex); mMainActivityState.view().updatePage(pageKind); // If lock and in Today page, we also move it to the Tomorrow page, with an // animation. if (pageKind == PageKind.TODAY && actionId == ItemMenuCache.LOCK_ACTION_ID) { // We do a short delay before the animation to let the use see the icon change // to lock before the item is moved to the other page. mMainActivityState.view().startItemAnimation(pageKind, itemIndex, AppView.ItemAnimationType.MOVING_ITEM_TO_OTHER_PAGE, 200, new Runnable() { @Override public void run() { moveItemToOtherPage(pageKind, itemIndex); } }); } else { maybeAutosortPageWithItemOfInterest(pageKind, itemIndex); } return; } case ItemMenuCache.DELETE_ACTION_ID: { mMainActivityState.services().maybePlayStockSound(AudioManager.FX_KEYPRESS_DELETE, false); startItemDeletionWithAnination(pageKind, itemIndex); return; } } throw new RuntimeException("Unknown menu action: " + actionId); } /** * Start item deletion from the current page. The item is deleted after a short animation and * the page view is then updated. */ private final void startItemDeletionWithAnination(final PageKind pageKind, final int itemIndex) { mMainActivityState.view().startItemAnimation(pageKind, itemIndex, AppView.ItemAnimationType.DELETING_ITEM, 0, new Runnable() { @Override public void run() { // This runs at the end of the animation. mMainActivityState.model().removeItemWithUndo(pageKind, itemIndex); mMainActivityState.view().updatePage(pageKind); } }); } /** Highlight the given item for a brief time. The item is assumed to already be visible. */ private final void briefItemHighlight(final PageKind pageKind, final int itemIndex, int millis) { mMainActivityState.view().setItemViewHighlight(pageKind, itemIndex, true); mMainActivityState.view().getRootView().postDelayed(new Runnable() { @Override public void run() { mMainActivityState.view().setItemViewHighlight(pageKind, itemIndex, false); } }, millis); } /** Called by the app view when the user clicks on the Undo button. */ public final void onUndoButton(PageKind pageKind) { mMainActivityState.services().maybePlayStockSound(AudioManager.FX_KEYPRESS_RETURN, false); final int itemRestored = mMainActivityState.model().applyUndo(pageKind); maybeAutoSortPage(pageKind, false, false); mMainActivityState.view().updatePage(pageKind); if (itemRestored == 1) { mMainActivityState.services().toast(R.string.undo_Restored_one_deleted_task); } else { mMainActivityState.services().toast(R.string.undo_Restored_d_deleted_tasks, itemRestored); } } /** Called by the app view when the user clicks on the Add Item button. */ public final void onAddItemByTextButton(final PageKind pageKind) { clearPageUndo(pageKind); mMainActivityState.services().maybePlayStockSound(AudioManager.FX_KEY_CLICK, false); final ItemColor initialColor = mMainActivityState.prefTracker().getItemColorsPreference() .getDefaultColor(); ItemTextEditor.startEditor(mMainActivityState, mMainActivityState.str(R.string.editor_title_New_Task), "", initialColor, new ItemTextEditor.ItemEditorListener() { @Override public void onDismiss(String finalString, ItemColor finalColor) { maybeAddNewItem(finalString, finalColor, pageKind, true); } }); } public final void onAddItemByVoiceButton(final PageKind pageKind) { mMainActivityState.services().maybePlayStockSound(AudioManager.FX_KEY_CLICK, false); mInSubActivity = true; ItemVoiceEditor.startVoiceEditor(mMainActivityState.mainActivity(), VOICE_RECOGNITION_REQUEST_CODE); } /** Add a new task from text editor or voice recognition. */ private final void maybeAddNewItem(final String text, ItemColor color, final PageKind pageKind, boolean upperCaseIt) { String cleanedValue = text.trim(); if (cleanedValue.length() == 0) { return; } // Look for special string to enable debug mode. if (cleanedValue.equals(DEBUG_MODE_TASK_CODE)) { mMainActivityState.debugController().setDebugMode(true); // Do not add the item. return; } if (upperCaseIt) { cleanedValue = cleanedValue.substring(0, 1).toUpperCase() + cleanedValue.substring(1); } ItemModel item = new ItemModel(System.currentTimeMillis(), IdGenerator.getFreshId(), cleanedValue, false, false, 0, color); final int insertionIndex = newItemInsertionIndex(pageKind); mMainActivityState.model().insertItem(pageKind, insertionIndex, item); mMainActivityState.view().updatePage(pageKind); mMainActivityState.view().scrollToItem(pageKind, insertionIndex); // We perform the highlight only after the view has been // stabilized from the scroll (since item views are reused during the scroll). mMainActivityState.view().getRootView().post(new Runnable() { @Override public void run() { briefItemHighlight(pageKind, insertionIndex, 700); } }); } // Return the insertion index of a new item. The new item is assumed to be // non locked and non completed. The returned index complies with add-to-top // and auto-sort preference settings. private final int newItemInsertionIndex(PageKind pageKind) { // If adding at top, always adding at index 0. if (mMainActivityState.prefTracker().getAddToTopPreference()) { return 0; } // If adding at bottom and no sorting then adding at n. final int n = mMainActivityState.model().getPageItemCount(pageKind); if (!mMainActivityState.prefTracker().getAutoSortPreference()) { return n; } // Here when adding at bottom and using auto sort. Scan from the end and skip // all completed and locked items. int i; for (i = n - 1; i >= 0; i--) { final ItemModelReadOnly item = mMainActivityState.model().getItemReadOnly(pageKind, i); if (!item.isCompleted() && !item.isLocked()) { break; } } return i + 1; } /** Handle the case where the app is responding to a restore from file action. */ private final void onRestoreBackupFromFileClick(Intent resumeIntent) { // NOTE: main activity already qualified this to have the expected content type. final AppModel newModel; try { final Uri uri = resumeIntent.getData(); final InputStream in = mMainActivityState.context().getContentResolver() .openInputStream(uri); FileReadResult readResult = FileUtil.readFileToString(in, uri.toString()); if (!readResult.outcome.isOk()) { mMainActivityState.services().toast( R.string.backup_restore_Failed_to_read_backup_file); return; } // TODO: test that the file size is reasonable // TODO: test that the file looks like maniana file PersistenceMetadata resultMetadata = new PersistenceMetadata(); newModel = new AppModel(); ModelDeserialization.deserializeModel(newModel, resultMetadata, readResult.content); } catch (Throwable e) { LogUtil.error(e, "Error while trying to restore data"); mMainActivityState.services().toast( R.string.backup_restore_Error_loading_the_backup_file); return; } final RestoreBackupDialogListener listener = new RestoreBackupDialogListener() { @Override public void onSelection(Action action) { onRestoreBackupFromFileConfirm(action, newModel); } }; RestoreBackupDialog.startDialog(mMainActivityState, listener, mMainActivityState.model() .projectedImportStats(newModel)); } private final void onRestoreBackupFromFileConfirm(Action action, AppModel newModel) { switch (action) { case REPLACE: mMainActivityState.model().restoreBackup(newModel); mMainActivityState.services().toast(R.string.backup_restore_Task_list_replaced); break; case MERGE: mMainActivityState.model().mergeFrom(newModel); mMainActivityState.services().toast(R.string.backup_restore_Task_list_merged); break; case CANCEL: default: mMainActivityState.services().toast(R.string.backup_restore_Task_list_not_changed); // Do nothing return; } maybeAutoSortPages(false, false); mMainActivityState.view().updatePages(); } public final void onActivityResult(int requestCode, int resultCode, Intent intent) { switch (requestCode) { case VOICE_RECOGNITION_REQUEST_CODE: { onVoiceActivityResult(resultCode, intent); break; } default: LogUtil.warning("Unknown onActivityResult requestCode: %s", requestCode); } } /** Handle the result of add-new-item by voice recongition. */ private final void onVoiceActivityResult(int resultCode, Intent intent) { // Prevents the main activity from scrolling to top of pages as we do when resuming from // an external activity. mInSubActivity = true; if (resultCode != Activity.RESULT_OK) { return; } ItemVoiceEditor.startSelectionDialog(mMainActivityState.context(), intent, new OnItemClickListener() { @Override public void onItemClick(AdapterView<?> arg0, View arg1, int arg2, long arg3) { final TextView itemTextView = (TextView) arg1; final ItemColor initialColor = mMainActivityState.prefTracker() .getItemColorsPreference().getDefaultColor(); maybeAddNewItem(itemTextView.getText().toString(), initialColor, mMainActivityState.view().getCurrentPageKind(), true); } }); } /** Called by the app view when the user click or long press the clean page button. */ public final void onCleanPageButton(final PageKind pageKind, boolean isLongPress) { final boolean deleteCompletedItems = isLongPress; // NOTE: reusing mTempSummary. mMainActivityState.model().organizePageWithUndo(pageKind, deleteCompletedItems, -1, mTempSummary); mMainActivityState.services().maybePlayStockSound( (mTempSummary.completedItemsDeleted > 0) ? AudioManager.FX_KEYPRESS_DELETE : AudioManager.FX_KEY_CLICK, false); mMainActivityState.view().updatePage(pageKind); // Display optional message to the user @Nullable final String message = constructPageCleanMessage(mTempSummary); if (message != null) { mMainActivityState.services().toast(message); } } /** Called from shake detector. */ public final void onShake() { // If we have open dialogs ignore this shake event. if (mMainActivityState.popupsTracker().count() > 0) { LogUtil.info("Shake ignored (dialog opened)"); return; } // Handle the shake event final PageKind currentPage = mMainActivityState.view().getCurrentPageKind(); final ShakerAction action = mMainActivityState.prefTracker().reader() .getShakerActionPreference(); switch (action) { case NEW_ITEM_BY_TEXT: onAddItemByTextButton(currentPage); break; case NEW_ITEM_BY_VOICE: onAddItemByVoiceButton(currentPage); break; case CLEAN: onCleanPageButton(currentPage, true); break; case QUIT: // if (!mApp.pref().getVerboseMessagesEnabledPreference()) { // mApp.services().toast("Shake action: quit"); // } mMainActivityState.mainActivity().finish(); break; default: mMainActivityState.services().toast("Unknown action: " + action); } } /** * Compose the message to show to the user after a page cleanup operation. * * @param summary the page cleanup summary. * @return the message or null if no message should me shown. */ @Nullable private final String constructPageCleanMessage(OrganizePageSummary summary) { if (!mMainActivityState.prefTracker().getVerboseMessagesEnabledPreference()) { return null; } // Deleted at least one completed tasks if (summary.completedItemsDeleted > 0) { if (summary.completedItemsDeleted == 1) { return mMainActivityState.str(R.string.organize_outcome_Deleted_one_completed_task); } return mMainActivityState.str(R.string.organize_outcome_Deleted_d_completed_tasks, summary.completedItemsDeleted); } // Here when completed tasks not deleted if (summary.orderChanged) { // Here when not deleted but reordered if (summary.completedItemsFound == 0) { return mMainActivityState.str(R.string.organize_outcome_Tasks_reordered); } if (summary.completedItemsFound == 1) { return mMainActivityState .str(R.string.organize_outcome_Tasks_reordered_Long_press_to_delete_one_completed_task); } return mMainActivityState .str(R.string.organize_outcome_Tasks_reordered_Long_press_to_delete_d_completed_tasks, summary.completedItemsFound); } // Here when not deleted and not reordred. if (summary.completedItemsFound > 0) { // Here if found completed items. if (summary.completedItemsFound == 1) { return mMainActivityState .str(R.string.organize_outcome_Page_already_organized_Long_press_to_delete_one_completed_task); } return mMainActivityState .str(R.string.organize_outcome_Page_already_organized_Long_press_to_delete_d_completed_tasks, summary.completedItemsFound); } return mMainActivityState.str(R.string.organize_outcome_Page_already_organized); } /** Called to show the main menu. */ public final boolean onMenuButton() { mMainActivityState.view().showMainMenu(); return true; } /** Called to launch calendar */ public final void onCalendarLaunchClick() { if (!mMainActivityState.prefReader().getCalendarLaunchPreference()) { return; } mMainActivityState.services().maybePlayStockSound(AudioManager.FX_KEY_CLICK, false); // See if we can find a calendar intent that has a matching reciever. @Nullable final Intent calendarIntent = CalendarUtil .maybeConstructGoogleCalendarIntent(mMainActivityState.context()); if (calendarIntent == null) { mMainActivityState.services().toast("Google Calender not found."); return; } // Intent found, try launching it. // // TODO: should we use startSubActivity() here? if (!mMainActivityState.services().startActivity(calendarIntent)) { mMainActivityState.services().toast("Failed launching Google calendar.", android.os.Build.VERSION.SDK_INT); } } /** Called by the framework when the user makes a main menu selection. */ public final void onMainMenuSelection(MainMenuEntry entry) { switch (entry) { case HELP: final Intent helpIntent = HelpUtil.helpPageIntent(mMainActivityState.context(), false); mMainActivityState.services().startActivity(helpIntent); break; case SETTINGS: startSubActivity(SettingsActivity.class); break; case ABOUT: startPopupMessageSubActivity(MessageKind.ABOUT); break; case DEBUG: mMainActivityState.debugController().startMainDialog(); break; default: throw new RuntimeException("Unknown main menu action id: " + entry); } } /** Handle back button event or return false if not used. */ public final boolean onBackButton() { // If the current page is not today, we still the back key event and switch back to the // today page. Otherwise we use the default back behavior. final PageKind currentPage = mMainActivityState.view().getCurrentPageKind(); if (currentPage != PageKind.TODAY) { mMainActivityState.view().setCurrentPage(PageKind.TODAY, -1); return true; } return false; } /** * Called by the app preferences client when app preferences changed. * * @param id the id of the preference item that was changed. */ public final void onPreferenceChange(PreferenceKind id) { onBackupDataChange(); switch (id) { case PAGE_ICON_SET: mMainActivityState.view().onPageIconSetPreferenceChange(); break; case PAGE_ITEM_FONT: case PAGE_ITEM_FONT_SIZE: case PAGE_ITEM_ACTIVE_TEXT_COLOR: case PAGE_ITEM_COMPLETED_TEXT_COLOR: mMainActivityState.view().onPageItemFontVariationPreferenceChange(); break; case PAGE_BACKGROUND_PAPER: case PAGE_PAPER_COLOR: case PAGE_BACKGROUND_SOLID_COLOR: mMainActivityState.view().onPageBackgroundPreferenceChange(); break; case PAGE_TITLE_FONT: case PAGE_TITLE_FONT_SIZE: case PAGE_TITLE_TODAY_COLOR: case PAGE_TITLE_TOMORROW_COLOR: mMainActivityState.view().onPageTitlePreferenceChange(); break; case PAGE_ITEM_DIVIDER_COLOR: mMainActivityState.view().onItemDividerColorPreferenceChange(); break; case AUTO_SORT: maybeAutoSortPages(true, true); // If auto sort got enabled, this may affect the list widgets and thus // we force widget updated. In the other direction it's not required. flushModelChanges(mMainActivityState.prefTracker().getAutoSortPreference()); break; case ADD_TO_TOP: case ITEM_COLORS: case SOUND_ENABLED: case APPLAUSE_LEVEL: case DAILY_NOTIFICATION: case NOTIFICATION_LED: case LOCK_PERIOD: case VERBOSE_MESSAGES: case STARTUP_ANIMATION: // Nothing to do here. We query these preferences on the fly. break; case AUTO_DAILY_CLEANUP: // This setting may affect the widget on next update but by itself, its // change event does require widget update (?). break; case SHAKER_ENABLED: case SHAKER_ACTION: case SHAKER_SENSITIVITY: // Nothing to do here. The controller will update the shaker next time // it will be resumed. Since setting is done in a separate activity, // the controller is paused when setting is changed. break; // NOTE: calendar launch change triggers widget update in case the widget // date display is enabled. case CALENDAR_LAUNCH: case WIDGET_BACKGROUND_PAPER: case WIDGET_PAPER_COLOR: case WIDGET_BACKGROUND_COLOR: case WIDGET_ITEM_FONT: case WIDGET_ITEM_TEXT_COLOR: case WIDGET_ITEM_FONT_SIZE: case WIDGET_AUTO_FIT: case WIDGET_SHOW_COMPLETED_ITEMS: case WIDGET_ITEM_COMPLETED_TEXT_COLOR: case WIDGET_SHOW_TOOLBAR: case WIDGET_SHOW_DATE: case WIDGET_SINGLE_LINE: // NOTE: This covers the case where the user changes widget settings and presses the // Home button immediately, going back to the widgets. The widget update at // onAppPause() is not triggered in this case because the main activity is already // paused. flushModelChanges(true); break; case DEBUG_MODE: // Nothing to do here. break; default: throw new RuntimeException("Unknown preference: " + id); } } /** Inform backup manager about change in persisted model data of app settings */ private final void onBackupDataChange() { LogUtil.info("Backup data changed"); mMainActivityState.services().backupManager().dataChanged(); } /** Force a widget update with the current */ private final void updateAllWidgets() { BaseWidgetProvider.updateAllWidgetsFromModel(mMainActivityState.context(), mMainActivityState.model(), mMainActivityState.dateTracker().sometimeToday()); } /** Called by the main activity when it is created. */ public final void onMainActivityCreated(MainActivityStartupKind startupKind) { // NOTE: at this point the model has not been processed yet for potential // task move/cleanup due to date change. This is done later in the // onMainActivityResume() event. mPopulateNewUserSampleDataOnResume = false; switch (startupKind) { case NORMAL: case NEW_VERSION_SILENT: // Model is assume to be clean here. break; case NEW_USER: // At this point we don't want to perist the model, we will do it // in the resume method after it will be populated with sample data. mMainActivityState.model().setClean(); mPopulateNewUserSampleDataOnResume = true; break; case NEW_VERSION_ANNOUNCE: // Mark the model for writing to avoid this what's up splash the next time. mMainActivityState.model().setDirty(); startPopupMessageSubActivity(MessageKind.WHATS_NEW); break; case MODEL_DATA_ERROR: mMainActivityState.model().clear(); mMainActivityState.services().toast( mMainActivityState.str(R.string.launch_error_Error_loading_task_list) + " (" + startupKind.toString().toLowerCase() + ")"); default: LogUtil.error("Unknown startup message type: ", startupKind); } } private final void startPopupMessageSubActivity(MessageKind messageKind) { startSubActivity(PopupMessageActivity.intentFor(mMainActivityState.context(), messageKind)); } private final void startSubActivity(Class<? extends Activity> cls) { final Intent intent = new Intent(mMainActivityState.context(), cls); startSubActivity(intent); } private final void startSubActivity(Intent intent) { // TODO: should we assert or print an error message that mInSubActivity is false here? mInSubActivity = true; mMainActivityState.services().startActivity(intent); } /** Called by the main activity when it is destroyed. */ public final void onMainActivityDestroy() { } /** Clear undo buffer of given model page. */ private final void clearPageUndo(PageKind pageKind) { mMainActivityState.model().clearPageUndo(pageKind); mMainActivityState.view().updateUndoButton(pageKind); } private final boolean maybeAutoSortPage(PageKind pageKind, boolean updateViewIfSorted, boolean showMessageIfSorted) { if (mMainActivityState.prefTracker().getAutoSortPreference()) { // NOTE: reusing temp summary mmeber. mMainActivityState.model().organizePageWithUndo(pageKind, false, -1, mTempSummary); if (mTempSummary.orderChanged) { if (updateViewIfSorted) { mMainActivityState.view().updatePage(pageKind); } if (showMessageIfSorted && mMainActivityState.prefTracker().getVerboseMessagesEnabledPreference()) { mMainActivityState.services().toast(R.string.Auto_sorted); } return true; } } return false; } private final boolean maybeAutoSortPages(boolean updateViewIfSorted, boolean showMessageIfSorted) { // NOTE: avoiding '||' operator short circuit to make sure both pages are sorted. final boolean sorted1 = maybeAutoSortPage(PageKind.TODAY, updateViewIfSorted, false); final boolean sorted2 = maybeAutoSortPage(PageKind.TOMOROW, updateViewIfSorted, false); final boolean sorted = sorted1 || sorted2; // NOTE: suppressing message if showing a sub activity (e.g. SettingActivity). if (sorted && showMessageIfSorted && mMainActivityState.prefTracker().getVerboseMessagesEnabledPreference() && !mInSubActivity) { mMainActivityState.services().toast(R.string.Auto_sorted); } return sorted; } /** * @param pageKind the page * @param itemOfInteresttOriginalIndex if >= 0, the pre sort index of the item to highlight post * sort. */ private final void maybeAutosortPageWithItemOfInterest(final PageKind pageKind, final int itemOfInteresttOriginalIndex) { if (!mMainActivityState.prefTracker().getAutoSortPreference() || mMainActivityState.model().isPageSorted(pageKind)) { return; } mMainActivityState.view().startItemAnimation(pageKind, itemOfInteresttOriginalIndex, ItemAnimationType.SORTING_ITEM, 0, new Runnable() { @Override public void run() { // NOTE: reusing temp summary member mMainActivityState.model().organizePageWithUndo(pageKind, false, itemOfInteresttOriginalIndex, mTempSummary); mMainActivityState.view().updatePage(pageKind); if (mMainActivityState.prefTracker().getVerboseMessagesEnabledPreference()) { mMainActivityState.services().toast(R.string.Auto_sorted); } if (mTempSummary.itemOfInterestNewIndex >= 0) { // After the animation, briefly highlight the item at the new // location. mMainActivityState.view().getRootView().post(new Runnable() { @Override public void run() { briefItemHighlight(pageKind, mTempSummary.itemOfInterestNewIndex, 300); } }); } } }); } }