diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 06f5205c..827f6927 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,10 +2,15 @@ + + + > + @get:Query("SELECT '' as name, -1 as _id, COUNT(CASE WHEN is_done > 0 THEN 1 END) AS done, COUNT(*) AS _all FROM notes " + + "UNION " + + "SELECT categories.name as name, categories._id, COUNT(CASE WHEN notes.is_done > 0 THEN 1 END) AS done, COUNT(*) AS _all FROM categories INNER JOIN notes ON categories._id == notes.category GROUP BY categories.name" + ) + val allCategoriesWithDoneInformation: Flow> + + @get:Query("SELECT * FROM categories GROUP BY name") + val allCategoriesSync: List @Query("SELECT name FROM categories WHERE _id=:thisCategoryId ") fun categoryNameFromId(thisCategoryId: Integer): LiveData diff --git a/app/src/main/java/org/secuso/privacyfriendlynotes/room/dao/NoteDao.kt b/app/src/main/java/org/secuso/privacyfriendlynotes/room/dao/NoteDao.kt index da0921f0..e5affbdb 100644 --- a/app/src/main/java/org/secuso/privacyfriendlynotes/room/dao/NoteDao.kt +++ b/app/src/main/java/org/secuso/privacyfriendlynotes/room/dao/NoteDao.kt @@ -53,4 +53,10 @@ interface NoteDao { @Query("SELECT * FROM notes WHERE _id = :id") fun getNoteByID(id: Long): Note? + + @Query("SELECT seq + 1 FROM sqlite_sequence WHERE name = :tableName") + fun getNextId(tableName: String = "notes"): Int + + @Query("SELECT * FROM notes WHERE in_trash = 1 AND in_trash_time <= :timestamp AND in_trash_time > 0") + fun getAllTrashedNotesOlderThan(timestamp: Long): List } \ No newline at end of file diff --git a/app/src/main/java/org/secuso/privacyfriendlynotes/room/model/Category.kt b/app/src/main/java/org/secuso/privacyfriendlynotes/room/model/Category.kt index 137807e0..96df67e7 100644 --- a/app/src/main/java/org/secuso/privacyfriendlynotes/room/model/Category.kt +++ b/app/src/main/java/org/secuso/privacyfriendlynotes/room/model/Category.kt @@ -13,6 +13,8 @@ */ package org.secuso.privacyfriendlynotes.room.model +import androidx.room.ColumnInfo +import androidx.room.Embedded import androidx.room.Entity import androidx.room.PrimaryKey @@ -40,4 +42,11 @@ data class Category( color = color ) -} \ No newline at end of file +} + +data class CategoryWithCompleteInformation( + @ColumnInfo(name = "name") val name: String, + @ColumnInfo(name = "_id") val _id: Int, + @ColumnInfo(name = "done") val done: Int, + @ColumnInfo(name = "_all") val all: Int, +) \ No newline at end of file diff --git a/app/src/main/java/org/secuso/privacyfriendlynotes/room/model/Note.kt b/app/src/main/java/org/secuso/privacyfriendlynotes/room/model/Note.kt index 3c1f63ee..3b978691 100644 --- a/app/src/main/java/org/secuso/privacyfriendlynotes/room/model/Note.kt +++ b/app/src/main/java/org/secuso/privacyfriendlynotes/room/model/Note.kt @@ -32,7 +32,10 @@ data class Note( var in_trash: Int = 0, var last_modified: Long, var custom_order: Int, - var readonly: Int + var readonly: Int, + var in_trash_time: Long = 0, + var pinned: Int = 0, + var is_done: Int = 0, ) { constructor(name: String, content: String, type: Int, category: Int) : this( diff --git a/app/src/main/java/org/secuso/privacyfriendlynotes/ui/adapter/ChecklistAdapter.kt b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/adapter/ChecklistAdapter.kt index fea14865..70e31f7f 100644 --- a/app/src/main/java/org/secuso/privacyfriendlynotes/ui/adapter/ChecklistAdapter.kt +++ b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/adapter/ChecklistAdapter.kt @@ -17,6 +17,7 @@ import android.annotation.SuppressLint import android.graphics.Paint import android.text.Editable import android.text.TextWatcher +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -38,6 +39,19 @@ class ChecklistAdapter( private val startDrag: (ItemHolder) -> Unit, ) : RecyclerView.Adapter() { + var sortingOption: SortingOption = SortingOption.NONE + set(value) { + items.sortWith { a, b -> + when (value) { + SortingOption.ASCENDING -> a.name.compareTo(b.name) + SortingOption.DESCENDING -> b.name.compareTo(a.name) + SortingOption.NONE -> 1 + } + } + Log.d("Sorting", "$value") + notifyDataSetChanged() + field = value + } private var items: MutableList = mutableListOf() var hasChanged = false private set @@ -77,16 +91,19 @@ class ChecklistAdapter( override fun onBindViewHolder(holder: ItemHolder, position: Int) { val (checked, item) = items[position] + val strikeThrough = PreferenceManager.getDefaultSharedPreferences(holder.itemView.context).getBoolean("settings_checklist_strike_items", true) holder.textView.text = item holder.checkbox.isChecked = checked - holder.dragHandle.setOnTouchListener { v, _ -> - startDrag(holder) - v.performClick() + if (sortingOption == SortingOption.NONE) { + holder.dragHandle.setOnTouchListener { v, _ -> + startDrag(holder) + v.performClick() + } } holder.checkbox.setOnClickListener { _ -> items[holder.bindingAdapterPosition].state = holder.checkbox.isChecked holder.textView.apply { - paintFlags = if (holder.checkbox.isChecked) { + paintFlags = if (holder.checkbox.isChecked && strikeThrough) { paintFlags or Paint.STRIKE_THRU_TEXT_FLAG } else { paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() @@ -111,7 +128,7 @@ class ChecklistAdapter( }) holder.textView.apply { - paintFlags = if (checked) { + paintFlags = if (checked && strikeThrough) { paintFlags or Paint.STRIKE_THRU_TEXT_FLAG } else { paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() @@ -129,8 +146,17 @@ class ChecklistAdapter( } fun addItem(item: String) { - this.items.add(ChecklistItem(false, item)) - notifyItemInserted(items.size - 1) + var index = when (sortingOption) { + SortingOption.NONE -> items.size + SortingOption.ASCENDING -> items.indexOfFirst { it.name >= item } + SortingOption.DESCENDING -> items.indexOfFirst { it.name <= item } + } + // item not found -> index = -1 -> should be at end + if (index < 0) { + index = items.size + } + this.items.add(index, ChecklistItem(false, item)) + notifyItemInserted(index) hasChanged = true } @@ -149,4 +175,10 @@ class ChecklistAdapter( val checkbox: MaterialCheckBox = itemView.findViewById(R.id.item_checkbox) val dragHandle: View = itemView.findViewById(R.id.drag_handle) } + + enum class SortingOption { + ASCENDING, + DESCENDING, + NONE + } } \ No newline at end of file diff --git a/app/src/main/java/org/secuso/privacyfriendlynotes/ui/adapter/NoteAdapter.kt b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/adapter/NoteAdapter.kt index 02443ac6..69b96870 100644 --- a/app/src/main/java/org/secuso/privacyfriendlynotes/ui/adapter/NoteAdapter.kt +++ b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/adapter/NoteAdapter.kt @@ -13,16 +13,22 @@ */ package org.secuso.privacyfriendlynotes.ui.adapter +import android.annotation.SuppressLint import android.app.Activity import android.graphics.Color +import android.graphics.Paint import android.text.Html +import android.util.Log import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.CheckBox import android.widget.ImageView import android.widget.TextView import androidx.appcompat.content.res.AppCompatResources +import androidx.appcompat.view.menu.MenuBuilder +import androidx.core.content.ContextCompat import androidx.preference.PreferenceManager import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide @@ -33,6 +39,7 @@ import org.secuso.privacyfriendlynotes.ui.main.MainActivityViewModel import org.secuso.privacyfriendlynotes.ui.util.DarkModeUtil import java.io.File + /** * Adapter that provides a binding for notes * @see org.secuso.privacyfriendlynotes.ui.main.MainActivity @@ -45,10 +52,40 @@ class NoteAdapter( var colorCategory: Boolean, ) : RecyclerView.Adapter() { var startDrag: ((NoteAdapter.NoteHolder) -> Unit)? = null + var setNoteLockState: ((NoteAdapter.NoteHolder, Note, Boolean) -> Unit)? = null + var setNotePinState: ((NoteAdapter.NoteHolder, Note, Boolean) -> Unit)? = null + var setNoteCheckedState: ((NoteAdapter.NoteHolder, Note, Boolean) -> Unit)? = null var notes: MutableList = ArrayList() private set + var saveContent: ((Note, NoteHolder) -> Unit)? = null private var listener: ((Note, NoteHolder) -> Unit)? = null + + private val _selection: MutableSet = mutableSetOf() + val selection: List + get() = _selection.map { notes[it] }.toList() + + var selectionMode: Boolean = false + @SuppressLint("NotifyDataSetChanged") + set(value) { + field = value + _selection.clear() + notifyDataSetChanged() + } + + constructor(adapter: NoteAdapter, notes: List? = null) : this( + adapter.activity, + adapter.mainActivityViewModel, + adapter.colorCategory + ) { + startDrag = adapter.startDrag + setNotePinState = adapter.setNotePinState + setNoteLockState = adapter.setNoteLockState + saveContent = adapter.saveContent + listener = adapter.listener + this.notes = (notes ?: adapter.notes).toMutableList() + } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NoteHolder { val itemView = LayoutInflater.from(parent.context) .inflate(R.layout.note_item, parent, false) @@ -102,6 +139,7 @@ class NoteAdapter( } } + try { when (currentNote.type) { DbContract.NoteEntry.TYPE_TEXT -> { if (showPreview) { @@ -120,12 +158,23 @@ class NoteAdapter( if (showPreview) { holder.imageViewcategory.setBackgroundColor(run { val value = TypedValue() - holder.itemView.context.theme.resolveAttribute(R.attr.colorSurfaceVariantLight, value, true) + holder.itemView.context.theme.resolveAttribute( + R.attr.colorSurfaceVariantLight, + value, + true + ) value.data }) - holder.imageViewcategory.minimumHeight = 200; holder.imageViewcategory.minimumWidth = 200 - Glide.with(activity).load(File("${activity.application.filesDir.path}/sketches${currentNote.content}")) - .placeholder(AppCompatResources.getDrawable(activity, R.drawable.ic_photo_icon_24dp)) + holder.imageViewcategory.minimumHeight = + 200; holder.imageViewcategory.minimumWidth = 200 + Glide.with(activity) + .load(File("${activity.application.filesDir.path}/sketches${currentNote.content}")) + .placeholder( + AppCompatResources.getDrawable( + activity, + R.drawable.ic_photo_icon_24dp + ) + ) .into(holder.imageViewcategory) } else { holder.imageViewcategory.setImageResource(R.drawable.ic_photo_icon_24dp) @@ -138,21 +187,105 @@ class NoteAdapter( if (showPreview) { val preview = mainActivityViewModel.checklistPreview(currentNote) - holder.textViewExtraText.text = "${preview.filter { it.first }.count()}/${preview.size}" - holder.textViewDescription.text = preview.take(3).joinToString(System.lineSeparator()) { it.second } + holder.textViewExtraText.text = + "${preview.filter { it.first }.count()}/${preview.size}" + holder.textViewDescription.text = + preview.take(3).joinToString(System.lineSeparator()) { it.second } holder.textViewDescription.maxLines = 3 } else { holder.textViewExtraText.text = "-/-" } } } + } catch (error: Exception) { + Log.d("NoteAdapter", "could not preview note.") + error.printStackTrace() + holder.textViewDescription.text = ContextCompat.getString(activity, R.string.preview_note_failed) + holder.itemView.setOnClickListener { + saveContent?.let { it(currentNote, holder) } + } + } // if the Description is empty, don't show it if (holder.textViewDescription.text.toString().isEmpty()) { holder.textViewDescription.visibility = View.GONE } holder.imageLock.visibility = if (currentNote.readonly > 0) View.VISIBLE else View.GONE - holder.dragHandle.visibility = if (mainActivityViewModel.isCustomOrdering()) View.VISIBLE else View.GONE + holder.pinHandle.visibility = if (currentNote.pinned > 0) View.VISIBLE else View.GONE + if (selectionMode) { + holder.dragHandle.visibility = View.GONE + holder.selectionCheckbox.visibility = View.VISIBLE + holder.selectionCheckbox.setOnClickListener { + if (_selection.contains(holder.bindingAdapterPosition)) { + _selection.remove(holder.bindingAdapterPosition) + } else { + _selection.add(holder.bindingAdapterPosition) + } + } + } else { + holder.dragHandle.visibility = if (mainActivityViewModel.isCustomOrdering()) View.VISIBLE else View.GONE + holder.selectionCheckbox.visibility = View.GONE + } + holder.space.visibility = if (currentNote.readonly > 0 || currentNote.pinned > 0 || currentNote.is_done > 0) View.VISIBLE else View.GONE + holder.checkedHandle.visibility = if (currentNote.is_done > 0) View.VISIBLE else View.GONE + holder.checkedHandle.setOnClickListener { setNoteCheckedState?.invoke(holder, notes[holder.bindingAdapterPosition], currentNote.is_done == 0) } + + val strikeThrough = PreferenceManager.getDefaultSharedPreferences(holder.itemView.context).getBoolean("settings_checklist_strike_items", true) + listOf(holder.textViewTitle, holder.textViewDescription).forEach { + it.apply { + paintFlags = if (currentNote.is_done > 0 && strikeThrough) { + paintFlags or Paint.STRIKE_THRU_TEXT_FLAG + } else { + paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() + } + } + } + + holder.itemView.setOnCreateContextMenuListener { menu, v, menuInfo -> + if (currentNote.readonly > 0) { + menu?.add(R.string.action_unlock) + ?.setIcon(R.drawable.lock_open_variant_outline) + ?.setOnMenuItemClickListener { + setNoteLockState?.invoke(holder, notes[holder.bindingAdapterPosition], false) + true + } + } else { + menu?.add(R.string.action_lock) + ?.setIcon(R.drawable.lock_outline) + ?.setOnMenuItemClickListener { + setNoteLockState?.invoke(holder, notes[holder.bindingAdapterPosition], true) + true + } + } + if (currentNote.pinned > 0) { + menu?.add(R.string.action_unpin) + ?.setIcon(R.drawable.ic_pin) + ?.setOnMenuItemClickListener { + setNotePinState?.invoke(holder, notes[holder.bindingAdapterPosition], false) + true + } + } else { + menu?.add( R.string.action_pin) + ?.setIcon(R.drawable.ic_pin) + ?.setOnMenuItemClickListener { + setNotePinState?.invoke(holder, notes[holder.bindingAdapterPosition], true) + true + } + } + if (currentNote.is_done > 0) { + menu?.add(R.string.action_not_done) + ?.setOnMenuItemClickListener { + setNoteCheckedState?.invoke(holder, notes[holder.bindingAdapterPosition], false) + true + } + } else { + menu?.add( R.string.action_done) + ?.setOnMenuItemClickListener { + setNoteCheckedState?.invoke(holder, notes[holder.bindingAdapterPosition], true) + true + } + } + } } override fun getItemCount(): Int { @@ -172,8 +305,12 @@ class NoteAdapter( val textViewExtraText: TextView val viewNoteItem: View val dragHandle: View + val pinHandle: View + val checkedHandle: View val imageLock: ImageView + val space: View + val selectionCheckbox: CheckBox init { textViewTitle = itemView.findViewById(R.id.text_view_title) @@ -183,6 +320,10 @@ class NoteAdapter( viewNoteItem = itemView.findViewById(R.id.note_item) dragHandle = itemView.findViewById(R.id.drag_handle) imageLock = itemView.findViewById(R.id.imageView_lock) + pinHandle = itemView.findViewById(R.id.pin_handle) + space = itemView.findViewById(R.id.note_item_title_space) + checkedHandle = itemView.findViewById(R.id.done_handle) + selectionCheckbox = itemView.findViewById(R.id.checkbox) itemView.setOnClickListener { bindingAdapterPosition.apply { if (listener != null && this != RecyclerView.NO_POSITION) { @@ -191,6 +332,8 @@ class NoteAdapter( } } } + + } fun setOnItemClickListener(listener: (Note, NoteHolder) -> Unit) { diff --git a/app/src/main/java/org/secuso/privacyfriendlynotes/ui/helper/ArrowKeyLinkTouchMovementMethod.kt b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/helper/ArrowKeyLinkTouchMovementMethod.kt new file mode 100644 index 00000000..874b06b5 --- /dev/null +++ b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/helper/ArrowKeyLinkTouchMovementMethod.kt @@ -0,0 +1,59 @@ +package org.secuso.privacyfriendlynotes.ui.helper + +import android.text.Selection +import android.text.Spannable +import android.text.method.ArrowKeyMovementMethod +import android.text.style.ClickableSpan +import android.view.MotionEvent +import android.widget.TextView + +class ArrowKeyLinkTouchMovementMethod : ArrowKeyMovementMethod() { + + override fun onTouchEvent(widget: TextView, buffer: Spannable, event: MotionEvent): Boolean { + val action = event.action + + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { + var x = event.x.toInt() + var y = event.y.toInt() + x -= widget.totalPaddingLeft + y -= widget.totalPaddingTop + + x += widget.scrollX + y += widget.scrollY + + val offset = widget.layout.let { + it.getOffsetForHorizontal( + it.getLineForVertical(y), + x.toFloat() + ) + } + + val link = buffer.getSpans(offset, offset, ClickableSpan::class.java) + + if (link.isNotEmpty()) { + if (action == MotionEvent.ACTION_UP) { + link[0].onClick(widget) + } else { + Selection.setSelection( + buffer, + buffer.getSpanStart(link[0]), + buffer.getSpanEnd(link[0]) + ) + } + return true + } + } + return super.onTouchEvent(widget, buffer, event) + } + + companion object { + private var instance: ArrowKeyLinkTouchMovementMethod? = null + + fun getInstance(): ArrowKeyLinkTouchMovementMethod { + if (instance == null) { + instance = ArrowKeyLinkTouchMovementMethod() + } + return instance!! + } + } +} diff --git a/app/src/main/java/org/secuso/privacyfriendlynotes/ui/helper/DraggableFAB.kt b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/helper/DraggableFAB.kt new file mode 100644 index 00000000..bc524b89 --- /dev/null +++ b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/helper/DraggableFAB.kt @@ -0,0 +1,59 @@ +package org.secuso.privacyfriendlynotes.ui.helper + +import android.view.MotionEvent +import android.view.View +import com.google.android.material.floatingactionbutton.FloatingActionButton +import kotlin.math.abs + +fun FloatingActionButton.makeDraggable(target: View = this) { + var downX = 0f + var downY = 0f + var dX = 0f + var dY = 0f + + val CLICK_DRAG_TOLERANCE = 10f + + this.setOnTouchListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + downX = event.rawX + downY = event.rawY + dX = target.x - downX + dY = target.y - downY + true + } + + MotionEvent.ACTION_MOVE -> { + val viewWidth = target.width + val viewHeight = target.height + + val viewParent = target.parent as View + val parentWidth = viewParent.width + val parentHeight = viewParent.height + + target.animate() + .x((parentWidth - viewWidth).toFloat().coerceAtMost(event.rawX + dX)) + .y((parentHeight - viewHeight).toFloat().coerceAtMost(event.rawY + dY)) + .setDuration(0) + .start() + true + } + + MotionEvent.ACTION_UP -> { + val upRawX = event.rawX + val upRawY = event.rawY + + val distanceX = upRawX - downX + val distanceY = upRawY - downY + + // If the finger didn't move much, trigger a click + if (abs(distanceX) < CLICK_DRAG_TOLERANCE && abs(distanceY) < CLICK_DRAG_TOLERANCE) { + performClick() + } + true + } + + else -> false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/secuso/privacyfriendlynotes/ui/main/MainActivity.kt b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/main/MainActivity.kt index abf0e6ba..910711b5 100644 --- a/app/src/main/java/org/secuso/privacyfriendlynotes/ui/main/MainActivity.kt +++ b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/main/MainActivity.kt @@ -19,8 +19,10 @@ import android.content.Intent import android.graphics.Rect import android.os.Bundle import android.preference.PreferenceManager +import android.text.Html import android.util.Log import android.util.TypedValue +import android.view.ActionMode import android.view.ContextThemeWrapper import android.view.Menu import android.view.MenuInflater @@ -35,6 +37,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate +import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.Toolbar import androidx.arch.core.util.Function @@ -47,6 +50,7 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.navigation.NavigationView import kotlinx.coroutines.CoroutineScope @@ -56,6 +60,7 @@ import kotlinx.coroutines.runBlocking import org.secuso.privacyfriendlynotes.R import org.secuso.privacyfriendlynotes.model.SortingOrder import org.secuso.privacyfriendlynotes.room.DbContract +import org.secuso.privacyfriendlynotes.room.model.Category import org.secuso.privacyfriendlynotes.room.model.Note import org.secuso.privacyfriendlynotes.ui.AboutActivity import org.secuso.privacyfriendlynotes.ui.HelpActivity @@ -71,9 +76,11 @@ import org.secuso.privacyfriendlynotes.ui.notes.BaseNoteActivity import org.secuso.privacyfriendlynotes.ui.notes.ChecklistNoteActivity import org.secuso.privacyfriendlynotes.ui.notes.SketchActivity import org.secuso.privacyfriendlynotes.ui.notes.TextNoteActivity +import java.io.File import java.io.FileOutputStream import java.io.OutputStream import java.util.Collections +import kotlin.math.max /** @@ -87,9 +94,13 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte //New Room variables private val mainActivityViewModel: MainActivityViewModel by lazy { ViewModelProvider(this)[MainActivityViewModel::class.java] } lateinit var adapter: NoteAdapter + var pinnedAdapter: NoteAdapter? = null private val searchView: SearchView by lazy { findViewById(R.id.searchViewFilter) } private lateinit var fab: MainFABFragment private var skipNextNoteFlow = false + private val separatePinnedNotes by lazy { PreferenceManager.getDefaultSharedPreferences(this).getBoolean("settings_pinned_notes_fixed", true) } + + val defaultCategory by lazy { Category(0,resources.getString(R.string.default_category), null) } // A launcher to receive and react to a NoteActivity returning a category // The category is used to set the selectecCategory @@ -129,6 +140,83 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } } + private var noteToExport: Note? = null + private val saveSingleNoteToExternalStorageResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + result.data?.data?.let { uri -> + val fileOutputStream = contentResolver.openOutputStream(uri) + if (fileOutputStream == null) { + return@registerForActivityResult + } + CoroutineScope(Dispatchers.IO).launch { + val content = when (noteToExport?.type) { + DbContract.NoteEntry.TYPE_TEXT -> noteToExport!!.content + DbContract.NoteEntry.TYPE_AUDIO -> File(filesDir.path + "/audio_notes" + noteToExport!!.content).readBytes().toString() + DbContract.NoteEntry.TYPE_SKETCH -> File(filesDir.path + "/sketches" + noteToExport!!.content).readBytes().toString() + DbContract.NoteEntry.TYPE_CHECKLIST -> noteToExport!!.content + else -> return@launch + } + fileOutputStream.bufferedWriter().write(content) + runOnUiThread { + Toast.makeText( + applicationContext, + String.format(getString(R.string.toast_file_exported_to), uri.toString()), + Toast.LENGTH_LONG + ).show() + } + } + } + } + } + + private val actionModeChangeCategory = object : ActionMode.Callback { + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + return if (item.itemId == R.id.action_mode_select_category) { + val categories = mainActivityViewModel.categoriesSync.toMutableList().apply { + add(0, defaultCategory) + } + var selectedCategory: Category? = null + MaterialAlertDialogBuilder(ContextThemeWrapper(this@MainActivity, R.style.AppTheme_PopupOverlay_DialogAlert)) + .setTitle(R.string.dialog_change_multiple_category_title) + .setSingleChoiceItems(categories.map { it.name }.toTypedArray(), -1) { _, which -> + selectedCategory = categories[which] + } + .setPositiveButton(R.string.dialog_change_multiple_category_btn) { _, _ -> + mainActivityViewModel.updateAll( + adapter.selection.map { + it.category = selectedCategory!!._id + it + } + ) + adapter.selectionMode = false + mode.finish() + } + .setNegativeButton(android.R.string.cancel) { _,_ -> + adapter.selectionMode = false + mode.finish() + } + .show() + true + } else { + false + } + } + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.main_change_category, menu) + return true + } + + override fun onDestroyActionMode(mode: ActionMode?) { + adapter.selectionMode = false + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + adapter.selectionMode = true + return true + } + } + override fun onCreate(savedInstanceState: Bundle?) { supportFragmentManager.fragmentFactory = object : FragmentFactory() { override fun instantiate(classLoader: ClassLoader, className: String): Fragment { @@ -169,29 +257,56 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte //Fill from Room database val recyclerView = findViewById(R.id.recycler_view) recyclerView.layoutManager = LinearLayoutManager(this) - recyclerView.setHasFixedSize(true) adapter = NoteAdapter( this, mainActivityViewModel, PreferenceManager.getDefaultSharedPreferences(this).getBoolean("settings_color_category", true) && mainActivityViewModel.getCategory() == CAT_ALL ) + adapter.saveContent = { note, _ -> + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.putExtra(Intent.EXTRA_TITLE, note.name + ".txt") + intent.type = "text/plain" + noteToExport = note + saveSingleNoteToExternalStorageResultLauncher.launch(intent) + } recyclerView.adapter = adapter lifecycleScope.launch { mainActivityViewModel.activeNotes.collect { notes -> if (!skipNextNoteFlow) { - adapter.setNotes(notes) + if (separatePinnedNotes) { + val index = max(0, notes.indexOfFirst { it.pinned == 0 }) + pinnedAdapter!!.setNotes(notes.subList(0, index)) + adapter.setNotes(notes.subList(index, notes.size)) + } else { + adapter.setNotes(notes) + } } skipNextNoteFlow = false } } + // Delete all trashed notes which are old enough + lifecycleScope.launch { + PreferenceManager.getDefaultSharedPreferences(this@MainActivity).apply { + if (getBoolean("settings_auto_delete_trashed_notes", false)) { + val time = getString("settings_auto_delete_trashed_notes_time", (7 * 24 * 60 * 60 * 1000L).toString())!!.toLong() + mainActivityViewModel.deleteOldTrashedNotes(time) + } + } + } + val ith = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) { override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { val to = target.bindingAdapterPosition val from = viewHolder.bindingAdapterPosition + // swapping with pinned notes is not possible. + if ((adapter.notes[to].pinned > 0) != (adapter.notes[from].pinned > 0)) { + return false + } // swap custom_orders val temp = adapter.notes[from].custom_order adapter.notes[from].custom_order = adapter.notes[to].custom_order @@ -229,9 +344,103 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte trashNote(note) } } + + override fun isLongPressDragEnabled() = false }) ith.attachToRecyclerView(recyclerView) adapter.startDrag = { holder -> ith.startDrag(holder) } + adapter.setNoteLockState = { holder, note, state -> + note.readonly = if (state) { 1 } else { 0 } + mainActivityViewModel.update(note) + skipNextNoteFlow = true + adapter.notifyItemChanged(holder.bindingAdapterPosition) + } + adapter.setNotePinState = { _, note, state -> + note.pinned = if (state) { 1 } else { 0 } + mainActivityViewModel.update(note) + } + adapter.setNoteCheckedState = { holder, note, state -> + note.is_done = if (state) 1 else 0 + skipNextNoteFlow = true + mainActivityViewModel.update(note) + adapter.notifyItemChanged(holder.bindingAdapterPosition) + } + + + if (separatePinnedNotes) { + pinnedAdapter = NoteAdapter(adapter, mutableListOf()) + pinnedAdapter?.setNoteLockState = { holder, note, state -> + note.readonly = if (state) { 1 } else { 0 } + mainActivityViewModel.update(note) + skipNextNoteFlow = true + pinnedAdapter?.notifyItemChanged(holder.bindingAdapterPosition) + } + pinnedAdapter?.setNotePinState = { _, note, state -> + note.pinned = if (state) { 1 } else { 0 } + mainActivityViewModel.update(note) + } + pinnedAdapter?.setNoteCheckedState = { holder, note, state -> + note.is_done = if (state) 1 else 0 + skipNextNoteFlow = true + mainActivityViewModel.update(note) + pinnedAdapter?.notifyItemChanged(holder.bindingAdapterPosition) + } + val pinnedIth = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) { + override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { + val to = target.bindingAdapterPosition + val from = viewHolder.bindingAdapterPosition + + // swapping with pinned notes is not possible. + if ((pinnedAdapter!!.notes[to].pinned > 0) != (pinnedAdapter!!.notes[from].pinned > 0)) { + return false + } + // swap custom_orders + val temp = pinnedAdapter!!.notes[from].custom_order + pinnedAdapter!!.notes[from].custom_order = pinnedAdapter!!.notes[to].custom_order + pinnedAdapter!!.notes[to].custom_order = temp + Collections.swap(adapter.notes, from, to) + skipNextNoteFlow = true + mainActivityViewModel.updateAll(listOf(pinnedAdapter!!.notes[from], pinnedAdapter!!.notes[to])) + + pinnedAdapter!!.notifyItemMoved(to, from) + + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val note = pinnedAdapter!!.getNoteAt(viewHolder.bindingAdapterPosition) + + // Do not delete the note if it is readonly + if (note.readonly > 0) { + pinnedAdapter!!.notifyItemChanged(viewHolder.bindingAdapterPosition) + return + } + + if (PreferenceManager.getDefaultSharedPreferences(this@MainActivity).getBoolean("settings_dialog_on_trashing", false)) { + MaterialAlertDialogBuilder(ContextThemeWrapper(this@MainActivity, R.style.AppTheme_PopupOverlay_DialogAlert)) + .setTitle(String.format(getString(R.string.dialog_delete_title), note.name)) + .setMessage(String.format(getString(R.string.dialog_delete_message), note.name)) + .setPositiveButton(R.string.dialog_option_delete) { _, _ -> + pinnedAdapter!!.notifyItemRemoved(viewHolder.bindingAdapterPosition) + trashNote(note) + } + .setNegativeButton(android.R.string.cancel, null) + .setOnDismissListener { pinnedAdapter!!.notifyItemChanged(viewHolder.bindingAdapterPosition) } + .show() + } else { + trashNote(note) + } + } + + override fun isLongPressDragEnabled() = false + }) + pinnedAdapter!!.startDrag = { holder -> pinnedIth.startDrag(holder) } + val pinnedRecyclerView = findViewById(R.id.pinned_notes) + pinnedRecyclerView.layoutManager = LinearLayoutManager(this) + pinnedRecyclerView.adapter = pinnedAdapter + pinnedIth.attachToRecyclerView(pinnedRecyclerView) + } + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextChange(newText: String): Boolean { mainActivityViewModel.setFilter(newText) @@ -254,26 +463,32 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte /* * Handels when a note is clicked. */ - adapter.setOnItemClickListener { (_id, name, content, type, category, in_trash): Note, _ -> - val launchActivity = - Function, Void?> { activity: Class? -> - val i = Intent(application, activity) - i.putExtra(BaseNoteActivity.EXTRA_ID, _id) - i.putExtra(BaseNoteActivity.EXTRA_TITLE, name) - i.putExtra(BaseNoteActivity.EXTRA_CONTENT, content) - i.putExtra(BaseNoteActivity.EXTRA_CATEGORY, category) - i.putExtra(BaseNoteActivity.EXTRA_ISTRASH, in_trash) - startActivity(i) - null + listOfNotNull(adapter, pinnedAdapter) + .forEach { + it.setOnItemClickListener { (_id, name, content, type, category, in_trash): Note, _ -> + val launchActivity = + Function, Void?> { activity: Class? -> + val i = Intent(application, activity) + i.putExtra(BaseNoteActivity.EXTRA_ID, _id) + i.putExtra(BaseNoteActivity.EXTRA_TITLE, name) + i.putExtra(BaseNoteActivity.EXTRA_CONTENT, content) + i.putExtra(BaseNoteActivity.EXTRA_CATEGORY, category) + i.putExtra(BaseNoteActivity.EXTRA_ISTRASH, in_trash) + startActivity(i) + null + } + when (type) { + DbContract.NoteEntry.TYPE_TEXT -> launchActivity.apply(TextNoteActivity::class.java) + DbContract.NoteEntry.TYPE_AUDIO -> launchActivity.apply(AudioNoteActivity::class.java) + DbContract.NoteEntry.TYPE_SKETCH -> launchActivity.apply(SketchActivity::class.java) + DbContract.NoteEntry.TYPE_CHECKLIST -> launchActivity.apply( + ChecklistNoteActivity::class.java + ) } - when (type) { - DbContract.NoteEntry.TYPE_TEXT -> launchActivity.apply(TextNoteActivity::class.java) - DbContract.NoteEntry.TYPE_AUDIO -> launchActivity.apply(AudioNoteActivity::class.java) - DbContract.NoteEntry.TYPE_SKETCH -> launchActivity.apply(SketchActivity::class.java) - DbContract.NoteEntry.TYPE_CHECKLIST -> launchActivity.apply(ChecklistNoteActivity::class.java) + fab.close() } - fab.close() } + val theme = PreferenceManager.getDefaultSharedPreferences(this).getString("settings_day_night_theme", "-1") AppCompatDelegate.setDefaultNightMode(theme!!.toInt()) } @@ -335,6 +550,17 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte dialog.chooseSortingOption() } else if (id == R.id.action_export_all) { exportAllNotes() + } else if (id == R.id.action_delete_all_finished) { + val notes = listOfNotNull(adapter, pinnedAdapter).flatMap { + it.notes.filter { note -> note.is_done > 0 } + }.map { + it.in_trash = 1 + it + } + mainActivityViewModel.updateAll(notes) + Toast.makeText(this@MainActivity, getString(R.string.toast_deleted_multiple), Toast.LENGTH_SHORT).show() + } else if (id == R.id.action_change_multiple_category) { + startActionMode(actionModeChangeCategory) } return super.onOptionsItemSelected(item) } @@ -391,10 +617,13 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte //Get the rest from the database lifecycleScope.launch { - mainActivityViewModel.categories.collect { - navMenu.add(R.id.drawer_group2, 0, Menu.NONE, getString(R.string.default_category)).setIcon(R.drawable.ic_label_black_24dp) - for ((id, name) in it) { - navMenu.add(R.id.drawer_group2, id, Menu.NONE, name).setIcon(R.drawable.ic_label_black_24dp) + mainActivityViewModel.categoriesWithDoneInformation.collect { + navMenu.removeItem(0) + val (name, _id, done, all) = it.first() + navMenu.add(R.id.drawer_group2, 0, Menu.NONE, String.format("%s \t %s/%s", getString(R.string.default_category), done, all)).setIcon(R.drawable.ic_label_black_24dp) + for ((name, _id, done, all) in it.subList(1, it.size)) { + navMenu.removeItem(_id) + navMenu.add(R.id.drawer_group2, _id, Menu.NONE, String.format("%s \t %s/%s", name, done, all)).setIcon(R.drawable.ic_label_black_24dp) } } } diff --git a/app/src/main/java/org/secuso/privacyfriendlynotes/ui/main/MainActivityViewModel.kt b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/main/MainActivityViewModel.kt index 64fce3c8..c7b38eb1 100644 --- a/app/src/main/java/org/secuso/privacyfriendlynotes/ui/main/MainActivityViewModel.kt +++ b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/main/MainActivityViewModel.kt @@ -35,6 +35,7 @@ import org.secuso.privacyfriendlynotes.preference.PreferenceKeys import org.secuso.privacyfriendlynotes.room.DbContract import org.secuso.privacyfriendlynotes.room.NoteDatabase import org.secuso.privacyfriendlynotes.room.model.Category +import org.secuso.privacyfriendlynotes.room.model.CategoryWithCompleteInformation import org.secuso.privacyfriendlynotes.room.model.Note import org.secuso.privacyfriendlynotes.ui.notes.AudioNoteActivity import org.secuso.privacyfriendlynotes.ui.notes.ChecklistNoteActivity @@ -42,6 +43,7 @@ import org.secuso.privacyfriendlynotes.ui.notes.SketchActivity import org.secuso.privacyfriendlynotes.ui.notes.TextNoteActivity import org.secuso.privacyfriendlynotes.ui.util.ChecklistUtil import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.io.File import java.io.FileInputStream import java.io.FileNotFoundException @@ -81,7 +83,12 @@ class MainActivityViewModel(application: Application) : AndroidViewModel(applica .filterCategories() .filterNotes() .sortNotes() + .sortPinned() val categories: Flow> = repository.categoryDao().allCategories + val categoriesSync: List + get() = repository.categoryDao().allCategoriesSync + + val categoriesWithDoneInformation: Flow> = repository.categoryDao().allCategoriesWithDoneInformation private val filesDir: File = application.filesDir private val resources: Resources = application.resources @@ -150,6 +157,8 @@ class MainActivityViewModel(application: Application) : AndroidViewModel(applica } else if (note.type == DbContract.NoteEntry.TYPE_SKETCH) { File(filesDir.path + "/sketches" + note.content).delete() File(filesDir.path + "/sketches" + note.content.substring(0, note.content.length - 3) + "jpg").delete() + } else if (note.type == DbContract.NoteEntry.TYPE_TEXT) { + TextNoteActivity.getImageFilePathForId(filesDir, note._id).delete() } } } @@ -195,6 +204,10 @@ class MainActivityViewModel(application: Application) : AndroidViewModel(applica return this.map { it.sortedWith(ordering.comparator()).apply { return@map if (reversed.value) this.reversed() else this } } } + private fun Flow>.sortPinned(): Flow> { + return this.map { it.sortedWith { a,b -> -a.pinned.compareTo(b.pinned) } } + } + private fun Flow>.filterCategories(): Flow> { return this.map { it.filter { note -> @@ -227,6 +240,15 @@ class MainActivityViewModel(application: Application) : AndroidViewModel(applica } } + fun deleteOldTrashedNotes(age: Long) { + viewModelScope.launch(Dispatchers.Default) { + repository.noteDao().getAllTrashedNotesOlderThan(System.currentTimeMillis() - age) + .forEach { + delete(it) + } + } + } + private fun loadSketchBitmap(file: String): BitmapDrawable? { File("${filesDir.path}/sketches${file}").apply { if (exists()) { @@ -262,25 +284,31 @@ class MainActivityViewModel(application: Application) : AndroidViewModel(applica fun zipAllNotes(notes: List, output: OutputStream) { ZipOutputStream(output).use { zipOut -> + val categories = + repository.categoryDao().allCategoriesSync.associate { it._id to it.name }.toMutableMap() + categories[CAT_ALL] = "default" notes.forEach { note -> val name = note.name.replace("/", "_") lateinit var entry: String lateinit var inputStream: InputStream when(note.type) { DbContract.NoteEntry.TYPE_TEXT -> { - entry = "text/" + name + "_" + System.currentTimeMillis() + "_" + TextNoteActivity.getFileExtension() - inputStream = ByteArrayInputStream(note.content.toByteArray()) + entry = categories[note.category] + "/text/" + name + "_" + note._id + "_" + TextNoteActivity.getFileExtension(filesDir, note._id) + // This could be a problem for large Notes, so if the app crashes due to OOM + val out = ByteArrayOutputStream() + TextNoteActivity.exportWithImages(filesDir, note.content, note._id, out) + inputStream = ByteArrayInputStream(out.toByteArray()) } DbContract.NoteEntry.TYPE_CHECKLIST -> { - entry = "checklist/" + name + "_" + System.currentTimeMillis() + "_" + ChecklistNoteActivity.getFileExtension() + entry = categories[note.category] + "/checklist/" + name + "_" + note._id + "_" + ChecklistNoteActivity.getFileExtension() inputStream = ByteArrayInputStream(note.content.toByteArray()) } DbContract.NoteEntry.TYPE_AUDIO -> { - entry = "audio/" + name + "_" + System.currentTimeMillis() + "_" + AudioNoteActivity.getFileExtension() + entry = categories[note.category] + "/audio/" + name + "_" + note._id + "_" + AudioNoteActivity.getFileExtension() inputStream = FileInputStream(File(filesDir.path + "/audio_notes" + note.content)) } DbContract.NoteEntry.TYPE_SKETCH -> { - entry ="sketch/" + name + "_" + System.currentTimeMillis() + "_" + SketchActivity.getFileExtension() + entry = categories[note.category] + "/sketch/" + name + "_" + note._id + "_" + SketchActivity.getFileExtension() inputStream = FileInputStream(File(filesDir.path + "/sketches" + note.content)) } } diff --git a/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/BaseNoteActivity.kt b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/BaseNoteActivity.kt index 78bf9680..af527681 100644 --- a/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/BaseNoteActivity.kt +++ b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/BaseNoteActivity.kt @@ -114,9 +114,11 @@ abstract class BaseNoteActivity(noteType: Int) : AppCompatActivity(), View.OnCli protected var shouldSaveOnPause = true private var hasChanged = false private var currentCat = 0 - private var id = -1 + protected var id = -1 + private set private val isLockedState: MutableStateFlow = MutableStateFlow(false) protected val isLocked: StateFlow = isLockedState + private var initialLockState: Boolean? = null private var lockedItem: MenuItem? = null @@ -128,6 +130,7 @@ abstract class BaseNoteActivity(noteType: Int) : AppCompatActivity(), View.OnCli private val noteType by lazy { noteType } protected abstract fun onNoteSave(name: String, category: Int): ActionResult + protected open fun onNoteWasSaved() {} protected abstract fun onLoadActivity() protected abstract fun onSaveExternalStorage(outputStream: OutputStream) @@ -155,6 +158,7 @@ abstract class BaseNoteActivity(noteType: Int) : AppCompatActivity(), View.OnCli val catName = catSelection.text.toString() if (catName == getString(R.string.default_category)) { currentCat = 0 + hasChanged = hasChanged or (savedCat != 0) return@setOnDismissListener } currentCat = -1 @@ -188,7 +192,9 @@ abstract class BaseNoteActivity(noteType: Int) : AppCompatActivity(), View.OnCli etName.isEnabled = !isLocked.value catSelection.isEnabled = !isLocked.value - hasChanged = true + if (initialLockState == null) { + initialLockState = isLocked.value + } } } } @@ -248,6 +254,12 @@ abstract class BaseNoteActivity(noteType: Int) : AppCompatActivity(), View.OnCli } isLoadedNote = id != -1 + if (id == -1) { + // We do not want to keep this default value as it does not behave nicely with the activity lifecycle if the activity depends on the note id + // e.g. storing files associated to the id like images for text notes. + id = createEditNoteViewModel.nextNoteId() + } + // Should we set a custom font size? val sp = PreferenceManager.getDefaultSharedPreferences(this) if (sp.getBoolean(SettingsActivity.PREF_CUSTOM_FONT, false)) { @@ -261,6 +273,7 @@ abstract class BaseNoteActivity(noteType: Int) : AppCompatActivity(), View.OnCli displayCategoryDialog() } + //fill in values if update if (isLoadedNote) { window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN) @@ -385,6 +398,7 @@ abstract class BaseNoteActivity(noteType: Int) : AppCompatActivity(), View.OnCli R.id.action_lock -> { isLockedState.value = !isLockedState.value + saveNote(force = true) } R.id.action_share -> { @@ -453,15 +467,12 @@ abstract class BaseNoteActivity(noteType: Int) : AppCompatActivity(), View.OnCli @Deprecated("Deprecated in Java") override fun onBackPressed() { - shouldSaveOnPause = true + if (hasChanged || initialLockState != isLocked.value) { + shouldSaveOnPause = true + } super.onBackPressed() } - override fun onResume() { - super.onResume() - loadActivity(false) - } - override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, @@ -507,8 +518,8 @@ abstract class BaseNoteActivity(noteType: Int) : AppCompatActivity(), View.OnCli etName.setText(note.name) } note.readonly = if (isLocked.value) 1 else 0 + note._id = id if (isLoadedNote) { - note._id = id if (showNotSaved) { //Wait for job to complete runBlocking { @@ -521,7 +532,11 @@ abstract class BaseNoteActivity(noteType: Int) : AppCompatActivity(), View.OnCli } else { id = createEditNoteViewModel.insert(note) Toast.makeText(applicationContext, R.string.toast_saved, Toast.LENGTH_SHORT).show() + // ensure that the note is only inserted once, even if the activity was paused + isLoadedNote = true } + initialLockState = isLocked.value + onNoteWasSaved() return true } @@ -592,12 +607,17 @@ abstract class BaseNoteActivity(noteType: Int) : AppCompatActivity(), View.OnCli } } + private var saveToExternalStorageCustomAction: ((OutputStream) -> Unit)? = null private val saveToExternalStorageResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == Activity.RESULT_OK) { result.data?.data?.let { uri -> val fileOutputStream: OutputStream? = contentResolver.openOutputStream(uri) fileOutputStream?.let { - onSaveExternalStorage(it) + if (saveToExternalStorageCustomAction != null) { + saveToExternalStorageCustomAction?.invoke(it) + } else { + onSaveExternalStorage(it) + } Toast.makeText( applicationContext, String.format(getString(R.string.toast_file_exported_to), uri.toString()), @@ -609,11 +629,16 @@ abstract class BaseNoteActivity(noteType: Int) : AppCompatActivity(), View.OnCli } } - private fun saveToExternalStorage() { + protected fun saveToExternalStorage( + extension: String? = null, + mimeType: String? = null, + saveToExternalStorageAction: ((OutputStream) -> Unit)? = null + ) { val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) intent.addCategory(Intent.CATEGORY_OPENABLE) - intent.putExtra(Intent.EXTRA_TITLE, etName.text.toString() + getFileExtension()) - intent.type = getMimeType() + intent.putExtra(Intent.EXTRA_TITLE, etName.text.toString() + (extension ?: getFileExtension())) + intent.type = mimeType ?: getMimeType() + saveToExternalStorageCustomAction = saveToExternalStorageAction saveToExternalStorageResultLauncher.launch(intent) } @@ -683,6 +708,20 @@ abstract class BaseNoteActivity(noteType: Int) : AppCompatActivity(), View.OnCli } } + fun newNote(content: String, type: Int, afterUpdate: (Int) -> Unit) { + saveNote(force = true) + shouldSaveOnPause = false + createEditNoteViewModel.getNoteByID(id.toLong()).observe(this) { + if (it != null) { + it.content = content + it.type = type + it._id = 0 + val id = createEditNoteViewModel.insert(it) + afterUpdate(id) + } + } + } + class ActionResult(private val status: Boolean, val ok: O?, val err: E? = null) { fun isOk(): Boolean { return this.status diff --git a/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/ChecklistNoteActivity.kt b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/ChecklistNoteActivity.kt index 50a9b475..161e02ca 100644 --- a/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/ChecklistNoteActivity.kt +++ b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/ChecklistNoteActivity.kt @@ -19,6 +19,7 @@ import android.os.Bundle import android.text.Html import android.text.SpannedString import android.view.ContextThemeWrapper +import android.view.KeyEvent import android.view.Menu import android.view.MenuItem import android.view.View @@ -72,7 +73,7 @@ class ChecklistNoteActivity : BaseNoteActivity(DbContract.NoteEntry.TYPE_CHECKLI override fun onLoadActivity() { etNewItem.setOnEditorActionListener { _, _, event -> - if (event == null && etNewItem.text.isNotEmpty()) { + if ((event == null || event.keyCode == KeyEvent.KEYCODE_ENTER) && etNewItem.text.isNotEmpty()) { addItem() } return@setOnEditorActionListener true @@ -122,6 +123,20 @@ class ChecklistNoteActivity : BaseNoteActivity(DbContract.NoteEntry.TYPE_CHECKLI override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { + R.id.action_sort_alphabetical -> { + val next = when (adapter.sortingOption) { + ChecklistAdapter.SortingOption.NONE -> ChecklistAdapter.SortingOption.ASCENDING + ChecklistAdapter.SortingOption.ASCENDING -> ChecklistAdapter.SortingOption.DESCENDING + ChecklistAdapter.SortingOption.DESCENDING -> ChecklistAdapter.SortingOption.NONE + } + val icon = when (next) { + ChecklistAdapter.SortingOption.NONE -> R.drawable.ic_sort_by_alpha_icon_24dp + ChecklistAdapter.SortingOption.ASCENDING -> R.drawable.ic_sort_by_alpha_asc_icon_24dp + ChecklistAdapter.SortingOption.DESCENDING -> R.drawable.ic_sort_by_alpha_desc_icon_24dp + } + adapter.sortingOption = next + item.setIcon(icon) + } R.id.action_convert_to_note -> { MaterialAlertDialogBuilder(ContextThemeWrapper(this@ChecklistNoteActivity, R.style.AppTheme_PopupOverlay_DialogAlert)) .setTitle(R.string.dialog_convert_to_text_title) @@ -140,6 +155,24 @@ class ChecklistNoteActivity : BaseNoteActivity(DbContract.NoteEntry.TYPE_CHECKLI } R.id.action_select_all -> adapter.selectAll() R.id.action_deselect_all -> adapter.deselectAll() + R.id.action_new_checked -> { + val items = adapter.getItems().filter { it.state }.map { ChecklistItem(false, it.name) } + super.newNote(ChecklistUtil.json(items).toString(), DbContract.NoteEntry.TYPE_CHECKLIST) { + val i = Intent(application, ChecklistNoteActivity::class.java) + i.putExtra(EXTRA_ID, it) + startActivity(i) + finish() + } + } + R.id.action_new_unchecked -> { + val items = adapter.getItems().filter { !it.state } + super.newNote(ChecklistUtil.json(items).toString(), DbContract.NoteEntry.TYPE_CHECKLIST) { + val i = Intent(application, ChecklistNoteActivity::class.java) + i.putExtra(EXTRA_ID, it) + startActivity(i) + finish() + } + } else -> {} } diff --git a/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/CreateEditNoteViewModel.kt b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/CreateEditNoteViewModel.kt index 115953e9..9eef6fba 100644 --- a/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/CreateEditNoteViewModel.kt +++ b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/CreateEditNoteViewModel.kt @@ -102,6 +102,10 @@ class CreateEditNoteViewModel(application: Application) : AndroidViewModel(appli return _categoryName } + fun nextNoteId(): Int { + return database.noteDao().getNextId() + } + /** * Returns id */ diff --git a/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/SketchActivity.kt b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/SketchActivity.kt index de20684e..74a8c38f 100644 --- a/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/SketchActivity.kt +++ b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/SketchActivity.kt @@ -222,6 +222,24 @@ class SketchActivity : BaseNoteActivity(DbContract.NoteEntry.TYPE_SKETCH), OnDia } } + R.id.action_export_png -> { + super.saveToExternalStorage( + extension = ".png", + mimeType = "image/png" + ) { + val bm = BitmapDrawable(resources, mFilePath).bitmap.overlay(drawView.bitmap) + val canvas = Canvas(bm) + canvas.drawColor(Color.WHITE) + canvas.drawBitmap( + BitmapDrawable(resources, mFilePath).bitmap.overlay(drawView.bitmap), + 0f, + 0f, + null + ) + bm.compress(Bitmap.CompressFormat.PNG, 100, it) + } + } + else -> {} } return super.onOptionsItemSelected(item) diff --git a/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/TextNoteActivity.kt b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/TextNoteActivity.kt index 4875d001..9db404f9 100644 --- a/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/TextNoteActivity.kt +++ b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/TextNoteActivity.kt @@ -14,26 +14,35 @@ package org.secuso.privacyfriendlynotes.ui.notes import android.app.Activity +import android.content.Context import android.content.Intent import android.content.res.ColorStateList +import android.graphics.Bitmap import android.graphics.Color import android.graphics.Typeface +import android.graphics.drawable.Drawable import android.net.Uri import android.os.Bundle import android.text.Html +import android.text.InputType import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned import android.text.method.LinkMovementMethod +import android.text.method.TextKeyListener import android.text.style.StyleSpan import android.text.style.UnderlineSpan +import android.util.Log import android.view.ContextThemeWrapper import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.EditText import android.widget.Toast +import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.FileProvider +import androidx.core.text.HtmlCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.MutableLiveData import androidx.lifecycle.lifecycleScope @@ -45,11 +54,29 @@ import kotlinx.coroutines.launch import org.secuso.privacyfriendlynotes.R import org.secuso.privacyfriendlynotes.room.DbContract import org.secuso.privacyfriendlynotes.room.model.Note +import org.secuso.privacyfriendlynotes.ui.helper.ArrowKeyLinkTouchMovementMethod +import org.secuso.privacyfriendlynotes.ui.helper.makeDraggable import org.secuso.privacyfriendlynotes.ui.util.ChecklistUtil import java.io.File +import java.io.FileOutputStream import java.io.InputStreamReader import java.io.OutputStream import java.io.PrintWriter +import java.util.jar.Manifest +import kotlin.io.path.exists +import androidx.core.text.toHtml +import androidx.core.text.parseAsHtml +import java.io.ByteArrayInputStream +import java.io.DataInputStream +import java.io.FileInputStream +import java.nio.file.StandardCopyOption +import java.util.UUID +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.io.path.Path +import kotlin.io.path.moveTo +import kotlin.properties.Delegates +import kotlin.random.Random /** * Activity that allows to add, edit and delete text notes. @@ -60,6 +87,9 @@ class TextNoteActivity : BaseNoteActivity(DbContract.NoteEntry.TYPE_TEXT) { private val boldBtn: FloatingActionButton by lazy { findViewById(R.id.btn_bold) } private val italicsBtn: FloatingActionButton by lazy { findViewById(R.id.btn_italics) } private val underlineBtn: FloatingActionButton by lazy { findViewById(R.id.btn_underline) } + private val galleryBtn: FloatingActionButton by lazy { findViewById(R.id.btn_gallery) } + private val cameraBtn: FloatingActionButton by lazy { findViewById(R.id.btn_camera) } + private var lastCursorPosition = 0 private val isBold = MutableLiveData(false) private val isItalic = MutableLiveData(false) @@ -71,11 +101,84 @@ class TextNoteActivity : BaseNoteActivity(DbContract.NoteEntry.TYPE_TEXT) { private val fileSizeLimit by lazy { PreferenceManager.getDefaultSharedPreferences(this@TextNoteActivity).getString("settings_import_text_file_size_limit", "10000")?.toInt() ?: 10000 } private val fileCharLimit by lazy { PreferenceManager.getDefaultSharedPreferences(this@TextNoteActivity).getString("settings_import_text_file_char_limit", "1000")?.toInt() ?: 1000 } + // Remember all loaded images to delete all not used images at activity end + private val loadedImages = mutableListOf() + val htmlImageGetter = Html.ImageGetter { source -> + try { + // This is intentionally not getImageFilePathForId as we want to produce a correct html file on export, + // Which points correctly to the images directory next to the exported source file. + val file = File("${filesDir.path}/text_notes/${id}/images", source) + + if (file.exists()) { + val drawable = Drawable.createFromPath(file.absolutePath) + drawable?.let { + it.setBounds(0, 0, it.intrinsicWidth, it.intrinsicHeight) + } + loadedImages.add(source) + return@ImageGetter drawable + } + } catch (e: Exception) { + e.printStackTrace() + } + null + } + + private var imageFile: String? = null + + val takePictureLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { + Log.d("TextNoteActivity", "Received picture callback with result $it") + if (!it || imageFile == null) { + return@registerForActivityResult + } + // image was successfully stored in our requested location + // so add the image to the text field + insertImageToText(imageFile!!) + } + val requestCameraPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { + if (it) { + imageFile = System.currentTimeMillis().toString() + ".png" + getImageFilePathForId(id).apply { + mkdirs() + val file = File(this, imageFile!!) + val uri = FileProvider.getUriForFile(this@TextNoteActivity, "org.secuso.privacyfriendlynotes", file) + Log.d("TextNoteActivity", "Now attempting to take picture for ${imageFile} and uri ${uri}") + takePictureLauncher.launch(uri) + } + } + } + + val pickImageLauncher = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri == null) { + return@registerForActivityResult + } + val file = "${System.currentTimeMillis()}.png" + val inputStream = contentResolver.openInputStream(uri) + getImageFilePathForId(id).apply { + mkdirs() + val outputStream = FileOutputStream(File(this, file)) + inputStream.use { input -> outputStream.use { input?.copyTo(it) } } + } + insertImageToText(file) + } + + private fun insertImageToText(file: String) { + // Use a random anchor to obtain the current cursor location and insert the image there. + val anchor = UUID.randomUUID().toString() + var html = SpannableStringBuilder(etContent.text).apply { + insert(etContent.selectionStart, anchor) + }.toHtml(HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL) + html = html.replace(anchor, "
\"${file}\"") + etContent.setText(html.parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY, htmlImageGetter)) + etContent.setSelection(lastCursorPosition.coerceIn(0, etContent.text.length)) + Log.d("TextNoteActivity", "Successfully taken picture and inserted into text note") + } + override fun onCreate(savedInstanceState: Bundle?) { setContentView(R.layout.activity_text_note) val fabMenuBtn = findViewById(R.id.fab_menu) val fabMenu = findViewById(R.id.fab_menu_wrapper) + fabMenuBtn.makeDraggable(fabMenuBtn.parent as View) var expanded = false fabMenuBtn.setOnClickListener { if (expanded) { @@ -91,6 +194,13 @@ class TextNoteActivity : BaseNoteActivity(DbContract.NoteEntry.TYPE_TEXT) { boldBtn.setOnClickListener(this) italicsBtn.setOnClickListener(this) underlineBtn.setOnClickListener(this) + galleryBtn.setOnClickListener { + pickImageLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } + cameraBtn.setOnClickListener { + requestCameraPermission.launch(android.Manifest.permission.CAMERA) + } + isBold.observe(this) { b: Boolean -> boldBtn.backgroundTintList = ColorStateList.valueOf(if (b) Color.parseColor("#000000") else resources.getColor(R.color.colorSecuso)) @@ -104,18 +214,30 @@ class TextNoteActivity : BaseNoteActivity(DbContract.NoteEntry.TYPE_TEXT) { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - isLocked.collect { - etContent.isEnabled = !it + isLocked.collect { readonly -> + if (readonly) { + etContent.keyListener = null + etContent.showSoftInputOnFocus = false + } else { + etContent.keyListener = TextKeyListener.getInstance() + etContent.showSoftInputOnFocus = true + etContent.movementMethod = ArrowKeyLinkTouchMovementMethod.getInstance() + } } } } - etContent.movementMethod = LinkMovementMethod.getInstance() + etContent.movementMethod = ArrowKeyLinkTouchMovementMethod.getInstance() super.onCreate(savedInstanceState) } override fun onNoteLoadedFromDB(note: Note) { - etContent.setText(Html.fromHtml(note.content)) + etContent.setText( + note.content.parseAsHtml( + HtmlCompat.FROM_HTML_MODE_LEGACY, + htmlImageGetter + ).trimEnd(' ', '\n')) + etContent.setSelection(lastCursorPosition.coerceIn(0, etContent.text.length)) oldText = etContent.text.toString() } @@ -196,10 +318,10 @@ class TextNoteActivity : BaseNoteActivity(DbContract.NoteEntry.TYPE_TEXT) { } } super.setTitle(Html.fromHtml(title).toString()) - etContent.setText(Html.fromHtml(text.joinToString("
"))) + etContent.setText(HtmlCompat.fromHtml(text.joinToString("
"), HtmlCompat.FROM_HTML_MODE_LEGACY, htmlImageGetter, null)) } intent.getStringExtra(Intent.EXTRA_TEXT)?.let { - etContent.setText(Html.fromHtml(it)) + etContent.setText(HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY, htmlImageGetter, null)) } } } @@ -225,6 +347,26 @@ class TextNoteActivity : BaseNoteActivity(DbContract.NoteEntry.TYPE_TEXT) { } } + override fun onNoteWasSaved() { + val target = getImageFilePathForId(id) + // cleanup not used files + target.apply { + if (exists() && isDirectory) { + listFiles()?.forEach { + if (!loadedImages.contains(it.name)) { + Log.d("TextNote", "Deleting file ${it.name}") + it.delete() + } + } + } + } + } + + override fun onPause() { + lastCursorPosition = etContent.selectionStart + super.onPause() + } + override fun onClick(v: View) { val startSelection: Int val endSelection: Int @@ -360,6 +502,9 @@ class TextNoteActivity : BaseNoteActivity(DbContract.NoteEntry.TYPE_TEXT) { etContent.text = totalText etContent.setSelection(startSelection) } + R.id.btn_gallery -> { + + } else -> {} } @@ -478,22 +623,28 @@ class TextNoteActivity : BaseNoteActivity(DbContract.NoteEntry.TYPE_TEXT) { return if (name.isEmpty() && etContent.text.toString().isEmpty()) { ActionResult(false, null) } else { - ActionResult(true, Note(name, Html.toHtml(etContent.text), DbContract.NoteEntry.TYPE_TEXT, category)) + ActionResult(true, Note(name, Html.toHtml(etContent.text).trimEnd(' ', '\n'), DbContract.NoteEntry.TYPE_TEXT, category)) } } - override fun getMimeType() = "text/plain" + override fun getMimeType() = if (loadedImages.isEmpty()) { + "text/plain" + } else { + "application/zip" + } - override fun getFileExtension() = TextNoteActivity.getFileExtension() + override fun getFileExtension() = if (loadedImages.isEmpty()) { + TextNoteActivity.getFileExtension() + } else { + ".zip" + } private val saveToExternalStorageResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == Activity.RESULT_OK) { result.data?.data?.let { uri -> val fileOutputStream: OutputStream? = contentResolver.openOutputStream(uri) - fileOutputStream?.let { - val out = PrintWriter(it) - out.println(Html.toHtml(etContent.text)) - out.close() + fileOutputStream?.let { outputStream -> + exportWithImages(filesDir, etContent.text.toHtml(), id, outputStream) Toast.makeText( applicationContext, String.format(getString(R.string.toast_file_exported_to), uri.toString()), @@ -506,12 +657,48 @@ class TextNoteActivity : BaseNoteActivity(DbContract.NoteEntry.TYPE_TEXT) { } override fun onSaveExternalStorage(outputStream: OutputStream) { - val out = PrintWriter(outputStream) - out.println(Html.fromHtml(Html.toHtml(etContent.text)).toString()) - out.close() + exportWithImages(filesDir, etContent.text.toHtml(), id, outputStream) } + private fun getImageFilePathForId(id: Int) = getImageFilePathForId(filesDir, id) + companion object { - fun getFileExtension() = ".txt" + + fun getImageFilePathForId(filesDir: File, id: Int): File { + val path = File("${filesDir}/text_notes/$id/images") + return path + } + fun getFileExtension(filesDir: File? = null, id: Int? = null): String { + if (id != null && filesDir != null) { + val dir = getImageFilePathForId(filesDir, id) + return if (dir.exists() && dir.isDirectory && dir.listFiles()?.isNotEmpty() == true) { + ".zip" + } else { + ".txt" + } + } else { + return ".txt" + } + } + + fun exportWithImages(filesDir: File, content: String, id: Int, outputStream: OutputStream, zipped: Boolean = true) { + val dir = getImageFilePathForId(filesDir, id) + if (dir.exists() && dir.isDirectory) { + ZipOutputStream(outputStream).use { + it.putNextEntry(ZipEntry("text.txt")) + ByteArrayInputStream(content.toByteArray()).copyTo(it) + it.closeEntry() + dir.listFiles()?.forEach { file -> + it.putNextEntry(ZipEntry("images/${file}")) + FileInputStream(file).copyTo(it) + it.closeEntry() + } + } + } else { + PrintWriter(outputStream).use { + println(content) + } + } + } } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_photo_camera_24px.xml b/app/src/main/res/drawable/ic_photo_camera_24px.xml new file mode 100644 index 00000000..e71e765f --- /dev/null +++ b/app/src/main/res/drawable/ic_photo_camera_24px.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_pin.xml b/app/src/main/res/drawable/ic_pin.xml new file mode 100644 index 00000000..505db578 --- /dev/null +++ b/app/src/main/res/drawable/ic_pin.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_sort_by_alpha_asc_icon_24dp.xml b/app/src/main/res/drawable/ic_sort_by_alpha_asc_icon_24dp.xml new file mode 100644 index 00000000..92824d6e --- /dev/null +++ b/app/src/main/res/drawable/ic_sort_by_alpha_asc_icon_24dp.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_sort_by_alpha_desc_icon_24dp.xml b/app/src/main/res/drawable/ic_sort_by_alpha_desc_icon_24dp.xml new file mode 100644 index 00000000..ce9b5ef0 --- /dev/null +++ b/app/src/main/res/drawable/ic_sort_by_alpha_desc_icon_24dp.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_text_note.xml b/app/src/main/res/layout/activity_text_note.xml index b4442af1..5c8fc5d9 100644 --- a/app/src/main/res/layout/activity_text_note.xml +++ b/app/src/main/res/layout/activity_text_note.xml @@ -18,23 +18,18 @@ - - + android:layout_marginTop="10dp" + android:layout_marginBottom="10dp"/> diff --git a/app/src/main/res/layout/content_main.xml b/app/src/main/res/layout/content_main.xml index 0d2b273d..cc4086bb 100644 --- a/app/src/main/res/layout/content_main.xml +++ b/app/src/main/res/layout/content_main.xml @@ -8,10 +8,9 @@ tools:context="org.secuso.privacyfriendlynotes.ui.main.MainActivity" tools:showIn="@layout/app_bar_main"> - + android:layout_height="match_parent"> + android:imeOptions="flagNoExtractUi" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintStart_toStartOf="parent" /> + + + android:clipToPadding="false" + app:layout_constraintTop_toBottomOf="@id/pinned_notes" + app:layout_constraintStart_toStartOf="parent" /> - + diff --git a/app/src/main/res/layout/note_item.xml b/app/src/main/res/layout/note_item.xml index 03f4148f..24f6d4d1 100644 --- a/app/src/main/res/layout/note_item.xml +++ b/app/src/main/res/layout/note_item.xml @@ -17,7 +17,7 @@ android:orientation="horizontal"> - - + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/text_note_fab.xml b/app/src/main/res/layout/text_note_fab.xml index c8d7631d..47bda397 100644 --- a/app/src/main/res/layout/text_note_fab.xml +++ b/app/src/main/res/layout/text_note_fab.xml @@ -1,5 +1,5 @@ - - - + + - - + diff --git a/app/src/main/res/menu/activity_checklist.xml b/app/src/main/res/menu/activity_checklist.xml index 930e8b7a..96c4ed7f 100644 --- a/app/src/main/res/menu/activity_checklist.xml +++ b/app/src/main/res/menu/activity_checklist.xml @@ -1,12 +1,29 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/main.xml b/app/src/main/res/menu/main.xml index 0b085061..f2dcfd0c 100644 --- a/app/src/main/res/menu/main.xml +++ b/app/src/main/res/menu/main.xml @@ -13,4 +13,16 @@ android:title="@string/action_export_all" app:showAsAction="never" /> + + diff --git a/app/src/main/res/menu/main_change_category.xml b/app/src/main/res/menu/main_change_category.xml new file mode 100644 index 00000000..bfbeaeea --- /dev/null +++ b/app/src/main/res/menu/main_change_category.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 0e77a109..a8c7fdfb 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -9,6 +9,7 @@ Alphabetisch sortieren Erinnerung setzen Alle auswählen + Neue Checkliste mit Offenen Speichern Privacy Friendly Notizen Entsperren @@ -31,6 +32,8 @@ In den Papierkorb verschieben Kategorie erstellen Erstellen + Kategorie auswählen + Auswählen Kategorie erstellen Ändere Kategorienname Audio @@ -78,9 +81,17 @@ Exportieren Alle Notizen exportieren Als HTML exportieren + Als PNG exportieren Kategorie In Checkliste umwandeln In Textnotiz umwandeln + Anheften + Lösen + Erledigt + Nicht erledigt + Lösche alle erledigten + Kategorie auswählen + Kategorie ändern Autoren: Version Über @@ -167,9 +178,24 @@ Die geöffnete Datei ist zu groß Die Datei hat zu viele Zeichen, lade nur bis zum Limit. Notiz gelöscht + Notizen gelöscht Textnotiz Zeichenlimit für importierte Textnotizen Dateigrößenlimit für importierte Textnotizen Alle abwählen Sperren + Diese Notiz konnte nicht geladen werden. Wenn Sie versuchen diese Notiz zu öffnen, wird stattdessen versucht den Inhalt der Notiz unformattiert zu speichern. + Neue Checkliste mit Fertigen + Einträge Checkliste durchstreichen + Streiche den Beschreibungstext eines Checklisteneintrages durch, sofern dieser abgehakt wurde. + Automatische Löschen im Papierkorb + Falls aktiviert, werden Notizen, die länger als die ausgewählte Zeitspanne im Papierkorb sind, automatisch gelöscht. + Zeitspanne automatisches Löschen + + Einen Tag + Eine Woche + Einen Monat + + Fixe angehefete Notizen + Falls aktiviert, dann werden angeheftete Notizen in einem separaten Bereich am über den restlichen Notizen angezeigt. diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml index 04003905..68958046 100644 --- a/app/src/main/res/values-night/styles.xml +++ b/app/src/main/res/values-night/styles.xml @@ -6,6 +6,8 @@ @color/colorSurfaceSecondaryDark @color/colorBackgroundDark @color/colorBackgroundDark + @color/colorBackgroundDark + true @color/md_theme_dark_primary @color/md_theme_dark_onPrimary @@ -49,6 +51,8 @@ @drawable/arrow_back @style/ThemeOverlay.App.Preference @style/AppTheme.ActionBar.OverflowMenu + @style/AppTheme.ActionMode + @drawable/arrow_back + + + + + + diff --git a/app/src/main/res/xml/fileprovider_paths.xml b/app/src/main/res/xml/fileprovider_paths.xml index 9fc008c0..3ba2f513 100644 --- a/app/src/main/res/xml/fileprovider_paths.xml +++ b/app/src/main/res/xml/fileprovider_paths.xml @@ -2,4 +2,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/xml/pref_settings.xml b/app/src/main/res/xml/pref_settings.xml index e74aad7c..0e1b8d62 100644 --- a/app/src/main/res/xml/pref_settings.xml +++ b/app/src/main/res/xml/pref_settings.xml @@ -16,6 +16,22 @@ android:title="@string/settings_note_trashing_dialog_title" android:summary="@string/settings_note_trashing_dialog_desc" app:iconSpaceReserved="false" /> + + + + \ No newline at end of file