Merge feature/v1.5.0: Complete UI redesign with Jetpack Compose

🎨 Major Features:
- Full Jetpack Compose UI redesign
- Material Design 3 with Dynamic Colors
- i18n support (English/German)
- Checklists with drag & drop
- Silent-Sync mode

Closes #5
This commit is contained in:
inventory69
2026-01-16 22:25:00 +01:00
105 changed files with 10728 additions and 2856 deletions

518
CHANGELOG.de.md Normal file
View File

@@ -0,0 +1,518 @@
# Changelog
Alle wichtigen Änderungen an Simple Notes Sync werden in dieser Datei dokumentiert.
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
**🌍 Sprachen:** **Deutsch** · [English](CHANGELOG.md)
---
## [1.5.0] - 2026-01-15
### 🎉 Major: Jetpack Compose UI Redesign
Das komplette UI wurde von XML-Views auf Jetpack Compose migriert. Die App ist jetzt moderner, schneller und flüssiger.
### 🌍 New Feature: Internationalization (i18n)
- **Englische Sprachunterstützung** - Alle 400+ Strings übersetzt
- **Automatische Spracherkennung** - Folgt der System-Sprache
- **Manuelle Sprachauswahl** - In den Einstellungen umschaltbar
- **Per-App Language (Android 13+)** - Native Spracheinstellung über System-Settings
- **locales_config.xml** - Vollständige Android-Integration
### ⚙️ Modernized Settings
- **7 kategorisierte Settings-Screens** - Übersichtlicher und intuitiver
- **Compose Navigation** - Flüssige Übergänge zwischen Screens
- **Konsistentes Design** - Material Design 3 durchgängig
### ✨ UI Improvements
- **Selection Mode** - Long-Press für Mehrfachauswahl statt Swipe-to-Delete
- **Batch Delete** - Mehrere Notizen gleichzeitig löschen
- **Silent-Sync Mode** - Kein Banner bei Auto-Sync (nur bei manuellem Sync)
- **App Icon in About Screen** - Hochwertige Darstellung
- **App Icon in Empty State** - Statt Emoji bei leerer Notizliste
- **Splash Screen Update** - Verwendet App-Foreground-Icon
- **Slide Animations** - Flüssige Animationen im NoteEditor
### 🔧 Technical Improvements
- **Jetpack Compose** - Komplette UI-Migration
- **Compose ViewModel Integration** - StateFlow für reactive UI
- **Improved Code Quality** - Detekt/Lint Warnings behoben
- **Unused Imports Cleanup** - Sauberer Codebase
### Looking Ahead
> 🚀 **v1.6.0** wird Server-Ordner Prüfung und weitere technische Modernisierungen bringen.
> Feature-Requests gerne als [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues) einreichen.
---
## [1.4.1] - 2026-01-11
### Fixed
- **🗑️ Löschen älterer Notizen (v1.2.0 Kompatibilität)**
- Notizen aus App-Version v1.2.0 oder früher werden jetzt korrekt vom Server gelöscht
- Behebt Problem bei Multi-Device-Nutzung mit älteren Notizen
- **🔄 Checklisten-Sync Abwärtskompatibilität**
- Checklisten werden jetzt auch als Text-Fallback im `content`-Feld gespeichert
- Ältere App-Versionen (v1.3.x) zeigen Checklisten als lesbaren Text
- Format: GitHub-Style Task-Listen (`[ ] Item` / `[x] Item`)
- Recovery-Mode: Falls Checklisten-Items verloren gehen, werden sie aus dem Content wiederhergestellt
### Improved
- **📝 Checklisten Auto-Zeilenumbruch**
- Lange Checklisten-Texte werden jetzt automatisch umgebrochen
- Keine Begrenzung auf 3 Zeilen mehr
- Enter-Taste erstellt weiterhin ein neues Item
### Looking Ahead
> 🚀 **v1.5.0** wird das nächste größere Release. Wir sammeln Ideen und Feedback!
> Feature-Requests gerne als [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues) einreichen.
---
## [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
- **<2A> 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
- **🧹 Code-Qualität: "Clean Slate" Release**
- Alle einfachen Lint-Issues behoben (Phase 1-7 des Cleanup-Plans)
- Unused Imports und Members entfernt
- Magic Numbers durch benannte Konstanten ersetzt
- SwallowedExceptions mit Logger.w() versehen
- MaxLineLength-Verstöße reformatiert
- ConstructorParameterNaming (snake_case → camelCase mit @SerializedName)
- Custom Exceptions: SyncException.kt und ValidationException.kt erstellt
### Added
- **📝 F-Droid Privacy Notice**
- Datenschutz-Hinweis für die Datei-Logging-Funktion
- Erklärt dass Logs nur lokal gespeichert werden
- Erfüllt F-Droid Opt-in Consent-Anforderungen
### Technical Improvements
- **⚡ Neue Konstanten für bessere Wartbarkeit**
- `SYNC_COMPLETED_DELAY_MS`, `ERROR_DISPLAY_DELAY_MS` (MainActivity)
- `CONNECTION_TIMEOUT_MS` (SettingsActivity)
- `SOCKET_TIMEOUT_MS`, `MAX_FILENAME_LENGTH`, `ETAG_PREVIEW_LENGTH` (WebDavSyncService)
- `AUTO_CANCEL_TIMEOUT_MS` (NotificationHelper)
- RFC 1918 IP-Range Konstanten (UrlValidator)
- `DAYS_THRESHOLD`, `TRUNCATE_SUFFIX_LENGTH` (Extensions)
- **🔒 @Suppress Annotations für legitime Patterns**
- ReturnCount: Frühe Returns für Validierung sind idiomatisch
- LoopWithTooManyJumpStatements: Komplexe Sync-Logik dokumentiert
### Notes
- Komplexe Refactorings (LargeClass, LongMethod) für v1.3.3+ geplant
- Deprecation-Warnungen (LocalBroadcastManager, ProgressDialog) bleiben bestehen
---
## [1.3.1] - 2026-01-08
### Fixed
- **🔧 Multi-Device JSON Sync (Danke an Thomas aus Bielefeld)**
- JSON-Dateien werden jetzt korrekt zwischen Geräten synchronisiert
- Funktioniert auch ohne aktiviertes Markdown
- Hybrid-Optimierung: Server-Timestamp (Primary) + E-Tag (Secondary) Checks
- E-Tag wird nach Upload gecached um Re-Download zu vermeiden
### Performance Improvements
- **⚡ JSON Sync Performance-Parität**
- JSON-Sync erreicht jetzt gleiche Performance wie Markdown (~2-3 Sekunden)
- Timestamp-basierte Skip-Logik für unveränderte Dateien (~500ms pro Datei gespart)
- E-Tag-Matching als Fallback für Dateien die seit letztem Sync modifiziert wurden
- **Beispiel:** 24 Dateien von 12-14s auf ~2.7s reduziert (keine Änderungen)
- **⏭️ Skip unveränderte Dateien** (Haupt-Performance-Fix!)
- JSON-Dateien: Überspringt alle Notizen, die seit letztem Sync nicht geändert wurden
- Markdown-Dateien: Überspringt unveränderte MD-Dateien basierend auf Server-Timestamp
- **Spart ~500ms pro Datei** bei Nextcloud (~20 Dateien = 10 Sekunden gespart!)
- Von 21 Sekunden Sync-Zeit auf 2-3 Sekunden reduziert
- **⚡ Session-Caching für WebDAV**
- Sardine-Client wird pro Sync-Session wiederverwendet (~600ms gespart)
- WiFi-IP-Adresse wird gecacht statt bei jeder Anfrage neu ermittelt (~300ms gespart)
- `/notes/` Ordner-Existenz wird nur einmal pro Sync geprüft (~500ms gespart)
- **Gesamt: ~1.4 Sekunden zusätzlich gespart**
- **📝 Content-basierte Markdown-Erkennung**
- Extern bearbeitete Markdown-Dateien werden auch erkannt wenn YAML-Timestamp nicht aktualisiert wurde
- Löst das Problem: Obsidian/Texteditor-Änderungen wurden nicht importiert
- Hybridansatz: Erst Timestamp-Check (schnell), dann Content-Vergleich (zuverlässig)
### Added
- **🔄 Sync-Status-Anzeige (UI)**
- Sichtbares Banner "Synchronisiere..." mit ProgressBar während Sync läuft
- Sync-Button und Pull-to-Refresh werden deaktiviert während Sync aktiv
- Verhindert versehentliche Doppel-Syncs durch visuelle Rückmeldung
- Auch in Einstellungen: "Jetzt synchronisieren" Button wird deaktiviert
### Fixed
- **🔧 Sync-Mutex verhindert doppelte Syncs**
- Keine doppelten Toast-Nachrichten mehr bei schnellem Pull-to-Refresh
- Concurrent Sync-Requests werden korrekt blockiert
- **🐛 Lint-Fehler behoben**
- `View.generateViewId()` statt hardcodierte IDs in RadioButtons
- `app:tint` statt `android:tint` für AppCompat-Kompatibilität
### Added
- **🔍 detekt Code-Analyse**
- Statische Code-Analyse mit detekt 1.23.4 integriert
- Pragmatische Konfiguration für Sync-intensive Codebasis
- 91 Issues identifiziert (als Baseline für v1.4.0)
- **🏗️ Debug Build mit separatem Package**
- Debug-APK kann parallel zur Release-Version installiert werden
- Package: `dev.dettmer.simplenotes.debug` (Debug) vs `dev.dettmer.simplenotes` (Release)
- App-Name zeigt "Simple Notes (Debug)" für einfache Unterscheidung
- **📊 Debug-Logging UI**
- Neuer "Debug Log" Button in Einstellungen → Erweitert
- Zeigt letzte Sync-Logs mit Zeitstempeln
- Export-Funktion für Fehlerberichte
### Technical
- `WebDavSyncService`: Hybrid-Optimierung für JSON-Downloads (Timestamp PRIMARY, E-Tag SECONDARY)
- `WebDavSyncService`: E-Tag refresh nach Upload statt Invalidierung (verhindert Re-Download)
- E-Tag Caching: `SharedPreferences` mit Key-Pattern `etag_json_{noteId}`
- Skip-Logik: `if (serverModified <= lastSync) skip` → ~1ms pro Datei
- Fallback E-Tag: `if (serverETag == cachedETag) skip` → für Dateien modifiziert nach lastSync
- PROPFIND nach PUT: Fetch E-Tag nach Upload für korrektes Caching
- `SyncStateManager`: Neuer Singleton mit `StateFlow<Boolean>` für Sync-Status
- `MainActivity`: Observer auf `SyncStateManager.isSyncing` für UI-Updates
- Layout: `sync_status_banner` mit `ProgressBar` + `TextView`
- `WebDavSyncService`: Skip-Logik für unveränderte JSON/MD Dateien basierend auf `lastSyncTimestamp`
- `WebDavSyncService`: Neue Session-Cache-Variablen (`sessionSardine`, `sessionWifiAddress`, `notesDirEnsured`)
- `getOrCreateSardine()`: Cached Sardine-Client mit automatischer Credentials-Konfiguration
- `getOrCacheWiFiAddress()`: WiFi-Adresse wird nur einmal pro Sync ermittelt
- `clearSessionCache()`: Aufräumen am Ende jeder Sync-Session
- `ensureNotesDirectoryExists()`: Cached Directory-Check
- Content-basierter Import: Vergleicht MD-Content mit lokaler Note wenn Timestamps gleich
- Build-Tooling: detekt aktiviert, ktlint vorbereitet (deaktiviert wegen Parser-Problemen)
- Debug BuildType: `applicationIdSuffix = ".debug"`, `versionNameSuffix = "-debug"`
---
## [1.3.0] - 2026-01-07
### Added
- **🚀 Multi-Device Sync** (Thanks to Thomas from Bielefeld for reporting!)
- Automatic download of new notes from other devices
- Deletion tracking prevents "zombie notes" (deleted notes don't come back)
- Smart cleanup: Re-created notes (newer timestamp) are downloaded
- Works with all devices: v1.2.0, v1.2.1, v1.2.2, and v1.3.0
- **🗑️ Server Deletion via Swipe Gesture**
- Swipe left on notes to delete from server (requires confirmation)
- Prevents duplicate notes on other devices
- Works with deletion tracking system
- Material Design confirmation dialog
- **⚡ E-Tag Performance Optimization**
- Smart server checking with E-Tag caching (~150ms vs 3000ms for "no changes")
- 20x faster when server has no updates
- E-Tag hybrid approach: E-Tag for JSON (fast), timestamp for Markdown (reliable)
- Battery-friendly with minimal server requests
- **📥 Markdown Auto-Sync Toggle**
- NEW: Unified Auto-Sync toggle in Settings (replaces separate Export/Auto-Import toggles)
- When enabled: Notes export to Markdown AND import changes automatically
- When disabled: Manual sync button appears for on-demand synchronization
- Performance: Auto-Sync OFF = 0ms overhead
- **🔘 Manual Markdown Sync Button**
- Manual sync button for performance-conscious users
- Shows import/export counts after completion
- Only visible when Auto-Sync is disabled
- On-demand synchronization (~150-200ms only when triggered)
- **⚙️ Server-Restore Modes**
- MERGE: Keep local notes + add server notes
- REPLACE: Delete all local + download from server
- OVERWRITE: Update duplicates, keep non-duplicates
- Restore modes now work correctly for WebDAV restore
### Technical
- New `DeletionTracker` model with JSON persistence
- `NotesStorage`: Added deletion tracking methods
- `WebDavSyncService.hasUnsyncedChanges()`: Intelligent server checks with E-Tag caching
- `WebDavSyncService.downloadRemoteNotes()`: Deletion-aware downloads
- `WebDavSyncService.restoreFromServer()`: Support for restore modes
- `WebDavSyncService.deleteNoteFromServer()`: Server deletion with YAML frontmatter scanning
- `WebDavSyncService.importMarkdownFiles()`: Automatic Markdown import during sync
- `WebDavSyncService.manualMarkdownSync()`: Manual sync with result counts
- `MainActivity.setupSwipeToDelete()`: Two-stage swipe deletion with confirmation
- E-Tag caching in SharedPreferences for performance
---
## [1.2.2] - 2026-01-06
### Fixed
- **Backward Compatibility for v1.2.0 Users (Critical)**
- App now reads BOTH old (Root) AND new (`/notes/`) folder structures
- Users upgrading from v1.2.0 no longer lose their existing notes
- Server-Restore now finds notes from v1.2.0 stored in Root folder
- Automatic deduplication prevents loading the same note twice
- Graceful error handling if Root folder is not accessible
### Technical
- `WebDavSyncService.downloadRemoteNotes()` - Dual-mode download (Root + /notes/)
- `WebDavSyncService.restoreFromServer()` - Now uses dual-mode download
- Migration happens naturally: new uploads go to `/notes/`, old notes stay readable
---
## [1.2.1] - 2026-01-05
### Fixed
- **Markdown Initial Export Bugfix**
- Existing notes are now exported as Markdown when Desktop Integration is activated
- Previously, only new notes created after activation were exported
- Progress dialog shows export status with current/total counter
- Error handling for network issues during export
- Individual note failures don't abort the entire export
- **Markdown Directory Structure Fix**
- Markdown files now correctly land in `/notes-md/` folder
- Smart URL detection supports both Root-URL and `/notes` URL structures
- Previously, MD files were incorrectly placed in the root directory
- Markdown import now finds files correctly
- **JSON URL Normalization**
- Simplified server configuration: enter only base URL (e.g., `http://server:8080/`)
- App automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown
- Smart detection: both `http://server:8080/` and `http://server:8080/notes/` work correctly
- Backward compatible: existing setups with `/notes` in URL continue to work
- No migration required for existing users
### Changed
- **Markdown Directory Creation**
- `notes-md/` folder is now created on first sync (regardless of Desktop Integration setting)
- Prevents 404 errors when mounting WebDAV folder
- Better user experience: folder is visible before enabling the feature
- **Settings UI Improvements**
- Updated example URL from `/webdav` to `/notes` to match app behavior
- Example now shows: `http://192.168.0.188:8080/notes`
### Technical
- `WebDavSyncService.ensureMarkdownDirectoryExists()` - Creates MD folder early
- `WebDavSyncService.getMarkdownUrl()` - Smart URL detection for both structures
- `WebDavSyncService.exportAllNotesToMarkdown()` - Exports all local notes with progress callback
- `SettingsActivity.onMarkdownExportToggled()` - Triggers initial export with ProgressDialog
---
## [1.2.0] - 2026-01-04
### Added
- **Local Backup System**
- Export all notes as JSON file to any location (Downloads, SD card, cloud folder)
- Import backup with 3 modes: Merge, Replace, or Overwrite duplicates
- Automatic safety backup created before every restore
- Backup validation (format and version check)
- **Markdown Desktop Integration**
- Optional Markdown export parallel to JSON sync
- `.md` files synced to `notes-md/` folder on WebDAV
- YAML frontmatter with `id`, `created`, `updated`, `device`
- Manual import button to pull Markdown changes from server
- Last-Write-Wins conflict resolution via timestamps
- **Settings UI Extensions**
- New "Backup & Restore" section with local + server restore
- New "Desktop Integration" section with Markdown toggle
- Universal restore dialog with radio button mode selection
### Changed
- **Server Restore Behavior**: Users now choose restore mode (Merge/Replace/Overwrite) instead of hard-coded replace-all
### Technical
- `BackupManager.kt` - Complete backup/restore logic
- `Note.toMarkdown()` / `Note.fromMarkdown()` - Markdown conversion with YAML frontmatter
- `WebDavSyncService` - Extended for dual-format sync (JSON master + Markdown mirror)
- ISO8601 timestamp formatting for desktop compatibility
- Filename sanitization for safe Markdown file names
### Documentation
- Added WebDAV mount instructions (Windows, macOS, Linux)
- Created [SYNC_ARCHITECTURE.md](../project-docs/simple-notes-sync/architecture/SYNC_ARCHITECTURE.md) - Complete sync documentation
- Created [MARKDOWN_DESKTOP_REALITY_CHECK.md](../project-docs/simple-notes-sync/markdown-desktop-plan/MARKDOWN_DESKTOP_REALITY_CHECK.md) - Desktop integration analysis
---
## [1.1.2] - 2025-12-28
### Fixed
- **"Job was cancelled" Error**
- Fixed coroutine cancellation in sync worker
- Proper error handling for interrupted syncs
- **UI Improvements**
- Back arrow instead of X in note editor (better UX)
- Pull-to-refresh for manual sync trigger
- HTTP/HTTPS protocol selection with radio buttons
- Inline error display (no toast spam)
- **Performance & Battery**
- Sync only on actual changes (saves battery)
- Auto-save notifications removed
- 24-hour server offline warning instead of instant error
### Changed
- Settings grouped into "Auto-Sync" and "Sync Interval" sections
- HTTP only allowed for local networks (RFC 1918 IPs)
- Swipe-to-delete without UI flicker
---
## [1.1.1] - 2025-12-27
### Fixed
- **WiFi Connect Sync**
- No error notifications in foreign WiFi networks
- Server reachability check before sync (2s timeout)
- Silent abort when server offline
- Pre-check waits until network is ready
- No errors during network initialization
### Changed
- **Notifications**
- Old sync notifications cleared on app start
- Error notifications auto-dismiss after 30 seconds
### UI
- Sync icon only shown when sync is configured
- Swipe-to-delete without flicker
- Scroll to top after saving note
### Technical
- Server check with 2-second timeout before sync attempts
- Network readiness check in WiFi connect trigger
- Notification cleanup on MainActivity.onCreate()
---
## [1.1.0] - 2025-12-26
### Added
- **Configurable Sync Intervals**
- User choice: 15, 30, or 60 minutes
- Real-world battery impact displayed (15min: ~0.8%/day, 30min: ~0.4%/day, 60min: ~0.2%/day)
- Radio button selection in settings
- Doze Mode optimization (syncs batched in maintenance windows)
- **About Section**
- App version from BuildConfig
- Links to GitHub repository and developer profile
- MIT license information
- Material 3 card design
### Changed
- Settings UI redesigned with grouped sections
- Periodic sync updated dynamically when interval changes
- WorkManager uses selected interval for background sync
### Removed
- Debug/Logs section from settings (cleaner UI)
### Technical
- `PREF_SYNC_INTERVAL_MINUTES` preference key
- NetworkMonitor reads interval from SharedPreferences
- `ExistingPeriodicWorkPolicy.UPDATE` for live interval changes
---
## [1.0.0] - 2025-12-25
### Added
- Initial release
- WebDAV synchronization
- Note creation, editing, deletion
- 6 sync triggers:
- Periodic sync (configurable interval)
- App start sync
- WiFi connect sync
- Manual sync (menu button)
- Pull-to-refresh
- Settings "Sync Now" button
- Material 3 design
- Light/Dark theme support
- F-Droid compatible (100% FOSS)
---
[1.2.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.0
[1.1.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.2
[1.1.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.1
[1.1.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.0
[1.0.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.0.0

View File

@@ -4,33 +4,79 @@ All notable changes to Simple Notes Sync will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
**🌍 Languages:** [Deutsch](CHANGELOG.de.md) · **English**
---
## [1.5.0] - 2026-01-15
### 🎉 Major: Jetpack Compose UI Redesign
The complete UI has been migrated from XML Views to Jetpack Compose. The app is now more modern, faster, and smoother.
### 🌍 New Feature: Internationalization (i18n)
- **English language support** - All 400+ strings translated
- **Automatic language detection** - Follows system language
- **Manual language selection** - Switchable in settings
- **Per-App Language (Android 13+)** - Native language setting via system settings
- **locales_config.xml** - Complete Android integration
### ⚙️ Modernized Settings
- **7 categorized settings screens** - Clearer and more intuitive
- **Compose Navigation** - Smooth transitions between screens
- **Consistent design** - Material Design 3 throughout
### ✨ UI Improvements
- **Selection Mode** - Long-press for multi-select instead of swipe-to-delete
- **Batch Delete** - Delete multiple notes at once
- **Silent-Sync Mode** - No banner during auto-sync (only for manual sync)
- **App Icon in About Screen** - High-quality display
- **App Icon in Empty State** - Instead of emoji when note list is empty
- **Splash Screen Update** - Uses app foreground icon
- **Slide Animations** - Smooth animations in NoteEditor
### 🔧 Technical Improvements
- **Jetpack Compose** - Complete UI migration
- **Compose ViewModel Integration** - StateFlow for reactive UI
- **Improved Code Quality** - Detekt/Lint warnings fixed
- **Unused Imports Cleanup** - Cleaner codebase
### Looking Ahead
> 🚀 **v1.6.0** will bring server folder checking and further technical modernizations.
> Feature requests welcome as [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues).
---
## [1.4.1] - 2026-01-11
### Fixed
- **🗑️ Löschen älterer Notizen (v1.2.0 Kompatibilität)**
- Notizen aus App-Version v1.2.0 oder früher werden jetzt korrekt vom Server gelöscht
- Behebt Problem bei Multi-Device-Nutzung mit älteren Notizen
- **🗑️ Deleting older notes (v1.2.0 compatibility)**
- Notes from app version v1.2.0 or earlier are now correctly deleted from the server
- Fixes issue with multi-device usage with older notes
- **🔄 Checklisten-Sync Abwärtskompatibilität**
- Checklisten werden jetzt auch als Text-Fallback im `content`-Feld gespeichert
- Ältere App-Versionen (v1.3.x) zeigen Checklisten als lesbaren Text
- Format: GitHub-Style Task-Listen (`[ ] Item` / `[x] Item`)
- Recovery-Mode: Falls Checklisten-Items verloren gehen, werden sie aus dem Content wiederhergestellt
- **🔄 Checklist sync backward compatibility**
- Checklists now also saved as text fallback in the `content` field
- Older app versions (v1.3.x) display checklists as readable text
- Format: GitHub-style task lists (`[ ] Item` / `[x] Item`)
- Recovery mode: If checklist items are lost, they are recovered from content
### Improved
- **📝 Checklisten Auto-Zeilenumbruch**
- Lange Checklisten-Texte werden jetzt automatisch umgebrochen
- Keine Begrenzung auf 3 Zeilen mehr
- Enter-Taste erstellt weiterhin ein neues Item
- **📝 Checklist auto line-wrap**
- Long checklist texts now automatically wrap
- No more limit to 3 lines
- Enter key still creates a new item
### Looking Ahead
> 🚀 **v1.5.0** wird das nächste größere Release. Wir sammeln Ideen und Feedback!
> Feature-Requests gerne als [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues) einreichen.
> 🚀 **v1.5.0** will be the next major release. We're collecting ideas and feedback!
> Feature requests welcome as [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues).
---

269
QUICKSTART.de.md Normal file
View File

@@ -0,0 +1,269 @@
# Quick Start Guide - Simple Notes Sync 📝
> Schritt-für-Schritt Anleitung zur Installation und Einrichtung
**🌍 Sprachen:** **Deutsch** · [English](QUICKSTART.md)
---
## Voraussetzungen
- ✅ Android 8.0+ Smartphone/Tablet
- ✅ WLAN-Verbindung
- ✅ Eigener Server mit Docker (optional - für Self-Hosting)
---
## Option 1: Mit eigenem Server (Self-Hosted) 🏠
### Schritt 1: WebDAV Server einrichten
Auf deinem Server (z.B. Raspberry Pi, NAS, VPS):
```bash
# Repository klonen
git clone https://github.com/inventory69/simple-notes-sync.git
cd simple-notes-sync/server
# Umgebungsvariablen konfigurieren
cp .env.example .env
nano .env
```
**In `.env` anpassen:**
```env
WEBDAV_PASSWORD=dein-sicheres-passwort-hier
```
**Server starten:**
```bash
docker compose up -d
```
**IP-Adresse finden:**
```bash
ip addr show | grep "inet " | grep -v 127.0.0.1
```
➡️ **Notiere dir:** `http://DEINE-SERVER-IP:8080/`
---
### Schritt 2: App installieren
1. **APK herunterladen:** [Neueste Version](https://github.com/inventory69/simple-notes-sync/releases/latest)
- Wähle: `simple-notes-sync-vX.X.X-standard-universal.apk`
2. **Installation erlauben:**
- Android: Einstellungen → Sicherheit → "Unbekannte Quellen" für deinen Browser aktivieren
3. **APK öffnen und installieren**
---
### Schritt 3: App konfigurieren
1. **App öffnen**
2. **Einstellungen öffnen** (⚙️ Icon oben rechts)
3. **Server-Einstellungen konfigurieren:**
| Feld | Wert |
|------|------|
| **WebDAV Server URL** | `http://DEINE-SERVER-IP:8080/` |
| **Benutzername** | `noteuser` |
| **Passwort** | (dein Passwort aus `.env`) |
> **💡 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.
4. **"Verbindung testen"** drücken
- ✅ Erfolg? → Weiter zu Schritt 4
- ❌ Fehler? → Siehe [Troubleshooting](#troubleshooting)
5. **Auto-Sync aktivieren** (Toggle Switch)
6. **Sync-Intervall wählen:**
- **15 Min** - Maximale Aktualität (~0.8% Akku/Tag)
- **30 Min** - Empfohlen (~0.4% Akku/Tag) ⭐
- **60 Min** - Maximale Akkulaufzeit (~0.2% Akku/Tag)
---
### Schritt 4: Erste Notiz erstellen
1. Zurück zur Hauptansicht (← Pfeil)
2. **"Notiz hinzufügen"** (+ Icon)
3. Titel und Text eingeben
4. **Speichern** (💾 Icon)
5. **Warten auf Auto-Sync** (oder manuell: ⚙️ → "Jetzt synchronisieren")
🎉 **Fertig!** Deine Notizen werden automatisch synchronisiert!
---
## Option 2: Nur lokale Notizen (kein Server) 📱
Du kannst Simple Notes auch **ohne Server** nutzen:
1. **App installieren** (siehe Schritt 2 oben)
2. **Ohne Server-Konfiguration verwenden:**
- Notizen werden nur lokal gespeichert
- Kein Auto-Sync
- Perfekt für reine Offline-Nutzung
---
## 🔋 Akku-Optimierung deaktivieren
Für zuverlässigen Auto-Sync:
1. **Einstellungen****Apps****Simple Notes Sync**
2. **Akku****Akkuverbrauch**
3. Wähle: **"Nicht optimieren"** oder **"Unbeschränkt"**
💡 **Hinweis:** Android Doze Mode kann trotzdem Sync im Standby verzögern (~60 Min). Das ist normal und betrifft alle Apps.
---
## 📊 Sync-Intervalle im Detail
| Intervall | Syncs/Tag | Akku/Tag | Akku/Sync | Anwendungsfall |
|-----------|-----------|----------|-----------|----------------|
| **15 Min** | ~96 | ~0.8% (~23 mAh) | ~0.008% | ⚡ Maximal aktuell (mehrere Geräte) |
| **30 Min** | ~48 | ~0.4% (~12 mAh) | ~0.008% | ✓ **Empfohlen** - ausgewogen |
| **60 Min** | ~24 | ~0.2% (~6 mAh) | ~0.008% | 🔋 Maximale Akkulaufzeit |
---
## 🐛 Troubleshooting
### Verbindungstest schlägt fehl
**Problem:** "Verbindung fehlgeschlagen" beim Test
**Lösungen:**
1. **Server läuft?**
```bash
docker compose ps
# Sollte "Up" zeigen
```
2. **Gleiches Netzwerk?**
- Smartphone und Server müssen im selben Netzwerk sein
3. **IP-Adresse korrekt?**
```bash
ip addr show | grep "inet "
# Prüfe ob IP in URL stimmt
```
4. **Firewall?**
```bash
# Port 8080 öffnen (falls Firewall aktiv)
sudo ufw allow 8080/tcp
```
5. **Server-Logs prüfen:**
```bash
docker compose logs -f
```
---
### Auto-Sync funktioniert nicht
**Problem:** Notizen werden nicht automatisch synchronisiert
**Lösungen:**
1. **Auto-Sync aktiviert?**
- ⚙️ Einstellungen → Toggle "Auto-Sync" muss **AN** sein
2. **Akku-Optimierung deaktiviert?**
- Siehe [Akku-Optimierung](#-akku-optimierung-deaktivieren)
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"
- Funktioniert das? → Auto-Sync sollte auch funktionieren
---
### Notizen werden nicht angezeigt
**Problem:** Nach Installation sind keine Notizen da, obwohl welche auf dem Server liegen
**Lösung:**
1. **Einmalig manuell synchronisieren:**
- ⚙️ Einstellungen → "Jetzt synchronisieren"
2. **Server-Daten prüfen:**
```bash
docker compose exec webdav ls -la /data/
# Sollte .json Dateien zeigen
```
---
### Fehler beim Sync
**Problem:** Fehlermeldung beim Synchronisieren
**Lösungen:**
1. **"401 Unauthorized"** → Passwort falsch
- Prüfe Passwort in App-Einstellungen
- Vergleiche mit `.env` auf Server
2. **"404 Not Found"** → URL falsch
- Sollte enden mit `/` (z.B. `http://192.168.1.100:8080/`)
3. **"Network error"** → Keine Verbindung
- Siehe [Verbindungstest schlägt fehl](#verbindungstest-schlägt-fehl)
---
## 📱 Updates
### Automatisch mit Obtainium (empfohlen)
1. **[Obtainium installieren](https://github.com/ImranR98/Obtanium/releases/latest)**
2. **App hinzufügen:**
- URL: `https://github.com/inventory69/simple-notes-sync`
- Auto-Update aktivieren
3. **Fertig!** Obtainium benachrichtigt dich bei neuen Versionen
### Manuell
1. Neue APK von [Releases](https://github.com/inventory69/simple-notes-sync/releases/latest) herunterladen
2. Installieren (überschreibt alte Version)
3. Alle Daten bleiben erhalten!
---
## 🆘 Weitere Hilfe
- **GitHub Issues:** [Problem melden](https://github.com/inventory69/simple-notes-sync/issues)
- **Vollständige Docs:** [DOCS.md](DOCS.md)
- **Server Setup Details:** [server/README.md](server/README.md)
---
**Version:** 1.1.0 · **Erstellt:** Dezember 2025

View File

@@ -1,269 +0,0 @@
# Quick Start Guide - Simple Notes Sync 📝
> Step-by-step installation and setup guide
**🌍 Languages:** [Deutsch](QUICKSTART.md) · **English**
---
## Prerequisites
- ✅ Android 8.0+ smartphone/tablet
- ✅ WiFi connection
- ✅ Own server with Docker (optional - for self-hosting)
---
## Option 1: With own server (Self-Hosted) 🏠
### Step 1: Setup WebDAV Server
On your server (e.g. Raspberry Pi, NAS, VPS):
```bash
# Clone repository
git clone https://github.com/inventory69/simple-notes-sync.git
cd simple-notes-sync/server
# Configure environment variables
cp .env.example .env
nano .env
```
**Adjust in `.env`:**
```env
WEBDAV_PASSWORD=your-secure-password-here
```
**Start server:**
```bash
docker compose up -d
```
**Find IP address:**
```bash
ip addr show | grep "inet " | grep -v 127.0.0.1
```
➡️ **Note down:** `http://YOUR-SERVER-IP:8080/`
---
### Step 2: Install App
1. **Download APK:** [Latest version](https://github.com/inventory69/simple-notes-sync/releases/latest)
- Choose: `simple-notes-sync-vX.X.X-standard-universal.apk`
2. **Allow installation:**
- Android: Settings → Security → Enable "Unknown sources" for your browser
3. **Open and install APK**
---
### Step 3: Configure App
1. **Open app**
2. **Open settings** (⚙️ icon top right)
3. **Configure server settings:**
| Field | Value |
|------|------|
| **WebDAV Server URL** | `http://YOUR-SERVER-IP:8080/` |
| **Username** | `noteuser` |
| **Password** | (your password from `.env`) |
> **💡 Note:** Enter only the base URL (without `/notes`). The app automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown export.
4. **Press "Test connection"****
- ✅ Success? → Continue to step 4
- ❌ Error? → See [Troubleshooting](#troubleshooting)
5. **Enable auto-sync** (toggle switch)
6. **Choose sync interval:**
- **15 min** - Maximum currency (~0.8% battery/day)
- **30 min** - Recommended (~0.4% battery/day) ⭐
- **60 min** - Maximum battery life (~0.2% battery/day)
---
### Step 4: Create First Note
1. Back to main view (← arrow)
2. **"Add note"** (+ icon)
3. Enter title and text
4. **Save** (💾 icon)
5. **Wait for auto-sync** (or manually: ⚙️ → "Sync now")
🎉 **Done!** Your notes will be automatically synchronized!
---
## Option 2: Local notes only (no server) 📱
You can also use Simple Notes **without a server**:
1. **Install app** (see step 2 above)
2. **Use without server configuration:**
- Notes are only stored locally
- No auto-sync
- Perfect for offline-only use
---
## 🔋 Disable Battery Optimization
For reliable auto-sync:
1. **Settings****Apps****Simple Notes Sync**
2. **Battery****Battery usage**
3. Select: **"Don't optimize"** or **"Unrestricted"**
💡 **Note:** Android Doze Mode may still delay sync in standby (~60 min). This is normal and affects all apps.
---
## 📊 Sync Intervals in Detail
| Interval | Syncs/day | Battery/day | Battery/sync | Use case |
|-----------|-----------|----------|-----------|----------------|
| **15 min** | ~96 | ~0.8% (~23 mAh) | ~0.008% | ⚡ Maximum currency (multiple devices) |
| **30 min** | ~48 | ~0.4% (~12 mAh) | ~0.008% | ✓ **Recommended** - balanced |
| **60 min** | ~24 | ~0.2% (~6 mAh) | ~0.008% | 🔋 Maximum battery life |
---
## 🐛 Troubleshooting
### Connection test fails
**Problem:** "Connection failed" during test
**Solutions:**
1. **Server running?**
```bash
docker compose ps
# Should show "Up"
```
2. **Same network?**
- Smartphone and server must be on same network
3. **IP address correct?**
```bash
ip addr show | grep "inet "
# Check if IP in URL matches
```
4. **Firewall?**
```bash
# Open port 8080 (if firewall active)
sudo ufw allow 8080/tcp
```
5. **Check server logs:**
```bash
docker compose logs -f
```
---
### Auto-sync not working
**Problem:** Notes are not automatically synchronized
**Solutions:**
1. **Auto-sync enabled?**
- ⚙️ Settings → Toggle "Auto-sync" must be **ON**
2. **Battery optimization disabled?**
- See [Disable Battery Optimization](#-disable-battery-optimization)
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"
- Works? → Auto-sync should work too
---
### Notes not showing up
**Problem:** After installation, no notes visible even though they exist on server
**Solution:**
1. **Manually sync once:**
- ⚙️ Settings → "Sync now"
2. **Check server data:**
```bash
docker compose exec webdav ls -la /data/
# Should show .json files
```
---
### Sync errors
**Problem:** Error message during sync
**Solutions:**
1. **"401 Unauthorized"** → Wrong password
- Check password in app settings
- Compare with `.env` on server
2. **"404 Not Found"** → Wrong URL
- Should end with `/` (e.g. `http://192.168.1.100:8080/`)
3. **"Network error"** → No connection
- See [Connection test fails](#connection-test-fails)
---
## 📱 Updates
### Automatic with Obtainium (recommended)
1. **[Install Obtainium](https://github.com/ImranR98/Obtanium/releases/latest)**
2. **Add app:**
- URL: `https://github.com/inventory69/simple-notes-sync`
- Enable auto-update
3. **Done!** Obtainium notifies you of new versions
### Manual
1. Download new APK from [Releases](https://github.com/inventory69/simple-notes-sync/releases/latest)
2. Install (overwrites old version)
3. All data remains intact!
---
## 🆘 Further Help
- **GitHub Issues:** [Report problem](https://github.com/inventory69/simple-notes-sync/issues)
- **Complete docs:** [DOCS.en.md](DOCS.en.md)
- **Server setup details:** [server/README.en.md](server/README.en.md)
---
**Version:** 1.1.0 · **Created:** December 2025

View File

@@ -1,269 +1,269 @@
# Quick Start Guide - Simple Notes Sync 📝
> Schritt-für-Schritt Anleitung zur Installation und Einrichtung
> Step-by-step installation and setup guide
**🌍 Sprachen:** **Deutsch** · [English](QUICKSTART.en.md)
**🌍 Languages:** [Deutsch](QUICKSTART.de.md) · **English**
---
## Voraussetzungen
## Prerequisites
- ✅ Android 8.0+ Smartphone/Tablet
- ✅ WLAN-Verbindung
-Eigener Server mit Docker (optional - für Self-Hosting)
- ✅ Android 8.0+ smartphone/tablet
- ✅ WiFi connection
-Own server with Docker (optional - for self-hosting)
---
## Option 1: Mit eigenem Server (Self-Hosted) 🏠
## Option 1: With own server (Self-Hosted) 🏠
### Schritt 1: WebDAV Server einrichten
### Step 1: Setup WebDAV Server
Auf deinem Server (z.B. Raspberry Pi, NAS, VPS):
On your server (e.g. Raspberry Pi, NAS, VPS):
```bash
# Repository klonen
# Clone repository
git clone https://github.com/inventory69/simple-notes-sync.git
cd simple-notes-sync/server
# Umgebungsvariablen konfigurieren
# Configure environment variables
cp .env.example .env
nano .env
```
**In `.env` anpassen:**
**Adjust in `.env`:**
```env
WEBDAV_PASSWORD=dein-sicheres-passwort-hier
WEBDAV_PASSWORD=your-secure-password-here
```
**Server starten:**
**Start server:**
```bash
docker compose up -d
```
**IP-Adresse finden:**
**Find IP address:**
```bash
ip addr show | grep "inet " | grep -v 127.0.0.1
```
➡️ **Notiere dir:** `http://DEINE-SERVER-IP:8080/`
➡️ **Note down:** `http://YOUR-SERVER-IP:8080/`
---
### Schritt 2: App installieren
### Step 2: Install App
1. **APK herunterladen:** [Neueste Version](https://github.com/inventory69/simple-notes-sync/releases/latest)
- Wähle: `simple-notes-sync-vX.X.X-standard-universal.apk`
1. **Download APK:** [Latest version](https://github.com/inventory69/simple-notes-sync/releases/latest)
- Choose: `simple-notes-sync-vX.X.X-standard-universal.apk`
2. **Installation erlauben:**
- Android: Einstellungen → Sicherheit → "Unbekannte Quellen" für deinen Browser aktivieren
2. **Allow installation:**
- Android: Settings → SecurityEnable "Unknown sources" for your browser
3. **APK öffnen und installieren**
3. **Open and install APK**
---
### Schritt 3: App konfigurieren
### Step 3: Configure App
1. **App öffnen**
1. **Open app**
2. **Einstellungen öffnen** (⚙️ Icon oben rechts)
2. **Open settings** (⚙️ icon top right)
3. **Server-Einstellungen konfigurieren:**
3. **Configure server settings:**
| Feld | Wert |
| Field | Value |
|------|------|
| **WebDAV Server URL** | `http://DEINE-SERVER-IP:8080/` |
| **Benutzername** | `noteuser` |
| **Passwort** | (dein Passwort aus `.env`) |
| **WebDAV Server URL** | `http://YOUR-SERVER-IP:8080/` |
| **Username** | `noteuser` |
| **Password** | (your password from `.env`) |
> **💡 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.
> **💡 Note:** Enter only the base URL (without `/notes`). The app automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown export.
4. **"Verbindung testen"** drücken
-Erfolg? → Weiter zu Schritt 4
-Fehler? → Siehe [Troubleshooting](#troubleshooting)
4. **Press "Test connection"****
-Success? → Continue to step 4
-Error? → See [Troubleshooting](#troubleshooting)
5. **Auto-Sync aktivieren** (Toggle Switch)
5. **Enable auto-sync** (toggle switch)
6. **Sync-Intervall wählen:**
- **15 Min** - Maximale Aktualität (~0.8% Akku/Tag)
- **30 Min** - Empfohlen (~0.4% Akku/Tag) ⭐
- **60 Min** - Maximale Akkulaufzeit (~0.2% Akku/Tag)
6. **Choose sync interval:**
- **15 min** - Maximum currency (~0.8% battery/day)
- **30 min** - Recommended (~0.4% battery/day) ⭐
- **60 min** - Maximum battery life (~0.2% battery/day)
---
### Schritt 4: Erste Notiz erstellen
### Step 4: Create First Note
1. Zurück zur Hauptansicht (← Pfeil)
1. Back to main view (← arrow)
2. **"Notiz hinzufügen"** (+ Icon)
2. **"Add note"** (+ icon)
3. Titel und Text eingeben
3. Enter title and text
4. **Speichern** (💾 Icon)
4. **Save** (💾 icon)
5. **Warten auf Auto-Sync** (oder manuell: ⚙️ → "Jetzt synchronisieren")
5. **Wait for auto-sync** (or manually: ⚙️ → "Sync now")
🎉 **Fertig!** Deine Notizen werden automatisch synchronisiert!
🎉 **Done!** Your notes will be automatically synchronized!
---
## Option 2: Nur lokale Notizen (kein Server) 📱
## Option 2: Local notes only (no server) 📱
Du kannst Simple Notes auch **ohne Server** nutzen:
You can also use Simple Notes **without a server**:
1. **App installieren** (siehe Schritt 2 oben)
1. **Install app** (see step 2 above)
2. **Ohne Server-Konfiguration verwenden:**
- Notizen werden nur lokal gespeichert
- Kein Auto-Sync
- Perfekt für reine Offline-Nutzung
2. **Use without server configuration:**
- Notes are only stored locally
- No auto-sync
- Perfect for offline-only use
---
## 🔋 Akku-Optimierung deaktivieren
## 🔋 Disable Battery Optimization
Für zuverlässigen Auto-Sync:
For reliable auto-sync:
1. **Einstellungen****Apps****Simple Notes Sync**
1. **Settings****Apps****Simple Notes Sync**
2. **Akku****Akkuverbrauch**
2. **Battery****Battery usage**
3. Wähle: **"Nicht optimieren"** oder **"Unbeschränkt"**
3. Select: **"Don't optimize"** or **"Unrestricted"**
💡 **Hinweis:** Android Doze Mode kann trotzdem Sync im Standby verzögern (~60 Min). Das ist normal und betrifft alle Apps.
💡 **Note:** Android Doze Mode may still delay sync in standby (~60 min). This is normal and affects all apps.
---
## 📊 Sync-Intervalle im Detail
## 📊 Sync Intervals in Detail
| Intervall | Syncs/Tag | Akku/Tag | Akku/Sync | Anwendungsfall |
| Interval | Syncs/day | Battery/day | Battery/sync | Use case |
|-----------|-----------|----------|-----------|----------------|
| **15 Min** | ~96 | ~0.8% (~23 mAh) | ~0.008% | ⚡ Maximal aktuell (mehrere Geräte) |
| **30 Min** | ~48 | ~0.4% (~12 mAh) | ~0.008% | ✓ **Empfohlen** - ausgewogen |
| **60 Min** | ~24 | ~0.2% (~6 mAh) | ~0.008% | 🔋 Maximale Akkulaufzeit |
| **15 min** | ~96 | ~0.8% (~23 mAh) | ~0.008% | ⚡ Maximum currency (multiple devices) |
| **30 min** | ~48 | ~0.4% (~12 mAh) | ~0.008% | ✓ **Recommended** - balanced |
| **60 min** | ~24 | ~0.2% (~6 mAh) | ~0.008% | 🔋 Maximum battery life |
---
## 🐛 Troubleshooting
### Verbindungstest schlägt fehl
### Connection test fails
**Problem:** "Verbindung fehlgeschlagen" beim Test
**Problem:** "Connection failed" during test
**Lösungen:**
**Solutions:**
1. **Server läuft?**
1. **Server running?**
```bash
docker compose ps
# Sollte "Up" zeigen
# Should show "Up"
```
2. **Gleiches Netzwerk?**
- Smartphone und Server müssen im selben Netzwerk sein
2. **Same network?**
- Smartphone and server must be on same network
3. **IP-Adresse korrekt?**
3. **IP address correct?**
```bash
ip addr show | grep "inet "
# Prüfe ob IP in URL stimmt
# Check if IP in URL matches
```
4. **Firewall?**
```bash
# Port 8080 öffnen (falls Firewall aktiv)
# Open port 8080 (if firewall active)
sudo ufw allow 8080/tcp
```
5. **Server-Logs prüfen:**
5. **Check server logs:**
```bash
docker compose logs -f
```
---
### Auto-Sync funktioniert nicht
### Auto-sync not working
**Problem:** Notizen werden nicht automatisch synchronisiert
**Problem:** Notes are not automatically synchronized
**Lösungen:**
**Solutions:**
1. **Auto-Sync aktiviert?**
- ⚙️ Einstellungen → Toggle "Auto-Sync" muss **AN** sein
1. **Auto-sync enabled?**
- ⚙️ Settings → Toggle "Auto-sync" must be **ON**
2. **Akku-Optimierung deaktiviert?**
- Siehe [Akku-Optimierung](#-akku-optimierung-deaktivieren)
2. **Battery optimization disabled?**
- See [Disable Battery Optimization](#-disable-battery-optimization)
3. **Mit WiFi verbunden?**
- Auto-Sync triggert bei jeder WiFi-Verbindung
- Prüfe, ob du mit einem WLAN verbunden bist
3. **Connected to WiFi?**
- Auto-sync triggers on any WiFi connection
- Check if you're connected to a WiFi network
4. **Manuell testen:**
- ⚙️ Einstellungen → "Jetzt synchronisieren"
- Funktioniert das? → Auto-Sync sollte auch funktionieren
4. **Test manually:**
- ⚙️ Settings → "Sync now"
- Works? → Auto-sync should work too
---
### Notizen werden nicht angezeigt
### Notes not showing up
**Problem:** Nach Installation sind keine Notizen da, obwohl welche auf dem Server liegen
**Problem:** After installation, no notes visible even though they exist on server
**Lösung:**
**Solution:**
1. **Einmalig manuell synchronisieren:**
- ⚙️ Einstellungen → "Jetzt synchronisieren"
1. **Manually sync once:**
- ⚙️ Settings → "Sync now"
2. **Server-Daten prüfen:**
2. **Check server data:**
```bash
docker compose exec webdav ls -la /data/
# Sollte .json Dateien zeigen
# Should show .json files
```
---
### Fehler beim Sync
### Sync errors
**Problem:** Fehlermeldung beim Synchronisieren
**Problem:** Error message during sync
**Lösungen:**
**Solutions:**
1. **"401 Unauthorized"** → Passwort falsch
- Prüfe Passwort in App-Einstellungen
- Vergleiche mit `.env` auf Server
1. **"401 Unauthorized"** → Wrong password
- Check password in app settings
- Compare with `.env` on server
2. **"404 Not Found"** → URL falsch
- Sollte enden mit `/` (z.B. `http://192.168.1.100:8080/`)
2. **"404 Not Found"** → Wrong URL
- Should end with `/` (e.g. `http://192.168.1.100:8080/`)
3. **"Network error"** → Keine Verbindung
- Siehe [Verbindungstest schlägt fehl](#verbindungstest-schlägt-fehl)
3. **"Network error"** → No connection
- See [Connection test fails](#connection-test-fails)
---
## 📱 Updates
### Automatisch mit Obtainium (empfohlen)
### Automatic with Obtainium (recommended)
1. **[Obtainium installieren](https://github.com/ImranR98/Obtanium/releases/latest)**
1. **[Install Obtainium](https://github.com/ImranR98/Obtanium/releases/latest)**
2. **App hinzufügen:**
2. **Add app:**
- URL: `https://github.com/inventory69/simple-notes-sync`
- Auto-Update aktivieren
- Enable auto-update
3. **Fertig!** Obtainium benachrichtigt dich bei neuen Versionen
3. **Done!** Obtainium notifies you of new versions
### Manuell
### Manual
1. Neue APK von [Releases](https://github.com/inventory69/simple-notes-sync/releases/latest) herunterladen
1. Download new APK from [Releases](https://github.com/inventory69/simple-notes-sync/releases/latest)
2. Installieren (überschreibt alte Version)
2. Install (overwrites old version)
3. Alle Daten bleiben erhalten!
3. All data remains intact!
---
## 🆘 Weitere Hilfe
## 🆘 Further Help
- **GitHub Issues:** [Problem melden](https://github.com/inventory69/simple-notes-sync/issues)
- **Vollständige Docs:** [DOCS.md](DOCS.md)
- **Server Setup Details:** [server/README.md](server/README.md)
- **GitHub Issues:** [Report problem](https://github.com/inventory69/simple-notes-sync/issues)
- **Complete docs:** [DOCS.en.md](DOCS.en.md)
- **Server setup details:** [server/README.en.md](server/README.en.md)
---
**Version:** 1.1.0 · **Erstellt:** Dezember 2025
**Version:** 1.1.0 · **Created:** December 2025

115
README.de.md Normal file
View File

@@ -0,0 +1,115 @@
# Simple Notes Sync 📝
> Minimalistische Offline-Notizen mit Auto-Sync zu deinem eigenen Server
[![Android](https://img.shields.io/badge/Android-8.0%2B-green.svg)](https://www.android.com/)
[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes)
[<img src="https://f-droid.org/badge/get-it-on-de.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/dev.dettmer.simplenotes/)
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](docs/DOCS.de.md)** · **🚀 [Quick Start](QUICKSTART.de.md)**
**🌍 Sprachen:** **Deutsch** · [English](README.md)
---
## 📱 Screenshots
<p align="center">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.png" width="250" alt="Notizliste">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.png" width="250" alt="Notiz bearbeiten">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.png" width="250" alt="Checkliste bearbeiten">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/4.png" width="250" alt="Einstellungen">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/5.png" width="250" alt="Server-Einstellungen">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/6.png" width="250" alt="Sync-Status">
</p>
---
## ✨ Highlights
-**NEU: Checklisten** - Tap-to-Check, Drag & Drop
- 🌍 **NEU: Mehrsprachig** - Deutsch/Englisch mit Sprachauswahl
- 📝 **Offline-First** - Funktioniert ohne Internet
- 🔄 **Auto-Sync** - WLAN-Verbindung, regelmäßige Intervalle (15/30/60 Min) & Multi-Geräte-Sync
- 🔒 **Self-Hosted** - Deine Daten bleiben bei dir (WebDAV)
- 💾 **Lokales Backup** - Export/Import als JSON-Datei
- 🖥️ **Desktop-Integration** - Markdown-Export für Obsidian, VS Code, Typora
- 🔋 **Akkuschonend** - ~0.2-0.8% pro Tag
- 🎨 **Material Design 3** - Dark Mode & Dynamic Colors
➡️ **Vollständige Feature-Liste:** [FEATURES.de.md](docs/FEATURES.de.md)
---
## 🚀 Schnellstart
### 1. Server Setup (5 Minuten)
```bash
git clone https://github.com/inventory69/simple-notes-sync.git
cd simple-notes-sync/server
cp .env.example .env
# Passwort in .env setzen
docker compose up -d
```
➡️ **Details:** [Server Setup Guide](server/README.de.md)
### 2. App Installation (2 Minuten)
1. [APK herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest)
2. Installieren & öffnen
3. ⚙️ Einstellungen → Server konfigurieren:
- **URL:** `http://DEINE-SERVER-IP:8080/` _(nur Base-URL!)_
- **User:** `noteuser`
- **Passwort:** _(aus .env)_
- **WLAN:** _(dein Netzwerk-Name)_
4. **Verbindung testen** → Auto-Sync aktivieren
5. Fertig! 🎉
➡️ **Ausführliche Anleitung:** [QUICKSTART.de.md](QUICKSTART.de.md)
---
## 📚 Dokumentation
| Dokument | Inhalt |
|----------|--------|
| **[QUICKSTART.de.md](QUICKSTART.de.md)** | Schritt-für-Schritt Installation |
| **[FEATURES.de.md](docs/FEATURES.de.md)** | Vollständige Feature-Liste |
| **[BACKUP.de.md](docs/BACKUP.de.md)** | Backup & Wiederherstellung |
| **[DESKTOP.de.md](docs/DESKTOP.de.md)** | Desktop-Integration (Markdown) |
| **[DOCS.de.md](docs/DOCS.de.md)** | Technische Details & Troubleshooting |
| **[CHANGELOG.de.md](CHANGELOG.de.md)** | Versionshistorie |
| **[UPCOMING.de.md](docs/UPCOMING.de.md)** | Geplante Features 🚀 |
| **[ÜBERSETZEN.md](docs/TRANSLATING.de.md)** | Übersetzungsanleitung 🌍 |
---
## 🛠️ Entwicklung
```bash
cd android
./gradlew assembleStandardRelease
```
➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md#-build--deployment)
---
## 🤝 Contributing
Beiträge willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md)
---
## 📄 Lizenz
MIT License - siehe [LICENSE](LICENSE)
---
**v1.4.1** · Built with ❤️ using Kotlin + Material Design 3

View File

@@ -1,109 +0,0 @@
# Simple Notes Sync 📝
> Minimalist offline notes with auto-sync to your own server
[![Android](https://img.shields.io/badge/Android-8.0%2B-green.svg)](https://www.android.com/)
[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes)
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Documentation](docs/DOCS.en.md)** · **🚀 [Quick Start](QUICKSTART.en.md)**
**🌍 Languages:** [Deutsch](README.md) · **English**
---
## 📱 Screenshots
<p align="center">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.jpg" width="250" alt="Notes list">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.jpg" width="250" alt="Edit note">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.jpg" width="250" alt="Settings">
</p>
---
## ✨ Highlights
-**NEW: Checklists** - Tap-to-check, drag & drop, swipe-to-delete
- 📝 **Offline-first** - Works without internet
- 🔄 **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 Obsidian, VS Code, Typora
- 🔋 **Battery-friendly** - ~0.2-0.8% per day
- 🎨 **Material Design 3** - Dark mode & dynamic colors
➡️ **Complete feature list:** [FEATURES.en.md](docs/FEATURES.en.md)
---
## 🚀 Quick Start
### 1. Server Setup (5 minutes)
```bash
git clone https://github.com/inventory69/simple-notes-sync.git
cd simple-notes-sync/server
cp .env.example .env
# Set password in .env
docker compose up -d
```
➡️ **Details:** [Server Setup Guide](server/README.en.md)
### 2. App Installation (2 minutes)
1. [Download APK](https://github.com/inventory69/simple-notes-sync/releases/latest)
2. Install & open
3. ⚙️ Settings → Configure server:
- **URL:** `http://YOUR-SERVER-IP:8080/` _(base URL only!)_
- **User:** `noteuser`
- **Password:** _(from .env)_
- **WiFi:** _(your network name)_
4. **Test connection** → Enable auto-sync
5. Done! 🎉
➡️ **Detailed guide:** [QUICKSTART.en.md](QUICKSTART.en.md)
---
## 📚 Documentation
| Document | Content |
|----------|---------|
| **[QUICKSTART.en.md](QUICKSTART.en.md)** | Step-by-step installation |
| **[FEATURES.en.md](docs/FEATURES.en.md)** | Complete feature list |
| **[BACKUP.en.md](docs/BACKUP.en.md)** | Backup & restore guide |
| **[DESKTOP.en.md](docs/DESKTOP.en.md)** | Desktop integration (Markdown) |
| **[DOCS.en.md](docs/DOCS.en.md)** | Technical details & troubleshooting |
| **[CHANGELOG.md](CHANGELOG.md)** | Version history |
| **[UPCOMING.en.md](docs/UPCOMING.en.md)** | Upcoming features 🚀 |
---
## 🛠️ Development
```bash
cd android
./gradlew assembleStandardRelease
```
➡️ **Build guide:** [DOCS.en.md](docs/DOCS.en.md#-build--deployment)
---
## 🤝 Contributing
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)
---
## 📄 License
MIT License - see [LICENSE](LICENSE)
---
**v1.4.1** · Built with ❤️ using Kotlin + Material Design 3

View File

@@ -1,109 +1,111 @@
# Simple Notes Sync 📝
> Minimalistische Offline-Notizen mit Auto-Sync zu deinem eigenen Server
> Minimalist offline notes with auto-sync to your own server
[![Android](https://img.shields.io/badge/Android-8.0%2B-green.svg)](https://www.android.com/)
[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes)
[<img src="https://f-droid.org/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/dev.dettmer.simplenotes/)
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](docs/DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)**
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Documentation](docs/DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)**
**🌍 Sprachen:** **Deutsch** · [English](README.en.md)
**🌍 Languages:** [Deutsch](README.de.md) · **English**
---
## 📱 Screenshots
<p align="center">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.jpg" width="250" alt="Notizliste">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.jpg" width="250" alt="Notiz bearbeiten">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.jpg" width="250" alt="Einstellungen">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="250" alt="Notes list">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="250" alt="Edit note">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="250" alt="Edit checklist">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="250" alt="Settings">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="250" alt="Server settings">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/6.png" width="250" alt="Sync status">
</p>
---
## ✨ Highlights
-**NEU: Checklisten** - Tap-to-Check, Drag & Drop, Swipe-to-Delete
- 📝 **Offline-First** - Funktioniert ohne Internet
- 🔄 **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 Obsidian, VS Code, Typora
- 🔋 **Akkuschonend** - ~0.2-0.8% pro Tag
- 🎨 **Material Design 3** - Dark Mode & Dynamic Colors
-**NEW: Checklists** - Tap-to-check, drag & drop
- 🌍 **NEW: Multilingual** - English/German with language selector
- 📝 **Offline-first** - Works without internet
- 🔄 **Auto-sync** - WiFi reconnect, periodic intervals (15/30/60 min) & multi-device sync
- 🔒 **Self-hosted** - Your data stays with you (WebDAV)
- 💾 **Local backup** - Export/Import as JSON file
- 🖥️ **Desktop integration** - Markdown export for Obsidian, VS Code, Typora
- 🔋 **Battery-friendly** - ~0.2-0.8% per day
- 🎨 **Material Design 3** - Dark mode & dynamic colors
➡️ **Vollständige Feature-Liste:** [FEATURES.md](docs/FEATURES.md)
➡️ **Complete feature list:** [FEATURES.md](docs/FEATURES.md)
---
## 🚀 Schnellstart
## 🚀 Quick Start
### 1. Server Setup (5 Minuten)
### 1. Server Setup (5 minutes)
```bash
git clone https://github.com/inventory69/simple-notes-sync.git
cd simple-notes-sync/server
cp .env.example .env
# Passwort in .env setzen
# Set password in .env
docker compose up -d
```
➡️ **Details:** [Server Setup Guide](server/README.md)
### 2. App Installation (2 Minuten)
### 2. App Installation (2 minutes)
1. [APK herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest)
2. Installieren & öffnen
3. ⚙️ Einstellungen → Server konfigurieren:
- **URL:** `http://DEINE-SERVER-IP:8080/` _(nur Base-URL!)_
1. [Download APK](https://github.com/inventory69/simple-notes-sync/releases/latest)
2. Install & open
3. ⚙️ Settings → Configure server:
- **URL:** `http://YOUR-SERVER-IP:8080/` _(base URL only!)_
- **User:** `noteuser`
- **Passwort:** _(aus .env)_
- **WLAN:** _(dein Netzwerk-Name)_
4. **Verbindung testen** → Auto-Sync aktivieren
5. Fertig! 🎉
- **Password:** _(from .env)_
- **WiFi:** _(your network name)_
4. **Test connection** → Enable auto-sync
5. Done! 🎉
➡️ **Ausführliche Anleitung:** [QUICKSTART.md](QUICKSTART.md)
➡️ **Detailed guide:** [QUICKSTART.md](QUICKSTART.md)
---
## 📚 Dokumentation
## 📚 Documentation
| Dokument | Inhalt |
|----------|--------|
| **[QUICKSTART.md](QUICKSTART.md)** | Schritt-für-Schritt Installation |
| **[FEATURES.md](docs/FEATURES.md)** | Vollständige Feature-Liste |
| **[BACKUP.md](docs/BACKUP.md)** | Backup & Wiederherstellung |
| **[DESKTOP.md](docs/DESKTOP.md)** | Desktop-Integration (Markdown) |
| **[DOCS.md](docs/DOCS.md)** | Technische Details & Troubleshooting |
| **[CHANGELOG.md](CHANGELOG.md)** | Versionshistorie |
| **[UPCOMING.md](docs/UPCOMING.md)** | Geplante Features 🚀 |
---
## 🛠️ Entwicklung
| Document | Content |
|----------|---------|
| **[QUICKSTART.md](QUICKSTART.md)** | Step-by-step installation |
| **[FEATURES.md](docs/FEATURES.md)** | Complete feature list |
| **[BACKUP.md](docs/BACKUP.md)** | Backup & restore guide |
| **[DESKTOP.md](docs/DESKTOP.md)** | Desktop integration (Markdown) |
| **[DOCS.md](docs/DOCS.md)** | Technical details & troubleshooting |
| **[CHANGELOG.md](CHANGELOG.md)** | Version history |
| **[UPCOMING.md](docs/UPCOMING.md)** | Upcoming features 🚀 |
| **[TRANSLATING.md](docs/TRANSLATING.md)** | Translation guide 🌍 |
```bash
cd android
./gradlew assembleStandardRelease
```
➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md#-build--deployment)
➡️ **Build guide:** [DOCS.md](docs/DOCS.md#-build--deployment)
---
## 🤝 Contributing
Beiträge willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md)
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)
---
## 📄 Lizenz
## 📄 License
MIT License - siehe [LICENSE](LICENSE)
MIT License - see [LICENSE](LICENSE)
---
**v1.4.1** · Built with ❤️ using Kotlin + Material Design 3
**v1.5.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3

View File

@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen, aktivieren in v1.4.0
// alias(libs.plugins.ktlint)
alias(libs.plugins.detekt)
@@ -20,8 +21,8 @@ android {
applicationId = "dev.dettmer.simplenotes"
minSdk = 24
targetSdk = 36
versionCode = 12 // 🔧 v1.4.1: Bugfixes (Root-Delete, Checklist Compat)
versionName = "1.4.1" // 🔧 v1.4.1: Root-Folder Delete Fix, Checklisten-Sync Abwärtskompatibilität
versionCode = 13 // 🔧 v1.5.0: Jetpack Compose Settings Redesign
versionName = "1.5.0" // 🔧 v1.5.0: Jetpack Compose Settings Redesign
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -96,6 +97,12 @@ android {
buildFeatures {
viewBinding = true
buildConfig = true // Enable BuildConfig generation
compose = true // v1.5.0: Jetpack Compose für Settings Redesign
}
// v1.5.0 Hotfix: Strong Skipping Mode für bessere 120Hz Performance
composeCompiler {
enableStrongSkippingMode = true
}
compileOptions {
@@ -135,6 +142,20 @@ dependencies {
// SwipeRefreshLayout für Pull-to-Refresh
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
// ═══════════════════════════════════════════════════════════════════════
// v1.5.0: Jetpack Compose für Settings Redesign
// ═══════════════════════════════════════════════════════════════════════
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
debugImplementation(libs.androidx.compose.ui.tooling)
// Testing (bleiben so)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Debug overlay for launcher icon - original icon + red "DEBUG" badge -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Original foreground icon from mipmap -->
<item android:drawable="@mipmap/ic_launcher_foreground" />
<!-- Debug Badge: Red circle with "D" in top-right corner -->
<item>
<inset
android:insetLeft="60dp"
android:insetTop="10dp"
android:insetRight="10dp"
android:insetBottom="60dp">
<layer-list>
<!-- Red circle background -->
<item>
<shape android:shape="oval">
<solid android:color="#E53935" />
<size android:width="28dp" android:height="28dp" />
</shape>
</item>
</layer-list>
</inset>
</item>
</layer-list>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Debug version of adaptive icon with badge -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Different background color for debug (darker/orange tint) -->
<background android:drawable="@color/ic_launcher_background_debug"/>
<foreground android:drawable="@drawable/ic_launcher_foreground_debug"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Debug version of adaptive icon with badge (round) -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Different background color for debug (darker/orange tint) -->
<background android:drawable="@color/ic_launcher_background_debug"/>
<foreground android:drawable="@drawable/ic_launcher_foreground_debug"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Debug version: Orange-tinted background to distinguish from release -->
<color name="ic_launcher_background_debug">#FFB74D</color>
</resources>

View File

@@ -22,13 +22,15 @@
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.SimpleNotes"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="31">
<!-- MainActivity v1.5.0 (Jetpack Compose) - Launcher -->
<activity
android:name=".MainActivity"
android:name=".ui.main.ComposeMainActivity"
android:exported="true"
android:theme="@style/Theme.SimpleNotes.Splash">
<intent-filter>
@@ -37,16 +39,35 @@
</intent-filter>
</activity>
<!-- Editor Activity -->
<!-- Legacy MainActivity (XML-based) - kept for reference -->
<activity
android:name=".MainActivity"
android:exported="false"
android:theme="@style/Theme.SimpleNotes" />
<!-- Editor Activity (Legacy - XML-based) -->
<activity
android:name=".NoteEditorActivity"
android:windowSoftInputMode="adjustResize"
android:parentActivityName=".MainActivity" />
android:parentActivityName=".ui.main.ComposeMainActivity" />
<!-- Settings Activity -->
<!-- Editor Activity v1.5.0 (Jetpack Compose) -->
<activity
android:name=".ui.editor.ComposeNoteEditorActivity"
android:windowSoftInputMode="adjustResize"
android:parentActivityName=".ui.main.ComposeMainActivity"
android:theme="@style/Theme.SimpleNotes" />
<!-- Settings Activity (Legacy - XML-based) -->
<activity
android:name=".SettingsActivity"
android:parentActivityName=".MainActivity" />
android:parentActivityName=".ui.main.ComposeMainActivity" />
<!-- Settings Activity v1.5.0 (Jetpack Compose) -->
<activity
android:name=".ui.settings.ComposeSettingsActivity"
android:parentActivityName=".ui.main.ComposeMainActivity"
android:theme="@style/Theme.SimpleNotes" />
<!-- Boot Receiver - Startet WorkManager nach Reboot -->
<receiver

View File

@@ -182,6 +182,11 @@ class MainActivity : AppCompatActivity() {
swipeRefreshLayout.isRefreshing = false
syncStatusBanner.visibility = View.GONE
}
// v1.5.0: Silent-Sync - Banner nicht anzeigen, aber Sync-Controls deaktivieren
SyncStateManager.SyncState.SYNCING_SILENT -> {
setSyncControlsEnabled(false)
// Kein Banner anzeigen bei Silent-Sync (z.B. onResume Auto-Sync)
}
}
}
}
@@ -222,6 +227,7 @@ class MainActivity : AppCompatActivity() {
* - Nur Success-Toast (kein "Auto-Sync..." Toast)
*
* NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!)
* v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt
*/
private fun triggerAutoSync(source: String = "unknown") {
// Throttling: Max 1 Sync pro Minute
@@ -230,7 +236,8 @@ class MainActivity : AppCompatActivity() {
}
// 🔄 v1.3.1: Check if sync already running
if (!SyncStateManager.tryStartSync("auto-$source")) {
// v1.5.0: silent=true - kein Banner bei Auto-Sync
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
return
}
@@ -460,10 +467,10 @@ class MainActivity : AppCompatActivity() {
val checkboxAlways = dialogView.findViewById<CheckBox>(R.id.checkboxAlwaysDeleteFromServer)
MaterialAlertDialogBuilder(this)
.setTitle("Notiz löschen")
.setMessage("\"${note.title}\" wird lokal gelöscht.\n\nAuch vom Server löschen?")
.setTitle(getString(R.string.legacy_delete_dialog_title))
.setMessage(getString(R.string.legacy_delete_dialog_message, note.title))
.setView(dialogView)
.setNeutralButton("Abbrechen") { _, _ ->
.setNeutralButton(getString(R.string.cancel)) { _, _ ->
// RESTORE: Re-submit original list (note is NOT deleted from storage)
adapter.submitList(originalList)
}
@@ -478,7 +485,7 @@ class MainActivity : AppCompatActivity() {
// NOW actually delete from storage
deleteNoteLocally(note, deleteFromServer = false)
}
.setNegativeButton("Vom Server löschen") { _, _ ->
.setNegativeButton(getString(R.string.legacy_delete_from_server)) { _, _ ->
if (checkboxAlways.isChecked) {
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, true).apply()
}
@@ -500,13 +507,13 @@ class MainActivity : AppCompatActivity() {
// Show Snackbar with UNDO option
val message = if (deleteFromServer) {
"\"${note.title}\" wird lokal und vom Server gelöscht"
getString(R.string.legacy_delete_with_server, note.title)
} else {
"\"${note.title}\" lokal gelöscht (Server bleibt)"
getString(R.string.legacy_delete_local_only, note.title)
}
Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG)
.setAction("RÜCKGÄNGIG") {
.setAction(getString(R.string.snackbar_undo)) {
// UNDO: Restore note
storage.saveNote(note)
pendingDeletions.remove(note.id)
@@ -528,7 +535,7 @@ class MainActivity : AppCompatActivity() {
runOnUiThread {
Toast.makeText(
this@MainActivity,
"Vom Server gelöscht",
getString(R.string.snackbar_deleted_from_server),
Toast.LENGTH_SHORT
).show()
}
@@ -536,7 +543,7 @@ class MainActivity : AppCompatActivity() {
runOnUiThread {
Toast.makeText(
this@MainActivity,
"Server-Löschung fehlgeschlagen",
getString(R.string.snackbar_server_delete_failed),
Toast.LENGTH_LONG
).show()
}
@@ -638,7 +645,8 @@ class MainActivity : AppCompatActivity() {
}
private fun openSettings() {
val intent = Intent(this, SettingsActivity::class.java)
// v1.5.0: Use new Jetpack Compose Settings
val intent = Intent(this, dev.dettmer.simplenotes.ui.settings.ComposeSettingsActivity::class.java)
@Suppress("DEPRECATION")
startActivityForResult(intent, REQUEST_SETTINGS)
}
@@ -792,10 +800,9 @@ class MainActivity : AppCompatActivity() {
REQUEST_NOTIFICATION_PERMISSION -> {
if (grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
showToast("Benachrichtigungen aktiviert")
showToast(getString(R.string.toast_notifications_enabled))
} else {
showToast("Benachrichtigungen deaktiviert. " +
"Du kannst sie in den Einstellungen aktivieren.")
showToast(getString(R.string.toast_notifications_disabled))
}
}
}

View File

@@ -227,9 +227,9 @@ class SettingsActivity : AppCompatActivity() {
*/
private fun updateProtocolHint() {
protocolHintText.text = if (radioHttp.isChecked) {
"HTTP nur für lokale Netzwerke (z.B. 192.168.x.x, 10.x.x.x)"
getString(R.string.server_connection_http_hint)
} else {
"HTTPS für sichere Verbindungen über das Internet"
getString(R.string.server_connection_https_hint)
}
}
@@ -359,7 +359,7 @@ class SettingsActivity : AppCompatActivity() {
60L -> "60 Minuten"
else -> "$newInterval Minuten"
}
showToast("⏱️ Sync-Intervall auf $intervalText geändert")
showToast(getString(R.string.toast_sync_interval_changed, intervalText))
Logger.i(TAG, "Sync interval changed to $newInterval minutes, restarted periodic sync")
} else {
showToast("⏱️ Sync-Intervall gespeichert (Auto-Sync ist deaktiviert)")
@@ -379,7 +379,7 @@ class SettingsActivity : AppCompatActivity() {
textViewAppVersion.text = "Version $versionName ($versionCode)"
} catch (e: Exception) {
Logger.e(TAG, "Failed to load version info", e)
textViewAppVersion.text = "Version nicht verfügbar"
textViewAppVersion.text = getString(R.string.version_not_available)
}
// GitHub Repository Card
@@ -475,12 +475,12 @@ class SettingsActivity : AppCompatActivity() {
*/
private fun showClearLogsConfirmation() {
AlertDialog.Builder(this)
.setTitle("Logs löschen?")
.setMessage("Alle gespeicherten Sync-Logs werden unwiderruflich gelöscht.")
.setPositiveButton("Löschen") { _, _ ->
.setTitle(getString(R.string.debug_delete_logs_title))
.setMessage(getString(R.string.debug_delete_logs_message))
.setPositiveButton(getString(R.string.delete)) { _, _ ->
clearLogs()
}
.setNegativeButton("Abbrechen", null)
.setNegativeButton(getString(R.string.cancel), null)
.show()
}
@@ -491,13 +491,13 @@ class SettingsActivity : AppCompatActivity() {
try {
val cleared = Logger.clearLogFile(this)
if (cleared) {
showToast("🗑️ Logs gelöscht")
showToast(getString(R.string.toast_logs_deleted))
} else {
showToast("📭 Keine Logs zum Löschen")
showToast(getString(R.string.toast_no_logs_to_delete))
}
} catch (e: Exception) {
Logger.e(TAG, "Failed to clear logs", e)
showToast("❌ Fehler beim Löschen: ${e.message}")
showToast(getString(R.string.toast_logs_delete_error, e.message ?: ""))
}
}
@@ -510,7 +510,7 @@ class SettingsActivity : AppCompatActivity() {
startActivity(intent)
} catch (e: Exception) {
Logger.e(TAG, "Failed to open URL: $url", e)
showToast("❌ Fehler beim Öffnen des Links")
showToast(getString(R.string.toast_link_error))
}
}
@@ -524,7 +524,7 @@ class SettingsActivity : AppCompatActivity() {
// 🔥 v1.1.2: Validate HTTP URL (only allow for local networks)
if (fullUrl.isNotEmpty()) {
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(fullUrl)
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(this, fullUrl)
if (!isValid) {
// Only show error in TextField (no Toast)
textInputLayoutServerUrl.isErrorEnabled = true
@@ -552,7 +552,7 @@ class SettingsActivity : AppCompatActivity() {
// 🔥 v1.1.2: Validate before testing
if (fullUrl.isNotEmpty()) {
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(fullUrl)
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(this, fullUrl)
if (!isValid) {
// Only show error in TextField (no Toast)
textInputLayoutServerUrl.isErrorEnabled = true
@@ -646,7 +646,7 @@ class SettingsActivity : AppCompatActivity() {
return
}
textViewServerStatus.text = "🔍 Prüfe..."
textViewServerStatus.text = getString(R.string.status_checking)
textViewServerStatus.setTextColor(getColor(android.R.color.darker_gray))
lifecycleScope.launch {
@@ -803,12 +803,12 @@ class SettingsActivity : AppCompatActivity() {
.setMessage(
"Damit die App im Hintergrund synchronisieren kann, " +
"muss die Akku-Optimierung deaktiviert werden.\n\n" +
"Bitte wähle 'Nicht optimieren' für Simple Notes."
getString(R.string.battery_optimization_dialog_message)
)
.setPositiveButton("Einstellungen öffnen") { _, _ ->
.setPositiveButton(getString(R.string.battery_optimization_open_settings)) { _, _ ->
openBatteryOptimizationSettings()
}
.setNegativeButton("Später") { dialog, _ ->
.setNegativeButton(getString(R.string.battery_optimization_later)) { dialog, _ ->
dialog.dismiss()
}
.setCancelable(false)
@@ -915,20 +915,20 @@ class SettingsActivity : AppCompatActivity() {
// Radio Buttons erstellen
val radioMerge = android.widget.RadioButton(this).apply {
text = "⚪ Zusammenführen (Standard)\n → Neue hinzufügen, Bestehende behalten"
text = getString(R.string.backup_mode_merge_full)
id = android.view.View.generateViewId()
isChecked = true
setPadding(10, 10, 10, 10)
}
val radioReplace = android.widget.RadioButton(this).apply {
text = "⚪ Ersetzen\n → Alle löschen & Backup importieren"
text = getString(R.string.backup_mode_replace_full)
id = android.view.View.generateViewId()
setPadding(10, 10, 10, 10)
}
val radioOverwrite = android.widget.RadioButton(this).apply {
text = "⚪ Duplikate überschreiben\n → Backup gewinnt bei Konflikten"
text = getString(R.string.backup_mode_overwrite_full)
id = android.view.View.generateViewId()
setPadding(10, 10, 10, 10)
}
@@ -978,7 +978,7 @@ class SettingsActivity : AppCompatActivity() {
RestoreSource.WEBDAV_SERVER -> performRestoreFromServer(selectedMode)
}
}
.setNegativeButton("Abbrechen", null)
.setNegativeButton(getString(R.string.cancel), null)
.show()
}

View File

@@ -5,6 +5,7 @@ import android.net.Uri
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.Logger
@@ -144,7 +145,7 @@ class BackupManager(private val context: Context) {
if (!validationResult.isValid) {
return@withContext RestoreResult(
success = false,
error = validationResult.errorMessage ?: "Ungültige Backup-Datei"
error = validationResult.errorMessage ?: context.getString(R.string.error_invalid_backup_file)
)
}
@@ -171,7 +172,7 @@ class BackupManager(private val context: Context) {
Logger.e(TAG, "Failed to restore backup", e)
RestoreResult(
success = false,
error = "Wiederherstellung fehlgeschlagen: ${e.message}"
error = context.getString(R.string.error_restore_failed, e.message ?: "")
)
}
}
@@ -187,8 +188,7 @@ class BackupManager(private val context: Context) {
if (backupData.backupVersion > BACKUP_VERSION) {
return ValidationResult(
isValid = false,
errorMessage = "Backup-Version nicht unterstützt " +
"(v${backupData.backupVersion} benötigt v${BACKUP_VERSION}+)"
errorMessage = context.getString(R.string.error_backup_version_unsupported, backupData.backupVersion, BACKUP_VERSION)
)
}
@@ -196,7 +196,7 @@ class BackupManager(private val context: Context) {
if (backupData.notes.isEmpty()) {
return ValidationResult(
isValid = false,
errorMessage = "Backup enthält keine Notizen"
errorMessage = context.getString(R.string.error_backup_empty)
)
}
@@ -208,7 +208,7 @@ class BackupManager(private val context: Context) {
if (invalidNotes.isNotEmpty()) {
return ValidationResult(
isValid = false,
errorMessage = "Backup enthält ${invalidNotes.size} ungültige Notizen"
errorMessage = context.getString(R.string.error_backup_invalid_notes, invalidNotes.size)
)
}
@@ -217,7 +217,7 @@ class BackupManager(private val context: Context) {
} catch (e: Exception) {
ValidationResult(
isValid = false,
errorMessage = "Backup-Datei beschädigt oder ungültig: ${e.message}"
errorMessage = context.getString(R.string.error_backup_corrupt, e.message ?: "")
)
}
}
@@ -241,7 +241,7 @@ class BackupManager(private val context: Context) {
success = true,
importedNotes = newNotes.size,
skippedNotes = skippedNotes,
message = "${newNotes.size} neue Notizen importiert, $skippedNotes übersprungen"
message = context.getString(R.string.restore_merge_result, newNotes.size, skippedNotes)
)
}
@@ -262,10 +262,10 @@ class BackupManager(private val context: Context) {
success = true,
importedNotes = backupNotes.size,
skippedNotes = 0,
message = "Alle Notizen ersetzt: ${backupNotes.size} importiert"
message = context.getString(R.string.restore_replace_result, backupNotes.size)
)
}
/**
* Restore-Modus: OVERWRITE_DUPLICATES
* Backup überschreibt bei ID-Konflikten
@@ -287,7 +287,7 @@ class BackupManager(private val context: Context) {
importedNotes = newNotes.size,
skippedNotes = 0,
overwrittenNotes = overwrittenNotes.size,
message = "${newNotes.size} neu, ${overwrittenNotes.size} überschrieben"
message = context.getString(R.string.restore_overwrite_result, newNotes.size, overwrittenNotes.size)
)
}

View File

@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.models
import androidx.compose.runtime.Immutable
import dev.dettmer.simplenotes.utils.Logger
import java.text.SimpleDateFormat
import java.util.Date
@@ -7,6 +8,12 @@ import java.util.Locale
import java.util.TimeZone
import java.util.UUID
/**
* Note data class with Compose stability annotation.
* @Immutable tells Compose this class is stable and won't change unexpectedly,
* enabling skip optimizations during recomposition.
*/
@Immutable
data class Note(
val id: String = UUID.randomUUID().toString(),
val title: String,

View File

@@ -19,7 +19,8 @@ object SyncStateManager {
*/
enum class SyncState {
IDLE, // Kein Sync aktiv
SYNCING, // Sync läuft gerade
SYNCING, // Sync läuft gerade (Banner sichtbar)
SYNCING_SILENT, // v1.5.0: Sync läuft im Hintergrund (kein Banner, z.B. onResume)
COMPLETED, // Sync erfolgreich abgeschlossen (kurz anzeigen)
ERROR // Sync fehlgeschlagen (kurz anzeigen)
}
@@ -31,6 +32,7 @@ object SyncStateManager {
val state: SyncState = SyncState.IDLE,
val message: String? = null,
val source: String? = null, // "manual", "auto", "pullToRefresh", "background"
val silent: Boolean = false, // v1.5.0: Wenn true, wird nach Completion kein Banner angezeigt
val timestamp: Long = System.currentTimeMillis()
)
@@ -44,28 +46,35 @@ object SyncStateManager {
private val lock = Any()
/**
* Prüft ob gerade ein Sync läuft
* Prüft ob gerade ein Sync läuft (inkl. Silent-Sync)
*/
val isSyncing: Boolean
get() = _syncStatus.value?.state == SyncState.SYNCING
get() {
val state = _syncStatus.value?.state
return state == SyncState.SYNCING || state == SyncState.SYNCING_SILENT
}
/**
* Versucht einen Sync zu starten.
* @param source Quelle des Syncs (für Logging)
* @param silent v1.5.0: Wenn true, wird kein Banner angezeigt (z.B. bei onResume Auto-Sync)
* @return true wenn Sync gestartet werden kann, false wenn bereits einer läuft
*/
fun tryStartSync(source: String): Boolean {
fun tryStartSync(source: String, silent: Boolean = false): Boolean {
synchronized(lock) {
if (isSyncing) {
Logger.d(TAG, "⚠️ Sync already in progress, rejecting new sync from: $source")
return false
}
Logger.d(TAG, "🔄 Starting sync from: $source")
val syncState = if (silent) SyncState.SYNCING_SILENT else SyncState.SYNCING
Logger.d(TAG, "🔄 Starting sync from: $source (silent=$silent)")
_syncStatus.postValue(
SyncStatus(
state = SyncState.SYNCING,
state = syncState,
message = "Synchronisiere...",
source = source
source = source,
silent = silent // v1.5.0: Merkt sich ob silent für markCompleted()
)
)
return true
@@ -74,18 +83,29 @@ object SyncStateManager {
/**
* Markiert Sync als erfolgreich abgeschlossen
* v1.5.0: Bei Silent-Sync direkt auf IDLE wechseln (kein Banner)
*/
fun markCompleted(message: String? = null) {
synchronized(lock) {
val currentSource = _syncStatus.value?.source
Logger.d(TAG, "✅ Sync completed from: $currentSource")
_syncStatus.postValue(
SyncStatus(
state = SyncState.COMPLETED,
message = message,
source = currentSource
val current = _syncStatus.value
val currentSource = current?.source
val wasSilent = current?.silent == true
Logger.d(TAG, "✅ Sync completed from: $currentSource (silent=$wasSilent)")
if (wasSilent) {
// v1.5.0: Silent-Sync - direkt auf IDLE, kein Banner anzeigen
_syncStatus.postValue(SyncStatus())
} else {
// Normaler Sync - COMPLETED State anzeigen
_syncStatus.postValue(
SyncStatus(
state = SyncState.COMPLETED,
message = message,
source = currentSource
)
)
)
}
}
}

View File

@@ -6,6 +6,7 @@ import android.net.NetworkCapabilities
import com.thegrizzlylabs.sardineandroid.Sardine
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.DeletionTracker
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SyncStatus
@@ -1852,15 +1853,15 @@ class WebDavSyncService(private val context: Context) {
suspend fun manualMarkdownSync(): ManualMarkdownSyncResult = withContext(Dispatchers.IO) {
return@withContext try {
val sardine = getOrCreateSardine()
?: throw SyncException("Sardine client konnte nicht erstellt werden")
?: throw SyncException(context.getString(R.string.error_sardine_client_failed))
val serverUrl = getServerUrl()
?: throw SyncException("Server-URL nicht konfiguriert")
?: throw SyncException(context.getString(R.string.error_server_url_not_configured))
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
throw SyncException("WebDAV-Server nicht vollständig konfiguriert")
throw SyncException(context.getString(R.string.error_server_not_configured))
}
Logger.d(TAG, "🔄 Manual Markdown Sync START")

View File

@@ -0,0 +1,104 @@
package dev.dettmer.simplenotes.ui.editor
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.lifecycle.AbstractSavedStateViewModelFactory
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.savedstate.SavedStateRegistryOwner
import com.google.android.material.color.DynamicColors
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme
/**
* Compose-based Note Editor Activity
*
* v1.5.0: Jetpack Compose NoteEditor Redesign
* Replaces the old NoteEditorActivity with a modern Compose implementation.
*
* Supports:
* - TEXT notes with title and content
* - CHECKLIST notes with drag & drop reordering
* - Auto-keyboard focus for new checklist items
*/
class ComposeNoteEditorActivity : ComponentActivity() {
companion object {
const val EXTRA_NOTE_ID = "extra_note_id"
const val EXTRA_NOTE_TYPE = "extra_note_type"
}
private val viewModel: NoteEditorViewModel by viewModels {
NoteEditorViewModelFactory(
application = application,
owner = this,
noteId = intent.getStringExtra(EXTRA_NOTE_ID),
noteType = intent.getStringExtra(EXTRA_NOTE_TYPE) ?: NoteType.TEXT.name
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Apply Dynamic Colors for Android 12+ (Material You)
DynamicColors.applyToActivityIfAvailable(this)
enableEdgeToEdge()
// v1.5.0: Handle back button with slide animation
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
finish()
@Suppress("DEPRECATION")
overridePendingTransition(
dev.dettmer.simplenotes.R.anim.slide_in_left,
dev.dettmer.simplenotes.R.anim.slide_out_right
)
}
})
setContent {
SimpleNotesTheme {
NoteEditorScreen(
viewModel = viewModel,
onNavigateBack = {
finish()
@Suppress("DEPRECATION")
overridePendingTransition(
dev.dettmer.simplenotes.R.anim.slide_in_left,
dev.dettmer.simplenotes.R.anim.slide_out_right
)
}
)
}
}
}
}
/**
* Custom ViewModelFactory to pass SavedStateHandle with intent extras
*/
class NoteEditorViewModelFactory(
private val application: android.app.Application,
owner: SavedStateRegistryOwner,
private val noteId: String?,
private val noteType: String
) : AbstractSavedStateViewModelFactory(owner, null) {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(
key: String,
modelClass: Class<T>,
handle: SavedStateHandle
): T {
// Populate SavedStateHandle with intent extras
handle[NoteEditorViewModel.ARG_NOTE_ID] = noteId
handle[NoteEditorViewModel.ARG_NOTE_TYPE] = noteType
return NoteEditorViewModel(application, handle) as T
}
}

View File

@@ -0,0 +1,154 @@
package dev.dettmer.simplenotes.ui.editor
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
/**
* FOSS Drag & Drop State für LazyList
*
* Native Compose-Implementierung ohne externe Dependencies
* v1.5.0: NoteEditor Redesign
*/
class DragDropListState(
private val state: LazyListState,
private val scope: CoroutineScope,
private val onMove: (Int, Int) -> Unit
) {
var draggingItemIndex by mutableStateOf<Int?>(null)
private set
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
private var draggingItemInitialOffset by mutableFloatStateOf(0f)
private var overscrollJob by mutableStateOf<Job?>(null)
val draggingItemOffset: Float
get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
} ?: 0f
private val draggingItemLayoutInfo: LazyListItemInfo?
get() = state.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == draggingItemIndex }
fun onDragStart(offset: Offset, itemIndex: Int) {
draggingItemIndex = itemIndex
draggingItemInitialOffset = draggingItemLayoutInfo?.offset?.toFloat() ?: 0f
draggingItemDraggedDelta = 0f
}
fun onDragInterrupted() {
draggingItemDraggedDelta = 0f
draggingItemIndex = null
draggingItemInitialOffset = 0f
overscrollJob?.cancel()
}
fun onDrag(offset: Offset) {
draggingItemDraggedDelta += offset.y
val draggingItem = draggingItemLayoutInfo ?: return
val startOffset = draggingItem.offset + draggingItemOffset
val endOffset = startOffset + draggingItem.size
val middleOffset = startOffset + (endOffset - startOffset) / 2f
val targetItem = state.layoutInfo.visibleItemsInfo.find { item ->
middleOffset.toInt() in item.offset..item.offsetEnd &&
draggingItem.index != item.index
}
if (targetItem != null) {
val scrollToIndex = if (targetItem.index == state.firstVisibleItemIndex) {
draggingItem.index
} else if (draggingItem.index == state.firstVisibleItemIndex) {
targetItem.index
} else {
null
}
if (scrollToIndex != null) {
scope.launch {
state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
onMove(draggingItem.index, targetItem.index)
}
} else {
onMove(draggingItem.index, targetItem.index)
}
draggingItemIndex = targetItem.index
} else {
val overscroll = when {
draggingItemDraggedDelta > 0 ->
(endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
draggingItemDraggedDelta < 0 ->
(startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
else -> 0f
}
if (overscroll != 0f) {
if (overscrollJob?.isActive != true) {
overscrollJob = scope.launch {
state.scrollBy(overscroll)
}
}
} else {
overscrollJob?.cancel()
}
}
}
private val LazyListItemInfo.offsetEnd: Int
get() = this.offset + this.size
}
@Composable
fun rememberDragDropListState(
lazyListState: LazyListState,
scope: CoroutineScope,
onMove: (Int, Int) -> Unit
): DragDropListState {
return remember(lazyListState, scope) {
DragDropListState(
state = lazyListState,
scope = scope,
onMove = onMove
)
}
}
fun Modifier.dragContainer(
dragDropState: DragDropListState,
itemIndex: Int
): Modifier {
return this.pointerInput(dragDropState) {
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
dragDropState.onDragStart(offset, itemIndex)
},
onDragEnd = {
dragDropState.onDragInterrupted()
},
onDragCancel = {
dragDropState.onDragInterrupted()
},
onDrag = { change, offset ->
change.consume()
dragDropState.onDrag(offset)
}
)
}
}

View File

@@ -0,0 +1,376 @@
package dev.dettmer.simplenotes.ui.editor
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.ui.editor.components.ChecklistItemRow
import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog
import kotlinx.coroutines.delay
import dev.dettmer.simplenotes.utils.showToast
import kotlin.math.roundToInt
/**
* Main Composable for the Note Editor screen.
*
* v1.5.0: Jetpack Compose NoteEditor Redesign
* - Supports both TEXT and CHECKLIST notes
* - Drag & Drop reordering for checklist items
* - Auto-keyboard focus for new items
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoteEditorScreen(
viewModel: NoteEditorViewModel,
onNavigateBack: () -> Unit
) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState()
val checklistItems by viewModel.checklistItems.collectAsState()
var showDeleteDialog by remember { mutableStateOf(false) }
var focusNewItemId by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
// v1.5.0: Auto-keyboard support
val keyboardController = LocalSoftwareKeyboardController.current
val titleFocusRequester = remember { FocusRequester() }
val contentFocusRequester = remember { FocusRequester() }
// v1.5.0: Auto-focus and show keyboard
LaunchedEffect(uiState.isNewNote, uiState.noteType) {
delay(100) // Wait for layout
when {
uiState.isNewNote -> {
// New note: focus title
titleFocusRequester.requestFocus()
keyboardController?.show()
}
!uiState.isNewNote && uiState.noteType == NoteType.TEXT -> {
// Editing text note: focus content
contentFocusRequester.requestFocus()
keyboardController?.show()
}
}
}
// Handle events
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is NoteEditorEvent.ShowToast -> {
val message = when (event.message) {
ToastMessage.NOTE_IS_EMPTY -> context.getString(R.string.note_is_empty)
ToastMessage.NOTE_SAVED -> context.getString(R.string.note_saved)
ToastMessage.NOTE_DELETED -> context.getString(R.string.note_deleted)
}
context.showToast(message)
}
is NoteEditorEvent.NavigateBack -> onNavigateBack()
is NoteEditorEvent.ShowDeleteConfirmation -> showDeleteDialog = true
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = when (uiState.toolbarTitle) {
ToolbarTitle.NEW_NOTE -> stringResource(R.string.new_note)
ToolbarTitle.EDIT_NOTE -> stringResource(R.string.edit_note)
ToolbarTitle.NEW_CHECKLIST -> stringResource(R.string.new_checklist)
ToolbarTitle.EDIT_CHECKLIST -> stringResource(R.string.edit_checklist)
}
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
actions = {
// Delete button (only for existing notes)
if (viewModel.canDelete()) {
IconButton(onClick = { showDeleteDialog = true }) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.delete)
)
}
}
// Save button
IconButton(onClick = { viewModel.saveNote() }) {
Icon(
imageVector = Icons.Default.Save,
contentDescription = stringResource(R.string.save)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
modifier = Modifier.imePadding()
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
) {
// Title Input (for both types)
OutlinedTextField(
value = uiState.title,
onValueChange = { viewModel.updateTitle(it) },
modifier = Modifier
.fillMaxWidth()
.focusRequester(titleFocusRequester),
label = { Text(stringResource(R.string.title)) },
singleLine = false,
maxLines = 2,
shape = RoundedCornerShape(16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
when (uiState.noteType) {
NoteType.TEXT -> {
// Content Input for TEXT notes
TextNoteContent(
content = uiState.content,
onContentChange = { viewModel.updateContent(it) },
focusRequester = contentFocusRequester,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
)
}
NoteType.CHECKLIST -> {
// Checklist Editor
ChecklistEditor(
items = checklistItems,
scope = scope,
focusNewItemId = focusNewItemId,
onTextChange = { id, text -> viewModel.updateChecklistItemText(id, text) },
onCheckedChange = { id, checked -> viewModel.updateChecklistItemChecked(id, checked) },
onDelete = { id -> viewModel.deleteChecklistItem(id) },
onAddNewItemAfter = { id ->
val newId = viewModel.addChecklistItemAfter(id)
focusNewItemId = newId
},
onAddItemAtEnd = {
val newId = viewModel.addChecklistItemAtEnd()
focusNewItemId = newId
},
onMove = { from, to -> viewModel.moveChecklistItem(from, to) },
onFocusHandled = { focusNewItemId = null },
modifier = Modifier
.fillMaxWidth()
.weight(1f)
)
}
}
}
}
// Delete Confirmation Dialog - v1.5.0: Use shared component with server/local options
if (showDeleteDialog) {
DeleteConfirmationDialog(
noteCount = 1,
onDismiss = { showDeleteDialog = false },
onDeleteLocal = {
showDeleteDialog = false
viewModel.deleteNote(deleteOnServer = false)
},
onDeleteEverywhere = {
showDeleteDialog = false
viewModel.deleteNote(deleteOnServer = true)
}
)
}
}
@Composable
private fun TextNoteContent(
content: String,
onContentChange: (String) -> Unit,
focusRequester: FocusRequester,
modifier: Modifier = Modifier
) {
// v1.5.0: Use TextFieldValue to control cursor position
// Track if initial cursor position has been set (only set to end once on first load)
var initialCursorSet by remember { mutableStateOf(false) }
var textFieldValue by remember {
mutableStateOf(TextFieldValue(
text = content,
selection = TextRange(content.length)
))
}
// Set initial cursor position only once when content first loads
LaunchedEffect(Unit) {
if (!initialCursorSet && content.isNotEmpty()) {
textFieldValue = TextFieldValue(
text = content,
selection = TextRange(content.length)
)
initialCursorSet = true
}
}
OutlinedTextField(
value = textFieldValue,
onValueChange = { newValue ->
textFieldValue = newValue
onContentChange(newValue.text)
},
modifier = modifier.focusRequester(focusRequester),
label = { Text(stringResource(R.string.content)) },
shape = RoundedCornerShape(16.dp)
)
}
@Composable
private fun ChecklistEditor(
items: List<ChecklistItemState>,
scope: kotlinx.coroutines.CoroutineScope,
focusNewItemId: String?,
onTextChange: (String, String) -> Unit,
onCheckedChange: (String, Boolean) -> Unit,
onDelete: (String) -> Unit,
onAddNewItemAfter: (String) -> Unit,
onAddItemAtEnd: () -> Unit,
onMove: (Int, Int) -> Unit,
onFocusHandled: () -> Unit,
modifier: Modifier = Modifier
) {
val listState = rememberLazyListState()
val dragDropState = rememberDragDropListState(
lazyListState = listState,
scope = scope,
onMove = onMove
)
Column(modifier = modifier) {
LazyColumn(
state = listState,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
itemsIndexed(
items = items,
key = { _, item -> item.id }
) { index, item ->
val isDragging = dragDropState.draggingItemIndex == index
val elevation by animateDpAsState(
targetValue = if (isDragging) 8.dp else 0.dp,
label = "elevation"
)
val shouldFocus = item.id == focusNewItemId
// v1.5.0: Clear focus request after handling
LaunchedEffect(shouldFocus) {
if (shouldFocus) {
onFocusHandled()
}
}
ChecklistItemRow(
item = item,
onTextChange = { onTextChange(item.id, it) },
onCheckedChange = { onCheckedChange(item.id, it) },
onDelete = { onDelete(item.id) },
onAddNewItem = { onAddNewItemAfter(item.id) },
requestFocus = shouldFocus,
modifier = Modifier
.dragContainer(dragDropState, index)
.offset {
IntOffset(
0,
if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 0
)
}
.shadow(elevation, shape = RoundedCornerShape(8.dp))
.background(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(8.dp)
)
)
}
}
// Add Item Button
TextButton(
onClick = onAddItemAtEnd,
modifier = Modifier.padding(start = 8.dp)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(stringResource(R.string.add_item))
}
}
}
// v1.5.0: Local DeleteConfirmationDialog removed - now using shared component from ui/main/components/

View File

@@ -0,0 +1,383 @@
package dev.dettmer.simplenotes.ui.editor
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
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.sync.WebDavSyncService
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
/**
* ViewModel for NoteEditor Compose Screen
* v1.5.0: Jetpack Compose NoteEditor Redesign
*
* Manages note editing state including title, content, and checklist items.
*/
class NoteEditorViewModel(
application: Application,
private val savedStateHandle: SavedStateHandle
) : AndroidViewModel(application) {
companion object {
private const val TAG = "NoteEditorViewModel"
const val ARG_NOTE_ID = "noteId"
const val ARG_NOTE_TYPE = "noteType"
}
private val storage = NotesStorage(application)
// ═══════════════════════════════════════════════════════════════════════
// State
// ═══════════════════════════════════════════════════════════════════════
private val _uiState = MutableStateFlow(NoteEditorUiState())
val uiState: StateFlow<NoteEditorUiState> = _uiState.asStateFlow()
private val _checklistItems = MutableStateFlow<List<ChecklistItemState>>(emptyList())
val checklistItems: StateFlow<List<ChecklistItemState>> = _checklistItems.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// Events
// ═══════════════════════════════════════════════════════════════════════
private val _events = MutableSharedFlow<NoteEditorEvent>()
val events: SharedFlow<NoteEditorEvent> = _events.asSharedFlow()
// Internal state
private var existingNote: Note? = null
private var currentNoteType: NoteType = NoteType.TEXT
init {
loadNote()
}
private fun loadNote() {
val noteId = savedStateHandle.get<String>(ARG_NOTE_ID)
val noteTypeString = savedStateHandle.get<String>(ARG_NOTE_TYPE) ?: NoteType.TEXT.name
if (noteId != null) {
// Load existing note
existingNote = storage.loadNote(noteId)
existingNote?.let { note ->
currentNoteType = note.noteType
_uiState.update { state ->
state.copy(
title = note.title,
content = note.content,
noteType = note.noteType,
isNewNote = false,
toolbarTitle = if (note.noteType == NoteType.CHECKLIST) {
ToolbarTitle.EDIT_CHECKLIST
} else {
ToolbarTitle.EDIT_NOTE
}
)
}
if (note.noteType == NoteType.CHECKLIST) {
val items = note.checklistItems?.sortedBy { it.order }?.map {
ChecklistItemState(
id = it.id,
text = it.text,
isChecked = it.isChecked,
order = it.order
)
} ?: emptyList()
_checklistItems.value = items
}
}
} else {
// New note
currentNoteType = try {
NoteType.valueOf(noteTypeString)
} catch (e: IllegalArgumentException) {
Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT")
NoteType.TEXT
}
_uiState.update { state ->
state.copy(
noteType = currentNoteType,
isNewNote = true,
toolbarTitle = if (currentNoteType == NoteType.CHECKLIST) {
ToolbarTitle.NEW_CHECKLIST
} else {
ToolbarTitle.NEW_NOTE
}
)
}
// Add first empty item for new checklists
if (currentNoteType == NoteType.CHECKLIST) {
_checklistItems.value = listOf(ChecklistItemState.createEmpty(0))
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// Actions
// ═══════════════════════════════════════════════════════════════════════
fun updateTitle(title: String) {
_uiState.update { it.copy(title = title) }
}
fun updateContent(content: String) {
_uiState.update { it.copy(content = content) }
}
fun updateChecklistItemText(itemId: String, newText: String) {
_checklistItems.update { items ->
items.map { item ->
if (item.id == itemId) item.copy(text = newText) else item
}
}
}
fun updateChecklistItemChecked(itemId: String, isChecked: Boolean) {
_checklistItems.update { items ->
items.map { item ->
if (item.id == itemId) item.copy(isChecked = isChecked) else item
}
}
}
fun addChecklistItemAfter(afterItemId: String): String {
val newItem = ChecklistItemState.createEmpty(0)
_checklistItems.update { items ->
val index = items.indexOfFirst { it.id == afterItemId }
if (index >= 0) {
val newList = items.toMutableList()
newList.add(index + 1, newItem)
// Update order values
newList.mapIndexed { i, item -> item.copy(order = i) }
} else {
items + newItem.copy(order = items.size)
}
}
return newItem.id
}
fun addChecklistItemAtEnd(): String {
val newItem = ChecklistItemState.createEmpty(_checklistItems.value.size)
_checklistItems.update { items -> items + newItem }
return newItem.id
}
fun deleteChecklistItem(itemId: String) {
_checklistItems.update { items ->
val filtered = items.filter { it.id != itemId }
// Ensure at least one item exists
if (filtered.isEmpty()) {
listOf(ChecklistItemState.createEmpty(0))
} else {
// Update order values
filtered.mapIndexed { index, item -> item.copy(order = index) }
}
}
}
fun moveChecklistItem(fromIndex: Int, toIndex: Int) {
_checklistItems.update { items ->
val mutableList = items.toMutableList()
val item = mutableList.removeAt(fromIndex)
mutableList.add(toIndex, item)
// Update order values
mutableList.mapIndexed { index, i -> i.copy(order = index) }
}
}
fun saveNote() {
viewModelScope.launch {
val state = _uiState.value
val title = state.title.trim()
when (currentNoteType) {
NoteType.TEXT -> {
val content = state.content.trim()
if (title.isEmpty() && content.isEmpty()) {
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_IS_EMPTY))
return@launch
}
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(getApplication()),
syncStatus = SyncStatus.LOCAL_ONLY
)
}
storage.saveNote(note)
}
NoteType.CHECKLIST -> {
// Filter empty items
val validItems = _checklistItems.value
.filter { it.text.isNotBlank() }
.mapIndexed { index, item ->
ChecklistItem(
id = item.id,
text = item.text,
isChecked = item.isChecked,
order = index
)
}
if (title.isEmpty() && validItems.isEmpty()) {
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_IS_EMPTY))
return@launch
}
val note = if (existingNote != null) {
existingNote!!.copy(
title = title,
content = "", // Empty for checklists
noteType = NoteType.CHECKLIST,
checklistItems = validItems,
updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING
)
} else {
Note(
title = title,
content = "",
noteType = NoteType.CHECKLIST,
checklistItems = validItems,
deviceId = DeviceIdGenerator.getDeviceId(getApplication()),
syncStatus = SyncStatus.LOCAL_ONLY
)
}
storage.saveNote(note)
}
}
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_SAVED))
_events.emit(NoteEditorEvent.NavigateBack)
}
}
/**
* Delete the current note
* @param deleteOnServer if true, also triggers server deletion; if false, only deletes locally
* v1.5.0: Added deleteOnServer parameter for unified delete dialog
*/
fun deleteNote(deleteOnServer: Boolean = true) {
viewModelScope.launch {
existingNote?.let { note ->
val noteId = note.id
// Delete locally first
storage.deleteNote(noteId)
// Delete from server if requested
if (deleteOnServer) {
try {
val webdavService = WebDavSyncService(getApplication())
val success = withContext(Dispatchers.IO) {
webdavService.deleteNoteFromServer(noteId)
}
if (success) {
Logger.d(TAG, "Note $noteId deleted from server")
} else {
Logger.w(TAG, "Failed to delete note $noteId from server")
}
} catch (e: Exception) {
Logger.e(TAG, "Error deleting note from server: ${e.message}")
}
}
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_DELETED))
_events.emit(NoteEditorEvent.NavigateBack)
}
}
}
fun showDeleteConfirmation() {
viewModelScope.launch {
_events.emit(NoteEditorEvent.ShowDeleteConfirmation)
}
}
fun canDelete(): Boolean = existingNote != null
}
// ═══════════════════════════════════════════════════════════════════════════
// State Classes
// ═══════════════════════════════════════════════════════════════════════════
data class NoteEditorUiState(
val title: String = "",
val content: String = "",
val noteType: NoteType = NoteType.TEXT,
val isNewNote: Boolean = true,
val toolbarTitle: ToolbarTitle = ToolbarTitle.NEW_NOTE
)
data class ChecklistItemState(
val id: String = UUID.randomUUID().toString(),
val text: String = "",
val isChecked: Boolean = false,
val order: Int = 0
) {
companion object {
fun createEmpty(order: Int): ChecklistItemState {
return ChecklistItemState(
id = UUID.randomUUID().toString(),
text = "",
isChecked = false,
order = order
)
}
}
}
enum class ToolbarTitle {
NEW_NOTE,
EDIT_NOTE,
NEW_CHECKLIST,
EDIT_CHECKLIST
}
enum class ToastMessage {
NOTE_IS_EMPTY,
NOTE_SAVED,
NOTE_DELETED
}
sealed interface NoteEditorEvent {
data class ShowToast(val message: ToastMessage) : NoteEditorEvent
data object NavigateBack : NoteEditorEvent
data object ShowDeleteConfirmation : NoteEditorEvent
}

View File

@@ -0,0 +1,178 @@
package dev.dettmer.simplenotes.ui.editor.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.ui.editor.ChecklistItemState
/**
* A single row in the checklist editor with drag handle, checkbox, text input, and delete button.
*
* v1.5.0: Jetpack Compose NoteEditor Redesign
*/
@Composable
fun ChecklistItemRow(
item: ChecklistItemState,
onTextChange: (String) -> Unit,
onCheckedChange: (Boolean) -> Unit,
onDelete: () -> Unit,
onAddNewItem: () -> Unit,
requestFocus: Boolean = false,
modifier: Modifier = Modifier
) {
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
var textFieldValue by remember(item.id) {
mutableStateOf(TextFieldValue(text = item.text, selection = TextRange(item.text.length)))
}
// v1.5.0: Auto-focus AND show keyboard when requestFocus is true (new items)
LaunchedEffect(requestFocus) {
if (requestFocus) {
focusRequester.requestFocus()
keyboardController?.show()
}
}
// Update text field when external state changes
LaunchedEffect(item.text) {
if (textFieldValue.text != item.text) {
textFieldValue = TextFieldValue(
text = item.text,
selection = TextRange(item.text.length)
)
}
}
val alpha = if (item.isChecked) 0.6f else 1.0f
val textDecoration = if (item.isChecked) TextDecoration.LineThrough else TextDecoration.None
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Drag Handle
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = stringResource(R.string.drag_to_reorder),
modifier = Modifier
.size(24.dp)
.alpha(0.5f),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(4.dp))
// Checkbox
Checkbox(
checked = item.isChecked,
onCheckedChange = onCheckedChange,
modifier = Modifier.alpha(alpha)
)
Spacer(modifier = Modifier.width(4.dp))
// Text Input with placeholder
BasicTextField(
value = textFieldValue,
onValueChange = { newValue ->
// Check for newline (Enter key)
if (newValue.text.contains("\n")) {
val cleanText = newValue.text.replace("\n", "")
textFieldValue = TextFieldValue(
text = cleanText,
selection = TextRange(cleanText.length)
)
onTextChange(cleanText)
onAddNewItem()
} else {
textFieldValue = newValue
onTextChange(newValue.text)
}
},
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester)
.alpha(alpha),
textStyle = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurface,
textDecoration = textDecoration
),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { onAddNewItem() }
),
singleLine = false,
maxLines = 5,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box {
if (textFieldValue.text.isEmpty()) {
Text(
text = stringResource(R.string.item_placeholder),
style = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
)
}
innerTextField()
}
}
)
Spacer(modifier = Modifier.width(4.dp))
// Delete Button
IconButton(
onClick = onDelete,
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.delete_item),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
}
}

View File

@@ -0,0 +1,386 @@
package dev.dettmer.simplenotes.ui.main
import android.Manifest
import android.app.ActivityOptions
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.google.android.material.color.DynamicColors
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.ui.editor.ComposeNoteEditorActivity
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncStateManager
import dev.dettmer.simplenotes.sync.SyncWorker
import dev.dettmer.simplenotes.ui.settings.ComposeSettingsActivity
import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.NotificationHelper
import kotlinx.coroutines.launch
/**
* Main Activity with Jetpack Compose UI
* v1.5.0: Complete MainActivity Redesign with Compose
*
* Replaces the old 805-line MainActivity.kt with a modern
* Compose-based implementation featuring:
* - Notes list with swipe-to-delete
* - Pull-to-refresh for sync
* - FAB with note type selection
* - Material 3 Design with Dynamic Colors (Material You)
* - Design consistent with ComposeSettingsActivity
*/
class ComposeMainActivity : ComponentActivity() {
companion object {
private const val TAG = "ComposeMainActivity"
private const val REQUEST_NOTIFICATION_PERMISSION = 1001
private const val REQUEST_SETTINGS = 1002
}
private val viewModel: MainViewModel by viewModels()
private val prefs by lazy {
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
}
// Phase 3: Track if coming from editor to scroll to top
private var cameFromEditor = false
/**
* BroadcastReceiver for Background-Sync Completion (Periodic Sync)
*/
private val syncCompletedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val success = intent?.getBooleanExtra("success", false) ?: false
val count = intent?.getIntExtra("count", 0) ?: 0
Logger.d(TAG, "📡 Sync completed broadcast received: success=$success, count=$count")
// UI refresh
if (success && count > 0) {
viewModel.loadNotes()
Logger.d(TAG, "🔄 Notes reloaded after background sync")
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
// Install Splash Screen (Android 12+)
installSplashScreen()
super.onCreate(savedInstanceState)
// Apply Dynamic Colors for Material You (Android 12+)
DynamicColors.applyToActivityIfAvailable(this)
// Enable edge-to-edge display
enableEdgeToEdge()
// Initialize Logger and enable file logging if configured
Logger.init(this)
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
Logger.setFileLoggingEnabled(true)
}
// Clear old sync notifications on app start
NotificationHelper.clearSyncNotifications(this)
// Request notification permission (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestNotificationPermission()
}
// v1.4.1: Migrate checklists for backwards compatibility
migrateChecklistsForBackwardsCompat()
// Setup Sync State Observer
setupSyncStateObserver()
setContent {
SimpleNotesTheme {
val context = LocalContext.current
// Dialog state for delete confirmation
var deleteDialogData by remember { mutableStateOf<MainViewModel.DeleteDialogData?>(null) }
// Handle delete dialog events
LaunchedEffect(Unit) {
viewModel.showDeleteDialog.collect { data ->
deleteDialogData = data
}
}
// Handle toast events
LaunchedEffect(Unit) {
viewModel.showToast.collect { message ->
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
// Delete confirmation dialog
deleteDialogData?.let { data ->
DeleteConfirmationDialog(
noteTitle = data.note.title,
onDismiss = {
viewModel.restoreNoteAfterSwipe(data.originalList)
deleteDialogData = null
},
onDeleteLocal = {
viewModel.deleteNoteConfirmed(data.note, deleteFromServer = false)
deleteDialogData = null
},
onDeleteFromServer = {
viewModel.deleteNoteConfirmed(data.note, deleteFromServer = true)
deleteDialogData = null
}
)
}
MainScreen(
viewModel = viewModel,
onOpenNote = { noteId -> openNoteEditor(noteId) },
onOpenSettings = { openSettings() },
onCreateNote = { noteType -> createNote(noteType) }
)
}
}
}
override fun onResume() {
super.onResume()
Logger.d(TAG, "📱 ComposeMainActivity.onResume() - Registering receivers")
// Register BroadcastReceiver for Background-Sync
LocalBroadcastManager.getInstance(this).registerReceiver(
syncCompletedReceiver,
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
)
Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)")
// Reload notes
viewModel.loadNotes()
// Phase 3: Scroll to top if coming from editor (new/edited note)
if (cameFromEditor) {
viewModel.scrollToTop()
cameFromEditor = false
Logger.d(TAG, "📜 Came from editor - scrolling to top")
}
// Trigger Auto-Sync on app resume
viewModel.triggerAutoSync("onResume")
}
override fun onPause() {
super.onPause()
// Unregister BroadcastReceiver
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
Logger.d(TAG, "📡 BroadcastReceiver unregistered")
}
private fun setupSyncStateObserver() {
SyncStateManager.syncStatus.observe(this) { status ->
viewModel.updateSyncState(status)
// Hide banner after delay for completed/error states
when (status.state) {
SyncStateManager.SyncState.COMPLETED -> {
lifecycleScope.launch {
kotlinx.coroutines.delay(1500L)
SyncStateManager.reset()
}
}
SyncStateManager.SyncState.ERROR -> {
lifecycleScope.launch {
kotlinx.coroutines.delay(3000L)
SyncStateManager.reset()
}
}
else -> { /* No action needed */ }
}
}
}
private fun openNoteEditor(noteId: String?) {
cameFromEditor = true
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
noteId?.let {
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_ID, it)
}
// v1.5.0: Add slide animation
val options = ActivityOptions.makeCustomAnimation(
this,
dev.dettmer.simplenotes.R.anim.slide_in_right,
dev.dettmer.simplenotes.R.anim.slide_out_left
)
startActivity(intent, options.toBundle())
}
private fun createNote(noteType: NoteType) {
cameFromEditor = true
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
// v1.5.0: Add slide animation
val options = ActivityOptions.makeCustomAnimation(
this,
dev.dettmer.simplenotes.R.anim.slide_in_right,
dev.dettmer.simplenotes.R.anim.slide_out_left
)
startActivity(intent, options.toBundle())
}
private fun openSettings() {
val intent = Intent(this, ComposeSettingsActivity::class.java)
val options = ActivityOptions.makeCustomAnimation(
this,
dev.dettmer.simplenotes.R.anim.slide_in_right,
dev.dettmer.simplenotes.R.anim.slide_out_left
)
@Suppress("DEPRECATION")
startActivityForResult(intent, REQUEST_SETTINGS, options.toBundle())
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
REQUEST_NOTIFICATION_PERMISSION
)
}
}
}
/**
* v1.4.1: Migrates existing checklists for backwards compatibility.
*/
private fun migrateChecklistsForBackwardsCompat() {
val migrationKey = "v1.4.1_checklist_migration_done"
// Only run once
if (prefs.getBoolean(migrationKey, false)) {
return
}
val storage = NotesStorage(this)
val allNotes = storage.loadAllNotes()
val checklistsToMigrate = allNotes.filter { note ->
note.noteType == NoteType.CHECKLIST &&
note.content.isBlank() &&
note.checklistItems?.isNotEmpty() == true
}
if (checklistsToMigrate.isNotEmpty()) {
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
for (note in checklistsToMigrate) {
val updatedNote = note.copy(
syncStatus = SyncStatus.PENDING
)
storage.saveNote(updatedNote)
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
}
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
}
// Mark migration as done
prefs.edit().putBoolean(migrationKey, true).apply()
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) {
// Settings changed, reload notes
viewModel.loadNotes()
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
REQUEST_NOTIFICATION_PERMISSION -> {
if (grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Toast.makeText(this, getString(R.string.toast_notifications_enabled), Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this,
getString(R.string.toast_notifications_disabled),
Toast.LENGTH_SHORT
).show()
}
}
}
}
}
/**
* Delete confirmation dialog
*/
@Composable
private fun DeleteConfirmationDialog(
noteTitle: String,
onDismiss: () -> Unit,
onDeleteLocal: () -> Unit,
onDeleteFromServer: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.legacy_delete_dialog_title)) },
text = {
Text(stringResource(R.string.legacy_delete_dialog_message, noteTitle))
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
},
confirmButton = {
TextButton(onClick = onDeleteLocal) {
Text(stringResource(R.string.delete_local_only))
}
TextButton(onClick = onDeleteFromServer) {
Text(stringResource(R.string.legacy_delete_from_server))
}
}
)
}

View File

@@ -0,0 +1,322 @@
package dev.dettmer.simplenotes.ui.main
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
// FabPosition nicht mehr benötigt - FAB wird manuell platziert
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.sync.SyncStateManager
import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog
import dev.dettmer.simplenotes.ui.main.components.EmptyState
import dev.dettmer.simplenotes.ui.main.components.NoteTypeFAB
import dev.dettmer.simplenotes.ui.main.components.NotesList
import dev.dettmer.simplenotes.ui.main.components.SyncStatusBanner
import kotlinx.coroutines.launch
/**
* Main screen displaying the notes list
* v1.5.0: Jetpack Compose MainActivity Redesign
*
* Performance optimized with proper state handling:
* - LazyListState for scroll control
* - Scaffold FAB slot for proper z-ordering
* - Scroll-to-top on new note
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
viewModel: MainViewModel,
onOpenNote: (String?) -> Unit,
onOpenSettings: () -> Unit,
onCreateNote: (NoteType) -> Unit
) {
val notes by viewModel.notes.collectAsState()
val syncState by viewModel.syncState.collectAsState()
val syncMessage by viewModel.syncMessage.collectAsState()
val scrollToTop by viewModel.scrollToTop.collectAsState()
// Multi-Select State
val selectedNotes by viewModel.selectedNotes.collectAsState()
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
// Delete confirmation dialog state
var showBatchDeleteDialog by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
// Compute isSyncing once
val isSyncing = syncState == SyncStateManager.SyncState.SYNCING
// Handle snackbar events from ViewModel
LaunchedEffect(Unit) {
viewModel.showSnackbar.collect { data ->
scope.launch {
val result = snackbarHostState.showSnackbar(
message = data.message,
actionLabel = data.actionLabel,
duration = SnackbarDuration.Long
)
if (result == SnackbarResult.ActionPerformed) {
data.onAction()
}
}
}
}
// Phase 3: Scroll to top when new note created
LaunchedEffect(scrollToTop) {
if (scrollToTop) {
listState.animateScrollToItem(0)
viewModel.resetScrollToTop()
}
}
// v1.5.0 Hotfix: FAB manuell mit zIndex platzieren für garantierte Sichtbarkeit
Scaffold(
topBar = {
// Animated switch between normal and selection TopBar
AnimatedVisibility(
visible = isSelectionMode,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut()
) {
SelectionTopBar(
selectedCount = selectedNotes.size,
totalCount = notes.size,
onCloseSelection = { viewModel.clearSelection() },
onSelectAll = { viewModel.selectAllNotes() },
onDeleteSelected = { showBatchDeleteDialog = true }
)
}
AnimatedVisibility(
visible = !isSelectionMode,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut()
) {
MainTopBar(
syncEnabled = !isSyncing,
onSyncClick = { viewModel.triggerManualSync("toolbar") },
onSettingsClick = onOpenSettings
)
}
},
// FAB wird manuell in Box platziert für korrekten z-Index
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.surface
) { paddingValues ->
// PullToRefreshBox wraps the content with pull-to-refresh capability
PullToRefreshBox(
isRefreshing = isSyncing,
onRefresh = { viewModel.triggerManualSync("pullToRefresh") },
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
Box(modifier = Modifier.fillMaxSize()) {
// Main content column
Column(modifier = Modifier.fillMaxSize()) {
// Sync Status Banner (not affected by pull-to-refresh)
SyncStatusBanner(
syncState = syncState,
message = syncMessage
)
// Content: Empty state or notes list
if (notes.isEmpty()) {
EmptyState(modifier = Modifier.weight(1f))
} else {
NotesList(
notes = notes,
showSyncStatus = viewModel.isServerConfigured(),
selectedNotes = selectedNotes,
isSelectionMode = isSelectionMode,
listState = listState,
modifier = Modifier.weight(1f),
onNoteClick = { note -> onOpenNote(note.id) },
onNoteLongPress = { note ->
// Long-press starts selection mode
viewModel.startSelectionMode(note.id)
},
onNoteSelectionToggle = { note ->
viewModel.toggleNoteSelection(note.id)
}
)
}
}
// FAB als TOP-LAYER - nur anzeigen wenn nicht im Selection Mode
AnimatedVisibility(
visible = !isSelectionMode,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
.zIndex(Float.MAX_VALUE)
) {
NoteTypeFAB(
onCreateNote = onCreateNote
)
}
}
}
// Batch Delete Confirmation Dialog
if (showBatchDeleteDialog) {
DeleteConfirmationDialog(
noteCount = selectedNotes.size,
onDismiss = { showBatchDeleteDialog = false },
onDeleteLocal = {
viewModel.deleteSelectedNotes(deleteFromServer = false)
showBatchDeleteDialog = false
},
onDeleteEverywhere = {
viewModel.deleteSelectedNotes(deleteFromServer = true)
showBatchDeleteDialog = false
}
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MainTopBar(
syncEnabled: Boolean,
onSyncClick: () -> Unit,
onSettingsClick: () -> Unit
) {
TopAppBar(
title = {
Text(
text = stringResource(R.string.main_title),
style = MaterialTheme.typography.titleLarge
)
},
actions = {
IconButton(
onClick = onSyncClick,
enabled = syncEnabled
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = stringResource(R.string.action_sync)
)
}
IconButton(onClick = onSettingsClick) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = stringResource(R.string.action_settings)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface
)
)
}
/**
* Selection mode TopBar - shows selected count and actions
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SelectionTopBar(
selectedCount: Int,
totalCount: Int,
onCloseSelection: () -> Unit,
onSelectAll: () -> Unit,
onDeleteSelected: () -> Unit
) {
TopAppBar(
navigationIcon = {
IconButton(onClick = onCloseSelection) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.action_close_selection)
)
}
},
title = {
Text(
text = stringResource(R.string.selection_count, selectedCount),
style = MaterialTheme.typography.titleLarge
)
},
actions = {
// Select All button (only if not all selected)
if (selectedCount < totalCount) {
IconButton(onClick = onSelectAll) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = stringResource(R.string.action_select_all)
)
}
}
// Delete button
IconButton(
onClick = onDeleteSelected,
enabled = selectedCount > 0
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.action_delete_selected),
tint = if (selectedCount > 0) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
}
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
}

View File

@@ -0,0 +1,613 @@
package dev.dettmer.simplenotes.ui.main
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncStateManager
import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* ViewModel for MainActivity Compose
* v1.5.0: Jetpack Compose MainActivity Redesign
*
* Manages notes list, sync state, and deletion with undo.
*/
class MainViewModel(application: Application) : AndroidViewModel(application) {
companion object {
private const val TAG = "MainViewModel"
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
}
private val storage = NotesStorage(application)
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
// ═══════════════════════════════════════════════════════════════════════
// Notes State
// ═══════════════════════════════════════════════════════════════════════
private val _notes = MutableStateFlow<List<Note>>(emptyList())
val notes: StateFlow<List<Note>> = _notes.asStateFlow()
private val _pendingDeletions = MutableStateFlow<Set<String>>(emptySet())
val pendingDeletions: StateFlow<Set<String>> = _pendingDeletions.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// Multi-Select State (v1.5.0)
// ═══════════════════════════════════════════════════════════════════════
private val _selectedNotes = MutableStateFlow<Set<String>>(emptySet())
val selectedNotes: StateFlow<Set<String>> = _selectedNotes.asStateFlow()
val isSelectionMode: StateFlow<Boolean> = _selectedNotes
.map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
// ═══════════════════════════════════════════════════════════════════════
// Sync State (derived from SyncStateManager)
// ═══════════════════════════════════════════════════════════════════════
private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE)
val syncState: StateFlow<SyncStateManager.SyncState> = _syncState.asStateFlow()
private val _syncMessage = MutableStateFlow<String?>(null)
val syncMessage: StateFlow<String?> = _syncMessage.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// UI Events
// ═══════════════════════════════════════════════════════════════════════
private val _showToast = MutableSharedFlow<String>()
val showToast: SharedFlow<String> = _showToast.asSharedFlow()
private val _showDeleteDialog = MutableSharedFlow<DeleteDialogData>()
val showDeleteDialog: SharedFlow<DeleteDialogData> = _showDeleteDialog.asSharedFlow()
private val _showSnackbar = MutableSharedFlow<SnackbarData>()
val showSnackbar: SharedFlow<SnackbarData> = _showSnackbar.asSharedFlow()
// Phase 3: Scroll-to-top when new note is created
private val _scrollToTop = MutableStateFlow(false)
val scrollToTop: StateFlow<Boolean> = _scrollToTop.asStateFlow()
// Track first note ID to detect new notes
private var previousFirstNoteId: String? = null
// ═══════════════════════════════════════════════════════════════════════
// Data Classes
// ═══════════════════════════════════════════════════════════════════════
data class DeleteDialogData(
val note: Note,
val originalList: List<Note>
)
data class SnackbarData(
val message: String,
val actionLabel: String,
val onAction: () -> Unit
)
// ═══════════════════════════════════════════════════════════════════════
// Initialization
// ═══════════════════════════════════════════════════════════════════════
init {
// v1.5.0 Performance: Load notes asynchronously to avoid blocking UI
viewModelScope.launch(Dispatchers.IO) {
loadNotesAsync()
}
}
// ═══════════════════════════════════════════════════════════════════════
// Notes Actions
// ═══════════════════════════════════════════════════════════════════════
/**
* Load notes asynchronously on IO dispatcher
* This prevents UI blocking during app startup
*/
private suspend fun loadNotesAsync() {
val allNotes = storage.loadAllNotes()
val pendingIds = _pendingDeletions.value
val filteredNotes = allNotes.filter { it.id !in pendingIds }
withContext(Dispatchers.Main) {
// Phase 3: Detect if a new note was added at the top
val newFirstNoteId = filteredNotes.firstOrNull()?.id
if (newFirstNoteId != null &&
previousFirstNoteId != null &&
newFirstNoteId != previousFirstNoteId) {
// New note at top → trigger scroll
_scrollToTop.value = true
Logger.d(TAG, "📜 New note detected at top, triggering scroll-to-top")
}
previousFirstNoteId = newFirstNoteId
_notes.value = filteredNotes
}
}
/**
* Public loadNotes - delegates to async version
*/
fun loadNotes() {
viewModelScope.launch(Dispatchers.IO) {
loadNotesAsync()
}
}
/**
* Reset scroll-to-top flag after scroll completed
*/
fun resetScrollToTop() {
_scrollToTop.value = false
}
/**
* Force scroll to top (e.g., after returning from editor)
*/
fun scrollToTop() {
_scrollToTop.value = true
}
// ═══════════════════════════════════════════════════════════════════════
// Multi-Select Actions (v1.5.0)
// ═══════════════════════════════════════════════════════════════════════
/**
* Toggle selection of a note
*/
fun toggleNoteSelection(noteId: String) {
_selectedNotes.value = if (noteId in _selectedNotes.value) {
_selectedNotes.value - noteId
} else {
_selectedNotes.value + noteId
}
}
/**
* Start selection mode with initial note
*/
fun startSelectionMode(noteId: String) {
_selectedNotes.value = setOf(noteId)
}
/**
* Select all notes
*/
fun selectAllNotes() {
_selectedNotes.value = _notes.value.map { it.id }.toSet()
}
/**
* Clear selection and exit selection mode
*/
fun clearSelection() {
_selectedNotes.value = emptySet()
}
/**
* Get count of selected notes
*/
fun getSelectedCount(): Int = _selectedNotes.value.size
/**
* Delete all selected notes
*/
fun deleteSelectedNotes(deleteFromServer: Boolean) {
val selectedIds = _selectedNotes.value.toList()
val selectedNotes = _notes.value.filter { it.id in selectedIds }
if (selectedNotes.isEmpty()) return
// Add to pending deletions
_pendingDeletions.value = _pendingDeletions.value + selectedIds.toSet()
// Delete from storage
selectedNotes.forEach { note ->
storage.deleteNote(note.id)
}
// Clear selection
clearSelection()
// Reload notes
loadNotes()
// Show snackbar with undo
val count = selectedNotes.size
val message = if (deleteFromServer) {
getString(R.string.snackbar_notes_deleted_server, count)
} else {
getString(R.string.snackbar_notes_deleted_local, count)
}
viewModelScope.launch {
_showSnackbar.emit(SnackbarData(
message = message,
actionLabel = getString(R.string.snackbar_undo),
onAction = {
undoDeleteMultiple(selectedNotes)
}
))
// If delete from server, actually delete after a short delay
// (to allow undo action before server deletion)
if (deleteFromServer) {
kotlinx.coroutines.delay(3500) // Snackbar shows for ~3s
// Only delete if not restored (check if still in pending)
val idsToDelete = selectedIds.filter { it in _pendingDeletions.value }
if (idsToDelete.isNotEmpty()) {
deleteMultipleNotesFromServer(idsToDelete)
}
} else {
// Just finalize local deletion
selectedIds.forEach { noteId ->
finalizeDeletion(noteId)
}
}
}
}
/**
* Undo deletion of multiple notes
*/
private fun undoDeleteMultiple(notes: List<Note>) {
// Remove from pending deletions
_pendingDeletions.value = _pendingDeletions.value - notes.map { it.id }.toSet()
// Restore to storage
notes.forEach { note ->
storage.saveNote(note)
}
// Reload notes
loadNotes()
}
/**
* Called when user long-presses a note to delete
* Shows dialog for delete confirmation (replaces swipe-to-delete for performance)
*/
fun onNoteLongPressDelete(note: Note) {
val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
// Store original list for potential restore
val originalList = _notes.value.toList()
if (alwaysDeleteFromServer) {
// Auto-delete without dialog
deleteNoteConfirmed(note, deleteFromServer = true)
} else {
// Show dialog - don't remove from UI yet (user can cancel)
viewModelScope.launch {
_showDeleteDialog.emit(DeleteDialogData(note, originalList))
}
}
}
/**
* Called when user swipes to delete a note (legacy - kept for compatibility)
* Shows dialog if "always delete from server" is not enabled
*/
fun onNoteSwipedToDelete(note: Note) {
onNoteLongPressDelete(note) // Delegate to long-press handler
}
/**
* Restore note after swipe (user cancelled dialog)
*/
fun restoreNoteAfterSwipe(originalList: List<Note>) {
_notes.value = originalList
}
/**
* Confirm note deletion (from dialog or auto-delete)
*/
fun deleteNoteConfirmed(note: Note, deleteFromServer: Boolean) {
// Add to pending deletions
_pendingDeletions.value = _pendingDeletions.value + note.id
// Delete from storage
storage.deleteNote(note.id)
// Reload notes
loadNotes()
// Show snackbar with undo
val message = if (deleteFromServer) {
getString(R.string.snackbar_note_deleted_server, note.title)
} else {
getString(R.string.snackbar_note_deleted_local, note.title)
}
viewModelScope.launch {
_showSnackbar.emit(SnackbarData(
message = message,
actionLabel = getString(R.string.snackbar_undo),
onAction = {
undoDelete(note)
}
))
// If delete from server, actually delete after snackbar timeout
if (deleteFromServer) {
kotlinx.coroutines.delay(3500) // Snackbar shows for ~3s
// Only delete if not restored (check if still in pending)
if (note.id in _pendingDeletions.value) {
deleteNoteFromServer(note.id)
}
} else {
// Just finalize local deletion
finalizeDeletion(note.id)
}
}
}
/**
* Undo note deletion
*/
fun undoDelete(note: Note) {
// Remove from pending deletions
_pendingDeletions.value = _pendingDeletions.value - note.id
// Restore to storage
storage.saveNote(note)
// Reload notes
loadNotes()
}
/**
* Actually delete note from server after snackbar dismissed
*/
fun deleteNoteFromServer(noteId: String) {
viewModelScope.launch {
try {
val webdavService = WebDavSyncService(getApplication())
val success = withContext(Dispatchers.IO) {
webdavService.deleteNoteFromServer(noteId)
}
if (success) {
_showToast.emit(getString(R.string.snackbar_deleted_from_server))
} else {
_showToast.emit(getString(R.string.snackbar_server_delete_failed))
}
} catch (e: Exception) {
_showToast.emit(getString(R.string.snackbar_server_error, e.message ?: ""))
} finally {
// Remove from pending deletions
_pendingDeletions.value = _pendingDeletions.value - noteId
}
}
}
/**
* Delete multiple notes from server with aggregated toast
* Shows single toast at the end instead of one per note
*/
private fun deleteMultipleNotesFromServer(noteIds: List<String>) {
viewModelScope.launch {
val webdavService = WebDavSyncService(getApplication())
var successCount = 0
var failCount = 0
noteIds.forEach { noteId ->
try {
val success = withContext(Dispatchers.IO) {
webdavService.deleteNoteFromServer(noteId)
}
if (success) successCount++ else failCount++
} catch (e: Exception) {
failCount++
} finally {
_pendingDeletions.value = _pendingDeletions.value - noteId
}
}
// Show aggregated toast
val message = when {
failCount == 0 -> getString(R.string.snackbar_notes_deleted_from_server, successCount)
successCount == 0 -> getString(R.string.snackbar_server_delete_failed)
else -> getString(
R.string.snackbar_notes_deleted_from_server_partial,
successCount,
successCount + failCount
)
}
_showToast.emit(message)
}
}
/**
* Finalize deletion (remove from pending set)
*/
fun finalizeDeletion(noteId: String) {
_pendingDeletions.value = _pendingDeletions.value - noteId
}
// ═══════════════════════════════════════════════════════════════════════
// Sync Actions
// ═══════════════════════════════════════════════════════════════════════
fun updateSyncState(status: SyncStateManager.SyncStatus) {
_syncState.value = status.state
_syncMessage.value = status.message
}
/**
* Trigger manual sync (from toolbar button or pull-to-refresh)
*/
fun triggerManualSync(source: String = "manual") {
if (!SyncStateManager.tryStartSync(source)) {
return
}
viewModelScope.launch {
try {
val syncService = WebDavSyncService(getApplication())
// Check for unsynced changes
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ $source Sync: No unsynced changes")
SyncStateManager.markCompleted("Bereits synchronisiert")
loadNotes()
return@launch
}
// Check server reachability
val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable()
}
if (!isReachable) {
Logger.d(TAG, "⏭️ $source Sync: Server not reachable")
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
return@launch
}
// Perform sync
val result = withContext(Dispatchers.IO) {
syncService.syncNotes()
}
if (result.isSuccess) {
val bannerMessage = if (result.syncedCount > 0) {
getString(R.string.toast_sync_success, result.syncedCount)
} else {
getString(R.string.snackbar_nothing_to_sync)
}
SyncStateManager.markCompleted(bannerMessage)
loadNotes()
} else {
SyncStateManager.markError(result.errorMessage)
}
} catch (e: Exception) {
SyncStateManager.markError(e.message)
}
}
}
/**
* Trigger auto-sync (onResume)
* Only runs if server is configured and interval has passed
* v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt
*/
fun triggerAutoSync(source: String = "auto") {
// Throttling check
if (!canTriggerAutoSync()) {
return
}
// Check if server is configured
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
return
}
// v1.5.0: silent=true - kein Banner bei Auto-Sync, aber Fehler werden trotzdem angezeigt
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
return
}
Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
// Update last sync timestamp
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
viewModelScope.launch {
try {
val syncService = WebDavSyncService(getApplication())
// Check for unsynced changes
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
SyncStateManager.reset()
return@launch
}
// Check server reachability
val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable()
}
if (!isReachable) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
SyncStateManager.reset()
return@launch
}
// Perform sync
val result = withContext(Dispatchers.IO) {
syncService.syncNotes()
}
if (result.isSuccess && result.syncedCount > 0) {
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
SyncStateManager.markCompleted(getString(R.string.toast_sync_success, result.syncedCount))
_showToast.emit(getString(R.string.snackbar_synced_count, result.syncedCount))
loadNotes()
} else if (result.isSuccess) {
Logger.d(TAG, " Auto-sync ($source): No changes")
SyncStateManager.markCompleted(getString(R.string.snackbar_nothing_to_sync))
} else {
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
SyncStateManager.markError(result.errorMessage)
}
} catch (e: Exception) {
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
SyncStateManager.markError(e.message)
}
}
}
private fun canTriggerAutoSync(): Boolean {
val lastSyncTime = prefs.getLong(PREF_LAST_AUTO_SYNC_TIME, 0)
val now = System.currentTimeMillis()
val timeSinceLastSync = now - lastSyncTime
if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) {
val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
Logger.d(TAG, "⏳ Auto-sync throttled - wait ${remainingSeconds}s")
return false
}
return true
}
// ═══════════════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════════════
private fun getString(resId: Int): String = getApplication<android.app.Application>().getString(resId)
private fun getString(resId: Int, vararg formatArgs: Any): String =
getApplication<android.app.Application>().getString(resId, *formatArgs)
fun isServerConfigured(): Boolean {
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
}
}

View File

@@ -0,0 +1,93 @@
package dev.dettmer.simplenotes.ui.main.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
/**
* Delete confirmation dialog with server/local options
* v1.5.0: Multi-Select Feature
*/
@Composable
fun DeleteConfirmationDialog(
noteCount: Int = 1,
onDismiss: () -> Unit,
onDeleteLocal: () -> Unit,
onDeleteEverywhere: () -> Unit
) {
val title = if (noteCount == 1) {
stringResource(R.string.delete_note_title)
} else {
stringResource(R.string.delete_notes_title, noteCount)
}
val message = if (noteCount == 1) {
stringResource(R.string.delete_note_message)
} else {
stringResource(R.string.delete_notes_message, noteCount)
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium
)
},
confirmButton = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Delete everywhere (server + local) - primary action
TextButton(
onClick = onDeleteEverywhere,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text(stringResource(R.string.delete_everywhere))
}
// Delete local only
TextButton(
onClick = onDeleteLocal,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.delete_local_only))
}
Spacer(modifier = Modifier.height(8.dp))
// Cancel button
TextButton(
onClick = onDismiss,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.cancel))
}
}
},
dismissButton = null // All buttons in confirmButton column
)
}

View File

@@ -0,0 +1,98 @@
package dev.dettmer.simplenotes.ui.main.components
import android.graphics.Bitmap
import android.graphics.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import dev.dettmer.simplenotes.R
/**
* Empty state card shown when no notes exist
* v1.5.0: Jetpack Compose MainActivity Redesign
*/
@Composable
fun EmptyState(
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Card(
modifier = Modifier.padding(horizontal = 32.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// App icon foreground (transparent background)
val context = LocalContext.current
val appIcon = remember {
val drawable = ContextCompat.getDrawable(context, R.mipmap.ic_launcher_foreground)
drawable?.let {
val size = 256
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
it.setBounds(0, 0, size, size)
it.draw(canvas)
bitmap.asImageBitmap()
}
}
appIcon?.let {
Image(
bitmap = it,
contentDescription = null,
modifier = Modifier.size(96.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
// Title
Text(
text = stringResource(R.string.empty_state_title),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
// Message
Text(
text = stringResource(R.string.empty_state_message),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}
}

View File

@@ -0,0 +1,240 @@
package dev.dettmer.simplenotes.ui.main.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.List
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.CloudDone
import androidx.compose.material.icons.outlined.CloudOff
import androidx.compose.material.icons.outlined.CloudSync
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.toReadableTime
/**
* Note card - v1.5.0 with Multi-Select Support
*
* ULTRA SIMPLE + SELECTION:
* - NO remember() anywhere
* - Direct MaterialTheme access
* - Selection indicator via border + checkbox overlay
* - Long-press starts selection mode
* - Tap in selection mode toggles selection
*/
@Composable
fun NoteCard(
note: Note,
showSyncStatus: Boolean,
isSelected: Boolean = false,
isSelectionMode: Boolean = false,
modifier: Modifier = Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
val context = LocalContext.current
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.then(
if (isSelected) {
Modifier.border(
width = 2.dp,
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(16.dp)
)
} else Modifier
)
.pointerInput(note.id, isSelectionMode) {
detectTapGestures(
onTap = { onClick() },
onLongPress = { onLongClick() }
)
},
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
)
) {
Box {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// Header row
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Type icon
Box(
modifier = Modifier
.size(32.dp)
.background(
MaterialTheme.colorScheme.primaryContainer,
CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (note.noteType == NoteType.TEXT)
Icons.Outlined.Description
else
Icons.AutoMirrored.Outlined.List,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(16.dp)
)
}
Spacer(modifier = Modifier.width(12.dp))
// Title
Text(
text = note.title.ifEmpty { stringResource(R.string.untitled) },
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(8.dp))
// Preview
Text(
text = when (note.noteType) {
NoteType.TEXT -> note.content.take(100)
NoteType.CHECKLIST -> {
val items = note.checklistItems ?: emptyList()
stringResource(R.string.checklist_progress, items.count { it.isChecked }, items.size)
}
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
// Footer
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = note.updatedAt.toReadableTime(context),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.weight(1f)
)
if (showSyncStatus) {
Icon(
imageVector = when (note.syncStatus) {
SyncStatus.SYNCED -> Icons.Outlined.CloudDone
SyncStatus.PENDING -> Icons.Outlined.CloudSync
SyncStatus.CONFLICT -> Icons.Default.Warning
SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff
},
contentDescription = null,
tint = when (note.syncStatus) {
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.outline
},
modifier = Modifier.size(16.dp)
)
}
}
}
// Selection indicator checkbox (top-right)
androidx.compose.animation.AnimatedVisibility(
visible = isSelectionMode,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
) {
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.background(
if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
}
)
.border(
width = 2.dp,
color = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline
},
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(R.string.selection_count, 1),
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
}

View File

@@ -0,0 +1,85 @@
package dev.dettmer.simplenotes.ui.main.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.List
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.NoteType
/**
* FAB with dropdown menu for note type selection
* v1.5.0: PERFORMANCE FIX - No Box wrapper for proper elevation
*
* Uses consistent icons with NoteCard:
* - TEXT: Description (document icon)
* - CHECKLIST: List (bullet list icon)
*/
@Composable
fun NoteTypeFAB(
modifier: Modifier = Modifier,
onCreateNote: (NoteType) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
// FAB directly without Box wrapper - elevation works correctly
FloatingActionButton(
onClick = { expanded = true },
modifier = modifier,
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.fab_new_note)
)
// Dropdown inside FAB - renders as popup overlay
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.fab_text_note)) },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.Description,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
},
onClick = {
expanded = false
onCreateNote(NoteType.TEXT)
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.fab_checklist)) },
leadingIcon = {
Icon(
imageVector = Icons.AutoMirrored.Outlined.List,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
},
onClick = {
expanded = false
onCreateNote(NoteType.CHECKLIST)
}
)
}
}
}

View File

@@ -0,0 +1,65 @@
package dev.dettmer.simplenotes.ui.main.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.models.Note
/**
* Notes list - v1.5.0 with Multi-Select Support
*
* ULTRA SIMPLE + SELECTION:
* - NO remember() anywhere
* - NO caching tricks
* - Selection state passed through as parameters
* - Tap behavior changes based on selection mode
*/
@Composable
fun NotesList(
notes: List<Note>,
showSyncStatus: Boolean,
selectedNotes: Set<String> = emptySet(),
isSelectionMode: Boolean = false,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
onNoteClick: (Note) -> Unit,
onNoteLongPress: (Note) -> Unit,
onNoteSelectionToggle: (Note) -> Unit = {}
) {
LazyColumn(
state = listState,
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp)
) {
items(
items = notes,
key = { it.id },
contentType = { "NoteCard" }
) { note ->
val isSelected = note.id in selectedNotes
NoteCard(
note = note,
showSyncStatus = showSyncStatus,
isSelected = isSelected,
isSelectionMode = isSelectionMode,
onClick = {
if (isSelectionMode) {
// In selection mode, tap toggles selection
onNoteSelectionToggle(note)
} else {
// Normal mode, open note
onNoteClick(note)
}
},
onLongClick = { onNoteLongPress(note) }
)
}
}
}

View File

@@ -0,0 +1,77 @@
package dev.dettmer.simplenotes.ui.main.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.sync.SyncStateManager
/**
* Sync status banner shown below the toolbar during sync
* v1.5.0: Jetpack Compose MainActivity Redesign
* v1.5.0: SYNCING_SILENT ignorieren - Banner nur bei manuellen Syncs oder Fehlern anzeigen
*/
@Composable
fun SyncStatusBanner(
syncState: SyncStateManager.SyncState,
message: String?,
modifier: Modifier = Modifier
) {
// v1.5.0: Banner nicht anzeigen bei IDLE oder SYNCING_SILENT (Auto-Sync im Hintergrund)
// Fehler werden trotzdem angezeigt (ERROR state nach Silent-Sync wechselt zu ERROR, nicht SYNCING_SILENT)
val isVisible = syncState != SyncStateManager.SyncState.IDLE
&& syncState != SyncStateManager.SyncState.SYNCING_SILENT
AnimatedVisibility(
visible = isVisible,
enter = expandVertically(),
exit = shrinkVertically(),
modifier = modifier
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (syncState == SyncStateManager.SyncState.SYNCING) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = when (syncState) {
SyncStateManager.SyncState.SYNCING -> stringResource(R.string.sync_status_syncing)
SyncStateManager.SyncState.SYNCING_SILENT -> "" // v1.5.0: Wird nicht angezeigt (isVisible = false)
SyncStateManager.SyncState.COMPLETED -> message ?: stringResource(R.string.sync_status_completed)
SyncStateManager.SyncState.ERROR -> message ?: stringResource(R.string.sync_status_error)
SyncStateManager.SyncState.IDLE -> ""
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.weight(1f)
)
}
}
}

View File

@@ -0,0 +1,186 @@
package dev.dettmer.simplenotes.ui.settings
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.rememberNavController
import com.google.android.material.color.DynamicColors
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.SimpleNotesApplication
import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme
import dev.dettmer.simplenotes.utils.Logger
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
/**
* Settings Activity with Jetpack Compose UI
* v1.5.0: Complete Settings Redesign with grouped screens
*
* Replaces the old 1147-line SettingsActivity.kt with a modern
* Compose-based implementation featuring:
* - 6 logical settings groups as separate screens
* - Material 3 Design with Dynamic Colors (Material You)
* - Navigation with back button in each screen
* - Clean separation of concerns with SettingsViewModel
*/
class ComposeSettingsActivity : AppCompatActivity() {
companion object {
private const val TAG = "ComposeSettingsActivity"
}
private val viewModel: SettingsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Apply Dynamic Colors for Material You (Android 12+)
DynamicColors.applyToActivityIfAvailable(this)
// Enable edge-to-edge display
enableEdgeToEdge()
// Handle back button with slide animation
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
setResult(RESULT_OK)
finish()
@Suppress("DEPRECATION")
overridePendingTransition(
dev.dettmer.simplenotes.R.anim.slide_in_left,
dev.dettmer.simplenotes.R.anim.slide_out_right
)
}
})
// Collect events from ViewModel (for Activity-level actions)
collectViewModelEvents()
setContent {
SimpleNotesTheme {
val navController = rememberNavController()
val context = LocalContext.current
// Toast handling from ViewModel
LaunchedEffect(Unit) {
viewModel.showToast.collect { message ->
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
SettingsNavHost(
navController = navController,
viewModel = viewModel,
onFinish = {
setResult(RESULT_OK)
finish()
@Suppress("DEPRECATION")
overridePendingTransition(
dev.dettmer.simplenotes.R.anim.slide_in_left,
dev.dettmer.simplenotes.R.anim.slide_out_right
)
}
)
}
}
}
/**
* Collect events from ViewModel for Activity-level actions
* v1.5.0: Ported from old SettingsActivity
*/
private fun collectViewModelEvents() {
lifecycleScope.launch {
viewModel.events.collect { event ->
when (event) {
is SettingsViewModel.SettingsEvent.RequestBatteryOptimization -> {
checkBatteryOptimization()
}
is SettingsViewModel.SettingsEvent.RestartNetworkMonitor -> {
restartNetworkMonitor()
}
}
}
}
}
/**
* Check if battery optimization is disabled for this app
* v1.5.0: Ported from old SettingsActivity
*/
private fun checkBatteryOptimization() {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
showBatteryOptimizationDialog()
}
}
/**
* Show dialog asking user to disable battery optimization
* v1.5.0: Ported from old SettingsActivity
*/
private fun showBatteryOptimizationDialog() {
AlertDialog.Builder(this)
.setTitle(getString(R.string.battery_optimization_dialog_title))
.setMessage(getString(R.string.battery_optimization_dialog_full_message))
.setPositiveButton(getString(R.string.battery_optimization_open_settings)) { _, _ ->
openBatteryOptimizationSettings()
}
.setNegativeButton(getString(R.string.battery_optimization_later)) { dialog, _ ->
dialog.dismiss()
}
.setCancelable(false)
.show()
}
/**
* Open system battery optimization settings
* v1.5.0: Ported from old SettingsActivity
*/
private fun openBatteryOptimizationSettings() {
try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
intent.data = Uri.parse("package:$packageName")
startActivity(intent)
} catch (e: Exception) {
Logger.w(TAG, "Failed to open battery optimization settings: ${e.message}")
// Fallback: Open general battery settings
try {
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
startActivity(intent)
} catch (e2: Exception) {
Logger.w(TAG, "Failed to open fallback battery settings: ${e2.message}")
Toast.makeText(this, "Bitte Akku-Optimierung manuell deaktivieren", Toast.LENGTH_LONG).show()
}
}
}
/**
* Restart the network monitor after sync settings change
* v1.5.0: Ported from old SettingsActivity
*/
private fun restartNetworkMonitor() {
try {
val app = application as SimpleNotesApplication
Logger.d(TAG, "🔄 Restarting NetworkMonitor with new settings")
app.networkMonitor.stopMonitoring()
app.networkMonitor.startMonitoring()
Logger.d(TAG, "✅ NetworkMonitor restarted successfully")
} catch (e: Exception) {
Logger.e(TAG, "❌ Failed to restart NetworkMonitor: ${e.message}")
}
}
}

View File

@@ -0,0 +1,93 @@
package dev.dettmer.simplenotes.ui.settings
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import dev.dettmer.simplenotes.ui.settings.screens.AboutScreen
import dev.dettmer.simplenotes.ui.settings.screens.BackupSettingsScreen
import dev.dettmer.simplenotes.ui.settings.screens.DebugSettingsScreen
import dev.dettmer.simplenotes.ui.settings.screens.LanguageSettingsScreen
import dev.dettmer.simplenotes.ui.settings.screens.MarkdownSettingsScreen
import dev.dettmer.simplenotes.ui.settings.screens.ServerSettingsScreen
import dev.dettmer.simplenotes.ui.settings.screens.SettingsMainScreen
import dev.dettmer.simplenotes.ui.settings.screens.SyncSettingsScreen
/**
* Settings navigation host with all routes
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun SettingsNavHost(
navController: NavHostController,
viewModel: SettingsViewModel,
onFinish: () -> Unit
) {
NavHost(
navController = navController,
startDestination = SettingsRoute.Main.route
) {
// Main Settings Overview
composable(SettingsRoute.Main.route) {
SettingsMainScreen(
viewModel = viewModel,
onNavigate = { route -> navController.navigate(route.route) },
onBack = onFinish
)
}
// Language Settings
composable(SettingsRoute.Language.route) {
LanguageSettingsScreen(
onBack = { navController.popBackStack() }
)
}
// Server Settings
composable(SettingsRoute.Server.route) {
ServerSettingsScreen(
viewModel = viewModel,
onBack = { navController.popBackStack() }
)
}
// Sync Settings
composable(SettingsRoute.Sync.route) {
SyncSettingsScreen(
viewModel = viewModel,
onBack = { navController.popBackStack() }
)
}
// Markdown Settings
composable(SettingsRoute.Markdown.route) {
MarkdownSettingsScreen(
viewModel = viewModel,
onBack = { navController.popBackStack() }
)
}
// Backup Settings
composable(SettingsRoute.Backup.route) {
BackupSettingsScreen(
viewModel = viewModel,
onBack = { navController.popBackStack() }
)
}
// About Screen
composable(SettingsRoute.About.route) {
AboutScreen(
onBack = { navController.popBackStack() }
)
}
// Debug Settings
composable(SettingsRoute.Debug.route) {
DebugSettingsScreen(
viewModel = viewModel,
onBack = { navController.popBackStack() }
)
}
}
}

View File

@@ -0,0 +1,16 @@
package dev.dettmer.simplenotes.ui.settings
/**
* Navigation routes for Settings screens
* v1.5.0: Jetpack Compose Settings Redesign
*/
sealed class SettingsRoute(val route: String) {
data object Main : SettingsRoute("settings_main")
data object Language : SettingsRoute("settings_language")
data object Server : SettingsRoute("settings_server")
data object Sync : SettingsRoute("settings_sync")
data object Markdown : SettingsRoute("settings_markdown")
data object Backup : SettingsRoute("settings_backup")
data object About : SettingsRoute("settings_about")
data object Debug : SettingsRoute("settings_debug")
}

View File

@@ -0,0 +1,519 @@
package dev.dettmer.simplenotes.ui.settings
import android.app.Application
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.backup.BackupManager
import dev.dettmer.simplenotes.backup.RestoreMode
import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
/**
* ViewModel for Settings screens
* v1.5.0: Jetpack Compose Settings Redesign
*
* Manages all settings state and actions across the Settings navigation graph.
*/
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
companion object {
private const val TAG = "SettingsViewModel"
private const val CONNECTION_TIMEOUT_MS = 3000
}
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val backupManager = BackupManager(application)
// ═══════════════════════════════════════════════════════════════════════
// Server Settings State
// ═══════════════════════════════════════════════════════════════════════
// v1.5.0 Fix: Initialize URL with protocol prefix if empty
private val storedUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
private val initialUrl = if (storedUrl.isEmpty()) "http://" else storedUrl
private val _serverUrl = MutableStateFlow(initialUrl)
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
private val _username = MutableStateFlow(prefs.getString(Constants.KEY_USERNAME, "") ?: "")
val username: StateFlow<String> = _username.asStateFlow()
private val _password = MutableStateFlow(prefs.getString(Constants.KEY_PASSWORD, "") ?: "")
val password: StateFlow<String> = _password.asStateFlow()
// v1.5.0 Fix: isHttps based on stored URL (false = HTTP if empty)
private val _isHttps = MutableStateFlow(storedUrl.startsWith("https://"))
val isHttps: StateFlow<Boolean> = _isHttps.asStateFlow()
private val _serverStatus = MutableStateFlow<ServerStatus>(ServerStatus.Unknown)
val serverStatus: StateFlow<ServerStatus> = _serverStatus.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// Events (for Activity-level actions like dialogs, intents)
// ═══════════════════════════════════════════════════════════════════════
private val _events = MutableSharedFlow<SettingsEvent>()
val events: SharedFlow<SettingsEvent> = _events.asSharedFlow()
// ═══════════════════════════════════════════════════════════════════════
// Markdown Export Progress State
// ═══════════════════════════════════════════════════════════════════════
private val _markdownExportProgress = MutableStateFlow<MarkdownExportProgress?>(null)
val markdownExportProgress: StateFlow<MarkdownExportProgress?> = _markdownExportProgress.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// Sync Settings State
// ═══════════════════════════════════════════════════════════════════════
private val _autoSyncEnabled = MutableStateFlow(prefs.getBoolean(Constants.KEY_AUTO_SYNC, false))
val autoSyncEnabled: StateFlow<Boolean> = _autoSyncEnabled.asStateFlow()
private val _syncInterval = MutableStateFlow(
prefs.getLong(Constants.PREF_SYNC_INTERVAL_MINUTES, Constants.DEFAULT_SYNC_INTERVAL_MINUTES)
)
val syncInterval: StateFlow<Long> = _syncInterval.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// Markdown Settings State
// ═══════════════════════════════════════════════════════════════════════
private val _markdownAutoSync = MutableStateFlow(
prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false) &&
prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
)
val markdownAutoSync: StateFlow<Boolean> = _markdownAutoSync.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// Debug Settings State
// ═══════════════════════════════════════════════════════════════════════
private val _fileLoggingEnabled = MutableStateFlow(
prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)
)
val fileLoggingEnabled: StateFlow<Boolean> = _fileLoggingEnabled.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// UI State
// ═══════════════════════════════════════════════════════════════════════
private val _isSyncing = MutableStateFlow(false)
val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow()
private val _isBackupInProgress = MutableStateFlow(false)
val isBackupInProgress: StateFlow<Boolean> = _isBackupInProgress.asStateFlow()
private val _showToast = MutableSharedFlow<String>()
val showToast: SharedFlow<String> = _showToast.asSharedFlow()
// ═══════════════════════════════════════════════════════════════════════
// Server Settings Actions
// ═══════════════════════════════════════════════════════════════════════
fun updateServerUrl(url: String) {
_serverUrl.value = url
saveServerSettings()
}
fun updateProtocol(useHttps: Boolean) {
_isHttps.value = useHttps
val currentUrl = _serverUrl.value
// v1.5.0 Fix: Automatisch Prefix setzen, auch bei leerem Feld
val newUrl = if (useHttps) {
when {
currentUrl.isEmpty() || currentUrl == "http://" -> "https://"
currentUrl.startsWith("http://") -> currentUrl.replace("http://", "https://")
!currentUrl.startsWith("https://") -> "https://$currentUrl"
else -> currentUrl
}
} else {
when {
currentUrl.isEmpty() || currentUrl == "https://" -> "http://"
currentUrl.startsWith("https://") -> currentUrl.replace("https://", "http://")
!currentUrl.startsWith("http://") -> "http://$currentUrl"
else -> currentUrl
}
}
_serverUrl.value = newUrl
saveServerSettings()
}
fun updateUsername(value: String) {
_username.value = value
saveServerSettings()
}
fun updatePassword(value: String) {
_password.value = value
saveServerSettings()
}
private fun saveServerSettings() {
prefs.edit().apply {
putString(Constants.KEY_SERVER_URL, _serverUrl.value)
putString(Constants.KEY_USERNAME, _username.value)
putString(Constants.KEY_PASSWORD, _password.value)
apply()
}
}
fun testConnection() {
viewModelScope.launch {
_serverStatus.value = ServerStatus.Checking
try {
val syncService = WebDavSyncService(getApplication())
val result = syncService.testConnection()
_serverStatus.value = if (result.isSuccess) {
ServerStatus.Reachable
} else {
ServerStatus.Unreachable(result.errorMessage)
}
val message = if (result.isSuccess) {
getString(R.string.toast_connection_success)
} else {
getString(R.string.toast_connection_failed, result.errorMessage ?: "")
}
emitToast(message)
} catch (e: Exception) {
_serverStatus.value = ServerStatus.Unreachable(e.message)
emitToast(getString(R.string.toast_error, e.message ?: ""))
}
}
}
fun checkServerStatus() {
val serverUrl = _serverUrl.value
// v1.5.0 Fix: URL mit nur Prefix gilt als "nicht konfiguriert"
if (serverUrl.isEmpty() || serverUrl == "http://" || serverUrl == "https://") {
_serverStatus.value = ServerStatus.NotConfigured
return
}
viewModelScope.launch {
_serverStatus.value = ServerStatus.Checking
val isReachable = withContext(Dispatchers.IO) {
try {
val url = URL(serverUrl)
val connection = url.openConnection() as HttpURLConnection
connection.connectTimeout = CONNECTION_TIMEOUT_MS
connection.readTimeout = CONNECTION_TIMEOUT_MS
val code = connection.responseCode
connection.disconnect()
code in 200..299 || code == 401
} catch (e: Exception) {
Log.e(TAG, "Server check failed: ${e.message}")
false
}
}
_serverStatus.value = if (isReachable) ServerStatus.Reachable else ServerStatus.Unreachable(null)
}
}
fun syncNow() {
if (_isSyncing.value) return
viewModelScope.launch {
_isSyncing.value = true
try {
emitToast(getString(R.string.toast_syncing))
val syncService = WebDavSyncService(getApplication())
if (!syncService.hasUnsyncedChanges()) {
emitToast(getString(R.string.toast_already_synced))
return@launch
}
val result = syncService.syncNotes()
if (result.isSuccess) {
emitToast(getString(R.string.toast_sync_success, result.syncedCount))
} else {
emitToast(getString(R.string.toast_sync_failed, result.errorMessage ?: ""))
}
} catch (e: Exception) {
emitToast(getString(R.string.toast_error, e.message ?: ""))
} finally {
_isSyncing.value = false
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// Sync Settings Actions
// ═══════════════════════════════════════════════════════════════════════
fun setAutoSync(enabled: Boolean) {
_autoSyncEnabled.value = enabled
prefs.edit().putBoolean(Constants.KEY_AUTO_SYNC, enabled).apply()
viewModelScope.launch {
if (enabled) {
// v1.5.0 Fix: Trigger battery optimization check and network monitor restart
_events.emit(SettingsEvent.RequestBatteryOptimization)
_events.emit(SettingsEvent.RestartNetworkMonitor)
emitToast(getString(R.string.toast_auto_sync_enabled))
} else {
_events.emit(SettingsEvent.RestartNetworkMonitor)
emitToast(getString(R.string.toast_auto_sync_disabled))
}
}
}
fun setSyncInterval(minutes: Long) {
_syncInterval.value = minutes
prefs.edit().putLong(Constants.PREF_SYNC_INTERVAL_MINUTES, minutes).apply()
viewModelScope.launch {
val text = when (minutes) {
15L -> getString(R.string.toast_sync_interval_15min)
60L -> getString(R.string.toast_sync_interval_60min)
else -> getString(R.string.toast_sync_interval_30min)
}
emitToast(getString(R.string.toast_sync_interval, text))
}
}
// ═══════════════════════════════════════════════════════════════════════
// Markdown Settings Actions
// ═══════════════════════════════════════════════════════════════════════
fun setMarkdownAutoSync(enabled: Boolean) {
if (enabled) {
// v1.5.0 Fix: Perform initial export when enabling (like old SettingsActivity)
viewModelScope.launch {
try {
// Check server configuration first
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
emitToast(getString(R.string.toast_configure_server_first))
// Don't enable - revert state
return@launch
}
// Check if there are notes to export
val noteStorage = dev.dettmer.simplenotes.storage.NotesStorage(getApplication())
val noteCount = noteStorage.loadAllNotes().size
if (noteCount > 0) {
// Show progress and perform initial export
_markdownExportProgress.value = MarkdownExportProgress(0, noteCount)
val syncService = WebDavSyncService(getApplication())
val exportedCount = withContext(Dispatchers.IO) {
syncService.exportAllNotesToMarkdown(
serverUrl = serverUrl,
username = username,
password = password,
onProgress = { current, total ->
_markdownExportProgress.value = MarkdownExportProgress(current, total)
}
)
}
// Export successful - save settings
_markdownAutoSync.value = true
prefs.edit()
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, true)
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, true)
.apply()
_markdownExportProgress.value = MarkdownExportProgress(noteCount, noteCount, isComplete = true)
emitToast(getString(R.string.toast_markdown_exported, exportedCount))
// Clear progress after short delay
kotlinx.coroutines.delay(500)
_markdownExportProgress.value = null
} else {
// No notes - just enable the feature
_markdownAutoSync.value = true
prefs.edit()
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, true)
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, true)
.apply()
emitToast(getString(R.string.toast_markdown_enabled))
}
} catch (e: Exception) {
_markdownExportProgress.value = null
emitToast(getString(R.string.toast_export_failed, e.message ?: ""))
// Don't enable on error
}
}
} else {
// Disable - simple
_markdownAutoSync.value = false
prefs.edit()
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, false)
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
.apply()
viewModelScope.launch {
emitToast(getString(R.string.toast_markdown_disabled))
}
}
}
fun performManualMarkdownSync() {
viewModelScope.launch {
try {
emitToast(getString(R.string.toast_markdown_syncing))
val syncService = WebDavSyncService(getApplication())
val result = syncService.manualMarkdownSync()
emitToast(getString(R.string.toast_markdown_result, result.exportedCount, result.importedCount))
} catch (e: Exception) {
emitToast(getString(R.string.toast_error, e.message ?: ""))
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// Backup Actions
// ═══════════════════════════════════════════════════════════════════════
fun createBackup(uri: Uri) {
viewModelScope.launch {
_isBackupInProgress.value = true
try {
val result = backupManager.createBackup(uri)
val message = if (result.success) {
getString(R.string.toast_backup_success, result.message ?: "")
} else {
getString(R.string.toast_backup_failed, result.error ?: "")
}
emitToast(message)
} catch (e: Exception) {
emitToast(getString(R.string.toast_backup_failed, e.message ?: ""))
} finally {
_isBackupInProgress.value = false
}
}
}
fun restoreFromFile(uri: Uri, mode: RestoreMode) {
viewModelScope.launch {
_isBackupInProgress.value = true
try {
val result = backupManager.restoreBackup(uri, mode)
val message = if (result.success) {
getString(R.string.toast_restore_success, result.importedNotes)
} else {
getString(R.string.toast_restore_failed, result.error ?: "")
}
emitToast(message)
} catch (e: Exception) {
emitToast(getString(R.string.toast_restore_failed, e.message ?: ""))
} finally {
_isBackupInProgress.value = false
}
}
}
fun restoreFromServer(mode: RestoreMode) {
viewModelScope.launch {
_isBackupInProgress.value = true
try {
emitToast(getString(R.string.restore_progress))
val syncService = WebDavSyncService(getApplication())
val result = withContext(Dispatchers.IO) {
syncService.restoreFromServer(mode)
}
val message = if (result.isSuccess) {
getString(R.string.toast_restore_success, result.restoredCount)
} else {
getString(R.string.toast_restore_failed, result.errorMessage ?: "")
}
emitToast(message)
} catch (e: Exception) {
emitToast(getString(R.string.toast_error, e.message ?: ""))
} finally {
_isBackupInProgress.value = false
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// Debug Settings Actions
// ═══════════════════════════════════════════════════════════════════════
fun setFileLogging(enabled: Boolean) {
_fileLoggingEnabled.value = enabled
prefs.edit().putBoolean(Constants.KEY_FILE_LOGGING_ENABLED, enabled).apply()
Logger.setFileLoggingEnabled(enabled)
viewModelScope.launch {
emitToast(if (enabled) getString(R.string.toast_file_logging_enabled) else getString(R.string.toast_file_logging_disabled))
}
}
fun clearLogs() {
viewModelScope.launch {
try {
val cleared = Logger.clearLogFile(getApplication())
emitToast(if (cleared) getString(R.string.toast_logs_deleted) else getString(R.string.toast_logs_deleted))
} catch (e: Exception) {
emitToast(getString(R.string.toast_error, e.message ?: ""))
}
}
}
fun getLogFile() = Logger.getLogFile(getApplication())
// ═══════════════════════════════════════════════════════════════════════
// Helper
// ═══════════════════════════════════════════════════════════════════════
private fun getString(resId: Int): String = getApplication<android.app.Application>().getString(resId)
private fun getString(resId: Int, vararg formatArgs: Any): String =
getApplication<android.app.Application>().getString(resId, *formatArgs)
private suspend fun emitToast(message: String) {
_showToast.emit(message)
}
/**
* Server status states
*/
sealed class ServerStatus {
data object Unknown : ServerStatus()
data object NotConfigured : ServerStatus()
data object Checking : ServerStatus()
data object Reachable : ServerStatus()
data class Unreachable(val error: String?) : ServerStatus()
}
/**
* Events for Activity-level actions (dialogs, intents, etc.)
* v1.5.0: Ported from old SettingsActivity
*/
sealed class SettingsEvent {
data object RequestBatteryOptimization : SettingsEvent()
data object RestartNetworkMonitor : SettingsEvent()
}
/**
* Progress state for Markdown export
* v1.5.0: For initial export progress dialog
*/
data class MarkdownExportProgress(
val current: Int,
val total: Int,
val isComplete: Boolean = false
)
}

View File

@@ -0,0 +1,113 @@
package dev.dettmer.simplenotes.ui.settings.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
/**
* Clickable Settings group card with icon, title, subtitle and optional status
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun SettingsCard(
icon: ImageVector,
title: String,
modifier: Modifier = Modifier,
subtitle: String? = null,
statusText: String? = null,
statusColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
onClick: () -> Unit
) {
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Icon with circle background
Box(
modifier = Modifier
.size(40.dp)
.background(
color = MaterialTheme.colorScheme.primaryContainer,
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(20.dp)
)
}
Spacer(modifier = Modifier.width(16.dp))
// Content
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
if (subtitle != null) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (statusText != null) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = statusColor
)
}
}
// Arrow
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@@ -0,0 +1,151 @@
package dev.dettmer.simplenotes.ui.settings.components
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Primary filled button for settings actions
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun SettingsButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
isLoading: Boolean = false
) {
Button(
onClick = onClick,
enabled = enabled && !isLoading,
modifier = modifier.fillMaxWidth()
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.height(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text(text)
}
}
}
/**
* Outlined secondary button for settings actions
*/
@Composable
fun SettingsOutlinedButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
isLoading: Boolean = false
) {
OutlinedButton(
onClick = onClick,
enabled = enabled && !isLoading,
modifier = modifier.fillMaxWidth()
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.height(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
} else {
Text(text)
}
}
}
/**
* Danger/destructive button for settings actions
*/
@Composable
fun SettingsDangerButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
modifier = modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text(text)
}
}
/**
* Info card with description text
*/
@Composable
fun SettingsInfoCard(
text: String,
modifier: Modifier = Modifier
) {
androidx.compose.material3.Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
)
) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
lineHeight = MaterialTheme.typography.bodySmall.lineHeight * 1.3f
)
}
}
/**
* Section header text
*/
@Composable
fun SettingsSectionHeader(
text: String,
modifier: Modifier = Modifier
) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
/**
* Divider between settings groups
*/
@Composable
fun SettingsDivider(
modifier: Modifier = Modifier
) {
Spacer(modifier = modifier.height(8.dp))
androidx.compose.material3.HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
Spacer(modifier = Modifier.height(8.dp))
}

View File

@@ -0,0 +1,94 @@
package dev.dettmer.simplenotes.ui.settings.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
/**
* Data class for radio option
*/
data class RadioOption<T>(
val value: T,
val title: String,
val subtitle: String? = null
)
/**
* Settings radio group for selecting one option
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun <T> SettingsRadioGroup(
options: List<RadioOption<T>>,
selectedValue: T,
onValueSelected: (T) -> Unit,
modifier: Modifier = Modifier,
title: String? = null
) {
Column(
modifier = modifier
.fillMaxWidth()
.selectableGroup()
) {
if (title != null) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
options.forEach { option ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = option.value == selectedValue,
onClick = { onValueSelected(option.value) },
role = Role.RadioButton
)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = option.value == selectedValue,
onClick = null // handled by selectable
)
Column(
modifier = Modifier
.padding(start = 16.dp)
.weight(1f)
) {
Text(
text = option.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
if (option.subtitle != null) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = option.subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}

View File

@@ -0,0 +1,59 @@
package dev.dettmer.simplenotes.ui.settings.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import dev.dettmer.simplenotes.R
/**
* Reusable Scaffold with back-navigation TopAppBar
* v1.5.0: Jetpack Compose Settings Redesign
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScaffold(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable (PaddingValues) -> Unit
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {
Text(
text = title,
style = MaterialTheme.typography.titleLarge
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.content_description_back)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface
)
)
},
containerColor = MaterialTheme.colorScheme.surface
) { paddingValues ->
content(paddingValues)
}
}

View File

@@ -0,0 +1,83 @@
package dev.dettmer.simplenotes.ui.settings.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
/**
* Settings switch item with title, optional subtitle and icon
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun SettingsSwitch(
title: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
subtitle: String? = null,
icon: ImageVector? = null,
enabled: Boolean = true
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (enabled) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
},
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
}
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = if (enabled) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
}
)
}
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled
)
}
}

View File

@@ -0,0 +1,242 @@
package dev.dettmer.simplenotes.ui.settings.screens
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Policy
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
/**
* About app information screen
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun AboutScreen(
onBack: () -> Unit
) {
val context = LocalContext.current
val githubRepoUrl = "https://github.com/inventory69/simple-notes-sync"
val githubProfileUrl = "https://github.com/inventory69"
val licenseUrl = "https://github.com/inventory69/simple-notes-sync/blob/main/LICENSE"
SettingsScaffold(
title = stringResource(R.string.about_settings_title),
onBack = onBack
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(16.dp))
// App Info Card
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// v1.5.0: App icon foreground loaded directly for better quality
val context = LocalContext.current
val appIcon = remember {
val drawable = ContextCompat.getDrawable(context, R.mipmap.ic_launcher_foreground)
drawable?.let {
// Use fixed size for consistent quality (256x256)
val size = 256
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
it.setBounds(0, 0, size, size)
it.draw(canvas)
bitmap.asImageBitmap()
}
}
appIcon?.let {
Image(
bitmap = it,
contentDescription = stringResource(R.string.about_app_name),
modifier = Modifier.size(96.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.about_app_name),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.about_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
)
}
}
Spacer(modifier = Modifier.height(24.dp))
SettingsSectionHeader(text = stringResource(R.string.about_links_section))
// GitHub Repository
AboutLinkItem(
icon = Icons.Default.Code,
title = stringResource(R.string.about_github_title),
subtitle = stringResource(R.string.about_github_subtitle),
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(githubRepoUrl))
context.startActivity(intent)
}
)
// Developer
AboutLinkItem(
icon = Icons.Default.Person,
title = stringResource(R.string.about_developer_title),
subtitle = stringResource(R.string.about_developer_subtitle),
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(githubProfileUrl))
context.startActivity(intent)
}
)
// License
AboutLinkItem(
icon = Icons.Default.Policy,
title = stringResource(R.string.about_license_title),
subtitle = stringResource(R.string.about_license_subtitle),
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(licenseUrl))
context.startActivity(intent)
}
)
SettingsDivider()
// Data Privacy Info
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = stringResource(R.string.about_privacy_title),
style = MaterialTheme.typography.titleSmall
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.about_privacy_text),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
/**
* Clickable link item for About section
*/
@Composable
private fun AboutLinkItem(
icon: ImageVector,
title: String,
subtitle: String,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@@ -0,0 +1,257 @@
package dev.dettmer.simplenotes.ui.settings.screens
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.backup.RestoreMode
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
import dev.dettmer.simplenotes.ui.settings.components.RadioOption
import dev.dettmer.simplenotes.ui.settings.components.SettingsButton
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
import dev.dettmer.simplenotes.ui.settings.components.SettingsInfoCard
import dev.dettmer.simplenotes.ui.settings.components.SettingsOutlinedButton
import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Backup and restore settings screen
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun BackupSettingsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit
) {
val isBackupInProgress by viewModel.isBackupInProgress.collectAsState()
// Restore dialog state
var showRestoreDialog by remember { mutableStateOf(false) }
var restoreSource by remember { mutableStateOf<RestoreSource>(RestoreSource.LocalFile) }
var pendingRestoreUri by remember { mutableStateOf<Uri?>(null) }
var selectedRestoreMode by remember { mutableStateOf(RestoreMode.MERGE) }
// File picker launchers
val createBackupLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json")
) { uri ->
uri?.let { viewModel.createBackup(it) }
}
val restoreFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let {
pendingRestoreUri = it
restoreSource = RestoreSource.LocalFile
showRestoreDialog = true
}
}
SettingsScaffold(
title = stringResource(R.string.backup_settings_title),
onBack = onBack
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
// Info Card
SettingsInfoCard(
text = stringResource(R.string.backup_auto_info)
)
Spacer(modifier = Modifier.height(16.dp))
// Local Backup Section
SettingsSectionHeader(text = stringResource(R.string.backup_local_section))
Spacer(modifier = Modifier.height(8.dp))
SettingsButton(
text = stringResource(R.string.backup_create),
onClick = {
val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
.format(Date())
val filename = "simplenotes_backup_$timestamp.json"
createBackupLauncher.launch(filename)
},
isLoading = isBackupInProgress,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(8.dp))
SettingsOutlinedButton(
text = stringResource(R.string.backup_restore_file),
onClick = {
restoreFileLauncher.launch(arrayOf("application/json"))
},
isLoading = isBackupInProgress,
modifier = Modifier.padding(horizontal = 16.dp)
)
SettingsDivider()
// Server Backup Section
SettingsSectionHeader(text = stringResource(R.string.backup_server_section))
Spacer(modifier = Modifier.height(8.dp))
SettingsOutlinedButton(
text = stringResource(R.string.backup_restore_server),
onClick = {
restoreSource = RestoreSource.Server
showRestoreDialog = true
},
isLoading = isBackupInProgress,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
}
}
// Restore Mode Dialog
if (showRestoreDialog) {
RestoreModeDialog(
source = restoreSource,
selectedMode = selectedRestoreMode,
onModeSelected = { selectedRestoreMode = it },
onConfirm = {
showRestoreDialog = false
when (restoreSource) {
RestoreSource.LocalFile -> {
pendingRestoreUri?.let { uri ->
viewModel.restoreFromFile(uri, selectedRestoreMode)
}
}
RestoreSource.Server -> {
viewModel.restoreFromServer(selectedRestoreMode)
}
}
},
onDismiss = {
showRestoreDialog = false
pendingRestoreUri = null
}
)
}
}
/**
* Restore source enum
*/
private enum class RestoreSource {
LocalFile,
Server
}
/**
* Dialog for selecting restore mode
*/
@Composable
private fun RestoreModeDialog(
source: RestoreSource,
selectedMode: RestoreMode,
onModeSelected: (RestoreMode) -> Unit,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val sourceText = when (source) {
RestoreSource.LocalFile -> stringResource(R.string.backup_restore_source_file)
RestoreSource.Server -> stringResource(R.string.backup_restore_source_server)
}
val modeOptions = listOf(
RadioOption(
value = RestoreMode.MERGE,
title = stringResource(R.string.backup_mode_merge_title),
subtitle = stringResource(R.string.backup_mode_merge_subtitle)
),
RadioOption(
value = RestoreMode.REPLACE,
title = stringResource(R.string.backup_mode_replace_title),
subtitle = stringResource(R.string.backup_mode_replace_subtitle)
),
RadioOption(
value = RestoreMode.OVERWRITE_DUPLICATES,
title = stringResource(R.string.backup_mode_overwrite_title),
subtitle = stringResource(R.string.backup_mode_overwrite_subtitle)
)
)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.backup_restore_dialog_title)) },
text = {
Column {
Text(
text = stringResource(R.string.backup_restore_source, sourceText),
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.backup_restore_mode_label),
style = MaterialTheme.typography.labelLarge
)
Spacer(modifier = Modifier.height(8.dp))
SettingsRadioGroup(
options = modeOptions,
selectedValue = selectedMode,
onValueSelected = onModeSelected
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.backup_restore_info),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringResource(R.string.backup_restore_button))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}

View File

@@ -0,0 +1,147 @@
package dev.dettmer.simplenotes.ui.settings.screens
import android.content.Intent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
import dev.dettmer.simplenotes.ui.settings.components.SettingsButton
import dev.dettmer.simplenotes.ui.settings.components.SettingsDangerButton
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
import dev.dettmer.simplenotes.ui.settings.components.SettingsInfoCard
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
/**
* Debug and diagnostics settings screen
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun DebugSettingsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit
) {
val context = LocalContext.current
val fileLoggingEnabled by viewModel.fileLoggingEnabled.collectAsState()
var showClearLogsDialog by remember { mutableStateOf(false) }
SettingsScaffold(
title = stringResource(R.string.debug_settings_title),
onBack = onBack
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
// File Logging Toggle
SettingsSwitch(
title = stringResource(R.string.debug_file_logging_title),
subtitle = stringResource(R.string.debug_file_logging_subtitle),
checked = fileLoggingEnabled,
onCheckedChange = { viewModel.setFileLogging(it) },
icon = Icons.AutoMirrored.Filled.Notes
)
// Privacy Info
SettingsInfoCard(
text = stringResource(R.string.debug_privacy_info)
)
SettingsDivider()
SettingsSectionHeader(text = stringResource(R.string.debug_log_actions_section))
Spacer(modifier = Modifier.height(8.dp))
// Export Logs Button
SettingsButton(
text = stringResource(R.string.debug_export_logs),
onClick = {
val logFile = viewModel.getLogFile()
if (logFile != null && logFile.exists() && logFile.length() > 0L) {
val logUri = FileProvider.getUriForFile(
context,
"${BuildConfig.APPLICATION_ID}.fileprovider",
logFile
)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, logUri)
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.debug_logs_subject))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(shareIntent, context.getString(R.string.debug_logs_share_via)))
}
},
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(8.dp))
// Clear Logs Button
SettingsDangerButton(
text = stringResource(R.string.debug_delete_logs),
onClick = { showClearLogsDialog = true },
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
}
}
// Clear Logs Confirmation Dialog
if (showClearLogsDialog) {
AlertDialog(
onDismissRequest = { showClearLogsDialog = false },
title = { Text(stringResource(R.string.debug_delete_logs_title)) },
text = {
Text(stringResource(R.string.debug_delete_logs_message))
},
confirmButton = {
TextButton(
onClick = {
showClearLogsDialog = false
viewModel.clearLogs()
}
) {
Text(stringResource(R.string.delete))
}
},
dismissButton = {
TextButton(onClick = { showClearLogsDialog = false }) {
Text(stringResource(R.string.cancel))
}
}
)
}
}

View File

@@ -0,0 +1,120 @@
package dev.dettmer.simplenotes.ui.settings.screens
import android.app.Activity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.ui.settings.components.RadioOption
import dev.dettmer.simplenotes.ui.settings.components.SettingsInfoCard
import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
/**
* Language selection settings screen
* v1.5.0: Internationalization feature
*
* Uses Android's Per-App Language API (Android 13+) with AppCompat fallback
*/
@Composable
fun LanguageSettingsScreen(
onBack: () -> Unit
) {
val context = LocalContext.current
// Get current app locale - fresh value each time (no remember, always reads current state)
val currentLocale = AppCompatDelegate.getApplicationLocales()
val currentLanguageCode = if (currentLocale.isEmpty) {
"" // System default
} else {
currentLocale.get(0)?.language ?: ""
}
var selectedLanguage by remember(currentLanguageCode) { mutableStateOf(currentLanguageCode) }
// Language options
val languageOptions = listOf(
RadioOption(
value = "",
title = stringResource(R.string.language_system_default),
subtitle = null
),
RadioOption(
value = "en",
title = stringResource(R.string.language_english),
subtitle = "English"
),
RadioOption(
value = "de",
title = stringResource(R.string.language_german),
subtitle = "German"
)
)
SettingsScaffold(
title = stringResource(R.string.language_settings_title),
onBack = onBack
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
// Info card
SettingsInfoCard(
text = stringResource(R.string.language_info)
)
Spacer(modifier = Modifier.height(8.dp))
// Language selection radio group
SettingsRadioGroup(
options = languageOptions,
selectedValue = selectedLanguage,
onValueSelected = { newLanguage ->
if (newLanguage != selectedLanguage) {
selectedLanguage = newLanguage
setAppLanguage(newLanguage, context as Activity)
}
}
)
}
}
}
/**
* Set app language using AppCompatDelegate
* Works on Android 13+ natively, falls back to AppCompat on older versions
*/
private fun setAppLanguage(languageCode: String, activity: Activity) {
val localeList = if (languageCode.isEmpty()) {
LocaleListCompat.getEmptyLocaleList()
} else {
LocaleListCompat.forLanguageTags(languageCode)
}
AppCompatDelegate.setApplicationLocales(localeList)
// Restart the activity to apply the change
// On Android 13+ the system handles this automatically for some apps,
// but we need to recreate to ensure our Compose UI recomposes with new locale
activity.recreate()
}

View File

@@ -0,0 +1,127 @@
package dev.dettmer.simplenotes.ui.settings.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Description
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
import dev.dettmer.simplenotes.ui.settings.components.SettingsButton
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
import dev.dettmer.simplenotes.ui.settings.components.SettingsInfoCard
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
/**
* Markdown Desktop integration settings screen
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun MarkdownSettingsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit
) {
val markdownAutoSync by viewModel.markdownAutoSync.collectAsState()
val exportProgress by viewModel.markdownExportProgress.collectAsState()
// v1.5.0 Fix: Progress Dialog for initial export
exportProgress?.let { progress ->
AlertDialog(
onDismissRequest = { /* Not dismissable */ },
title = { Text(stringResource(R.string.markdown_dialog_title)) },
text = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = if (progress.isComplete) {
stringResource(R.string.markdown_export_complete)
} else {
stringResource(R.string.markdown_export_progress, progress.current, progress.total)
},
style = MaterialTheme.typography.bodyMedium
)
LinearProgressIndicator(
progress = {
if (progress.total > 0) {
progress.current.toFloat() / progress.total.toFloat()
} else 0f
},
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = { /* No button - auto dismiss */ }
)
}
SettingsScaffold(
title = stringResource(R.string.markdown_settings_title),
onBack = onBack
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
// Info Card
SettingsInfoCard(
text = stringResource(R.string.markdown_info)
)
Spacer(modifier = Modifier.height(8.dp))
// Markdown Auto-Sync Toggle
SettingsSwitch(
title = stringResource(R.string.markdown_auto_sync_title),
subtitle = stringResource(R.string.markdown_auto_sync_subtitle),
checked = markdownAutoSync,
onCheckedChange = { viewModel.setMarkdownAutoSync(it) },
icon = Icons.Default.Description
)
// Manual sync button (only visible when auto-sync is off)
if (!markdownAutoSync) {
SettingsDivider()
SettingsInfoCard(
text = stringResource(R.string.markdown_manual_sync_info)
)
Spacer(modifier = Modifier.height(8.dp))
SettingsButton(
text = stringResource(R.string.markdown_manual_sync_button),
onClick = { viewModel.performManualMarkdownSync() },
modifier = Modifier.padding(horizontal = 16.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}

View File

@@ -0,0 +1,247 @@
package dev.dettmer.simplenotes.ui.settings.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
/**
* Server configuration settings screen
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun ServerSettingsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit
) {
val serverUrl by viewModel.serverUrl.collectAsState()
val username by viewModel.username.collectAsState()
val password by viewModel.password.collectAsState()
val isHttps by viewModel.isHttps.collectAsState()
val serverStatus by viewModel.serverStatus.collectAsState()
val isSyncing by viewModel.isSyncing.collectAsState()
var passwordVisible by remember { mutableStateOf(false) }
// Check server status on load
LaunchedEffect(Unit) {
viewModel.checkServerStatus()
}
SettingsScaffold(
title = stringResource(R.string.server_settings_title),
onBack = onBack
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Verbindungstyp
Text(
text = stringResource(R.string.server_connection_type),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(bottom = 8.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterChip(
selected = !isHttps,
onClick = { viewModel.updateProtocol(false) },
label = { Text(stringResource(R.string.server_connection_http)) },
modifier = Modifier.weight(1f)
)
FilterChip(
selected = isHttps,
onClick = { viewModel.updateProtocol(true) },
label = { Text(stringResource(R.string.server_connection_https)) },
modifier = Modifier.weight(1f)
)
}
Text(
text = if (!isHttps) {
stringResource(R.string.server_connection_http_hint)
} else {
stringResource(R.string.server_connection_https_hint)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp)
)
// Server-Adresse
OutlinedTextField(
value = serverUrl,
onValueChange = { viewModel.updateServerUrl(it) },
label = { Text(stringResource(R.string.server_address)) },
supportingText = { Text(stringResource(R.string.server_address_hint)) },
leadingIcon = { Icon(Icons.Default.Language, null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
)
Spacer(modifier = Modifier.height(12.dp))
// Benutzername
OutlinedTextField(
value = username,
onValueChange = { viewModel.updateUsername(it) },
label = { Text(stringResource(R.string.username)) },
leadingIcon = { Icon(Icons.Default.Person, null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(12.dp))
// Passwort
OutlinedTextField(
value = password,
onValueChange = { viewModel.updatePassword(it) },
label = { Text(stringResource(R.string.password)) },
leadingIcon = { Icon(Icons.Default.Lock, null) },
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) {
Icons.Default.VisibilityOff
} else {
Icons.Default.Visibility
},
contentDescription = if (passwordVisible) {
stringResource(R.string.server_password_hide)
} else {
stringResource(R.string.server_password_show)
}
)
}
},
visualTransformation = if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
Spacer(modifier = Modifier.height(16.dp))
// Server-Status
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.server_status_label), style = MaterialTheme.typography.labelLarge)
Text(
text = when (serverStatus) {
is SettingsViewModel.ServerStatus.Reachable -> stringResource(R.string.server_status_reachable)
is SettingsViewModel.ServerStatus.Unreachable -> stringResource(R.string.server_status_unreachable)
is SettingsViewModel.ServerStatus.Checking -> stringResource(R.string.server_status_checking)
is SettingsViewModel.ServerStatus.NotConfigured -> stringResource(R.string.server_status_not_configured)
else -> stringResource(R.string.server_status_unknown)
},
color = when (serverStatus) {
is SettingsViewModel.ServerStatus.Reachable -> Color(0xFF4CAF50)
is SettingsViewModel.ServerStatus.Unreachable -> Color(0xFFF44336)
is SettingsViewModel.ServerStatus.NotConfigured -> Color(0xFFFF9800)
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = { viewModel.testConnection() },
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.test_connection))
}
Button(
onClick = { viewModel.syncNow() },
enabled = !isSyncing,
modifier = Modifier.weight(1f)
) {
if (isSyncing) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.sync_now))
}
}
}
}
}

View File

@@ -0,0 +1,179 @@
package dev.dettmer.simplenotes.ui.settings.screens
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Sync
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.ui.settings.SettingsRoute
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
import dev.dettmer.simplenotes.ui.settings.components.SettingsCard
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
/**
* Main Settings overview screen with clickable group cards
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun SettingsMainScreen(
viewModel: SettingsViewModel,
onNavigate: (SettingsRoute) -> Unit,
onBack: () -> Unit
) {
val serverUrl by viewModel.serverUrl.collectAsState()
val serverStatus by viewModel.serverStatus.collectAsState()
val autoSyncEnabled by viewModel.autoSyncEnabled.collectAsState()
val syncInterval by viewModel.syncInterval.collectAsState()
val markdownAutoSync by viewModel.markdownAutoSync.collectAsState()
val fileLoggingEnabled by viewModel.fileLoggingEnabled.collectAsState()
// Check server status on first load
LaunchedEffect(Unit) {
viewModel.checkServerStatus()
}
// Get current language for display (no remember - always fresh value after activity recreate)
val locales = AppCompatDelegate.getApplicationLocales()
val currentLanguageName = if (locales.isEmpty) {
null // System default
} else {
locales[0]?.displayLanguage?.replaceFirstChar { it.uppercase() }
}
val systemDefaultText = stringResource(R.string.language_system_default)
val languageSubtitle = currentLanguageName ?: systemDefaultText
SettingsScaffold(
title = stringResource(R.string.settings_title),
onBack = onBack
) { paddingValues ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(vertical = 8.dp)
) {
// Language Settings
item {
SettingsCard(
icon = Icons.Default.Language,
title = stringResource(R.string.settings_language),
subtitle = languageSubtitle,
onClick = { onNavigate(SettingsRoute.Language) }
)
}
// Server-Einstellungen
item {
// v1.5.0 Fix: Nur Prefix-URLs gelten als "nicht konfiguriert"
val isConfigured = serverUrl.isNotEmpty() &&
serverUrl != "http://" &&
serverUrl != "https://"
SettingsCard(
icon = Icons.Default.Cloud,
title = stringResource(R.string.settings_server),
subtitle = if (isConfigured) serverUrl else null,
statusText = when (serverStatus) {
is SettingsViewModel.ServerStatus.Reachable -> stringResource(R.string.settings_server_status_reachable)
is SettingsViewModel.ServerStatus.Unreachable -> stringResource(R.string.settings_server_status_unreachable)
is SettingsViewModel.ServerStatus.Checking -> stringResource(R.string.settings_server_status_checking)
is SettingsViewModel.ServerStatus.NotConfigured -> stringResource(R.string.settings_server_status_not_configured)
else -> null
},
statusColor = when (serverStatus) {
is SettingsViewModel.ServerStatus.Reachable -> Color(0xFF4CAF50)
is SettingsViewModel.ServerStatus.Unreachable -> Color(0xFFF44336)
is SettingsViewModel.ServerStatus.NotConfigured -> Color(0xFFFF9800)
else -> Color.Gray
},
onClick = { onNavigate(SettingsRoute.Server) }
)
}
// Sync-Einstellungen
item {
val intervalText = when (syncInterval) {
15L -> stringResource(R.string.settings_interval_15min)
60L -> stringResource(R.string.settings_interval_60min)
else -> stringResource(R.string.settings_interval_30min)
}
SettingsCard(
icon = Icons.Default.Sync,
title = stringResource(R.string.settings_sync),
subtitle = if (autoSyncEnabled) {
stringResource(R.string.settings_sync_auto_on, intervalText)
} else {
stringResource(R.string.settings_sync_auto_off)
},
onClick = { onNavigate(SettingsRoute.Sync) }
)
}
// Markdown-Integration
item {
SettingsCard(
icon = Icons.Default.Description,
title = stringResource(R.string.settings_markdown),
subtitle = if (markdownAutoSync) {
stringResource(R.string.settings_markdown_auto_on)
} else {
stringResource(R.string.settings_markdown_auto_off)
},
onClick = { onNavigate(SettingsRoute.Markdown) }
)
}
// Backup & Wiederherstellung
item {
SettingsCard(
icon = Icons.Default.Backup,
title = stringResource(R.string.settings_backup),
subtitle = stringResource(R.string.settings_backup_subtitle),
onClick = { onNavigate(SettingsRoute.Backup) }
)
}
// Über diese App
item {
SettingsCard(
icon = Icons.Default.Info,
title = stringResource(R.string.settings_about),
subtitle = stringResource(R.string.about_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE),
onClick = { onNavigate(SettingsRoute.About) }
)
}
// Debug & Diagnose
item {
SettingsCard(
icon = Icons.Default.BugReport,
title = stringResource(R.string.settings_debug),
subtitle = if (fileLoggingEnabled) {
stringResource(R.string.settings_debug_logging_on)
} else {
stringResource(R.string.settings_debug_logging_off)
},
onClick = { onNavigate(SettingsRoute.Debug) }
)
}
}
}
}

View File

@@ -0,0 +1,106 @@
package dev.dettmer.simplenotes.ui.settings.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Sync
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
import dev.dettmer.simplenotes.ui.settings.components.RadioOption
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
import dev.dettmer.simplenotes.ui.settings.components.SettingsInfoCard
import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
/**
* Sync settings screen (Auto-Sync toggle and interval selection)
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun SyncSettingsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit
) {
val autoSyncEnabled by viewModel.autoSyncEnabled.collectAsState()
val syncInterval by viewModel.syncInterval.collectAsState()
SettingsScaffold(
title = stringResource(R.string.sync_settings_title),
onBack = onBack
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
// Auto-Sync Info
SettingsInfoCard(
text = stringResource(R.string.sync_auto_sync_info)
)
Spacer(modifier = Modifier.height(8.dp))
// Auto-Sync Toggle
SettingsSwitch(
title = stringResource(R.string.sync_auto_sync_enabled),
checked = autoSyncEnabled,
onCheckedChange = { viewModel.setAutoSync(it) },
icon = Icons.Default.Sync
)
SettingsDivider()
// Sync Interval Section
SettingsSectionHeader(text = stringResource(R.string.sync_interval_section))
SettingsInfoCard(
text = stringResource(R.string.sync_interval_info)
)
Spacer(modifier = Modifier.height(8.dp))
// Interval Radio Group
val intervalOptions = listOf(
RadioOption(
value = 15L,
title = stringResource(R.string.sync_interval_15min_title),
subtitle = stringResource(R.string.sync_interval_15min_subtitle)
),
RadioOption(
value = 30L,
title = stringResource(R.string.sync_interval_30min_title),
subtitle = stringResource(R.string.sync_interval_30min_subtitle)
),
RadioOption(
value = 60L,
title = stringResource(R.string.sync_interval_60min_title),
subtitle = stringResource(R.string.sync_interval_60min_subtitle)
)
)
SettingsRadioGroup(
options = intervalOptions,
selectedValue = syncInterval,
onValueSelected = { viewModel.setSyncInterval(it) }
)
Spacer(modifier = Modifier.height(16.dp))
}
}
}

View File

@@ -0,0 +1,47 @@
package dev.dettmer.simplenotes.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
/**
* Shared Material 3 Theme with Dynamic Colors (Material You) support
* v1.5.0: Unified theme for MainActivity and Settings
*
* Used by:
* - ComposeMainActivity (Notes list)
* - ComposeSettingsActivity (Settings screens)
*/
@Composable
fun SimpleNotesTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val context = LocalContext.current
val colorScheme = when {
// Dynamic colors are available on Android 12+
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) {
dynamicDarkColorScheme(context)
} else {
dynamicLightColorScheme(context)
}
}
// Fallback to static Material 3 colors
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}

View File

@@ -2,6 +2,7 @@ package dev.dettmer.simplenotes.utils
import android.content.Context
import android.widget.Toast
import dev.dettmer.simplenotes.R
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -15,7 +16,7 @@ fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) {
Toast.makeText(this, message, duration).show()
}
// Timestamp to readable format
// Timestamp to readable format (legacy - without context, uses German)
fun Long.toReadableTime(): String {
val now = System.currentTimeMillis()
val diff = now - this
@@ -41,6 +42,32 @@ fun Long.toReadableTime(): String {
}
}
// Timestamp to readable format (with context for i18n)
fun Long.toReadableTime(context: Context): String {
val now = System.currentTimeMillis()
val diff = now - this
return when {
diff < TimeUnit.MINUTES.toMillis(1) -> context.getString(R.string.time_just_now)
diff < TimeUnit.HOURS.toMillis(1) -> {
val minutes = TimeUnit.MILLISECONDS.toMinutes(diff).toInt()
context.getString(R.string.time_minutes_ago, minutes)
}
diff < TimeUnit.DAYS.toMillis(1) -> {
val hours = TimeUnit.MILLISECONDS.toHours(diff).toInt()
context.getString(R.string.time_hours_ago, hours)
}
diff < TimeUnit.DAYS.toMillis(DAYS_THRESHOLD) -> {
val days = TimeUnit.MILLISECONDS.toDays(diff).toInt()
context.getString(R.string.time_days_ago, days)
}
else -> {
val sdf = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault())
sdf.format(Date(this))
}
}
}
// Truncate long strings
fun String.truncate(maxLength: Int): String {
return if (length > maxLength) {

View File

@@ -2,6 +2,7 @@ package dev.dettmer.simplenotes.utils
import android.app.NotificationChannel
import android.app.NotificationManager
import dev.dettmer.simplenotes.R
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
@@ -16,8 +17,6 @@ object NotificationHelper {
private const val TAG = "NotificationHelper"
private const val CHANNEL_ID = "notes_sync_channel"
private const val CHANNEL_NAME = "Notizen Synchronisierung"
private const val CHANNEL_DESCRIPTION = "Benachrichtigungen über Sync-Status"
private const val NOTIFICATION_ID = 1001
private const val SYNC_NOTIFICATION_ID = 2
private const val AUTO_CANCEL_TIMEOUT_MS = 30_000L
@@ -29,9 +28,11 @@ object NotificationHelper {
fun createNotificationChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channelName = context.getString(R.string.notification_channel_name)
val channelDescription = context.getString(R.string.notification_channel_desc)
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, importance).apply {
description = CHANNEL_DESCRIPTION
val channel = NotificationChannel(CHANNEL_ID, channelName, importance).apply {
description = channelDescription
enableVibration(true)
enableLights(true)
}
@@ -68,8 +69,8 @@ object NotificationHelper {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_menu_upload)
.setContentTitle("Sync erfolgreich")
.setContentText("$syncedCount Notiz(en) synchronisiert")
.setContentTitle(context.getString(R.string.notification_sync_success_title))
.setContentText(context.getString(R.string.notification_sync_success_message, syncedCount))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
@@ -96,7 +97,7 @@ object NotificationHelper {
fun showSyncFailureNotification(context: Context, errorMessage: String) {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_alert)
.setContentTitle("Sync fehlgeschlagen")
.setContentTitle(context.getString(R.string.notification_sync_failed_title))
.setContentText(errorMessage)
.setStyle(NotificationCompat.BigTextStyle()
.bigText(errorMessage))
@@ -125,8 +126,8 @@ object NotificationHelper {
fun showSyncProgressNotification(context: Context): Int {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_popup_sync)
.setContentTitle("Synchronisiere...")
.setContentText("Notizen werden synchronisiert")
.setContentTitle(context.getString(R.string.notification_sync_progress_title))
.setContentText(context.getString(R.string.notification_sync_progress_message))
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setProgress(0, 0, true)
@@ -161,8 +162,8 @@ object NotificationHelper {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Sync-Konflikt erkannt")
.setContentText("$conflictCount Notiz(en) haben Konflikte")
.setContentTitle(context.getString(R.string.notification_sync_conflict_title))
.setContentText(context.getString(R.string.notification_sync_conflict_message, conflictCount))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
@@ -212,8 +213,8 @@ object NotificationHelper {
fun showSyncInProgress(context: Context) {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle("Synchronisierung läuft")
.setContentText("Notizen werden synchronisiert...")
.setContentTitle(context.getString(R.string.notification_sync_in_progress_title))
.setContentText(context.getString(R.string.notification_sync_in_progress_message))
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.build()
@@ -240,8 +241,8 @@ object NotificationHelper {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle("Sync erfolgreich")
.setContentText("$count Notizen synchronisiert")
.setContentTitle(context.getString(R.string.notification_sync_success_title))
.setContentText(context.getString(R.string.notification_sync_success_message, count))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setContentIntent(pendingIntent) // Click öffnet App
@@ -271,7 +272,7 @@ object NotificationHelper {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentTitle("Sync Fehler")
.setContentTitle(context.getString(R.string.notification_sync_error_title))
.setContentText(message)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_ERROR)
@@ -308,11 +309,10 @@ object NotificationHelper {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentTitle("⚠️ Sync-Warnung")
.setContentText("Server seit ${hoursSinceLastSync}h nicht erreichbar")
.setContentTitle(context.getString(R.string.notification_sync_warning_title))
.setContentText(context.getString(R.string.notification_sync_warning_message, hoursSinceLastSync.toInt()))
.setStyle(NotificationCompat.BigTextStyle()
.bigText("Der WebDAV-Server ist seit ${hoursSinceLastSync} Stunden nicht erreichbar. " +
"Bitte prüfe deine Netzwerkverbindung oder Server-Einstellungen."))
.bigText(context.getString(R.string.notification_sync_warning_detail, hoursSinceLastSync.toInt())))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setContentIntent(pendingIntent)

View File

@@ -1,5 +1,7 @@
package dev.dettmer.simplenotes.utils
import android.content.Context
import dev.dettmer.simplenotes.R
import java.net.URL
/**
@@ -91,7 +93,7 @@ object UrlValidator {
* Validiert ob HTTP URL erlaubt ist
* @return Pair<Boolean, String?> - (isValid, errorMessage)
*/
fun validateHttpUrl(url: String): Pair<Boolean, String?> {
fun validateHttpUrl(context: Context, url: String): Pair<Boolean, String?> {
return try {
val parsedUrl = URL(url)
@@ -107,16 +109,15 @@ object UrlValidator {
} else {
return Pair(
false,
"HTTP ist nur für lokale Server erlaubt (z.B. 192.168.x.x, 10.x.x.x, nas.local). " +
"Für öffentliche Server verwende bitte HTTPS."
context.getString(R.string.error_http_local_only)
)
}
}
// Anderes Protokoll
Pair(false, "Ungültiges Protokoll: ${parsedUrl.protocol}. Bitte verwende HTTP oder HTTPS.")
Pair(false, context.getString(R.string.error_invalid_protocol, parsedUrl.protocol))
} catch (e: Exception) {
Pair(false, "Ungültige URL: ${e.message}")
Pair(false, context.getString(R.string.error_invalid_url, e.message ?: ""))
}
}
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Slide in from left - Main screen return animation -->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:interpolator/accelerate_decelerate">
<translate
android:fromXDelta="-100%"
android:toXDelta="0%"
android:duration="300" />
</set>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Slide in from right - Settings opening animation -->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:interpolator/accelerate_decelerate">
<translate
android:fromXDelta="100%"
android:toXDelta="0%"
android:duration="300" />
</set>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Slide out to left - Main screen exit when opening Settings -->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:interpolator/accelerate_decelerate">
<translate
android:fromXDelta="0%"
android:toXDelta="-100%"
android:duration="300" />
</set>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Slide out to right - Settings closing animation -->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:interpolator/accelerate_decelerate">
<translate
android:fromXDelta="0%"
android:toXDelta="100%"
android:duration="300" />
</set>

View File

@@ -0,0 +1,396 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- ============================= -->
<!-- APP IDENTITY -->
<!-- ============================= -->
<string name="app_name">Simple Notes</string>
<!-- ============================= -->
<!-- MAIN SCREEN -->
<!-- ============================= -->
<string name="main_title">Simple Notes</string>
<string name="no_notes_yet">Noch keine Notizen.\nTippe + um eine zu erstellen.</string>
<string name="add_note">Notiz hinzufügen</string>
<string name="sync">Synchronisieren</string>
<string name="settings">Einstellungen</string>
<string name="action_sync">Synchronisieren</string>
<string name="action_settings">Einstellungen</string>
<string name="action_close_selection">Auswahl beenden</string>
<string name="action_select_all">Alle auswählen</string>
<string name="action_delete_selected">Ausgewählte löschen</string>
<string name="selection_count">%d ausgewählt</string>
<!-- ============================= -->
<!-- EMPTY STATE -->
<!-- ============================= -->
<string name="empty_state_title">Noch keine Notizen</string>
<string name="empty_state_message">Tippe + um eine neue Notiz zu erstellen</string>
<!-- ============================= -->
<!-- FAB MENU -->
<!-- ============================= -->
<string name="fab_new_note">Neue Notiz</string>
<string name="fab_text_note">Text-Notiz</string>
<string name="fab_checklist">Checkliste</string>
<string name="create_text_note">Notiz</string>
<string name="create_checklist">Liste</string>
<!-- ============================= -->
<!-- NOTE CARD -->
<!-- ============================= -->
<string name="note_title_placeholder">Notiz-Titel</string>
<string name="note_content_placeholder">Notiz-Vorschau…</string>
<string name="note_timestamp_placeholder">Vor 2 Std</string>
<string name="untitled">Ohne Titel</string>
<string name="checklist_progress">%1$d/%2$d erledigt</string>
<string name="empty_checklist">Keine Einträge</string>
<!-- ============================= -->
<!-- SYNC STATUS BANNER -->
<!-- ============================= -->
<string name="sync_status">Sync-Status</string>
<string name="sync_syncing">Synchronisiere…</string>
<string name="sync_completed">Synchronisiert</string>
<string name="sync_error">Fehler</string>
<string name="sync_status_syncing">Synchronisiere…</string>
<string name="sync_status_completed">Synchronisierung abgeschlossen</string>
<string name="sync_status_error">Synchronisierung fehlgeschlagen</string>
<string name="sync_already_running">Synchronisierung läuft bereits</string>
<!-- ============================= -->
<!-- DELETE DIALOGS -->
<!-- ============================= -->
<string name="delete_note_title">Notiz löschen?</string>
<string name="delete_notes_title">%d Notizen löschen?</string>
<string name="delete_note_message">Wie möchtest du diese Notiz löschen?</string>
<string name="delete_notes_message">Wie möchtest du diese %d Notizen löschen?</string>
<string name="delete_everywhere">Überall löschen (auch Server)</string>
<string name="delete_local_only">Nur lokal löschen</string>
<string name="delete">Löschen</string>
<string name="cancel">Abbrechen</string>
<string name="ok">OK</string>
<!-- Legacy delete dialogs -->
<string name="legacy_delete_dialog_title">Notiz löschen</string>
<string name="legacy_delete_dialog_message">\"%s\" wird lokal gelöscht.\n\nAuch vom Server löschen?</string>
<string name="legacy_delete_from_server">Vom Server löschen</string>
<string name="legacy_delete_with_server">\"%s\" wird lokal und vom Server gelöscht</string>
<string name="legacy_delete_local_only">\"%s\" lokal gelöscht (Server bleibt)</string>
<!-- ============================= -->
<!-- SNACKBAR MESSAGES -->
<!-- ============================= -->
<string name="snackbar_undo">RÜCKGÄNGIG</string>
<string name="snackbar_note_deleted_local">\"%s\" lokal gelöscht</string>
<string name="snackbar_note_deleted_server">\"%s\" wird vom Server gelöscht</string>
<string name="snackbar_notes_deleted_local">%d Notiz(en) lokal gelöscht</string>
<string name="snackbar_notes_deleted_server">%d Notiz(en) werden vom Server gelöscht</string>
<string name="snackbar_deleted_from_server">Vom Server gelöscht</string>
<string name="snackbar_notes_deleted_from_server">%d Notiz(en) vom Server gelöscht</string>
<string name="snackbar_notes_deleted_from_server_partial">%1$d von %2$d Notizen vom Server gelöscht</string>
<string name="snackbar_server_delete_failed">Server-Löschung fehlgeschlagen</string>
<string name="snackbar_server_error">Server-Fehler: %s</string>
<string name="snackbar_already_synced">Bereits synchronisiert</string>
<string name="snackbar_server_unreachable">Server nicht erreichbar</string>
<string name="snackbar_synced_count">✅ Gesynct: %d Notizen</string>
<string name="snackbar_nothing_to_sync"> Nichts zu syncen</string>
<!-- ============================= -->
<!-- URL VALIDATION ERRORS -->
<!-- ============================= -->
<string name="error_http_local_only">HTTP ist nur für lokale Server erlaubt (z.B. 192.168.x.x, 10.x.x.x, nas.local). Für öffentliche Server verwende bitte HTTPS.</string>
<string name="error_invalid_protocol">Ungültiges Protokoll: %s. Bitte verwende HTTP oder HTTPS.</string>
<string name="error_invalid_url">Ungültige URL: %s</string>
<string name="error_server_not_configured">WebDAV-Server nicht vollständig konfiguriert</string>
<string name="error_sardine_client_failed">Sardine Client konnte nicht erstellt werden</string>
<string name="error_server_url_not_configured">Server-URL nicht konfiguriert</string>
<!-- ============================= -->
<!-- NOTE EDITOR -->
<!-- ============================= -->
<string name="new_note">Neue Notiz</string>
<string name="edit_note">Notiz bearbeiten</string>
<string name="new_checklist">Neue Liste</string>
<string name="edit_checklist">Liste bearbeiten</string>
<string name="title">Titel</string>
<string name="content">Inhalt</string>
<string name="back">Zurück</string>
<string name="save">Speichern</string>
<string name="add_item">Element hinzufügen</string>
<string name="item_placeholder">Neues Element…</string>
<string name="reorder_item">Element verschieben</string>
<string name="drag_to_reorder">Ziehen zum Sortieren</string>
<string name="delete_item">Element löschen</string>
<string name="note_is_empty">Notiz ist leer</string>
<string name="note_saved">Notiz gespeichert</string>
<string name="note_deleted">Notiz gelöscht</string>
<!-- ============================= -->
<!-- SETTINGS - MAIN -->
<!-- ============================= -->
<string name="settings_title">Einstellungen</string>
<string name="settings_language">Sprache</string>
<string name="settings_language_subtitle">%s</string>
<string name="settings_server">Server-Einstellungen</string>
<string name="settings_server_status_reachable">✅ Erreichbar</string>
<string name="settings_server_status_unreachable">❌ Nicht erreichbar</string>
<string name="settings_server_status_checking">🔍 Prüfe…</string>
<string name="settings_server_status_not_configured">⚠️ Nicht konfiguriert</string>
<string name="settings_sync">Sync-Einstellungen</string>
<string name="settings_sync_auto_on">Auto-Sync: An • %s</string>
<string name="settings_sync_auto_off">Auto-Sync: Aus</string>
<string name="settings_interval_15min">15 Min</string>
<string name="settings_interval_30min">30 Min</string>
<string name="settings_interval_60min">60 Min</string>
<string name="settings_markdown">Markdown Desktop-Integration</string>
<string name="settings_markdown_auto_on">Auto-Sync: An</string>
<string name="settings_markdown_auto_off">Auto-Sync: Aus</string>
<string name="settings_backup">Backup &amp; Wiederherstellung</string>
<string name="settings_backup_subtitle">Lokales oder Server-Backup</string>
<string name="settings_about">Über diese App</string>
<string name="settings_debug">Debug &amp; Diagnose</string>
<string name="settings_debug_logging_on">Logging: An</string>
<string name="settings_debug_logging_off">Logging: Aus</string>
<!-- ============================= -->
<!-- SETTINGS - SERVER -->
<!-- ============================= -->
<string name="server_settings">Server-Einstellungen</string>
<string name="server_settings_title">Server-Einstellungen</string>
<string name="server_connection_type">Verbindungstyp</string>
<string name="server_connection_http">🏠 Intern (HTTP)</string>
<string name="server_connection_https">🌐 Extern (HTTPS)</string>
<string name="server_connection_http_hint">HTTP nur für lokale Netzwerke (z.B. 192.168.x.x, 10.x.x.x)</string>
<string name="server_connection_https_hint">HTTPS für sichere Verbindungen über das Internet</string>
<string name="server_address">Server-Adresse</string>
<string name="server_address_hint">z.B. http://192.168.0.188:8080/notes</string>
<string name="server_url">Server URL</string>
<string name="username">Benutzername</string>
<string name="password">Passwort</string>
<string name="server_password_show">Anzeigen</string>
<string name="server_password_hide">Verstecken</string>
<string name="server_status_label">Server-Status:</string>
<string name="server_status_reachable">✅ Erreichbar</string>
<string name="server_status_unreachable">❌ Nicht erreichbar</string>
<string name="server_status_checking">🔍 Prüfe…</string>
<string name="server_status_not_configured">⚠️ Nicht konfiguriert</string>
<string name="server_status_unknown">❓ Unbekannt</string>
<string name="test_connection">Verbindung testen</string>
<string name="sync_now">Jetzt synchronisieren</string>
<!-- ============================= -->
<!-- SETTINGS - SYNC -->
<!-- ============================= -->
<string name="sync_settings">Sync-Einstellungen</string>
<string name="sync_settings_title">Sync-Einstellungen</string>
<string name="auto_sync">Auto-Sync aktiviert</string>
<string name="sync_auto_sync_info">🔄 Auto-Sync:\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)</string>
<string name="sync_auto_sync_enabled">Auto-Sync aktiviert</string>
<string name="sync_interval_section">Sync-Intervall</string>
<string name="sync_interval_info">Legt fest, wie oft die App im Hintergrund synchronisiert. Kürzere Intervalle bedeuten aktuellere Daten, verbrauchen aber etwas mehr Akku.\n\n⏱ Hinweis: Wenn dein Smartphone im Standby ist, kann Android die Synchronisation verzögern (bis zu 60 Min.), um Akku zu sparen. Das ist normal und betrifft alle Hintergrund-Apps.</string>
<string name="sync_interval_15min_title">⚡ Alle 15 Minuten</string>
<string name="sync_interval_15min_subtitle">Schnellste Synchronisation • ~0.8% Akku/Tag (~23 mAh)</string>
<string name="sync_interval_30min_title">✓ Alle 30 Minuten (Empfohlen)</string>
<string name="sync_interval_30min_subtitle">Ausgewogenes Verhältnis • ~0.4% Akku/Tag (~12 mAh)</string>
<string name="sync_interval_60min_title">🔋 Alle 60 Minuten</string>
<string name="sync_interval_60min_subtitle">Maximale Akkulaufzeit • ~0.2% Akku/Tag (~6 mAh geschätzt)</string>
<!-- Legacy -->
<string name="auto_sync_info"> 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)</string>
<!-- ============================= -->
<!-- SETTINGS - MARKDOWN -->
<!-- ============================= -->
<string name="markdown_settings_title">Markdown Desktop-Integration</string>
<string name="markdown_dialog_title">Markdown Auto-Sync</string>
<string name="markdown_export_complete">✅ Export abgeschlossen</string>
<string name="markdown_export_progress">Exportiere %1$d/%2$d Notizen…</string>
<string name="markdown_info">📝 Exportiert Notizen zusätzlich als .md-Dateien. Mounte WebDAV als Netzlaufwerk um mit VS Code, Typora oder jedem Markdown-Editor zu bearbeiten. JSON-Sync bleibt primäres Format.</string>
<string name="markdown_auto_sync_title">Markdown Auto-Sync</string>
<string name="markdown_auto_sync_subtitle">Synchronisiert Notizen automatisch als .md-Dateien (Upload + Download bei jedem Sync)</string>
<string name="markdown_manual_sync_info">Manueller Sync exportiert alle Notizen als .md-Dateien und importiert .md-Dateien vom Server. Nützlich für einmalige Synchronisation.</string>
<string name="markdown_manual_sync_button">📝 Manueller Markdown-Sync</string>
<!-- ============================= -->
<!-- SETTINGS - BACKUP -->
<!-- ============================= -->
<string name="backup_settings_title">Backup &amp; Wiederherstellung</string>
<string name="backup_restore_title">Backup &amp; Wiederherstellung</string>
<string name="backup_auto_info">📦 Bei jeder Wiederherstellung wird automatisch ein Sicherheits-Backup erstellt.</string>
<string name="backup_local_section">Lokales Backup</string>
<string name="backup_create">💾 Backup erstellen</string>
<string name="backup_restore_file">📂 Aus Datei wiederherstellen</string>
<string name="backup_server_section">Server-Backup</string>
<string name="backup_restore_server">☁️ Vom Server wiederherstellen</string>
<string name="backup_restore_dialog_title">⚠️ Backup wiederherstellen?</string>
<string name="backup_restore_source">Quelle: %s</string>
<string name="backup_restore_source_file">Lokale Datei</string>
<string name="backup_restore_source_server">WebDAV Server</string>
<string name="backup_restore_mode_label">Wiederherstellungs-Modus:</string>
<string name="backup_mode_merge_title">⚪ Zusammenführen (Standard)</string>
<string name="backup_mode_merge_subtitle">Neue hinzufügen, Bestehende behalten</string>
<string name="backup_mode_merge_full">⚪ Zusammenführen (Standard)\n → Neue hinzufügen, Bestehende behalten</string>
<string name="backup_mode_replace_title">⚪ Ersetzen</string>
<string name="backup_mode_replace_subtitle">Alle löschen &amp; Backup importieren</string>
<string name="backup_mode_replace_full">⚪ Ersetzen\n → Alle löschen &amp; Backup importieren</string>
<string name="backup_mode_overwrite_title">⚪ Duplikate überschreiben</string>
<string name="backup_mode_overwrite_subtitle">Backup gewinnt bei Konflikten</string>
<string name="backup_mode_overwrite_full">⚪ Duplikate überschreiben\n → Backup gewinnt bei Konflikten</string>
<string name="backup_restore_info"> Ein Sicherheits-Backup wird vor dem Wiederherstellen automatisch erstellt.</string>
<string name="backup_restore_button">Wiederherstellen</string>
<!-- Legacy -->
<string name="backup_restore_warning">⚠️ Achtung:\n\nDie Wiederherstellung überschreibt ALLE lokalen Notizen mit den Daten vom Server. Diese Aktion kann nicht rückgängig gemacht werden!</string>
<string name="restore_from_server">Vom Server wiederherstellen</string>
<string name="restore_confirmation_title">⚠️ Vom Server wiederherstellen?</string>
<string name="restore_confirmation_message">WARNUNG: Alle lokalen Notizen werden gelöscht und durch die Notizen vom Server ersetzt.\n\nDieser Vorgang kann nicht rückgängig gemacht werden!</string>
<string name="restore_button">Wiederherstellen</string>
<string name="restore_progress">Stelle Notizen wieder her…</string>
<string name="restore_success">✓ %d Notizen wiederhergestellt</string>
<string name="restore_error">Fehler: %s</string>
<!-- ============================= -->
<!-- SETTINGS - DEBUG -->
<!-- ============================= -->
<string name="debug_settings_title">Debug &amp; Diagnose</string>
<string name="debug_file_logging_title">Datei-Logging</string>
<string name="debug_file_logging_subtitle">Sync-Logs in Datei speichern</string>
<string name="debug_privacy_info">🔒 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.</string>
<string name="debug_log_actions_section">Log-Aktionen</string>
<string name="debug_export_logs">📤 Logs exportieren &amp; teilen</string>
<string name="debug_logs_subject">SimpleNotes Sync Logs</string>
<string name="debug_logs_share_via">Logs teilen via…</string>
<string name="debug_delete_logs">🗑️ Logs löschen</string>
<string name="debug_delete_logs_title">Logs löschen?</string>
<string name="debug_delete_logs_message">Alle gespeicherten Sync-Logs werden unwiderruflich gelöscht.</string>
<!-- Legacy -->
<string name="file_logging_privacy_notice"> 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.</string>
<!-- ============================= -->
<!-- SETTINGS - LANGUAGE -->
<!-- ============================= -->
<string name="language_settings_title">Sprache</string>
<string name="language_system_default">Systemstandard</string>
<string name="language_english">English</string>
<string name="language_german">Deutsch</string>
<string name="language_info"> Wähle deine bevorzugte Sprache. Die App wird neu gestartet, um die Änderung anzuwenden.</string>
<string name="language_changed_restart">Sprache geändert. Neustart…</string>
<!-- ============================= -->
<!-- SETTINGS - ABOUT -->
<!-- ============================= -->
<string name="about_settings_title">Über diese App</string>
<string name="about_app_name">Simple Notes Sync</string>
<string name="about_version">Version %1$s (%2$d)</string>
<string name="about_links_section">Links</string>
<string name="about_github_title">GitHub Repository</string>
<string name="about_github_subtitle">Quellcode, Issues &amp; Dokumentation</string>
<string name="about_developer_title">Entwickler</string>
<string name="about_developer_subtitle">GitHub Profil: @inventory69</string>
<string name="about_license_title">Lizenz</string>
<string name="about_license_subtitle">MIT License - Open Source</string>
<string name="about_privacy_title">🔒 Datenschutz</string>
<string name="about_privacy_text">Diese App sammelt keine Daten. Alle Notizen werden nur lokal auf deinem Gerät und auf deinem eigenen WebDAV-Server gespeichert. Keine Telemetrie, keine Werbung.</string>
<!-- ============================= -->
<!-- TOAST MESSAGES -->
<!-- ============================= -->
<string name="toast_connection_success">✅ Verbindung erfolgreich!</string>
<string name="toast_connection_failed">❌ %s</string>
<string name="toast_error">❌ Fehler: %s</string>
<string name="toast_syncing">🔄 Synchronisiere…</string>
<string name="toast_already_synced">✅ Bereits synchronisiert</string>
<string name="toast_sync_success">✅ %d Notizen synchronisiert</string>
<string name="toast_sync_failed">❌ %s</string>
<string name="toast_auto_sync_enabled">✅ Auto-Sync aktiviert</string>
<string name="toast_auto_sync_disabled">Auto-Sync deaktiviert</string>
<string name="toast_sync_interval">⏱️ Sync-Intervall: %s</string>
<string name="toast_sync_interval_15min">15 Minuten</string>
<string name="toast_sync_interval_30min">30 Minuten</string>
<string name="toast_sync_interval_60min">60 Minuten</string>
<string name="toast_configure_server_first">⚠️ Bitte zuerst WebDAV-Server konfigurieren</string>
<string name="toast_markdown_exported">✅ %d Notizen nach Markdown exportiert</string>
<string name="toast_markdown_enabled">📝 Markdown Auto-Sync aktiviert</string>
<string name="toast_markdown_disabled">📝 Markdown Auto-Sync deaktiviert</string>
<string name="toast_markdown_syncing">📝 Markdown-Sync läuft…</string>
<string name="toast_markdown_result">✅ Export: %1$d • Import: %2$d</string>
<string name="toast_export_failed">❌ Export fehlgeschlagen: %s</string>
<string name="toast_backup_success">✅ %s</string>
<string name="toast_backup_failed">❌ Backup fehlgeschlagen: %s</string>
<string name="toast_restore_success">✅ %d Notizen wiederhergestellt</string>
<string name="toast_restore_failed">❌ Wiederherstellung fehlgeschlagen: %s</string>
<string name="toast_notifications_enabled">Benachrichtigungen aktiviert</string>
<string name="toast_notifications_disabled">Benachrichtigungen deaktiviert. Du kannst sie in den Einstellungen aktivieren.</string>
<string name="toast_battery_optimization">Bitte Akku-Optimierung manuell deaktivieren</string>
<string name="toast_logs_deleted">🗑️ Logs gelöscht</string>
<string name="toast_no_logs_to_delete">📭 Keine Logs zum Löschen</string>
<string name="toast_logs_delete_error">❌ Fehler beim Löschen: %s</string>
<string name="toast_link_error">❌ Fehler beim Öffnen des Links</string>
<string name="toast_file_logging_enabled">📝 Datei-Logging aktiviert</string>
<string name="toast_file_logging_disabled">📝 Datei-Logging deaktiviert</string>
<string name="toast_sync_interval_changed">⏱️ Sync-Intervall auf %s geändert</string>
<string name="version_not_available">Version nicht verfügbar</string>
<string name="status_checking">🔍 Prüfe…</string>
<string name="battery_optimization_dialog_message">Bitte wähle \'Nicht optimieren\' für Simple Notes.</string>
<string name="battery_optimization_dialog_title">Hintergrund-Synchronisation</string>
<string name="battery_optimization_dialog_full_message">Damit die App im Hintergrund synchronisieren kann, muss die Akku-Optimierung deaktiviert werden.\n\nBitte wähle \'Nicht optimieren\' für Simple Notes.</string>
<string name="battery_optimization_open_settings">Einstellungen öffnen</string>
<string name="battery_optimization_later">Später</string>
<string name="content_description_back">Zurück</string>
<string name="error_invalid_backup_file">Ungültige Backup-Datei</string>
<string name="error_backup_version_unsupported">Backup-Version nicht unterstützt (v%1$d benötigt v%2$d+)</string>
<string name="error_backup_empty">Backup enthält keine Notizen</string>
<string name="error_backup_invalid_notes">Backup enthält %d ungültige Notizen</string>
<string name="error_backup_corrupt">Backup-Datei beschädigt oder ungültig: %s</string>
<string name="error_restore_failed">Wiederherstellung fehlgeschlagen: %s</string>
<string name="restore_merge_result">%1$d neue Notizen importiert, %2$d übersprungen</string>
<string name="restore_overwrite_result">%1$d neu, %2$d überschrieben</string>
<string name="restore_replace_result">Alle Notizen ersetzt: %d importiert</string>
<!-- ============================= -->
<!-- RELATIVE TIME -->
<!-- ============================= -->
<string name="time_just_now">Gerade eben</string>
<string name="time_minutes_ago">Vor %d Min</string>
<string name="time_hours_ago">Vor %d Std</string>
<string name="time_days_ago">Vor %d Tagen</string>
<!-- ============================= -->
<!-- NOTIFICATIONS -->
<!-- ============================= -->
<string name="notification_channel_name">Notizen Synchronisierung</string>
<string name="notification_channel_desc">Benachrichtigungen über Sync-Status</string>
<string name="notification_sync_success_title">Sync erfolgreich</string>
<string name="notification_sync_success_message">%d Notiz(en) synchronisiert</string>
<string name="notification_sync_failed_title">Sync fehlgeschlagen</string>
<string name="notification_sync_progress_title">Synchronisiere…</string>
<string name="notification_sync_progress_message">Notizen werden synchronisiert</string>
<string name="notification_sync_conflict_title">Sync-Konflikt erkannt</string>
<string name="notification_sync_conflict_message">%d Notiz(en) haben Konflikte</string>
<string name="notification_sync_warning_title">⚠️ Sync-Warnung</string>
<string name="notification_sync_warning_message">Server seit %dh nicht erreichbar</string>
<string name="notification_sync_warning_detail">Der WebDAV-Server ist seit %d Stunden nicht erreichbar. Bitte prüfe deine Netzwerkverbindung oder Server-Einstellungen.</string>
<string name="notification_sync_in_progress_title">Synchronisierung läuft</string>
<string name="notification_sync_in_progress_message">Notizen werden synchronisiert…</string>
<string name="notification_sync_error_title">Sync Fehler</string>
<!-- ============================= -->
<!-- PLURALS -->
<!-- ============================= -->
<plurals name="notes_count">
<item quantity="one">%d Notiz</item>
<item quantity="other">%d Notizen</item>
</plurals>
<plurals name="notes_deleted_local">
<item quantity="one">%d Notiz lokal gelöscht</item>
<item quantity="other">%d Notizen lokal gelöscht</item>
</plurals>
<plurals name="notes_deleted_server">
<item quantity="one">%d Notiz wird vom Server gelöscht</item>
<item quantity="other">%d Notizen werden vom Server gelöscht</item>
</plurals>
<plurals name="notes_synced">
<item quantity="one">%d Notiz synchronisiert</item>
<item quantity="other">%d Notizen synchronisiert</item>
</plurals>
</resources>

View File

@@ -1,92 +1,397 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- ============================= -->
<!-- APP IDENTITY -->
<!-- ============================= -->
<string name="app_name">Simple Notes</string>
<!-- Main Activity -->
<string name="no_notes_yet">Noch keine Notizen.\nTippe + um eine zu erstellen.</string>
<string name="add_note">Notiz hinzufügen</string>
<string name="sync">Synchronisieren</string>
<string name="settings">Einstellungen</string>
<!-- ============================= -->
<!-- MAIN SCREEN -->
<!-- ============================= -->
<string name="main_title">Simple Notes</string>
<string name="no_notes_yet">No notes yet.\nTap + to create one.</string>
<string name="add_note">Add note</string>
<string name="sync">Sync</string>
<string name="settings">Settings</string>
<string name="action_sync">Sync</string>
<string name="action_settings">Settings</string>
<string name="action_close_selection">Close selection</string>
<string name="action_select_all">Select all</string>
<string name="action_delete_selected">Delete selected</string>
<string name="selection_count">%d selected</string>
<!-- Empty State -->
<!-- ============================= -->
<!-- EMPTY STATE -->
<!-- ============================= -->
<string name="empty_state_emoji">📝</string>
<string name="empty_state_title">Noch keine Notizen</string>
<string name="empty_state_message">Tippe auf um deine erste Notiz zu erstellen</string>
<string name="empty_state_title">No notes yet</string>
<string name="empty_state_message">Tap + to create a new note</string>
<!-- Note Editor -->
<string name="edit_note">Notiz bearbeiten</string>
<string name="new_note">Neue Notiz</string>
<string name="title">Titel</string>
<string name="content">Inhalt</string>
<string name="save">Speichern</string>
<string name="delete">Löschen</string>
<!-- ============================= -->
<!-- FAB MENU -->
<!-- ============================= -->
<string name="fab_new_note">New note</string>
<string name="fab_text_note">Text note</string>
<string name="fab_checklist">Checklist</string>
<string name="create_text_note">Note</string>
<string name="create_checklist">List</string>
<!-- Note List Item (Preview placeholders) -->
<!-- ============================= -->
<!-- NOTE CARD -->
<!-- ============================= -->
<string name="note_title_placeholder">Note Title</string>
<string name="note_content_placeholder">Note content preview…</string>
<string name="note_timestamp_placeholder">Vor 2 Std</string>
<string name="untitled">Ohne Titel</string>
<string name="note_timestamp_placeholder">2 hours ago</string>
<string name="untitled">Untitled</string>
<string name="checklist_progress">%1$d/%2$d done</string>
<string name="empty_checklist">No entries</string>
<!-- Delete Confirmation Dialog -->
<string name="delete_note_title">Notiz löschen?</string>
<string name="delete_note_message">Diese Aktion kann nicht rückgängig gemacht werden.</string>
<string name="cancel">Abbrechen</string>
<!-- ============================= -->
<!-- SYNC STATUS BANNER -->
<!-- ============================= -->
<string name="sync_status">Sync Status</string>
<string name="sync_syncing">Syncing…</string>
<string name="sync_completed">Synced</string>
<string name="sync_error">Error</string>
<string name="sync_status_syncing">Syncing…</string>
<string name="sync_status_completed">Sync completed</string>
<string name="sync_status_error">Sync failed</string>
<string name="sync_already_running">Sync already in progress</string>
<!-- Settings -->
<string name="server_settings">Server-Einstellungen</string>
<!-- ============================= -->
<!-- DELETE DIALOGS -->
<!-- ============================= -->
<string name="delete_note_title">Delete note?</string>
<string name="delete_notes_title">Delete %d notes?</string>
<string name="delete_note_message">How do you want to delete this note?</string>
<string name="delete_notes_message">How do you want to delete these %d notes?</string>
<string name="delete_everywhere">Delete everywhere (also server)</string>
<string name="delete_local_only">Delete local only</string>
<string name="delete">Delete</string>
<string name="cancel">Cancel</string>
<string name="ok">OK</string>
<!-- Legacy delete dialogs -->
<string name="legacy_delete_dialog_title">Delete note</string>
<string name="legacy_delete_dialog_message">\"%s\" will be deleted locally.\n\nAlso delete from server?</string>
<string name="legacy_delete_from_server">Delete from server</string>
<string name="legacy_delete_with_server">\"%s\" will be deleted locally and from server</string>
<string name="legacy_delete_local_only">\"%s\" deleted locally (server remains)</string>
<!-- ============================= -->
<!-- SNACKBAR MESSAGES -->
<!-- ============================= -->
<string name="snackbar_undo">UNDO</string>
<string name="snackbar_note_deleted_local">\"%s\" deleted locally</string>
<string name="snackbar_note_deleted_server">\"%s\" will be deleted from server</string>
<string name="snackbar_notes_deleted_local">%d note(s) deleted locally</string>
<string name="snackbar_notes_deleted_server">%d note(s) will be deleted from server</string>
<string name="snackbar_deleted_from_server">Deleted from server</string>
<string name="snackbar_notes_deleted_from_server">%d note(s) deleted from server</string>
<string name="snackbar_notes_deleted_from_server_partial">%1$d of %2$d notes deleted from server</string>
<string name="snackbar_server_delete_failed">Server deletion failed</string>
<string name="snackbar_server_error">Server error: %s</string>
<string name="snackbar_already_synced">Already synced</string>
<string name="snackbar_server_unreachable">Server not reachable</string>
<string name="snackbar_synced_count">✅ Synced: %d notes</string>
<string name="snackbar_nothing_to_sync"> Nothing to sync</string>
<!-- ============================= -->
<!-- URL VALIDATION ERRORS -->
<!-- ============================= -->
<string name="error_http_local_only">HTTP is only allowed for local servers (e.g. 192.168.x.x, 10.x.x.x, nas.local). For public servers, please use HTTPS.</string>
<string name="error_invalid_protocol">Invalid protocol: %s. Please use HTTP or HTTPS.</string>
<string name="error_invalid_url">Invalid URL: %s</string>
<string name="error_server_not_configured">WebDAV server not fully configured</string>
<string name="error_sardine_client_failed">Sardine client could not be created</string>
<string name="error_server_url_not_configured">Server URL not configured</string>
<!-- ============================= -->
<!-- NOTE EDITOR -->
<!-- ============================= -->
<string name="new_note">New Note</string>
<string name="edit_note">Edit Note</string>
<string name="new_checklist">New List</string>
<string name="edit_checklist">Edit List</string>
<string name="title">Title</string>
<string name="content">Content</string>
<string name="back">Back</string>
<string name="save">Save</string>
<string name="add_item">Add item</string>
<string name="item_placeholder">New item…</string>
<string name="reorder_item">Reorder item</string>
<string name="drag_to_reorder">Drag to reorder</string>
<string name="delete_item">Delete item</string>
<string name="note_is_empty">Note is empty</string>
<string name="note_saved">Note saved</string>
<string name="note_deleted">Note deleted</string>
<!-- ============================= -->
<!-- SETTINGS - MAIN -->
<!-- ============================= -->
<string name="settings_title">Settings</string>
<string name="settings_language">Language</string>
<string name="settings_language_subtitle">%s</string>
<string name="settings_server">Server Settings</string>
<string name="settings_server_status_reachable">✅ Reachable</string>
<string name="settings_server_status_unreachable">❌ Not reachable</string>
<string name="settings_server_status_checking">🔍 Checking…</string>
<string name="settings_server_status_not_configured">⚠️ Not configured</string>
<string name="settings_sync">Sync Settings</string>
<string name="settings_sync_auto_on">Auto-Sync: On • %s</string>
<string name="settings_sync_auto_off">Auto-Sync: Off</string>
<string name="settings_interval_15min">15 min</string>
<string name="settings_interval_30min">30 min</string>
<string name="settings_interval_60min">60 min</string>
<string name="settings_markdown">Markdown Desktop Integration</string>
<string name="settings_markdown_auto_on">Auto-Sync: On</string>
<string name="settings_markdown_auto_off">Auto-Sync: Off</string>
<string name="settings_backup">Backup &amp; Restore</string>
<string name="settings_backup_subtitle">Local or server backup</string>
<string name="settings_about">About this App</string>
<string name="settings_debug">Debug &amp; Diagnostics</string>
<string name="settings_debug_logging_on">Logging: On</string>
<string name="settings_debug_logging_off">Logging: Off</string>
<!-- ============================= -->
<!-- SETTINGS - SERVER -->
<!-- ============================= -->
<string name="server_settings">Server Settings</string>
<string name="server_settings_title">Server Settings</string>
<string name="server_connection_type">Connection Type</string>
<string name="server_connection_http">🏠 Internal (HTTP)</string>
<string name="server_connection_https">🌐 External (HTTPS)</string>
<string name="server_connection_http_hint">HTTP only for local networks (e.g. 192.168.x.x, 10.x.x.x)</string>
<string name="server_connection_https_hint">HTTPS for secure connections over the internet</string>
<string name="server_address">Server Address</string>
<string name="server_address_hint">e.g. http://192.168.0.188:8080/notes</string>
<string name="server_url">Server URL</string>
<string name="username">Benutzername</string>
<string name="password">Passwort</string>
<string name="server_status_label">Server-Status:</string>
<string name="server_status_checking">Prüfe…</string>
<string name="test_connection">Verbindung testen</string>
<string name="sync_now">Jetzt synchronisieren</string>
<string name="username">Username</string>
<string name="password">Password</string>
<string name="server_password_show">Show</string>
<string name="server_password_hide">Hide</string>
<string name="server_status_label">Server Status:</string>
<string name="server_status_reachable">✅ Reachable</string>
<string name="server_status_unreachable">❌ Not reachable</string>
<string name="server_status_checking">🔍 Checking…</string>
<string name="server_status_not_configured">⚠️ Not configured</string>
<string name="server_status_unknown">❓ Unknown</string>
<string name="test_connection">Test Connection</string>
<string name="sync_now">Sync now</string>
<!-- Auto-Sync Settings -->
<string name="sync_settings">Sync-Einstellungen</string>
<string name="auto_sync">Auto-Sync aktiviert</string>
<string name="sync_status">Sync-Status</string>
<string name="auto_sync_info"> 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)</string>
<!-- ============================= -->
<!-- SETTINGS - SYNC -->
<!-- ============================= -->
<string name="sync_settings">Sync Settings</string>
<string name="sync_settings_title">Sync Settings</string>
<string name="auto_sync">Auto-Sync enabled</string>
<string name="sync_auto_sync_info">🔄 Auto-Sync:\n• Checks every 30 min if server is reachable\n• Works on any WiFi connection\n• Runs in background\n• Minimal battery usage (~0.4%/day)</string>
<string name="sync_auto_sync_enabled">Auto-Sync enabled</string>
<string name="sync_interval_section">Sync Interval</string>
<string name="sync_interval_info">Determines how often the app syncs in the background. Shorter intervals mean more up-to-date data, but use slightly more battery.\n\n⏱ Note: When your phone is in standby, Android may delay syncs (up to 60 min) to save battery. This is normal and affects all background apps.</string>
<string name="sync_interval_15min_title">⚡ Every 15 minutes</string>
<string name="sync_interval_15min_subtitle">Fastest sync • ~0.8% battery/day (~23 mAh)</string>
<string name="sync_interval_30min_title">✓ Every 30 minutes (Recommended)</string>
<string name="sync_interval_30min_subtitle">Balanced ratio • ~0.4% battery/day (~12 mAh)</string>
<string name="sync_interval_60min_title">🔋 Every 60 minutes</string>
<string name="sync_interval_60min_subtitle">Maximum battery life • ~0.2% battery/day (~6 mAh est.)</string>
<!-- Legacy -->
<string name="auto_sync_info"> Auto-Sync:\n\n• Checks every 30 min if server is reachable\n• Works on any WiFi connection\n• Runs in background\n• Minimal battery usage (~0.4%%/day)</string>
<!-- Backup & Restore -->
<string name="backup_restore_title">Backup &amp; Wiederherstellung</string>
<string name="backup_restore_warning">⚠️ Achtung:\n\nDie Wiederherstellung überschreibt ALLE lokalen Notizen mit den Daten vom Server. Diese Aktion kann nicht rückgängig gemacht werden!</string>
<string name="restore_from_server">Vom Server wiederherstellen</string>
<string name="restore_confirmation_title">⚠️ Vom Server wiederherstellen?</string>
<string name="restore_confirmation_message">WARNUNG: Alle lokalen Notizen werden gelöscht und durch die Notizen vom Server ersetzt.\n\nDieser Vorgang kann nicht rückgängig gemacht werden!</string>
<string name="restore_button">Wiederherstellen</string>
<string name="restore_progress">Stelle Notizen wieder her…</string>
<string name="restore_success">✓ %d Notizen wiederhergestellt</string>
<string name="restore_error">Fehler: %s</string>
<!-- ============================= -->
<!-- SETTINGS - MARKDOWN -->
<!-- ============================= -->
<string name="markdown_settings_title">Markdown Desktop Integration</string>
<string name="markdown_dialog_title">Markdown Auto-Sync</string>
<string name="markdown_export_complete">✅ Export complete</string>
<string name="markdown_export_progress">Exporting %1$d/%2$d notes…</string>
<string name="markdown_info">📝 Exports notes additionally as .md files. Mount WebDAV as network drive to edit with VS Code, Typora, or any Markdown editor. JSON sync remains primary format.</string>
<string name="markdown_auto_sync_title">Markdown Auto-Sync</string>
<string name="markdown_auto_sync_subtitle">Automatically syncs notes as .md files (upload + download on each sync)</string>
<string name="markdown_manual_sync_info">Manual sync exports all notes as .md files and imports .md files from the server. Useful for one-time sync.</string>
<string name="markdown_manual_sync_button">📝 Manual Markdown Sync</string>
<!-- Sync Status Banner (v1.3.1) -->
<string name="sync_status_syncing">Synchronisiere…</string>
<string name="sync_status_completed">Synchronisierung abgeschlossen</string>
<string name="sync_status_error">Synchronisierung fehlgeschlagen</string>
<string name="sync_already_running">Synchronisierung läuft bereits</string>
<!-- ============================= -->
<!-- SETTINGS - BACKUP -->
<!-- ============================= -->
<string name="backup_settings_title">Backup &amp; Restore</string>
<string name="backup_restore_title">Backup &amp; Restore</string>
<string name="backup_auto_info">📦 A safety backup is automatically created before each restore.</string>
<string name="backup_local_section">Local Backup</string>
<string name="backup_create">💾 Create Backup</string>
<string name="backup_restore_file">📂 Restore from File</string>
<string name="backup_server_section">Server Backup</string>
<string name="backup_restore_server">☁️ Restore from Server</string>
<string name="backup_restore_dialog_title">⚠️ Restore Backup?</string>
<string name="backup_restore_source">Source: %s</string>
<string name="backup_restore_source_file">Local File</string>
<string name="backup_restore_source_server">WebDAV Server</string>
<string name="backup_restore_mode_label">Restore Mode:</string>
<string name="backup_mode_merge_title">⚪ Merge (Default)</string>
<string name="backup_mode_merge_subtitle">Add new, keep existing</string>
<string name="backup_mode_merge_full">⚪ Merge (Default)\n → Add new, keep existing</string>
<string name="backup_mode_replace_title">⚪ Replace</string>
<string name="backup_mode_replace_subtitle">Delete all &amp; import backup</string>
<string name="backup_mode_replace_full">⚪ Replace\n → Delete all &amp; import backup</string>
<string name="backup_mode_overwrite_title">⚪ Overwrite duplicates</string>
<string name="backup_mode_overwrite_subtitle">Backup wins on conflicts</string>
<string name="backup_mode_overwrite_full">⚪ Overwrite duplicates\n → Backup wins on conflicts</string>
<string name="backup_restore_info"> A safety backup will be automatically created before restoring.</string>
<string name="backup_restore_button">Restore</string>
<!-- Legacy -->
<string name="backup_restore_warning">⚠️ Warning:\n\nRestoring will overwrite ALL local notes with data from the server. This action cannot be undone!</string>
<string name="restore_from_server">Restore from Server</string>
<string name="restore_confirmation_title">⚠️ Restore from Server?</string>
<string name="restore_confirmation_message">WARNING: All local notes will be deleted and replaced with notes from the server.\n\nThis action cannot be undone!</string>
<string name="restore_button">Restore</string>
<string name="restore_progress">Restoring notes…</string>
<string name="restore_success">✓ %d notes restored</string>
<string name="restore_error">Error: %s</string>
<!-- Debug/Logging Section (v1.3.2) -->
<string name="file_logging_privacy_notice"> 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.</string>
<!-- ============================= -->
<!-- SETTINGS - DEBUG -->
<!-- ============================= -->
<string name="debug_settings_title">Debug &amp; Diagnostics</string>
<string name="debug_file_logging_title">File Logging</string>
<string name="debug_file_logging_subtitle">Save sync logs to file</string>
<string name="debug_privacy_info">🔒 Privacy: Logs are only stored locally on your device and are never sent to external servers. Logs contain sync activities for troubleshooting. You can delete or export them at any time.</string>
<string name="debug_log_actions_section">Log Actions</string>
<string name="debug_export_logs">📤 Export &amp; Share Logs</string>
<string name="debug_logs_subject">SimpleNotes Sync Logs</string>
<string name="debug_logs_share_via">Share logs via…</string>
<string name="debug_delete_logs">🗑️ Delete Logs</string>
<string name="debug_delete_logs_title">Delete logs?</string>
<string name="debug_delete_logs_message">All saved sync logs will be permanently deleted.</string>
<!-- Legacy -->
<string name="file_logging_privacy_notice"> Privacy: Logs are only stored locally on your device and are never sent to external servers. Logs contain sync activities for troubleshooting. You can delete or export them at any time.</string>
<!-- ========================== -->
<!-- CHECKLIST FEATURE (v1.4.0) -->
<!-- ========================== -->
<!-- ============================= -->
<!-- SETTINGS - LANGUAGE -->
<!-- ============================= -->
<string name="language_settings_title">Language</string>
<string name="language_system_default">System Default</string>
<string name="language_english">English</string>
<string name="language_german">Deutsch</string>
<string name="language_info"> Choose your preferred language. The app will restart to apply the change.</string>
<string name="language_changed_restart">Language changed. Restarting…</string>
<!-- FAB Menu -->
<string name="create_text_note">Notiz</string>
<string name="create_checklist">Liste</string>
<!-- ============================= -->
<!-- SETTINGS - ABOUT -->
<!-- ============================= -->
<string name="about_settings_title">About this App</string>
<string name="about_app_name">Simple Notes Sync</string>
<string name="about_version">Version %1$s (%2$d)</string>
<string name="about_links_section">Links</string>
<string name="about_github_title">GitHub Repository</string>
<string name="about_github_subtitle">Source code, issues &amp; documentation</string>
<string name="about_developer_title">Developer</string>
<string name="about_developer_subtitle">GitHub Profile: @inventory69</string>
<string name="about_license_title">License</string>
<string name="about_license_subtitle">MIT License - Open Source</string>
<string name="about_privacy_title">🔒 Privacy</string>
<string name="about_privacy_text">This app collects no data. All notes are stored only locally on your device and on your own WebDAV server. No telemetry, no ads.</string>
<!-- Editor -->
<string name="new_checklist">Neue Liste</string>
<string name="edit_checklist">Liste bearbeiten</string>
<string name="add_item">Element hinzufügen</string>
<string name="item_placeholder">Neues Element…</string>
<string name="reorder_item">Element verschieben</string>
<string name="delete_item">Element löschen</string>
<string name="note_is_empty">Notiz ist leer</string>
<string name="note_saved">Notiz gespeichert</string>
<string name="note_deleted">Notiz gelöscht</string>
<!-- ============================= -->
<!-- TOAST MESSAGES -->
<!-- ============================= -->
<string name="toast_connection_success">✅ Connection successful!</string>
<string name="toast_connection_failed">❌ %s</string>
<string name="toast_error">❌ Error: %s</string>
<string name="toast_syncing">🔄 Syncing…</string>
<string name="toast_already_synced">✅ Already synced</string>
<string name="toast_sync_success">✅ %d notes synced</string>
<string name="toast_sync_failed">❌ %s</string>
<string name="toast_auto_sync_enabled">✅ Auto-Sync enabled</string>
<string name="toast_auto_sync_disabled">Auto-Sync disabled</string>
<string name="toast_sync_interval">⏱️ Sync interval: %s</string>
<string name="toast_sync_interval_15min">15 minutes</string>
<string name="toast_sync_interval_30min">30 minutes</string>
<string name="toast_sync_interval_60min">60 minutes</string>
<string name="toast_configure_server_first">⚠️ Please configure WebDAV server first</string>
<string name="toast_markdown_exported">✅ %d notes exported to Markdown</string>
<string name="toast_markdown_enabled">📝 Markdown Auto-Sync enabled</string>
<string name="toast_markdown_disabled">📝 Markdown Auto-Sync disabled</string>
<string name="toast_markdown_syncing">📝 Markdown sync running…</string>
<string name="toast_markdown_result">✅ Export: %1$d • Import: %2$d</string>
<string name="toast_export_failed">❌ Export failed: %s</string>
<string name="toast_backup_success">✅ %s</string>
<string name="toast_backup_failed">❌ Backup failed: %s</string>
<string name="toast_restore_success">✅ %d notes restored</string>
<string name="toast_restore_failed">❌ Restore failed: %s</string>
<string name="toast_notifications_enabled">Notifications enabled</string>
<string name="toast_notifications_disabled">Notifications disabled. You can enable them in settings.</string>
<string name="toast_battery_optimization">Please disable battery optimization manually</string>
<string name="toast_logs_deleted">🗑️ Logs deleted</string>
<string name="toast_no_logs_to_delete">📭 No logs to delete</string>
<string name="toast_logs_delete_error">❌ Error deleting: %s</string>
<string name="toast_link_error">❌ Error opening link</string>
<string name="toast_file_logging_enabled">📝 File logging enabled</string>
<string name="toast_file_logging_disabled">📝 File logging disabled</string>
<string name="toast_sync_interval_changed">⏱️ Sync interval changed to %s</string>
<string name="version_not_available">Version not available</string>
<string name="status_checking">🔍 Checking…</string>
<string name="battery_optimization_dialog_message">Please select \'Not optimized\' for Simple Notes.</string>
<string name="battery_optimization_dialog_title">Background Synchronization</string>
<string name="battery_optimization_dialog_full_message">For the app to sync in the background, battery optimization must be disabled.\n\nPlease select \'Not optimized\' for Simple Notes.</string>
<string name="battery_optimization_open_settings">Open Settings</string>
<string name="battery_optimization_later">Later</string>
<string name="content_description_back">Back</string>
<string name="error_invalid_backup_file">Invalid backup file</string>
<string name="error_backup_version_unsupported">Backup version not supported (v%1$d requires v%2$d+)</string>
<string name="error_backup_empty">Backup contains no notes</string>
<string name="error_backup_invalid_notes">Backup contains %d invalid notes</string>
<string name="error_backup_corrupt">Backup file corrupt or invalid: %s</string>
<string name="error_restore_failed">Restore failed: %s</string>
<string name="restore_merge_result">%1$d new notes imported, %2$d skipped</string>
<string name="restore_overwrite_result">%1$d new, %2$d overwritten</string>
<string name="restore_replace_result">All notes replaced: %d imported</string>
<!-- List Preview -->
<string name="checklist_progress">%1$d/%2$d erledigt</string>
<string name="empty_checklist">Keine Einträge</string>
<!-- ============================= -->
<!-- RELATIVE TIME -->
<!-- ============================= -->
<string name="time_just_now">Just now</string>
<string name="time_minutes_ago">%d min ago</string>
<string name="time_hours_ago">%d hours ago</string>
<string name="time_days_ago">%d days ago</string>
<!-- ============================= -->
<!-- NOTIFICATIONS -->
<!-- ============================= -->
<string name="notification_channel_name">Notes Synchronization</string>
<string name="notification_channel_desc">Notifications about sync status</string>
<string name="notification_sync_success_title">Sync successful</string>
<string name="notification_sync_success_message">%d note(s) synchronized</string>
<string name="notification_sync_failed_title">Sync failed</string>
<string name="notification_sync_progress_title">Syncing…</string>
<string name="notification_sync_progress_message">Notes are being synchronized</string>
<string name="notification_sync_conflict_title">Sync conflict detected</string>
<string name="notification_sync_conflict_message">%d note(s) have conflicts</string>
<string name="notification_sync_warning_title">⚠️ Sync Warning</string>
<string name="notification_sync_warning_message">Server unreachable for %dh</string>
<string name="notification_sync_warning_detail">The WebDAV server has been unreachable for %d hours. Please check your network connection or server settings.</string>
<string name="notification_sync_in_progress_title">Synchronization in progress</string>
<string name="notification_sync_in_progress_message">Notes are being synchronized…</string>
<string name="notification_sync_error_title">Sync Error</string>
<!-- ============================= -->
<!-- PLURALS -->
<!-- ============================= -->
<plurals name="notes_count">
<item quantity="one">%d note</item>
<item quantity="other">%d notes</item>
</plurals>
<plurals name="notes_deleted_local">
<item quantity="one">%d note deleted locally</item>
<item quantity="other">%d notes deleted locally</item>
</plurals>
<plurals name="notes_deleted_server">
<item quantity="one">%d note will be deleted from server</item>
<item quantity="other">%d notes will be deleted from server</item>
</plurals>
<plurals name="notes_synced">
<item quantity="one">%d note synced</item>
<item quantity="other">%d notes synced</item>
</plurals>
</resources>

View File

@@ -38,7 +38,7 @@
<!-- Splash Screen Theme (Android 12+) -->
<style name="Theme.SimpleNotes.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">?attr/colorPrimary</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_icon</item>
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher_foreground</item>
<item name="windowSplashScreenAnimationDuration">500</item>
<item name="postSplashScreenTheme">@style/Theme.SimpleNotes</item>
</style>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Default/Fallback language -->
<locale android:name="en" />
<!-- Supported languages -->
<locale android:name="de" />
</locale-config>

View File

@@ -2,6 +2,7 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false // v1.5.0: Jetpack Compose
alias(libs.plugins.ktlint) apply false
alias(libs.plugins.detekt) apply false
}

View File

@@ -23,25 +23,25 @@ complexity:
threshold: 5
CyclomaticComplexMethod:
active: true
threshold: 15
threshold: 65 # v1.5.0: Increased for sync methods (TODO: refactor in v1.6.0)
ignoreSingleWhenExpression: true
LargeClass:
active: true
threshold: 600 # Increased for WebDavSyncService
LongMethod:
active: true
threshold: 80 # Increased for sync methods
threshold: 200 # v1.5.0: Increased for sync methods (TODO: refactor in v1.6.0)
LongParameterList:
active: true
functionThreshold: 6
functionThreshold: 10 # v1.5.0: Compose functions often have many params
constructorThreshold: 7
NestedBlockDepth:
active: true
threshold: 5
TooManyFunctions:
active: true
thresholdInFiles: 25
thresholdInClasses: 25
thresholdInFiles: 35 # v1.5.0: Increased for large classes
thresholdInClasses: 35
thresholdInInterfaces: 20
thresholdInObjects: 20
thresholdInEnums: 10
@@ -117,9 +117,10 @@ style:
ignoreExtensionFunctions: true
MaxLineLength:
active: true
maxLineLength: 120
maxLineLength: 140 # v1.5.0: Increased for Compose code readability
excludePackageStatements: true
excludeImportStatements: true
excludeCommentStatements: true
ReturnCount:
active: true
max: 4

View File

@@ -11,6 +11,11 @@ activity = "1.8.0"
constraintlayout = "2.1.4"
ktlint = "12.1.0"
detekt = "1.23.4"
# Jetpack Compose v1.5.0 - Updated for 120Hz performance
composeBom = "2026.01.00"
navigationCompose = "2.7.6"
lifecycleRuntimeCompose = "2.7.0"
activityCompose = "1.8.2"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -21,10 +26,22 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
# Jetpack Compose v1.5.0
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }

324
docs/BACKUP.de.md Normal file
View File

@@ -0,0 +1,324 @@
# Backup & Wiederherstellung 💾
**🌍 Sprachen:** **Deutsch** · [English](BACKUP.md)
> Sichere deine Notizen lokal - unabhängig vom Server
---
## 📋 Übersicht
Das Backup-System funktioniert **komplett offline** und unabhängig vom WebDAV-Server. Perfekt für:
- 📥 Regelmäßige Sicherungen
- 📤 Migration zu neuem Server
- 🔄 Wiederherstellung nach Datenverlust
- 💾 Archivierung alter Notizen
---
## 📥 Backup erstellen
### Schritt-für-Schritt
1. **Einstellungen öffnen** (⚙️ Icon oben rechts)
2. **"Backup & Wiederherstellung"** Section finden
3. **"📥 Backup erstellen"** antippen
4. **Speicherort wählen:**
- 📁 Downloads
- 💳 SD-Karte
- ☁️ Cloud-Ordner (Nextcloud, Google Drive, etc.)
- 📧 E-Mail als Anhang
5. **Fertig!** Backup-Datei ist gespeichert
### Dateiformat
**Dateiname:** `simplenotes_backup_YYYY-MM-DD_HHmmss.json`
**Beispiel:** `simplenotes_backup_2026-01-05_143022.json`
**Inhalt:**
```json
{
"version": "1.2.1",
"exported_at": "2026-01-05T14:30:22Z",
"notes_count": 42,
"notes": [
{
"id": "abc-123-def",
"title": "Einkaufsliste",
"content": "Milch\nBrot\nKäse",
"createdAt": 1704467422000,
"updatedAt": 1704467422000
}
]
}
```
**Format-Details:**
- ✅ Menschenlesbar (formatiertes JSON)
- ✅ Alle Daten inklusive (Titel, Inhalt, IDs, Timestamps)
- ✅ Versions-Info für Kompatibilität
- ✅ Anzahl der Notizen für Validierung
---
## 📤 Backup wiederherstellen
### 3 Wiederherstellungs-Modi
#### 1. Zusammenführen (Merge) ⭐ _Empfohlen_
**Was passiert:**
- ✅ Neue Notizen aus Backup werden hinzugefügt
- ✅ Bestehende Notizen bleiben unverändert
- ✅ Keine Datenverluste
**Wann nutzen:**
- Backup von anderem Gerät einspielen
- Alte Notizen zurückholen
- Versehentlich gelöschte Notizen wiederherstellen
**Beispiel:**
```
App: [Notiz A, Notiz B, Notiz C]
Backup: [Notiz A, Notiz D, Notiz E]
Ergebnis: [Notiz A, Notiz B, Notiz C, Notiz D, Notiz E]
```
#### 2. Ersetzen (Replace)
**Was passiert:**
- ❌ ALLE bestehenden Notizen werden gelöscht
- ✅ Backup-Notizen werden importiert
- ⚠️ Unwiderruflich (außer durch Auto-Backup)
**Wann nutzen:**
- Server-Wechsel (kompletter Neustart)
- Zurück zu altem Backup-Stand
- App-Neuinstallation
**Beispiel:**
```
App: [Notiz A, Notiz B, Notiz C]
Backup: [Notiz X, Notiz Y]
Ergebnis: [Notiz X, Notiz Y]
```
**⚠️ Warnung:** Automatisches Sicherheits-Backup wird erstellt!
#### 3. Duplikate überschreiben (Overwrite)
**Was passiert:**
- ✅ Neue Notizen aus Backup werden hinzugefügt
- 🔄 Bei ID-Konflikten gewinnt das Backup
- ✅ Andere Notizen bleiben unverändert
**Wann nutzen:**
- Backup ist neuer als App-Daten
- Desktop-Änderungen einspielen
- Konflikt-Auflösung
**Beispiel:**
```
App: [Notiz A (v1), Notiz B, Notiz C]
Backup: [Notiz A (v2), Notiz D]
Ergebnis: [Notiz A (v2), Notiz B, Notiz C, Notiz D]
```
### Wiederherstellungs-Prozess
1. **Einstellungen****"📤 Aus Datei wiederherstellen"**
2. **Backup-Datei auswählen** (`.json`)
3. **Modus wählen:**
- 🔵 Zusammenführen _(Standard)_
- 🟡 Duplikate überschreiben
- 🔴 Ersetzen _(Vorsicht!)_
4. **Bestätigen** - Automatisches Sicherheits-Backup wird erstellt
5. **Warten** - Import läuft
6. **Fertig!** - Erfolgsmeldung mit Anzahl importierter Notizen
---
## 🛡️ Automatisches Sicherheits-Backup
**Vor jeder Wiederherstellung:**
- ✅ Automatisches Backup wird erstellt
- 📁 Gespeichert in: `Android/data/dev.dettmer.simplenotes/files/`
- 🏷️ Dateiname: `auto_backup_before_restore_YYYY-MM-DD_HHmmss.json`
- ⏱️ Zeitstempel: Direkt vor Wiederherstellung
**Warum?**
- Schutz vor versehentlichem "Ersetzen"
- Möglichkeit zum Rückgängigmachen
- Doppelte Sicherheit
**Zugriff via Dateimanager:**
```
/Android/data/dev.dettmer.simplenotes/files/auto_backup_before_restore_*.json
```
---
## 💡 Best Practices
### Backup-Strategie
#### Regelmäßige Backups
```
Täglich: ❌ Zu oft (Server-Sync reicht)
Wöchentlich: ✅ Empfohlen für wichtige Notizen
Monatlich: ✅ Archivierung
Vor Updates: ✅ Sicherheit
```
#### 3-2-1 Regel
1. **3 Kopien** - Original + 2 Backups
2. **2 Medien** - z.B. SD-Karte + Cloud
3. **1 Offsite** - z.B. Cloud-Speicher
### Backup-Speicherorte
**Lokal (schnell):**
- 📱 Internal Storage / Downloads
- 💳 SD-Karte
- 🖥️ PC (via USB)
**Cloud (sicher):**
- ☁️ Nextcloud (Self-Hosted)
- 📧 E-Mail an sich selbst
- 🗄️ Syncthing (Sync zwischen Geräten)
**⚠️ Vermeiden:**
- ❌ Google Drive / Dropbox (Privacy)
- ❌ Nur eine Kopie
- ❌ Nur auf Server (wenn Server ausfällt)
---
## 🔧 Erweiterte Nutzung
### Backup-Datei bearbeiten
Die `.json` Datei kann mit jedem Texteditor bearbeitet werden:
1. **Öffnen mit:** VS Code, Notepad++, nano
2. **Notizen hinzufügen/entfernen**
3. **Titel/Inhalt ändern**
4. **IDs anpassen** (für Migration)
5. **Speichern** und in App importieren
**⚠️ Wichtig:**
- Valides JSON-Format behalten
- IDs müssen eindeutig sein (UUIDs)
- Timestamps in Millisekunden (Unix Epoch)
### Bulk-Import
Mehrere Backups zusammenführen:
1. Backup 1 importieren (Modus: Zusammenführen)
2. Backup 2 importieren (Modus: Zusammenführen)
3. Backup 3 importieren (Modus: Zusammenführen)
4. Ergebnis: Alle Notizen vereint
### Server-Migration
Schritt-für-Schritt:
1. **Backup erstellen** auf altem Server
2. **Neuen Server einrichten** (siehe [QUICKSTART.md](QUICKSTART.md))
3. **Server-URL ändern** in App-Einstellungen
4. **Backup wiederherstellen** (Modus: Ersetzen)
5. **Sync testen** - Alle Notizen auf neuem Server
---
## ❌ Fehlerbehebung
### "Backup-Datei ungültig"
**Ursachen:**
- Korrupte JSON-Datei
- Falsche Datei-Endung (muss `.json` sein)
- Inkompatible App-Version
**Lösung:**
1. JSON-Datei mit Validator prüfen (z.B. jsonlint.com)
2. Dateiendung überprüfen
3. Backup mit aktueller App-Version erstellen
### "Keine Berechtigung zum Speichern"
**Ursachen:**
- Speicher-Berechtigung fehlt
- Schreibgeschützter Ordner
**Lösung:**
1. Android: Einstellungen → Apps → Simple Notes → Berechtigungen
2. "Speicher" aktivieren
3. Anderen Speicherort wählen
### "Import fehlgeschlagen"
**Ursachen:**
- Zu wenig Speicherplatz
- Korrupte Backup-Datei
- App-Crash während Import
**Lösung:**
1. Speicherplatz freigeben
2. Backup-Datei neu erstellen
3. App neu starten und erneut importieren
---
## 🔒 Sicherheit & Privacy
### Daten-Schutz
-**Lokal gespeichert** - Kein Cloud-Upload ohne deine Aktion
-**Keine Verschlüsselung** - Klartextformat für Lesbarkeit
- ⚠️ **Sensible Daten?** - Backup-Datei selbst verschlüsseln (z.B. 7-Zip mit Passwort)
### Empfehlungen
- 🔐 Backup-Dateien in verschlüsseltem Container speichern
- 🗑️ Alte Backups regelmäßig löschen
- 📧 Nicht per unverschlüsselter E-Mail versenden
- ☁️ Self-Hosted Cloud nutzen (Nextcloud)
---
## 📊 Technische Details
### Format-Spezifikation
**JSON-Struktur:**
```json
{
"version": "string", // App-Version beim Export
"exported_at": "ISO8601", // Zeitstempel des Exports
"notes_count": number, // Anzahl der Notizen
"notes": [
{
"id": "UUID", // Eindeutige ID
"title": "string", // Notiz-Titel
"content": "string", // Notiz-Inhalt
"createdAt": number, // Unix Timestamp (ms)
"updatedAt": number // Unix Timestamp (ms)
}
]
}
```
### Kompatibilität
- ✅ v1.2.0+ - Vollständig kompatibel
- ⚠️ v1.1.x - Grundfunktionen (ohne Auto-Backup)
- ❌ v1.0.x - Nicht unterstützt
---
**📚 Siehe auch:**
- [QUICKSTART.md](../QUICKSTART.md) - App-Installation und Einrichtung
- [FEATURES.md](FEATURES.md) - Vollständige Feature-Liste
- [DESKTOP.md](DESKTOP.md) - Desktop-Integration mit Markdown
**Letzte Aktualisierung:** v1.2.1 (2026-01-05)

View File

@@ -1,324 +0,0 @@
# Backup & Restore 💾
**🌍 Languages:** [Deutsch](BACKUP.md) · **English**
> Secure your notes locally - independent from the server
---
## 📋 Overview
The backup system works **completely offline** and independent from the WebDAV server. Perfect for:
- 📥 Regular backups
- 📤 Migration to new server
- 🔄 Recovery after data loss
- 💾 Archiving old notes
---
## 📥 Create Backup
### Step-by-Step
1. **Open settings** (⚙️ icon top right)
2. **Find "Backup & Restore"** section
3. **Tap "📥 Create backup"**
4. **Choose location:**
- 📁 Downloads
- 💳 SD card
- ☁️ Cloud folder (Nextcloud, Google Drive, etc.)
- 📧 Email as attachment
5. **Done!** Backup file is saved
### File Format
**Filename:** `simplenotes_backup_YYYY-MM-DD_HHmmss.json`
**Example:** `simplenotes_backup_2026-01-05_143022.json`
**Content:**
```json
{
"version": "1.2.1",
"exported_at": "2026-01-05T14:30:22Z",
"notes_count": 42,
"notes": [
{
"id": "abc-123-def",
"title": "Shopping List",
"content": "Milk\nBread\nCheese",
"createdAt": 1704467422000,
"updatedAt": 1704467422000
}
]
}
```
**Format details:**
- ✅ Human-readable (formatted JSON)
- ✅ All data included (title, content, IDs, timestamps)
- ✅ Version info for compatibility
- ✅ Note count for validation
---
## 📤 Restore Backup
### 3 Restore Modes
#### 1. Merge ⭐ _Recommended_
**What happens:**
- ✅ New notes from backup are added
- ✅ Existing notes remain unchanged
- ✅ No data loss
**When to use:**
- Import backup from another device
- Recover old notes
- Restore accidentally deleted notes
**Example:**
```
App: [Note A, Note B, Note C]
Backup: [Note A, Note D, Note E]
Result: [Note A, Note B, Note C, Note D, Note E]
```
#### 2. Replace
**What happens:**
- ❌ ALL existing notes are deleted
- ✅ Backup notes are imported
- ⚠️ Irreversible (except through auto-backup)
**When to use:**
- Server migration (complete restart)
- Return to old backup state
- App reinstallation
**Example:**
```
App: [Note A, Note B, Note C]
Backup: [Note X, Note Y]
Result: [Note X, Note Y]
```
**⚠️ Warning:** Automatic safety backup is created!
#### 3. Overwrite Duplicates
**What happens:**
- ✅ New notes from backup are added
- 🔄 On ID conflicts, backup wins
- ✅ Other notes remain unchanged
**When to use:**
- Backup is newer than app data
- Import desktop changes
- Conflict resolution
**Example:**
```
App: [Note A (v1), Note B, Note C]
Backup: [Note A (v2), Note D]
Result: [Note A (v2), Note B, Note C, Note D]
```
### Restore Process
1. **Settings****"📤 Restore from file"**
2. **Select backup file** (`.json`)
3. **Choose mode:**
- 🔵 Merge _(Default)_
- 🟡 Overwrite duplicates
- 🔴 Replace _(Caution!)_
4. **Confirm** - Automatic safety backup is created
5. **Wait** - Import runs
6. **Done!** - Success message with number of imported notes
---
## 🛡️ Automatic Safety Backup
**Before every restore:**
- ✅ Automatic backup is created
- 📁 Saved in: `Android/data/dev.dettmer.simplenotes/files/`
- 🏷️ Filename: `auto_backup_before_restore_YYYY-MM-DD_HHmmss.json`
- ⏱️ Timestamp: Right before restore
**Why?**
- Protection against accidental "Replace"
- Ability to undo
- Double security
**Access via file manager:**
```
/Android/data/dev.dettmer.simplenotes/files/auto_backup_before_restore_*.json
```
---
## 💡 Best Practices
### Backup Strategy
#### Regular Backups
```
Daily: ❌ Too often (server sync is enough)
Weekly: ✅ Recommended for important notes
Monthly: ✅ Archiving
Before updates: ✅ Safety
```
#### 3-2-1 Rule
1. **3 copies** - Original + 2 backups
2. **2 media** - e.g., SD card + cloud
3. **1 offsite** - e.g., cloud storage
### Backup Locations
**Local (fast):**
- 📱 Internal storage / Downloads
- 💳 SD card
- 🖥️ PC (via USB)
**Cloud (secure):**
- ☁️ Nextcloud (self-hosted)
- 📧 Email to yourself
- 🗄️ Syncthing (sync between devices)
**⚠️ Avoid:**
- ❌ Google Drive / Dropbox (privacy)
- ❌ Only one copy
- ❌ Only on server (if server fails)
---
## 🔧 Advanced Usage
### Edit Backup File
The `.json` file can be edited with any text editor:
1. **Open with:** VS Code, Notepad++, nano
2. **Add/remove notes**
3. **Change title/content**
4. **Adjust IDs** (for migration)
5. **Save** and import to app
**⚠️ Important:**
- Keep valid JSON format
- IDs must be unique (UUIDs)
- Timestamps in milliseconds (Unix Epoch)
### Bulk Import
Merge multiple backups:
1. Import backup 1 (Mode: Merge)
2. Import backup 2 (Mode: Merge)
3. Import backup 3 (Mode: Merge)
4. Result: All notes combined
### Server Migration
Step-by-step:
1. **Create backup** on old server
2. **Set up new server** (see [QUICKSTART.en.md](QUICKSTART.en.md))
3. **Change server URL** in app settings
4. **Restore backup** (Mode: Replace)
5. **Test sync** - All notes on new server
---
## ❌ Troubleshooting
### "Invalid backup file"
**Causes:**
- Corrupt JSON file
- Wrong file extension (must be `.json`)
- Incompatible app version
**Solution:**
1. Check JSON file with validator (e.g., jsonlint.com)
2. Verify file extension
3. Create backup with current app version
### "No permission to save"
**Causes:**
- Storage permission missing
- Write-protected folder
**Solution:**
1. Android: Settings → Apps → Simple Notes → Permissions
2. Activate "Storage"
3. Choose different location
### "Import failed"
**Causes:**
- Not enough storage space
- Corrupt backup file
- App crash during import
**Solution:**
1. Free up storage space
2. Create new backup file
3. Restart app and try again
---
## 🔒 Security & Privacy
### Data Protection
-**Locally stored** - No cloud upload without your action
-**No encryption** - Plain text format for readability
- ⚠️ **Sensitive data?** - Encrypt backup file yourself (e.g., 7-Zip with password)
### Recommendations
- 🔐 Store backup files in encrypted container
- 🗑️ Regularly delete old backups
- 📧 Don't send via unencrypted email
- ☁️ Use self-hosted cloud (Nextcloud)
---
## 📊 Technical Details
### Format Specification
**JSON structure:**
```json
{
"version": "string", // App version at export
"exported_at": "ISO8601", // Export timestamp
"notes_count": number, // Number of notes
"notes": [
{
"id": "UUID", // Unique ID
"title": "string", // Note title
"content": "string", // Note content
"createdAt": number, // Unix timestamp (ms)
"updatedAt": number // Unix timestamp (ms)
}
]
}
```
### Compatibility
- ✅ v1.2.0+ - Fully compatible
- ⚠️ v1.1.x - Basic functions (without auto-backup)
- ❌ v1.0.x - Not supported
---
**📚 See also:**
- [QUICKSTART.en.md](../QUICKSTART.en.md) - App installation and setup
- [FEATURES.en.md](FEATURES.en.md) - Complete feature list
- [DESKTOP.en.md](DESKTOP.en.md) - Desktop integration with Markdown
**Last update:** v1.2.1 (2026-01-05)

View File

@@ -1,42 +1,42 @@
# Backup & Wiederherstellung 💾
# Backup & Restore 💾
**🌍 Languages:** **Deutsch** · [English](BACKUP.en.md)
**🌍 Languages:** [Deutsch](BACKUP.de.md) · **English**
> Sichere deine Notizen lokal - unabhängig vom Server
> Secure your notes locally - independent from the server
---
## 📋 Übersicht
## 📋 Overview
Das Backup-System funktioniert **komplett offline** und unabhängig vom WebDAV-Server. Perfekt für:
- 📥 Regelmäßige Sicherungen
- 📤 Migration zu neuem Server
- 🔄 Wiederherstellung nach Datenverlust
- 💾 Archivierung alter Notizen
The backup system works **completely offline** and independent from the WebDAV server. Perfect for:
- 📥 Regular backups
- 📤 Migration to new server
- 🔄 Recovery after data loss
- 💾 Archiving old notes
---
## 📥 Backup erstellen
## 📥 Create Backup
### Schritt-für-Schritt
### Step-by-Step
1. **Einstellungen öffnen** (⚙️ Icon oben rechts)
2. **"Backup & Wiederherstellung"** Section finden
3. **"📥 Backup erstellen"** antippen
4. **Speicherort wählen:**
1. **Open settings** (⚙️ icon top right)
2. **Find "Backup & Restore"** section
3. **Tap "📥 Create backup"**
4. **Choose location:**
- 📁 Downloads
- 💳 SD-Karte
- ☁️ Cloud-Ordner (Nextcloud, Google Drive, etc.)
- 📧 E-Mail als Anhang
5. **Fertig!** Backup-Datei ist gespeichert
- 💳 SD card
- ☁️ Cloud folder (Nextcloud, Google Drive, etc.)
- 📧 Email as attachment
5. **Done!** Backup file is saved
### Dateiformat
### File Format
**Dateiname:** `simplenotes_backup_YYYY-MM-DD_HHmmss.json`
**Filename:** `simplenotes_backup_YYYY-MM-DD_HHmmss.json`
**Beispiel:** `simplenotes_backup_2026-01-05_143022.json`
**Example:** `simplenotes_backup_2026-01-05_143022.json`
**Inhalt:**
**Content:**
```json
{
"version": "1.2.1",
@@ -45,8 +45,8 @@ Das Backup-System funktioniert **komplett offline** und unabhängig vom WebDAV-S
"notes": [
{
"id": "abc-123-def",
"title": "Einkaufsliste",
"content": "Milch\nBrot\nKäse",
"title": "Shopping List",
"content": "Milk\nBread\nCheese",
"createdAt": 1704467422000,
"updatedAt": 1704467422000
}
@@ -54,105 +54,105 @@ Das Backup-System funktioniert **komplett offline** und unabhängig vom WebDAV-S
}
```
**Format-Details:**
-Menschenlesbar (formatiertes JSON)
- ✅ Alle Daten inklusive (Titel, Inhalt, IDs, Timestamps)
- ✅ Versions-Info für Kompatibilität
-Anzahl der Notizen für Validierung
**Format details:**
-Human-readable (formatted JSON)
- ✅ All data included (title, content, IDs, timestamps)
- ✅ Version info for compatibility
-Note count for validation
---
## 📤 Backup wiederherstellen
## 📤 Restore Backup
### 3 Wiederherstellungs-Modi
### 3 Restore Modes
#### 1. Zusammenführen (Merge) ⭐ _Empfohlen_
#### 1. Merge ⭐ _Recommended_
**Was passiert:**
- ✅ Neue Notizen aus Backup werden hinzugefügt
-Bestehende Notizen bleiben unverändert
-Keine Datenverluste
**What happens:**
- ✅ New notes from backup are added
-Existing notes remain unchanged
-No data loss
**Wann nutzen:**
- Backup von anderem Gerät einspielen
- Alte Notizen zurückholen
- Versehentlich gelöschte Notizen wiederherstellen
**When to use:**
- Import backup from another device
- Recover old notes
- Restore accidentally deleted notes
**Beispiel:**
**Example:**
```
App: [Notiz A, Notiz B, Notiz C]
Backup: [Notiz A, Notiz D, Notiz E]
Ergebnis: [Notiz A, Notiz B, Notiz C, Notiz D, Notiz E]
App: [Note A, Note B, Note C]
Backup: [Note A, Note D, Note E]
Result: [Note A, Note B, Note C, Note D, Note E]
```
#### 2. Ersetzen (Replace)
#### 2. Replace
**Was passiert:**
- ❌ ALLE bestehenden Notizen werden gelöscht
- ✅ Backup-Notizen werden importiert
- ⚠️ Unwiderruflich (außer durch Auto-Backup)
**What happens:**
- ❌ ALL existing notes are deleted
- ✅ Backup notes are imported
- ⚠️ Irreversible (except through auto-backup)
**Wann nutzen:**
- Server-Wechsel (kompletter Neustart)
- Zurück zu altem Backup-Stand
- App-Neuinstallation
**When to use:**
- Server migration (complete restart)
- Return to old backup state
- App reinstallation
**Beispiel:**
**Example:**
```
App: [Notiz A, Notiz B, Notiz C]
Backup: [Notiz X, Notiz Y]
Ergebnis: [Notiz X, Notiz Y]
App: [Note A, Note B, Note C]
Backup: [Note X, Note Y]
Result: [Note X, Note Y]
```
**⚠️ Warnung:** Automatisches Sicherheits-Backup wird erstellt!
**⚠️ Warning:** Automatic safety backup is created!
#### 3. Duplikate überschreiben (Overwrite)
#### 3. Overwrite Duplicates
**Was passiert:**
- ✅ Neue Notizen aus Backup werden hinzugefügt
- 🔄 Bei ID-Konflikten gewinnt das Backup
-Andere Notizen bleiben unverändert
**What happens:**
- ✅ New notes from backup are added
- 🔄 On ID conflicts, backup wins
-Other notes remain unchanged
**Wann nutzen:**
- Backup ist neuer als App-Daten
- Desktop-Änderungen einspielen
- Konflikt-Auflösung
**When to use:**
- Backup is newer than app data
- Import desktop changes
- Conflict resolution
**Beispiel:**
**Example:**
```
App: [Notiz A (v1), Notiz B, Notiz C]
Backup: [Notiz A (v2), Notiz D]
Ergebnis: [Notiz A (v2), Notiz B, Notiz C, Notiz D]
App: [Note A (v1), Note B, Note C]
Backup: [Note A (v2), Note D]
Result: [Note A (v2), Note B, Note C, Note D]
```
### Wiederherstellungs-Prozess
### Restore Process
1. **Einstellungen****"📤 Aus Datei wiederherstellen"**
2. **Backup-Datei auswählen** (`.json`)
3. **Modus wählen:**
- 🔵 Zusammenführen _(Standard)_
- 🟡 Duplikate überschreiben
- 🔴 Ersetzen _(Vorsicht!)_
4. **Bestätigen** - Automatisches Sicherheits-Backup wird erstellt
5. **Warten** - Import läuft
6. **Fertig!** - Erfolgsmeldung mit Anzahl importierter Notizen
1. **Settings****"📤 Restore from file"**
2. **Select backup file** (`.json`)
3. **Choose mode:**
- 🔵 Merge _(Default)_
- 🟡 Overwrite duplicates
- 🔴 Replace _(Caution!)_
4. **Confirm** - Automatic safety backup is created
5. **Wait** - Import runs
6. **Done!** - Success message with number of imported notes
---
## 🛡️ Automatisches Sicherheits-Backup
## 🛡️ Automatic Safety Backup
**Vor jeder Wiederherstellung:**
- ✅ Automatisches Backup wird erstellt
- 📁 Gespeichert in: `Android/data/dev.dettmer.simplenotes/files/`
- 🏷️ Dateiname: `auto_backup_before_restore_YYYY-MM-DD_HHmmss.json`
- ⏱️ Zeitstempel: Direkt vor Wiederherstellung
**Before every restore:**
- ✅ Automatic backup is created
- 📁 Saved in: `Android/data/dev.dettmer.simplenotes/files/`
- 🏷️ Filename: `auto_backup_before_restore_YYYY-MM-DD_HHmmss.json`
- ⏱️ Timestamp: Right before restore
**Warum?**
- Schutz vor versehentlichem "Ersetzen"
- Möglichkeit zum Rückgängigmachen
- Doppelte Sicherheit
**Why?**
- Protection against accidental "Replace"
- Ability to undo
- Double security
**Zugriff via Dateimanager:**
**Access via file manager:**
```
/Android/data/dev.dettmer.simplenotes/files/auto_backup_before_restore_*.json
```
@@ -161,164 +161,164 @@ Ergebnis: [Notiz A (v2), Notiz B, Notiz C, Notiz D]
## 💡 Best Practices
### Backup-Strategie
### Backup Strategy
#### Regelmäßige Backups
#### Regular Backups
```
Täglich: ❌ Zu oft (Server-Sync reicht)
Wöchentlich: ✅ Empfohlen für wichtige Notizen
Monatlich: ✅ Archivierung
Vor Updates: ✅ Sicherheit
Daily: ❌ Too often (server sync is enough)
Weekly: ✅ Recommended for important notes
Monthly: ✅ Archiving
Before updates: ✅ Safety
```
#### 3-2-1 Regel
1. **3 Kopien** - Original + 2 Backups
2. **2 Medien** - z.B. SD-Karte + Cloud
3. **1 Offsite** - z.B. Cloud-Speicher
#### 3-2-1 Rule
1. **3 copies** - Original + 2 backups
2. **2 media** - e.g., SD card + cloud
3. **1 offsite** - e.g., cloud storage
### Backup-Speicherorte
### Backup Locations
**Lokal (schnell):**
- 📱 Internal Storage / Downloads
- 💳 SD-Karte
**Local (fast):**
- 📱 Internal storage / Downloads
- 💳 SD card
- 🖥️ PC (via USB)
**Cloud (sicher):**
- ☁️ Nextcloud (Self-Hosted)
- 📧 E-Mail an sich selbst
- 🗄️ Syncthing (Sync zwischen Geräten)
**Cloud (secure):**
- ☁️ Nextcloud (self-hosted)
- 📧 Email to yourself
- 🗄️ Syncthing (sync between devices)
**⚠️ Vermeiden:**
- ❌ Google Drive / Dropbox (Privacy)
-Nur eine Kopie
-Nur auf Server (wenn Server ausfällt)
**⚠️ Avoid:**
- ❌ Google Drive / Dropbox (privacy)
-Only one copy
-Only on server (if server fails)
---
## 🔧 Erweiterte Nutzung
## 🔧 Advanced Usage
### Backup-Datei bearbeiten
### Edit Backup File
Die `.json` Datei kann mit jedem Texteditor bearbeitet werden:
The `.json` file can be edited with any text editor:
1. **Öffnen mit:** VS Code, Notepad++, nano
2. **Notizen hinzufügen/entfernen**
3. **Titel/Inhalt ändern**
4. **IDs anpassen** (für Migration)
5. **Speichern** und in App importieren
1. **Open with:** VS Code, Notepad++, nano
2. **Add/remove notes**
3. **Change title/content**
4. **Adjust IDs** (for migration)
5. **Save** and import to app
**⚠️ Wichtig:**
- Valides JSON-Format behalten
- IDs müssen eindeutig sein (UUIDs)
- Timestamps in Millisekunden (Unix Epoch)
**⚠️ Important:**
- Keep valid JSON format
- IDs must be unique (UUIDs)
- Timestamps in milliseconds (Unix Epoch)
### Bulk-Import
### Bulk Import
Mehrere Backups zusammenführen:
Merge multiple backups:
1. Backup 1 importieren (Modus: Zusammenführen)
2. Backup 2 importieren (Modus: Zusammenführen)
3. Backup 3 importieren (Modus: Zusammenführen)
4. Ergebnis: Alle Notizen vereint
1. Import backup 1 (Mode: Merge)
2. Import backup 2 (Mode: Merge)
3. Import backup 3 (Mode: Merge)
4. Result: All notes combined
### Server-Migration
### Server Migration
Schritt-für-Schritt:
Step-by-step:
1. **Backup erstellen** auf altem Server
2. **Neuen Server einrichten** (siehe [QUICKSTART.md](QUICKSTART.md))
3. **Server-URL ändern** in App-Einstellungen
4. **Backup wiederherstellen** (Modus: Ersetzen)
5. **Sync testen** - Alle Notizen auf neuem Server
1. **Create backup** on old server
2. **Set up new server** (see [QUICKSTART.en.md](QUICKSTART.en.md))
3. **Change server URL** in app settings
4. **Restore backup** (Mode: Replace)
5. **Test sync** - All notes on new server
---
## ❌ Fehlerbehebung
## ❌ Troubleshooting
### "Backup-Datei ungültig"
### "Invalid backup file"
**Ursachen:**
- Korrupte JSON-Datei
- Falsche Datei-Endung (muss `.json` sein)
- Inkompatible App-Version
**Causes:**
- Corrupt JSON file
- Wrong file extension (must be `.json`)
- Incompatible app version
**Lösung:**
1. JSON-Datei mit Validator prüfen (z.B. jsonlint.com)
2. Dateiendung überprüfen
3. Backup mit aktueller App-Version erstellen
**Solution:**
1. Check JSON file with validator (e.g., jsonlint.com)
2. Verify file extension
3. Create backup with current app version
### "Keine Berechtigung zum Speichern"
### "No permission to save"
**Ursachen:**
- Speicher-Berechtigung fehlt
- Schreibgeschützter Ordner
**Causes:**
- Storage permission missing
- Write-protected folder
**Lösung:**
1. Android: Einstellungen → Apps → Simple Notes → Berechtigungen
2. "Speicher" aktivieren
3. Anderen Speicherort wählen
**Solution:**
1. Android: Settings → Apps → Simple Notes → Permissions
2. Activate "Storage"
3. Choose different location
### "Import fehlgeschlagen"
### "Import failed"
**Ursachen:**
- Zu wenig Speicherplatz
- Korrupte Backup-Datei
- App-Crash während Import
**Causes:**
- Not enough storage space
- Corrupt backup file
- App crash during import
**Lösung:**
1. Speicherplatz freigeben
2. Backup-Datei neu erstellen
3. App neu starten und erneut importieren
**Solution:**
1. Free up storage space
2. Create new backup file
3. Restart app and try again
---
## 🔒 Sicherheit & Privacy
## 🔒 Security & Privacy
### Daten-Schutz
-**Lokal gespeichert** - Kein Cloud-Upload ohne deine Aktion
-**Keine Verschlüsselung** - Klartextformat für Lesbarkeit
- ⚠️ **Sensible Daten?** - Backup-Datei selbst verschlüsseln (z.B. 7-Zip mit Passwort)
### Data Protection
-**Locally stored** - No cloud upload without your action
-**No encryption** - Plain text format for readability
- ⚠️ **Sensitive data?** - Encrypt backup file yourself (e.g., 7-Zip with password)
### Empfehlungen
- 🔐 Backup-Dateien in verschlüsseltem Container speichern
- 🗑️ Alte Backups regelmäßig löschen
- 📧 Nicht per unverschlüsselter E-Mail versenden
- ☁️ Self-Hosted Cloud nutzen (Nextcloud)
### Recommendations
- 🔐 Store backup files in encrypted container
- 🗑️ Regularly delete old backups
- 📧 Don't send via unencrypted email
- ☁️ Use self-hosted cloud (Nextcloud)
---
## 📊 Technische Details
## 📊 Technical Details
### Format-Spezifikation
### Format Specification
**JSON-Struktur:**
**JSON structure:**
```json
{
"version": "string", // App-Version beim Export
"exported_at": "ISO8601", // Zeitstempel des Exports
"notes_count": number, // Anzahl der Notizen
"version": "string", // App version at export
"exported_at": "ISO8601", // Export timestamp
"notes_count": number, // Number of notes
"notes": [
{
"id": "UUID", // Eindeutige ID
"title": "string", // Notiz-Titel
"content": "string", // Notiz-Inhalt
"createdAt": number, // Unix Timestamp (ms)
"updatedAt": number // Unix Timestamp (ms)
"id": "UUID", // Unique ID
"title": "string", // Note title
"content": "string", // Note content
"createdAt": number, // Unix timestamp (ms)
"updatedAt": number // Unix timestamp (ms)
}
]
}
```
### Kompatibilität
- ✅ v1.2.0+ - Vollständig kompatibel
- ⚠️ v1.1.x - Grundfunktionen (ohne Auto-Backup)
- ❌ v1.0.x - Nicht unterstützt
### Compatibility
- ✅ v1.2.0+ - Fully compatible
- ⚠️ v1.1.x - Basic functions (without auto-backup)
- ❌ v1.0.x - Not supported
---
**📚 Siehe auch:**
- [QUICKSTART.md](../QUICKSTART.md) - App-Installation und Einrichtung
- [FEATURES.md](FEATURES.md) - Vollständige Feature-Liste
- [DESKTOP.md](DESKTOP.md) - Desktop-Integration mit Markdown
**📚 See also:**
- [QUICKSTART.en.md](../QUICKSTART.en.md) - App installation and setup
- [FEATURES.en.md](FEATURES.en.md) - Complete feature list
- [DESKTOP.en.md](DESKTOP.en.md) - Desktop integration with Markdown
**Letzte Aktualisierung:** v1.2.1 (2026-01-05)
**Last update:** v1.2.1 (2026-01-05)

505
docs/DESKTOP.de.md Normal file
View File

@@ -0,0 +1,505 @@
# Desktop-Integration 🖥️
**🌍 Sprachen:** **Deutsch** · [English](DESKTOP.md)
> Bearbeite deine Notizen mit jedem Markdown-Editor auf dem Desktop
---
## 📋 Übersicht
Die Desktop-Integration ermöglicht dir, Notizen auf dem PC/Mac zu bearbeiten:
- 📝 Jeder Markdown-Editor funktioniert
- 🔄 Automatische Synchronisation über WebDAV
- 💾 Dual-Format: JSON (Master) + Markdown (Mirror)
- ⚡ Last-Write-Wins Konfliktauflösung
---
## 🎯 Warum Markdown?
### Dual-Format Architektur
```
┌─────────────────────────────────────┐
│ Android App │
│ │
│ ┌──────────┐ ┌─────────────┐ │
│ │ JSON │ ──→ │ Markdown │ │
│ │ (Master) │ │ (Mirror) │ │
│ └──────────┘ └─────────────┘ │
└────────┬────────────────┬───────────┘
│ │
↓ ↓
WebDAV Server
│ │
┌────┴────┐ ┌────┴──────┐
│ /notes/ │ │ /notes-md/│
│ *.json │ │ *.md │
└─────────┘ └───────────┘
↑ ↑
│ │
┌────┴────────────────┴───────────┐
│ Desktop Editor │
│ (VS Code, Typora, etc.) │
└──────────────────────────────────┘
```
### Vorteile
**JSON (Master):**
- ✅ Zuverlässig und schnell
- ✅ Strukturierte Daten (IDs, Timestamps)
- ✅ Primärer Sync-Mechanismus
- ✅ Immer aktiv
**Markdown (Mirror):**
- ✅ Menschenlesbar
- ✅ Desktop-Editor kompatibel
- ✅ Syntax-Highlighting
- ✅ Optional aktivierbar
---
## 🚀 Schnellstart
### 1. Erste Synchronisation
**Wichtig:** Führe ZUERST einen Sync durch, bevor du Desktop-Integration aktivierst!
1. **App einrichten** (siehe [QUICKSTART.md](QUICKSTART.md))
2. **Server-Verbindung testen**
3. **Erste Notiz erstellen**
4. **Synchronisieren** (Pull-to-Refresh oder Auto-Sync)
5. ✅ Server erstellt automatisch `/notes/` und `/notes-md/` Ordner
### 2. Desktop-Integration aktivieren
1. **Einstellungen****Desktop-Integration**
2. **Toggle aktivieren**
3. **Initial Export startet** - Zeigt Progress (X/Y)
4. ✅ Alle bestehenden Notizen werden als `.md` exportiert
### 3. WebDAV als Netzlaufwerk mounten
#### Windows
```
1. Explorer öffnen
2. Rechtsklick auf "Dieser PC"
3. "Netzlaufwerk verbinden"
4. URL eingeben: http://DEIN-SERVER:8080/notes-md/
5. Benutzername: noteuser
6. Passwort: (dein WebDAV-Passwort)
7. Laufwerksbuchstabe: Z:\ (oder beliebig)
8. Fertig!
```
**Zugriff:** `Z:\` im Explorer
#### macOS
```
1. Finder öffnen
2. Menü "Gehe zu" → "Mit Server verbinden" (⌘K)
3. Server-Adresse: http://DEIN-SERVER:8080/notes-md/
4. Verbinden
5. Benutzername: noteuser
6. Passwort: (dein WebDAV-Passwort)
7. Fertig!
```
**Zugriff:** Finder → Netzwerk → notes-md
#### Linux (GNOME)
```
1. Files / Nautilus öffnen
2. "Andere Orte"
3. "Mit Server verbinden"
4. Server-Adresse: dav://DEIN-SERVER:8080/notes-md/
5. Benutzername: noteuser
6. Passwort: (dein WebDAV-Passwort)
7. Fertig!
```
**Zugriff:** `/run/user/1000/gvfs/dav:host=...`
#### Linux (davfs2 - permanent)
```bash
# Installation
sudo apt install davfs2
# Mount-Point erstellen
sudo mkdir -p /mnt/notes-md
# Einmalig mounten
sudo mount -t davfs http://DEIN-SERVER:8080/notes-md/ /mnt/notes-md
# Permanent in /etc/fstab
echo "http://DEIN-SERVER:8080/notes-md/ /mnt/notes-md davfs rw,user,noauto 0 0" | sudo tee -a /etc/fstab
```
**Zugriff:** `/mnt/notes-md/`
---
## 📝 Markdown-Editoren
### Empfohlene Editoren
#### 1. VS Code ⭐ _Empfohlen_
**Vorteile:**
- ✅ Kostenlos & Open Source
- ✅ Markdown-Preview (Ctrl+Shift+V)
- ✅ Syntax-Highlighting
- ✅ Git-Integration
- ✅ Erweiterungen (Spell Check, etc.)
**Setup:**
```
1. VS Code installieren
2. WebDAV-Laufwerk mounten
3. Ordner öffnen: Z:\notes-md\ (Windows) oder /mnt/notes-md (Linux)
4. Fertig! Markdown-Dateien bearbeiten
```
**Extensions (optional):**
- `Markdown All in One` - Shortcuts & Preview
- `Markdown Preview Enhanced` - Bessere Preview
- `Code Spell Checker` - Rechtschreibprüfung
#### 2. Typora
**Vorteile:**
- ✅ WYSIWYG Markdown-Editor
- ✅ Minimalistisches Design
- ✅ Live-Preview
- ⚠️ Kostenpflichtig (~15€)
**Setup:**
```
1. Typora installieren
2. WebDAV mounten
3. Ordner in Typora öffnen
4. Notizen bearbeiten
```
#### 3. Notepad++
**Vorteile:**
- ✅ Leichtgewichtig
- ✅ Schnell
- ✅ Syntax-Highlighting
- ⚠️ Keine Markdown-Preview
**Setup:**
```
1. Notepad++ installieren
2. WebDAV mounten
3. Dateien direkt öffnen
```
#### 4. Obsidian
**Vorteile:**
- ✅ Zweite Gehirn-Philosophie
- ✅ Graph-View für Verlinkungen
- ✅ Viele Plugins
- ⚠️ Sync-Konflikte möglich (2 Master)
**Setup:**
```
1. Obsidian installieren
2. WebDAV als Vault öffnen
3. Vorsicht: Obsidian erstellt eigene Metadaten!
```
**⚠️ Nicht empfohlen:** Kann Frontmatter verändern
---
## 📄 Markdown-Dateiformat
### Struktur
Jede Notiz wird als `.md` Datei mit YAML-Frontmatter exportiert:
```markdown
---
id: abc-123-def-456
created: 2026-01-05T14:30:22Z
updated: 2026-01-05T14:30:22Z
tags: []
---
# Notiz-Titel
Notiz-Inhalt hier...
```
### Frontmatter-Felder
| Feld | Typ | Beschreibung | Pflicht |
|------|-----|--------------|---------|
| `id` | UUID | Eindeutige Notiz-ID | ✅ Ja |
| `created` | ISO8601 | Erstellungsdatum | ✅ Ja |
| `updated` | ISO8601 | Änderungsdatum | ✅ Ja |
| `tags` | Array | Tags (zukünftig) | ❌ Nein |
### Dateinamen
**Sanitization-Regeln:**
```
Titel: "Meine Einkaufsliste 🛒"
→ Dateiname: "Meine_Einkaufsliste.md"
Entfernt werden:
- Emojis: 🛒 → entfernt
- Sonderzeichen: / \ : * ? " < > | → entfernt
- Mehrfache Leerzeichen → einzelnes Leerzeichen
- Leerzeichen → Unterstrich _
```
**Beispiele:**
```
"Meeting Notes 2026" → "Meeting_Notes_2026.md"
"To-Do: Projekt" → "To-Do_Projekt.md"
"Urlaub ☀️" → "Urlaub.md"
```
---
## 🔄 Synchronisation
### Workflow: Android → Desktop
1. **Notiz in App erstellen/bearbeiten**
2. **Sync ausführen** (Auto oder manuell)
3. **JSON wird hochgeladen** (`/notes/abc-123.json`)
4. **Markdown wird exportiert** (`/notes-md/Notiz_Titel.md`) _(nur wenn Desktop-Integration AN)_
5. **Desktop-Editor zeigt Änderungen** (nach Refresh)
### Workflow: Desktop → Android
1. **Markdown-Datei bearbeiten** (im gemounteten Ordner)
2. **Speichern** - Datei liegt sofort auf Server
3. **In App: Markdown-Import ausführen**
- Einstellungen → "Import Markdown Changes"
- Oder: Auto-Import bei jedem Sync (zukünftig)
4. **App übernimmt Änderungen** (wenn Desktop-Version neuer)
### Konfliktauflösung: Last-Write-Wins
**Regel:** Neueste Version (nach `updated` Timestamp) gewinnt
**Beispiel:**
```
App-Version: updated: 2026-01-05 14:00
Desktop-Version: updated: 2026-01-05 14:30
→ Desktop gewinnt (neuerer Timestamp)
```
**Automatisch:**
- ✅ Beim Markdown-Import
- ✅ Beim JSON-Sync
- ⚠️ Keine Merge-Konflikte - nur komplettes Überschreiben
---
## ⚙️ Einstellungen
### Desktop-Integration Toggle
**Einstellungen → Desktop-Integration**
**AN (aktiviert):**
- ✅ Neue Notizen → automatisch als `.md` exportiert
- ✅ Aktualisierte Notizen → `.md` Update
- ✅ Gelöschte Notizen → `.md` bleibt (zukünftig: auch löschen)
**AUS (deaktiviert):**
- ❌ Kein Markdown-Export
- ✅ JSON-Sync läuft normal weiter
- ✅ Bestehende `.md` Dateien bleiben erhalten
### Initial Export
**Was passiert beim Aktivieren:**
1. Alle bestehenden Notizen werden gescannt
2. Progress-Dialog zeigt Fortschritt (z.B. "23/42")
3. Jede Notiz wird als `.md` exportiert
4. Bei Fehlern: Einzelne Notiz wird übersprungen
5. Erfolgsmeldung mit Anzahl exportierter Notizen
**Zeit:** ~1-2 Sekunden pro 50 Notizen
---
## 🛠️ Erweiterte Nutzung
### Manuelle Markdown-Erstellung
Du kannst `.md` Dateien manuell erstellen:
```markdown
---
id: 00000000-0000-0000-0000-000000000001
created: 2026-01-05T12:00:00Z
updated: 2026-01-05T12:00:00Z
---
# Neue Desktop-Notiz
Inhalt hier...
```
**⚠️ Wichtig:**
- `id` muss gültige UUID sein (z.B. mit uuidgen.io)
- Timestamps in ISO8601-Format
- Frontmatter mit `---` umschließen
### Bulk-Operations
**Mehrere Notizen auf einmal bearbeiten:**
1. WebDAV mounten
2. Alle `.md` Dateien in VS Code öffnen
3. Suchen & Ersetzen über alle Dateien (Ctrl+Shift+H)
4. Speichern
5. In App: "Import Markdown Changes"
### Scripting
**Beispiel: Alle Notizen nach Datum sortieren**
```bash
#!/bin/bash
cd /mnt/notes-md/
# Alle .md Dateien nach Update-Datum sortieren
for file in *.md; do
updated=$(grep "^updated:" "$file" | cut -d' ' -f2)
echo "$updated $file"
done | sort
```
---
## ❌ Fehlerbehebung
### "404 Not Found" beim WebDAV-Mount
**Ursache:** `/notes-md/` Ordner existiert nicht
**Lösung:**
1. **Erste Sync durchführen** - Ordner wird automatisch erstellt
2. ODER: Manuell erstellen via Terminal:
```bash
curl -X MKCOL -u noteuser:password http://server:8080/notes-md/
```
### Markdown-Dateien erscheinen nicht
**Ursache:** Desktop-Integration nicht aktiviert
**Lösung:**
1. Einstellungen → "Desktop-Integration" AN
2. Warten auf Initial Export
3. WebDAV-Ordner refreshen
### Änderungen vom Desktop erscheinen nicht in App
**Ursache:** Markdown-Import nicht ausgeführt
**Lösung:**
1. Einstellungen → "Import Markdown Changes"
2. ODER: Auto-Sync abwarten (zukünftiges Feature)
### "Frontmatter fehlt" Fehler
**Ursache:** `.md` Datei ohne gültiges YAML-Frontmatter
**Lösung:**
1. Datei in Editor öffnen
2. Frontmatter am Anfang hinzufügen:
```yaml
---
id: NEUE-UUID-HIER
created: 2026-01-05T12:00:00Z
updated: 2026-01-05T12:00:00Z
---
```
3. Speichern und erneut importieren
---
## 🔒 Sicherheit & Best Practices
### Do's ✅
- ✅ **Backup vor Bulk-Edits** - Lokales Backup erstellen
- ✅ **Ein Editor zur Zeit** - Nicht parallel in App UND Desktop bearbeiten
- ✅ **Sync abwarten** - Vor Desktop-Bearbeitung Sync durchführen
- ✅ **Frontmatter respektieren** - Nicht manuell ändern (außer du weißt was du tust)
### Don'ts ❌
- ❌ **Parallel bearbeiten** - App und Desktop gleichzeitig → Konflikte
- ❌ **Frontmatter löschen** - Notiz kann nicht mehr importiert werden
- ❌ **IDs ändern** - Notiz wird als neue erkannt
- ❌ **Timestamps manipulieren** - Konfliktauflösung funktioniert nicht
### Empfohlener Workflow
```
1. Sync in App (Pull-to-Refresh)
2. Desktop öffnen
3. Änderungen machen
4. Speichern
5. In App: "Import Markdown Changes"
6. Überprüfen
7. Weiteren Sync durchführen
```
---
## 📊 Vergleich: JSON vs Markdown
| Aspekt | JSON | Markdown |
|--------|------|----------|
| **Format** | Strukturiert | Fließtext |
| **Lesbarkeit (Mensch)** | ⚠️ Mittel | ✅ Gut |
| **Lesbarkeit (Maschine)** | ✅ Perfekt | ⚠️ Parsing nötig |
| **Metadata** | Native | Frontmatter |
| **Editoren** | Code-Editoren | Alle Text-Editoren |
| **Sync-Geschwindigkeit** | ✅ Schnell | ⚠️ Langsamer |
| **Zuverlässigkeit** | ✅ 100% | ⚠️ Frontmatter-Fehler möglich |
| **Mobile-First** | ✅ Ja | ❌ Nein |
| **Desktop-First** | ❌ Nein | ✅ Ja |
**Fazit:** Beide Formate nutzen = Beste Erfahrung auf beiden Plattformen!
---
## 🔮 Zukünftige Features
Geplant für v1.3.0+:
-**Auto-Markdown-Import** - Bei jedem Sync automatisch
-**Bidirektionaler Sync** - Ohne manuellen Import
-**Markdown-Vorschau** - In der App
-**Konflikts-UI** - Bei gleichzeitigen Änderungen
-**Tags in Frontmatter** - Synchronisiert mit App
-**Attachments** - Bilder/Dateien in Markdown
---
**📚 Siehe auch:**
- [QUICKSTART.md](../QUICKSTART.md) - App-Einrichtung
- [FEATURES.md](FEATURES.md) - Vollständige Feature-Liste
- [BACKUP.md](BACKUP.md) - Backup & Wiederherstellung
**Letzte Aktualisierung:** v1.2.1 (2026-01-05)

View File

@@ -1,505 +0,0 @@
# Desktop Integration 🖥️
**🌍 Languages:** [Deutsch](DESKTOP.md) · **English**
> Edit your notes with any Markdown editor on desktop
---
## 📋 Overview
Desktop integration allows you to edit notes on PC/Mac:
- 📝 Any Markdown editor works
- 🔄 Automatic synchronization via WebDAV
- 💾 Dual-format: JSON (master) + Markdown (mirror)
- ⚡ Last-Write-Wins conflict resolution
---
## 🎯 Why Markdown?
### Dual-Format Architecture
```
┌─────────────────────────────────────┐
│ Android App │
│ │
│ ┌──────────┐ ┌─────────────┐ │
│ │ JSON │ ──→ │ Markdown │ │
│ │ (Master) │ │ (Mirror) │ │
│ └──────────┘ └─────────────┘ │
└────────┬────────────────┬───────────┘
│ │
↓ ↓
WebDAV Server
│ │
┌────┴────┐ ┌────┴──────┐
│ /notes/ │ │ /notes-md/│
│ *.json │ │ *.md │
└─────────┘ └───────────┘
↑ ↑
│ │
┌────┴────────────────┴───────────┐
│ Desktop Editor │
│ (VS Code, Typora, etc.) │
└──────────────────────────────────┘
```
### Advantages
**JSON (Master):**
- ✅ Reliable and fast
- ✅ Structured data (IDs, timestamps)
- ✅ Primary sync mechanism
- ✅ Always active
**Markdown (Mirror):**
- ✅ Human-readable
- ✅ Desktop editor compatible
- ✅ Syntax highlighting
- ✅ Optionally activatable
---
## 🚀 Quick Start
### 1. First Synchronization
**Important:** Perform a sync FIRST before activating desktop integration!
1. **Set up app** (see [QUICKSTART.en.md](QUICKSTART.en.md))
2. **Test server connection**
3. **Create first note**
4. **Synchronize** (pull-to-refresh or auto-sync)
5. ✅ Server automatically creates `/notes/` and `/notes-md/` folders
### 2. Activate Desktop Integration
1. **Settings****Desktop Integration**
2. **Toggle ON**
3. **Initial export starts** - Shows progress (X/Y)
4. ✅ All existing notes are exported as `.md`
### 3. Mount WebDAV as Network Drive
#### Windows
```
1. Open Explorer
2. Right-click on "This PC"
3. "Map network drive"
4. Enter URL: http://YOUR-SERVER:8080/notes-md/
5. Username: noteuser
6. Password: (your WebDAV password)
7. Drive letter: Z:\ (or any)
8. Done!
```
**Access:** `Z:\` in Explorer
#### macOS
```
1. Open Finder
2. Menu "Go" → "Connect to Server" (⌘K)
3. Server address: http://YOUR-SERVER:8080/notes-md/
4. Connect
5. Username: noteuser
6. Password: (your WebDAV password)
7. Done!
```
**Access:** Finder → Network → notes-md
#### Linux (GNOME)
```
1. Open Files / Nautilus
2. "Other Locations"
3. "Connect to Server"
4. Server address: dav://YOUR-SERVER:8080/notes-md/
5. Username: noteuser
6. Password: (your WebDAV password)
7. Done!
```
**Access:** `/run/user/1000/gvfs/dav:host=...`
#### Linux (davfs2 - permanent)
```bash
# Installation
sudo apt install davfs2
# Create mount point
sudo mkdir -p /mnt/notes-md
# Mount once
sudo mount -t davfs http://YOUR-SERVER:8080/notes-md/ /mnt/notes-md
# Permanent in /etc/fstab
echo "http://YOUR-SERVER:8080/notes-md/ /mnt/notes-md davfs rw,user,noauto 0 0" | sudo tee -a /etc/fstab
```
**Access:** `/mnt/notes-md/`
---
## 📝 Markdown Editors
### Recommended Editors
#### 1. VS Code ⭐ _Recommended_
**Advantages:**
- ✅ Free & open source
- ✅ Markdown preview (Ctrl+Shift+V)
- ✅ Syntax highlighting
- ✅ Git integration
- ✅ Extensions (spell check, etc.)
**Setup:**
```
1. Install VS Code
2. Mount WebDAV drive
3. Open folder: Z:\notes-md\ (Windows) or /mnt/notes-md (Linux)
4. Done! Edit Markdown files
```
**Extensions (optional):**
- `Markdown All in One` - Shortcuts & preview
- `Markdown Preview Enhanced` - Better preview
- `Code Spell Checker` - Spell checking
#### 2. Typora
**Advantages:**
- ✅ WYSIWYG Markdown editor
- ✅ Minimalist design
- ✅ Live preview
- ⚠️ Paid (~15€)
**Setup:**
```
1. Install Typora
2. Mount WebDAV
3. Open folder in Typora
4. Edit notes
```
#### 3. Notepad++
**Advantages:**
- ✅ Lightweight
- ✅ Fast
- ✅ Syntax highlighting
- ⚠️ No Markdown preview
**Setup:**
```
1. Install Notepad++
2. Mount WebDAV
3. Open files directly
```
#### 4. Obsidian
**Advantages:**
- ✅ Second brain philosophy
- ✅ Graph view for links
- ✅ Many plugins
- ⚠️ Sync conflicts possible (2 masters)
**Setup:**
```
1. Install Obsidian
2. Open WebDAV as vault
3. Caution: Obsidian creates own metadata!
```
**⚠️ Not recommended:** Can alter frontmatter
---
## 📄 Markdown File Format
### Structure
Each note is exported as `.md` file with YAML frontmatter:
```markdown
---
id: abc-123-def-456
created: 2026-01-05T14:30:22Z
updated: 2026-01-05T14:30:22Z
tags: []
---
# Note Title
Note content here...
```
### Frontmatter Fields
| Field | Type | Description | Required |
|-------|------|-------------|----------|
| `id` | UUID | Unique note ID | ✅ Yes |
| `created` | ISO8601 | Creation date | ✅ Yes |
| `updated` | ISO8601 | Modification date | ✅ Yes |
| `tags` | Array | Tags (future) | ❌ No |
### Filenames
**Sanitization rules:**
```
Title: "My Shopping List 🛒"
→ Filename: "My_Shopping_List.md"
Removed:
- Emojis: 🛒 → removed
- Special chars: / \ : * ? " < > | → removed
- Multiple spaces → single space
- Spaces → underscore _
```
**Examples:**
```
"Meeting Notes 2026" → "Meeting_Notes_2026.md"
"To-Do: Project" → "To-Do_Project.md"
"Vacation ☀️" → "Vacation.md"
```
---
## 🔄 Synchronization
### Workflow: Android → Desktop
1. **Create/edit note in app**
2. **Run sync** (auto or manual)
3. **JSON is uploaded** (`/notes/abc-123.json`)
4. **Markdown is exported** (`/notes-md/Note_Title.md`) _(only if Desktop Integration ON)_
5. **Desktop editor shows changes** (after refresh)
### Workflow: Desktop → Android
1. **Edit Markdown file** (in mounted folder)
2. **Save** - File is immediately on server
3. **In app: Run Markdown import**
- Settings → "Import Markdown Changes"
- Or: Auto-import on every sync (future)
4. **App adopts changes** (if desktop version is newer)
### Conflict Resolution: Last-Write-Wins
**Rule:** Newest version (by `updated` timestamp) wins
**Example:**
```
App version: updated: 2026-01-05 14:00
Desktop version: updated: 2026-01-05 14:30
→ Desktop wins (newer timestamp)
```
**Automatic:**
- ✅ On Markdown import
- ✅ On JSON sync
- ⚠️ No merge conflicts - only complete overwrite
---
## ⚙️ Settings
### Desktop Integration Toggle
**Settings → Desktop Integration**
**ON (activated):**
- ✅ New notes → automatically exported as `.md`
- ✅ Updated notes → `.md` update
- ✅ Deleted notes → `.md` remains (future: also delete)
**OFF (deactivated):**
- ❌ No Markdown export
- ✅ JSON sync continues normally
- ✅ Existing `.md` files remain
### Initial Export
**What happens on activation:**
1. All existing notes are scanned
2. Progress dialog shows progress (e.g., "23/42")
3. Each note is exported as `.md`
4. On errors: Individual note is skipped
5. Success message with number of exported notes
**Time:** ~1-2 seconds per 50 notes
---
## 🛠️ Advanced Usage
### Manual Markdown Creation
You can create `.md` files manually:
```markdown
---
id: 00000000-0000-0000-0000-000000000001
created: 2026-01-05T12:00:00Z
updated: 2026-01-05T12:00:00Z
---
# New Desktop Note
Content here...
```
**⚠️ Important:**
- `id` must be valid UUID (e.g., with uuidgen.io)
- Timestamps in ISO8601 format
- Frontmatter enclosed with `---`
### Bulk Operations
**Edit multiple notes at once:**
1. Mount WebDAV
2. Open all `.md` files in VS Code
3. Find & Replace across all files (Ctrl+Shift+H)
4. Save
5. In app: "Import Markdown Changes"
### Scripting
**Example: Sort all notes by date**
```bash
#!/bin/bash
cd /mnt/notes-md/
# Sort all .md files by update date
for file in *.md; do
updated=$(grep "^updated:" "$file" | cut -d' ' -f2)
echo "$updated $file"
done | sort
```
---
## ❌ Troubleshooting
### "404 Not Found" when mounting WebDAV
**Cause:** `/notes-md/` folder doesn't exist
**Solution:**
1. **Perform first sync** - Folder is created automatically
2. OR: Create manually via terminal:
```bash
curl -X MKCOL -u noteuser:password http://server:8080/notes-md/
```
### Markdown files don't appear
**Cause:** Desktop integration not activated
**Solution:**
1. Settings → "Desktop Integration" ON
2. Wait for initial export
3. Refresh WebDAV folder
### Changes from desktop don't appear in app
**Cause:** Markdown import not executed
**Solution:**
1. Settings → "Import Markdown Changes"
2. OR: Wait for auto-sync (future feature)
### "Frontmatter missing" error
**Cause:** `.md` file without valid YAML frontmatter
**Solution:**
1. Open file in editor
2. Add frontmatter at the beginning:
```yaml
---
id: NEW-UUID-HERE
created: 2026-01-05T12:00:00Z
updated: 2026-01-05T12:00:00Z
---
```
3. Save and import again
---
## 🔒 Security & Best Practices
### Do's ✅
- ✅ **Backup before bulk edits** - Create local backup
- ✅ **One editor at a time** - Don't edit in app AND desktop in parallel
- ✅ **Wait for sync** - Run sync before desktop editing
- ✅ **Respect frontmatter** - Don't change manually (unless you know what you're doing)
### Don'ts ❌
- ❌ **Parallel editing** - App and desktop simultaneously → conflicts
- ❌ **Delete frontmatter** - Note can't be imported anymore
- ❌ **Change IDs** - Note is recognized as new
- ❌ **Manipulate timestamps** - Conflict resolution doesn't work
### Recommended Workflow
```
1. Sync in app (pull-to-refresh)
2. Open desktop
3. Make changes
4. Save
5. In app: "Import Markdown Changes"
6. Verify
7. Run another sync
```
---
## 📊 Comparison: JSON vs Markdown
| Aspect | JSON | Markdown |
|--------|------|----------|
| **Format** | Structured | Flowing text |
| **Readability (human)** | ⚠️ Medium | ✅ Good |
| **Readability (machine)** | ✅ Perfect | ⚠️ Parsing needed |
| **Metadata** | Native | Frontmatter |
| **Editors** | Code editors | All text editors |
| **Sync speed** | ✅ Fast | ⚠️ Slower |
| **Reliability** | ✅ 100% | ⚠️ Frontmatter errors possible |
| **Mobile-first** | ✅ Yes | ❌ No |
| **Desktop-first** | ❌ No | ✅ Yes |
**Conclusion:** Using both formats = Best experience on both platforms!
---
## 🔮 Future Features
Planned for v1.3.0+:
-**Auto-Markdown-import** - Automatically on every sync
-**Bidirectional sync** - Without manual import
-**Markdown preview** - In the app
-**Conflict UI** - On simultaneous changes
-**Tags in frontmatter** - Synchronized with app
-**Attachments** - Images/files in Markdown
---
**📚 See also:**
- [QUICKSTART.en.md](../QUICKSTART.en.md) - App setup
- [FEATURES.en.md](FEATURES.en.md) - Complete feature list
- [BACKUP.en.md](BACKUP.en.md) - Backup & restore
**Last update:** v1.2.1 (2026-01-05)

View File

@@ -1,24 +1,24 @@
# Desktop-Integration 🖥️
# Desktop Integration 🖥️
**🌍 Languages:** **Deutsch** · [English](DESKTOP.en.md)
**🌍 Languages:** [Deutsch](DESKTOP.de.md) · **English**
> Bearbeite deine Notizen mit jedem Markdown-Editor auf dem Desktop
> Edit your notes with any Markdown editor on desktop
---
## 📋 Übersicht
## 📋 Overview
Die Desktop-Integration ermöglicht dir, Notizen auf dem PC/Mac zu bearbeiten:
- 📝 Jeder Markdown-Editor funktioniert
- 🔄 Automatische Synchronisation über WebDAV
- 💾 Dual-Format: JSON (Master) + Markdown (Mirror)
- ⚡ Last-Write-Wins Konfliktauflösung
Desktop integration allows you to edit notes on PC/Mac:
- 📝 Any Markdown editor works
- 🔄 Automatic synchronization via WebDAV
- 💾 Dual-format: JSON (master) + Markdown (mirror)
- ⚡ Last-Write-Wins conflict resolution
---
## 🎯 Warum Markdown?
## 🎯 Why Markdown?
### Dual-Format Architektur
### Dual-Format Architecture
```
┌─────────────────────────────────────┐
@@ -45,85 +45,85 @@ Die Desktop-Integration ermöglicht dir, Notizen auf dem PC/Mac zu bearbeiten:
└──────────────────────────────────┘
```
### Vorteile
### Advantages
**JSON (Master):**
-Zuverlässig und schnell
- ✅ Strukturierte Daten (IDs, Timestamps)
- ✅ Primärer Sync-Mechanismus
-Immer aktiv
-Reliable and fast
- ✅ Structured data (IDs, timestamps)
- ✅ Primary sync mechanism
-Always active
**Markdown (Mirror):**
-Menschenlesbar
- ✅ Desktop-Editor kompatibel
- ✅ Syntax-Highlighting
- ✅ Optional aktivierbar
-Human-readable
- ✅ Desktop editor compatible
- ✅ Syntax highlighting
- ✅ Optionally activatable
---
## 🚀 Schnellstart
## 🚀 Quick Start
### 1. Erste Synchronisation
### 1. First Synchronization
**Wichtig:** Führe ZUERST einen Sync durch, bevor du Desktop-Integration aktivierst!
**Important:** Perform a sync FIRST before activating desktop integration!
1. **App einrichten** (siehe [QUICKSTART.md](QUICKSTART.md))
2. **Server-Verbindung testen**
3. **Erste Notiz erstellen**
4. **Synchronisieren** (Pull-to-Refresh oder Auto-Sync)
5. ✅ Server erstellt automatisch `/notes/` und `/notes-md/` Ordner
1. **Set up app** (see [QUICKSTART.en.md](QUICKSTART.en.md))
2. **Test server connection**
3. **Create first note**
4. **Synchronize** (pull-to-refresh or auto-sync)
5. ✅ Server automatically creates `/notes/` and `/notes-md/` folders
### 2. Desktop-Integration aktivieren
### 2. Activate Desktop Integration
1. **Einstellungen****Desktop-Integration**
2. **Toggle aktivieren**
3. **Initial Export startet** - Zeigt Progress (X/Y)
4. ✅ Alle bestehenden Notizen werden als `.md` exportiert
1. **Settings****Desktop Integration**
2. **Toggle ON**
3. **Initial export starts** - Shows progress (X/Y)
4. ✅ All existing notes are exported as `.md`
### 3. WebDAV als Netzlaufwerk mounten
### 3. Mount WebDAV as Network Drive
#### Windows
```
1. Explorer öffnen
2. Rechtsklick auf "Dieser PC"
3. "Netzlaufwerk verbinden"
4. URL eingeben: http://DEIN-SERVER:8080/notes-md/
5. Benutzername: noteuser
6. Passwort: (dein WebDAV-Passwort)
7. Laufwerksbuchstabe: Z:\ (oder beliebig)
8. Fertig!
1. Open Explorer
2. Right-click on "This PC"
3. "Map network drive"
4. Enter URL: http://YOUR-SERVER:8080/notes-md/
5. Username: noteuser
6. Password: (your WebDAV password)
7. Drive letter: Z:\ (or any)
8. Done!
```
**Zugriff:** `Z:\` im Explorer
**Access:** `Z:\` in Explorer
#### macOS
```
1. Finder öffnen
2. Menü "Gehe zu" → "Mit Server verbinden" (⌘K)
3. Server-Adresse: http://DEIN-SERVER:8080/notes-md/
4. Verbinden
5. Benutzername: noteuser
6. Passwort: (dein WebDAV-Passwort)
7. Fertig!
1. Open Finder
2. Menu "Go" → "Connect to Server" (⌘K)
3. Server address: http://YOUR-SERVER:8080/notes-md/
4. Connect
5. Username: noteuser
6. Password: (your WebDAV password)
7. Done!
```
**Zugriff:** Finder → Netzwerk → notes-md
**Access:** Finder → Network → notes-md
#### Linux (GNOME)
```
1. Files / Nautilus öffnen
2. "Andere Orte"
3. "Mit Server verbinden"
4. Server-Adresse: dav://DEIN-SERVER:8080/notes-md/
5. Benutzername: noteuser
6. Passwort: (dein WebDAV-Passwort)
7. Fertig!
1. Open Files / Nautilus
2. "Other Locations"
3. "Connect to Server"
4. Server address: dav://YOUR-SERVER:8080/notes-md/
5. Username: noteuser
6. Password: (your WebDAV password)
7. Done!
```
**Zugriff:** `/run/user/1000/gvfs/dav:host=...`
**Access:** `/run/user/1000/gvfs/dav:host=...`
#### Linux (davfs2 - permanent)
@@ -131,101 +131,101 @@ Die Desktop-Integration ermöglicht dir, Notizen auf dem PC/Mac zu bearbeiten:
# Installation
sudo apt install davfs2
# Mount-Point erstellen
# Create mount point
sudo mkdir -p /mnt/notes-md
# Einmalig mounten
sudo mount -t davfs http://DEIN-SERVER:8080/notes-md/ /mnt/notes-md
# Mount once
sudo mount -t davfs http://YOUR-SERVER:8080/notes-md/ /mnt/notes-md
# Permanent in /etc/fstab
echo "http://DEIN-SERVER:8080/notes-md/ /mnt/notes-md davfs rw,user,noauto 0 0" | sudo tee -a /etc/fstab
echo "http://YOUR-SERVER:8080/notes-md/ /mnt/notes-md davfs rw,user,noauto 0 0" | sudo tee -a /etc/fstab
```
**Zugriff:** `/mnt/notes-md/`
**Access:** `/mnt/notes-md/`
---
## 📝 Markdown-Editoren
## 📝 Markdown Editors
### Empfohlene Editoren
### Recommended Editors
#### 1. VS Code ⭐ _Empfohlen_
#### 1. VS Code ⭐ _Recommended_
**Vorteile:**
-Kostenlos & Open Source
- ✅ Markdown-Preview (Ctrl+Shift+V)
- ✅ Syntax-Highlighting
- ✅ Git-Integration
- ✅ Erweiterungen (Spell Check, etc.)
**Advantages:**
-Free & open source
- ✅ Markdown preview (Ctrl+Shift+V)
- ✅ Syntax highlighting
- ✅ Git integration
- ✅ Extensions (spell check, etc.)
**Setup:**
```
1. VS Code installieren
2. WebDAV-Laufwerk mounten
3. Ordner öffnen: Z:\notes-md\ (Windows) oder /mnt/notes-md (Linux)
4. Fertig! Markdown-Dateien bearbeiten
1. Install VS Code
2. Mount WebDAV drive
3. Open folder: Z:\notes-md\ (Windows) or /mnt/notes-md (Linux)
4. Done! Edit Markdown files
```
**Extensions (optional):**
- `Markdown All in One` - Shortcuts & Preview
- `Markdown Preview Enhanced` - Bessere Preview
- `Code Spell Checker` - Rechtschreibprüfung
- `Markdown All in One` - Shortcuts & preview
- `Markdown Preview Enhanced` - Better preview
- `Code Spell Checker` - Spell checking
#### 2. Typora
**Vorteile:**
- ✅ WYSIWYG Markdown-Editor
- ✅ Minimalistisches Design
- ✅ Live-Preview
- ⚠️ Kostenpflichtig (~15€)
**Advantages:**
- ✅ WYSIWYG Markdown editor
- ✅ Minimalist design
- ✅ Live preview
- ⚠️ Paid (~15€)
**Setup:**
```
1. Typora installieren
2. WebDAV mounten
3. Ordner in Typora öffnen
4. Notizen bearbeiten
1. Install Typora
2. Mount WebDAV
3. Open folder in Typora
4. Edit notes
```
#### 3. Notepad++
**Vorteile:**
- ✅ Leichtgewichtig
-Schnell
- ✅ Syntax-Highlighting
- ⚠️ Keine Markdown-Preview
**Advantages:**
- ✅ Lightweight
-Fast
- ✅ Syntax highlighting
- ⚠️ No Markdown preview
**Setup:**
```
1. Notepad++ installieren
2. WebDAV mounten
3. Dateien direkt öffnen
1. Install Notepad++
2. Mount WebDAV
3. Open files directly
```
#### 4. Obsidian
**Vorteile:**
-Zweite Gehirn-Philosophie
- ✅ Graph-View für Verlinkungen
-Viele Plugins
- ⚠️ Sync-Konflikte möglich (2 Master)
**Advantages:**
-Second brain philosophy
- ✅ Graph view for links
-Many plugins
- ⚠️ Sync conflicts possible (2 masters)
**Setup:**
```
1. Obsidian installieren
2. WebDAV als Vault öffnen
3. Vorsicht: Obsidian erstellt eigene Metadaten!
1. Install Obsidian
2. Open WebDAV as vault
3. Caution: Obsidian creates own metadata!
```
**⚠️ Nicht empfohlen:** Kann Frontmatter verändern
**⚠️ Not recommended:** Can alter frontmatter
---
## 📄 Markdown-Dateiformat
## 📄 Markdown File Format
### Struktur
### Structure
Jede Notiz wird als `.md` Datei mit YAML-Frontmatter exportiert:
Each note is exported as `.md` file with YAML frontmatter:
```markdown
---
@@ -235,114 +235,114 @@ updated: 2026-01-05T14:30:22Z
tags: []
---
# Notiz-Titel
# Note Title
Notiz-Inhalt hier...
Note content here...
```
### Frontmatter-Felder
### Frontmatter Fields
| Feld | Typ | Beschreibung | Pflicht |
|------|-----|--------------|---------|
| `id` | UUID | Eindeutige Notiz-ID | ✅ Ja |
| `created` | ISO8601 | Erstellungsdatum | ✅ Ja |
| `updated` | ISO8601 | Änderungsdatum | ✅ Ja |
| `tags` | Array | Tags (zukünftig) | ❌ Nein |
| Field | Type | Description | Required |
|-------|------|-------------|----------|
| `id` | UUID | Unique note ID | ✅ Yes |
| `created` | ISO8601 | Creation date | ✅ Yes |
| `updated` | ISO8601 | Modification date | ✅ Yes |
| `tags` | Array | Tags (future) | ❌ No |
### Dateinamen
### Filenames
**Sanitization-Regeln:**
**Sanitization rules:**
```
Titel: "Meine Einkaufsliste 🛒"
Dateiname: "Meine_Einkaufsliste.md"
Title: "My Shopping List 🛒"
Filename: "My_Shopping_List.md"
Entfernt werden:
- Emojis: 🛒 → entfernt
- Sonderzeichen: / \ : * ? " < > | → entfernt
- Mehrfache Leerzeicheneinzelnes Leerzeichen
- Leerzeichen → Unterstrich _
Removed:
- Emojis: 🛒 → removed
- Special chars: / \ : * ? " < > | → removed
- Multiple spacessingle space
- Spaces → underscore _
```
**Beispiele:**
**Examples:**
```
"Meeting Notes 2026" → "Meeting_Notes_2026.md"
"To-Do: Projekt" → "To-Do_Projekt.md"
"Urlaub ☀️" → "Urlaub.md"
"To-Do: Project" → "To-Do_Project.md"
"Vacation ☀️" → "Vacation.md"
```
---
## 🔄 Synchronisation
## 🔄 Synchronization
### Workflow: Android → Desktop
1. **Notiz in App erstellen/bearbeiten**
2. **Sync ausführen** (Auto oder manuell)
3. **JSON wird hochgeladen** (`/notes/abc-123.json`)
4. **Markdown wird exportiert** (`/notes-md/Notiz_Titel.md`) _(nur wenn Desktop-Integration AN)_
5. **Desktop-Editor zeigt Änderungen** (nach Refresh)
1. **Create/edit note in app**
2. **Run sync** (auto or manual)
3. **JSON is uploaded** (`/notes/abc-123.json`)
4. **Markdown is exported** (`/notes-md/Note_Title.md`) _(only if Desktop Integration ON)_
5. **Desktop editor shows changes** (after refresh)
### Workflow: Desktop → Android
1. **Markdown-Datei bearbeiten** (im gemounteten Ordner)
2. **Speichern** - Datei liegt sofort auf Server
3. **In App: Markdown-Import ausführen**
- Einstellungen → "Import Markdown Changes"
- Oder: Auto-Import bei jedem Sync (zukünftig)
4. **App übernimmt Änderungen** (wenn Desktop-Version neuer)
1. **Edit Markdown file** (in mounted folder)
2. **Save** - File is immediately on server
3. **In app: Run Markdown import**
- Settings → "Import Markdown Changes"
- Or: Auto-import on every sync (future)
4. **App adopts changes** (if desktop version is newer)
### Konfliktauflösung: Last-Write-Wins
### Conflict Resolution: Last-Write-Wins
**Regel:** Neueste Version (nach `updated` Timestamp) gewinnt
**Rule:** Newest version (by `updated` timestamp) wins
**Beispiel:**
**Example:**
```
App-Version: updated: 2026-01-05 14:00
Desktop-Version: updated: 2026-01-05 14:30
→ Desktop gewinnt (neuerer Timestamp)
App version: updated: 2026-01-05 14:00
Desktop version: updated: 2026-01-05 14:30
→ Desktop wins (newer timestamp)
```
**Automatisch:**
-Beim Markdown-Import
-Beim JSON-Sync
- ⚠️ Keine Merge-Konflikte - nur komplettes Überschreiben
**Automatic:**
-On Markdown import
-On JSON sync
- ⚠️ No merge conflicts - only complete overwrite
---
## ⚙️ Einstellungen
## ⚙️ Settings
### Desktop-Integration Toggle
### Desktop Integration Toggle
**Einstellungen → Desktop-Integration**
**Settings → Desktop Integration**
**AN (aktiviert):**
- ✅ Neue Notizen → automatisch als `.md` exportiert
-Aktualisierte Notizen`.md` Update
-Gelöschte Notizen → `.md` bleibt (zukünftig: auch löschen)
**ON (activated):**
- ✅ New notes → automatically exported as `.md`
-Updated notes`.md` update
-Deleted notes → `.md` remains (future: also delete)
**AUS (deaktiviert):**
-Kein Markdown-Export
- ✅ JSON-Sync läuft normal weiter
-Bestehende `.md` Dateien bleiben erhalten
**OFF (deactivated):**
-No Markdown export
- ✅ JSON sync continues normally
-Existing `.md` files remain
### Initial Export
**Was passiert beim Aktivieren:**
1. Alle bestehenden Notizen werden gescannt
2. Progress-Dialog zeigt Fortschritt (z.B. "23/42")
3. Jede Notiz wird als `.md` exportiert
4. Bei Fehlern: Einzelne Notiz wird übersprungen
5. Erfolgsmeldung mit Anzahl exportierter Notizen
**What happens on activation:**
1. All existing notes are scanned
2. Progress dialog shows progress (e.g., "23/42")
3. Each note is exported as `.md`
4. On errors: Individual note is skipped
5. Success message with number of exported notes
**Zeit:** ~1-2 Sekunden pro 50 Notizen
**Time:** ~1-2 seconds per 50 notes
---
## 🛠️ Erweiterte Nutzung
## 🛠️ Advanced Usage
### Manuelle Markdown-Erstellung
### Manual Markdown Creation
Du kannst `.md` Dateien manuell erstellen:
You can create `.md` files manually:
```markdown
---
@@ -351,35 +351,35 @@ created: 2026-01-05T12:00:00Z
updated: 2026-01-05T12:00:00Z
---
# Neue Desktop-Notiz
# New Desktop Note
Inhalt hier...
Content here...
```
**⚠️ Wichtig:**
- `id` muss gültige UUID sein (z.B. mit uuidgen.io)
- Timestamps in ISO8601-Format
- Frontmatter mit `---` umschließen
**⚠️ Important:**
- `id` must be valid UUID (e.g., with uuidgen.io)
- Timestamps in ISO8601 format
- Frontmatter enclosed with `---`
### Bulk-Operations
### Bulk Operations
**Mehrere Notizen auf einmal bearbeiten:**
**Edit multiple notes at once:**
1. WebDAV mounten
2. Alle `.md` Dateien in VS Code öffnen
3. Suchen & Ersetzen über alle Dateien (Ctrl+Shift+H)
4. Speichern
5. In App: "Import Markdown Changes"
1. Mount WebDAV
2. Open all `.md` files in VS Code
3. Find & Replace across all files (Ctrl+Shift+H)
4. Save
5. In app: "Import Markdown Changes"
### Scripting
**Beispiel: Alle Notizen nach Datum sortieren**
**Example: Sort all notes by date**
```bash
#!/bin/bash
cd /mnt/notes-md/
# Alle .md Dateien nach Update-Datum sortieren
# Sort all .md files by update date
for file in *.md; do
updated=$(grep "^updated:" "$file" | cut -d' ' -f2)
echo "$updated $file"
@@ -388,118 +388,118 @@ done | sort
---
## ❌ Fehlerbehebung
## ❌ Troubleshooting
### "404 Not Found" beim WebDAV-Mount
### "404 Not Found" when mounting WebDAV
**Ursache:** `/notes-md/` Ordner existiert nicht
**Cause:** `/notes-md/` folder doesn't exist
**Lösung:**
1. **Erste Sync durchführen** - Ordner wird automatisch erstellt
2. ODER: Manuell erstellen via Terminal:
**Solution:**
1. **Perform first sync** - Folder is created automatically
2. OR: Create manually via terminal:
```bash
curl -X MKCOL -u noteuser:password http://server:8080/notes-md/
```
### Markdown-Dateien erscheinen nicht
### Markdown files don't appear
**Ursache:** Desktop-Integration nicht aktiviert
**Cause:** Desktop integration not activated
**Lösung:**
1. Einstellungen → "Desktop-Integration" AN
2. Warten auf Initial Export
3. WebDAV-Ordner refreshen
**Solution:**
1. Settings → "Desktop Integration" ON
2. Wait for initial export
3. Refresh WebDAV folder
### Änderungen vom Desktop erscheinen nicht in App
### Changes from desktop don't appear in app
**Ursache:** Markdown-Import nicht ausgeführt
**Cause:** Markdown import not executed
**Lösung:**
1. Einstellungen → "Import Markdown Changes"
2. ODER: Auto-Sync abwarten (zukünftiges Feature)
**Solution:**
1. Settings → "Import Markdown Changes"
2. OR: Wait for auto-sync (future feature)
### "Frontmatter fehlt" Fehler
### "Frontmatter missing" error
**Ursache:** `.md` Datei ohne gültiges YAML-Frontmatter
**Cause:** `.md` file without valid YAML frontmatter
**Lösung:**
1. Datei in Editor öffnen
2. Frontmatter am Anfang hinzufügen:
**Solution:**
1. Open file in editor
2. Add frontmatter at the beginning:
```yaml
---
id: NEUE-UUID-HIER
id: NEW-UUID-HERE
created: 2026-01-05T12:00:00Z
updated: 2026-01-05T12:00:00Z
---
```
3. Speichern und erneut importieren
3. Save and import again
---
## 🔒 Sicherheit & Best Practices
## 🔒 Security & Best Practices
### Do's ✅
- ✅ **Backup vor Bulk-Edits** - Lokales Backup erstellen
- ✅ **Ein Editor zur Zeit** - Nicht parallel in App UND Desktop bearbeiten
- ✅ **Sync abwarten** - Vor Desktop-Bearbeitung Sync durchführen
- ✅ **Frontmatter respektieren** - Nicht manuell ändern (außer du weißt was du tust)
- ✅ **Backup before bulk edits** - Create local backup
- ✅ **One editor at a time** - Don't edit in app AND desktop in parallel
- ✅ **Wait for sync** - Run sync before desktop editing
- ✅ **Respect frontmatter** - Don't change manually (unless you know what you're doing)
### Don'ts ❌
- ❌ **Parallel bearbeiten** - App und Desktop gleichzeitigKonflikte
- ❌ **Frontmatter löschen** - Notiz kann nicht mehr importiert werden
- ❌ **IDs ändern** - Notiz wird als neue erkannt
- ❌ **Timestamps manipulieren** - Konfliktauflösung funktioniert nicht
- ❌ **Parallel editing** - App and desktop simultaneouslyconflicts
- ❌ **Delete frontmatter** - Note can't be imported anymore
- ❌ **Change IDs** - Note is recognized as new
- ❌ **Manipulate timestamps** - Conflict resolution doesn't work
### Empfohlener Workflow
### Recommended Workflow
```
1. Sync in App (Pull-to-Refresh)
2. Desktop öffnen
3. Änderungen machen
4. Speichern
5. In App: "Import Markdown Changes"
6. Überprüfen
7. Weiteren Sync durchführen
1. Sync in app (pull-to-refresh)
2. Open desktop
3. Make changes
4. Save
5. In app: "Import Markdown Changes"
6. Verify
7. Run another sync
```
---
## 📊 Vergleich: JSON vs Markdown
## 📊 Comparison: JSON vs Markdown
| Aspekt | JSON | Markdown |
| Aspect | JSON | Markdown |
|--------|------|----------|
| **Format** | Strukturiert | Fließtext |
| **Lesbarkeit (Mensch)** | ⚠️ Mittel | ✅ Gut |
| **Lesbarkeit (Maschine)** | ✅ Perfekt | ⚠️ Parsing nötig |
| **Format** | Structured | Flowing text |
| **Readability (human)** | ⚠️ Medium | ✅ Good |
| **Readability (machine)** | ✅ Perfect | ⚠️ Parsing needed |
| **Metadata** | Native | Frontmatter |
| **Editoren** | Code-Editoren | Alle Text-Editoren |
| **Sync-Geschwindigkeit** | ✅ Schnell | ⚠️ Langsamer |
| **Zuverlässigkeit** | ✅ 100% | ⚠️ Frontmatter-Fehler möglich |
| **Mobile-First** | ✅ Ja | ❌ Nein |
| **Desktop-First** | ❌ Nein | ✅ Ja |
| **Editors** | Code editors | All text editors |
| **Sync speed** | ✅ Fast | ⚠️ Slower |
| **Reliability** | ✅ 100% | ⚠️ Frontmatter errors possible |
| **Mobile-first** | ✅ Yes | ❌ No |
| **Desktop-first** | ❌ No | ✅ Yes |
**Fazit:** Beide Formate nutzen = Beste Erfahrung auf beiden Plattformen!
**Conclusion:** Using both formats = Best experience on both platforms!
---
## 🔮 Zukünftige Features
## 🔮 Future Features
Geplant für v1.3.0+:
Planned for v1.3.0+:
-**Auto-Markdown-Import** - Bei jedem Sync automatisch
-**Bidirektionaler Sync** - Ohne manuellen Import
-**Markdown-Vorschau** - In der App
-**Konflikts-UI** - Bei gleichzeitigen Änderungen
-**Tags in Frontmatter** - Synchronisiert mit App
-**Attachments** - Bilder/Dateien in Markdown
-**Auto-Markdown-import** - Automatically on every sync
-**Bidirectional sync** - Without manual import
-**Markdown preview** - In the app
-**Conflict UI** - On simultaneous changes
-**Tags in frontmatter** - Synchronized with app
-**Attachments** - Images/files in Markdown
---
**📚 Siehe auch:**
- [QUICKSTART.md](../QUICKSTART.md) - App-Einrichtung
- [FEATURES.md](FEATURES.md) - Vollständige Feature-Liste
- [BACKUP.md](BACKUP.md) - Backup & Wiederherstellung
**📚 See also:**
- [QUICKSTART.en.md](../QUICKSTART.en.md) - App setup
- [FEATURES.en.md](FEATURES.en.md) - Complete feature list
- [BACKUP.en.md](BACKUP.en.md) - Backup & restore
**Letzte Aktualisierung:** v1.2.1 (2026-01-05)
**Last update:** v1.2.1 (2026-01-05)

View File

@@ -1,14 +1,14 @@
# Simple Notes Sync - Technical Documentation
# Simple Notes Sync - Technische Dokumentation
This file contains detailed technical information about implementation, architecture, and advanced features.
Diese Datei enthält detaillierte technische Informationen über die Implementierung, Architektur und erweiterte Funktionen.
**🌍 Languages:** [Deutsch](DOCS.md) · **English**
**🌍 Sprachen:** **Deutsch** · [English](DOCS.md)
---
## 📐 Architecture
## 📐 Architektur
### Overall Overview
### Gesamtübersicht
```
┌─────────────────┐
@@ -23,81 +23,81 @@ This file contains detailed technical information about implementation, architec
└─────────────────┘
```
### Android App Architecture
### Android App Architektur
```
app/
├── models/
│ ├── Note.kt # Data class for notes
│ └── SyncStatus.kt # Sync status enum
│ ├── Note.kt # Data class für Notizen
│ └── SyncStatus.kt # Sync-Status Enum
├── storage/
│ └── NotesStorage.kt # Local JSON file storage
│ └── NotesStorage.kt # Lokale JSON-Datei Speicherung
├── sync/
│ ├── WebDavSyncService.kt # WebDAV sync logic
│ ├── NetworkMonitor.kt # WiFi detection
│ ├── SyncWorker.kt # WorkManager background worker
│ └── BootReceiver.kt # Device reboot handler
│ ├── WebDavSyncService.kt # WebDAV Sync-Logik
│ ├── NetworkMonitor.kt # WLAN-Erkennung
│ ├── SyncWorker.kt # WorkManager Background Worker
│ └── BootReceiver.kt # Device Reboot Handler
├── adapters/
│ └── NotesAdapter.kt # RecyclerView adapter
│ └── NotesAdapter.kt # RecyclerView Adapter
├── utils/
│ ├── Constants.kt # App constants
│ ├── NotificationHelper.kt# Notification management
│ └── Logger.kt # Debug/release logging
│ ├── Constants.kt # App-Konstanten
│ ├── NotificationHelper.kt# Notification Management
│ └── Logger.kt # Debug/Release Logging
└── activities/
├── MainActivity.kt # Main view with list
├── NoteEditorActivity.kt# Note editor
└── SettingsActivity.kt # Server configuration
├── MainActivity.kt # Hauptansicht mit Liste
├── NoteEditorActivity.kt# Editor für Notizen
└── SettingsActivity.kt # Server-Konfiguration
```
---
## 🔄 Auto-Sync Implementation
## 🔄 Auto-Sync Implementierung
### WorkManager Periodic Task
Auto-sync is based on **WorkManager** with the following configuration:
Der Auto-Sync basiert auf **WorkManager** mit folgender Konfiguration:
```kotlin
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // WiFi only
.setRequiredNetworkType(NetworkType.UNMETERED) // Nur WiFi
.build()
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
30, TimeUnit.MINUTES, // Every 30 minutes
30, TimeUnit.MINUTES, // Alle 30 Minuten
10, TimeUnit.MINUTES // Flex interval
)
.setConstraints(constraints)
.build()
```
**Why WorkManager?**
- ✅ Runs even when app is closed
- ✅ Automatic restart after device reboot
**Warum WorkManager?**
- ✅ Läuft auch wenn App geschlossen ist
- ✅ Automatischer Restart nach Device Reboot
- ✅ Battery-efficient (Android managed)
- ✅ Guaranteed execution when constraints are met
- ✅ Garantierte Ausführung bei erfüllten Constraints
### Network Detection
We use **Gateway IP Comparison** to check if the server is reachable:
Wir verwenden **Gateway IP Comparison** um zu prüfen, ob der Server erreichbar ist:
```kotlin
fun isInHomeNetwork(): Boolean {
val gatewayIP = getGatewayIP() // e.g. 192.168.0.1
val serverIP = extractIPFromUrl(serverUrl) // e.g. 192.168.0.188
val gatewayIP = getGatewayIP() // z.B. 192.168.0.1
val serverIP = extractIPFromUrl(serverUrl) // z.B. 192.168.0.188
return isSameNetwork(gatewayIP, serverIP) // Checks /24 network
return isSameNetwork(gatewayIP, serverIP) // Prüft /24 Netzwerk
}
```
**Advantages:**
- ✅ No location permissions needed
- ✅ Works with all Android versions
- ✅ Reliable and fast
**Vorteile:**
- ✅ Keine Location Permissions nötig
- ✅ Funktioniert mit allen Android Versionen
- ✅ Zuverlässig und schnell
### Sync Flow
```
1. WorkManager wakes up (every 30 min)
1. WorkManager wacht auf (alle 30 Min)
2. Check: WiFi connected?
@@ -105,7 +105,7 @@ fun isInHomeNetwork(): Boolean {
4. Load local notes
5. Upload new/changed notes → Server
5. Upload neue/geänderte Notes → Server
6. Download remote notes ← Server
@@ -118,20 +118,20 @@ fun isInHomeNetwork(): Boolean {
---
## 🔄 Sync Trigger Overview
## <EFBFBD> Sync-Trigger Übersicht
The app uses **4 different sync triggers** with different use cases:
Die App verwendet **4 verschiedene Sync-Trigger** mit unterschiedlichen Anwendungsfällen:
| Trigger | File | Function | When? | Pre-Check? |
|---------|------|----------|-------|------------|
| **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 connected | ✅ Yes |
| Trigger | Datei | Funktion | Wann? | Pre-Check? |
|---------|-------|----------|-------|------------|
| **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 verbunden | ✅ Ja |
### Server Reachability Check (Pre-Check)
### Server-Erreichbarkeits-Check (Pre-Check)
**All 4 sync triggers** use a **pre-check** before the actual sync:
**Alle 4 Sync-Trigger** verwenden vor dem eigentlichen Sync einen **Pre-Check**:
```kotlin
// WebDavSyncService.kt - isServerReachable()
@@ -148,53 +148,53 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
}
```
**Why Socket Check instead of HTTP Request?**
- ⚡ **Faster:** Socket connect is instant, HTTP request takes longer
- 🔋 **Battery Efficient:** No HTTP overhead (headers, TLS handshake, etc.)
- 🎯 **More Precise:** Only checks network reachability, not server logic
- 🛡️ **Prevents Errors:** Detects foreign WiFi networks before sync error occurs
**Warum Socket-Check statt HTTP-Request?**
- ⚡ **Schneller:** Socket-Connect ist instant, HTTP-Request dauert länger
- 🔋 **Akkuschonender:** Kein HTTP-Overhead (Headers, TLS Handshake, etc.)
- 🎯 **Präziser:** Prüft nur Netzwerk-Erreichbarkeit, nicht Server-Logik
- 🛡️ **Verhindert Fehler:** Erkennt fremde WiFi-Netze bevor Sync-Fehler entsteht
**When does the check fail?**
- ❌ Server offline/unreachable
- ❌ Wrong WiFi network (e.g. public café WiFi)
- ❌ Network not ready yet (DHCP/routing delay after WiFi connect)
- ❌ VPN blocks server access
- ❌ No WebDAV server URL configured
**Wann schlägt der Check fehl?**
- ❌ Server offline/nicht erreichbar
- ❌ Falsches WiFi-Netzwerk (z.B. öffentliches Café-WiFi)
- ❌ Netzwerk noch nicht bereit (DHCP/Routing-Delay nach WiFi-Connect)
- ❌ VPN blockiert Server-Zugriff
- ❌ Keine WebDAV-Server-URL konfiguriert
### Sync Behavior by Trigger Type
### Sync-Verhalten nach Trigger-Typ
| Trigger | When server not reachable | On successful sync | Throttling |
|---------|--------------------------|-------------------|------------|
| 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) | WiFi-based |
| Trigger | Bei Server nicht erreichbar | Bei erfolgreichem Sync | Throttling |
|---------|----------------------------|----------------------|------------|
| 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) | WiFi-basiert |
---
## 🔋 Battery Optimization
## 🔋 Akku-Optimierung
### Usage Analysis
### Verbrauchsanalyse
| Component | Frequency | Usage | Details |
| Komponente | Frequenz | Verbrauch | Details |
|------------|----------|-----------|---------|
| WorkManager Wakeup | Every 30 min | ~0.15 mAh | System wakes up |
| Network Check | 48x/day | ~0.03 mAh | Gateway IP check |
| WebDAV Sync | 2-3x/day | ~1.5 mAh | Only when changes |
| **Total** | - | **~12 mAh/day** | **~0.4%** at 3000mAh |
| WorkManager Wakeup | Alle 30 Min | ~0.15 mAh | System wacht auf |
| Network Check | 48x/Tag | ~0.03 mAh | Gateway IP check |
| WebDAV Sync | 2-3x/Tag | ~1.5 mAh | Nur bei Änderungen |
| **Total** | - | **~12 mAh/Tag** | **~0.4%** bei 3000mAh |
### Optimizations
### Optimierungen
1. **IP Caching**
```kotlin
private var cachedServerIP: String? = null
// DNS lookup only once at start, not every check
// DNS lookup nur 1x beim Start, nicht bei jedem Check
```
2. **Throttling**
```kotlin
private var lastSyncTime = 0L
private const val MIN_SYNC_INTERVAL_MS = 60_000L // Max 1 sync/min
private const val MIN_SYNC_INTERVAL_MS = 60_000L // Max 1 Sync/Min
```
3. **Conditional Logging**
@@ -207,9 +207,9 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
```
4. **Network Constraints**
- WiFi only (not mobile data)
- Only when server is reachable
- No permanent listeners
- Nur WiFi (nicht mobile Daten)
- Nur wenn Server erreichbar
- Keine permanenten Listeners
---
@@ -255,15 +255,15 @@ suspend fun downloadNotes(): DownloadResult {
val localNote = storage.loadNote(remoteNote.id)
if (localNote == null) {
// New note from server
// Neue Note vom Server
storage.saveNote(remoteNote)
downloadedCount++
} else if (localNote.modifiedAt < remoteNote.modifiedAt) {
// Server has newer version
// Server hat neuere Version
storage.saveNote(remoteNote)
downloadedCount++
} else if (localNote.modifiedAt > remoteNote.modifiedAt) {
// Local version is newer → Conflict
// Lokale Version ist neuer → Conflict
resolveConflict(localNote, remoteNote)
conflictCount++
}
@@ -275,19 +275,19 @@ suspend fun downloadNotes(): DownloadResult {
### Conflict Resolution
Strategy: **Last-Write-Wins** with **Conflict Copy**
Strategie: **Last-Write-Wins** mit **Conflict Copy**
```kotlin
fun resolveConflict(local: Note, remote: Note) {
// Rename remote note (conflict copy)
// Remote Note umbenennen (Conflict Copy)
val conflictNote = remote.copy(
id = "${remote.id}_conflict_${System.currentTimeMillis()}",
title = "${remote.title} (Conflict)"
title = "${remote.title} (Konflikt)"
)
storage.saveNote(conflictNote)
// Local note remains
// Lokale Note bleibt
local.syncStatus = SyncStatus.SYNCED
storage.saveNote(local)
}
@@ -302,7 +302,7 @@ fun resolveConflict(local: Note, remote: Note) {
```kotlin
val channel = NotificationChannel(
"notes_sync_channel",
"Notes Synchronization",
"Notizen Synchronisierung",
NotificationManager.IMPORTANCE_DEFAULT
)
```
@@ -315,9 +315,9 @@ fun showSyncSuccess(context: Context, count: Int) {
val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAGS)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle("Sync successful")
.setContentText("$count notes synchronized")
.setContentIntent(pendingIntent) // Click opens app
.setContentTitle("Sync erfolgreich")
.setContentText("$count Notizen synchronisiert")
.setContentIntent(pendingIntent) // Click öffnet App
.setAutoCancel(true) // Dismiss on click
.build()
@@ -329,10 +329,10 @@ fun showSyncSuccess(context: Context, count: Int) {
## 🛡️ Permissions
The app requires **minimal permissions**:
Die App benötigt **minimale Permissions**:
```xml
<!-- Network -->
<!-- Netzwerk -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
@@ -348,28 +348,28 @@ The app requires **minimal permissions**:
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
```
**No Location Permissions!**
We use Gateway IP Comparison instead of SSID detection. No location permission required.
**Keine Location Permissions!**
Wir verwenden Gateway IP Comparison statt SSID-Erkennung. Keine Standortberechtigung nötig.
---
## 🧪 Testing
### Test Server
### Server testen
```bash
# WebDAV server reachable?
# WebDAV Server erreichbar?
curl -u noteuser:password http://192.168.0.188:8080/
# Upload file
# Datei hochladen
echo '{"test":"data"}' > test.json
curl -u noteuser:password -T test.json http://192.168.0.188:8080/test.json
# Download file
# Datei herunterladen
curl -u noteuser:password http://192.168.0.188:8080/test.json
```
### Test Android App
### Android App testen
**Unit Tests:**
```bash
@@ -384,15 +384,15 @@ cd android
**Manual Testing Checklist:**
- [ ] Create note → visible in list
- [ ] Edit note → changes saved
- [ ] Delete note → removed from list
- [ ] Manual sync → server status "Reachable"
- [ ] Auto-sync → notification after ~30 min
- [ ] Close appauto-sync continues
- [ ] Device reboot → auto-sync starts automatically
- [ ] Server offline → error notification
- [ ] Notification click → app opens
- [ ] Notiz erstellen → in Liste sichtbar
- [ ] Notiz bearbeitenÄnderungen gespeichert
- [ ] Notiz löschen → aus Liste entfernt
- [ ] Manueller Sync → Server Status "Erreichbar"
- [ ] Auto-Sync → Notification nach ~30 Min
- [ ] App schließenAuto-Sync funktioniert weiter
- [ ] Device Reboot → Auto-Sync startet automatisch
- [ ] Server offline → Error Notification
- [ ] Notification Click → App öffnet sich
---
@@ -413,18 +413,18 @@ cd android
# APK: app/build/outputs/apk/release/app-release-unsigned.apk
```
### Sign (for Distribution)
### Signieren (für Distribution)
```bash
# Create keystore
# Keystore erstellen
keytool -genkey -v -keystore my-release-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias my-alias
# Sign APK
# APK signieren
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
-keystore my-release-key.jks \
app-release-unsigned.apk my-alias
# Optimize
# Optimieren
zipalign -v 4 app-release-unsigned.apk app-release.apk
```
@@ -435,39 +435,39 @@ zipalign -v 4 app-release-unsigned.apk app-release.apk
### LogCat Filter
```bash
# Only app logs
# Nur App-Logs
adb logcat -s SimpleNotesApp NetworkMonitor SyncWorker WebDavSyncService
# With timestamps
# Mit Timestamps
adb logcat -v time -s SyncWorker
# Save to file
# In Datei speichern
adb logcat -s SyncWorker > sync_debug.log
```
### Common Issues
**Problem: Auto-sync not working**
**Problem: Auto-Sync funktioniert nicht**
```
Solution: Disable battery optimization
Lösung: Akku-Optimierung deaktivieren
Settings → Apps → Simple Notes → Battery → Don't optimize
```
**Problem: Server not reachable**
**Problem: Server nicht erreichbar**
```
Check:
1. Server running? → docker-compose ps
2. IP correct? → ip addr show
3. Port open? → telnet 192.168.0.188 8080
1. Server läuft? → docker-compose ps
2. IP korrekt? → ip addr show
3. Port offen? → telnet 192.168.0.188 8080
4. Firewall? → sudo ufw allow 8080
```
**Problem: Notifications not appearing**
**Problem: Notifications kommen nicht**
```
Check:
1. Notification permission granted?
2. Do Not Disturb active?
3. App in background? → Force stop & restart
1. Notification Permission erteilt?
2. Do Not Disturb aktiv?
3. App im Background? → Force stop & restart
```
---
@@ -504,26 +504,26 @@ androidx.localbroadcastmanager:localbroadcastmanager:1.1.0
## 🔮 Roadmap
### v1.1
- [ ] Search & Filter
- [ ] Suche & Filter
- [ ] Dark Mode
- [ ] Tags/Categories
- [ ] Tags/Kategorien
- [ ] Markdown Preview
### v2.0
- [ ] Desktop Client (Flutter)
- [ ] End-to-End Encryption
- [ ] End-to-End Verschlüsselung
- [ ] Shared Notes (Collaboration)
- [ ] Attachment Support
---
## 📖 Further Documentation
## 📖 Weitere Dokumentation
- [Project Docs](https://github.com/inventory69/project-docs/tree/main/simple-notes-sync)
- [Sync Architecture](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/SYNC_ARCHITECTURE.md) - **Detailed Sync Trigger Documentation**
- [Sync Architecture](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/SYNC_ARCHITECTURE.md) - **Detaillierte Sync-Trigger Dokumentation**
- [Android Guide](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/ANDROID_GUIDE.md)
- [Bugfix Documentation](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/BUGFIX_SYNC_SPAM_AND_NOTIFICATIONS.md)
---
**Last updated:** December 25, 2025
**Letzte Aktualisierung:** 25. Dezember 2025

View File

@@ -1,14 +1,14 @@
# Simple Notes Sync - Technische Dokumentation
# Simple Notes Sync - Technical Documentation
Diese Datei enthält detaillierte technische Informationen über die Implementierung, Architektur und erweiterte Funktionen.
This file contains detailed technical information about implementation, architecture, and advanced features.
**🌍 Sprachen:** **Deutsch** · [English](DOCS.en.md)
**🌍 Languages:** [Deutsch](DOCS.de.md) · **English**
---
## 📐 Architektur
## 📐 Architecture
### Gesamtübersicht
### Overall Overview
```
┌─────────────────┐
@@ -23,81 +23,81 @@ Diese Datei enthält detaillierte technische Informationen über die Implementie
└─────────────────┘
```
### Android App Architektur
### Android App Architecture
```
app/
├── models/
│ ├── Note.kt # Data class für Notizen
│ └── SyncStatus.kt # Sync-Status Enum
│ ├── Note.kt # Data class for notes
│ └── SyncStatus.kt # Sync status enum
├── storage/
│ └── NotesStorage.kt # Lokale JSON-Datei Speicherung
│ └── NotesStorage.kt # Local JSON file storage
├── sync/
│ ├── WebDavSyncService.kt # WebDAV Sync-Logik
│ ├── NetworkMonitor.kt # WLAN-Erkennung
│ ├── SyncWorker.kt # WorkManager Background Worker
│ └── BootReceiver.kt # Device Reboot Handler
│ ├── WebDavSyncService.kt # WebDAV sync logic
│ ├── NetworkMonitor.kt # WiFi detection
│ ├── SyncWorker.kt # WorkManager background worker
│ └── BootReceiver.kt # Device reboot handler
├── adapters/
│ └── NotesAdapter.kt # RecyclerView Adapter
│ └── NotesAdapter.kt # RecyclerView adapter
├── utils/
│ ├── Constants.kt # App-Konstanten
│ ├── NotificationHelper.kt# Notification Management
│ └── Logger.kt # Debug/Release Logging
│ ├── Constants.kt # App constants
│ ├── NotificationHelper.kt# Notification management
│ └── Logger.kt # Debug/release logging
└── activities/
├── MainActivity.kt # Hauptansicht mit Liste
├── NoteEditorActivity.kt# Editor für Notizen
└── SettingsActivity.kt # Server-Konfiguration
├── MainActivity.kt # Main view with list
├── NoteEditorActivity.kt# Note editor
└── SettingsActivity.kt # Server configuration
```
---
## 🔄 Auto-Sync Implementierung
## 🔄 Auto-Sync Implementation
### WorkManager Periodic Task
Der Auto-Sync basiert auf **WorkManager** mit folgender Konfiguration:
Auto-sync is based on **WorkManager** with the following configuration:
```kotlin
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // Nur WiFi
.setRequiredNetworkType(NetworkType.UNMETERED) // WiFi only
.build()
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
30, TimeUnit.MINUTES, // Alle 30 Minuten
30, TimeUnit.MINUTES, // Every 30 minutes
10, TimeUnit.MINUTES // Flex interval
)
.setConstraints(constraints)
.build()
```
**Warum WorkManager?**
-Läuft auch wenn App geschlossen ist
- ✅ Automatischer Restart nach Device Reboot
**Why WorkManager?**
-Runs even when app is closed
- ✅ Automatic restart after device reboot
- ✅ Battery-efficient (Android managed)
- ✅ Garantierte Ausführung bei erfüllten Constraints
- ✅ Guaranteed execution when constraints are met
### Network Detection
Wir verwenden **Gateway IP Comparison** um zu prüfen, ob der Server erreichbar ist:
We use **Gateway IP Comparison** to check if the server is reachable:
```kotlin
fun isInHomeNetwork(): Boolean {
val gatewayIP = getGatewayIP() // z.B. 192.168.0.1
val serverIP = extractIPFromUrl(serverUrl) // z.B. 192.168.0.188
val gatewayIP = getGatewayIP() // e.g. 192.168.0.1
val serverIP = extractIPFromUrl(serverUrl) // e.g. 192.168.0.188
return isSameNetwork(gatewayIP, serverIP) // Prüft /24 Netzwerk
return isSameNetwork(gatewayIP, serverIP) // Checks /24 network
}
```
**Vorteile:**
-Keine Location Permissions nötig
-Funktioniert mit allen Android Versionen
-Zuverlässig und schnell
**Advantages:**
-No location permissions needed
-Works with all Android versions
-Reliable and fast
### Sync Flow
```
1. WorkManager wacht auf (alle 30 Min)
1. WorkManager wakes up (every 30 min)
2. Check: WiFi connected?
@@ -105,7 +105,7 @@ fun isInHomeNetwork(): Boolean {
4. Load local notes
5. Upload neue/geänderte Notes → Server
5. Upload new/changed notes → Server
6. Download remote notes ← Server
@@ -118,20 +118,20 @@ fun isInHomeNetwork(): Boolean {
---
## <EFBFBD> Sync-Trigger Übersicht
## 🔄 Sync Trigger Overview
Die App verwendet **4 verschiedene Sync-Trigger** mit unterschiedlichen Anwendungsfällen:
The app uses **4 different sync triggers** with different use cases:
| Trigger | Datei | Funktion | Wann? | Pre-Check? |
|---------|-------|----------|-------|------------|
| **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 verbunden | ✅ Ja |
| Trigger | File | Function | When? | Pre-Check? |
|---------|------|----------|-------|------------|
| **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 connected | ✅ Yes |
### Server-Erreichbarkeits-Check (Pre-Check)
### Server Reachability Check (Pre-Check)
**Alle 4 Sync-Trigger** verwenden vor dem eigentlichen Sync einen **Pre-Check**:
**All 4 sync triggers** use a **pre-check** before the actual sync:
```kotlin
// WebDavSyncService.kt - isServerReachable()
@@ -148,53 +148,53 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
}
```
**Warum Socket-Check statt HTTP-Request?**
-**Schneller:** Socket-Connect ist instant, HTTP-Request dauert länger
- 🔋 **Akkuschonender:** Kein HTTP-Overhead (Headers, TLS Handshake, etc.)
- 🎯 **Präziser:** Prüft nur Netzwerk-Erreichbarkeit, nicht Server-Logik
- 🛡️ **Verhindert Fehler:** Erkennt fremde WiFi-Netze bevor Sync-Fehler entsteht
**Why Socket Check instead of HTTP Request?**
-**Faster:** Socket connect is instant, HTTP request takes longer
- 🔋 **Battery Efficient:** No HTTP overhead (headers, TLS handshake, etc.)
- 🎯 **More Precise:** Only checks network reachability, not server logic
- 🛡️ **Prevents Errors:** Detects foreign WiFi networks before sync error occurs
**Wann schlägt der Check fehl?**
- ❌ Server offline/nicht erreichbar
-Falsches WiFi-Netzwerk (z.B. öffentliches Café-WiFi)
- ❌ Netzwerk noch nicht bereit (DHCP/Routing-Delay nach WiFi-Connect)
- ❌ VPN blockiert Server-Zugriff
-Keine WebDAV-Server-URL konfiguriert
**When does the check fail?**
- ❌ Server offline/unreachable
-Wrong WiFi network (e.g. public café WiFi)
- ❌ Network not ready yet (DHCP/routing delay after WiFi connect)
- ❌ VPN blocks server access
-No WebDAV server URL configured
### Sync-Verhalten nach Trigger-Typ
### Sync Behavior by Trigger Type
| Trigger | Bei Server nicht erreichbar | Bei erfolgreichem Sync | Throttling |
|---------|----------------------------|----------------------|------------|
| 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) | WiFi-basiert |
| Trigger | When server not reachable | On successful sync | Throttling |
|---------|--------------------------|-------------------|------------|
| 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) | WiFi-based |
---
## 🔋 Akku-Optimierung
## 🔋 Battery Optimization
### Verbrauchsanalyse
### Usage Analysis
| Komponente | Frequenz | Verbrauch | Details |
| Component | Frequency | Usage | Details |
|------------|----------|-----------|---------|
| WorkManager Wakeup | Alle 30 Min | ~0.15 mAh | System wacht auf |
| Network Check | 48x/Tag | ~0.03 mAh | Gateway IP check |
| WebDAV Sync | 2-3x/Tag | ~1.5 mAh | Nur bei Änderungen |
| **Total** | - | **~12 mAh/Tag** | **~0.4%** bei 3000mAh |
| WorkManager Wakeup | Every 30 min | ~0.15 mAh | System wakes up |
| Network Check | 48x/day | ~0.03 mAh | Gateway IP check |
| WebDAV Sync | 2-3x/day | ~1.5 mAh | Only when changes |
| **Total** | - | **~12 mAh/day** | **~0.4%** at 3000mAh |
### Optimierungen
### Optimizations
1. **IP Caching**
```kotlin
private var cachedServerIP: String? = null
// DNS lookup nur 1x beim Start, nicht bei jedem Check
// DNS lookup only once at start, not every check
```
2. **Throttling**
```kotlin
private var lastSyncTime = 0L
private const val MIN_SYNC_INTERVAL_MS = 60_000L // Max 1 Sync/Min
private const val MIN_SYNC_INTERVAL_MS = 60_000L // Max 1 sync/min
```
3. **Conditional Logging**
@@ -207,9 +207,9 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
```
4. **Network Constraints**
- Nur WiFi (nicht mobile Daten)
- Nur wenn Server erreichbar
- Keine permanenten Listeners
- WiFi only (not mobile data)
- Only when server is reachable
- No permanent listeners
---
@@ -255,15 +255,15 @@ suspend fun downloadNotes(): DownloadResult {
val localNote = storage.loadNote(remoteNote.id)
if (localNote == null) {
// Neue Note vom Server
// New note from server
storage.saveNote(remoteNote)
downloadedCount++
} else if (localNote.modifiedAt < remoteNote.modifiedAt) {
// Server hat neuere Version
// Server has newer version
storage.saveNote(remoteNote)
downloadedCount++
} else if (localNote.modifiedAt > remoteNote.modifiedAt) {
// Lokale Version ist neuer → Conflict
// Local version is newer → Conflict
resolveConflict(localNote, remoteNote)
conflictCount++
}
@@ -275,19 +275,19 @@ suspend fun downloadNotes(): DownloadResult {
### Conflict Resolution
Strategie: **Last-Write-Wins** mit **Conflict Copy**
Strategy: **Last-Write-Wins** with **Conflict Copy**
```kotlin
fun resolveConflict(local: Note, remote: Note) {
// Remote Note umbenennen (Conflict Copy)
// Rename remote note (conflict copy)
val conflictNote = remote.copy(
id = "${remote.id}_conflict_${System.currentTimeMillis()}",
title = "${remote.title} (Konflikt)"
title = "${remote.title} (Conflict)"
)
storage.saveNote(conflictNote)
// Lokale Note bleibt
// Local note remains
local.syncStatus = SyncStatus.SYNCED
storage.saveNote(local)
}
@@ -302,7 +302,7 @@ fun resolveConflict(local: Note, remote: Note) {
```kotlin
val channel = NotificationChannel(
"notes_sync_channel",
"Notizen Synchronisierung",
"Notes Synchronization",
NotificationManager.IMPORTANCE_DEFAULT
)
```
@@ -315,9 +315,9 @@ fun showSyncSuccess(context: Context, count: Int) {
val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAGS)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle("Sync erfolgreich")
.setContentText("$count Notizen synchronisiert")
.setContentIntent(pendingIntent) // Click öffnet App
.setContentTitle("Sync successful")
.setContentText("$count notes synchronized")
.setContentIntent(pendingIntent) // Click opens app
.setAutoCancel(true) // Dismiss on click
.build()
@@ -329,10 +329,10 @@ fun showSyncSuccess(context: Context, count: Int) {
## 🛡️ Permissions
Die App benötigt **minimale Permissions**:
The app requires **minimal permissions**:
```xml
<!-- Netzwerk -->
<!-- Network -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
@@ -348,28 +348,28 @@ Die App benötigt **minimale Permissions**:
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
```
**Keine Location Permissions!**
Wir verwenden Gateway IP Comparison statt SSID-Erkennung. Keine Standortberechtigung nötig.
**No Location Permissions!**
We use Gateway IP Comparison instead of SSID detection. No location permission required.
---
## 🧪 Testing
### Server testen
### Test Server
```bash
# WebDAV Server erreichbar?
# WebDAV server reachable?
curl -u noteuser:password http://192.168.0.188:8080/
# Datei hochladen
# Upload file
echo '{"test":"data"}' > test.json
curl -u noteuser:password -T test.json http://192.168.0.188:8080/test.json
# Datei herunterladen
# Download file
curl -u noteuser:password http://192.168.0.188:8080/test.json
```
### Android App testen
### Test Android App
**Unit Tests:**
```bash
@@ -384,15 +384,15 @@ cd android
**Manual Testing Checklist:**
- [ ] Notiz erstellen → in Liste sichtbar
- [ ] Notiz bearbeitenÄnderungen gespeichert
- [ ] Notiz löschen → aus Liste entfernt
- [ ] Manueller Sync → Server Status "Erreichbar"
- [ ] Auto-Sync → Notification nach ~30 Min
- [ ] App schließenAuto-Sync funktioniert weiter
- [ ] Device Reboot → Auto-Sync startet automatisch
- [ ] Server offline → Error Notification
- [ ] Notification Click → App öffnet sich
- [ ] Create note → visible in list
- [ ] Edit note → changes saved
- [ ] Delete note → removed from list
- [ ] Manual sync → server status "Reachable"
- [ ] Auto-sync → notification after ~30 min
- [ ] Close appauto-sync continues
- [ ] Device reboot → auto-sync starts automatically
- [ ] Server offline → error notification
- [ ] Notification click → app opens
---
@@ -413,18 +413,18 @@ cd android
# APK: app/build/outputs/apk/release/app-release-unsigned.apk
```
### Signieren (für Distribution)
### Sign (for Distribution)
```bash
# Keystore erstellen
# Create keystore
keytool -genkey -v -keystore my-release-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias my-alias
# APK signieren
# Sign APK
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
-keystore my-release-key.jks \
app-release-unsigned.apk my-alias
# Optimieren
# Optimize
zipalign -v 4 app-release-unsigned.apk app-release.apk
```
@@ -435,39 +435,39 @@ zipalign -v 4 app-release-unsigned.apk app-release.apk
### LogCat Filter
```bash
# Nur App-Logs
# Only app logs
adb logcat -s SimpleNotesApp NetworkMonitor SyncWorker WebDavSyncService
# Mit Timestamps
# With timestamps
adb logcat -v time -s SyncWorker
# In Datei speichern
# Save to file
adb logcat -s SyncWorker > sync_debug.log
```
### Common Issues
**Problem: Auto-Sync funktioniert nicht**
**Problem: Auto-sync not working**
```
Lösung: Akku-Optimierung deaktivieren
Solution: Disable battery optimization
Settings → Apps → Simple Notes → Battery → Don't optimize
```
**Problem: Server nicht erreichbar**
**Problem: Server not reachable**
```
Check:
1. Server läuft? → docker-compose ps
2. IP korrekt? → ip addr show
3. Port offen? → telnet 192.168.0.188 8080
1. Server running? → docker-compose ps
2. IP correct? → ip addr show
3. Port open? → telnet 192.168.0.188 8080
4. Firewall? → sudo ufw allow 8080
```
**Problem: Notifications kommen nicht**
**Problem: Notifications not appearing**
```
Check:
1. Notification Permission erteilt?
2. Do Not Disturb aktiv?
3. App im Background? → Force stop & restart
1. Notification permission granted?
2. Do Not Disturb active?
3. App in background? → Force stop & restart
```
---
@@ -504,26 +504,26 @@ androidx.localbroadcastmanager:localbroadcastmanager:1.1.0
## 🔮 Roadmap
### v1.1
- [ ] Suche & Filter
- [ ] Search & Filter
- [ ] Dark Mode
- [ ] Tags/Kategorien
- [ ] Tags/Categories
- [ ] Markdown Preview
### v2.0
- [ ] Desktop Client (Flutter)
- [ ] End-to-End Verschlüsselung
- [ ] End-to-End Encryption
- [ ] Shared Notes (Collaboration)
- [ ] Attachment Support
---
## 📖 Weitere Dokumentation
## 📖 Further Documentation
- [Project Docs](https://github.com/inventory69/project-docs/tree/main/simple-notes-sync)
- [Sync Architecture](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/SYNC_ARCHITECTURE.md) - **Detaillierte Sync-Trigger Dokumentation**
- [Sync Architecture](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/SYNC_ARCHITECTURE.md) - **Detailed Sync Trigger Documentation**
- [Android Guide](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/ANDROID_GUIDE.md)
- [Bugfix Documentation](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/BUGFIX_SYNC_SPAM_AND_NOTIFICATIONS.md)
---
**Letzte Aktualisierung:** 25. Dezember 2025
**Last updated:** December 25, 2025

305
docs/FEATURES.de.md Normal file
View File

@@ -0,0 +1,305 @@
# Vollständige Feature-Liste 📋
**🌍 Sprachen:** **Deutsch** · [English](FEATURES.md)
> Alle Features von Simple Notes Sync im Detail
---
## 📝 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
- ~~Durchstreichen~~ bei erledigten Einträgen
### Basis-Funktionen
-**Automatisches Speichern** - Kein manuelles Speichern nötig
-**Titel + Inhalt** - Klare Struktur für jede Notiz
-**Zeitstempel** - Erstellungs- und Änderungsdatum automatisch
-**Auswahlmodus** _(NEU in v1.5.0)_ - Long-Press für Mehrfachauswahl und Batch-Löschen
-**Bestätigungs-Dialog** - Schutz vor versehentlichem Löschen
-**Jetpack Compose UI** _(NEU in v1.5.0)_ - Moderne, performante Benutzeroberfläche
-**Material Design 3** - Moderne, saubere UI
-**Dark Mode** - Automatisch je nach System-Einstellung
-**Dynamic Colors** - Passt sich deinem Android-Theme an
### Editor
-**Minimalistischer Editor** - Kein Schnickschnack
-**Auto-Fokus** - Direkt losschreiben
-**Vollbild-Modus** - Maximale Schreibfläche
-**Speichern-Button** - Manuelle Bestätigung möglich
-**Zurück-Navigation** - Speichert automatisch
-**Slide-Animationen** _(NEU in v1.5.0)_ - Flüssige Übergänge
---
## 🌍 Mehrsprachigkeit _(NEU in v1.5.0)_
### Unterstützte Sprachen
-**Englisch** - Primäre Sprache (Standard)
-**Deutsch** - Vollständige Übersetzung
### Sprachauswahl
-**Automatische Erkennung** - Folgt der System-Sprache
-**Manuelle Auswahl** - In den Einstellungen umschaltbar
-**Per-App Language** - Android 13+ native Sprachauswahl
-**locales_config.xml** - Vollständige Android-Integration
### Umfang
-**400+ Strings** - Komplett übersetzt
-**UI-Texte** - Alle Buttons, Dialoge, Menüs
-**Fehlermeldungen** - Hilfreiche lokalisierte Hinweise
-**Einstellungen** - 7 kategorisierte Screens
---
## 💾 Backup & Wiederherstellung
### Lokales Backup System
-**JSON-Export** - Alle Notizen in einer Datei
-**Freie Speicherort-Wahl** - Downloads, SD-Karte, Cloud-Ordner
-**Dateinamen mit Zeitstempel** - `simplenotes_backup_YYYY-MM-DD_HHmmss.json`
-**Vollständiger Export** - Titel, Inhalt, Timestamps, IDs
-**Menschenlesbares Format** - JSON mit Formatierung
-**Unabhängig vom Server** - Funktioniert komplett offline
### Wiederherstellungs-Modi
-**Zusammenführen (Merge)** - Neue Notizen hinzufügen, bestehende behalten _(Standard)_
-**Ersetzen (Replace)** - Alle löschen und Backup importieren
-**Duplikate überschreiben (Overwrite)** - Backup gewinnt bei ID-Konflikten
-**Automatisches Sicherheits-Backup** - Vor jeder Wiederherstellung
-**Backup-Validierung** - Prüft Format und Version
-**Fehlerbehandlung** - Klare Fehlermeldungen bei Problemen
---
## 🖥️ Desktop-Integration
### 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
-**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
-**Progress-Anzeige** - Zeigt X/Y beim Export
### Markdown-Import
-**Desktop → App** - Änderungen vom Desktop importieren
-**Last-Write-Wins** - Konfliktauflösung via Timestamp
-**Frontmatter-Parsing** - Liest Metadata aus `.md` Dateien
-**Neue Notizen erkennen** - Automatisch in App übernehmen
-**Updates erkennen** - Nur wenn Desktop-Version neuer ist
-**Fehlertoleranz** - Einzelne Fehler brechen Import nicht ab
### WebDAV-Zugriff
-**Network Drive Mount** - Windows, macOS, Linux
-**Jeder Markdown-Editor** - VS Code, Typora, Notepad++, iA Writer
-**Live-Bearbeitung** - Direkter Zugriff auf `.md` Dateien
-**Ordner-Struktur** - `/notes/` für JSON, `/notes-md/` für Markdown
-**Automatische Ordner-Erstellung** - Beim ersten Sync
---
## 🔄 Synchronisation
### Auto-Sync
-**Intervall-Auswahl** - 15, 30 oder 60 Minuten
-**WiFi-Trigger** - Sync bei WiFi-Verbindung _(keine SSID-Einschränkung)_
-**Akkuschonend** - ~0.2-0.8% pro Tag
-**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** - 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
### Sync-Mechanismus
-**Upload** - Lokale Änderungen zum Server
-**Download** - Server-Änderungen in App
-**Konflikt-Erkennung** - Bei gleichzeitigen Änderungen
-**Konfliktfreies Merging** - Last-Write-Wins via Timestamp
-**Sync-Status Tracking** - LOCAL_ONLY, PENDING, SYNCED, CONFLICT
-**Fehlerbehandlung** - Retry bei Netzwerkproblemen
-**Offline-First** - App funktioniert ohne Server
### Server-Verbindung
-**WebDAV-Protokoll** - Standard-Protokoll
-**HTTP/HTTPS** - HTTP nur lokal, HTTPS für extern
-**Username/Password** - Basic Authentication
-**Connection Test** - In Einstellungen testen
-**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/`
---
## 🔒 Privacy & Sicherheit
### Self-Hosted
-**Eigener Server** - Volle Kontrolle über Daten
-**Keine Cloud** - Keine Drittanbieter
-**Kein Tracking** - Keine Analytik, keine Telemetrie
-**Kein Account** - Nur Server-Zugangsdaten
-**100% Open Source** - MIT Lizenz
### Daten-Sicherheit
-**Lokale Speicherung** - App-Private Storage (Android)
-**WebDAV-Verschlüsselung** - HTTPS für externe Server
-**Passwort-Speicherung** - Android SharedPreferences (verschlüsselt)
-**Keine Drittanbieter-Libs** - Nur Android SDK + Sardine (WebDAV)
### Entwickler-Features
-**Datei-Logging** - Optional, nur bei Aktivierung _(NEU in v1.3.2)_
-**Datenschutz-Hinweis** - Explizite Warnung bei Aktivierung
-**Lokale Logs** - Logs bleiben auf dem Gerät
---
## 🔋 Performance & Optimierung
### Akku-Effizienz
-**Optimierte Sync-Intervalle** - 15/30/60 Min
-**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:**
- 15 Min: ~0.8% / Tag (~23 mAh)
- 30 Min: ~0.4% / Tag (~12 mAh) ⭐ _Empfohlen_
- 60 Min: ~0.2% / Tag (~6 mAh)
### App-Performance
-**Offline-First** - Funktioniert ohne Internet
-**Instant-Load** - Notizen laden in <100ms
- **Smooth Scrolling** - RecyclerView mit ViewHolder
- **Material Design 3** - Native Android UI
- **Kotlin Coroutines** - Asynchrone Operationen
- **Minimale APK-Größe** - ~2 MB
---
## 🛠️ Technische Details
### Plattform
- **Android 8.0+** (API 26+)
- **Target SDK 36** (Android 15)
- **Kotlin** - Moderne Programmiersprache
- **Material Design 3** - Neueste Design-Richtlinien
- **ViewBinding** - Typ-sichere View-Referenzen
### Architektur
- **MVVM-Light** - Einfache Architektur
- **Single Activity** - Moderne Navigation
- **Kotlin Coroutines** - Async/Await Pattern
- **Dispatchers.IO** - Background-Operationen
- **SharedPreferences** - Settings-Speicherung
- **File-Based Storage** - JSON-Dateien lokal
- **Custom Exceptions** - Dedizierte SyncException für bessere Fehlerbehandlung _(NEU in v1.3.2)_
### Abhängigkeiten
- **AndroidX** - Jetpack Libraries
- **Material Components** - Material Design 3
- **Sardine** - WebDAV Client (com.thegrizzlylabs)
- **Gson** - JSON Serialization
- **WorkManager** - Background Tasks
- **OkHttp** - HTTP Client (via Sardine)
### Build-Varianten
- **Standard** - Universal APK (100% FOSS, keine Google-Dependencies)
- **F-Droid** - Identisch mit Standard (100% FOSS)
- **Debug/Release** - Entwicklung und Production
- **Keine Google Services** - Komplett FOSS, keine proprietären Bibliotheken
---
## 📦 Server-Kompatibilität
### Getestete WebDAV-Server
- **Docker WebDAV** (empfohlen für Self-Hosting)
- **Nextcloud** - Vollständig kompatibel
- **ownCloud** - Funktioniert einwandfrei
- **Apache mod_dav** - Standard WebDAV
- **nginx + WebDAV** - Mit korrekter Konfiguration
### Server-Features
- **Basic Auth** - Username/Password
- **Directory Listing** - Für Download
- **PUT/GET** - Upload/Download
- **MKCOL** - Ordner erstellen
- **DELETE** - Notizen löschen (zukünftig)
---
## 🔮 Zukünftige Features
Geplant für kommende Versionen:
### v1.4.0 - Checklisten
- **Checklisten-Notizen** - Neuer Notiz-Typ mit Checkboxen
- **Erledigte Items** - Durchstreichen/Abhaken
- **Drag & Drop** - Items neu anordnen
### v1.5.0 - Internationalisierung
- **Mehrsprachigkeit** - Deutsch + Englisch UI
- **Sprachauswahl** - In Einstellungen wählbar
- **Vollständige Übersetzung** - Alle Strings in beiden Sprachen
### v1.6.0 - Modern APIs
- **LocalBroadcastManager ersetzen** - SharedFlow stattdessen
- **PackageInfo Flags** - PackageInfoFlags.of() verwenden
- **Komplexitäts-Refactoring** - Lange Funktionen aufteilen
---
## 📊 Vergleich mit anderen Apps
| Feature | Simple Notes Sync | Google Keep | Nextcloud Notes |
|---------|------------------|-------------|-----------------|
| Offline-First | | Eingeschränkt | Eingeschränkt |
| Self-Hosted | | | |
| Auto-Sync | | | |
| Markdown-Export | | | |
| Desktop-Zugriff | (WebDAV) | (Web) | (Web + WebDAV) |
| Lokales Backup | | | Server-Backup |
| Kein Google-Account | | | |
| Open Source | MIT | | AGPL |
| APK-Größe | ~2 MB | ~50 MB | ~8 MB |
| Akku-Verbrauch | ~0.4%/Tag | ~1-2%/Tag | ~0.5%/Tag |
---
## ❓ FAQ
**Q: Brauche ich einen Server?**
A: Nein! Die App funktioniert auch komplett offline. Der Server ist optional für Sync.
**Q: Welcher Server ist am besten?**
A: Für Einstieg: Docker WebDAV (einfach, leicht). Für Profis: Nextcloud (viele Features).
**Q: Funktioniert Markdown-Export ohne Desktop-Integration?**
A: Nein, du musst das Feature in den Einstellungen aktivieren.
**Q: Gehen meine Daten verloren wenn ich den Server wechsle?**
A: Nein! Erstelle ein lokales Backup, wechsle Server, stelle wieder her.
**Q: Warum JSON + Markdown?**
A: JSON ist zuverlässig und schnell (Master). Markdown ist menschenlesbar (Mirror für Desktop).
**Q: Kann ich die App ohne Google Play nutzen?**
A: Ja! Lade die APK direkt von GitHub oder nutze F-Droid.
---
**Letzte Aktualisierung:** v1.3.2 (2026-01-10)

View File

@@ -1,284 +0,0 @@
# Complete Feature List 📋
**🌍 Languages:** [Deutsch](FEATURES.md) · **English**
> All features of Simple Notes Sync in detail
---
## 📝 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
-**Auto-save** - No manual saving needed
-**Title + content** - Clear structure for each note
-**Timestamps** - Creation and modification date automatically
-**Swipe-to-delete** - Intuitive gesture for deletion
-**Confirmation dialog** - Protection against accidental deletion
-**Material Design 3** - Modern, clean UI
-**Dark mode** - Automatically based on system settings
-**Dynamic colors** - Adapts to your Android theme
### Editor
-**Minimalist editor** - No bells and whistles
-**Auto-focus** - Start writing immediately
-**Fullscreen mode** - Maximum writing space
-**Save button** - Manual confirmation possible
-**Back navigation** - Saves automatically
---
## 💾 Backup & Restore
### Local Backup System
-**JSON export** - All notes in one file
-**Free location choice** - Downloads, SD card, cloud folder
-**Filenames with timestamp** - `simplenotes_backup_YYYY-MM-DD_HHmmss.json`
-**Complete export** - Title, content, timestamps, IDs
-**Human-readable format** - JSON with formatting
-**Independent from server** - Works completely offline
### Restore Modes
-**Merge** - Add new notes, keep existing ones _(Default)_
-**Replace** - Delete all and import backup
-**Overwrite duplicates** - Backup wins on ID conflicts
-**Automatic safety backup** - Before every restore
-**Backup validation** - Checks format and version
-**Error handling** - Clear error messages on issues
---
## 🖥️ Desktop Integration
### 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
-**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
-**Progress indicator** - Shows X/Y during export
### Markdown Import
-**Desktop → App** - Import changes from desktop
-**Last-Write-Wins** - Conflict resolution via timestamp
-**Frontmatter parsing** - Reads metadata from `.md` files
-**Detect new notes** - Automatically adopt to app
-**Detect updates** - Only if desktop version is newer
-**Error tolerance** - Individual errors don't abort import
### WebDAV Access
-**Network drive mount** - Windows, macOS, Linux
-**Any Markdown editor** - VS Code, Typora, Notepad++, iA Writer
-**Live editing** - Direct access to `.md` files
-**Folder structure** - `/notes/` for JSON, `/notes-md/` for Markdown
-**Automatic folder creation** - On first sync
---
## 🔄 Synchronization
### Auto-Sync
-**Interval selection** - 15, 30 or 60 minutes
-**WiFi trigger** - Sync on WiFi connection _(no SSID restriction)_
-**Battery-friendly** - ~0.2-0.8% per day
-**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** - 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
### Sync Mechanism
-**Upload** - Local changes to server
-**Download** - Server changes to app
-**Conflict detection** - On simultaneous changes
-**Conflict-free merging** - Last-Write-Wins via timestamp
-**Sync status tracking** - LOCAL_ONLY, PENDING, SYNCED, CONFLICT
-**Error handling** - Retry on network issues
-**Offline-first** - App works without server
### Server Connection
-**WebDAV protocol** - Standard protocol
-**HTTP/HTTPS** - HTTP only local, HTTPS for external
-**Username/password** - Basic authentication
-**Connection test** - Test in settings
-**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/`
---
## 🔒 Privacy & Security
### Self-Hosted
-**Own server** - Full control over data
-**No cloud** - No third parties
-**No tracking** - No analytics, no telemetry
-**No account** - Only server credentials
-**100% open source** - MIT License
### Data Security
-**Local storage** - App-private storage (Android)
-**WebDAV encryption** - HTTPS for external servers
-**Password storage** - Android SharedPreferences (encrypted)
-**No third-party libs** - Only Android SDK + Sardine (WebDAV)
### Developer Features
-**File logging** - Optional, only when enabled _(NEW in v1.3.2)_
-**Privacy notice** - Explicit warning on activation
-**Local logs** - Logs stay on device
---
## 🔋 Performance & Optimization
### Battery Efficiency
-**Optimized sync intervals** - 15/30/60 min
-**WiFi-only** - No mobile data sync
-**Smart server check** - Sync only when server is reachable
-**WorkManager** - System-optimized execution
-**Doze mode compatible** - Sync runs even in standby
-**Measured consumption:**
- 15 min: ~0.8% / day (~23 mAh)
- 30 min: ~0.4% / day (~12 mAh) ⭐ _Recommended_
- 60 min: ~0.2% / day (~6 mAh)
### App Performance
-**Offline-first** - Works without internet
-**Instant-load** - Notes load in <100ms
- **Smooth scrolling** - RecyclerView with ViewHolder
- **Material Design 3** - Native Android UI
- **Kotlin Coroutines** - Asynchronous operations
- **Minimal APK size** - ~2 MB
---
## 🛠️ Technical Details
### Platform
- **Android 8.0+** (API 26+)
- **Target SDK 36** (Android 15)
- **Kotlin** - Modern programming language
- **Material Design 3** - Latest design guidelines
- **ViewBinding** - Type-safe view references
### Architecture
- **MVVM-Light** - Simple architecture
- **Single Activity** - Modern navigation
- **Kotlin Coroutines** - Async/Await pattern
- **Dispatchers.IO** - Background operations
- **SharedPreferences** - Settings storage
- **File-based storage** - JSON files locally
- **Custom exceptions** - Dedicated SyncException for better error handling _(NEW in v1.3.2)_
### Dependencies
- **AndroidX** - Jetpack libraries
- **Material Components** - Material Design 3
- **Sardine** - WebDAV client (com.thegrizzlylabs)
- **Gson** - JSON serialization
- **WorkManager** - Background tasks
- **OkHttp** - HTTP client (via Sardine)
### Build Variants
- **Standard** - Universal APK (100% FOSS, no Google dependencies)
- **F-Droid** - Identical to Standard (100% FOSS)
- **Debug/Release** - Development and production
- **No Google Services** - Completely FOSS, no proprietary libraries
---
## 📦 Server Compatibility
### Tested WebDAV Servers
- **Docker WebDAV** (recommended for self-hosting)
- **Nextcloud** - Fully compatible
- **ownCloud** - Works perfectly
- **Apache mod_dav** - Standard WebDAV
- **nginx + WebDAV** - With correct configuration
### Server Features
- **Basic Auth** - Username/password
- **Directory listing** - For download
- **PUT/GET** - Upload/download
- **MKCOL** - Create folders
- **DELETE** - Delete notes (future)
---
## 🔮 Future Features
Planned for upcoming versions:
### v1.4.0 - Checklists
- **Checklist notes** - New note type with checkboxes
- **Completed items** - Strike-through/check off
- **Drag & drop** - Reorder items
### v1.5.0 - Internationalization
- **Multi-language** - German + English UI
- **Language selection** - Selectable in settings
- **Full translation** - All strings in both languages
### v1.6.0 - Modern APIs
- **Replace LocalBroadcastManager** - Use SharedFlow instead
- **PackageInfo Flags** - Use PackageInfoFlags.of()
- **Complexity refactoring** - Split long functions
---
## 📊 Comparison with Other Apps
| Feature | Simple Notes Sync | Google Keep | Nextcloud Notes |
|---------|------------------|-------------|-----------------|
| Offline-first | | Limited | Limited |
| Self-hosted | | | |
| Auto-sync | | | |
| Markdown export | | | |
| Desktop access | (WebDAV) | (Web) | (Web + WebDAV) |
| Local backup | | | Server backup |
| No Google account | | | |
| Open source | MIT | | AGPL |
| APK size | ~2 MB | ~50 MB | ~8 MB |
| Battery usage | ~0.4%/day | ~1-2%/day | ~0.5%/day |
---
## ❓ FAQ
**Q: Do I need a server?**
A: No! The app works completely offline. The server is optional for sync.
**Q: Which server is best?**
A: For beginners: Docker WebDAV (simple, easy). For pros: Nextcloud (many features).
**Q: Does Markdown export work without Desktop Integration?**
A: No, you need to activate the feature in settings.
**Q: Will my data be lost if I switch servers?**
A: No! Create a local backup, switch servers, restore.
**Q: Why JSON + Markdown?**
A: JSON is reliable and fast (master). Markdown is human-readable (mirror for desktop).
**Q: Can I use the app without Google Play?**
A: Yes! Download the APK directly from GitHub or use F-Droid.
---
**Last update:** v1.3.2 (2026-01-10)

View File

@@ -1,284 +1,305 @@
# Vollständige Feature-Liste 📋
# Complete Feature List 📋
**🌍 Languages:** **Deutsch** · [English](FEATURES.en.md)
**🌍 Languages:** [Deutsch](FEATURES.de.md) · **English**
> Alle Features von Simple Notes Sync im Detail
> All features of Simple Notes Sync in detail
---
## 📝 Notiz-Verwaltung
## 📝 Note Management
### 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
### 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
- ~~Strikethrough~~ for completed entries
### Basis-Funktionen
-**Automatisches Speichern** - Kein manuelles Speichern nötig
-**Titel + Inhalt** - Klare Struktur für jede Notiz
-**Zeitstempel** - Erstellungs- und Änderungsdatum automatisch
-**Swipe-to-Delete** - Intuitive Geste zum Löschen
-**Bestätigungs-Dialog** - Schutz vor versehentlichem Löschen
-**Material Design 3** - Moderne, saubere UI
-**Dark Mode** - Automatisch je nach System-Einstellung
-**Dynamic Colors** - Passt sich deinem Android-Theme an
### Basic Features
-**Auto-save** - No manual saving needed
-**Title + content** - Clear structure for each note
-**Timestamps** - Creation and modification date automatically
-**Selection Mode** _(NEW in v1.5.0)_ - Long-press for multi-select and batch delete
-**Confirmation dialog** - Protection against accidental deletion
-**Jetpack Compose UI** _(NEW in v1.5.0)_ - Modern, performant user interface
-**Material Design 3** - Modern, clean UI
-**Dark mode** - Automatically based on system settings
-**Dynamic colors** - Adapts to your Android theme
### Editor
-**Minimalistischer Editor** - Kein Schnickschnack
-**Auto-Fokus** - Direkt losschreiben
-**Vollbild-Modus** - Maximale Schreibfläche
-**Speichern-Button** - Manuelle Bestätigung möglich
-**Zurück-Navigation** - Speichert automatisch
-**Minimalist editor** - No bells and whistles
-**Auto-focus** - Start writing immediately
-**Fullscreen mode** - Maximum writing space
-**Save button** - Manual confirmation possible
-**Back navigation** - Saves automatically
-**Slide animations** _(NEW in v1.5.0)_ - Smooth transitions
---
## 💾 Backup & Wiederherstellung
## 🌍 Multilingual Support _(NEW in v1.5.0)_
### Lokales Backup System
-**JSON-Export** - Alle Notizen in einer Datei
-**Freie Speicherort-Wahl** - Downloads, SD-Karte, Cloud-Ordner
-**Dateinamen mit Zeitstempel** - `simplenotes_backup_YYYY-MM-DD_HHmmss.json`
-**Vollständiger Export** - Titel, Inhalt, Timestamps, IDs
-**Menschenlesbares Format** - JSON mit Formatierung
-**Unabhängig vom Server** - Funktioniert komplett offline
### Supported Languages
-**English** - Primary language (default)
-**German** - Fully translated
### Wiederherstellungs-Modi
-**Zusammenführen (Merge)** - Neue Notizen hinzufügen, bestehende behalten _(Standard)_
-**Ersetzen (Replace)** - Alle löschen und Backup importieren
-**Duplikate überschreiben (Overwrite)** - Backup gewinnt bei ID-Konflikten
-**Automatisches Sicherheits-Backup** - Vor jeder Wiederherstellung
-**Backup-Validierung** - Prüft Format und Version
-**Fehlerbehandlung** - Klare Fehlermeldungen bei Problemen
### Language Selection
-**Automatic detection** - Follows system language
-**Manual selection** - Switchable in settings
-**Per-App Language** - Android 13+ native language selection
-**locales_config.xml** - Complete Android integration
### Scope
-**400+ strings** - Fully translated
-**UI texts** - All buttons, dialogs, menus
-**Error messages** - Helpful localized hints
-**Settings** - 7 categorized screens
---
## 🖥️ Desktop-Integration
## 💾 Backup & Restore
### 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
-**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
-**Progress-Anzeige** - Zeigt X/Y beim Export
### Local Backup System
-**JSON export** - All notes in one file
-**Free location choice** - Downloads, SD card, cloud folder
-**Filenames with timestamp** - `simplenotes_backup_YYYY-MM-DD_HHmmss.json`
-**Complete export** - Title, content, timestamps, IDs
-**Human-readable format** - JSON with formatting
-**Independent from server** - Works completely offline
### Markdown-Import
-**Desktop → App** - Änderungen vom Desktop importieren
-**Last-Write-Wins** - Konfliktauflösung via Timestamp
-**Frontmatter-Parsing** - Liest Metadata aus `.md` Dateien
-**Neue Notizen erkennen** - Automatisch in App übernehmen
-**Updates erkennen** - Nur wenn Desktop-Version neuer ist
-**Fehlertoleranz** - Einzelne Fehler brechen Import nicht ab
### WebDAV-Zugriff
-**Network Drive Mount** - Windows, macOS, Linux
-**Jeder Markdown-Editor** - VS Code, Typora, Notepad++, iA Writer
-**Live-Bearbeitung** - Direkter Zugriff auf `.md` Dateien
-**Ordner-Struktur** - `/notes/` für JSON, `/notes-md/` für Markdown
-**Automatische Ordner-Erstellung** - Beim ersten Sync
### Restore Modes
-**Merge** - Add new notes, keep existing ones _(Default)_
-**Replace** - Delete all and import backup
-**Overwrite duplicates** - Backup wins on ID conflicts
-**Automatic safety backup** - Before every restore
-**Backup validation** - Checks format and version
-**Error handling** - Clear error messages on issues
---
## 🔄 Synchronisation
## 🖥️ Desktop Integration
### 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
-**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
-**Progress indicator** - Shows X/Y during export
### Markdown Import
-**Desktop → App** - Import changes from desktop
-**Last-Write-Wins** - Conflict resolution via timestamp
-**Frontmatter parsing** - Reads metadata from `.md` files
-**Detect new notes** - Automatically adopt to app
-**Detect updates** - Only if desktop version is newer
-**Error tolerance** - Individual errors don't abort import
### WebDAV Access
-**Network drive mount** - Windows, macOS, Linux
-**Any Markdown editor** - VS Code, Typora, Notepad++, iA Writer
-**Live editing** - Direct access to `.md` files
-**Folder structure** - `/notes/` for JSON, `/notes-md/` for Markdown
-**Automatic folder creation** - On first sync
---
## 🔄 Synchronization
### Auto-Sync
-**Intervall-Auswahl** - 15, 30 oder 60 Minuten
-**WiFi-Trigger** - Sync bei WiFi-Verbindung _(keine SSID-Einschränkung)_
-**Akkuschonend** - ~0.2-0.8% pro Tag
-**Smart Server-Check** - Sync nur wenn Server erreichbar
-**WorkManager** - Zuverlässige Background-Ausführung
-**Battery-Optimierung kompatibel** - Funktioniert auch mit Doze Mode
-**Interval selection** - 15, 30 or 60 minutes
-**WiFi trigger** - Sync on WiFi connection _(no SSID restriction)_
-**Battery-friendly** - ~0.2-0.8% per day
-**Smart server check** - Sync only when server is reachable
-**WorkManager** - Reliable background execution
-**Battery optimization compatible** - Works even with 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** - 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
### Sync Triggers (6 total)
1.**Periodic sync** - Automatically after interval
2.**App-start sync** - When opening the app
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
### Sync-Mechanismus
-**Upload** - Lokale Änderungen zum Server
-**Download** - Server-Änderungen in App
-**Konflikt-Erkennung** - Bei gleichzeitigen Änderungen
-**Konfliktfreies Merging** - Last-Write-Wins via Timestamp
-**Sync-Status Tracking** - LOCAL_ONLY, PENDING, SYNCED, CONFLICT
-**Fehlerbehandlung** - Retry bei Netzwerkproblemen
-**Offline-First** - App funktioniert ohne Server
### Sync Mechanism
-**Upload** - Local changes to server
-**Download** - Server changes to app
-**Conflict detection** - On simultaneous changes
-**Conflict-free merging** - Last-Write-Wins via timestamp
-**Sync status tracking** - LOCAL_ONLY, PENDING, SYNCED, CONFLICT
-**Error handling** - Retry on network issues
-**Offline-first** - App works without server
### Server-Verbindung
-**WebDAV-Protokoll** - Standard-Protokoll
-**HTTP/HTTPS** - HTTP nur lokal, HTTPS für extern
-**Username/Password** - Basic Authentication
-**Connection Test** - In Einstellungen testen
-**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/`
### Server Connection
-**WebDAV protocol** - Standard protocol
-**HTTP/HTTPS** - HTTP only local, HTTPS for external
-**Username/password** - Basic authentication
-**Connection test** - Test in settings
-**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/`
---
## 🔒 Privacy & Sicherheit
## 🔒 Privacy & Security
### Self-Hosted
-**Eigener Server** - Volle Kontrolle über Daten
-**Keine Cloud** - Keine Drittanbieter
-**Kein Tracking** - Keine Analytik, keine Telemetrie
-**Kein Account** - Nur Server-Zugangsdaten
-**100% Open Source** - MIT Lizenz
-**Own server** - Full control over data
-**No cloud** - No third parties
-**No tracking** - No analytics, no telemetry
-**No account** - Only server credentials
-**100% open source** - MIT License
### Daten-Sicherheit
-**Lokale Speicherung** - App-Private Storage (Android)
-**WebDAV-Verschlüsselung** - HTTPS für externe Server
-**Passwort-Speicherung** - Android SharedPreferences (verschlüsselt)
-**Keine Drittanbieter-Libs** - Nur Android SDK + Sardine (WebDAV)
### Data Security
-**Local storage** - App-private storage (Android)
-**WebDAV encryption** - HTTPS for external servers
-**Password storage** - Android SharedPreferences (encrypted)
-**No third-party libs** - Only Android SDK + Sardine (WebDAV)
### Entwickler-Features
-**Datei-Logging** - Optional, nur bei Aktivierung _(NEU in v1.3.2)_
-**Datenschutz-Hinweis** - Explizite Warnung bei Aktivierung
-**Lokale Logs** - Logs bleiben auf dem Gerät
### Developer Features
-**File logging** - Optional, only when enabled _(NEW in v1.3.2)_
-**Privacy notice** - Explicit warning on activation
-**Local logs** - Logs stay on device
---
## 🔋 Performance & Optimierung
## 🔋 Performance & Optimization
### Akku-Effizienz
-**Optimierte Sync-Intervalle** - 15/30/60 Min
-**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:**
- 15 Min: ~0.8% / Tag (~23 mAh)
- 30 Min: ~0.4% / Tag (~12 mAh) ⭐ _Empfohlen_
- 60 Min: ~0.2% / Tag (~6 mAh)
### Battery Efficiency
-**Optimized sync intervals** - 15/30/60 min
-**WiFi-only** - No mobile data sync
-**Smart server check** - Sync only when server is reachable
-**WorkManager** - System-optimized execution
-**Doze mode compatible** - Sync runs even in standby
-**Measured consumption:**
- 15 min: ~0.8% / day (~23 mAh)
- 30 min: ~0.4% / day (~12 mAh) ⭐ _Recommended_
- 60 min: ~0.2% / day (~6 mAh)
### App-Performance
-**Offline-First** - Funktioniert ohne Internet
-**Instant-Load** - Notizen laden in <100ms
- **Smooth Scrolling** - RecyclerView mit ViewHolder
### App Performance
-**Offline-first** - Works without internet
-**Instant-load** - Notes load in <100ms
- **Smooth scrolling** - RecyclerView with ViewHolder
- **Material Design 3** - Native Android UI
- **Kotlin Coroutines** - Asynchrone Operationen
- **Minimale APK-Größe** - ~2 MB
- **Kotlin Coroutines** - Asynchronous operations
- **Minimal APK size** - ~2 MB
---
## 🛠️ Technische Details
## 🛠️ Technical Details
### Plattform
### Platform
- **Android 8.0+** (API 26+)
- **Target SDK 36** (Android 15)
- **Kotlin** - Moderne Programmiersprache
- **Material Design 3** - Neueste Design-Richtlinien
- **ViewBinding** - Typ-sichere View-Referenzen
- **Kotlin** - Modern programming language
- **Material Design 3** - Latest design guidelines
- **ViewBinding** - Type-safe view references
### Architektur
- **MVVM-Light** - Einfache Architektur
- **Single Activity** - Moderne Navigation
- **Kotlin Coroutines** - Async/Await Pattern
- **Dispatchers.IO** - Background-Operationen
- **SharedPreferences** - Settings-Speicherung
- **File-Based Storage** - JSON-Dateien lokal
- **Custom Exceptions** - Dedizierte SyncException für bessere Fehlerbehandlung _(NEU in v1.3.2)_
### Architecture
- **MVVM-Light** - Simple architecture
- **Single Activity** - Modern navigation
- **Kotlin Coroutines** - Async/Await pattern
- **Dispatchers.IO** - Background operations
- **SharedPreferences** - Settings storage
- **File-based storage** - JSON files locally
- **Custom exceptions** - Dedicated SyncException for better error handling _(NEW in v1.3.2)_
### Abhängigkeiten
- **AndroidX** - Jetpack Libraries
### Dependencies
- **AndroidX** - Jetpack libraries
- **Material Components** - Material Design 3
- **Sardine** - WebDAV Client (com.thegrizzlylabs)
- **Gson** - JSON Serialization
- **WorkManager** - Background Tasks
- **OkHttp** - HTTP Client (via Sardine)
- **Sardine** - WebDAV client (com.thegrizzlylabs)
- **Gson** - JSON serialization
- **WorkManager** - Background tasks
- **OkHttp** - HTTP client (via Sardine)
### Build-Varianten
- **Standard** - Universal APK (100% FOSS, keine Google-Dependencies)
- **F-Droid** - Identisch mit Standard (100% FOSS)
- **Debug/Release** - Entwicklung und Production
- **Keine Google Services** - Komplett FOSS, keine proprietären Bibliotheken
### Build Variants
- **Standard** - Universal APK (100% FOSS, no Google dependencies)
- **F-Droid** - Identical to Standard (100% FOSS)
- **Debug/Release** - Development and production
- **No Google Services** - Completely FOSS, no proprietary libraries
---
## 📦 Server-Kompatibilität
## 📦 Server Compatibility
### Getestete WebDAV-Server
- **Docker WebDAV** (empfohlen für Self-Hosting)
- **Nextcloud** - Vollständig kompatibel
- **ownCloud** - Funktioniert einwandfrei
### Tested WebDAV Servers
- **Docker WebDAV** (recommended for self-hosting)
- **Nextcloud** - Fully compatible
- **ownCloud** - Works perfectly
- **Apache mod_dav** - Standard WebDAV
- **nginx + WebDAV** - Mit korrekter Konfiguration
- **nginx + WebDAV** - With correct configuration
### Server-Features
- **Basic Auth** - Username/Password
- **Directory Listing** - Für Download
- **PUT/GET** - Upload/Download
- **MKCOL** - Ordner erstellen
- **DELETE** - Notizen löschen (zukünftig)
### Server Features
- **Basic Auth** - Username/password
- **Directory listing** - For download
- **PUT/GET** - Upload/download
- **MKCOL** - Create folders
- **DELETE** - Delete notes (future)
---
## 🔮 Zukünftige Features
## 🔮 Future Features
Geplant für kommende Versionen:
Planned for upcoming versions:
### v1.4.0 - Checklisten
- **Checklisten-Notizen** - Neuer Notiz-Typ mit Checkboxen
- **Erledigte Items** - Durchstreichen/Abhaken
- **Drag & Drop** - Items neu anordnen
### v1.4.0 - Checklists
- **Checklist notes** - New note type with checkboxes
- **Completed items** - Strike-through/check off
- **Drag & drop** - Reorder items
### v1.5.0 - Internationalisierung
- **Mehrsprachigkeit** - Deutsch + Englisch UI
- **Sprachauswahl** - In Einstellungen wählbar
- **Vollständige Übersetzung** - Alle Strings in beiden Sprachen
### v1.5.0 - Internationalization
- **Multi-language** - German + English UI
- **Language selection** - Selectable in settings
- **Full translation** - All strings in both languages
### v1.6.0 - Modern APIs
- **LocalBroadcastManager ersetzen** - SharedFlow stattdessen
- **PackageInfo Flags** - PackageInfoFlags.of() verwenden
- **Komplexitäts-Refactoring** - Lange Funktionen aufteilen
- **Replace LocalBroadcastManager** - Use SharedFlow instead
- **PackageInfo Flags** - Use PackageInfoFlags.of()
- **Complexity refactoring** - Split long functions
---
## 📊 Vergleich mit anderen Apps
## 📊 Comparison with Other Apps
| Feature | Simple Notes Sync | Google Keep | Nextcloud Notes |
|---------|------------------|-------------|-----------------|
| Offline-First | | Eingeschränkt | Eingeschränkt |
| Self-Hosted | | | |
| Auto-Sync | | | |
| Markdown-Export | | | |
| Desktop-Zugriff | (WebDAV) | (Web) | (Web + WebDAV) |
| Lokales Backup | | | Server-Backup |
| Kein Google-Account | | | |
| Open Source | MIT | | AGPL |
| APK-Größe | ~2 MB | ~50 MB | ~8 MB |
| Akku-Verbrauch | ~0.4%/Tag | ~1-2%/Tag | ~0.5%/Tag |
| Offline-first | | Limited | Limited |
| Self-hosted | | | |
| Auto-sync | | | |
| Markdown export | | | |
| Desktop access | (WebDAV) | (Web) | (Web + WebDAV) |
| Local backup | | | Server backup |
| No Google account | | | |
| Open source | MIT | | AGPL |
| APK size | ~2 MB | ~50 MB | ~8 MB |
| Battery usage | ~0.4%/day | ~1-2%/day | ~0.5%/day |
---
## ❓ FAQ
**Q: Brauche ich einen Server?**
A: Nein! Die App funktioniert auch komplett offline. Der Server ist optional für Sync.
**Q: Do I need a server?**
A: No! The app works completely offline. The server is optional for sync.
**Q: Welcher Server ist am besten?**
A: Für Einstieg: Docker WebDAV (einfach, leicht). Für Profis: Nextcloud (viele Features).
**Q: Which server is best?**
A: For beginners: Docker WebDAV (simple, easy). For pros: Nextcloud (many features).
**Q: Funktioniert Markdown-Export ohne Desktop-Integration?**
A: Nein, du musst das Feature in den Einstellungen aktivieren.
**Q: Does Markdown export work without Desktop Integration?**
A: No, you need to activate the feature in settings.
**Q: Gehen meine Daten verloren wenn ich den Server wechsle?**
A: Nein! Erstelle ein lokales Backup, wechsle Server, stelle wieder her.
**Q: Will my data be lost if I switch servers?**
A: No! Create a local backup, switch servers, restore.
**Q: Warum JSON + Markdown?**
A: JSON ist zuverlässig und schnell (Master). Markdown ist menschenlesbar (Mirror für Desktop).
**Q: Why JSON + Markdown?**
A: JSON is reliable and fast (master). Markdown is human-readable (mirror for desktop).
**Q: Kann ich die App ohne Google Play nutzen?**
A: Ja! Lade die APK direkt von GitHub oder nutze F-Droid.
**Q: Can I use the app without Google Play?**
A: Yes! Download the APK directly from GitHub or use F-Droid.
---
**Letzte Aktualisierung:** v1.3.2 (2026-01-10)
**Last update:** v1.3.2 (2026-01-10)

151
docs/TRANSLATING.de.md Normal file
View File

@@ -0,0 +1,151 @@
# Übersetzung beitragen 🌍
**🌍 Sprachen:** **Deutsch** · [English](TRANSLATING.md)
> So kannst du Simple Notes Sync in deine Sprache übersetzen!
---
## 📋 Übersicht
Simple Notes Sync unterstützt aktuell:
- 🇺🇸 **Englisch** (en) - Primärsprache
- 🇩🇪 **Deutsch** (de) - Vollständig übersetzt
Wir freuen uns über neue Übersetzungen!
---
## 🚀 Schnellstart
### 1. Repository forken
1. Gehe zu [github.com/inventory69/simple-notes-sync](https://github.com/inventory69/simple-notes-sync)
2. Klicke auf **Fork** (oben rechts)
3. Clone dein Fork: `git clone https://github.com/DEIN-USERNAME/simple-notes-sync.git`
### 2. Sprachdateien erstellen
```bash
cd simple-notes-sync/android/app/src/main/res
# Ordner für deine Sprache erstellen (z.B. Französisch)
mkdir values-fr
# Strings kopieren
cp values/strings.xml values-fr/strings.xml
```
### 3. Strings übersetzen
Öffne `values-fr/strings.xml` und übersetze alle `<string>`-Einträge:
```xml
<!-- Original (Englisch) -->
<string name="app_name">Simple Notes</string>
<string name="notes_title">Notes</string>
<!-- Übersetzt (Französisch) -->
<string name="app_name">Notes Simples</string>
<string name="notes_title">Notes</string>
```
**Wichtig:**
- Übersetze nur den Text zwischen `>` und `</string>`
- Ändere NICHT die `name="..."` Attribute
- Behalte `%s`, `%d`, `%1$s` etc. als Platzhalter
### 4. locales_config.xml aktualisieren
Füge deine Sprache in `android/app/src/main/res/xml/locales_config.xml` hinzu:
```xml
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="de" />
<locale android:name="fr" /> <!-- NEU -->
</locale-config>
```
### 5. Pull Request erstellen
1. Committe deine Änderungen
2. Pushe zu deinem Fork
3. Erstelle einen Pull Request mit Titel: `Add [Language] translation`
---
## 📁 Dateistruktur
```
android/app/src/main/res/
├── values/ # Englisch (Fallback)
│ └── strings.xml
├── values-de/ # Deutsch
│ └── strings.xml
├── values-fr/ # Französisch (neu)
│ └── strings.xml
└── xml/
└── locales_config.xml # Sprachregistrierung
```
---
## 📝 String-Kategorien
Die `strings.xml` enthält etwa 400+ Strings, aufgeteilt in:
| Kategorie | Beschreibung | Anzahl |
|-----------|--------------|--------|
| UI Texte | Buttons, Labels, Titel | ~100 |
| Settings | Alle 7 Einstellungs-Screens | ~150 |
| Dialoge | Bestätigungen, Fehler | ~80 |
| Sync | Synchronisations-Meldungen | ~50 |
| Sonstige | Tooltips, Accessibility | ~30 |
---
## ✅ Qualitätscheckliste
Vor dem Pull Request:
- [ ] Alle Strings übersetzt (keine englischen Reste)
- [ ] Platzhalter (`%s`, `%d`) beibehalten
- [ ] Keine XML-Syntaxfehler
- [ ] App startet ohne Crashes
- [ ] Text passt in UI-Elemente (nicht zu lang)
- [ ] `locales_config.xml` aktualisiert
---
## 🔧 Testen
```bash
cd android
./gradlew app:assembleDebug
# APK installieren und Sprache in Android-Einstellungen wechseln
```
---
## ❓ FAQ
**Muss ich alle Strings übersetzen?**
> Idealerweise ja. Fehlende Strings fallen auf Englisch zurück.
**Was passiert mit Platzhaltern?**
> `%s` = Text, `%d` = Zahl. Position beibehalten oder mit `%1$s` nummerieren.
**Wie teste ich meine Übersetzung?**
> App bauen, installieren, in Android-Einstellungen → Apps → Simple Notes → Sprache wählen.
---
## 🙏 Danke!
Jede Übersetzung hilft Simple Notes Sync mehr Menschen zu erreichen.
Bei Fragen: [GitHub Issue erstellen](https://github.com/inventory69/simple-notes-sync/issues)
[← Zurück zur Dokumentation](DOCS.md)

151
docs/TRANSLATING.md Normal file
View File

@@ -0,0 +1,151 @@
# Contributing Translations 🌍
**🌍 Languages:** [Deutsch](TRANSLATING.de.md) · **English**
> How to translate Simple Notes Sync into your language!
---
## 📋 Overview
Simple Notes Sync currently supports:
- 🇺🇸 **English** (en) - Primary language
- 🇩🇪 **German** (de) - Fully translated
We welcome new translations!
---
## 🚀 Quick Start
### 1. Fork the Repository
1. Go to [github.com/inventory69/simple-notes-sync](https://github.com/inventory69/simple-notes-sync)
2. Click **Fork** (top right)
3. Clone your fork: `git clone https://github.com/YOUR-USERNAME/simple-notes-sync.git`
### 2. Create Language Files
```bash
cd simple-notes-sync/android/app/src/main/res
# Create folder for your language (e.g., French)
mkdir values-fr
# Copy strings
cp values/strings.xml values-fr/strings.xml
```
### 3. Translate Strings
Open `values-fr/strings.xml` and translate all `<string>` entries:
```xml
<!-- Original (English) -->
<string name="app_name">Simple Notes</string>
<string name="notes_title">Notes</string>
<!-- Translated (French) -->
<string name="app_name">Notes Simples</string>
<string name="notes_title">Notes</string>
```
**Important:**
- Only translate text between `>` and `</string>`
- Do NOT change `name="..."` attributes
- Keep `%s`, `%d`, `%1$s` etc. as placeholders
### 4. Update locales_config.xml
Add your language to `android/app/src/main/res/xml/locales_config.xml`:
```xml
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="de" />
<locale android:name="fr" /> <!-- NEW -->
</locale-config>
```
### 5. Create Pull Request
1. Commit your changes
2. Push to your fork
3. Create a Pull Request with title: `Add [Language] translation`
---
## 📁 File Structure
```
android/app/src/main/res/
├── values/ # English (Fallback)
│ └── strings.xml
├── values-de/ # German
│ └── strings.xml
├── values-fr/ # French (new)
│ └── strings.xml
└── xml/
└── locales_config.xml # Language registration
```
---
## 📝 String Categories
The `strings.xml` contains about 400+ strings, divided into:
| Category | Description | Count |
|----------|-------------|-------|
| UI Texts | Buttons, labels, titles | ~100 |
| Settings | All 7 settings screens | ~150 |
| Dialogs | Confirmations, errors | ~80 |
| Sync | Synchronization messages | ~50 |
| Other | Tooltips, accessibility | ~30 |
---
## ✅ Quality Checklist
Before creating your Pull Request:
- [ ] All strings translated (no English leftovers)
- [ ] Placeholders (`%s`, `%d`) preserved
- [ ] No XML syntax errors
- [ ] App launches without crashes
- [ ] Text fits in UI elements (not too long)
- [ ] `locales_config.xml` updated
---
## 🔧 Testing
```bash
cd android
./gradlew app:assembleDebug
# Install APK and switch language in Android settings
```
---
## ❓ FAQ
**Do I need to translate all strings?**
> Ideally yes. Missing strings fall back to English.
**What about placeholders?**
> `%s` = text, `%d` = number. Keep position or use `%1$s` for numbering.
**How do I test my translation?**
> Build app, install, go to Android Settings → Apps → Simple Notes → Language.
---
## 🙏 Thank You!
Every translation helps Simple Notes Sync reach more people.
Questions? [Create a GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues)
[← Back to Documentation](DOCS.md)

76
docs/UPCOMING.de.md Normal file
View File

@@ -0,0 +1,76 @@
# Geplante Features 🚀
**🌍 Sprachen:** **Deutsch** · [English](UPCOMING.md)
> Was kommt als Nächstes? Hier findest du unsere Pläne für zukünftige Versionen.
---
## v1.5.0 - Jetpack Compose & Internationalisierung ✅
> **Status:** Released 🎉 (Januar 2026)
### 🎨 Jetpack Compose UI
-**Komplettes UI-Redesign** - Von XML-Views zu Jetpack Compose
-**Modernisierte Einstellungen** - 7 kategorisierte Screens
-**Selection Mode** - Long-Press für Mehrfachauswahl
-**Silent-Sync Mode** - Kein Banner bei Auto-Sync
### 🌍 Mehrsprachigkeit
-**Englisch + Deutsch** - 400+ übersetzte Strings
-**Automatische Spracherkennung** - Folgt der System-Sprache
-**Per-App Language (Android 13+)** - Native Sprachauswahl
### 🎨 UI-Verbesserungen
-**Splash Screen** - App-Foreground-Icon
-**App Icon** - In About Screen und Empty State
-**Slide-Animationen** - Flüssige Übergänge im NoteEditor
---
## v1.6.0 - Technische Modernisierung
> **Status:** In Planung 📋
### 🔧 Server-Ordner Prüfung
- **WebDAV Folder Check** - Prüft ob der Ordner auf dem Server existiert und beschreibbar ist
- **Bessere Fehlermeldungen** - Hilfreiche Hinweise bei Server-Problemen
- **Connection-Test Verbesserung** - Prüft Read/Write Permissions
### 🔧 Technische Verbesserungen
- **Code-Refactoring** - LongMethod und LargeClass Warnings beheben
- **Modernere Background-Sync Architektur** - Noch zuverlässiger
- **Verbesserte Progress-Dialoge** - Material Design 3 konform
---
## v1.7.0 - Community Features
> **Status:** Ideen-Sammlung 💡
### Mögliche Features
- **Zusätzliche Sprachen** - Community-Übersetzungen (FR, ES, IT, ...)
- **Kategorien/Tags** - Notizen organisieren
- **Suche** - Volltextsuche in Notizen
- **Widget** - Schnellzugriff vom Homescreen
---
## 💡 Feedback & Wünsche
Hast du eine Idee für ein neues Feature?
- **[Feature Request erstellen](https://github.com/inventory69/simple-notes-sync/issues/new?template=feature_request.yml)**
- **[Bestehende Wünsche ansehen](https://github.com/inventory69/simple-notes-sync/issues?q=is%3Aissue+label%3Aenhancement)**
---
**Hinweis:** Diese Roadmap zeigt unsere aktuellen Pläne. Prioritäten können sich basierend auf Community-Feedback ändern.
[← Zurück zur Dokumentation](DOCS.md)

View File

@@ -1,56 +0,0 @@
# Upcoming Features 🚀
**🌍 Languages:** [Deutsch](UPCOMING.md) · **English**
> What's next? Here you'll find our plans for future versions.
---
## v1.5.0 - Internationalization & UI Polish
> **Status:** In Development 🔨
### 🌍 Multi-Language Support
- **English as default language** - International users welcome
- **German translation** - Fully translated
- **Automatic language detection** - Follows system language
- **Manual language selection** - Switchable in settings
### ⚙️ Redesigned Settings
- **Modernized settings design** - Cleaner and more intuitive
- **Categorized options** - Sync, backup, developer options
### 🎨 UI Improvements
- **Splash screen** - App icon on startup
- **Server folder check** - Better error messages during setup
- **Subtle sync indicator** - Banner only shown on actual changes
---
## v1.6.0 - Modern APIs
> **Status:** Planned 📋
### 🔧 Technical Modernization
- **Modern background sync architecture** - Even more reliable
- **Improved progress dialogs** - Material Design 3 compliant
- **Code quality** - Internal optimizations
---
## 💡 Feedback & Suggestions
Have an idea for a new feature?
- **[Create a feature request](https://github.com/inventory69/simple-notes-sync/issues/new?template=feature_request.yml)**
- **[View existing requests](https://github.com/inventory69/simple-notes-sync/issues?q=is%3Aissue+label%3Aenhancement)**
---
**Note:** This roadmap shows our current plans. Priorities may change based on community feedback.
[← Back to documentation](DOCS.en.md)

View File

@@ -1,56 +1,76 @@
# Geplante Features 🚀
# Upcoming Features 🚀
**🌍 Languages:** **Deutsch** · [English](UPCOMING.en.md)
**🌍 Languages:** [Deutsch](UPCOMING.de.md) · **English**
> Was kommt als Nächstes? Hier findest du unsere Pläne für zukünftige Versionen.
> What's next? Here you'll find our plans for future versions.
---
## v1.5.0 - Internationalisierung & UI-Polish
## v1.5.0 - Jetpack Compose & Internationalization ✅
> **Status:** In Entwicklung 🔨
> **Status:** Released 🎉 (January 2026)
### 🌍 Mehrsprachigkeit
### 🎨 Jetpack Compose UI
- **Englisch als Standard-Sprache** - Internationale Nutzer willkommen
- **Deutsche Übersetzung** - Vollständig übersetzt
- **Automatische Spracherkennung** - Folgt der System-Sprache
- **Manuelle Sprachwahl** - In den Einstellungen umschaltbar
- **Complete UI redesign** - From XML views to Jetpack Compose
- **Modernized settings** - 7 categorized screens
- **Selection Mode** - Long-press for multi-select
- **Silent-Sync Mode** - No banner during auto-sync
### ⚙️ Überarbeitete Einstellungen
### 🌍 Multi-Language Support
- **Modernisiertes Settings-Design** - Übersichtlicher und intuitiver
- **Kategorisierte Optionen** - Sync, Backup, Entwickler-Optionen
- **English + German** - 400+ translated strings
- **Automatic language detection** - Follows system language
-**Per-App Language (Android 13+)** - Native language selection
### 🎨 UI-Verbesserungen
### 🎨 UI Improvements
- **Splash Screen** - App-Icon beim Start
- **Server-Ordner Prüfung** - Bessere Fehlermeldungen bei der Einrichtung
- **Dezentere Sync-Anzeige** - Banner nur bei tatsächlichen Änderungen
- **Splash screen** - App foreground icon
- **App icon** - In About screen and empty state
- **Slide animations** - Smooth transitions in NoteEditor
---
## v1.6.0 - Modern APIs
## v1.6.0 - Technical Modernization
> **Status:** Geplant 📋
> **Status:** Planned 📋
### 🔧 Technische Modernisierung
### 🔧 Server Folder Check
- **Modernere Background-Sync Architektur** - Noch zuverlässiger
- **Verbesserte Progress-Dialoge** - Material Design 3 konform
- **Code-Qualität** - Interne Optimierungen
- **WebDAV folder check** - Checks if folder exists and is writable on server
- **Better error messages** - Helpful hints for server problems
- **Connection test improvement** - Checks read/write permissions
### 🔧 Technical Improvements
- **Code refactoring** - Fix LongMethod and LargeClass warnings
- **Modern background sync architecture** - Even more reliable
- **Improved progress dialogs** - Material Design 3 compliant
---
## 💡 Feedback & Wünsche
## v1.7.0 - Community Features
Hast du eine Idee für ein neues Feature?
> **Status:** Idea Collection 💡
- **[Feature Request erstellen](https://github.com/inventory69/simple-notes-sync/issues/new?template=feature_request.yml)**
- **[Bestehende Wünsche ansehen](https://github.com/inventory69/simple-notes-sync/issues?q=is%3Aissue+label%3Aenhancement)**
### Potential Features
- **Additional languages** - Community translations (FR, ES, IT, ...)
- **Categories/Tags** - Organize notes
- **Search** - Full-text search in notes
- **Widget** - Quick access from homescreen
---
**Hinweis:** Diese Roadmap zeigt unsere aktuellen Pläne. Prioritäten können sich basierend auf Community-Feedback ändern.
## 💡 Feedback & Suggestions
[← Zurück zur Dokumentation](DOCS.md)
Have an idea for a new feature?
- **[Create a feature request](https://github.com/inventory69/simple-notes-sync/issues/new?template=feature_request.yml)**
- **[View existing requests](https://github.com/inventory69/simple-notes-sync/issues?q=is%3Aissue+label%3Aenhancement)**
---
**Note:** This roadmap shows our current plans. Priorities may change based on community feedback.
[← Back to documentation](DOCS.md)

View File

@@ -0,0 +1,9 @@
• Komplett neues UI-Design mit Jetpack Compose
• NEU: Englische Sprachunterstützung + Sprachauswahl
• NEU: Automatische Systemsprachen-Erkennung
• NEU: Long-Press Mehrfachauswahl zum Löschen
• Modernisierte Einstellungen mit 7 Bereichen
• Silent-Sync (kein Banner bei Auto-Sync)
• "Nichts zu syncen" Feedback im Status-Banner
• Diverse UI/UX Verbesserungen und Bugfixes
• App-Icon in Über-Screen und Leerzustand

View File

@@ -3,7 +3,8 @@ Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisatio
HAUPTFUNKTIONEN:
• Text-Notizen und Checklisten erstellen
• Checklisten mit Tap-to-Check, Drag & Drop, Swipe-to-Delete
• Checklisten mit Tap-to-Check und Drag & Drop
• Auswahlmodus: Long-Press zur Mehrfachauswahl für Batch-Aktionen
• WebDAV-Synchronisation mit eigenem Server
• Multi-Device Sync (Handy, Tablet, Desktop)
• Markdown-Export für Obsidian/Desktop-Editoren
@@ -11,9 +12,17 @@ HAUPTFUNKTIONEN:
• Automatische Synchronisation im Heim-WLAN
• Konfigurierbares Sync-Interval (15/30/60 Minuten)
• Material Design 3 mit Dynamic Colors (Android 12+)
• Jetpack Compose UI - modern, schnell und flüssig
• Komplett offline nutzbar
• Keine Werbung, keine Tracker
MEHRSPRACHIG:
• Englische und deutsche Sprachunterstützung
• Per-App Sprachauswahl (Android 13+)
• Automatische Systemsprachen-Erkennung
• Über 400 übersetzte Strings
DATENSCHUTZ:
Deine Daten bleiben bei dir! Die App kommuniziert nur mit deinem eigenen WebDAV-Server. Keine Cloud-Dienste, keine Tracking-Bibliotheken, keine Analysetools.
@@ -29,20 +38,23 @@ MULTI-DEVICE SYNC:
SYNCHRONISATION:
• Unterstützt alle WebDAV-Server (Nextcloud, ownCloud, etc.)
• Automatische WiFi-Sync: Synchronisiert automatisch wenn du ein beliebiges WLAN betrittst (wenn Server erreichbar ist)
• Konfigurierbares Interval: 15, 30 oder 60 Minuten
• Optimierte Performance: überspringt unveränderte Dateien (~2-3s Sync-Zeit)
• E-Tag Caching für 20x schnellere "keine Änderungen" Checks
• Gemessener Akkuverbrauch: nur ~0.4% pro Tag (bei 30min)
• Silent-Sync Modus: kein Banner bei Auto-Sync
• Doze Mode optimiert für zuverlässige Background-Syncs
• Manuelle Synchronisation jederzeit möglich
MATERIAL DESIGN 3:
• Moderne Benutzeroberfläche
• Moderne Jetpack Compose Benutzeroberfläche
• Dynamic Colors (Material You) auf Android 12+
• Dark Mode Support
Intuitive Gesten (Swipe-to-Delete)
Auswahlmodus mit Batch-Löschen
• Live Sync-Status Anzeige
• Flüssige Slide-Animationen
Open Source unter MIT-Lizenz
Quellcode: https://github.com/inventory69/simple-notes-sync

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -0,0 +1,9 @@
• Complete UI redesign with Jetpack Compose
• NEW: English language support + Language selector
• NEW: Auto-detects system language
• NEW: Long-press selection mode for batch delete
• Modernized Settings with 7 organized screens
• Silent-Sync mode (no banner during auto-sync)
• "Nothing to sync" feedback in status banner
• Various UI/UX improvements and bug fixes
• App icon in About screen and empty state

View File

@@ -3,7 +3,8 @@ Simple Notes Sync is a minimalist note-taking app with WebDAV synchronization.
KEY FEATURES:
• Create text notes and checklists
• Checklists with tap-to-check, drag & drop, swipe-to-delete
• Checklists with tap-to-check, drag & drop reordering
• Selection mode: long-press to select multiple notes for batch actions
• WebDAV synchronization with your own server
• Multi-device sync (phone, tablet, desktop)
• Markdown export for Obsidian/desktop editors
@@ -11,9 +12,17 @@ KEY FEATURES:
• Automatic synchronization on home WiFi
• Configurable sync interval (15/30/60 minutes)
• Material Design 3 with Dynamic Colors (Android 12+)
• Jetpack Compose UI - modern, fast, and smooth
• Fully usable offline
• No ads, no trackers
MULTILINGUAL:
• English and German language support
• Per-App Language selector (Android 13+)
• Automatic system language detection
• 400+ translated strings
PRIVACY:
Your data stays with you! The app only communicates with your own WebDAV server. No cloud services, no tracking libraries, no analytics tools.
@@ -29,20 +38,23 @@ MULTI-DEVICE SYNC:
SYNCHRONIZATION:
• Supports all WebDAV servers (Nextcloud, ownCloud, etc.)
• Automatic WiFi sync: synchronizes whenever you join any WiFi network (if server is reachable)
• Configurable interval: 15, 30, or 60 minutes
• Optimized performance: skips unchanged files (~2-3s sync time)
• E-Tag caching for 20x faster "no changes" checks
• Measured battery consumption: only ~0.4% per day (at 30min)
• Silent-Sync mode: no banner during auto-sync
• Doze Mode optimized for reliable background syncs
• Manual synchronization available anytime
MATERIAL DESIGN 3:
• Modern user interface
• Modern Jetpack Compose user interface
• Dynamic Colors (Material You) on Android 12+
• Dark Mode support
Intuitive gestures (Swipe-to-delete)
Selection mode with batch delete
• Live sync status indicator
• Smooth slide animations
Open Source under MIT License
Source code: https://github.com/inventory69/simple-notes-sync

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Some files were not shown because too many files have changed in this diff Show More