package com.automattic.simplenote; import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.database.sqlite.SQLiteException; import android.os.AsyncTask; import android.os.Bundle; import android.os.Handler; import android.support.annotation.NonNull; import android.support.design.widget.FloatingActionButton; import android.support.v4.app.ListFragment; import android.text.SpannableString; import android.text.style.TextAppearanceSpan; import android.util.SparseBooleanArray; import android.util.TypedValue; import android.view.ActionMode; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.AbsListView; import android.widget.AdapterView; import android.widget.CursorAdapter; import android.widget.LinearLayout; import android.widget.ListView; import android.widget.TextView; import android.widget.ToggleButton; import com.automattic.simplenote.analytics.AnalyticsTracker; import com.automattic.simplenote.models.Note; import com.automattic.simplenote.utils.DisplayUtils; import com.automattic.simplenote.utils.DrawableUtils; import com.automattic.simplenote.utils.HtmlCompat; import com.automattic.simplenote.utils.NoteUtils; import com.automattic.simplenote.utils.PrefUtils; import com.automattic.simplenote.utils.SearchSnippetFormatter; import com.automattic.simplenote.utils.SearchTokenizer; import com.automattic.simplenote.utils.StrUtils; import com.automattic.simplenote.utils.TextHighlighter; import com.simperium.client.Bucket; import com.simperium.client.Bucket.ObjectCursor; import com.simperium.client.Query; import com.simperium.client.Query.SortType; import java.util.ArrayList; import java.util.Calendar; import java.util.List; /** * A list fragment representing a list of Notes. This fragment also supports * tablet devices by allowing list items to be given an 'activated' state upon * selection. This helps indicate which item is currently being viewed in a * {@link NoteEditorFragment}. * <p> * Activities containing this fragment MUST implement the {@link Callbacks} * interface. */ public class NoteListFragment extends ListFragment implements AdapterView.OnItemLongClickListener, AbsListView.MultiChoiceModeListener { /** * The preferences key representing the activated item position. Only used on tablets. */ private static final String STATE_ACTIVATED_POSITION = "activated_position"; /** * A dummy implementation of the {@link Callbacks} interface that does * nothing. Used only when this fragment is not attached to an activity. */ private static Callbacks sCallbacks = new Callbacks() { @Override public void onNoteSelected(String noteID, int position, boolean isNew, String matchOffsets, boolean isMarkdownEnabled) { } }; protected NotesCursorAdapter mNotesAdapter; protected String mSearchString; private ActionMode mActionMode; private View mRootView; private TextView mEmptyListTextView; private LinearLayout mDividerShadow; private FloatingActionButton mFloatingActionButton; private int mNumPreviewLines; private String mSelectedNoteId; private refreshListTask mRefreshListTask; private int mTitleFontSize; private int mPreviewFontSize; /** * The fragment's current callback object, which is notified of list item * clicks. */ private Callbacks mCallbacks = sCallbacks; /** * The current activated item position. Only used on tablets. */ private int mActivatedPosition = ListView.INVALID_POSITION; /** * Mandatory empty constructor for the fragment manager to instantiate the * fragment (e.g. upon screen orientation changes). */ public NoteListFragment() { } public void setEmptyListViewClickable(boolean isClickable) { if (mEmptyListTextView != null) { mEmptyListTextView.setClickable(isClickable); } } @Override public boolean onItemLongClick(AdapterView<?> adapterView, View view, int position, long l) { getListView().setChoiceMode(ListView.CHOICE_MODE_MULTIPLE_MODAL); getListView().setItemChecked(position, true); if (mActionMode == null) getActivity().startActionMode(this); return true; } @Override public boolean onCreateActionMode(ActionMode actionMode, Menu menu) { MenuInflater inflater = actionMode.getMenuInflater(); inflater.inflate(R.menu.bulk_edit, menu); DrawableUtils.tintMenuWithAttribute(getActivity(), menu, R.attr.actionModeTextColor); mActionMode = actionMode; return true; } @Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { return false; } @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { if (getListView().getCheckedItemIds().length > 0) { switch (item.getItemId()) { case R.id.menu_delete: new trashNotesTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); break; case R.id.menu_pin: new pinNotesTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); break; } } return false; } @Override public void onDestroyActionMode(ActionMode mode) { mActionMode = null; new Handler().post(new Runnable() { @Override public void run() { if (getActivity() != null) { NotesActivity notesActivity = (NotesActivity) getActivity(); setActivateOnItemClick(DisplayUtils.isLargeScreenLandscape(notesActivity)); notesActivity.showDetailPlaceholder(); } } }); } @Override public void onItemCheckedStateChanged(ActionMode actionMode, int position, long id, boolean checked) { int checkedCount = getListView().getCheckedItemCount(); if (checkedCount == 0) actionMode.setTitle(""); else actionMode.setTitle(getResources().getQuantityString(R.plurals.selected_notes, checkedCount, checkedCount)); } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mNotesAdapter = new NotesCursorAdapter(getActivity().getBaseContext(), null, 0); setListAdapter(mNotesAdapter); } // nbradbury - load values from preferences protected void getPrefs() { boolean condensedList = PrefUtils.getBoolPref(getActivity(), PrefUtils.PREF_CONDENSED_LIST, false); mNumPreviewLines = (condensedList) ? 0 : 2; mPreviewFontSize = PrefUtils.getIntPref(getActivity(), PrefUtils.PREF_FONT_SIZE, 14); mTitleFontSize = mPreviewFontSize + 2; } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_notes_list, container, false); } @Override public void onViewCreated(View view, Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); NotesActivity notesActivity = (NotesActivity) getActivity(); mRootView = view.findViewById(R.id.list_root); LinearLayout emptyView = (LinearLayout) view.findViewById(android.R.id.empty); emptyView.setVisibility(View.GONE); mEmptyListTextView = (TextView) view.findViewById(R.id.empty_message); mEmptyListTextView.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { addNote(); } }); setEmptyListMessage("<strong>" + getString(R.string.no_notes_here) + "</strong><br />" + String.format(getString(R.string.why_not_create_one), "<u>", "</u>")); mDividerShadow = (LinearLayout) view.findViewById(R.id.divider_shadow); if (DisplayUtils.isLargeScreenLandscape(notesActivity)) { setActivateOnItemClick(true); mDividerShadow.setVisibility(View.VISIBLE); } mFloatingActionButton = (FloatingActionButton) view.findViewById(R.id.fab_button); mFloatingActionButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { if (!isAdded()) return; addNote(); AnalyticsTracker.track( AnalyticsTracker.Stat.LIST_NOTE_CREATED, AnalyticsTracker.CATEGORY_NOTE, "action_bar_button" ); } }); getListView().setOnItemLongClickListener(this); getListView().setMultiChoiceModeListener(this); } @Override public void onAttach(Context activity) { super.onAttach(activity); // Activities containing this fragment must implement its callbacks. if (!(activity instanceof Callbacks)) { throw new IllegalStateException("Activity must implement fragment's callbacks."); } mCallbacks = (Callbacks) activity; } @Override public void onResume() { super.onResume(); getPrefs(); refreshList(); } @Override public void onDetach() { super.onDetach(); // Reset the active callbacks interface to the dummy implementation. mCallbacks = sCallbacks; } @Override public void onDestroy() { super.onDestroy(); } public void setEmptyListMessage(String message) { if (mEmptyListTextView != null && message != null) mEmptyListTextView.setText(HtmlCompat.fromHtml(message)); } @Override public void onListItemClick(ListView listView, View view, int position, long id) { if (!isAdded()) return; super.onListItemClick(listView, view, position, id); NoteViewHolder holder = (NoteViewHolder) view.getTag(); String noteID = holder.getNoteId(); if (noteID != null) { mCallbacks.onNoteSelected(noteID, position, false, holder.matchOffsets, mNotesAdapter.getItem(position).isMarkdownEnabled()); } mActivatedPosition = position; } /** * Selects first row in the list if available */ public void selectFirstNote() { if (mNotesAdapter.getCount() > 0) { Note selectedNote = mNotesAdapter.getItem(0); mCallbacks.onNoteSelected(selectedNote.getSimperiumKey(), 0, false, null, selectedNote.isMarkdownEnabled()); } } @Override public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); if (mActivatedPosition != ListView.INVALID_POSITION) { // Serialize and persist the activated item position. outState.putInt(STATE_ACTIVATED_POSITION, mActivatedPosition); } } public View getRootView() { return mRootView; } /** * Turns on activate-on-click mode. When this mode is on, list items will be * given the 'activated' state when touched. */ public void setActivateOnItemClick(boolean activateOnItemClick) { // When setting CHOICE_MODE_SINGLE, ListView will automatically // give items the 'activated' state when touched. getListView().setChoiceMode(activateOnItemClick ? ListView.CHOICE_MODE_SINGLE : ListView.CHOICE_MODE_NONE); } public void setActivatedPosition(int position) { if (getListView() != null) { if (position == ListView.INVALID_POSITION) { getListView().setItemChecked(mActivatedPosition, false); } else { getListView().setItemChecked(position, true); } mActivatedPosition = position; } } public void setDividerVisible(boolean visible) { if (visible) mDividerShadow.setVisibility(View.VISIBLE); else mDividerShadow.setVisibility(View.GONE); } public void setFloatingActionButtonVisible(boolean visible) { if (mFloatingActionButton == null) return; if (visible) { mFloatingActionButton.show(); } else { mFloatingActionButton.hide(); } } public void refreshList() { refreshList(false); } public void refreshList(boolean fromNav) { if (mRefreshListTask != null && mRefreshListTask.getStatus() != AsyncTask.Status.FINISHED) mRefreshListTask.cancel(true); mRefreshListTask = new refreshListTask(); mRefreshListTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, fromNav); } public void refreshListFromNavSelect() { refreshList(true); } public ObjectCursor<Note> queryNotes() { if (!isAdded()) return null; NotesActivity notesActivity = (NotesActivity) getActivity(); Query<Note> query = notesActivity.getSelectedTag().query(); if (hasSearchQuery()) { query.where(new Query.FullTextMatch(new SearchTokenizer(mSearchString))); query.include(new Query.FullTextOffsets("match_offsets")); query.include(new Query.FullTextSnippet(Note.MATCHED_TITLE_INDEX_NAME, Note.TITLE_INDEX_NAME)); query.include(new Query.FullTextSnippet(Note.MATCHED_CONTENT_INDEX_NAME, Note.CONTENT_PROPERTY)); query.include(Note.TITLE_INDEX_NAME, Note.CONTENT_PREVIEW_INDEX_NAME); } else { query.include(Note.TITLE_INDEX_NAME, Note.CONTENT_PREVIEW_INDEX_NAME); } query.include(Note.PINNED_INDEX_NAME); sortNoteQuery(query); return query.execute(); } public void addNote() { // Prevents jarring 'New note...' from showing in the list view when creating a new note NotesActivity notesActivity = (NotesActivity) getActivity(); if (!DisplayUtils.isLargeScreenLandscape(notesActivity)) notesActivity.stopListeningToNotesBucket(); // Create & save new note Simplenote simplenote = (Simplenote) getActivity().getApplication(); Bucket<Note> notesBucket = simplenote.getNotesBucket(); Note note = notesBucket.newObject(); note.setCreationDate(Calendar.getInstance()); note.setModificationDate(note.getCreationDate()); note.setMarkdownEnabled(PrefUtils.getBoolPref(getActivity(), PrefUtils.PREF_MARKDOWN_ENABLED, false)); if (notesActivity.getSelectedTag() != null && notesActivity.getSelectedTag().name != null) { String tagName = notesActivity.getSelectedTag().name; if (!tagName.equals(getString(R.string.notes)) && !tagName.equals(getString(R.string.trash))) note.setTagString(tagName); } note.save(); if (DisplayUtils.isLargeScreenLandscape(getActivity())) { mCallbacks.onNoteSelected(note.getSimperiumKey(), 0, true, null, note.isMarkdownEnabled()); } else { Bundle arguments = new Bundle(); arguments.putString(NoteEditorFragment.ARG_ITEM_ID, note.getSimperiumKey()); arguments.putBoolean(NoteEditorFragment.ARG_NEW_NOTE, true); arguments.putBoolean(NoteEditorFragment.ARG_MARKDOWN_ENABLED, note.isMarkdownEnabled()); Intent editNoteIntent = new Intent(getActivity(), NoteEditorActivity.class); editNoteIntent.putExtras(arguments); getActivity().startActivityForResult(editNoteIntent, Simplenote.INTENT_EDIT_NOTE); } } public void setNoteSelected(String selectedNoteID) { // Loop through notes and set note selected if found //noinspection unchecked ObjectCursor<Note> cursor = (ObjectCursor<Note>) mNotesAdapter.getCursor(); if (cursor != null) { for (int i = 0; i < cursor.getCount(); i++) { cursor.moveToPosition(i); String noteKey = cursor.getSimperiumKey(); if (noteKey != null && noteKey.equals(selectedNoteID)) { setActivatedPosition(i); return; } } } // Didn't find the note, let's try again after the cursor updates (see refreshListTask) mSelectedNoteId = selectedNoteID; } public void searchNotes(String searchString) { if (!searchString.equals(mSearchString)) { mSearchString = searchString; refreshList(); } } /** * Clear search and load all notes */ public void clearSearch() { if (mSearchString != null && !mSearchString.equals("")) { mSearchString = null; refreshList(); } } public boolean hasSearchQuery() { return mSearchString != null && !mSearchString.equals(""); } public void sortNoteQuery(Query<Note> noteQuery) { noteQuery.order("pinned", SortType.DESCENDING); int sortPref = PrefUtils.getIntPref(getActivity(), PrefUtils.PREF_SORT_ORDER); switch (sortPref) { case 0: noteQuery.order(Note.MODIFIED_INDEX_NAME, SortType.DESCENDING); break; case 1: noteQuery.order(Note.MODIFIED_INDEX_NAME, SortType.ASCENDING); break; case 2: noteQuery.order(Note.CREATED_INDEX_NAME, SortType.DESCENDING); break; case 3: noteQuery.order(Note.CREATED_INDEX_NAME, SortType.ASCENDING); break; case 4: noteQuery.order(Note.CONTENT_PROPERTY, SortType.ASCENDING); break; case 5: noteQuery.order(Note.CONTENT_PROPERTY, SortType.DESCENDING); break; } } /** * A callback interface that all activities containing this fragment must * implement. This mechanism allows activities to be notified of item * selections. */ public interface Callbacks { /** * Callback for when a note has been selected. */ void onNoteSelected(String noteID, int position, boolean isNew, String matchOffsets, boolean isMarkdownEnabled); } // view holder for NotesCursorAdapter private static class NoteViewHolder { public String matchOffsets; TextView titleTextView; TextView contentTextView; ToggleButton toggleView; private String mNoteId; public String getNoteId() { return mNoteId; } public void setNoteId(String noteId) { mNoteId = noteId; } } public class NotesCursorAdapter extends CursorAdapter { private ObjectCursor<Note> mCursor; private SearchSnippetFormatter.SpanFactory mSnippetHighlighter = new TextHighlighter(getActivity(), R.attr.listSearchHighlightForegroundColor, R.attr.listSearchHighlightBackgroundColor); public NotesCursorAdapter(Context context, ObjectCursor<Note> c, int flags) { super(context, c, flags); mCursor = c; } public void changeCursor(ObjectCursor<Note> cursor) { mCursor = cursor; super.changeCursor(cursor); } @Override public Note getItem(int position) { mCursor.moveToPosition(position); return mCursor.getObject(); } /* * nbradbury - implemented "holder pattern" to boost performance with large note lists */ @Override public View getView(final int position, View view, ViewGroup parent) { final NoteViewHolder holder; if (view == null) { view = View.inflate(getActivity().getBaseContext(), R.layout.note_list_row, null); holder = new NoteViewHolder(); holder.titleTextView = (TextView) view.findViewById(R.id.note_title); holder.contentTextView = (TextView) view.findViewById(R.id.note_content); holder.toggleView = (ToggleButton) view.findViewById(R.id.pin_button); view.setTag(holder); } else { holder = (NoteViewHolder) view.getTag(); } if (holder.titleTextView.getTextSize() != mTitleFontSize) { holder.titleTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, mTitleFontSize); holder.contentTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, mPreviewFontSize); } if (position == getListView().getCheckedItemPosition()) view.setActivated(true); else view.setActivated(false); // for performance reasons we are going to get indexed values // from the cursor instead of instantiating the entire bucket object holder.contentTextView.setVisibility(View.VISIBLE); holder.contentTextView.setMaxLines(mNumPreviewLines); mCursor.moveToPosition(position); holder.setNoteId(mCursor.getSimperiumKey()); int pinned = mCursor.getInt(mCursor.getColumnIndex(Note.PINNED_INDEX_NAME)); if (pinned == 1) { holder.toggleView.setChecked(true); holder.toggleView.setVisibility(View.VISIBLE); } else { holder.toggleView.setVisibility(View.GONE); } holder.toggleView.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { Note note = mNotesAdapter.getItem(position); NoteUtils.setNotePin(note, holder.toggleView.isChecked()); } }); String title = mCursor.getString(mCursor.getColumnIndex(Note.TITLE_INDEX_NAME)); if (title == null || title.equals("")) { SpannableString untitled = new SpannableString(getString(R.string.new_note_list)); untitled.setSpan(new TextAppearanceSpan(getActivity(), R.style.UntitledNoteAppearance), 0, untitled.length(), 0x0); holder.titleTextView.setText(untitled); } else { holder.titleTextView.setText(title); } holder.matchOffsets = null; int matchOffsetsIndex = mCursor.getColumnIndex("match_offsets"); if (hasSearchQuery() && matchOffsetsIndex != -1) { title = mCursor.getString(mCursor.getColumnIndex(Note.MATCHED_TITLE_INDEX_NAME)); String snippet = mCursor.getString(mCursor.getColumnIndex(Note.MATCHED_CONTENT_INDEX_NAME)); holder.matchOffsets = mCursor.getString(matchOffsetsIndex); try { holder.contentTextView.setText(SearchSnippetFormatter.formatString(snippet, mSnippetHighlighter)); holder.titleTextView.setText(SearchSnippetFormatter.formatString(title, mSnippetHighlighter)); } catch (NullPointerException e) { title = StrUtils.notNullStr(mCursor.getString(mCursor.getColumnIndex(Note.TITLE_INDEX_NAME))); holder.titleTextView.setText(title); String matchedContentPreview = StrUtils.notNullStr(mCursor.getString(mCursor.getColumnIndex(Note.CONTENT_PREVIEW_INDEX_NAME))); holder.contentTextView.setText(matchedContentPreview); } } else if (mNumPreviewLines > 0) { String contentPreview = mCursor.getString(mCursor.getColumnIndex(Note.CONTENT_PREVIEW_INDEX_NAME)); if (title == null || title.equals(contentPreview) || title.equals(getString(R.string.new_note_list))) holder.contentTextView.setVisibility(View.GONE); else holder.contentTextView.setText(contentPreview); } return view; } @Override public View newView(Context context, Cursor cursor, ViewGroup viewGroup) { return null; } @Override public void bindView(View view, Context context, Cursor cursor) { } } private class refreshListTask extends AsyncTask<Boolean, Void, ObjectCursor<Note>> { boolean mIsFromNavSelect; @Override protected ObjectCursor<Note> doInBackground(Boolean... args) { mIsFromNavSelect = args[0]; return queryNotes(); } @Override protected void onPostExecute(ObjectCursor<Note> cursor) { if (cursor == null || getActivity() == null || getActivity().isFinishing()) return; // While using a Query.FullTextMatch it's easy to enter an invalid term so catch the error and clear the cursor int count; try { mNotesAdapter.changeCursor(cursor); count = mNotesAdapter.getCount(); } catch (SQLiteException e) { count = 0; android.util.Log.e(Simplenote.TAG, "Invalid SQL statement", e); mNotesAdapter.changeCursor(null); } NotesActivity notesActivity = (NotesActivity) getActivity(); if (notesActivity != null) { if (mIsFromNavSelect && DisplayUtils.isLargeScreenLandscape(notesActivity)) { if (count == 0) { notesActivity.showDetailPlaceholder(); } else { // Select the first note selectFirstNote(); } } notesActivity.updateTrashMenuItem(); } if (mSelectedNoteId != null) { setNoteSelected(mSelectedNoteId); mSelectedNoteId = null; } } } private class pinNotesTask extends AsyncTask<Void, Void, Void> { private SparseBooleanArray mSelectedRows = new SparseBooleanArray(); @Override protected void onPreExecute() { if (getListView() != null) { mSelectedRows = getListView().getCheckedItemPositions(); } } @Override protected Void doInBackground(Void... args) { // Get the checked notes and add them to the pinnedNotesList // We can't modify the note in this loop because the adapter could change List<Note> pinnedNotesList = new ArrayList<>(); for (int i = 0; i < mSelectedRows.size(); i++) { if (mSelectedRows.valueAt(i)) { pinnedNotesList.add(mNotesAdapter.getItem(mSelectedRows.keyAt(i))); } } // Now loop through the notes list and mark them as pinned for (Note pinnedNote : pinnedNotesList) { pinnedNote.setPinned(!pinnedNote.isPinned()); pinnedNote.setModificationDate(Calendar.getInstance()); pinnedNote.save(); } return null; } @Override protected void onPostExecute(Void aVoid) { mActionMode.finish(); refreshList(); } } private class trashNotesTask extends AsyncTask<Void, Void, Void> { private List<String> mDeletedNoteIds = new ArrayList<>(); private SparseBooleanArray mSelectedRows = new SparseBooleanArray(); @Override protected void onPreExecute() { if (getListView() != null) { mSelectedRows = getListView().getCheckedItemPositions(); } } @Override protected Void doInBackground(Void... args) { // Get the checked notes and add them to the deletedNotesList // We can't modify the note in this loop because the adapter could change List<Note> deletedNotesList = new ArrayList<>(); for (int i = 0; i < mSelectedRows.size(); i++) { if (mSelectedRows.valueAt(i)) { deletedNotesList.add(mNotesAdapter.getItem(mSelectedRows.keyAt(i))); } } // Now loop through the notes list and mark them as deleted for (Note deletedNote : deletedNotesList) { mDeletedNoteIds.add(deletedNote.getSimperiumKey()); deletedNote.setDeleted(!deletedNote.isDeleted()); deletedNote.setModificationDate(Calendar.getInstance()); deletedNote.save(); } return null; } @Override protected void onPostExecute(Void aVoid) { NotesActivity notesActivity = ((NotesActivity) getActivity()); if (notesActivity != null) notesActivity.showUndoBarWithNoteIds(mDeletedNoteIds); refreshList(); } } }