30 Commits

Author SHA1 Message Date
inventory69
d524bc715d Merge branch 'feature/v1.6.1-clean-code'
v1.6.1 - Clean Code Release

 detekt: 29 → 0 issues
 Build warnings: 21 → 0
 ktlint reactivated with Compose rules
 CI/CD lint checks integrated in pr workflow
 Constants refactoring
 Preparation for v2.0.0 legacy cleanup

Commits:
- ea5c6da: feat: v1.6.1 Clean Code implementation
- ff6510a: docs: update UPCOMING for v1.6.1
- 80a35da: chore: prepare v1.6.1 release changelogs
- b5cb4e1: chore: update v1.6.1 screenshot path in README
2026-01-20 22:00:09 +01:00
inventory69
2a22e7d88e chore: update F-Droid changelogs for v1.6.1
- User-friendly descriptions focusing on performance and stability
- Remove technical implementation details
- Emphasize user-visible improvements and future readiness
2026-01-20 21:59:09 +01:00
inventory69
b5cb4e1d96 chore: update v1.6.1 screenshot path in README 2026-01-20 21:40:59 +01:00
inventory69
80a35da3ff chore: prepare v1.6.1 release changelogs
- Add F-Droid changelogs (versionCode 15)
  - de-DE: Code-Qualität, Zero Warnings, ktlint, CI/CD
  - en-US: Code quality, Zero warnings, ktlint, CI/CD
  - Both under 500 characters as required

- Update CHANGELOG.md / CHANGELOG.de.md
  - detekt: 29 → 0 issues
  - Build warnings: 21 → 0
  - ktlint reactivated with .editorconfig
  - CI/CD lint checks integrated
  - Constants refactoring (Dimensions, SyncConstants)
  - Preparation for v2.0.0 legacy cleanup
2026-01-20 15:11:35 +01:00
inventory69
6254758a03 chore: update screenshots for fdroid metadata in both de-DE and en-US 2026-01-20 15:06:53 +01:00
inventory69
ff6510af90 docs: update UPCOMING for v1.6.1 release and v1.7.0 planning
- Mark v1.6.0 and v1.6.1 as Released
- Add v1.6.1 Clean Code section (detekt 0 issues, zero warnings)
- Restructure v1.7.0 as Staggered Grid Layout release
  - LazyVerticalStaggeredGrid for 120 FPS performance
  - Server Folder Check feature
  - Technical improvements (MD3 dialogs, code refactoring)
- Add v2.0.0 Legacy Cleanup section
- Add Backlog section with future features:
  - Password-protected local backups
  - Biometric unlock
  - Widget, Categories/Tags, Search
- Remove 'Modern background sync' (already implemented with WorkManager)
2026-01-20 14:59:10 +01:00
inventory69
ea5c6dae70 feat: v1.6.1 Clean Code - detekt 0 issues, zero build warnings
- detekt: 29 → 0 issues 
  - Triviale Fixes: Unused imports, MaxLineLength
  - DragDropState.kt → DragDropListState.kt umbenennen
  - MagicNumbers → Constants (Dimensions.kt, SyncConstants.kt)
  - SwallowedException: Logger.w() hinzugefügt
  - LongParameterList: ChecklistEditorCallbacks data class
  - LongMethod: ServerSettingsScreen in Komponenten aufgeteilt
  - @Suppress für komplexe Legacy-Code (WebDavSyncService, SettingsActivity)

- Deprecation Warnings: 21 → 0 
  - File-level @Suppress für alle deprecated Imports
  - ProgressDialog, LocalBroadcastManager, AbstractSavedStateViewModelFactory
  - onActivityResult, onRequestPermissionsResult
  - Vorbereitung für v2.0.0 Legacy Cleanup

- ktlint: Reaktiviert mit .editorconfig 
  - Compose-spezifische Regeln konfiguriert
  - WebDavSyncService.kt, build.gradle.kts in Exclusions
  - ignoreFailures=true für graduelle Migration

- CI/CD: GitHub Actions erweitert 
  - Lint-Checks in pr-build-check.yml integriert
  - Detekt + ktlint + Android Lint vor Build
2026-01-20 14:35:22 +01:00
inventory69
1d010d0034 Release v1.6.0: Configurable Sync Triggers + Offline Mode
- NEW: Configurable sync triggers (onSave, onResume, WiFi, Periodic, Boot)
- NEW: Offline mode toggle to disable all network features
- Various fixes and UI improvements
- Version bumped to 1.6.0 (code 14)
2026-01-19 23:31:25 +01:00
inventory69
ef6e939567 chore: update F-Droid metadata commit hash for v1.5.0 2026-01-16 22:26:05 +01:00
inventory69
65395142fa 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
2026-01-16 22:25:00 +01:00
inventory69
0c8f21ef58 chore(v1.5.0): update F-Droid changelogs with final features 2026-01-16 22:15:57 +01:00
inventory69
806194f4e8 chore(v1.5.0): update F-Droid metadata and descriptions
- Add v1.5.0 build entry (commit placeholder for post-merge update)
- Update full_description.txt: clarify WiFi sync works with any network
- CurrentVersion: 1.5.0, CurrentVersionCode: 13
2026-01-16 22:11:03 +01:00
inventory69
3bf97dbc14 feat(1.5.0): add "Nothing to sync" banner and improve sync UX for v1.5.0
Code changes:
- Show "ℹ️ Nothing to sync" when no notes need syncing (banner instead of toast)
- Add i18n strings for EN/DE

Documentation:
- Improve auto-sync description: mention WiFi reconnect + multi-device sync
- Add 2 new screenshots: server settings & sync status banner

Assets:
- Add screenshots 5 & 6 (server settings & sync banner showcase)
2026-01-16 21:56:42 +01:00
inventory69
06dda24a64 docs: fix F-Droid badge URLs (use official badge endpoint)
- EN: Use https://f-droid.org/badge/get-it-on.png
- DE: Use localized https://f-droid.org/badge/get-it-on-de.png
2026-01-16 19:38:30 +01:00
inventory69
c180fb2416 docs: update screenshot references to new PNG versions (v1.5.0) 2026-01-16 17:04:28 +01:00
inventory69
16a3338903 chore: update app screenshots for v1.5.0
- Replace JPG screenshots with higher quality PNG versions
- Add 4th screenshot for better app showcase
2026-01-16 16:50:41 +01:00
inventory69
67b226a5c3 feat(v1.5.0): icons, batch delete toast, cursor fix, docs refactor
FEATURES
========

Batch Delete Toast Aggregation:
- New deleteMultipleNotesFromServer() method
- Shows single aggregated toast instead of multiple ("3 notes deleted from server")
- Partial success handling ("3 of 5 notes deleted from server")
- Added string resources: snackbar_notes_deleted_from_server, snackbar_notes_deleted_from_server_partial

Text Editor Cursor Fix:
- Fixed cursor jumping to end after every keystroke when editing notes
- Added initialCursorSet flag to only set cursor position on first load
- Cursor now stays at user's position while editing
- Changed LaunchedEffect(content) to LaunchedEffect(Unit) to prevent repeated resets

DOCUMENTATION REFACTOR
======================

Breaking Change: English is now the default language
- README.md: Now English (was German)
- QUICKSTART.md: Now English (was German)
- CHANGELOG.md: Now English (was mixed EN/DE)
- docs/*.md: All English (was German)
- German versions: Use .de.md suffix (README.de.md, QUICKSTART.de.md, etc.)

Updated for v1.5.0:
- CHANGELOG.md: Fully translated to English with v1.5.0 release notes
- CHANGELOG.de.md: Created German version
- FEATURES.md: Added i18n section, Selection Mode, Jetpack Compose updates
- FEATURES.de.md: Updated with v1.5.0 features
- UPCOMING.md: v1.5.0 marked as released, v1.6.0/v1.7.0 roadmap
- UPCOMING.de.md: Updated German version

All language headers updated:
- English: [Deutsch](*.de.md) · **English**
- German: **Deutsch** · [English](*.md)

F-DROID METADATA
================

Changelogs (F-Droid):
- fastlane/metadata/android/en-US/changelogs/13.txt: Created
- fastlane/metadata/android/de-DE/changelogs/13.txt: Created

Descriptions:
- full_description.txt (EN/DE): Updated with v1.5.0 changes
  - Selection Mode instead of Swipe-to-Delete
  - i18n support highlighted
  - Jetpack Compose UI mentioned
  - Silent-Sync Mode added

OTHER FIXES
===========

Code Quality:
- Unused imports removed from multiple files
- maxLineLength fixes
- Detekt config optimized (increased thresholds for v1.5.0)
- AboutScreen: Uses app foreground icon directly
- EmptyState: Shows app icon instead of emoji
- themes.xml: Splash screen uses app foreground icon
2026-01-16 16:31:30 +01:00
inventory69
3af99f31b8 feat(v1.5.0): Complete i18n implementation + Language Selector feature
- Added comprehensive English (strings.xml) and German (strings-de.xml) localization with 400+ strings
- Created new LanguageSettingsScreen with System Default, English, and German options
- Fixed hardcoded German notification toasts in MainActivity and ComposeMainActivity
- Integrated Language selector in Settings as top-level menu item
- Changed ComposeSettingsActivity from ComponentActivity to AppCompatActivity for AppCompatDelegate compatibility
- Added locales_config.xml for Android 13+ Per-App Language support
- Updated Extensions.kt with i18n-aware timestamp formatting (toReadableTime with context)
- Translated all UI strings including settings, toasts, notifications, and error messages
- Added dynamic language display in SettingsMainScreen showing current language

Fixes:
- Notification permission toast now respects system language setting
- Activity correctly restarts when language is changed
- All string formatting with parameters properly localized

Migration:
- MainViewModel: All toast messages now use getString()
- SettingsViewModel: All toast and dialog messages localized
- NotificationHelper: Notification titles and messages translated
- UrlValidator: Error messages now accept Context parameter for translation
- NoteCard, DeleteConfirmationDialog, SyncStatusBanner: All strings externalized

Testing completed on device with both EN and DE locale switching.
Closes #5
2026-01-16 10:39:21 +01:00
inventory69
3ada6c966d fix(v1.5.0): Silent-Sync Mode + UI Improvements
Silent-Sync Implementation (Auto-Sync Banner Fix):
- Add SYNCING_SILENT state to SyncStateManager for background syncs
- Auto-sync (onResume) now triggers silently without banner interruption
- Silent-sync state blocks additional manual syncs (mutual exclusion)
- Error banners still display even after silent-sync failures
- SyncStatus tracks 'silent' flag to hide COMPLETED banners after silent-sync

UI/UX Improvements (from v1.5.0 post-migration fixes):
- Fix text wrapping in checklist items (singleLine=false, maxLines=5)
- Fix cursor position in text notes (use TextFieldValue with TextRange)
- Display app icon instead of emoji in AboutScreen
- Add smooth slide animations for NoteEditor transitions
- Remove visual noise from AboutScreen icon

Technical Changes:
- ComposeNoteEditorActivity: Add back animation with OnBackPressedCallback
- ComposeMainActivity: Add entry/exit slide animations for note editing
- NoteEditorScreen: Use TextFieldValue for proper cursor positioning
- ChecklistItemRow: Enable text wrapping for long checklist items
- AboutScreen: Convert Drawable to Bitmap via Canvas (supports AdaptiveIcon)
- SyncStatusBanner: Exclude SYNCING_SILENT from visibility checks
- MainActivity: Update legacy auto-sync to use silent mode

Fixes #[auto-sync-banner], improves #[user-experience]

Branch: feature/v1.5.0
2026-01-15 22:08:00 +01:00
inventory69
20ec5ba9f9 feat(v1.5.0): Complete NoteEditor Redesign with Jetpack Compose
Features:
- Migrate NoteEditorActivity from XML to Jetpack Compose
- Support both TEXT and CHECKLIST note types
- FOSS native drag & drop using Compose Foundation APIs (no external dependencies)
- Auto-keyboard focus with explicit keyboard controller show() calls
- Consistent placeholder text for empty checklist items
- Unified delete dialog with Server/Local deletion options

Components:
- ComposeNoteEditorActivity: Activity wrapper with ViewModelFactory for SavedStateHandle
- NoteEditorScreen: Main editor screen supporting TEXT and CHECKLIST modes
- NoteEditorViewModel: State management with WebDav server deletion support
- ChecklistItemRow: Individual checklist item with drag handle, checkbox, text input
- DragDropState: FOSS drag & drop implementation using LazyListState

Improvements:
- Auto-keyboard shows when creating new notes (focuses title/content)
- Keyboard consistently shows when adding new checklist items
- Placeholder text 'Neues Element…' for empty list items
- Delete dialog unified with MainScreen (Server/Local options)
- Server deletion via WebDavSyncService.deleteNoteFromServer()

Debug Enhancements:
- Debug builds have orange icon background (#FFB74D) with red badge
- Debug builds show 'Simple Notes (Debug)' app name
- Easy differentiation between debug and release APKs in launcher

Build Status:
- compileStandardDebug: SUCCESS
- No breaking changes to existing XML-based screens
- Material 3 theming with Dynamic Colors (Material You) applied

Migration Notes:
- Old NoteEditorActivity (XML-based) remains for reference/backwards compatibility
- All editor UI is now Compose-based
- ComposeMainActivity updated to use new ComposeNoteEditorActivity
- Plan document (v1.5.0_EXTENDED_FEATURES_PLAN.md) updated with implementation details
2026-01-15 17:19:56 +01:00
inventory69
c33448f841 feat(v1.5.0): Complete MainActivity Jetpack Compose redesign
## Major Changes

### New Jetpack Compose Architecture (1,883 LOC total)
- ComposeMainActivity.kt (370L): Main activity with Compose integration
- MainScreen.kt (322L): Root Compose screen with layout orchestration
- MainViewModel.kt (564L): MVVM state management for notes and sync
- components/NoteCard.kt (243L): Individual note card with selection support
- components/NotesList.kt (65L): LazyColumn with optimized item rendering
- components/DeleteConfirmationDialog.kt (93L): Dialog with Server/Local options
- components/EmptyState.kt (73L): Empty state UI
- components/NoteTypeFAB.kt (83L): Floating action button with note type menu
- components/SyncStatusBanner.kt (70L): Sync status indicator banner

### Material 3 & Design System
- SimpleNotesTheme.kt: Material 3 theme with Dynamic Colors (Material You)
- Full Material 3 color scheme implementation
- Dynamic color support for Android 12+ (Material You)
- Consistent design across MainActivity and SettingsActivity

### Performance Optimizations
- Upgraded Compose BOM: 2024.12.01 → 2026.01.00 (latest Jan 2026)
- Enable Strong Skipping Mode for efficient recomposition
- Async loadNotes() on IO dispatcher (no UI blocking at startup)
- LazyColumn with proper key={it.id} and contentType
- Pull-to-refresh with PullToRefreshBox
- Minimal recomposition with state separation

### Multi-Select Feature (v1.5.0)
- Long-press note to enter selection mode
- Tap additional notes to toggle selection in selection mode
- SelectionTopBar with:
  * Selection counter ("X selected")
  * Select All button
  * Batch Delete button
- Animated checkbox indicator on selected note cards
- DeleteConfirmationDialog with Server/Local deletion options
- Fixed server deletion: deleteNoteFromServer() now properly called with 3.5s delay

### Dependency Updates
- Added org.jetbrains.kotlin.plugin.compose (Compose Compiler)
- Jetpack Compose libraries:
  * androidx.compose.bom:2026.01.00
  * androidx.compose.ui, material3, material-icons-extended
  * androidx.activity-compose, androidx.navigation-compose
  * androidx.lifecycle-runtime-compose
- All dependencies remain Apache 2.0 licensed (100% FOSS)

### Animation & Transitions
- New animation resources:
  * slide_in_right.xml, slide_out_left.xml
  * slide_in_left.xml, slide_out_right.xml
- Settings slide animations (left/right navigation)
- Selection mode TopBar transitions (fade + slide)
- Smooth selection checkbox appearance

### Backward Compatibility
- NoteEditorActivity (XML) still used (kept for compatibility)
- Existing database and sync functionality unchanged
- Smooth migration path for future Compose editor

### Bug Fixes
- Server deletion now executes after snackbar timeout (3.5s)
- Multi-select batch deletion with undo support
- FAB z-index fixed to ensure visibility above all content
- Scroll-to-top animation on new note creation

### Code Quality
- Removed 805-line legacy MainActivity.kt
- Clean separation of concerns: Activity → Screen → ViewModel
- Composable functions follow Material 3 guidelines
- No remember() blocks inside LazyColumn items (performance)
- Direct MaterialTheme access (Compose handles optimization)

### Manifest Changes
- Updated to use ComposeMainActivity as main launcher
- Activity transition animations configured

### Testing
- Build successful on Pixel 9 Pro XL (Android 16, 120Hz)
- Release build optimized (minified + shrinkResources)
- Multi-select UX tested
- Server deletion verified

BREAKING CHANGE: Long-press on note now enters multi-select mode instead of direct delete
RELATED: v1.5.0_EXTENDED_FEATURES_PLAN.md added to project-docs
2026-01-15 15:41:47 +01:00
inventory69
64b2cfaf78 v1.5.0: Jetpack Compose Settings Redesign + Fixes
Features:
-  Complete Settings UI redesign with Jetpack Compose
- 🎨 Material 3 Design with Dynamic Colors (Material You)
- 📊 6 logical settings groups in separate screens:
  * Server Settings (URL, Credentials, Connection Test)
  * Sync Settings (Auto-Sync, Interval 15/30/60 min)
  * Markdown Desktop Integration (Auto-Sync for .md files)
  * Backup & Restore (Local/Server)
  * About this App (Version, GitHub, License)
  * Debug & Diagnostics (File Logging, Log Export)

Bugfixes (ported from old SettingsActivity):
- 🔧 Fix #1: Server URL prefix (http://|https://) auto-set on init
- 🔧 Fix #2: Battery optimization dialog on Auto-Sync enable
- 🔧 Fix #3: Markdown initial export on feature activation

Implementation Details:
- SettingsViewModel with Kotlin Flows state management
- SettingsEvent system for Activity-level actions (dialogs, intents)
- Reusable Compose components (SettingsCard, Switch, RadioGroup, etc.)
- Progress dialog for markdown initial export
- Edge-to-edge display with system bar handling
- Navigation Compose for screen transitions with back button

Breaking Changes:
- Old SettingsActivity.kt (1147 lines) no longer used
  (Can be removed in future as legacy code)
2026-01-15 11:02:38 +01:00
inventory69
b052fb0f0a Update docs, roadmap, and F-Droid metadata [skip ci] 2026-01-14 11:46:00 +01:00
inventory69
7128c25bd5 Merge feature/v1.4.1-bugfixes: Bugfixes + Checklist improvements 2026-01-11 21:59:24 +01:00
inventory69
356ccde627 feat(v1.4.1): Bugfixes + Checklist auto line wrap
Fixed:
- Delete notes from older app versions (v1.2.0 compatibility)
- Checklist sync backwards compatibility (v1.3.x)
  - Fallback content in GitHub task list format
  - Recovery mode for lost checklistItems

Improved:
- Checklist auto line wrap (no maxLines limit)
- Enter key creates new item (via TextWatcher)

Metadata:
- Changelogs for versionCode 12
- IzzyOnDroid metadata updated
2026-01-11 21:59:09 +01:00
inventory69
9b37078cce Change repository URL in CONTRIBUTING.md
Updated repository URL in Quick Start section.
2026-01-11 16:23:51 +01:00
inventory69
dee85233b6 fix: Remove dynamic build date for Reproducible Builds
Fixes #7 - Thanks @IzzySoft for reporting and investigating!

Removed:
- getBuildDate() function from build.gradle.kts
- BUILD_DATE buildConfigField
- Build date display in SettingsActivity

Sorry for the hour of work this caused - will be more careful about RB in the future.
2026-01-10 23:47:57 +01:00
inventory69
fbcca3807d Merge feature/v1.4.0-checklists: Checklists + WiFi permission cleanup 2026-01-10 23:40:10 +01:00
inventory69
e3e64b83e2 feat(v1.4.0): Checklists feature + WiFi permission cleanup
Features:
- Interactive checklists with tap-to-check, drag & drop sorting
- GitHub-flavored Markdown export (- [ ] / - [x])
- FAB menu for note type selection

Fixes:
- Improved Markdown parsing (robust line-based content extraction)
- Better duplicate filename handling (ID suffix)
- Foreground notification suppression

Privacy:
- Removed ACCESS_WIFI_STATE and CHANGE_WIFI_STATE permissions
  (SSID binding was never used, app only checks connectivity state)

Code Quality:
- Fixed 7 Detekt warnings (SwallowedException, MaxLineLength, MagicNumber)
2026-01-10 23:37:22 +01:00
inventory69
2324743f43 Update IzzyOnDroid metadata to v1.3.2 [skip ci] 2026-01-10 08:26:47 +01:00
143 changed files with 13708 additions and 3448 deletions

View File

@@ -1,4 +1,4 @@
name: 🐛 Bug Report / Fehlerbericht name: "🐛 Bug Report / Fehlerbericht"
description: Melde einen Fehler in der App / Report a bug in the app description: Melde einen Fehler in der App / Report a bug in the app
title: "[BUG] " title: "[BUG] "
labels: ["bug"] labels: ["bug"]
@@ -13,7 +13,7 @@ body:
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
label: 🐛 Beschreibung / Description label: "🐛 Beschreibung / Description"
description: Beschreibe den Fehler kurz und präzise / Describe the bug briefly and precisely description: Beschreibe den Fehler kurz und präzise / Describe the bug briefly and precisely
placeholder: "z.B. Auto-Sync funktioniert nicht mehr nach App-Update / e.g. Auto-sync stopped working after app update" placeholder: "z.B. Auto-Sync funktioniert nicht mehr nach App-Update / e.g. Auto-sync stopped working after app update"
validations: validations:
@@ -22,9 +22,11 @@ body:
- type: dropdown - type: dropdown
id: android-version id: android-version
attributes: attributes:
label: 📱 Android Version label: "📱 Android Version"
description: Welche Android Version verwendest du? / Which Android version are you using? description: Welche Android Version verwendest du? / Which Android version are you using?
options: options:
- Android 16
- Android 15
- Android 14 - Android 14
- Android 13 - Android 13
- Android 12 - Android 12
@@ -40,7 +42,7 @@ body:
- type: input - type: input
id: app-version id: app-version
attributes: attributes:
label: 📲 App Version label: "📲 App Version"
description: Welche Version der App verwendest du? (Einstellungen → Über) / Which app version? (Settings → About) description: Welche Version der App verwendest du? (Einstellungen → Über) / Which app version? (Settings → About)
placeholder: "z.B. / e.g. v1.1.0" placeholder: "z.B. / e.g. v1.1.0"
validations: validations:
@@ -49,7 +51,7 @@ body:
- type: input - type: input
id: device id: device
attributes: attributes:
label: 📱 Gerät / Device label: "📱 Gerät / Device"
description: Welches Gerät verwendest du? / Which device are you using? description: Welches Gerät verwendest du? / Which device are you using?
placeholder: "z.B. Samsung Galaxy S21, Google Pixel 7, etc." placeholder: "z.B. Samsung Galaxy S21, Google Pixel 7, etc."
validations: validations:
@@ -58,7 +60,7 @@ body:
- type: textarea - type: textarea
id: steps id: steps
attributes: attributes:
label: 🔄 Schritte zum Reproduzieren / Steps to Reproduce label: "📋 Schritte zum Reproduzieren / Steps to Reproduce"
description: Wie kann der Fehler reproduziert werden? / How can the bug be reproduced? description: Wie kann der Fehler reproduziert werden? / How can the bug be reproduced?
placeholder: | placeholder: |
1. Öffne die App / Open the app 1. Öffne die App / Open the app
@@ -71,7 +73,7 @@ body:
- type: textarea - type: textarea
id: expected id: expected
attributes: attributes:
label: ✅ Erwartetes Verhalten / Expected Behavior label: "✅ Erwartetes Verhalten / Expected Behavior"
description: Was sollte passieren? / What should happen? description: Was sollte passieren? / What should happen?
placeholder: "z.B. Notizen sollten alle 30 Min synchronisiert werden / e.g. Notes should sync every 30 min" placeholder: "z.B. Notizen sollten alle 30 Min synchronisiert werden / e.g. Notes should sync every 30 min"
validations: validations:
@@ -80,7 +82,7 @@ body:
- type: textarea - type: textarea
id: actual id: actual
attributes: attributes:
label: ❌ Tatsächliches Verhalten / Actual Behavior label: "❌ Tatsächliches Verhalten / Actual Behavior"
description: Was passiert stattdessen? / What happens instead? description: Was passiert stattdessen? / What happens instead?
placeholder: "z.B. Sync funktioniert nicht, keine Notification / e.g. Sync doesn't work, no notification" placeholder: "z.B. Sync funktioniert nicht, keine Notification / e.g. Sync doesn't work, no notification"
validations: validations:
@@ -89,7 +91,7 @@ body:
- type: dropdown - type: dropdown
id: sync-enabled id: sync-enabled
attributes: attributes:
label: <EFBFBD> Auto-Sync aktiviert? / Auto-Sync enabled? label: Auto-Sync aktiviert? / Auto-Sync enabled?
options: options:
- "Ja / Yes" - "Ja / Yes"
- "Nein / No" - "Nein / No"

View File

@@ -1,11 +1,11 @@
blank_issues_enabled: true blank_issues_enabled: true
contact_links: contact_links:
- name: 📖 Dokumentation / Documentation - name: "📖 Dokumentation / Documentation"
url: https://github.com/inventory69/simple-notes-sync/blob/main/README.md url: https://github.com/inventory69/simple-notes-sync/blob/main/README.md
about: Schau zuerst in die Dokumentation / Check documentation first about: Schau zuerst in die Dokumentation / Check documentation first
- name: 🚀 Quick Start Guide - name: "🚀 Quick Start Guide"
url: https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md url: https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md
about: Schritt-für-Schritt Anleitung / Step-by-step guide about: Schritt-für-Schritt Anleitung / Step-by-step guide
- name: 🐛 Troubleshooting - name: "🐛 Troubleshooting"
url: https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md#troubleshooting url: https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md#troubleshooting
about: Häufige Probleme und Lösungen / Common issues and solutions about: Häufige Probleme und Lösungen / Common issues and solutions

View File

@@ -1,4 +1,4 @@
name: 💡 Feature Request / Feature-Wunsch name: "💡 Feature Request / Feature-Wunsch"
description: Schlage eine neue Funktion vor / Suggest a new feature description: Schlage eine neue Funktion vor / Suggest a new feature
title: "[FEATURE] " title: "[FEATURE] "
labels: ["enhancement"] labels: ["enhancement"]
@@ -11,7 +11,7 @@ body:
- type: textarea - type: textarea
id: feature-description id: feature-description
attributes: attributes:
label: 💡 Feature-Beschreibung / Feature Description label: "💡 Feature-Beschreibung / Feature Description"
description: Was möchtest du hinzugefügt haben? / What would you like to be added? description: Was möchtest du hinzugefügt haben? / What would you like to be added?
placeholder: "z.B. Notizen sollten Markdown-Formatierung unterstützen / e.g. Notes should support markdown formatting" placeholder: "z.B. Notizen sollten Markdown-Formatierung unterstützen / e.g. Notes should support markdown formatting"
validations: validations:
@@ -20,7 +20,7 @@ body:
- type: textarea - type: textarea
id: problem id: problem
attributes: attributes:
label: 🎯 Problem / Motivation label: "🎯 Problem / Motivation"
description: Welches Problem würde dieses Feature lösen? / What problem would this feature solve? description: Welches Problem würde dieses Feature lösen? / What problem would this feature solve?
placeholder: "z.B. Ich möchte Code-Snippets und Listen in meinen Notizen formatieren / e.g. I want to format code snippets and lists in my notes" placeholder: "z.B. Ich möchte Code-Snippets und Listen in meinen Notizen formatieren / e.g. I want to format code snippets and lists in my notes"
validations: validations:
@@ -29,7 +29,7 @@ body:
- type: textarea - type: textarea
id: solution id: solution
attributes: attributes:
label: 📝 Vorgeschlagene Lösung / Proposed Solution label: "📝 Vorgeschlagene Lösung / Proposed Solution"
description: Wie könnte das Feature funktionieren? / How could the feature work? description: Wie könnte das Feature funktionieren? / How could the feature work?
placeholder: "z.B. Markdown-Editor mit Live-Preview / e.g. Markdown editor with live preview" placeholder: "z.B. Markdown-Editor mit Live-Preview / e.g. Markdown editor with live preview"
validations: validations:
@@ -38,7 +38,7 @@ body:
- type: textarea - type: textarea
id: alternatives id: alternatives
attributes: attributes:
label: 🔄 Alternativen / Alternatives label: "🔄 Alternativen / Alternatives"
description: Hast du andere Lösungsansätze in Betracht gezogen? / Have you considered other solutions? description: Hast du andere Lösungsansätze in Betracht gezogen? / Have you considered other solutions?
validations: validations:
required: false required: false
@@ -46,7 +46,7 @@ body:
- type: dropdown - type: dropdown
id: platform id: platform
attributes: attributes:
label: 📱 Plattform / Platform label: "📱 Plattform / Platform"
description: Für welche Komponente ist das Feature? / For which component is the feature? description: Für welche Komponente ist das Feature? / For which component is the feature?
options: options:
- Android App - Android App
@@ -59,7 +59,7 @@ body:
- type: dropdown - type: dropdown
id: priority id: priority
attributes: attributes:
label: 🌟 Priorität (aus deiner Sicht) / Priority (from your perspective) label: "⭐ Priorität (aus deiner Sicht) / Priority (from your perspective)"
options: options:
- Nice to have - Nice to have
- Wichtig / Important - Wichtig / Important
@@ -70,7 +70,7 @@ body:
- type: checkboxes - type: checkboxes
id: willing-to-contribute id: willing-to-contribute
attributes: attributes:
label: 🤝 Beitragen / Contribute label: "🤝 Beitragen / Contribute"
options: options:
- label: Ich würde gerne bei der Implementierung helfen / I would like to help with implementation - label: Ich würde gerne bei der Implementierung helfen / I would like to help with implementation
required: false required: false
@@ -78,7 +78,7 @@ body:
- type: textarea - type: textarea
id: additional id: additional
attributes: attributes:
label: 🔧 Zusätzliche Informationen / Additional Context label: "🔧 Zusätzliche Informationen / Additional Context"
description: Screenshots, Mockups, Links, ähnliche Apps, etc. description: Screenshots, Mockups, Links, ähnliche Apps, etc.
validations: validations:
required: false required: false

View File

@@ -1,4 +1,4 @@
name: ❓ Question / Frage name: "❓ Question / Frage"
description: Stelle eine Frage zur Nutzung / Ask a question about usage description: Stelle eine Frage zur Nutzung / Ask a question about usage
title: "[QUESTION] " title: "[QUESTION] "
labels: ["question"] labels: ["question"]
@@ -11,7 +11,7 @@ body:
- type: textarea - type: textarea
id: question id: question
attributes: attributes:
label: ❓ Frage / Question label: "❓ Frage / Question"
description: Was möchtest du wissen? / What would you like to know? description: Was möchtest du wissen? / What would you like to know?
placeholder: "z.B. Wie kann ich die Sync-URL für einen externen Server konfigurieren? / e.g. How can I configure the sync URL for an external server?" placeholder: "z.B. Wie kann ich die Sync-URL für einen externen Server konfigurieren? / e.g. How can I configure the sync URL for an external server?"
validations: validations:
@@ -20,7 +20,7 @@ body:
- type: checkboxes - type: checkboxes
id: documentation-checked id: documentation-checked
attributes: attributes:
label: 📚 Dokumentation gelesen / Documentation checked label: "📚 Dokumentation gelesen / Documentation checked"
description: Hast du bereits in der Dokumentation nachgeschaut? / Have you already checked the documentation? description: Hast du bereits in der Dokumentation nachgeschaut? / Have you already checked the documentation?
options: options:
- label: Ich habe die [README](https://github.com/inventory69/simple-notes-sync/blob/main/README.md) gelesen / I have read the README - label: Ich habe die [README](https://github.com/inventory69/simple-notes-sync/blob/main/README.md) gelesen / I have read the README
@@ -33,7 +33,7 @@ body:
- type: textarea - type: textarea
id: tried id: tried
attributes: attributes:
label: 🔍 Was hast du bereits versucht? / What have you already tried? label: "🔍 Was hast du bereits versucht? / What have you already tried?"
description: Hilf uns, dir besser zu helfen / Help us help you better description: Hilf uns, dir besser zu helfen / Help us help you better
placeholder: "z.B. Ich habe versucht die Server-URL anzupassen, aber... / e.g. I tried adjusting the server URL, but..." placeholder: "z.B. Ich habe versucht die Server-URL anzupassen, aber... / e.g. I tried adjusting the server URL, but..."
validations: validations:
@@ -42,7 +42,7 @@ body:
- type: dropdown - type: dropdown
id: topic id: topic
attributes: attributes:
label: <EFBFBD> Thema / Topic label: "📌 Thema / Topic"
description: Um was geht es? / What is this about? description: Um was geht es? / What is this about?
options: options:
- Server Setup / Server-Einrichtung - Server Setup / Server-Einrichtung
@@ -57,7 +57,7 @@ body:
- type: textarea - type: textarea
id: context id: context
attributes: attributes:
label: <EFBFBD> Kontext / Context label: "💬 Kontext / Context"
description: Zusätzliche Informationen die hilfreich sein könnten / Additional information that might be helpful description: Zusätzliche Informationen die hilfreich sein könnten / Additional information that might be helpful
placeholder: | placeholder: |
- Android Version: Android 13 - Android Version: Android 13
@@ -70,7 +70,7 @@ body:
- type: textarea - type: textarea
id: additional id: additional
attributes: attributes:
label: 🔧 Screenshots / Config label: "🔧 Screenshots / Config"
description: Falls hilfreich (KEINE Passwörter!) / If helpful (NO passwords!) description: Falls hilfreich (KEINE Passwörter!) / If helpful (NO passwords!)
validations: validations:
required: false required: false

View File

@@ -116,6 +116,19 @@ jobs:
prerelease: false prerelease: false
generate_release_notes: false generate_release_notes: false
body: | body: |
## 📋 Changelog / Release Notes
${{ env.CHANGELOG_EN }}
<details>
<summary>🇩🇪 German Version</summary>
${{ env.CHANGELOG_DE }}
</details>
---
## 📦 Downloads ## 📦 Downloads
| Variante | Datei | Info | | Variante | Datei | Info |
@@ -125,19 +138,6 @@ jobs:
--- ---
## 📋 Changelog / Release Notes
${{ env.CHANGELOG_DE }}
<details>
<summary>🌍 English Version</summary>
${{ env.CHANGELOG_EN }}
</details>
---
## 📊 Build-Info ## 📊 Build-Info
- **Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.BUILD_NUMBER }}) - **Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.BUILD_NUMBER }})

View File

@@ -33,6 +33,31 @@ jobs:
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV
echo "📱 Version: $VERSION_NAME (Code: $VERSION_CODE)" echo "📱 Version: $VERSION_NAME (Code: $VERSION_CODE)"
# 🔍 Code Quality Checks (v1.6.1)
- name: Run detekt (Code Quality)
run: |
cd android
./gradlew detekt --no-daemon
continue-on-error: false
- name: Run ktlint (Code Style)
run: |
cd android
./gradlew ktlintCheck --no-daemon
continue-on-error: true # Parser-Probleme in Legacy-Code
- name: Upload Lint Reports
if: always()
uses: actions/upload-artifact@v4
with:
name: lint-reports-pr-${{ github.event.pull_request.number }}
path: |
android/app/build/reports/detekt/
android/app/build/reports/ktlint/
android/app/build/reports/lint-results*.html
retention-days: 7
- name: Debug Build erstellen (ohne Signing) - name: Debug Build erstellen (ohne Signing)
run: | run: |
cd android cd android

6
.gitignore vendored
View File

@@ -42,3 +42,9 @@ Thumbs.db
*.tmp *.tmp
*.swp *.swp
*~ *~
test-apks/
# F-Droid metadata (managed in fdroiddata repo)
# Exclude fastlane metadata (we want to track those screenshots)
metadata/
!fastlane/metadata/

606
CHANGELOG.de.md Normal file
View File

@@ -0,0 +1,606 @@
# 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.6.1] - 2026-01-20
### 🧹 Code-Qualität & Build-Verbesserungen
- **detekt: 0 Issues** - Alle 29 Code-Qualitäts-Issues behoben
- Triviale Fixes: Unused Imports, MaxLineLength
- Datei umbenannt: DragDropState.kt → DragDropListState.kt
- MagicNumbers → Constants (Dimensions.kt, SyncConstants.kt)
- SwallowedException: Logger.w() für besseres Error-Tracking hinzugefügt
- LongParameterList: ChecklistEditorCallbacks data class erstellt
- LongMethod: ServerSettingsScreen in Komponenten aufgeteilt
- @Suppress Annotationen für Legacy-Code (WebDavSyncService, SettingsActivity)
- **Zero Build Warnings** - Alle 21 Deprecation Warnings eliminiert
- File-level @Suppress für deprecated Imports
- ProgressDialog, LocalBroadcastManager, AbstractSavedStateViewModelFactory
- onActivityResult, onRequestPermissionsResult
- Gradle Compose Config bereinigt (StrongSkipping ist jetzt Standard)
- **ktlint reaktiviert** - Linting mit Compose-spezifischen Regeln wieder aktiviert
- .editorconfig mit Compose Formatierungsregeln erstellt
- Legacy-Dateien ausgeschlossen: WebDavSyncService.kt, build.gradle.kts
- ignoreFailures=true für graduelle Migration
- **CI/CD Verbesserungen** - GitHub Actions Lint-Checks integriert
- detekt + ktlint + Android Lint laufen vor Build in pr-build-check.yml
- Stellt Code-Qualität bei jedem Pull Request sicher
### 🔧 Technische Verbesserungen
- **Constants Refactoring** - Bessere Code-Organisation
- ui/theme/Dimensions.kt: UI-bezogene Konstanten
- utils/SyncConstants.kt: Sync-Operations Konstanten
- **Vorbereitung für v2.0.0** - Legacy-Code für Entfernung markiert
- SettingsActivity und MainActivity (ersetzt durch Compose-Versionen)
- Alle deprecated APIs mit Removal-Plan dokumentiert
---
## [1.6.0] - 2026-01-19
### 🎉 Major: Konfigurierbare Sync-Trigger
Feingranulare Kontrolle darüber, wann deine Notizen synchronisiert werden - wähle die Trigger, die am besten zu deinem Workflow passen!
### ⚙️ Sync-Trigger System
- **Individuelle Trigger-Kontrolle** - Jeden Sync-Trigger einzeln in den Einstellungen aktivieren/deaktivieren
- **5 Unabhängige Trigger:**
- **onSave Sync** - Sync sofort nach dem Speichern einer Notiz (5s Throttle)
- **onResume Sync** - Sync beim Öffnen der App (60s Throttle)
- **WiFi-Connect Sync** - Sync bei WiFi-Verbindung
- **Periodischer Sync** - Hintergrund-Sync alle 15/30/60 Minuten (konfigurierbar)
- **Boot Sync** - Startet Hintergrund-Sync nach Geräteneustart
- **Smarte Defaults** - Nur ereignisbasierte Trigger standardmäßig aktiv (onSave, onResume, WiFi-Connect)
- **Akku-optimiert** - ~0.2%/Tag mit Defaults, bis zu ~1.0% mit aktiviertem periodischen Sync
- **Offline-Modus UI** - Ausgegraute Sync-Toggles wenn kein Server konfiguriert
- **Dynamischer Settings-Subtitle** - Zeigt Anzahl aktiver Trigger im Haupteinstellungs-Screen
### 🔧 Server-Konfiguration Verbesserungen
- **Offline-Modus Toggle** - Alle Netzwerkfunktionen mit einem Schalter deaktivieren
- **Getrennte Protokoll & Host Eingabe** - Protokoll (http/https) als nicht-editierbares Präfix angezeigt
- **Klickbare Settings-Cards** - Gesamte Card klickbar für bessere UX
- **Klickbare Toggle-Zeilen** - Text/Icon klicken um Switches zu bedienen (nicht nur der Switch selbst)
### 🐛 Bug Fixes
- **Fix:** Fehlender 5. Sync-Trigger (Boot) in der Haupteinstellungs-Screen Subtitle-Zählung
- **Fix:** Offline-Modus Status wird nicht aktualisiert beim Zurückkehren aus Einstellungen
- **Fix:** Pull-to-Refresh funktioniert auch im Offline-Modus
### 🔧 Technische Verbesserungen
- **Reaktiver Offline-Modus Status** - StateFlow stellt sicher, dass UI korrekt aktualisiert wird
- **Getrennte Server-Config Checks** - `hasServerConfig()` vs `isServerConfigured()` (offline-aware)
- **Verbesserte Konstanten** - Alle Sync-Trigger Keys und Defaults in Constants.kt
- **Bessere Code-Organisation** - Settings-Screens für Klarheit refactored
### Looking Ahead
> 🚀 **v1.7.0** wird Server-Ordner Prüfung und weitere Community-Features bringen.
> Feature-Requests sind willkommen als [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues).
---
## [1.5.0] - 2026-01-15
### 🎉 Major: Jetpack Compose UI Redesign
Das komplette UI wurde von XML-Views auf Jetpack Compose migriert. Die App ist jetzt moderner, schneller und flüssiger.
### 🌍 New Feature: Internationalization (i18n)
- **Englische Sprachunterstützung** - Alle 400+ Strings übersetzt
- **Automatische Spracherkennung** - Folgt der System-Sprache
- **Manuelle Sprachauswahl** - In den Einstellungen umschaltbar
- **Per-App Language (Android 13+)** - Native Spracheinstellung über System-Settings
- **locales_config.xml** - Vollständige Android-Integration
### ⚙️ Modernized Settings
- **7 kategorisierte Settings-Screens** - Übersichtlicher und intuitiver
- **Compose Navigation** - Flüssige Übergänge zwischen Screens
- **Konsistentes Design** - Material Design 3 durchgängig
### ✨ UI Improvements
- **Selection Mode** - Long-Press für Mehrfachauswahl statt Swipe-to-Delete
- **Batch Delete** - Mehrere Notizen gleichzeitig löschen
- **Silent-Sync Mode** - Kein Banner bei Auto-Sync (nur bei manuellem Sync)
- **App Icon in About Screen** - Hochwertige Darstellung
- **App Icon in Empty State** - Statt Emoji bei leerer Notizliste
- **Splash Screen Update** - Verwendet App-Foreground-Icon
- **Slide Animations** - Flüssige Animationen im NoteEditor
### 🔧 Technical Improvements
- **Jetpack Compose** - Komplette UI-Migration
- **Compose ViewModel Integration** - StateFlow für reactive UI
- **Improved Code Quality** - Detekt/Lint Warnings behoben
- **Unused Imports Cleanup** - Sauberer Codebase
### Looking Ahead
> 🚀 **v1.6.0** wird Server-Ordner Prüfung und weitere technische Modernisierungen bringen.
> Feature-Requests gerne als [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues) einreichen.
---
## [1.4.1] - 2026-01-11
### Fixed
- **🗑️ Löschen älterer Notizen (v1.2.0 Kompatibilität)**
- Notizen aus App-Version v1.2.0 oder früher werden jetzt korrekt vom Server gelöscht
- Behebt Problem bei Multi-Device-Nutzung mit älteren Notizen
- **🔄 Checklisten-Sync Abwärtskompatibilität**
- Checklisten werden jetzt auch als Text-Fallback im `content`-Feld gespeichert
- Ältere App-Versionen (v1.3.x) zeigen Checklisten als lesbaren Text
- Format: GitHub-Style Task-Listen (`[ ] Item` / `[x] Item`)
- Recovery-Mode: Falls Checklisten-Items verloren gehen, werden sie aus dem Content wiederhergestellt
### Improved
- **📝 Checklisten Auto-Zeilenumbruch**
- Lange Checklisten-Texte werden jetzt automatisch umgebrochen
- Keine Begrenzung auf 3 Zeilen mehr
- Enter-Taste erstellt weiterhin ein neues Item
### Looking Ahead
> 🚀 **v1.5.0** wird das nächste größere Release. Wir sammeln Ideen und Feedback!
> Feature-Requests gerne als [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues) einreichen.
---
## [1.4.0] - 2026-01-10
### 🎉 New Feature: Checklists
- **✅ Checklist Notes**
- New note type: Checklists with tap-to-toggle items
- Add items via dedicated input field with "+" button
- Drag & drop reordering (long-press to activate)
- Swipe-to-delete items
- Visual distinction: Checked items get strikethrough styling
- Type selector when creating new notes (Text or Checklist)
- **📝 Markdown Integration**
- Checklists export as GitHub-style task lists (`- [ ]` / `- [x]`)
- Compatible with Obsidian, Notion, and other Markdown editors
- Full round-trip: Edit in Obsidian → Sync back to app
- YAML frontmatter includes `type: checklist` for identification
### Fixed
- **<2A> Markdown Parsing Robustness**
- Fixed content extraction after title (was returning empty for some formats)
- Now handles single newline after title (was requiring double newline)
- Protection: Skips import if parsed content is empty but local has content
- **📂 Duplicate Filename Handling**
- Notes with identical titles now get unique Markdown filenames
- Format: `title_shortid.md` (e.g., `test_71540ca9.md`)
- Prevents data loss from filename collisions
- **🔔 Notification UX**
- No sync notifications when app is in foreground
- User sees changes directly in UI - no redundant notification
- Background syncs still show notifications as expected
### Privacy Improvements
- **🔒 WiFi Permissions Removed**
- Removed `ACCESS_WIFI_STATE` permission
- Removed `CHANGE_WIFI_STATE` permission
- WiFi binding now works via IP detection instead of SSID matching
- Cleaned up all SSID-related code from codebase and documentation
### Technical Improvements
- **📦 New Data Model**
- `NoteType` enum: `TEXT`, `CHECKLIST`
- `ChecklistItem` data class with id, text, isChecked, order
- `Note.kt` extended with `noteType` and `checklistItems` fields
- **🔄 Sync Protocol v1.4.0**
- JSON format updated to include checklist fields
- Full backward compatibility with v1.3.x notes
- Robust JSON parsing with manual field extraction
---
## [1.3.2] - 2026-01-10
### Changed
- **🧹 Code-Qualität: "Clean Slate" Release**
- Alle einfachen Lint-Issues behoben (Phase 1-7 des Cleanup-Plans)
- Unused Imports und Members entfernt
- Magic Numbers durch benannte Konstanten ersetzt
- SwallowedExceptions mit Logger.w() versehen
- MaxLineLength-Verstöße reformatiert
- ConstructorParameterNaming (snake_case → camelCase mit @SerializedName)
- Custom Exceptions: SyncException.kt und ValidationException.kt erstellt
### Added
- **📝 F-Droid Privacy Notice**
- Datenschutz-Hinweis für die Datei-Logging-Funktion
- Erklärt dass Logs nur lokal gespeichert werden
- Erfüllt F-Droid Opt-in Consent-Anforderungen
### Technical Improvements
- **⚡ Neue Konstanten für bessere Wartbarkeit**
- `SYNC_COMPLETED_DELAY_MS`, `ERROR_DISPLAY_DELAY_MS` (MainActivity)
- `CONNECTION_TIMEOUT_MS` (SettingsActivity)
- `SOCKET_TIMEOUT_MS`, `MAX_FILENAME_LENGTH`, `ETAG_PREVIEW_LENGTH` (WebDavSyncService)
- `AUTO_CANCEL_TIMEOUT_MS` (NotificationHelper)
- RFC 1918 IP-Range Konstanten (UrlValidator)
- `DAYS_THRESHOLD`, `TRUNCATE_SUFFIX_LENGTH` (Extensions)
- **🔒 @Suppress Annotations für legitime Patterns**
- ReturnCount: Frühe Returns für Validierung sind idiomatisch
- LoopWithTooManyJumpStatements: Komplexe Sync-Logik dokumentiert
### Notes
- Komplexe Refactorings (LargeClass, LongMethod) für v1.3.3+ geplant
- Deprecation-Warnungen (LocalBroadcastManager, ProgressDialog) bleiben bestehen
---
## [1.3.1] - 2026-01-08
### Fixed
- **🔧 Multi-Device JSON Sync (Danke an Thomas aus Bielefeld)**
- JSON-Dateien werden jetzt korrekt zwischen Geräten synchronisiert
- Funktioniert auch ohne aktiviertes Markdown
- Hybrid-Optimierung: Server-Timestamp (Primary) + E-Tag (Secondary) Checks
- E-Tag wird nach Upload gecached um Re-Download zu vermeiden
### Performance Improvements
- **⚡ JSON Sync Performance-Parität**
- JSON-Sync erreicht jetzt gleiche Performance wie Markdown (~2-3 Sekunden)
- Timestamp-basierte Skip-Logik für unveränderte Dateien (~500ms pro Datei gespart)
- E-Tag-Matching als Fallback für Dateien die seit letztem Sync modifiziert wurden
- **Beispiel:** 24 Dateien von 12-14s auf ~2.7s reduziert (keine Änderungen)
- **⏭️ Skip unveränderte Dateien** (Haupt-Performance-Fix!)
- JSON-Dateien: Überspringt alle Notizen, die seit letztem Sync nicht geändert wurden
- Markdown-Dateien: Überspringt unveränderte MD-Dateien basierend auf Server-Timestamp
- **Spart ~500ms pro Datei** bei Nextcloud (~20 Dateien = 10 Sekunden gespart!)
- Von 21 Sekunden Sync-Zeit auf 2-3 Sekunden reduziert
- **⚡ Session-Caching für WebDAV**
- Sardine-Client wird pro Sync-Session wiederverwendet (~600ms gespart)
- WiFi-IP-Adresse wird gecacht statt bei jeder Anfrage neu ermittelt (~300ms gespart)
- `/notes/` Ordner-Existenz wird nur einmal pro Sync geprüft (~500ms gespart)
- **Gesamt: ~1.4 Sekunden zusätzlich gespart**
- **📝 Content-basierte Markdown-Erkennung**
- Extern bearbeitete Markdown-Dateien werden auch erkannt wenn YAML-Timestamp nicht aktualisiert wurde
- Löst das Problem: Obsidian/Texteditor-Änderungen wurden nicht importiert
- Hybridansatz: Erst Timestamp-Check (schnell), dann Content-Vergleich (zuverlässig)
### Added
- **🔄 Sync-Status-Anzeige (UI)**
- Sichtbares Banner "Synchronisiere..." mit ProgressBar während Sync läuft
- Sync-Button und Pull-to-Refresh werden deaktiviert während Sync aktiv
- Verhindert versehentliche Doppel-Syncs durch visuelle Rückmeldung
- Auch in Einstellungen: "Jetzt synchronisieren" Button wird deaktiviert
### Fixed
- **🔧 Sync-Mutex verhindert doppelte Syncs**
- Keine doppelten Toast-Nachrichten mehr bei schnellem Pull-to-Refresh
- Concurrent Sync-Requests werden korrekt blockiert
- **🐛 Lint-Fehler behoben**
- `View.generateViewId()` statt hardcodierte IDs in RadioButtons
- `app:tint` statt `android:tint` für AppCompat-Kompatibilität
### Added
- **🔍 detekt Code-Analyse**
- Statische Code-Analyse mit detekt 1.23.4 integriert
- Pragmatische Konfiguration für Sync-intensive Codebasis
- 91 Issues identifiziert (als Baseline für v1.4.0)
- **🏗️ Debug Build mit separatem Package**
- Debug-APK kann parallel zur Release-Version installiert werden
- Package: `dev.dettmer.simplenotes.debug` (Debug) vs `dev.dettmer.simplenotes` (Release)
- App-Name zeigt "Simple Notes (Debug)" für einfache Unterscheidung
- **📊 Debug-Logging UI**
- Neuer "Debug Log" Button in Einstellungen → Erweitert
- Zeigt letzte Sync-Logs mit Zeitstempeln
- Export-Funktion für Fehlerberichte
### Technical
- `WebDavSyncService`: Hybrid-Optimierung für JSON-Downloads (Timestamp PRIMARY, E-Tag SECONDARY)
- `WebDavSyncService`: E-Tag refresh nach Upload statt Invalidierung (verhindert Re-Download)
- E-Tag Caching: `SharedPreferences` mit Key-Pattern `etag_json_{noteId}`
- Skip-Logik: `if (serverModified <= lastSync) skip` → ~1ms pro Datei
- Fallback E-Tag: `if (serverETag == cachedETag) skip` → für Dateien modifiziert nach lastSync
- PROPFIND nach PUT: Fetch E-Tag nach Upload für korrektes Caching
- `SyncStateManager`: Neuer Singleton mit `StateFlow<Boolean>` für Sync-Status
- `MainActivity`: Observer auf `SyncStateManager.isSyncing` für UI-Updates
- Layout: `sync_status_banner` mit `ProgressBar` + `TextView`
- `WebDavSyncService`: Skip-Logik für unveränderte JSON/MD Dateien basierend auf `lastSyncTimestamp`
- `WebDavSyncService`: Neue Session-Cache-Variablen (`sessionSardine`, `sessionWifiAddress`, `notesDirEnsured`)
- `getOrCreateSardine()`: Cached Sardine-Client mit automatischer Credentials-Konfiguration
- `getOrCacheWiFiAddress()`: WiFi-Adresse wird nur einmal pro Sync ermittelt
- `clearSessionCache()`: Aufräumen am Ende jeder Sync-Session
- `ensureNotesDirectoryExists()`: Cached Directory-Check
- Content-basierter Import: Vergleicht MD-Content mit lokaler Note wenn Timestamps gleich
- Build-Tooling: detekt aktiviert, ktlint vorbereitet (deaktiviert wegen Parser-Problemen)
- Debug BuildType: `applicationIdSuffix = ".debug"`, `versionNameSuffix = "-debug"`
---
## [1.3.0] - 2026-01-07
### Added
- **🚀 Multi-Device Sync** (Thanks to Thomas from Bielefeld for reporting!)
- Automatic download of new notes from other devices
- Deletion tracking prevents "zombie notes" (deleted notes don't come back)
- Smart cleanup: Re-created notes (newer timestamp) are downloaded
- Works with all devices: v1.2.0, v1.2.1, v1.2.2, and v1.3.0
- **🗑️ Server Deletion via Swipe Gesture**
- Swipe left on notes to delete from server (requires confirmation)
- Prevents duplicate notes on other devices
- Works with deletion tracking system
- Material Design confirmation dialog
- **⚡ E-Tag Performance Optimization**
- Smart server checking with E-Tag caching (~150ms vs 3000ms for "no changes")
- 20x faster when server has no updates
- E-Tag hybrid approach: E-Tag for JSON (fast), timestamp for Markdown (reliable)
- Battery-friendly with minimal server requests
- **📥 Markdown Auto-Sync Toggle**
- NEW: Unified Auto-Sync toggle in Settings (replaces separate Export/Auto-Import toggles)
- When enabled: Notes export to Markdown AND import changes automatically
- When disabled: Manual sync button appears for on-demand synchronization
- Performance: Auto-Sync OFF = 0ms overhead
- **🔘 Manual Markdown Sync Button**
- Manual sync button for performance-conscious users
- Shows import/export counts after completion
- Only visible when Auto-Sync is disabled
- On-demand synchronization (~150-200ms only when triggered)
- **⚙️ Server-Restore Modes**
- MERGE: Keep local notes + add server notes
- REPLACE: Delete all local + download from server
- OVERWRITE: Update duplicates, keep non-duplicates
- Restore modes now work correctly for WebDAV restore
### Technical
- New `DeletionTracker` model with JSON persistence
- `NotesStorage`: Added deletion tracking methods
- `WebDavSyncService.hasUnsyncedChanges()`: Intelligent server checks with E-Tag caching
- `WebDavSyncService.downloadRemoteNotes()`: Deletion-aware downloads
- `WebDavSyncService.restoreFromServer()`: Support for restore modes
- `WebDavSyncService.deleteNoteFromServer()`: Server deletion with YAML frontmatter scanning
- `WebDavSyncService.importMarkdownFiles()`: Automatic Markdown import during sync
- `WebDavSyncService.manualMarkdownSync()`: Manual sync with result counts
- `MainActivity.setupSwipeToDelete()`: Two-stage swipe deletion with confirmation
- E-Tag caching in SharedPreferences for performance
---
## [1.2.2] - 2026-01-06
### Fixed
- **Backward Compatibility for v1.2.0 Users (Critical)**
- App now reads BOTH old (Root) AND new (`/notes/`) folder structures
- Users upgrading from v1.2.0 no longer lose their existing notes
- Server-Restore now finds notes from v1.2.0 stored in Root folder
- Automatic deduplication prevents loading the same note twice
- Graceful error handling if Root folder is not accessible
### Technical
- `WebDavSyncService.downloadRemoteNotes()` - Dual-mode download (Root + /notes/)
- `WebDavSyncService.restoreFromServer()` - Now uses dual-mode download
- Migration happens naturally: new uploads go to `/notes/`, old notes stay readable
---
## [1.2.1] - 2026-01-05
### Fixed
- **Markdown Initial Export Bugfix**
- Existing notes are now exported as Markdown when Desktop Integration is activated
- Previously, only new notes created after activation were exported
- Progress dialog shows export status with current/total counter
- Error handling for network issues during export
- Individual note failures don't abort the entire export
- **Markdown Directory Structure Fix**
- Markdown files now correctly land in `/notes-md/` folder
- Smart URL detection supports both Root-URL and `/notes` URL structures
- Previously, MD files were incorrectly placed in the root directory
- Markdown import now finds files correctly
- **JSON URL Normalization**
- Simplified server configuration: enter only base URL (e.g., `http://server:8080/`)
- App automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown
- Smart detection: both `http://server:8080/` and `http://server:8080/notes/` work correctly
- Backward compatible: existing setups with `/notes` in URL continue to work
- No migration required for existing users
### Changed
- **Markdown Directory Creation**
- `notes-md/` folder is now created on first sync (regardless of Desktop Integration setting)
- Prevents 404 errors when mounting WebDAV folder
- Better user experience: folder is visible before enabling the feature
- **Settings UI Improvements**
- Updated example URL from `/webdav` to `/notes` to match app behavior
- Example now shows: `http://192.168.0.188:8080/notes`
### Technical
- `WebDavSyncService.ensureMarkdownDirectoryExists()` - Creates MD folder early
- `WebDavSyncService.getMarkdownUrl()` - Smart URL detection for both structures
- `WebDavSyncService.exportAllNotesToMarkdown()` - Exports all local notes with progress callback
- `SettingsActivity.onMarkdownExportToggled()` - Triggers initial export with ProgressDialog
---
## [1.2.0] - 2026-01-04
### Added
- **Local Backup System**
- Export all notes as JSON file to any location (Downloads, SD card, cloud folder)
- Import backup with 3 modes: Merge, Replace, or Overwrite duplicates
- Automatic safety backup created before every restore
- Backup validation (format and version check)
- **Markdown Desktop Integration**
- Optional Markdown export parallel to JSON sync
- `.md` files synced to `notes-md/` folder on WebDAV
- YAML frontmatter with `id`, `created`, `updated`, `device`
- Manual import button to pull Markdown changes from server
- Last-Write-Wins conflict resolution via timestamps
- **Settings UI Extensions**
- New "Backup & Restore" section with local + server restore
- New "Desktop Integration" section with Markdown toggle
- Universal restore dialog with radio button mode selection
### Changed
- **Server Restore Behavior**: Users now choose restore mode (Merge/Replace/Overwrite) instead of hard-coded replace-all
### Technical
- `BackupManager.kt` - Complete backup/restore logic
- `Note.toMarkdown()` / `Note.fromMarkdown()` - Markdown conversion with YAML frontmatter
- `WebDavSyncService` - Extended for dual-format sync (JSON master + Markdown mirror)
- ISO8601 timestamp formatting for desktop compatibility
- Filename sanitization for safe Markdown file names
### Documentation
- Added WebDAV mount instructions (Windows, macOS, Linux)
- Created [SYNC_ARCHITECTURE.md](../project-docs/simple-notes-sync/architecture/SYNC_ARCHITECTURE.md) - Complete sync documentation
- Created [MARKDOWN_DESKTOP_REALITY_CHECK.md](../project-docs/simple-notes-sync/markdown-desktop-plan/MARKDOWN_DESKTOP_REALITY_CHECK.md) - Desktop integration analysis
---
## [1.1.2] - 2025-12-28
### Fixed
- **"Job was cancelled" Error**
- Fixed coroutine cancellation in sync worker
- Proper error handling for interrupted syncs
- **UI Improvements**
- Back arrow instead of X in note editor (better UX)
- Pull-to-refresh for manual sync trigger
- HTTP/HTTPS protocol selection with radio buttons
- Inline error display (no toast spam)
- **Performance & Battery**
- Sync only on actual changes (saves battery)
- Auto-save notifications removed
- 24-hour server offline warning instead of instant error
### Changed
- Settings grouped into "Auto-Sync" and "Sync Interval" sections
- HTTP only allowed for local networks (RFC 1918 IPs)
- Swipe-to-delete without UI flicker
---
## [1.1.1] - 2025-12-27
### Fixed
- **WiFi Connect Sync**
- No error notifications in foreign WiFi networks
- Server reachability check before sync (2s timeout)
- Silent abort when server offline
- Pre-check waits until network is ready
- No errors during network initialization
### Changed
- **Notifications**
- Old sync notifications cleared on app start
- Error notifications auto-dismiss after 30 seconds
### UI
- Sync icon only shown when sync is configured
- Swipe-to-delete without flicker
- Scroll to top after saving note
### Technical
- Server check with 2-second timeout before sync attempts
- Network readiness check in WiFi connect trigger
- Notification cleanup on MainActivity.onCreate()
---
## [1.1.0] - 2025-12-26
### Added
- **Configurable Sync Intervals**
- User choice: 15, 30, or 60 minutes
- Real-world battery impact displayed (15min: ~0.8%/day, 30min: ~0.4%/day, 60min: ~0.2%/day)
- Radio button selection in settings
- Doze Mode optimization (syncs batched in maintenance windows)
- **About Section**
- App version from BuildConfig
- Links to GitHub repository and developer profile
- MIT license information
- Material 3 card design
### Changed
- Settings UI redesigned with grouped sections
- Periodic sync updated dynamically when interval changes
- WorkManager uses selected interval for background sync
### Removed
- Debug/Logs section from settings (cleaner UI)
### Technical
- `PREF_SYNC_INTERVAL_MINUTES` preference key
- NetworkMonitor reads interval from SharedPreferences
- `ExistingPeriodicWorkPolicy.UPDATE` for live interval changes
---
## [1.0.0] - 2025-12-25
### Added
- Initial release
- WebDAV synchronization
- Note creation, editing, deletion
- 6 sync triggers:
- Periodic sync (configurable interval)
- App start sync
- WiFi connect sync
- Manual sync (menu button)
- Pull-to-refresh
- Settings "Sync Now" button
- Material 3 design
- Light/Dark theme support
- F-Droid compatible (100% FOSS)
---
[1.2.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.0
[1.1.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.2
[1.1.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.1
[1.1.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.0
[1.0.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.0.0

View File

@@ -4,6 +4,224 @@ 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
**🌍 Languages:** [Deutsch](CHANGELOG.de.md) · **English**
---
## [1.6.1] - 2026-01-20
### 🧹 Code Quality & Build Improvements
- **detekt: 0 issues** - All 29 code quality issues resolved
- Trivial fixes: Unused imports, MaxLineLength
- File rename: DragDropState.kt → DragDropListState.kt
- MagicNumbers → Constants (Dimensions.kt, SyncConstants.kt)
- SwallowedException: Logger.w() added for better error tracking
- LongParameterList: ChecklistEditorCallbacks data class created
- LongMethod: ServerSettingsScreen split into components
- @Suppress annotations for legacy code (WebDavSyncService, SettingsActivity)
- **Zero build warnings** - All 21 deprecation warnings eliminated
- File-level @Suppress for deprecated imports
- ProgressDialog, LocalBroadcastManager, AbstractSavedStateViewModelFactory
- onActivityResult, onRequestPermissionsResult
- Gradle Compose config cleaned up (StrongSkipping is now default)
- **ktlint reactivated** - Linting re-enabled with Compose-specific rules
- .editorconfig created with Compose formatting rules
- Legacy files excluded: WebDavSyncService.kt, build.gradle.kts
- ignoreFailures=true for gradual migration
- **CI/CD improvements** - GitHub Actions lint checks integrated
- detekt + ktlint + Android Lint run before build in pr-build-check.yml
- Ensures code quality on every pull request
### 🔧 Technical Improvements
- **Constants refactoring** - Better code organization
- ui/theme/Dimensions.kt: UI-related constants
- utils/SyncConstants.kt: Sync operation constants
- **Preparation for v2.0.0** - Legacy code marked for removal
- SettingsActivity and MainActivity (replaced by Compose versions)
- All deprecated APIs documented with removal plan
---
## [1.6.0] - 2026-01-19
### 🎉 Major: Configurable Sync Triggers
Fine-grained control over when your notes sync - choose which triggers fit your workflow best!
### ⚙️ Sync Trigger System
- **Individual trigger control** - Enable/disable each sync trigger separately in settings
- **5 Independent Triggers:**
- **onSave Sync** - Sync immediately after saving a note (5s throttle)
- **onResume Sync** - Sync when app is opened (60s throttle)
- **WiFi-Connect Sync** - Sync when WiFi is connected
- **Periodic Sync** - Background sync every 15/30/60 minutes (configurable)
- **Boot Sync** - Start background sync after device restart
- **Smart Defaults** - Only event-driven triggers active by default (onSave, onResume, WiFi-Connect)
- **Battery Optimized** - ~0.2%/day with defaults, up to ~1.0% with periodic sync enabled
- **Offline Mode UI** - Grayed-out sync toggles when no server configured
- **Dynamic Settings Subtitle** - Shows count of active triggers on main settings screen
### 🔧 Server Configuration Improvements
- **Offline Mode Toggle** - Disable all network features with one switch
- **Split Protocol & Host** - Protocol (http/https) shown as non-editable prefix
- **Clickable Settings Cards** - Full card clickable for better UX
- **Clickable Toggle Rows** - Click text/icon to toggle switches (not just the switch itself)
### 🐛 Bug Fixes
- **Fixed:** Missing 5th sync trigger (Boot) in main settings screen subtitle count
- **Various fixes** - UI improvements and stability enhancements
### 🔧 Technical Improvements
- **Reactive offline mode state** - StateFlow ensures UI updates correctly
- **Separated server config checks** - `hasServerConfig()` vs `isServerConfigured()` (offline-aware)
- **Improved constants** - All sync trigger keys and defaults in Constants.kt
- **Better code organization** - Settings screens refactored for clarity
### Looking Ahead
> 🚀 **v1.7.0** will bring server folder checking and additional community features.
> Feature requests welcome as [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues).
---
## [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
- **🗑️ 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
- **🔄 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
- **📝 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** 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).
---
## [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 ## [1.3.2] - 2026-01-10

View File

@@ -14,7 +14,7 @@ Danke, dass du zu Simple Notes Sync beitragen möchtest!
1. **Fork & Clone** 1. **Fork & Clone**
```bash ```bash
git clone https://github.com/DEIN-USERNAME/simple-notes-sync.git git clone https://github.com/inventory69/simple-notes-sync.git
cd simple-notes-sync cd simple-notes-sync
``` ```
@@ -139,7 +139,7 @@ Thanks for wanting to contribute to Simple Notes Sync!
1. **Fork & Clone** 1. **Fork & Clone**
```bash ```bash
git clone https://github.com/YOUR-USERNAME/simple-notes-sync.git git clone https://github.com/inventory69/simple-notes-sync.git
cd simple-notes-sync cd simple-notes-sync
``` ```

269
QUICKSTART.de.md Normal file
View File

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

View File

@@ -1,271 +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`) |
| **Gateway SSID** | Name of your WiFi network |
> **💡 Note:** Enter only the base URL (without `/notes`). The app automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown export.
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 WiFi?**
- Smartphone and server must be on same network
- Check SSID in app settings
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. **On correct WiFi?**
- Sync only works when SSID = Gateway SSID
- Check current SSID in Android settings
4. **Test manually:**
- ⚙️ Settings → "Sync now"
- Works? → Auto-sync should work too
---
### Notes not showing up
**Problem:** After installation, no notes visible even though they exist on server
**Solution:**
1. **Manually sync once:**
- ⚙️ Settings → "Sync now"
2. **Check server data:**
```bash
docker compose exec webdav ls -la /data/
# Should show .json files
```
---
### Sync errors
**Problem:** Error message during sync
**Solutions:**
1. **"401 Unauthorized"** → Wrong password
- Check password in app settings
- Compare with `.env` on server
2. **"404 Not Found"** → Wrong URL
- Should end with `/` (e.g. `http://192.168.1.100:8080/`)
3. **"Network error"** → No connection
- See [Connection test fails](#connection-test-fails)
---
## 📱 Updates
### Automatic with Obtainium (recommended)
1. **[Install Obtainium](https://github.com/ImranR98/Obtanium/releases/latest)**
2. **Add app:**
- URL: `https://github.com/inventory69/simple-notes-sync`
- Enable auto-update
3. **Done!** Obtainium notifies you of new versions
### Manual
1. Download new APK from [Releases](https://github.com/inventory69/simple-notes-sync/releases/latest)
2. Install (overwrites old version)
3. All data remains intact!
---
## 🆘 Further Help
- **GitHub Issues:** [Report problem](https://github.com/inventory69/simple-notes-sync/issues)
- **Complete docs:** [DOCS.en.md](DOCS.en.md)
- **Server setup details:** [server/README.en.md](server/README.en.md)
---
**Version:** 1.1.0 · **Created:** December 2025

View File

@@ -1,271 +1,269 @@
# Quick Start Guide - Simple Notes Sync 📝 # 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 - ✅ Android 8.0+ smartphone/tablet
- ✅ WLAN-Verbindung - ✅ WiFi connection
-Eigener Server mit Docker (optional - für Self-Hosting) -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 ```bash
# Repository klonen # Clone repository
git clone https://github.com/inventory69/simple-notes-sync.git git clone https://github.com/inventory69/simple-notes-sync.git
cd simple-notes-sync/server cd simple-notes-sync/server
# Umgebungsvariablen konfigurieren # Configure environment variables
cp .env.example .env cp .env.example .env
nano .env nano .env
``` ```
**In `.env` anpassen:** **Adjust in `.env`:**
```env ```env
WEBDAV_PASSWORD=dein-sicheres-passwort-hier WEBDAV_PASSWORD=your-secure-password-here
``` ```
**Server starten:** **Start server:**
```bash ```bash
docker compose up -d docker compose up -d
``` ```
**IP-Adresse finden:** **Find IP address:**
```bash ```bash
ip addr show | grep "inet " | grep -v 127.0.0.1 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) 1. **Download APK:** [Latest version](https://github.com/inventory69/simple-notes-sync/releases/latest)
- Wähle: `simple-notes-sync-vX.X.X-standard-universal.apk` - Choose: `simple-notes-sync-vX.X.X-standard-universal.apk`
2. **Installation erlauben:** 2. **Allow installation:**
- Android: Einstellungen → Sicherheit → "Unbekannte Quellen" für deinen Browser aktivieren - Android: Settings → SecurityEnable "Unknown sources" for your browser
3. **APK öffnen und installieren** 3. **Open and install APK**
--- ---
### Schritt 3: App konfigurieren ### Step 3: Configure App
1. **App öffnen** 1. **Open app**
2. **Einstellungen öffnen** (⚙️ Icon oben rechts) 2. **Open settings** (⚙️ icon top right)
3. **Server-Einstellungen konfigurieren:** 3. **Configure server settings:**
| Feld | Wert | | Field | Value |
|------|------| |------|------|
| **WebDAV Server URL** | `http://DEINE-SERVER-IP:8080/` | | **WebDAV Server URL** | `http://YOUR-SERVER-IP:8080/` |
| **Benutzername** | `noteuser` | | **Username** | `noteuser` |
| **Passwort** | (dein Passwort aus `.env`) | | **Password** | (your password from `.env`) |
| **Gateway SSID** | Name deines WLAN-Netzwerks |
> **💡 Hinweis:** Gib nur die Base-URL ein (ohne `/notes`). Die App erstellt automatisch `/notes/` für JSON-Dateien und `/notes-md/` für Markdown-Export. > **💡 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 4. **Press "Test connection"****
-Erfolg? → Weiter zu Schritt 4 -Success? → Continue to step 4
-Fehler? → Siehe [Troubleshooting](#troubleshooting) -Error? → See [Troubleshooting](#troubleshooting)
5. **Auto-Sync aktivieren** (Toggle Switch) 5. **Enable auto-sync** (toggle switch)
6. **Sync-Intervall wählen:** 6. **Choose sync interval:**
- **15 Min** - Maximale Aktualität (~0.8% Akku/Tag) - **15 min** - Maximum currency (~0.8% battery/day)
- **30 Min** - Empfohlen (~0.4% Akku/Tag) ⭐ - **30 min** - Recommended (~0.4% battery/day) ⭐
- **60 Min** - Maximale Akkulaufzeit (~0.2% Akku/Tag) - **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:** 2. **Use without server configuration:**
- Notizen werden nur lokal gespeichert - Notes are only stored locally
- Kein Auto-Sync - No auto-sync
- Perfekt für reine Offline-Nutzung - 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) | | **15 min** | ~96 | ~0.8% (~23 mAh) | ~0.008% | ⚡ Maximum currency (multiple devices) |
| **30 Min** | ~48 | ~0.4% (~12 mAh) | ~0.008% | ✓ **Empfohlen** - ausgewogen | | **30 min** | ~48 | ~0.4% (~12 mAh) | ~0.008% | ✓ **Recommended** - balanced |
| **60 Min** | ~24 | ~0.2% (~6 mAh) | ~0.008% | 🔋 Maximale Akkulaufzeit | | **60 min** | ~24 | ~0.2% (~6 mAh) | ~0.008% | 🔋 Maximum battery life |
--- ---
## 🐛 Troubleshooting ## 🐛 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 ```bash
docker compose ps docker compose ps
# Sollte "Up" zeigen # Should show "Up"
``` ```
2. **Gleiche WLAN?** 2. **Same network?**
- Smartphone und Server müssen im selben Netzwerk sein - Smartphone and server must be on same network
- Prüfe SSID in App-Einstellungen
3. **IP-Adresse korrekt?** 3. **IP address correct?**
```bash ```bash
ip addr show | grep "inet " ip addr show | grep "inet "
# Prüfe ob IP in URL stimmt # Check if IP in URL matches
``` ```
4. **Firewall?** 4. **Firewall?**
```bash ```bash
# Port 8080 öffnen (falls Firewall aktiv) # Open port 8080 (if firewall active)
sudo ufw allow 8080/tcp sudo ufw allow 8080/tcp
``` ```
5. **Server-Logs prüfen:** 5. **Check server logs:**
```bash ```bash
docker compose logs -f 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?** 1. **Auto-sync enabled?**
- ⚙️ Einstellungen → Toggle "Auto-Sync" muss **AN** sein - ⚙️ Settings → Toggle "Auto-sync" must be **ON**
2. **Akku-Optimierung deaktiviert?** 2. **Battery optimization disabled?**
- Siehe [Akku-Optimierung](#-akku-optimierung-deaktivieren) - See [Disable Battery Optimization](#-disable-battery-optimization)
3. **Im richtigen WLAN?** 3. **Connected to WiFi?**
- Sync funktioniert nur wenn SSID = Gateway SSID - Auto-sync triggers on any WiFi connection
- Prüfe aktuelle SSID in Android-Einstellungen - Check if you're connected to a WiFi network
4. **Manuell testen:** 4. **Test manually:**
- ⚙️ Einstellungen → "Jetzt synchronisieren" - ⚙️ Settings → "Sync now"
- Funktioniert das? → Auto-Sync sollte auch funktionieren - 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:** 1. **Manually sync once:**
- ⚙️ Einstellungen → "Jetzt synchronisieren" - ⚙️ Settings → "Sync now"
2. **Server-Daten prüfen:** 2. **Check server data:**
```bash ```bash
docker compose exec webdav ls -la /data/ 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 1. **"401 Unauthorized"** → Wrong password
- Prüfe Passwort in App-Einstellungen - Check password in app settings
- Vergleiche mit `.env` auf Server - Compare with `.env` on server
2. **"404 Not Found"** → URL falsch 2. **"404 Not Found"** → Wrong URL
- Sollte enden mit `/` (z.B. `http://192.168.1.100:8080/`) - Should end with `/` (e.g. `http://192.168.1.100:8080/`)
3. **"Network error"** → Keine Verbindung 3. **"Network error"** → No connection
- Siehe [Verbindungstest schlägt fehl](#verbindungstest-schlägt-fehl) - See [Connection test fails](#connection-test-fails)
--- ---
## 📱 Updates ## 📱 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` - 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) - **GitHub Issues:** [Report problem](https://github.com/inventory69/simple-notes-sync/issues)
- **Vollständige Docs:** [DOCS.md](DOCS.md) - **Complete docs:** [DOCS.en.md](DOCS.en.md)
- **Server Setup Details:** [server/README.md](server/README.md) - **Server setup details:** [server/README.en.md](server/README.en.md)
--- ---
**Version:** 1.1.0 · **Erstellt:** Dezember 2025 **Version:** 1.1.0 · **Created:** December 2025

115
README.de.md Normal file
View File

@@ -0,0 +1,115 @@
# Simple Notes Sync 📝
> Minimalistische Offline-Notizen mit Auto-Sync zu deinem eigenen Server
[![Android](https://img.shields.io/badge/Android-8.0%2B-green.svg)](https://www.android.com/)
[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes)
[<img src="https://f-droid.org/badge/get-it-on-de.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/dev.dettmer.simplenotes/)
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](docs/DOCS.de.md)** · **🚀 [Quick Start](QUICKSTART.de.md)**
**🌍 Sprachen:** **Deutsch** · [English](README.md)
---
## 📱 Screenshots
<p align="center">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.png" width="250" alt="Sync-Status">
<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/7.png" width="250" alt="Sync-Einstellungen">
</p>
---
## ✨ Highlights
-**NEU: Checklisten** - Tap-to-Check, Drag & Drop
- 🌍 **NEU: Mehrsprachig** - Deutsch/Englisch mit Sprachauswahl
- 📝 **Offline-First** - Funktioniert ohne Internet
- 🔄 **Konfigurierbare Sync-Trigger** - onSave, onResume, WiFi-Verbindung, periodisch (15/30/60 Min), Boot
- 🔒 **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% mit Defaults, bis zu ~1.0% mit Periodic Sync
- 🎨 **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.6.0** · Built with ❤️ using Kotlin + Material Design 3

View File

@@ -1,104 +0,0 @@
# Simple Notes Sync 📝
> Minimalist offline notes with auto-sync to your own server
[![Android](https://img.shields.io/badge/Android-8.0%2B-green.svg)](https://www.android.com/)
[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes)
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Documentation](docs/DOCS.en.md)** · **🚀 [Quick Start](QUICKSTART.en.md)**
**🌍 Languages:** [Deutsch](README.md) · **English**
---
## 📱 Screenshots
<p align="center">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.jpg" width="250" alt="Notes list">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.jpg" width="250" alt="Edit note">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.jpg" width="250" alt="Settings">
</p>
---
## ✨ Highlights
- 📝 **Offline-first** - Works without internet
- 🔄 **Auto-sync** - Home WiFi only (15/30/60 min)
- 🔒 **Self-hosted** - Your data stays with you (WebDAV)
- 💾 **Local backup** - Export/Import as JSON file
- 🖥️ **Desktop integration** - Markdown export for VS Code, Typora, etc.
- 🔋 **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) |
---
## 🛠️ 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.3.2** · Built with ❤️ using Kotlin + Material Design 3

View File

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

View File

@@ -1,8 +1,8 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen, aktivieren in v1.4.0 alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler
// alias(libs.plugins.ktlint) alias(libs.plugins.ktlint) // ✅ v1.6.1: Reaktiviert nach Code-Cleanup
alias(libs.plugins.detekt) alias(libs.plugins.detekt)
} }
@@ -20,13 +20,10 @@ android {
applicationId = "dev.dettmer.simplenotes" applicationId = "dev.dettmer.simplenotes"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 10 // 🚀 v1.3.2: Lint-Cleanup "Clean Slate" versionCode = 15 // 🔧 v1.6.1: Lint-Cleanup detekt and ktlint
versionName = "1.3.2" // 🚀 v1.3.2: Code-Qualität-Release (alle einfachen Lint-Issues behoben) versionName = "1.6.1" // 🔧 v1.6.1: Lint-Cleanup detekt and ktlint
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// 🔥 NEU: Build Date für About Screen
buildConfigField("String", "BUILD_DATE", "\"${getBuildDate()}\"")
} }
// Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility // Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility
@@ -99,8 +96,13 @@ android {
buildFeatures { buildFeatures {
viewBinding = true viewBinding = true
buildConfig = true // Enable BuildConfig generation 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
// v1.6.1: Feature ist ab dieser Kotlin/Compose Version bereits Standard
// composeCompiler { }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
@@ -138,30 +140,41 @@ dependencies {
// SwipeRefreshLayout für Pull-to-Refresh // SwipeRefreshLayout für Pull-to-Refresh
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") 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) // Testing (bleiben so)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
} }
// 🔥 NEU: Helper function für Build Date // ✅ v1.6.1: ktlint reaktiviert nach Code-Cleanup
fun getBuildDate(): String { ktlint {
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US) android = true
return dateFormat.format(Date()) outputToConsole = true
} ignoreFailures = true // Parser-Probleme in WebDavSyncService.kt und build.gradle.kts
enableExperimentalRules = false
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen filter {
// Aktivieren in v1.4.0 wenn Code-Stil bereinigt wurde exclude("**/generated/**")
// ktlint { exclude("**/build/**")
// android = true // Legacy adapters with ktlint parser issues
// outputToConsole = true exclude("**/adapters/NotesAdapter.kt")
// ignoreFailures = true exclude("**/SettingsActivity.kt")
// enableExperimentalRules = false }
// filter { }
// exclude("**/generated/**")
// exclude("**/build/**")
// }
// }
// ⚡ v1.3.1: detekt-Konfiguration // ⚡ v1.3.1: detekt-Konfiguration
detekt { detekt {

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,6 @@
<!-- Network & Sync Permissions --> <!-- Network & Sync Permissions -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Notifications --> <!-- Notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -24,13 +22,15 @@
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.SimpleNotes" android:theme="@style/Theme.SimpleNotes"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="31"> tools:targetApi="31">
<!-- MainActivity v1.5.0 (Jetpack Compose) - Launcher -->
<activity <activity
android:name=".MainActivity" android:name=".ui.main.ComposeMainActivity"
android:exported="true" android:exported="true"
android:theme="@style/Theme.SimpleNotes.Splash"> android:theme="@style/Theme.SimpleNotes.Splash">
<intent-filter> <intent-filter>
@@ -39,16 +39,35 @@
</intent-filter> </intent-filter>
</activity> </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 <activity
android:name=".NoteEditorActivity" android:name=".NoteEditorActivity"
android:windowSoftInputMode="adjustResize" 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 <activity
android:name=".SettingsActivity" 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 --> <!-- Boot Receiver - Startet WorkManager nach Reboot -->
<receiver <receiver

View File

@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
package dev.dettmer.simplenotes package dev.dettmer.simplenotes
import android.Manifest import android.Manifest
@@ -44,7 +46,15 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import android.view.View import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import android.view.Gravity
import android.widget.PopupMenu
import dev.dettmer.simplenotes.models.NoteType
/**
* Legacy MainActivity - DEPRECATED seit v1.5.0, wird entfernt in v2.0.0
* Ersetzt durch ComposeMainActivity
*/
@Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var recyclerViewNotes: RecyclerView private lateinit var recyclerViewNotes: RecyclerView
@@ -128,6 +138,9 @@ class MainActivity : AppCompatActivity() {
setupRecyclerView() setupRecyclerView()
setupFab() setupFab()
// v1.4.1: Migrate checklists for backwards compatibility
migrateChecklistsForBackwardsCompat()
loadNotes() loadNotes()
// 🔄 v1.3.1: Observe sync state for UI updates // 🔄 v1.3.1: Observe sync state for UI updates
@@ -176,6 +189,11 @@ class MainActivity : AppCompatActivity() {
swipeRefreshLayout.isRefreshing = false swipeRefreshLayout.isRefreshing = false
syncStatusBanner.visibility = View.GONE 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)
}
} }
} }
} }
@@ -216,6 +234,7 @@ class MainActivity : AppCompatActivity() {
* - Nur Success-Toast (kein "Auto-Sync..." Toast) * - Nur Success-Toast (kein "Auto-Sync..." Toast)
* *
* NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!) * 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") { private fun triggerAutoSync(source: String = "unknown") {
// Throttling: Max 1 Sync pro Minute // Throttling: Max 1 Sync pro Minute
@@ -224,7 +243,8 @@ class MainActivity : AppCompatActivity() {
} }
// 🔄 v1.3.1: Check if sync already running // 🔄 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") Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
return return
} }
@@ -454,10 +474,10 @@ class MainActivity : AppCompatActivity() {
val checkboxAlways = dialogView.findViewById<CheckBox>(R.id.checkboxAlwaysDeleteFromServer) val checkboxAlways = dialogView.findViewById<CheckBox>(R.id.checkboxAlwaysDeleteFromServer)
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
.setTitle("Notiz löschen") .setTitle(getString(R.string.legacy_delete_dialog_title))
.setMessage("\"${note.title}\" wird lokal gelöscht.\n\nAuch vom Server löschen?") .setMessage(getString(R.string.legacy_delete_dialog_message, note.title))
.setView(dialogView) .setView(dialogView)
.setNeutralButton("Abbrechen") { _, _ -> .setNeutralButton(getString(R.string.cancel)) { _, _ ->
// RESTORE: Re-submit original list (note is NOT deleted from storage) // RESTORE: Re-submit original list (note is NOT deleted from storage)
adapter.submitList(originalList) adapter.submitList(originalList)
} }
@@ -472,7 +492,7 @@ class MainActivity : AppCompatActivity() {
// NOW actually delete from storage // NOW actually delete from storage
deleteNoteLocally(note, deleteFromServer = false) deleteNoteLocally(note, deleteFromServer = false)
} }
.setNegativeButton("Vom Server löschen") { _, _ -> .setNegativeButton(getString(R.string.legacy_delete_from_server)) { _, _ ->
if (checkboxAlways.isChecked) { if (checkboxAlways.isChecked) {
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, true).apply() prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, true).apply()
} }
@@ -494,13 +514,13 @@ class MainActivity : AppCompatActivity() {
// Show Snackbar with UNDO option // Show Snackbar with UNDO option
val message = if (deleteFromServer) { val message = if (deleteFromServer) {
"\"${note.title}\" wird lokal und vom Server gelöscht" getString(R.string.legacy_delete_with_server, note.title)
} else { } else {
"\"${note.title}\" lokal gelöscht (Server bleibt)" getString(R.string.legacy_delete_local_only, note.title)
} }
Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG) Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG)
.setAction("RÜCKGÄNGIG") { .setAction(getString(R.string.snackbar_undo)) {
// UNDO: Restore note // UNDO: Restore note
storage.saveNote(note) storage.saveNote(note)
pendingDeletions.remove(note.id) pendingDeletions.remove(note.id)
@@ -522,7 +542,7 @@ class MainActivity : AppCompatActivity() {
runOnUiThread { runOnUiThread {
Toast.makeText( Toast.makeText(
this@MainActivity, this@MainActivity,
"Vom Server gelöscht", getString(R.string.snackbar_deleted_from_server),
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
).show() ).show()
} }
@@ -530,7 +550,7 @@ class MainActivity : AppCompatActivity() {
runOnUiThread { runOnUiThread {
Toast.makeText( Toast.makeText(
this@MainActivity, this@MainActivity,
"Server-Löschung fehlgeschlagen", getString(R.string.snackbar_server_delete_failed),
Toast.LENGTH_LONG Toast.LENGTH_LONG
).show() ).show()
} }
@@ -551,12 +571,55 @@ class MainActivity : AppCompatActivity() {
}).show() }).show()
} }
/**
* v1.4.0: Setup FAB mit Dropdown für Notiz-Typ Auswahl
*/
private fun setupFab() { private fun setupFab() {
fabAddNote.setOnClickListener { fabAddNote.setOnClickListener { view ->
openNoteEditor(null) showNoteTypePopup(view)
} }
} }
/**
* v1.4.0: Zeigt Popup-Menü zur Auswahl des Notiz-Typs
*/
private fun showNoteTypePopup(anchor: View) {
val popupMenu = PopupMenu(this, anchor, Gravity.END)
popupMenu.inflate(R.menu.menu_fab_note_types)
// Icons im Popup anzeigen (via Reflection, da standardmäßig ausgeblendet)
try {
val fields = popupMenu.javaClass.declaredFields
for (field in fields) {
if ("mPopup" == field.name) {
field.isAccessible = true
val menuPopupHelper = field.get(popupMenu)
val classPopupHelper = Class.forName(menuPopupHelper.javaClass.name)
val setForceIcons = classPopupHelper.getMethod("setForceShowIcon", Boolean::class.java)
setForceIcons.invoke(menuPopupHelper, true)
break
}
}
} catch (e: Exception) {
Logger.w(TAG, "Could not force show icons in popup menu: ${e.message}")
}
popupMenu.setOnMenuItemClickListener { menuItem ->
val noteType = when (menuItem.itemId) {
R.id.action_create_text_note -> NoteType.TEXT
R.id.action_create_checklist -> NoteType.CHECKLIST
else -> return@setOnMenuItemClickListener false
}
val intent = Intent(this, NoteEditorActivity::class.java)
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
startActivity(intent)
true
}
popupMenu.show()
}
private fun loadNotes() { private fun loadNotes() {
val notes = storage.loadAllNotes() val notes = storage.loadAllNotes()
@@ -589,7 +652,8 @@ class MainActivity : AppCompatActivity() {
} }
private fun openSettings() { 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") @Suppress("DEPRECATION")
startActivityForResult(intent, REQUEST_SETTINGS) startActivityForResult(intent, REQUEST_SETTINGS)
} }
@@ -684,6 +748,54 @@ class MainActivity : AppCompatActivity() {
} }
} }
/**
* v1.4.1: Migriert bestehende Checklisten für Abwärtskompatibilität.
*
* Problem: v1.4.0 Checklisten haben leeren "content", was auf älteren
* App-Versionen (v1.3.x) als leere Notiz angezeigt wird.
*
* Lösung: Alle Checklisten ohne Fallback-Content als PENDING markieren,
* damit sie beim nächsten Sync mit Fallback-Content hochgeladen werden.
*
* TODO: Diese Migration kann entfernt werden, sobald v1.4.0 nicht mehr
* im Umlauf ist (ca. 6 Monate nach v1.4.1 Release, also ~Juli 2026).
* Tracking: https://github.com/inventory69/simple-notes-sync/issues/XXX
*/
private fun migrateChecklistsForBackwardsCompat() {
val migrationKey = "v1.4.1_checklist_migration_done"
// Nur einmal ausführen
if (prefs.getBoolean(migrationKey, false)) {
return
}
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) {
// Als PENDING markieren, damit beim nächsten Sync der Fallback-Content
// generiert und hochgeladen wird
val updatedNote = note.copy(
syncStatus = dev.dettmer.simplenotes.models.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")
}
// Migration als erledigt markieren
prefs.edit().putBoolean(migrationKey, true).apply()
}
override fun onRequestPermissionsResult( override fun onRequestPermissionsResult(
requestCode: Int, requestCode: Int,
permissions: Array<out String>, permissions: Array<out String>,
@@ -695,10 +807,9 @@ class MainActivity : AppCompatActivity() {
REQUEST_NOTIFICATION_PERMISSION -> { REQUEST_NOTIFICATION_PERMISSION -> {
if (grantResults.isNotEmpty() && if (grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED) { grantResults[0] == PackageManager.PERMISSION_GRANTED) {
showToast("Benachrichtigungen aktiviert") showToast(getString(R.string.toast_notifications_enabled))
} else { } else {
showToast("Benachrichtigungen deaktiviert. " + showToast(getString(R.string.toast_notifications_disabled))
"Du kannst sie in den Einstellungen aktivieren.")
} }
} }
} }

View File

@@ -3,27 +3,58 @@ package dev.dettmer.simplenotes
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View
import android.widget.LinearLayout
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.button.MaterialButton
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import dev.dettmer.simplenotes.adapters.ChecklistEditorAdapter
import dev.dettmer.simplenotes.models.ChecklistItem
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.DeviceIdGenerator import dev.dettmer.simplenotes.utils.DeviceIdGenerator
import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.showToast import dev.dettmer.simplenotes.utils.showToast
/**
* Editor Activity für Notizen und Checklisten
*
* v1.4.0: Unterstützt jetzt sowohl TEXT als auch CHECKLIST Notizen
*/
class NoteEditorActivity : AppCompatActivity() { class NoteEditorActivity : AppCompatActivity() {
// Views
private lateinit var toolbar: MaterialToolbar
private lateinit var tilTitle: TextInputLayout
private lateinit var editTextTitle: TextInputEditText private lateinit var editTextTitle: TextInputEditText
private lateinit var tilContent: TextInputLayout
private lateinit var editTextContent: TextInputEditText private lateinit var editTextContent: TextInputEditText
private lateinit var checklistContainer: LinearLayout
private lateinit var rvChecklistItems: RecyclerView
private lateinit var btnAddItem: MaterialButton
private lateinit var storage: NotesStorage private lateinit var storage: NotesStorage
// State
private var existingNote: Note? = null private var existingNote: Note? = null
private var currentNoteType: NoteType = NoteType.TEXT
private val checklistItems = mutableListOf<ChecklistItem>()
private var checklistAdapter: ChecklistEditorAdapter? = null
private var itemTouchHelper: ItemTouchHelper? = null
companion object { companion object {
private const val TAG = "NoteEditorActivity"
const val EXTRA_NOTE_ID = "extra_note_id" const val EXTRA_NOTE_ID = "extra_note_id"
const val EXTRA_NOTE_TYPE = "extra_note_type"
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -36,39 +67,172 @@ class NoteEditorActivity : AppCompatActivity() {
storage = NotesStorage(this) storage = NotesStorage(this)
// Setup toolbar findViews()
val toolbar = findViewById<MaterialToolbar>(R.id.toolbar) setupToolbar()
setSupportActionBar(toolbar) loadNoteOrDetermineType()
supportActionBar?.apply { setupUIForNoteType()
setDisplayHomeAsUpEnabled(true)
// 🔥 v1.1.2: Use default back arrow (Material Design) instead of X icon
// Icon is set in XML: app:navigationIcon="?attr/homeAsUpIndicator"
} }
// Find views private fun findViews() {
toolbar = findViewById(R.id.toolbar)
tilTitle = findViewById(R.id.tilTitle)
editTextTitle = findViewById(R.id.editTextTitle) editTextTitle = findViewById(R.id.editTextTitle)
tilContent = findViewById(R.id.tilContent)
editTextContent = findViewById(R.id.editTextContent) editTextContent = findViewById(R.id.editTextContent)
checklistContainer = findViewById(R.id.checklistContainer)
rvChecklistItems = findViewById(R.id.rvChecklistItems)
btnAddItem = findViewById(R.id.btnAddItem)
}
// Load existing note if editing private fun setupToolbar() {
setSupportActionBar(toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
private fun loadNoteOrDetermineType() {
val noteId = intent.getStringExtra(EXTRA_NOTE_ID) val noteId = intent.getStringExtra(EXTRA_NOTE_ID)
if (noteId != null) { if (noteId != null) {
// Existierende Notiz laden
existingNote = storage.loadNote(noteId) existingNote = storage.loadNote(noteId)
existingNote?.let { existingNote?.let { note ->
editTextTitle.setText(it.title) editTextTitle.setText(note.title)
editTextContent.setText(it.content) currentNoteType = note.noteType
supportActionBar?.title = "Notiz bearbeiten"
when (note.noteType) {
NoteType.TEXT -> {
editTextContent.setText(note.content)
supportActionBar?.title = getString(R.string.edit_note)
}
NoteType.CHECKLIST -> {
note.checklistItems?.let { items ->
checklistItems.clear()
checklistItems.addAll(items.sortedBy { it.order })
}
supportActionBar?.title = getString(R.string.edit_checklist)
}
}
} }
} else { } else {
supportActionBar?.title = "Neue Notiz" // Neue Notiz - Typ aus Intent
val typeString = intent.getStringExtra(EXTRA_NOTE_TYPE) ?: NoteType.TEXT.name
currentNoteType = try {
NoteType.valueOf(typeString)
} catch (e: IllegalArgumentException) {
Logger.w(TAG, "Invalid note type '$typeString', defaulting to TEXT: ${e.message}")
NoteType.TEXT
}
when (currentNoteType) {
NoteType.TEXT -> {
supportActionBar?.title = getString(R.string.new_note)
}
NoteType.CHECKLIST -> {
supportActionBar?.title = getString(R.string.new_checklist)
// Erstes leeres Item hinzufügen
checklistItems.add(ChecklistItem.createEmpty(0))
}
}
}
}
private fun setupUIForNoteType() {
when (currentNoteType) {
NoteType.TEXT -> {
tilContent.visibility = View.VISIBLE
checklistContainer.visibility = View.GONE
}
NoteType.CHECKLIST -> {
tilContent.visibility = View.GONE
checklistContainer.visibility = View.VISIBLE
setupChecklistRecyclerView()
}
}
}
private fun setupChecklistRecyclerView() {
checklistAdapter = ChecklistEditorAdapter(
items = checklistItems,
onItemCheckedChanged = { position, isChecked ->
if (position in checklistItems.indices) {
checklistItems[position].isChecked = isChecked
}
},
onItemTextChanged = { position, newText ->
if (position in checklistItems.indices) {
checklistItems[position] = checklistItems[position].copy(text = newText)
}
},
onItemDeleted = { position ->
deleteChecklistItem(position)
},
onAddNewItem = { position ->
addChecklistItemAt(position)
},
onStartDrag = { viewHolder ->
itemTouchHelper?.startDrag(viewHolder)
}
)
rvChecklistItems.apply {
layoutManager = LinearLayoutManager(this@NoteEditorActivity)
adapter = checklistAdapter
}
// Drag & Drop Setup
val callback = object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
0 // Kein Swipe
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val from = viewHolder.bindingAdapterPosition
val to = target.bindingAdapterPosition
checklistAdapter?.moveItem(from, to)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
// Nicht verwendet
}
override fun isLongPressDragEnabled(): Boolean = false // Nur via Handle
}
itemTouchHelper = ItemTouchHelper(callback)
itemTouchHelper?.attachToRecyclerView(rvChecklistItems)
// Add Item Button
btnAddItem.setOnClickListener {
addChecklistItemAt(checklistItems.size)
}
}
private fun addChecklistItemAt(position: Int) {
val newItem = ChecklistItem.createEmpty(position)
checklistAdapter?.insertItem(position, newItem)
// Zum neuen Item scrollen und fokussieren
rvChecklistItems.scrollToPosition(position)
checklistAdapter?.focusItem(rvChecklistItems, position)
}
private fun deleteChecklistItem(position: Int) {
checklistAdapter?.removeItem(position)
// Wenn letztes Item gelöscht, automatisch neues hinzufügen
if (checklistItems.isEmpty()) {
addChecklistItemAt(0)
} }
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_editor, menu) menuInflater.inflate(R.menu.menu_editor, menu)
// Delete nur für existierende Notizen
// Show delete only for existing notes
menu.findItem(R.id.action_delete)?.isVisible = existingNote != null menu.findItem(R.id.action_delete)?.isVisible = existingNote != null
return true return true
} }
@@ -92,51 +256,96 @@ class NoteEditorActivity : AppCompatActivity() {
private fun saveNote() { private fun saveNote() {
val title = editTextTitle.text?.toString()?.trim() ?: "" val title = editTextTitle.text?.toString()?.trim() ?: ""
when (currentNoteType) {
NoteType.TEXT -> {
val content = editTextContent.text?.toString()?.trim() ?: "" val content = editTextContent.text?.toString()?.trim() ?: ""
if (title.isEmpty() && content.isEmpty()) { if (title.isEmpty() && content.isEmpty()) {
showToast("Notiz ist leer") showToast(getString(R.string.note_is_empty))
return return
} }
val note = if (existingNote != null) { val note = if (existingNote != null) {
// Update existing note
existingNote!!.copy( existingNote!!.copy(
title = title, title = title,
content = content, content = content,
noteType = NoteType.TEXT,
checklistItems = null,
updatedAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING syncStatus = SyncStatus.PENDING
) )
} else { } else {
// Create new note
Note( Note(
title = title, title = title,
content = content, content = content,
noteType = NoteType.TEXT,
checklistItems = null,
deviceId = DeviceIdGenerator.getDeviceId(this), deviceId = DeviceIdGenerator.getDeviceId(this),
syncStatus = SyncStatus.LOCAL_ONLY syncStatus = SyncStatus.LOCAL_ONLY
) )
} }
storage.saveNote(note) storage.saveNote(note)
showToast("Notiz gespeichert") }
NoteType.CHECKLIST -> {
// Leere Items filtern
val validItems = checklistItems.filter { it.text.isNotBlank() }
if (title.isEmpty() && validItems.isEmpty()) {
showToast(getString(R.string.note_is_empty))
return
}
// Order neu setzen
val orderedItems = validItems.mapIndexed { index, item ->
item.copy(order = index)
}
val note = if (existingNote != null) {
existingNote!!.copy(
title = title,
content = "", // Leer für Checklisten
noteType = NoteType.CHECKLIST,
checklistItems = orderedItems,
updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING
)
} else {
Note(
title = title,
content = "",
noteType = NoteType.CHECKLIST,
checklistItems = orderedItems,
deviceId = DeviceIdGenerator.getDeviceId(this),
syncStatus = SyncStatus.LOCAL_ONLY
)
}
storage.saveNote(note)
}
}
showToast(getString(R.string.note_saved))
finish() finish()
} }
private fun confirmDelete() { private fun confirmDelete() {
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle("Notiz löschen?") .setTitle(getString(R.string.delete_note_title))
.setMessage("Diese Aktion kann nicht rückgängig gemacht werden.") .setMessage(getString(R.string.delete_note_message))
.setPositiveButton("Löschen") { _, _ -> .setPositiveButton(getString(R.string.delete)) { _, _ ->
deleteNote() deleteNote()
} }
.setNegativeButton("Abbrechen", null) .setNegativeButton(getString(R.string.cancel), null)
.show() .show()
} }
private fun deleteNote() { private fun deleteNote() {
existingNote?.let { existingNote?.let {
storage.deleteNote(it.id) storage.deleteNote(it.id)
showToast("Notiz gelöscht") showToast(getString(R.string.note_deleted))
finish() finish()
} }
} }

View File

@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION") // Legacy code using ProgressDialog & LocalBroadcastManager, will be removed in v2.0.0
package dev.dettmer.simplenotes package dev.dettmer.simplenotes
import android.app.ProgressDialog import android.app.ProgressDialog
@@ -42,6 +44,7 @@ import java.net.URL
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
@Suppress("LargeClass", "DEPRECATION") // Legacy code using ProgressDialog & LocalBroadcastManager, will be removed in v2.0.0
class SettingsActivity : AppCompatActivity() { class SettingsActivity : AppCompatActivity() {
companion object { companion object {
@@ -227,9 +230,9 @@ class SettingsActivity : AppCompatActivity() {
*/ */
private fun updateProtocolHint() { private fun updateProtocolHint() {
protocolHintText.text = if (radioHttp.isChecked) { 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 { } else {
"HTTPS für sichere Verbindungen über das Internet" getString(R.string.server_connection_https_hint)
} }
} }
@@ -359,7 +362,7 @@ class SettingsActivity : AppCompatActivity() {
60L -> "60 Minuten" 60L -> "60 Minuten"
else -> "$newInterval 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") Logger.i(TAG, "Sync interval changed to $newInterval minutes, restarted periodic sync")
} else { } else {
showToast("⏱️ Sync-Intervall gespeichert (Auto-Sync ist deaktiviert)") showToast("⏱️ Sync-Intervall gespeichert (Auto-Sync ist deaktiviert)")
@@ -371,16 +374,15 @@ class SettingsActivity : AppCompatActivity() {
* Setup about section with version info and clickable cards * Setup about section with version info and clickable cards
*/ */
private fun setupAboutSection() { private fun setupAboutSection() {
// Display app version with build date // Display app version
try { try {
val versionName = BuildConfig.VERSION_NAME val versionName = BuildConfig.VERSION_NAME
val versionCode = BuildConfig.VERSION_CODE val versionCode = BuildConfig.VERSION_CODE
val buildDate = BuildConfig.BUILD_DATE
textViewAppVersion.text = "Version $versionName ($versionCode)\nErstellt am: $buildDate" textViewAppVersion.text = "Version $versionName ($versionCode)"
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "Failed to load version info", e) 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 // GitHub Repository Card
@@ -476,12 +478,12 @@ class SettingsActivity : AppCompatActivity() {
*/ */
private fun showClearLogsConfirmation() { private fun showClearLogsConfirmation() {
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle("Logs löschen?") .setTitle(getString(R.string.debug_delete_logs_title))
.setMessage("Alle gespeicherten Sync-Logs werden unwiderruflich gelöscht.") .setMessage(getString(R.string.debug_delete_logs_message))
.setPositiveButton("Löschen") { _, _ -> .setPositiveButton(getString(R.string.delete)) { _, _ ->
clearLogs() clearLogs()
} }
.setNegativeButton("Abbrechen", null) .setNegativeButton(getString(R.string.cancel), null)
.show() .show()
} }
@@ -492,13 +494,13 @@ class SettingsActivity : AppCompatActivity() {
try { try {
val cleared = Logger.clearLogFile(this) val cleared = Logger.clearLogFile(this)
if (cleared) { if (cleared) {
showToast("🗑️ Logs gelöscht") showToast(getString(R.string.toast_logs_deleted))
} else { } else {
showToast("📭 Keine Logs zum Löschen") showToast(getString(R.string.toast_no_logs_to_delete))
} }
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "Failed to clear logs", e) 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 ?: ""))
} }
} }
@@ -511,7 +513,7 @@ class SettingsActivity : AppCompatActivity() {
startActivity(intent) startActivity(intent)
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "Failed to open URL: $url", e) Logger.e(TAG, "Failed to open URL: $url", e)
showToast("❌ Fehler beim Öffnen des Links") showToast(getString(R.string.toast_link_error))
} }
} }
@@ -525,7 +527,7 @@ class SettingsActivity : AppCompatActivity() {
// 🔥 v1.1.2: Validate HTTP URL (only allow for local networks) // 🔥 v1.1.2: Validate HTTP URL (only allow for local networks)
if (fullUrl.isNotEmpty()) { if (fullUrl.isNotEmpty()) {
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(fullUrl) val (isValid, errorMessage) = UrlValidator.validateHttpUrl(this, fullUrl)
if (!isValid) { if (!isValid) {
// Only show error in TextField (no Toast) // Only show error in TextField (no Toast)
textInputLayoutServerUrl.isErrorEnabled = true textInputLayoutServerUrl.isErrorEnabled = true
@@ -553,7 +555,7 @@ class SettingsActivity : AppCompatActivity() {
// 🔥 v1.1.2: Validate before testing // 🔥 v1.1.2: Validate before testing
if (fullUrl.isNotEmpty()) { if (fullUrl.isNotEmpty()) {
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(fullUrl) val (isValid, errorMessage) = UrlValidator.validateHttpUrl(this, fullUrl)
if (!isValid) { if (!isValid) {
// Only show error in TextField (no Toast) // Only show error in TextField (no Toast)
textInputLayoutServerUrl.isErrorEnabled = true textInputLayoutServerUrl.isErrorEnabled = true
@@ -647,7 +649,7 @@ class SettingsActivity : AppCompatActivity() {
return return
} }
textViewServerStatus.text = "🔍 Prüfe..." textViewServerStatus.text = getString(R.string.status_checking)
textViewServerStatus.setTextColor(getColor(android.R.color.darker_gray)) textViewServerStatus.setTextColor(getColor(android.R.color.darker_gray))
lifecycleScope.launch { lifecycleScope.launch {
@@ -804,12 +806,12 @@ class SettingsActivity : AppCompatActivity() {
.setMessage( .setMessage(
"Damit die App im Hintergrund synchronisieren kann, " + "Damit die App im Hintergrund synchronisieren kann, " +
"muss die Akku-Optimierung deaktiviert werden.\n\n" + "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() openBatteryOptimizationSettings()
} }
.setNegativeButton("Später") { dialog, _ -> .setNegativeButton(getString(R.string.battery_optimization_later)) { dialog, _ ->
dialog.dismiss() dialog.dismiss()
} }
.setCancelable(false) .setCancelable(false)
@@ -916,20 +918,20 @@ class SettingsActivity : AppCompatActivity() {
// Radio Buttons erstellen // Radio Buttons erstellen
val radioMerge = android.widget.RadioButton(this).apply { 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() id = android.view.View.generateViewId()
isChecked = true isChecked = true
setPadding(10, 10, 10, 10) setPadding(10, 10, 10, 10)
} }
val radioReplace = android.widget.RadioButton(this).apply { 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() id = android.view.View.generateViewId()
setPadding(10, 10, 10, 10) setPadding(10, 10, 10, 10)
} }
val radioOverwrite = android.widget.RadioButton(this).apply { 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() id = android.view.View.generateViewId()
setPadding(10, 10, 10, 10) setPadding(10, 10, 10, 10)
} }
@@ -979,7 +981,7 @@ class SettingsActivity : AppCompatActivity() {
RestoreSource.WEBDAV_SERVER -> performRestoreFromServer(selectedMode) RestoreSource.WEBDAV_SERVER -> performRestoreFromServer(selectedMode)
} }
} }
.setNegativeButton("Abbrechen", null) .setNegativeButton(getString(R.string.cancel), null)
.show() .show()
} }

View File

@@ -0,0 +1,177 @@
package dev.dettmer.simplenotes.adapters
import android.graphics.Paint
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.ImageButton
import android.widget.ImageView
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.checkbox.MaterialCheckBox
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.ChecklistItem
/**
* Adapter für die Bearbeitung von Checklist-Items im Editor
*
* v1.4.0: Checklisten-Feature
*/
class ChecklistEditorAdapter(
private val items: MutableList<ChecklistItem>,
private val onItemCheckedChanged: (Int, Boolean) -> Unit,
private val onItemTextChanged: (Int, String) -> Unit,
private val onItemDeleted: (Int) -> Unit,
private val onAddNewItem: (Int) -> Unit,
private val onStartDrag: (RecyclerView.ViewHolder) -> Unit
) : RecyclerView.Adapter<ChecklistEditorAdapter.ViewHolder>() {
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val dragHandle: ImageView = view.findViewById(R.id.ivDragHandle)
val checkbox: MaterialCheckBox = view.findViewById(R.id.cbItem)
val editText: EditText = view.findViewById(R.id.etItemText)
val deleteButton: ImageButton = view.findViewById(R.id.btnDeleteItem)
private var textWatcher: TextWatcher? = null
@Suppress("NestedBlockDepth", "UNUSED_PARAMETER")
fun bind(item: ChecklistItem, position: Int) {
// Vorherigen TextWatcher entfernen um Loops zu vermeiden
textWatcher?.let { editText.removeTextChangedListener(it) }
// Checkbox
checkbox.isChecked = item.isChecked
checkbox.setOnCheckedChangeListener { _, isChecked ->
onItemCheckedChanged(bindingAdapterPosition, isChecked)
updateStrikethrough(isChecked)
}
// Text
editText.setText(item.text)
updateStrikethrough(item.isChecked)
// v1.4.1: TextWatcher für Änderungen + Enter-Erkennung für neues Item
textWatcher = object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
val pos = bindingAdapterPosition
if (pos == RecyclerView.NO_POSITION) return
val text = s?.toString() ?: ""
// Prüfe ob ein Newline eingegeben wurde
if (text.contains("\n")) {
// Newline entfernen und neues Item erstellen
val cleanText = text.replace("\n", "")
editText.setText(cleanText)
editText.setSelection(cleanText.length)
onItemTextChanged(pos, cleanText)
onAddNewItem(pos + 1)
} else {
onItemTextChanged(pos, text)
}
}
}
editText.addTextChangedListener(textWatcher)
// Delete Button
deleteButton.setOnClickListener {
val pos = bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) {
onItemDeleted(pos)
}
}
// Drag Handle Touch Listener
dragHandle.setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
onStartDrag(this)
}
false
}
}
private fun updateStrikethrough(isChecked: Boolean) {
if (isChecked) {
editText.paintFlags = editText.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
editText.alpha = CHECKED_ITEM_ALPHA
} else {
editText.paintFlags = editText.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv()
editText.alpha = UNCHECKED_ITEM_ALPHA
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_checklist_editor, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position], position)
}
override fun getItemCount(): Int = items.size
/**
* Bewegt ein Item von einer Position zu einer anderen (für Drag & Drop)
*/
fun moveItem(fromPosition: Int, toPosition: Int) {
val item = items.removeAt(fromPosition)
items.add(toPosition, item)
notifyItemMoved(fromPosition, toPosition)
// Order-Werte aktualisieren
items.forEachIndexed { index, checklistItem ->
checklistItem.order = index
}
}
/**
* Entfernt ein Item an der angegebenen Position
*/
fun removeItem(position: Int) {
if (position in items.indices) {
items.removeAt(position)
notifyItemRemoved(position)
// Order-Werte aktualisieren
items.forEachIndexed { index, checklistItem ->
checklistItem.order = index
}
}
}
/**
* Fügt ein neues Item an der angegebenen Position ein
*/
fun insertItem(position: Int, item: ChecklistItem) {
items.add(position, item)
notifyItemInserted(position)
// Order-Werte aktualisieren
items.forEachIndexed { index, checklistItem ->
checklistItem.order = index
}
}
/**
* Fokussiert das EditText des Items an der angegebenen Position
*/
fun focusItem(recyclerView: RecyclerView, position: Int) {
recyclerView.post {
val viewHolder = recyclerView.findViewHolderForAdapterPosition(position) as? ViewHolder
viewHolder?.editText?.requestFocus()
}
}
companion object {
/** Alpha-Wert für abgehakte Items (durchgestrichen) */
private const val CHECKED_ITEM_ALPHA = 0.6f
/** Alpha-Wert für nicht abgehakte Items */
private const val UNCHECKED_ITEM_ALPHA = 1.0f
}
}

View File

@@ -11,11 +11,17 @@ import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.toReadableTime import dev.dettmer.simplenotes.utils.toReadableTime
import dev.dettmer.simplenotes.utils.truncate import dev.dettmer.simplenotes.utils.truncate
/**
* Adapter für die Notizen-Liste
*
* v1.4.0: Unterstützt jetzt TEXT und CHECKLIST Notizen
*/
class NotesAdapter( class NotesAdapter(
private val onNoteClick: (Note) -> Unit private val onNoteClick: (Note) -> Unit
) : ListAdapter<Note, NotesAdapter.NoteViewHolder>(NoteDiffCallback()) { ) : ListAdapter<Note, NotesAdapter.NoteViewHolder>(NoteDiffCallback()) {
@@ -31,16 +37,46 @@ class NotesAdapter(
} }
inner class NoteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { inner class NoteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val ivNoteTypeIcon: ImageView = itemView.findViewById(R.id.ivNoteTypeIcon)
private val textViewTitle: TextView = itemView.findViewById(R.id.textViewTitle) private val textViewTitle: TextView = itemView.findViewById(R.id.textViewTitle)
private val textViewContent: TextView = itemView.findViewById(R.id.textViewContent) private val textViewContent: TextView = itemView.findViewById(R.id.textViewContent)
private val textViewChecklistPreview: TextView = itemView.findViewById(R.id.textViewChecklistPreview)
private val textViewTimestamp: TextView = itemView.findViewById(R.id.textViewTimestamp) private val textViewTimestamp: TextView = itemView.findViewById(R.id.textViewTimestamp)
private val imageViewSyncStatus: ImageView = itemView.findViewById(R.id.imageViewSyncStatus) private val imageViewSyncStatus: ImageView = itemView.findViewById(R.id.imageViewSyncStatus)
fun bind(note: Note) { fun bind(note: Note) {
textViewTitle.text = note.title.ifEmpty { "Ohne Titel" } // Titel
textViewContent.text = note.content.truncate(100) textViewTitle.text = note.title.ifEmpty {
itemView.context.getString(R.string.untitled)
}
textViewTimestamp.text = note.updatedAt.toReadableTime() textViewTimestamp.text = note.updatedAt.toReadableTime()
// v1.4.0: Typ-spezifische Anzeige
when (note.noteType) {
NoteType.TEXT -> {
ivNoteTypeIcon.setImageResource(R.drawable.ic_note_24)
textViewContent.text = note.content.truncate(100)
textViewContent.visibility = View.VISIBLE
textViewChecklistPreview.visibility = View.GONE
}
NoteType.CHECKLIST -> {
ivNoteTypeIcon.setImageResource(R.drawable.ic_checklist_24)
textViewContent.visibility = View.GONE
textViewChecklistPreview.visibility = View.VISIBLE
// Fortschritt berechnen
val items = note.checklistItems ?: emptyList()
val checkedCount = items.count { it.isChecked }
val totalCount = items.size
textViewChecklistPreview.text = if (totalCount > 0) {
itemView.context.getString(R.string.checklist_progress, checkedCount, totalCount)
} else {
itemView.context.getString(R.string.empty_checklist)
}
}
}
// Sync Icon nur zeigen wenn Sync konfiguriert ist // Sync Icon nur zeigen wenn Sync konfiguriert ist
val prefs = itemView.context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) val prefs = itemView.context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)

View File

@@ -5,6 +5,7 @@ import android.net.Uri
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import dev.dettmer.simplenotes.BuildConfig import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
@@ -144,7 +145,7 @@ class BackupManager(private val context: Context) {
if (!validationResult.isValid) { if (!validationResult.isValid) {
return@withContext RestoreResult( return@withContext RestoreResult(
success = false, 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) Logger.e(TAG, "Failed to restore backup", e)
RestoreResult( RestoreResult(
success = false, 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) { if (backupData.backupVersion > BACKUP_VERSION) {
return ValidationResult( return ValidationResult(
isValid = false, isValid = false,
errorMessage = "Backup-Version nicht unterstützt " + errorMessage = context.getString(R.string.error_backup_version_unsupported, backupData.backupVersion, BACKUP_VERSION)
"(v${backupData.backupVersion} benötigt v${BACKUP_VERSION}+)"
) )
} }
@@ -196,7 +196,7 @@ class BackupManager(private val context: Context) {
if (backupData.notes.isEmpty()) { if (backupData.notes.isEmpty()) {
return ValidationResult( return ValidationResult(
isValid = false, 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()) { if (invalidNotes.isNotEmpty()) {
return ValidationResult( return ValidationResult(
isValid = false, 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) { } catch (e: Exception) {
ValidationResult( ValidationResult(
isValid = false, 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, success = true,
importedNotes = newNotes.size, importedNotes = newNotes.size,
skippedNotes = skippedNotes, 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, success = true,
importedNotes = backupNotes.size, importedNotes = backupNotes.size,
skippedNotes = 0, 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, importedNotes = newNotes.size,
skippedNotes = 0, skippedNotes = 0,
overwrittenNotes = overwrittenNotes.size, overwrittenNotes = overwrittenNotes.size,
message = "${newNotes.size} neu, ${overwrittenNotes.size} überschrieben" message = context.getString(R.string.restore_overwrite_result, newNotes.size, overwrittenNotes.size)
) )
} }

View File

@@ -0,0 +1,34 @@
package dev.dettmer.simplenotes.models
import java.util.UUID
/**
* Repräsentiert ein einzelnes Item in einer Checkliste
*
* v1.4.0: Checklisten-Feature
*
* @property id Eindeutige ID für Sync-Konflikterkennung
* @property text Der Text des Items
* @property isChecked Ob das Item abgehakt ist
* @property order Sortierreihenfolge (0-basiert)
*/
data class ChecklistItem(
val id: String = UUID.randomUUID().toString(),
val text: String = "",
var isChecked: Boolean = false,
var order: Int = 0
) {
companion object {
/**
* Erstellt ein neues leeres ChecklistItem
*/
fun createEmpty(order: Int): ChecklistItem {
return ChecklistItem(
id = UUID.randomUUID().toString(),
text = "",
isChecked = false,
order = order
)
}
}
}

View File

@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.models package dev.dettmer.simplenotes.models
import androidx.compose.runtime.Immutable
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@@ -7,6 +8,12 @@ import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
import java.util.UUID 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( data class Note(
val id: String = UUID.randomUUID().toString(), val id: String = UUID.randomUUID().toString(),
val title: String, val title: String,
@@ -14,56 +21,195 @@ data class Note(
val createdAt: Long = System.currentTimeMillis(), val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(), val updatedAt: Long = System.currentTimeMillis(),
val deviceId: String, val deviceId: String,
val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY,
// v1.4.0: Checklisten-Felder
val noteType: NoteType = NoteType.TEXT,
val checklistItems: List<ChecklistItem>? = null
) { ) {
/**
* Serialisiert Note zu JSON
* v1.4.0: Nutzt Gson für komplexe Strukturen
* v1.4.1: Für Checklisten wird ein Fallback-Content generiert, damit ältere
* App-Versionen (v1.3.x) die Notiz als Text anzeigen können.
*/
fun toJson(): String { fun toJson(): String {
return """ val gson = com.google.gson.GsonBuilder()
{ .setPrettyPrinting()
"id": "$id", .create()
"title": "${title.escapeJson()}",
"content": "${content.escapeJson()}", // v1.4.1: Für Checklisten den Fallback-Content generieren
"createdAt": $createdAt, val noteToSerialize = if (noteType == NoteType.CHECKLIST && checklistItems != null) {
"updatedAt": $updatedAt, this.copy(content = generateChecklistFallbackContent())
"deviceId": "$deviceId", } else {
"syncStatus": "${syncStatus.name}" this
} }
""".trimIndent()
return gson.toJson(noteToSerialize)
}
/**
* v1.4.1: Generiert einen lesbaren Text-Fallback aus Checklist-Items.
* Format: GitHub-Style Task-Listen (kompatibel mit Markdown)
*
* Beispiel:
* [ ] Milch kaufen
* [x] Brot gekauft
* [ ] Eier
*
* Wird von älteren App-Versionen (v1.3.x) als normaler Text angezeigt.
*/
private fun generateChecklistFallbackContent(): String {
return checklistItems?.sortedBy { it.order }?.joinToString("\n") { item ->
val checkbox = if (item.isChecked) "[x]" else "[ ]"
"$checkbox ${item.text}"
} ?: ""
} }
/** /**
* Konvertiert Note zu Markdown mit YAML Frontmatter (Task #1.2.0-08) * Konvertiert Note zu Markdown mit YAML Frontmatter (Task #1.2.0-08)
* Format kompatibel mit Obsidian, Joplin, Typora * Format kompatibel mit Obsidian, Joplin, Typora
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
*/ */
fun toMarkdown(): String { fun toMarkdown(): String {
return """ val header = """
--- ---
id: $id id: $id
created: ${formatISO8601(createdAt)} created: ${formatISO8601(createdAt)}
updated: ${formatISO8601(updatedAt)} updated: ${formatISO8601(updatedAt)}
device: $deviceId device: $deviceId
type: ${noteType.name.lowercase()}
--- ---
# $title # $title
$content """.trimIndent()
""".trimIndent()
return when (noteType) {
NoteType.TEXT -> header + content
NoteType.CHECKLIST -> {
val checklistMarkdown = checklistItems?.sortedBy { it.order }?.joinToString("\n") { item ->
val checkbox = if (item.isChecked) "[x]" else "[ ]"
"- $checkbox ${item.text}"
} ?: ""
header + checklistMarkdown
}
}
} }
companion object { companion object {
private const val TAG = "Note" private const val TAG = "Note"
/**
* Parst JSON zu Note-Objekt mit Backward Compatibility für alte Notizen ohne noteType
*/
fun fromJson(json: String): Note? { fun fromJson(json: String): Note? {
return try { return try {
val gson = com.google.gson.Gson() val gson = com.google.gson.Gson()
gson.fromJson(json, Note::class.java) val jsonObject = com.google.gson.JsonParser.parseString(json).asJsonObject
// Backward Compatibility: Alte Notizen ohne noteType bekommen TEXT
val noteType = if (jsonObject.has("noteType") && !jsonObject.get("noteType").isJsonNull) {
try {
NoteType.valueOf(jsonObject.get("noteType").asString)
} catch (e: Exception) {
Logger.w(TAG, "Unknown noteType, defaulting to TEXT: ${e.message}")
NoteType.TEXT
}
} else {
NoteType.TEXT
}
// Parsen der Basis-Note
val rawNote = gson.fromJson(json, NoteRaw::class.java)
// Checklist-Items parsen (kann null sein)
val checklistItemsType = object : com.google.gson.reflect.TypeToken<List<ChecklistItem>>() {}.type
var checklistItems: List<ChecklistItem>? = if (jsonObject.has("checklistItems") &&
!jsonObject.get("checklistItems").isJsonNull
) {
gson.fromJson<List<ChecklistItem>>(
jsonObject.get("checklistItems"),
checklistItemsType
)
} else {
null
}
// v1.4.1: Recovery-Mode - Falls Checkliste aber keine Items,
// versuche Content als Fallback zu parsen
if (noteType == NoteType.CHECKLIST &&
(checklistItems == null || checklistItems.isEmpty()) &&
rawNote.content.isNotBlank()) {
val recoveredItems = parseChecklistFromContent(rawNote.content)
if (recoveredItems.isNotEmpty()) {
Logger.d(TAG, "🔄 Recovered ${recoveredItems.size} checklist items from content fallback")
checklistItems = recoveredItems
}
}
// Note mit korrekten Werten erstellen
Note(
id = rawNote.id,
title = rawNote.title,
content = rawNote.content,
createdAt = rawNote.createdAt,
updatedAt = rawNote.updatedAt,
deviceId = rawNote.deviceId,
syncStatus = rawNote.syncStatus ?: SyncStatus.LOCAL_ONLY,
noteType = noteType,
checklistItems = checklistItems
)
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(TAG, "Failed to parse JSON: ${e.message}") Logger.w(TAG, "Failed to parse JSON: ${e.message}")
null null
} }
} }
/**
* Hilfsklasse für Gson-Parsing mit nullable Feldern
*/
private data class NoteRaw(
val id: String = UUID.randomUUID().toString(),
val title: String = "",
val content: String = "",
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
val deviceId: String = "",
val syncStatus: SyncStatus? = null
)
/**
* v1.4.1: Parst GitHub-Style Checklisten aus Text (Recovery-Mode).
*
* Unterstützte Formate:
* - [ ] Unchecked item
* - [x] Checked item
* - [X] Checked item (case insensitive)
*
* Wird verwendet, wenn eine v1.4.0 Checkliste von einer älteren
* App-Version (v1.3.x) bearbeitet wurde und die checklistItems verloren gingen.
*
* @param content Der Text-Content der Notiz
* @return Liste von ChecklistItems oder leere Liste
*/
private fun parseChecklistFromContent(content: String): List<ChecklistItem> {
val pattern = Regex("""^\s*\[([ xX])\]\s*(.+)$""", RegexOption.MULTILINE)
return pattern.findAll(content).mapIndexed { index, match ->
val checked = match.groupValues[1].lowercase() == "x"
val text = match.groupValues[2].trim()
ChecklistItem(
id = UUID.randomUUID().toString(),
text = text,
isChecked = checked,
order = index
)
}.toList()
}
/** /**
* Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09) * Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09)
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
* *
* @param md Markdown-String mit YAML Frontmatter * @param md Markdown-String mit YAML Frontmatter
* @return Note-Objekt oder null bei Parse-Fehler * @return Note-Objekt oder null bei Parse-Fehler
@@ -91,10 +237,47 @@ $content
.firstOrNull { it.startsWith("# ") } .firstOrNull { it.startsWith("# ") }
?.removePrefix("# ")?.trim() ?: "Untitled" ?.removePrefix("# ")?.trim() ?: "Untitled"
// Extract content (everything after heading) // v1.4.0: Prüfe ob type: checklist im Frontmatter
val content = contentBlock val noteTypeStr = metadata["type"]?.lowercase() ?: "text"
.substringAfter("# $title\n\n", "") val noteType = when (noteTypeStr) {
"checklist" -> NoteType.CHECKLIST
else -> NoteType.TEXT
}
// v1.4.0: Parse Content basierend auf Typ
// FIX: Robusteres Parsing - suche nach dem Titel-Header und extrahiere den Rest
val titleLineIndex = contentBlock.lines().indexOfFirst { it.startsWith("# ") }
val contentAfterTitle = if (titleLineIndex >= 0) {
// Alles nach der Titel-Zeile, überspringe führende Leerzeilen
contentBlock.lines()
.drop(titleLineIndex + 1)
.dropWhile { it.isBlank() }
.joinToString("\n")
.trim() .trim()
} else {
// Fallback: Gesamter Content (kein Titel gefunden)
contentBlock.trim()
}
val content: String
val checklistItems: List<ChecklistItem>?
if (noteType == NoteType.CHECKLIST) {
// Parse Checklist Items
val checklistRegex = Regex("^- \\[([ xX])\\] (.*)$", RegexOption.MULTILINE)
checklistItems = checklistRegex.findAll(contentAfterTitle).mapIndexed { index, matchResult ->
ChecklistItem(
id = UUID.randomUUID().toString(),
text = matchResult.groupValues[2].trim(),
isChecked = matchResult.groupValues[1].lowercase() == "x",
order = index
)
}.toList().ifEmpty { null }
content = "" // Checklisten haben keinen "content"
} else {
content = contentAfterTitle
checklistItems = null
}
Note( Note(
id = metadata["id"] ?: UUID.randomUUID().toString(), id = metadata["id"] ?: UUID.randomUUID().toString(),
@@ -103,7 +286,9 @@ $content
createdAt = parseISO8601(metadata["created"] ?: ""), createdAt = parseISO8601(metadata["created"] ?: ""),
updatedAt = parseISO8601(metadata["updated"] ?: ""), updatedAt = parseISO8601(metadata["updated"] ?: ""),
deviceId = metadata["device"] ?: "desktop", deviceId = metadata["device"] ?: "desktop",
syncStatus = SyncStatus.SYNCED // Annahme: Vom Server importiert syncStatus = SyncStatus.SYNCED, // Annahme: Vom Server importiert
noteType = noteType,
checklistItems = checklistItems
) )
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(TAG, "Failed to parse Markdown: ${e.message}") Logger.w(TAG, "Failed to parse Markdown: ${e.message}")

View File

@@ -0,0 +1,11 @@
package dev.dettmer.simplenotes.models
/**
* Definiert die verschiedenen Notiz-Typen
*
* v1.4.0: Checklisten-Feature
*/
enum class NoteType {
TEXT, // Normale Text-Notiz (Standard)
CHECKLIST // Checkliste mit abhakbaren Items
}

View File

@@ -9,6 +9,7 @@ import dev.dettmer.simplenotes.utils.Logger
/** /**
* BootReceiver: Startet WorkManager nach Device Reboot * BootReceiver: Startet WorkManager nach Device Reboot
* CRITICAL: Ohne diesen Receiver funktioniert Auto-Sync nach Reboot NICHT! * CRITICAL: Ohne diesen Receiver funktioniert Auto-Sync nach Reboot NICHT!
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_BOOT
*/ */
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
@@ -24,16 +25,22 @@ class BootReceiver : BroadcastReceiver() {
Logger.d(TAG, "📱 BOOT_COMPLETED received") Logger.d(TAG, "📱 BOOT_COMPLETED received")
// Prüfe ob Auto-Sync aktiviert ist
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
if (!autoSyncEnabled) { // 🌟 v1.6.0: Check if Boot trigger is enabled
Logger.d(TAG, "❌ Auto-sync disabled - not starting WorkManager") if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, Constants.DEFAULT_TRIGGER_BOOT)) {
Logger.d(TAG, "⏭️ Boot sync disabled - not starting WorkManager")
return return
} }
Logger.d(TAG, "🚀 Auto-sync enabled - starting WorkManager") // Check if server is configured
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
Logger.d(TAG, "⏭️ Offline mode - not starting WorkManager")
return
}
Logger.d(TAG, "🚀 Boot sync enabled - starting WorkManager")
// WorkManager neu starten // WorkManager neu starten
val networkMonitor = NetworkMonitor(context.applicationContext) val networkMonitor = NetworkMonitor(context.applicationContext)

View File

@@ -102,8 +102,22 @@ class NetworkMonitor(private val context: Context) {
/** /**
* Triggert WiFi-Connect Sync via WorkManager * Triggert WiFi-Connect Sync via WorkManager
* WorkManager wacht App auf (funktioniert auch wenn App geschlossen!) * WorkManager wacht App auf (funktioniert auch wenn App geschlossen!)
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_WIFI_CONNECT
*/ */
private fun triggerWifiConnectSync() { private fun triggerWifiConnectSync() {
// 🌟 v1.6.0: Check if WiFi-Connect trigger is enabled
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)) {
Logger.d(TAG, "⏭️ WiFi-Connect sync disabled - skipping")
return
}
// Check if server is configured
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
Logger.d(TAG, "⏭️ Offline mode - skipping WiFi-Connect sync")
return
}
Logger.d(TAG, "📡 Scheduling WiFi-Connect sync via WorkManager") Logger.d(TAG, "📡 Scheduling WiFi-Connect sync via WorkManager")
// 🔥 WICHTIG: NetworkType.UNMETERED constraint! // 🔥 WICHTIG: NetworkType.UNMETERED constraint!
@@ -148,8 +162,25 @@ class NetworkMonitor(private val context: Context) {
/** /**
* Startet WorkManager periodic sync * Startet WorkManager periodic sync
* 🔥 Interval aus SharedPrefs konfigurierbar (15/30/60 min) * 🔥 Interval aus SharedPrefs konfigurierbar (15/30/60 min)
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_PERIODIC
*/ */
private fun startPeriodicSync() { private fun startPeriodicSync() {
// 🌟 v1.6.0: Check if Periodic trigger is enabled
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)) {
Logger.d(TAG, "⏭️ Periodic sync disabled - skipping")
// Cancel existing periodic work if disabled
WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
return
}
// Check if server is configured
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
Logger.d(TAG, "⏭️ Offline mode - skipping Periodic sync")
WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
return
}
// 🔥 Interval aus SharedPrefs lesen // 🔥 Interval aus SharedPrefs lesen
val intervalMinutes = prefs.getLong( val intervalMinutes = prefs.getLong(
Constants.PREF_SYNC_INTERVAL_MINUTES, Constants.PREF_SYNC_INTERVAL_MINUTES,

View File

@@ -19,7 +19,8 @@ object SyncStateManager {
*/ */
enum class SyncState { enum class SyncState {
IDLE, // Kein Sync aktiv 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) COMPLETED, // Sync erfolgreich abgeschlossen (kurz anzeigen)
ERROR // Sync fehlgeschlagen (kurz anzeigen) ERROR // Sync fehlgeschlagen (kurz anzeigen)
} }
@@ -31,6 +32,7 @@ object SyncStateManager {
val state: SyncState = SyncState.IDLE, val state: SyncState = SyncState.IDLE,
val message: String? = null, val message: String? = null,
val source: String? = null, // "manual", "auto", "pullToRefresh", "background" 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() val timestamp: Long = System.currentTimeMillis()
) )
@@ -44,28 +46,35 @@ object SyncStateManager {
private val lock = Any() 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 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. * 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 * @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) { synchronized(lock) {
if (isSyncing) { if (isSyncing) {
Logger.d(TAG, "⚠️ Sync already in progress, rejecting new sync from: $source") Logger.d(TAG, "⚠️ Sync already in progress, rejecting new sync from: $source")
return false 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.postValue(
SyncStatus( SyncStatus(
state = SyncState.SYNCING, state = syncState,
message = "Synchronisiere...", message = "Synchronisiere...",
source = source source = source,
silent = silent // v1.5.0: Merkt sich ob silent für markCompleted()
) )
) )
return true return true
@@ -74,11 +83,21 @@ object SyncStateManager {
/** /**
* Markiert Sync als erfolgreich abgeschlossen * Markiert Sync als erfolgreich abgeschlossen
* v1.5.0: Bei Silent-Sync direkt auf IDLE wechseln (kein Banner)
*/ */
fun markCompleted(message: String? = null) { fun markCompleted(message: String? = null) {
synchronized(lock) { synchronized(lock) {
val currentSource = _syncStatus.value?.source val current = _syncStatus.value
Logger.d(TAG, "✅ Sync completed from: $currentSource") 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.postValue(
SyncStatus( SyncStatus(
state = SyncState.COMPLETED, state = SyncState.COMPLETED,
@@ -88,6 +107,7 @@ object SyncStateManager {
) )
} }
} }
}
/** /**
* Markiert Sync als fehlgeschlagen * Markiert Sync als fehlgeschlagen

View File

@@ -1,5 +1,8 @@
@file:Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional, will migrate in v2.0.0
package dev.dettmer.simplenotes.sync package dev.dettmer.simplenotes.sync
import android.app.ActivityManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
@@ -22,6 +25,21 @@ class SyncWorker(
const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED" const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED"
} }
/**
* Prüft ob die App im Vordergrund ist.
* Wenn ja, brauchen wir keine Benachrichtigung - die UI zeigt die Änderungen direkt.
*/
private fun isAppInForeground(): Boolean {
val activityManager = applicationContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val appProcesses = activityManager.runningAppProcesses ?: return false
val packageName = applicationContext.packageName
return appProcesses.any { process ->
process.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND &&
process.processName == packageName
}
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Logger.d(TAG, "═══════════════════════════════════════") Logger.d(TAG, "═══════════════════════════════════════")
@@ -131,7 +149,12 @@ class SyncWorker(
Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes") Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes")
// Nur Notification zeigen wenn tatsächlich etwas gesynct wurde // Nur Notification zeigen wenn tatsächlich etwas gesynct wurde
// UND die App nicht im Vordergrund ist (sonst sieht User die Änderungen direkt)
if (result.syncedCount > 0) { if (result.syncedCount > 0) {
val appInForeground = isAppInForeground()
if (appInForeground) {
Logger.d(TAG, " App in foreground - skipping notification (UI shows changes)")
} else {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Logger.d(TAG, " Showing success notification...") Logger.d(TAG, " Showing success notification...")
} }
@@ -139,6 +162,7 @@ class SyncWorker(
applicationContext, applicationContext,
result.syncedCount result.syncedCount
) )
}
} else { } else {
Logger.d(TAG, " No changes to sync - no notification") Logger.d(TAG, " No changes to sync - no notification")
} }
@@ -233,6 +257,7 @@ class SyncWorker(
/** /**
* Sendet Broadcast an MainActivity für UI Refresh * Sendet Broadcast an MainActivity für UI Refresh
*/ */
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but still functional, will migrate in v2.0.0
private fun broadcastSyncCompleted(success: Boolean, count: Int) { private fun broadcastSyncCompleted(success: Boolean, count: Int) {
val intent = Intent(ACTION_SYNC_COMPLETED).apply { val intent = Intent(ACTION_SYNC_COMPLETED).apply {
putExtra("success", success) putExtra("success", success)

View File

@@ -6,6 +6,7 @@ import android.net.NetworkCapabilities
import com.thegrizzlylabs.sardineandroid.Sardine import com.thegrizzlylabs.sardineandroid.Sardine
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
import dev.dettmer.simplenotes.BuildConfig import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.DeletionTracker import dev.dettmer.simplenotes.models.DeletionTracker
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.models.SyncStatus
@@ -34,6 +35,8 @@ data class ManualMarkdownSyncResult(
val importedCount: Int val importedCount: Int
) )
@Suppress("LargeClass")
// TODO v2.0.0: Split into SyncOrchestrator, NoteUploader, NoteDownloader, ConflictResolver
class WebDavSyncService(private val context: Context) { class WebDavSyncService(private val context: Context) {
companion object { companion object {
@@ -41,6 +44,7 @@ class WebDavSyncService(private val context: Context) {
private const val SOCKET_TIMEOUT_MS = 2000 private const val SOCKET_TIMEOUT_MS = 2000
private const val MAX_FILENAME_LENGTH = 200 private const val MAX_FILENAME_LENGTH = 200
private const val ETAG_PREVIEW_LENGTH = 8 private const val ETAG_PREVIEW_LENGTH = 8
private const val CONTENT_PREVIEW_LENGTH = 50
// 🔒 v1.3.1: Mutex um parallele Syncs zu verhindern // 🔒 v1.3.1: Mutex um parallele Syncs zu verhindern
private val syncMutex = Mutex() private val syncMutex = Mutex()
@@ -134,6 +138,7 @@ class WebDavSyncService(private val context: Context) {
Logger.d(TAG, "✅ Network is WiFi, searching for interface...") Logger.d(TAG, "✅ Network is WiFi, searching for interface...")
@Suppress("LoopWithTooManyJumpStatements") // Network interface filtering requires multiple conditions
// Finde WiFi Interface // Finde WiFi Interface
val interfaces = NetworkInterface.getNetworkInterfaces() val interfaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) { while (interfaces.hasMoreElements()) {
@@ -778,6 +783,8 @@ class WebDavSyncService(private val context: Context) {
} }
} }
@Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
// Sync logic requires nested conditions for comprehensive error handling and state management
private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int { private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int {
var uploadedCount = 0 var uploadedCount = 0
val localNotes = storage.loadAllNotes() val localNotes = storage.loadAllNotes()
@@ -858,8 +865,31 @@ class WebDavSyncService(private val context: Context) {
} }
// Sanitize Filename (Task #1.2.0-12) // Sanitize Filename (Task #1.2.0-12)
val filename = sanitizeFilename(note.title) + ".md" val baseFilename = sanitizeFilename(note.title)
val noteUrl = "$mdUrl/$filename" var filename = "$baseFilename.md"
var noteUrl = "$mdUrl/$filename"
// Prüfe ob Datei bereits existiert und von anderer Note stammt
try {
if (sardine.exists(noteUrl)) {
// Lese existierende Datei und prüfe ID im YAML-Header
val existingContent = sardine.get(noteUrl).bufferedReader().use { it.readText() }
val existingIdMatch = Regex("^---\\n.*?\\nid:\\s*([a-f0-9-]+)", RegexOption.DOT_MATCHES_ALL)
.find(existingContent)
val existingId = existingIdMatch?.groupValues?.get(1)
if (existingId != null && existingId != note.id) {
// Andere Note hat gleichen Titel - verwende ID-Suffix
val shortId = note.id.take(8)
filename = "${baseFilename}_$shortId.md"
noteUrl = "$mdUrl/$filename"
Logger.d(TAG, "📝 Duplicate title, using: $filename")
}
}
} catch (e: Exception) {
Logger.w(TAG, "⚠️ Could not check existing file: ${e.message}")
// Continue with default filename
}
// Konvertiere zu Markdown // Konvertiere zu Markdown
val mdContent = note.toMarkdown().toByteArray() val mdContent = note.toMarkdown().toByteArray()
@@ -884,6 +914,29 @@ class WebDavSyncService(private val context: Context) {
.trim('_', ' ') // Trim Underscores/Spaces .trim('_', ' ') // Trim Underscores/Spaces
} }
/**
* Generiert eindeutigen Markdown-Dateinamen für eine Notiz.
* Bei Duplikaten wird die Note-ID als Suffix angehängt.
*
* @param note Die Notiz
* @param usedFilenames Set der bereits verwendeten Dateinamen (ohne .md)
* @return Eindeutiger Dateiname (ohne .md Extension)
*/
private fun getUniqueMarkdownFilename(note: Note, usedFilenames: MutableSet<String>): String {
val baseFilename = sanitizeFilename(note.title)
return if (usedFilenames.contains(baseFilename)) {
// Duplikat - hänge gekürzte ID an
val shortId = note.id.take(8)
val uniqueFilename = "${baseFilename}_$shortId"
usedFilenames.add(uniqueFilename)
uniqueFilename
} else {
usedFilenames.add(baseFilename)
baseFilename
}
}
/** /**
* Exportiert ALLE lokalen Notizen als Markdown (Initial-Export) * Exportiert ALLE lokalen Notizen als Markdown (Initial-Export)
* *
@@ -927,6 +980,9 @@ class WebDavSyncService(private val context: Context) {
val totalCount = allNotes.size val totalCount = allNotes.size
var exportedCount = 0 var exportedCount = 0
// Track used filenames to handle duplicates
val usedFilenames = mutableSetOf<String>()
Logger.d(TAG, "📝 Found $totalCount notes to export") Logger.d(TAG, "📝 Found $totalCount notes to export")
allNotes.forEachIndexed { index, note -> allNotes.forEachIndexed { index, note ->
@@ -934,8 +990,8 @@ class WebDavSyncService(private val context: Context) {
// Progress-Callback // Progress-Callback
onProgress(index + 1, totalCount) onProgress(index + 1, totalCount)
// Sanitize Filename // Eindeutiger Filename (mit Duplikat-Handling)
val filename = sanitizeFilename(note.title) + ".md" val filename = getUniqueMarkdownFilename(note, usedFilenames) + ".md"
val noteUrl = "$mdUrl/$filename" val noteUrl = "$mdUrl/$filename"
// Konvertiere zu Markdown // Konvertiere zu Markdown
@@ -945,7 +1001,7 @@ class WebDavSyncService(private val context: Context) {
sardine.put(noteUrl, mdContent, "text/markdown") sardine.put(noteUrl, mdContent, "text/markdown")
exportedCount++ exportedCount++
Logger.d(TAG, " ✅ Exported [${index + 1}/$totalCount]: ${note.title}") Logger.d(TAG, " ✅ Exported [${index + 1}/$totalCount]: ${note.title} -> $filename")
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "❌ Failed to export ${note.title}: ${e.message}") Logger.e(TAG, "❌ Failed to export ${note.title}: ${e.message}")
@@ -971,6 +1027,8 @@ class WebDavSyncService(private val context: Context) {
val conflictCount: Int val conflictCount: Int
) )
@Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
// Sync logic requires nested conditions for comprehensive error handling and conflict resolution
private fun downloadRemoteNotes( private fun downloadRemoteNotes(
sardine: Sardine, sardine: Sardine,
serverUrl: String, serverUrl: String,
@@ -1490,6 +1548,8 @@ class WebDavSyncService(private val context: Context) {
* *
* ⚡ v1.3.1: Performance-Optimierung - Skip unveränderte Dateien * ⚡ v1.3.1: Performance-Optimierung - Skip unveränderte Dateien
*/ */
@Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
// Import logic requires nested conditions for file validation and duplicate handling
private fun importMarkdownFiles(sardine: Sardine, serverUrl: String): Int { private fun importMarkdownFiles(sardine: Sardine, serverUrl: String): Int {
return try { return try {
Logger.d(TAG, "📝 Importing Markdown files...") Logger.d(TAG, "📝 Importing Markdown files...")
@@ -1539,13 +1599,27 @@ class WebDavSyncService(private val context: Context) {
Logger.w(TAG, " ⚠️ Failed to parse ${resource.name} - fromMarkdown returned null") Logger.w(TAG, " ⚠️ Failed to parse ${resource.name} - fromMarkdown returned null")
continue continue
} }
// v1.4.0 FIX: Validierung - leere TEXT-Notizen nicht importieren wenn lokal Content existiert
val localNote = storage.loadNote(mdNote.id)
if (mdNote.noteType == dev.dettmer.simplenotes.models.NoteType.TEXT &&
mdNote.content.isBlank() &&
localNote != null && localNote.content.isNotBlank()) {
Logger.w(
TAG,
" ⚠️ Skipping ${resource.name}: " +
"MD content empty but local has content - likely parse error!"
)
continue
}
Logger.d( Logger.d(
TAG, TAG,
" Parsed: id=${mdNote.id}, title=${mdNote.title}, " + " Parsed: id=${mdNote.id}, title=${mdNote.title}, " +
"updatedAt=${Date(mdNote.updatedAt)}" "updatedAt=${Date(mdNote.updatedAt)}, " +
"content=${mdNote.content.take(CONTENT_PREVIEW_LENGTH)}..."
) )
val localNote = storage.loadNote(mdNote.id)
Logger.d( Logger.d(
TAG, TAG,
" Local note: " + if (localNote == null) { " Local note: " + if (localNote == null) {
@@ -1700,6 +1774,9 @@ class WebDavSyncService(private val context: Context) {
* Deletes a note from the server (JSON + Markdown) * Deletes a note from the server (JSON + Markdown)
* Does NOT delete from local storage! * Does NOT delete from local storage!
* *
* v1.4.1: Now supports v1.2.0 compatibility mode - also checks ROOT folder
* for notes that were created before the /notes/ directory structure.
*
* @param noteId The ID of the note to delete * @param noteId The ID of the note to delete
* @return true if at least one file was deleted, false otherwise * @return true if at least one file was deleted, false otherwise
*/ */
@@ -1711,12 +1788,21 @@ class WebDavSyncService(private val context: Context) {
var deletedJson = false var deletedJson = false
var deletedMd = false var deletedMd = false
// Delete JSON // v1.4.1: Try to delete JSON from /notes/ first (standard path)
val jsonUrl = getNotesUrl(serverUrl) + "$noteId.json" val jsonUrl = getNotesUrl(serverUrl) + "$noteId.json"
if (sardine.exists(jsonUrl)) { if (sardine.exists(jsonUrl)) {
sardine.delete(jsonUrl) sardine.delete(jsonUrl)
deletedJson = true deletedJson = true
Logger.d(TAG, "🗑️ Deleted from server: $noteId.json") Logger.d(TAG, "🗑️ Deleted from server: $noteId.json (from /notes/)")
} else {
// v1.4.1: Fallback - check ROOT folder for v1.2.0 compatibility
val rootJsonUrl = serverUrl.trimEnd('/') + "/$noteId.json"
Logger.d(TAG, "🔍 JSON not in /notes/, checking ROOT: $rootJsonUrl")
if (sardine.exists(rootJsonUrl)) {
sardine.delete(rootJsonUrl)
deletedJson = true
Logger.d(TAG, "🗑️ Deleted from server: $noteId.json (from ROOT - v1.2.0 compat)")
}
} }
// Delete Markdown (v1.3.0: YAML-scan based approach) // Delete Markdown (v1.3.0: YAML-scan based approach)
@@ -1776,15 +1862,15 @@ class WebDavSyncService(private val context: Context) {
suspend fun manualMarkdownSync(): ManualMarkdownSyncResult = withContext(Dispatchers.IO) { suspend fun manualMarkdownSync(): ManualMarkdownSyncResult = withContext(Dispatchers.IO) {
return@withContext try { return@withContext try {
val sardine = getOrCreateSardine() val sardine = getOrCreateSardine()
?: throw SyncException("Sardine client konnte nicht erstellt werden") ?: throw SyncException(context.getString(R.string.error_sardine_client_failed))
val serverUrl = getServerUrl() 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 username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: "" val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) { 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") Logger.d(TAG, "🔄 Manual Markdown Sync START")

View File

@@ -5,12 +5,17 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.wifi.WifiManager
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/**
* WiFi-Sync BroadcastReceiver
*
* Triggert Sync wenn WiFi verbunden wird (jedes WiFi, keine SSID-Prüfung mehr)
* Die eigentliche Server-Erreichbarkeitsprüfung erfolgt im SyncWorker.
*/
class WifiSyncReceiver : BroadcastReceiver() { class WifiSyncReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
@@ -22,34 +27,24 @@ class WifiSyncReceiver : BroadcastReceiver() {
return return
} }
// Check if connected to home WiFi // Check if connected to any WiFi (SSID-Prüfung entfernt in v1.4.0)
if (isConnectedToHomeWifi(context)) { if (isConnectedToWifi(context)) {
scheduleSyncWork(context) scheduleSyncWork(context)
} }
} }
@Suppress("ReturnCount") // Early returns for WiFi validation checks /**
private fun isConnectedToHomeWifi(context: Context): Boolean { * Prüft ob ein WiFi-Netzwerk verbunden ist (beliebiges WiFi)
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) * Die Server-Erreichbarkeitsprüfung erfolgt erst im SyncWorker.
val homeSSID = prefs.getString(Constants.KEY_HOME_SSID, null) ?: return false */
private fun isConnectedToWifi(context: Context): Boolean {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
as ConnectivityManager as ConnectivityManager
val network = connectivityManager.activeNetwork ?: return false val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
return false
}
// Get current SSID
val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE)
as WifiManager
val wifiInfo = wifiManager.connectionInfo
val currentSSID = wifiInfo.ssid.replace("\"", "")
return currentSSID == homeSSID
} }
private fun scheduleSyncWork(context: Context) { private fun scheduleSyncWork(context: Context) {

View File

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

View File

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

View File

@@ -0,0 +1,381 @@
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()
// 🌟 v1.6.0: Offline mode state
val isOfflineMode by viewModel.isOfflineMode.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,
isOfflineMode = isOfflineMode,
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)
)
}
@Suppress("LongParameterList") // Compose functions commonly have many callback parameters
@Composable
private fun ChecklistEditor(
items: List<ChecklistItemState>,
scope: kotlinx.coroutines.CoroutineScope,
focusNewItemId: String?,
onTextChange: (String, String) -> Unit,
onCheckedChange: (String, Boolean) -> Unit,
onDelete: (String) -> Unit,
onAddNewItemAfter: (String) -> Unit,
onAddItemAtEnd: () -> Unit,
onMove: (Int, Int) -> Unit,
onFocusHandled: () -> Unit,
modifier: Modifier = Modifier
) {
val listState = rememberLazyListState()
val dragDropState = rememberDragDropListState(
lazyListState = listState,
scope = scope,
onMove = onMove
)
Column(modifier = modifier) {
LazyColumn(
state = listState,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
itemsIndexed(
items = items,
key = { _, item -> item.id }
) { index, item ->
val isDragging = dragDropState.draggingItemIndex == index
val elevation by animateDpAsState(
targetValue = if (isDragging) 8.dp else 0.dp,
label = "elevation"
)
val shouldFocus = item.id == focusNewItemId
// v1.5.0: Clear focus request after handling
LaunchedEffect(shouldFocus) {
if (shouldFocus) {
onFocusHandled()
}
}
ChecklistItemRow(
item = item,
onTextChange = { onTextChange(item.id, it) },
onCheckedChange = { onCheckedChange(item.id, it) },
onDelete = { onDelete(item.id) },
onAddNewItem = { onAddNewItemAfter(item.id) },
requestFocus = shouldFocus,
modifier = Modifier
.dragContainer(dragDropState, index)
.offset {
IntOffset(
0,
if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 0
)
}
.shadow(elevation, shape = RoundedCornerShape(8.dp))
.background(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(8.dp)
)
)
}
}
// Add Item Button
TextButton(
onClick = onAddItemAtEnd,
modifier = Modifier.padding(start = 8.dp)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(stringResource(R.string.add_item))
}
}
}
// v1.5.0: Local DeleteConfirmationDialog removed - now using shared component from ui/main/components/

View File

@@ -0,0 +1,445 @@
package dev.dettmer.simplenotes.ui.editor
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
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.SyncWorker
import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.utils.Constants
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)
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
// ═══════════════════════════════════════════════════════════════════════
// 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()
// 🌟 v1.6.0: Offline Mode State
private val _isOfflineMode = MutableStateFlow(
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
)
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.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: ${e.message}")
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))
// 🌟 v1.6.0: Trigger onSave Sync
triggerOnSaveSync()
_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
// ═══════════════════════════════════════════════════════════════════════════
// 🌟 v1.6.0: Sync Trigger - onSave
// ═══════════════════════════════════════════════════════════════════════════
/**
* Triggers sync after saving a note (if enabled and server configured)
* v1.6.0: New configurable sync trigger
*
* Separate throttling (5 seconds) to prevent spam when saving multiple times
*/
private fun triggerOnSaveSync() {
// Check 1: Trigger enabled?
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, Constants.DEFAULT_TRIGGER_ON_SAVE)) {
Logger.d(TAG, "⏭️ onSave sync disabled - skipping")
return
}
// Check 2: Server configured?
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
Logger.d(TAG, "⏭️ Offline mode - skipping onSave sync")
return
}
// Check 3: Throttling (5 seconds) to prevent spam
val lastOnSaveSyncTime = prefs.getLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, 0)
val now = System.currentTimeMillis()
val timeSinceLastSync = now - lastOnSaveSyncTime
if (timeSinceLastSync < Constants.MIN_ON_SAVE_SYNC_INTERVAL_MS) {
val remainingSeconds = (Constants.MIN_ON_SAVE_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
Logger.d(TAG, "⏳ onSave sync throttled - wait ${remainingSeconds}s")
return
}
// Update last sync time
prefs.edit().putLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, now).apply()
// Trigger sync via WorkManager
Logger.d(TAG, "📤 Triggering onSave sync")
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.addTag(Constants.SYNC_WORK_TAG)
.build()
WorkManager.getInstance(getApplication()).enqueue(syncRequest)
}
}
// ═══════════════════════════════════════════════════════════════════════════
// State Classes
// ═══════════════════════════════════════════════════════════════════════════
data class NoteEditorUiState(
val title: String = "",
val content: String = "",
val noteType: NoteType = NoteType.TEXT,
val isNewNote: Boolean = true,
val toolbarTitle: ToolbarTitle = ToolbarTitle.NEW_NOTE
)
data class ChecklistItemState(
val id: String = UUID.randomUUID().toString(),
val text: String = "",
val isChecked: Boolean = false,
val order: Int = 0
) {
companion object {
fun createEmpty(order: Int): ChecklistItemState {
return ChecklistItemState(
id = UUID.randomUUID().toString(),
text = "",
isChecked = false,
order = order
)
}
}
}
enum class ToolbarTitle {
NEW_NOTE,
EDIT_NOTE,
NEW_CHECKLIST,
EDIT_CHECKLIST
}
enum class ToastMessage {
NOTE_IS_EMPTY,
NOTE_SAVED,
NOTE_DELETED
}
sealed interface NoteEditorEvent {
data class ShowToast(val message: ToastMessage) : NoteEditorEvent
data object NavigateBack : NoteEditorEvent
data object ShowDeleteConfirmation : NoteEditorEvent
}

View File

@@ -0,0 +1,179 @@
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
@Suppress("MagicNumber") // UI padding values are self-explanatory
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Drag Handle
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = stringResource(R.string.drag_to_reorder),
modifier = Modifier
.size(24.dp)
.alpha(0.5f),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(4.dp))
// Checkbox
Checkbox(
checked = item.isChecked,
onCheckedChange = onCheckedChange,
modifier = Modifier.alpha(alpha)
)
Spacer(modifier = Modifier.width(4.dp))
// Text Input with placeholder
BasicTextField(
value = textFieldValue,
onValueChange = { newValue ->
// Check for newline (Enter key)
if (newValue.text.contains("\n")) {
val cleanText = newValue.text.replace("\n", "")
textFieldValue = TextFieldValue(
text = cleanText,
selection = TextRange(cleanText.length)
)
onTextChange(cleanText)
onAddNewItem()
} else {
textFieldValue = newValue
onTextChange(newValue.text)
}
},
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester)
.alpha(alpha),
textStyle = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurface,
textDecoration = textDecoration
),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { onAddNewItem() }
),
singleLine = false,
maxLines = 5,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box {
if (textFieldValue.text.isEmpty()) {
Text(
text = stringResource(R.string.item_placeholder),
style = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
)
}
innerTextField()
}
}
)
Spacer(modifier = Modifier.width(4.dp))
// Delete Button
IconButton(
onClick = onDelete,
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.delete_item),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
}
}

View File

@@ -0,0 +1,397 @@
@file:Suppress("DEPRECATION") // LocalBroadcastManager & deprecated lifecycle methods, will migrate in v2.0.0
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")
// 🌟 v1.6.0: Refresh offline mode state FIRST (before any sync checks)
// This ensures UI reflects current offline mode when returning from Settings
viewModel.refreshOfflineModeState()
// Register BroadcastReceiver for Background-Sync
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional
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
@Suppress("DEPRECATION")
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
Logger.d(TAG, "📡 BroadcastReceiver unregistered")
}
private fun setupSyncStateObserver() {
SyncStateManager.syncStatus.observe(this) { status ->
viewModel.updateSyncState(status)
@Suppress("MagicNumber") // UI timing delays for banner visibility
// 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()
}
}
@Deprecated("Deprecated in API 23", ReplaceWith("Use ActivityResultContracts"))
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
REQUEST_NOTIFICATION_PERMISSION -> {
if (grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Toast.makeText(this, getString(R.string.toast_notifications_enabled), Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this,
getString(R.string.toast_notifications_disabled),
Toast.LENGTH_SHORT
).show()
}
}
}
}
}
/**
* Delete confirmation dialog
*/
@Composable
private fun DeleteConfirmationDialog(
noteTitle: String,
onDismiss: () -> Unit,
onDeleteLocal: () -> Unit,
onDeleteFromServer: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.legacy_delete_dialog_title)) },
text = {
Text(stringResource(R.string.legacy_delete_dialog_message, noteTitle))
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
},
confirmButton = {
TextButton(onClick = onDeleteLocal) {
Text(stringResource(R.string.delete_local_only))
}
TextButton(onClick = onDeleteFromServer) {
Text(stringResource(R.string.legacy_delete_from_server))
}
}
)
}

View File

@@ -0,0 +1,333 @@
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()
// 🌟 v1.6.0: Reactive offline mode state
val isOfflineMode by viewModel.isOfflineMode.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
// 🌟 v1.6.0: Reactive sync availability (recomposes when offline mode changes)
// Note: isOfflineMode is updated via StateFlow from MainViewModel.refreshOfflineModeState()
// which is called in ComposeMainActivity.onResume() when returning from Settings
val hasServerConfig = viewModel.hasServerConfig()
val isSyncAvailable = !isOfflineMode && hasServerConfig
val canSync = isSyncAvailable && !isSyncing
// 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 = canSync,
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 ->
// 🌟 v1.6.0: PullToRefreshBox only enabled when sync available
PullToRefreshBox(
isRefreshing = isSyncing,
onRefresh = { if (isSyncAvailable) 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,
isOfflineMode = isOfflineMode,
onDismiss = { showBatchDeleteDialog = false },
onDeleteLocal = {
viewModel.deleteSelectedNotes(deleteFromServer = false)
showBatchDeleteDialog = false
},
onDeleteEverywhere = {
viewModel.deleteSelectedNotes(deleteFromServer = true)
showBatchDeleteDialog = false
}
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MainTopBar(
syncEnabled: Boolean,
onSyncClick: () -> Unit,
onSettingsClick: () -> Unit
) {
TopAppBar(
title = {
Text(
text = stringResource(R.string.main_title),
style = MaterialTheme.typography.titleLarge
)
},
actions = {
IconButton(
onClick = onSyncClick,
enabled = syncEnabled
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = stringResource(R.string.action_sync)
)
}
IconButton(onClick = onSettingsClick) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = stringResource(R.string.action_settings)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface
)
)
}
/**
* Selection mode TopBar - shows selected count and actions
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SelectionTopBar(
selectedCount: Int,
totalCount: Int,
onCloseSelection: () -> Unit,
onSelectAll: () -> Unit,
onDeleteSelected: () -> Unit
) {
TopAppBar(
navigationIcon = {
IconButton(onClick = onCloseSelection) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.action_close_selection)
)
}
},
title = {
Text(
text = stringResource(R.string.selection_count, selectedCount),
style = MaterialTheme.typography.titleLarge
)
},
actions = {
// Select All button (only if not all selected)
if (selectedCount < totalCount) {
IconButton(onClick = onSelectAll) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = stringResource(R.string.action_select_all)
)
}
}
// Delete button
IconButton(
onClick = onDeleteSelected,
enabled = selectedCount > 0
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.action_delete_selected),
tint = if (selectedCount > 0) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
}
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
}

View File

@@ -0,0 +1,664 @@
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 dev.dettmer.simplenotes.utils.SyncConstants
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)
// ═══════════════════════════════════════════════════════════════════════
// 🌟 v1.6.0: Offline Mode State (reactive)
// ═══════════════════════════════════════════════════════════════════════
private val _isOfflineMode = MutableStateFlow(
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
)
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow()
/**
* Refresh offline mode state from SharedPreferences
* Called when returning from Settings screen (in onResume)
*/
fun refreshOfflineModeState() {
val oldValue = _isOfflineMode.value
val newValue = prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
_isOfflineMode.value = newValue
Logger.d(TAG, "🔄 refreshOfflineModeState: offlineMode=$oldValue$newValue")
}
// ═══════════════════════════════════════════════════════════════════════
// 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)
}
))
@Suppress("MagicNumber") // Snackbar timing coordination
// 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)
}
))
@Suppress("MagicNumber") // Snackbar timing
// 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) {
Logger.w(TAG, "Failed to delete note $noteId from server: ${e.message}")
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") {
// 🌟 v1.6.0: Block sync in offline mode
if (prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)) {
Logger.d(TAG, "⏭️ $source Sync blocked: Offline mode enabled")
return
}
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
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_ON_RESUME
*/
fun triggerAutoSync(source: String = "auto") {
// 🌟 v1.6.0: Check if onResume trigger is enabled
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, Constants.DEFAULT_TRIGGER_ON_RESUME)) {
Logger.d(TAG, "⏭️ onResume sync disabled - skipping")
return
}
// 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://") {
Logger.d(TAG, "⏭️ Offline mode - skipping onResume sync")
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 {
// 🌟 v1.6.0: Use reactive offline mode state
if (_isOfflineMode.value) {
return false
}
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
}
/**
* 🌟 v1.6.0: Check if server has a configured URL (ignores offline mode)
* Used for determining if sync would be available when offline mode is disabled
*/
fun hasServerConfig(): Boolean {
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
}
}

View File

@@ -0,0 +1,144 @@
package dev.dettmer.simplenotes.ui.main.components
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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
/**
* Delete confirmation dialog with server/local options
* v1.5.0: Multi-Select Feature
* v1.6.0: Offline mode support - disables server deletion option
*/
@Composable
fun DeleteConfirmationDialog(
noteCount: Int = 1,
isOfflineMode: Boolean = false,
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
// 🌟 v1.6.0: Disabled in offline mode with visual hint
TextButton(
onClick = onDeleteEverywhere,
modifier = Modifier.fillMaxWidth(),
enabled = !isOfflineMode,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error,
disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
) {
Text(stringResource(R.string.delete_everywhere))
}
// 🌟 v1.6.0: Show offline hint in a subtle Surface container
if (isOfflineMode) {
Surface(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = MaterialTheme.shapes.small
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.CloudOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = stringResource(R.string.delete_everywhere_offline_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary
)
}
}
Spacer(modifier = Modifier.height(4.dp))
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
Spacer(modifier = Modifier.height(4.dp))
}
// Delete local only
TextButton(
onClick = onDeleteLocal,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.delete_local_only))
}
Spacer(modifier = Modifier.height(8.dp))
// Cancel button
TextButton(
onClick = onDismiss,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.cancel))
}
}
},
dismissButton = null // All buttons in confirmButton column
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,99 @@
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() },
onNavigateToServerSettings = {
navController.navigate(SettingsRoute.Server.route) {
// Avoid multiple copies of server settings in back stack
launchSingleTop = true
}
}
)
}
// Markdown Settings
composable(SettingsRoute.Markdown.route) {
MarkdownSettingsScreen(
viewModel = viewModel,
onBack = { navController.popBackStack() }
)
}
// Backup Settings
composable(SettingsRoute.Backup.route) {
BackupSettingsScreen(
viewModel = viewModel,
onBack = { navController.popBackStack() }
)
}
// About Screen
composable(SettingsRoute.About.route) {
AboutScreen(
onBack = { navController.popBackStack() }
)
}
// Debug Settings
composable(SettingsRoute.Debug.route) {
DebugSettingsScreen(
viewModel = viewModel,
onBack = { navController.popBackStack() }
)
}
}
}

View File

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

View File

@@ -0,0 +1,667 @@
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.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
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, "") ?: ""
// 🌟 v1.6.0: Separate host from prefix for better UX
// isHttps determines the prefix, serverHost is the editable part
private val _isHttps = MutableStateFlow(storedUrl.startsWith("https://"))
val isHttps: StateFlow<Boolean> = _isHttps.asStateFlow()
// Extract host part (everything after http:// or https://)
private fun extractHostFromUrl(url: String): String {
return when {
url.startsWith("https://") -> url.removePrefix("https://")
url.startsWith("http://") -> url.removePrefix("http://")
else -> url
}
}
// 🌟 v1.6.0: Only the host part is editable (without protocol prefix)
private val _serverHost = MutableStateFlow(extractHostFromUrl(storedUrl))
val serverHost: StateFlow<String> = _serverHost.asStateFlow()
// 🌟 v1.6.0: Full URL for display purposes (computed from prefix + host)
val serverUrl: StateFlow<String> = combine(_isHttps, _serverHost) { https, host ->
val prefix = if (https) "https://" else "http://"
if (host.isEmpty()) "" else prefix + host
}.stateIn(viewModelScope, SharingStarted.Eagerly, storedUrl)
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()
private val _serverStatus = MutableStateFlow<ServerStatus>(ServerStatus.Unknown)
val serverStatus: StateFlow<ServerStatus> = _serverStatus.asStateFlow()
// 🌟 v1.6.0: Offline Mode Toggle
// Default: true for new users (no server), false for existing users (has server config)
private val _offlineMode = MutableStateFlow(
if (prefs.contains(Constants.KEY_OFFLINE_MODE)) {
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
} else {
// Migration: auto-detect based on existing server config
!hasExistingServerConfig()
}
)
val offlineMode: StateFlow<Boolean> = _offlineMode.asStateFlow()
private fun hasExistingServerConfig(): Boolean {
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
return !serverUrl.isNullOrEmpty() &&
serverUrl != "http://" &&
serverUrl != "https://"
}
// ═══════════════════════════════════════════════════════════════════════
// 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()
// 🌟 v1.6.0: Configurable Sync Triggers
private val _triggerOnSave = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, Constants.DEFAULT_TRIGGER_ON_SAVE)
)
val triggerOnSave: StateFlow<Boolean> = _triggerOnSave.asStateFlow()
private val _triggerOnResume = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, Constants.DEFAULT_TRIGGER_ON_RESUME)
)
val triggerOnResume: StateFlow<Boolean> = _triggerOnResume.asStateFlow()
private val _triggerWifiConnect = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)
)
val triggerWifiConnect: StateFlow<Boolean> = _triggerWifiConnect.asStateFlow()
private val _triggerPeriodic = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)
)
val triggerPeriodic: StateFlow<Boolean> = _triggerPeriodic.asStateFlow()
private val _triggerBoot = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, Constants.DEFAULT_TRIGGER_BOOT)
)
val triggerBoot: StateFlow<Boolean> = _triggerBoot.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
// ═══════════════════════════════════════════════════════════════════════
/**
* v1.6.0: Set offline mode on/off
* When enabled, all network features are disabled
*/
fun setOfflineMode(enabled: Boolean) {
_offlineMode.value = enabled
prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, enabled).apply()
if (enabled) {
_serverStatus.value = ServerStatus.OfflineMode
} else {
// Re-check server status when disabling offline mode
checkServerStatus()
}
}
fun updateServerUrl(url: String) {
// 🌟 v1.6.0: Deprecated - use updateServerHost instead
// This function is kept for compatibility but now delegates to updateServerHost
val host = extractHostFromUrl(url)
updateServerHost(host)
}
/**
* 🌟 v1.6.0: Update only the host part of the server URL
* The protocol prefix is handled separately by updateProtocol()
*/
fun updateServerHost(host: String) {
_serverHost.value = host
saveServerSettings()
}
fun updateProtocol(useHttps: Boolean) {
_isHttps.value = useHttps
// 🌟 v1.6.0: Host stays the same, only prefix changes
saveServerSettings()
}
fun updateUsername(value: String) {
_username.value = value
saveServerSettings()
}
fun updatePassword(value: String) {
_password.value = value
saveServerSettings()
}
private fun saveServerSettings() {
// 🌟 v1.6.0: Construct full URL from prefix + host
val prefix = if (_isHttps.value) "https://" else "http://"
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
prefs.edit().apply {
putString(Constants.KEY_SERVER_URL, fullUrl)
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() {
// 🌟 v1.6.0: Respect offline mode first
if (_offlineMode.value) {
_serverStatus.value = ServerStatus.OfflineMode
return
}
// 🌟 v1.6.0: Check if host is configured
val serverHost = _serverHost.value
if (serverHost.isEmpty()) {
_serverStatus.value = ServerStatus.NotConfigured
return
}
// Construct full URL
val prefix = if (_isHttps.value) "https://" else "http://"
val serverUrl = prefix + serverHost
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))
}
}
// 🌟 v1.6.0: Configurable Sync Triggers Setters
fun setTriggerOnSave(enabled: Boolean) {
_triggerOnSave.value = enabled
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, enabled).apply()
Logger.d(TAG, "Trigger onSave: $enabled")
}
fun setTriggerOnResume(enabled: Boolean) {
_triggerOnResume.value = enabled
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, enabled).apply()
Logger.d(TAG, "Trigger onResume: $enabled")
}
fun setTriggerWifiConnect(enabled: Boolean) {
_triggerWifiConnect.value = enabled
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, enabled).apply()
viewModelScope.launch {
_events.emit(SettingsEvent.RestartNetworkMonitor)
}
Logger.d(TAG, "Trigger WiFi-Connect: $enabled")
}
fun setTriggerPeriodic(enabled: Boolean) {
_triggerPeriodic.value = enabled
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, enabled).apply()
viewModelScope.launch {
_events.emit(SettingsEvent.RestartNetworkMonitor)
}
Logger.d(TAG, "Trigger Periodic: $enabled")
}
fun setTriggerBoot(enabled: Boolean) {
_triggerBoot.value = enabled
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, enabled).apply()
Logger.d(TAG, "Trigger Boot: $enabled")
}
// ═══════════════════════════════════════════════════════════════════════
// 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))
@Suppress("MagicNumber") // UI progress delay
// 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() {
// 🌟 v1.6.0: Block in offline mode
if (_offlineMode.value) {
Logger.d(TAG, "⏭️ Manual Markdown sync blocked: Offline mode enabled")
return
}
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
// ═══════════════════════════════════════════════════════════════════════
/**
* Check if server is configured AND not in offline mode
* v1.6.0: Returns false if offline mode is enabled
*/
fun isServerConfigured(): Boolean {
// Offline mode takes priority
if (_offlineMode.value) return false
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
return !serverUrl.isNullOrEmpty() &&
serverUrl != "http://" &&
serverUrl != "https://"
}
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
* v1.6.0: Added OfflineMode state
*/
sealed class ServerStatus {
data object Unknown : ServerStatus()
data object OfflineMode : ServerStatus() // 🌟 v1.6.0
data object NotConfigured : ServerStatus()
data object Checking : ServerStatus()
data object Reachable : ServerStatus()
data class Unreachable(val error: String?) : ServerStatus()
}
/**
* Events for Activity-level actions (dialogs, intents, etc.)
* v1.5.0: Ported from old SettingsActivity
*/
sealed class SettingsEvent {
data object RequestBatteryOptimization : SettingsEvent()
data object RestartNetworkMonitor : SettingsEvent()
}
/**
* Progress state for Markdown export
* v1.5.0: For initial export progress dialog
*/
data class MarkdownExportProgress(
val current: Int,
val total: Int,
val isComplete: Boolean = false
)
}

View File

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

View File

@@ -0,0 +1,161 @@
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
* v1.6.0: Added isWarning parameter for offline mode warning
*/
@Composable
fun SettingsInfoCard(
text: String,
modifier: Modifier = Modifier,
isWarning: Boolean = false
) {
androidx.compose.material3.Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = if (isWarning) {
MaterialTheme.colorScheme.errorContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
}
)
) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = if (isWarning) {
MaterialTheme.colorScheme.onErrorContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.padding(16.dp),
lineHeight = MaterialTheme.typography.bodySmall.lineHeight * 1.3f
)
}
}
/**
* Section header text
*/
@Composable
fun SettingsSectionHeader(
text: String,
modifier: Modifier = Modifier
) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
/**
* Divider between settings groups
*/
@Composable
fun SettingsDivider(
modifier: Modifier = Modifier
) {
Spacer(modifier = modifier.height(8.dp))
androidx.compose.material3.HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
Spacer(modifier = Modifier.height(8.dp))
}

View File

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

View File

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

View File

@@ -0,0 +1,85 @@
package dev.dettmer.simplenotes.ui.settings.components
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.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()
.clickable(enabled = enabled) { onCheckedChange(!checked) }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (enabled) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
},
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
}
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = if (enabled) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
}
)
}
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled
)
}
}

View File

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

View File

@@ -0,0 +1,273 @@
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()
// 🌟 v1.6.0: Check if server restore is available
val isServerConfigured = viewModel.isServerConfigured()
// 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))
// 🌟 v1.6.0: Disabled when offline mode active
SettingsOutlinedButton(
text = stringResource(R.string.backup_restore_server),
onClick = {
restoreSource = RestoreSource.Server
showRestoreDialog = true
},
isLoading = isBackupInProgress,
enabled = isServerConfigured,
modifier = Modifier.padding(horizontal = 16.dp)
)
// 🌟 v1.6.0: Show hint when offline
if (!isServerConfigured) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.settings_sync_offline_mode),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
// Restore Mode Dialog
if (showRestoreDialog) {
RestoreModeDialog(
source = restoreSource,
selectedMode = selectedRestoreMode,
onModeSelected = { selectedRestoreMode = it },
onConfirm = {
showRestoreDialog = false
when (restoreSource) {
RestoreSource.LocalFile -> {
pendingRestoreUri?.let { uri ->
viewModel.restoreFromFile(uri, selectedRestoreMode)
}
}
RestoreSource.Server -> {
viewModel.restoreFromServer(selectedRestoreMode)
}
}
},
onDismiss = {
showRestoreDialog = false
pendingRestoreUri = null
}
)
}
}
/**
* Restore source enum
*/
private enum class RestoreSource {
LocalFile,
Server
}
/**
* Dialog for selecting restore mode
*/
@Composable
private fun RestoreModeDialog(
source: RestoreSource,
selectedMode: RestoreMode,
onModeSelected: (RestoreMode) -> Unit,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val sourceText = when (source) {
RestoreSource.LocalFile -> stringResource(R.string.backup_restore_source_file)
RestoreSource.Server -> stringResource(R.string.backup_restore_source_server)
}
val modeOptions = listOf(
RadioOption(
value = RestoreMode.MERGE,
title = stringResource(R.string.backup_mode_merge_title),
subtitle = stringResource(R.string.backup_mode_merge_subtitle)
),
RadioOption(
value = RestoreMode.REPLACE,
title = stringResource(R.string.backup_mode_replace_title),
subtitle = stringResource(R.string.backup_mode_replace_subtitle)
),
RadioOption(
value = RestoreMode.OVERWRITE_DUPLICATES,
title = stringResource(R.string.backup_mode_overwrite_title),
subtitle = stringResource(R.string.backup_mode_overwrite_subtitle)
)
)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.backup_restore_dialog_title)) },
text = {
Column {
Text(
text = stringResource(R.string.backup_restore_source, sourceText),
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.backup_restore_mode_label),
style = MaterialTheme.typography.labelLarge
)
Spacer(modifier = Modifier.height(8.dp))
SettingsRadioGroup(
options = modeOptions,
selectedValue = selectedMode,
onValueSelected = onModeSelected
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.backup_restore_info),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringResource(R.string.backup_restore_button))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}

View File

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

View File

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

View File

@@ -0,0 +1,150 @@
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.6.0: Check offline mode
val offlineMode by viewModel.offlineMode.collectAsState()
val isServerConfigured = viewModel.isServerConfigured()
// 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
// 🌟 v1.6.0: Disabled when offline mode active
SettingsSwitch(
title = stringResource(R.string.markdown_auto_sync_title),
subtitle = if (!isServerConfigured) {
stringResource(R.string.settings_sync_offline_mode)
} else {
stringResource(R.string.markdown_auto_sync_subtitle)
},
checked = markdownAutoSync,
onCheckedChange = { viewModel.setMarkdownAutoSync(it) },
icon = Icons.Default.Description,
enabled = isServerConfigured
)
// Manual sync button (only visible when auto-sync is off)
// 🌟 v1.6.0: Also disabled in offline mode
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() },
enabled = isServerConfigured,
modifier = Modifier.padding(horizontal = 16.dp)
)
// 🌟 v1.6.0: Show hint when offline
if (!isServerConfigured) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.settings_sync_offline_mode),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}

View File

@@ -0,0 +1,330 @@
package dev.dettmer.simplenotes.ui.settings.screens
import androidx.compose.foundation.clickable
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.Switch
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.draw.alpha
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
* v1.6.0: Offline Mode Toggle
*/
@Suppress("LongMethod", "MagicNumber") // Compose UI + Color hex values
@Composable
fun ServerSettingsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit
) {
val offlineMode by viewModel.offlineMode.collectAsState()
val serverHost by viewModel.serverHost.collectAsState() // 🌟 v1.6.0: Only host part
val serverUrl by viewModel.serverUrl.collectAsState() // Full URL for display
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 (only if not in offline mode)
LaunchedEffect(offlineMode) {
if (!offlineMode) {
viewModel.checkServerStatus()
}
}
SettingsScaffold(
title = stringResource(R.string.server_settings_title),
onBack = onBack
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// ═══════════════════════════════════════════════════════════════
// 🌟 v1.6.0: Offline-Modus Toggle (TOP)
// ═══════════════════════════════════════════════════════════════
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { viewModel.setOfflineMode(!offlineMode) },
colors = CardDefaults.cardColors(
containerColor = if (offlineMode) {
MaterialTheme.colorScheme.tertiaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
}
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.server_offline_mode_title),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(R.string.server_offline_mode_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = offlineMode,
onCheckedChange = { viewModel.setOfflineMode(it) }
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// ═══════════════════════════════════════════════════════════════
// Server Configuration (grayed out when offline mode)
// ═══════════════════════════════════════════════════════════════
val fieldsEnabled = !offlineMode
val fieldsAlpha = if (offlineMode) 0.5f else 1f
Column(modifier = Modifier.alpha(fieldsAlpha)) {
// 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)) },
enabled = fieldsEnabled,
modifier = Modifier.weight(1f)
)
FilterChip(
selected = isHttps,
onClick = { viewModel.updateProtocol(true) },
label = { Text(stringResource(R.string.server_connection_https)) },
enabled = fieldsEnabled,
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)
)
// 🌟 v1.6.0: Server-Adresse with non-editable prefix
OutlinedTextField(
value = serverHost, // Only host part is editable
onValueChange = { viewModel.updateServerHost(it) },
label = { Text(stringResource(R.string.server_address)) },
supportingText = { Text(stringResource(R.string.server_address_hint)) },
prefix = {
// Protocol prefix is displayed but not editable
Text(
text = if (isHttps) "https://" else "http://",
style = MaterialTheme.typography.bodyLarge,
color = if (fieldsEnabled) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
}
)
},
leadingIcon = { Icon(Icons.Default.Language, null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = fieldsEnabled,
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,
enabled = fieldsEnabled
)
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,
enabled = fieldsEnabled,
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.OfflineMode -> stringResource(R.string.server_status_offline_mode)
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_offline_mode)
else -> stringResource(R.string.server_status_unknown)
},
color = when (serverStatus) {
is SettingsViewModel.ServerStatus.OfflineMode -> MaterialTheme.colorScheme.tertiary
is SettingsViewModel.ServerStatus.Reachable -> Color(0xFF4CAF50)
is SettingsViewModel.ServerStatus.Unreachable -> Color(0xFFF44336)
is SettingsViewModel.ServerStatus.NotConfigured -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Action Buttons (disabled in offline mode)
Row(
modifier = Modifier
.fillMaxWidth()
.alpha(fieldsAlpha),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = { viewModel.testConnection() },
enabled = fieldsEnabled,
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.test_connection))
}
Button(
onClick = { viewModel.syncNow() },
enabled = fieldsEnabled && !isSyncing,
modifier = Modifier.weight(1f)
) {
if (isSyncing) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.sync_now))
}
}
}
}
}

View File

@@ -0,0 +1,220 @@
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.material3.MaterialTheme
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
*/
@Suppress("MagicNumber") // Color hex values
@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()
// 🌟 v1.6.0: Collect offline mode and trigger states
val offlineMode by viewModel.offlineMode.collectAsState()
val triggerOnSave by viewModel.triggerOnSave.collectAsState()
val triggerOnResume by viewModel.triggerOnResume.collectAsState()
val triggerWifiConnect by viewModel.triggerWifiConnect.collectAsState()
val triggerPeriodic by viewModel.triggerPeriodic.collectAsState()
val triggerBoot by viewModel.triggerBoot.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.6.0: Check if server is configured (host is not empty)
val isConfigured = serverUrl.isNotEmpty()
SettingsCard(
icon = Icons.Default.Cloud,
title = stringResource(R.string.settings_server),
subtitle = if (!offlineMode && isConfigured) serverUrl else null,
statusText = when {
offlineMode ->
stringResource(R.string.settings_server_status_offline_mode)
serverStatus is SettingsViewModel.ServerStatus.OfflineMode ->
stringResource(R.string.settings_server_status_offline_mode)
serverStatus is SettingsViewModel.ServerStatus.Reachable ->
stringResource(R.string.settings_server_status_reachable)
serverStatus is SettingsViewModel.ServerStatus.Unreachable ->
stringResource(R.string.settings_server_status_unreachable)
serverStatus is SettingsViewModel.ServerStatus.Checking ->
stringResource(R.string.settings_server_status_checking)
serverStatus is SettingsViewModel.ServerStatus.NotConfigured ->
stringResource(R.string.settings_server_status_offline_mode)
else -> null
},
statusColor = when {
offlineMode -> MaterialTheme.colorScheme.tertiary
serverStatus is SettingsViewModel.ServerStatus.OfflineMode ->
MaterialTheme.colorScheme.tertiary
serverStatus is SettingsViewModel.ServerStatus.Reachable ->
Color(0xFF4CAF50)
serverStatus is SettingsViewModel.ServerStatus.Unreachable ->
Color(0xFFF44336)
serverStatus is SettingsViewModel.ServerStatus.NotConfigured ->
MaterialTheme.colorScheme.tertiary
else -> Color.Gray
},
onClick = { onNavigate(SettingsRoute.Server) }
)
}
// Sync-Einstellungen
item {
// 🌟 v1.6.0: Build dynamic subtitle based on active triggers
val isServerConfigured = viewModel.isServerConfigured()
val activeTriggersCount = listOf(
triggerOnSave,
triggerOnResume,
triggerWifiConnect,
triggerPeriodic,
triggerBoot
).count { it }
// 🌟 v1.6.0 Fix: Use statusText for offline mode (consistent with Server card)
val syncSubtitle = if (isServerConfigured) {
if (activeTriggersCount == 0) {
stringResource(R.string.settings_sync_manual_only)
} else {
stringResource(R.string.settings_sync_triggers_active, activeTriggersCount)
}
} else null
SettingsCard(
icon = Icons.Default.Sync,
title = stringResource(R.string.settings_sync),
subtitle = syncSubtitle,
statusText = if (!isServerConfigured) stringResource(R.string.settings_sync_offline_mode) else null,
statusColor = if (!isServerConfigured) MaterialTheme.colorScheme.tertiary else Color.Gray,
onClick = { onNavigate(SettingsRoute.Sync) }
)
}
// Markdown-Integration
item {
// 🌟 v1.6.0 Fix: Use statusText for offline mode (consistent with Server card)
val isServerConfiguredForMarkdown = viewModel.isServerConfigured()
SettingsCard(
icon = Icons.Default.Description,
title = stringResource(R.string.settings_markdown),
subtitle = if (isServerConfiguredForMarkdown) {
if (markdownAutoSync) {
stringResource(R.string.settings_markdown_auto_on)
} else {
stringResource(R.string.settings_markdown_auto_off)
}
} else null,
statusText = if (!isServerConfiguredForMarkdown) stringResource(R.string.settings_sync_offline_mode) else null,
statusColor = if (!isServerConfiguredForMarkdown) MaterialTheme.colorScheme.tertiary else Color.Gray,
onClick = { onNavigate(SettingsRoute.Markdown) }
)
}
// Backup & Wiederherstellung
item {
SettingsCard(
icon = Icons.Default.Backup,
title = stringResource(R.string.settings_backup),
subtitle = stringResource(R.string.settings_backup_subtitle),
onClick = { onNavigate(SettingsRoute.Backup) }
)
}
// Über diese App
item {
SettingsCard(
icon = Icons.Default.Info,
title = stringResource(R.string.settings_about),
subtitle = stringResource(R.string.about_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE),
onClick = { onNavigate(SettingsRoute.About) }
)
}
// Debug & Diagnose
item {
SettingsCard(
icon = Icons.Default.BugReport,
title = stringResource(R.string.settings_debug),
subtitle = if (fileLoggingEnabled) {
stringResource(R.string.settings_debug_logging_on)
} else {
stringResource(R.string.settings_debug_logging_off)
},
onClick = { onNavigate(SettingsRoute.Debug) }
)
}
}
}
}

View File

@@ -0,0 +1,204 @@
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.PhonelinkRing
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.SettingsInputAntenna
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.Button
import androidx.compose.material3.Text
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 - Configurable Sync Triggers
* v1.5.0: Jetpack Compose Settings Redesign
* v1.6.0: Individual toggle for each sync trigger (onSave, onResume, WiFi-Connect, Periodic, Boot)
*/
@Composable
fun SyncSettingsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit,
onNavigateToServerSettings: () -> Unit
) {
// Collect all trigger states
val triggerOnSave by viewModel.triggerOnSave.collectAsState()
val triggerOnResume by viewModel.triggerOnResume.collectAsState()
val triggerWifiConnect by viewModel.triggerWifiConnect.collectAsState()
val triggerPeriodic by viewModel.triggerPeriodic.collectAsState()
val triggerBoot by viewModel.triggerBoot.collectAsState()
val syncInterval by viewModel.syncInterval.collectAsState()
// Check if server is configured
val isServerConfigured = viewModel.isServerConfigured()
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))
// 🌟 v1.6.0: Offline Mode Warning if server not configured
if (!isServerConfigured) {
SettingsInfoCard(
text = stringResource(R.string.sync_offline_mode_message),
isWarning = true
)
Button(
onClick = onNavigateToServerSettings,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(stringResource(R.string.sync_offline_mode_button))
}
Spacer(modifier = Modifier.height(8.dp))
}
// ═══════════════════════════════════════════════════════════════
// SOFORT-SYNC Section
// ═══════════════════════════════════════════════════════════════
SettingsSectionHeader(text = stringResource(R.string.sync_section_instant))
// onSave Trigger
SettingsSwitch(
title = stringResource(R.string.sync_trigger_on_save_title),
subtitle = stringResource(R.string.sync_trigger_on_save_subtitle),
checked = triggerOnSave,
onCheckedChange = { viewModel.setTriggerOnSave(it) },
icon = Icons.Default.Save,
enabled = isServerConfigured
)
// onResume Trigger
SettingsSwitch(
title = stringResource(R.string.sync_trigger_on_resume_title),
subtitle = stringResource(R.string.sync_trigger_on_resume_subtitle),
checked = triggerOnResume,
onCheckedChange = { viewModel.setTriggerOnResume(it) },
icon = Icons.Default.PhonelinkRing,
enabled = isServerConfigured
)
SettingsDivider()
// ═══════════════════════════════════════════════════════════════
// HINTERGRUND-SYNC Section
// ═══════════════════════════════════════════════════════════════
SettingsSectionHeader(text = stringResource(R.string.sync_section_background))
// WiFi-Connect Trigger
SettingsSwitch(
title = stringResource(R.string.sync_trigger_wifi_connect_title),
subtitle = stringResource(R.string.sync_trigger_wifi_connect_subtitle),
checked = triggerWifiConnect,
onCheckedChange = { viewModel.setTriggerWifiConnect(it) },
icon = Icons.Default.Wifi,
enabled = isServerConfigured
)
// Periodic Trigger
SettingsSwitch(
title = stringResource(R.string.sync_trigger_periodic_title),
subtitle = stringResource(R.string.sync_trigger_periodic_subtitle),
checked = triggerPeriodic,
onCheckedChange = { viewModel.setTriggerPeriodic(it) },
icon = Icons.Default.Schedule,
enabled = isServerConfigured
)
// Periodic Interval Selection (only visible if periodic trigger is enabled)
if (triggerPeriodic && isServerConfigured) {
Spacer(modifier = Modifier.height(8.dp))
val intervalOptions = listOf(
RadioOption(
value = 15L,
title = stringResource(R.string.sync_interval_15min_title),
subtitle = null
),
RadioOption(
value = 30L,
title = stringResource(R.string.sync_interval_30min_title),
subtitle = null
),
RadioOption(
value = 60L,
title = stringResource(R.string.sync_interval_60min_title),
subtitle = null
)
)
SettingsRadioGroup(
options = intervalOptions,
selectedValue = syncInterval,
onValueSelected = { viewModel.setSyncInterval(it) }
)
Spacer(modifier = Modifier.height(8.dp))
}
SettingsDivider()
// ═══════════════════════════════════════════════════════════════
// ADVANCED Section (Boot Sync)
// ═══════════════════════════════════════════════════════════════
SettingsSectionHeader(text = stringResource(R.string.sync_section_advanced))
// Boot Trigger
SettingsSwitch(
title = stringResource(R.string.sync_trigger_boot_title),
subtitle = stringResource(R.string.sync_trigger_boot_subtitle),
checked = triggerBoot,
onCheckedChange = { viewModel.setTriggerBoot(it) },
icon = Icons.Default.SettingsInputAntenna,
enabled = isServerConfigured
)
SettingsDivider()
// Manual Sync Info
val manualHintText = if (isServerConfigured) {
stringResource(R.string.sync_manual_hint)
} else {
stringResource(R.string.sync_manual_hint_disabled)
}
SettingsInfoCard(
text = manualHintText
)
Spacer(modifier = Modifier.height(16.dp))
}
}
}

View File

@@ -0,0 +1,28 @@
package dev.dettmer.simplenotes.ui.theme
import androidx.compose.ui.unit.dp
/**
* Zentrale UI-Dimensionen für konsistentes Design
*/
object Dimensions {
// Padding & Spacing
val SpacingSmall = 4.dp
val SpacingMedium = 8.dp
val SpacingLarge = 16.dp
val SpacingXLarge = 24.dp
// Icon Sizes
val IconSizeSmall = 16.dp
val IconSizeMedium = 24.dp
val IconSizeLarge = 32.dp
// Minimum Touch Target (Material Design: 48dp)
val MinTouchTarget = 48.dp
// Checklist
val ChecklistItemMinHeight = 48.dp
// Status Bar Heights
val StatusBarHeightDefault = 56.dp
}

View File

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

View File

@@ -6,7 +6,6 @@ object Constants {
const val KEY_SERVER_URL = "server_url" const val KEY_SERVER_URL = "server_url"
const val KEY_USERNAME = "username" const val KEY_USERNAME = "username"
const val KEY_PASSWORD = "password" const val KEY_PASSWORD = "password"
const val KEY_HOME_SSID = "home_ssid"
const val KEY_AUTO_SYNC = "auto_sync_enabled" const val KEY_AUTO_SYNC = "auto_sync_enabled"
const val KEY_LAST_SYNC = "last_sync_timestamp" const val KEY_LAST_SYNC = "last_sync_timestamp"
@@ -30,6 +29,27 @@ object Constants {
// 🔥 v1.3.1: Debug & Logging // 🔥 v1.3.1: Debug & Logging
const val KEY_FILE_LOGGING_ENABLED = "file_logging_enabled" const val KEY_FILE_LOGGING_ENABLED = "file_logging_enabled"
// 🔥 v1.6.0: Offline Mode Toggle
const val KEY_OFFLINE_MODE = "offline_mode_enabled"
// 🔥 v1.6.0: Configurable Sync Triggers
const val KEY_SYNC_TRIGGER_ON_SAVE = "sync_trigger_on_save"
const val KEY_SYNC_TRIGGER_ON_RESUME = "sync_trigger_on_resume"
const val KEY_SYNC_TRIGGER_WIFI_CONNECT = "sync_trigger_wifi_connect"
const val KEY_SYNC_TRIGGER_PERIODIC = "sync_trigger_periodic"
const val KEY_SYNC_TRIGGER_BOOT = "sync_trigger_boot"
// Sync Trigger Defaults (active after server configuration)
const val DEFAULT_TRIGGER_ON_SAVE = true
const val DEFAULT_TRIGGER_ON_RESUME = true
const val DEFAULT_TRIGGER_WIFI_CONNECT = true
const val DEFAULT_TRIGGER_PERIODIC = false
const val DEFAULT_TRIGGER_BOOT = false
// Throttling for onSave sync (5 seconds)
const val MIN_ON_SAVE_SYNC_INTERVAL_MS = 5_000L
const val PREF_LAST_ON_SAVE_SYNC_TIME = "last_on_save_sync_time"
// WorkManager // WorkManager
const val SYNC_WORK_TAG = "notes_sync" const val SYNC_WORK_TAG = "notes_sync"
const val SYNC_DELAY_SECONDS = 5L const val SYNC_DELAY_SECONDS = 5L

View File

@@ -2,6 +2,7 @@ package dev.dettmer.simplenotes.utils
import android.content.Context import android.content.Context
import android.widget.Toast import android.widget.Toast
import dev.dettmer.simplenotes.R
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -15,7 +16,7 @@ fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) {
Toast.makeText(this, message, duration).show() Toast.makeText(this, message, duration).show()
} }
// Timestamp to readable format // Timestamp to readable format (legacy - without context, uses German)
fun Long.toReadableTime(): String { fun Long.toReadableTime(): String {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val diff = now - this 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 // Truncate long strings
fun String.truncate(maxLength: Int): String { fun String.truncate(maxLength: Int): String {
return if (length > maxLength) { return if (length > maxLength) {

View File

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

View File

@@ -0,0 +1,13 @@
package dev.dettmer.simplenotes.utils
/**
* Konstanten für Sync-Operationen
*/
object SyncConstants {
// Debounce Delays
const val SEARCH_DEBOUNCE_MS = 300L
const val SYNC_DEBOUNCE_MS = 500L
// Connection Timeouts
const val CONNECTION_TEST_TIMEOUT_MS = 5000L
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M22,7h-9v2h9V7zM22,15h-9v2h9V15zM5.54,11L2,7.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L5.54,11zM5.54,19L2,15.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L5.54,19z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurfaceVariant">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurfaceVariant">
<path
android:fillColor="@android:color/white"
android:pathData="M20,9H4v2h16V9zM4,15h16v-2H4v2z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M14,2H6C4.9,2 4.01,2.9 4.01,4L4,20c0,1.1 0.89,2 1.99,2H18c1.1,0 2,-0.9 2,-2V8L14,2zM16,18H8v-2h8v2zM16,14H8v-2h8v2zM13,9V3.5L18.5,9H13z"/>
</vector>

View File

@@ -2,6 +2,7 @@
<LinearLayout <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
@@ -18,8 +19,9 @@
app:title="@string/edit_note" app:title="@string/edit_note"
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" /> app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
<!-- Material 3 Outlined TextInputLayout with 16dp corners --> <!-- Title Input (für beide Typen) -->
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilTitle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="16dp" android:layout_margin="16dp"
@@ -44,8 +46,9 @@
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<!-- Material 3 Outlined TextInputLayout for Content --> <!-- Content Input (nur für TEXT sichtbar) -->
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilContent"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
@@ -74,4 +77,39 @@
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<!-- v1.4.0: Checklist Container (nur für CHECKLIST sichtbar) -->
<LinearLayout
android:id="@+id/checklistContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<!-- Checklist Items RecyclerView -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvChecklistItems"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginHorizontal="8dp"
android:clipToPadding="false"
android:paddingBottom="8dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_checklist_editor" />
<!-- Add Item Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAddItem"
style="@style/Widget.Material3.Button.TextButton.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginBottom="16dp"
android:text="@string/add_item"
app:icon="@android:drawable/ic_input_add" />
</LinearLayout>
</LinearLayout> </LinearLayout>

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- v1.4.0: Checklist Item Layout für Editor -->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingVertical="4dp"
android:paddingHorizontal="8dp"
android:minHeight="48dp">
<!-- Drag Handle -->
<ImageView
android:id="@+id/ivDragHandle"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="8dp"
android:src="@drawable/ic_drag_handle_24"
android:contentDescription="@string/reorder_item"
android:importantForAccessibility="yes" />
<!-- Checkbox -->
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/cbItem"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="0dp"
android:minHeight="0dp" />
<!-- Text Input (ohne Box, nur transparent) -->
<!-- v1.4.1: Auto-Zeilenumbruch für lange Texte -->
<EditText
android:id="@+id/etItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@null"
android:hint="@string/item_placeholder"
android:inputType="textMultiLine|textCapSentences"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
tools:text="Milch kaufen" />
<!-- Delete Button -->
<ImageButton
android:id="@+id/btnDeleteItem"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_delete_24"
android:contentDescription="@string/delete_item"
app:tint="?attr/colorOnSurfaceVariant" />
</LinearLayout>

View File

@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Material 3: Filled Card Style (Flat, No Shadow) --> <!-- Material 3: Filled Card Style (Flat, No Shadow) -->
<!-- v1.4.0: Unterstützt jetzt TEXT und CHECKLIST Notizen -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp" android:layout_marginHorizontal="8dp"
@@ -17,17 +19,37 @@
android:orientation="vertical" android:orientation="vertical"
android:padding="20dp"> android:padding="20dp">
<!-- v1.4.0: Header Row mit Icon und Titel -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<!-- v1.4.0: Note Type Icon -->
<ImageView
android:id="@+id/ivNoteTypeIcon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="8dp"
android:src="@drawable/ic_note_24"
app:tint="?attr/colorPrimary"
android:contentDescription="@null" />
<!-- Material 3 Typography: TitleMedium --> <!-- Material 3 Typography: TitleMedium -->
<TextView <TextView
android:id="@+id/textViewTitle" android:id="@+id/textViewTitle"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/note_title_placeholder" android:text="@string/note_title_placeholder"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium" android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:maxLines="2" android:maxLines="2"
android:ellipsize="end" /> android:ellipsize="end" />
<!-- Material 3 Typography: BodyMedium --> </LinearLayout>
<!-- Content Preview (für TEXT Notizen) -->
<TextView <TextView
android:id="@+id/textViewContent" android:id="@+id/textViewContent"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -39,6 +61,18 @@
android:maxLines="3" android:maxLines="3"
android:ellipsize="end" /> android:ellipsize="end" />
<!-- v1.4.0: Checklist Preview (für CHECKLIST Notizen) -->
<TextView
android:id="@+id/textViewChecklistPreview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:visibility="gone"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
tools:visibility="visible"
tools:text="2/5 erledigt" />
<!-- Metadata Row mit Timestamp und Sync-Status --> <!-- Metadata Row mit Timestamp und Sync-Status -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_create_text_note"
android:icon="@drawable/ic_note_24"
android:title="@string/create_text_note" />
<item
android:id="@+id/action_create_checklist"
android:icon="@drawable/ic_checklist_24"
android:title="@string/create_checklist" />
</menu>

View File

@@ -0,0 +1,431 @@
<?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_everywhere_offline_hint">Nicht verfügbar im Offline-Modus</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_server_status_offline_mode">📴 Offline-Modus</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_sync_offline_mode">📴 Offline-Modus</string>
<string name="settings_sync_manual_only">Nur manueller Sync</string>
<string name="settings_sync_triggers_active">%d Trigger aktiv</string>
<string name="settings_interval_15min">15 Min</string>
<string name="settings_interval_30min">30 Min</string>
<string name="settings_interval_60min">60 Min</string>
<string name="settings_markdown">Markdown Desktop-Integration</string>
<string name="settings_markdown_auto_on">Auto-Sync: An</string>
<string name="settings_markdown_auto_off">Auto-Sync: Aus</string>
<string name="settings_backup">Backup &amp; Wiederherstellung</string>
<string name="settings_backup_subtitle">Lokales oder Server-Backup</string>
<string name="settings_about">Über diese App</string>
<string name="settings_debug">Debug &amp; Diagnose</string>
<string name="settings_debug_logging_on">Logging: An</string>
<string name="settings_debug_logging_off">Logging: Aus</string>
<!-- ============================= -->
<!-- SETTINGS - SERVER -->
<!-- ============================= -->
<string name="server_settings">Server-Einstellungen</string>
<string name="server_settings_title">Server-Einstellungen</string>
<string name="server_connection_type">Verbindungstyp</string>
<string name="server_connection_http">🏠 Intern (HTTP)</string>
<string name="server_connection_https">🌐 Extern (HTTPS)</string>
<string name="server_connection_http_hint">HTTP nur für lokale Netzwerke (z.B. 192.168.x.x, 10.x.x.x)</string>
<string name="server_connection_https_hint">HTTPS für sichere Verbindungen über das Internet</string>
<string name="server_address">Server-Adresse</string>
<string name="server_address_hint">z.B. http://192.168.0.188:8080/notes</string>
<string name="server_url">Server URL</string>
<string name="username">Benutzername</string>
<string name="password">Passwort</string>
<string name="server_password_show">Anzeigen</string>
<string name="server_password_hide">Verstecken</string>
<string name="server_status_label">Server-Status:</string>
<string name="server_status_reachable">✅ Erreichbar</string>
<string name="server_status_unreachable">❌ Nicht erreichbar</string>
<string name="server_status_checking">🔍 Prüfe…</string>
<string name="server_status_not_configured">⚠️ Nicht konfiguriert</string>
<string name="server_status_offline_mode">📴 Offline-Modus aktiv</string>
<string name="server_status_unknown">❓ Unbekannt</string>
<string name="server_offline_mode_title">📴 Offline-Modus</string>
<string name="server_offline_mode_subtitle">Alle Netzwerkfunktionen deaktivieren</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>
<!-- 🌟 v1.6.0: Configurable Sync Triggers -->
<string name="sync_section_instant">📲 Sofort-Sync</string>
<string name="sync_section_background">📡 Hintergrund-Sync</string>
<string name="sync_section_advanced">⚙️ Erweitert</string>
<string name="sync_trigger_on_save_title">Nach dem Speichern</string>
<string name="sync_trigger_on_save_subtitle">Sync sofort nach jeder Änderung</string>
<string name="sync_trigger_on_resume_title">Beim App-Start</string>
<string name="sync_trigger_on_resume_subtitle">Sync wenn die App geöffnet wird</string>
<string name="sync_trigger_wifi_connect_title">Bei WiFi-Verbindung</string>
<string name="sync_trigger_wifi_connect_subtitle">Sync wenn WiFi verbunden wird</string>
<string name="sync_trigger_periodic_title">Automatisch alle X Minuten</string>
<string name="sync_trigger_periodic_subtitle">Regelmäßiger Hintergrund-Sync</string>
<string name="sync_trigger_boot_title">Nach Gerät-Neustart</string>
<string name="sync_trigger_boot_subtitle">Startet Hintergrund-Sync nach Reboot</string>
<string name="sync_manual_hint">Manueller Sync (Toolbar/Pull-to-Refresh) ist ebenfalls verfügbar.</string>
<string name="sync_manual_hint_disabled">Sync ist im Offline-Modus nicht verfügbar.</string>
<string name="sync_offline_mode_title">Offline-Modus</string>
<string name="sync_offline_mode_message">Du nutzt die App im Offline-Modus. Richte einen Server ein, um Notizen zu synchronisieren.</string>
<string name="sync_offline_mode_button">Server einrichten</string>
<!-- ============================= -->
<!-- SETTINGS - MARKDOWN -->
<!-- ============================= -->
<string name="markdown_settings_title">Markdown Desktop-Integration</string>
<string name="markdown_dialog_title">Markdown Auto-Sync</string>
<string name="markdown_export_complete">✅ Export abgeschlossen</string>
<string name="markdown_export_progress">Exportiere %1$d/%2$d Notizen…</string>
<string name="markdown_info">📝 Exportiert Notizen zusätzlich als .md-Dateien. Mounte WebDAV als Netzlaufwerk um mit VS Code, Typora oder jedem Markdown-Editor zu bearbeiten. JSON-Sync bleibt primäres Format.</string>
<string name="markdown_auto_sync_title">Markdown Auto-Sync</string>
<string name="markdown_auto_sync_subtitle">Synchronisiert Notizen automatisch als .md-Dateien (Upload + Download bei jedem Sync)</string>
<string name="markdown_manual_sync_info">Manueller Sync exportiert alle Notizen als .md-Dateien und importiert .md-Dateien vom Server. Nützlich für einmalige Synchronisation.</string>
<string name="markdown_manual_sync_button">📝 Manueller Markdown-Sync</string>
<!-- ============================= -->
<!-- SETTINGS - BACKUP -->
<!-- ============================= -->
<string name="backup_settings_title">Backup &amp; Wiederherstellung</string>
<string name="backup_restore_title">Backup &amp; Wiederherstellung</string>
<string name="backup_auto_info">📦 Bei jeder Wiederherstellung wird automatisch ein Sicherheits-Backup erstellt.</string>
<string name="backup_local_section">Lokales Backup</string>
<string name="backup_create">💾 Backup erstellen</string>
<string name="backup_restore_file">📂 Aus Datei wiederherstellen</string>
<string name="backup_server_section">Server-Backup</string>
<string name="backup_restore_server">☁️ Vom Server wiederherstellen</string>
<string name="backup_restore_dialog_title">⚠️ Backup wiederherstellen?</string>
<string name="backup_restore_source">Quelle: %s</string>
<string name="backup_restore_source_file">Lokale Datei</string>
<string name="backup_restore_source_server">WebDAV Server</string>
<string name="backup_restore_mode_label">Wiederherstellungs-Modus:</string>
<string name="backup_mode_merge_title">⚪ Zusammenführen (Standard)</string>
<string name="backup_mode_merge_subtitle">Neue hinzufügen, Bestehende behalten</string>
<string name="backup_mode_merge_full">⚪ Zusammenführen (Standard)\n → Neue hinzufügen, Bestehende behalten</string>
<string name="backup_mode_replace_title">⚪ Ersetzen</string>
<string name="backup_mode_replace_subtitle">Alle löschen &amp; Backup importieren</string>
<string name="backup_mode_replace_full">⚪ Ersetzen\n → Alle löschen &amp; Backup importieren</string>
<string name="backup_mode_overwrite_title">⚪ Duplikate überschreiben</string>
<string name="backup_mode_overwrite_subtitle">Backup gewinnt bei Konflikten</string>
<string name="backup_mode_overwrite_full">⚪ Duplikate überschreiben\n → Backup gewinnt bei Konflikten</string>
<string name="backup_restore_info"> Ein Sicherheits-Backup wird vor dem Wiederherstellen automatisch erstellt.</string>
<string name="backup_restore_button">Wiederherstellen</string>
<!-- Legacy -->
<string name="backup_restore_warning">⚠️ Achtung:\n\nDie Wiederherstellung überschreibt ALLE lokalen Notizen mit den Daten vom Server. Diese Aktion kann nicht rückgängig gemacht werden!</string>
<string name="restore_from_server">Vom Server wiederherstellen</string>
<string name="restore_confirmation_title">⚠️ Vom Server wiederherstellen?</string>
<string name="restore_confirmation_message">WARNUNG: Alle lokalen Notizen werden gelöscht und durch die Notizen vom Server ersetzt.\n\nDieser Vorgang kann nicht rückgängig gemacht werden!</string>
<string name="restore_button">Wiederherstellen</string>
<string name="restore_progress">Stelle Notizen wieder her…</string>
<string name="restore_success">✓ %d Notizen wiederhergestellt</string>
<string name="restore_error">Fehler: %s</string>
<!-- ============================= -->
<!-- SETTINGS - DEBUG -->
<!-- ============================= -->
<string name="debug_settings_title">Debug &amp; Diagnose</string>
<string name="debug_file_logging_title">Datei-Logging</string>
<string name="debug_file_logging_subtitle">Sync-Logs in Datei speichern</string>
<string name="debug_privacy_info">🔒 Datenschutz: Logs werden nur lokal auf deinem Gerät gespeichert und niemals an externe Server gesendet. Die Logs enthalten Sync-Aktivitäten zur Fehlerdiagnose. Du kannst sie jederzeit löschen oder exportieren.</string>
<string name="debug_log_actions_section">Log-Aktionen</string>
<string name="debug_export_logs">📤 Logs exportieren &amp; teilen</string>
<string name="debug_logs_subject">SimpleNotes Sync Logs</string>
<string name="debug_logs_share_via">Logs teilen via…</string>
<string name="debug_delete_logs">🗑️ Logs löschen</string>
<string name="debug_delete_logs_title">Logs löschen?</string>
<string name="debug_delete_logs_message">Alle gespeicherten Sync-Logs werden unwiderruflich gelöscht.</string>
<!-- Legacy -->
<string name="file_logging_privacy_notice"> Datenschutz: Logs werden nur lokal auf deinem Gerät gespeichert und niemals an externe Server gesendet. Die Logs enthalten Sync-Aktivitäten zur Fehlerdiagnose. Du kannst sie jederzeit löschen oder exportieren.</string>
<!-- ============================= -->
<!-- SETTINGS - LANGUAGE -->
<!-- ============================= -->
<string name="language_settings_title">Sprache</string>
<string name="language_system_default">Systemstandard</string>
<string name="language_english">English</string>
<string name="language_german">Deutsch</string>
<string name="language_info"> Wähle deine bevorzugte Sprache. Die App wird neu gestartet, um die Änderung anzuwenden.</string>
<string name="language_changed_restart">Sprache geändert. Neustart…</string>
<!-- ============================= -->
<!-- SETTINGS - ABOUT -->
<!-- ============================= -->
<string name="about_settings_title">Über diese App</string>
<string name="about_app_name">Simple Notes Sync</string>
<string name="about_version">Version %1$s (%2$d)</string>
<string name="about_links_section">Links</string>
<string name="about_github_title">GitHub Repository</string>
<string name="about_github_subtitle">Quellcode, Issues &amp; Dokumentation</string>
<string name="about_developer_title">Entwickler</string>
<string name="about_developer_subtitle">GitHub Profil: @inventory69</string>
<string name="about_license_title">Lizenz</string>
<string name="about_license_subtitle">MIT License - Open Source</string>
<string name="about_privacy_title">🔒 Datenschutz</string>
<string name="about_privacy_text">Diese App sammelt keine Daten. Alle Notizen werden nur lokal auf deinem Gerät und auf deinem eigenen WebDAV-Server gespeichert. Keine Telemetrie, keine Werbung.</string>
<!-- ============================= -->
<!-- TOAST MESSAGES -->
<!-- ============================= -->
<string name="toast_connection_success">✅ Verbindung erfolgreich!</string>
<string name="toast_connection_failed">❌ %s</string>
<string name="toast_error">❌ Fehler: %s</string>
<string name="toast_syncing">🔄 Synchronisiere…</string>
<string name="toast_already_synced">✅ Bereits synchronisiert</string>
<string name="toast_sync_success">✅ %d Notizen synchronisiert</string>
<string name="toast_sync_failed">❌ %s</string>
<string name="toast_auto_sync_enabled">✅ Auto-Sync aktiviert</string>
<string name="toast_auto_sync_disabled">Auto-Sync deaktiviert</string>
<string name="toast_sync_interval">⏱️ Sync-Intervall: %s</string>
<string name="toast_sync_interval_15min">15 Minuten</string>
<string name="toast_sync_interval_30min">30 Minuten</string>
<string name="toast_sync_interval_60min">60 Minuten</string>
<string name="toast_configure_server_first">⚠️ Bitte zuerst WebDAV-Server konfigurieren</string>
<string name="toast_markdown_exported">✅ %d Notizen nach Markdown exportiert</string>
<string name="toast_markdown_enabled">📝 Markdown Auto-Sync aktiviert</string>
<string name="toast_markdown_disabled">📝 Markdown Auto-Sync deaktiviert</string>
<string name="toast_markdown_syncing">📝 Markdown-Sync läuft…</string>
<string name="toast_markdown_result">✅ Export: %1$d • Import: %2$d</string>
<string name="toast_export_failed">❌ Export fehlgeschlagen: %s</string>
<string name="toast_backup_success">✅ %s</string>
<string name="toast_backup_failed">❌ Backup fehlgeschlagen: %s</string>
<string name="toast_restore_success">✅ %d Notizen wiederhergestellt</string>
<string name="toast_restore_failed">❌ Wiederherstellung fehlgeschlagen: %s</string>
<string name="toast_notifications_enabled">Benachrichtigungen aktiviert</string>
<string name="toast_notifications_disabled">Benachrichtigungen deaktiviert. Du kannst sie in den Einstellungen aktivieren.</string>
<string name="toast_battery_optimization">Bitte Akku-Optimierung manuell deaktivieren</string>
<string name="toast_logs_deleted">🗑️ Logs gelöscht</string>
<string name="toast_no_logs_to_delete">📭 Keine Logs zum Löschen</string>
<string name="toast_logs_delete_error">❌ Fehler beim Löschen: %s</string>
<string name="toast_link_error">❌ Fehler beim Öffnen des Links</string>
<string name="toast_file_logging_enabled">📝 Datei-Logging aktiviert</string>
<string name="toast_file_logging_disabled">📝 Datei-Logging deaktiviert</string>
<string name="toast_sync_interval_changed">⏱️ Sync-Intervall auf %s geändert</string>
<string name="version_not_available">Version nicht verfügbar</string>
<string name="status_checking">🔍 Prüfe…</string>
<string name="battery_optimization_dialog_message">Bitte wähle \'Nicht optimieren\' für Simple Notes.</string>
<string name="battery_optimization_dialog_title">Hintergrund-Synchronisation</string>
<string name="battery_optimization_dialog_full_message">Damit die App im Hintergrund synchronisieren kann, muss die Akku-Optimierung deaktiviert werden.\n\nBitte wähle \'Nicht optimieren\' für Simple Notes.</string>
<string name="battery_optimization_open_settings">Einstellungen öffnen</string>
<string name="battery_optimization_later">Später</string>
<string name="content_description_back">Zurück</string>
<string name="error_invalid_backup_file">Ungültige Backup-Datei</string>
<string name="error_backup_version_unsupported">Backup-Version nicht unterstützt (v%1$d benötigt v%2$d+)</string>
<string name="error_backup_empty">Backup enthält keine Notizen</string>
<string name="error_backup_invalid_notes">Backup enthält %d ungültige Notizen</string>
<string name="error_backup_corrupt">Backup-Datei beschädigt oder ungültig: %s</string>
<string name="error_restore_failed">Wiederherstellung fehlgeschlagen: %s</string>
<string name="restore_merge_result">%1$d neue Notizen importiert, %2$d übersprungen</string>
<string name="restore_overwrite_result">%1$d neu, %2$d überschrieben</string>
<string name="restore_replace_result">Alle Notizen ersetzt: %d importiert</string>
<!-- ============================= -->
<!-- RELATIVE TIME -->
<!-- ============================= -->
<string name="time_just_now">Gerade eben</string>
<string name="time_minutes_ago">Vor %d Min</string>
<string name="time_hours_ago">Vor %d Std</string>
<string name="time_days_ago">Vor %d Tagen</string>
<!-- ============================= -->
<!-- NOTIFICATIONS -->
<!-- ============================= -->
<string name="notification_channel_name">Notizen Synchronisierung</string>
<string name="notification_channel_desc">Benachrichtigungen über Sync-Status</string>
<string name="notification_sync_success_title">Sync erfolgreich</string>
<string name="notification_sync_success_message">%d Notiz(en) synchronisiert</string>
<string name="notification_sync_failed_title">Sync fehlgeschlagen</string>
<string name="notification_sync_progress_title">Synchronisiere…</string>
<string name="notification_sync_progress_message">Notizen werden synchronisiert</string>
<string name="notification_sync_conflict_title">Sync-Konflikt erkannt</string>
<string name="notification_sync_conflict_message">%d Notiz(en) haben Konflikte</string>
<string name="notification_sync_warning_title">⚠️ Sync-Warnung</string>
<string name="notification_sync_warning_message">Server seit %dh nicht erreichbar</string>
<string name="notification_sync_warning_detail">Der WebDAV-Server ist seit %d Stunden nicht erreichbar. Bitte prüfe deine Netzwerkverbindung oder Server-Einstellungen.</string>
<string name="notification_sync_in_progress_title">Synchronisierung läuft</string>
<string name="notification_sync_in_progress_message">Notizen werden synchronisiert…</string>
<string name="notification_sync_error_title">Sync Fehler</string>
<!-- ============================= -->
<!-- PLURALS -->
<!-- ============================= -->
<plurals name="notes_count">
<item quantity="one">%d Notiz</item>
<item quantity="other">%d Notizen</item>
</plurals>
<plurals name="notes_deleted_local">
<item quantity="one">%d Notiz lokal gelöscht</item>
<item quantity="other">%d Notizen lokal gelöscht</item>
</plurals>
<plurals name="notes_deleted_server">
<item quantity="one">%d Notiz wird vom Server gelöscht</item>
<item quantity="other">%d Notizen werden vom Server gelöscht</item>
</plurals>
<plurals name="notes_synced">
<item quantity="one">%d Notiz synchronisiert</item>
<item quantity="other">%d Notizen synchronisiert</item>
</plurals>
</resources>

View File

@@ -1,69 +1,432 @@
<?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- ============================= -->
<!-- APP IDENTITY -->
<!-- ============================= -->
<string name="app_name">Simple Notes</string> <string name="app_name">Simple Notes</string>
<!-- Main Activity --> <!-- ============================= -->
<string name="no_notes_yet">Noch keine Notizen.\nTippe + um eine zu erstellen.</string> <!-- MAIN SCREEN -->
<string name="add_note">Notiz hinzufügen</string> <!-- ============================= -->
<string name="sync">Synchronisieren</string> <string name="main_title">Simple Notes</string>
<string name="settings">Einstellungen</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_emoji">📝</string>
<string name="empty_state_title">Noch keine Notizen</string> <string name="empty_state_title">No notes yet</string>
<string name="empty_state_message">Tippe auf um deine erste Notiz zu erstellen</string> <string name="empty_state_message">Tap + to create a new note</string>
<!-- Note Editor --> <!-- ============================= -->
<string name="edit_note">Notiz bearbeiten</string> <!-- FAB MENU -->
<string name="new_note">Neue Notiz</string> <!-- ============================= -->
<string name="title">Titel</string> <string name="fab_new_note">New note</string>
<string name="content">Inhalt</string> <string name="fab_text_note">Text note</string>
<string name="save">Speichern</string> <string name="fab_checklist">Checklist</string>
<string name="delete">Löschen</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_title_placeholder">Note Title</string>
<string name="note_content_placeholder">Note content preview…</string> <string name="note_content_placeholder">Note content preview…</string>
<string name="note_timestamp_placeholder">Vor 2 Std</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> <!-- SYNC STATUS BANNER -->
<string name="delete_note_message">Diese Aktion kann nicht rückgängig gemacht werden.</string> <!-- ============================= -->
<string name="cancel">Abbrechen</string> <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_everywhere_offline_hint">Not available in offline mode</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_server_status_offline_mode">📴 Offline Mode</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_sync_offline_mode">📴 Offline Mode</string>
<string name="settings_sync_manual_only">Manual sync only</string>
<string name="settings_sync_triggers_active">%d triggers active</string>
<string name="settings_interval_15min">15 min</string>
<string name="settings_interval_30min">30 min</string>
<string name="settings_interval_60min">60 min</string>
<string name="settings_markdown">Markdown Desktop Integration</string>
<string name="settings_markdown_auto_on">Auto-Sync: On</string>
<string name="settings_markdown_auto_off">Auto-Sync: Off</string>
<string name="settings_backup">Backup &amp; Restore</string>
<string name="settings_backup_subtitle">Local or server backup</string>
<string name="settings_about">About this App</string>
<string name="settings_debug">Debug &amp; Diagnostics</string>
<string name="settings_debug_logging_on">Logging: On</string>
<string name="settings_debug_logging_off">Logging: Off</string>
<!-- ============================= -->
<!-- SETTINGS - SERVER -->
<!-- ============================= -->
<string name="server_settings">Server Settings</string>
<string name="server_settings_title">Server Settings</string>
<string name="server_connection_type">Connection Type</string>
<string name="server_connection_http">🏠 Internal (HTTP)</string>
<string name="server_connection_https">🌐 External (HTTPS)</string>
<string name="server_connection_http_hint">HTTP only for local networks (e.g. 192.168.x.x, 10.x.x.x)</string>
<string name="server_connection_https_hint">HTTPS for secure connections over the internet</string>
<string name="server_address">Server Address</string>
<string name="server_address_hint">e.g. http://192.168.0.188:8080/notes</string>
<string name="server_url">Server URL</string> <string name="server_url">Server URL</string>
<string name="username">Benutzername</string> <string name="username">Username</string>
<string name="password">Passwort</string> <string name="password">Password</string>
<string name="server_status_label">Server-Status:</string> <string name="server_password_show">Show</string>
<string name="server_status_checking">Prüfe…</string> <string name="server_password_hide">Hide</string>
<string name="test_connection">Verbindung testen</string> <string name="server_status_label">Server Status:</string>
<string name="sync_now">Jetzt synchronisieren</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_offline_mode">📴 Offline mode active</string>
<string name="server_status_unknown">❓ Unknown</string>
<string name="server_offline_mode_title">📴 Offline Mode</string>
<string name="server_offline_mode_subtitle">Disable all network features</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> <!-- SETTINGS - SYNC -->
<string name="home_ssid">Heim-WLAN SSID</string> <!-- ============================= -->
<string name="auto_sync">Auto-Sync aktiviert</string> <string name="sync_settings">Sync Settings</string>
<string name="sync_status">Sync-Status</string> <string name="sync_settings_title">Sync Settings</string>
<string name="auto_sync_info"> Auto-Sync:\n\n• Prüft alle 30 Min ob Server erreichbar\n• Funktioniert nur im selben Netzwerk\n• Läuft auch im Hintergrund\n• Minimaler Akkuverbrauch (~0.4%%/Tag)</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 --> <!-- 🌟 v1.6.0: Configurable Sync Triggers -->
<string name="backup_restore_title">Backup &amp; Wiederherstellung</string> <string name="sync_section_instant">📲 Instant Sync</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="sync_section_background">📡 Background Sync</string>
<string name="restore_from_server">Vom Server wiederherstellen</string> <string name="sync_section_advanced">⚙️ Advanced</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>
<!-- Sync Status Banner (v1.3.1) --> <string name="sync_trigger_on_save_title">After Saving</string>
<string name="sync_status_syncing">Synchronisiere…</string> <string name="sync_trigger_on_save_subtitle">Sync immediately after each change</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>
<!-- Debug/Logging Section (v1.3.2) --> <string name="sync_trigger_on_resume_title">On App Start</string>
<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> <string name="sync_trigger_on_resume_subtitle">Sync when the app is opened</string>
<string name="sync_trigger_wifi_connect_title">On WiFi Connection</string>
<string name="sync_trigger_wifi_connect_subtitle">Sync when WiFi is connected</string>
<string name="sync_trigger_periodic_title">Automatically every X minutes</string>
<string name="sync_trigger_periodic_subtitle">Regular background sync</string>
<string name="sync_trigger_boot_title">After Device Restart</string>
<string name="sync_trigger_boot_subtitle">Starts background sync after reboot</string>
<string name="sync_manual_hint">Manual sync (toolbar/pull-to-refresh) is also available.</string>
<string name="sync_manual_hint_disabled">Sync is not available in offline mode.</string>
<string name="sync_offline_mode_title">Offline Mode</string>
<string name="sync_offline_mode_message">You are using the app in offline mode. Set up a server to synchronize notes.</string>
<string name="sync_offline_mode_button">Set Up Server</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>
<!-- ============================= -->
<!-- SETTINGS - BACKUP -->
<!-- ============================= -->
<string name="backup_settings_title">Backup &amp; Restore</string>
<string name="backup_restore_title">Backup &amp; Restore</string>
<string name="backup_auto_info">📦 A safety backup is automatically created before each restore.</string>
<string name="backup_local_section">Local Backup</string>
<string name="backup_create">💾 Create Backup</string>
<string name="backup_restore_file">📂 Restore from File</string>
<string name="backup_server_section">Server Backup</string>
<string name="backup_restore_server">☁️ Restore from Server</string>
<string name="backup_restore_dialog_title">⚠️ Restore Backup?</string>
<string name="backup_restore_source">Source: %s</string>
<string name="backup_restore_source_file">Local File</string>
<string name="backup_restore_source_server">WebDAV Server</string>
<string name="backup_restore_mode_label">Restore Mode:</string>
<string name="backup_mode_merge_title">⚪ Merge (Default)</string>
<string name="backup_mode_merge_subtitle">Add new, keep existing</string>
<string name="backup_mode_merge_full">⚪ Merge (Default)\n → Add new, keep existing</string>
<string name="backup_mode_replace_title">⚪ Replace</string>
<string name="backup_mode_replace_subtitle">Delete all &amp; import backup</string>
<string name="backup_mode_replace_full">⚪ Replace\n → Delete all &amp; import backup</string>
<string name="backup_mode_overwrite_title">⚪ Overwrite duplicates</string>
<string name="backup_mode_overwrite_subtitle">Backup wins on conflicts</string>
<string name="backup_mode_overwrite_full">⚪ Overwrite duplicates\n → Backup wins on conflicts</string>
<string name="backup_restore_info"> A safety backup will be automatically created before restoring.</string>
<string name="backup_restore_button">Restore</string>
<!-- Legacy -->
<string name="backup_restore_warning">⚠️ Warning:\n\nRestoring will overwrite ALL local notes with data from the server. This action cannot be undone!</string>
<string name="restore_from_server">Restore from Server</string>
<string name="restore_confirmation_title">⚠️ Restore from Server?</string>
<string name="restore_confirmation_message">WARNING: All local notes will be deleted and replaced with notes from the server.\n\nThis action cannot be undone!</string>
<string name="restore_button">Restore</string>
<string name="restore_progress">Restoring notes…</string>
<string name="restore_success">✓ %d notes restored</string>
<string name="restore_error">Error: %s</string>
<!-- ============================= -->
<!-- SETTINGS - DEBUG -->
<!-- ============================= -->
<string name="debug_settings_title">Debug &amp; Diagnostics</string>
<string name="debug_file_logging_title">File Logging</string>
<string name="debug_file_logging_subtitle">Save sync logs to file</string>
<string name="debug_privacy_info">🔒 Privacy: Logs are only stored locally on your device and are never sent to external servers. Logs contain sync activities for troubleshooting. You can delete or export them at any time.</string>
<string name="debug_log_actions_section">Log Actions</string>
<string name="debug_export_logs">📤 Export &amp; Share Logs</string>
<string name="debug_logs_subject">SimpleNotes Sync Logs</string>
<string name="debug_logs_share_via">Share logs via…</string>
<string name="debug_delete_logs">🗑️ Delete Logs</string>
<string name="debug_delete_logs_title">Delete logs?</string>
<string name="debug_delete_logs_message">All saved sync logs will be permanently deleted.</string>
<!-- Legacy -->
<string name="file_logging_privacy_notice"> Privacy: Logs are only stored locally on your device and are never sent to external servers. Logs contain sync activities for troubleshooting. You can delete or export them at any time.</string>
<!-- ============================= -->
<!-- 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>
<!-- ============================= -->
<!-- SETTINGS - ABOUT -->
<!-- ============================= -->
<string name="about_settings_title">About this App</string>
<string name="about_app_name">Simple Notes Sync</string>
<string name="about_version">Version %1$s (%2$d)</string>
<string name="about_links_section">Links</string>
<string name="about_github_title">GitHub Repository</string>
<string name="about_github_subtitle">Source code, issues &amp; documentation</string>
<string name="about_developer_title">Developer</string>
<string name="about_developer_subtitle">GitHub Profile: @inventory69</string>
<string name="about_license_title">License</string>
<string name="about_license_subtitle">MIT License - Open Source</string>
<string name="about_privacy_title">🔒 Privacy</string>
<string name="about_privacy_text">This app collects no data. All notes are stored only locally on your device and on your own WebDAV server. No telemetry, no ads.</string>
<!-- ============================= -->
<!-- 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>
<!-- ============================= -->
<!-- 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> </resources>

View File

@@ -38,7 +38,7 @@
<!-- Splash Screen Theme (Android 12+) --> <!-- Splash Screen Theme (Android 12+) -->
<style name="Theme.SimpleNotes.Splash" parent="Theme.SplashScreen"> <style name="Theme.SimpleNotes.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">?attr/colorPrimary</item> <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="windowSplashScreenAnimationDuration">500</item>
<item name="postSplashScreenTheme">@style/Theme.SimpleNotes</item> <item name="postSplashScreenTheme">@style/Theme.SimpleNotes</item>
</style> </style>

View File

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

View File

@@ -2,6 +2,7 @@
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) 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.ktlint) apply false
alias(libs.plugins.detekt) apply false alias(libs.plugins.detekt) apply false
} }

View File

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

View File

@@ -11,6 +11,11 @@ activity = "1.8.0"
constraintlayout = "2.1.4" constraintlayout = "2.1.4"
ktlint = "12.1.0" ktlint = "12.1.0"
detekt = "1.23.4" 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] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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" } material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 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" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }

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

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

View File

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

View File

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

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

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

View File

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

View File

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

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