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
518
CHANGELOG.de.md
Normal 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
|
||||
74
CHANGELOG.md
@@ -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
@@ -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
|
||||
269
QUICKSTART.en.md
@@ -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
|
||||
238
QUICKSTART.md
@@ -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 → Security → Enable "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
@@ -0,0 +1,115 @@
|
||||
# Simple Notes Sync 📝
|
||||
|
||||
> Minimalistische Offline-Notizen mit Auto-Sync zu deinem eigenen Server
|
||||
|
||||
[](https://www.android.com/)
|
||||
[](https://m3.material.io/)
|
||||
[](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
|
||||
109
README.en.md
@@ -1,109 +0,0 @@
|
||||
# Simple Notes Sync 📝
|
||||
|
||||
> Minimalist offline notes with auto-sync to your own server
|
||||
|
||||
[](https://www.android.com/)
|
||||
[](https://m3.material.io/)
|
||||
[](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
|
||||
96
README.md
@@ -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
|
||||
|
||||
[](https://www.android.com/)
|
||||
[](https://m3.material.io/)
|
||||
[](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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
5
android/app/src/debug/res/values/colors_debug.xml
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,7 +262,7 @@ 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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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://"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ?: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9
android/app/src/main/res/anim/slide_in_left.xml
Normal 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>
|
||||
9
android/app/src/main/res/anim/slide_in_right.xml
Normal 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>
|
||||
9
android/app/src/main/res/anim/slide_out_left.xml
Normal 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>
|
||||
9
android/app/src/main/res/anim/slide_out_right.xml
Normal 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>
|
||||
396
android/app/src/main/res/values-de/strings.xml
Normal 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 & 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 & 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 & Wiederherstellung</string>
|
||||
<string name="backup_restore_title">Backup & 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 & Backup importieren</string>
|
||||
<string name="backup_mode_replace_full">⚪ Ersetzen\n → Alle löschen & 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 & 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 & 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 & 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>
|
||||
@@ -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 & 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 & 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 & 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 & Restore</string>
|
||||
<string name="backup_restore_title">Backup & 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 & import backup</string>
|
||||
<string name="backup_mode_replace_full">⚪ Replace\n → Delete all & 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 & 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 & 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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
7
android/app/src/main/res/xml/locales_config.xml
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
394
docs/BACKUP.md
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
506
docs/DESKTOP.md
@@ -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 Leerzeichen → einzelnes Leerzeichen
|
||||
- Leerzeichen → Unterstrich _
|
||||
Removed:
|
||||
- Emojis: 🛒 → removed
|
||||
- Special chars: / \ : * ? " < > | → removed
|
||||
- Multiple spaces → single 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 gleichzeitig → Konflikte
|
||||
- ❌ **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 simultaneously → conflicts
|
||||
- ❌ **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)
|
||||
|
||||
@@ -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 app → auto-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ßen → Auto-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
|
||||
262
docs/DOCS.md
@@ -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ßen → Auto-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 app → auto-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
@@ -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)
|
||||
@@ -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)
|
||||
423
docs/FEATURES.md
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
9
fastlane/metadata/android/de-DE/changelogs/13.txt
Normal 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
|
||||
@@ -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
|
||||
|
Before Width: | Height: | Size: 120 KiB |
BIN
fastlane/metadata/android/de-DE/images/phoneScreenshots/1.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 138 KiB |
BIN
fastlane/metadata/android/de-DE/images/phoneScreenshots/2.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 134 KiB |
BIN
fastlane/metadata/android/de-DE/images/phoneScreenshots/3.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
fastlane/metadata/android/de-DE/images/phoneScreenshots/4.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
fastlane/metadata/android/de-DE/images/phoneScreenshots/5.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
fastlane/metadata/android/de-DE/images/phoneScreenshots/6.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
9
fastlane/metadata/android/en-US/changelogs/13.txt
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 118 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 90 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 137 KiB |