diff --git a/app/src/main/java/org/secuso/privacyfriendlynotes/room/dao/CategoryDao.kt b/app/src/main/java/org/secuso/privacyfriendlynotes/room/dao/CategoryDao.kt index f9734fa0..ec76334e 100644 --- a/app/src/main/java/org/secuso/privacyfriendlynotes/room/dao/CategoryDao.kt +++ b/app/src/main/java/org/secuso/privacyfriendlynotes/room/dao/CategoryDao.kt @@ -44,6 +44,9 @@ interface CategoryDao { @get:Query("SELECT * FROM categories GROUP BY name") val allCategories: 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/ui/adapter/NoteAdapter.kt b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/adapter/NoteAdapter.kt index 02443ac6..903f3ca3 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 @@ -16,6 +16,7 @@ package org.secuso.privacyfriendlynotes.ui.adapter import android.app.Activity import android.graphics.Color import android.text.Html +import android.util.Log import android.util.TypedValue import android.view.LayoutInflater import android.view.View @@ -23,6 +24,7 @@ import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.ContextCompat import androidx.preference.PreferenceManager import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide @@ -48,6 +50,7 @@ class NoteAdapter( var notes: MutableList = ArrayList() private set + var saveContent: ((Note, NoteHolder) -> Unit)? = null private var listener: ((Note, NoteHolder) -> Unit)? = null override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NoteHolder { val itemView = LayoutInflater.from(parent.context) @@ -102,6 +105,7 @@ class NoteAdapter( } } + try { when (currentNote.type) { DbContract.NoteEntry.TYPE_TEXT -> { if (showPreview) { @@ -120,12 +124,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,14 +153,24 @@ 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()) { 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..49259c1c 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,6 +19,7 @@ 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.ContextThemeWrapper @@ -35,6 +36,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 +49,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 @@ -71,6 +74,7 @@ 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 @@ -129,6 +133,35 @@ 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() + } + } + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { supportFragmentManager.fragmentFactory = object : FragmentFactory() { override fun instantiate(classLoader: ClassLoader, className: String): Fragment { @@ -176,6 +209,14 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte 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 { 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..3f8d1156 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 @@ -262,25 +262,28 @@ 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() + entry = categories[note.category] + "/text/" + name + "_" + note._id + "_" + TextNoteActivity.getFileExtension() inputStream = ByteArrayInputStream(note.content.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..cc207c85 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 @@ -683,6 +683,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..04ed274c 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 @@ -140,6 +141,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/TextNoteActivity.kt b/app/src/main/java/org/secuso/privacyfriendlynotes/ui/notes/TextNoteActivity.kt index 4875d001..ccb6d448 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 @@ -21,10 +21,12 @@ import android.graphics.Typeface 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.view.ContextThemeWrapper @@ -45,6 +47,8 @@ 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.InputStreamReader @@ -60,6 +64,7 @@ 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 var lastCursorPosition = 0 private val isBold = MutableLiveData(false) private val isItalic = MutableLiveData(false) @@ -76,6 +81,7 @@ class TextNoteActivity : BaseNoteActivity(DbContract.NoteEntry.TYPE_TEXT) { 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) { @@ -104,18 +110,26 @@ 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.setSelection(lastCursorPosition.coerceIn(0, etContent.text.length)) oldText = etContent.text.toString() } @@ -225,6 +239,11 @@ class TextNoteActivity : BaseNoteActivity(DbContract.NoteEntry.TYPE_TEXT) { } } + override fun onPause() { + lastCursorPosition = etContent.selectionStart + super.onPause() + } + override fun onClick(v: View) { val startSelection: Int val endSelection: Int 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/text_note_fab.xml b/app/src/main/res/layout/text_note_fab.xml index c8d7631d..24b6885a 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..67448b7e 100644 --- a/app/src/main/res/menu/activity_checklist.xml +++ b/app/src/main/res/menu/activity_checklist.xml @@ -7,6 +7,16 @@ android:title="@string/action_convert_to_text" android:icon="@drawable/ic_short_text_icon_24dp" app:showAsAction="ifRoom"/> + + Alphabetisch sortieren Erinnerung setzen Alle auswählen + Neue Checkliste mit Offenen Speichern Privacy Friendly Notizen Entsperren @@ -172,4 +173,6 @@ 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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 58042f19..b195e495 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -35,6 +35,8 @@ Delete all Deselect all Select all + New checklist from checked + New checklist from unchecked Save Lock Unlock @@ -196,4 +198,5 @@ Text Note Character limit for imported text notes File Size limit for imported text notes + This note could not be loaded. Click to save the stored content.