91 Commits

Author SHA1 Message Date
inventory69
5764e8c0ec Release v1.8.1: Checklist Sorting, Sync Improvements & UX Polish 2026-02-11 22:01:22 +01:00
inventory69
b1e7f1750e chore: update screenshots for fdroid metadata in both de-DE and en-US 2026-02-11 21:54:47 +01:00
inventory69
da371436cd fix(editor): IMPL_15 - Add Item respects separator position in checklist
Changes:
- NoteEditorViewModel.kt: Add calculateInsertIndexForNewItem() helper method
- NoteEditorViewModel.kt: Rewrite addChecklistItemAtEnd() to insert before
  first checked item (MANUAL/UNCHECKED_FIRST) instead of appending at list end
- NoteEditorViewModel.kt: Add cross-boundary guard to addChecklistItemAfter()
  preventing new unchecked items from being inserted inside checked section
- ChecklistSortingTest.kt: Add ChecklistSortOption import
- ChecklistSortingTest.kt: Add 10 IMPL_15 unit tests covering all sort modes,
  edge cases (empty list, all checked, no checked), and position stability

Root cause: addChecklistItemAtEnd() appended new unchecked items at the end
of the flat list, after checked items. The UI splits items by count
(subList(0, uncheckedCount)), not by isChecked state — causing checked items
to appear above the separator and new items below it.

Fix: Insert new items at the semantically correct position per sort mode.
MANUAL/UNCHECKED_FIRST: before first checked item (above separator).
All other modes: at list end (no separator visible, no visual issue).

All 19 unit tests pass (9 existing + 10 new). No UI changes required.
2026-02-11 21:34:18 +01:00
inventory69
cf54f44377 docs(v1.8.1): comprehensive metadata & documentation update for F-Droid release
Complete overhaul of all metadata and documentation for v1.8.1:

F-Droid Critical:
- Fix broken umlauts in de-DE/10.txt changelog (für, hinzugefügt, aufgeräumt)
- Shorten de-DE/7.txt changelog for brevity
- Remove trailing newline from en-US/title.txt

Version & Accuracy:
- Update README badges (Android 7.0+) and footer (v1.8.1)
- Add widgets, sorting, parallel sync to highlights
- Fix APK names in workflows (removed "universal")
- Update cache action v3 → v4 in PR workflow
- Fix CONTRIBUTING.md filename references
- Update QUICKSTART guides (APK name, typo, Android version, docs links)

Feature Documentation:
- Update full_description.txt (both locales) - remove NEW/NEU labels, add v1.8.0+ features
- Major FEATURES.md update - add Views & Layout, Widgets sections, updated tech stack
- Add UPCOMING.md v1.7.0-1.8.1 as released sections
- Update DOCS.md roadmap references and dates to Feb 2026
- Create missing en-US changelogs 1.txt, 2.txt

Supplementary Fixes:
- Update BACKUP.md - add encryption docs, fix cross-references
- Add CHANGELOG.md reference links v1.2.1-v1.8.1 (15 missing)
- Fix DEBUG_APK.md duplicate header
- Rewrite fastlane/README.md with both locales and limits table
- Create SELF_SIGNED_SSL.de.md (full German translation)

Affects: 26 files across READMEs, docs/, fastlane/, workflows
2026-02-11 15:45:51 +01:00
inventory69
bc3f137669 docs: add v1.8.1 changelogs and F-Droid release notes
Changes:
- CHANGELOG.md: Add v1.8.1 section (Bug Fixes, New Features, Improvements, Code Quality)
- CHANGELOG.de.md: Add v1.8.1 section (German translation)
- fastlane/en-US/changelogs/21.txt: F-Droid release notes (EN, 496 chars)
- fastlane/de-DE/changelogs/21.txt: F-Droid release notes (DE, 493 chars)

Changelog covers all 13 branch commits:
- 4 bug fixes (sort persistence, widget scroll, auto-sync toast, drag flicker)
- 4 new features (widget sorting, preview sorting, auto-scroll, cross-boundary drag)
- 3 improvements (sync rate-limiting, toast→banner migration, ProGuard audit)
- 1 code quality (detekt compliance, 0 issues)

F-Droid changelogs stay under 500-char limit.
In-app assets are auto-generated by copyChangelogsToAssets build task.
2026-02-11 11:39:21 +01:00
inventory69
1a6617a4ec chore(quality): resolve all detekt findings for v1.8.1 release
Changes:
- UpdateChangelogSheet.kt: Remove 3 unused imports (Intent, Uri, withStyle)
- NoteEditorScreen.kt: Extract DRAGGING_ELEVATION_DP and AUTO_SCROLL_DELAY_MS constants
- NoteEditorScreen.kt: Add @Suppress("LongParameterList") to DraggableChecklistItem
- NoteEditorViewModel.kt: Refactor loadNote() into loadExistingNote(), loadChecklistData(), initNewNote()
- NoteEditorViewModel.kt: Extract parseSortOption() utility for safe enum parsing
- NoteEditorViewModel.kt: Fix NestedBlockDepth and SwallowedException findings
- ChecklistPreviewHelper.kt: Suppress SwallowedException for intentional fallback
- NoteWidgetActions.kt: Suppress SwallowedException for intentional fallback
- NoteWidgetContent.kt: Suppress SwallowedException in ChecklistCompactView + ChecklistFullView
- SyncWorker.kt: Suppress LongMethod on doWork() (linear flow, debug logging)
- detekt.yml: Update maxIssues from 100 to 0, update version comment

All 12 detekt findings resolved. Build compiles clean, 0 lint errors.
2026-02-11 11:22:10 +01:00
inventory69
27e6b9d4ac refactor(ui): IMPL_12 - Migrate Toast notifications to Banner system
Changes:
- SyncProgress.kt: Add INFO phase to SyncPhase enum
- SyncProgress.kt: Update isVisible to always show INFO phase
- SyncStateManager.kt: Add showInfo() and showError() methods
- SyncProgressBanner.kt: Add INFO icon (Icons.Outlined.Info) + secondaryContainer color
- SyncProgressBanner.kt: Add INFO branch in phaseToString()
- ComposeMainActivity.kt: Add auto-hide for INFO phase (2.5s delay)
- MainViewModel.kt: Replace Toast with Banner in deleteNoteFromServer()
- MainViewModel.kt: Replace Toast with Banner in deleteMultipleNotesFromServer()
- NoteEditorViewModel.kt: Import SyncStateManager
- NoteEditorViewModel.kt: Add Banner feedback (INFO/ERROR) in editor deleteNote()
- NoteEditorViewModel.kt: Remove NOTE_SAVED toast (NavigateBack is sufficient)
- NoteEditorViewModel.kt: Remove NOTE_DELETED toast (already done)

All non-interactive notifications now use the unified Banner system.
Server-delete results show as INFO (success) or ERROR (failure) banners.
Snackbars with Undo actions and NOTE_IS_EMPTY validation toasts remain unchanged.
2026-02-11 11:00:52 +01:00
inventory69
3e4b1bd07e feat(editor): IMPL_05 - Auto-scroll on line wrap in checklist editor
Changes:
- ChecklistItemRow.kt: Import mutableIntStateOf
- ChecklistItemRow.kt: Add onHeightChanged callback parameter
- ChecklistItemRow.kt: Track lastLineCount state for line wrap detection
- ChecklistItemRow.kt: Detect line count increase in onTextLayout and trigger callback
- NoteEditorScreen.kt: Add scrollToItemIndex state in ChecklistEditor
- NoteEditorScreen.kt: Add LaunchedEffect for auto-scroll on height change
- NoteEditorScreen.kt: Pass onHeightChanged callback to ChecklistItemRow

When typing in a checklist item at the bottom of the list, the editor now
automatically scrolls to keep the cursor visible when text wraps to a new line.
2026-02-11 10:42:08 +01:00
inventory69
66d98c0cad feat(widget): IMPL_04 - Add sorting & separator to widget checklists
Changes:
- NoteWidgetContent.kt: Import sortChecklistItemsForPreview and ChecklistSortOption
- NoteWidgetContent.kt: Add WidgetCheckedItemsSeparator composable
- NoteWidgetContent.kt: Apply note.checklistSortOption in ChecklistCompactView
- NoteWidgetContent.kt: Apply note.checklistSortOption in ChecklistFullView
- NoteWidgetContent.kt: Integrate separator for MANUAL/UNCHECKED_FIRST modes
- NoteWidgetContent.kt: Change  to ☑️ emoji (IMPL_06)
- NoteWidgetActions.kt: Add auto-sort after toggle in ToggleChecklistItemAction

Widgets now match editor behavior: apply saved sort option, show separator
between unchecked/checked items, and auto-sort when toggling checkboxes.
2026-02-11 10:32:38 +01:00
inventory69
c72b3fe1c0 fix(widget): IMPL_09 - Fix scroll bug in standard widget size (3x2)
Changes:
- WidgetSizeClass.kt: Add NARROW_SCROLL and WIDE_SCROLL enum values
- NoteWidget.kt: Add SIZE_NARROW_SCROLL (110x150dp) and SIZE_WIDE_SCROLL (250x150dp) breakpoints
- NoteWidget.kt: Update sizeMode to include new breakpoints
- NoteWidgetContent.kt: Add WIDGET_HEIGHT_SCROLL_THRESHOLD (150dp)
- NoteWidgetContent.kt: Update toSizeClass() to classify 150dp+ height as SCROLL sizes
- NoteWidgetContent.kt: Combine NARROW_SCROLL + NARROW_TALL into single when branch
- NoteWidgetContent.kt: Combine WIDE_SCROLL + WIDE_TALL into single when branch
- NoteWidgetContent.kt: Remove clickable modifier from unlocked checklists to enable scrolling
- Locked checklists retain clickable for options, unlocked checklists scroll freely
2026-02-11 10:20:48 +01:00
inventory69
a1a574a725 fix(sync): IMPL_08B - Bypass global cooldown for onSave syncs
Changes:
- Constants.kt: Add SYNC_ONSAVE_TAG constant for worker tagging
- NoteEditorViewModel.triggerOnSaveSync(): Tag SyncWorker with "onsave" tag
- SyncWorker.doWork(): Skip global cooldown check for onSave-tagged workers
- onSave retains 3 protection layers: 5s own throttle, tryStartSync mutex, syncMutex
- Auto/WiFi/periodic syncs still respect 30s global cooldown
2026-02-11 10:15:33 +01:00
inventory69
7dbc06d102 fix(editor): IMPL_03-FIX - Fix sort option not applied when reopening checklist
Changes:
- NoteEditorViewModel.sortChecklistItems(): Use _lastChecklistSortOption.value instead of always sorting unchecked-first
- Supports all sort modes: MANUAL, UNCHECKED_FIRST, CHECKED_FIRST, ALPHABETICAL_ASC/DESC
- reloadFromStorage() also benefits from fix (uses same sortChecklistItems method)
- Root cause: loadNote() correctly restored _lastChecklistSortOption but sortChecklistItems() ignored it
2026-02-11 09:35:25 +01:00
inventory69
2c43b47e96 feat(preview): IMPL_03 - Add checklist sorting in main preview
Changes:
- Note.kt: Add checklistSortOption field (String?, nullable for backward compatibility)
- Note.fromJson(): Parse checklistSortOption from JSON
- Note.fromMarkdown(): Parse sort field from YAML frontmatter
- Note.toMarkdown(): Include sort field in YAML frontmatter for checklists
- ChecklistPreviewHelper.kt: New file with sortChecklistItemsForPreview() and generateChecklistPreview()
- NoteEditorViewModel.loadNote(): Load and restore checklistSortOption from note
- NoteEditorViewModel.saveNote(): Persist checklistSortOption when saving (both new and existing notes)
- NoteCardCompact.kt: Use generateChecklistPreview() for sorted preview (includes IMPL_06 emoji change)
- NoteCardGrid.kt: Use generateChecklistPreview() for sorted preview (includes IMPL_06 emoji change)
2026-02-11 09:30:18 +01:00
inventory69
ffe0e46e3d feat(sync): IMPL_08 - Add global sync rate-limiting & battery protection
Changes:
- Constants.kt: Add KEY_LAST_GLOBAL_SYNC_TIME and MIN_GLOBAL_SYNC_INTERVAL_MS (30s)
- SyncStateManager.kt: Add canSyncGlobally() and markGlobalSyncStarted() methods
- MainViewModel.triggerAutoSync(): Check global cooldown before individual throttle
- MainViewModel.triggerAutoSync(): Mark global sync start before viewModelScope.launch
- MainViewModel.triggerManualSync(): Mark global sync start after tryStartSync()
- SyncWorker.doWork(): Add SyncStateManager.tryStartSync() coordination with silent=true
- SyncWorker.doWork(): Check global cooldown before expensive server checks
- SyncWorker.doWork(): Call markCompleted()/markError() to update SyncStateManager
- WifiSyncReceiver: Add global cooldown check and KEY_SYNC_TRIGGER_WIFI_CONNECT validation
2026-02-11 09:13:06 +01:00
inventory69
fe6935a6b7 fix(sync): IMPL_11 - Remove unexpected toast on auto-sync
- Remove toast emission in triggerAutoSync() (L697)
- Silent sync now fully respects silent=true flag
- Notes list still updates via loadNotes(), no UX loss
- Toast was bypassing SyncStateManager's silent handling

Changes:
- android/app/.../ui/main/MainViewModel.kt: Remove _showToast.emit() on auto-sync success
2026-02-11 09:06:30 +01:00
inventory69
7b558113cf feat(editor): IMPL_14 - Separator drag cross-boundary with auto-toggle
Separator is now its own LazyColumn item instead of being rendered
inline inside the first checked item's composable. This fixes:

Bug A: Separator disappearing during drag (was hidden as workaround
for height inflation). Now always visible with primary color highlight.

Bug B: Cross-boundary move blocked (isChecked != toItem.isChecked
returned early). Now auto-toggles isChecked when crossing boundary
— like Google Tasks.

Bug C: Drag flicker at separator boundary (draggingItemIndex updated
even when onMove was a no-op → oscillation). Index remapping via
visualToDataIndex()/dataToVisualIndex() ensures correct data indices.

Architecture changes:
- DragDropListState: separatorVisualIndex, index remapping functions,
  isAdjacentSkippingSeparator() skips separator in swap detection
- NoteEditorScreen: Extracted DraggableChecklistItem composable,
  3 LazyColumn blocks (unchecked items, separator, checked items),
  removed hardcoded AnimatedVisibility(visible=true) wrapper
- NoteEditorViewModel: moveChecklistItem() allows cross-boundary
  moves with automatic isChecked toggle
- CheckedItemsSeparator: isDragActive parameter for visual feedback

Files changed:
- DragDropListState.kt (+56 lines)
- NoteEditorScreen.kt (refactored, net +84 lines)
- NoteEditorViewModel.kt (simplified cross-boundary logic)
- CheckedItemsSeparator.kt (drag-awareness parameter)
2026-02-11 08:56:48 +01:00
inventory69
24fe32a973 fix(editor): IMPL_13 - Fix gradient regression & drag-flicker
Bug A1: Gradient never showed because maxLines capped lineCount,
making lineCount > maxLines impossible. Fixed by always setting
maxLines = Int.MAX_VALUE and using hasVisualOverflow + heightIn(max)
for clipped overflow detection.

Bug A2: derivedStateOf captured stale val from Detekt refactor
(commit 1da1a63). Replaced with direct computation.

Bug B1: animateItem() on dragged item conflicted with manual offset
(from IMPL_017 commit 900dad7). Fixed with conditional Modifier:
if (!isDragging) Modifier.animateItem() else Modifier.

Bug B2: Item size could change during drag. Added size snapshot
in DragDropListState.onDragStart for stable endOffset calculation.

Files changed:
- ChecklistItemRow.kt: hasVisualOverflow, direct lineCount check,
  maxLines=Int.MAX_VALUE always, heightIn(max=collapsedHeightDp)
- NoteEditorScreen.kt: conditional animateItem on isDragging
- DragDropListState.kt: draggingItemSize snapshot for stable drag
2026-02-11 08:49:13 +01:00
inventory69
b5a3a3c096 Merge branch 'main' into release/v1.8.1 2026-02-11 08:39:21 +01:00
inventory69
63561737e1 feat(proguard): IMPL_07 - Add missing Widget & Compose ProGuard rules
- Add Widget ActionCallback rules to prevent R8 removal (fixes potential widget crashes)
- Add Glance state preservation rules for widget preferences
- Add Compose TextLayout rules to prevent gradient regression in release builds
- Bump version to 1.8.1 (versionCode 21, versionName "1.8.1")

Fixes:
- Widget actions (ToggleChecklistItemAction, etc.) could be stripped by R8
- Glance widget state (DataStore-based) needs reflection protection
- Compose onTextLayout callbacks could be optimized away causing gradient issues

Files changed:
- android/app/proguard-rules.pro: Added v1.8.1 rules section (+17 lines)
- android/app/build.gradle.kts: Version bump 1.8.0 → 1.8.1
2026-02-10 23:10:43 +01:00
inventory69
849e4080d6 fix(v1.8.0): CRITICAL - Fix ProGuard obfuscation causing data loss
CRITICAL BUGFIX:
- Fixed incorrect ProGuard class path for Note\$Companion\$NoteRaw
- Original v1.8.0 had specific -keep rules that didn't match actual JVM class name
- R8 obfuscated all NoteRaw fields (id→a, title→b, ...) → Gson parse failure
- ALL notes appeared lost after update (but were safe on disk/server)
- Reverted to safe broad rule: -keep class dev.dettmer.simplenotes.** { *; }

Added safety-guards in detectServerDeletions():
- Abort if serverNoteIds is empty (network error, not mass deletion)
- Abort if ALL local notes would be marked deleted (almost certainly a bug)

- Tested: Update from v1.7.2 restores all notes successfully
2026-02-10 18:20:32 +01:00
inventory69
59c417cc4c Merge branch: Release v1.8.0 – Widgets, Sorting & Advanced Sync
🆕 Homescreen Widgets (IMPL_019)
- Jetpack Glance widget framework with 5 responsive size classes
- Interactive checklist checkboxes with immediate server sync
- Material You dynamic colors, configurable opacity (0-100%)
- Lock widget toggle, read-only mode, configuration activity
- Auto-refresh after sync, resource cleanup fixes

📊 Note & Checklist Sorting (IMPL_020, IMPL_017)
- Note sorting: Updated, Created, Title (A-Z/Z-A), Type
- Checklist sorting: Manual, Alphabetical, Unchecked First, Checked Last
- Visual separator between unchecked/checked items with count
- Persistent sort preferences, drag within groups only
- 9 unit tests for sorting logic validation

🔄 Sync Improvements (IMPL_016, IMPL_021, IMPL_022, IMPL_006, IMPL_005)
- Server deletion detection with DELETED_ON_SERVER status
- Sync status legend dialog (?) in TopAppBar
- Live sync progress UI with phase indicators
- Parallel downloads (configurable 1-10 simultaneous)
- Multi-client deletion enhancement

 UX Improvements
- IMPL_018: Checklist overflow gradient, auto-expand/collapse
- IMPL_023b: Drag & Drop flicker fix (straddle-target-center)
- IMPL_01: Smooth language switching without activity recreate
- IMPL_02: Grid view as default for new installations
- IMPL_03: Sync settings restructured (Triggers & Performance)
- IMPL_04: Backup progress card with 3-phase status system
- IMPL_06: Post-update changelog dialog (F-Droid changelogs as source)
- IMPL_07: Changelog link in About screen
- IMPL_08: Widget text display fix (line-based rendering)

🔧 Code Quality & Performance
- All 22 Detekt warnings resolved, 0 Lint errors
- ProGuard/R8 rules optimized: APK 5.0 MB → 4.8 MB
- ClickableText deprecation replaced with LinkAnnotation API
- Comprehensive CHANGELOG.md updates (EN + DE)

20 commits, 23 features implemented.
2026-02-10 17:03:34 +01:00
inventory69
90f1f48810 perf(v1.8.0): optimize ProGuard/R8 rules for smaller APK size
- Replace broad '-keep class dev.dettmer.simplenotes.** { *; }' with targeted rules
- Keep only Gson data models and enum values that require reflection
- Allow R8 to shrink/obfuscate remaining app code
- Update rules for: Note, ChecklistItem, DeletionTracker, BackupData models
- Keep enum values() and valueOf() for state serialization
- Remove unnecessary keep rules for non-reflection classes
- Add structured comments for maintainability

APK size reduced from 5.0 MB → 4.8 MB (200 KB saved).
R8 now has better optimization opportunities while maintaining functionality.
All unit tests pass, lint reports clean (0 errors, 0 warnings).

Build: BUILD SUCCESSFUL - 4.8M app-fdroid-release.apk
2026-02-10 17:01:01 +01:00
inventory69
e2bce099f3 fix(v1.8.0): IMPL_06 Resolve ClickableText deprecation warning
- Replace deprecated ClickableText with modern Text + LinkAnnotation API
- Update imports: remove ClickableText, add LinkAnnotation & withLink
- Use TextLinkStyles for styled clickable links in changelog
- Maintain URL click functionality without deprecated API
- Update CHANGELOG.md with missing IMPL_04 & IMPL_06 feature details
- Update CHANGELOG.de.md with matching German translations
- Add Backup Settings Progress improvements to changelog
- Add Post-Update Changelog Dialog documentation
- Verify all 23 v1.8.0 features documented in changelog

Resolves deprecation warning in Kotlin/Compose build. ClickableText is
deprecated in favor of Text with LinkAnnotation for link handling. Migration
maintains full functionality while using modern Compose APIs.

Changelogs updated to include all major features from feature branch:
IMPL_04 Backup Progress UI improvements and IMPL_06 Post-Update Changelog,
marking v1.8.0 feature-complete with comprehensive documentation.

Build: BUILD SUCCESSFUL - 0 Lint errors + 0 Deprecation warnings
2026-02-10 16:47:02 +01:00
inventory69
661d9e0992 feat(v1.8.0): IMPL_06 Post-Update Changelog Dialog
- Add UpdateChangelogSheet.kt with Material 3 ModalBottomSheet
- Show changelog automatically on first launch after update
- Load changelog from F-Droid metadata via assets (single source of truth)
- Add copyChangelogsToAssets Gradle task (runs before preBuild)
- Copy F-Droid changelogs to /assets/changelogs/{locale}/ at build time
- Store last shown version in SharedPreferences (last_shown_changelog_version)
- Add ClickableText for GitHub CHANGELOG.md link (opens in browser)
- Add update_changelog_title and update_changelog_dismiss strings (EN + DE)
- Add KEY_LAST_SHOWN_CHANGELOG_VERSION constant
- Integrate UpdateChangelogSheet in ComposeMainActivity
- Add Test Mode in Debug Settings with "Reset Changelog Dialog" button
- Add SettingsViewModel.resetChangelogVersion() for testing
- Add test mode strings (debug_test_section, debug_reset_changelog, etc.)
- Update F-Droid changelogs (20.txt) with focus on key features
- Add exception logging in loadChangelog() function
- Add /app/src/main/assets/changelogs/ to .gitignore
- Dialog dismissable via button or swipe gesture
- One-time display per versionCode

Adds post-update changelog dialog with automatic F-Droid changelog reuse.
F-Droid changelogs are the single source of truth for both F-Droid metadata
and in-app display. Gradle task copies changelogs to assets at build time.
Users see localized changelog (DE/EN) based on app language.
2026-02-10 16:38:39 +01:00
inventory69
3e946edafb feat(v1.8.0): IMPL_04 Backup Settings - Improved Progress Display
- Remove in-button spinner from SettingsButton and SettingsOutlinedButton
- Buttons keep text during loading and become disabled (enabled=false)
- Add BackupProgressCard component with LinearProgressIndicator + status text
- Add backupStatusText StateFlow in SettingsViewModel
- Add 3-phase status system: In Progress → Completion → Clear
- Show success completion status for 2 seconds ("Backup created!", etc.)
- Show error status for 3 seconds ("Backup failed", etc.)
- Status auto-clears after delay (no manual intervention needed)
- Update createBackup() with completion delay and status messages
- Update restoreFromFile() with completion delay and status messages
- Update restoreFromServer() with completion delay and status messages
- Remove all redundant toast messages from backup/restore operations
- Add exception logging (Logger.e) to replace swallowed exceptions
- Integrate BackupProgressCard in BackupSettingsScreen (visible during operations)
- Add delayed restore execution (200ms) to ensure dialog closes before progress shows
- Add DIALOG_CLOSE_DELAY_MS constant (200ms)
- Add STATUS_CLEAR_DELAY_SUCCESS_MS constant (2000ms)
- Add STATUS_CLEAR_DELAY_ERROR_MS constant (3000ms)
- Add 6 new backup completion/error strings (EN + DE)
- Import kotlinx.coroutines.delay for status delay functionality
2026-02-10 15:24:32 +01:00
inventory69
4a621b622b chore(v1.8.0): IMPL_05 Version Bump - v1.7.2 → v1.8.0
- Update versionCode from 19 to 20
- Update versionName from "1.7.2" to "1.8.0"
- Create F-Droid changelog for versionCode 20 (EN + DE)
- Update CHANGELOG.md with comprehensive v1.8.0 entry (all 16 features)
- Update CHANGELOG.de.md with German v1.8.0 entry
- Add commit hashes to all changelog entries for traceability

Major feature release: Widgets with interactive checklists, note/checklist
sorting, server deletion detection, sync status legend, live sync progress,
parallel downloads, checklist UX improvements, and widget text display fix.
Complete changelog lists all features with commit references.
2026-02-10 14:53:22 +01:00
inventory69
49810ff6f1 feat(v1.8.0): IMPL_07 About Screen - Add Changelog Link
- Add History icon import to AboutScreen
- Add changelogUrl pointing to GitHub CHANGELOG.md
- Add about_changelog_title and about_changelog_subtitle strings (DE + EN)
- Insert AboutLinkItem for Changelog between License and Privacy sections

Provides users easy access to full version history directly from the
About screen. Link opens CHANGELOG.md on GitHub in external browser.
2026-02-10 14:42:00 +01:00
inventory69
d045d4d3db feat(v1.8.0): IMPL_08 Widget - Fix Text Note Display Bug
- Change TextNoteFullView() to render individual lines instead of paragraphs
- Preserve empty lines as 8dp spacers for paragraph separation
- Add maxLines=5 per line item to prevent single-item overflow
- Increase preview limits: compact 100→120, full 200→300 chars
- Increase preview maxLines: compact 2→3, full 3→5 lines

Fixes widget text truncation bug where long text notes only showed
3 lines regardless of widget size. By splitting into individual line
items, LazyColumn can properly scroll through all content. Each line
is rendered as a separate item that fits within the visible area.
2026-02-10 14:37:04 +01:00
inventory69
eaac5a0775 feat(v1.8.0): IMPL_03 Sync Settings - Trigger Visual Separation
- Restructure settings into two clear sections: Triggers & Performance
- Add sync_section_triggers and sync_section_network_performance strings (DE + EN)
- Group all 5 triggers together (Instant: onSave/onResume, Background: WiFi/Periodic/Boot)
- Move WiFi-Only + Parallel Downloads to separate "Network & Performance" section
- Place Manual Sync info card after triggers, before Performance section

Improves UX by logically grouping related settings. Triggers are now
in one cohesive block, separated from network/performance options.
Easier to find and understand sync configuration.
2026-02-10 14:30:56 +01:00
inventory69
68584461b3 feat(v1.8.0): IMPL_02 Display Settings - Grid as Default
- Change DEFAULT_DISPLAY_MODE from 'list' to 'grid'
- Update display_mode_info strings (EN + DE)
- Remove outdated 'full width' reference from info text
- New description reflects actual two-column grid layout

Only affects new installations - existing users keep their preference.
Grid view provides better overview and cleaner visual hierarchy.
2026-02-10 14:19:11 +01:00
inventory69
881c0fd0fa feat(v1.8.0): IMPL_01 Language Settings - Smooth Language Switching
- Prevent activity recreate during language changes via configChanges
- Add onConfigurationChanged() handler in ComposeSettingsActivity
- Simplify setAppLanguage() - let system handle locale change smoothly
- Update language_info strings to reflect smooth transition
- Remove unused Activity parameter and imports

Fixes flicker during language switching by handling configuration
changes instead of recreating the entire activity. Compose recomposes
automatically when locale changes, providing seamless UX.
2026-02-10 14:02:42 +01:00
inventory69
1da1a63566 chore(v1.8.0): Resolve all Detekt code quality warnings
Fixes 22 Detekt warnings across the codebase:

- Remove 7 unused imports from UI components
- Add @Suppress annotations for 4 preview functions
- Define constants for 5 magic numbers
- Optimize state reads with derivedStateOf (2 fixes)
- Add @Suppress for long parameter list
- Move WidgetSizeClass to separate file
- Reformat long line in NoteEditorScreen
- Suppress unused parameter and property annotations
- Suppress WebDavSyncService method length/complexity with TODO for v1.9.0 refactoring

Test results:
- detekt: 0 warnings
- lintFdroidDebug: 0 errors
- Build successful

Progress v1.8.0: 0 Lint errors + 0 Detekt warnings complete
2026-02-10 12:44:14 +01:00
inventory69
96c819b154 feat(v1.8.0): IMPL_020 Note & Checklist Sorting
- Add SortOption enum for note sorting (Updated, Created, Title, Type)
- Add SortDirection enum with ASCENDING/DESCENDING and toggle()
- Add ChecklistSortOption enum for in-editor sorting (Manual, Alphabetical, Unchecked/Checked First)
- Implement persistent note sort preferences in SharedPreferences
- Add SortDialog for main screen with sort option and direction selection
- Add ChecklistSortDialog for editor screen with current sort option state
- Implement sort logic in MainViewModel with combined sortedNotes StateFlow
- Implement sort logic in NoteEditorViewModel with auto-sort for MANUAL and UNCHECKED_FIRST
- Add separator display logic for MANUAL and UNCHECKED_FIRST sort options
- Add 16 sorting-related strings (English and German)
- Update Constants.kt with sort preference keys
- Update MainScreen.kt, NoteEditorScreen.kt with sort UI integration
2026-02-10 11:30:06 +01:00
inventory69
539987f2ed feat(v1.8.0): IMPL_019 Homescreen Widgets Implementation
Complete Jetpack Glance Widget Framework
- Implement NoteWidget with 5 responsive size classes
- Support TEXT and CHECKLIST note types
- Material You dynamic colors integration
- Interactive checklist checkboxes in large layouts
- Read-only mode for locked widgets

Widget Configuration System
- NoteWidgetConfigActivity for placement and reconfiguration (Android 12+)
- NoteWidgetConfigScreen with note selection and settings
- Lock widget toggle to prevent accidental edits
- Background opacity slider with 0-100% range
- Auto-save on back navigation plus Save FAB

Widget State Management
- NoteWidgetState keys for per-instance persistence via DataStore
- NoteWidgetActionKeys for type-safe parameter passing
- Five top-level ActionCallback classes
  * ToggleChecklistItemAction (updates checklist and marks for sync)
  * ToggleLockAction (toggle read-only mode)
  * ShowOptionsAction (show permanent options bar)
  * RefreshAction (reload from storage)
  * OpenConfigAction (launch widget config activity)

Responsive Layout System
- SMALL (110x80dp): Title only
- NARROW_MEDIUM (110x110dp): Preview or compact checklist
- NARROW_TALL (110x250dp): Full content display
- WIDE_MEDIUM (250x110dp): Preview layout
- WIDE_TALL (250x250dp): Interactive checklist

Interactive Widget Features
- Tap content to open editor (unlock) or show options (lock)
- Checklist checkboxes with immediate state sync
- Options bar with Lock/Unlock, Refresh, Settings, Open in App buttons
- Per-widget background transparency control

Connection Leak Fixes (Part of IMPL_019)
- Override put/delete/createDirectory in SafeSardineWrapper with response.use{}
- Proper resource cleanup in exportAllNotesToMarkdown and syncMarkdownFiles
- Use modern OkHttp APIs (toMediaTypeOrNull, toRequestBody)

UI Improvements (Part of IMPL_019)
- Checkbox toggle includes KEY_LAST_UPDATED to force Glance recomposition
- Note selection in config is visual-only (separate from save)
- Config uses moveTaskToBack() plus FLAG_ACTIVITY_CLEAR_TASK
- Proper options bar with standard Material icons

Resources and Configuration
- 8 drawable icons for widget controls
- Widget metadata file (note_widget_info.xml)
- Widget preview layout for Android 12+ widget picker
- Multi-language strings (English and German)
- Glance Jetpack dependencies version 1.1.1

System Integration
- SyncWorker updates all widgets after sync completion
- NoteEditorViewModel reloads checklist state on resume
- ComposeNoteEditorActivity reflects widget edits
- WebDavSyncService maintains clean connections
- AndroidManifest declares widget receiver and config activity

Complete v1.8.0 Widget Feature Set
- Fully responsive design for phones, tablets and foldables
- Seamless Material Design 3 integration
- Production-ready error handling
- Zero connection leaks
- Immediate UI feedback for all interactions
2026-02-10 10:42:40 +01:00
inventory69
900dad76fe feat(v1.8.0): IMPL_017 Checklist Separator & Sorting - Unchecked/Checked Separation
- Separator component: Visual divider between unchecked and checked items
  * Shows count of completed items with denominator styling
  * Prevents accidental drag across group boundaries
  * Smooth transitions with fade/slide animations

- Sorting logic: Maintains unchecked items first, checked items last
  * Stable sort: Relative order within groups is preserved
  * Auto-updates on item toggle and reordering
  * Validates drag moves to same-group only

- UI improvements: Enhanced LazyColumn animations
  * AnimatedVisibility for smooth item transitions
  * Added animateItem() for LazyColumn layout changes
  * Item elevation during drag state

- Comprehensive test coverage
  * 9 unit tests for sorting logic validation
  * Edge cases: empty lists, single items, mixed groups
  * Verifies order reassignment and group separation

Affected components:
- CheckedItemsSeparator: New UI component for visual separation
- NoteEditorViewModel: sortChecklistItems() method with validation
- NoteEditorScreen: Separator integration & animation setup
- ChecklistSortingTest: Complete test suite with 9 test cases
- Localizations: German & English plurals
2026-02-09 14:09:18 +01:00
inventory69
538a705def feat(v1.8.0): IMPL_023b Drag & Drop Flicker Fix - Straddle-Target-Center Detection
- Swap detection: Changed from midpoint check to straddle-target-center detection
  * Old: Checks if midpoint of dragged item lies within target
  * New: Checks if dragged item spans the midpoint of target
  * Prevents oscillation when items have different sizes

- Adjacency filter: Only adjacent items (index ± 1) as swap candidates
  * Prevents item jumps during fast drag
  * Reduces recalculation of visibleItemsInfo

- Race-condition fix for scroll + move
  * draggingItemIndex update moved after onMove() in coroutine block
  * Prevents inconsistent state between index update and layout change

Affected files:
- DragDropListState.kt: onDrag() method (~10 lines changed)
2026-02-09 13:50:16 +01:00
inventory69
3462f93f25 feat(v1.8.0): IMPL_018 Checklist Long Text UX - Overflow Gradient
- Add OverflowGradient.kt: Reusable Compose component for visual text overflow indicator
- Gradient fade effect shows "more text below" without hard cutoff
- Smooth black-to-transparent gradient (customizable intensity)
- Auto-expands ChecklistItem when focused for full editing
- Collapses back to max 5 lines when focus lost
- Prevents accidental text hiding while editing
- Improved visual feedback for long text items
- Works on all screen sizes and orientations

Closes #IMPL_018
2026-02-09 13:31:20 +01:00
inventory69
bdfc0bf060 feat(v1.8.0): IMPL_005 Parallel Downloads - Performance Optimization
- Add concurrent download support via Kotlin coroutines
- Refactor downloadRemoteNotes() to use async/awaitAll pattern
- Implement configurable parallelism level (default: 3 concurrent downloads)
- Update progress callback for parallel operations tracking
- Add individual download timeout handling
- Graceful sequential fallback on concurrent errors
- Optimize network utilization for faster sync operations
- Preserve conflict detection and state management during parallel downloads

Closes #IMPL_005
2026-02-09 11:17:46 +01:00
inventory69
df37d2a47c feat(v1.8.0): IMPL_006 Sync Progress UI - Complete Implementation
- Add SyncProgress.kt: Data class for entire sync lifecycle UI state
- Add SyncPhase enum: IDLE, PREPARING, UPLOADING, DOWNLOADING, IMPORTING_MARKDOWN, COMPLETED, ERROR
- Rewrite SyncStateManager.kt: SyncProgress (StateFlow) is single source of truth
- Remove pre-set phases: CHECKING_SERVER and SAVING cause flickering
- UPLOADING phase only set when actual uploads happen
- DOWNLOADING phase only set when actual downloads happen
- IMPORTING_MARKDOWN phase only set when feature enabled
- Add onProgress callback to uploadLocalNotes() with uploadedCount/totalToUpload
- Add onProgress callback to downloadRemoteNotes() for actual downloads only
- Progress display: x/y for uploads (known total), count for downloads (unknown)
- Add SyncProgressBanner.kt: Unified banner (replaces dual system)
- Update SyncStatusBanner.kt: Kept for legacy compatibility, only COMPLETED/ERROR
- Update MainViewModel.kt: Remove _syncMessage, add syncProgress StateFlow
- Update MainScreen.kt: Use only SyncProgressBanner (unified)
- Update ComposeMainActivity.kt: Auto-hide COMPLETED (2s), ERROR (4s) via lifecycle
- Add strings.xml (DE+EN): sync_phase_* and sync_wifi_only_error
- Banner appears instantly on sync button click (PREPARING phase)
- Silent auto-sync (onResume) completely silent, errors always shown
- No misleading counters when nothing to sync

Closes #IMPL_006
2026-02-09 10:38:47 +01:00
inventory69
bf7a74ec30 feat(v1.8.0): IMPL_022 Multi-Client Deletion Enhancement
Defensive improvements for server deletion detection:

1. Enhanced logging in detectServerDeletions():
   - Statistics: server/local/synced note counts
   - Summary log when deletions found

2. Explicit documentation:
   - Comment clarifying checklists are included
   - Both Notes and Checklists use same detection mechanism

3. Sync banner now shows deletion count:
   - '3 synced · 2 deleted on server'
   - New strings: sync_deleted_on_server_count (en + de)

4. DELETED_ON_SERVER → PENDING on edit:
   - Verified existing logic works correctly
   - All edited notes → PENDING (re-upload to server)
   - Added comments for clarity

Cross-client analysis confirmed:
-  Android/Desktop/Web deletions detected correctly
- ⚠️ Obsidian .md-only deletions not detected (by design: JSON = source of truth)

IMPL_022_MULTI_CLIENT_DELETION.md
2026-02-09 09:31:27 +01:00
inventory69
07607fc095 feat(v1.8.0): IMPL_021 Sync Status Legend
- New SyncStatusLegendDialog.kt showing all 5 sync status icons with descriptions
- Help button (?) in MainScreen TopAppBar (only visible when sync available)
- Localized strings (English + German) for all 5 status explanations
- Material You design with consistent colors matching NoteCard icons
- Dialog shows: Synced, Pending, Conflict, Local only, Deleted on server

IMPL_021_SYNC_STATUS_LEGEND.md
2026-02-09 09:24:15 +01:00
inventory69
40d7c83c84 feat(v1.8.0): IMPL_016 - Server Deletion Detection
- Add DELETED_ON_SERVER to SyncStatus enum
- Add deletedOnServerCount to SyncResult
- Implement detectServerDeletions() function in WebDavSyncService
- Integrate server deletion detection in downloadRemoteNotes()
- Update UI icons in NoteCard, NoteCardGrid, NoteCardCompact
- Add string resources for deleted_on_server status
- No additional HTTP requests (uses existing PROPFIND data)
- Zero performance impact

Closes #IMPL_016
2026-02-08 23:38:50 +01:00
inventory69
e9e4b87853 chore(release): v1.7.2 - Critical Bugfixes & Performance Improvements
CRITICAL BUGFIXES:
- IMPL_014: JSON/Markdown Timestamp Sync - Server mtime source of truth
- IMPL_015: SyncStatus PENDING Fix - Set before JSON serialization
- IMPL_001: Deletion Tracker Race Condition - Mutex-based sync
- IMPL_002: ISO8601 Timezone Parsing - Multi-format support
- IMPL_003: Memory Leak Prevention - SafeSardine Closeable
- IMPL_004: E-Tag Batch Caching - ~50-100ms performance gain

FEATURES:
- Auto-updating timestamps in UI (every 30s)
- Performance optimizations for Staggered Grid scrolling

BUILD:
- versionCode: 19
- versionName: 1.7.2

This release prepares for a new cross-platform Markdown editor
(Web, Desktop Windows + Linux, Mobile) with proper JSON ↔ Markdown synchronization
and resolves critical sync issues for external editor integration.
2026-02-04 16:08:46 +01:00
inventory69
45f528ea0e merge: fix connection issues and locale bugs (v1.7.1)
## Changes

• Fixed: App crash on Android 9 (Issue #15)
• Fixed: German text showing despite English language setting
• Improved: Sync connection stability (10s timeout)
• Improved: Code quality (removed 65 lines)

## Technical Details

- Increased socket timeout from 1s to 10s
- Fixed hardcoded German strings in UI
- Enhanced locale support with AppCompatDelegate
- Removed unreliable VPN bypass code
2026-02-02 17:25:30 +01:00
inventory69
cb1bc46405 docs: update changelogs for v18 2026-02-02 17:21:14 +01:00
inventory69
0b143e5f0d fix: timeout increase (1s→10s) and locale hardcoded strings
## Changes:

### Timeout Fix (v1.7.2)
- SOCKET_TIMEOUT_MS: 1000ms → 10000ms for more stable connections
- Better error handling in hasUnsyncedChanges(): returns TRUE on error

### Locale Fix (v1.7.2)
- Replaced hardcoded German strings with getString(R.string.*)
- MainActivity, SettingsActivity, MainViewModel: 'Bereits synchronisiert' → getString()
- SettingsViewModel: Enhanced getString() with AppCompatDelegate locale support
- Added locale debug logging in MainActivity

### Code Cleanup
- Removed non-working VPN bypass code:
  - WiFiSocketFactory class
  - getWiFiInetAddressInternal() function
  - getOrCacheWiFiAddress() function
  - sessionWifiAddress cache variables
  - WiFi-binding logic in createSardineClient()
- Kept isVpnInterfaceActive() for logging/debugging

Note: VPN users should configure their VPN to exclude private IPs (e.g., 192.168.x.x)
for local server connectivity. App-level VPN bypass is not reliable on Android.
2026-02-02 17:14:23 +01:00
inventory69
cf9695844c chore: Add SystemForegroundService to manifest and Feature Requests link to issue template
- AndroidManifest.xml: Added WorkManager SystemForegroundService declaration
  with dataSync foregroundServiceType to fix lint error for Expedited Work
- .github/ISSUE_TEMPLATE/config.yml: Added Feature Requests & Ideas link
  pointing to GitHub Discussions for non-bug feature discussions
2026-02-02 13:45:16 +01:00
inventory69
24ea7ec59a fix: Android 9 crash - Implement getForegroundInfo() for WorkManager Expedited Work (Issue #15)
This commit fixes the critical crash on Android 9 (API 28) that occurred when using
WorkManager Expedited Work for background sync operations.

## Root Cause
When setExpedited() is used in WorkManager, the CoroutineWorker must implement
getForegroundInfo() to return a ForegroundInfo object with a Foreground Service
notification. On Android 9-11, WorkManager calls this method, but the default
implementation throws: IllegalStateException: Not implemented

## Solution
- Implemented getForegroundInfo() in SyncWorker
- Returns ForegroundInfo with sync progress notification
- Android 10+: Sets FOREGROUND_SERVICE_TYPE_DATA_SYNC for proper service typing
- Added required Foreground Service permissions to AndroidManifest.xml

## Technical Changes
- SyncWorker.kt: Added getForegroundInfo() override
- NotificationHelper.kt: Added createSyncProgressNotification() factory method
- strings.xml: Added sync_in_progress UI strings (EN + DE)
- AndroidManifest.xml: Added FOREGROUND_SERVICE permissions
- Version updated to 1.7.1 (versionCode 18)

## Previously Fixed (in this release)
- Kernel-VPN compatibility (Wireguard interface detection)
- HTTP connection lifecycle optimization (SafeSardineWrapper)
- Stability improvements for sync sessions

## Testing
- Tested on Android 9 (API 28) - No crash on second app start
- Tested on Android 15 (API 35) - No regressions
- WiFi-connect sync working correctly
- Expedited work notifications display properly

Fixes #15
Thanks to @roughnecks for detailed bug report and testing!
2026-02-02 13:09:12 +01:00
inventory69
df4ee4bed0 v1.7.1: Fix Android 9 crash and Kernel-VPN compatibility
- Fix connection leak on Android 9 (close() in finally block)
- Fix VPN detection for Kernel Wireguard (interface name patterns)
- Fix missing files after app data clear (local existence check)
- Update changelogs for v1.7.1 (versionCode 18)

Refs: #15
2026-01-30 16:21:04 +01:00
inventory69
68e8490db8 Fix connection leaks causing crash on Android 9
- Added SafeSardineWrapper to properly close HTTP responses
- Prevents resource exhaustion after extended use (30-45 min)
- Added preemptive authentication to reduce 401 round-trips
- Added ProGuard rule for TextInclusionStrategy warnings
- Updated version to 1.7.1

Refs: #15
2026-01-30 13:37:52 +01:00
inventory69
614650e37d delete: remove feature request issue template [skip ci] 2026-01-28 16:14:10 +01:00
Fabian Dettmer
785a6c011a Add feature requests section to README [skip ci]
Added a section for feature requests and ideas with guidelines.
2026-01-28 15:24:17 +01:00
inventory69
a96d373e78 Merge branch: Release v1.7.0 – Major Improvements & Features
- New: Grid view for notes – thanks to freemen
- New: WiFi-only sync toggle in settings
- New: Encryption for local backups – thanks to @SilentCoderHere (ref #9)
- Fixed: Sync now works correctly when VPN is active – thanks to @roughnecks (closes #11)
- Improved: Server change now resets sync status for all notes
- Improved: 'Sync already running' feedback for additional executions
- Various bug fixes and UI improvements
- Added support for self-signed SSL certificates; documentation updated – thanks to Stefan L.
- SHA-256 hash of the signing certificate is now shown in the README and on release pages – thanks to @isawaway (ref #10)

This release brings enhanced security, better sync reliability, and improved usability for self-hosted and private server setups.
2026-01-27 14:33:47 +01:00
inventory69
a59e89fe91 fix: add server-test directory to .gitignore [skip ci] 2026-01-27 14:03:38 +01:00
inventory69
91beee0f8b docs: fix badge layout finally [skip ci] 2026-01-27 13:53:44 +01:00
inventory69
c536ad3177 fix: badges aligned and underline removed [skip ci] 2026-01-27 13:30:17 +01:00
inventory69
6dba091c03 Unify and streamline documentation, changelogs, and app descriptions (DE/EN). Improved clarity, removed redundancies, and updated feature highlights for v1.7.0. [skip ci] 2026-01-27 13:20:14 +01:00
inventory69
5135c711a5 chore: Suppress SwallowedException in stopWifiMonitoring()
The exception is intentionally swallowed - it's OK if the callback is already unregistered.
2026-01-26 23:25:13 +01:00
inventory69
ebab347d4b fix: Notification opens ComposeMainActivity, WiFi-Only toggle in own section
Fixes:
1. Notification click now opens ComposeMainActivity instead of legacy MainActivity
2. WiFi-Only toggle moved to its own 'Network Restriction' section at top of sync settings
3. Added hint explaining WiFi-Connect trigger is not affected by WiFi-Only setting

UI Changes:
- New section header: 'Network Restriction' / 'Netzwerk-Einschränkung'
- WiFi-Only toggle now clearly separated from sync triggers
- Info card shows when WiFi-Only is enabled explaining the exception
2026-01-26 23:21:13 +01:00
inventory69
cb63aa1220 fix(sync): Implement central canSync() gate for WiFi-only check
- Add WebDavSyncService.canSync() as single source of truth
- Add SyncGateResult data class for structured response
- Update MainViewModel.triggerManualSync() to use canSync()
- Update MainViewModel.triggerAutoSync() to use canSync() - FIXES onResume bug
- Update NoteEditorViewModel.triggerOnSaveSync() to use canSync()
- Update SettingsViewModel.syncNow() to use canSync()
- Update SyncWorker to use canSync() instead of direct prefs check

All 9 sync paths now respect WiFi-only setting through one central gate.
2026-01-26 22:41:00 +01:00
inventory69
0df8282eb4 fix(sync): Add WiFi-only check for onSave and background sync
- SyncWorker: Add central WiFi-only guard before all sync operations
- NoteEditorViewModel: Add WiFi-only check before onSave sync trigger
- Prevents notes from syncing over 5G/mobile when WiFi-only is enabled
- Fixes: onSave sync ignored WiFi-only setting completely
2026-01-26 21:42:03 +01:00
inventory69
b70bc4d8f6 debug: v1.7.0 Features - Grid Layout, WiFi-only Sync, VPN Support 2026-01-26 21:19:46 +01:00
inventory69
217a174478 Merge feature/v1.6.2-offline-mode-hotfix: Fix offline mode migration bug 2026-01-23 21:39:30 +01:00
inventory69
d58d9036cb fix: offline mode migration bug for v1.5.0 → v1.6.2 updates
- Fixes offline mode incorrectly enabled after updating from v1.5.0
- Users with existing server configuration no longer appear as offline
- Root cause: KEY_OFFLINE_MODE didn't exist in v1.5.0
- MainViewModel/NoteEditorViewModel used hardcoded default 'true'
- Fix: Migration in SimpleNotesApplication.onCreate() detects server config
- Version bumped to v1.6.2 (versionCode 16)
- F-Droid changelogs added

Tested: v1.5.0 → v1.6.2 update successful
Migration log: hasServer=true → offlineMode=false ✓
2026-01-23 21:39:04 +01:00
inventory69
dfdccfe6c7 chore: restructured README and added Obtanium badge
credits: https://github.com/ImranR98/Obtainium/issues/1287

[skip ci]
2026-01-21 23:16:16 +01:00
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
205 changed files with 23134 additions and 4167 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
title: "[BUG] "
labels: ["bug"]
@@ -13,7 +13,7 @@ body:
- type: textarea
id: description
attributes:
label: 🐛 Beschreibung / Description
label: "🐛 Beschreibung / Description"
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"
validations:
@@ -22,9 +22,11 @@ body:
- type: dropdown
id: android-version
attributes:
label: 📱 Android Version
label: "📱 Android Version"
description: Welche Android Version verwendest du? / Which Android version are you using?
options:
- Android 16
- Android 15
- Android 14
- Android 13
- Android 12
@@ -40,7 +42,7 @@ body:
- type: input
id: app-version
attributes:
label: 📲 App Version
label: "📲 App Version"
description: Welche Version der App verwendest du? (Einstellungen → Über) / Which app version? (Settings → About)
placeholder: "z.B. / e.g. v1.1.0"
validations:
@@ -49,7 +51,7 @@ body:
- type: input
id: device
attributes:
label: 📱 Gerät / Device
label: "📱 Gerät / Device"
description: Welches Gerät verwendest du? / Which device are you using?
placeholder: "z.B. Samsung Galaxy S21, Google Pixel 7, etc."
validations:
@@ -58,7 +60,7 @@ body:
- type: textarea
id: steps
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?
placeholder: |
1. Öffne die App / Open the app
@@ -71,7 +73,7 @@ body:
- type: textarea
id: expected
attributes:
label: ✅ Erwartetes Verhalten / Expected Behavior
label: "✅ Erwartetes Verhalten / Expected Behavior"
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"
validations:
@@ -80,7 +82,7 @@ body:
- type: textarea
id: actual
attributes:
label: ❌ Tatsächliches Verhalten / Actual Behavior
label: "❌ Tatsächliches Verhalten / Actual Behavior"
description: Was passiert stattdessen? / What happens instead?
placeholder: "z.B. Sync funktioniert nicht, keine Notification / e.g. Sync doesn't work, no notification"
validations:
@@ -89,7 +91,7 @@ body:
- type: dropdown
id: sync-enabled
attributes:
label: <EFBFBD> Auto-Sync aktiviert? / Auto-Sync enabled?
label: Auto-Sync aktiviert? / Auto-Sync enabled?
options:
- "Ja / Yes"
- "Nein / No"

View File

@@ -1,11 +1,14 @@
blank_issues_enabled: true
contact_links:
- name: 📖 Dokumentation / Documentation
- name: "📖 Dokumentation / Documentation"
url: https://github.com/inventory69/simple-notes-sync/blob/main/README.md
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
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
about: Häufige Probleme und Lösungen / Common issues and solutions
- name: "✨ Feature Requests & Ideas"
url: https://github.com/inventory69/simple-notes-sync/discussions/categories/ideas
about: Diskutiere neue Features in Discussions / Discuss new features in Discussions

View File

@@ -1,84 +0,0 @@
name: 💡 Feature Request / Feature-Wunsch
description: Schlage eine neue Funktion vor / Suggest a new feature
title: "[FEATURE] "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Danke für deinen Feature-Vorschlag! / Thanks for your feature suggestion!
- type: textarea
id: feature-description
attributes:
label: 💡 Feature-Beschreibung / Feature Description
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"
validations:
required: true
- type: textarea
id: problem
attributes:
label: 🎯 Problem / Motivation
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"
validations:
required: true
- type: textarea
id: solution
attributes:
label: 📝 Vorgeschlagene Lösung / Proposed Solution
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"
validations:
required: false
- type: textarea
id: alternatives
attributes:
label: 🔄 Alternativen / Alternatives
description: Hast du andere Lösungsansätze in Betracht gezogen? / Have you considered other solutions?
validations:
required: false
- type: dropdown
id: platform
attributes:
label: 📱 Plattform / Platform
description: Für welche Komponente ist das Feature? / For which component is the feature?
options:
- Android App
- WebDAV Server
- Dokumentation / Documentation
- Andere / Other
validations:
required: true
- type: dropdown
id: priority
attributes:
label: 🌟 Priorität (aus deiner Sicht) / Priority (from your perspective)
options:
- Nice to have
- Wichtig / Important
- Sehr wichtig / Very important
validations:
required: false
- type: checkboxes
id: willing-to-contribute
attributes:
label: 🤝 Beitragen / Contribute
options:
- label: Ich würde gerne bei der Implementierung helfen / I would like to help with implementation
required: false
- type: textarea
id: additional
attributes:
label: 🔧 Zusätzliche Informationen / Additional Context
description: Screenshots, Mockups, Links, ähnliche Apps, etc.
validations:
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
title: "[QUESTION] "
labels: ["question"]
@@ -11,7 +11,7 @@ body:
- type: textarea
id: question
attributes:
label: ❓ Frage / Question
label: "❓ Frage / Question"
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?"
validations:
@@ -20,7 +20,7 @@ body:
- type: checkboxes
id: documentation-checked
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?
options:
- 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
id: tried
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
placeholder: "z.B. Ich habe versucht die Server-URL anzupassen, aber... / e.g. I tried adjusting the server URL, but..."
validations:
@@ -42,7 +42,7 @@ body:
- type: dropdown
id: topic
attributes:
label: <EFBFBD> Thema / Topic
label: "📌 Thema / Topic"
description: Um was geht es? / What is this about?
options:
- Server Setup / Server-Einrichtung
@@ -57,7 +57,7 @@ body:
- type: textarea
id: context
attributes:
label: <EFBFBD> Kontext / Context
label: "💬 Kontext / Context"
description: Zusätzliche Informationen die hilfreich sein könnten / Additional information that might be helpful
placeholder: |
- Android Version: Android 13
@@ -70,7 +70,7 @@ body:
- type: textarea
id: additional
attributes:
label: 🔧 Screenshots / Config
label: "🔧 Screenshots / Config"
description: Falls hilfreich (KEINE Passwörter!) / If helpful (NO passwords!)
validations:
required: false

87
.github/workflows/build-debug-apk.yml vendored Normal file
View File

@@ -0,0 +1,87 @@
name: Build Debug APK
on:
push:
branches:
- 'debug/**'
- 'fix/**'
- 'feature/**'
workflow_dispatch: # Manueller Trigger möglich
jobs:
build-debug:
name: Build Debug APK
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Extract version info
run: |
VERSION_NAME=$(grep "versionName = " android/app/build.gradle.kts | sed 's/.*versionName = "\(.*\)".*/\1/')
VERSION_CODE=$(grep "versionCode = " android/app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
BRANCH_NAME=${GITHUB_REF#refs/heads/}
COMMIT_SHA=$(git rev-parse --short HEAD)
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
echo "COMMIT_SHA=$COMMIT_SHA" >> $GITHUB_ENV
echo "BUILD_TIME=$(date +'%Y-%m-%d_%H-%M-%S')" >> $GITHUB_ENV
- name: Build Debug APK (Standard + F-Droid)
run: |
cd android
./gradlew assembleStandardDebug assembleFdroidDebug --no-daemon --stacktrace
- name: Prepare Debug APK artifacts
run: |
mkdir -p debug-apks
cp android/app/build/outputs/apk/standard/debug/app-standard-debug.apk \
debug-apks/simple-notes-sync-v${{ env.VERSION_NAME }}-${{ env.COMMIT_SHA }}-standard-debug.apk
cp android/app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk \
debug-apks/simple-notes-sync-v${{ env.VERSION_NAME }}-${{ env.COMMIT_SHA }}-fdroid-debug.apk
echo "✅ Debug APK Files ready:"
ls -lh debug-apks/
- name: Upload Debug APK Artifacts
uses: actions/upload-artifact@v4
with:
name: simple-notes-sync-debug-v${{ env.VERSION_NAME }}-${{ env.BUILD_TIME }}
path: debug-apks/*.apk
retention-days: 30 # Debug Builds länger aufbewahren
compression-level: 0 # APK ist bereits komprimiert
- name: Create summary
run: |
echo "## 🐛 Debug APK Build" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Build Info" >> $GITHUB_STEP_SUMMARY
echo "- **Version:** v${{ env.VERSION_NAME }} (Code: ${{ env.VERSION_CODE }})" >> $GITHUB_STEP_SUMMARY
echo "- **Branch:** ${{ env.BRANCH_NAME }}" >> $GITHUB_STEP_SUMMARY
echo "- **Commit:** ${{ env.COMMIT_SHA }}" >> $GITHUB_STEP_SUMMARY
echo "- **Built:** ${{ env.BUILD_TIME }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Download" >> $GITHUB_STEP_SUMMARY
echo "Debug APK available in the Artifacts section above (expires in 30 days)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Installation" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
echo "# Enable unknown sources" >> $GITHUB_STEP_SUMMARY
echo "adb install simple-notes-sync-*-debug.apk" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### What's included?" >> $GITHUB_STEP_SUMMARY
echo "- Full Logging enabled" >> $GITHUB_STEP_SUMMARY
echo "- Not production signed" >> $GITHUB_STEP_SUMMARY
echo "- May have performance impact" >> $GITHUB_STEP_SUMMARY

View File

@@ -2,11 +2,11 @@ name: Build Android Production APK
on:
push:
branches: [ main ] # Nur bei Push/Merge auf main triggern
workflow_dispatch: # Ermöglicht manuellen Trigger
branches: [ main ] # Only trigger on push/merge to main
workflow_dispatch: # Enables manual trigger
permissions:
contents: write # Fuer Release-Erstellung erforderlich
contents: write # Required for release creation
jobs:
build:
@@ -14,89 +14,89 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Code auschecken
- name: Checkout code
uses: actions/checkout@v4
- name: Java einrichten
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Semantic Versionsnummer aus build.gradle.kts extrahieren
- name: Extract semantic version from build.gradle.kts
run: |
# Version aus build.gradle.kts fuer F-Droid Kompatibilität
# Version from build.gradle.kts for F-Droid compatibility
VERSION_NAME=$(grep "versionName = " android/app/build.gradle.kts | sed 's/.*versionName = "\(.*\)".*/\1/')
VERSION_CODE=$(grep "versionCode = " android/app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
# Semantische Versionierung (nicht datums-basiert)
# Semantic versioning (not date-based)
BUILD_NUMBER="$VERSION_CODE"
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV
echo "VERSION_TAG=v$VERSION_NAME" >> $GITHUB_ENV
echo "🚀 Baue Version: $VERSION_NAME (Code: $BUILD_NUMBER)"
echo "🚀 Building version: $VERSION_NAME (Code: $BUILD_NUMBER)"
- name: Version aus build.gradle.kts verifizieren
- name: Verify version from build.gradle.kts
run: |
echo "✅ Verwende Version aus build.gradle.kts:"
echo "✅ Using version from build.gradle.kts:"
grep -E "versionCode|versionName" android/app/build.gradle.kts
- name: Android Signing konfigurieren
- name: Configure Android signing
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/simple-notes-release.jks
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" > android/key.properties
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/key.properties
echo "storeFile=simple-notes-release.jks" >> android/key.properties
echo "✅ Signing-Konfiguration erstellt"
echo "✅ Signing configuration created"
- name: Produktions-APK bauen (Standard + F-Droid Flavors)
- name: Build production APK (Standard + F-Droid Flavors)
run: |
cd android
./gradlew assembleStandardRelease assembleFdroidRelease --no-daemon --stacktrace
- name: APK-Varianten mit Versionsnamen kopieren
- name: Copy APK variants with version names
run: |
mkdir -p apk-output
# Standard Flavor - Universal APK
# Standard Flavor
cp android/app/build/outputs/apk/standard/release/app-standard-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk
# F-Droid Flavor - Universal APK
# F-Droid Flavor
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk
echo "✅ APK-Dateien vorbereitet:"
echo "✅ APK files prepared:"
ls -lh apk-output/
- name: APK-Artefakte hochladen
- name: Upload APK artifacts
uses: actions/upload-artifact@v4
with:
name: simple-notes-sync-apks-v${{ env.VERSION_NAME }}
path: apk-output/*.apk
retention-days: 90 # Produktions-Builds länger aufbewahren
retention-days: 90 # Keep production builds longer
- name: Commit-Informationen auslesen
- name: Extract commit information
run: |
echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
echo "COMMIT_DATE=$(git log -1 --format=%cd --date=iso-strict)" >> $GITHUB_ENV
- name: F-Droid Changelogs lesen
- name: Read F-Droid changelogs
run: |
# Lese deutsche Changelog (Hauptsprache) - Use printf to ensure proper formatting
# Read German changelog (main language) - Use printf to ensure proper formatting
if [ -f "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
CHANGELOG_CONTENT=$(cat "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt")
echo "CHANGELOG_DE<<GHADELIMITER" >> $GITHUB_ENV
echo "$CHANGELOG_CONTENT" >> $GITHUB_ENV
echo "GHADELIMITER" >> $GITHUB_ENV
else
echo "CHANGELOG_DE=Keine deutschen Release Notes verfügbar." >> $GITHUB_ENV
echo "CHANGELOG_DE=No German release notes available." >> $GITHUB_ENV
fi
# Lese englische Changelog (optional)
# Read English changelog (optional)
if [ -f "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
CHANGELOG_CONTENT_EN=$(cat "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt")
echo "CHANGELOG_EN<<GHADELIMITER" >> $GITHUB_ENV
@@ -116,36 +116,41 @@ jobs:
prerelease: false
generate_release_notes: false
body: |
## 📦 Downloads
| Variante | Datei | Info |
|----------|-------|------|
| **🏆 Empfohlen** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk` | Standard-Version (funktioniert auf allen Geraeten) |
| F-Droid | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk` | Fuer F-Droid Store |
---
## 📋 Changelog / Release Notes
${{ env.CHANGELOG_DE }}
<details>
<summary>🌍 English Version</summary>
${{ env.CHANGELOG_EN }}
<details>
<summary>🇩🇪 German Version</summary>
${{ env.CHANGELOG_DE }}
</details>
## 📦 Downloads
---
| Variant | File | Info |
|---------|------|------|
| **🏆 Recommended** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk` | Standard version (works on all devices) |
| F-Droid | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk` | For F-Droid Store |
## 📊 Build-Info
## 📊 Build Info
- **Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.BUILD_NUMBER }})
- **Datum:** ${{ env.COMMIT_DATE }}
- **Date:** ${{ env.COMMIT_DATE }}
- **Commit:** ${{ env.SHORT_SHA }}
---
## 🔐 APK Signature Verification
**[📖 Dokumentation](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md)** · **[🐛 Issue melden](https://github.com/inventory69/simple-notes-sync/issues)**
All APKs are signed with the official release certificate.
**Recommended:** Verify with [AppVerifier](https://github.com/nicholson-lab/AppVerifier) (Android app)
**Expected SHA-256:**
```
42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
```
**[📖 Documentation](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md)** · **[🐛 Report Bug](https://github.com/inventory69/simple-notes-sync/issues)**
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -18,7 +18,7 @@ jobs:
distribution: 'temurin'
java-version: '17'
- name: Gradle Cache
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
@@ -33,6 +33,31 @@ jobs:
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV
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)
run: |
cd android
@@ -44,14 +69,14 @@ jobs:
continue-on-error: true
- name: Build-Ergebnis pruefen
run: |
if [ -f "android/app/build/outputs/apk/standard/debug/app-standard-universal-debug.apk" ]; then
if [ -f "android/app/build/outputs/apk/standard/debug/app-standard-debug.apk" ]; then
echo "✅ Standard Debug APK erfolgreich gebaut"
ls -lh android/app/build/outputs/apk/standard/debug/*.apk
else
echo "❌ Standard Debug APK Build fehlgeschlagen"
exit 1
fi
if [ -f "android/app/build/outputs/apk/fdroid/debug/app-fdroid-universal-debug.apk" ]; then
if [ -f "android/app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk" ]; then
echo "✅ F-Droid Debug APK erfolgreich gebaut"
ls -lh android/app/build/outputs/apk/fdroid/debug/*.apk
else

7
.gitignore vendored
View File

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

990
CHANGELOG.de.md Normal file
View File

@@ -0,0 +1,990 @@
# 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.8.1] - 2026-02-11
### 🛠️ Bugfix & Polish Release
Checklisten-Fixes, Widget-Verbesserungen, Sync-Härtung und Code-Qualität.
### 🐛 Fehlerbehebungen
**Checklisten-Sortierung Persistenz** ([7dbc06d](https://github.com/inventory69/simple-notes-sync/commit/7dbc06d))
- Sortier-Option wurde beim erneuten Öffnen einer Checkliste nicht angewendet
- Ursache: `sortChecklistItems()` sortierte immer unchecked-first statt `_lastChecklistSortOption` zu lesen
- Alle Sortier-Modi werden nun korrekt wiederhergestellt (Manuell, Alphabetisch, Unchecked/Checked First)
**Widget-Scroll bei Standard-Größe** ([c72b3fe](https://github.com/inventory69/simple-notes-sync/commit/c72b3fe))
- Scrollen funktionierte nicht bei Standard-3×2-Widget-Größe (110150dp Höhe)
- Neue Größenklassen `NARROW_SCROLL` und `WIDE_SCROLL` mit 150dp-Schwelle
- `clickable`-Modifier bei entsperrten Checklisten entfernt, um Scrollen zu ermöglichen
**Auto-Sync Toast entfernt** ([fe6935a](https://github.com/inventory69/simple-notes-sync/commit/fe6935a))
- Unerwartete Toast-Benachrichtigung bei automatischem Hintergrund-Sync entfernt
- Stiller Auto-Sync bleibt still; nur Fehler werden angezeigt
**Gradient- & Drag-Regression** ([24fe32a](https://github.com/inventory69/simple-notes-sync/commit/24fe32a))
- Gradient-Overlay-Regression bei langen Checklisten-Items behoben
- Drag-and-Drop-Flackern beim Verschieben zwischen Bereichen behoben
### 🆕 Neue Funktionen
**Widget-Checklisten: Sortierung & Trennlinien** ([66d98c0](https://github.com/inventory69/simple-notes-sync/commit/66d98c0))
- Widgets übernehmen die gespeicherte Sortier-Option aus dem Editor
- Visuelle Trennlinie zwischen unerledigten/erledigten Items (MANUAL & UNCHECKED_FIRST)
- Auto-Sortierung beim Abhaken von Checkboxen im Widget
- Emoji-Änderung: ✅ → ☑️ für erledigte Items
**Checklisten-Vorschau-Sortierung** ([2c43b47](https://github.com/inventory69/simple-notes-sync/commit/2c43b47))
- Hauptbildschirm-Vorschau (NoteCard, NoteCardCompact, NoteCardGrid) zeigt gespeicherte Sortierung
- Neuer `ChecklistPreviewHelper` mit geteilter Sortier-Logik
**Auto-Scroll bei Zeilenumbruch** ([3e4b1bd](https://github.com/inventory69/simple-notes-sync/commit/3e4b1bd))
- Checklisten-Editor scrollt automatisch wenn Text in eine neue Zeile umbricht
- Cursor bleibt am unteren Rand sichtbar während der Eingabe
**Separator Drag Cross-Boundary** ([7b55811](https://github.com/inventory69/simple-notes-sync/commit/7b55811))
- Drag-and-Drop funktioniert nun über die Checked/Unchecked-Trennlinie hinweg
- Items wechseln automatisch ihren Status beim Verschieben über die Grenze
- Extrahiertes `DraggableChecklistItem`-Composable für Wiederverwendbarkeit
### 🔄 Verbesserungen
**Sync-Ratenlimit & Akkuschutz** ([ffe0e46](https://github.com/inventory69/simple-notes-sync/commit/ffe0e46), [a1a574a](https://github.com/inventory69/simple-notes-sync/commit/a1a574a))
- Globaler 30-Sekunden-Cooldown zwischen Sync-Operationen (Auto/WiFi/Periodisch)
- onSave-Syncs umgehen den globalen Cooldown (behalten eigenen 5s-Throttle)
- Neuer `SyncStateManager`-Singleton für zentrales State-Tracking
- Verhindert Akkuverbrauch durch schnelle aufeinanderfolgende Syncs
**Toast → Banner-Migration** ([27e6b9d](https://github.com/inventory69/simple-notes-sync/commit/27e6b9d))
- Alle nicht-interaktiven Benachrichtigungen auf einheitliches Banner-System migriert
- Server-Lösch-Ergebnisse als INFO/ERROR-Banner angezeigt
- INFO-Phase zu SyncPhase-Enum mit Auto-Hide (2,5s) hinzugefügt
- Snackbars mit Undo-Aktionen bleiben unverändert
**ProGuard-Regeln Audit** ([6356173](https://github.com/inventory69/simple-notes-sync/commit/6356173))
- Fehlende Keep-Regeln für Widget-ActionCallback-Klassen hinzugefügt
- Compose-spezifische ProGuard-Regeln hinzugefügt
- Verhindert ClassNotFoundException in Release-Builds
### 🧹 Code-Qualität
**Detekt-Compliance** ([1a6617a](https://github.com/inventory69/simple-notes-sync/commit/1a6617a))
- Alle 12 Detekt-Findings behoben (0 Issues verbleibend)
- `NoteEditorViewModel.loadNote()` refactored um Verschachtelungstiefe zu reduzieren
- Konstanten für Magic Numbers im Editor extrahiert
- Unbenutzte Imports aus `UpdateChangelogSheet` entfernt
- `maxIssues: 0` in Detekt-Konfiguration gesetzt
---
## [1.8.0] - 2026-02-10
### 🚨 CRITICAL BUGFIX (Tag neu erstellt)
**R8/ProGuard Obfuscation Fix - Verhindert Datenverlust**
- 🔧 **KRITISCH:** Falscher ProGuard-Klassenpfad für `Note$Companion$NoteRaw` korrigiert
- Original v1.8.0 hatte spezifische `-keep` Regeln die nicht griffen
- R8 obfuskierte alle NoteRaw-Felder (id→a, title→b, ...)
- Gson konnte JSON nicht mehr parsen → **ALLE Notizen erschienen verschwunden**
- Zurück zur sicheren breiten Regel: `-keep class dev.dettmer.simplenotes.** { *; }`
- 🛡️ Safety-Guards in `detectServerDeletions()` hinzugefügt
- Verhindert Massenlöschung bei leeren `serverNoteIds` (Netzwerkfehler)
- Abort wenn ALLE lokalen Notizen als gelöscht erkannt würden
- ✅ Notizen waren nie wirklich verloren (JSON-Dateien intakt auf Disk + Server)
- ✅ Downgrade auf v1.7.2 holte alle Notizen zurück
**⚠️ Falls du v1.8.0 erste Version installiert hattest:** Deine Notizen sind sicher! Einfach updaten.
### 🎉 Major: Widgets, Sortierung & Erweiterte Sync-Features
Komplettes Widget-System mit interaktiven Checklisten, Notiz-Sortierung und umfangreiche Sync-Verbesserungen!
### 🆕 Homescreen-Widgets
**Vollständiges Jetpack Glance Widget-Framework** ([539987f](https://github.com/inventory69/simple-notes-sync/commit/539987f))
- 5 responsive Größenklassen (SMALL, NARROW_MED, NARROW_TALL, WIDE_MED, WIDE_TALL)
- Interaktive Checklist-Checkboxen die sofort zum Server synchronisieren
- Material You Dynamic Colors mit konfigurierbarer Hintergrund-Transparenz (0-100%)
- Widget-Sperre-Toggle zum Verhindern versehentlicher Änderungen
- Read-Only-Modus mit permanenter Options-Leiste für gesperrte Widgets
- Widget-Konfigurations-Activity mit Notiz-Auswahl und Einstellungen
- Auto-Refresh nach Sync-Abschluss
- Tippen auf Inhalt öffnet Editor (entsperrt) oder zeigt Optionen (gesperrt)
- Vollständige Resource-Cleanup-Fixes für Connection Leaks
**Widget State Management:**
- NoteWidgetState Keys für pro-Instanz-Persistierung via DataStore
- Fünf Top-Level ActionCallbacks (Toggle Checkbox, Lock, Options, Refresh, Config)
- Type-Safe Parameter-Übergabe mit NoteWidgetActionKeys
### 📊 Notiz- & Checklisten-Sortierung
**Notiz-Sortierung** ([96c819b](https://github.com/inventory69/simple-notes-sync/commit/96c819b))
- Sortieren nach: Aktualisiert (neueste/älteste), Erstellt, Titel (A-Z/Z-A), Typ
- Persistente Sortierungs-Präferenzen (gespeichert in SharedPreferences)
- Sortierungs-Dialog im Hauptbildschirm mit Richtungs-Toggle
- Kombinierte sortedNotes StateFlow im MainViewModel
**Checklisten-Sortierung** ([96c819b](https://github.com/inventory69/simple-notes-sync/commit/96c819b), [900dad7](https://github.com/inventory69/simple-notes-sync/commit/900dad7))
- Sortieren nach: Manual, Alphabetisch, Offen zuerst, Erledigt zuletzt
- Visueller Separator zwischen offenen/erledigten Items mit Anzahl-Anzeige
- Auto-Sort bei Item-Toggle und Neuordnung
- Drag nur innerhalb gleicher Gruppe (offen/erledigt)
- Sanfte Fade/Slide-Animationen für Item-Übergänge
- Unit-getestet mit 9 Testfällen für Sortierungs-Logik-Validierung
### 🔄 Sync-Verbesserungen
**Server-Löschungs-Erkennung** ([40d7c83](https://github.com/inventory69/simple-notes-sync/commit/40d7c83), [bf7a74e](https://github.com/inventory69/simple-notes-sync/commit/bf7a74e))
- Neuer `DELETED_ON_SERVER` Sync-Status für Multi-Device-Szenarien
- Erkennt wenn Notizen auf anderen Clients gelöscht wurden
- Zero Performance-Impact (nutzt existierende PROPFIND-Daten)
- Löschungs-Anzahl im Sync-Banner: "3 synchronisiert · 2 auf Server gelöscht"
- Bearbeitete gelöschte Notizen werden automatisch zum Server hochgeladen (Status → PENDING)
**Sync-Status-Legende** ([07607fc](https://github.com/inventory69/simple-notes-sync/commit/07607fc))
- Hilfe-Button (?) in Hauptbildschirm TopAppBar
- Dialog erklärt alle 5 Sync-Status-Icons mit Beschreibungen
- Nur sichtbar wenn Sync konfiguriert ist
**Live-Sync-Fortschritts-UI** ([df37d2a](https://github.com/inventory69/simple-notes-sync/commit/df37d2a))
- Echtzeit-Phasen-Indikatoren: PREPARING, UPLOADING, DOWNLOADING, IMPORTING_MARKDOWN
- Upload-Fortschritt zeigt x/y Counter (bekannte Gesamtzahl)
- Download-Fortschritt zeigt Anzahl (unbekannte Gesamtzahl)
- Einheitliches SyncProgressBanner (ersetzt Dual-System)
- Auto-Hide: COMPLETED (2s), ERROR (4s)
- Keine irreführenden Counter wenn nichts zu synchronisieren ist
- Stiller Auto-Sync bleibt still, Fehler werden immer angezeigt
**Parallele Downloads** ([bdfc0bf](https://github.com/inventory69/simple-notes-sync/commit/bdfc0bf))
- Konfigurierbare gleichzeitige Downloads (Standard: 3 simultan)
- Kotlin Coroutines async/awaitAll Pattern
- Individuelle Download-Timeout-Behandlung
- Graceful sequentieller Fallback bei gleichzeitigen Fehlern
- Optimierte Netzwerk-Auslastung für schnelleren Sync
### ✨ UX-Verbesserungen
**Checklisten-Verbesserungen:**
- Überlauf-Verlauf für lange Text-Items ([3462f93](https://github.com/inventory69/simple-notes-sync/commit/3462f93))
- Auto-Expand bei Fokus, Collapse auf 5 Zeilen bei Fokus-Verlust
- Drag & Drop Flackern-Fix mit Straddle-Target-Center-Erkennung ([538a705](https://github.com/inventory69/simple-notes-sync/commit/538a705))
- Adjacency-Filter verhindert Item-Sprünge bei schnellem Drag
- Race-Condition-Fix für Scroll + Move-Operationen
**Einstellungs-UI-Polish:**
- Sanfter Sprachwechsel ohne Activity-Recreate ([881c0fd](https://github.com/inventory69/simple-notes-sync/commit/881c0fd))
- Raster-Ansicht als Standard für Neu-Installationen ([6858446](https://github.com/inventory69/simple-notes-sync/commit/6858446))
- Sync-Einstellungen umstrukturiert in klare Sektionen: Auslöser & Performance ([eaac5a0](https://github.com/inventory69/simple-notes-sync/commit/eaac5a0))
- Changelog-Link zum About-Screen hinzugefügt ([49810ff](https://github.com/inventory69/simple-notes-sync/commit/49810ff))
**Post-Update Changelog-Dialog** ([661d9e0](https://github.com/inventory69/simple-notes-sync/commit/661d9e0))
- Zeigt lokalisierten Changelog beim ersten Start nach Update
- Material 3 ModalBottomSheet mit Slide-up-Animation
- Lädt F-Droid Changelogs via Assets (Single Source of Truth)
- Einmalige Anzeige pro versionCode (gespeichert in SharedPreferences)
- Klickbarer GitHub-Link für vollständigen Changelog
- Durch Button oder Swipe-Geste schließbar
- Test-Modus in Debug-Einstellungen mit Reset-Option
**Backup-Einstellungs-Verbesserungen** ([3e946ed](https://github.com/inventory69/simple-notes-sync/commit/3e946ed))
- Neue BackupProgressCard mit LinearProgressIndicator
- 3-Phasen-Status-System: In Progress → Abschluss → Löschen
- Erfolgs-Status für 2s angezeigt, Fehler für 3s
- Redundante Toast-Nachrichten entfernt
- Buttons bleiben sichtbar und deaktiviert während Operationen
- Exception-Logging für besseres Error-Tracking
### 🐛 Fehlerbehebungen
**Widget-Text-Anzeige** ([d045d4d](https://github.com/inventory69/simple-notes-sync/commit/d045d4d))
- Text-Notizen zeigen nicht mehr nur 3 Zeilen in Widgets
- Von Absatz-basiert zu Zeilen-basiertem Rendering geändert
- LazyColumn scrollt jetzt korrekt durch gesamten Inhalt
- Leere Zeilen als 8dp Spacer beibehalten
- Vorschau-Limits erhöht: compact 100→120, full 200→300 Zeichen
### 🔧 Code-Qualität
**Detekt-Cleanup** ([1da1a63](https://github.com/inventory69/simple-notes-sync/commit/1da1a63))
- Alle 22 Detekt-Warnungen behoben
- 7 ungenutzte Imports entfernt
- Konstanten für 5 Magic Numbers definiert
- State-Reads mit derivedStateOf optimiert
- Build: 0 Lint-Fehler + 0 Detekt-Warnungen
### 📚 Dokumentation
- Vollständige Implementierungs-Pläne für alle 23 v1.8.0 Features
- Widget-System-Architektur und State-Management-Docs
- Sortierungs-Logik Unit-Tests mit Edge-Case-Coverage
- F-Droid Changelogs (Englisch + Deutsch)
---
## [1.7.2] - 2026-02-04
### 🐛 Kritische Fehlerbehebungen
#### JSON/Markdown Timestamp-Synchronisation
**Problem:** Externe Editoren (Obsidian, Typora, VS Code, eigene Editoren) aktualisieren Markdown-Inhalt, aber nicht den YAML `updated:` Timestamp, wodurch die Android-App Änderungen überspringt.
**Lösung:**
- Server-Datei Änderungszeit (`mtime`) wird jetzt als Source of Truth statt YAML-Timestamp verwendet
- Inhaltsänderungen werden via Hash-Vergleich erkannt
- Notizen nach Markdown-Import als `PENDING` markiert → JSON automatisch beim nächsten Sync hochgeladen
- Behebt Sortierungsprobleme nach externen Bearbeitungen
#### SyncStatus auf Server immer PENDING
**Problem:** Alle JSON-Dateien auf dem Server enthielten `"syncStatus": "PENDING"` auch nach erfolgreichem Sync, was externe Clients verwirrte.
**Lösung:**
- Status wird jetzt auf `SYNCED` gesetzt **vor** JSON-Serialisierung
- Server- und lokale Kopien sind jetzt konsistent
- Externe Web/Tauri-Editoren können Sync-Status korrekt interpretieren
#### Deletion Tracker Race Condition
**Problem:** Batch-Löschungen konnten Lösch-Einträge verlieren durch konkurrierenden Dateizugriff.
**Lösung:**
- Mutex-basierte Synchronisation für Deletion Tracking
- Neue `trackDeletionSafe()` Funktion verhindert Race Conditions
- Garantiert Zombie-Note-Prevention auch bei schnellen Mehrfach-Löschungen
#### ISO8601 Timezone-Parsing
**Problem:** Markdown-Importe schlugen fehl mit Timezone-Offsets wie `+01:00` oder `-05:00`.
**Lösung:**
- Multi-Format ISO8601 Parser mit Fallback-Kette
- Unterstützt UTC (Z), Timezone-Offsets (+01:00, +0100) und Millisekunden
- Kompatibel mit Obsidian, Typora, VS Code Timestamps
### ⚡ Performance-Verbesserungen
#### E-Tag Batch Caching
- E-Tags werden jetzt in einer einzigen Batch-Operation geschrieben statt N einzelner Schreibvorgänge
- Performance-Gewinn: ~50-100ms pro Sync mit mehreren Notizen
- Reduzierte Disk-I/O-Operationen
#### Memory Leak Prevention
- `SafeSardineWrapper` implementiert jetzt `Closeable` für explizites Resource-Cleanup
- HTTP Connection Pool wird nach Sync korrekt aufgeräumt
- Verhindert Socket-Exhaustion bei häufigen Syncs
### 🔧 Technische Details
- **IMPL_001:** `kotlinx.coroutines.sync.Mutex` für thread-sicheres Deletion Tracking
- **IMPL_002:** Pattern-basierter ISO8601 Parser mit 8 Format-Varianten
- **IMPL_003:** Connection Pool Eviction + Dispatcher Shutdown in `close()`
- **IMPL_004:** Batch `SharedPreferences.Editor` Updates
- **IMPL_014:** Server `mtime` Parameter in `Note.fromMarkdown()`
- **IMPL_015:** `syncStatus` vor `toJson()` Aufruf gesetzt
### 📚 Dokumentation
- External Editor Specification für Web/Tauri-Editor-Entwickler
- Detaillierte Implementierungs-Dokumentation für alle Bugfixes
---
## [1.7.1] - 2026-02-02
### 🐛 Kritische Fehlerbehebungen
#### Android 9 App-Absturz Fix ([#15](https://github.com/inventory69/simple-notes-sync/issues/15))
**Problem:** App stürzte auf Android 9 (API 28) ab wenn WorkManager Expedited Work für Hintergrund-Sync verwendet wurde.
**Root Cause:** Wenn `setExpedited()` in WorkManager verwendet wird, muss die `CoroutineWorker` die Methode `getForegroundInfo()` implementieren um eine Foreground Service Notification zurückzugeben. Auf Android 9-11 ruft WorkManager diese Methode auf, aber die Standard-Implementierung wirft `IllegalStateException: Not implemented`.
**Lösung:** `getForegroundInfo()` in `SyncWorker` implementiert um eine korrekte `ForegroundInfo` mit Sync-Progress-Notification zurückzugeben.
**Details:**
- `ForegroundInfo` mit Sync-Progress-Notification für Android 9-11 hinzugefügt
- Android 10+: Setzt `FOREGROUND_SERVICE_TYPE_DATA_SYNC` für korrekte Service-Typisierung
- Foreground Service Permissions in AndroidManifest.xml hinzugefügt
- Notification zeigt Sync-Progress mit indeterminiertem Progress Bar
- Danke an [@roughnecks](https://github.com/roughnecks) für das detaillierte Debugging!
#### VPN-Kompatibilitäts-Fix ([#11](https://github.com/inventory69/simple-notes-sync/issues/11))
- WiFi Socket-Binding erkennt jetzt korrekt Wireguard VPN-Interfaces (tun*, wg*, *-wg-*)
- Traffic wird korrekt durch VPN-Tunnel geleitet statt direkt über WiFi
- Behebt "Verbindungs-Timeout" beim Sync zu externen Servern über VPN
### 🔧 Technische Änderungen
- Neue `SafeSardineWrapper` Klasse stellt korrektes HTTP-Connection-Cleanup sicher
- Weniger unnötige 401-Authentifizierungs-Challenges durch preemptive Auth-Header
- ProGuard-Regel hinzugefügt um harmlose TextInclusionStrategy-Warnungen zu unterdrücken
- VPN-Interface-Erkennung via `NetworkInterface.getNetworkInterfaces()` Pattern-Matching
- Foreground Service Erkennung und Notification-System für Hintergrund-Sync-Tasks
### 🌍 Lokalisierung
- Hardcodierte deutsche Fehlermeldungen behoben - jetzt String-Resources für korrekte Lokalisierung
- Deutsche und englische Strings für Sync-Progress-Notifications hinzugefügt
---
## [1.7.0] - 2026-01-26
### 🎉 Major: Grid-Ansicht, Nur-WLAN Sync & VPN-Unterstützung
Pinterest-Style Grid, Nur-WLAN Sync-Modus und korrekte VPN-Unterstützung!
### 🎨 Grid-Layout
- Pinterest-Style Staggered Grid ohne Lücken
- Konsistente 12dp Abstände zwischen Cards
- Scroll-Position bleibt erhalten nach Einstellungen
- Neue einheitliche `NoteCardGrid` mit dynamischen Vorschauzeilen (3 klein, 6 groß)
### 📡 Sync-Verbesserungen
- **Nur-WLAN Sync Toggle** - Sync nur wenn WLAN verbunden
- **VPN-Unterstützung** - Sync funktioniert korrekt bei aktivem VPN (Traffic über VPN)
- **Server-Wechsel Erkennung** - Alle Notizen auf PENDING zurückgesetzt bei Server-URL Änderung
- **Schnellere Server-Prüfung** - Socket-Timeout von 2s auf 1s reduziert
- **"Sync läuft bereits" Feedback** - Zeigt Snackbar wenn Sync bereits läuft
### 🔒 Self-Signed SSL Unterstützung
- **Dokumentation hinzugefügt** - Anleitung für selbst-signierte Zertifikate
- Nutzt Android's eingebauten CA Trust Store
- Funktioniert mit ownCloud, Nextcloud, Synology, Home-Servern
### 🔧 Technisch
- `NoteCardGrid` Komponente mit dynamischen maxLines
- FullLine Spans entfernt für lückenloses Layout
- `resetAllSyncStatusToPending()` in NotesStorage
- VPN-Erkennung in `getOrCacheWiFiAddress()`
---
## [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)
- Complete sync architecture documentation
- 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.8.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.8.1
[1.8.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.8.0
[1.7.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.2
[1.7.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.1
[1.7.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.0
[1.6.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.6.1
[1.6.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.6.0
[1.5.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.5.0
[1.4.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.4.1
[1.4.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.4.0
[1.3.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.2
[1.3.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.1
[1.3.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.0
[1.2.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.2
[1.2.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.1
[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,536 @@ All notable changes to Simple Notes Sync will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
**🌍 Languages:** [Deutsch](CHANGELOG.de.md) · **English**
---
## [1.8.1] - 2026-02-11
### 🛠️ Bugfix & Polish Release
Checklist fixes, widget improvements, sync hardening, and code quality cleanup.
### 🐛 Bug Fixes
**Checklist Sort Persistence** ([7dbc06d](https://github.com/inventory69/simple-notes-sync/commit/7dbc06d))
- Fixed sort option not applied when reopening a checklist
- Root cause: `sortChecklistItems()` always sorted unchecked-first instead of reading `_lastChecklistSortOption`
- Now correctly restores all sort modes (Manual, Alphabetical, Unchecked/Checked First)
**Widget Scroll on Standard Size** ([c72b3fe](https://github.com/inventory69/simple-notes-sync/commit/c72b3fe))
- Fixed scroll not working on standard 3×2 widget size (110150dp height)
- Added `NARROW_SCROLL` and `WIDE_SCROLL` size classes with 150dp threshold
- Removed `clickable` modifier from unlocked checklists to enable scrolling
**Auto-Sync Toast Removed** ([fe6935a](https://github.com/inventory69/simple-notes-sync/commit/fe6935a))
- Removed unexpected toast notification on automatic background sync
- Silent auto-sync stays silent; only errors are shown
**Gradient & Drag Regression** ([24fe32a](https://github.com/inventory69/simple-notes-sync/commit/24fe32a))
- Fixed gradient overlay regression on long checklist items
- Fixed drag-and-drop flicker when moving items between boundaries
### 🆕 New Features
**Widget Checklist Sorting & Separators** ([66d98c0](https://github.com/inventory69/simple-notes-sync/commit/66d98c0))
- Widgets now apply saved sort option from the editor
- Visual separator between unchecked/checked items (MANUAL & UNCHECKED_FIRST modes)
- Auto-sort when toggling checkboxes in the widget
- Changed ✅ → ☑️ emoji for checked items
**Checklist Preview Sorting** ([2c43b47](https://github.com/inventory69/simple-notes-sync/commit/2c43b47))
- Main screen preview (NoteCard, NoteCardCompact, NoteCardGrid) now respects saved sort option
- New `ChecklistPreviewHelper` with shared sorting logic
**Auto-Scroll on Line Wrap** ([3e4b1bd](https://github.com/inventory69/simple-notes-sync/commit/3e4b1bd))
- Checklist editor auto-scrolls when typing causes text to wrap to a new line
- Keeps cursor visible at bottom of list during editing
**Separator Drag Cross-Boundary** ([7b55811](https://github.com/inventory69/simple-notes-sync/commit/7b55811))
- Drag-and-drop now works across the checked/unchecked separator
- Items auto-toggle their checked state when dragged across boundaries
- Extracted `DraggableChecklistItem` composable for reusability
### 🔄 Improvements
**Sync Rate-Limiting & Battery Protection** ([ffe0e46](https://github.com/inventory69/simple-notes-sync/commit/ffe0e46), [a1a574a](https://github.com/inventory69/simple-notes-sync/commit/a1a574a))
- Global 30-second cooldown between sync operations (auto/WiFi/periodic)
- onSave syncs bypass global cooldown (retain own 5s throttle)
- New `SyncStateManager` singleton for centralized state tracking
- Prevents battery drain from rapid successive syncs
**Toast → Banner Migration** ([27e6b9d](https://github.com/inventory69/simple-notes-sync/commit/27e6b9d))
- All non-interactive notifications migrated to unified Banner system
- Server-delete results show as INFO/ERROR banners
- Added INFO phase to SyncPhase enum with auto-hide (2.5s)
- Snackbars with Undo actions remain unchanged
**ProGuard Rules Audit** ([6356173](https://github.com/inventory69/simple-notes-sync/commit/6356173))
- Added missing keep rules for Widget ActionCallback classes
- Added Compose-specific ProGuard rules
- Prevents ClassNotFoundException in release builds
### 🧹 Code Quality
**Detekt Compliance** ([1a6617a](https://github.com/inventory69/simple-notes-sync/commit/1a6617a))
- Resolved all 12 detekt findings (0 issues remaining)
- Refactored `NoteEditorViewModel.loadNote()` to reduce nesting depth
- Extracted constants for magic numbers in editor
- Removed unused imports from `UpdateChangelogSheet`
- Set `maxIssues: 0` in detekt config
---
## [1.8.0] - 2026-02-10
### 🚨 CRITICAL BUGFIX (Tag recreated)
**R8/ProGuard Obfuscation Fix - Prevents Data Loss**
- 🔧 **CRITICAL:** Fixed incorrect ProGuard class path for `Note$Companion$NoteRaw`
- Original v1.8.0 had specific `-keep` rules that didn't match
- R8 obfuscated all NoteRaw fields (id→a, title→b, ...)
- Gson couldn't parse JSON anymore → **ALL notes appeared lost**
- Reverted to safe broad rule: `-keep class dev.dettmer.simplenotes.** { *; }`
- 🛡️ Added safety-guards in `detectServerDeletions()`
- Prevents mass deletion when `serverNoteIds` is empty (network errors)
- Aborts if ALL local notes would be marked as deleted
- ✅ Notes were never actually lost (JSON files intact on disk + server)
- ✅ Downgrade to v1.7.2 restored all notes
**⚠️ If you installed original v1.8.0:** Your notes are safe! Just update.
### 🎉 Major: Widgets, Sorting & Advanced Sync
Complete widget system with interactive checklists, note sorting, and major sync improvements!
### 🆕 Homescreen Widgets
**Full Jetpack Glance Widget Framework** ([539987f](https://github.com/inventory69/simple-notes-sync/commit/539987f))
- 5 responsive size classes (SMALL, NARROW_MED, NARROW_TALL, WIDE_MED, WIDE_TALL)
- Interactive checklist checkboxes that sync immediately to server
- Material You dynamic colors with configurable background opacity (0-100%)
- Lock widget toggle to prevent accidental edits
- Read-only mode with permanent options bar for locked widgets
- Widget configuration activity with note selection and settings
- Auto-refresh after sync completion
- Tap content to open editor (unlocked) or show options (locked)
- Complete resource cleanup fixes for connection leaks
**Widget State Management:**
- NoteWidgetState keys for per-instance persistence via DataStore
- Five top-level ActionCallbacks (Toggle Checkbox, Lock, Options, Refresh, Config)
- Type-safe parameter passing with NoteWidgetActionKeys
### 📊 Note & Checklist Sorting
**Note Sorting** ([96c819b](https://github.com/inventory69/simple-notes-sync/commit/96c819b))
- Sort by: Updated (newest/oldest), Created, Title (A-Z/Z-A), Type
- Persistent sort preferences (saved in SharedPreferences)
- Sort dialog in main screen with direction toggle
- Combined sortedNotes StateFlow in MainViewModel
**Checklist Sorting** ([96c819b](https://github.com/inventory69/simple-notes-sync/commit/96c819b), [900dad7](https://github.com/inventory69/simple-notes-sync/commit/900dad7))
- Sort by: Manual, Alphabetical, Unchecked First, Checked Last
- Visual separator between unchecked/checked items with count display
- Auto-sort on item toggle and reordering
- Drag-only within same group (unchecked/checked)
- Smooth fade/slide animations for item transitions
- Unit tested with 9 test cases for sorting logic validation
### 🔄 Sync Improvements
**Server Deletion Detection** ([40d7c83](https://github.com/inventory69/simple-notes-sync/commit/40d7c83), [bf7a74e](https://github.com/inventory69/simple-notes-sync/commit/bf7a74e))
- New `DELETED_ON_SERVER` sync status for multi-device scenarios
- Detects when notes are deleted on other clients
- Zero performance impact (uses existing PROPFIND data)
- Deletion count shown in sync banner: "3 synced · 2 deleted on server"
- Edited deleted notes automatically re-upload to server (status → PENDING)
**Sync Status Legend** ([07607fc](https://github.com/inventory69/simple-notes-sync/commit/07607fc))
- Help button (?) in main screen TopAppBar
- Dialog explaining all 5 sync status icons with descriptions
- Only visible when sync is configured
**Live Sync Progress UI** ([df37d2a](https://github.com/inventory69/simple-notes-sync/commit/df37d2a))
- Real-time phase indicators: PREPARING, UPLOADING, DOWNLOADING, IMPORTING_MARKDOWN
- Upload progress shows x/y counter (known total)
- Download progress shows count (unknown total)
- Single unified SyncProgressBanner (replaces dual system)
- Auto-hide: COMPLETED (2s), ERROR (4s)
- No misleading counters when nothing to sync
- Silent auto-sync stays silent, errors always shown
**Parallel Downloads** ([bdfc0bf](https://github.com/inventory69/simple-notes-sync/commit/bdfc0bf))
- Configurable concurrent downloads (default: 3 simultaneous)
- Kotlin coroutines async/awaitAll pattern
- Individual download timeout handling
- Graceful sequential fallback on concurrent errors
- Optimized network utilization for faster sync
### ✨ UX Improvements
**Checklist Enhancements:**
- Overflow gradient for long text items ([3462f93](https://github.com/inventory69/simple-notes-sync/commit/3462f93))
- Auto-expand on focus, collapse to 5 lines when unfocused
- Drag & Drop flicker fix with straddle-target-center detection ([538a705](https://github.com/inventory69/simple-notes-sync/commit/538a705))
- Adjacency filter prevents item jumps during fast drag
- Race-condition fix for scroll + move operations
**Settings UI Polish:**
- Smooth language switching without activity recreate ([881c0fd](https://github.com/inventory69/simple-notes-sync/commit/881c0fd))
- Grid view as default for new installations ([6858446](https://github.com/inventory69/simple-notes-sync/commit/6858446))
- Sync settings restructured into clear sections: Triggers & Performance ([eaac5a0](https://github.com/inventory69/simple-notes-sync/commit/eaac5a0))
- Changelog link added to About screen ([49810ff](https://github.com/inventory69/simple-notes-sync/commit/49810ff))
**Post-Update Changelog Dialog** ([661d9e0](https://github.com/inventory69/simple-notes-sync/commit/661d9e0))
- Shows localized changelog on first launch after update
- Material 3 ModalBottomSheet with slide-up animation
- Loads F-Droid changelogs via assets (single source of truth)
- One-time display per versionCode (stored in SharedPreferences)
- Clickable GitHub link for full changelog
- Dismissable via button or swipe gesture
- Test mode in Debug Settings with reset option
**Backup Settings Improvements** ([3e946ed](https://github.com/inventory69/simple-notes-sync/commit/3e946ed))
- New BackupProgressCard with LinearProgressIndicator
- 3-phase status system: In Progress → Completion → Clear
- Success status shown for 2s, errors for 3s
- Removed redundant toast messages
- Buttons stay visible and disabled during operations
- Exception logging for better error tracking
### 🐛 Bug Fixes
**Widget Text Display** ([d045d4d](https://github.com/inventory69/simple-notes-sync/commit/d045d4d))
- Fixed text notes showing only 3 lines in widgets
- Changed from paragraph-based to line-based rendering
- LazyColumn now properly scrolls through all content
- Empty lines preserved as 8dp spacers
- Preview limits increased: compact 100→120, full 200→300 chars
### 🔧 Code Quality
**Detekt Cleanup** ([1da1a63](https://github.com/inventory69/simple-notes-sync/commit/1da1a63))
- Resolved all 22 Detekt warnings
- Removed 7 unused imports
- Defined constants for 5 magic numbers
- Optimized state reads with derivedStateOf
- Build: 0 Lint errors + 0 Detekt warnings
### 📚 Documentation
- Complete implementation plans for all 23 v1.8.0 features
- Widget system architecture and state management docs
- Sorting logic unit tests with edge case coverage
- F-Droid changelogs (English + German)
---
## [1.7.2] - 2026-02-04
### 🐛 Critical Bug Fixes
#### JSON/Markdown Timestamp Sync
**Problem:** External editors (Obsidian, Typora, VS Code, custom editors) update Markdown content but don't update YAML `updated:` timestamp, causing the Android app to skip changes.
**Solution:**
- Server file modification time (`mtime`) is now used as source of truth instead of YAML timestamp
- Content changes detected via hash comparison
- Notes marked as `PENDING` after Markdown import → JSON automatically re-uploaded on next sync
- Fixes sorting issues after external edits
#### SyncStatus on Server Always PENDING
**Problem:** All JSON files on server contained `"syncStatus": "PENDING"` even after successful sync, confusing external clients.
**Solution:**
- Status is now set to `SYNCED` **before** JSON serialization
- Server and local copies are now consistent
- External web/Tauri editors can correctly interpret sync state
#### Deletion Tracker Race Condition
**Problem:** Batch deletes could lose deletion records due to concurrent file access.
**Solution:**
- Mutex-based synchronization for deletion tracking
- New `trackDeletionSafe()` function prevents race conditions
- Guarantees zombie note prevention even with rapid deletes
#### ISO8601 Timezone Parsing
**Problem:** Markdown imports failed with timezone offsets like `+01:00` or `-05:00`.
**Solution:**
- Multi-format ISO8601 parser with fallback chain
- Supports UTC (Z), timezone offsets (+01:00, +0100), and milliseconds
- Compatible with Obsidian, Typora, VS Code timestamps
### ⚡ Performance Improvements
#### E-Tag Batch Caching
- E-Tags are now written in single batch operation instead of N individual writes
- Performance gain: ~50-100ms per sync with multiple notes
- Reduced disk I/O operations
#### Memory Leak Prevention
- `SafeSardineWrapper` now implements `Closeable` for explicit resource cleanup
- HTTP connection pool is properly evicted after sync
- Prevents socket exhaustion during frequent syncs
### 🔧 Technical Details
- **IMPL_001:** `kotlinx.coroutines.sync.Mutex` for thread-safe deletion tracking
- **IMPL_002:** Pattern-based ISO8601 parser with 8 format variants
- **IMPL_003:** Connection pool eviction + dispatcher shutdown in `close()`
- **IMPL_004:** Batch `SharedPreferences.Editor` updates
- **IMPL_014:** Server `mtime` parameter in `Note.fromMarkdown()`
- **IMPL_015:** `syncStatus` set before `toJson()` call
### 📚 Documentation
- External Editor Specification for web/Tauri editor developers
- Detailed implementation documentation for all bugfixes
---
## [1.7.1] - 2026-02-02
### 🐛 Critical Bug Fixes
#### Android 9 App Crash Fix ([#15](https://github.com/inventory69/simple-notes-sync/issues/15))
**Problem:** App crashed on Android 9 (API 28) when using WorkManager Expedited Work for background sync.
**Root Cause:** When `setExpedited()` is used in WorkManager, the `CoroutineWorker` must implement `getForegroundInfo()` to return a Foreground Service notification. On Android 9-11, WorkManager calls this method, but the default implementation throws `IllegalStateException: Not implemented`.
**Solution:** Implemented `getForegroundInfo()` in `SyncWorker` to return a proper `ForegroundInfo` with sync progress notification.
**Details:**
- Added `ForegroundInfo` with sync progress notification for Android 9-11
- Android 10+: Sets `FOREGROUND_SERVICE_TYPE_DATA_SYNC` for proper service typing
- Added Foreground Service permissions to AndroidManifest.xml
- Notification shows sync progress with indeterminate progress bar
- Thanks to [@roughnecks](https://github.com/roughnecks) for the detailed debugging!
#### VPN Compatibility Fix ([#11](https://github.com/inventory69/simple-notes-sync/issues/11))
- WiFi socket binding now correctly detects Wireguard VPN interfaces (tun*, wg*, *-wg-*)
- Traffic routes through VPN tunnel instead of bypassing it directly to WiFi
- Fixes "Connection timeout" when syncing to external servers via VPN
### 🔧 Technical Changes
- New `SafeSardineWrapper` class ensures proper HTTP connection cleanup
- Reduced unnecessary 401 authentication challenges with preemptive auth headers
- Added ProGuard rule to suppress harmless TextInclusionStrategy warnings on older Android versions
- VPN interface detection via `NetworkInterface.getNetworkInterfaces()` pattern matching
- Foreground Service detection and notification system for background sync tasks
### 🌍 Localization
- Fixed hardcoded German error messages - now uses string resources for proper localization
- Added German and English strings for sync progress notifications
---
## [1.7.0] - 2026-01-26
### 🎉 Major: Grid View, WiFi-Only Sync & VPN Support
Pinterest-style grid, WiFi-only sync mode, and proper VPN support!
### 🎨 Grid Layout
- Pinterest-style staggered grid without gaps
- Consistent 12dp spacing between cards
- Scroll position preserved when returning from settings
- New unified `NoteCardGrid` with dynamic preview lines (3 small, 6 large)
### 📡 Sync Improvements
- **WiFi-only sync toggle** - Sync only when connected to WiFi
- **VPN support** - Sync works correctly when VPN is active (traffic routes through VPN)
- **Server change detection** - All notes reset to PENDING when server URL changes
- **Faster server check** - Socket timeout reduced from 2s to 1s
- **"Sync already running" feedback** - Shows snackbar when sync is in progress
### 🔒 Self-Signed SSL Support
- **Documentation added** - Guide for using self-signed certificates
- Uses Android's built-in CA trust store
- Works with ownCloud, Nextcloud, Synology, home servers
### 🔧 Technical
- `NoteCardGrid` component with dynamic maxLines
- Removed FullLine spans for gapless layout
- `resetAllSyncStatusToPending()` in NotesStorage
- VPN detection in `getOrCacheWiFiAddress()`
---
## [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
@@ -331,8 +861,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### 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
- Complete sync architecture documentation
- Desktop integration analysis
---
@@ -437,6 +967,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
---
[1.8.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.8.1
[1.8.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.8.0
[1.7.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.2
[1.7.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.1
[1.7.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.0
[1.6.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.6.1
[1.6.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.6.0
[1.5.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.5.0
[1.4.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.4.1
[1.4.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.4.0
[1.3.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.2
[1.3.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.1
[1.3.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.0
[1.2.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.2
[1.2.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.1
[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

View File

@@ -14,7 +14,7 @@ Danke, dass du zu Simple Notes Sync beitragen möchtest!
1. **Fork & Clone**
```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
```
@@ -94,10 +94,10 @@ Nutze die [Feature Request Template](https://github.com/inventory69/simple-notes
Dokumentations-Verbesserungen sind auch Contributions!
**Dateien:**
- `README.md` / `README.en.md` - Übersicht
- `QUICKSTART.md` / `QUICKSTART.en.md` - Schritt-für-Schritt Anleitung
- `DOCS.md` / `DOCS.en.md` - Technische Details
- `server/README.md` / `server/README.en.md` - Server Setup
- `README.de.md` / `README.md` - Übersicht
- `QUICKSTART.de.md` / `QUICKSTART.md` - Schritt-für-Schritt Anleitung
- `docs/DOCS.de.md` / `docs/DOCS.md` - Technische Details
- `server/README.de.md` / `server/README.md` - Server Setup
**Bitte:** Halte beide Sprachen (DE/EN) synchron!
@@ -139,7 +139,7 @@ Thanks for wanting to contribute to Simple Notes Sync!
1. **Fork & Clone**
```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
```
@@ -219,10 +219,10 @@ Use the [Feature Request Template](https://github.com/inventory69/simple-notes-s
Documentation improvements are also contributions!
**Files:**
- `README.md` / `README.en.md` - Overview
- `QUICKSTART.md` / `QUICKSTART.en.md` - Step-by-step guide
- `DOCS.md` / `DOCS.en.md` - Technical details
- `server/README.md` / `server/README.en.md` - Server setup
- `README.de.md` / `README.md` - Overview
- `QUICKSTART.de.md` / `QUICKSTART.md` - Step-by-step guide
- `docs/DOCS.de.md` / `docs/DOCS.md` - Technical details
- `server/README.de.md` / `server/README.md` - Server setup
**Please:** Keep both languages (DE/EN) in sync!
@@ -260,4 +260,4 @@ By contributing, you agree that your code will be published under the [MIT Licen
Öffne ein [Issue](https://github.com/inventory69/simple-notes-sync/issues) oder nutze die [Question Template](https://github.com/inventory69/simple-notes-sync/issues/new/choose).
**Frohe Weihnachten & Happy Coding! 🎄**
**Happy Coding! 🚀**

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 7.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.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/DOCS.md)
- **Server Setup Details:** [server/README.md](server/README.md)
---
**Version:** 1.1.0 · **Erstellt:** Dezember 2025

View File

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

View File

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

145
README.de.md Normal file
View File

@@ -0,0 +1,145 @@
<div align="center">
<img src="android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png" alt="Logo" />
</div>
<h1 align="center">Simple Notes Sync</h1>
<h4 align="center">Minimalistische Offline-Notizen mit intelligentem Sync - Einfachheit trifft smarte Synchronisation.</h4>
<div align="center">
[![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)](https://www.android.com/)
[![Kotlin](https://img.shields.io/badge/Kotlin-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white)](https://kotlinlang.org/)
[![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?style=for-the-badge&logo=jetpackcompose&logoColor=white)](https://developer.android.com/compose/)
[![Material 3](https://img.shields.io/badge/Material_3-6750A4?style=for-the-badge&logo=material-design&logoColor=white)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-F5C400?style=for-the-badge)](LICENSE)
</div>
<div align="center">
<a href="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" align="center" height="80" /></a>
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/inventory69/simple-notes-sync">
<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png"
alt="Get it on Obtainium" align="center" height="54" />
</a>
<a href="https://f-droid.org/packages/dev.dettmer.simplenotes">
<img src="https://f-droid.org/badge/get-it-on.png"
alt="Get it on F-Droid" align="center" height="80" /></a>
</div>
<div align="center">
<strong>SHA-256 Hash des Signaturzertifikats:</strong><br /> 42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
</div>
<div align="center">
<br />[📱 APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest) · [📖 Dokumentation](docs/DOCS.de.md) · [🚀 Schnellstart](QUICKSTART.de.md)<br />
**🌍** Deutsch · [English](README.md)
</div>
## 📱 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="Edit note">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.png" width="250" alt="Edit checklist">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/4.png" width="250" alt="Settings">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/5.png" width="250" alt="Server settings">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/7.png" width="250" alt="Sync settings">
</p>
<div align="center">
📝 Offline-first &nbsp;&nbsp; 🔄 Smart Sync &nbsp;&nbsp; 🔒 Self-hosted &nbsp;&nbsp; 🔋 Akkuschonend
</div>
## ✨ Highlights
- 📝 **Offline-first** Funktioniert ohne Internet
- 📊 **Flexible Ansichten** Listen- und Grid-Layout
-**Checklisten** Tap-to-Check, Drag & Drop
- 🔄 **Konfigurierbare Sync-Trigger** onSave, onResume, WiFi, periodisch (15/30/60 Min), Boot
- 📌 **Widgets** Home-Screen Quick-Note und Notizlisten-Widget
- 🔀 **Smartes Sortieren** Nach Titel, Änderungsdatum, Erstelldatum, Farbe
-**Paralleler Sync** Lädt bis zu 5 Notizen gleichzeitig herunter
- 🌍 **Mehrsprachig** Deutsch/Englisch mit Sprachauswahl
- 🔒 **Self-hosted** Deine Daten bleiben bei dir (WebDAV)
- 💾 **Lokales Backup** Export/Import als JSON-Datei (optional verschlüsselt)
- 🖥️ **Desktop-Integration** Markdown-Export für Obsidian, VS Code, Typora
- 🎨 **Material Design 3** Dynamischer Dark/Light Mode & Farben
➡️ **Vollständige Feature-Liste:** [docs/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) |
| **[SELF_SIGNED_SSL.md](docs/SELF_SIGNED_SSL.md)** | Self-signed SSL Zertifikat Setup |
| **[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/DOCS.de.md#-build--deployment](docs/DOCS.de.md#-build--deployment)
## 🤝 Contributing
Beiträge willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md)
## 📄 Lizenz
MIT License siehe [LICENSE](LICENSE)
<div align="center">
<br /><br />
**v1.8.1** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
</div>

View File

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

167
README.md
View File

@@ -1,108 +1,155 @@
# Simple Notes Sync 📝
<div align="center">
<img src="android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png" alt="Logo" />
</div>
> Minimalistische Offline-Notizen mit Auto-Sync zu deinem eigenen Server
<h1 align="center">Simple Notes Sync</h1>
[![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)
<h4 align="center">Clean, offline-first notes with intelligent sync - simplicity meets smart synchronization.</h4>
[<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)
<div align="center">
[![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)](https://www.android.com/)
[![Kotlin](https://img.shields.io/badge/Kotlin-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white)](https://kotlinlang.org/)
[![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?style=for-the-badge&logo=jetpackcompose&logoColor=white)](https://developer.android.com/compose/)
[![Material 3](https://img.shields.io/badge/Material_3-6750A4?style=for-the-badge&logo=material-design&logoColor=white)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-F5C400?style=for-the-badge)](LICENSE)
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](docs/DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)**
</div>
**🌍 Sprachen:** **Deutsch** · [English](README.en.md)
<div align="center">
---
<a href="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" align="center" height="80" /></a>
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/inventory69/simple-notes-sync">
<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png"
alt="Get it on Obtainium" align="center" height="54" />
</a>
<a href="https://f-droid.org/packages/dev.dettmer.simplenotes">
<img src="https://f-droid.org/badge/get-it-on.png"
alt="Get it on F-Droid" align="center" height="80" /></a>
</div>
<div align="center">
<strong>SHA-256 hash of the signing certificate:</strong><br />42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
</div>
<div align="center">
<br />[📱 APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest) · [📖 Documentation](docs/DOCS.md) · [🚀 Quick Start](QUICKSTART.md)<br />
**🌍** [Deutsch](README.de.md) · **English**
</div>
## 📱 Screenshots
<p align="center">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.jpg" width="250" alt="Notizliste">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.jpg" width="250" alt="Notiz bearbeiten">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.jpg" width="250" alt="Einstellungen">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="250" alt="Sync status">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="250" alt="Edit note">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="250" alt="Edit checklist">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="250" alt="Settings">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="250" alt="Server settings">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/7.png" width="250" alt="Sync settings">
</p>
---
<div align="center">
📝 Offline-first &nbsp;&nbsp; 🔄 Smart Sync &nbsp;&nbsp; 🔒 Self-hosted &nbsp;&nbsp; 🔋 Battery-friendly
</div>
## ✨ Highlights
- **NEU: Checklisten** - Tap-to-Check, Drag & Drop, Swipe-to-Delete
- 📝 **Offline-First** - Funktioniert ohne Internet
- 🔄 **Auto-Sync** - Bei WiFi-Verbindung (15/30/60 Min)
- 🔒 **Self-Hosted** - Deine Daten bleiben bei dir (WebDAV)
- 💾 **Lokales Backup** - Export/Import als JSON-Datei
- 🖥️ **Desktop-Integration** - Markdown-Export für Obsidian, VS Code, Typora
- 🔋 **Akkuschonend** - ~0.2-0.8% pro Tag
- 🎨 **Material Design 3** - Dark Mode & Dynamic Colors
- 📝 **Offline-first** - Works without internet
- 📊 **Flexible views** - Switch between list and grid layout
- **Checklists** - Tap-to-check, drag & drop
- 🔄 **Configurable sync triggers** - onSave, onResume, WiFi-connect, periodic (15/30/60 min), boot
- 📌 **Widgets** - Home screen quick-note and note list widgets
- 🔀 **Smart sorting** - By title, date modified, date created, color
- **Parallel sync** - Downloads up to 5 notes simultaneously
- 🌍 **Multilingual** - English/German with language selector
- 🔒 **Self-hosted** - Your data stays with you (WebDAV)
- 💾 **Local backup** - Export/Import as JSON file (encryption available)
- 🖥️ **Desktop integration** - Markdown export for Obsidian, VS Code, Typora
- 🎨 **Material Design 3** - Dynamic dark/light mode & colors based on system settings
➡️ **Vollständige Feature-Liste:** [FEATURES.md](docs/FEATURES.md)
➡️ **Complete feature list:** [FEATURES.md](docs/FEATURES.md)
---
## 🚀 Quick Start
## 🚀 Schnellstart
### 1. Server Setup (5 Minuten)
### 1. Server Setup (5 minutes)
```bash
git clone https://github.com/inventory69/simple-notes-sync.git
cd simple-notes-sync/server
cp .env.example .env
# Passwort in .env setzen
# Set password in .env
docker compose up -d
```
➡️ **Details:** [Server Setup Guide](server/README.md)
### 2. App Installation (2 Minuten)
### 2. App Installation (2 minutes)
1. [APK herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest)
2. Installieren & öffnen
3. ⚙️ Einstellungen → Server konfigurieren:
- **URL:** `http://DEINE-SERVER-IP:8080/` _(nur Base-URL!)_
1. [Download APK](https://github.com/inventory69/simple-notes-sync/releases/latest)
2. Install & open
3. ⚙️ Settings → Configure server:
- **URL:** `http://YOUR-SERVER-IP:8080/` _(base URL only!)_
- **User:** `noteuser`
- **Passwort:** _(aus .env)_
- **WLAN:** _(dein Netzwerk-Name)_
4. **Verbindung testen** → Auto-Sync aktivieren
5. Fertig! 🎉
- **Password:** _(from .env)_
- **WiFi:** _(your network name)_
4. **Test connection** → Enable auto-sync
5. Done! 🎉
➡️ **Ausführliche Anleitung:** [QUICKSTART.md](QUICKSTART.md)
➡️ **Detailed guide:** [QUICKSTART.md](QUICKSTART.md)
---
## 📚 Documentation
## 📚 Dokumentation
| Dokument | Inhalt |
|----------|--------|
| **[QUICKSTART.md](QUICKSTART.md)** | Schritt-für-Schritt Installation |
| **[FEATURES.md](docs/FEATURES.md)** | Vollständige Feature-Liste |
| **[BACKUP.md](docs/BACKUP.md)** | Backup & Wiederherstellung |
| **[DESKTOP.md](docs/DESKTOP.md)** | Desktop-Integration (Markdown) |
| **[DOCS.md](docs/DOCS.md)** | Technische Details & Troubleshooting |
| **[CHANGELOG.md](CHANGELOG.md)** | Versionshistorie |
---
## 🛠️ Entwicklung
| Document | Content |
|----------|---------|
| **[QUICKSTART.md](QUICKSTART.md)** | Step-by-step installation |
| **[FEATURES.md](docs/FEATURES.md)** | Complete feature list |
| **[BACKUP.md](docs/BACKUP.md)** | Backup & restore guide |
| **[DESKTOP.md](docs/DESKTOP.md)** | Desktop integration (Markdown) |
| **[SELF_SIGNED_SSL.md](docs/SELF_SIGNED_SSL.md)** | Self-signed SSL certificate setup |
| **[DOCS.md](docs/DOCS.md)** | Technical details & troubleshooting |
| **[CHANGELOG.md](CHANGELOG.md)** | Version history |
| **[UPCOMING.md](docs/UPCOMING.md)** | Upcoming features 🚀 |
| **[TRANSLATING.md](docs/TRANSLATING.md)** | Translation guide 🌍 |
```bash
cd android
./gradlew assembleStandardRelease
```
➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md#-build--deployment)
➡️ **Build guide:** [DOCS.md](docs/DOCS.md#-build--deployment)
---
## 💡 Feature Requests & Ideas
Have an idea for a new feature or improvement? We'd love to hear it!
➡️ **How to suggest features:**
1. Check [existing discussions](https://github.com/inventory69/simple-notes-sync/discussions) to see if someone already suggested it
2. If not, start a new discussion in the "Feature Requests / Ideas" category
3. Upvote (👍) features you'd like to see
Features with enough community support will be considered for implementation. Please keep in mind that this app is designed to stay simple and user-friendly.
## 🤝 Contributing
Beiträge willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md)
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)
---
## 📄 License
## 📄 Lizenz
MIT License - see [LICENSE](LICENSE)
MIT License - siehe [LICENSE](LICENSE)
<div align="center">
<br /><br />
---
**v1.8.1** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
**v1.4.0** · Built with ❤️ using Kotlin + Material Design 3
</div>

1
android/.gitignore vendored
View File

@@ -18,3 +18,4 @@ local.properties
key.properties
*.jks
*.keystore
/app/src/main/assets/changelogs/

View File

@@ -1,8 +1,8 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen, aktivieren in v1.4.0
// alias(libs.plugins.ktlint)
alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler
alias(libs.plugins.ktlint) // ✅ v1.6.1: Reaktiviert nach Code-Cleanup
alias(libs.plugins.detekt)
}
@@ -20,8 +20,8 @@ android {
applicationId = "dev.dettmer.simplenotes"
minSdk = 24
targetSdk = 36
versionCode = 11 // 🚀 v1.4.0: Checklists Feature
versionName = "1.4.0" // 🚀 v1.4.0: Checklists, Multi-Device Sync Fixes, UX Improvements
versionCode = 21 // 🐛 v1.8.1: Checklist Fixes, Widget Sorting, ProGuard Audit
versionName = "1.8.1" // 🐛 v1.8.1: Bugfix & Polish Release
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -96,7 +96,17 @@ android {
buildFeatures {
viewBinding = true
buildConfig = true // Enable BuildConfig generation
compose = true // v1.5.0: Jetpack Compose für Settings Redesign
}
// v1.7.0: Mock Android framework classes in unit tests (Log, etc.)
testOptions {
unitTests.isReturnDefaultValues = true
}
// 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 {
sourceCompatibility = JavaVersion.VERSION_11
@@ -135,24 +145,50 @@ dependencies {
// SwipeRefreshLayout für Pull-to-Refresh
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
// 🔐 v1.7.0: AndroidX Security Crypto für Backup-Verschlüsselung
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// ═══════════════════════════════════════════════════════════════════════
// 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)
// ═══════════════════════════════════════════════════════════════════════
// 🆕 v1.8.0: Homescreen Widgets
// ═══════════════════════════════════════════════════════════════════════
implementation("androidx.glance:glance-appwidget:1.1.1")
implementation("androidx.glance:glance-material3:1.1.1")
// Testing (bleiben so)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}
// v1.3.1: ktlint deaktiviert wegen Parser-Problemen
// Aktivieren in v1.4.0 wenn Code-Stil bereinigt wurde
// ktlint {
// android = true
// outputToConsole = true
// ignoreFailures = true
// enableExperimentalRules = false
// filter {
// exclude("**/generated/**")
// exclude("**/build/**")
// }
// }
// v1.6.1: ktlint reaktiviert nach Code-Cleanup
ktlint {
android = true
outputToConsole = true
ignoreFailures = true // Parser-Probleme in WebDavSyncService.kt und build.gradle.kts
enableExperimentalRules = false
filter {
exclude("**/generated/**")
exclude("**/build/**")
// Legacy adapters with ktlint parser issues
exclude("**/adapters/NotesAdapter.kt")
exclude("**/SettingsActivity.kt")
}
}
// ⚡ v1.3.1: detekt-Konfiguration
detekt {
@@ -163,4 +199,34 @@ detekt {
// Parallel-Verarbeitung für schnellere Checks
parallel = true
}
// 📋 v1.8.0: Copy F-Droid changelogs to assets for post-update dialog
// Single source of truth: F-Droid changelogs are reused in the app
tasks.register<Copy>("copyChangelogsToAssets") {
description = "Copies F-Droid changelogs to app assets for post-update dialog"
from("$rootDir/../fastlane/metadata/android") {
include("*/changelogs/*.txt")
}
into("$projectDir/src/main/assets/changelogs")
// Preserve directory structure: en-US/20.txt, de-DE/20.txt
eachFile {
val parts = relativePath.segments
if (parts.size >= 3) {
// parts[0] = locale (en-US, de-DE)
// parts[1] = "changelogs"
// parts[2] = version file (20.txt)
relativePath = RelativePath(true, parts[0], parts[2])
}
}
includeEmptyDirs = false
}
// Run before preBuild to ensure changelogs are available
tasks.named("preBuild") {
dependsOn("copyChangelogsToAssets")
}

View File

@@ -59,5 +59,41 @@
-keep class * implements com.google.gson.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer
# Keep your app's data classes
-keep class dev.dettmer.simplenotes.** { *; }
# ═══════════════════════════════════════════════════════════════════════
# App-specific rules: Only keep what Gson/reflection needs
# ═══════════════════════════════════════════════════════════════════════
# 🔧 v1.8.1 FIX: Breite Regel verwenden statt spezifischer Klassen
#
# GRUND: NoteRaw ist eine private data class innerhalb von Note.Companion.
# Der JVM-Klassenname ist Note$Companion$NoteRaw, NICHT Note$NoteRaw.
# Die spezifische Regel griff nicht R8 obfuskierte NoteRaw-Felder
# Gson konnte keine JSON-Felder matchen ALLE Notizen unlesbar!
#
# Sichere Lösung: Alle App-Klassen behalten (wie in v1.7.2).
# APK-Größenoptimierung kann in v1.9.0 sicher evaluiert werden.
-keep class dev.dettmer.simplenotes.** { *; }
# v1.7.1: Suppress TextInclusionStrategy warnings on older Android versions
# This class only exists on API 35+ but Compose handles the fallback gracefully
-dontwarn android.text.Layout$TextInclusionStrategy
# ═══════════════════════════════════════════════════════════════════════
# v1.8.1: Widget & Compose Fixes
# ═══════════════════════════════════════════════════════════════════════
# Glance Widget ActionCallbacks (instanziiert via Reflection durch actionRunCallback<T>())
# Ohne diese Rule findet R8 die Klassen nicht zur Laufzeit Widget-Crash
-keep class dev.dettmer.simplenotes.widget.*Action { *; }
-keep class dev.dettmer.simplenotes.widget.*Receiver { *; }
# Glance Widget State (Preferences-basiert, intern via Reflection)
-keep class androidx.glance.appwidget.state.** { *; }
-keep class androidx.datastore.preferences.** { *; }
# Compose Text Layout: Verhindert dass R8 onTextLayout-Callbacks
# als Side-Effect-Free optimiert (behebt Gradient-Regression)
-keepclassmembers class androidx.compose.foundation.text.** {
<methods>;
}
-keep class androidx.compose.ui.text.TextLayoutResult { *; }

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

@@ -12,6 +12,11 @@
<!-- Battery Optimization (for WorkManager background sync) -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- v1.7.1: Foreground Service for Expedited Work (Android 9-11) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- v1.7.1: Foreground Service Type for Android 10+ -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- BOOT Permission - CRITICAL für Auto-Sync nach Neustart! -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -22,13 +27,15 @@
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.SimpleNotes"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="31">
<!-- MainActivity v1.5.0 (Jetpack Compose) - Launcher -->
<activity
android:name=".MainActivity"
android:name=".ui.main.ComposeMainActivity"
android:exported="true"
android:theme="@style/Theme.SimpleNotes.Splash">
<intent-filter>
@@ -37,16 +44,37 @@
</intent-filter>
</activity>
<!-- Editor Activity -->
<!-- Legacy MainActivity (XML-based) - kept for reference -->
<activity
android:name=".MainActivity"
android:exported="false"
android:theme="@style/Theme.SimpleNotes" />
<!-- Editor Activity (Legacy - XML-based) -->
<activity
android:name=".NoteEditorActivity"
android:windowSoftInputMode="adjustResize"
android:parentActivityName=".MainActivity" />
android:parentActivityName=".ui.main.ComposeMainActivity" />
<!-- Settings Activity -->
<!-- Editor Activity v1.5.0 (Jetpack Compose) -->
<activity
android:name=".ui.editor.ComposeNoteEditorActivity"
android:windowSoftInputMode="adjustResize"
android:parentActivityName=".ui.main.ComposeMainActivity"
android:theme="@style/Theme.SimpleNotes" />
<!-- Settings Activity (Legacy - XML-based) -->
<activity
android:name=".SettingsActivity"
android:parentActivityName=".MainActivity" />
android:parentActivityName=".ui.main.ComposeMainActivity" />
<!-- Settings Activity v1.5.0 (Jetpack Compose) -->
<!-- v1.8.0: Handle locale changes without recreate for smooth language switching -->
<activity
android:name=".ui.settings.ComposeSettingsActivity"
android:configChanges="locale|layoutDirection"
android:parentActivityName=".ui.main.ComposeMainActivity"
android:theme="@style/Theme.SimpleNotes" />
<!-- Boot Receiver - Startet WorkManager nach Reboot -->
<receiver
@@ -70,6 +98,31 @@
android:resource="@xml/file_paths" />
</provider>
<!-- v1.7.1: WorkManager SystemForegroundService for Expedited Work -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<!-- 🆕 v1.8.0: Widget Receiver -->
<receiver
android:name=".widget.NoteWidgetReceiver"
android:exported="true"
android:label="@string/widget_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/note_widget_info" />
</receiver>
<!-- 🆕 v1.8.0: Widget Config Activity -->
<activity
android:name=".widget.NoteWidgetConfigActivity"
android:exported="false"
android:theme="@style/Theme.SimpleNotes" />
</application>
</manifest>

View File

@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
package dev.dettmer.simplenotes
import android.Manifest
@@ -48,6 +50,11 @@ 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() {
private lateinit var recyclerViewNotes: RecyclerView
@@ -126,11 +133,17 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermission()
}
// 🌍 v1.7.2: Debug Locale für Fehlersuche
logLocaleInfo()
findViews()
setupToolbar()
setupRecyclerView()
setupFab()
// v1.4.1: Migrate checklists for backwards compatibility
migrateChecklistsForBackwardsCompat()
loadNotes()
// 🔄 v1.3.1: Observe sync state for UI updates
@@ -179,6 +192,11 @@ class MainActivity : AppCompatActivity() {
swipeRefreshLayout.isRefreshing = false
syncStatusBanner.visibility = View.GONE
}
// v1.5.0: Silent-Sync - Banner nicht anzeigen, aber Sync-Controls deaktivieren
SyncStateManager.SyncState.SYNCING_SILENT -> {
setSyncControlsEnabled(false)
// Kein Banner anzeigen bei Silent-Sync (z.B. onResume Auto-Sync)
}
}
}
}
@@ -219,6 +237,7 @@ class MainActivity : AppCompatActivity() {
* - Nur Success-Toast (kein "Auto-Sync..." Toast)
*
* NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!)
* v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt
*/
private fun triggerAutoSync(source: String = "unknown") {
// Throttling: Max 1 Sync pro Minute
@@ -227,7 +246,8 @@ class MainActivity : AppCompatActivity() {
}
// 🔄 v1.3.1: Check if sync already running
if (!SyncStateManager.tryStartSync("auto-$source")) {
// v1.5.0: silent=true - kein Banner bei Auto-Sync
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
return
}
@@ -375,13 +395,13 @@ class MainActivity : AppCompatActivity() {
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
SyncStateManager.markCompleted("Bereits synchronisiert")
SyncStateManager.markCompleted(getString(R.string.snackbar_already_synced))
return@launch
}
// Check if server is reachable
if (!syncService.isServerReachable()) {
SyncStateManager.markError("Server nicht erreichbar")
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
return@launch
}
@@ -389,7 +409,7 @@ class MainActivity : AppCompatActivity() {
val result = syncService.syncNotes()
if (result.isSuccess) {
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
SyncStateManager.markCompleted(getString(R.string.snackbar_synced_count, result.syncedCount))
loadNotes()
} else {
SyncStateManager.markError(result.errorMessage)
@@ -457,10 +477,10 @@ class MainActivity : AppCompatActivity() {
val checkboxAlways = dialogView.findViewById<CheckBox>(R.id.checkboxAlwaysDeleteFromServer)
MaterialAlertDialogBuilder(this)
.setTitle("Notiz löschen")
.setMessage("\"${note.title}\" wird lokal gelöscht.\n\nAuch vom Server löschen?")
.setTitle(getString(R.string.legacy_delete_dialog_title))
.setMessage(getString(R.string.legacy_delete_dialog_message, note.title))
.setView(dialogView)
.setNeutralButton("Abbrechen") { _, _ ->
.setNeutralButton(getString(R.string.cancel)) { _, _ ->
// RESTORE: Re-submit original list (note is NOT deleted from storage)
adapter.submitList(originalList)
}
@@ -475,7 +495,7 @@ class MainActivity : AppCompatActivity() {
// NOW actually delete from storage
deleteNoteLocally(note, deleteFromServer = false)
}
.setNegativeButton("Vom Server löschen") { _, _ ->
.setNegativeButton(getString(R.string.legacy_delete_from_server)) { _, _ ->
if (checkboxAlways.isChecked) {
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, true).apply()
}
@@ -497,13 +517,13 @@ class MainActivity : AppCompatActivity() {
// Show Snackbar with UNDO option
val message = if (deleteFromServer) {
"\"${note.title}\" wird lokal und vom Server gelöscht"
getString(R.string.legacy_delete_with_server, note.title)
} else {
"\"${note.title}\" lokal gelöscht (Server bleibt)"
getString(R.string.legacy_delete_local_only, note.title)
}
Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG)
.setAction("RÜCKGÄNGIG") {
.setAction(getString(R.string.snackbar_undo)) {
// UNDO: Restore note
storage.saveNote(note)
pendingDeletions.remove(note.id)
@@ -525,7 +545,7 @@ class MainActivity : AppCompatActivity() {
runOnUiThread {
Toast.makeText(
this@MainActivity,
"Vom Server gelöscht",
getString(R.string.snackbar_deleted_from_server),
Toast.LENGTH_SHORT
).show()
}
@@ -533,7 +553,7 @@ class MainActivity : AppCompatActivity() {
runOnUiThread {
Toast.makeText(
this@MainActivity,
"Server-Löschung fehlgeschlagen",
getString(R.string.snackbar_server_delete_failed),
Toast.LENGTH_LONG
).show()
}
@@ -635,7 +655,8 @@ class MainActivity : AppCompatActivity() {
}
private fun openSettings() {
val intent = Intent(this, SettingsActivity::class.java)
// v1.5.0: Use new Jetpack Compose Settings
val intent = Intent(this, dev.dettmer.simplenotes.ui.settings.ComposeSettingsActivity::class.java)
@Suppress("DEPRECATION")
startActivityForResult(intent, REQUEST_SETTINGS)
}
@@ -654,7 +675,8 @@ class MainActivity : AppCompatActivity() {
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
SyncStateManager.markCompleted("Bereits synchronisiert")
val message = getString(R.string.toast_already_synced)
SyncStateManager.markCompleted(message)
return@launch
}
@@ -665,7 +687,7 @@ class MainActivity : AppCompatActivity() {
if (!isReachable) {
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
SyncStateManager.markError("Server nicht erreichbar")
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
return@launch
}
@@ -730,6 +752,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(
requestCode: Int,
permissions: Array<out String>,
@@ -741,12 +811,46 @@ class MainActivity : AppCompatActivity() {
REQUEST_NOTIFICATION_PERMISSION -> {
if (grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
showToast("Benachrichtigungen aktiviert")
showToast(getString(R.string.toast_notifications_enabled))
} else {
showToast("Benachrichtigungen deaktiviert. " +
"Du kannst sie in den Einstellungen aktivieren.")
showToast(getString(R.string.toast_notifications_disabled))
}
}
}
}
/**
* 🌍 v1.7.2: Debug-Logging für Locale-Problem
* Hilft zu identifizieren warum deutsche Strings trotz englischer App angezeigt werden
*/
private fun logLocaleInfo() {
if (!BuildConfig.DEBUG) return
Logger.d(TAG, "╔═══════════════════════════════════════════════════")
Logger.d(TAG, "║ 🌍 LOCALE DEBUG INFO")
Logger.d(TAG, "╠═══════════════════════════════════════════════════")
// System Locale
val systemLocale = java.util.Locale.getDefault()
Logger.d(TAG, "║ System Locale (Locale.getDefault()): $systemLocale")
// Resources Locale
val resourcesLocale = resources.configuration.locales[0]
Logger.d(TAG, "║ Resources Locale: $resourcesLocale")
// Context Locale (API 24+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val contextLocales = resources.configuration.locales
Logger.d(TAG, "║ Context Locales (all): $contextLocales")
}
// Test String Loading
val testString = getString(R.string.toast_already_synced)
Logger.d(TAG, "║ Test: getString(R.string.toast_already_synced)")
Logger.d(TAG, "║ Result: '$testString'")
Logger.d(TAG, "║ Expected EN: '✅ Already synced'")
Logger.d(TAG, "║ Is German?: ${testString.contains("Bereits") || testString.contains("synchronisiert")}")
Logger.d(TAG, "╚═══════════════════════════════════════════════════")
}
}

View File

@@ -1,5 +1,8 @@
@file:Suppress("DEPRECATION") // Legacy code using ProgressDialog & LocalBroadcastManager, will be removed in v2.0.0
package dev.dettmer.simplenotes
import android.annotation.SuppressLint
import android.app.ProgressDialog
import android.content.Context
import android.content.Intent
@@ -42,6 +45,7 @@ import java.net.URL
import java.text.SimpleDateFormat
import java.util.Locale
@Suppress("LargeClass", "DEPRECATION") // Legacy code using ProgressDialog & LocalBroadcastManager, will be removed in v2.0.0
class SettingsActivity : AppCompatActivity() {
companion object {
@@ -183,10 +187,12 @@ class SettingsActivity : AppCompatActivity() {
}
// Set URL with protocol prefix in the text field
@Suppress("SetTextI18n") // Technical URL, not UI text
editTextServerUrl.setText("$protocol://$hostPath")
} else {
// Default: HTTP selected (lokale Server sind häufiger), empty URL with prefix
radioHttp.isChecked = true
@Suppress("SetTextI18n") // Technical URL, not UI text
editTextServerUrl.setText("http://")
}
@@ -227,9 +233,9 @@ class SettingsActivity : AppCompatActivity() {
*/
private fun updateProtocolHint() {
protocolHintText.text = if (radioHttp.isChecked) {
"HTTP nur für lokale Netzwerke (z.B. 192.168.x.x, 10.x.x.x)"
getString(R.string.server_connection_http_hint)
} else {
"HTTPS für sichere Verbindungen über das Internet"
getString(R.string.server_connection_https_hint)
}
}
@@ -249,6 +255,7 @@ class SettingsActivity : AppCompatActivity() {
}
// Set new URL with correct protocol
@Suppress("SetTextI18n") // Technical URL, not UI text
editTextServerUrl.setText("$newProtocol://$hostPath")
// Move cursor to end
@@ -359,7 +366,7 @@ class SettingsActivity : AppCompatActivity() {
60L -> "60 Minuten"
else -> "$newInterval Minuten"
}
showToast("⏱️ Sync-Intervall auf $intervalText geändert")
showToast(getString(R.string.toast_sync_interval_changed, intervalText))
Logger.i(TAG, "Sync interval changed to $newInterval minutes, restarted periodic sync")
} else {
showToast("⏱️ Sync-Intervall gespeichert (Auto-Sync ist deaktiviert)")
@@ -376,10 +383,10 @@ class SettingsActivity : AppCompatActivity() {
val versionName = BuildConfig.VERSION_NAME
val versionCode = BuildConfig.VERSION_CODE
textViewAppVersion.text = "Version $versionName ($versionCode)"
textViewAppVersion.text = getString(R.string.about_version, versionName, versionCode)
} catch (e: Exception) {
Logger.e(TAG, "Failed to load version info", e)
textViewAppVersion.text = "Version nicht verfügbar"
textViewAppVersion.text = getString(R.string.version_not_available)
}
// GitHub Repository Card
@@ -475,12 +482,12 @@ class SettingsActivity : AppCompatActivity() {
*/
private fun showClearLogsConfirmation() {
AlertDialog.Builder(this)
.setTitle("Logs löschen?")
.setMessage("Alle gespeicherten Sync-Logs werden unwiderruflich gelöscht.")
.setPositiveButton("Löschen") { _, _ ->
.setTitle(getString(R.string.debug_delete_logs_title))
.setMessage(getString(R.string.debug_delete_logs_message))
.setPositiveButton(getString(R.string.delete)) { _, _ ->
clearLogs()
}
.setNegativeButton("Abbrechen", null)
.setNegativeButton(getString(R.string.cancel), null)
.show()
}
@@ -491,13 +498,13 @@ class SettingsActivity : AppCompatActivity() {
try {
val cleared = Logger.clearLogFile(this)
if (cleared) {
showToast("🗑️ Logs gelöscht")
showToast(getString(R.string.toast_logs_deleted))
} else {
showToast("📭 Keine Logs zum Löschen")
showToast(getString(R.string.toast_no_logs_to_delete))
}
} catch (e: Exception) {
Logger.e(TAG, "Failed to clear logs", e)
showToast("❌ Fehler beim Löschen: ${e.message}")
showToast(getString(R.string.toast_logs_delete_error, e.message ?: ""))
}
}
@@ -510,7 +517,7 @@ class SettingsActivity : AppCompatActivity() {
startActivity(intent)
} catch (e: Exception) {
Logger.e(TAG, "Failed to open URL: $url", e)
showToast("❌ Fehler beim Öffnen des Links")
showToast(getString(R.string.toast_link_error))
}
}
@@ -524,7 +531,7 @@ class SettingsActivity : AppCompatActivity() {
// 🔥 v1.1.2: Validate HTTP URL (only allow for local networks)
if (fullUrl.isNotEmpty()) {
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(fullUrl)
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(this, fullUrl)
if (!isValid) {
// Only show error in TextField (no Toast)
textInputLayoutServerUrl.isErrorEnabled = true
@@ -552,7 +559,7 @@ class SettingsActivity : AppCompatActivity() {
// 🔥 v1.1.2: Validate before testing
if (fullUrl.isNotEmpty()) {
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(fullUrl)
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(this, fullUrl)
if (!isValid) {
// Only show error in TextField (no Toast)
textInputLayoutServerUrl.isErrorEnabled = true
@@ -596,7 +603,7 @@ class SettingsActivity : AppCompatActivity() {
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) {
showToast("✅ Bereits synchronisiert")
showToast(getString(R.string.toast_already_synced))
SyncStateManager.markCompleted()
return@launch
}
@@ -605,8 +612,8 @@ class SettingsActivity : AppCompatActivity() {
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in anderen Triggern)
if (!syncService.isServerReachable()) {
showToast("⚠️ Server nicht erreichbar")
SyncStateManager.markError("Server nicht erreichbar")
showToast("⚠️ ${getString(R.string.snackbar_server_unreachable)}")
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
checkServerStatus() // Server-Status aktualisieren
return@launch
}
@@ -641,12 +648,12 @@ class SettingsActivity : AppCompatActivity() {
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty()) {
textViewServerStatus.text = "❌ Nicht konfiguriert"
textViewServerStatus.text = getString(R.string.server_status_not_configured)
textViewServerStatus.setTextColor(getColor(android.R.color.holo_red_dark))
return
}
textViewServerStatus.text = "🔍 Prüfe..."
textViewServerStatus.text = getString(R.string.status_checking)
textViewServerStatus.setTextColor(getColor(android.R.color.darker_gray))
lifecycleScope.launch {
@@ -666,10 +673,10 @@ class SettingsActivity : AppCompatActivity() {
}
if (isReachable) {
textViewServerStatus.text = "✅ Erreichbar"
textViewServerStatus.text = getString(R.string.server_status_reachable)
textViewServerStatus.setTextColor(getColor(android.R.color.holo_green_dark))
} else {
textViewServerStatus.text = "❌ Nicht erreichbar"
textViewServerStatus.text = getString(R.string.server_status_unreachable)
textViewServerStatus.setTextColor(getColor(android.R.color.holo_red_dark))
}
}
@@ -803,18 +810,24 @@ class SettingsActivity : AppCompatActivity() {
.setMessage(
"Damit die App im Hintergrund synchronisieren kann, " +
"muss die Akku-Optimierung deaktiviert werden.\n\n" +
"Bitte wähle 'Nicht optimieren' für Simple Notes."
getString(R.string.battery_optimization_dialog_message)
)
.setPositiveButton("Einstellungen öffnen") { _, _ ->
.setPositiveButton(getString(R.string.battery_optimization_open_settings)) { _, _ ->
openBatteryOptimizationSettings()
}
.setNegativeButton("Später") { dialog, _ ->
.setNegativeButton(getString(R.string.battery_optimization_later)) { dialog, _ ->
dialog.dismiss()
}
.setCancelable(false)
.show()
}
/**
* Note: REQUEST_IGNORE_BATTERY_OPTIMIZATIONS is acceptable for F-Droid builds.
* For Play Store builds, this would need to be changed to
* ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS (shows list, doesn't request directly).
*/
@SuppressLint("BatteryLife")
private fun openBatteryOptimizationSettings() {
try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
@@ -915,20 +928,20 @@ class SettingsActivity : AppCompatActivity() {
// Radio Buttons erstellen
val radioMerge = android.widget.RadioButton(this).apply {
text = "⚪ Zusammenführen (Standard)\n → Neue hinzufügen, Bestehende behalten"
text = getString(R.string.backup_mode_merge_full)
id = android.view.View.generateViewId()
isChecked = true
setPadding(10, 10, 10, 10)
}
val radioReplace = android.widget.RadioButton(this).apply {
text = "⚪ Ersetzen\n → Alle löschen & Backup importieren"
text = getString(R.string.backup_mode_replace_full)
id = android.view.View.generateViewId()
setPadding(10, 10, 10, 10)
}
val radioOverwrite = android.widget.RadioButton(this).apply {
text = "⚪ Duplikate überschreiben\n → Backup gewinnt bei Konflikten"
text = getString(R.string.backup_mode_overwrite_full)
id = android.view.View.generateViewId()
setPadding(10, 10, 10, 10)
}
@@ -944,6 +957,7 @@ class SettingsActivity : AppCompatActivity() {
}
// Info Text
@Suppress("SetTextI18n") // Programmatically generated dialog text
val infoText = android.widget.TextView(this).apply {
text = "Quelle: $sourceText\n\nWiederherstellungs-Modus:"
textSize = 16f
@@ -952,7 +966,7 @@ class SettingsActivity : AppCompatActivity() {
// Hinweis Text
val hintText = android.widget.TextView(this).apply {
text = "\n Ein Sicherheits-Backup wird vor dem Wiederherstellen automatisch erstellt."
text = getString(R.string.backup_restore_info)
textSize = 14f
setTypeface(null, android.graphics.Typeface.ITALIC)
setPadding(0, 20, 0, 0)
@@ -978,7 +992,7 @@ class SettingsActivity : AppCompatActivity() {
RestoreSource.WEBDAV_SERVER -> performRestoreFromServer(selectedMode)
}
}
.setNegativeButton("Abbrechen", null)
.setNegativeButton(getString(R.string.cancel), null)
.show()
}

View File

@@ -15,11 +15,29 @@ class SimpleNotesApplication : Application() {
lateinit var networkMonitor: NetworkMonitor // Public access für SettingsActivity
/**
* 🌍 v1.7.1: Apply app locale to Application Context
*
* This ensures ViewModels and other components using Application Context
* get the correct locale-specific strings.
*/
override fun attachBaseContext(base: Context) {
// Apply the app locale before calling super
// This is handled by AppCompatDelegate which reads from system storage
super.attachBaseContext(base)
}
override fun onCreate() {
super.onCreate()
// File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!)
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
// 🔧 Hotfix v1.6.2: Migrate offline mode setting BEFORE any ViewModel initialization
// This prevents the offline mode bug where users updating from v1.5.0 incorrectly
// appear as offline even though they have a configured server
migrateOfflineModeSetting(prefs)
// File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!)
if (prefs.getBoolean("file_logging_enabled", false)) {
Logger.enableFileLogging(this)
Logger.d(TAG, "📝 File logging enabled at Application startup")
@@ -50,4 +68,30 @@ class SimpleNotesApplication : Application() {
// WorkManager läuft weiter auch nach onTerminate!
// Nur bei deaktiviertem Auto-Sync stoppen wir es
}
/**
* 🔧 Hotfix v1.6.2: Migrate offline mode setting for updates from v1.5.0
*
* Problem: KEY_OFFLINE_MODE didn't exist in v1.5.0, but MainViewModel
* and NoteEditorViewModel use `true` as default, causing existing users
* with configured servers to appear in offline mode after update.
*
* Fix: Set the key BEFORE any ViewModel is initialized based on whether
* a server was already configured.
*/
private fun migrateOfflineModeSetting(prefs: android.content.SharedPreferences) {
if (!prefs.contains(Constants.KEY_OFFLINE_MODE)) {
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
val hasServerConfig = !serverUrl.isNullOrEmpty() &&
serverUrl != "http://" &&
serverUrl != "https://"
// If server was configured → offlineMode = false (continue syncing)
// If no server → offlineMode = true (new users / offline users)
val offlineModeValue = !hasServerConfig
prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, offlineModeValue).apply()
Logger.i(TAG, "🔄 Migrated offline_mode_enabled: hasServer=$hasServerConfig → offlineMode=$offlineModeValue")
}
}
}

View File

@@ -3,12 +3,10 @@ package dev.dettmer.simplenotes.adapters
import android.graphics.Paint
import android.text.Editable
import android.text.TextWatcher
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import android.widget.ImageButton
import android.widget.ImageView
@@ -55,33 +53,31 @@ class ChecklistEditorAdapter(
editText.setText(item.text)
updateStrikethrough(item.isChecked)
// TextWatcher für Änderungen
// 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) {
onItemTextChanged(pos, s?.toString() ?: "")
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)
// Enter-Taste = neues Item
editText.setOnEditorActionListener { _, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_NEXT ||
(event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)) {
val pos = bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) {
onAddNewItem(pos + 1)
}
true
} else {
false
}
}
// Delete Button
deleteButton.setOnClickListener {
val pos = bindingAdapterPosition

View File

@@ -89,6 +89,7 @@ class NotesAdapter(
SyncStatus.PENDING -> android.R.drawable.ic_popup_sync
SyncStatus.CONFLICT -> android.R.drawable.ic_dialog_alert
SyncStatus.LOCAL_ONLY -> android.R.drawable.ic_menu_save
SyncStatus.DELETED_ON_SERVER -> android.R.drawable.ic_menu_delete // 🆕 v1.8.0
}
imageViewSyncStatus.setImageResource(syncIcon)
imageViewSyncStatus.visibility = View.VISIBLE

View File

@@ -5,6 +5,7 @@ import android.net.Uri
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.Logger
@@ -30,20 +31,24 @@ class BackupManager(private val context: Context) {
private const val BACKUP_VERSION = 1
private const val AUTO_BACKUP_DIR = "auto_backups"
private const val AUTO_BACKUP_RETENTION_DAYS = 7
private const val MAGIC_BYTES_LENGTH = 4 // v1.7.0: For encryption check
}
private val storage = NotesStorage(context)
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
private val encryptionManager = EncryptionManager() // 🔐 v1.7.0
/**
* Erstellt Backup aller Notizen
*
* @param uri Output-URI (via Storage Access Framework)
* @param password Optional password for encryption (null = unencrypted)
* @return BackupResult mit Erfolg/Fehler Info
*/
suspend fun createBackup(uri: Uri): BackupResult = withContext(Dispatchers.IO) {
suspend fun createBackup(uri: Uri, password: String? = null): BackupResult = withContext(Dispatchers.IO) {
return@withContext try {
Logger.d(TAG, "📦 Creating backup to: $uri")
val encryptedSuffix = if (password != null) " (encrypted)" else ""
Logger.d(TAG, "📦 Creating backup$encryptedSuffix to: $uri")
val allNotes = storage.loadAllNotes()
Logger.d(TAG, " Found ${allNotes.size} notes to backup")
@@ -58,15 +63,22 @@ class BackupManager(private val context: Context) {
val jsonString = gson.toJson(backupData)
// 🔐 v1.7.0: Encrypt if password is provided
val dataToWrite = if (password != null) {
encryptionManager.encrypt(jsonString.toByteArray(), password)
} else {
jsonString.toByteArray()
}
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(jsonString.toByteArray())
Logger.d(TAG, "✅ Backup created successfully")
outputStream.write(dataToWrite)
Logger.d(TAG, "✅ Backup created successfully$encryptedSuffix")
}
BackupResult(
success = true,
notesCount = allNotes.size,
message = "Backup erstellt: ${allNotes.size} Notizen"
message = "Backup erstellt: ${allNotes.size} Notizen$encryptedSuffix"
)
} catch (e: Exception) {
@@ -125,26 +137,48 @@ class BackupManager(private val context: Context) {
*
* @param uri Backup-Datei URI
* @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite)
* @param password Optional password if backup is encrypted
* @return RestoreResult mit Details
*/
suspend fun restoreBackup(uri: Uri, mode: RestoreMode): RestoreResult = withContext(Dispatchers.IO) {
suspend fun restoreBackup(uri: Uri, mode: RestoreMode, password: String? = null): RestoreResult = withContext(Dispatchers.IO) {
return@withContext try {
Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)")
// 1. Backup-Datei lesen
val jsonString = context.contentResolver.openInputStream(uri)?.use { inputStream ->
inputStream.bufferedReader().use { it.readText() }
val fileData = context.contentResolver.openInputStream(uri)?.use { inputStream ->
inputStream.readBytes()
} ?: return@withContext RestoreResult(
success = false,
error = "Datei konnte nicht gelesen werden"
)
// 🔐 v1.7.0: Check if encrypted and decrypt if needed
val jsonString = try {
if (encryptionManager.isEncrypted(fileData)) {
if (password == null) {
return@withContext RestoreResult(
success = false,
error = "Backup ist verschlüsselt. Bitte Passwort eingeben."
)
}
val decrypted = encryptionManager.decrypt(fileData, password)
String(decrypted)
} else {
String(fileData)
}
} catch (e: EncryptionException) {
return@withContext RestoreResult(
success = false,
error = "Entschlüsselung fehlgeschlagen: ${e.message}"
)
}
// 2. Backup validieren & parsen
val validationResult = validateBackup(jsonString)
if (!validationResult.isValid) {
return@withContext RestoreResult(
success = false,
error = validationResult.errorMessage ?: "Ungültige Backup-Datei"
error = validationResult.errorMessage ?: context.getString(R.string.error_invalid_backup_file)
)
}
@@ -171,11 +205,27 @@ class BackupManager(private val context: Context) {
Logger.e(TAG, "Failed to restore backup", e)
RestoreResult(
success = false,
error = "Wiederherstellung fehlgeschlagen: ${e.message}"
error = context.getString(R.string.error_restore_failed, e.message ?: "")
)
}
}
/**
* 🔐 v1.7.0: Check if backup file is encrypted
*/
suspend fun isBackupEncrypted(uri: Uri): Boolean = withContext(Dispatchers.IO) {
return@withContext try {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
val header = ByteArray(MAGIC_BYTES_LENGTH)
val bytesRead = inputStream.read(header)
bytesRead == MAGIC_BYTES_LENGTH && encryptionManager.isEncrypted(header)
} ?: false
} catch (e: Exception) {
Logger.e(TAG, "Failed to check encryption status", e)
false
}
}
/**
* Validiert Backup-Datei
*/
@@ -187,8 +237,7 @@ class BackupManager(private val context: Context) {
if (backupData.backupVersion > BACKUP_VERSION) {
return ValidationResult(
isValid = false,
errorMessage = "Backup-Version nicht unterstützt " +
"(v${backupData.backupVersion} benötigt v${BACKUP_VERSION}+)"
errorMessage = context.getString(R.string.error_backup_version_unsupported, backupData.backupVersion, BACKUP_VERSION)
)
}
@@ -196,7 +245,7 @@ class BackupManager(private val context: Context) {
if (backupData.notes.isEmpty()) {
return ValidationResult(
isValid = false,
errorMessage = "Backup enthält keine Notizen"
errorMessage = context.getString(R.string.error_backup_empty)
)
}
@@ -208,7 +257,7 @@ class BackupManager(private val context: Context) {
if (invalidNotes.isNotEmpty()) {
return ValidationResult(
isValid = false,
errorMessage = "Backup enthält ${invalidNotes.size} ungültige Notizen"
errorMessage = context.getString(R.string.error_backup_invalid_notes, invalidNotes.size)
)
}
@@ -217,7 +266,7 @@ class BackupManager(private val context: Context) {
} catch (e: Exception) {
ValidationResult(
isValid = false,
errorMessage = "Backup-Datei beschädigt oder ungültig: ${e.message}"
errorMessage = context.getString(R.string.error_backup_corrupt, e.message ?: "")
)
}
}
@@ -241,7 +290,7 @@ class BackupManager(private val context: Context) {
success = true,
importedNotes = newNotes.size,
skippedNotes = skippedNotes,
message = "${newNotes.size} neue Notizen importiert, $skippedNotes übersprungen"
message = context.getString(R.string.restore_merge_result, newNotes.size, skippedNotes)
)
}
@@ -262,10 +311,10 @@ class BackupManager(private val context: Context) {
success = true,
importedNotes = backupNotes.size,
skippedNotes = 0,
message = "Alle Notizen ersetzt: ${backupNotes.size} importiert"
message = context.getString(R.string.restore_replace_result, backupNotes.size)
)
}
/**
* Restore-Modus: OVERWRITE_DUPLICATES
* Backup überschreibt bei ID-Konflikten
@@ -287,7 +336,7 @@ class BackupManager(private val context: Context) {
importedNotes = newNotes.size,
skippedNotes = 0,
overwrittenNotes = overwrittenNotes.size,
message = "${newNotes.size} neu, ${overwrittenNotes.size} überschrieben"
message = context.getString(R.string.restore_overwrite_result, newNotes.size, overwrittenNotes.size)
)
}

View File

@@ -0,0 +1,172 @@
package dev.dettmer.simplenotes.backup
import dev.dettmer.simplenotes.utils.Logger
import java.nio.ByteBuffer
import java.nio.charset.StandardCharsets
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
/**
* 🔐 v1.7.0: Encryption Manager for Backup Files
*
* Provides AES-256-GCM encryption for local backups with:
* - Password-based encryption (PBKDF2 key derivation)
* - Random salt + IV for each encryption
* - GCM authentication tag for integrity
* - Simple file format: [MAGIC][VERSION][SALT][IV][ENCRYPTED_DATA]
*/
class EncryptionManager {
companion object {
private const val TAG = "EncryptionManager"
// File format constants
private const val MAGIC = "SNE1" // Simple Notes Encrypted v1
private const val VERSION: Byte = 1
private const val MAGIC_BYTES = 4
private const val VERSION_BYTES = 1
private const val SALT_LENGTH = 32 // 256 bits
private const val IV_LENGTH = 12 // 96 bits (recommended for GCM)
private const val HEADER_LENGTH = MAGIC_BYTES + VERSION_BYTES + SALT_LENGTH + IV_LENGTH // 49 bytes
// Encryption constants
private const val KEY_LENGTH = 256 // AES-256
private const val GCM_TAG_LENGTH = 128 // 128 bits
private const val PBKDF2_ITERATIONS = 100_000 // OWASP recommendation
// Algorithm names
private const val KEY_DERIVATION_ALGORITHM = "PBKDF2WithHmacSHA256"
private const val ENCRYPTION_ALGORITHM = "AES/GCM/NoPadding"
}
/**
* Encrypt data with password
*
* @param data Plaintext data to encrypt
* @param password User password
* @return Encrypted byte array with header [MAGIC][VERSION][SALT][IV][CIPHERTEXT]
*/
fun encrypt(data: ByteArray, password: String): ByteArray {
Logger.d(TAG, "🔐 Encrypting ${data.size} bytes...")
// Generate random salt and IV
val salt = ByteArray(SALT_LENGTH)
val iv = ByteArray(IV_LENGTH)
SecureRandom().apply {
nextBytes(salt)
nextBytes(iv)
}
// Derive encryption key from password
val key = deriveKey(password, salt)
// Encrypt data
val cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM)
val secretKey = SecretKeySpec(key, "AES")
val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmSpec)
val ciphertext = cipher.doFinal(data)
// Build encrypted file: MAGIC + VERSION + SALT + IV + CIPHERTEXT
val result = ByteBuffer.allocate(HEADER_LENGTH + ciphertext.size).apply {
put(MAGIC.toByteArray(StandardCharsets.US_ASCII))
put(VERSION)
put(salt)
put(iv)
put(ciphertext)
}.array()
Logger.d(TAG, "✅ Encryption successful: ${result.size} bytes (header: $HEADER_LENGTH, ciphertext: ${ciphertext.size})")
return result
}
/**
* Decrypt data with password
*
* @param encryptedData Encrypted byte array (with header)
* @param password User password
* @return Decrypted plaintext
* @throws EncryptionException if decryption fails (wrong password, corrupted data, etc.)
*/
@Suppress("ThrowsCount") // Multiple validation steps require separate throws
fun decrypt(encryptedData: ByteArray, password: String): ByteArray {
Logger.d(TAG, "🔓 Decrypting ${encryptedData.size} bytes...")
// Validate minimum size
if (encryptedData.size < HEADER_LENGTH) {
throw EncryptionException("File too small: ${encryptedData.size} bytes (expected at least $HEADER_LENGTH)")
}
// Parse header
val buffer = ByteBuffer.wrap(encryptedData)
// Verify magic bytes
val magic = ByteArray(MAGIC_BYTES)
buffer.get(magic)
val magicString = String(magic, StandardCharsets.US_ASCII)
if (magicString != MAGIC) {
throw EncryptionException("Invalid file format: expected '$MAGIC', got '$magicString'")
}
// Check version
val version = buffer.get()
if (version != VERSION) {
throw EncryptionException("Unsupported version: $version (expected $VERSION)")
}
// Extract salt and IV
val salt = ByteArray(SALT_LENGTH)
val iv = ByteArray(IV_LENGTH)
buffer.get(salt)
buffer.get(iv)
// Extract ciphertext
val ciphertext = ByteArray(buffer.remaining())
buffer.get(ciphertext)
// Derive key from password
val key = deriveKey(password, salt)
// Decrypt
return try {
val cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM)
val secretKey = SecretKeySpec(key, "AES")
val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmSpec)
val plaintext = cipher.doFinal(ciphertext)
Logger.d(TAG, "✅ Decryption successful: ${plaintext.size} bytes")
plaintext
} catch (e: Exception) {
Logger.e(TAG, "Decryption failed", e)
throw EncryptionException("Decryption failed: ${e.message}. Wrong password?", e)
}
}
/**
* Derive 256-bit encryption key from password using PBKDF2
*/
private fun deriveKey(password: String, salt: ByteArray): ByteArray {
val spec = PBEKeySpec(password.toCharArray(), salt, PBKDF2_ITERATIONS, KEY_LENGTH)
val factory = SecretKeyFactory.getInstance(KEY_DERIVATION_ALGORITHM)
return factory.generateSecret(spec).encoded
}
/**
* Check if data is encrypted (starts with magic bytes)
*/
fun isEncrypted(data: ByteArray): Boolean {
if (data.size < MAGIC_BYTES) return false
val magic = data.sliceArray(0 until MAGIC_BYTES)
return String(magic, StandardCharsets.US_ASCII) == MAGIC
}
}
/**
* Exception thrown when encryption/decryption fails
*/
class EncryptionException(message: String, cause: Throwable? = null) : Exception(message, cause)

View File

@@ -0,0 +1,21 @@
package dev.dettmer.simplenotes.models
/**
* 🆕 v1.8.0: Sortieroptionen für Checklist-Items im Editor
*/
enum class ChecklistSortOption {
/** Manuelle Reihenfolge (Drag & Drop) — kein Re-Sort */
MANUAL,
/** Alphabetisch A→Z */
ALPHABETICAL_ASC,
/** Alphabetisch Z→A */
ALPHABETICAL_DESC,
/** Unchecked zuerst, dann Checked */
UNCHECKED_FIRST,
/** Checked zuerst, dann Unchecked */
CHECKED_FIRST
}

View File

@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.models
import androidx.compose.runtime.Immutable
import dev.dettmer.simplenotes.utils.Logger
import java.text.SimpleDateFormat
import java.util.Date
@@ -7,6 +8,12 @@ import java.util.Locale
import java.util.TimeZone
import java.util.UUID
/**
* Note data class with Compose stability annotation.
* @Immutable tells Compose this class is stable and won't change unexpectedly,
* enabling skip optimizations during recomposition.
*/
@Immutable
data class Note(
val id: String = UUID.randomUUID().toString(),
val title: String,
@@ -17,16 +24,47 @@ data class Note(
val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY,
// v1.4.0: Checklisten-Felder
val noteType: NoteType = NoteType.TEXT,
val checklistItems: List<ChecklistItem>? = null
val checklistItems: List<ChecklistItem>? = null,
// 🆕 v1.8.1 (IMPL_03): Persistierte Sortierung
val checklistSortOption: String? = null
) {
/**
* Serialisiert Note zu JSON (v1.4.0: Nutzt Gson für komplexe Strukturen)
* 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 {
val gson = com.google.gson.GsonBuilder()
.setPrettyPrinting()
.create()
return gson.toJson(this)
// v1.4.1: Für Checklisten den Fallback-Content generieren
val noteToSerialize = if (noteType == NoteType.CHECKLIST && checklistItems != null) {
this.copy(content = generateChecklistFallbackContent())
} else {
this
}
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}"
} ?: ""
}
/**
@@ -35,13 +73,20 @@ data class Note(
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
*/
fun toMarkdown(): String {
// 🆕 v1.8.1 (IMPL_03): Sortierung im Frontmatter
val sortLine = if (noteType == NoteType.CHECKLIST && checklistSortOption != null) {
"\nsort: $checklistSortOption"
} else {
""
}
val header = """
---
id: $id
created: ${formatISO8601(createdAt)}
updated: ${formatISO8601(updatedAt)}
device: $deviceId
type: ${noteType.name.lowercase()}
type: ${noteType.name.lowercase()}$sortLine
---
# $title
@@ -83,12 +128,20 @@ type: ${noteType.name.lowercase()}
NoteType.TEXT
}
// 🆕 v1.8.1 (IMPL_03): Gespeicherte Sortierung laden
val checklistSortOption = if (jsonObject.has("checklistSortOption") &&
!jsonObject.get("checklistSortOption").isJsonNull) {
jsonObject.get("checklistSortOption").asString
} else {
null
}
// 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
val checklistItems = if (jsonObject.has("checklistItems") &&
var checklistItems: List<ChecklistItem>? = if (jsonObject.has("checklistItems") &&
!jsonObject.get("checklistItems").isJsonNull
) {
gson.fromJson<List<ChecklistItem>>(
@@ -99,6 +152,19 @@ type: ${noteType.name.lowercase()}
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,
@@ -109,7 +175,8 @@ type: ${noteType.name.lowercase()}
deviceId = rawNote.deviceId,
syncStatus = rawNote.syncStatus ?: SyncStatus.LOCAL_ONLY,
noteType = noteType,
checklistItems = checklistItems
checklistItems = checklistItems,
checklistSortOption = checklistSortOption // 🆕 v1.8.1 (IMPL_03)
)
} catch (e: Exception) {
Logger.w(TAG, "Failed to parse JSON: ${e.message}")
@@ -130,14 +197,44 @@ type: ${noteType.name.lowercase()}
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)
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
* 🔧 v1.7.2 (IMPL_014): Optional serverModifiedTime für korrekte Timestamp-Sync
*
* @param md Markdown-String mit YAML Frontmatter
* @param serverModifiedTime Optionaler Server-Datei mtime (Priorität über YAML timestamp)
* @return Note-Objekt oder null bei Parse-Fehler
*/
fun fromMarkdown(md: String): Note? {
fun fromMarkdown(md: String, serverModifiedTime: Long? = null): Note? {
return try {
// Parse YAML Frontmatter + Markdown Content
val frontmatterRegex = Regex("^---\\n(.+?)\\n---\\n(.*)$", RegexOption.DOT_MATCHES_ALL)
@@ -167,6 +264,9 @@ type: ${noteType.name.lowercase()}
else -> NoteType.TEXT
}
// 🆕 v1.8.1 (IMPL_03): Gespeicherte Sortierung aus YAML laden
val checklistSortOption = metadata["sort"]
// 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("# ") }
@@ -202,16 +302,27 @@ type: ${noteType.name.lowercase()}
checklistItems = null
}
// 🔧 v1.7.2 (IMPL_014): Server mtime hat Priorität über YAML timestamp
val yamlUpdatedAt = parseISO8601(metadata["updated"] ?: "")
val effectiveUpdatedAt = when {
serverModifiedTime != null && serverModifiedTime > yamlUpdatedAt -> {
Logger.d(TAG, "Using server mtime ($serverModifiedTime) over YAML ($yamlUpdatedAt)")
serverModifiedTime
}
else -> yamlUpdatedAt
}
Note(
id = metadata["id"] ?: UUID.randomUUID().toString(),
title = title,
content = content,
createdAt = parseISO8601(metadata["created"] ?: ""),
updatedAt = parseISO8601(metadata["updated"] ?: ""),
updatedAt = effectiveUpdatedAt,
deviceId = metadata["device"] ?: "desktop",
syncStatus = SyncStatus.SYNCED, // Annahme: Vom Server importiert
noteType = noteType,
checklistItems = checklistItems
checklistItems = checklistItems,
checklistSortOption = checklistSortOption // 🆕 v1.8.1 (IMPL_03)
)
} catch (e: Exception) {
Logger.w(TAG, "Failed to parse Markdown: ${e.message}")
@@ -230,18 +341,99 @@ type: ${noteType.name.lowercase()}
}
/**
* Parst ISO8601 zurück zu Timestamp (Task #1.2.0-10)
* 🔧 v1.7.2 (IMPL_002): Robustes ISO8601 Parsing mit Multi-Format Unterstützung
*
* Unterstützte Formate (in Prioritätsreihenfolge):
* 1. 2024-12-21T18:00:00Z (UTC mit Z)
* 2. 2024-12-21T18:00:00+01:00 (mit Offset)
* 3. 2024-12-21T18:00:00+0100 (Offset ohne Doppelpunkt)
* 4. 2024-12-21T18:00:00.123Z (mit Millisekunden)
* 5. 2024-12-21T18:00:00.123+01:00 (Millisekunden + Offset)
* 6. 2024-12-21 18:00:00 (Leerzeichen statt T)
*
* Fallback: Aktueller Timestamp bei Fehler
*
* @param dateString ISO8601 Datum-String
* @return Unix Timestamp in Millisekunden
*/
private fun parseISO8601(dateString: String): Long {
return try {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
sdf.timeZone = TimeZone.getTimeZone("UTC")
sdf.parse(dateString)?.time ?: System.currentTimeMillis()
} catch (e: Exception) {
Logger.w(TAG, "Failed to parse ISO8601 date '$dateString': ${e.message}")
System.currentTimeMillis() // Fallback
if (dateString.isBlank()) {
return System.currentTimeMillis()
}
// Normalisiere: Leerzeichen → T
val normalized = dateString.trim().replace(' ', 'T')
// Format-Patterns in Prioritätsreihenfolge
val patterns = listOf(
// Mit Timezone Z
"yyyy-MM-dd'T'HH:mm:ss'Z'",
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
// Mit Offset XXX (+01:00)
"yyyy-MM-dd'T'HH:mm:ssXXX",
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
// Mit Offset ohne Doppelpunkt (+0100)
"yyyy-MM-dd'T'HH:mm:ssZ",
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
// Ohne Timezone (interpretiere als UTC)
"yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd'T'HH:mm:ss.SSS"
)
// Versuche alle Patterns nacheinander
for (pattern in patterns) {
@Suppress("SwallowedException") // Intentional: try all patterns before logging
try {
val sdf = SimpleDateFormat(pattern, Locale.US)
// Für Patterns ohne Timezone: UTC annehmen
if (!pattern.contains("XXX") && !pattern.contains("Z")) {
sdf.timeZone = TimeZone.getTimeZone("UTC")
}
val parsed = sdf.parse(normalized)
if (parsed != null) {
return parsed.time
}
} catch (e: Exception) {
// 🔇 Exception intentionally swallowed - try next pattern
// Only log if no pattern matches (see fallback below)
continue
}
}
// Fallback wenn kein Pattern passt
Logger.w(TAG, "Failed to parse ISO8601 date '$dateString' with any pattern, using current time")
return System.currentTimeMillis()
}
}
}
/**
* 🎨 v1.7.0: Note size classification for Staggered Grid Layout
*/
enum class NoteSize {
SMALL, // Compact display (< 80 chars or ≤ 4 checklist items)
LARGE; // Full-width display
companion object {
const val SMALL_TEXT_THRESHOLD = 80 // Max characters for compact text note
const val SMALL_CHECKLIST_THRESHOLD = 4 // Max items for compact checklist
}
}
/**
* 🎨 v1.7.0: Determine note size for grid layout optimization
*/
fun Note.getSize(): NoteSize {
return when (noteType) {
NoteType.TEXT -> {
if (content.length < NoteSize.SMALL_TEXT_THRESHOLD) NoteSize.SMALL else NoteSize.LARGE
}
NoteType.CHECKLIST -> {
val itemCount = checklistItems?.size ?: 0
if (itemCount <= NoteSize.SMALL_CHECKLIST_THRESHOLD) NoteSize.SMALL else NoteSize.LARGE
}
}
}

View File

@@ -0,0 +1,20 @@
package dev.dettmer.simplenotes.models
/**
* 🆕 v1.8.0: Sortierrichtung
*/
enum class SortDirection(val prefsValue: String) {
ASCENDING("asc"),
DESCENDING("desc");
fun toggle(): SortDirection = when (this) {
ASCENDING -> DESCENDING
DESCENDING -> ASCENDING
}
companion object {
fun fromPrefsValue(value: String): SortDirection {
return entries.find { it.prefsValue == value } ?: DESCENDING
}
}
}

View File

@@ -0,0 +1,24 @@
package dev.dettmer.simplenotes.models
/**
* 🆕 v1.8.0: Sortieroptionen für die Notizliste
*/
enum class SortOption(val prefsValue: String) {
/** Zuletzt bearbeitete zuerst (Default) */
UPDATED_AT("updatedAt"),
/** Zuletzt erstellte zuerst */
CREATED_AT("createdAt"),
/** Alphabetisch nach Titel */
TITLE("title"),
/** Nach Notiz-Typ (Text / Checkliste) */
NOTE_TYPE("noteType");
companion object {
fun fromPrefsValue(value: String): SortOption {
return entries.find { it.prefsValue == value } ?: UPDATED_AT
}
}
}

View File

@@ -1,8 +1,15 @@
package dev.dettmer.simplenotes.models
/**
* Sync-Status einer Notiz
*
* v1.4.0: Initial (LOCAL_ONLY, SYNCED, PENDING, CONFLICT)
* v1.8.0: DELETED_ON_SERVER hinzugefügt
*/
enum class SyncStatus {
LOCAL_ONLY, // Noch nie gesynct
SYNCED, // Erfolgreich gesynct
PENDING, // Wartet auf Sync
CONFLICT // Konflikt erkannt
LOCAL_ONLY, // Noch nie gesynct
SYNCED, // Erfolgreich gesynct
PENDING, // Wartet auf Sync
CONFLICT, // Konflikt erkannt
DELETED_ON_SERVER // 🆕 v1.8.0: Server hat gelöscht, lokal noch vorhanden
}

View File

@@ -5,12 +5,16 @@ import dev.dettmer.simplenotes.models.DeletionTracker
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
class NotesStorage(private val context: Context) {
companion object {
private const val TAG = "NotesStorage"
// 🔒 v1.7.2 (IMPL_001): Mutex für thread-sichere Deletion Tracker Operationen
private val deletionTrackerMutex = Mutex()
}
private val notesDir: File = File(context.filesDir, "notes").apply {
@@ -31,11 +35,16 @@ class NotesStorage(private val context: Context) {
}
}
/**
* Lädt alle Notizen aus dem lokalen Speicher.
*
* 🔀 v1.8.0: Sortierung entfernt — wird jetzt im ViewModel durchgeführt,
* damit der User die Sortierung konfigurieren kann.
*/
fun loadAllNotes(): List<Note> {
return notesDir.listFiles()
?.filter { it.extension == "json" }
?.mapNotNull { Note.fromJson(it.readText()) }
?.sortedByDescending { it.updatedAt }
?: emptyList()
}
@@ -107,6 +116,30 @@ class NotesStorage(private val context: Context) {
}
}
/**
* 🔒 v1.7.2 (IMPL_001): Thread-sichere Deletion-Tracking mit Mutex
*
* Verhindert Race Conditions bei Batch-Deletes durch exklusiven Zugriff
* auf den Deletion Tracker.
*
* @param noteId ID der gelöschten Notiz
* @param deviceId Geräte-ID für Konflikt-Erkennung
*/
suspend fun trackDeletionSafe(noteId: String, deviceId: String) {
deletionTrackerMutex.withLock {
val tracker = loadDeletionTracker()
tracker.addDeletion(noteId, deviceId)
saveDeletionTracker(tracker)
Logger.d(TAG, "📝 Tracked deletion (mutex-protected): $noteId")
}
}
/**
* Legacy-Methode ohne Mutex-Schutz.
* Verwendet für synchrone Aufrufe wo Coroutines nicht verfügbar sind.
*
* @deprecated Verwende trackDeletionSafe() für Thread-Safety wo möglich
*/
fun trackDeletion(noteId: String, deviceId: String) {
val tracker = loadDeletionTracker()
tracker.addDeletion(noteId, deviceId)
@@ -123,6 +156,26 @@ class NotesStorage(private val context: Context) {
saveDeletionTracker(DeletionTracker())
Logger.d(TAG, "🗑️ Deletion tracker cleared")
}
/**
* 🔄 v1.7.0: Reset all sync statuses to PENDING when server changes
* This ensures notes are uploaded to the new server on next sync
*/
fun resetAllSyncStatusToPending(): Int {
val notes = loadAllNotes()
var updatedCount = 0
notes.forEach { note ->
if (note.syncStatus == dev.dettmer.simplenotes.models.SyncStatus.SYNCED) {
val updatedNote = note.copy(syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING)
saveNote(updatedNote)
updatedCount++
}
}
Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING")
return updatedCount
}
fun getNotesDir(): File = notesDir

View File

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

View File

@@ -68,15 +68,20 @@ class NetworkMonitor(private val context: Context) {
lastConnectedNetworkId = currentNetworkId
// Auto-Sync check
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
Logger.d(TAG, " Auto-Sync enabled: $autoSyncEnabled")
// WiFi-Connect Trigger prüfen - NICHT KEY_AUTO_SYNC!
// Der Callback ist registriert WEIL KEY_SYNC_TRIGGER_WIFI_CONNECT = true
// Aber defensive Prüfung für den Fall, dass Settings sich geändert haben
val wifiConnectEnabled = prefs.getBoolean(
Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT,
Constants.DEFAULT_TRIGGER_WIFI_CONNECT
)
Logger.d(TAG, " WiFi-Connect trigger enabled: $wifiConnectEnabled")
if (autoSyncEnabled) {
Logger.d(TAG, " ✅ Triggering WorkManager...")
if (wifiConnectEnabled) {
Logger.d(TAG, " ✅ Triggering WiFi-Connect sync...")
triggerWifiConnectSync()
} else {
Logger.d(TAG, " ❌ Auto-sync disabled - not triggering")
Logger.d(TAG, " ⏭️ WiFi-Connect trigger disabled in settings")
}
} else {
Logger.d(TAG, " ⚠️ Same WiFi network as before - ignoring (no network change)")
@@ -102,8 +107,22 @@ class NetworkMonitor(private val context: Context) {
/**
* Triggert WiFi-Connect Sync via WorkManager
* WorkManager wacht App auf (funktioniert auch wenn App geschlossen!)
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_WIFI_CONNECT
*/
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")
// 🔥 WICHTIG: NetworkType.UNMETERED constraint!
@@ -126,30 +145,80 @@ class NetworkMonitor(private val context: Context) {
/**
* Startet WorkManager mit Network Constraints + NetworkCallback
*
* 🆕 v1.7.0: Überarbeitete Logik - WiFi-Connect Trigger funktioniert UNABHÄNGIG von KEY_AUTO_SYNC
* - KEY_AUTO_SYNC + KEY_SYNC_TRIGGER_PERIODIC → Periodic Sync
* - KEY_SYNC_TRIGGER_WIFI_CONNECT → WiFi-Connect Trigger (unabhängig!)
*/
fun startMonitoring() {
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
Logger.d(TAG, "🚀 NetworkMonitor.startMonitoring() called")
if (!autoSyncEnabled) {
Logger.d(TAG, "Auto-sync disabled - stopping all monitoring")
stopMonitoring()
return
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
val periodicEnabled = prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)
val wifiConnectEnabled = prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)
Logger.d(TAG, " Settings: autoSync=$autoSyncEnabled, periodic=$periodicEnabled, wifiConnect=$wifiConnectEnabled")
// 1. Periodic Sync (nur wenn KEY_AUTO_SYNC UND KEY_SYNC_TRIGGER_PERIODIC aktiv)
if (autoSyncEnabled && periodicEnabled) {
Logger.d(TAG, "📅 Starting periodic sync...")
startPeriodicSync()
} else {
WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
Logger.d(TAG, "⏭️ Periodic sync disabled (autoSync=$autoSyncEnabled, periodic=$periodicEnabled)")
}
Logger.d(TAG, "🚀 Starting NetworkMonitor (WorkManager + WiFi Callback)")
// 2. WiFi-Connect Trigger (🆕 UNABHÄNGIG von KEY_AUTO_SYNC!)
if (wifiConnectEnabled) {
Logger.d(TAG, "📶 Starting WiFi monitoring...")
startWifiMonitoring()
} else {
stopWifiMonitoring()
Logger.d(TAG, "⏭️ WiFi-Connect trigger disabled")
}
// 1. WorkManager für periodic sync
startPeriodicSync()
// 2. NetworkCallback für WiFi-Connect Detection
startWifiMonitoring()
// 3. Logging für Debug
if (!autoSyncEnabled && !wifiConnectEnabled) {
Logger.d(TAG, "🛑 No background triggers active")
}
}
/**
* 🆕 v1.7.0: Stoppt nur WiFi-Monitoring, nicht den gesamten NetworkMonitor
*/
@Suppress("SwallowedException")
private fun stopWifiMonitoring() {
try {
connectivityManager.unregisterNetworkCallback(networkCallback)
Logger.d(TAG, "🛑 WiFi NetworkCallback unregistered")
} catch (e: Exception) {
// Already unregistered - das ist OK
Logger.d(TAG, " WiFi callback already unregistered")
}
}
/**
* Startet WorkManager periodic sync
* 🔥 Interval aus SharedPrefs konfigurierbar (15/30/60 min)
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_PERIODIC
*/
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
val intervalMinutes = prefs.getLong(
Constants.PREF_SYNC_INTERVAL_MINUTES,

View File

@@ -0,0 +1,210 @@
package dev.dettmer.simplenotes.sync
import com.thegrizzlylabs.sardineandroid.DavResource
import com.thegrizzlylabs.sardineandroid.Sardine
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
import dev.dettmer.simplenotes.utils.Logger
import okhttp3.Credentials
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.Closeable
import java.io.InputStream
private const val HTTP_METHOD_NOT_ALLOWED = 405
/**
* 🔧 v1.7.1: Wrapper für Sardine der Connection Leaks verhindert
* 🔧 v1.7.2 (IMPL_003): Implementiert Closeable für explizites Resource-Management
*
* Hintergrund:
* - OkHttpSardine.exists() schließt den Response-Body nicht
* - Dies führt zu "connection leaked" Warnungen im Log
* - Kann bei vielen Requests zu Socket-Exhaustion führen
* - Session-Cache hält Referenzen ohne explizites Cleanup
*
* Lösung:
* - Eigene exists()-Implementation mit korrektem Response-Cleanup
* - Preemptive Authentication um 401-Round-Trips zu vermeiden
* - Closeable Pattern für explizite Resource-Freigabe
*
* @see <a href="https://square.github.io/okhttp/4.x/okhttp/okhttp3/-response-body/">OkHttp Response Body Docs</a>
*/
class SafeSardineWrapper private constructor(
private val delegate: OkHttpSardine,
private val okHttpClient: OkHttpClient,
private val authHeader: String
) : Sardine by delegate, Closeable {
companion object {
private const val TAG = "SafeSardine"
/**
* Factory-Methode für SafeSardineWrapper
*/
fun create(
okHttpClient: OkHttpClient,
username: String,
password: String
): SafeSardineWrapper {
val delegate = OkHttpSardine(okHttpClient).apply {
setCredentials(username, password)
}
val authHeader = Credentials.basic(username, password)
return SafeSardineWrapper(delegate, okHttpClient, authHeader)
}
}
// 🆕 v1.7.2 (IMPL_003): Track ob bereits geschlossen
@Volatile
private var isClosed = false
/**
* ✅ Sichere exists()-Implementation mit Response Cleanup
*
* Im Gegensatz zu OkHttpSardine.exists() wird hier:
* 1. Preemptive Auth-Header gesendet (kein 401 Round-Trip)
* 2. Response.use{} für garantiertes Cleanup verwendet
*/
override fun exists(url: String): Boolean {
val request = Request.Builder()
.url(url)
.head()
.header("Authorization", authHeader)
.build()
return try {
okHttpClient.newCall(request).execute().use { response ->
val isSuccess = response.isSuccessful
Logger.d(TAG, "exists($url) → $isSuccess (${response.code})")
isSuccess
}
} catch (e: Exception) {
Logger.d(TAG, "exists($url) failed: ${e.message}")
false
}
}
/**
* ✅ Wrapper um get() mit Logging
*
* WICHTIG: Der zurückgegebene InputStream MUSS vom Caller geschlossen werden!
* Empfohlen: inputStream.bufferedReader().use { it.readText() }
*/
override fun get(url: String): InputStream {
Logger.d(TAG, "get($url)")
return delegate.get(url)
}
/**
* ✅ Wrapper um list() mit Logging
*/
override fun list(url: String): List<DavResource> {
Logger.d(TAG, "list($url)")
return delegate.list(url)
}
/**
* ✅ Wrapper um list(url, depth) mit Logging
*/
override fun list(url: String, depth: Int): List<DavResource> {
Logger.d(TAG, "list($url, depth=$depth)")
return delegate.list(url, depth)
}
/**
* ✅ Sichere put()-Implementation mit Response Cleanup
*
* Im Gegensatz zu OkHttpSardine.put() wird hier der Response-Body garantiert geschlossen.
* Verhindert "connection leaked" Warnungen.
*/
override fun put(url: String, data: ByteArray, contentType: String?) {
val mediaType = contentType?.toMediaTypeOrNull()
val body = data.toRequestBody(mediaType)
val request = Request.Builder()
.url(url)
.put(body)
.header("Authorization", authHeader)
.build()
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw java.io.IOException("PUT failed: ${response.code} ${response.message}")
}
Logger.d(TAG, "put($url) → ${response.code}")
}
}
/**
* ✅ Sichere delete()-Implementation mit Response Cleanup
*
* Im Gegensatz zu OkHttpSardine.delete() wird hier der Response-Body garantiert geschlossen.
* Verhindert "connection leaked" Warnungen.
*/
override fun delete(url: String) {
val request = Request.Builder()
.url(url)
.delete()
.header("Authorization", authHeader)
.build()
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw java.io.IOException("DELETE failed: ${response.code} ${response.message}")
}
Logger.d(TAG, "delete($url) → ${response.code}")
}
}
/**
* ✅ Sichere createDirectory()-Implementation mit Response Cleanup
*
* Im Gegensatz zu OkHttpSardine.createDirectory() wird hier der Response-Body garantiert geschlossen.
* Verhindert "connection leaked" Warnungen.
* 405 (Method Not Allowed) wird toleriert da dies bedeutet, dass der Ordner bereits existiert.
*/
override fun createDirectory(url: String) {
val request = Request.Builder()
.url(url)
.method("MKCOL", null)
.header("Authorization", authHeader)
.build()
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful && response.code != HTTP_METHOD_NOT_ALLOWED) { // 405 = already exists
throw java.io.IOException("MKCOL failed: ${response.code} ${response.message}")
}
Logger.d(TAG, "createDirectory($url) → ${response.code}")
}
}
/**
* 🆕 v1.7.2 (IMPL_003): Schließt alle offenen Verbindungen
*
* Wichtig: Nach close() darf der Client nicht mehr verwendet werden!
* Eviction von Connection Pool Einträgen und Cleanup von internen Ressourcen.
*/
override fun close() {
if (isClosed) {
Logger.d(TAG, "Already closed, skipping")
return
}
try {
// OkHttpClient Connection Pool räumen
okHttpClient.connectionPool.evictAll()
// Dispatcher shutdown (beendet laufende Calls)
okHttpClient.dispatcher.cancelAll()
isClosed = true
Logger.d(TAG, "✅ Closed successfully (connections evicted)")
} catch (e: Exception) {
Logger.e(TAG, "Failed to close", e)
}
}
// Alle anderen Methoden werden automatisch durch 'by delegate' weitergeleitet
}

View File

@@ -0,0 +1,103 @@
package dev.dettmer.simplenotes.sync
/**
* 🆕 v1.8.0: Detaillierter Sync-Fortschritt für UI
*
* Einziges Banner-System für den gesamten Sync-Lebenszyklus:
* - PREPARING: Sofort beim Klick, bleibt während Vor-Checks und Server-Prüfung
* - UPLOADING / DOWNLOADING / IMPORTING_MARKDOWN: Nur bei echten Aktionen
* - COMPLETED / ERROR: Ergebnis mit Nachricht + Auto-Hide
*
* Ersetzt das alte duale Banner-System (SyncStatusBanner + SyncProgressBanner)
*/
data class SyncProgress(
val phase: SyncPhase = SyncPhase.IDLE,
val current: Int = 0,
val total: Int = 0,
val currentFileName: String? = null,
val resultMessage: String? = null,
val silent: Boolean = false,
val startTime: Long = System.currentTimeMillis()
) {
/**
* Fortschritt als Float zwischen 0.0 und 1.0
*/
val progress: Float
get() = if (total > 0) current.toFloat() / total else 0f
/**
* Fortschritt als Prozent (0-100)
*/
val percentComplete: Int
get() = (progress * 100).toInt()
/**
* Vergangene Zeit seit Start in Millisekunden
*/
val elapsedMs: Long
get() = System.currentTimeMillis() - startTime
/**
* Geschätzte verbleibende Zeit in Millisekunden
* Basiert auf durchschnittlicher Zeit pro Item
*/
val estimatedRemainingMs: Long?
get() {
if (current == 0 || total == 0) return null
val avgTimePerItem = elapsedMs / current
val remaining = total - current
return avgTimePerItem * remaining
}
/**
* Ob das Banner sichtbar sein soll
* Silent syncs zeigen nie ein Banner
* 🆕 v1.8.1 (IMPL_12): INFO ist immer sichtbar (nicht vom silent-Flag betroffen)
*/
val isVisible: Boolean
get() = phase == SyncPhase.INFO || (!silent && phase != SyncPhase.IDLE)
/**
* Ob gerade ein aktiver Sync läuft (mit Spinner)
*/
val isActiveSync: Boolean
get() = phase in listOf(
SyncPhase.PREPARING,
SyncPhase.UPLOADING,
SyncPhase.DOWNLOADING,
SyncPhase.IMPORTING_MARKDOWN
)
companion object {
val IDLE = SyncProgress(phase = SyncPhase.IDLE)
}
}
/**
* 🆕 v1.8.0: Sync-Phasen für detailliertes Progress-Tracking
*/
enum class SyncPhase {
/** Kein Sync aktiv */
IDLE,
/** Sync wurde gestartet, Vor-Checks laufen (hasUnsyncedChanges, isReachable, Server-Verzeichnis) */
PREPARING,
/** Lädt lokale Änderungen auf den Server hoch */
UPLOADING,
/** Lädt Server-Änderungen herunter */
DOWNLOADING,
/** Importiert Markdown-Dateien vom Server */
IMPORTING_MARKDOWN,
/** Sync erfolgreich abgeschlossen */
COMPLETED,
/** Sync mit Fehler abgebrochen */
ERROR,
/** 🆕 v1.8.1 (IMPL_12): Kurzfristige Info-Meldung (nicht sync-bezogen) */
INFO
}

View File

@@ -1,11 +1,18 @@
package dev.dettmer.simplenotes.sync
/**
* Ergebnis eines Sync-Vorgangs
*
* v1.7.0: Initial
* v1.8.0: deletedOnServerCount hinzugefügt
*/
data class SyncResult(
val isSuccess: Boolean,
val syncedCount: Int = 0,
val conflictCount: Int = 0,
val deletedOnServerCount: Int = 0, // 🆕 v1.8.0
val errorMessage: String? = null
) {
val hasConflicts: Boolean
get() = conflictCount > 0
val hasConflicts: Boolean get() = conflictCount > 0
val hasServerDeletions: Boolean get() = deletedOnServerCount > 0 // 🆕 v1.8.0
}

View File

@@ -3,127 +3,302 @@ package dev.dettmer.simplenotes.sync
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* 🔄 v1.3.1: Zentrale Verwaltung des Sync-Status
* 🆕 v1.8.0: Komplett überarbeitet - SyncProgress ist jetzt das einzige Banner-System
*
* Verhindert doppelte Syncs und informiert die UI über den aktuellen Status.
* Thread-safe Singleton mit LiveData für UI-Reaktivität.
* SyncProgress (StateFlow) steuert den gesamten Sync-Lebenszyklus:
* PREPARING → [UPLOADING] → [DOWNLOADING] → [IMPORTING_MARKDOWN] → COMPLETED/ERROR → IDLE
*
* SyncStatus (LiveData) wird nur noch intern für Mutex/Silent-Tracking verwendet.
*/
object SyncStateManager {
private const val TAG = "SyncStateManager"
/**
* Mögliche Sync-Zustände
* Mögliche Sync-Zustände (intern für Mutex + PullToRefresh)
*/
enum class SyncState {
IDLE, // Kein Sync aktiv
SYNCING, // Sync läuft gerade
COMPLETED, // Sync erfolgreich abgeschlossen (kurz anzeigen)
ERROR // Sync fehlgeschlagen (kurz anzeigen)
IDLE,
SYNCING,
SYNCING_SILENT,
COMPLETED,
ERROR
}
/**
* Detaillierte Sync-Informationen für UI
* Interne Sync-Informationen (für Mutex-Management + Silent-Tracking)
*/
data class SyncStatus(
val state: SyncState = SyncState.IDLE,
val message: String? = null,
val source: String? = null, // "manual", "auto", "pullToRefresh", "background"
val source: String? = null,
val silent: Boolean = false,
val timestamp: Long = System.currentTimeMillis()
)
// Private mutable LiveData
// Intern: Mutex + PullToRefresh State
private val _syncStatus = MutableLiveData(SyncStatus())
// Public immutable LiveData für Observer
val syncStatus: LiveData<SyncStatus> = _syncStatus
// Lock für Thread-Sicherheit
// 🆕 v1.8.0: Einziges Banner-System - SyncProgress
private val _syncProgress = MutableStateFlow(SyncProgress.IDLE)
val syncProgress: StateFlow<SyncProgress> = _syncProgress.asStateFlow()
private val lock = Any()
/**
* Prüft ob gerade ein Sync läuft
* Prüft ob gerade ein Sync läuft (inkl. Silent-Sync)
*/
val isSyncing: Boolean
get() = _syncStatus.value?.state == SyncState.SYNCING
get() {
val state = _syncStatus.value?.state
return state == SyncState.SYNCING || state == SyncState.SYNCING_SILENT
}
/**
* Versucht einen Sync zu starten.
* @return true wenn Sync gestartet werden kann, false wenn bereits einer läuft
* Bei silent=false: Setzt sofort PREPARING-Phase → Banner erscheint instant
* Bei silent=true: Setzt silent-Flag → kein Banner wird angezeigt
*/
fun tryStartSync(source: String): Boolean {
fun tryStartSync(source: String, silent: Boolean = false): Boolean {
synchronized(lock) {
if (isSyncing) {
Logger.d(TAG, "⚠️ Sync already in progress, rejecting new sync from: $source")
Logger.d(TAG, "⚠️ Sync already in progress, rejecting from: $source")
return false
}
Logger.d(TAG, "🔄 Starting sync from: $source")
val syncState = if (silent) SyncState.SYNCING_SILENT else SyncState.SYNCING
Logger.d(TAG, "🔄 Starting sync from: $source (silent=$silent)")
_syncStatus.postValue(
SyncStatus(
state = SyncState.SYNCING,
message = "Synchronisiere...",
source = source
state = syncState,
source = source,
silent = silent
)
)
// 🆕 v1.8.0: Sofort PREPARING-Phase setzen (Banner erscheint instant)
_syncProgress.value = SyncProgress(
phase = SyncPhase.PREPARING,
silent = silent,
startTime = System.currentTimeMillis()
)
return true
}
}
/**
* Markiert Sync als erfolgreich abgeschlossen
* Bei Silent-Sync: direkt auf IDLE (kein Banner)
* Bei normalem Sync: COMPLETED mit Nachricht (auto-hide durch UI)
*/
fun markCompleted(message: String? = null) {
synchronized(lock) {
val currentSource = _syncStatus.value?.source
Logger.d(TAG, "✅ Sync completed from: $currentSource")
_syncStatus.postValue(
SyncStatus(
state = SyncState.COMPLETED,
message = message,
source = currentSource
val current = _syncStatus.value
val wasSilent = current?.silent == true
val currentSource = current?.source
Logger.d(TAG, "✅ Sync completed from: $currentSource (silent=$wasSilent)")
if (wasSilent) {
// Silent-Sync: Direkt auf IDLE - kein Banner
_syncStatus.postValue(SyncStatus())
_syncProgress.value = SyncProgress.IDLE
} else {
// Normaler Sync: COMPLETED mit Nachricht anzeigen
_syncStatus.postValue(
SyncStatus(state = SyncState.COMPLETED, message = message, source = currentSource)
)
)
_syncProgress.value = SyncProgress(
phase = SyncPhase.COMPLETED,
resultMessage = message
)
}
}
}
/**
* Markiert Sync als fehlgeschlagen
* Bei Silent-Sync: Fehler trotzdem anzeigen (wichtig für User)
*/
fun markError(errorMessage: String?) {
synchronized(lock) {
val currentSource = _syncStatus.value?.source
val current = _syncStatus.value
val wasSilent = current?.silent == true
val currentSource = current?.source
Logger.e(TAG, "❌ Sync failed from: $currentSource - $errorMessage")
_syncStatus.postValue(
SyncStatus(
state = SyncState.ERROR,
message = errorMessage,
source = currentSource
)
SyncStatus(state = SyncState.ERROR, message = errorMessage, source = currentSource)
)
// Fehler immer anzeigen (auch bei Silent-Sync)
_syncProgress.value = SyncProgress(
phase = SyncPhase.ERROR,
resultMessage = errorMessage,
silent = false // Fehler nie silent
)
}
}
/**
* Setzt Status zurück auf IDLE
* Setzt alles zurück auf IDLE
*/
fun reset() {
synchronized(lock) {
_syncStatus.postValue(SyncStatus())
_syncProgress.value = SyncProgress.IDLE
}
}
// ═══════════════════════════════════════════════════════════════════════
// 🆕 v1.8.0: Detailliertes Progress-Tracking (während syncNotes())
// ═══════════════════════════════════════════════════════════════════════
/**
* Aktualisiert den detaillierten Sync-Fortschritt
* Behält silent-Flag und startTime der aktuellen Session bei
*/
fun updateProgress(
phase: SyncPhase,
current: Int = 0,
total: Int = 0,
currentFileName: String? = null
) {
synchronized(lock) {
val existing = _syncProgress.value
_syncProgress.value = SyncProgress(
phase = phase,
current = current,
total = total,
currentFileName = currentFileName,
silent = existing.silent,
startTime = existing.startTime
)
}
}
/**
* Aktualisiert die Nachricht während des Syncs (z.B. Progress)
* Inkrementiert den Fortschritt um 1
* Praktisch für Schleifen: nach jedem tatsächlichen Download
*/
fun updateMessage(message: String) {
fun incrementProgress(currentFileName: String? = null) {
synchronized(lock) {
val current = _syncStatus.value ?: return
if (current.state == SyncState.SYNCING) {
_syncStatus.postValue(current.copy(message = message))
val current = _syncProgress.value
_syncProgress.value = current.copy(
current = current.current + 1,
currentFileName = currentFileName
)
}
}
/**
* Setzt Progress zurück auf IDLE (am Ende von syncNotes())
* Wird NICHT für COMPLETED/ERROR verwendet - nur für Cleanup
*/
fun resetProgress() {
// Nicht zurücksetzen wenn COMPLETED/ERROR - die UI braucht den State noch für auto-hide
synchronized(lock) {
val current = _syncProgress.value
if (current.phase != SyncPhase.COMPLETED && current.phase != SyncPhase.ERROR) {
_syncProgress.value = SyncProgress.IDLE
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// 🆕 v1.8.1 (IMPL_08): Globaler Sync-Cooldown
// ═══════════════════════════════════════════════════════════════════════
/**
* Prüft ob seit dem letzten erfolgreichen Sync-Start genügend Zeit vergangen ist.
* Wird von ALLEN Sync-Triggern als erste Prüfung aufgerufen.
*
* @return true wenn ein neuer Sync erlaubt ist
*/
fun canSyncGlobally(prefs: android.content.SharedPreferences): Boolean {
val lastGlobalSync = prefs.getLong(dev.dettmer.simplenotes.utils.Constants.KEY_LAST_GLOBAL_SYNC_TIME, 0)
val now = System.currentTimeMillis()
val elapsed = now - lastGlobalSync
if (elapsed < dev.dettmer.simplenotes.utils.Constants.MIN_GLOBAL_SYNC_INTERVAL_MS) {
val remainingSec = (dev.dettmer.simplenotes.utils.Constants.MIN_GLOBAL_SYNC_INTERVAL_MS - elapsed) / 1000
dev.dettmer.simplenotes.utils.Logger.d(TAG, "⏳ Global sync cooldown active - wait ${remainingSec}s")
return false
}
return true
}
/**
* Markiert den aktuellen Zeitpunkt als letzten Sync-Start (global).
* Aufzurufen wenn ein Sync tatsächlich startet (nach allen Checks).
*/
fun markGlobalSyncStarted(prefs: android.content.SharedPreferences) {
prefs.edit().putLong(dev.dettmer.simplenotes.utils.Constants.KEY_LAST_GLOBAL_SYNC_TIME, System.currentTimeMillis()).apply()
}
// ═══════════════════════════════════════════════════════════════════════
// 🆕 v1.8.1 (IMPL_12): Info-Meldungen über das Banner-System
// ═══════════════════════════════════════════════════════════════════════
/**
* Zeigt eine kurzfristige Info-Meldung im Banner an.
* Wird für nicht-sync-bezogene Benachrichtigungen verwendet
* (z.B. Server-Delete-Ergebnisse).
*
* ACHTUNG: Wenn gerade ein Sync läuft (isSyncing), wird die Meldung
* ignoriert — der Sync-Progress hat Vorrang.
*
* Auto-Hide erfolgt über ComposeMainActivity (2.5s).
*/
fun showInfo(message: String) {
synchronized(lock) {
// Nicht während aktivem Sync anzeigen — Sync-Fortschritt hat Vorrang
if (isSyncing) {
Logger.d(TAG, " Info suppressed during sync: $message")
return
}
_syncProgress.value = SyncProgress(
phase = SyncPhase.INFO,
resultMessage = message,
silent = false // INFO ist nie silent
)
Logger.d(TAG, " Showing info: $message")
}
}
/**
* Zeigt eine Fehlermeldung im Banner an, auch außerhalb eines Syncs.
* Für nicht-sync-bezogene Fehler (z.B. Server-Delete fehlgeschlagen).
*
* Auto-Hide erfolgt über ComposeMainActivity (4s).
*/
fun showError(message: String?) {
synchronized(lock) {
// Nicht während aktivem Sync anzeigen
if (isSyncing) {
Logger.d(TAG, "❌ Error suppressed during sync: $message")
return
}
_syncProgress.value = SyncProgress(
phase = SyncPhase.ERROR,
resultMessage = message,
silent = false
)
Logger.e(TAG, "❌ Showing error: $message")
}
}
}

View File

@@ -1,12 +1,18 @@
@file:Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional, will migrate in v2.0.0
package dev.dettmer.simplenotes.sync
import android.app.ActivityManager
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.NotificationHelper
import kotlinx.coroutines.CancellationException
@@ -23,6 +29,35 @@ class SyncWorker(
const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED"
}
/**
* 🔧 v1.7.2: Required for expedited work on Android 9-11
*
* WorkManager ruft diese Methode auf um die Foreground-Notification zu erstellen
* wenn der Worker als Expedited Work gestartet wird.
*
* Ab Android 12+ wird diese Methode NICHT aufgerufen (neue Expedited API).
* Auf Android 9-11 MUSS diese Methode implementiert sein!
*
* @see https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#foregroundinfo
*/
override suspend fun getForegroundInfo(): ForegroundInfo {
val notification = NotificationHelper.createSyncProgressNotification(applicationContext)
// Android 10+ benötigt foregroundServiceType
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(
NotificationHelper.SYNC_PROGRESS_NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else {
ForegroundInfo(
NotificationHelper.SYNC_PROGRESS_NOTIFICATION_ID,
notification
)
}
}
/**
* Prüft ob die App im Vordergrund ist.
* Wenn ja, brauchen wir keine Benachrichtigung - die UI zeigt die Änderungen direkt.
@@ -38,6 +73,7 @@ class SyncWorker(
}
}
@Suppress("LongMethod") // Linear sync flow with debug logging — splitting would hurt readability
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
if (BuildConfig.DEBUG) {
Logger.d(TAG, "═══════════════════════════════════════")
@@ -69,7 +105,42 @@ class SyncWorker(
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 2: Checking for unsynced changes (Performance Pre-Check)")
Logger.d(TAG, "📍 Step 2: SyncStateManager coordination & global cooldown (v1.8.1)")
}
// 🆕 v1.8.1 (IMPL_08): SyncStateManager-Koordination
// Verhindert dass Foreground und Background gleichzeitig syncing-State haben
val prefs = applicationContext.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
// 🆕 v1.8.1 (IMPL_08B): onSave-Syncs bypassen den globalen Cooldown
// Grund: User hat explizit gespeichert → erwartet zeitnahen Sync
// Der eigene 5s-Throttle + isSyncing-Mutex reichen als Schutz
val isOnSaveSync = tags.contains(Constants.SYNC_ONSAVE_TAG)
// Globaler Cooldown-Check (nicht für onSave-Syncs)
if (!isOnSaveSync && !SyncStateManager.canSyncGlobally(prefs)) {
Logger.d(TAG, "⏭️ SyncWorker: Global sync cooldown active - skipping")
if (BuildConfig.DEBUG) {
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (cooldown)")
Logger.d(TAG, "═══════════════════════════════════════")
}
return@withContext Result.success()
}
if (!SyncStateManager.tryStartSync("worker-${tags.firstOrNull() ?: "unknown"}", silent = true)) {
Logger.d(TAG, "⏭️ SyncWorker: Another sync already in progress - skipping")
if (BuildConfig.DEBUG) {
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (already syncing)")
Logger.d(TAG, "═══════════════════════════════════════")
}
return@withContext Result.success()
}
// Globalen Cooldown markieren
SyncStateManager.markGlobalSyncStarted(prefs)
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 3: Checking for unsynced changes (Performance Pre-Check)")
}
// 🔥 v1.1.2: Performance-Optimierung - Skip Sync wenn keine lokalen Änderungen
@@ -87,7 +158,28 @@ class SyncWorker(
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 3: Checking server reachability (Pre-Check)")
Logger.d(TAG, "📍 Step 4: Checking sync gate (canSync)")
}
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (WiFi-Only, Offline Mode, Server Config)
val gateResult = syncService.canSync()
if (!gateResult.canSync) {
if (gateResult.isBlockedByWifiOnly) {
Logger.d(TAG, "⏭️ WiFi-only mode enabled, but not on WiFi - skipping sync")
} else {
Logger.d(TAG, "⏭️ Sync blocked by gate: ${gateResult.blockReason ?: "offline/no server"}")
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (gate blocked)")
Logger.d(TAG, "═══════════════════════════════════════")
}
return@withContext Result.success()
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 5: Checking server reachability (Pre-Check)")
}
// ⭐ KRITISCH: Server-Erreichbarkeits-Check VOR Sync
@@ -111,7 +203,7 @@ class SyncWorker(
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 3: Server reachable - proceeding with sync")
Logger.d(TAG, "📍 Step 6: Server reachable - proceeding with sync")
Logger.d(TAG, " SyncService: $syncService")
}
@@ -132,7 +224,7 @@ class SyncWorker(
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 4: Processing result")
Logger.d(TAG, "📍 Step 7: Processing result")
Logger.d(
TAG,
"📦 Sync result: success=${result.isSuccess}, " +
@@ -142,10 +234,13 @@ class SyncWorker(
if (result.isSuccess) {
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 5: Success path")
Logger.d(TAG, "📍 Step 8: Success path")
}
Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes")
// 🆕 v1.8.1 (IMPL_08): SyncStateManager aktualisieren
SyncStateManager.markCompleted()
// 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) {
@@ -170,7 +265,21 @@ class SyncWorker(
Logger.d(TAG, " Broadcasting sync completed...")
}
broadcastSyncCompleted(true, result.syncedCount)
// 🆕 v1.8.0: Alle Widgets aktualisieren nach Sync
try {
if (BuildConfig.DEBUG) {
Logger.d(TAG, " Updating widgets...")
}
val glanceManager = androidx.glance.appwidget.GlanceAppWidgetManager(applicationContext)
val glanceIds = glanceManager.getGlanceIds(dev.dettmer.simplenotes.widget.NoteWidget::class.java)
glanceIds.forEach { id ->
dev.dettmer.simplenotes.widget.NoteWidget().update(applicationContext, id)
}
} catch (e: Exception) {
Logger.w(TAG, "Failed to update widgets: ${e.message}")
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS")
Logger.d(TAG, "═══════════════════════════════════════")
@@ -178,9 +287,13 @@ class SyncWorker(
Result.success()
} else {
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 5: Failure path")
Logger.d(TAG, "📍 Step 8: Failure path")
}
Logger.e(TAG, "❌ Sync failed: ${result.errorMessage}")
// 🆕 v1.8.1 (IMPL_08): SyncStateManager aktualisieren
SyncStateManager.markError(result.errorMessage)
NotificationHelper.showSyncError(
applicationContext,
result.errorMessage ?: "Unbekannter Fehler"
@@ -255,6 +368,7 @@ class SyncWorker(
/**
* 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) {
val intent = Intent(ACTION_SYNC_COMPLETED).apply {
putExtra("success", success)

View File

@@ -27,6 +27,16 @@ class WifiSyncReceiver : BroadcastReceiver() {
return
}
// 🆕 v1.8.1 (IMPL_08): Globaler Cooldown (verhindert Doppel-Trigger mit NetworkMonitor)
if (!SyncStateManager.canSyncGlobally(prefs)) {
return
}
// 🆕 v1.8.1 (IMPL_08): Auch KEY_SYNC_TRIGGER_WIFI_CONNECT prüfen (Konsistenz mit NetworkMonitor)
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)) {
return
}
// Check if connected to any WiFi (SSID-Prüfung entfernt in v1.4.0)
if (isConnectedToWifi(context)) {
scheduleSyncWork(context)

View File

@@ -0,0 +1,63 @@
package dev.dettmer.simplenotes.sync.parallel
import com.thegrizzlylabs.sardineandroid.DavResource
/**
* 🆕 v1.8.0: Repräsentiert einen einzelnen Download-Task
*
* @param noteId Die ID der Notiz (ohne .json Extension)
* @param url Vollständige URL zur JSON-Datei
* @param resource WebDAV-Resource mit Metadaten
* @param serverETag E-Tag vom Server (für Caching)
* @param serverModified Letztes Änderungsdatum vom Server (Unix timestamp)
*/
data class DownloadTask(
val noteId: String,
val url: String,
val resource: DavResource,
val serverETag: String?,
val serverModified: Long
)
/**
* 🆕 v1.8.0: Ergebnis eines einzelnen Downloads
*
* Sealed class für typ-sichere Verarbeitung von Download-Ergebnissen.
* Jeder Download kann erfolgreich sein, fehlschlagen oder übersprungen werden.
*/
sealed class DownloadTaskResult {
/**
* Download erfolgreich abgeschlossen
*
* @param noteId Die ID der heruntergeladenen Notiz
* @param content JSON-Inhalt der Notiz
* @param etag E-Tag vom Server (für zukünftiges Caching)
*/
data class Success(
val noteId: String,
val content: String,
val etag: String?
) : DownloadTaskResult()
/**
* Download fehlgeschlagen
*
* @param noteId Die ID der Notiz, die nicht heruntergeladen werden konnte
* @param error Der aufgetretene Fehler
*/
data class Failure(
val noteId: String,
val error: Throwable
) : DownloadTaskResult()
/**
* Download übersprungen (z.B. wegen gelöschter Notiz)
*
* @param noteId Die ID der übersprungenen Notiz
* @param reason Grund für das Überspringen
*/
data class Skipped(
val noteId: String,
val reason: String
) : DownloadTaskResult()
}

View File

@@ -0,0 +1,138 @@
package dev.dettmer.simplenotes.sync.parallel
import com.thegrizzlylabs.sardineandroid.Sardine
import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.util.concurrent.atomic.AtomicInteger
/**
* 🆕 v1.8.0: Paralleler Download-Handler für Notizen
*
* Features:
* - Konfigurierbare max. parallele Downloads (default: 5)
* - Graceful Error-Handling (einzelne Fehler stoppen nicht den ganzen Sync)
* - Progress-Callback für UI-Updates
* - Retry-Logic für transiente Fehler mit Exponential Backoff
*
* Performance:
* - 100 Notizen: ~20s → ~4s (5x schneller)
* - 50 Notizen: ~10s → ~2s
*
* @param sardine WebDAV-Client für Downloads
* @param maxParallelDownloads Maximale Anzahl gleichzeitiger Downloads (1-10)
* @param retryCount Anzahl der Wiederholungsversuche bei Fehlern
*/
class ParallelDownloader(
private val sardine: Sardine,
private val maxParallelDownloads: Int = DEFAULT_MAX_PARALLEL,
private val retryCount: Int = DEFAULT_RETRY_COUNT
) {
companion object {
private const val TAG = "ParallelDownloader"
const val DEFAULT_MAX_PARALLEL = 5
const val DEFAULT_RETRY_COUNT = 2
private const val RETRY_DELAY_MS = 500L
}
/**
* Download-Progress Callback
*
* @param completed Anzahl abgeschlossener Downloads
* @param total Gesamtanzahl Downloads
* @param currentFile Aktueller Dateiname (optional)
*/
var onProgress: ((completed: Int, total: Int, currentFile: String?) -> Unit)? = null
/**
* Führt parallele Downloads aus
*
* Die Downloads werden mit einem Semaphore begrenzt, um Server-Überlastung
* zu vermeiden. Jeder Download wird unabhängig behandelt - Fehler in einem
* Download stoppen nicht die anderen.
*
* @param tasks Liste der Download-Tasks
* @return Liste der Ergebnisse (Success, Failure, Skipped)
*/
suspend fun downloadAll(
tasks: List<DownloadTask>
): List<DownloadTaskResult> = coroutineScope {
if (tasks.isEmpty()) {
Logger.d(TAG, "⏭️ No tasks to download")
return@coroutineScope emptyList()
}
Logger.d(TAG, "🚀 Starting parallel download: ${tasks.size} tasks, max $maxParallelDownloads concurrent")
val semaphore = Semaphore(maxParallelDownloads)
val completedCount = AtomicInteger(0)
val totalCount = tasks.size
val jobs = tasks.map { task ->
async(Dispatchers.IO) {
semaphore.withPermit {
val result = downloadWithRetry(task)
// Progress Update
val completed = completedCount.incrementAndGet()
onProgress?.invoke(completed, totalCount, task.noteId)
result
}
}
}
// Warte auf alle Downloads
val results = jobs.awaitAll()
// Statistiken loggen
val successCount = results.count { it is DownloadTaskResult.Success }
val failureCount = results.count { it is DownloadTaskResult.Failure }
val skippedCount = results.count { it is DownloadTaskResult.Skipped }
Logger.d(TAG, "📊 Download complete: $successCount success, $failureCount failed, $skippedCount skipped")
results
}
/**
* Download mit Retry-Logic und Exponential Backoff
*
* Versucht den Download bis zu (retryCount + 1) mal. Bei jedem Fehlversuch
* wird exponentiell länger gewartet (500ms, 1000ms, 1500ms, ...).
*
* @param task Der Download-Task
* @return Ergebnis des Downloads (Success oder Failure)
*/
private suspend fun downloadWithRetry(task: DownloadTask): DownloadTaskResult {
var lastError: Throwable? = null
repeat(retryCount + 1) { attempt ->
try {
val content = sardine.get(task.url).bufferedReader().use { it.readText() }
Logger.d(TAG, "✅ Downloaded ${task.noteId} (attempt ${attempt + 1})")
return DownloadTaskResult.Success(
noteId = task.noteId,
content = content,
etag = task.serverETag
)
} catch (e: Exception) {
lastError = e
Logger.w(TAG, "⚠️ Download failed ${task.noteId} (attempt ${attempt + 1}): ${e.message}")
// Retry nach Delay (außer beim letzten Versuch)
if (attempt < retryCount) {
delay(RETRY_DELAY_MS * (attempt + 1)) // Exponential backoff
}
}
}
Logger.e(TAG, "❌ Download failed after ${retryCount + 1} attempts: ${task.noteId}")
return DownloadTaskResult.Failure(task.noteId, lastError ?: Exception("Unknown error"))
}
}

View File

@@ -0,0 +1,118 @@
@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
)
}
)
}
}
}
/**
* 🆕 v1.8.0 (IMPL_025): Reload Checklist-State falls Widget Änderungen gemacht hat.
*
* Wenn die Activity aus dem Hintergrund zurückkehrt (z.B. nach Widget-Toggle),
* wird der aktuelle Note-Stand von Disk geladen und der ViewModel-State
* für Checklist-Items aktualisiert.
*/
override fun onResume() {
super.onResume()
viewModel.reloadFromStorage()
}
}
/**
* 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,222 @@
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.rememberUpdatedState
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
* v1.8.0: IMPL_023 - Drag & Drop Fix (pointerInput key + Handle-only drag)
* v1.8.0: IMPL_023b - Flicker-Fix (Straddle-Target-Center-Erkennung statt Mittelpunkt)
* v1.8.1: IMPL_14 - Separator als eigenes Item, Cross-Boundary-Drag mit Auto-Toggle
*/
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)
// 🆕 v1.8.1: Item-Größe beim Drag-Start fixieren
// Verhindert dass Höhenänderungen die Swap-Erkennung destabilisieren
private var draggingItemSize by mutableStateOf(0)
private var overscrollJob by mutableStateOf<Job?>(null)
// 🆕 v1.8.1 IMPL_14: Visual-Index des Separators (-1 = kein Separator)
var separatorVisualIndex by mutableStateOf(-1)
val draggingItemOffset: Float
get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
} ?: 0f
private val draggingItemLayoutInfo: LazyListItemInfo?
get() = state.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == draggingItemIndex }
/**
* 🆕 v1.8.1 IMPL_14: Visual-Index → Data-Index Konvertierung.
* Wenn ein Separator existiert, sind alle Items nach dem Separator um 1 verschoben.
*/
fun visualToDataIndex(visualIndex: Int): Int {
if (separatorVisualIndex < 0) return visualIndex
return if (visualIndex > separatorVisualIndex) visualIndex - 1 else visualIndex
}
/**
* 🆕 v1.8.1 IMPL_14: Data-Index → Visual-Index Konvertierung.
*/
fun dataToVisualIndex(dataIndex: Int): Int {
if (separatorVisualIndex < 0) return dataIndex
return if (dataIndex >= separatorVisualIndex) dataIndex + 1 else dataIndex
}
fun onDragStart(offset: Offset, itemIndex: Int) {
draggingItemIndex = itemIndex
val info = draggingItemLayoutInfo
draggingItemInitialOffset = info?.offset?.toFloat() ?: 0f
draggingItemSize = info?.size ?: 0
draggingItemDraggedDelta = 0f
}
fun onDragInterrupted() {
draggingItemDraggedDelta = 0f
draggingItemIndex = null
draggingItemInitialOffset = 0f
draggingItemSize = 0
overscrollJob?.cancel()
}
fun onDrag(offset: Offset) {
draggingItemDraggedDelta += offset.y
val draggingItem = draggingItemLayoutInfo ?: return
val startOffset = draggingItem.offset + draggingItemOffset
// 🆕 v1.8.1: Fixierte Item-Größe für stabile Swap-Erkennung
val endOffset = startOffset + draggingItemSize
// 🆕 v1.8.0: IMPL_023b — Straddle-Target-Center + Adjazenz-Filter
// Statt den Mittelpunkt des gezogenen Items zu prüfen ("liegt mein Zentrum im Target?"),
// wird geprüft ob das gezogene Item den MITTELPUNKT des Targets überspannt.
// Dies verhindert Oszillation bei Items unterschiedlicher Größe.
// 🆕 v1.8.1 IMPL_14: Separator überspringen, Adjazenz berücksichtigt Separator-Lücke
val targetItem = state.layoutInfo.visibleItemsInfo.firstOrNull { item ->
// Separator überspringen
item.index != separatorVisualIndex &&
// Nur adjazente Items (Separator-Lücke wird übersprungen)
isAdjacentSkippingSeparator(draggingItem.index, item.index) &&
run {
val targetCenter = item.offset + item.size / 2
startOffset < targetCenter && endOffset > targetCenter
}
}
if (targetItem != null) {
val scrollToIndex = if (targetItem.index == state.firstVisibleItemIndex) {
draggingItem.index
} else if (draggingItem.index == state.firstVisibleItemIndex) {
targetItem.index
} else {
null
}
// 🆕 v1.8.1 IMPL_14: Visual-Indizes zu Data-Indizes konvertieren für onMove
val fromDataIndex = visualToDataIndex(draggingItem.index)
val toDataIndex = visualToDataIndex(targetItem.index)
if (scrollToIndex != null) {
scope.launch {
state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
onMove(fromDataIndex, toDataIndex)
// 🆕 v1.8.0: IMPL_023b — Index-Update NACH dem Move (verhindert Race-Condition)
draggingItemIndex = targetItem.index
}
} else {
onMove(fromDataIndex, toDataIndex)
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()
}
}
}
/**
* 🆕 v1.8.1 IMPL_14: Prüft ob zwei Visual-Indizes adjazent sind,
* wobei der Separator übersprungen wird.
* Beispiel: Items bei Visual 1 und Visual 3 sind adjazent wenn Separator bei Visual 2 liegt.
*/
private fun isAdjacentSkippingSeparator(indexA: Int, indexB: Int): Boolean {
val diff = kotlin.math.abs(indexA - indexB)
if (diff == 1) {
// Direkt benachbart — aber NICHT wenn der Separator dazwischen liegt
val between = minOf(indexA, indexB) + 1
return between != separatorVisualIndex || separatorVisualIndex < 0
}
if (diff == 2 && separatorVisualIndex >= 0) {
// 2 Positionen entfernt — adjazent wenn Separator dazwischen
val between = minOf(indexA, indexB) + 1
return between == separatorVisualIndex
}
return false
}
@Suppress("UnusedPrivateProperty")
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
)
}
}
@Composable
fun Modifier.dragContainer(
dragDropState: DragDropListState,
itemIndex: Int
): Modifier {
val currentIndex = rememberUpdatedState(itemIndex) // 🆕 v1.8.0: rememberUpdatedState statt Key
return this.pointerInput(dragDropState) { // Nur dragDropState als Key - verhindert Gesture-Restart
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
dragDropState.onDragStart(offset, currentIndex.value) // Aktuellen Wert lesen
},
onDragEnd = {
dragDropState.onDragInterrupted()
},
onDragCancel = {
dragDropState.onDragInterrupted()
},
onDrag = { change, offset ->
change.consume()
dragDropState.onDrag(offset)
}
)
}
}

View File

@@ -0,0 +1,528 @@
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.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.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
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.automirrored.outlined.Sort
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 androidx.compose.ui.zIndex
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.ChecklistSortOption
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.ui.editor.components.CheckedItemsSeparator
import dev.dettmer.simplenotes.ui.editor.components.ChecklistItemRow
import dev.dettmer.simplenotes.ui.editor.components.ChecklistSortDialog
import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog
import kotlinx.coroutines.delay
import dev.dettmer.simplenotes.utils.showToast
import kotlin.math.roundToInt
private const val LAYOUT_DELAY_MS = 100L
private const val AUTO_SCROLL_DELAY_MS = 50L
private const val ITEM_CORNER_RADIUS_DP = 8
private const val DRAGGING_ITEM_Z_INDEX = 10f
private val DRAGGING_ELEVATION_DP = 8.dp
/**
* 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 showChecklistSortDialog by remember { mutableStateOf(false) } // 🔀 v1.8.0
val lastChecklistSortOption by viewModel.lastChecklistSortOption.collectAsState() // 🔀 v1.8.0
var focusNewItemId by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
// Strings for toast messages (avoid LocalContextGetResourceValueCall lint)
val msgNoteIsEmpty = stringResource(R.string.note_is_empty)
val msgNoteSaved = stringResource(R.string.note_saved)
val msgNoteDeleted = stringResource(R.string.note_deleted)
// 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(LAYOUT_DELAY_MS) // 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 -> msgNoteIsEmpty
ToastMessage.NOTE_SAVED -> msgNoteSaved
ToastMessage.NOTE_DELETED -> msgNoteDeleted
}
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,
currentSortOption = lastChecklistSortOption, // 🔀 v1.8.0
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 },
onSortClick = { showChecklistSortDialog = true }, // 🔀 v1.8.0
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)
}
)
}
// 🔀 v1.8.0: Checklist Sort Dialog
if (showChecklistSortDialog) {
ChecklistSortDialog(
currentOption = lastChecklistSortOption,
onOptionSelected = { option ->
viewModel.sortChecklistItems(option)
showChecklistSortDialog = false
},
onDismiss = { showChecklistSortDialog = false }
)
}
}
@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)
)
}
/**
* 🆕 v1.8.1 IMPL_14: Extrahiertes Composable für ein einzelnes draggbares Checklist-Item.
* Entkoppelt von der Separator-Logik — wiederverwendbar für unchecked und checked Items.
*/
@Suppress("LongParameterList") // Compose callbacks — cannot be reduced without wrapper class
@Composable
private fun LazyItemScope.DraggableChecklistItem(
item: ChecklistItemState,
visualIndex: Int,
dragDropState: DragDropListState,
focusNewItemId: String?,
onTextChange: (String, String) -> Unit,
onCheckedChange: (String, Boolean) -> Unit,
onDelete: (String) -> Unit,
onAddNewItemAfter: (String) -> Unit,
onFocusHandled: () -> Unit,
onHeightChanged: () -> Unit, // 🆕 v1.8.1 (IMPL_05)
) {
val isDragging = dragDropState.draggingItemIndex == visualIndex
val elevation by animateDpAsState(
targetValue = if (isDragging) DRAGGING_ELEVATION_DP else 0.dp,
label = "elevation"
)
val shouldFocus = item.id == focusNewItemId
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,
isDragging = isDragging,
isAnyItemDragging = dragDropState.draggingItemIndex != null,
dragModifier = Modifier.dragContainer(dragDropState, visualIndex),
onHeightChanged = onHeightChanged, // 🆕 v1.8.1 (IMPL_05)
modifier = Modifier
.then(if (!isDragging) Modifier.animateItem() else Modifier)
.offset {
IntOffset(
0,
if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 0
)
}
.zIndex(if (isDragging) DRAGGING_ITEM_Z_INDEX else 0f)
.shadow(elevation, shape = RoundedCornerShape(ITEM_CORNER_RADIUS_DP.dp))
.background(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(ITEM_CORNER_RADIUS_DP.dp)
)
)
}
@Suppress("LongParameterList") // Compose functions commonly have many callback parameters
@Composable
private fun ChecklistEditor(
items: List<ChecklistItemState>,
scope: kotlinx.coroutines.CoroutineScope,
focusNewItemId: String?,
currentSortOption: ChecklistSortOption, // 🔀 v1.8.0: Aktuelle Sortierung
onTextChange: (String, String) -> Unit,
onCheckedChange: (String, Boolean) -> Unit,
onDelete: (String) -> Unit,
onAddNewItemAfter: (String) -> Unit,
onAddItemAtEnd: () -> Unit,
onMove: (Int, Int) -> Unit,
onFocusHandled: () -> Unit,
onSortClick: () -> Unit, // 🔀 v1.8.0
modifier: Modifier = Modifier
) {
val listState = rememberLazyListState()
val dragDropState = rememberDragDropListState(
lazyListState = listState,
scope = scope,
onMove = onMove
)
// 🆕 v1.8.1 (IMPL_05): Auto-Scroll bei Zeilenumbruch
var scrollToItemIndex by remember { mutableStateOf<Int?>(null) }
// 🆕 v1.8.0 (IMPL_017 + IMPL_020): Separator nur bei MANUAL und UNCHECKED_FIRST anzeigen
val uncheckedCount = items.count { !it.isChecked }
val checkedCount = items.count { it.isChecked }
val shouldShowSeparator = currentSortOption == ChecklistSortOption.MANUAL ||
currentSortOption == ChecklistSortOption.UNCHECKED_FIRST
val showSeparator = shouldShowSeparator && uncheckedCount > 0 && checkedCount > 0
Column(modifier = modifier) {
// 🆕 v1.8.1 IMPL_14: Separator-Position für DragDropState aktualisieren
val separatorVisualIndex = if (showSeparator) uncheckedCount else -1
LaunchedEffect(separatorVisualIndex) {
dragDropState.separatorVisualIndex = separatorVisualIndex
}
// 🆕 v1.8.1 (IMPL_05): Auto-Scroll wenn ein Item durch Zeilenumbruch wächst
LaunchedEffect(scrollToItemIndex) {
scrollToItemIndex?.let { index ->
delay(AUTO_SCROLL_DELAY_MS) // Warten bis Layout-Pass abgeschlossen
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
if (index >= lastVisibleIndex - 1) {
listState.animateScrollToItem(
index = minOf(index + 1, items.size + if (showSeparator) 1 else 0),
scrollOffset = 0
)
}
scrollToItemIndex = null
}
}
LazyColumn(
state = listState,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
// 🆕 v1.8.1 IMPL_14: Unchecked Items (Visual Index 0..uncheckedCount-1)
itemsIndexed(
items = if (showSeparator) items.subList(0, uncheckedCount) else items,
key = { _, item -> item.id }
) { index, item ->
DraggableChecklistItem(
item = item,
visualIndex = index,
dragDropState = dragDropState,
focusNewItemId = focusNewItemId,
onTextChange = onTextChange,
onCheckedChange = onCheckedChange,
onDelete = onDelete,
onAddNewItemAfter = onAddNewItemAfter,
onFocusHandled = onFocusHandled,
onHeightChanged = { scrollToItemIndex = index } // 🆕 v1.8.1 (IMPL_05)
)
}
// 🆕 v1.8.1 IMPL_14: Separator als eigenes LazyColumn-Item
if (showSeparator) {
item(key = "separator") {
CheckedItemsSeparator(
checkedCount = checkedCount,
isDragActive = dragDropState.draggingItemIndex != null
)
}
// 🆕 v1.8.1 IMPL_14: Checked Items (Visual Index uncheckedCount+1..)
itemsIndexed(
items = items.subList(uncheckedCount, items.size),
key = { _, item -> item.id }
) { index, item ->
val visualIndex = uncheckedCount + 1 + index // +1 für Separator
DraggableChecklistItem(
item = item,
visualIndex = visualIndex,
dragDropState = dragDropState,
focusNewItemId = focusNewItemId,
onTextChange = onTextChange,
onCheckedChange = onCheckedChange,
onDelete = onDelete,
onAddNewItemAfter = onAddNewItemAfter,
onFocusHandled = onFocusHandled,
onHeightChanged = { scrollToItemIndex = visualIndex } // 🆕 v1.8.1 (IMPL_05)
)
}
}
}
// 🔀 v1.8.0: Add Item Button + Sort Button
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp, end = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
TextButton(onClick = onAddItemAtEnd) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(stringResource(R.string.add_item))
}
IconButton(onClick = onSortClick) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.Sort,
contentDescription = stringResource(R.string.sort_checklist),
modifier = androidx.compose.ui.Modifier.padding(4.dp)
)
}
}
}
}
// v1.5.0: Local DeleteConfirmationDialog removed - now using shared component from ui/main/components/

View File

@@ -0,0 +1,691 @@
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.ChecklistSortOption
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.SyncStateManager
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()
// 🔀 v1.8.0 (IMPL_020): Letzte Checklist-Sortierung (Session-Scope)
private val _lastChecklistSortOption = MutableStateFlow(ChecklistSortOption.MANUAL)
val lastChecklistSortOption: StateFlow<ChecklistSortOption> = _lastChecklistSortOption.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) {
loadExistingNote(noteId)
} else {
initNewNote(noteTypeString)
}
}
private fun loadExistingNote(noteId: String) {
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) {
loadChecklistData(note)
}
}
}
private fun loadChecklistData(note: Note) {
// 🆕 v1.8.1 (IMPL_03): Gespeicherte Sortierung laden
note.checklistSortOption?.let { sortName ->
_lastChecklistSortOption.value = parseSortOption(sortName)
}
val items = note.checklistItems?.sortedBy { it.order }?.map {
ChecklistItemState(
id = it.id,
text = it.text,
isChecked = it.isChecked,
order = it.order
)
} ?: emptyList()
// 🆕 v1.8.0 (IMPL_017): Sortierung sicherstellen (falls alte Daten unsortiert sind)
_checklistItems.value = sortChecklistItems(items)
}
private fun initNewNote(noteTypeString: String) {
currentNoteType = try {
NoteType.valueOf(noteTypeString)
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) {
Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT")
NoteType.TEXT
}
_uiState.update { state ->
state.copy(
noteType = currentNoteType,
isNewNote = true,
toolbarTitle = if (currentNoteType == NoteType.CHECKLIST) {
ToolbarTitle.NEW_CHECKLIST
} else {
ToolbarTitle.NEW_NOTE
}
)
}
// Add first empty item for new checklists
if (currentNoteType == NoteType.CHECKLIST) {
_checklistItems.value = listOf(ChecklistItemState.createEmpty(0))
}
}
/**
* Safely parse a ChecklistSortOption from its string name.
* Falls back to MANUAL if the name is unknown (e.g., from older app versions).
*/
private fun parseSortOption(sortName: String): ChecklistSortOption {
return try {
ChecklistSortOption.valueOf(sortName)
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) {
Logger.w(TAG, "Unknown sort option '$sortName', using MANUAL")
ChecklistSortOption.MANUAL
}
}
// ═══════════════════════════════════════════════════════════════════════
// 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
}
}
}
/**
* 🆕 v1.8.0 (IMPL_017): Sortiert Checklist-Items mit Unchecked oben, Checked unten.
* Stabile Sortierung: Relative Reihenfolge innerhalb jeder Gruppe bleibt erhalten.
*/
/**
* Sortiert Checklist-Items basierend auf der aktuellen Sortier-Option.
* 🆕 v1.8.1 (IMPL_03-FIX): Berücksichtigt jetzt _lastChecklistSortOption
* anstatt immer unchecked-first zu sortieren.
*/
private fun sortChecklistItems(items: List<ChecklistItemState>): List<ChecklistItemState> {
val sorted = when (_lastChecklistSortOption.value) {
ChecklistSortOption.MANUAL,
ChecklistSortOption.UNCHECKED_FIRST -> {
val unchecked = items.filter { !it.isChecked }
val checked = items.filter { it.isChecked }
unchecked + checked
}
ChecklistSortOption.CHECKED_FIRST ->
items.sortedByDescending { it.isChecked }
ChecklistSortOption.ALPHABETICAL_ASC ->
items.sortedBy { it.text.lowercase() }
ChecklistSortOption.ALPHABETICAL_DESC ->
items.sortedByDescending { it.text.lowercase() }
}
return sorted.mapIndexed { index, item ->
item.copy(order = index)
}
}
fun updateChecklistItemChecked(itemId: String, isChecked: Boolean) {
_checklistItems.update { items ->
val updatedItems = items.map { item ->
if (item.id == itemId) item.copy(isChecked = isChecked) else item
}
// 🆕 v1.8.0 (IMPL_017 + IMPL_020): Auto-Sort nur bei MANUAL und UNCHECKED_FIRST
val currentSort = _lastChecklistSortOption.value
if (currentSort == ChecklistSortOption.MANUAL || currentSort == ChecklistSortOption.UNCHECKED_FIRST) {
sortChecklistItems(updatedItems)
} else {
// Bei anderen Sortierungen (alphabetisch, checked first) nicht auto-sortieren
updatedItems.mapIndexed { index, item -> item.copy(order = index) }
}
}
}
/**
* 🆕 v1.8.1 (IMPL_15): Fügt ein neues Item nach dem angegebenen Item ein.
*
* Guard: Bei MANUAL/UNCHECKED_FIRST wird sichergestellt, dass das neue (unchecked)
* Item nicht innerhalb der checked-Sektion eingefügt wird. Falls das Trigger-Item
* checked ist, wird stattdessen vor dem ersten checked Item eingefügt.
*/
fun addChecklistItemAfter(afterItemId: String): String {
val newItem = ChecklistItemState.createEmpty(0)
_checklistItems.update { items ->
val index = items.indexOfFirst { it.id == afterItemId }
if (index >= 0) {
val currentSort = _lastChecklistSortOption.value
val hasSeparator = currentSort == ChecklistSortOption.MANUAL ||
currentSort == ChecklistSortOption.UNCHECKED_FIRST
// 🆕 v1.8.1 (IMPL_15): Wenn das Trigger-Item checked ist und ein Separator
// existiert, darf das neue unchecked Item nicht in die checked-Sektion.
// → Stattdessen vor dem ersten checked Item einfügen.
val effectiveIndex = if (hasSeparator && items[index].isChecked) {
val firstCheckedIndex = items.indexOfFirst { it.isChecked }
if (firstCheckedIndex >= 0) firstCheckedIndex else index + 1
} else {
index + 1
}
val newList = items.toMutableList()
newList.add(effectiveIndex, newItem)
// Update order values
newList.mapIndexed { i, item -> item.copy(order = i) }
} else {
items + newItem.copy(order = items.size)
}
}
return newItem.id
}
/**
* 🆕 v1.8.1 (IMPL_15): Fügt ein neues Item an der semantisch korrekten Position ein.
*
* Bei MANUAL/UNCHECKED_FIRST: Vor dem ersten checked Item (= direkt über dem Separator).
* Bei allen anderen Modi: Am Ende der Liste (kein Separator sichtbar).
*
* Verhindert, dass checked Items über den Separator springen oder das neue Item
* unter dem Separator erscheint.
*/
fun addChecklistItemAtEnd(): String {
val newItem = ChecklistItemState.createEmpty(0)
_checklistItems.update { items ->
val insertIndex = calculateInsertIndexForNewItem(items)
val newList = items.toMutableList()
newList.add(insertIndex, newItem)
newList.mapIndexed { i, item -> item.copy(order = i) }
}
return newItem.id
}
/**
* 🆕 v1.8.1 (IMPL_15): Berechnet die korrekte Insert-Position für ein neues unchecked Item.
*
* - MANUAL / UNCHECKED_FIRST: Vor dem ersten checked Item (direkt über dem Separator)
* - Alle anderen Modi: Am Ende der Liste (kein Separator, kein visuelles Problem)
*
* Falls keine checked Items existieren, wird am Ende eingefügt.
*/
private fun calculateInsertIndexForNewItem(items: List<ChecklistItemState>): Int {
val currentSort = _lastChecklistSortOption.value
return when (currentSort) {
ChecklistSortOption.MANUAL,
ChecklistSortOption.UNCHECKED_FIRST -> {
val firstCheckedIndex = items.indexOfFirst { it.isChecked }
if (firstCheckedIndex >= 0) firstCheckedIndex else items.size
}
else -> items.size
}
}
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 fromItem = items.getOrNull(fromIndex) ?: return@update items
val toItem = items.getOrNull(toIndex) ?: return@update items
val mutableList = items.toMutableList()
val item = mutableList.removeAt(fromIndex)
// 🆕 v1.8.1 IMPL_14: Cross-Boundary Move mit Auto-Toggle
// Wenn ein Item die Grenze überschreitet, wird es automatisch checked/unchecked.
val movedItem = if (fromItem.isChecked != toItem.isChecked) {
item.copy(isChecked = toItem.isChecked)
} else {
item
}
mutableList.add(toIndex, movedItem)
// Update order values
mutableList.mapIndexed { index, i -> i.copy(order = index) }
}
}
/**
* 🔀 v1.8.0 (IMPL_020): Sortiert Checklist-Items nach gewählter Option.
* Einmalige Aktion (nicht persistiert) — User kann danach per Drag & Drop feinjustieren.
*/
fun sortChecklistItems(option: ChecklistSortOption) {
// Merke die Auswahl für diesen Editor-Session
_lastChecklistSortOption.value = option
_checklistItems.update { items ->
val sorted = when (option) {
// Bei MANUAL: Sortiere nach checked/unchecked, damit Separator korrekt platziert wird
ChecklistSortOption.MANUAL -> items.sortedBy { it.isChecked }
ChecklistSortOption.ALPHABETICAL_ASC ->
items.sortedBy { it.text.lowercase() }
ChecklistSortOption.ALPHABETICAL_DESC ->
items.sortedByDescending { it.text.lowercase() }
ChecklistSortOption.UNCHECKED_FIRST ->
items.sortedBy { it.isChecked }
ChecklistSortOption.CHECKED_FIRST ->
items.sortedByDescending { it.isChecked }
}
// Order-Werte neu zuweisen
sorted.mapIndexed { index, item -> item.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) {
// 🆕 v1.8.0 (IMPL_022): syncStatus wird immer auf PENDING gesetzt
// beim Bearbeiten - gilt für SYNCED, CONFLICT, DELETED_ON_SERVER, etc.
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) {
// 🆕 v1.8.0 (IMPL_022): syncStatus wird immer auf PENDING gesetzt
// beim Bearbeiten - gilt für SYNCED, CONFLICT, DELETED_ON_SERVER, etc.
existingNote!!.copy(
title = title,
content = "", // Empty for checklists
noteType = NoteType.CHECKLIST,
checklistItems = validItems,
checklistSortOption = _lastChecklistSortOption.value.name, // 🆕 v1.8.1 (IMPL_03)
updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING
)
} else {
Note(
title = title,
content = "",
noteType = NoteType.CHECKLIST,
checklistItems = validItems,
checklistSortOption = _lastChecklistSortOption.value.name, // 🆕 v1.8.1 (IMPL_03)
deviceId = DeviceIdGenerator.getDeviceId(getApplication()),
syncStatus = SyncStatus.LOCAL_ONLY
)
}
storage.saveNote(note)
}
}
// 🆕 v1.8.1 (IMPL_12): NOTE_SAVED Toast entfernt — NavigateBack ist ausreichend
// 🌟 v1.6.0: Trigger onSave Sync
triggerOnSaveSync()
// 🆕 v1.8.0: Betroffene Widgets aktualisieren
try {
val glanceManager = androidx.glance.appwidget.GlanceAppWidgetManager(getApplication())
val glanceIds = glanceManager.getGlanceIds(dev.dettmer.simplenotes.widget.NoteWidget::class.java)
glanceIds.forEach { id ->
dev.dettmer.simplenotes.widget.NoteWidget().update(getApplication(), id)
}
} catch (e: Exception) {
Logger.w(TAG, "Failed to update widgets: ${e.message}")
}
_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)
}
// 🆕 v1.8.1 (IMPL_12): Banner-Feedback statt stiller Log-Einträge
if (success) {
Logger.d(TAG, "Note $noteId deleted from server")
SyncStateManager.showInfo(
getApplication<Application>().getString(
dev.dettmer.simplenotes.R.string.snackbar_deleted_from_server
)
)
} else {
Logger.w(TAG, "Failed to delete note $noteId from server")
SyncStateManager.showError(
getApplication<Application>().getString(
dev.dettmer.simplenotes.R.string.snackbar_server_delete_failed
)
)
}
} catch (e: Exception) {
Logger.e(TAG, "Error deleting note from server: ${e.message}")
SyncStateManager.showError(
getApplication<Application>().getString(
dev.dettmer.simplenotes.R.string.snackbar_server_error,
e.message ?: ""
)
)
}
}
_events.emit(NoteEditorEvent.NavigateBack)
}
}
}
fun showDeleteConfirmation() {
viewModelScope.launch {
_events.emit(NoteEditorEvent.ShowDeleteConfirmation)
}
}
fun canDelete(): Boolean = existingNote != null
/**
* 🆕 v1.8.0 (IMPL_025): Reload Note aus Storage nach Resume
*
* Wird aufgerufen wenn die Activity aus dem Hintergrund zurückkehrt.
* Liest den aktuellen Note-Stand von Disk und aktualisiert den ViewModel-State.
*
* Wird nur für existierende Checklist-Notes benötigt (neue Notes haben keinen
* externen Schreiber). Relevant für Widget-Checklist-Toggles.
*
* Nur checklistItems werden aktualisiert — nicht title oder content,
* damit ungespeicherte Text-Änderungen im Editor nicht verloren gehen.
*/
fun reloadFromStorage() {
val noteId = savedStateHandle.get<String>(ARG_NOTE_ID) ?: return
val freshNote = storage.loadNote(noteId) ?: return
// Nur Checklist-Items aktualisieren
if (freshNote.noteType == NoteType.CHECKLIST) {
val freshItems = freshNote.checklistItems?.sortedBy { it.order }?.map {
ChecklistItemState(
id = it.id,
text = it.text,
isChecked = it.isChecked,
order = it.order
)
} ?: return
_checklistItems.value = sortChecklistItems(freshItems)
// existingNote aktualisieren damit beim Speichern der richtige
// Basis-State verwendet wird
existingNote = freshNote
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 🌟 v1.6.0: Sync Trigger - onSave
// ═══════════════════════════════════════════════════════════════════════════
/**
* Triggers sync after saving a note (if enabled and server configured)
* v1.6.0: New configurable sync trigger
* v1.7.0: Uses central canSync() gate for WiFi-only check
*
* 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
}
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
val syncService = WebDavSyncService(getApplication())
val gateResult = syncService.canSync()
if (!gateResult.canSync) {
if (gateResult.isBlockedByWifiOnly) {
Logger.d(TAG, "⏭️ onSave sync blocked: WiFi-only mode, not on WiFi")
} else {
Logger.d(TAG, "⏭️ onSave sync blocked: ${gateResult.blockReason ?: "offline/no server"}")
}
return
}
// Check 2: 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)
.addTag(Constants.SYNC_ONSAVE_TAG) // 🆕 v1.8.1 (IMPL_08B): Bypassed globalen Cooldown
.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,65 @@
package dev.dettmer.simplenotes.ui.editor.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
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.pluralStringResource
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
/**
* 🆕 v1.8.0 (IMPL_017): Visueller Separator zwischen unchecked und checked Items
* 🆕 v1.8.1 (IMPL_14): Drag-Awareness — Primary-Farbe während Drag als visueller Hinweis
*
* Zeigt eine dezente Linie mit Anzahl der erledigten Items:
* ── 3 completed ──
*/
@Composable
fun CheckedItemsSeparator(
checkedCount: Int,
modifier: Modifier = Modifier,
isDragActive: Boolean = false // 🆕 v1.8.1 IMPL_14
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
HorizontalDivider(
modifier = Modifier.weight(1f),
color = if (isDragActive)
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
else
MaterialTheme.colorScheme.outlineVariant
)
Text(
text = pluralStringResource(
R.plurals.checked_items_count,
checkedCount,
checkedCount
),
style = MaterialTheme.typography.labelSmall,
color = if (isDragActive)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(horizontal = 12.dp)
)
HorizontalDivider(
modifier = Modifier.weight(1f),
color = if (isDragActive)
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
else
MaterialTheme.colorScheme.outlineVariant
)
}
}

View File

@@ -0,0 +1,385 @@
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.heightIn
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.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
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.mutableIntStateOf
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.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalDensity
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.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
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
* v1.8.0: Long text UX improvements (gradient fade, auto-expand on focus)
* v1.8.0: IMPL_023 - Enlarged drag handle (48dp touch target) + drag modifier
*
* Note: Using 10 parameters for Composable is acceptable for complex UI components.
* @suppress LongParameterList - Composables naturally have many parameters
*/
@Suppress("LongParameterList")
@Composable
fun ChecklistItemRow(
item: ChecklistItemState,
onTextChange: (String) -> Unit,
onCheckedChange: (Boolean) -> Unit,
onDelete: () -> Unit,
onAddNewItem: () -> Unit,
requestFocus: Boolean = false,
isDragging: Boolean = false, // 🆕 v1.8.0: IMPL_023 - Drag state
isAnyItemDragging: Boolean = false, // 🆕 v1.8.0: IMPL_023 - Hide gradient during any drag
dragModifier: Modifier = Modifier, // 🆕 v1.8.0: IMPL_023 - Drag modifier for handle
onHeightChanged: (() -> Unit)? = null, // 🆕 v1.8.1: IMPL_05 - Auto-scroll callback
modifier: Modifier = Modifier
) {
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val density = LocalDensity.current
var textFieldValue by remember(item.id) {
mutableStateOf(TextFieldValue(text = item.text, selection = TextRange(0)))
}
// 🆕 v1.8.0: Focus-State tracken für Expand/Collapse
var isFocused by remember { mutableStateOf(false) }
// 🆕 v1.8.0: Overflow erkennen (Text länger als maxLines)
var hasOverflow by remember { mutableStateOf(false) }
// 🆕 v1.8.0: Höhe für collapsed-Ansicht (aus TextLayout berechnet)
var collapsedHeightDp by remember { mutableStateOf<Dp?>(null) }
// 🆕 v1.8.0: ScrollState für dynamischen Gradient
val scrollState = rememberScrollState()
// 🆕 v1.8.1: IMPL_05 - Letzte Zeilenanzahl tracken für Auto-Scroll
var lastLineCount by remember { mutableIntStateOf(0) }
// 🆕 v1.8.1: Gradient-Sichtbarkeit direkt berechnet (kein derivedStateOf)
// derivedStateOf mit remember{} fängt showGradient als stale val — nie aktualisiert.
val showGradient = hasOverflow && collapsedHeightDp != null && !isFocused && !isAnyItemDragging
val showTopGradient = showGradient && scrollState.value > 0
val showBottomGradient = showGradient && scrollState.value < scrollState.maxValue
// v1.5.0: Auto-focus AND show keyboard when requestFocus is true (new items)
LaunchedEffect(requestFocus) {
if (requestFocus) {
focusRequester.requestFocus()
keyboardController?.show()
}
}
// 🆕 v1.8.0: Cursor ans Ende setzen wenn fokussiert (für Bearbeitung)
LaunchedEffect(isFocused) {
if (isFocused && textFieldValue.selection.start == 0) {
textFieldValue = textFieldValue.copy(
selection = TextRange(textFieldValue.text.length)
)
}
}
// Update text field when external state changes
LaunchedEffect(item.text) {
if (textFieldValue.text != item.text) {
textFieldValue = TextFieldValue(
text = item.text,
selection = if (isFocused) TextRange(item.text.length) else TextRange(0)
)
}
}
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(end = 8.dp, top = 4.dp, bottom = 4.dp), // 🆕 v1.8.0: IMPL_023 - links kein Padding (Handle hat eigene Fläche)
verticalAlignment = if (hasOverflow) Alignment.Top else Alignment.CenterVertically // 🆕 v1.8.0: Dynamisch
) {
// 🆕 v1.8.0: IMPL_023 - Vergrößerter Drag Handle (48dp Touch-Target)
Box(
modifier = dragModifier
.size(48.dp) // Material Design minimum touch target
.alpha(if (isDragging) 1.0f else 0.6f), // Visual feedback beim Drag
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = stringResource(R.string.drag_to_reorder),
modifier = Modifier.size(28.dp), // Icon größer als vorher (24dp → 28dp)
tint = if (isDragging) {
MaterialTheme.colorScheme.primary // Primary color während Drag
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
// Checkbox
Checkbox(
checked = item.isChecked,
onCheckedChange = onCheckedChange,
modifier = Modifier.alpha(alpha)
)
Spacer(modifier = Modifier.width(4.dp))
// 🆕 v1.8.0: Text Input mit dynamischem Overflow-Gradient
Box(modifier = Modifier.weight(1f)) {
// Scrollbarer Wrapper: begrenzt Höhe auf ~5 Zeilen wenn collapsed
Box(
modifier = if (!isFocused && hasOverflow && collapsedHeightDp != null) {
Modifier
.heightIn(max = collapsedHeightDp!!)
.verticalScroll(scrollState)
} else {
Modifier
}
) {
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
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged { focusState ->
isFocused = focusState.isFocused
}
.alpha(alpha),
textStyle = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurface,
textDecoration = textDecoration
),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { onAddNewItem() }
),
singleLine = false,
// 🆕 v1.8.1: maxLines IMMER Int.MAX_VALUE — keine Oszillation möglich
// Höhenbegrenzung erfolgt ausschließlich über heightIn-Modifier oben.
// Vorher: maxLines=5 → lineCount gedeckelt → Overflow nie erkannt → Deadlock
maxLines = Int.MAX_VALUE,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
onTextLayout = { textLayoutResult ->
// 🆕 v1.8.1: lineCount ist jetzt akkurat (maxLines=MAX_VALUE deckelt nicht)
val lineCount = textLayoutResult.lineCount
if (!isAnyItemDragging) {
val overflow = lineCount > COLLAPSED_MAX_LINES
hasOverflow = overflow
// Höhe der ersten 5 Zeilen berechnen (einmalig)
if (overflow && collapsedHeightDp == null) {
collapsedHeightDp = with(density) {
textLayoutResult.getLineBottom(COLLAPSED_MAX_LINES - 1).toDp()
}
}
// Reset wenn Text gekürzt wird
if (!overflow) {
collapsedHeightDp = null
}
}
// 🆕 v1.8.1 (IMPL_05): Höhenänderung bei Zeilenumbruch melden
if (isFocused && lineCount > lastLineCount && lastLineCount > 0) {
onHeightChanged?.invoke()
}
lastLineCount = lineCount
},
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()
}
}
)
}
// 🆕 v1.8.0: Dynamischer Gradient basierend auf Scroll-Position
// Oben: sichtbar wenn nach unten gescrollt (Text oberhalb versteckt)
if (showTopGradient) {
OverflowGradient(
modifier = Modifier.align(Alignment.TopCenter),
isTopGradient = true
)
}
// Unten: sichtbar wenn noch Text unterhalb vorhanden
if (showBottomGradient) {
OverflowGradient(
modifier = Modifier.align(Alignment.BottomCenter),
isTopGradient = false
)
}
}
Spacer(modifier = Modifier.width(4.dp))
// Delete Button
IconButton(
onClick = onDelete,
modifier = Modifier
.size(36.dp)
.padding(top = 4.dp) // 🆕 v1.8.0: Ausrichtung mit Top-aligned Text
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.delete_item),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
}
}
// 🆕 v1.8.0: Maximum lines when collapsed (not focused)
private const val COLLAPSED_MAX_LINES = 5
// ════════════════════════════════════════════════════════════════
// 🆕 v1.8.0: Preview Composables for Manual Testing
// ════════════════════════════════════════════════════════════════
@Suppress("UnusedPrivateMember")
@Preview(showBackground = true)
@Composable
private fun ChecklistItemRowShortTextPreview() {
ChecklistItemRow(
item = ChecklistItemState(
id = "preview-1",
text = "Kurzer Text",
isChecked = false
),
onTextChange = {},
onCheckedChange = {},
onDelete = {},
onAddNewItem = {},
isDragging = false,
dragModifier = Modifier
)
}
@Suppress("UnusedPrivateMember")
@Preview(showBackground = true)
@Composable
private fun ChecklistItemRowLongTextPreview() {
ChecklistItemRow(
item = ChecklistItemState(
id = "preview-2",
text = "Dies ist ein sehr langer Text der sich über viele Zeilen erstreckt " +
"und dazu dient den Overflow-Gradient zu demonstrieren. Er hat deutlich " +
"mehr als fünf Zeilen wenn er in der normalen Breite eines Smartphones " +
"angezeigt wird und sollte einen schönen Fade-Effekt am unteren Rand zeigen. " +
"Dieser zusätzliche Text sorgt dafür, dass wir wirklich genug Zeilen haben " +
"um den Gradient sichtbar zu machen.",
isChecked = false
),
onTextChange = {},
onCheckedChange = {},
onDelete = {},
onAddNewItem = {},
isDragging = false,
dragModifier = Modifier
)
}
@Suppress("UnusedPrivateMember")
@Preview(showBackground = true)
@Composable
private fun ChecklistItemRowCheckedPreview() {
ChecklistItemRow(
item = ChecklistItemState(
id = "preview-3",
text = "Erledigte Aufgabe mit durchgestrichenem Text",
isChecked = true
),
onTextChange = {},
onCheckedChange = {},
onDelete = {},
onAddNewItem = {},
isDragging = false,
dragModifier = Modifier
)
}
// 🆕 v1.8.0: IMPL_023 - Preview for dragging state
@Suppress("UnusedPrivateMember")
@Preview(showBackground = true)
@Composable
private fun ChecklistItemRowDraggingPreview() {
ChecklistItemRow(
item = ChecklistItemState(
id = "preview-4",
text = "Wird gerade verschoben - Handle ist highlighted",
isChecked = false
),
onTextChange = {},
onCheckedChange = {},
onDelete = {},
onAddNewItem = {},
isDragging = true,
dragModifier = Modifier
)
}

View File

@@ -0,0 +1,123 @@
package dev.dettmer.simplenotes.ui.editor.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.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.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.models.ChecklistSortOption
/**
* 🔀 v1.8.0: Dialog zur Auswahl der Checklist-Sortierung.
*
* Einmalige Sortier-Aktion (nicht persistiert).
* User kann danach per Drag & Drop feinjustieren.
*
* ┌─────────────────────────────────┐
* │ Sort Checklist │
* ├─────────────────────────────────┤
* │ ( ) Manual │
* │ ( ) A → Z │
* │ ( ) Z → A │
* │ (●) Unchecked first │
* │ ( ) Checked first │
* ├─────────────────────────────────┤
* │ [Cancel] [Apply] │
* └─────────────────────────────────┘
*/
@Composable
fun ChecklistSortDialog(
currentOption: ChecklistSortOption, // 🔀 v1.8.0: Aktuelle Auswahl merken
onOptionSelected: (ChecklistSortOption) -> Unit,
onDismiss: () -> Unit
) {
var selectedOption by remember { mutableStateOf(currentOption) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(R.string.sort_checklist),
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Column {
ChecklistSortOption.entries.forEach { option ->
SortOptionRow(
label = stringResource(option.toStringRes()),
isSelected = selectedOption == option,
onClick = { selectedOption = option }
)
}
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
},
confirmButton = {
TextButton(
onClick = {
onOptionSelected(selectedOption)
}
) {
Text(stringResource(R.string.apply))
}
}
)
}
@Composable
private fun SortOptionRow(
label: String,
isSelected: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = isSelected,
onClick = onClick
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = label,
style = MaterialTheme.typography.bodyLarge
)
}
}
/**
* Extension: ChecklistSortOption → String-Resource-ID
*/
fun ChecklistSortOption.toStringRes(): Int = when (this) {
ChecklistSortOption.MANUAL -> R.string.sort_checklist_manual
ChecklistSortOption.ALPHABETICAL_ASC -> R.string.sort_checklist_alpha_asc
ChecklistSortOption.ALPHABETICAL_DESC -> R.string.sort_checklist_alpha_desc
ChecklistSortOption.UNCHECKED_FIRST -> R.string.sort_checklist_unchecked_first
ChecklistSortOption.CHECKED_FIRST -> R.string.sort_checklist_checked_first
}

View File

@@ -0,0 +1,62 @@
package dev.dettmer.simplenotes.ui.editor.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/**
* 🆕 v1.8.0: Dezenter Gradient-Overlay der anzeigt, dass mehr Text
* vorhanden ist als aktuell sichtbar.
*
* Features:
* - Top gradient: surface → transparent (zeigt Text oberhalb)
* - Bottom gradient: transparent → surface (zeigt Text unterhalb)
* - Höhe: 24dp für subtilen, aber erkennbaren Effekt
* - Material You kompatibel: nutzt dynamische surface-Farbe
* - Dark Mode Support: automatisch durch MaterialTheme
*
* Verwendet in: ChecklistItemRow für lange Texteinträge
*
* @param isTopGradient true = Gradient von surface→transparent (oben), false = transparent→surface (unten)
*/
@Composable
fun OverflowGradient(
modifier: Modifier = Modifier,
isTopGradient: Boolean = false
) {
val surfaceColor = MaterialTheme.colorScheme.surface
val gradientColors = if (isTopGradient) {
// Oben: surface → transparent (zeigt dass Text OBERHALB existiert)
listOf(
surfaceColor.copy(alpha = 0.95f),
surfaceColor.copy(alpha = 0.7f),
Color.Transparent
)
} else {
// Unten: transparent → surface (zeigt dass Text UNTERHALB existiert)
listOf(
Color.Transparent,
surfaceColor.copy(alpha = 0.7f),
surfaceColor.copy(alpha = 0.95f)
)
}
Box(
modifier = modifier
.fillMaxWidth()
.height(GRADIENT_HEIGHT)
.background(
brush = Brush.verticalGradient(colors = gradientColors)
)
)
}
private val GRADIENT_HEIGHT = 24.dp

View File

@@ -0,0 +1,409 @@
@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) }
)
// v1.8.0: Post-Update Changelog (shows once after update)
UpdateChangelogSheet()
}
}
}
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()
// 🎨 v1.7.0: Refresh display mode when returning from Settings
viewModel.refreshDisplayMode()
// 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() {
// 🆕 v1.8.0: SyncStatus nur noch für PullToRefresh-Indikator (intern)
SyncStateManager.syncStatus.observe(this) { status ->
viewModel.updateSyncState(status)
}
// 🆕 v1.8.0: Auto-Hide via SyncProgress (einziges Banner-System)
lifecycleScope.launch {
SyncStateManager.syncProgress.collect { progress ->
@Suppress("MagicNumber") // UI timing delays for banner visibility
when (progress.phase) {
dev.dettmer.simplenotes.sync.SyncPhase.COMPLETED -> {
kotlinx.coroutines.delay(2000L)
SyncStateManager.reset()
}
// 🆕 v1.8.1 (IMPL_12): INFO-Meldungen nach 2.5s ausblenden
dev.dettmer.simplenotes.sync.SyncPhase.INFO -> {
kotlinx.coroutines.delay(2500L)
SyncStateManager.reset()
}
dev.dettmer.simplenotes.sync.SyncPhase.ERROR -> {
kotlinx.coroutines.delay(4000L)
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,440 @@
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
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.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.automirrored.outlined.Sort
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.ui.main.components.SortDialog
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.NotesStaggeredGrid
import dev.dettmer.simplenotes.ui.main.components.SyncProgressBanner
import dev.dettmer.simplenotes.ui.main.components.SyncStatusLegendDialog
import kotlinx.coroutines.launch
private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L
/**
* 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.sortedNotes.collectAsState()
val syncState by viewModel.syncState.collectAsState()
val scrollToTop by viewModel.scrollToTop.collectAsState()
// 🆕 v1.8.0: Einziges Banner-System
val syncProgress by viewModel.syncProgress.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()
// 🎨 v1.7.0: Display mode (list or grid)
val displayMode by viewModel.displayMode.collectAsState()
// Delete confirmation dialog state
var showBatchDeleteDialog by remember { mutableStateOf(false) }
// 🆕 v1.8.0: Sync status legend dialog
var showSyncLegend by remember { mutableStateOf(false) }
// 🔀 v1.8.0: Sort dialog state
var showSortDialog by remember { mutableStateOf(false) }
val sortOption by viewModel.sortOption.collectAsState()
val sortDirection by viewModel.sortDirection.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
// 🎨 v1.7.0: gridState für Staggered Grid Layout
val gridState = rememberLazyStaggeredGridState()
// ⏱️ Timestamp ticker - increments every 30 seconds to trigger recomposition of relative times
var timestampTicker by remember { mutableStateOf(0L) }
LaunchedEffect(Unit) {
while (true) {
kotlinx.coroutines.delay(TIMESTAMP_UPDATE_INTERVAL_MS)
timestampTicker = System.currentTimeMillis()
}
}
// 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
// 🎨 v1.7.0: Unterstützt beide Display-Modi (list & grid)
LaunchedEffect(scrollToTop) {
if (scrollToTop) {
if (displayMode == "grid") {
gridState.animateScrollToItem(0)
} else {
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,
showSyncLegend = isSyncAvailable, // 🆕 v1.8.0: Nur wenn Sync verfügbar
onSyncLegendClick = { showSyncLegend = true }, // 🆕 v1.8.0
onSortClick = { showSortDialog = true }, // 🔀 v1.8.0
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()) {
// 🆕 v1.8.0: Einziges Sync Banner (Progress + Ergebnis)
SyncProgressBanner(
progress = syncProgress,
modifier = Modifier.fillMaxWidth()
)
// Content: Empty state or notes list
if (notes.isEmpty()) {
EmptyState(modifier = Modifier.weight(1f))
} else {
// 🎨 v1.7.0: Switch between List and Grid based on display mode
if (displayMode == "grid") {
NotesStaggeredGrid(
notes = notes,
gridState = gridState,
showSyncStatus = viewModel.isServerConfigured(),
selectedNoteIds = selectedNotes,
isSelectionMode = isSelectionMode,
timestampTicker = timestampTicker,
modifier = Modifier.weight(1f),
onNoteClick = { note ->
if (isSelectionMode) {
viewModel.toggleNoteSelection(note.id)
} else {
onOpenNote(note.id)
}
},
onNoteLongClick = { note ->
viewModel.startSelectionMode(note.id)
}
)
} else {
NotesList(
notes = notes,
showSyncStatus = viewModel.isServerConfigured(),
selectedNotes = selectedNotes,
isSelectionMode = isSelectionMode,
timestampTicker = timestampTicker,
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
}
)
}
// 🆕 v1.8.0: Sync Status Legend Dialog
if (showSyncLegend) {
SyncStatusLegendDialog(
onDismiss = { showSyncLegend = false }
)
}
// 🔀 v1.8.0: Sort Dialog
if (showSortDialog) {
SortDialog(
currentOption = sortOption,
currentDirection = sortDirection,
onOptionSelected = { option ->
viewModel.setSortOption(option)
},
onDirectionToggled = {
viewModel.toggleSortDirection()
},
onDismiss = { showSortDialog = false }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MainTopBar(
syncEnabled: Boolean,
showSyncLegend: Boolean, // 🆕 v1.8.0: Ob der Hilfe-Button sichtbar sein soll
onSyncLegendClick: () -> Unit, // 🆕 v1.8.0
onSortClick: () -> Unit, // 🔀 v1.8.0: Sort-Button
onSyncClick: () -> Unit,
onSettingsClick: () -> Unit
) {
TopAppBar(
title = {
Text(
text = stringResource(R.string.main_title),
style = MaterialTheme.typography.titleLarge
)
},
actions = {
// 🔀 v1.8.0: Sort Button
IconButton(onClick = onSortClick) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.Sort,
contentDescription = stringResource(R.string.sort_notes)
)
}
// 🆕 v1.8.0: Sync Status Legend Button (nur wenn Sync verfügbar)
if (showSyncLegend) {
IconButton(onClick = onSyncLegendClick) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.HelpOutline,
contentDescription = stringResource(R.string.sync_legend_button)
)
}
}
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,830 @@
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.models.SortDirection
import dev.dettmer.simplenotes.models.SortOption
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncProgress
import dev.dettmer.simplenotes.sync.SyncStateManager
import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
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")
}
// ═══════════════════════════════════════════════════════════════════════
// 🎨 v1.7.0: Display Mode State
// ═══════════════════════════════════════════════════════════════════════
private val _displayMode = MutableStateFlow(
prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
)
val displayMode: StateFlow<String> = _displayMode.asStateFlow()
/**
* Refresh display mode from SharedPreferences
* Called when returning from Settings screen
*/
fun refreshDisplayMode() {
val newValue = prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
_displayMode.value = newValue
Logger.d(TAG, "🔄 refreshDisplayMode: displayMode=${_displayMode.value}$newValue")
}
// ═══════════════════════════════════════════════════════════════════════
// 🔀 v1.8.0: Sort State
// ═══════════════════════════════════════════════════════════════════════
private val _sortOption = MutableStateFlow(
SortOption.fromPrefsValue(
prefs.getString(Constants.KEY_SORT_OPTION, Constants.DEFAULT_SORT_OPTION) ?: Constants.DEFAULT_SORT_OPTION
)
)
val sortOption: StateFlow<SortOption> = _sortOption.asStateFlow()
private val _sortDirection = MutableStateFlow(
SortDirection.fromPrefsValue(
prefs.getString(Constants.KEY_SORT_DIRECTION, Constants.DEFAULT_SORT_DIRECTION) ?: Constants.DEFAULT_SORT_DIRECTION
)
)
val sortDirection: StateFlow<SortDirection> = _sortDirection.asStateFlow()
/**
* 🔀 v1.8.0: Sortierte Notizen — kombiniert aus Notes + SortOption + SortDirection.
* Reagiert automatisch auf Änderungen in allen drei Flows.
*/
val sortedNotes: StateFlow<List<Note>> = combine(
_notes,
_sortOption,
_sortDirection
) { notes, option, direction ->
sortNotes(notes, option, direction)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
// ═══════════════════════════════════════════════════════════════════════
// Sync State
// ═══════════════════════════════════════════════════════════════════════
// 🆕 v1.8.0: Einziges Banner-System - SyncProgress
val syncProgress: StateFlow<SyncProgress> = SyncStateManager.syncProgress
// Intern: SyncState für PullToRefresh-Indikator
private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE)
val syncState: StateFlow<SyncStateManager.SyncState> = _syncState.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) {
// 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO
SyncStateManager.showInfo(getString(R.string.snackbar_deleted_from_server))
} else {
SyncStateManager.showError(getString(R.string.snackbar_server_delete_failed))
}
} catch (e: Exception) {
SyncStateManager.showError(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
}
}
// 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO/ERROR
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
)
}
if (failCount > 0) {
SyncStateManager.showError(message)
} else {
SyncStateManager.showInfo(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
}
/**
* Trigger manual sync (from toolbar button or pull-to-refresh)
* v1.7.0: Uses central canSync() gate for WiFi-only check
* v1.8.0: Banner erscheint sofort beim Klick (PREPARING-Phase)
*/
fun triggerManualSync(source: String = "manual") {
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
val syncService = WebDavSyncService(getApplication())
val gateResult = syncService.canSync()
if (!gateResult.canSync) {
if (gateResult.isBlockedByWifiOnly) {
Logger.d(TAG, "⏭️ $source Sync blocked: WiFi-only mode, not on WiFi")
SyncStateManager.markError(getString(R.string.sync_wifi_only_error))
} else {
Logger.d(TAG, "⏭️ $source Sync blocked: ${gateResult.blockReason ?: "offline/no server"}")
}
return
}
// 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (verhindert Auto-Sync direkt danach)
// Manueller Sync prüft NICHT den globalen Cooldown (User will explizit synchronisieren)
val prefs = getApplication<android.app.Application>().getSharedPreferences(
Constants.PREFS_NAME,
android.content.Context.MODE_PRIVATE
)
// 🆕 v1.7.0: Feedback wenn Sync bereits läuft
// 🆕 v1.8.0: tryStartSync setzt sofort PREPARING → Banner erscheint instant
if (!SyncStateManager.tryStartSync(source)) {
if (SyncStateManager.isSyncing) {
Logger.d(TAG, "⏭️ $source Sync blocked: Another sync in progress")
viewModelScope.launch {
_showSnackbar.emit(SnackbarData(
message = getString(R.string.sync_already_running),
actionLabel = "",
onAction = {}
))
}
}
return
}
// 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (nach tryStartSync, vor Launch)
SyncStateManager.markGlobalSyncStarted(prefs)
viewModelScope.launch {
try {
// Check for unsynced changes (Banner zeigt bereits PREPARING)
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ $source Sync: No unsynced changes")
SyncStateManager.markCompleted(getString(R.string.toast_already_synced))
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) {
// 🆕 v1.8.0 (IMPL_022): Erweiterte Banner-Nachricht mit Löschungen
val bannerMessage = buildString {
if (result.syncedCount > 0) {
append(getString(R.string.toast_sync_success, result.syncedCount))
}
if (result.deletedOnServerCount > 0) {
if (isNotEmpty()) append(" · ")
append(getString(R.string.sync_deleted_on_server_count, result.deletedOnServerCount))
}
if (isEmpty()) {
append(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
* v1.7.0: Uses central canSync() gate for WiFi-only check
*/
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
}
// 🆕 v1.8.1 (IMPL_08): Globaler Sync-Cooldown (alle Trigger teilen sich diesen)
if (!SyncStateManager.canSyncGlobally(prefs)) {
return
}
// Throttling check (eigener 60s-Cooldown für onResume)
if (!canTriggerAutoSync()) {
return
}
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
val syncService = WebDavSyncService(getApplication())
val gateResult = syncService.canSync()
if (!gateResult.canSync) {
if (gateResult.isBlockedByWifiOnly) {
Logger.d(TAG, "⏭️ Auto-sync ($source) blocked: WiFi-only mode, not on WiFi")
} else {
Logger.d(TAG, "⏭️ Auto-sync ($source) blocked: ${gateResult.blockReason ?: "offline/no server"}")
}
return
}
// v1.5.0: silent=true → kein Banner bei Auto-Sync
// 🆕 v1.8.0: tryStartSync mit silent=true → SyncProgress.silent=true → Banner unsichtbar
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()
// 🆕 v1.8.1 (IMPL_08): Globalen Sync-Cooldown markieren
SyncStateManager.markGlobalSyncStarted(prefs)
viewModelScope.launch {
try {
// Check for unsynced changes
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
SyncStateManager.reset() // Silent → geht direkt auf IDLE
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() // Silent → kein Error-Banner
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")
// 🆕 v1.8.1 (IMPL_11): Kein Toast bei Silent-Sync
// Das Banner-System respektiert silent=true korrekt (markCompleted → IDLE)
// Toast wurde fälschlicherweise trotzdem angezeigt
SyncStateManager.markCompleted(getString(R.string.toast_sync_success, result.syncedCount))
loadNotes()
} else if (result.isSuccess) {
Logger.d(TAG, " Auto-sync ($source): No changes")
SyncStateManager.markCompleted() // Silent → geht direkt auf IDLE
} else {
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
// Fehler werden IMMER angezeigt (auch bei Silent-Sync)
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
}
// ═══════════════════════════════════════════════════════════════════════
// 🔀 v1.8.0: Sortierung
// ═══════════════════════════════════════════════════════════════════════
/**
* 🔀 v1.8.0: Sortiert Notizen nach gewählter Option und Richtung.
*/
private fun sortNotes(
notes: List<Note>,
option: SortOption,
direction: SortDirection
): List<Note> {
val comparator: Comparator<Note> = when (option) {
SortOption.UPDATED_AT -> compareBy { it.updatedAt }
SortOption.CREATED_AT -> compareBy { it.createdAt }
SortOption.TITLE -> compareBy(String.CASE_INSENSITIVE_ORDER) { it.title }
SortOption.NOTE_TYPE -> compareBy<Note> { it.noteType.ordinal }
.thenByDescending { it.updatedAt } // Sekundär: Datum innerhalb gleicher Typen
}
return when (direction) {
SortDirection.ASCENDING -> notes.sortedWith(comparator)
SortDirection.DESCENDING -> notes.sortedWith(comparator.reversed())
}
}
/**
* 🔀 v1.8.0: Setzt die Sortieroption und speichert in SharedPreferences.
*/
fun setSortOption(option: SortOption) {
_sortOption.value = option
prefs.edit().putString(Constants.KEY_SORT_OPTION, option.prefsValue).apply()
Logger.d(TAG, "🔀 Sort option changed to: ${option.prefsValue}")
}
/**
* 🔀 v1.8.0: Setzt die Sortierrichtung und speichert in SharedPreferences.
*/
fun setSortDirection(direction: SortDirection) {
_sortDirection.value = direction
prefs.edit().putString(Constants.KEY_SORT_DIRECTION, direction.prefsValue).apply()
Logger.d(TAG, "🔀 Sort direction changed to: ${direction.prefsValue}")
}
/**
* 🔀 v1.8.0: Toggelt die Sortierrichtung.
*/
fun toggleSortDirection() {
val newDirection = _sortDirection.value.toggle()
setSortDirection(newDirection)
}
// ═══════════════════════════════════════════════════════════════════════
// 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,202 @@
package dev.dettmer.simplenotes.ui.main
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.launch
/**
* v1.8.0: Post-Update Changelog Bottom Sheet
*
* Shows a subtle changelog on first launch after an update.
* - Reads changelog from raw resources (supports DE/EN)
* - Only shows once per versionCode (stored in SharedPreferences)
* - Uses Material 3 ModalBottomSheet with built-in slide-up animation
* - Dismissable via button or swipe-down
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UpdateChangelogSheet() {
val context = LocalContext.current
val prefs = remember {
context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
}
val currentVersionCode = BuildConfig.VERSION_CODE
val lastShownVersion = prefs.getInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, 0)
// Only show if this is a new version
var showSheet by remember { mutableStateOf(currentVersionCode > lastShownVersion) }
if (!showSheet) return
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
// Load changelog text based on current locale
val changelogText = remember {
loadChangelog(context)
}
ModalBottomSheet(
onDismissRequest = {
showSheet = false
prefs.edit()
.putInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, currentVersionCode)
.apply()
},
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
tonalElevation = 2.dp
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.navigationBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Title
Text(
text = stringResource(R.string.update_changelog_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
// Changelog content with clickable links
val annotatedText = buildAnnotatedString {
val lines = changelogText.split("\n")
lines.forEachIndexed { index, line ->
if (line.startsWith("http://") || line.startsWith("https://")) {
// Make URLs clickable
withLink(
LinkAnnotation.Url(
url = line.trim(),
styles = androidx.compose.ui.text.TextLinkStyles(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline
)
)
)
) {
append(line)
}
} else {
append(line)
}
if (index < lines.size - 1) append("\n")
}
}
Text(
text = annotatedText,
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(24.dp))
// Dismiss button
Button(
onClick = {
scope.launch {
sheetState.hide()
}.invokeOnCompletion {
showSheet = false
prefs.edit()
.putInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, currentVersionCode)
.apply()
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
) {
Text(stringResource(R.string.update_changelog_dismiss))
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
/**
* Load changelog text from assets based on current app locale and versionCode.
* Changelogs are copied from /fastlane/metadata/android/{locale}/changelogs/{versionCode}.txt
* at build time, providing a single source of truth for F-Droid and in-app display.
* Falls back to English if the localized version is not available.
*/
private fun loadChangelog(context: Context): String {
val currentLocale = AppCompatDelegate.getApplicationLocales()
val languageCode = if (currentLocale.isEmpty) {
// System default — check system locale
java.util.Locale.getDefault().language
} else {
currentLocale.get(0)?.language ?: "en"
}
// Map language code to F-Droid locale directory
val localeDir = when (languageCode) {
"de" -> "de-DE"
else -> "en-US"
}
val versionCode = BuildConfig.VERSION_CODE
val changelogPath = "changelogs/$localeDir/$versionCode.txt"
return try {
context.assets.open(changelogPath)
.bufferedReader()
.use { it.readText() }
} catch (e: Exception) {
Logger.e("UpdateChangelogSheet", "Failed to load changelog for locale: $localeDir", e)
// Fallback to English
try {
context.assets.open("changelogs/en-US/$versionCode.txt")
.bufferedReader()
.use { it.readText() }
} catch (e2: Exception) {
Logger.e("UpdateChangelogSheet", "Failed to load English fallback changelog", e2)
"v${BuildConfig.VERSION_NAME}"
}
}
}

View File

@@ -0,0 +1,63 @@
package dev.dettmer.simplenotes.ui.main.components
import dev.dettmer.simplenotes.models.ChecklistItem
import dev.dettmer.simplenotes.models.ChecklistSortOption
/**
* 🆕 v1.8.1 (IMPL_03): Helper-Funktionen für die Checklisten-Vorschau in Main Activity.
*
* Stellt sicher, dass die Sortierung aus dem Editor konsistent
* in allen Preview-Components (NoteCard, NoteCardCompact, NoteCardGrid)
* angezeigt wird.
*/
/**
* Sortiert Checklist-Items für die Vorschau basierend auf der
* gespeicherten Sortier-Option.
*/
fun sortChecklistItemsForPreview(
items: List<ChecklistItem>,
sortOptionName: String?
): List<ChecklistItem> {
val sortOption = try {
sortOptionName?.let { ChecklistSortOption.valueOf(it) }
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) {
null
} ?: ChecklistSortOption.MANUAL
return when (sortOption) {
ChecklistSortOption.MANUAL,
ChecklistSortOption.UNCHECKED_FIRST ->
items.sortedBy { it.isChecked }
ChecklistSortOption.CHECKED_FIRST ->
items.sortedByDescending { it.isChecked }
ChecklistSortOption.ALPHABETICAL_ASC ->
items.sortedBy { it.text.lowercase() }
ChecklistSortOption.ALPHABETICAL_DESC ->
items.sortedByDescending { it.text.lowercase() }
}
}
/**
* Generiert den Vorschau-Text für eine Checkliste mit korrekter
* Sortierung und passenden Emojis.
*
* @param items Die Checklisten-Items
* @param sortOptionName Der Name der ChecklistSortOption (oder null für MANUAL)
* @return Formatierter Preview-String mit Emojis und Zeilenumbrüchen
*
* 🆕 v1.8.1 (IMPL_06): Emoji-Änderung (☑️ statt ✅ für checked items)
*/
fun generateChecklistPreview(
items: List<ChecklistItem>,
sortOptionName: String?
): String {
val sorted = sortChecklistItemsForPreview(items, sortOptionName)
return sorted.joinToString("\n") { item ->
val prefix = if (item.isChecked) "☑️" else ""
"$prefix ${item.text}"
}
}

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,253 @@
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,
timestampTicker: Long = 0L,
modifier: Modifier = Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
val context = LocalContext.current
// ⏱️ Reading timestampTicker triggers recomposition only for visible cards
@Suppress("UNUSED_VARIABLE")
val ticker = timestampTicker
Card(
modifier = modifier
.fillMaxWidth()
// 🎨 v1.7.0: Externes Padding entfernt - Grid/Liste steuert Abstände
.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
SyncStatus.DELETED_ON_SERVER -> Icons.Outlined.CloudOff // 🆕 v1.8.0
},
contentDescription = when (note.syncStatus) {
SyncStatus.SYNCED -> stringResource(R.string.sync_status_synced)
SyncStatus.PENDING -> stringResource(R.string.sync_status_pending)
SyncStatus.CONFLICT -> stringResource(R.string.sync_status_conflict)
SyncStatus.LOCAL_ONLY -> stringResource(R.string.sync_status_local_only)
SyncStatus.DELETED_ON_SERVER -> stringResource(R.string.sync_status_deleted_on_server) // 🆕 v1.8.0
},
tint = when (note.syncStatus) {
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
SyncStatus.DELETED_ON_SERVER -> MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) // 🆕 v1.8.0
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,247 @@
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
/**
* 🎨 v1.7.0: Compact Note Card for Grid Layout
*
* COMPACT DESIGN für kleine Notizen:
* - Reduzierter Padding (12dp statt 16dp)
* - Kleinere Icons (24dp statt 32dp)
* - Kompakte Typography (titleSmall)
* - Max 3 Zeilen Preview
* - Optimiert für Grid-Ansicht
*/
@Composable
fun NoteCardCompact(
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()
.then(
if (isSelected) {
Modifier.border(
width = 2.dp,
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(12.dp)
)
} else Modifier
)
.pointerInput(note.id, isSelectionMode) {
detectTapGestures(
onTap = { onClick() },
onLongPress = { onLongClick() }
)
},
shape = RoundedCornerShape(12.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(12.dp)
) {
// Header row - COMPACT
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Type icon - SMALLER
Box(
modifier = Modifier
.size(24.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(12.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
// Title - COMPACT Typography
Text(
text = note.title.ifEmpty { stringResource(R.string.untitled) },
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(6.dp))
// Preview - MAX 3 ZEILEN
Text(
text = when (note.noteType) {
NoteType.TEXT -> note.content
NoteType.CHECKLIST -> {
// 🆕 v1.8.1 (IMPL_03 + IMPL_06): Sortierte Preview mit neuen Emojis
note.checklistItems?.let { items ->
generateChecklistPreview(items, note.checklistSortOption)
} ?: ""
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(6.dp))
// Bottom row - KOMPAKT
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Timestamp - SMALLER
Text(
text = note.updatedAt.toReadableTime(context),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.weight(1f)
)
// Sync Status - KOMPAKT
if (showSyncStatus) {
Spacer(modifier = Modifier.width(4.dp))
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
SyncStatus.DELETED_ON_SERVER -> Icons.Outlined.CloudOff // 🆕 v1.8.0
},
contentDescription = null,
tint = when (note.syncStatus) {
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
SyncStatus.DELETED_ON_SERVER -> MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) // 🆕 v1.8.0
else -> MaterialTheme.colorScheme.outline
},
modifier = Modifier.size(14.dp)
)
}
}
}
// Selection indicator checkbox (top-right)
androidx.compose.animation.AnimatedVisibility(
visible = isSelectionMode,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp)
) {
Box(
modifier = Modifier
.size(20.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.Filled.Check,
contentDescription = stringResource(R.string.selection_count, 1),
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(12.dp)
)
}
}
}
}
}
}

View File

@@ -0,0 +1,259 @@
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.runtime.remember
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.NoteSize
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.models.getSize
import dev.dettmer.simplenotes.utils.toReadableTime
/**
* 🎨 v1.7.0: Unified Note Card for Grid Layout
*
* Einheitliche Card für ALLE Notizen im Grid:
* - Dynamische maxLines basierend auf NoteSize
* - LARGE notes: 6 Zeilen Preview
* - SMALL notes: 3 Zeilen Preview
* - Kein externes Padding - Grid steuert Abstände
* - Optimiert für Pinterest-style dynamisches Layout
*/
@Composable
fun NoteCardGrid(
note: Note,
showSyncStatus: Boolean,
isSelected: Boolean = false,
isSelectionMode: Boolean = false,
timestampTicker: Long = 0L,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
val context = LocalContext.current
// 🚀 Performance: Cache noteSize - nur bei note-Änderung neu berechnen
val noteSize = remember(note.id, note.content, note.checklistItems) { note.getSize() }
// ⏱️ Reading timestampTicker triggers recomposition only for visible cards
@Suppress("UNUSED_VARIABLE")
val ticker = timestampTicker
// Dynamische maxLines basierend auf Größe
val previewMaxLines = if (noteSize == NoteSize.LARGE) 6 else 3
Card(
modifier = Modifier
.fillMaxWidth()
// Kein externes Padding - Grid steuert alles
.then(
if (isSelected) {
Modifier.border(
width = 2.dp,
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(12.dp)
)
} else Modifier
)
.pointerInput(note.id, isSelectionMode) {
detectTapGestures(
onTap = { onClick() },
onLongPress = { onLongClick() }
)
},
shape = RoundedCornerShape(12.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(12.dp) // Einheitliches internes Padding
) {
// Header row
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Type icon
Box(
modifier = Modifier
.size(24.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(12.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
// Title
Text(
text = note.title.ifEmpty { stringResource(R.string.untitled) },
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(6.dp))
// Preview - Dynamische Zeilen basierend auf NoteSize
Text(
text = when (note.noteType) {
NoteType.TEXT -> note.content
NoteType.CHECKLIST -> {
// 🆕 v1.8.1 (IMPL_03 + IMPL_06): Sortierte Preview mit neuen Emojis
note.checklistItems?.let { items ->
generateChecklistPreview(items, note.checklistSortOption)
} ?: ""
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = previewMaxLines, // 🎯 Dynamisch: LARGE=6, SMALL=3
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(6.dp))
// Footer
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = note.updatedAt.toReadableTime(context),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.weight(1f)
)
if (showSyncStatus) {
Spacer(modifier = Modifier.width(4.dp))
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
SyncStatus.DELETED_ON_SERVER -> Icons.Outlined.CloudOff // 🆕 v1.8.0
},
contentDescription = null,
tint = when (note.syncStatus) {
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
SyncStatus.DELETED_ON_SERVER -> MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) // 🆕 v1.8.0
else -> MaterialTheme.colorScheme.outline
},
modifier = Modifier.size(14.dp)
)
}
}
}
// Selection indicator checkbox (top-right)
androidx.compose.animation.AnimatedVisibility(
visible = isSelectionMode,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp)
) {
Box(
modifier = Modifier
.size(20.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.Filled.Check,
contentDescription = stringResource(R.string.selection_count, 1),
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(12.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,72 @@
package dev.dettmer.simplenotes.ui.main.components
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.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
* - ⏱️ timestampTicker triggers recomposition for relative time updates
*/
@Suppress("LongParameterList") // Composable with many UI state parameters
@Composable
fun NotesList(
notes: List<Note>,
showSyncStatus: Boolean,
selectedNotes: Set<String> = emptySet(),
isSelectionMode: Boolean = false,
timestampTicker: Long = 0L,
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,
timestampTicker = timestampTicker,
// 🎨 v1.7.0: Padding hier in Liste (nicht in Card selbst)
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
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,73 @@
package dev.dettmer.simplenotes.ui.main.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.utils.Constants
/**
* 🎨 v1.7.0: Staggered Grid Layout - OPTIMIERT
*
* Pinterest-style Grid:
* - ALLE Items als SingleLane (halbe Breite)
* - Dynamische Höhe basierend auf NoteSize (LARGE=6 Zeilen, SMALL=3 Zeilen)
* - Keine Lücken mehr durch FullLine-Items
* - Selection mode support
* - Efficient LazyVerticalStaggeredGrid
* - ⏱️ timestampTicker triggers recomposition for relative time updates
*/
@Composable
fun NotesStaggeredGrid(
notes: List<Note>,
gridState: LazyStaggeredGridState,
showSyncStatus: Boolean,
selectedNoteIds: Set<String>,
isSelectionMode: Boolean,
timestampTicker: Long = 0L,
modifier: Modifier = Modifier,
onNoteClick: (Note) -> Unit,
onNoteLongClick: (Note) -> Unit
) {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(Constants.GRID_COLUMNS),
modifier = modifier.fillMaxSize(),
state = gridState,
// 🎨 v1.7.0: Konsistente Abstände - 16dp horizontal wie Liste, mehr Platz für FAB
contentPadding = PaddingValues(
start = 16.dp, // Wie Liste, war 8dp
end = 16.dp,
top = 8.dp,
bottom = 80.dp // Mehr Platz für FAB, war 16dp
),
horizontalArrangement = Arrangement.spacedBy(12.dp), // War 8dp
verticalItemSpacing = 12.dp // War Constants.GRID_SPACING_DP (8dp)
) {
items(
items = notes,
key = { it.id },
contentType = { "NoteCardGrid" }
// 🎨 v1.7.0: KEIN span mehr - alle Items sind SingleLane (halbe Breite)
) { note ->
val isSelected = selectedNoteIds.contains(note.id)
// 🎉 Einheitliche Card für alle Größen - dynamische maxLines intern
NoteCardGrid(
note = note,
showSyncStatus = showSyncStatus,
isSelected = isSelected,
isSelectionMode = isSelectionMode,
timestampTicker = timestampTicker,
onClick = { onNoteClick(note) },
onLongClick = { onNoteLongClick(note) }
)
}
}
}

View File

@@ -0,0 +1,160 @@
package dev.dettmer.simplenotes.ui.main.components
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.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.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
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
import dev.dettmer.simplenotes.models.SortDirection
import dev.dettmer.simplenotes.models.SortOption
/**
* 🔀 v1.8.0: Dialog zur Auswahl der Sortierung für die Notizliste.
*
* Zeigt RadioButtons für die Sortieroption und einen Toggle für die Richtung.
*
* ┌─────────────────────────────────┐
* │ Sort Notes │
* ├─────────────────────────────────┤
* │ (●) Last modified ↓↑ │
* │ ( ) Date created │
* │ ( ) Name │
* │ ( ) Type │
* ├─────────────────────────────────┤
* │ [Close] │
* └─────────────────────────────────┘
*/
@Composable
fun SortDialog(
currentOption: SortOption,
currentDirection: SortDirection,
onOptionSelected: (SortOption) -> Unit,
onDirectionToggled: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.sort_notes),
style = MaterialTheme.typography.headlineSmall
)
// Direction Toggle Button
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
IconButton(onClick = onDirectionToggled) {
Icon(
imageVector = if (currentDirection == SortDirection.DESCENDING) {
Icons.Default.ArrowDownward
} else {
Icons.Default.ArrowUpward
},
contentDescription = stringResource(
if (currentDirection == SortDirection.DESCENDING) {
R.string.sort_descending
} else {
R.string.sort_ascending
}
),
modifier = Modifier.size(24.dp)
)
}
Text(
text = stringResource(
if (currentDirection == SortDirection.DESCENDING) {
R.string.sort_descending
} else {
R.string.sort_ascending
}
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
text = {
Column {
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
SortOption.entries.forEach { option ->
SortOptionRow(
label = stringResource(option.toStringRes()),
isSelected = currentOption == option,
onClick = { onOptionSelected(option) }
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.close))
}
}
)
}
@Composable
private fun SortOptionRow(
label: String,
isSelected: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = isSelected,
onClick = onClick
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = label,
style = MaterialTheme.typography.bodyLarge
)
}
}
/**
* Extension: SortOption → String-Resource-ID
*/
fun SortOption.toStringRes(): Int = when (this) {
SortOption.UPDATED_AT -> R.string.sort_by_updated
SortOption.CREATED_AT -> R.string.sort_by_created
SortOption.TITLE -> R.string.sort_by_name
SortOption.NOTE_TYPE -> R.string.sort_by_type
}

View File

@@ -0,0 +1,204 @@
package dev.dettmer.simplenotes.ui.main.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
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.CheckCircle
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.sync.SyncPhase
import dev.dettmer.simplenotes.sync.SyncProgress
/**
* 🆕 v1.8.0: Einziges Sync-Banner für den gesamten Sync-Lebenszyklus
*
* Deckt alle Phasen ab:
* - PREPARING: Indeterminate Spinner + "Synchronisiere…" (sofort beim Klick, bleibt bis echte Arbeit)
* - UPLOADING / DOWNLOADING / IMPORTING_MARKDOWN: Nur bei echten Aktionen
* - COMPLETED: Erfolgsmeldung mit Checkmark-Icon (auto-hide durch ComposeMainActivity)
* - ERROR: Fehlermeldung mit Error-Icon (auto-hide durch ComposeMainActivity)
*
* Silent Syncs (onResume) zeigen kein Banner (progress.isVisible == false)
*/
@Composable
fun SyncProgressBanner(
progress: SyncProgress,
modifier: Modifier = Modifier
) {
// Farbe animiert wechseln je nach State
val isError = progress.phase == SyncPhase.ERROR
val isCompleted = progress.phase == SyncPhase.COMPLETED
val isInfo = progress.phase == SyncPhase.INFO // 🆕 v1.8.1 (IMPL_12)
val isResult = isError || isCompleted || isInfo
val backgroundColor by animateColorAsState(
targetValue = when {
isError -> MaterialTheme.colorScheme.errorContainer
isInfo -> MaterialTheme.colorScheme.secondaryContainer // 🆕 v1.8.1 (IMPL_12)
else -> MaterialTheme.colorScheme.primaryContainer
},
label = "bannerColor"
)
val contentColor by animateColorAsState(
targetValue = when {
isError -> MaterialTheme.colorScheme.onErrorContainer
isInfo -> MaterialTheme.colorScheme.onSecondaryContainer // 🆕 v1.8.1 (IMPL_12)
else -> MaterialTheme.colorScheme.onPrimaryContainer
},
label = "bannerContentColor"
)
AnimatedVisibility(
visible = progress.isVisible,
enter = expandVertically(),
exit = shrinkVertically(),
modifier = modifier
) {
Surface(
color = backgroundColor,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp)
) {
// Zeile 1: Icon + Phase/Message + Counter
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
// Icon: Spinner (aktiv), Checkmark (completed), Error (error), Info (info)
when {
isCompleted -> {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = contentColor
)
}
isInfo -> { // 🆕 v1.8.1 (IMPL_12)
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = contentColor
)
}
isError -> {
Icon(
imageVector = Icons.Filled.ErrorOutline,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = contentColor
)
}
else -> {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = contentColor
)
}
}
// Text: Ergebnisnachricht oder Phase
Text(
text = when {
isResult && !progress.resultMessage.isNullOrBlank() -> progress.resultMessage
else -> phaseToString(progress.phase)
},
style = MaterialTheme.typography.bodyMedium,
color = contentColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
// Counter: x/y bei Uploads (Total bekannt), nur Zähler bei Downloads
if (!isResult && progress.current > 0) {
Text(
text = if (progress.total > 0) {
"${progress.current}/${progress.total}"
} else {
"${progress.current}"
},
style = MaterialTheme.typography.labelMedium,
color = contentColor.copy(alpha = 0.7f)
)
}
}
// Zeile 2: Progress Bar (nur bei Upload mit bekanntem Total)
if (!isResult && progress.total > 0 && progress.current > 0 &&
progress.phase == SyncPhase.UPLOADING) {
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
progress = { progress.progress },
modifier = Modifier
.fillMaxWidth()
.height(4.dp),
color = contentColor,
trackColor = contentColor.copy(alpha = 0.2f)
)
}
// Zeile 3: Aktueller Notiz-Titel (optional, nur bei aktivem Sync)
if (!isResult && !progress.currentFileName.isNullOrBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = progress.currentFileName,
style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.6f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
/**
* Konvertiert SyncPhase zu lokalisierten String
*/
@Composable
private fun phaseToString(phase: SyncPhase): String {
return when (phase) {
SyncPhase.IDLE -> ""
SyncPhase.PREPARING -> stringResource(R.string.sync_phase_preparing)
SyncPhase.UPLOADING -> stringResource(R.string.sync_phase_uploading)
SyncPhase.DOWNLOADING -> stringResource(R.string.sync_phase_downloading)
SyncPhase.IMPORTING_MARKDOWN -> stringResource(R.string.sync_phase_importing_markdown)
SyncPhase.COMPLETED -> stringResource(R.string.sync_phase_completed)
SyncPhase.ERROR -> stringResource(R.string.sync_phase_error)
SyncPhase.INFO -> "" // 🆕 v1.8.1 (IMPL_12): INFO nutzt immer resultMessage
}
}

View File

@@ -0,0 +1,64 @@
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
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
* v1.8.0: Nur noch COMPLETED/ERROR States - SYNCING wird von SyncProgressBanner übernommen
*/
@Composable
fun SyncStatusBanner(
syncState: SyncStateManager.SyncState,
message: String?,
modifier: Modifier = Modifier
) {
// v1.8.0: Nur COMPLETED/ERROR anzeigen (SYNCING wird von SyncProgressBanner übernommen)
// IDLE und SYNCING_SILENT werden ignoriert
val isVisible = syncState == SyncStateManager.SyncState.COMPLETED
|| syncState == SyncStateManager.SyncState.ERROR
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
) {
// v1.8.0: Kein Loading-Icon mehr - wird von SyncProgressBanner übernommen
Text(
text = when (syncState) {
SyncStateManager.SyncState.COMPLETED -> message ?: stringResource(R.string.sync_status_completed)
SyncStateManager.SyncState.ERROR -> message ?: stringResource(R.string.sync_status_error)
else -> "" // SYNCING/IDLE/SYNCING_SILENT nicht mehr relevant
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.weight(1f)
)
}
}
}

View File

@@ -0,0 +1,148 @@
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
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.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
/**
* 🆕 v1.8.0: Dialog showing the sync status icon legend
*
* Displays all 5 SyncStatus values with their icons, colors,
* and descriptions. Helps users understand what each icon means.
*/
@Composable
fun SyncStatusLegendDialog(
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(R.string.sync_legend_title),
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Optional: Kurze Einleitung
Text(
text = stringResource(R.string.sync_legend_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
HorizontalDivider()
// ☁️✓ SYNCED
LegendRow(
icon = Icons.Outlined.CloudDone,
tint = MaterialTheme.colorScheme.primary,
label = stringResource(R.string.sync_legend_synced_label),
description = stringResource(R.string.sync_legend_synced_desc)
)
// ☁️↻ PENDING
LegendRow(
icon = Icons.Outlined.CloudSync,
tint = MaterialTheme.colorScheme.outline,
label = stringResource(R.string.sync_legend_pending_label),
description = stringResource(R.string.sync_legend_pending_desc)
)
// ⚠️ CONFLICT
LegendRow(
icon = Icons.Default.Warning,
tint = MaterialTheme.colorScheme.error,
label = stringResource(R.string.sync_legend_conflict_label),
description = stringResource(R.string.sync_legend_conflict_desc)
)
// ☁️✗ LOCAL_ONLY
LegendRow(
icon = Icons.Outlined.CloudOff,
tint = MaterialTheme.colorScheme.outline,
label = stringResource(R.string.sync_legend_local_only_label),
description = stringResource(R.string.sync_legend_local_only_desc)
)
// ☁️✗ DELETED_ON_SERVER
LegendRow(
icon = Icons.Outlined.CloudOff,
tint = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
label = stringResource(R.string.sync_legend_deleted_label),
description = stringResource(R.string.sync_legend_deleted_desc)
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.ok))
}
}
)
}
/**
* Single row in the sync status legend
* Shows icon + label + description
*/
@Composable
private fun LegendRow(
icon: ImageVector,
tint: Color,
label: String,
description: String
) {
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = icon,
contentDescription = null, // Dekorativ, Label reicht
tint = tint,
modifier = Modifier
.size(20.dp)
.padding(top = 2.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@@ -0,0 +1,204 @@
package dev.dettmer.simplenotes.ui.settings
import android.annotation.SuppressLint
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
*
* Note: REQUEST_IGNORE_BATTERY_OPTIMIZATIONS is acceptable for F-Droid builds.
* For Play Store builds, this would need to be changed to
* ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS (shows list, doesn't request directly).
*/
@SuppressLint("BatteryLife")
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}")
}
}
/**
* Handle configuration changes (e.g., locale) without recreating activity
* v1.8.0: Prevents flickering during language changes by avoiding full recreate
* Compose automatically recomposes when configuration changes
*/
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
Logger.d(TAG, "📱 Configuration changed (likely locale switch) - Compose will recompose")
// Compose handles UI updates automatically via recomposition
// No manual action needed - stringResource() etc. will pick up new locale
}
}

View File

@@ -0,0 +1,108 @@
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.DisplaySettingsScreen
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() }
)
}
// 🎨 v1.7.0: Display Settings
composable(SettingsRoute.Display.route) {
DisplaySettingsScreen(
viewModel = viewModel,
onBack = { navController.popBackStack() }
)
}
}
}

View File

@@ -0,0 +1,17 @@
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")
data object Display : SettingsRoute("settings_display") // 🎨 v1.7.0
}

View File

@@ -0,0 +1,927 @@
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.storage.NotesStorage
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.delay
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.
*/
@Suppress("TooManyFunctions") // v1.7.0: 35 Funktionen durch viele kleine Setter (setTrigger*, set*)
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
companion object {
private const val TAG = "SettingsViewModel"
private const val CONNECTION_TIMEOUT_MS = 3000
private const val STATUS_CLEAR_DELAY_SUCCESS_MS = 2000L // 2s for successful operations
private const val STATUS_CLEAR_DELAY_ERROR_MS = 3000L // 3s for errors (more important)
}
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val backupManager = BackupManager(application)
private val notesStorage = NotesStorage(application) // v1.7.0: For server change detection
// 🔧 v1.7.0 Hotfix: Track last confirmed server URL for change detection
// This prevents false-positive "server changed" toasts during text input
private var confirmedServerUrl: String = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
// ═══════════════════════════════════════════════════════════════════════
// 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.8.0: Max Parallel Downloads
private val _maxParallelDownloads = MutableStateFlow(
prefs.getInt(Constants.KEY_MAX_PARALLEL_DOWNLOADS, Constants.DEFAULT_MAX_PARALLEL_DOWNLOADS)
)
val maxParallelDownloads: StateFlow<Int> = _maxParallelDownloads.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()
// 🎉 v1.7.0: WiFi-Only Sync Toggle
private val _wifiOnlySync = MutableStateFlow(
prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC)
)
val wifiOnlySync: StateFlow<Boolean> = _wifiOnlySync.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()
// ═══════════════════════════════════════════════════════════════════════
// 🎨 v1.7.0: Display Settings State
// ═══════════════════════════════════════════════════════════════════════
private val _displayMode = MutableStateFlow(
prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
)
val displayMode: StateFlow<String> = _displayMode.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()
// v1.8.0: Descriptive backup status text
private val _backupStatusText = MutableStateFlow("")
val backupStatusText: StateFlow<String> = _backupStatusText.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()
* 🔧 v1.7.0 Hotfix: Removed auto-save to prevent false server-change detection
* 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
* but WITHOUT server-change detection (detection happens only on screen exit)
*/
fun updateServerHost(host: String) {
_serverHost.value = host
// ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection
val prefix = if (_isHttps.value) "https://" else "http://"
val fullUrl = if (host.isEmpty()) "" else prefix + host
prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
}
fun updateProtocol(useHttps: Boolean) {
_isHttps.value = useHttps
// 🌟 v1.6.0: Host stays the same, only prefix changes
// 🔧 v1.7.0 Hotfix: Removed auto-save to prevent false server-change detection
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
// ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection
val prefix = if (useHttps) "https://" else "http://"
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
}
fun updateUsername(value: String) {
_username.value = value
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
prefs.edit().putString(Constants.KEY_USERNAME, value).apply()
}
fun updatePassword(value: String) {
_password.value = value
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
prefs.edit().putString(Constants.KEY_PASSWORD, value).apply()
}
/**
* 🔧 v1.7.0 Hotfix: Manual save function - only called when leaving settings screen
* This prevents false "server changed" detection during text input
* 🔧 v1.7.0 Regression Fix: Settings are now saved IMMEDIATELY in update functions.
* This function now ONLY handles server-change detection and sync reset.
*/
fun saveServerSettingsManually() {
// 🌟 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
// 🔄 v1.7.0: Detect server change ONLY against last confirmed URL
val serverChanged = isServerReallyChanged(confirmedServerUrl, fullUrl)
// ✅ Settings are already saved in updateServerHost/Protocol/Username/Password
// This function now ONLY handles server-change detection
// Reset sync status if server actually changed
if (serverChanged) {
viewModelScope.launch {
val count = notesStorage.resetAllSyncStatusToPending()
Logger.d(TAG, "🔄 Server changed from '$confirmedServerUrl' to '$fullUrl': Reset $count notes to PENDING")
emitToast(getString(R.string.toast_server_changed_sync_reset, count))
}
// Update confirmed state after reset
confirmedServerUrl = fullUrl
} else {
Logger.d(TAG, "💾 Server settings check complete (no server change detected)")
}
}
/**
* <20> v1.7.0 Hotfix: Improved server change detection
*
* Only returns true if the server URL actually changed in a meaningful way.
* Handles edge cases:
* - First setup (empty → filled) = NOT a change
* - Protocol only (http → https) = NOT a change
* - Server removed (filled → empty) = NOT a change
* - Trailing slashes, case differences = NOT a change
* - Different hostname/port/path = IS a change ✓
*/
private fun isServerReallyChanged(confirmedUrl: String, newUrl: String): Boolean {
// Empty → Non-empty = First setup, NOT a change
if (confirmedUrl.isEmpty() && newUrl.isNotEmpty()) {
Logger.d(TAG, "First server setup detected (no reset needed)")
return false
}
// Both empty = No change
if (confirmedUrl.isEmpty() && newUrl.isEmpty()) {
return false
}
// Non-empty → Empty = Server removed (keep notes local, no reset)
if (confirmedUrl.isNotEmpty() && newUrl.isEmpty()) {
Logger.d(TAG, "Server removed (notes stay local, no reset needed)")
return false
}
// Same URL = No change
if (confirmedUrl == newUrl) {
return false
}
// Normalize URLs for comparison (ignore protocol, trailing slash, case)
val normalize = { url: String ->
url.trim()
.removePrefix("http://")
.removePrefix("https://")
.removeSuffix("/")
.lowercase()
}
val confirmedNormalized = normalize(confirmedUrl)
val newNormalized = normalize(newUrl)
// Check if normalized URLs differ
val changed = confirmedNormalized != newNormalized
if (changed) {
Logger.d(TAG, "Server URL changed: '$confirmedNormalized' → '$newNormalized'")
}
return changed
}
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 {
val syncService = WebDavSyncService(getApplication())
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung
val gateResult = syncService.canSync()
if (!gateResult.canSync) {
if (gateResult.isBlockedByWifiOnly) {
emitToast(getString(R.string.sync_wifi_only_hint))
} else {
emitToast(getString(R.string.toast_sync_failed, "Offline mode"))
}
return@launch
}
emitToast(getString(R.string.toast_syncing))
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.8.0: Max Parallel Downloads Setter
fun setMaxParallelDownloads(count: Int) {
val validCount = count.coerceIn(
Constants.MIN_PARALLEL_DOWNLOADS,
Constants.MAX_PARALLEL_DOWNLOADS
)
_maxParallelDownloads.value = validCount
prefs.edit().putInt(Constants.KEY_MAX_PARALLEL_DOWNLOADS, validCount).apply()
}
// 🌟 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")
}
/**
* 🎉 v1.7.0: Set WiFi-only sync mode
* When enabled, sync only happens when connected to WiFi
*/
fun setWifiOnlySync(enabled: Boolean) {
_wifiOnlySync.value = enabled
prefs.edit().putBoolean(Constants.KEY_WIFI_ONLY_SYNC, enabled).apply()
Logger.d(TAG, "📡 WiFi-only sync: $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, password: String? = null) {
viewModelScope.launch {
_isBackupInProgress.value = true
_backupStatusText.value = getString(R.string.backup_progress_creating)
try {
val result = backupManager.createBackup(uri, password)
// Phase 2: Show completion status
_backupStatusText.value = if (result.success) {
getString(R.string.backup_progress_complete)
} else {
getString(R.string.backup_progress_failed)
}
// Phase 3: Clear after delay
delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
} catch (e: Exception) {
Logger.e(TAG, "Failed to create backup", e)
_backupStatusText.value = getString(R.string.backup_progress_failed)
delay(STATUS_CLEAR_DELAY_ERROR_MS)
} finally {
_isBackupInProgress.value = false
_backupStatusText.value = ""
}
}
}
fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) {
viewModelScope.launch {
_isBackupInProgress.value = true
_backupStatusText.value = getString(R.string.backup_progress_restoring)
try {
val result = backupManager.restoreBackup(uri, mode, password)
// Phase 2: Show completion status
_backupStatusText.value = if (result.success) {
getString(R.string.restore_progress_complete)
} else {
getString(R.string.restore_progress_failed)
}
// Phase 3: Clear after delay
delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
} catch (e: Exception) {
Logger.e(TAG, "Failed to restore backup from file", e)
_backupStatusText.value = getString(R.string.restore_progress_failed)
delay(STATUS_CLEAR_DELAY_ERROR_MS)
} finally {
_isBackupInProgress.value = false
_backupStatusText.value = ""
}
}
}
/**
* 🔐 v1.7.0: Check if backup is encrypted and call appropriate callback
*/
fun checkBackupEncryption(
uri: Uri,
onEncrypted: () -> Unit,
onPlaintext: () -> Unit
) {
viewModelScope.launch {
try {
val isEncrypted = backupManager.isBackupEncrypted(uri)
if (isEncrypted) {
onEncrypted()
} else {
onPlaintext()
}
} catch (e: Exception) {
Logger.e(TAG, "Failed to check encryption status", e)
onPlaintext() // Assume plaintext on error
}
}
}
fun restoreFromServer(mode: RestoreMode) {
viewModelScope.launch {
_isBackupInProgress.value = true
_backupStatusText.value = getString(R.string.backup_progress_restoring_server)
try {
val syncService = WebDavSyncService(getApplication())
val result = withContext(Dispatchers.IO) {
syncService.restoreFromServer(mode)
}
// Phase 2: Show completion status
_backupStatusText.value = if (result.isSuccess) {
getString(R.string.restore_server_progress_complete)
} else {
getString(R.string.restore_server_progress_failed)
}
// Phase 3: Clear after delay
delay(if (result.isSuccess) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
} catch (e: Exception) {
Logger.e(TAG, "Failed to restore from server", e)
_backupStatusText.value = getString(R.string.restore_server_progress_failed)
delay(STATUS_CLEAR_DELAY_ERROR_MS)
} finally {
_isBackupInProgress.value = false
_backupStatusText.value = ""
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// 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())
/**
* v1.8.0: Reset changelog version to force showing the changelog dialog on next start
* Used for testing the post-update changelog feature
*/
fun resetChangelogVersion() {
prefs.edit()
.putInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, 0)
.apply()
}
// ═══════════════════════════════════════════════════════════════════════
// 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://"
}
/**
* 🌍 v1.7.1: Get string resources with correct app locale
*
* AndroidViewModel uses Application context which may not have the correct locale
* applied when using per-app language settings. We need to get a Context that
* respects AppCompatDelegate.getApplicationLocales().
*/
private fun getString(resId: Int): String {
// Get context with correct locale configuration from AppCompatDelegate
val appLocales = androidx.appcompat.app.AppCompatDelegate.getApplicationLocales()
val context = if (!appLocales.isEmpty) {
// Create configuration with app locale
val config = android.content.res.Configuration(getApplication<Application>().resources.configuration)
config.setLocale(appLocales.get(0))
getApplication<Application>().createConfigurationContext(config)
} else {
// Use system locale (default)
getApplication<Application>()
}
return context.getString(resId)
}
private fun getString(resId: Int, vararg formatArgs: Any): String {
// Get context with correct locale configuration from AppCompatDelegate
val appLocales = androidx.appcompat.app.AppCompatDelegate.getApplicationLocales()
val context = if (!appLocales.isEmpty) {
// Create configuration with app locale
val config = android.content.res.Configuration(getApplication<Application>().resources.configuration)
config.setLocale(appLocales.get(0))
getApplication<Application>().createConfigurationContext(config)
} else {
// Use system locale (default)
getApplication<Application>()
}
return context.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
)
// ═══════════════════════════════════════════════════════════════════════
// 🎨 v1.7.0: Display Mode Functions
// ═══════════════════════════════════════════════════════════════════════
/**
* Set display mode (list or grid)
*/
fun setDisplayMode(mode: String) {
_displayMode.value = mode
prefs.edit().putString(Constants.KEY_DISPLAY_MODE, mode).apply()
Logger.d(TAG, "Display mode changed to: $mode")
}
}

View File

@@ -0,0 +1,180 @@
package dev.dettmer.simplenotes.ui.settings.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
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
private const val MIN_PASSWORD_LENGTH = 8
/**
* 🔒 v1.7.0: Password input dialog for backup encryption/decryption
*/
@Composable
fun BackupPasswordDialog(
title: String,
onDismiss: () -> Unit,
onConfirm: (password: String) -> Unit,
requireConfirmation: Boolean = true
) {
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
var confirmPasswordVisible by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
val focusRequester = remember { FocusRequester() }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
// Password field
OutlinedTextField(
value = password,
onValueChange = {
password = it
errorMessage = null
},
label = { Text(stringResource(R.string.backup_encryption_password)) },
placeholder = { Text(stringResource(R.string.backup_encryption_password_hint)) },
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
contentDescription = null
)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = if (requireConfirmation) ImeAction.Next else ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = if (!requireConfirmation) {
{ validateAndConfirm(password, null, onConfirm) { errorMessage = it } }
} else null
),
singleLine = true,
isError = errorMessage != null,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
// Confirm password field (only for encryption, not decryption)
if (requireConfirmation) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = confirmPassword,
onValueChange = {
confirmPassword = it
errorMessage = null
},
label = { Text(stringResource(R.string.backup_encryption_confirm)) },
placeholder = { Text(stringResource(R.string.backup_encryption_confirm_hint)) },
visualTransformation = if (confirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) {
Icon(
imageVector = if (confirmPasswordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
contentDescription = null
)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { validateAndConfirm(password, confirmPassword, onConfirm) { errorMessage = it } }
),
singleLine = true,
isError = errorMessage != null,
modifier = Modifier.fillMaxWidth()
)
}
// Error message
if (errorMessage != null) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = errorMessage!!,
color = androidx.compose.material3.MaterialTheme.colorScheme.error,
style = androidx.compose.material3.MaterialTheme.typography.bodySmall
)
}
}
},
confirmButton = {
TextButton(
onClick = {
validateAndConfirm(
password,
if (requireConfirmation) confirmPassword else null,
onConfirm
) { errorMessage = it }
}
) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
}
)
}
/**
* Validate password and call onConfirm if valid
*/
private fun validateAndConfirm(
password: String,
confirmPassword: String?,
onConfirm: (String) -> Unit,
onError: (String) -> Unit
) {
when {
password.length < MIN_PASSWORD_LENGTH -> {
onError("Password too short (min. $MIN_PASSWORD_LENGTH characters)")
}
confirmPassword != null && password != confirmPassword -> {
onError("Passwords don't match")
}
else -> {
onConfirm(password)
}
}
}

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,197 @@
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.layout.size
import androidx.compose.foundation.layout.width
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Primary filled button for settings actions
* v1.5.0: Jetpack Compose Settings Redesign
* v1.8.0: Button keeps text during loading, just becomes disabled
*/
@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()
) {
Text(text)
}
}
/**
* Outlined secondary button for settings actions
* v1.8.0: Button keeps text during loading, just becomes disabled
*/
@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()
) {
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))
}
/**
* v1.8.0: Backup progress indicator shown above buttons
* Replaces the ugly in-button spinner with a clear status display
*/
@Composable
fun BackupProgressCard(
statusText: String,
modifier: Modifier = Modifier
) {
androidx.compose.material3.Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = statusText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.height(8.dp))
androidx.compose.material3.LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.primaryContainer
)
}
}
}

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,255 @@
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.History
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"
val changelogUrl = "https://github.com/inventory69/simple-notes-sync/blob/main/CHANGELOG.md" // v1.8.0
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)
}
)
// v1.8.0: Changelog
AboutLinkItem(
icon = Icons.Default.History,
title = stringResource(R.string.about_changelog_title),
subtitle = stringResource(R.string.about_changelog_subtitle),
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(changelogUrl))
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,387 @@
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.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.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.BackupPasswordDialog
import dev.dettmer.simplenotes.ui.settings.components.BackupProgressCard
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 dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.delay
// v1.8.0: Delay for dialog close animation before starting restore
private const val DIALOG_CLOSE_DELAY_MS = 200L
/**
* 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) }
// v1.8.0: Trigger for delayed restore execution (after dialog closes)
var triggerRestore by remember { mutableStateOf(0) }
var pendingRestoreAction by remember { mutableStateOf<(() -> Unit)?>(null) }
// 🔐 v1.7.0: Encryption state
var encryptBackup by remember { mutableStateOf(false) }
var showEncryptionPasswordDialog by remember { mutableStateOf(false) }
var showDecryptionPasswordDialog by remember { mutableStateOf(false) }
var pendingBackupUri by remember { mutableStateOf<Uri?>(null) }
// File picker launchers
val createBackupLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json")
) { uri ->
uri?.let {
// 🔐 v1.7.0: If encryption enabled, show password dialog first
if (encryptBackup) {
pendingBackupUri = it
showEncryptionPasswordDialog = true
} else {
viewModel.createBackup(it, password = null)
}
}
}
val restoreFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let {
pendingRestoreUri = it
restoreSource = RestoreSource.LocalFile
showRestoreDialog = true
}
}
// v1.8.0: Delayed restore execution after dialog closes
LaunchedEffect(triggerRestore) {
if (triggerRestore > 0) {
delay(DIALOG_CLOSE_DELAY_MS) // Wait for dialog close animation
pendingRestoreAction?.invoke()
pendingRestoreAction = null
}
}
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)
)
// v1.8.0: Progress indicator (visible during backup/restore)
if (isBackupInProgress) {
val backupStatus by viewModel.backupStatusText.collectAsState()
BackupProgressCard(
statusText = backupStatus.ifEmpty {
stringResource(R.string.backup_progress_creating)
}
)
}
Spacer(modifier = Modifier.height(16.dp))
// Local Backup Section
SettingsSectionHeader(text = stringResource(R.string.backup_local_section))
Spacer(modifier = Modifier.height(8.dp))
// 🔐 v1.7.0: Encryption toggle
SettingsSwitch(
title = stringResource(R.string.backup_encryption_title),
subtitle = stringResource(R.string.backup_encryption_subtitle),
checked = encryptBackup,
onCheckedChange = { encryptBackup = it }
)
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))
}
}
// 🔐 v1.7.0: Encryption password dialog (for backup creation)
if (showEncryptionPasswordDialog) {
BackupPasswordDialog(
title = stringResource(R.string.backup_encryption_title),
onDismiss = {
showEncryptionPasswordDialog = false
pendingBackupUri = null
},
onConfirm = { password ->
showEncryptionPasswordDialog = false
pendingBackupUri?.let { uri ->
viewModel.createBackup(uri, password)
}
pendingBackupUri = null
},
requireConfirmation = true
)
}
// 🔐 v1.7.0: Decryption password dialog (for restore)
if (showDecryptionPasswordDialog) {
BackupPasswordDialog(
title = stringResource(R.string.backup_decryption_required),
onDismiss = {
showDecryptionPasswordDialog = false
pendingRestoreUri = null
},
onConfirm = { password ->
showDecryptionPasswordDialog = false
pendingRestoreUri?.let { uri ->
when (restoreSource) {
RestoreSource.LocalFile -> viewModel.restoreFromFile(uri, selectedRestoreMode, password)
RestoreSource.Server -> { /* Server restore doesn't support encryption */ }
}
}
pendingRestoreUri = null
},
requireConfirmation = false
)
}
// Restore Mode Dialog
if (showRestoreDialog) {
RestoreModeDialog(
source = restoreSource,
selectedMode = selectedRestoreMode,
onModeSelected = { selectedRestoreMode = it },
onConfirm = {
showRestoreDialog = false
when (restoreSource) {
RestoreSource.LocalFile -> {
pendingRestoreUri?.let { uri ->
// v1.8.0: Schedule restore with delay for dialog close
pendingRestoreAction = {
// 🔐 v1.7.0: Check if backup is encrypted
viewModel.checkBackupEncryption(
uri = uri,
onEncrypted = {
showDecryptionPasswordDialog = true
},
onPlaintext = {
viewModel.restoreFromFile(uri, selectedRestoreMode, password = null)
pendingRestoreUri = null
}
)
}
triggerRestore++
}
}
RestoreSource.Server -> {
// v1.8.0: Schedule restore with delay for dialog close
pendingRestoreAction = {
viewModel.restoreFromServer(selectedRestoreMode)
}
triggerRestore++
}
}
},
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,175 @@
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
val logsSubject = stringResource(R.string.debug_logs_subject)
val logsShareVia = stringResource(R.string.debug_logs_share_via)
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, logsSubject)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(shareIntent, logsShareVia))
}
},
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))
SettingsDivider()
// v1.8.0: Test Mode Section
SettingsSectionHeader(text = stringResource(R.string.debug_test_section))
Spacer(modifier = Modifier.height(8.dp))
// Info about test mode
SettingsInfoCard(
text = stringResource(R.string.debug_reset_changelog_desc)
)
val changelogResetToast = stringResource(R.string.debug_changelog_reset)
SettingsButton(
text = stringResource(R.string.debug_reset_changelog),
onClick = {
viewModel.resetChangelogVersion()
android.widget.Toast.makeText(context, changelogResetToast, android.widget.Toast.LENGTH_SHORT).show()
},
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,74 @@
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.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.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
/**
* 🎨 v1.7.0: Display Settings Screen
*
* Allows switching between List and Grid view modes.
*/
@Composable
fun DisplaySettingsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit
) {
val displayMode by viewModel.displayMode.collectAsState()
SettingsScaffold(
title = stringResource(R.string.display_settings_title),
onBack = onBack
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
SettingsSectionHeader(text = stringResource(R.string.display_mode_title))
SettingsRadioGroup(
options = listOf(
RadioOption(
value = "list",
title = stringResource(R.string.display_mode_list),
subtitle = null
),
RadioOption(
value = "grid",
title = stringResource(R.string.display_mode_grid),
subtitle = null
)
),
selectedValue = displayMode,
onValueSelected = { viewModel.setDisplayMode(it) }
)
SettingsInfoCard(
text = stringResource(R.string.display_mode_info)
)
Spacer(modifier = Modifier.height(16.dp))
}
}
}

View File

@@ -0,0 +1,116 @@
package dev.dettmer.simplenotes.ui.settings.screens
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.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
) {
// 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)
}
}
)
}
}
}
/**
* Set app language using AppCompatDelegate
* v1.8.0: Smooth language change without activity recreate
*
* ComposeSettingsActivity handles locale changes via android:configChanges="locale"
* in AndroidManifest.xml, preventing full activity recreate and eliminating flicker.
* Compose automatically recomposes when the configuration changes.
*/
private fun setAppLanguage(languageCode: String) {
val localeList = if (languageCode.isEmpty()) {
LocaleListCompat.getEmptyLocaleList()
} else {
LocaleListCompat.forLanguageTags(languageCode)
}
// Sets the app locale - triggers onConfigurationChanged() instead of recreate()
AppCompatDelegate.setApplicationLocales(localeList)
}

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,340 @@
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.DisposableEffect
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
* v1.7.0 Hotfix: Save settings on screen exit (not on every keystroke)
*/
@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) }
// 🔧 v1.7.0 Hotfix: Save server settings when leaving this screen
// This prevents false "server changed" detection during text input
DisposableEffect(Unit) {
onDispose {
viewModel.saveServerSettingsManually()
}
}
// 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,237 @@
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.GridView
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) }
)
}
// 🎨 v1.7.0: Display Settings
item {
val displayMode by viewModel.displayMode.collectAsState()
val displaySubtitle = when (displayMode) {
"grid" -> stringResource(R.string.display_mode_grid)
else -> stringResource(R.string.display_mode_list)
}
SettingsCard(
icon = Icons.Default.GridView,
title = stringResource(R.string.display_settings_title),
subtitle = displaySubtitle,
onClick = { onNavigate(SettingsRoute.Display) }
)
}
// 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,257 @@
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 — Restructured for v1.8.0
*
* Two clear sections:
* 1. Sync Triggers (all 5 triggers grouped logically)
* 2. Network & Performance (WiFi-only + Parallel Downloads)
*/
@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()
val maxParallelDownloads by viewModel.maxParallelDownloads.collectAsState()
val wifiOnlySync by viewModel.wifiOnlySync.collectAsState()
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))
// ── Offline Mode Warning ──
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))
}
// ═══════════════════════════════════════════════════════════════
// SECTION 1: SYNC TRIGGERS
// ═══════════════════════════════════════════════════════════════
SettingsSectionHeader(text = stringResource(R.string.sync_section_triggers))
// ── Sofort-Sync ──
SettingsSectionHeader(text = stringResource(R.string.sync_section_instant))
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
)
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
)
Spacer(modifier = Modifier.height(4.dp))
// ── Hintergrund-Sync ──
SettingsSectionHeader(text = stringResource(R.string.sync_section_background))
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
)
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
)
// Interval-Auswahl (nur sichtbar wenn Periodic aktiv)
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))
}
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
)
Spacer(modifier = Modifier.height(8.dp))
// ── Info Card ──
val manualHintText = if (isServerConfigured) {
stringResource(R.string.sync_manual_hint)
} else {
stringResource(R.string.sync_manual_hint_disabled)
}
SettingsInfoCard(
text = manualHintText
)
SettingsDivider()
// ═══════════════════════════════════════════════════════════════
// SECTION 2: NETZWERK & PERFORMANCE
// ═══════════════════════════════════════════════════════════════
SettingsSectionHeader(text = stringResource(R.string.sync_section_network_performance))
// WiFi-Only Toggle
SettingsSwitch(
title = stringResource(R.string.sync_wifi_only_title),
subtitle = stringResource(R.string.sync_wifi_only_subtitle),
checked = wifiOnlySync,
onCheckedChange = { viewModel.setWifiOnlySync(it) },
icon = Icons.Default.Wifi,
enabled = isServerConfigured
)
if (wifiOnlySync && isServerConfigured) {
SettingsInfoCard(
text = stringResource(R.string.sync_wifi_only_hint)
)
}
Spacer(modifier = Modifier.height(8.dp))
// Parallel Downloads
val parallelOptions = listOf(
RadioOption(
value = 1,
title = "1 ${stringResource(R.string.sync_parallel_downloads_unit)}",
subtitle = stringResource(R.string.sync_parallel_downloads_desc_1)
),
RadioOption(
value = 3,
title = "3 ${stringResource(R.string.sync_parallel_downloads_unit)}",
subtitle = stringResource(R.string.sync_parallel_downloads_desc_3)
),
RadioOption(
value = 5,
title = "5 ${stringResource(R.string.sync_parallel_downloads_unit)}",
subtitle = stringResource(R.string.sync_parallel_downloads_desc_5)
),
RadioOption(
value = 7,
title = "7 ${stringResource(R.string.sync_parallel_downloads_unit)}",
subtitle = stringResource(R.string.sync_parallel_downloads_desc_7)
),
RadioOption(
value = 10,
title = "10 ${stringResource(R.string.sync_parallel_downloads_unit)}",
subtitle = stringResource(R.string.sync_parallel_downloads_desc_10)
)
)
SettingsRadioGroup(
title = stringResource(R.string.sync_parallel_downloads_title),
options = parallelOptions,
selectedValue = maxParallelDownloads,
onValueSelected = { viewModel.setMaxParallelDownloads(it) }
)
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

@@ -29,6 +29,31 @@ object Constants {
// 🔥 v1.3.1: Debug & Logging
const val KEY_FILE_LOGGING_ENABLED = "file_logging_enabled"
// 🔥 v1.6.0: Offline Mode Toggle
const val KEY_OFFLINE_MODE = "offline_mode_enabled"
// 🔥 v1.7.0: WiFi-Only Sync Toggle
const val KEY_WIFI_ONLY_SYNC = "wifi_only_sync_enabled"
const val DEFAULT_WIFI_ONLY_SYNC = false // Standardmäßig auch mobil syncen
// 🔥 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
const val SYNC_WORK_TAG = "notes_sync"
const val SYNC_DELAY_SECONDS = 5L
@@ -36,4 +61,32 @@ object Constants {
// Notifications
const val NOTIFICATION_CHANNEL_ID = "notes_sync_channel"
const val NOTIFICATION_ID = 1001
// 🎨 v1.7.0: Staggered Grid Layout
const val KEY_DISPLAY_MODE = "display_mode" // "list" or "grid"
const val DEFAULT_DISPLAY_MODE = "grid" // v1.8.0: Grid als Standard-Ansicht
const val GRID_COLUMNS = 2
const val GRID_SPACING_DP = 8
// ⚡ v1.8.0: Parallel Downloads
const val KEY_MAX_PARALLEL_DOWNLOADS = "max_parallel_downloads"
const val DEFAULT_MAX_PARALLEL_DOWNLOADS = 5
const val MIN_PARALLEL_DOWNLOADS = 1
const val MAX_PARALLEL_DOWNLOADS = 10
// 🔀 v1.8.0: Sortierung
const val KEY_SORT_OPTION = "sort_option"
const val KEY_SORT_DIRECTION = "sort_direction"
const val DEFAULT_SORT_OPTION = "updatedAt"
const val DEFAULT_SORT_DIRECTION = "desc"
// 📋 v1.8.0: Post-Update Changelog
const val KEY_LAST_SHOWN_CHANGELOG_VERSION = "last_shown_changelog_version"
// 🆕 v1.8.1 (IMPL_08): Globaler Sync-Cooldown (über alle Trigger hinweg)
const val KEY_LAST_GLOBAL_SYNC_TIME = "last_global_sync_timestamp"
const val MIN_GLOBAL_SYNC_INTERVAL_MS = 30_000L // 30 Sekunden
// 🆕 v1.8.1 (IMPL_08B): onSave-Sync Worker-Tag (bypassed globalen Cooldown)
const val SYNC_ONSAVE_TAG = "onsave"
}

View File

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

View File

@@ -2,6 +2,7 @@ package dev.dettmer.simplenotes.utils
import android.app.NotificationChannel
import android.app.NotificationManager
import dev.dettmer.simplenotes.R
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
@@ -10,16 +11,15 @@ import android.os.Handler
import android.os.Looper
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import dev.dettmer.simplenotes.MainActivity
import dev.dettmer.simplenotes.ui.main.ComposeMainActivity
object NotificationHelper {
private const val TAG = "NotificationHelper"
private const val CHANNEL_ID = "notes_sync_channel"
private const val CHANNEL_NAME = "Notizen Synchronisierung"
private const val CHANNEL_DESCRIPTION = "Benachrichtigungen über Sync-Status"
private const val NOTIFICATION_ID = 1001
private const val SYNC_NOTIFICATION_ID = 2
const val SYNC_PROGRESS_NOTIFICATION_ID = 1003 // v1.7.2: For expedited work foreground notification
private const val AUTO_CANCEL_TIMEOUT_MS = 30_000L
/**
@@ -29,9 +29,11 @@ object NotificationHelper {
fun createNotificationChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channelName = context.getString(R.string.notification_channel_name)
val channelDescription = context.getString(R.string.notification_channel_desc)
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, importance).apply {
description = CHANNEL_DESCRIPTION
val channel = NotificationChannel(CHANNEL_ID, channelName, importance).apply {
description = channelDescription
enableVibration(true)
enableLights(true)
}
@@ -53,11 +55,31 @@ object NotificationHelper {
Logger.d(TAG, "🗑️ Cleared old sync notifications")
}
/**
* 🔧 v1.7.2: Erstellt Notification für Sync-Progress (Expedited Work)
*
* Wird von SyncWorker.getForegroundInfo() aufgerufen auf Android 9-11.
* Muss eine gültige, sichtbare Notification zurückgeben.
*
* @return Notification (nicht anzeigen, nur erstellen)
*/
fun createSyncProgressNotification(context: Context): android.app.Notification {
return NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle(context.getString(R.string.sync_in_progress))
.setContentText(context.getString(R.string.sync_in_progress_text))
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setProgress(0, 0, true) // Indeterminate progress
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.build()
}
/**
* Zeigt Erfolgs-Notification nach Sync
*/
fun showSyncSuccessNotification(context: Context, syncedCount: Int) {
val intent = Intent(context, MainActivity::class.java).apply {
val intent = Intent(context, ComposeMainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
@@ -68,8 +90,8 @@ object NotificationHelper {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_menu_upload)
.setContentTitle("Sync erfolgreich")
.setContentText("$syncedCount Notiz(en) synchronisiert")
.setContentTitle(context.getString(R.string.notification_sync_success_title))
.setContentText(context.getString(R.string.notification_sync_success_message, syncedCount))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
@@ -96,7 +118,7 @@ object NotificationHelper {
fun showSyncFailureNotification(context: Context, errorMessage: String) {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_alert)
.setContentTitle("Sync fehlgeschlagen")
.setContentTitle(context.getString(R.string.notification_sync_failed_title))
.setContentText(errorMessage)
.setStyle(NotificationCompat.BigTextStyle()
.bigText(errorMessage))
@@ -125,8 +147,8 @@ object NotificationHelper {
fun showSyncProgressNotification(context: Context): Int {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_popup_sync)
.setContentTitle("Synchronisiere...")
.setContentText("Notizen werden synchronisiert")
.setContentTitle(context.getString(R.string.notification_sync_progress_title))
.setContentText(context.getString(R.string.notification_sync_progress_message))
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setProgress(0, 0, true)
@@ -153,7 +175,7 @@ object NotificationHelper {
* Zeigt Notification bei erkanntem Konflikt
*/
fun showConflictNotification(context: Context, conflictCount: Int) {
val intent = Intent(context, MainActivity::class.java)
val intent = Intent(context, ComposeMainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
@@ -161,8 +183,8 @@ object NotificationHelper {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Sync-Konflikt erkannt")
.setContentText("$conflictCount Notiz(en) haben Konflikte")
.setContentTitle(context.getString(R.string.notification_sync_conflict_title))
.setContentText(context.getString(R.string.notification_sync_conflict_message, conflictCount))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
@@ -212,8 +234,8 @@ object NotificationHelper {
fun showSyncInProgress(context: Context) {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle("Synchronisierung läuft")
.setContentText("Notizen werden synchronisiert...")
.setContentTitle(context.getString(R.string.notification_sync_in_progress_title))
.setContentText(context.getString(R.string.notification_sync_in_progress_message))
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.build()
@@ -228,7 +250,7 @@ object NotificationHelper {
*/
fun showSyncSuccess(context: Context, count: Int) {
// PendingIntent für App-Öffnung
val intent = Intent(context, MainActivity::class.java).apply {
val intent = Intent(context, ComposeMainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
@@ -240,8 +262,8 @@ object NotificationHelper {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle("Sync erfolgreich")
.setContentText("$count Notizen synchronisiert")
.setContentTitle(context.getString(R.string.notification_sync_success_title))
.setContentText(context.getString(R.string.notification_sync_success_message, count))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setContentIntent(pendingIntent) // Click öffnet App
@@ -259,7 +281,7 @@ object NotificationHelper {
*/
fun showSyncError(context: Context, message: String) {
// PendingIntent für App-Öffnung
val intent = Intent(context, MainActivity::class.java).apply {
val intent = Intent(context, ComposeMainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
@@ -271,7 +293,7 @@ object NotificationHelper {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentTitle("Sync Fehler")
.setContentTitle(context.getString(R.string.notification_sync_error_title))
.setContentText(message)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_ERROR)
@@ -296,7 +318,7 @@ object NotificationHelper {
*/
fun showSyncWarning(context: Context, hoursSinceLastSync: Long) {
// PendingIntent für App-Öffnung
val intent = Intent(context, MainActivity::class.java).apply {
val intent = Intent(context, ComposeMainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
@@ -308,11 +330,10 @@ object NotificationHelper {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentTitle("⚠️ Sync-Warnung")
.setContentText("Server seit ${hoursSinceLastSync}h nicht erreichbar")
.setContentTitle(context.getString(R.string.notification_sync_warning_title))
.setContentText(context.getString(R.string.notification_sync_warning_message, hoursSinceLastSync.toInt()))
.setStyle(NotificationCompat.BigTextStyle()
.bigText("Der WebDAV-Server ist seit ${hoursSinceLastSync} Stunden nicht erreichbar. " +
"Bitte prüfe deine Netzwerkverbindung oder Server-Einstellungen."))
.bigText(context.getString(R.string.notification_sync_warning_detail, hoursSinceLastSync.toInt())))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setContentIntent(pendingIntent)

View File

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

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