137 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
inventory69
dee85233b6 fix: Remove dynamic build date for Reproducible Builds
Fixes #7 - Thanks @IzzySoft for reporting and investigating!

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

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

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

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

Code Quality:
- Fixed 7 Detekt warnings (SwallowedException, MaxLineLength, MagicNumber)
2026-01-10 23:37:22 +01:00
inventory69
2324743f43 Update IzzyOnDroid metadata to v1.3.2 [skip ci] 2026-01-10 08:26:47 +01:00
inventory69
0e96757fab Merge feature/v1.3.2-lint-cleanup into main 2026-01-10 00:57:46 +01:00
inventory69
547c0a1011 v1.3.2: Lint Cleanup & Code Quality
- Complete lint cleanup (Phase 1-7)
- Replace magic numbers with constants
- Remove unused imports/members
- Add Logger.w() for swallowed exceptions
- Custom SyncException for better error handling
- ConstructorParameterNaming with @SerializedName
- ReturnCount & Destructuring with @Suppress
- F-Droid: Add privacy notice for file logging
- Update docs (FEATURES.md, README.md)
- Add fastlane changelogs for versionCode 10
2026-01-10 00:57:28 +01:00
inventory69
b79c0d25e6 [skip ci] fix: simplify workflow for single universal APK per flavor
- Remove APK splits logic from workflow
- Build only universal APKs for both standard and fdroid flavors
- Simplifies release process and fixes F-Droid compatibility
2026-01-09 13:38:43 +01:00
inventory69
cf1142afa2 [skip ci] fix: disable APK splits for F-Droid build
- F-Droid expects single universal APK, not multiple architecture-specific APKs
- Remove splits.abi configuration to fix build error in F-Droid CI
- Pipeline failed with: 'More than one resulting apks found' (armeabi-v7a, arm64-v8a, universal)
- Resolves F-Droid MR #31695 build failure
2026-01-09 13:19:47 +01:00
inventory69
359325bf64 [skip ci] chore: update author information in metadata 2026-01-09 11:33:18 +01:00
inventory69
c7d0f899e7 [skip ci] feat: new app icon with monochrome support & updated descriptions
🎨 New App Icon:
- Fresh adaptive icon design with warm background (#f9e9c8)
- Monochrome icon support for Android 13+ themed icons
- PNG format replacing WebP for better compatibility
- All densities: mdpi, hdpi, xhdpi, xxhdpi, xxxhdpi

📝 Updated Descriptions (EN/DE):
- Added Multi-Device Sync feature
- Added Markdown export for Obsidian/desktop editors
- Added deletion tracking (zombie notes prevention)
- Added E-Tag caching (20x faster checks)
- Added optimized performance (~2-3s sync time)
- Added live sync status indicator
- Added Server-Restore modes (Merge/Replace/Overwrite)

📦 F-Droid Metadata:
- Updated build entries for v1.2.1, v1.2.2, v1.3.0, v1.3.1
- CurrentVersion now 1.3.1 (versionCode 9)
- Prepared for F-Droid merge request submission
2026-01-09 10:55:39 +01:00
inventory69
5121a7b2b8 Fix F-Droid changelogs: Remove emojis and bullet points [skip ci] 2026-01-08 23:18:29 +01:00
inventory69
04664c8920 v1.3.1 - Multi-Device Sync Fix + Performance + Restore Bug Fix
🔧 Fixed:
- Multi-device JSON sync now works (thanks Thomas!)
- Restore from Server skipped files (timestamp bug)
- No duplicate downloads
- First MD sync after export now fast

 Performance:
- JSON sync: 12-14s → 2-3s
- Hybrid timestamp + E-Tag optimization
- Matches Markdown sync speed

 New:
- Sync status UI in MainActivity
- Content-based MD import
- Debug logging improvements
- SyncStateManager for sync coordination

🔧 Technical:
- Clear lastSyncTimestamp on restore
- Clear E-Tag caches on restore
- E-Tag refresh after upload
- Fixed timestamp update after MD export
2026-01-08 23:09:59 +01:00
Inventory69
2a56dd8128 Merge pull request #4 from inventory69/release/v1.3.0
Release v1.3.0: Multi-Device Sync
2026-01-07 12:28:31 +01:00
inventory69
63af7d30dc Release v1.3.0: Multi-Device Sync with Deletion Tracking
New Features:
- Multi-Device Sync with deletion tracking (prevents zombie notes)
- Server deletion via swipe gesture with confirmation dialog
- E-Tag performance optimization (~150ms vs 3s for no-change syncs)
- Markdown Auto-Sync toggle (unified Export + Auto-Import)
- Manual Markdown sync button for performance control
- Server-Restore modes (Merge/Replace/Overwrite)

Technical Implementation:
- DeletionTracker model with JSON persistence
- Intelligent server checks with E-Tag caching
- Deletion-aware download logic
- Two-stage swipe deletion with Material Design dialog
- Automatic Markdown import during sync
- YAML frontmatter scanning for robust file deletion

Thanks to Thomas from Bielefeld for reporting the multi-device sync issue!

Compatible with: v1.2.0-v1.3.0
2026-01-07 12:27:27 +01:00
inventory69
62423f5a5b Release v1.2.2: Backward compatibility for v1.2.0 users
- Added dual-mode download for server restore
- Scans both /notes/ (new) and Root (old v1.2.0) folders
- Normal sync only uses /notes/ for performance
- Fixed URL construction bugs
- Updated F-Droid changelogs
2026-01-05 16:46:07 +01:00
inventory69
9eabc9a5f0 [skip ci] 📚 Docs: Reorganize + Web Editor to v1.3.0
## 📁 Reorganization
- Moved all docs to docs/ folder (FEATURES, BACKUP, DESKTOP, DOCS)
- Updated all cross-references in README.md/en
- Fixed internal links in docs

## �� Corrections
- FEATURES.md: Fixed build variants - both are 100% FOSS (no Google Services)
- Clarified: App is completely FOSS with no proprietary libraries

##  Changes
- Web Editor moved from v1.6.0 to v1.3.0 (earlier implementation)
- Combined with organization features (tags, search, sorting)
2026-01-05 12:43:01 +01:00
inventory69
015b90d56e 🐛 v1.2.1: Markdown Initial Export Bugfix + URL Normalization + GitHub Workflow Fix
## 🐛 Fixed
- Initial Markdown export: Existing notes now exported when Desktop Integration activated
- Markdown directory structure: Files now land correctly in /notes-md/
- JSON URL normalization: Smart detection for both Root-URL and /notes-URL
- GitHub release notes: Fixed language order (DE primary, EN collapsible) and emoji

##  Improved
- Settings UI: Example URL shows /notes instead of /webdav
- Server config: Enter only base URL (app adds /notes/ and /notes-md/ automatically)
- Flexible URL input: Both http://server/ and http://server/notes/ work
- Changelogs: Shortened for F-Droid 500 char limit

## 🔧 Technical
- getNotesUrl() helper with smart /notes/ detection
- getMarkdownUrl() simplified to use getNotesUrl()
- All JSON operations updated to use normalized URLs
- exportAllNotesToMarkdown() with progress callback
- Workflow: Swapped CHANGELOG_DE/EN, replaced broken emoji with 🌍

versionCode: 6
versionName: 1.2.1
2026-01-05 11:46:25 +01:00
inventory69
6d135e8f0d fix: Use unique delimiter GHADELIMITER for multiline env vars 2026-01-04 08:28:31 +01:00
inventory69
5d82431bb6 fix: Remove emojis from F-Droid changelogs and fix EOF delimiter
- Removed emojis (🆕 📚) from F-Droid changelogs (better compatibility)
- Changed EOF to CHANGELOG_EOF in workflow (prevents delimiter conflicts)
2026-01-04 02:07:52 +01:00
inventory69
6bb87816f3 Release v1.2.0 - Local Backup & Markdown Desktop Integration
 New Features:
- Local backup/restore system with 3 modes (Merge/Replace/Overwrite)
- Markdown export for desktop access via WebDAV mount
- Dual-format architecture (JSON master + Markdown mirror)
- Settings UI extended with backup & desktop integration sections

📝 Changes:
- Server restore now asks for mode selection (user safety)
- WebDAV mount instructions for Windows/Mac/Linux in README
- Complete CHANGELOG.md with all version history

🔧 Technical:
- BackupManager.kt for complete backup/restore logic
- Note.toMarkdown/fromMarkdown with YAML frontmatter
- ISO8601 timestamps for desktop compatibility
- Last-Write-Wins conflict resolution

📚 Documentation:
- CHANGELOG.md (Keep a Changelog format)
- README updates (removed Joplin/Obsidian, added WebDAV-mount)
- F-Droid changelogs (DE+EN, under 500 chars)
- SYNC_ARCHITECTURE.md in project-docs
- MARKDOWN_DESKTOP_REALITY_CHECK.md strategic plan
- WEB_EDITOR_PLAN_v1.3.0.md for future web editor feature
2026-01-04 01:57:31 +01:00
inventory69
4802c3d979 Update changelog paths, enhance README features, and replace screenshots for v1.1.2 [skip ci] 2025-12-29 10:39:46 +01:00
Inventory69
85625b4f67 Merge release v1.1.2: UX improvements, HTTP restriction & stability fixes
Release v1.1.2: UX-Verbesserungen, HTTP-Restriktion & Stabilitätsfixes
2025-12-29 09:26:10 +01:00
inventory69
609da827c5 Refactor PR build check workflow for improved readability and structure [skip ci] 2025-12-29 09:22:55 +01:00
inventory69
539f17cdda Release v1.1.2: Improve UX, restrict HTTP to local networks, fix sync stability 2025-12-29 09:13:27 +01:00
inventory69
0bd686008d Add custom notepad icon and improve F-Droid metadata [skip ci]
- Replace default Android icon with custom notepad design
- Use PNG-based adaptive icons (mipmap) instead of vector drawables for better launcher compatibility
- Add ic_launcher_background.png (light blue #90CAF9) for all densities
- Add ic_launcher_foreground.png (transparent notepad design) for all densities
- Update legacy WebP icons (mdpi/hdpi/xhdpi/xxhdpi/xxxhdpi) with new design
- Update Fastlane metadata icons (de-DE + en-US) with 512x512 PNG
- Improve F-Droid NonFreeNet AntiFeature documentation:
  * Clarify HTTP restricted to local networks only (RFC 1918 private IPs, localhost, .local domains)
  * Document upcoming v1.1.2 security restrictions
  * Emphasize HTTPS support and recommendation

Icon Design:
- White notepad paper with gray border
- Red header line (like real notepads)
- Three blue text bars (representing notes)
- Orange pencil with white tip in bottom-right corner
- Light blue background for adaptive icon

Technical Changes:
- Delete drawable/ic_launcher_background.xml (vector drawables)
- Delete drawable/ic_launcher_foreground.xml (vector drawables)
- Update mipmap-anydpi-v26/ic_launcher.xml: @drawable -> @mipmap
- Update mipmap-anydpi-v26/ic_launcher_round.xml: @drawable -> @mipmap
- Remove monochrome tag (not needed for this design)

Addresses IzzyOnDroid Issue #2 feedback
2025-12-27 20:11:37 +01:00
inventory69
65ce3746ca Fix IzzyOnDroid feedback (Issue #2) [skip ci]
1. Add en-US icon and screenshots as fallback for all languages
   - Convert app icon from WebP to PNG (512x512)
   - Copy phoneScreenshots from de-DE to en-US
   - Ensures non-German users see icon and screenshots

2. Disable Google DEPENDENCY_INFO_BLOCK
   - Add dependenciesInfo { includeInApk = false }
   - Removes encrypted Google blob from APK
   - Improves privacy and F-Droid compatibility

Fixes #2
2025-12-27 08:52:38 +01:00
inventory69
6079df3b1e Fix IzzyOnDroid feedback (Issue #2) [skip ci]
1. Add en-US screenshots as fallback for all languages
   - Copy phoneScreenshots from de-DE to en-US
   - Ensures non-German users see screenshots

2. Disable Google DEPENDENCY_INFO_BLOCK
   - Add dependenciesInfo { includeInApk = false }
   - Removes encrypted Google blob from APK
   - Improves privacy and F-Droid compatibility

Fixes #2
2025-12-27 08:12:57 +01:00
inventory69
5f0dc8a981 Fix image paths in EN README screenshots section [skip ci] 2025-12-26 21:39:51 +01:00
inventory69
d79a44491d Make feature list more compact and minimalist [skip ci]
- Remove bold formatting for cleaner look
- Shorten descriptions to essentials
- Keep 5 main categories
- More scannable and minimalist style
2025-12-26 21:34:55 +01:00
Inventory69
4a04b21975 Aktualisieren von README.md [skip ci] 2025-12-26 20:16:56 +01:00
inventory69
881162737b Shorten changelogs to meet F-Droid 500 char limit [skip ci]
- DE: 870 → 455 characters
- EN: 809 → 438 characters

Addresses F-Droid bot feedback in RFP #3458
2025-12-26 19:33:15 +01:00
inventory69
1f78953959 Fix F-Droid bot feedback issues [skip ci]
- Move fastlane metadata to repository root (was in android/fastlane)
- Add distributionSha256Sum to gradle-wrapper.properties for security
- Update Gradle Wrapper JAR to match version 8.13
- Document NonFreeNet anti-feature (HTTP support for local WebDAV servers)

Addresses F-Droid RFP issue #3458 bot feedback
2025-12-26 18:49:31 +01:00
inventory69
3092fcc6d3 Add screenshots and update README for v1.1.1 [skip ci]
- Add 3 app screenshots (phoneScreenshots)
- Update README.md with screenshot gallery
- Update README.en.md with screenshot gallery
- Update version reference to v1.1.1 in both READMEs
2025-12-26 18:10:54 +01:00
inventory69
60d6b1effc 📦 Add F-Droid metadata for v1.1.1 release [skip ci] 2025-12-26 15:36:27 +01:00
inventory69
9b6bf04954 🐛 Release v1.1.1 - Critical Bugfixes
 Server-Erreichbarkeits-Check vor jedem Sync
- Socket-Check mit 2s Timeout (DHCP/Routing-Init abwarten)
- Verhindert Fehler-Notifications in fremden WiFi-Netzen
- Verhindert Fehler bei Netzwerk-Initialisierung (WiFi-Connect)
- Stiller Abbruch wenn Server nicht erreichbar
- 80% schnellerer Abbruch: 2s statt 10+ Sekunden

🔧 Notification-Verbesserungen
- Alte Notifications werden beim App-Start gelöscht
- Fehler-Notifications verschwinden automatisch nach 30s
- Bessere Batterie-Effizienz

📱 UI-Bugfixes
- Sync-Icon nur anzeigen wenn Sync konfiguriert ist
- Swipe-to-Delete: Kein Flackern mehr bei schnellem Löschen
- Scroll-to-Top nach Note Save (ListAdapter async fix)

📡 Sync-Architektur Dokumentation
- SYNC_ARCHITECTURE.md mit allen 4 Sync-Triggern
- DOCS.md + DOCS.en.md aktualisiert
- GitHub Actions: F-Droid Changelogs statt Commit-Messages

🎯 Testing: BUGFIX_SPURIOUS_SYNC_ERROR_NOTIFICATIONS.md
📦 Version: 1.1.1 (versionCode=3)
2025-12-26 12:18:51 +01:00
inventory69
7644f5bf76 📝 Add CONTRIBUTING.md with PR workflow documentation
- Comprehensive contributor guide (bilingual DE/EN)
- Explains automated PR build checks
- Local build & test instructions
- Code style guidelines
- PR checklist
- What contributions are welcome
- Linked in README.md and README.en.md

[skip ci]
2025-12-24 00:12:11 +01:00
inventory69
300dc67a7c 🔧 Add PR build check workflow
- Builds debug APKs for pull requests (no signing required)
- Runs unit tests
- Uploads APKs as artifacts (30 days retention)
- Posts build status comment to PR
- No production releases for PRs (only on main merge)
- Gradle cache for faster builds

[skip ci]
2025-12-24 00:06:27 +01:00
inventory69
c42a9c84d7 Improve issue templates with structured forms
- Add proper GitHub Form templates with dropdowns and checkboxes
- Bug report: Android version, app version, sync/battery optimization status
- Feature request: Platform selection, priority, willingness to contribute
- Question: Documentation checklist, topic selection, context fields
- All forms bilingual (DE/EN) with app-specific questions

[skip ci]
2025-12-24 00:00:43 +01:00
inventory69
7942b73af3 fix: Enable blank issues in issue templates [skip ci] 2025-12-23 23:57:01 +01:00
inventory69
b4d868434f 📝 Add GitHub Issue Templates (Bug/Feature/Question)
- Bug report template with system info and reproduction steps
- Feature request template with priority selection
- Question template with documentation checklist
- Config with links to docs and troubleshooting
- All templates bilingual (DE/EN)

[skip ci]
2025-12-23 23:45:54 +01:00
inventory69
ad5fd0a313 fix: Update documentation and add English versions for all guides [skip ci] 2025-12-23 22:47:34 +01:00
inventory69
80a46e0e49 Update documentation for Simple Notes Sync
- Revamped QUICKSTART.md for clearer installation and setup instructions, including detailed steps for server setup and app configuration.
- Revised README.md to reflect new features and streamlined installation process, emphasizing offline capabilities and auto-sync functionality.
- Removed outdated README.old.md to maintain a clean repository. [skip ci]
2025-12-23 22:36:41 +01:00
inventory69
1338da9dde 🔐 Add keystore management scripts and documentation [skip ci]
- Add create-keystore.fish: Generate new release keystore with auto-generated passwords
- Add verify-secrets.fish: Verify GitHub Secrets and local keystore setup
- Add build-release-local.fish: Build signed release APKs locally
- Add LOCAL_BUILDS.md: Documentation for local release builds
- Add key.properties.example: Template for signing configuration
- Update android/.gitignore: Protect sensitive keystore files
- Integrate GitHub CLI for automatic secret management
- All scripts support both manual and automated workflows
2025-12-23 18:13:12 +01:00
inventory69
0c2d069443 fix: Update repository URL in setup instructions [skip ci] 2025-12-23 17:43:53 +01:00
inventory69
70efc13ea4 fix: Workflow für F-Droid APKs + Emoji-Fixes + Korrekturen [skip ci]
- 📦 F-Droid Flavor APKs werden jetzt mit gebaut (6 statt 3 APKs)
- 🎉 README Emoji-Darstellungsfehler behoben
- 🇩🇪 Workflow-Kommentare auf Deutsch
-  Korrekte Beschreibung: HTTP/HTTPS wählbar (nicht nur HTTPS)
- 💡 Klarstellung: Standard + F-Droid sind identisch (100% FOSS)
2025-12-22 14:54:49 +01:00
inventory69
55401977e3 fix: Korrigiere GitHub Actions Workflow für Standard-Flavor und semantische Versionierung
- Verwende assembleStandardRelease statt assembleRelease
- Korrigiere APK-Pfade: app-standard-*-release.apk
- Verwende versionName/versionCode aus build.gradle.kts (1.1.0/2)
- Keine Überschreibung mit Datums-Versionierung mehr
- F-Droid kompatible semantische Versionierung (v1.1.0)
2025-12-22 01:03:04 +01:00
286 changed files with 31020 additions and 4471 deletions

154
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,154 @@
name: "🐛 Bug Report / Fehlerbericht"
description: Melde einen Fehler in der App / Report a bug in the app
title: "[BUG] "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Danke für deinen Bug Report! / Thanks for reporting a bug!
Bitte fülle alle relevanten Felder aus. / Please fill out all relevant fields.
- type: textarea
id: description
attributes:
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:
required: true
- type: dropdown
id: android-version
attributes:
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
- Android 11
- Android 10
- Android 9
- Android 8.1
- Android 8.0
- Andere / Other
validations:
required: true
- type: input
id: app-version
attributes:
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:
required: true
- type: input
id: device
attributes:
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:
required: false
- type: textarea
id: steps
attributes:
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
2. Gehe zu Einstellungen / Go to settings
3. Klicke auf ... / Click on ...
4. Fehler tritt auf / Bug occurs
validations:
required: true
- type: textarea
id: expected
attributes:
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:
required: true
- type: textarea
id: actual
attributes:
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:
required: true
- type: dropdown
id: sync-enabled
attributes:
label: Auto-Sync aktiviert? / Auto-Sync enabled?
options:
- "Ja / Yes"
- "Nein / No"
validations:
required: false
- type: dropdown
id: battery-optimization
attributes:
label: 🔋 Akku-Optimierung deaktiviert? / Battery optimization disabled?
description: Einstellungen → Apps → Simple Notes → Akku → Nicht optimieren / Settings → Apps → Simple Notes → Battery → Don't optimize
options:
- "Ja, deaktiviert / Yes, disabled"
- "Nein, noch optimiert / No, still optimized"
- "Weiß nicht / Don't know"
validations:
required: false
- type: textarea
id: server-config
attributes:
label: 🌐 Server-Konfiguration / Server Configuration
description: Falls relevant / If relevant (KEINE Passwörter! / NO passwords!)
placeholder: |
- Server läuft lokal / Server runs locally
- Docker auf Raspberry Pi / Docker on Raspberry Pi
- Gleiche WiFi / Same WiFi
- Server-IP: 192.168.x.x (erste 3 Zahlen reichen / first 3 numbers sufficient)
validations:
required: false
- type: textarea
id: logs
attributes:
label: 📋 Logs / Screenshots
description: |
Falls vorhanden: Screenshots oder LogCat Output / If available: Screenshots or LogCat output
LogCat Filter: `adb logcat -s SyncWorker NetworkMonitor WebDavSyncService`
placeholder: "Füge hier Logs oder Screenshots ein / Paste logs or screenshots here"
validations:
required: false
- type: textarea
id: additional
attributes:
label: 🔧 Zusätzliche Informationen / Additional Context
description: Gibt es noch etwas, das wir wissen sollten? / Is there anything else we should know?
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: ✅ Checklist
options:
- label: Ich habe die [Troubleshooting-Sektion](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md#troubleshooting) gelesen / I have read the troubleshooting section
required: false
- label: Ich habe "Verbindung testen" in den Einstellungen probiert / I have tried "Test connection" in settings
required: false

14
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
blank_issues_enabled: true
contact_links:
- 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"
url: https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md
about: Schritt-für-Schritt Anleitung / Step-by-step guide
- 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

76
.github/ISSUE_TEMPLATE/question.yml vendored Normal file
View File

@@ -0,0 +1,76 @@
name: "❓ Question / Frage"
description: Stelle eine Frage zur Nutzung / Ask a question about usage
title: "[QUESTION] "
labels: ["question"]
body:
- type: markdown
attributes:
value: |
Hast du eine Frage? Wir helfen gerne! / Have a question? We're happy to help!
- type: textarea
id: question
attributes:
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:
required: true
- type: checkboxes
id: documentation-checked
attributes:
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
required: false
- label: Ich habe den [Quick Start Guide](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md) gelesen / I have read the Quick Start Guide
required: false
- label: Ich habe das [Troubleshooting](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md#troubleshooting) durchgearbeitet / I have checked the troubleshooting section
required: false
- type: textarea
id: tried
attributes:
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:
required: false
- type: dropdown
id: topic
attributes:
label: "📌 Thema / Topic"
description: Um was geht es? / What is this about?
options:
- Server Setup / Server-Einrichtung
- App-Konfiguration / App configuration
- Sync-Probleme / Sync issues
- Netzwerk / Network
- Android-Einstellungen / Android settings
- Andere / Other
validations:
required: false
- type: textarea
id: context
attributes:
label: "💬 Kontext / Context"
description: Zusätzliche Informationen die hilfreich sein könnten / Additional information that might be helpful
placeholder: |
- Android Version: Android 13
- App Version: v1.1.0
- Server: Raspberry Pi mit Docker / Raspberry Pi with Docker
- Netzwerk: Lokales WiFi / Local WiFi
validations:
required: false
- type: textarea
id: additional
attributes:
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 ] # Trigger on push to main branch
workflow_dispatch: # Allow manual trigger
branches: [ main ] # Only trigger on push/merge to main
workflow_dispatch: # Enables manual trigger
permissions:
contents: write # Required for creating releases
contents: write # Required for release creation
jobs:
build:
@@ -23,30 +23,27 @@ jobs:
distribution: 'temurin'
java-version: '17'
- name: Generate Production version number
- name: Extract semantic version from build.gradle.kts
run: |
# Generate semantic version: YYYY.MM.DD
VERSION_NAME="$(date +'%Y.%m.%d')"
# 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/')
# Use GitHub run number as build number for production
BUILD_NUMBER="${{ github.run_number }}"
# 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-prod.$BUILD_NUMBER" >> $GITHUB_ENV
echo "VERSION_TAG=v$VERSION_NAME" >> $GITHUB_ENV
echo "🚀 Generated PRODUCTION version: $VERSION_NAME+$BUILD_NUMBER"
echo "🚀 Building version: $VERSION_NAME (Code: $BUILD_NUMBER)"
- name: Update build.gradle.kts with Production version
- name: Verify version from build.gradle.kts
run: |
# Update versionCode and versionName in build.gradle.kts
sed -i "s/versionCode = [0-9]*/versionCode = ${{ env.BUILD_NUMBER }}/" android/app/build.gradle.kts
sed -i "s/versionName = \".*\"/versionName = \"${{ env.VERSION_NAME }}\"/" android/app/build.gradle.kts
echo "✅ Updated build.gradle.kts:"
echo "✅ Using version from build.gradle.kts:"
grep -E "versionCode|versionName" android/app/build.gradle.kts
- name: Setup Android signing
- 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
@@ -55,26 +52,22 @@ jobs:
echo "storeFile=simple-notes-release.jks" >> android/key.properties
echo "✅ Signing configuration created"
- name: Build Production APK (Release)
- name: Build production APK (Standard + F-Droid Flavors)
run: |
cd android
./gradlew assembleRelease --no-daemon --stacktrace
./gradlew assembleStandardRelease assembleFdroidRelease --no-daemon --stacktrace
- name: Copy APK variants to root with version names
- name: Copy APK variants with version names
run: |
mkdir -p apk-output
# Universal APK
cp android/app/build/outputs/apk/release/app-universal-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-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
# ARM64 APK
cp android/app/build/outputs/apk/release/app-arm64-v8a-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-arm64-v8a.apk
# ARMv7 APK
cp android/app/build/outputs/apk/release/app-armeabi-v7a-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-armeabi-v7a.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 files prepared:"
ls -lh apk-output/
@@ -86,113 +79,78 @@ jobs:
path: apk-output/*.apk
retention-days: 90 # Keep production builds longer
- name: Get commit info
- 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
# Get full commit message preserving newlines and emojis (UTF-8)
{
echo 'COMMIT_MSG<<EOF'
git -c core.quotepath=false log -1 --pretty=%B
echo 'EOF'
} >> $GITHUB_ENV
- name: Read F-Droid changelogs
run: |
# 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=No German release notes available." >> $GITHUB_ENV
fi
# 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
echo "$CHANGELOG_CONTENT_EN" >> $GITHUB_ENV
echo "GHADELIMITER" >> $GITHUB_ENV
else
echo "CHANGELOG_EN=" >> $GITHUB_ENV
fi
- name: Create Production Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ env.VERSION_TAG }}
name: "📝 Simple Notes Sync v${{ env.VERSION_NAME }} (Produktions-Release)"
name: "📝 Simple Notes Sync v${{ env.VERSION_NAME }}"
files: apk-output/*.apk
draft: false
prerelease: false
generate_release_notes: false
body: |
# 📝 Produktions-Release: Simple Notes Sync v${{ env.VERSION_NAME }}
## 📋 Changelog / Release Notes
## Build-Informationen
${{ env.CHANGELOG_EN }}
- **Version:** ${{ env.VERSION_NAME }}+${{ env.BUILD_NUMBER }}
- **Build-Datum:** ${{ env.COMMIT_DATE }}
<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
- **Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.BUILD_NUMBER }})
- **Date:** ${{ env.COMMIT_DATE }}
- **Commit:** ${{ env.SHORT_SHA }}
- **Umgebung:** 🟢 **PRODUKTION**
---
## 🔐 APK Signature Verification
## 📋 Änderungen
All APKs are signed with the official release certificate.
${{ env.COMMIT_MSG }}
**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
```
## 📦 Download & Installation
### Welche APK soll ich herunterladen?
| Dein Gerät | Lade diese APK herunter | Größe | Kompatibilität |
|------------|------------------------|-------|----------------|
| 🤷 Nicht sicher? | `simple-notes-sync-v${{ env.VERSION_NAME }}-universal.apk` | ~5 MB | Funktioniert auf allen Geräten |
| Modern (2018+) | `simple-notes-sync-v${{ env.VERSION_NAME }}-arm64-v8a.apk` | ~3 MB | Schneller, kleiner |
| Ältere Geräte | `simple-notes-sync-v${{ env.VERSION_NAME }}-armeabi-v7a.apk` | ~3 MB | Ältere ARM-Chips |
### Installationsschritte
1. Lade die passende APK aus den Assets unten herunter
2. Aktiviere "Installation aus unbekannten Quellen" in den Android-Einstellungen
3. Öffne die heruntergeladene APK-Datei
4. Folge den Installationsanweisungen
5. Konfiguriere die WebDAV-Einstellungen in der App
---
## ⚙️ Funktionen
- ✅ Automatische WebDAV-Synchronisation alle 30 Minuten (~0,4% Akku/Tag)
- ✅ Intelligente Gateway-Erkennung (automatische Heimnetzwerk-Erkennung)
- ✅ Material Design 3 Oberfläche
- ✅ Datenschutzorientiert (kein Tracking, keine Analysen)
- ✅ Offline-First Architektur
---
## 🔄 Update von vorheriger Version
Installiere diese APK einfach über die bestehende Installation - alle Daten und Einstellungen bleiben erhalten.
---
## 📱 Obtanium - Auto-Update App
Erhalte automatische Updates mit [Obtanium](https://github.com/ImranR98/Obtanium/releases/latest).
**Einrichtung:**
1. Installiere Obtanium über den Link oben
2. Füge die App mit dieser URL hinzu: `https://github.com/dettmersLiq/simple-notes-sync`
3. Aktiviere Auto-Updates
---
## 🆘 Support
Bei Problemen oder Fragen öffne bitte ein Issue auf GitHub.
---
## 🔒 Datenschutz & Sicherheit
- Alle Daten werden über deinen eigenen WebDAV-Server synchronisiert
- Keine Drittanbieter-Analysen oder Tracking
- Keine Internet-Berechtigungen außer für WebDAV-Sync
- Alle Synchronisationsvorgänge verschlüsselt (HTTPS)
- Open Source - prüfe den Code selbst
---
## 🛠️ Erstellt mit
- **Sprache:** Kotlin
- **UI:** Material Design 3
- **Sync:** WorkManager + WebDAV
- **Target SDK:** Android 16 (API 36)
- **Min SDK:** Android 8.0 (API 26)
**[📖 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 }}

124
.github/workflows/pr-build-check.yml vendored Normal file
View File

@@ -0,0 +1,124 @@
name: PR Build Check
on:
pull_request:
branches: [ main ]
paths:
- 'android/**'
- '.github/workflows/pr-build-check.yml'
jobs:
build:
name: Build & Test APK
runs-on: ubuntu-latest
steps:
- name: Code auschecken
uses: actions/checkout@v4
- name: Java einrichten
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Gradle Cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Version auslesen
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/')
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
./gradlew assembleStandardDebug assembleFdroidDebug --no-daemon --stacktrace
- name: Unit Tests ausfuehren
run: |
cd android
./gradlew test --no-daemon --stacktrace
continue-on-error: true
- name: Build-Ergebnis pruefen
run: |
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-debug.apk" ]; then
echo "✅ F-Droid Debug APK erfolgreich gebaut"
ls -lh android/app/build/outputs/apk/fdroid/debug/*.apk
else
echo "❌ F-Droid Debug APK Build fehlgeschlagen"
exit 1
fi
- name: Debug APKs hochladen (Artefakte)
uses: actions/upload-artifact@v4
with:
name: debug-apks-pr-${{ github.event.pull_request.number }}
path: |
android/app/build/outputs/apk/standard/debug/*.apk
android/app/build/outputs/apk/fdroid/debug/*.apk
retention-days: 30
- name: Kommentar zu PR hinzufuegen
uses: actions/github-script@v7
if: success()
with:
script: |
const fs = require('fs');
const standardApk = fs.readdirSync('android/app/build/outputs/apk/standard/debug/')
.filter(f => f.endsWith('.apk'));
const fdroidApk = fs.readdirSync('android/app/build/outputs/apk/fdroid/debug/')
.filter(f => f.endsWith('.apk'));
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## ✅ Build erfolgreich!
**Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.VERSION_CODE }})
### 📦 Debug APKs (Test-Builds)
Die Debug-APKs wurden erfolgreich gebaut und sind als Artefakte verfuegbar:
**Standard Flavor:**
${standardApk.map(f => '- \`' + f + '\`').join('\n')}
**F-Droid Flavor:**
${fdroidApk.map(f => '- \`' + f + '\`').join('\n')}
> ⚠️ **Hinweis:** Dies sind unsigned Debug-Builds zum Testen. Production Releases werden nur bei Merge auf \`main\` erstellt.
[📥 Download Artefakte](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})`
})

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

989
CHANGELOG.md Normal file
View File

@@ -0,0 +1,989 @@
# Changelog
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
### 🎉 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

263
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,263 @@
# Contributing to Simple Notes Sync 🤝
> Beiträge sind willkommen! / Contributions are welcome!
**🌍 Languages:** [Deutsch](#deutsch) · [English](#english)
---
## Deutsch
Danke, dass du zu Simple Notes Sync beitragen möchtest!
### 🚀 Schnellstart
1. **Fork & Clone**
```bash
git clone https://github.com/inventory69/simple-notes-sync.git
cd simple-notes-sync
```
2. **Branch erstellen**
```bash
git checkout -b feature/mein-feature
# oder
git checkout -b fix/mein-bugfix
```
3. **Änderungen machen**
- Code schreiben
- Testen
- Committen mit aussagekräftiger Message
4. **Pull Request erstellen**
- Push deinen Branch: `git push origin feature/mein-feature`
- Gehe zu GitHub und erstelle einen Pull Request
- Beschreibe deine Änderungen
### 🧪 Automatische Tests
Wenn du einen Pull Request erstellst, läuft automatisch ein **Build Check**:
- ✅ Debug APKs werden gebaut (Standard + F-Droid)
- ✅ Unit Tests werden ausgeführt
- ✅ APKs werden als Artefakte hochgeladen (zum Testen)
- ✅ Build-Status wird als Kommentar im PR gepostet
**Wichtig:** Der Build muss erfolgreich sein (grüner Haken ✅) bevor der PR gemerged werden kann.
### 📱 Android App Development
**Build lokal testen:**
```bash
cd android
# Debug Build
./gradlew assembleStandardDebug
# Tests ausführen
./gradlew test
# Lint Check
./gradlew lint
```
**Anforderungen:**
- Android SDK 36 (Target)
- Android SDK 24 (Minimum)
- JDK 17
- Kotlin 1.9+
### 📝 Code Style
- **Kotlin:** Folge den [Kotlin Coding Conventions](https://kotlinlang.org/docs/coding-conventions.html)
- **Formatierung:** Android Studio Default Formatter
- **Kommentare:** Deutsch oder Englisch (bevorzugt Englisch für Code)
### 🐛 Bug Reports
Nutze die [Bug Report Template](https://github.com/inventory69/simple-notes-sync/issues/new/choose) mit:
- Android Version
- App Version
- Schritte zum Reproduzieren
- Erwartetes vs. tatsächliches Verhalten
### 💡 Feature Requests
Nutze die [Feature Request Template](https://github.com/inventory69/simple-notes-sync/issues/new/choose) und beschreibe:
- Was soll hinzugefügt werden
- Warum ist es nützlich
- Wie könnte es funktionieren
### 📚 Dokumentation
Dokumentations-Verbesserungen sind auch Contributions!
**Dateien:**
- `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!
### ✅ Pull Request Checklist
- [ ] Code kompiliert lokal (`./gradlew assembleStandardDebug`)
- [ ] Tests laufen durch (`./gradlew test`)
- [ ] Keine neuen Lint-Warnungen
- [ ] Commit-Messages sind aussagekräftig
- [ ] Dokumentation aktualisiert (falls nötig)
- [ ] Beide Sprachen aktualisiert (bei Doku-Änderungen)
### 🎯 Was wird akzeptiert?
**✅ Gerne:**
- Bug Fixes
- Performance-Verbesserungen
- Neue Features (nach Diskussion in einem Issue)
- Dokumentations-Verbesserungen
- Tests
- UI/UX Verbesserungen
**❌ Schwierig:**
- Breaking Changes (bitte erst als Issue diskutieren)
- Komplett neue Architektur
- Dependencies mit fragwürdigen Lizenzen
### 📄 Lizenz
Indem du contributest, stimmst du zu dass dein Code unter der [MIT License](LICENSE) veröffentlicht wird.
---
## English
Thanks for wanting to contribute to Simple Notes Sync!
### 🚀 Quick Start
1. **Fork & Clone**
```bash
git clone https://github.com/inventory69/simple-notes-sync.git
cd simple-notes-sync
```
2. **Create Branch**
```bash
git checkout -b feature/my-feature
# or
git checkout -b fix/my-bugfix
```
3. **Make Changes**
- Write code
- Test
- Commit with meaningful message
4. **Create Pull Request**
- Push your branch: `git push origin feature/my-feature`
- Go to GitHub and create a Pull Request
- Describe your changes
### 🧪 Automated Tests
When you create a Pull Request, an automatic **Build Check** runs:
- ✅ Debug APKs are built (Standard + F-Droid)
- ✅ Unit tests are executed
- ✅ APKs are uploaded as artifacts (for testing)
- ✅ Build status is posted as comment in PR
**Important:** The build must succeed (green checkmark ✅) before the PR can be merged.
### 📱 Android App Development
**Test build locally:**
```bash
cd android
# Debug Build
./gradlew assembleStandardDebug
# Run tests
./gradlew test
# Lint Check
./gradlew lint
```
**Requirements:**
- Android SDK 36 (Target)
- Android SDK 24 (Minimum)
- JDK 17
- Kotlin 1.9+
### 📝 Code Style
- **Kotlin:** Follow [Kotlin Coding Conventions](https://kotlinlang.org/docs/coding-conventions.html)
- **Formatting:** Android Studio Default Formatter
- **Comments:** German or English (preferably English for code)
### 🐛 Bug Reports
Use the [Bug Report Template](https://github.com/inventory69/simple-notes-sync/issues/new/choose) with:
- Android version
- App version
- Steps to reproduce
- Expected vs. actual behavior
### 💡 Feature Requests
Use the [Feature Request Template](https://github.com/inventory69/simple-notes-sync/issues/new/choose) and describe:
- What should be added
- Why is it useful
- How could it work
### 📚 Documentation
Documentation improvements are also contributions!
**Files:**
- `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!
### ✅ Pull Request Checklist
- [ ] Code compiles locally (`./gradlew assembleStandardDebug`)
- [ ] Tests pass (`./gradlew test`)
- [ ] No new lint warnings
- [ ] Commit messages are meaningful
- [ ] Documentation updated (if needed)
- [ ] Both languages updated (for doc changes)
### 🎯 What Gets Accepted?
**✅ Welcome:**
- Bug fixes
- Performance improvements
- New features (after discussion in an issue)
- Documentation improvements
- Tests
- UI/UX improvements
**❌ Difficult:**
- Breaking changes (please discuss in issue first)
- Completely new architecture
- Dependencies with questionable licenses
### 📄 License
By contributing, you agree that your code will be published under the [MIT License](LICENSE).
---
## 🆘 Fragen? / Questions?
Ö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).
**Happy Coding! 🚀**

View File

@@ -1,188 +0,0 @@
# GitHub Actions Setup Guide
This guide explains how to set up the GitHub Actions workflow for automated APK builds with proper signing.
## Overview
The workflow in `.github/workflows/build-production-apk.yml` automatically:
- Builds signed APKs on every push to `main`
- Generates version numbers using `YYYY.MM.DD` + build number
- Creates 3 APK variants (universal, arm64-v8a, armeabi-v7a)
- Creates GitHub releases with all APKs attached
## Prerequisites
- GitHub CLI (`gh`) installed
- Java 17+ installed (for keytool)
- Git repository initialized with GitHub remote
## Step 1: Generate Signing Keystore
⚠️ **IMPORTANT**: Store the keystore securely! Without it, you cannot publish updates to your app.
```bash
# Navigate to project root
cd /path/to/simple-notes-sync
# Generate keystore (replace values as needed)
keytool -genkey -v \
-keystore android/app/simple-notes-release.jks \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-alias simple-notes
# You will be prompted for:
# - Keystore password (remember this!)
# - Key password (remember this!)
# - Your name, organization, etc.
```
**Store these securely:**
- Keystore password
- Key password
- Alias: `simple-notes`
- Keystore file: `android/app/simple-notes-release.jks`
⚠️ **BACKUP**: Make a backup of the keystore file in a secure location (NOT in the repository).
## Step 2: Base64 Encode Keystore
```bash
# Create base64 encoded version
base64 android/app/simple-notes-release.jks > simple-notes-release.jks.b64
# Or on macOS:
base64 -i android/app/simple-notes-release.jks -o simple-notes-release.jks.b64
```
## Step 3: Set GitHub Secrets
Using GitHub CLI (recommended):
```bash
# Set KEYSTORE_BASE64 secret
gh secret set KEYSTORE_BASE64 < simple-notes-release.jks.b64
# Set KEYSTORE_PASSWORD (will prompt for input)
gh secret set KEYSTORE_PASSWORD
# Set KEY_PASSWORD (will prompt for input)
gh secret set KEY_PASSWORD
# Set KEY_ALIAS (value: simple-notes)
printf "simple-notes" | gh secret set KEY_ALIAS
```
Or manually via GitHub web interface:
1. Go to repository Settings → Secrets and variables → Actions
2. Click "New repository secret"
3. Add these secrets:
- `KEYSTORE_BASE64`: Paste content of `simple-notes-release.jks.b64`
- `KEYSTORE_PASSWORD`: Your keystore password
- `KEY_PASSWORD`: Your key password
- `KEY_ALIAS`: `simple-notes`
## Step 4: Verify Setup
```bash
# Check secrets are set
gh secret list
# Expected output:
# KEYSTORE_BASE64 Updated YYYY-MM-DD
# KEYSTORE_PASSWORD Updated YYYY-MM-DD
# KEY_PASSWORD Updated YYYY-MM-DD
# KEY_ALIAS Updated YYYY-MM-DD
```
## Step 5: Cleanup
```bash
# Remove sensitive files (they're in .gitignore, but double-check)
rm simple-notes-release.jks.b64
rm -f android/key.properties # Generated by workflow
# Verify keystore is NOT tracked by git
git status | grep -i jks
# Should return nothing
```
## Step 6: Trigger First Build
```bash
# Commit and push to main
git add .
git commit -m "🚀 feat: Add GitHub Actions deployment workflow"
git push origin main
# Or manually trigger workflow
gh workflow run build-production-apk.yml
```
## Verification
1. Go to GitHub repository → Actions tab
2. Check workflow run status
3. Once complete, go to Releases tab
4. Verify release was created with 3 APK variants
5. Download and test one of the APKs
## Troubleshooting
### Build fails with "Keystore not found"
- Check `KEYSTORE_BASE64` secret is set correctly
- Verify base64 encoding was done without line breaks
### Build fails with "Incorrect password"
- Verify `KEYSTORE_PASSWORD` and `KEY_PASSWORD` are correct
- Re-set secrets if needed
### APK files not found
- Check build logs for errors in assembleRelease step
- Verify APK output paths match workflow expectations
### Updates don't work
- Ensure you're using the same keystore for all builds
- Verify `applicationId` in build.gradle.kts matches
## Security Notes
- ✅ Keystore is base64-encoded in GitHub secrets (secure)
- ✅ Passwords are stored in GitHub secrets (encrypted)
-`key.properties` and `.jks` files are in `.gitignore`
- ⚠️ Never commit keystore files to repository
- ⚠️ Keep backup of keystore in secure location
- ⚠️ Don't share keystore passwords
## Versioning
Versions follow this pattern:
- **Version Name**: `YYYY.MM.DD` (e.g., `2025.01.15`)
- **Version Code**: GitHub run number (e.g., `42`)
- **Release Tag**: `vYYYY.MM.DD-prod.BUILD` (e.g., `v2025.01.15-prod.42`)
This ensures:
- Semantic versioning based on release date
- Incremental version codes for Play Store compatibility
- Clear distinction between builds
## APK Variants
The workflow generates 3 APK variants:
1. **Universal APK** (~5 MB)
- Works on all devices
- Larger file size
- Recommended for most users
2. **arm64-v8a APK** (~3 MB)
- For modern devices (2018+)
- Smaller, faster
- 64-bit ARM processors
3. **armeabi-v7a APK** (~3 MB)
- For older devices
- 32-bit ARM processors
Users can choose based on their device - Obtanium auto-updates work with all variants.

File diff suppressed because it is too large Load Diff

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,208 +1,269 @@
# 🚀 Quick Start Guide
# Quick Start Guide - Simple Notes Sync 📝
## ✅ Server ist bereits gestartet!
> Step-by-step installation and setup guide
Der WebDAV-Server läuft bereits auf:
- **Lokal:** `http://localhost:8080/`
- **Im Netzwerk:** `http://192.168.0.188:8080/`
### Credentials
- **Username:** `noteuser`
- **Password:** `SimpleNotes2025!`
## 📱 Nächste Schritte: Android App erstellen
### Option 1: Mit Android Studio (Empfohlen)
1. **Android Studio öffnen**
```
File → New → New Project
```
2. **Template wählen:**
- Empty Views Activity
3. **Projekt konfigurieren:**
```
Name: Simple Notes
Package: com.example.simplenotes
Save location: /home/liq/gitProjects/simple-notes-sync/android/
Language: Kotlin
Minimum SDK: API 24 (Android 7.0)
Build configuration: Kotlin DSL
```
4. **Dependencies hinzufügen:**
Öffne `app/build.gradle.kts` und füge hinzu:
```kotlin
dependencies {
// ... existing dependencies
// WebDAV
implementation("com.github.thegrizzlylabs:sardine-android:0.8")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// JSON
implementation("com.google.code.gson:gson:2.10.1")
// WorkManager
implementation("androidx.work:work-runtime-ktx:2.9.0")
}
```
Und in `settings.gradle.kts`:
```kotlin
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") } // Für Sardine
}
}
```
5. **Code implementieren:**
Alle Code-Beispiele findest du in:
- [ANDROID_GUIDE.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/ANDROID_GUIDE.md)
Kopiere der Reihe nach:
- `models/Note.kt`
- `models/SyncStatus.kt`
- `storage/NotesStorage.kt`
- `utils/DeviceIdGenerator.kt`
- `utils/NotificationHelper.kt`
- `utils/Extensions.kt`
- `utils/Constants.kt`
- UI Layouts aus dem Guide
- Activities (MainActivity, NoteEditorActivity, SettingsActivity)
- `sync/WebDavSyncService.kt`
- `sync/WifiSyncReceiver.kt`
- `sync/SyncWorker.kt`
- `adapters/NotesAdapter.kt`
6. **AndroidManifest.xml anpassen:**
```xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
...
android:usesCleartextTraffic="true">
```
7. **Build & Run:**
```
Build → Make Project
Run → Run 'app'
```
8. **In der App konfigurieren:**
- Einstellungen öffnen
- Server URL: `http://192.168.0.188:8080/`
- Username: `noteuser`
- Password: `SimpleNotes2025!`
- Heim-WLAN SSID: `DeinWLANName`
- "Verbindung testen" → sollte erfolgreich sein ✓
### Option 2: Schritt-für-Schritt Implementation
Folge dem [IMPLEMENTATION_PLAN.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/IMPLEMENTATION_PLAN.md) mit 6 Sprints:
1. **Sprint 1:** Server & Foundation (bereits done ✓)
2. **Sprint 2:** Basic UI (4-6h)
3. **Sprint 3:** Settings & WebDAV (6h)
4. **Sprint 4:** Auto-Sync (6h)
5. **Sprint 5:** Conflicts & Errors (6h)
6. **Sprint 6:** Polish & Testing (6h)
## 🧪 Server testen
Der Server läuft bereits. Teste ihn:
```bash
# Einfacher Test
curl -u noteuser:SimpleNotes2025! http://localhost:8080/
# Test-Notiz hochladen
echo '{"id":"test-123","title":"Test","content":"Hello World","createdAt":1703001234567,"updatedAt":1703001234567,"deviceId":"test","syncStatus":"SYNCED"}' > test.json
curl -u noteuser:SimpleNotes2025! \
-T test.json \
http://localhost:8080/test.json
# Test-Notiz abrufen
curl -u noteuser:SimpleNotes2025! http://localhost:8080/test.json
# Löschen
curl -u noteuser:SimpleNotes2025! \
-X DELETE \
http://localhost:8080/test.json
```
## 📊 Server Management
```bash
cd /home/liq/gitProjects/simple-notes-sync/server
# Status
docker-compose ps
# Logs
docker-compose logs -f
# Stoppen
docker-compose down
# Neu starten
docker-compose up -d
# Daten ansehen
ls -la notes-data/
```
## 🔧 Troubleshooting
### Server nicht erreichbar von Android
1. **Firewall prüfen:**
```bash
sudo ufw status
sudo ufw allow 8080
```
2. **Ping-Test:**
```bash
ping 192.168.0.188
```
3. **Port-Test:**
```bash
telnet 192.168.0.188 8080
```
### Permission Denied in Android
- Android 13+: POST_NOTIFICATIONS Permission akzeptieren
- Internet Permission in Manifest vorhanden?
- `usesCleartextTraffic="true"` gesetzt?
## 📚 Weitere Hilfe
- **Vollständige Doku:** [project-docs/simple-notes-sync](https://github.com/inventory69/project-docs/tree/main/simple-notes-sync)
- **Android Code:** [ANDROID_GUIDE.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/ANDROID_GUIDE.md)
- **Server Setup:** [SERVER_SETUP.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/SERVER_SETUP.md)
- **Notifications:** [NOTIFICATIONS.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/NOTIFICATIONS.md)
**🌍 Languages:** [Deutsch](QUICKSTART.de.md) · **English**
---
**Server Status:** ✅ Running on `http://192.168.0.188:8080/`
**Next:** Android App in Android Studio erstellen
**Estimated Time:** 18-24 Stunden für vollständige App
## Prerequisites
Viel Erfolg! 🚀
- ✅ Android 7.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.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.md](docs/DOCS.md)
- **Server setup details:** [server/README.md](server/README.md)
---
**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>

214
README.md
View File

@@ -1,137 +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 Android Notiz-App mit automatischer WLAN-Synchronisierung**
<h1 align="center">Simple Notes Sync</h1>
[![Android](https://img.shields.io/badge/Android-8.0%2B-green.svg)](https://www.android.com/)
[![Kotlin](https://img.shields.io/badge/Kotlin-1.9%2B-blue.svg)](https://kotlinlang.org/)
[![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>
Schlanke Offline-Notizen ohne Schnickschnack - deine Daten bleiben bei dir. Automatische Synchronisierung zu deinem eigenen WebDAV-Server, kein Google, kein Microsoft, keine Cloud.
<div align="center">
## ✨ Features
[![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)
- 📝 **Offline-First** - Notizen lokal gespeichert, immer verfügbar
- 🔄 **Auto-Sync** - Konfigurierbare Intervalle (15/30/60 Min.) mit ~0.2-0.8% Akku/Tag
- 🏠 **Self-Hosted** - Deine Daten auf deinem Server (WebDAV)
- 🎨 **Material Design 3** - Modern & Dynamic Theming
- 🔋 **Akkuschonend** - Optimiert für Hintergrund-Synchronisierung
- 🔐 **Privacy-First** - Kein Tracking, keine Analytics, keine Cloud
- 🚫 **Keine Berechtigungen** - Nur Internet für WebDAV Sync
</div>
## 📥 Quick Download
<div align="center">
**Android APK:** [📱 Neueste Version herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest)
<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>
💡 **Tipp:** Nutze [Obtainium](https://github.com/ImranR98/Obtainium) für automatische Updates!
<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>
## 🚀 Schnellstart
</div>
### 1⃣ WebDAV Server starten
<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>
```fish
cd server
<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/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
- 📝 **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
➡️ **Complete feature list:** [FEATURES.md](docs/FEATURES.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
# Passwort in .env anpassen
# Set password in .env
docker compose up -d
```
### 2⃣ App installieren & konfigurieren
➡️ **Details:** [Server Setup Guide](server/README.md)
1. APK herunterladen und installieren
2. App öffnen → **Einstellungen** (⚙️)
3. Server konfigurieren:
- URL: `http://192.168.0.XXX:8080/notes`
- Benutzername: `noteuser`
- Passwort: (aus `.env`)
4. **Auto-Sync aktivieren**
5. **Sync-Intervall wählen** (15/30/60 Min.)
### 2. App Installation (2 minutes)
**Fertig!** Notizen werden automatisch synchronisiert 🎉
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.md](QUICKSTART.md)
## ⚙️ Sync-Intervalle
## 📚 Documentation
| Intervall | Akku/Tag | Anwendungsfall |
|-----------|----------|----------------|
| **15 Min** | ~0.8% (~23 mAh) | ⚡ Maximale Aktualität |
| **30 Min** | ~0.4% (~12 mAh) | ✓ Empfohlen - Ausgewogen |
| **60 Min** | ~0.2% (~6 mAh) | 🔋 Maximale Akkulaufzeit |
| 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 🌍 |
💡 **Hinweis:** Android Doze Mode kann Sync im Standby auf ~60 Min. verzögern (betrifft alle Apps).
---
## <20> Neue Features in v1.1.0
### Konfigurierbare Sync-Intervalle
- ⏱️ Wählbare Intervalle: 15/30/60 Minuten
- 📊 Transparente Akkuverbrauchs-Anzeige
- <20> Sofortige Anwendung ohne App-Neustart
### Über-Sektion
- <20> App-Version & Build-Datum
- 🌐 Links zu GitHub Repo & Entwickler
- ⚖️ Lizenz-Information
### Verbesserungen
- 🎯 Benutzerfreundliche Doze-Mode Erklärung
- 🔕 Keine störenden Sync-Fehler Toasts im Hintergrund
- 📝 Erweiterte Debug-Logs für Troubleshooting
---
## 🛠️ Selbst bauen
```fish
```bash
cd android
./gradlew assembleStandardRelease
# APK: android/app/build/outputs/apk/standard/release/
```
---
➡️ **Build guide:** [DOCS.md](docs/DOCS.md#-build--deployment)
## 🐛 Troubleshooting
## 💡 Feature Requests & Ideas
### Auto-Sync funktioniert nicht
Have an idea for a new feature or improvement? We'd love to hear it!
1. **Akku-Optimierung deaktivieren**
- Einstellungen → Apps → Simple Notes → Akku → Nicht optimieren
2. **WLAN-Verbindung prüfen**
- Funktioniert nur im selben Netzwerk wie Server
3. **Server-Status checken**
- Settings → "Verbindung testen"
➡️ **How to suggest features:**
### Server nicht erreichbar
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
```fish
# Status prüfen
docker compose ps
# Logs ansehen
docker compose logs -f
# IP-Adresse finden
ip addr show | grep "inet " | grep -v 127.0.0.1
```
Mehr Details: [📖 Dokumentation](DOCS.md)
---
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
Contributions sind willkommen! Bitte öffne ein Issue oder Pull Request.
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
**Version:** 1.1.0 · **Status:** ✅ Produktiv · **Gebaut mit:** Kotlin + Material Design 3
</div>

View File

@@ -1,335 +0,0 @@
# Simple Notes Sync 📝
> Minimalistische Android-App für Offline-Notizen mit automatischer WLAN-Synchronisierung
Eine schlanke Notiz-App ohne Schnickschnack - perfekt für schnelle Gedanken, die automatisch zu Hause synchronisiert werden.
---
## ✨ Features
- 📝 **Offline-first** - Notizen werden lokal gespeichert und sind immer verfügbar
- 🔄 **Auto-Sync** - Automatische Synchronisierung wenn du im Heimnetzwerk bist
- 🏠 **WebDAV Server** - Deine Daten bleiben bei dir (Docker-Container)
- 🔋 **Akkuschonend** - Nur ~0.4% Akkuverbrauch pro Tag
- 🚫 **Keine Cloud** - Keine Google, keine Microsoft, keine Drittanbieter
- 🔐 **Privacy** - Keine Tracking, keine Analytics, keine Standort-Berechtigungen
---
## 📥 Installation
### Android App
**Option 1: APK herunterladen**
1. Neueste [Release](../../releases/latest) öffnen
2. `app-debug.apk` herunterladen
3. APK auf dem Handy installieren
**Option 2: Selbst bauen**
```bash
cd android
./gradlew assembleDebug
# APK: android/app/build/outputs/apk/debug/app-debug.apk
```
### WebDAV Server
Der Server läuft als Docker-Container und speichert deine Notizen.
```bash
cd server
cp .env.example .env
nano .env # Passwort anpassen!
docker-compose up -d
```
**Server testen:**
```bash
curl -u noteuser:dein_passwort http://192.168.0.XXX:8080/
```
---
## 🚀 Schnellstart
1. **Server starten** (siehe oben)
2. **App installieren** und öffnen
3. **Einstellungen öffnen** (⚙️ Symbol oben rechts)
4. **Server konfigurieren:**
- Server-URL: `http://192.168.0.XXX:8080/notes`
- Benutzername: `noteuser`
- Passwort: (aus `.env` Datei)
- Auto-Sync: **AN**
5. **Fertig!** Notizen werden jetzt automatisch synchronisiert
---
## 💡 Wie funktioniert Auto-Sync?
Die App prüft **alle 30 Minuten**, ob:
- ✅ WLAN verbunden ist
- ✅ Server im gleichen Netzwerk erreichbar ist
- ✅ Neue Notizen vorhanden sind
Wenn alle Bedingungen erfüllt → **Automatische Synchronisierung**
**Wichtig:** Funktioniert nur im selben Netzwerk wie der Server (kein Internet-Zugriff nötig!)
---
## 🔋 Akkuverbrauch
| Komponente | Verbrauch/Tag |
|------------|---------------|
| WorkManager (alle 30 Min) | ~0.3% |
| Netzwerk-Checks | ~0.1% |
| **Total** | **~0.4%** |
Bei einem 3000 mAh Akku entspricht das ~12 mAh pro Tag.
---
## 📱 Screenshots
_TODO: Screenshots hinzufügen_
---
## 🛠️ Technische Details
Mehr Infos zur Architektur und Implementierung findest du in der [technischen Dokumentation](DOCS.md).
**Stack:**
- **Android:** Kotlin, Material Design 3, WorkManager
- **Server:** Docker, WebDAV (bytemark/webdav)
- **Sync:** Sardine Android (WebDAV Client)
---
## 📄 Lizenz
[Lizenz hier einfügen]
---
## 🤝 Beitragen
Contributions sind willkommen! Bitte öffne ein Issue oder Pull Request.
---
## 📄 Lizenz
MIT License - siehe [LICENSE](LICENSE)
---
**Projekt Start:** 19. Dezember 2025
**Status:** ✅ Funktional & Produktiv
## 📖 Dokumentation
### In diesem Repository:
- **[QUICKSTART.md](QUICKSTART.md)** - Schnellstart-Anleitung
- **[server/README.md](server/README.md)** - Server-Verwaltung
### Vollständige Dokumentation (project-docs):
- [README.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/README.md) - Projekt-Übersicht & Architektur
- [IMPLEMENTATION_PLAN.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/IMPLEMENTATION_PLAN.md) - Detaillierter Sprint-Plan
- [SERVER_SETUP.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/SERVER_SETUP.md) - Server-Setup Details
- [ANDROID_GUIDE.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/ANDROID_GUIDE.md) - 📱 Kompletter Android-Code
- [NOTIFICATIONS.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/NOTIFICATIONS.md) - Notification-System Details
- [WINDOWS_SETUP.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/WINDOWS_SETUP.md) - 🪟 Windows + Android Studio Setup
- [CODE_REFERENCE.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/CODE_REFERENCE.md) - Schnelle Code-Referenz
## ⚙️ Server Konfiguration
**Standard-Credentials:**
- Username: `noteuser`
- Password: Siehe `.env` im `server/` Verzeichnis
**Server-URL:**
- Lokal: `http://localhost:8080/`
- Im Netzwerk: `http://YOUR_IP:8080/`
IP-Adresse finden:
```bash
ip addr show | grep "inet " | grep -v 127.0.0.1
```
## 📱 Android App Setup
### Vorraussetzungen
- Android Studio Hedgehog (2023.1.1) oder neuer
- JDK 17
- Min SDK 24 (Android 7.0)
- Target SDK 34 (Android 14)
### In App konfigurieren
1. App starten
2. Einstellungen öffnen
3. Server-URL eintragen (z.B. `http://192.168.1.100:8080/`)
4. Username & Passwort eingeben
5. Heim-WLAN SSID eingeben
6. "Verbindung testen"
## 🔧 Entwicklung
### Server-Management
```bash
# Status prüfen
docker-compose ps
# Logs anschauen
docker-compose logs -f
# Neustarten
docker-compose restart
# Stoppen
docker-compose down
```
### Android-Build
```bash
cd android
./gradlew assembleDebug
# APK Location:
# app/build/outputs/apk/debug/app-debug.apk
```
## 🧪 Testing
### Server-Test
```bash
# Testdatei hochladen
echo '{"id":"test","title":"Test","content":"Hello"}' > test.json
curl -u noteuser:password -T test.json http://localhost:8080/test.json
# Datei abrufen
curl -u noteuser:password http://localhost:8080/test.json
# Datei löschen
curl -u noteuser:password -X DELETE http://localhost:8080/test.json
```
### Android-App
1. Notiz erstellen → speichern → in Liste sichtbar ✓
2. WLAN verbinden → Auto-Sync ✓
3. Server offline → Fehlermeldung ✓
4. Konflikt-Szenario → Auflösung ✓
## 📦 Deployment
### Server (Production)
**Option 1: Lokaler Server (Raspberry Pi, etc.)**
```bash
docker-compose up -d
```
**Option 2: VPS (DigitalOcean, Hetzner, etc.)**
```bash
# Mit HTTPS (empfohlen)
# Zusätzlich: Reverse Proxy (nginx/Caddy) + Let's Encrypt
```
### Android App
```bash
# Release Build
./gradlew assembleRelease
# APK signieren
# Play Store Upload oder Direct Install
```
## 🔐 Security
**Entwicklung:**
- ✅ HTTP Basic Auth
- ✅ Nur im lokalen Netzwerk
**Produktion:**
- ⚠️ HTTPS mit SSL/TLS (empfohlen)
- ⚠️ Starkes Passwort
- ⚠️ Firewall-Regeln
- ⚠️ VPN für externen Zugriff
## 🐛 Troubleshooting
### Server startet nicht
```bash
# Port bereits belegt?
sudo netstat -tlnp | grep 8080
# Logs checken
docker-compose logs webdav
```
### Android kann nicht verbinden
- Ist Android im gleichen WLAN?
- Ist die Server-IP korrekt?
- Firewall blockiert Port 8080?
- Credentials korrekt?
```bash
# Ping zum Server
ping YOUR_SERVER_IP
# Port erreichbar?
telnet YOUR_SERVER_IP 8080
```
## 📝 TODO / Roadmap
### Version 1.0 (MVP)
- [x] Docker WebDAV Server
- [ ] Android Basic CRUD
- [ ] Auto-Sync bei WLAN
- [ ] Error Handling
- [ ] Notifications
### Version 1.1
- [ ] Suche
- [ ] Dark Mode
- [ ] Markdown-Support
### Version 2.0
- [ ] Desktop-Client (Flutter Desktop)
- [ ] Tags/Kategorien
- [ ] Verschlüsselung
- [ ] Shared Notes
## 📄 License
MIT License - siehe [LICENSE](LICENSE)
## 👤 Author
Created for personal use - 2025
## 🙏 Acknowledgments
- [bytemark/webdav](https://hub.docker.com/r/bytemark/webdav) - Docker WebDAV Server
- [Sardine Android](https://github.com/thegrizzlylabs/sardine-android) - WebDAV Client
- [Android WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager) - Background Tasks
---
**Project Start:** 19. Dezember 2025
**Status:** 🚧 In Development

61
android/.editorconfig Normal file
View File

@@ -0,0 +1,61 @@
# ⚡ v1.3.1: EditorConfig for ktlint
# https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
max_line_length = 120
trim_trailing_whitespace = true
[*.{kt,kts}]
# ktlint rules
ktlint_code_style = android_studio
ktlint_standard_no-wildcard-imports = disabled
ktlint_standard_package-name = enabled
ktlint_standard_filename = enabled
ktlint_standard_class-naming = enabled
ktlint_standard_function-naming = enabled
ktlint_standard_property-naming = enabled
ktlint_standard_backing-property-naming = enabled
ktlint_standard_enum-entry-name-case = enabled
ktlint_standard_multiline-if-else = enabled
ktlint_standard_no-empty-class-body = enabled
ktlint_standard_no-empty-first-line-in-class-body = enabled
ktlint_standard_blank-line-before-declaration = enabled
ktlint_standard_context-receiver-wrapping = enabled
ktlint_standard_function-expression-body = disabled
ktlint_standard_function-literal = enabled
ktlint_standard_function-type-modifier-spacing = enabled
ktlint_standard_kdoc-wrapping = enabled
ktlint_standard_modifier-list-spacing = enabled
ktlint_standard_no-blank-line-in-list = enabled
ktlint_standard_no-consecutive-comments = disabled
ktlint_standard_no-single-line-block-comment = enabled
ktlint_standard_parameter-list-spacing = enabled
ktlint_standard_parameter-list-wrapping = enabled
ktlint_standard_property-wrapping = enabled
ktlint_standard_spacing-between-function-name-and-opening-parenthesis = enabled
ktlint_standard_statement-wrapping = enabled
ktlint_standard_string-template-indent = disabled
ktlint_standard_try-catch-finally-spacing = enabled
ktlint_standard_type-argument-list-spacing = enabled
ktlint_standard_type-parameter-list-spacing = enabled
ktlint_standard_value-argument-comment = enabled
ktlint_standard_value-parameter-comment = enabled
[*.md]
trim_trailing_whitespace = false
[*.{xml,json}]
indent_size = 2
[*.yml]
indent_size = 2
[Makefile]
indent_style = tab

6
android/.gitignore vendored
View File

@@ -13,3 +13,9 @@
.externalNativeBuild
.cxx
local.properties
# Signing configuration (contains sensitive keys)
key.properties
*.jks
*.keystore
/app/src/main/assets/changelogs/

157
android/LOCAL_BUILDS.md Normal file
View File

@@ -0,0 +1,157 @@
# Lokale Gradle Builds mit Release-Signierung
Dieses Dokument erklärt, wie du lokal signierte APKs erstellst, die mit den GitHub Release-APKs kompatibel sind.
## Problem
- **GitHub Actions** erstellt signierte Release-APKs mit dem Production-Keystore
- **Lokale Debug-Builds** verwenden einen temporären Debug-Key
-**Resultat:** Nutzer können lokale Debug-APKs NICHT über Release-APKs installieren (Signature Mismatch!)
## Lösung: Lokale Release-Builds mit Production-Key
### 1⃣ Keystore-Konfiguration einrichten
Du hast bereits den Keystore: `/android/app/simple-notes-release.jks`
Erstelle eine `key.properties` Datei im `/android/` Ordner:
```bash
cd /home/liq/gitProjects/simple-notes-sync/android
cp key.properties.example key.properties
```
Bearbeite `key.properties` mit den echten Werten:
```properties
storeFile=simple-notes-release.jks
storePassword=<dein-keystore-password>
keyAlias=<dein-key-alias>
keyPassword=<dein-key-password>
```
**Wichtig:** Die Werte müssen **exakt** mit den GitHub Secrets übereinstimmen:
- `KEYSTORE_PASSWORD``storePassword`
- `KEY_ALIAS``keyAlias`
- `KEY_PASSWORD``keyPassword`
### 2⃣ Lokal signierte Release-APKs bauen
```bash
cd android
./gradlew assembleStandardRelease
```
Die signierten APKs findest du dann hier:
```
android/app/build/outputs/apk/standard/release/
├── app-standard-universal-release.apk
├── app-standard-arm64-v8a-release.apk
└── app-standard-armeabi-v7a-release.apk
```
### 3⃣ F-Droid Flavor bauen (optional)
```bash
./gradlew assembleFdroidRelease
```
### 4⃣ Beide Flavors gleichzeitig bauen
```bash
./gradlew assembleStandardRelease assembleFdroidRelease
```
## Verifizierung der Signatur
Um zu prüfen, ob dein lokaler Build die gleiche Signatur wie die Release-Builds hat:
```bash
# Signatur von lokalem Build anzeigen
keytool -printcert -jarfile app/build/outputs/apk/standard/release/app-standard-universal-release.apk
# Signatur von GitHub Release-APK anzeigen (zum Vergleich)
keytool -printcert -jarfile ~/Downloads/simple-notes-sync-v1.1.0-standard-universal.apk
```
Die **SHA256** Fingerprints müssen **identisch** sein!
## Troubleshooting
### ❌ Build schlägt fehl: "Keystore not found"
**Problem:** `key.properties` oder Keystore-Datei fehlt
**Lösung:**
1. Prüfe, ob `key.properties` existiert: `ls -la key.properties`
2. Prüfe, ob der Keystore existiert: `ls -la app/simple-notes-release.jks`
### ❌ "Signature mismatch" beim Update
**Problem:** Der lokale Build verwendet einen anderen Key als die Release-Builds
**Lösung:**
1. Vergleiche die Signaturen mit `keytool` (siehe oben)
2. Stelle sicher, dass `key.properties` die **exakten** GitHub Secret-Werte enthält
3. Deinstalliere die alte Version und installiere die neue (als letzter Ausweg)
### ❌ Build verwendet Debug-Signatur
**Problem:** `build.gradle.kts` findet `key.properties` nicht
**Lösung:**
```bash
# Prüfe, ob die Datei im richtigen Verzeichnis liegt
ls -la android/key.properties # ✅ Richtig
ls -la android/app/key.properties # ❌ Falsch
```
## Sicherheitshinweise
⚠️ **NIEMALS** diese Dateien committen:
- `key.properties` (in `.gitignore`)
- `*.jks` / `*.keystore` (in `.gitignore`)
**Schon in `.gitignore`:**
```gitignore
key.properties
*.jks
*.keystore
```
⚠️ Die GitHub Secrets (`KEYSTORE_PASSWORD`, etc.) und die lokale `key.properties` müssen **synchron** bleiben!
## Workflow-Vergleich
### GitHub Actions Build
```yaml
- Lädt Keystore aus Base64 Secret
- Erstellt key.properties aus Secrets
- Baut mit: ./gradlew assembleStandardRelease
- ✅ Produktions-signiert
```
### Lokaler Build
```bash
# Mit key.properties konfiguriert:
./gradlew assembleStandardRelease
# ✅ Produktions-signiert (gleiche Signatur wie GitHub!)
# Ohne key.properties:
./gradlew assembleStandardRelease
# ⚠️ Debug-signiert (inkompatibel mit Releases!)
```
## Quick Reference
```bash
# Release-APK bauen (signiert, klein, optimiert)
./gradlew assembleStandardRelease
# Debug-APK bauen (unsigniert, groß, debuggable)
./gradlew assembleStandardDebug
# APK per HTTP Server verteilen
cd app/build/outputs/apk/standard/release
python3 -m http.server 8892
```

View File

@@ -1,6 +1,9 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
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)
}
import java.util.Properties
@@ -17,32 +20,27 @@ android {
applicationId = "dev.dettmer.simplenotes"
minSdk = 24
targetSdk = 36
versionCode = 2 // 🔥 F-Droid Release v1.1.0
versionName = "1.1.0" // 🔥 Configurable Sync Interval + About Section
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"
// 🔥 NEU: Build Date für About Screen
buildConfigField("String", "BUILD_DATE", "\"${getBuildDate()}\"")
}
// Enable multiple APKs per ABI for smaller downloads
splits {
abi {
isEnable = true
reset()
include("armeabi-v7a", "arm64-v8a")
isUniversalApk = true // Also generate universal APK
}
// Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility
dependenciesInfo {
includeInApk = false // Removes DEPENDENCY_INFO_BLOCK from APK
includeInBundle = false // Also disable for AAB (Google Play)
}
// Product Flavors for F-Droid and standard builds
// Note: APK splits are disabled to ensure single APK output
flavorDimensions += "distribution"
productFlavors {
create("fdroid") {
dimension = "distribution"
// F-Droid builds have no proprietary dependencies
// All dependencies in this project are already FOSS-compatible
// No APK splits - F-Droid expects single universal APK
}
create("standard") {
@@ -69,6 +67,16 @@ android {
}
buildTypes {
debug {
// ⚡ v1.3.1: Debug-Builds können parallel zur Release-App installiert werden
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
isDebuggable = true
// Optionales separates Icon-Label für Debug-Builds
resValue("string", "app_name_debug", "Simple Notes (Debug)")
}
release {
isMinifyEnabled = true
isShrinkResources = true
@@ -88,8 +96,18 @@ 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
targetCompatibility = JavaVersion.VERSION_11
@@ -124,14 +142,91 @@ dependencies {
// LocalBroadcastManager für UI Refresh
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
// 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)
}
// 🔥 NEU: Helper function für Build Date
fun getBuildDate(): String {
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
return dateFormat.format(Date())
// ✅ 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 {
buildUponDefaultConfig = true
allRules = false
config.setFrom(files("$rootDir/config/detekt/detekt.yml"))
baseline = file("$rootDir/config/detekt/baseline.xml")
// 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
# ═══════════════════════════════════════════════════════════════════════
# 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

@@ -5,8 +5,6 @@
<!-- Network & Sync Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -14,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" />
@@ -24,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:usesCleartextTraffic="true"
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>
@@ -39,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
@@ -72,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
@@ -21,30 +23,59 @@ import com.google.android.material.color.DynamicColors
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.card.MaterialCardView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.dettmer.simplenotes.adapters.NotesAdapter
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncWorker
import dev.dettmer.simplenotes.utils.NotificationHelper
import dev.dettmer.simplenotes.utils.showToast
import dev.dettmer.simplenotes.utils.Constants
import android.widget.TextView
import android.widget.CheckBox
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.sync.SyncStateManager
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import android.view.View
import android.widget.LinearLayout
import android.view.Gravity
import android.widget.PopupMenu
import dev.dettmer.simplenotes.models.NoteType
/**
* 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
private lateinit var emptyStateCard: MaterialCardView
private lateinit var fabAddNote: FloatingActionButton
private lateinit var toolbar: MaterialToolbar
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
// 🔄 v1.3.1: Sync Status Banner
private lateinit var syncStatusBanner: LinearLayout
private lateinit var syncStatusText: TextView
private lateinit var adapter: NotesAdapter
private val storage by lazy { NotesStorage(this) }
// Menu reference for sync button state
private var optionsMenu: Menu? = null
// Track pending deletions to prevent flicker when notes reload
private val pendingDeletions = mutableSetOf<String>()
private val prefs by lazy {
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
}
@@ -55,6 +86,8 @@ class MainActivity : AppCompatActivity() {
private const val REQUEST_SETTINGS = 1002
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 const val SYNC_COMPLETED_DELAY_MS = 1500L
private const val ERROR_DISPLAY_DELAY_MS = 3000L
}
/**
@@ -86,22 +119,96 @@ class MainActivity : AppCompatActivity() {
setContentView(R.layout.activity_main)
// File Logging aktivieren wenn eingestellt
if (prefs.getBoolean("file_logging_enabled", false)) {
Logger.enableFileLogging(this)
// Logger initialisieren und File-Logging aktivieren wenn eingestellt
Logger.init(this)
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
Logger.setFileLoggingEnabled(true)
}
// Alte Sync-Notifications beim App-Start löschen
NotificationHelper.clearSyncNotifications(this)
// Permission für Notifications (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
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
setupSyncStateObserver()
}
/**
* 🔄 v1.3.1: Beobachtet Sync-Status für UI-Feedback
*/
private fun setupSyncStateObserver() {
SyncStateManager.syncStatus.observe(this) { status ->
when (status.state) {
SyncStateManager.SyncState.SYNCING -> {
// Disable sync controls
setSyncControlsEnabled(false)
// 🔄 v1.3.1: Show sync status banner (ersetzt SwipeRefresh-Animation)
syncStatusText.text = getString(R.string.sync_status_syncing)
syncStatusBanner.visibility = View.VISIBLE
}
SyncStateManager.SyncState.COMPLETED -> {
// Re-enable sync controls
setSyncControlsEnabled(true)
swipeRefreshLayout.isRefreshing = false
// Show completed briefly, then hide
syncStatusText.text = status.message ?: getString(R.string.sync_status_completed)
lifecycleScope.launch {
kotlinx.coroutines.delay(SYNC_COMPLETED_DELAY_MS)
syncStatusBanner.visibility = View.GONE
SyncStateManager.reset()
}
}
SyncStateManager.SyncState.ERROR -> {
// Re-enable sync controls
setSyncControlsEnabled(true)
swipeRefreshLayout.isRefreshing = false
// Show error briefly, then hide
syncStatusText.text = status.message ?: getString(R.string.sync_status_error)
lifecycleScope.launch {
kotlinx.coroutines.delay(ERROR_DISPLAY_DELAY_MS)
syncStatusBanner.visibility = View.GONE
SyncStateManager.reset()
}
}
SyncStateManager.SyncState.IDLE -> {
setSyncControlsEnabled(true)
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)
}
}
}
}
/**
* 🔄 v1.3.1: Aktiviert/deaktiviert Sync-Controls (Button + SwipeRefresh)
*/
private fun setSyncControlsEnabled(enabled: Boolean) {
// Menu Sync-Button
optionsMenu?.findItem(R.id.action_sync)?.isEnabled = enabled
// SwipeRefresh
swipeRefreshLayout.isEnabled = enabled
}
override fun onResume() {
@@ -117,7 +224,7 @@ class MainActivity : AppCompatActivity() {
Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)")
// Reload notes
// Reload notes (scroll to top wird in loadNotes() gemacht)
loadNotes()
// Trigger Auto-Sync beim App-Wechsel in Vordergrund (Toast)
@@ -130,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
@@ -137,15 +245,41 @@ class MainActivity : AppCompatActivity() {
return
}
// 🔄 v1.3.1: Check if sync already running
// 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
}
Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
// Update last sync timestamp
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
// GLEICHER Sync-Code wie manueller Sync (funktioniert!)
lifecycleScope.launch {
try {
val syncService = WebDavSyncService(this@MainActivity)
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
SyncStateManager.reset()
return@launch
}
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable()
}
if (!isReachable) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
SyncStateManager.reset()
return@launch
}
// Server ist erreichbar → Sync durchführen
val result = withContext(Dispatchers.IO) {
syncService.syncNotes()
}
@@ -153,6 +287,7 @@ class MainActivity : AppCompatActivity() {
// Feedback abhängig von Source
if (result.isSuccess && result.syncedCount > 0) {
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
// onResume: Nur Success-Toast
showToast("✅ Gesynct: ${result.syncedCount} Notizen")
@@ -160,14 +295,17 @@ class MainActivity : AppCompatActivity() {
} else if (result.isSuccess) {
Logger.d(TAG, " Auto-sync ($source): No changes")
SyncStateManager.markCompleted()
} else {
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
SyncStateManager.markError(result.errorMessage)
// Kein Toast - App ist im Hintergrund
}
} catch (e: Exception) {
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
SyncStateManager.markError(e.message)
// Kein Toast - App ist im Hintergrund
}
}
@@ -203,6 +341,11 @@ class MainActivity : AppCompatActivity() {
emptyStateCard = findViewById(R.id.emptyStateCard)
fabAddNote = findViewById(R.id.fabAddNote)
toolbar = findViewById(R.id.toolbar)
swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
// 🔄 v1.3.1: Sync Status Banner
syncStatusBanner = findViewById(R.id.syncStatusBanner)
syncStatusText = findViewById(R.id.syncStatusText)
}
private fun setupToolbar() {
@@ -216,10 +359,74 @@ class MainActivity : AppCompatActivity() {
recyclerViewNotes.adapter = adapter
recyclerViewNotes.layoutManager = LinearLayoutManager(this)
// 🔥 v1.1.2: Setup Pull-to-Refresh
setupPullToRefresh()
// Setup Swipe-to-Delete
setupSwipeToDelete()
}
/**
* Setup Pull-to-Refresh für manuellen Sync (v1.1.2)
*/
private fun setupPullToRefresh() {
swipeRefreshLayout.setOnRefreshListener {
Logger.d(TAG, "🔄 Pull-to-Refresh triggered - starting manual sync")
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
if (!SyncStateManager.tryStartSync("pullToRefresh")) {
swipeRefreshLayout.isRefreshing = false
return@setOnRefreshListener
}
lifecycleScope.launch {
try {
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty()) {
showToast("⚠️ Server noch nicht konfiguriert")
SyncStateManager.reset()
return@launch
}
val syncService = WebDavSyncService(this@MainActivity)
// 🔥 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(getString(R.string.snackbar_already_synced))
return@launch
}
// Check if server is reachable
if (!syncService.isServerReachable()) {
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
return@launch
}
// Perform sync
val result = syncService.syncNotes()
if (result.isSuccess) {
SyncStateManager.markCompleted(getString(R.string.snackbar_synced_count, result.syncedCount))
loadNotes()
} else {
SyncStateManager.markError(result.errorMessage)
}
} catch (e: Exception) {
Logger.e(TAG, "Pull-to-Refresh sync failed", e)
SyncStateManager.markError(e.message)
}
}
}
// Set Material 3 color scheme
swipeRefreshLayout.setColorSchemeResources(
com.google.android.material.R.color.material_dynamic_primary50
)
}
private fun setupSwipeToDelete() {
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
0, // No drag
@@ -232,31 +439,20 @@ class MainActivity : AppCompatActivity() {
): Boolean = false
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.adapterPosition
val note = adapter.currentList[position]
val notesCopy = adapter.currentList.toMutableList()
val position = viewHolder.bindingAdapterPosition
val swipedNote = adapter.currentList[position]
// Remove from list immediately for visual feedback
notesCopy.removeAt(position)
adapter.submitList(notesCopy)
// Store original list BEFORE removing note
val originalList = adapter.currentList.toList()
// Show Snackbar with UNDO
Snackbar.make(
recyclerViewNotes,
"Notiz gelöscht",
Snackbar.LENGTH_LONG
).setAction("RÜCKGÄNGIG") {
// UNDO: Restore note in list
loadNotes()
}.addCallback(object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
if (event != DISMISS_EVENT_ACTION) {
// Snackbar dismissed without UNDO → Actually delete the note
storage.deleteNote(note.id)
loadNotes()
// Remove from list for visual feedback (NOT from storage yet!)
val listWithoutNote = originalList.toMutableList().apply {
removeAt(position)
}
}
}).show()
adapter.submitList(listWithoutNote)
// Show dialog with ability to restore
showServerDeletionDialog(swipedNote, originalList)
}
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
@@ -268,18 +464,182 @@ class MainActivity : AppCompatActivity() {
itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
}
private fun setupFab() {
fabAddNote.setOnClickListener {
openNoteEditor(null)
private fun showServerDeletionDialog(note: Note, originalList: List<Note>) {
val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
if (alwaysDeleteFromServer) {
// Auto-delete from server without asking
deleteNoteLocally(note, deleteFromServer = true)
return
}
val dialogView = layoutInflater.inflate(R.layout.dialog_server_deletion, null)
val checkboxAlways = dialogView.findViewById<CheckBox>(R.id.checkboxAlwaysDeleteFromServer)
MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.legacy_delete_dialog_title))
.setMessage(getString(R.string.legacy_delete_dialog_message, note.title))
.setView(dialogView)
.setNeutralButton(getString(R.string.cancel)) { _, _ ->
// RESTORE: Re-submit original list (note is NOT deleted from storage)
adapter.submitList(originalList)
}
.setOnCancelListener {
// User pressed back - also restore
adapter.submitList(originalList)
}
.setPositiveButton("Nur lokal") { _, _ ->
if (checkboxAlways.isChecked) {
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false).apply()
}
// NOW actually delete from storage
deleteNoteLocally(note, deleteFromServer = false)
}
.setNegativeButton(getString(R.string.legacy_delete_from_server)) { _, _ ->
if (checkboxAlways.isChecked) {
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, true).apply()
}
deleteNoteLocally(note, deleteFromServer = true)
}
.setCancelable(true)
.show()
}
private fun deleteNoteLocally(note: Note, deleteFromServer: Boolean) {
// Track pending deletion to prevent flicker
pendingDeletions.add(note.id)
// Delete from storage
storage.deleteNote(note.id)
// Reload to reflect changes
loadNotes()
// Show Snackbar with UNDO option
val message = if (deleteFromServer) {
getString(R.string.legacy_delete_with_server, note.title)
} else {
getString(R.string.legacy_delete_local_only, note.title)
}
Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG)
.setAction(getString(R.string.snackbar_undo)) {
// UNDO: Restore note
storage.saveNote(note)
pendingDeletions.remove(note.id)
loadNotes()
}
.addCallback(object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
if (event != DISMISS_EVENT_ACTION) {
// Snackbar dismissed without UNDO
pendingDeletions.remove(note.id)
// Delete from server if requested
if (deleteFromServer) {
lifecycleScope.launch {
try {
val webdavService = WebDavSyncService(this@MainActivity)
val success = webdavService.deleteNoteFromServer(note.id)
if (success) {
runOnUiThread {
Toast.makeText(
this@MainActivity,
getString(R.string.snackbar_deleted_from_server),
Toast.LENGTH_SHORT
).show()
}
} else {
runOnUiThread {
Toast.makeText(
this@MainActivity,
getString(R.string.snackbar_server_delete_failed),
Toast.LENGTH_LONG
).show()
}
}
} catch (e: Exception) {
runOnUiThread {
Toast.makeText(
this@MainActivity,
"Server-Fehler: ${e.message}",
Toast.LENGTH_LONG
).show()
}
}
}
}
}
}
}).show()
}
/**
* v1.4.0: Setup FAB mit Dropdown für Notiz-Typ Auswahl
*/
private fun setupFab() {
fabAddNote.setOnClickListener { view ->
showNoteTypePopup(view)
}
}
/**
* v1.4.0: Zeigt Popup-Menü zur Auswahl des Notiz-Typs
*/
private fun showNoteTypePopup(anchor: View) {
val popupMenu = PopupMenu(this, anchor, Gravity.END)
popupMenu.inflate(R.menu.menu_fab_note_types)
// Icons im Popup anzeigen (via Reflection, da standardmäßig ausgeblendet)
try {
val fields = popupMenu.javaClass.declaredFields
for (field in fields) {
if ("mPopup" == field.name) {
field.isAccessible = true
val menuPopupHelper = field.get(popupMenu)
val classPopupHelper = Class.forName(menuPopupHelper.javaClass.name)
val setForceIcons = classPopupHelper.getMethod("setForceShowIcon", Boolean::class.java)
setForceIcons.invoke(menuPopupHelper, true)
break
}
}
} catch (e: Exception) {
Logger.w(TAG, "Could not force show icons in popup menu: ${e.message}")
}
popupMenu.setOnMenuItemClickListener { menuItem ->
val noteType = when (menuItem.itemId) {
R.id.action_create_text_note -> NoteType.TEXT
R.id.action_create_checklist -> NoteType.CHECKLIST
else -> return@setOnMenuItemClickListener false
}
val intent = Intent(this, NoteEditorActivity::class.java)
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
startActivity(intent)
true
}
popupMenu.show()
}
private fun loadNotes() {
val notes = storage.loadAllNotes()
adapter.submitList(notes)
// Filter out notes that are pending deletion (prevent flicker)
val filteredNotes = notes.filter { it.id !in pendingDeletions }
// Submit list with callback to scroll to top after list is updated
adapter.submitList(filteredNotes) {
// Scroll to top after list update is complete
// Wichtig: Nach dem Erstellen/Bearbeiten einer Notiz
if (filteredNotes.isNotEmpty()) {
recyclerViewNotes.scrollToPosition(0)
}
}
// Material 3 Empty State Card
emptyStateCard.visibility = if (notes.isEmpty()) {
emptyStateCard.visibility = if (filteredNotes.isEmpty()) {
android.view.View.VISIBLE
} else {
android.view.View.GONE
@@ -295,38 +655,64 @@ 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)
}
private fun triggerManualSync() {
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
if (!SyncStateManager.tryStartSync("manual")) {
return
}
lifecycleScope.launch {
try {
showToast("Starte Synchronisation...")
// Start sync
// Create sync service
val syncService = WebDavSyncService(this@MainActivity)
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
val message = getString(R.string.toast_already_synced)
SyncStateManager.markCompleted(message)
return@launch
}
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable()
}
if (!isReachable) {
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
return@launch
}
// Server ist erreichbar → Sync durchführen
val result = withContext(Dispatchers.IO) {
syncService.syncNotes()
}
// Show result
if (result.isSuccess) {
showToast("Sync erfolgreich: ${result.syncedCount} Notizen")
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
loadNotes() // Reload notes
} else {
showToast("Sync Fehler: ${result.errorMessage}")
SyncStateManager.markError(result.errorMessage)
}
} catch (e: Exception) {
showToast("Sync Fehler: ${e.message}")
SyncStateManager.markError(e.message)
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_main, menu)
optionsMenu = menu // 🔄 v1.3.1: Store reference for sync button state
return true
}
@@ -366,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>,
@@ -377,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

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

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

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

View File

@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.adapters
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -10,10 +11,17 @@ import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.toReadableTime
import dev.dettmer.simplenotes.utils.truncate
/**
* Adapter für die Notizen-Liste
*
* v1.4.0: Unterstützt jetzt TEXT und CHECKLIST Notizen
*/
class NotesAdapter(
private val onNoteClick: (Note) -> Unit
) : ListAdapter<Note, NotesAdapter.NoteViewHolder>(NoteDiffCallback()) {
@@ -29,24 +37,66 @@ class NotesAdapter(
}
inner class NoteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val ivNoteTypeIcon: ImageView = itemView.findViewById(R.id.ivNoteTypeIcon)
private val textViewTitle: TextView = itemView.findViewById(R.id.textViewTitle)
private val textViewContent: TextView = itemView.findViewById(R.id.textViewContent)
private val textViewChecklistPreview: TextView = itemView.findViewById(R.id.textViewChecklistPreview)
private val textViewTimestamp: TextView = itemView.findViewById(R.id.textViewTimestamp)
private val imageViewSyncStatus: ImageView = itemView.findViewById(R.id.imageViewSyncStatus)
fun bind(note: Note) {
textViewTitle.text = note.title.ifEmpty { "Ohne Titel" }
textViewContent.text = note.content.truncate(100)
// Titel
textViewTitle.text = note.title.ifEmpty {
itemView.context.getString(R.string.untitled)
}
textViewTimestamp.text = note.updatedAt.toReadableTime()
// v1.4.0: Typ-spezifische Anzeige
when (note.noteType) {
NoteType.TEXT -> {
ivNoteTypeIcon.setImageResource(R.drawable.ic_note_24)
textViewContent.text = note.content.truncate(100)
textViewContent.visibility = View.VISIBLE
textViewChecklistPreview.visibility = View.GONE
}
NoteType.CHECKLIST -> {
ivNoteTypeIcon.setImageResource(R.drawable.ic_checklist_24)
textViewContent.visibility = View.GONE
textViewChecklistPreview.visibility = View.VISIBLE
// Fortschritt berechnen
val items = note.checklistItems ?: emptyList()
val checkedCount = items.count { it.isChecked }
val totalCount = items.size
textViewChecklistPreview.text = if (totalCount > 0) {
itemView.context.getString(R.string.checklist_progress, checkedCount, totalCount)
} else {
itemView.context.getString(R.string.empty_checklist)
}
}
}
// Sync Icon nur zeigen wenn Sync konfiguriert ist
val prefs = itemView.context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
val isSyncConfigured = !serverUrl.isNullOrEmpty()
if (isSyncConfigured) {
// Sync status icon
val syncIcon = when (note.syncStatus) {
SyncStatus.SYNCED -> android.R.drawable.ic_menu_upload
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
} else {
// Sync nicht konfiguriert → Icon verstecken
imageViewSyncStatus.visibility = View.GONE
}
itemView.setOnClickListener {
onNoteClick(note)

View File

@@ -0,0 +1,416 @@
package dev.dettmer.simplenotes.backup
import android.content.Context
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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
/**
* BackupManager: Lokale Backup & Restore Funktionalität
*
* Features:
* - Backup aller Notizen in JSON-Datei
* - Restore mit 3 Modi (Merge, Replace, Overwrite Duplicates)
* - Auto-Backup vor Restore (Sicherheitsnetz)
* - Backup-Validierung
*/
class BackupManager(private val context: Context) {
companion object {
private const val TAG = "BackupManager"
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, password: String? = null): BackupResult = withContext(Dispatchers.IO) {
return@withContext try {
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")
val backupData = BackupData(
backupVersion = BACKUP_VERSION,
createdAt = System.currentTimeMillis(),
notesCount = allNotes.size,
appVersion = BuildConfig.VERSION_NAME,
notes = allNotes
)
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(dataToWrite)
Logger.d(TAG, "✅ Backup created successfully$encryptedSuffix")
}
BackupResult(
success = true,
notesCount = allNotes.size,
message = "Backup erstellt: ${allNotes.size} Notizen$encryptedSuffix"
)
} catch (e: Exception) {
Logger.e(TAG, "Failed to create backup", e)
BackupResult(
success = false,
error = "Backup fehlgeschlagen: ${e.message}"
)
}
}
/**
* Erstellt automatisches Backup (vor Restore)
* Gespeichert in app-internem Storage
*
* @return Uri des Auto-Backups oder null bei Fehler
*/
suspend fun createAutoBackup(): Uri? = withContext(Dispatchers.IO) {
return@withContext try {
val autoBackupDir = File(context.filesDir, AUTO_BACKUP_DIR).apply {
if (!exists()) mkdirs()
}
val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
.format(Date())
val filename = "auto_backup_before_restore_$timestamp.json"
val file = File(autoBackupDir, filename)
Logger.d(TAG, "📦 Creating auto-backup: ${file.absolutePath}")
val allNotes = storage.loadAllNotes()
val backupData = BackupData(
backupVersion = BACKUP_VERSION,
createdAt = System.currentTimeMillis(),
notesCount = allNotes.size,
appVersion = BuildConfig.VERSION_NAME,
notes = allNotes
)
file.writeText(gson.toJson(backupData))
// Cleanup alte Auto-Backups
cleanupOldAutoBackups(autoBackupDir)
Logger.d(TAG, "✅ Auto-backup created: ${file.absolutePath}")
Uri.fromFile(file)
} catch (e: Exception) {
Logger.e(TAG, "Failed to create auto-backup", e)
null
}
}
/**
* Stellt Notizen aus Backup wieder her
*
* @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, password: String? = null): RestoreResult = withContext(Dispatchers.IO) {
return@withContext try {
Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)")
// 1. Backup-Datei lesen
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 ?: context.getString(R.string.error_invalid_backup_file)
)
}
val backupData = gson.fromJson(jsonString, BackupData::class.java)
Logger.d(TAG, " Backup valid: ${backupData.notesCount} notes, version ${backupData.backupVersion}")
// 3. Auto-Backup erstellen (Sicherheitsnetz)
val autoBackupUri = createAutoBackup()
if (autoBackupUri == null) {
Logger.w(TAG, "⚠️ Auto-backup failed, but continuing with restore")
}
// 4. Restore durchführen (je nach Modus)
val result = when (mode) {
RestoreMode.MERGE -> restoreMerge(backupData.notes)
RestoreMode.REPLACE -> restoreReplace(backupData.notes)
RestoreMode.OVERWRITE_DUPLICATES -> restoreOverwriteDuplicates(backupData.notes)
}
Logger.d(TAG, "✅ Restore completed: ${result.importedNotes} imported, ${result.skippedNotes} skipped")
result
} catch (e: Exception) {
Logger.e(TAG, "Failed to restore backup", e)
RestoreResult(
success = false,
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
*/
private fun validateBackup(jsonString: String): ValidationResult {
return try {
val backupData = gson.fromJson(jsonString, BackupData::class.java)
// Version kompatibel?
if (backupData.backupVersion > BACKUP_VERSION) {
return ValidationResult(
isValid = false,
errorMessage = context.getString(R.string.error_backup_version_unsupported, backupData.backupVersion, BACKUP_VERSION)
)
}
// Notizen-Array vorhanden?
if (backupData.notes.isEmpty()) {
return ValidationResult(
isValid = false,
errorMessage = context.getString(R.string.error_backup_empty)
)
}
// Alle Notizen haben ID, title, content?
val invalidNotes = backupData.notes.filter { note ->
note.id.isBlank() || note.title.isBlank()
}
if (invalidNotes.isNotEmpty()) {
return ValidationResult(
isValid = false,
errorMessage = context.getString(R.string.error_backup_invalid_notes, invalidNotes.size)
)
}
ValidationResult(isValid = true)
} catch (e: Exception) {
ValidationResult(
isValid = false,
errorMessage = context.getString(R.string.error_backup_corrupt, e.message ?: "")
)
}
}
/**
* Restore-Modus: MERGE
* Fügt neue Notizen hinzu, behält bestehende
*/
private fun restoreMerge(backupNotes: List<Note>): RestoreResult {
val existingNotes = storage.loadAllNotes()
val existingIds = existingNotes.map { it.id }.toSet()
val newNotes = backupNotes.filter { it.id !in existingIds }
val skippedNotes = backupNotes.size - newNotes.size
newNotes.forEach { note ->
storage.saveNote(note)
}
return RestoreResult(
success = true,
importedNotes = newNotes.size,
skippedNotes = skippedNotes,
message = context.getString(R.string.restore_merge_result, newNotes.size, skippedNotes)
)
}
/**
* Restore-Modus: REPLACE
* Löscht alle bestehenden Notizen, importiert Backup
*/
private fun restoreReplace(backupNotes: List<Note>): RestoreResult {
// Alle bestehenden Notizen löschen
storage.deleteAllNotes()
// Backup-Notizen importieren
backupNotes.forEach { note ->
storage.saveNote(note)
}
return RestoreResult(
success = true,
importedNotes = backupNotes.size,
skippedNotes = 0,
message = context.getString(R.string.restore_replace_result, backupNotes.size)
)
}
/**
* Restore-Modus: OVERWRITE_DUPLICATES
* Backup überschreibt bei ID-Konflikten
*/
private fun restoreOverwriteDuplicates(backupNotes: List<Note>): RestoreResult {
val existingNotes = storage.loadAllNotes()
val existingIds = existingNotes.map { it.id }.toSet()
val newNotes = backupNotes.filter { it.id !in existingIds }
val overwrittenNotes = backupNotes.filter { it.id in existingIds }
// Alle Backup-Notizen speichern (überschreibt automatisch)
backupNotes.forEach { note ->
storage.saveNote(note)
}
return RestoreResult(
success = true,
importedNotes = newNotes.size,
skippedNotes = 0,
overwrittenNotes = overwrittenNotes.size,
message = context.getString(R.string.restore_overwrite_result, newNotes.size, overwrittenNotes.size)
)
}
/**
* Löscht Auto-Backups älter als RETENTION_DAYS
*/
private fun cleanupOldAutoBackups(autoBackupDir: File) {
try {
val retentionTimeMs = AUTO_BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000L
val cutoffTime = System.currentTimeMillis() - retentionTimeMs
autoBackupDir.listFiles()?.forEach { file ->
if (file.lastModified() < cutoffTime) {
Logger.d(TAG, "🗑️ Deleting old auto-backup: ${file.name}")
file.delete()
}
}
} catch (e: Exception) {
Logger.e(TAG, "Failed to cleanup old backups", e)
}
}
}
/**
* Backup-Daten Struktur (JSON)
* NOTE: Property names use @SerializedName for JSON compatibility with snake_case
*/
data class BackupData(
@com.google.gson.annotations.SerializedName("backup_version")
val backupVersion: Int,
@com.google.gson.annotations.SerializedName("created_at")
val createdAt: Long,
@com.google.gson.annotations.SerializedName("notes_count")
val notesCount: Int,
@com.google.gson.annotations.SerializedName("app_version")
val appVersion: String,
val notes: List<Note>
)
/**
* Wiederherstellungs-Modi
*/
enum class RestoreMode {
MERGE, // Bestehende + Neue (Standard)
REPLACE, // Alles löschen + Importieren
OVERWRITE_DUPLICATES // Backup überschreibt bei ID-Konflikten
}
/**
* Backup-Ergebnis
*/
data class BackupResult(
val success: Boolean,
val notesCount: Int = 0,
val message: String? = null,
val error: String? = null
)
/**
* Restore-Ergebnis
*/
data class RestoreResult(
val success: Boolean,
val importedNotes: Int = 0,
val skippedNotes: Int = 0,
val overwrittenNotes: Int = 0,
val message: String? = null,
val error: String? = null
)
/**
* Validierungs-Ergebnis
*/
data class ValidationResult(
val isValid: Boolean,
val errorMessage: String? = null
)

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

View File

@@ -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

@@ -0,0 +1,81 @@
package dev.dettmer.simplenotes.models
import dev.dettmer.simplenotes.utils.Logger
import org.json.JSONArray
import org.json.JSONObject
data class DeletionRecord(
val id: String,
val deletedAt: Long,
val deviceId: String
)
data class DeletionTracker(
val version: Int = 1,
val deletedNotes: MutableList<DeletionRecord> = mutableListOf()
) {
fun addDeletion(noteId: String, deviceId: String) {
if (!deletedNotes.any { it.id == noteId }) {
deletedNotes.add(DeletionRecord(noteId, System.currentTimeMillis(), deviceId))
}
}
fun isDeleted(noteId: String): Boolean {
return deletedNotes.any { it.id == noteId }
}
fun getDeletionTimestamp(noteId: String): Long? {
return deletedNotes.find { it.id == noteId }?.deletedAt
}
fun removeDeletion(noteId: String) {
deletedNotes.removeIf { it.id == noteId }
}
fun toJson(): String {
val jsonObject = JSONObject()
jsonObject.put("version", version)
val notesArray = JSONArray()
for (record in deletedNotes) {
val recordObj = JSONObject()
recordObj.put("id", record.id)
recordObj.put("deletedAt", record.deletedAt)
recordObj.put("deviceId", record.deviceId)
notesArray.put(recordObj)
}
jsonObject.put("deletedNotes", notesArray)
return jsonObject.toString(2) // Pretty print with 2-space indent
}
companion object {
private const val TAG = "DeletionTracker"
fun fromJson(json: String): DeletionTracker? {
return try {
val jsonObject = JSONObject(json)
val version = jsonObject.optInt("version", 1)
val deletedNotes = mutableListOf<DeletionRecord>()
val notesArray = jsonObject.optJSONArray("deletedNotes")
if (notesArray != null) {
for (i in 0 until notesArray.length()) {
val recordObj = notesArray.getJSONObject(i)
val record = DeletionRecord(
id = recordObj.getString("id"),
deletedAt = recordObj.getLong("deletedAt"),
deviceId = recordObj.getString("deviceId")
)
deletedNotes.add(record)
}
}
DeletionTracker(version, deletedNotes)
} catch (e: Exception) {
Logger.w(TAG, "Failed to parse DeletionTracker JSON: ${e.message}")
null
}
}
}
}

View File

@@ -1,7 +1,19 @@
package dev.dettmer.simplenotes.models
import androidx.compose.runtime.Immutable
import dev.dettmer.simplenotes.utils.Logger
import java.text.SimpleDateFormat
import java.util.Date
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,
@@ -9,30 +21,419 @@ data class Note(
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
val deviceId: String,
val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY
val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY,
// v1.4.0: Checklisten-Felder
val noteType: NoteType = NoteType.TEXT,
val checklistItems: List<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
* 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 {
return """
{
"id": "$id",
"title": "${title.escapeJson()}",
"content": "${content.escapeJson()}",
"createdAt": $createdAt,
"updatedAt": $updatedAt,
"deviceId": "$deviceId",
"syncStatus": "${syncStatus.name}"
val gson = com.google.gson.GsonBuilder()
.setPrettyPrinting()
.create()
// 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}"
} ?: ""
}
/**
* Konvertiert Note zu Markdown mit YAML Frontmatter (Task #1.2.0-08)
* Format kompatibel mit Obsidian, Joplin, Typora
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
*/
fun toMarkdown(): String {
// 🆕 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()}$sortLine
---
# $title
""".trimIndent()
return when (noteType) {
NoteType.TEXT -> header + content
NoteType.CHECKLIST -> {
val checklistMarkdown = checklistItems?.sortedBy { it.order }?.joinToString("\n") { item ->
val checkbox = if (item.isChecked) "[x]" else "[ ]"
"- $checkbox ${item.text}"
} ?: ""
header + checklistMarkdown
}
}
}
companion object {
private const val TAG = "Note"
/**
* Parst JSON zu Note-Objekt mit Backward Compatibility für alte Notizen ohne noteType
*/
fun fromJson(json: String): Note? {
return try {
val gson = com.google.gson.Gson()
gson.fromJson(json, Note::class.java)
val jsonObject = com.google.gson.JsonParser.parseString(json).asJsonObject
// Backward Compatibility: Alte Notizen ohne noteType bekommen TEXT
val noteType = if (jsonObject.has("noteType") && !jsonObject.get("noteType").isJsonNull) {
try {
NoteType.valueOf(jsonObject.get("noteType").asString)
} catch (e: Exception) {
Logger.w(TAG, "Unknown noteType, defaulting to TEXT: ${e.message}")
NoteType.TEXT
}
} else {
NoteType.TEXT
}
// 🆕 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
var checklistItems: List<ChecklistItem>? = if (jsonObject.has("checklistItems") &&
!jsonObject.get("checklistItems").isJsonNull
) {
gson.fromJson<List<ChecklistItem>>(
jsonObject.get("checklistItems"),
checklistItemsType
)
} else {
null
}
// v1.4.1: Recovery-Mode - Falls Checkliste aber keine Items,
// versuche Content als Fallback zu parsen
if (noteType == NoteType.CHECKLIST &&
(checklistItems == null || checklistItems.isEmpty()) &&
rawNote.content.isNotBlank()) {
val recoveredItems = parseChecklistFromContent(rawNote.content)
if (recoveredItems.isNotEmpty()) {
Logger.d(TAG, "🔄 Recovered ${recoveredItems.size} checklist items from content fallback")
checklistItems = recoveredItems
}
}
// Note mit korrekten Werten erstellen
Note(
id = rawNote.id,
title = rawNote.title,
content = rawNote.content,
createdAt = rawNote.createdAt,
updatedAt = rawNote.updatedAt,
deviceId = rawNote.deviceId,
syncStatus = rawNote.syncStatus ?: SyncStatus.LOCAL_ONLY,
noteType = noteType,
checklistItems = checklistItems,
checklistSortOption = checklistSortOption // 🆕 v1.8.1 (IMPL_03)
)
} catch (e: Exception) {
Logger.w(TAG, "Failed to parse JSON: ${e.message}")
null
}
}
/**
* Hilfsklasse für Gson-Parsing mit nullable Feldern
*/
private data class NoteRaw(
val id: String = UUID.randomUUID().toString(),
val title: String = "",
val content: String = "",
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
val deviceId: String = "",
val syncStatus: SyncStatus? = null
)
/**
* 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, serverModifiedTime: Long? = null): Note? {
return try {
// Parse YAML Frontmatter + Markdown Content
val frontmatterRegex = Regex("^---\\n(.+?)\\n---\\n(.*)$", RegexOption.DOT_MATCHES_ALL)
val match = frontmatterRegex.find(md) ?: return null
val yamlBlock = match.groupValues[1]
val contentBlock = match.groupValues[2]
// Parse YAML (einfach per String-Split für MVP)
val metadata = yamlBlock.lines()
.mapNotNull { line ->
val parts = line.split(":", limit = 2)
if (parts.size == 2) {
parts[0].trim() to parts[1].trim()
} else null
}.toMap()
// Extract title from first # heading
val title = contentBlock.lines()
.firstOrNull { it.startsWith("# ") }
?.removePrefix("# ")?.trim() ?: "Untitled"
// v1.4.0: Prüfe ob type: checklist im Frontmatter
val noteTypeStr = metadata["type"]?.lowercase() ?: "text"
val noteType = when (noteTypeStr) {
"checklist" -> NoteType.CHECKLIST
else -> NoteType.TEXT
}
// 🆕 v1.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("# ") }
val contentAfterTitle = if (titleLineIndex >= 0) {
// Alles nach der Titel-Zeile, überspringe führende Leerzeilen
contentBlock.lines()
.drop(titleLineIndex + 1)
.dropWhile { it.isBlank() }
.joinToString("\n")
.trim()
} else {
// Fallback: Gesamter Content (kein Titel gefunden)
contentBlock.trim()
}
val content: String
val checklistItems: List<ChecklistItem>?
if (noteType == NoteType.CHECKLIST) {
// Parse Checklist Items
val checklistRegex = Regex("^- \\[([ xX])\\] (.*)$", RegexOption.MULTILINE)
checklistItems = checklistRegex.findAll(contentAfterTitle).mapIndexed { index, matchResult ->
ChecklistItem(
id = UUID.randomUUID().toString(),
text = matchResult.groupValues[2].trim(),
isChecked = matchResult.groupValues[1].lowercase() == "x",
order = index
)
}.toList().ifEmpty { null }
content = "" // Checklisten haben keinen "content"
} else {
content = contentAfterTitle
checklistItems = null
}
// 🔧 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 = effectiveUpdatedAt,
deviceId = metadata["device"] ?: "desktop",
syncStatus = SyncStatus.SYNCED, // Annahme: Vom Server importiert
noteType = noteType,
checklistItems = checklistItems,
checklistSortOption = checklistSortOption // 🆕 v1.8.1 (IMPL_03)
)
} catch (e: Exception) {
Logger.w(TAG, "Failed to parse Markdown: ${e.message}")
null
}
}
/**
* Formatiert Timestamp zu ISO8601 (Task #1.2.0-10)
* Format: 2024-12-21T18:00:00Z (UTC)
*/
private fun formatISO8601(timestamp: Long): String {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
sdf.timeZone = TimeZone.getTimeZone("UTC")
return sdf.format(Date(timestamp))
}
/**
* 🔧 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 {
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,11 @@
package dev.dettmer.simplenotes.models
/**
* Definiert die verschiedenen Notiz-Typen
*
* v1.4.0: Checklisten-Feature
*/
enum class NoteType {
TEXT, // Normale Text-Notiz (Standard)
CHECKLIST // Checkliste mit abhakbaren Items
}

View File

@@ -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
CONFLICT, // Konflikt erkannt
DELETED_ON_SERVER // 🆕 v1.8.0: Server hat gelöscht, lokal noch vorhanden
}

View File

@@ -1,11 +1,22 @@
package dev.dettmer.simplenotes.storage
import android.content.Context
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 {
if (!exists()) mkdirs()
}
@@ -24,29 +35,148 @@ 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()
}
fun deleteNote(id: String): Boolean {
val file = File(notesDir, "$id.json")
return file.delete()
val deleted = file.delete()
if (deleted) {
Logger.d(TAG, "🗑️ Deleted note: $id")
// Track deletion to prevent zombie notes
val deviceId = DeviceIdGenerator.getDeviceId(context)
trackDeletion(id, deviceId)
}
return deleted
}
fun deleteAllNotes(): Boolean {
return try {
notesDir.listFiles()
?.filter { it.extension == "json" }
?.forEach { it.delete() }
val notes = loadAllNotes()
val deviceId = DeviceIdGenerator.getDeviceId(context)
for (note in notes) {
deleteNote(note.id) // Uses trackDeletion() automatically
}
Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)")
true
} catch (e: Exception) {
Logger.e(TAG, "Failed to delete all notes", e)
false
}
}
// === Deletion Tracking ===
private fun getDeletionTrackerFile(): File {
return File(context.filesDir, "deleted_notes.json")
}
fun loadDeletionTracker(): DeletionTracker {
val file = getDeletionTrackerFile()
if (!file.exists()) {
return DeletionTracker()
}
return try {
val json = file.readText()
DeletionTracker.fromJson(json) ?: DeletionTracker()
} catch (e: Exception) {
Logger.e(TAG, "Failed to load deletion tracker", e)
DeletionTracker()
}
}
fun saveDeletionTracker(tracker: DeletionTracker) {
try {
val file = getDeletionTrackerFile()
file.writeText(tracker.toJson())
if (tracker.deletedNotes.size > 1000) {
Logger.w(TAG, "⚠️ Deletion tracker large: ${tracker.deletedNotes.size} entries")
}
Logger.d(TAG, "✅ Deletion tracker saved (${tracker.deletedNotes.size} entries)")
} catch (e: Exception) {
Logger.e(TAG, "Failed to save deletion tracker", e)
}
}
/**
* 🔒 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)
saveDeletionTracker(tracker)
Logger.d(TAG, "📝 Tracked deletion: $noteId")
}
fun isNoteDeleted(noteId: String): Boolean {
val tracker = loadDeletionTracker()
return tracker.isDeleted(noteId)
}
fun clearDeletionTracker() {
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)")
// 1. WorkManager für periodic sync
startPeriodicSync()
// 2. NetworkCallback für WiFi-Connect Detection
// 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")
}
// 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,
@@ -227,7 +296,11 @@ class NetworkMonitor(private val context: Context) {
if (isWifi) {
lastConnectedNetworkId = activeNetwork.toString()
Logger.d(TAG, " ✅ Initial WiFi network: $lastConnectedNetworkId")
Logger.d(TAG, " 📡 WiFi already connected at startup - onAvailable() will only trigger on network change")
Logger.d(
TAG,
" 📡 WiFi already connected at startup - " +
"onAvailable() will only trigger on network change"
)
} else {
lastConnectedNetworkId = null
Logger.d(TAG, " ⚠️ Not on WiFi at startup")
@@ -268,7 +341,7 @@ class NetworkMonitor(private val context: Context) {
connectivityManager.unregisterNetworkCallback(networkCallback)
Logger.d(TAG, "✅ WiFi monitoring stopped")
} catch (e: Exception) {
// Already unregistered
Logger.w(TAG, "NetworkCallback already unregistered: ${e.message}")
}
}
}

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

@@ -0,0 +1,304 @@
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
*
* 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 (intern für Mutex + PullToRefresh)
*/
enum class SyncState {
IDLE,
SYNCING,
SYNCING_SILENT,
COMPLETED,
ERROR
}
/**
* 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,
val silent: Boolean = false,
val timestamp: Long = System.currentTimeMillis()
)
// Intern: Mutex + PullToRefresh State
private val _syncStatus = MutableLiveData(SyncStatus())
val syncStatus: LiveData<SyncStatus> = _syncStatus
// 🆕 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 (inkl. Silent-Sync)
*/
val isSyncing: Boolean
get() {
val state = _syncStatus.value?.state
return state == SyncState.SYNCING || state == SyncState.SYNCING_SILENT
}
/**
* Versucht einen Sync zu starten.
* Bei silent=false: Setzt sofort PREPARING-Phase → Banner erscheint instant
* Bei silent=true: Setzt silent-Flag → kein Banner wird angezeigt
*/
fun tryStartSync(source: String, silent: Boolean = false): Boolean {
synchronized(lock) {
if (isSyncing) {
Logger.d(TAG, "⚠️ Sync already in progress, rejecting from: $source")
return false
}
val syncState = if (silent) SyncState.SYNCING_SILENT else SyncState.SYNCING
Logger.d(TAG, "🔄 Starting sync from: $source (silent=$silent)")
_syncStatus.postValue(
SyncStatus(
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 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 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)
)
// Fehler immer anzeigen (auch bei Silent-Sync)
_syncProgress.value = SyncProgress(
phase = SyncPhase.ERROR,
resultMessage = errorMessage,
silent = false // Fehler nie silent
)
}
}
/**
* 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
)
}
}
/**
* Inkrementiert den Fortschritt um 1
* Praktisch für Schleifen: nach jedem tatsächlichen Download
*/
fun incrementProgress(currentFileName: String? = null) {
synchronized(lock) {
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,13 +1,21 @@
@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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -21,6 +29,51 @@ 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.
*/
private fun isAppInForeground(): Boolean {
val activityManager = applicationContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val appProcesses = activityManager.runningAppProcesses ?: return false
val packageName = applicationContext.packageName
return appProcesses.any { process ->
process.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND &&
process.processName == packageName
}
}
@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, "═══════════════════════════════════════")
@@ -52,7 +105,105 @@ class SyncWorker(
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 2: Before syncNotes() call")
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
// Spart Batterie + Netzwerk-Traffic + Server-Last
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ No local changes - skipping sync (performance optimization)")
Logger.d(TAG, " Saves battery, network traffic, and server load")
if (BuildConfig.DEBUG) {
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (no changes to sync)")
Logger.d(TAG, "═══════════════════════════════════════")
}
return@withContext Result.success()
}
if (BuildConfig.DEBUG) {
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
// Verhindert Fehler-Notifications in fremden WiFi-Netzen
// Wartet bis Netzwerk bereit ist (DHCP, Routing, Gateway)
if (!syncService.isServerReachable()) {
Logger.d(TAG, "⏭️ Server not reachable - skipping sync (no error)")
Logger.d(TAG, " Reason: Server offline/wrong network/network not ready/not configured")
Logger.d(TAG, " This is normal in foreign WiFi or during network initialization")
// 🔥 v1.1.2: Check if we should show warning (server unreachable for >24h)
checkAndShowSyncWarning(syncService)
if (BuildConfig.DEBUG) {
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (silent skip)")
Logger.d(TAG, "═══════════════════════════════════════")
}
// Success zurückgeben (kein Fehler, Server ist halt nicht erreichbar)
return@withContext Result.success()
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 6: Server reachable - proceeding with sync")
Logger.d(TAG, " SyncService: $syncService")
}
@@ -73,18 +224,30 @@ class SyncWorker(
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 3: Processing result")
Logger.d(TAG, "📦 Sync result: success=${result.isSuccess}, count=${result.syncedCount}, error=${result.errorMessage}")
Logger.d(TAG, "📍 Step 7: Processing result")
Logger.d(
TAG,
"📦 Sync result: success=${result.isSuccess}, " +
"count=${result.syncedCount}, error=${result.errorMessage}"
)
}
if (result.isSuccess) {
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 4: 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) {
val appInForeground = isAppInForeground()
if (appInForeground) {
Logger.d(TAG, " App in foreground - skipping notification (UI shows changes)")
} else {
if (BuildConfig.DEBUG) {
Logger.d(TAG, " Showing success notification...")
}
@@ -92,6 +255,7 @@ class SyncWorker(
applicationContext,
result.syncedCount
)
}
} else {
Logger.d(TAG, " No changes to sync - no notification")
}
@@ -102,6 +266,20 @@ class SyncWorker(
}
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, "═══════════════════════════════════════")
@@ -109,9 +287,13 @@ class SyncWorker(
Result.success()
} else {
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 4: 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"
@@ -126,6 +308,32 @@ class SyncWorker(
}
Result.failure()
}
} catch (e: CancellationException) {
// ⭐ Job wurde gecancelt - KEIN FEHLER!
// Gründe: App-Update, Doze Mode, Battery Optimization, Network Constraint, etc.
if (BuildConfig.DEBUG) {
Logger.d(TAG, "═══════════════════════════════════════")
}
Logger.d(TAG, "⏹️ Job was cancelled (normal - update/doze/constraints)")
Logger.d(TAG, " Reason could be: App update, Doze mode, Battery opt, Network disconnect")
Logger.d(TAG, " This is expected Android behavior - not an error!")
try {
// UI-Refresh trotzdem triggern (falls MainActivity geöffnet)
broadcastSyncCompleted(false, 0)
} catch (broadcastError: Exception) {
Logger.e(TAG, "Failed to broadcast after cancellation", broadcastError)
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (cancelled, no error)")
Logger.d(TAG, "═══════════════════════════════════════")
}
// ⚠️ WICHTIG: Result.success() zurückgeben!
// Cancellation ist KEIN Fehler, WorkManager soll nicht retries machen
Result.success()
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
Logger.d(TAG, "═══════════════════════════════════════")
@@ -160,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)
@@ -168,4 +377,69 @@ class SyncWorker(
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent)
Logger.d(TAG, "📡 Broadcast sent: success=$success, count=$count")
}
/**
* Prüft ob Server längere Zeit unreachable und zeigt ggf. Warnung (v1.1.2)
* - Nur wenn Auto-Sync aktiviert
* - Nur wenn schon mal erfolgreich gesynct
* - Nur wenn >24h seit letztem erfolgreichen Sync
* - Throttling: Max. 1 Warnung pro 24h
*/
private fun checkAndShowSyncWarning(syncService: WebDavSyncService) {
try {
val prefs = applicationContext.getSharedPreferences(
dev.dettmer.simplenotes.utils.Constants.PREFS_NAME,
android.content.Context.MODE_PRIVATE
)
// Check 1: Auto-Sync aktiviert?
val autoSyncEnabled = prefs.getBoolean(
dev.dettmer.simplenotes.utils.Constants.KEY_AUTO_SYNC,
false
)
if (!autoSyncEnabled) {
Logger.d(TAG, "⏭️ Auto-Sync disabled - no warning needed")
return
}
// Check 2: Schon mal erfolgreich gesynct?
val lastSuccessfulSync = syncService.getLastSuccessfulSyncTimestamp()
if (lastSuccessfulSync == 0L) {
Logger.d(TAG, "⏭️ Never synced successfully - no warning needed")
return
}
// Check 3: >24h seit letztem erfolgreichen Sync?
val now = System.currentTimeMillis()
val timeSinceLastSync = now - lastSuccessfulSync
if (timeSinceLastSync < dev.dettmer.simplenotes.utils.Constants.SYNC_WARNING_THRESHOLD_MS) {
Logger.d(TAG, "⏭️ Last successful sync <24h ago - no warning needed")
return
}
// Check 4: Throttling - schon Warnung in letzten 24h gezeigt?
val lastWarningShown = prefs.getLong(
dev.dettmer.simplenotes.utils.Constants.KEY_LAST_SYNC_WARNING_SHOWN,
0L
)
if (now - lastWarningShown < dev.dettmer.simplenotes.utils.Constants.SYNC_WARNING_THRESHOLD_MS) {
Logger.d(TAG, "⏭️ Warning already shown in last 24h - throttling")
return
}
// Zeige Warnung
val hoursSinceLastSync = timeSinceLastSync / (1000 * 60 * 60)
NotificationHelper.showSyncWarning(applicationContext, hoursSinceLastSync)
// Speichere Zeitpunkt der Warnung
prefs.edit()
.putLong(dev.dettmer.simplenotes.utils.Constants.KEY_LAST_SYNC_WARNING_SHOWN, now)
.apply()
Logger.d(TAG, "⚠️ Sync warning shown: Server unreachable for ${hoursSinceLastSync}h")
} catch (e: Exception) {
Logger.e(TAG, "Failed to check/show sync warning", e)
}
}
}

View File

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

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
}

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