diff --git a/CHANGELOG.md b/CHANGELOG.md index d84fefd..26d69e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,63 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [1.4.0] - 2026-01-10 + +### 🎉 New Feature: Checklists + +- **✅ Checklist Notes** + - New note type: Checklists with tap-to-toggle items + - Add items via dedicated input field with "+" button + - Drag & drop reordering (long-press to activate) + - Swipe-to-delete items + - Visual distinction: Checked items get strikethrough styling + - Type selector when creating new notes (Text or Checklist) + +- **📝 Markdown Integration** + - Checklists export as GitHub-style task lists (`- [ ]` / `- [x]`) + - Compatible with Obsidian, Notion, and other Markdown editors + - Full round-trip: Edit in Obsidian → Sync back to app + - YAML frontmatter includes `type: checklist` for identification + +### Fixed + +- **� Markdown Parsing Robustness** + - Fixed content extraction after title (was returning empty for some formats) + - Now handles single newline after title (was requiring double newline) + - Protection: Skips import if parsed content is empty but local has content + +- **📂 Duplicate Filename Handling** + - Notes with identical titles now get unique Markdown filenames + - Format: `title_shortid.md` (e.g., `test_71540ca9.md`) + - Prevents data loss from filename collisions + +- **🔔 Notification UX** + - No sync notifications when app is in foreground + - User sees changes directly in UI - no redundant notification + - Background syncs still show notifications as expected + +### Privacy Improvements + +- **🔒 WiFi Permissions Removed** + - Removed `ACCESS_WIFI_STATE` permission + - Removed `CHANGE_WIFI_STATE` permission + - WiFi binding now works via IP detection instead of SSID matching + - Cleaned up all SSID-related code from codebase and documentation + +### Technical Improvements + +- **📦 New Data Model** + - `NoteType` enum: `TEXT`, `CHECKLIST` + - `ChecklistItem` data class with id, text, isChecked, order + - `Note.kt` extended with `noteType` and `checklistItems` fields + +- **🔄 Sync Protocol v1.4.0** + - JSON format updated to include checklist fields + - Full backward compatibility with v1.3.x notes + - Robust JSON parsing with manual field extraction + +--- + ## [1.3.2] - 2026-01-10 ### Changed diff --git a/QUICKSTART.en.md b/QUICKSTART.en.md index b42ff3a..43128bd 100644 --- a/QUICKSTART.en.md +++ b/QUICKSTART.en.md @@ -74,7 +74,6 @@ ip addr show | grep "inet " | grep -v 127.0.0.1 | **WebDAV Server URL** | `http://YOUR-SERVER-IP:8080/` | | **Username** | `noteuser` | | **Password** | (your password from `.env`) | - | **Gateway SSID** | Name of your WiFi network | > **💡 Note:** Enter only the base URL (without `/notes`). The app automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown export. @@ -158,9 +157,8 @@ For reliable auto-sync: # Should show "Up" ``` -2. **Same WiFi?** +2. **Same network?** - Smartphone and server must be on same network - - Check SSID in app settings 3. **IP address correct?** ```bash @@ -193,9 +191,9 @@ For reliable auto-sync: 2. **Battery optimization disabled?** - See [Disable Battery Optimization](#-disable-battery-optimization) -3. **On correct WiFi?** - - Sync only works when SSID = Gateway SSID - - Check current SSID in Android settings +3. **Connected to WiFi?** + - Auto-sync triggers on any WiFi connection + - Check if you're connected to a WiFi network 4. **Test manually:** - ⚙️ Settings → "Sync now" diff --git a/QUICKSTART.md b/QUICKSTART.md index 3626167..ead0005 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -74,7 +74,6 @@ ip addr show | grep "inet " | grep -v 127.0.0.1 | **WebDAV Server URL** | `http://DEINE-SERVER-IP:8080/` | | **Benutzername** | `noteuser` | | **Passwort** | (dein Passwort aus `.env`) | - | **Gateway SSID** | Name deines WLAN-Netzwerks | > **💡 Hinweis:** Gib nur die Base-URL ein (ohne `/notes`). Die App erstellt automatisch `/notes/` für JSON-Dateien und `/notes-md/` für Markdown-Export. @@ -158,9 +157,8 @@ Für zuverlässigen Auto-Sync: # Sollte "Up" zeigen ``` -2. **Gleiche WLAN?** +2. **Gleiches Netzwerk?** - Smartphone und Server müssen im selben Netzwerk sein - - Prüfe SSID in App-Einstellungen 3. **IP-Adresse korrekt?** ```bash @@ -193,9 +191,9 @@ Für zuverlässigen Auto-Sync: 2. **Akku-Optimierung deaktiviert?** - Siehe [Akku-Optimierung](#-akku-optimierung-deaktivieren) -3. **Im richtigen WLAN?** - - Sync funktioniert nur wenn SSID = Gateway SSID - - Prüfe aktuelle SSID in Android-Einstellungen +3. **Mit WiFi verbunden?** + - Auto-Sync triggert bei jeder WiFi-Verbindung + - Prüfe, ob du mit einem WLAN verbunden bist 4. **Manuell testen:** - ⚙️ Einstellungen → "Jetzt synchronisieren" diff --git a/README.en.md b/README.en.md index 013d86e..66fdd7d 100644 --- a/README.en.md +++ b/README.en.md @@ -26,11 +26,12 @@ ## ✨ Highlights +- ✅ **NEW: Checklists** - Tap-to-check, drag & drop, swipe-to-delete - 📝 **Offline-first** - Works without internet -- 🔄 **Auto-sync** - Home WiFi only (15/30/60 min) +- 🔄 **Auto-sync** - On WiFi connection (15/30/60 min) - 🔒 **Self-hosted** - Your data stays with you (WebDAV) - 💾 **Local backup** - Export/Import as JSON file -- 🖥️ **Desktop integration** - Markdown export for VS Code, Typora, etc. +- 🖥️ **Desktop integration** - Markdown export for Obsidian, VS Code, Typora - 🔋 **Battery-friendly** - ~0.2-0.8% per day - 🎨 **Material Design 3** - Dark mode & dynamic colors @@ -101,4 +102,4 @@ MIT License - see [LICENSE](LICENSE) --- -**v1.3.2** · Built with ❤️ using Kotlin + Material Design 3 +**v1.4.0** · Built with ❤️ using Kotlin + Material Design 3 diff --git a/README.md b/README.md index 30c5168..b0ddc13 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,12 @@ ## ✨ Highlights +- ✅ **NEU: Checklisten** - Tap-to-Check, Drag & Drop, Swipe-to-Delete - 📝 **Offline-First** - Funktioniert ohne Internet -- 🔄 **Auto-Sync** - Nur im Heim-WLAN (15/30/60 Min) +- 🔄 **Auto-Sync** - Bei WiFi-Verbindung (15/30/60 Min) - 🔒 **Self-Hosted** - Deine Daten bleiben bei dir (WebDAV) - 💾 **Lokales Backup** - Export/Import als JSON-Datei -- 🖥️ **Desktop-Integration** - Markdown-Export für VS Code, Typora, etc. +- 🖥️ **Desktop-Integration** - Markdown-Export für Obsidian, VS Code, Typora - 🔋 **Akkuschonend** - ~0.2-0.8% pro Tag - 🎨 **Material Design 3** - Dark Mode & Dynamic Colors @@ -104,4 +105,4 @@ MIT License - siehe [LICENSE](LICENSE) --- -**v1.3.2** · Built with ❤️ using Kotlin + Material Design 3 +**v1.4.0** · Built with ❤️ using Kotlin + Material Design 3 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f33fbfc..95a044f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "dev.dettmer.simplenotes" minSdk = 24 targetSdk = 36 - versionCode = 10 // 🚀 v1.3.2: Lint-Cleanup "Clean Slate" - versionName = "1.3.2" // 🚀 v1.3.2: Code-Qualität-Release (alle einfachen Lint-Issues behoben) + versionCode = 11 // 🚀 v1.4.0: Checklists Feature + versionName = "1.4.0" // 🚀 v1.4.0: Checklists, Multi-Device Sync Fixes, UX Improvements testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fe254e1..be931a7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,8 +5,6 @@ - - diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt index 16d249a..f7e2272 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt @@ -44,6 +44,9 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import android.view.View import android.widget.LinearLayout +import android.view.Gravity +import android.widget.PopupMenu +import dev.dettmer.simplenotes.models.NoteType class MainActivity : AppCompatActivity() { @@ -551,12 +554,55 @@ class MainActivity : AppCompatActivity() { }).show() } + /** + * v1.4.0: Setup FAB mit Dropdown für Notiz-Typ Auswahl + */ private fun setupFab() { - fabAddNote.setOnClickListener { - openNoteEditor(null) + fabAddNote.setOnClickListener { view -> + showNoteTypePopup(view) } } + /** + * v1.4.0: Zeigt Popup-Menü zur Auswahl des Notiz-Typs + */ + private fun showNoteTypePopup(anchor: View) { + val popupMenu = PopupMenu(this, anchor, Gravity.END) + popupMenu.inflate(R.menu.menu_fab_note_types) + + // Icons im Popup anzeigen (via Reflection, da standardmäßig ausgeblendet) + try { + val fields = popupMenu.javaClass.declaredFields + for (field in fields) { + if ("mPopup" == field.name) { + field.isAccessible = true + val menuPopupHelper = field.get(popupMenu) + val classPopupHelper = Class.forName(menuPopupHelper.javaClass.name) + val setForceIcons = classPopupHelper.getMethod("setForceShowIcon", Boolean::class.java) + setForceIcons.invoke(menuPopupHelper, true) + break + } + } + } catch (e: Exception) { + Logger.w(TAG, "Could not force show icons in popup menu: ${e.message}") + } + + popupMenu.setOnMenuItemClickListener { menuItem -> + val noteType = when (menuItem.itemId) { + R.id.action_create_text_note -> NoteType.TEXT + R.id.action_create_checklist -> NoteType.CHECKLIST + else -> return@setOnMenuItemClickListener false + } + + val intent = Intent(this, NoteEditorActivity::class.java) + intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name) + startActivity(intent) + true + } + + popupMenu.show() + } + private fun loadNotes() { val notes = storage.loadAllNotes() diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/NoteEditorActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/NoteEditorActivity.kt index 5dd9704..3e82256 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/NoteEditorActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/NoteEditorActivity.kt @@ -3,27 +3,58 @@ package dev.dettmer.simplenotes import android.os.Bundle import android.view.Menu import android.view.MenuItem +import android.view.View +import android.widget.LinearLayout import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.button.MaterialButton import com.google.android.material.color.DynamicColors import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import dev.dettmer.simplenotes.adapters.ChecklistEditorAdapter +import dev.dettmer.simplenotes.models.ChecklistItem import dev.dettmer.simplenotes.models.Note +import dev.dettmer.simplenotes.models.NoteType import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.utils.DeviceIdGenerator +import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.showToast +/** + * Editor Activity für Notizen und Checklisten + * + * v1.4.0: Unterstützt jetzt sowohl TEXT als auch CHECKLIST Notizen + */ class NoteEditorActivity : AppCompatActivity() { + // Views + private lateinit var toolbar: MaterialToolbar + private lateinit var tilTitle: TextInputLayout private lateinit var editTextTitle: TextInputEditText + private lateinit var tilContent: TextInputLayout private lateinit var editTextContent: TextInputEditText + private lateinit var checklistContainer: LinearLayout + private lateinit var rvChecklistItems: RecyclerView + private lateinit var btnAddItem: MaterialButton + private lateinit var storage: NotesStorage + // State private var existingNote: Note? = null + private var currentNoteType: NoteType = NoteType.TEXT + private val checklistItems = mutableListOf() + private var checklistAdapter: ChecklistEditorAdapter? = null + private var itemTouchHelper: ItemTouchHelper? = null companion object { + private const val TAG = "NoteEditorActivity" const val EXTRA_NOTE_ID = "extra_note_id" + const val EXTRA_NOTE_TYPE = "extra_note_type" } override fun onCreate(savedInstanceState: Bundle?) { @@ -36,39 +67,172 @@ class NoteEditorActivity : AppCompatActivity() { storage = NotesStorage(this) - // Setup toolbar - val toolbar = findViewById(R.id.toolbar) - setSupportActionBar(toolbar) - supportActionBar?.apply { - setDisplayHomeAsUpEnabled(true) - // 🔥 v1.1.2: Use default back arrow (Material Design) instead of X icon - // Icon is set in XML: app:navigationIcon="?attr/homeAsUpIndicator" - } - - // Find views + findViews() + setupToolbar() + loadNoteOrDetermineType() + setupUIForNoteType() + } + + private fun findViews() { + toolbar = findViewById(R.id.toolbar) + tilTitle = findViewById(R.id.tilTitle) editTextTitle = findViewById(R.id.editTextTitle) + tilContent = findViewById(R.id.tilContent) editTextContent = findViewById(R.id.editTextContent) - - // Load existing note if editing + checklistContainer = findViewById(R.id.checklistContainer) + rvChecklistItems = findViewById(R.id.rvChecklistItems) + btnAddItem = findViewById(R.id.btnAddItem) + } + + private fun setupToolbar() { + setSupportActionBar(toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + + private fun loadNoteOrDetermineType() { val noteId = intent.getStringExtra(EXTRA_NOTE_ID) + if (noteId != null) { + // Existierende Notiz laden existingNote = storage.loadNote(noteId) - existingNote?.let { - editTextTitle.setText(it.title) - editTextContent.setText(it.content) - supportActionBar?.title = "Notiz bearbeiten" + existingNote?.let { note -> + editTextTitle.setText(note.title) + currentNoteType = note.noteType + + when (note.noteType) { + NoteType.TEXT -> { + editTextContent.setText(note.content) + supportActionBar?.title = getString(R.string.edit_note) + } + NoteType.CHECKLIST -> { + note.checklistItems?.let { items -> + checklistItems.clear() + checklistItems.addAll(items.sortedBy { it.order }) + } + supportActionBar?.title = getString(R.string.edit_checklist) + } + } } } else { - supportActionBar?.title = "Neue Notiz" + // Neue Notiz - Typ aus Intent + val typeString = intent.getStringExtra(EXTRA_NOTE_TYPE) ?: NoteType.TEXT.name + currentNoteType = try { + NoteType.valueOf(typeString) + } catch (e: IllegalArgumentException) { + Logger.w(TAG, "Invalid note type '$typeString', defaulting to TEXT: ${e.message}") + NoteType.TEXT + } + + when (currentNoteType) { + NoteType.TEXT -> { + supportActionBar?.title = getString(R.string.new_note) + } + NoteType.CHECKLIST -> { + supportActionBar?.title = getString(R.string.new_checklist) + // Erstes leeres Item hinzufügen + checklistItems.add(ChecklistItem.createEmpty(0)) + } + } + } + } + + private fun setupUIForNoteType() { + when (currentNoteType) { + NoteType.TEXT -> { + tilContent.visibility = View.VISIBLE + checklistContainer.visibility = View.GONE + } + NoteType.CHECKLIST -> { + tilContent.visibility = View.GONE + checklistContainer.visibility = View.VISIBLE + setupChecklistRecyclerView() + } + } + } + + private fun setupChecklistRecyclerView() { + checklistAdapter = ChecklistEditorAdapter( + items = checklistItems, + onItemCheckedChanged = { position, isChecked -> + if (position in checklistItems.indices) { + checklistItems[position].isChecked = isChecked + } + }, + onItemTextChanged = { position, newText -> + if (position in checklistItems.indices) { + checklistItems[position] = checklistItems[position].copy(text = newText) + } + }, + onItemDeleted = { position -> + deleteChecklistItem(position) + }, + onAddNewItem = { position -> + addChecklistItemAt(position) + }, + onStartDrag = { viewHolder -> + itemTouchHelper?.startDrag(viewHolder) + } + ) + + rvChecklistItems.apply { + layoutManager = LinearLayoutManager(this@NoteEditorActivity) + adapter = checklistAdapter + } + + // Drag & Drop Setup + val callback = object : ItemTouchHelper.SimpleCallback( + ItemTouchHelper.UP or ItemTouchHelper.DOWN, + 0 // Kein Swipe + ) { + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val from = viewHolder.bindingAdapterPosition + val to = target.bindingAdapterPosition + checklistAdapter?.moveItem(from, to) + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + // Nicht verwendet + } + + override fun isLongPressDragEnabled(): Boolean = false // Nur via Handle + } + + itemTouchHelper = ItemTouchHelper(callback) + itemTouchHelper?.attachToRecyclerView(rvChecklistItems) + + // Add Item Button + btnAddItem.setOnClickListener { + addChecklistItemAt(checklistItems.size) + } + } + + private fun addChecklistItemAt(position: Int) { + val newItem = ChecklistItem.createEmpty(position) + checklistAdapter?.insertItem(position, newItem) + + // Zum neuen Item scrollen und fokussieren + rvChecklistItems.scrollToPosition(position) + checklistAdapter?.focusItem(rvChecklistItems, position) + } + + private fun deleteChecklistItem(position: Int) { + checklistAdapter?.removeItem(position) + + // Wenn letztes Item gelöscht, automatisch neues hinzufügen + if (checklistItems.isEmpty()) { + addChecklistItemAt(0) } } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_editor, menu) - - // Show delete only for existing notes + // Delete nur für existierende Notizen menu.findItem(R.id.action_delete)?.isVisible = existingNote != null - return true } @@ -92,51 +256,96 @@ class NoteEditorActivity : AppCompatActivity() { private fun saveNote() { val title = editTextTitle.text?.toString()?.trim() ?: "" - val content = editTextContent.text?.toString()?.trim() ?: "" - if (title.isEmpty() && content.isEmpty()) { - showToast("Notiz ist leer") - return + when (currentNoteType) { + NoteType.TEXT -> { + val content = editTextContent.text?.toString()?.trim() ?: "" + + if (title.isEmpty() && content.isEmpty()) { + showToast(getString(R.string.note_is_empty)) + return + } + + val note = if (existingNote != null) { + existingNote!!.copy( + title = title, + content = content, + noteType = NoteType.TEXT, + checklistItems = null, + updatedAt = System.currentTimeMillis(), + syncStatus = SyncStatus.PENDING + ) + } else { + Note( + title = title, + content = content, + noteType = NoteType.TEXT, + checklistItems = null, + deviceId = DeviceIdGenerator.getDeviceId(this), + syncStatus = SyncStatus.LOCAL_ONLY + ) + } + + storage.saveNote(note) + } + + NoteType.CHECKLIST -> { + // Leere Items filtern + val validItems = checklistItems.filter { it.text.isNotBlank() } + + if (title.isEmpty() && validItems.isEmpty()) { + showToast(getString(R.string.note_is_empty)) + return + } + + // Order neu setzen + val orderedItems = validItems.mapIndexed { index, item -> + item.copy(order = index) + } + + val note = if (existingNote != null) { + existingNote!!.copy( + title = title, + content = "", // Leer für Checklisten + noteType = NoteType.CHECKLIST, + checklistItems = orderedItems, + updatedAt = System.currentTimeMillis(), + syncStatus = SyncStatus.PENDING + ) + } else { + Note( + title = title, + content = "", + noteType = NoteType.CHECKLIST, + checklistItems = orderedItems, + deviceId = DeviceIdGenerator.getDeviceId(this), + syncStatus = SyncStatus.LOCAL_ONLY + ) + } + + storage.saveNote(note) + } } - val note = if (existingNote != null) { - // Update existing note - existingNote!!.copy( - title = title, - content = content, - updatedAt = System.currentTimeMillis(), - syncStatus = SyncStatus.PENDING - ) - } else { - // Create new note - Note( - title = title, - content = content, - deviceId = DeviceIdGenerator.getDeviceId(this), - syncStatus = SyncStatus.LOCAL_ONLY - ) - } - - storage.saveNote(note) - showToast("Notiz gespeichert") + showToast(getString(R.string.note_saved)) finish() } private fun confirmDelete() { AlertDialog.Builder(this) - .setTitle("Notiz löschen?") - .setMessage("Diese Aktion kann nicht rückgängig gemacht werden.") - .setPositiveButton("Löschen") { _, _ -> + .setTitle(getString(R.string.delete_note_title)) + .setMessage(getString(R.string.delete_note_message)) + .setPositiveButton(getString(R.string.delete)) { _, _ -> deleteNote() } - .setNegativeButton("Abbrechen", null) + .setNegativeButton(getString(R.string.cancel), null) .show() } private fun deleteNote() { existingNote?.let { storage.deleteNote(it.id) - showToast("Notiz gelöscht") + showToast(getString(R.string.note_deleted)) finish() } } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/adapters/ChecklistEditorAdapter.kt b/android/app/src/main/java/dev/dettmer/simplenotes/adapters/ChecklistEditorAdapter.kt new file mode 100644 index 0000000..4fdd014 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/adapters/ChecklistEditorAdapter.kt @@ -0,0 +1,181 @@ +package dev.dettmer.simplenotes.adapters + +import android.graphics.Paint +import android.text.Editable +import android.text.TextWatcher +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import android.widget.ImageButton +import android.widget.ImageView +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.checkbox.MaterialCheckBox +import dev.dettmer.simplenotes.R +import dev.dettmer.simplenotes.models.ChecklistItem + +/** + * Adapter für die Bearbeitung von Checklist-Items im Editor + * + * v1.4.0: Checklisten-Feature + */ +class ChecklistEditorAdapter( + private val items: MutableList, + private val onItemCheckedChanged: (Int, Boolean) -> Unit, + private val onItemTextChanged: (Int, String) -> Unit, + private val onItemDeleted: (Int) -> Unit, + private val onAddNewItem: (Int) -> Unit, + private val onStartDrag: (RecyclerView.ViewHolder) -> Unit +) : RecyclerView.Adapter() { + + inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val dragHandle: ImageView = view.findViewById(R.id.ivDragHandle) + val checkbox: MaterialCheckBox = view.findViewById(R.id.cbItem) + val editText: EditText = view.findViewById(R.id.etItemText) + val deleteButton: ImageButton = view.findViewById(R.id.btnDeleteItem) + + private var textWatcher: TextWatcher? = null + + @Suppress("NestedBlockDepth", "UNUSED_PARAMETER") + fun bind(item: ChecklistItem, position: Int) { + // Vorherigen TextWatcher entfernen um Loops zu vermeiden + textWatcher?.let { editText.removeTextChangedListener(it) } + + // Checkbox + checkbox.isChecked = item.isChecked + checkbox.setOnCheckedChangeListener { _, isChecked -> + onItemCheckedChanged(bindingAdapterPosition, isChecked) + updateStrikethrough(isChecked) + } + + // Text + editText.setText(item.text) + updateStrikethrough(item.isChecked) + + // TextWatcher für Änderungen + textWatcher = object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable?) { + val pos = bindingAdapterPosition + if (pos != RecyclerView.NO_POSITION) { + onItemTextChanged(pos, s?.toString() ?: "") + } + } + } + editText.addTextChangedListener(textWatcher) + + // Enter-Taste = neues Item + editText.setOnEditorActionListener { _, actionId, event -> + if (actionId == EditorInfo.IME_ACTION_NEXT || + (event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)) { + val pos = bindingAdapterPosition + if (pos != RecyclerView.NO_POSITION) { + onAddNewItem(pos + 1) + } + true + } else { + false + } + } + + // Delete Button + deleteButton.setOnClickListener { + val pos = bindingAdapterPosition + if (pos != RecyclerView.NO_POSITION) { + onItemDeleted(pos) + } + } + + // Drag Handle Touch Listener + dragHandle.setOnTouchListener { _, event -> + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + onStartDrag(this) + } + false + } + } + + private fun updateStrikethrough(isChecked: Boolean) { + if (isChecked) { + editText.paintFlags = editText.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG + editText.alpha = CHECKED_ITEM_ALPHA + } else { + editText.paintFlags = editText.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() + editText.alpha = UNCHECKED_ITEM_ALPHA + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_checklist_editor, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(items[position], position) + } + + override fun getItemCount(): Int = items.size + + /** + * Bewegt ein Item von einer Position zu einer anderen (für Drag & Drop) + */ + fun moveItem(fromPosition: Int, toPosition: Int) { + val item = items.removeAt(fromPosition) + items.add(toPosition, item) + notifyItemMoved(fromPosition, toPosition) + + // Order-Werte aktualisieren + items.forEachIndexed { index, checklistItem -> + checklistItem.order = index + } + } + + /** + * Entfernt ein Item an der angegebenen Position + */ + fun removeItem(position: Int) { + if (position in items.indices) { + items.removeAt(position) + notifyItemRemoved(position) + // Order-Werte aktualisieren + items.forEachIndexed { index, checklistItem -> + checklistItem.order = index + } + } + } + + /** + * Fügt ein neues Item an der angegebenen Position ein + */ + fun insertItem(position: Int, item: ChecklistItem) { + items.add(position, item) + notifyItemInserted(position) + // Order-Werte aktualisieren + items.forEachIndexed { index, checklistItem -> + checklistItem.order = index + } + } + + /** + * Fokussiert das EditText des Items an der angegebenen Position + */ + fun focusItem(recyclerView: RecyclerView, position: Int) { + recyclerView.post { + val viewHolder = recyclerView.findViewHolderForAdapterPosition(position) as? ViewHolder + viewHolder?.editText?.requestFocus() + } + } + + companion object { + /** Alpha-Wert für abgehakte Items (durchgestrichen) */ + private const val CHECKED_ITEM_ALPHA = 0.6f + /** Alpha-Wert für nicht abgehakte Items */ + private const val UNCHECKED_ITEM_ALPHA = 1.0f + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/adapters/NotesAdapter.kt b/android/app/src/main/java/dev/dettmer/simplenotes/adapters/NotesAdapter.kt index d0c0e2d..6161d61 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/adapters/NotesAdapter.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/adapters/NotesAdapter.kt @@ -11,11 +11,17 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.models.Note +import dev.dettmer.simplenotes.models.NoteType import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.toReadableTime import dev.dettmer.simplenotes.utils.truncate +/** + * Adapter für die Notizen-Liste + * + * v1.4.0: Unterstützt jetzt TEXT und CHECKLIST Notizen + */ class NotesAdapter( private val onNoteClick: (Note) -> Unit ) : ListAdapter(NoteDiffCallback()) { @@ -31,16 +37,46 @@ class NotesAdapter( } inner class NoteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val ivNoteTypeIcon: ImageView = itemView.findViewById(R.id.ivNoteTypeIcon) private val textViewTitle: TextView = itemView.findViewById(R.id.textViewTitle) private val textViewContent: TextView = itemView.findViewById(R.id.textViewContent) + private val textViewChecklistPreview: TextView = itemView.findViewById(R.id.textViewChecklistPreview) private val textViewTimestamp: TextView = itemView.findViewById(R.id.textViewTimestamp) private val imageViewSyncStatus: ImageView = itemView.findViewById(R.id.imageViewSyncStatus) fun bind(note: Note) { - textViewTitle.text = note.title.ifEmpty { "Ohne Titel" } - textViewContent.text = note.content.truncate(100) + // Titel + textViewTitle.text = note.title.ifEmpty { + itemView.context.getString(R.string.untitled) + } textViewTimestamp.text = note.updatedAt.toReadableTime() + // v1.4.0: Typ-spezifische Anzeige + when (note.noteType) { + NoteType.TEXT -> { + ivNoteTypeIcon.setImageResource(R.drawable.ic_note_24) + textViewContent.text = note.content.truncate(100) + textViewContent.visibility = View.VISIBLE + textViewChecklistPreview.visibility = View.GONE + } + NoteType.CHECKLIST -> { + ivNoteTypeIcon.setImageResource(R.drawable.ic_checklist_24) + textViewContent.visibility = View.GONE + textViewChecklistPreview.visibility = View.VISIBLE + + // Fortschritt berechnen + val items = note.checklistItems ?: emptyList() + val checkedCount = items.count { it.isChecked } + val totalCount = items.size + + textViewChecklistPreview.text = if (totalCount > 0) { + itemView.context.getString(R.string.checklist_progress, checkedCount, totalCount) + } else { + itemView.context.getString(R.string.empty_checklist) + } + } + } + // Sync Icon nur zeigen wenn Sync konfiguriert ist val prefs = itemView.context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/models/ChecklistItem.kt b/android/app/src/main/java/dev/dettmer/simplenotes/models/ChecklistItem.kt new file mode 100644 index 0000000..b07fc08 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/models/ChecklistItem.kt @@ -0,0 +1,34 @@ +package dev.dettmer.simplenotes.models + +import java.util.UUID + +/** + * Repräsentiert ein einzelnes Item in einer Checkliste + * + * v1.4.0: Checklisten-Feature + * + * @property id Eindeutige ID für Sync-Konflikterkennung + * @property text Der Text des Items + * @property isChecked Ob das Item abgehakt ist + * @property order Sortierreihenfolge (0-basiert) + */ +data class ChecklistItem( + val id: String = UUID.randomUUID().toString(), + val text: String = "", + var isChecked: Boolean = false, + var order: Int = 0 +) { + companion object { + /** + * Erstellt ein neues leeres ChecklistItem + */ + fun createEmpty(order: Int): ChecklistItem { + return ChecklistItem( + id = UUID.randomUUID().toString(), + text = "", + isChecked = false, + order = order + ) + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt b/android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt index a43b5a6..ab2b16b 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt @@ -14,56 +14,125 @@ data class Note( val createdAt: Long = System.currentTimeMillis(), val updatedAt: Long = System.currentTimeMillis(), val deviceId: String, - val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY + val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY, + // v1.4.0: Checklisten-Felder + val noteType: NoteType = NoteType.TEXT, + val checklistItems: List? = null ) { + /** + * Serialisiert Note zu JSON (v1.4.0: Nutzt Gson für komplexe Strukturen) + */ fun toJson(): String { - return """ - { - "id": "$id", - "title": "${title.escapeJson()}", - "content": "${content.escapeJson()}", - "createdAt": $createdAt, - "updatedAt": $updatedAt, - "deviceId": "$deviceId", - "syncStatus": "${syncStatus.name}" - } - """.trimIndent() + val gson = com.google.gson.GsonBuilder() + .setPrettyPrinting() + .create() + return gson.toJson(this) } /** * Konvertiert Note zu Markdown mit YAML Frontmatter (Task #1.2.0-08) * Format kompatibel mit Obsidian, Joplin, Typora + * v1.4.0: Unterstützt jetzt auch Checklisten-Format */ fun toMarkdown(): String { - return """ + val header = """ --- id: $id created: ${formatISO8601(createdAt)} updated: ${formatISO8601(updatedAt)} device: $deviceId +type: ${noteType.name.lowercase()} --- # $title -$content - """.trimIndent() +""".trimIndent() + + return when (noteType) { + NoteType.TEXT -> header + content + NoteType.CHECKLIST -> { + val checklistMarkdown = checklistItems?.sortedBy { it.order }?.joinToString("\n") { item -> + val checkbox = if (item.isChecked) "[x]" else "[ ]" + "- $checkbox ${item.text}" + } ?: "" + header + checklistMarkdown + } + } } companion object { private const val TAG = "Note" + /** + * Parst JSON zu Note-Objekt mit Backward Compatibility für alte Notizen ohne noteType + */ fun fromJson(json: String): Note? { return try { val gson = com.google.gson.Gson() - gson.fromJson(json, Note::class.java) + val jsonObject = com.google.gson.JsonParser.parseString(json).asJsonObject + + // Backward Compatibility: Alte Notizen ohne noteType bekommen TEXT + val noteType = if (jsonObject.has("noteType") && !jsonObject.get("noteType").isJsonNull) { + try { + NoteType.valueOf(jsonObject.get("noteType").asString) + } catch (e: Exception) { + Logger.w(TAG, "Unknown noteType, defaulting to TEXT: ${e.message}") + NoteType.TEXT + } + } else { + NoteType.TEXT + } + + // Parsen der Basis-Note + val rawNote = gson.fromJson(json, NoteRaw::class.java) + + // Checklist-Items parsen (kann null sein) + val checklistItemsType = object : com.google.gson.reflect.TypeToken>() {}.type + val checklistItems = if (jsonObject.has("checklistItems") && + !jsonObject.get("checklistItems").isJsonNull + ) { + gson.fromJson>( + jsonObject.get("checklistItems"), + checklistItemsType + ) + } else { + null + } + + // Note mit korrekten Werten erstellen + Note( + id = rawNote.id, + title = rawNote.title, + content = rawNote.content, + createdAt = rawNote.createdAt, + updatedAt = rawNote.updatedAt, + deviceId = rawNote.deviceId, + syncStatus = rawNote.syncStatus ?: SyncStatus.LOCAL_ONLY, + noteType = noteType, + checklistItems = checklistItems + ) } catch (e: Exception) { Logger.w(TAG, "Failed to parse JSON: ${e.message}") null } } + /** + * Hilfsklasse für Gson-Parsing mit nullable Feldern + */ + private data class NoteRaw( + val id: String = UUID.randomUUID().toString(), + val title: String = "", + val content: String = "", + val createdAt: Long = System.currentTimeMillis(), + val updatedAt: Long = System.currentTimeMillis(), + val deviceId: String = "", + val syncStatus: SyncStatus? = null + ) + /** * Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09) + * v1.4.0: Unterstützt jetzt auch Checklisten-Format * * @param md Markdown-String mit YAML Frontmatter * @return Note-Objekt oder null bei Parse-Fehler @@ -91,10 +160,47 @@ $content .firstOrNull { it.startsWith("# ") } ?.removePrefix("# ")?.trim() ?: "Untitled" - // Extract content (everything after heading) - val content = contentBlock - .substringAfter("# $title\n\n", "") - .trim() + // v1.4.0: Prüfe ob type: checklist im Frontmatter + val noteTypeStr = metadata["type"]?.lowercase() ?: "text" + val noteType = when (noteTypeStr) { + "checklist" -> NoteType.CHECKLIST + else -> NoteType.TEXT + } + + // v1.4.0: Parse Content basierend auf Typ + // FIX: Robusteres Parsing - suche nach dem Titel-Header und extrahiere den Rest + val titleLineIndex = contentBlock.lines().indexOfFirst { it.startsWith("# ") } + val contentAfterTitle = if (titleLineIndex >= 0) { + // Alles nach der Titel-Zeile, überspringe führende Leerzeilen + contentBlock.lines() + .drop(titleLineIndex + 1) + .dropWhile { it.isBlank() } + .joinToString("\n") + .trim() + } else { + // Fallback: Gesamter Content (kein Titel gefunden) + contentBlock.trim() + } + + val content: String + val checklistItems: List? + + if (noteType == NoteType.CHECKLIST) { + // Parse Checklist Items + val checklistRegex = Regex("^- \\[([ xX])\\] (.*)$", RegexOption.MULTILINE) + checklistItems = checklistRegex.findAll(contentAfterTitle).mapIndexed { index, matchResult -> + ChecklistItem( + id = UUID.randomUUID().toString(), + text = matchResult.groupValues[2].trim(), + isChecked = matchResult.groupValues[1].lowercase() == "x", + order = index + ) + }.toList().ifEmpty { null } + content = "" // Checklisten haben keinen "content" + } else { + content = contentAfterTitle + checklistItems = null + } Note( id = metadata["id"] ?: UUID.randomUUID().toString(), @@ -103,7 +209,9 @@ $content createdAt = parseISO8601(metadata["created"] ?: ""), updatedAt = parseISO8601(metadata["updated"] ?: ""), deviceId = metadata["device"] ?: "desktop", - syncStatus = SyncStatus.SYNCED // Annahme: Vom Server importiert + syncStatus = SyncStatus.SYNCED, // Annahme: Vom Server importiert + noteType = noteType, + checklistItems = checklistItems ) } catch (e: Exception) { Logger.w(TAG, "Failed to parse Markdown: ${e.message}") diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/models/NoteType.kt b/android/app/src/main/java/dev/dettmer/simplenotes/models/NoteType.kt new file mode 100644 index 0000000..d010958 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/models/NoteType.kt @@ -0,0 +1,11 @@ +package dev.dettmer.simplenotes.models + +/** + * Definiert die verschiedenen Notiz-Typen + * + * v1.4.0: Checklisten-Feature + */ +enum class NoteType { + TEXT, // Normale Text-Notiz (Standard) + CHECKLIST // Checkliste mit abhakbaren Items +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt index 4641312..1516b46 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt @@ -1,5 +1,6 @@ package dev.dettmer.simplenotes.sync +import android.app.ActivityManager import android.content.Context import android.content.Intent import androidx.localbroadcastmanager.content.LocalBroadcastManager @@ -22,6 +23,21 @@ class SyncWorker( const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED" } + /** + * Prüft ob die App im Vordergrund ist. + * Wenn ja, brauchen wir keine Benachrichtigung - die UI zeigt die Änderungen direkt. + */ + private fun isAppInForeground(): Boolean { + val activityManager = applicationContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val appProcesses = activityManager.runningAppProcesses ?: return false + val packageName = applicationContext.packageName + + return appProcesses.any { process -> + process.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND && + process.processName == packageName + } + } + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { if (BuildConfig.DEBUG) { Logger.d(TAG, "═══════════════════════════════════════") @@ -131,14 +147,20 @@ class SyncWorker( Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes") // Nur Notification zeigen wenn tatsächlich etwas gesynct wurde + // UND die App nicht im Vordergrund ist (sonst sieht User die Änderungen direkt) if (result.syncedCount > 0) { - if (BuildConfig.DEBUG) { - Logger.d(TAG, " Showing success notification...") + val appInForeground = isAppInForeground() + if (appInForeground) { + Logger.d(TAG, "ℹ️ App in foreground - skipping notification (UI shows changes)") + } else { + if (BuildConfig.DEBUG) { + Logger.d(TAG, " Showing success notification...") + } + NotificationHelper.showSyncSuccess( + applicationContext, + result.syncedCount + ) } - NotificationHelper.showSyncSuccess( - applicationContext, - result.syncedCount - ) } else { Logger.d(TAG, "ℹ️ No changes to sync - no notification") } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt index 8560ff5..f70cbea 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt @@ -41,6 +41,7 @@ class WebDavSyncService(private val context: Context) { private const val SOCKET_TIMEOUT_MS = 2000 private const val MAX_FILENAME_LENGTH = 200 private const val ETAG_PREVIEW_LENGTH = 8 + private const val CONTENT_PREVIEW_LENGTH = 50 // 🔒 v1.3.1: Mutex um parallele Syncs zu verhindern private val syncMutex = Mutex() @@ -858,8 +859,31 @@ class WebDavSyncService(private val context: Context) { } // Sanitize Filename (Task #1.2.0-12) - val filename = sanitizeFilename(note.title) + ".md" - val noteUrl = "$mdUrl/$filename" + val baseFilename = sanitizeFilename(note.title) + var filename = "$baseFilename.md" + var noteUrl = "$mdUrl/$filename" + + // Prüfe ob Datei bereits existiert und von anderer Note stammt + try { + if (sardine.exists(noteUrl)) { + // Lese existierende Datei und prüfe ID im YAML-Header + val existingContent = sardine.get(noteUrl).bufferedReader().use { it.readText() } + val existingIdMatch = Regex("^---\\n.*?\\nid:\\s*([a-f0-9-]+)", RegexOption.DOT_MATCHES_ALL) + .find(existingContent) + val existingId = existingIdMatch?.groupValues?.get(1) + + if (existingId != null && existingId != note.id) { + // Andere Note hat gleichen Titel - verwende ID-Suffix + val shortId = note.id.take(8) + filename = "${baseFilename}_$shortId.md" + noteUrl = "$mdUrl/$filename" + Logger.d(TAG, "📝 Duplicate title, using: $filename") + } + } + } catch (e: Exception) { + Logger.w(TAG, "⚠️ Could not check existing file: ${e.message}") + // Continue with default filename + } // Konvertiere zu Markdown val mdContent = note.toMarkdown().toByteArray() @@ -884,6 +908,29 @@ class WebDavSyncService(private val context: Context) { .trim('_', ' ') // Trim Underscores/Spaces } + /** + * Generiert eindeutigen Markdown-Dateinamen für eine Notiz. + * Bei Duplikaten wird die Note-ID als Suffix angehängt. + * + * @param note Die Notiz + * @param usedFilenames Set der bereits verwendeten Dateinamen (ohne .md) + * @return Eindeutiger Dateiname (ohne .md Extension) + */ + private fun getUniqueMarkdownFilename(note: Note, usedFilenames: MutableSet): String { + val baseFilename = sanitizeFilename(note.title) + + return if (usedFilenames.contains(baseFilename)) { + // Duplikat - hänge gekürzte ID an + val shortId = note.id.take(8) + val uniqueFilename = "${baseFilename}_$shortId" + usedFilenames.add(uniqueFilename) + uniqueFilename + } else { + usedFilenames.add(baseFilename) + baseFilename + } + } + /** * Exportiert ALLE lokalen Notizen als Markdown (Initial-Export) * @@ -927,6 +974,9 @@ class WebDavSyncService(private val context: Context) { val totalCount = allNotes.size var exportedCount = 0 + // Track used filenames to handle duplicates + val usedFilenames = mutableSetOf() + Logger.d(TAG, "📝 Found $totalCount notes to export") allNotes.forEachIndexed { index, note -> @@ -934,8 +984,8 @@ class WebDavSyncService(private val context: Context) { // Progress-Callback onProgress(index + 1, totalCount) - // Sanitize Filename - val filename = sanitizeFilename(note.title) + ".md" + // Eindeutiger Filename (mit Duplikat-Handling) + val filename = getUniqueMarkdownFilename(note, usedFilenames) + ".md" val noteUrl = "$mdUrl/$filename" // Konvertiere zu Markdown @@ -945,7 +995,7 @@ class WebDavSyncService(private val context: Context) { sardine.put(noteUrl, mdContent, "text/markdown") exportedCount++ - Logger.d(TAG, " ✅ Exported [${index + 1}/$totalCount]: ${note.title}") + Logger.d(TAG, " ✅ Exported [${index + 1}/$totalCount]: ${note.title} -> $filename") } catch (e: Exception) { Logger.e(TAG, "❌ Failed to export ${note.title}: ${e.message}") @@ -1539,13 +1589,27 @@ class WebDavSyncService(private val context: Context) { Logger.w(TAG, " ⚠️ Failed to parse ${resource.name} - fromMarkdown returned null") continue } + + // v1.4.0 FIX: Validierung - leere TEXT-Notizen nicht importieren wenn lokal Content existiert + val localNote = storage.loadNote(mdNote.id) + if (mdNote.noteType == dev.dettmer.simplenotes.models.NoteType.TEXT && + mdNote.content.isBlank() && + localNote != null && localNote.content.isNotBlank()) { + Logger.w( + TAG, + " ⚠️ Skipping ${resource.name}: " + + "MD content empty but local has content - likely parse error!" + ) + continue + } + Logger.d( TAG, " Parsed: id=${mdNote.id}, title=${mdNote.title}, " + - "updatedAt=${Date(mdNote.updatedAt)}" + "updatedAt=${Date(mdNote.updatedAt)}, " + + "content=${mdNote.content.take(CONTENT_PREVIEW_LENGTH)}..." ) - val localNote = storage.loadNote(mdNote.id) Logger.d( TAG, " Local note: " + if (localNote == null) { diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WifiSyncReceiver.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WifiSyncReceiver.kt index 56e9eec..ea90e82 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WifiSyncReceiver.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WifiSyncReceiver.kt @@ -5,12 +5,17 @@ import android.content.Context import android.content.Intent import android.net.ConnectivityManager import android.net.NetworkCapabilities -import android.net.wifi.WifiManager import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import dev.dettmer.simplenotes.utils.Constants import java.util.concurrent.TimeUnit +/** + * WiFi-Sync BroadcastReceiver + * + * Triggert Sync wenn WiFi verbunden wird (jedes WiFi, keine SSID-Prüfung mehr) + * Die eigentliche Server-Erreichbarkeitsprüfung erfolgt im SyncWorker. + */ class WifiSyncReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -22,34 +27,24 @@ class WifiSyncReceiver : BroadcastReceiver() { return } - // Check if connected to home WiFi - if (isConnectedToHomeWifi(context)) { + // Check if connected to any WiFi (SSID-Prüfung entfernt in v1.4.0) + if (isConnectedToWifi(context)) { scheduleSyncWork(context) } } - @Suppress("ReturnCount") // Early returns for WiFi validation checks - private fun isConnectedToHomeWifi(context: Context): Boolean { - val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) - val homeSSID = prefs.getString(Constants.KEY_HOME_SSID, null) ?: return false - + /** + * Prüft ob ein WiFi-Netzwerk verbunden ist (beliebiges WiFi) + * Die Server-Erreichbarkeitsprüfung erfolgt erst im SyncWorker. + */ + private fun isConnectedToWifi(context: Context): Boolean { val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val network = connectivityManager.activeNetwork ?: return false val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false - if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { - return false - } - - // Get current SSID - val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) - as WifiManager - val wifiInfo = wifiManager.connectionInfo - val currentSSID = wifiInfo.ssid.replace("\"", "") - - return currentSSID == homeSSID + return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } private fun scheduleSyncWork(context: Context) { diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt index b4f9fe5..e10daac 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt @@ -6,7 +6,6 @@ object Constants { const val KEY_SERVER_URL = "server_url" const val KEY_USERNAME = "username" const val KEY_PASSWORD = "password" - const val KEY_HOME_SSID = "home_ssid" const val KEY_AUTO_SYNC = "auto_sync_enabled" const val KEY_LAST_SYNC = "last_sync_timestamp" diff --git a/android/app/src/main/res/drawable/ic_checklist_24.xml b/android/app/src/main/res/drawable/ic_checklist_24.xml new file mode 100644 index 0000000..9723cba --- /dev/null +++ b/android/app/src/main/res/drawable/ic_checklist_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_delete_24.xml b/android/app/src/main/res/drawable/ic_delete_24.xml new file mode 100644 index 0000000..e2db651 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_delete_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_drag_handle_24.xml b/android/app/src/main/res/drawable/ic_drag_handle_24.xml new file mode 100644 index 0000000..9fe249a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_drag_handle_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_note_24.xml b/android/app/src/main/res/drawable/ic_note_24.xml new file mode 100644 index 0000000..9c78cbf --- /dev/null +++ b/android/app/src/main/res/drawable/ic_note_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/layout/activity_editor.xml b/android/app/src/main/res/layout/activity_editor.xml index 41943fc..6aa5638 100644 --- a/android/app/src/main/res/layout/activity_editor.xml +++ b/android/app/src/main/res/layout/activity_editor.xml @@ -2,6 +2,7 @@ - + - + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_checklist_editor.xml b/android/app/src/main/res/layout/item_checklist_editor.xml new file mode 100644 index 0000000..3afec45 --- /dev/null +++ b/android/app/src/main/res/layout/item_checklist_editor.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_note.xml b/android/app/src/main/res/layout/item_note.xml index 97d882c..c60c229 100644 --- a/android/app/src/main/res/layout/item_note.xml +++ b/android/app/src/main/res/layout/item_note.xml @@ -1,8 +1,10 @@ + - - + + android:orientation="horizontal" + android:gravity="center_vertical"> - + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 36c1114..b390b2e 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -24,6 +24,7 @@ Note Title Note content preview… Vor 2 Std + Ohne Titel Notiz löschen? @@ -42,10 +43,9 @@ Sync-Einstellungen - Heim-WLAN SSID Auto-Sync aktiviert Sync-Status - ℹ️ Auto-Sync:\n\n• Prüft alle 30 Min ob Server erreichbar\n• Funktioniert nur im selben Netzwerk\n• Läuft auch im Hintergrund\n• Minimaler Akkuverbrauch (~0.4%%/Tag) + ℹ️ Auto-Sync:\n\n• Prüft alle 30 Min ob Server erreichbar\n• Funktioniert bei jeder WiFi-Verbindung\n• Läuft auch im Hintergrund\n• Minimaler Akkuverbrauch (~0.4%%/Tag) Backup & Wiederherstellung @@ -66,4 +66,27 @@ ℹ️ Datenschutz: Logs werden nur lokal auf deinem Gerät gespeichert und niemals an externe Server gesendet. Die Logs enthalten Sync-Aktivitäten zur Fehlerdiagnose. Du kannst sie jederzeit löschen oder exportieren. + + + + + + + Notiz + Liste + + + Neue Liste + Liste bearbeiten + Element hinzufügen + Neues Element… + Element verschieben + Element löschen + Notiz ist leer + Notiz gespeichert + Notiz gelöscht + + + %1$d/%2$d erledigt + Keine Einträge diff --git a/docs/DOCS.en.md b/docs/DOCS.en.md index b353f90..5adeeba 100644 --- a/docs/DOCS.en.md +++ b/docs/DOCS.en.md @@ -78,7 +78,7 @@ val syncRequest = PeriodicWorkRequestBuilder( ### Network Detection -Instead of SSID-based detection (Android 13+ privacy issues), we use **Gateway IP Comparison**: +We use **Gateway IP Comparison** to check if the server is reachable: ```kotlin fun isInHomeNetwork(): Boolean { @@ -127,7 +127,7 @@ The app uses **4 different sync triggers** with different use cases: | **1. Manual Sync** | `MainActivity.kt` | `triggerManualSync()` | User clicks sync button in menu | ✅ Yes | | **2. Auto-Sync (onResume)** | `MainActivity.kt` | `triggerAutoSync()` | App opened/resumed | ✅ Yes | | **3. Background Sync (Periodic)** | `SyncWorker.kt` | `doWork()` | Every 15/30/60 minutes (configurable) | ✅ Yes | -| **4. WiFi-Connect Sync** | `NetworkMonitor.kt` → `SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi enabled/SSID changed | ✅ Yes | +| **4. WiFi-Connect Sync** | `NetworkMonitor.kt` → `SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi connected | ✅ Yes | ### Server Reachability Check (Pre-Check) @@ -168,7 +168,7 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) { | Manual Sync | Toast: "Server not reachable" | Toast: "✅ Synced: X notes" | None | | Auto-Sync (onResume) | Silent abort (no toast) | Toast: "✅ Synced: X notes" | Max. 1x/min | | Background Sync | Silent abort (no toast) | Silent (LocalBroadcast only) | 15/30/60 min | -| WiFi-Connect Sync | Silent abort (no toast) | Silent (LocalBroadcast only) | SSID-based | +| WiFi-Connect Sync | Silent abort (no toast) | Silent (LocalBroadcast only) | WiFi-based | --- @@ -349,7 +349,7 @@ The app requires **minimal permissions**: ``` **No Location Permissions!** -Earlier versions required `ACCESS_FINE_LOCATION` for SSID detection. Now we use Gateway IP Comparison. +We use Gateway IP Comparison instead of SSID detection. No location permission required. --- diff --git a/docs/DOCS.md b/docs/DOCS.md index 1f1fe17..b34be0d 100644 --- a/docs/DOCS.md +++ b/docs/DOCS.md @@ -78,7 +78,7 @@ val syncRequest = PeriodicWorkRequestBuilder( ### Network Detection -Statt SSID-basierter Erkennung (Android 13+ Privacy-Probleme) verwenden wir **Gateway IP Comparison**: +Wir verwenden **Gateway IP Comparison** um zu prüfen, ob der Server erreichbar ist: ```kotlin fun isInHomeNetwork(): Boolean { @@ -127,7 +127,7 @@ Die App verwendet **4 verschiedene Sync-Trigger** mit unterschiedlichen Anwendun | **1. Manueller Sync** | `MainActivity.kt` | `triggerManualSync()` | User klickt auf Sync-Button im Menü | ✅ Ja | | **2. Auto-Sync (onResume)** | `MainActivity.kt` | `triggerAutoSync()` | App wird geöffnet/fortgesetzt | ✅ Ja | | **3. Hintergrund-Sync (Periodic)** | `SyncWorker.kt` | `doWork()` | Alle 15/30/60 Minuten (konfigurierbar) | ✅ Ja | -| **4. WiFi-Connect Sync** | `NetworkMonitor.kt` → `SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi an/SSID-Wechsel | ✅ Ja | +| **4. WiFi-Connect Sync** | `NetworkMonitor.kt` → `SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi verbunden | ✅ Ja | ### Server-Erreichbarkeits-Check (Pre-Check) @@ -168,7 +168,7 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) { | Manueller Sync | Toast: "Server nicht erreichbar" | Toast: "✅ Gesynct: X Notizen" | Keins | | Auto-Sync (onResume) | Silent abort (kein Toast) | Toast: "✅ Gesynct: X Notizen" | Max. 1x/Min | | Hintergrund-Sync | Silent abort (kein Toast) | Silent (LocalBroadcast only) | 15/30/60 Min | -| WiFi-Connect Sync | Silent abort (kein Toast) | Silent (LocalBroadcast only) | SSID-basiert | +| WiFi-Connect Sync | Silent abort (kein Toast) | Silent (LocalBroadcast only) | WiFi-basiert | --- @@ -349,7 +349,7 @@ Die App benötigt **minimale Permissions**: ``` **Keine Location Permissions!** -Frühere Versionen benötigten `ACCESS_FINE_LOCATION` für SSID-Erkennung. Jetzt verwenden wir Gateway IP Comparison. +Wir verwenden Gateway IP Comparison statt SSID-Erkennung. Keine Standortberechtigung nötig. --- diff --git a/docs/FEATURES.en.md b/docs/FEATURES.en.md index b55857b..0fbdb8f 100644 --- a/docs/FEATURES.en.md +++ b/docs/FEATURES.en.md @@ -8,8 +8,16 @@ ## 📝 Note Management +### Note Types +- ✅ **Text notes** - Classic free-form notes +- ✅ **Checklists** _(NEW in v1.4.0)_ - Task lists with tap-to-check + - ➕ Add items via input field + - ☑️ Tap to check/uncheck + - 📌 Long-press for drag & drop sorting + - 🗑️ Swipe-to-delete individual items + - ~~Strikethrough~~ for completed entries + ### Basic Features -- ✅ **Simple text notes** - Focus on content, no distractions - ✅ **Auto-save** - No manual saving needed - ✅ **Title + content** - Clear structure for each note - ✅ **Timestamps** - Creation and modification date automatically @@ -52,9 +60,11 @@ ### Markdown Export - ✅ **Automatic export** - Each note → `.md` file +- ✅ **Checklists as task lists** _(NEW)_ - `- [ ]` / `- [x]` format (GitHub-compatible) - ✅ **Dual-format** - JSON (master) + Markdown (mirror) - ✅ **Filename sanitization** - Safe filenames from titles -- ✅ **Frontmatter metadata** - YAML with ID, timestamps, tags +- ✅ **Duplicate handling** _(NEW)_ - ID suffix for same titles +- ✅ **Frontmatter metadata** - YAML with ID, timestamps, type - ✅ **WebDAV sync** - Parallel to JSON sync - ✅ **Optional** - Toggle in settings - ✅ **Initial export** - All existing notes when activated @@ -81,16 +91,16 @@ ### Auto-Sync - ✅ **Interval selection** - 15, 30 or 60 minutes -- ✅ **WiFi binding** - Only in configured home WiFi +- ✅ **WiFi trigger** - Sync on WiFi connection _(no SSID restriction)_ - ✅ **Battery-friendly** - ~0.2-0.8% per day -- ✅ **Smart server check** - No errors on foreign networks +- ✅ **Smart server check** - Sync only when server is reachable - ✅ **WorkManager** - Reliable background execution - ✅ **Battery optimization compatible** - Works even with Doze mode ### Sync Triggers (6 total) 1. ✅ **Periodic sync** - Automatically after interval 2. ✅ **App-start sync** - When opening the app -3. ✅ **WiFi-connect sync** - When home WiFi connects +3. ✅ **WiFi-connect sync** - On any WiFi connection 4. ✅ **Manual sync** - Button in settings 5. ✅ **Pull-to-refresh** - Swipe gesture in notes list 6. ✅ **Settings-save sync** - After server configuration @@ -109,7 +119,6 @@ - ✅ **HTTP/HTTPS** - HTTP only local, HTTPS for external - ✅ **Username/password** - Basic authentication - ✅ **Connection test** - Test in settings -- ✅ **Gateway SSID** - WiFi name for auto-sync - ✅ **Server URL normalization** - Automatic `/notes/` and `/notes-md/` _(NEW in v1.2.1)_ - ✅ **Flexible URL input** - Both variants work: `http://server/` and `http://server/notes/` @@ -142,7 +151,7 @@ ### Battery Efficiency - ✅ **Optimized sync intervals** - 15/30/60 min - ✅ **WiFi-only** - No mobile data sync -- ✅ **Smart server check** - Only in home WiFi +- ✅ **Smart server check** - Sync only when server is reachable - ✅ **WorkManager** - System-optimized execution - ✅ **Doze mode compatible** - Sync runs even in standby - ✅ **Measured consumption:** diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 62b5f27..c00d7ba 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -8,8 +8,16 @@ ## 📝 Notiz-Verwaltung +### Notiz-Typen +- ✅ **Textnotizen** - Klassische Freitext-Notizen +- ✅ **Checklisten** _(NEU in v1.4.0)_ - Aufgabenlisten mit Tap-to-Check + - ➕ Items hinzufügen über Eingabefeld + - ☑️ Tap zum Abhaken/Wieder-Öffnen + - 📌 Long-Press für Drag & Drop Sortierung + - 🗑️ Swipe-to-Delete für einzelne Items + - ~~Durchstreichen~~ bei erledigten Einträgen + ### Basis-Funktionen -- ✅ **Einfache Textnotizen** - Fokus auf Inhalt, keine Ablenkung - ✅ **Automatisches Speichern** - Kein manuelles Speichern nötig - ✅ **Titel + Inhalt** - Klare Struktur für jede Notiz - ✅ **Zeitstempel** - Erstellungs- und Änderungsdatum automatisch @@ -52,9 +60,11 @@ ### Markdown-Export - ✅ **Automatischer Export** - Jede Notiz → `.md` Datei +- ✅ **Checklisten als Task-Listen** _(NEU)_ - `- [ ]` / `- [x]` Format (GitHub-kompatibel) - ✅ **Dual-Format** - JSON (Master) + Markdown (Mirror) - ✅ **Dateinamen-Sanitization** - Sichere Dateinamen aus Titeln -- ✅ **Frontmatter-Metadata** - YAML mit ID, Timestamps, Tags +- ✅ **Duplikat-Handling** _(NEU)_ - ID-Suffix bei gleichen Titeln +- ✅ **Frontmatter-Metadata** - YAML mit ID, Timestamps, Type - ✅ **WebDAV-Sync** - Parallel zum JSON-Sync - ✅ **Optional** - In Einstellungen ein/ausschaltbar - ✅ **Initial Export** - Alle bestehenden Notizen beim Aktivieren @@ -81,16 +91,16 @@ ### Auto-Sync - ✅ **Intervall-Auswahl** - 15, 30 oder 60 Minuten -- ✅ **WLAN-Bindung** - Nur im konfigurierten Heim-WLAN +- ✅ **WiFi-Trigger** - Sync bei WiFi-Verbindung _(keine SSID-Einschränkung)_ - ✅ **Akkuschonend** - ~0.2-0.8% pro Tag -- ✅ **Smart Server-Check** - Keine Fehler in fremden Netzwerken +- ✅ **Smart Server-Check** - Sync nur wenn Server erreichbar - ✅ **WorkManager** - Zuverlässige Background-Ausführung - ✅ **Battery-Optimierung kompatibel** - Funktioniert auch mit Doze Mode ### Sync-Trigger (6 Stück) 1. ✅ **Periodic Sync** - Automatisch nach Intervall 2. ✅ **App-Start Sync** - Beim Öffnen der App -3. ✅ **WiFi-Connect Sync** - Wenn Heim-WLAN verbindet +3. ✅ **WiFi-Connect Sync** - Bei jeder WiFi-Verbindung 4. ✅ **Manual Sync** - Button in Einstellungen 5. ✅ **Pull-to-Refresh** - Wisch-Geste in Notizliste 6. ✅ **Settings-Save Sync** - Nach Server-Konfiguration @@ -109,7 +119,6 @@ - ✅ **HTTP/HTTPS** - HTTP nur lokal, HTTPS für extern - ✅ **Username/Password** - Basic Authentication - ✅ **Connection Test** - In Einstellungen testen -- ✅ **Gateway SSID** - WLAN-Name für Auto-Sync - ✅ **Server-URL Normalisierung** - Automatisches `/notes/` und `/notes-md/` _(NEU in v1.2.1)_ - ✅ **Flexible URL-Eingabe** - Beide Varianten funktionieren: `http://server/` und `http://server/notes/` @@ -141,8 +150,8 @@ ### Akku-Effizienz - ✅ **Optimierte Sync-Intervalle** - 15/30/60 Min -- ✅ **WLAN-Only** - Kein Mobile Data Sync -- ✅ **Smart Server-Check** - Nur im Heim-WLAN +- ✅ **WiFi-Only** - Kein Mobile Data Sync +- ✅ **Smart Server-Check** - Sync nur wenn Server erreichbar - ✅ **WorkManager** - System-optimierte Ausführung - ✅ **Doze Mode kompatibel** - Sync läuft auch im Standby - ✅ **Gemessener Verbrauch:** diff --git a/docs/SETTINGS_REDESIGN_PLAN.md b/docs/SETTINGS_REDESIGN_PLAN.md new file mode 100644 index 0000000..c353874 --- /dev/null +++ b/docs/SETTINGS_REDESIGN_PLAN.md @@ -0,0 +1,234 @@ +# Settings-Redesign Plan (v1.5.0) + +## 📋 Übersicht + +Die aktuelle Settings-Activity hat 857 Zeilen XML und ist unübersichtlich geworden. +Ziel: Moderne, gruppierte Settings nach Material Design 3 Richtlinien. + +--- + +## 🔍 Aktuelle Struktur (v1.4.0) + +### Vorhandene Cards (6 Stück) +1. **Server-Konfiguration** (~200 Zeilen) + - Protokoll-Auswahl (HTTP/HTTPS) + - Server-URL + - Username/Password + - Verbindung testen + +2. **Sync-Einstellungen** (~160 Zeilen) + - Auto-Sync Toggle + - Sync-Intervall (15/30/60 Min) + - Jetzt synchronisieren Button + +3. **Markdown-Integration** (~100 Zeilen) + - Markdown Auto-Sync Toggle + - Manueller Markdown-Sync Button + +4. **Backup & Wiederherstellung** (~100 Zeilen) + - Backup erstellen + - Aus Datei wiederherstellen + - Vom Server wiederherstellen + +5. **Debug/Entwickler** (~100 Zeilen) + - File Logging Toggle + - Logs exportieren + - Logs löschen + +6. **Über die App** (~100 Zeilen) + - Version + - GitHub-Links + - Lizenz + +--- + +## 🎯 Neue Struktur (v1.5.0) + +### Ansatz: PreferenceFragmentCompat + Material 3 + +Anstatt XML-Cards mit manuellen Views verwenden wir das moderne Preference-System: + +``` +┌─────────────────────────────────────────┐ +│ ⚙️ Einstellungen │ +├─────────────────────────────────────────┤ +│ │ +│ 🔄 SYNCHRONISATION │ +│ ├─ Server-Verbindung → │ +│ ├─ Auto-Sync 🔘 │ +│ └─ Sync-Intervall 30 Min │ +│ │ +│ 📝 NOTIZEN │ +│ └─ Markdown-Export 🔘 │ +│ │ +│ 💾 DATEN │ +│ ├─ Backup erstellen → │ +│ ├─ Wiederherstellen → │ +│ └─ Vom Server laden → │ +│ │ +│ 🔧 ERWEITERT │ +│ ├─ Datei-Logging 🔘 │ +│ └─ Logs verwalten → │ +│ │ +│ ℹ️ ÜBER │ +│ ├─ Version 1.5.0 │ +│ ├─ GitHub Repository → │ +│ ├─ Entwickler → │ +│ └─ Lizenz MIT │ +│ │ +└─────────────────────────────────────────┘ +``` + +--- + +## 📐 Technische Implementierung + +### Option A: PreferenceFragmentCompat (Empfohlen) +**Vorteile:** +- Native Android Preference-System +- Automatische State-Verwaltung +- Eingebaute Material 3 Styles +- Hierarchische Navigation (Sub-Screens) +- Weniger Code (~300 Zeilen statt 1148) + +**Dateien:** +``` +res/xml/ +├── preferences_root.xml # Hauptmenü mit Kategorien +├── preferences_sync.xml # Server-Konfiguration +├── preferences_data.xml # Backup-Optionen +└── preferences_debug.xml # Entwickler-Optionen + +SettingsActivity.kt # Wird zu SettingsFragment.kt +``` + +### Option B: Jetpack Compose (Zukunftssicher) +**Vorteile:** +- Modernste UI-Technologie +- Deklarativer Code +- Hot Reload während Entwicklung +- Besser für komplexe Custom-UI + +**Nachteile:** +- Größere Migration +- Mischung mit View-System komplizierter +- Lernkurve + +### Empfehlung: **Option A** für v1.5.0 +PreferenceFragmentCompat ist der richtige Mittelweg: +- Schnell zu implementieren (~1-2 Tage) +- Native Material 3 Unterstützung +- Etabliertes Pattern +- Compose-Migration kann später erfolgen (v2.0.0) + +--- + +## 🎨 Design-Prinzipien (Material 3) + +### 1. Preference Categories +```xml + +``` + +### 2. Switch Preferences +```xml + +``` + +### 3. Navigations-Preferences (→ Sub-Screen) +```xml + +``` + +### 4. List Preferences (Dropdown) +```xml + +``` + +--- + +## 📁 Neue Dateistruktur + +``` +app/src/main/java/dev/dettmer/simplenotes/ +├── settings/ +│ ├── SettingsFragment.kt # Haupt-Preference-Fragment +│ ├── ServerSettingsFragment.kt # Server-Konfiguration +│ ├── BackupSettingsFragment.kt # Backup/Restore Dialoge +│ └── DebugSettingsFragment.kt # Logging & Logs + +app/src/main/res/xml/ +├── preferences_root.xml +├── preferences_server.xml +├── preferences_backup.xml +└── preferences_debug.xml +``` + +--- + +## ✅ Implementierungs-Checklist + +### Phase 1: Grundstruktur +- [ ] `preferences_root.xml` erstellen +- [ ] `SettingsFragment.kt` mit PreferenceFragmentCompat +- [ ] SettingsActivity als Container anpassen +- [ ] Kategorien: Sync, Notizen, Daten, Erweitert, Über + +### Phase 2: Server-Konfiguration +- [ ] `preferences_server.xml` für Server-Details +- [ ] `ServerSettingsFragment.kt` mit Custom-Dialogen +- [ ] Verbindungstest-Button als Preference-Action +- [ ] Protocol-Auswahl als ListPreference + +### Phase 3: Sync & Markdown +- [ ] Auto-Sync als SwitchPreference +- [ ] Sync-Intervall als ListPreference +- [ ] Markdown-Export als SwitchPreference +- [ ] "Jetzt synchronisieren" als Action-Preference + +### Phase 4: Backup & Debug +- [ ] Backup-Aktionen als Preferences +- [ ] Logging-Toggle +- [ ] Log-Export/Clear Aktionen + +### Phase 5: Über & Polish +- [ ] Version, Links, Lizenz +- [ ] Icons für alle Kategorien +- [ ] Animationen & Transitions +- [ ] Dark Mode Testing + +--- + +## ⏱️ Zeitschätzung + +| Phase | Aufwand | +|-------|---------| +| Phase 1: Grundstruktur | 2-3h | +| Phase 2: Server | 3-4h | +| Phase 3: Sync | 2h | +| Phase 4: Backup/Debug | 2h | +| Phase 5: Polish | 2h | +| **Gesamt** | **~12h** | + +--- + +## 🔗 Referenzen + +- [Material 3 Settings](https://m3.material.io/components/lists/overview) +- [AndroidX Preference](https://developer.android.com/develop/ui/views/components/settings) +- [Preference Styling](https://developer.android.com/develop/ui/views/components/settings/organize-your-settings) diff --git a/fastlane/metadata/android/de-DE/changelogs/11.txt b/fastlane/metadata/android/de-DE/changelogs/11.txt new file mode 100644 index 0000000..86ac44c --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/11.txt @@ -0,0 +1,12 @@ +NEU: Checklisten! +- Erstelle Checklisten-Notizen mit Tap-to-Check +- Markdown-Export als GitHub-Style Aufgabenlisten + +Fixes: +- Robusteres Markdown-Parsing +- Doppelte Dateinamen bekommen ID-Suffix +- Keine Benachrichtigungen wenn App offen ist + +Privacy: +- 2 WiFi-Permissions entfernt (ACCESS/CHANGE_WIFI_STATE) +- WiFi-Binding funktioniert bereits ohne SSID-Zugriff! diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt index 0239a9a..8065c9e 100644 --- a/fastlane/metadata/android/de-DE/full_description.txt +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -2,16 +2,15 @@ Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisatio HAUPTFUNKTIONEN: -• Einfache Notizen erstellen und bearbeiten +• Text-Notizen und Checklisten erstellen +• Checklisten mit Tap-to-Check, Drag & Drop, Swipe-to-Delete • WebDAV-Synchronisation mit eigenem Server • Multi-Device Sync (Handy, Tablet, Desktop) • Markdown-Export für Obsidian/Desktop-Editoren +• Checklisten als GitHub-Style Task-Listen exportieren • Automatische Synchronisation im Heim-WLAN • Konfigurierbares Sync-Interval (15/30/60 Minuten) -• Transparente Batterie-Verbrauchsanzeige • Material Design 3 mit Dynamic Colors (Android 12+) -• Swipe-to-Delete mit Server-Sync -• Server-Backup & Wiederherstellung (Merge/Replace/Overwrite) • Komplett offline nutzbar • Keine Werbung, keine Tracker diff --git a/fastlane/metadata/android/de-DE/short_description.txt b/fastlane/metadata/android/de-DE/short_description.txt index 1dce9c4..b694185 100644 --- a/fastlane/metadata/android/de-DE/short_description.txt +++ b/fastlane/metadata/android/de-DE/short_description.txt @@ -1 +1 @@ -Einfache Notizen-App mit WebDAV-Synchronisation \ No newline at end of file +Notizen & Checklisten mit WebDAV-Sync zu deinem eigenen Server \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/11.txt b/fastlane/metadata/android/en-US/changelogs/11.txt new file mode 100644 index 0000000..602060f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/11.txt @@ -0,0 +1,12 @@ +NEW: Checklists! +- Create checklist notes with tap-to-check items +- Markdown export as GitHub-style task lists + +Fixes: +- More robust Markdown parsing +- Duplicate filenames get ID suffix +- No notifications when app is open + +Privacy: +- Removed 2 WiFi permissions (ACCESS/CHANGE_WIFI_STATE) +- WiFi binding already works without SSID access! diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index a5197a5..92257a2 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -2,16 +2,15 @@ Simple Notes Sync is a minimalist note-taking app with WebDAV synchronization. KEY FEATURES: -• Create and edit simple notes +• Create text notes and checklists +• Checklists with tap-to-check, drag & drop, swipe-to-delete • WebDAV synchronization with your own server • Multi-device sync (phone, tablet, desktop) • Markdown export for Obsidian/desktop editors +• Checklists export as GitHub-style task lists • Automatic synchronization on home WiFi • Configurable sync interval (15/30/60 minutes) -• Transparent battery usage display • Material Design 3 with Dynamic Colors (Android 12+) -• Swipe-to-delete with server sync -• Server backup & restore (Merge/Replace/Overwrite) • Fully usable offline • No ads, no trackers diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt index 4c3c773..f149f8e 100644 --- a/fastlane/metadata/android/en-US/short_description.txt +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -1 +1 @@ -Simple note-taking app with WebDAV synchronization +Notes & checklists with WebDAV sync to your own server diff --git a/metadata/dev.dettmer.simplenotes.yml b/metadata/dev.dettmer.simplenotes.yml index afc526c..56332b5 100644 --- a/metadata/dev.dettmer.simplenotes.yml +++ b/metadata/dev.dettmer.simplenotes.yml @@ -133,7 +133,21 @@ Builds: scandelete: - android/gradle/wrapper + - versionName: 1.4.0 + versionCode: 11 + commit: v1.4.0 + subdir: android/app + sudo: + - apt-get update + - apt-get install -y openjdk-17-jdk-headless + - update-java-alternatives -a + gradle: + - fdroid + prebuild: sed -i -e '/signingConfig/d' build.gradle.kts + scandelete: + - android/gradle/wrapper + AutoUpdateMode: Version UpdateCheckMode: Tags -CurrentVersion: 1.3.2 -CurrentVersionCode: 10 +CurrentVersion: 1.4.0 +CurrentVersionCode: 11