diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/ExternalLinksTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/ExternalLinksTest.kt index 8930285d5..191d29760 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/ExternalLinksTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/ExternalLinksTest.kt @@ -59,7 +59,7 @@ class ExternalLinksTest(private val param: Parameter) : OrgzlyTest() { onBook(0).perform(click()) // Click on link - onNoteInBook(1, R.id.item_head_content).perform(clickClickableSpan(param.link)) + onNoteInBook(1, R.id.note_content_section_text).perform(clickClickableSpan(param.link)) param.check() } diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/InternalLinksTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/InternalLinksTest.kt index 368e69383..aed1ea912 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/InternalLinksTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/InternalLinksTest.kt @@ -66,28 +66,28 @@ class InternalLinksTest : OrgzlyTest() { @Test fun testDifferentCaseUuidInternalLink() { - onNoteInBook(1, R.id.item_head_content) + onNoteInBook(1, R.id.note_content_section_text) .perform(clickClickableSpan("id:bdce923b-C3CD-41ED-B58E-8BDF8BABA54F")) onView(withId(R.id.fragment_note_title)).check(matches(withText("Note [b-2]"))) } @Test fun testDifferentCaseCustomIdInternalLink() { - onNoteInBook(2, R.id.item_head_content) + onNoteInBook(2, R.id.note_content_section_text) .perform(clickClickableSpan("#Different case custom id")) onView(withId(R.id.fragment_note_title)).check(matches(withText("Note [b-1]"))) } @Test fun testCustomIdLink() { - onNoteInBook(3, R.id.item_head_content) + onNoteInBook(3, R.id.note_content_section_text) .perform(clickClickableSpan("#Link to note in a different book")) onView(withId(R.id.fragment_note_title)).check(matches(withText("Note [b-3]"))) } @Test fun testBookLink() { - onNoteInBook(4, R.id.item_head_content) + onNoteInBook(4, R.id.note_content_section_text) .perform(clickClickableSpan("file:book-b.org")) onView(withId(R.id.fragment_book_view_flipper)).check(matches(isDisplayed())) onNoteInBook(1, R.id.item_head_title).check(matches(withText("Note [b-1]"))) @@ -95,7 +95,7 @@ class InternalLinksTest : OrgzlyTest() { @Test fun testBookRelativeLink() { - onNoteInBook(5, R.id.item_head_content) + onNoteInBook(5, R.id.note_content_section_text) .perform(clickClickableSpan("file:./book-b.org")) onView(withId(R.id.fragment_book_view_flipper)).check(matches(isDisplayed())) onNoteInBook(1, R.id.item_head_title).check(matches(withText("Note [b-1]"))) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java index c798f5905..bd9a212ff 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java @@ -720,7 +720,7 @@ public void testContentOfFoldedNoteDisplayed() { onView(withId(R.id.fragment_query_search_view_flipper)).check(matches(isDisplayed())); onNotesInSearch().check(matches(recyclerViewItemCount(3))); onNoteInSearch(1, R.id.item_head_title).check(matches(allOf(withText(containsString("Note B")), isDisplayed()))); - onNoteInSearch(1, R.id.item_head_content).check(matches(allOf(withText(containsString("Content for Note B")), isDisplayed()))); + onNoteInSearch(1, R.id.note_content_section_text).check(matches(allOf(withText(containsString("Content for Note B")), isDisplayed()))); } @Test diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/SettingsChangeTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/SettingsChangeTest.java index d5c4128ee..134d01682 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/SettingsChangeTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/SettingsChangeTest.java @@ -82,7 +82,7 @@ public void testChangeDefaultPriorityAgendaResultsShouldBeReordered() { public void testDisplayedContentInBook() { onBook(0).perform(click()); - onNoteInBook(1, R.id.item_head_content) + onNoteInBook(1, R.id.note_content_section_text) .check(matches(allOf(withText(containsString("Content for [a-1]")), isDisplayed()))); onActionItemClick(R.id.activity_action_settings, R.string.settings); @@ -91,7 +91,7 @@ public void testDisplayedContentInBook() { pressBack(); pressBack(); - onNoteInBook(1, R.id.item_head_content).check(matches(not(isDisplayed()))); + onNoteInBook(1, R.id.item_head_content_list).check(matches(not(isDisplayed()))); } private void setDefaultPriority(String priority) { diff --git a/app/src/androidTest/java/com/orgzly/android/ui/notes/NoteContentTest.kt b/app/src/androidTest/java/com/orgzly/android/ui/notes/NoteContentTest.kt new file mode 100644 index 000000000..e20963c42 --- /dev/null +++ b/app/src/androidTest/java/com/orgzly/android/ui/notes/NoteContentTest.kt @@ -0,0 +1,155 @@ +package com.orgzly.android.ui.notes + +import org.hamcrest.Matchers.emptyCollectionOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThat +import org.junit.Test +import java.util.Random + +// TODO - check CRLF vs LF vs whatever MacOS does +class NoteContentTest { + + @Test + fun emptyString() { + val parse = NoteContent.parse("") + assertThat(parse, (emptyCollectionOf(NoteContent.javaClass))) + } + + @Test + fun emptyLinesShouldStayInSingleSection() { + checkExpected("\n\n", listOf(NoteContent("\n\n", 0, 1, NoteContent.TextType.TEXT))) + } + + @Test + fun pipeInText() { + checkExpected("""foo +| + +foo|bar""", listOf( + NoteContent("foo\n", 0, 3, NoteContent.TextType.TEXT), + NoteContent("|\n", 4, 5, NoteContent.TextType.TABLE), + NoteContent("\nfoo|bar", 6, 13, NoteContent.TextType.TEXT) + )) + } + + @Test + fun singleTable() { + checkExpected("""|a|b| +|c|d| +""", listOf(NoteContent("""|a|b| +|c|d| +""", 0, 11, NoteContent.TextType.TABLE))) + } + + @Test + fun singleTableNoFinalNewline() { + checkExpected("""|a|b| +|c|d|""", listOf(NoteContent("""|a|b| +|c|d|""", 0, 10, NoteContent.TextType.TABLE))) + } + + @Test + fun singleLineTextTableText() { + checkExpected("""foo +| +bar""", listOf( + NoteContent("foo\n", 0, 3, NoteContent.TextType.TEXT), + NoteContent("|\n", 4, 5, NoteContent.TextType.TABLE), + NoteContent("bar", 6, 8, NoteContent.TextType.TEXT) + )) + } + + + @Test + fun blankLineTextTableText() { + checkExpected(""" +| +bar +""", listOf( + NoteContent("\n", 0, 0, NoteContent.TextType.TEXT), + NoteContent("|\n", 1, 2, NoteContent.TextType.TABLE), + NoteContent("bar\n", 3, 6, NoteContent.TextType.TEXT) + )) + } + + @Test + fun tableBlankLineTable() { + checkExpected("""|zoo| + +|zog|""", listOf( + NoteContent("|zoo|\n", 0, 5, NoteContent.TextType.TABLE), + NoteContent("\n", 6, 6, NoteContent.TextType.TEXT), + NoteContent("|zog|", 7, 11, NoteContent.TextType.TABLE) + )) + } + + @Test + fun textTableBlankLineText() { + checkExpected("""foo +| + +chops""", listOf( + NoteContent("foo\n", 0, 3, NoteContent.TextType.TEXT), + NoteContent("|\n", 4, 5, NoteContent.TextType.TABLE), + NoteContent("\nchops", 6, 11, NoteContent.TextType.TEXT) + )) + } + + + @Test + fun textTableTextTableText() { + checkExpected("""text1 +|table2a| +|table2b| +text3a +text3b +text3c +|table4| +text5 +""", listOf( + NoteContent("text1\n", 0, 5, NoteContent.TextType.TEXT), + NoteContent("|table2a|\n|table2b|\n", 6, 25, NoteContent.TextType.TABLE), + NoteContent("text3a\ntext3b\ntext3c\n", 26, 46, NoteContent.TextType.TEXT), + NoteContent("|table4|\n", 47, 55, NoteContent.TextType.TABLE), + NoteContent("text5\n", 56, 61, NoteContent.TextType.TEXT) + )) + } + + @Test + fun randomStringsRoundTrip() { + + val stringAtoms: List = listOf("\n", "a", "|") + + for (i in 0..1000) { + val rawStringLength = Random().nextInt(100) + val builder = StringBuilder() + for (j in 0..rawStringLength) { + builder.append(stringAtoms.random()) + } + + val raw = builder.toString() + + val actual: List = NoteContent.parse(raw) + + val roundTripped: String = actual.fold("") { acc: String, current: NoteContent -> acc + current.text } + + assertEquals(raw, roundTripped) + + } + + } + + + private fun checkExpected(input: String, expected: List) { + val actual: List = NoteContent.parse(input) + assertEquals(expected, actual) + + val roundTripped: String = actual.fold("") { acc: String, current: NoteContent -> acc + current.text } + + assertEquals(input, roundTripped) + + actual.forEach { + assertEquals(it.text, input.substring(it.startOffset, it.endOffset + 1)) + } + } +} diff --git a/app/src/main/java/com/orgzly/android/AppIntent.java b/app/src/main/java/com/orgzly/android/AppIntent.java index ddb92d7c6..1165f3b89 100644 --- a/app/src/main/java/com/orgzly/android/AppIntent.java +++ b/app/src/main/java/com/orgzly/android/AppIntent.java @@ -32,6 +32,7 @@ public class AppIntent { public static final String ACTION_OPEN_BOOKS = "com.orgzly.intent.action.OPEN_BOOKS"; public static final String ACTION_OPEN_BOOK = "com.orgzly.intent.action.OPEN_BOOK"; public static final String ACTION_OPEN_SETTINGS = "com.orgzly.intent.action.OPEN_SETTINGS"; + public static final String ACTION_EDIT_TABLE = "com.orgzly.intent.action.EDIT_TABLE"; public static final String ACTION_SHOW_SNACKBAR = "com.orgzly.intent.action.SHOW_SNACKBAR"; @@ -40,6 +41,8 @@ public class AppIntent { public static final String EXTRA_BOOK_PREFACE = "com.orgzly.intent.extra.BOOK_PREFACE"; public static final String EXTRA_NOTE_ID = "com.orgzly.intent.extra.NOTE_ID"; public static final String EXTRA_NOTE_CONTENT = "com.orgzly.intent.extra.NOTE_CONTENT"; + public static final String EXTRA_TABLE_START_OFFSET = "com.orgzly.intent.action.EXTRA_TABLE_START_OFFSET"; + public static final String EXTRA_TABLE_END_OFFSET = "com.orgzly.intent.action.EXTRA_TABLE_END_OFFSET"; public static final String EXTRA_QUERY_STRING = "com.orgzly.intent.extra.QUERY_STRING"; public static final String EXTRA_PROPERTY_NAME = "com.orgzly.intent.extra.PROPERTY_NAME"; public static final String EXTRA_PROPERTY_VALUE = "com.orgzly.intent.extra.PROPERTY_VALUE"; diff --git a/app/src/main/java/com/orgzly/android/di/AppComponent.kt b/app/src/main/java/com/orgzly/android/di/AppComponent.kt index dab32b2e2..260993935 100644 --- a/app/src/main/java/com/orgzly/android/di/AppComponent.kt +++ b/app/src/main/java/com/orgzly/android/di/AppComponent.kt @@ -13,6 +13,7 @@ import com.orgzly.android.ui.BookChooserActivity import com.orgzly.android.ui.TemplateChooserActivity import com.orgzly.android.ui.books.BooksFragment import com.orgzly.android.ui.main.MainActivity +import com.orgzly.android.ui.note.EditTableFragment import com.orgzly.android.ui.note.NoteFragment import com.orgzly.android.ui.notes.NotesFragment import com.orgzly.android.ui.notes.book.BookFragment @@ -68,6 +69,7 @@ interface AppComponent { fun inject(arg: SearchFragment) fun inject(arg: AgendaFragment) fun inject(arg: NoteFragment) + fun inject(arg: EditTableFragment) fun inject(arg: SavedSearchesFragment) fun inject(arg: SavedSearchFragment) fun inject(arg: RefileFragment) diff --git a/app/src/main/java/com/orgzly/android/ui/DisplayManager.java b/app/src/main/java/com/orgzly/android/ui/DisplayManager.java index a08a052b6..ec8f67df9 100644 --- a/app/src/main/java/com/orgzly/android/ui/DisplayManager.java +++ b/app/src/main/java/com/orgzly/android/ui/DisplayManager.java @@ -12,6 +12,7 @@ import com.orgzly.android.query.Query; import com.orgzly.android.query.QueryParser; import com.orgzly.android.query.user.InternalQueryParser; +import com.orgzly.android.ui.note.EditTableFragment; import com.orgzly.android.ui.savedsearch.SavedSearchFragment; import com.orgzly.android.ui.main.MainActivity; import com.orgzly.android.ui.notes.book.BookFragment; @@ -151,6 +152,16 @@ public static void displayExistingNote(FragmentManager fragmentManager, long boo } } + public static void displayEditTable(FragmentManager fragmentManager, + long bookId, + long noteId, + int tableStartOffset, + int tableEndOffset) { + Fragment fragment = EditTableFragment.newInstance(bookId, noteId, tableStartOffset, tableEndOffset); + + displayNoteFragment(fragmentManager, fragment); + } + public static void displayNewNote(FragmentManager fragmentManager, NotePlace target) { Fragment fragment = NoteFragment.forNewNote(target); diff --git a/app/src/main/java/com/orgzly/android/ui/main/MainActivity.java b/app/src/main/java/com/orgzly/android/ui/main/MainActivity.java index d857586c2..f00a8702e 100644 --- a/app/src/main/java/com/orgzly/android/ui/main/MainActivity.java +++ b/app/src/main/java/com/orgzly/android/ui/main/MainActivity.java @@ -92,7 +92,6 @@ import com.orgzly.android.usecase.SavedSearchCreate; import com.orgzly.android.usecase.SavedSearchDelete; import com.orgzly.android.usecase.SavedSearchExport; -import com.orgzly.android.usecase.SavedSearchImport; import com.orgzly.android.usecase.SavedSearchMoveDown; import com.orgzly.android.usecase.SavedSearchMoveUp; import com.orgzly.android.usecase.SavedSearchUpdate; @@ -101,7 +100,6 @@ import com.orgzly.android.usecase.UseCaseRunner; import com.orgzly.android.util.AppPermissions; import com.orgzly.android.util.LogUtils; -import com.orgzly.android.util.MiscUtils; import com.orgzly.org.datetime.OrgDateTime; import org.jetbrains.annotations.NotNull; @@ -635,6 +633,7 @@ protected void onResumeFragments() { LocalBroadcastManager bm = LocalBroadcastManager.getInstance(this); bm.registerReceiver(receiver, new IntentFilter(AppIntent.ACTION_OPEN_NOTE)); + bm.registerReceiver(receiver, new IntentFilter(AppIntent.ACTION_EDIT_TABLE)); bm.registerReceiver(receiver, new IntentFilter(AppIntent.ACTION_FOLLOW_LINK_TO_NOTE_WITH_PROPERTY)); bm.registerReceiver(receiver, new IntentFilter(AppIntent.ACTION_FOLLOW_LINK_TO_FILE)); bm.registerReceiver(receiver, new IntentFilter(AppIntent.ACTION_OPEN_SAVED_SEARCHES)); @@ -1267,6 +1266,15 @@ public void onNoteOpen(long noteId) { viewModel.openNote(noteId); } + public void editTableOfView(View view) { + editTable( + (long) view.getTag(AppIntent.EXTRA_BOOK_ID.hashCode()), + (long) view.getTag(AppIntent.EXTRA_NOTE_ID.hashCode()), + (int) view.getTag(AppIntent.EXTRA_TABLE_START_OFFSET.hashCode()), + (int) view.getTag(AppIntent.EXTRA_TABLE_END_OFFSET.hashCode()) + ); + } + // TODO: Consider creating NavigationBroadcastReceiver public static void openSpecificNote(long bookId, long noteId) { Intent intent = new Intent(AppIntent.ACTION_OPEN_NOTE); @@ -1275,6 +1283,15 @@ public static void openSpecificNote(long bookId, long noteId) { LocalBroadcastManager.getInstance(App.getAppContext()).sendBroadcast(intent); } + public static void editTable(long bookId, long noteId, int tableStartOffset, int tableEndOffset) { + Intent intent = new Intent(AppIntent.ACTION_EDIT_TABLE); + intent.putExtra(AppIntent.EXTRA_NOTE_ID, noteId); + intent.putExtra(AppIntent.EXTRA_BOOK_ID, bookId); + intent.putExtra(AppIntent.EXTRA_TABLE_START_OFFSET, tableStartOffset); + intent.putExtra(AppIntent.EXTRA_TABLE_END_OFFSET, tableEndOffset); + LocalBroadcastManager.getInstance(App.getAppContext()).sendBroadcast(intent); + } + public static void followLinkToFile(String path) { Intent intent = new Intent(AppIntent.ACTION_FOLLOW_LINK_TO_FILE); intent.putExtra(AppIntent.EXTRA_PATH, path); @@ -1307,6 +1324,15 @@ private void handleIntent(@NonNull Intent intent, @NonNull String action) { break; } + case AppIntent.ACTION_EDIT_TABLE: { + long bookId = intent.getLongExtra(AppIntent.EXTRA_BOOK_ID, -1); + long noteId = intent.getLongExtra(AppIntent.EXTRA_NOTE_ID, -1); + int tableStartOffset = intent.getIntExtra(AppIntent.EXTRA_TABLE_START_OFFSET, -1); + int tableEndOffset = intent.getIntExtra(AppIntent.EXTRA_TABLE_END_OFFSET, -1); + DisplayManager.displayEditTable(getSupportFragmentManager(), bookId, noteId, tableStartOffset, tableEndOffset); + break; + } + case AppIntent.ACTION_OPEN_SAVED_SEARCHES: { DisplayManager.displaySavedSearches(getSupportFragmentManager()); break; diff --git a/app/src/main/java/com/orgzly/android/ui/note/EditTableFragment.kt b/app/src/main/java/com/orgzly/android/ui/note/EditTableFragment.kt new file mode 100644 index 000000000..606d07769 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/note/EditTableFragment.kt @@ -0,0 +1,260 @@ +package com.orgzly.android.ui.note + +import android.app.AlertDialog +import android.content.Context +import android.os.Bundle +import android.text.InputType +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.view.inputmethod.EditorInfo +import androidx.activity.OnBackPressedCallback +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import com.orgzly.BuildConfig +import com.orgzly.R +import com.orgzly.android.App +import com.orgzly.android.data.DataRepository +import com.orgzly.android.db.entity.Note +import com.orgzly.android.ui.CommonActivity +import com.orgzly.android.ui.main.SharedMainActivityViewModel +import com.orgzly.android.ui.util.ActivityUtils +import com.orgzly.android.util.LogUtils +import com.orgzly.databinding.FragmentEditTableBinding +import javax.inject.Inject + +class EditTableFragment : Fragment() { + + private val TAG = EditTableFragment::class.java.name + + private lateinit var binding: FragmentEditTableBinding + + @Inject + internal lateinit var dataRepository: DataRepository + + private var listener: NoteFragment.Listener? = null + + private lateinit var viewModel: TableViewModel + + private lateinit var sharedMainActivityViewModel: SharedMainActivityViewModel + + private var dialog: AlertDialog? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + + App.appComponent.inject(this) + + listener = activity as NoteFragment.Listener + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + sharedMainActivityViewModel.setFragment(FRAGMENT_TAG, null, null, 0) + + viewModel.noteViewModel.noteDetailsDataEvent.observeSingle(viewLifecycleOwner, { + viewModel.loadTableData() + }) + // TODO Load payload from saved Bundle if available + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // we ought to do something with the Bundle if it's non-null + + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, "onCreate") + + val args = requireNotNull(arguments) + + val nvmf: NoteViewModelFactory = NoteViewModelFactory.getInstance( + dataRepository, + arguments?.getLong(ARG_BOOK_ID) ?: 0, + args.getLong(ARG_NOTE_ID), + null, + null, + null) as NoteViewModelFactory + + val factory = TableViewModelFactory.getInstance( + nvmf, + args.getInt(ARG_TABLE_START_OFFSET), + args.getInt(ARG_TABLE_END_OFFSET)) + + viewModel = ViewModelProviders.of(this, factory).get(TableViewModel::class.java) + + sharedMainActivityViewModel = ViewModelProviders.of(requireActivity()) + .get(SharedMainActivityViewModel::class.java) + + setHasOptionsMenu(true) + + requireActivity().onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + onBackPressed() + } + }) + + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + binding = FragmentEditTableBinding.inflate(inflater, container, false) + + binding.tableViewModel = viewModel + + binding.lifecycleOwner = viewLifecycleOwner + + // from https://stackoverflow.com/a/41022589/116509, to have a DONE tickbox icon on the soft keyboard instead a newline icon + binding.tableContentEditText.imeOptions = android.view.inputmethod.EditorInfo.IME_ACTION_DONE + binding.tableContentEditText.setRawInputType(InputType.TYPE_CLASS_TEXT); + + binding.tableContentEditText.setOnEditorActionListener { textView, actionId, event -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + userSave(null) + return@setOnEditorActionListener true + } + false + } + + return binding.root + } + + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, menu, inflater) + + inflater.inflate(R.menu.note_actions, menu) + + menu.removeItem(R.id.activity_action_search) + + menu.removeItem(R.id.note_view_edit) + + menu.removeItem(R.id.metadata) + + menu.removeItem(R.id.delete) + } + + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, item) + + when (item.itemId) { + R.id.done -> { + userSave(null) + return true + } + + R.id.keep_screen_on -> { + dialog = ActivityUtils.keepScreenOnToggle(activity, item) + return true + } + + else -> return super.onOptionsItemSelected(item) + } + } + + private fun userSave(postSave: ((note: Note) -> Unit)?) { + ActivityUtils.closeSoftKeyboard(activity) + viewModel.updateNote(postSave) + } + + private fun onBackPressed() { + userCancel() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupObservers() + + viewModel.loadNoteData() + } + + private fun setupObservers() { + + viewModel.tableUpdatedEvent.observe(viewLifecycleOwner, Observer { note -> + listener?.onNoteUpdated(note) + }) + + + viewModel.errorEvent.observeSingle(viewLifecycleOwner, Observer { error -> + showSnackbar((error.cause ?: error).localizedMessage) + }) + + viewModel.snackBarMessage.observeSingle(viewLifecycleOwner, Observer { resId -> + showSnackbar(resId) + }) + } + + private fun showSnackbar(message: String?) { + CommonActivity.showSnackbar(context, message) + } + + private fun showSnackbar(@StringRes resId: Int) { + CommonActivity.showSnackbar(context, resId) + } + + + override fun onDetach() { + super.onDetach() + + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG) + + listener = null + } + + private fun userCancel(): Boolean { + ActivityUtils.closeSoftKeyboard(activity) + + if (viewModel.isNoteModified()) { + dialog = AlertDialog.Builder(context) + .setTitle(R.string.note_has_been_modified) + .setMessage(R.string.discard_or_save_changes) + .setPositiveButton(R.string.save) { _, _ -> + viewModel.updateNote { + listener?.onNoteCanceled() + } + } + .setNegativeButton(R.string.discard) { _, _ -> + listener?.onNoteCanceled() + } + .setNeutralButton(R.string.cancel, null) + .show() + + return true + + } else { + listener?.onNoteCanceled() + return false + } + } + + + companion object { + + val FRAGMENT_TAG: String = EditTableFragment::class.java.name + private const val ARG_BOOK_ID = "book_id" + private const val ARG_NOTE_ID = "note_id" + private const val ARG_TABLE_START_OFFSET = "table_start_offset" + private const val ARG_TABLE_END_OFFSET = "table_end_offset" + + + @JvmStatic + fun newInstance(bookId: Long, + noteId: Long, + tableStartOffset: Int, + tableEndOffset: Int) = + EditTableFragment().apply { + arguments = Bundle().apply { + putLong(ARG_BOOK_ID, bookId) + putLong(ARG_NOTE_ID, noteId) + putInt(ARG_TABLE_START_OFFSET, tableStartOffset) + putInt(ARG_TABLE_END_OFFSET, tableEndOffset) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/ui/note/NoteViewModel.kt b/app/src/main/java/com/orgzly/android/ui/note/NoteViewModel.kt index 5988a20d5..b944b5d89 100644 --- a/app/src/main/java/com/orgzly/android/ui/note/NoteViewModel.kt +++ b/app/src/main/java/com/orgzly/android/ui/note/NoteViewModel.kt @@ -29,7 +29,7 @@ class NoteViewModel( private var noteId: Long, private val place: Place?, private val title: String?, - private val content: String? + internal val content: String? ) : CommonViewModel() { enum class ViewEditMode { @@ -60,7 +60,7 @@ class NoteViewModel( val noteDeleteRequest: SingleLiveEvent = SingleLiveEvent() val bookChangeRequestEvent: SingleLiveEvent> = SingleLiveEvent() - var notePayload: NotePayload? = null + internal var notePayload: NotePayload? = null private var originalHash: Long = 0L @@ -263,7 +263,7 @@ class NoteViewModel( } } - private fun updateNote(postSave: ((note: Note) -> Unit)?) { + internal fun updateNote(postSave: ((note: Note) -> Unit)?) { notePayload?.let { payload -> App.EXECUTORS.diskIO().execute { catchAndPostError { diff --git a/app/src/main/java/com/orgzly/android/ui/note/TableViewModel.kt b/app/src/main/java/com/orgzly/android/ui/note/TableViewModel.kt new file mode 100644 index 000000000..9d22cbac9 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/note/TableViewModel.kt @@ -0,0 +1,73 @@ +package com.orgzly.android.ui.note + +import androidx.lifecycle.MutableLiveData +import com.orgzly.android.db.entity.Note +import com.orgzly.android.ui.CommonViewModel +import com.orgzly.android.ui.SingleLiveEvent + +class TableViewModel( + val noteViewModel: NoteViewModel, + private val tableStartOffset: Int, + private val tableEndOffset: Int +) : CommonViewModel() { + + private val TAG = TableViewModel::class.java.name + + val tableView: MutableLiveData = MutableLiveData("") + + val tableUpdatedEvent: SingleLiveEvent = noteViewModel.noteUpdatedEvent + + lateinit var tableTextBeforeEditing: String + + fun loadNoteData() { + noteViewModel.loadData() + } + + fun loadTableData() { + val content = noteViewModel.notePayload!!.content!! + + tableTextBeforeEditing = content.substring(tableStartOffset, tableEndOffset) + + tableView.postValue(tableTextBeforeEditing) + } + + fun isNoteModified(): Boolean { + val unchanged: Boolean? = tableView.value?.equals(tableTextBeforeEditing) + if (unchanged == null) { + return false + } else { + return !unchanged + } + } + + fun updateNote(postSave: ((note: Note) -> Unit)?) { + updatePayload() + noteViewModel.updateNote(postSave) + } + + private fun updatePayload() { + + val np = noteViewModel.notePayload!! + + val updatedContent = np.content + + val beforeTable = updatedContent!!.substring(0, tableStartOffset) + val table = tableView.value + val afterTable = updatedContent.substring(tableEndOffset, noteViewModel.notePayload!!.content!!.length) + + noteViewModel.notePayload = + np.copy( + title = np.title, + content = beforeTable + table + afterTable, + state = np.state, + priority = np.priority, + scheduled = np.scheduled, + deadline = np.deadline, + closed = np.closed, + tags = np.tags, + properties = np.properties) + + } + + +} diff --git a/app/src/main/java/com/orgzly/android/ui/note/TableViewModelFactory.kt b/app/src/main/java/com/orgzly/android/ui/note/TableViewModelFactory.kt new file mode 100644 index 000000000..f402112c4 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/note/TableViewModelFactory.kt @@ -0,0 +1,28 @@ +package com.orgzly.android.ui.note + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider + +class TableViewModelFactory( + private val nvmf: NoteViewModelFactory, + private val tableStartOffset: Int, + private val tableEndOffset: Int +) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return TableViewModel(nvmf.create(NoteViewModel::class.java), tableStartOffset, tableEndOffset) as T + } + + companion object { + @JvmStatic + fun getInstance( + nvmf: NoteViewModelFactory, + tableStartOffset: Int, + tableEndOffset: Int + + ): ViewModelProvider.Factory { + return TableViewModelFactory(nvmf, tableStartOffset, tableEndOffset) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/ui/notes/NoteContent.kt b/app/src/main/java/com/orgzly/android/ui/notes/NoteContent.kt new file mode 100644 index 000000000..8d642c3b6 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/notes/NoteContent.kt @@ -0,0 +1,87 @@ +package com.orgzly.android.ui.notes + +/** + * Represents a subsection of note content: either text, or a table + */ +data class NoteContent(val text: String, val startOffset: Int, val endOffset: Int, val textType: TextType) { + + enum class TextType { + TEXT, TABLE + } + + + companion object { + + private fun lineIsTable(raw: String) = raw.isNotEmpty() && raw[0] == '|' + + /** + * Converts the provided raw string (with embedded newlines) into a list of sections of + * either text or tables. Each section is contiguous and can contain newlines. + * + * This is horrible, never try to write your own parser. Consider using a regex instead. + */ + fun parse(raw: String): List { + val list: MutableList = mutableListOf() + + var currentText = "" + var currentTable = "" + + var previousIsTable: Boolean = this.lineIsTable(raw) + + val rawSplitByNewlines = raw.split("\n") + + val missingLastNewline = rawSplitByNewlines.last() != "" + + val linesForParsing = + if (missingLastNewline) { + rawSplitByNewlines + } else { + rawSplitByNewlines.dropLast(1) + } + + linesForParsing.forEach { + val currentIsTable = lineIsTable(it) + when { + currentIsTable && previousIsTable -> { + currentTable += it + "\n" + } + currentIsTable && !previousIsTable -> { + currentTable = it + "\n" + val startOffset = getLastOffset(list) + list.add(NoteContent(currentText, startOffset, startOffset + currentText.length - 1, TextType.TEXT)) + currentText = "" + } + !currentIsTable && previousIsTable -> { + currentText = it + "\n" + val startOffset = getLastOffset(list) + list.add(NoteContent(currentTable, startOffset, startOffset + currentTable.length - 1, TextType.TABLE)) + currentTable = "" + } + !currentIsTable && !previousIsTable -> { + currentText += it + "\n" + } + } + previousIsTable = currentIsTable + } + + if (linesForParsing.isNotEmpty()) { + + val endOffsetAdjustment = if (missingLastNewline) 2 else 1 + + if (previousIsTable) { + list.add(NoteContent(if (missingLastNewline) { + currentTable.dropLast(1) + } else currentTable, getLastOffset(list), getLastOffset(list) + currentTable.length - endOffsetAdjustment, TextType.TABLE)) + } else { + list.add(NoteContent(if (missingLastNewline) { + currentText.dropLast(1) + } else currentText, getLastOffset(list), getLastOffset(list) + currentText.length - endOffsetAdjustment, TextType.TEXT)) + } + } + + return list + } + + private fun getLastOffset(list: MutableList) = if (list.isEmpty()) 0 else list.last().endOffset + 1 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/ui/notes/NoteItemViewBinder.kt b/app/src/main/java/com/orgzly/android/ui/notes/NoteItemViewBinder.kt index 5c8084704..64e721281 100644 --- a/app/src/main/java/com/orgzly/android/ui/notes/NoteItemViewBinder.kt +++ b/app/src/main/java/com/orgzly/android/ui/notes/NoteItemViewBinder.kt @@ -1,15 +1,17 @@ package com.orgzly.android.ui.notes import android.content.Context -import android.graphics.Typeface import android.graphics.drawable.Drawable +import android.view.LayoutInflater import android.view.View import android.widget.ImageView +import android.widget.LinearLayout import android.widget.TextView import androidx.annotation.ColorInt import androidx.constraintlayout.widget.ConstraintLayout import com.orgzly.R import com.orgzly.android.App +import com.orgzly.android.AppIntent import com.orgzly.android.db.entity.Note import com.orgzly.android.db.entity.NoteView import com.orgzly.android.prefs.AppPreferences @@ -17,6 +19,7 @@ import com.orgzly.android.ui.ImageLoader import com.orgzly.android.ui.TimeType import com.orgzly.android.ui.util.TitleGenerator import com.orgzly.android.ui.util.styledAttributes +import com.orgzly.android.ui.views.TextViewWithMarkup import com.orgzly.android.usecase.NoteToggleFolding import com.orgzly.android.usecase.NoteToggleFoldingSubtree import com.orgzly.android.usecase.NoteUpdateContent @@ -109,35 +112,68 @@ class NoteItemViewBinder(private val context: Context, private val inBook: Boole } private fun setupContent(holder: NoteItemViewHolder, note: Note) { - holder.binding.itemHeadContent.text = note.content if (note.hasContent() && titleGenerator.shouldDisplayContent(note)) { - if (AppPreferences.isFontMonospaced(context)) { - holder.binding.itemHeadContent.typeface = Typeface.MONOSPACE - } - holder.binding.itemHeadContent.setRawText(note.content as CharSequence) + // this is absolutely not the place to split the note, but doing it here for PoC + val alternatingTableAndTextContent: List = NoteContent.parse(note.content!!) + + val linearLayout = holder.itemView.findViewById(R.id.item_head_content_list) + + linearLayout.removeAllViews() + + val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater + + alternatingTableAndTextContent.forEach { noteContent -> + when (noteContent.textType) { + NoteContent.TextType.TABLE -> { - /* If content changes (for example by toggling the checkbox), update the note. */ - holder.binding.itemHeadContent.onUserTextChangeListener = Runnable { - if (holder.binding.itemHeadContent.getRawText() != null) { - val useCase = NoteUpdateContent( - note.position.bookId, - note.id, - holder.binding.itemHeadContent.getRawText()?.toString()) + val noteContentSectionTableTextView = layoutInflater.inflate(R.layout.item_note_content_section_table, linearLayout, false) - App.EXECUTORS.diskIO().execute { - UseCaseRunner.run(useCase) + val tableTextView = noteContentSectionTableTextView.findViewById(R.id.note_content_section_table_text) + tableTextView.text = noteContent.text + + tableTextView.setTag(AppIntent.EXTRA_BOOK_ID.hashCode(), note.position.bookId) + tableTextView.setTag(AppIntent.EXTRA_NOTE_ID.hashCode(), note.id) + tableTextView.setTag(AppIntent.EXTRA_TABLE_START_OFFSET.hashCode(), noteContent.startOffset) + tableTextView.setTag(AppIntent.EXTRA_TABLE_END_OFFSET.hashCode(), noteContent.endOffset) + + linearLayout.addView(noteContentSectionTableTextView) + } + NoteContent.TextType.TEXT -> { + + val layout = layoutInflater.inflate(R.layout.item_note_content_section_text, linearLayout, false) + + val textView = layout.findViewById(R.id.note_content_section_text) + + textView.setRawText(noteContent.text) + + linearLayout.addView(layout) + + /* If content changes (for example by toggling the checkbox), update the note. */ + textView.onUserTextChangeListener = Runnable { + if (textView.getRawText() != null) { + val useCase = NoteUpdateContent( + note.position.bookId, + note.id, + textView.getRawText()?.toString()) + + App.EXECUTORS.diskIO().execute { + UseCaseRunner.run(useCase) + } + } + } + + ImageLoader.loadImages(textView) } } - } - ImageLoader.loadImages(holder.binding.itemHeadContent) + } - holder.binding.itemHeadContent.visibility = View.VISIBLE + holder.binding.itemHeadContentList.visibility = View.VISIBLE } else { - holder.binding.itemHeadContent.visibility = View.GONE + holder.binding.itemHeadContentList.visibility = View.GONE } } @@ -425,7 +461,7 @@ class NoteItemViewBinder(private val context: Context, private val inBook: Boole binding.itemHeadEventIcon, binding.itemHeadClosedIcon, binding.itemHeadClosedText, - binding.itemHeadContent) + binding.itemHeadContentList) for (view in views) { (view.layoutParams as ConstraintLayout.LayoutParams).apply { diff --git a/app/src/main/res/layout/fragment_edit_table.xml b/app/src/main/res/layout/fragment_edit_table.xml new file mode 100644 index 000000000..052ca5eb2 --- /dev/null +++ b/app/src/main/res/layout/fragment_edit_table.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_head.xml b/app/src/main/res/layout/item_head.xml index ffc835e91..c2adf2da7 100644 --- a/app/src/main/res/layout/item_head.xml +++ b/app/src/main/res/layout/item_head.xml @@ -26,7 +26,7 @@ - + + diff --git a/app/src/main/res/layout/item_note_content_section_table.xml b/app/src/main/res/layout/item_note_content_section_table.xml new file mode 100644 index 000000000..575904166 --- /dev/null +++ b/app/src/main/res/layout/item_note_content_section_table.xml @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_note_content_section_text.xml b/app/src/main/res/layout/item_note_content_section_text.xml new file mode 100644 index 000000000..60f3aee07 --- /dev/null +++ b/app/src/main/res/layout/item_note_content_section_text.xml @@ -0,0 +1,16 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/raw/orgzly_getting_started.org b/app/src/main/res/raw/orgzly_getting_started.org index b782d5060..fa9992b24 100644 --- a/app/src/main/res/raw/orgzly_getting_started.org +++ b/app/src/main/res/raw/orgzly_getting_started.org @@ -70,6 +70,20 @@ You can make words *bold*, /italic/, _underlined_, =verbatim=, ~code~ and +strik Click the checkbox to toggle it. Press new-line button at the end of the line to create a new item. +** Tables can be created + +A table starts the line with the pipe | character. + +| Here is a table | | | | +| We rely on horizontal scrolling | to show very wide | table content | | + +You can have more than one table in a note. + +| Like this | one | +| for | example | + +Click on a table to edit it. + * Search ** There are many search operators supported