73 Commits

Author SHA1 Message Date
7ddad7e5e7 mid commit 2026-02-18 00:45:02 +00:00
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
164 changed files with 11920 additions and 2162 deletions

View File

@@ -9,3 +9,6 @@ contact_links:
- name: "🐛 Troubleshooting" - name: "🐛 Troubleshooting"
url: https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md#troubleshooting url: https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md#troubleshooting
about: Häufige Probleme und Lösungen / Common issues and solutions about: Häufige Probleme und Lösungen / Common issues and solutions
- name: "✨ Feature Requests & Ideas"
url: https://github.com/inventory69/simple-notes-sync/discussions/categories/ideas
about: Diskutiere neue Features in Discussions / Discuss new features in Discussions

View File

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

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

View File

@@ -18,7 +18,7 @@ jobs:
distribution: 'temurin' distribution: 'temurin'
java-version: '17' java-version: '17'
- name: Gradle Cache - name: Gradle Cache
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -33,6 +33,31 @@ jobs:
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV
echo "📱 Version: $VERSION_NAME (Code: $VERSION_CODE)" echo "📱 Version: $VERSION_NAME (Code: $VERSION_CODE)"
# 🔍 Code Quality Checks (v1.6.1)
- name: Run detekt (Code Quality)
run: |
cd android
./gradlew detekt --no-daemon
continue-on-error: false
- name: Run ktlint (Code Style)
run: |
cd android
./gradlew ktlintCheck --no-daemon
continue-on-error: true # Parser-Probleme in Legacy-Code
- name: Upload Lint Reports
if: always()
uses: actions/upload-artifact@v4
with:
name: lint-reports-pr-${{ github.event.pull_request.number }}
path: |
android/app/build/reports/detekt/
android/app/build/reports/ktlint/
android/app/build/reports/lint-results*.html
retention-days: 7
- name: Debug Build erstellen (ohne Signing) - name: Debug Build erstellen (ohne Signing)
run: | run: |
cd android cd android
@@ -44,14 +69,14 @@ jobs:
continue-on-error: true continue-on-error: true
- name: Build-Ergebnis pruefen - name: Build-Ergebnis pruefen
run: | run: |
if [ -f "android/app/build/outputs/apk/standard/debug/app-standard-universal-debug.apk" ]; then if [ -f "android/app/build/outputs/apk/standard/debug/app-standard-debug.apk" ]; then
echo "✅ Standard Debug APK erfolgreich gebaut" echo "✅ Standard Debug APK erfolgreich gebaut"
ls -lh android/app/build/outputs/apk/standard/debug/*.apk ls -lh android/app/build/outputs/apk/standard/debug/*.apk
else else
echo "❌ Standard Debug APK Build fehlgeschlagen" echo "❌ Standard Debug APK Build fehlgeschlagen"
exit 1 exit 1
fi fi
if [ -f "android/app/build/outputs/apk/fdroid/debug/app-fdroid-universal-debug.apk" ]; then if [ -f "android/app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk" ]; then
echo "✅ F-Droid Debug APK erfolgreich gebaut" echo "✅ F-Droid Debug APK erfolgreich gebaut"
ls -lh android/app/build/outputs/apk/fdroid/debug/*.apk ls -lh android/app/build/outputs/apk/fdroid/debug/*.apk
else else

1
.gitignore vendored
View File

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

View File

@@ -8,6 +8,415 @@ Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
--- ---
## [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 ## [1.6.0] - 2026-01-19
### 🎉 Major: Konfigurierbare Sync-Trigger ### 🎉 Major: Konfigurierbare Sync-Trigger
@@ -453,8 +862,8 @@ Das komplette UI wurde von XML-Views auf Jetpack Compose migriert. Die App ist j
### Documentation ### Documentation
- Added WebDAV mount instructions (Windows, macOS, Linux) - Added WebDAV mount instructions (Windows, macOS, Linux)
- Created [SYNC_ARCHITECTURE.md](../project-docs/simple-notes-sync/architecture/SYNC_ARCHITECTURE.md) - Complete sync documentation - Complete sync architecture documentation
- Created [MARKDOWN_DESKTOP_REALITY_CHECK.md](../project-docs/simple-notes-sync/markdown-desktop-plan/MARKDOWN_DESKTOP_REALITY_CHECK.md) - Desktop integration analysis - Desktop integration analysis
--- ---
@@ -559,6 +968,21 @@ Das komplette UI wurde von XML-Views auf Jetpack Compose migriert. Die App ist j
--- ---
[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.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.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.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.1

View File

@@ -8,6 +8,415 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
--- ---
## [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 ## [1.6.0] - 2026-01-19
### 🎉 Major: Configurable Sync Triggers ### 🎉 Major: Configurable Sync Triggers
@@ -452,8 +861,8 @@ The complete UI has been migrated from XML Views to Jetpack Compose. The app is
### Documentation ### Documentation
- Added WebDAV mount instructions (Windows, macOS, Linux) - Added WebDAV mount instructions (Windows, macOS, Linux)
- Created [SYNC_ARCHITECTURE.md](../project-docs/simple-notes-sync/architecture/SYNC_ARCHITECTURE.md) - Complete sync documentation - Complete sync architecture documentation
- Created [MARKDOWN_DESKTOP_REALITY_CHECK.md](../project-docs/simple-notes-sync/markdown-desktop-plan/MARKDOWN_DESKTOP_REALITY_CHECK.md) - Desktop integration analysis - Desktop integration analysis
--- ---
@@ -558,6 +967,21 @@ The complete UI has been migrated from XML Views to Jetpack Compose. The app is
--- ---
[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.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.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.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.1

View File

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

View File

@@ -8,7 +8,7 @@
## Voraussetzungen ## Voraussetzungen
- ✅ Android 8.0+ Smartphone/Tablet - ✅ Android 7.0+ Smartphone/Tablet
- ✅ WLAN-Verbindung - ✅ WLAN-Verbindung
- ✅ Eigener Server mit Docker (optional - für Self-Hosting) - ✅ Eigener Server mit Docker (optional - für Self-Hosting)
@@ -52,7 +52,7 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
### Schritt 2: App installieren ### Schritt 2: App installieren
1. **APK herunterladen:** [Neueste Version](https://github.com/inventory69/simple-notes-sync/releases/latest) 1. **APK herunterladen:** [Neueste Version](https://github.com/inventory69/simple-notes-sync/releases/latest)
- Wähle: `simple-notes-sync-vX.X.X-standard-universal.apk` - Wähle: `simple-notes-sync-vX.X.X-standard.apk`
2. **Installation erlauben:** 2. **Installation erlauben:**
- Android: Einstellungen → Sicherheit → "Unbekannte Quellen" für deinen Browser aktivieren - Android: Einstellungen → Sicherheit → "Unbekannte Quellen" für deinen Browser aktivieren
@@ -261,7 +261,7 @@ Für zuverlässigen Auto-Sync:
## 🆘 Weitere Hilfe ## 🆘 Weitere Hilfe
- **GitHub Issues:** [Problem melden](https://github.com/inventory69/simple-notes-sync/issues) - **GitHub Issues:** [Problem melden](https://github.com/inventory69/simple-notes-sync/issues)
- **Vollständige Docs:** [DOCS.md](DOCS.md) - **Vollständige Docs:** [DOCS.md](docs/DOCS.md)
- **Server Setup Details:** [server/README.md](server/README.md) - **Server Setup Details:** [server/README.md](server/README.md)
--- ---

View File

@@ -8,7 +8,7 @@
## Prerequisites ## Prerequisites
- ✅ Android 8.0+ smartphone/tablet - ✅ Android 7.0+ smartphone/tablet
- ✅ WiFi connection - ✅ WiFi connection
- ✅ Own server with Docker (optional - for self-hosting) - ✅ Own server with Docker (optional - for self-hosting)
@@ -52,7 +52,7 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
### Step 2: Install App ### Step 2: Install App
1. **Download APK:** [Latest version](https://github.com/inventory69/simple-notes-sync/releases/latest) 1. **Download APK:** [Latest version](https://github.com/inventory69/simple-notes-sync/releases/latest)
- Choose: `simple-notes-sync-vX.X.X-standard-universal.apk` - Choose: `simple-notes-sync-vX.X.X-standard.apk`
2. **Allow installation:** 2. **Allow installation:**
- Android: Settings → Security → Enable "Unknown sources" for your browser - Android: Settings → Security → Enable "Unknown sources" for your browser
@@ -77,7 +77,7 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
> **💡 Note:** Enter only the base URL (without `/notes`). The app automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown export. > **💡 Note:** Enter only the base URL (without `/notes`). The app automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown export.
4. **Press "Test connection"**** 4. **Press "Test connection"**
- ✅ Success? → Continue to step 4 - ✅ Success? → Continue to step 4
- ❌ Error? → See [Troubleshooting](#troubleshooting) - ❌ Error? → See [Troubleshooting](#troubleshooting)
@@ -261,8 +261,8 @@ For reliable auto-sync:
## 🆘 Further Help ## 🆘 Further Help
- **GitHub Issues:** [Report problem](https://github.com/inventory69/simple-notes-sync/issues) - **GitHub Issues:** [Report problem](https://github.com/inventory69/simple-notes-sync/issues)
- **Complete docs:** [DOCS.en.md](DOCS.en.md) - **Complete docs:** [DOCS.md](docs/DOCS.md)
- **Server setup details:** [server/README.en.md](server/README.en.md) - **Server setup details:** [server/README.md](server/README.md)
--- ---

View File

@@ -1,48 +1,82 @@
# Simple Notes Sync 📝 <div align="center">
<img src="android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png" alt="Logo" />
</div>
> Minimalistische Offline-Notizen mit Auto-Sync zu deinem eigenen Server <h1 align="center">Simple Notes Sync</h1>
[![Android](https://img.shields.io/badge/Android-8.0%2B-green.svg)](https://www.android.com/) <h4 align="center">Minimalistische Offline-Notizen mit intelligentem Sync - Einfachheit trifft smarte Synchronisation.</h4>
[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes) <div align="center">
[<img src="https://f-droid.org/badge/get-it-on-de.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/dev.dettmer.simplenotes/)
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](docs/DOCS.de.md)** · **🚀 [Quick Start](QUICKSTART.de.md)** [![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)
**🌍 Sprachen:** **Deutsch** · [English](README.md) </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 ## 📱 Screenshots
<p align="center"> <p align="center">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/6.png" width="250" alt="Sync-Status"> <img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.png" width="250" alt="Sync status">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.png" width="250" alt="Notiz bearbeiten"> <img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.png" width="250" alt="Edit note">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.png" width="250" alt="Checkliste bearbeiten"> <img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.png" width="250" alt="Edit checklist">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/4.png" width="250" alt="Einstellungen"> <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-Einstellungen"> <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-Einstellungen"> <img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/7.png" width="250" alt="Sync settings">
</p> </p>
--- <div align="center">
📝 Offline-first &nbsp;&nbsp; 🔄 Smart Sync &nbsp;&nbsp; 🔒 Self-hosted &nbsp;&nbsp; 🔋 Akkuschonend
</div>
## ✨ Highlights ## ✨ Highlights
- **NEU: Checklisten** - Tap-to-Check, Drag & Drop - 📝 **Offline-first** Funktioniert ohne Internet
- 🌍 **NEU: Mehrsprachig** - Deutsch/Englisch mit Sprachauswahl - 📊 **Flexible Ansichten** Listen- und Grid-Layout
- 📝 **Offline-First** - Funktioniert ohne Internet - **Checklisten** Tap-to-Check, Drag & Drop
- 🔄 **Konfigurierbare Sync-Trigger** - onSave, onResume, WiFi-Verbindung, periodisch (15/30/60 Min), Boot - 🔄 **Konfigurierbare Sync-Trigger** onSave, onResume, WiFi, periodisch (15/30/60 Min), Boot
- 🔒 **Self-Hosted** - Deine Daten bleiben bei dir (WebDAV) - 📌 **Widgets** Home-Screen Quick-Note und Notizlisten-Widget
- 💾 **Lokales Backup** - Export/Import als JSON-Datei - 🔀 **Smartes Sortieren** Nach Titel, Änderungsdatum, Erstelldatum, Farbe
- 🖥️ **Desktop-Integration** - Markdown-Export für Obsidian, VS Code, Typora - **Paralleler Sync** Lädt bis zu 5 Notizen gleichzeitig herunter
- 🔋 **Akkuschonend** - ~0.2% mit Defaults, bis zu ~1.0% mit Periodic Sync - 🌍 **Mehrsprachig** Deutsch/Englisch mit Sprachauswahl
- 🎨 **Material Design 3** - Dark Mode & Dynamic Colors - 🔒 **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:** [FEATURES.de.md](docs/FEATURES.de.md) ➡️ **Vollständige Feature-Liste:** [docs/FEATURES.de.md](docs/FEATURES.de.md)
---
## 🚀 Schnellstart ## 🚀 Schnellstart
@@ -63,17 +97,15 @@ docker compose up -d
1. [APK herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest) 1. [APK herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest)
2. Installieren & öffnen 2. Installieren & öffnen
3. ⚙️ Einstellungen → Server konfigurieren: 3. ⚙️ Einstellungen → Server konfigurieren:
- **URL:** `http://DEINE-SERVER-IP:8080/` _(nur Base-URL!)_ - **URL:** `http://DEINE-SERVER-IP:8080/` _(nur Base-URL!)_
- **User:** `noteuser` - **User:** `noteuser`
- **Passwort:** _(aus .env)_ - **Passwort:** _(aus .env)_
- **WLAN:** _(dein Netzwerk-Name)_ - **WLAN:** _(dein Netzwerk-Name)_
4. **Verbindung testen** → Auto-Sync aktivieren 4. **Verbindung testen** → Auto-Sync aktivieren
5. Fertig! 🎉 5. Fertig! 🎉
➡️ **Ausführliche Anleitung:** [QUICKSTART.de.md](QUICKSTART.de.md) ➡️ **Ausführliche Anleitung:** [QUICKSTART.de.md](QUICKSTART.de.md)
---
## 📚 Dokumentation ## 📚 Dokumentation
| Dokument | Inhalt | | Dokument | Inhalt |
@@ -82,13 +114,12 @@ docker compose up -d
| **[FEATURES.de.md](docs/FEATURES.de.md)** | Vollständige Feature-Liste | | **[FEATURES.de.md](docs/FEATURES.de.md)** | Vollständige Feature-Liste |
| **[BACKUP.de.md](docs/BACKUP.de.md)** | Backup & Wiederherstellung | | **[BACKUP.de.md](docs/BACKUP.de.md)** | Backup & Wiederherstellung |
| **[DESKTOP.de.md](docs/DESKTOP.de.md)** | Desktop-Integration (Markdown) | | **[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 | | **[DOCS.de.md](docs/DOCS.de.md)** | Technische Details & Troubleshooting |
| **[CHANGELOG.de.md](CHANGELOG.de.md)** | Versionshistorie | | **[CHANGELOG.de.md](CHANGELOG.de.md)** | Versionshistorie |
| **[UPCOMING.de.md](docs/UPCOMING.de.md)** | Geplante Features 🚀 | | **[UPCOMING.de.md](docs/UPCOMING.de.md)** | Geplante Features 🚀 |
| **[ÜBERSETZEN.md](docs/TRANSLATING.de.md)** | Übersetzungsanleitung 🌍 | | **[ÜBERSETZEN.md](docs/TRANSLATING.de.md)** | Übersetzungsanleitung 🌍 |
---
## 🛠️ Entwicklung ## 🛠️ Entwicklung
```bash ```bash
@@ -96,20 +127,19 @@ cd android
./gradlew assembleStandardRelease ./gradlew assembleStandardRelease
``` ```
➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md#-build--deployment) ➡️ **Build-Anleitung:** [docs/DOCS.de.md#-build--deployment](docs/DOCS.de.md#-build--deployment)
---
## 🤝 Contributing ## 🤝 Contributing
Beiträge willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md) Beiträge willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md)
---
## 📄 Lizenz ## 📄 Lizenz
MIT License - siehe [LICENSE](LICENSE) MIT License siehe [LICENSE](LICENSE)
--- <div align="center">
<br /><br />
**v1.6.0** · Built with ❤️ using Kotlin + Material Design 3 **v1.8.1** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
</div>

View File

@@ -1,24 +1,53 @@
# Simple Notes Sync 📝 <div align="center">
<img src="android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png" alt="Logo" />
</div>
> Minimalist offline notes with auto-sync to your own server <h1 align="center">Simple Notes Sync</h1>
[![Android](https://img.shields.io/badge/Android-8.0%2B-green.svg)](https://www.android.com/) <h4 align="center">Clean, offline-first notes with intelligent sync - simplicity meets smart synchronization.</h4>
[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes) <div align="center">
[<img src="https://f-droid.org/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/dev.dettmer.simplenotes/)
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Documentation](docs/DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)** [![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)
**🌍 Languages:** [Deutsch](README.de.md) · **English** </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 of the signing certificate:</strong><br />42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
</div>
<div align="center">
<br />[📱 APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest) · [📖 Documentation](docs/DOCS.md) · [🚀 Quick Start](QUICKSTART.md)<br />
**🌍** [Deutsch](README.de.md) · **English**
</div>
## 📱 Screenshots ## 📱 Screenshots
<p align="center"> <p align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/6.png" width="250" alt="Sync status"> <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/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/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/4.png" width="250" alt="Settings">
@@ -26,24 +55,29 @@
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/7.png" width="250" alt="Sync settings"> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/7.png" width="250" alt="Sync settings">
</p> </p>
--- <div align="center">
📝 Offline-first &nbsp;&nbsp; 🔄 Smart Sync &nbsp;&nbsp; 🔒 Self-hosted &nbsp;&nbsp; 🔋 Battery-friendly
</div>
## ✨ Highlights ## ✨ Highlights
-**NEW: Checklists** - Tap-to-check, drag & drop
- 🌍 **NEW: Multilingual** - English/German with language selector
- 📝 **Offline-first** - Works without internet - 📝 **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 - 🔄 **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) - 🔒 **Self-hosted** - Your data stays with you (WebDAV)
- 💾 **Local backup** - Export/Import as JSON file - 💾 **Local backup** - Export/Import as JSON file (encryption available)
- 🖥️ **Desktop integration** - Markdown export for Obsidian, VS Code, Typora - 🖥️ **Desktop integration** - Markdown export for Obsidian, VS Code, Typora
- 🔋 **Battery-friendly** - ~0.2% with defaults, up to ~1.0% with periodic sync - 🎨 **Material Design 3** - Dynamic dark/light mode & colors based on system settings
- 🎨 **Material Design 3** - Dark mode & dynamic colors
➡️ **Complete feature list:** [FEATURES.md](docs/FEATURES.md) ➡️ **Complete feature list:** [FEATURES.md](docs/FEATURES.md)
---
## 🚀 Quick Start ## 🚀 Quick Start
### 1. Server Setup (5 minutes) ### 1. Server Setup (5 minutes)
@@ -72,8 +106,6 @@ docker compose up -d
➡️ **Detailed guide:** [QUICKSTART.md](QUICKSTART.md) ➡️ **Detailed guide:** [QUICKSTART.md](QUICKSTART.md)
---
## 📚 Documentation ## 📚 Documentation
| Document | Content | | Document | Content |
@@ -82,6 +114,7 @@ docker compose up -d
| **[FEATURES.md](docs/FEATURES.md)** | Complete feature list | | **[FEATURES.md](docs/FEATURES.md)** | Complete feature list |
| **[BACKUP.md](docs/BACKUP.md)** | Backup & restore guide | | **[BACKUP.md](docs/BACKUP.md)** | Backup & restore guide |
| **[DESKTOP.md](docs/DESKTOP.md)** | Desktop integration (Markdown) | | **[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 | | **[DOCS.md](docs/DOCS.md)** | Technical details & troubleshooting |
| **[CHANGELOG.md](CHANGELOG.md)** | Version history | | **[CHANGELOG.md](CHANGELOG.md)** | Version history |
| **[UPCOMING.md](docs/UPCOMING.md)** | Upcoming features 🚀 | | **[UPCOMING.md](docs/UPCOMING.md)** | Upcoming features 🚀 |
@@ -94,18 +127,29 @@ cd android
➡️ **Build guide:** [DOCS.md](docs/DOCS.md#-build--deployment) ➡️ **Build guide:** [DOCS.md](docs/DOCS.md#-build--deployment)
--- ## 💡 Feature Requests & Ideas
Have an idea for a new feature or improvement? We'd love to hear it!
➡️ **How to suggest features:**
1. Check [existing discussions](https://github.com/inventory69/simple-notes-sync/discussions) to see if someone already suggested it
2. If not, start a new discussion in the "Feature Requests / Ideas" category
3. Upvote (👍) features you'd like to see
Features with enough community support will be considered for implementation. Please keep in mind that this app is designed to stay simple and user-friendly.
## 🤝 Contributing ## 🤝 Contributing
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)
---
## 📄 License ## 📄 License
MIT License - see [LICENSE](LICENSE) MIT License - see [LICENSE](LICENSE)
--- <div align="center">
<br /><br />
**v1.6.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3 **v1.8.1** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
</div>

1
android/.gitignore vendored
View File

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

View File

@@ -2,9 +2,9 @@ plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler
// v1.3.1: ktlint deaktiviert wegen Parser-Problemen, aktivieren in v1.4.0 alias(libs.plugins.ktlint) // v1.6.1: Reaktiviert nach Code-Cleanup
// alias(libs.plugins.ktlint)
alias(libs.plugins.detekt) alias(libs.plugins.detekt)
alias(libs.plugins.ksp)
} }
import java.util.Properties import java.util.Properties
@@ -21,8 +21,8 @@ android {
applicationId = "dev.dettmer.simplenotes" applicationId = "dev.dettmer.simplenotes"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 14 // 🔧 v1.6.0: Configurable Sync Triggers versionCode = 21 // 🐛 v1.8.1: Checklist Fixes, Widget Sorting, ProGuard Audit
versionName = "1.6.0" // 🔧 v1.6.0: Configurable Sync Triggers versionName = "1.8.1" // 🐛 v1.8.1: Bugfix & Polish Release
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -100,11 +100,15 @@ android {
compose = true // v1.5.0: Jetpack Compose für Settings Redesign compose = true // v1.5.0: Jetpack Compose für Settings Redesign
} }
// v1.5.0 Hotfix: Strong Skipping Mode für bessere 120Hz Performance // v1.7.0: Mock Android framework classes in unit tests (Log, etc.)
composeCompiler { testOptions {
enableStrongSkippingMode = true 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 { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
@@ -142,6 +146,9 @@ dependencies {
// SwipeRefreshLayout für Pull-to-Refresh // SwipeRefreshLayout für Pull-to-Refresh
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
// 🔐 v1.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 // v1.5.0: Jetpack Compose für Settings Redesign
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -156,24 +163,42 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
// Koin
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
// Room
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// ═══════════════════════════════════════════════════════════════════════
// 🆕 v1.8.0: Homescreen Widgets
// ═══════════════════════════════════════════════════════════════════════
implementation("androidx.glance:glance-appwidget:1.1.1")
implementation("androidx.glance:glance-material3:1.1.1")
// Testing (bleiben so) // Testing (bleiben so)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
} }
// v1.3.1: ktlint deaktiviert wegen Parser-Problemen // v1.6.1: ktlint reaktiviert nach Code-Cleanup
// Aktivieren in v1.4.0 wenn Code-Stil bereinigt wurde ktlint {
// ktlint { android = true
// android = true outputToConsole = true
// outputToConsole = true ignoreFailures = true // Parser-Probleme in WebDavSyncService.kt und build.gradle.kts
// ignoreFailures = true enableExperimentalRules = false
// enableExperimentalRules = false
// filter { filter {
// exclude("**/generated/**") exclude("**/generated/**")
// exclude("**/build/**") exclude("**/build/**")
// } // Legacy adapters with ktlint parser issues
// } exclude("**/adapters/NotesAdapter.kt")
exclude("**/SettingsActivity.kt")
}
}
// ⚡ v1.3.1: detekt-Konfiguration // ⚡ v1.3.1: detekt-Konfiguration
detekt { detekt {
@@ -185,3 +210,33 @@ detekt {
// Parallel-Verarbeitung für schnellere Checks // Parallel-Verarbeitung für schnellere Checks
parallel = true 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.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer -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.** { *; } -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

@@ -12,6 +12,11 @@
<!-- Battery Optimization (for WorkManager background sync) --> <!-- Battery Optimization (for WorkManager background sync) -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <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! --> <!-- BOOT Permission - CRITICAL für Auto-Sync nach Neustart! -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -64,8 +69,10 @@
android:parentActivityName=".ui.main.ComposeMainActivity" /> android:parentActivityName=".ui.main.ComposeMainActivity" />
<!-- Settings Activity v1.5.0 (Jetpack Compose) --> <!-- Settings Activity v1.5.0 (Jetpack Compose) -->
<!-- v1.8.0: Handle locale changes without recreate for smooth language switching -->
<activity <activity
android:name=".ui.settings.ComposeSettingsActivity" android:name=".ui.settings.ComposeSettingsActivity"
android:configChanges="locale|layoutDirection"
android:parentActivityName=".ui.main.ComposeMainActivity" android:parentActivityName=".ui.main.ComposeMainActivity"
android:theme="@style/Theme.SimpleNotes" /> android:theme="@style/Theme.SimpleNotes" />
@@ -91,6 +98,31 @@
android:resource="@xml/file_paths" /> android:resource="@xml/file_paths" />
</provider> </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> </application>
</manifest> </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 package dev.dettmer.simplenotes
import android.Manifest import android.Manifest
@@ -34,8 +36,6 @@ import android.widget.CheckBox
import android.widget.Toast import android.widget.Toast
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.WebDavSyncService
@@ -48,6 +48,11 @@ import android.view.Gravity
import android.widget.PopupMenu import android.widget.PopupMenu
import dev.dettmer.simplenotes.models.NoteType import dev.dettmer.simplenotes.models.NoteType
/**
* Legacy MainActivity - DEPRECATED seit v1.5.0, wird entfernt in v2.0.0
* Ersetzt durch ComposeMainActivity
*/
@Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var recyclerViewNotes: RecyclerView private lateinit var recyclerViewNotes: RecyclerView
@@ -126,6 +131,9 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermission() requestNotificationPermission()
} }
// 🌍 v1.7.2: Debug Locale für Fehlersuche
logLocaleInfo()
findViews() findViews()
setupToolbar() setupToolbar()
setupRecyclerView() setupRecyclerView()
@@ -385,13 +393,13 @@ class MainActivity : AppCompatActivity() {
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check") Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
SyncStateManager.markCompleted("Bereits synchronisiert") SyncStateManager.markCompleted(getString(R.string.snackbar_already_synced))
return@launch return@launch
} }
// Check if server is reachable // Check if server is reachable
if (!syncService.isServerReachable()) { if (!syncService.isServerReachable()) {
SyncStateManager.markError("Server nicht erreichbar") SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
return@launch return@launch
} }
@@ -399,7 +407,7 @@ class MainActivity : AppCompatActivity() {
val result = syncService.syncNotes() val result = syncService.syncNotes()
if (result.isSuccess) { if (result.isSuccess) {
SyncStateManager.markCompleted("${result.syncedCount} Notizen") SyncStateManager.markCompleted(getString(R.string.snackbar_synced_count, result.syncedCount))
loadNotes() loadNotes()
} else { } else {
SyncStateManager.markError(result.errorMessage) SyncStateManager.markError(result.errorMessage)
@@ -665,7 +673,8 @@ class MainActivity : AppCompatActivity() {
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping") Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
SyncStateManager.markCompleted("Bereits synchronisiert") val message = getString(R.string.toast_already_synced)
SyncStateManager.markCompleted(message)
return@launch return@launch
} }
@@ -676,7 +685,7 @@ class MainActivity : AppCompatActivity() {
if (!isReachable) { if (!isReachable) {
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting") Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
SyncStateManager.markError("Server nicht erreichbar") SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
return@launch return@launch
} }
@@ -807,4 +816,39 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
/**
* 🌍 v1.7.2: Debug-Logging für Locale-Problem
* Hilft zu identifizieren warum deutsche Strings trotz englischer App angezeigt werden
*/
private fun logLocaleInfo() {
if (!BuildConfig.DEBUG) return
Logger.d(TAG, "╔═══════════════════════════════════════════════════")
Logger.d(TAG, "║ 🌍 LOCALE DEBUG INFO")
Logger.d(TAG, "╠═══════════════════════════════════════════════════")
// System Locale
val systemLocale = java.util.Locale.getDefault()
Logger.d(TAG, "║ System Locale (Locale.getDefault()): $systemLocale")
// Resources Locale
val resourcesLocale = resources.configuration.locales[0]
Logger.d(TAG, "║ Resources Locale: $resourcesLocale")
// Context Locale (API 24+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val contextLocales = resources.configuration.locales
Logger.d(TAG, "║ Context Locales (all): $contextLocales")
}
// Test String Loading
val testString = getString(R.string.toast_already_synced)
Logger.d(TAG, "║ Test: getString(R.string.toast_already_synced)")
Logger.d(TAG, "║ Result: '$testString'")
Logger.d(TAG, "║ Expected EN: '✅ Already synced'")
Logger.d(TAG, "║ Is German?: ${testString.contains("Bereits") || testString.contains("synchronisiert")}")
Logger.d(TAG, "╚═══════════════════════════════════════════════════")
}
} }

View File

@@ -1,5 +1,8 @@
@file:Suppress("DEPRECATION") // Legacy code using ProgressDialog & LocalBroadcastManager, will be removed in v2.0.0
package dev.dettmer.simplenotes package dev.dettmer.simplenotes
import android.annotation.SuppressLint
import android.app.ProgressDialog import android.app.ProgressDialog
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -42,6 +45,7 @@ import java.net.URL
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
@Suppress("LargeClass", "DEPRECATION") // Legacy code using ProgressDialog & LocalBroadcastManager, will be removed in v2.0.0
class SettingsActivity : AppCompatActivity() { class SettingsActivity : AppCompatActivity() {
companion object { companion object {
@@ -183,10 +187,12 @@ class SettingsActivity : AppCompatActivity() {
} }
// Set URL with protocol prefix in the text field // Set URL with protocol prefix in the text field
@Suppress("SetTextI18n") // Technical URL, not UI text
editTextServerUrl.setText("$protocol://$hostPath") editTextServerUrl.setText("$protocol://$hostPath")
} else { } else {
// Default: HTTP selected (lokale Server sind häufiger), empty URL with prefix // Default: HTTP selected (lokale Server sind häufiger), empty URL with prefix
radioHttp.isChecked = true radioHttp.isChecked = true
@Suppress("SetTextI18n") // Technical URL, not UI text
editTextServerUrl.setText("http://") editTextServerUrl.setText("http://")
} }
@@ -249,6 +255,7 @@ class SettingsActivity : AppCompatActivity() {
} }
// Set new URL with correct protocol // Set new URL with correct protocol
@Suppress("SetTextI18n") // Technical URL, not UI text
editTextServerUrl.setText("$newProtocol://$hostPath") editTextServerUrl.setText("$newProtocol://$hostPath")
// Move cursor to end // Move cursor to end
@@ -376,7 +383,7 @@ class SettingsActivity : AppCompatActivity() {
val versionName = BuildConfig.VERSION_NAME val versionName = BuildConfig.VERSION_NAME
val versionCode = BuildConfig.VERSION_CODE val versionCode = BuildConfig.VERSION_CODE
textViewAppVersion.text = "Version $versionName ($versionCode)" textViewAppVersion.text = getString(R.string.about_version, versionName, versionCode)
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "Failed to load version info", e) Logger.e(TAG, "Failed to load version info", e)
textViewAppVersion.text = getString(R.string.version_not_available) textViewAppVersion.text = getString(R.string.version_not_available)
@@ -596,7 +603,7 @@ class SettingsActivity : AppCompatActivity() {
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
showToast("✅ Bereits synchronisiert") showToast(getString(R.string.toast_already_synced))
SyncStateManager.markCompleted() SyncStateManager.markCompleted()
return@launch return@launch
} }
@@ -605,8 +612,8 @@ class SettingsActivity : AppCompatActivity() {
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in anderen Triggern) // ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in anderen Triggern)
if (!syncService.isServerReachable()) { if (!syncService.isServerReachable()) {
showToast("⚠️ Server nicht erreichbar") showToast("⚠️ ${getString(R.string.snackbar_server_unreachable)}")
SyncStateManager.markError("Server nicht erreichbar") SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
checkServerStatus() // Server-Status aktualisieren checkServerStatus() // Server-Status aktualisieren
return@launch return@launch
} }
@@ -641,7 +648,7 @@ class SettingsActivity : AppCompatActivity() {
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty()) { if (serverUrl.isNullOrEmpty()) {
textViewServerStatus.text = "❌ Nicht konfiguriert" textViewServerStatus.text = getString(R.string.server_status_not_configured)
textViewServerStatus.setTextColor(getColor(android.R.color.holo_red_dark)) textViewServerStatus.setTextColor(getColor(android.R.color.holo_red_dark))
return return
} }
@@ -666,10 +673,10 @@ class SettingsActivity : AppCompatActivity() {
} }
if (isReachable) { if (isReachable) {
textViewServerStatus.text = "✅ Erreichbar" textViewServerStatus.text = getString(R.string.server_status_reachable)
textViewServerStatus.setTextColor(getColor(android.R.color.holo_green_dark)) textViewServerStatus.setTextColor(getColor(android.R.color.holo_green_dark))
} else { } else {
textViewServerStatus.text = "❌ Nicht erreichbar" textViewServerStatus.text = getString(R.string.server_status_unreachable)
textViewServerStatus.setTextColor(getColor(android.R.color.holo_red_dark)) textViewServerStatus.setTextColor(getColor(android.R.color.holo_red_dark))
} }
} }
@@ -815,6 +822,12 @@ class SettingsActivity : AppCompatActivity() {
.show() .show()
} }
/**
* Note: REQUEST_IGNORE_BATTERY_OPTIMIZATIONS is acceptable for F-Droid builds.
* For Play Store builds, this would need to be changed to
* ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS (shows list, doesn't request directly).
*/
@SuppressLint("BatteryLife")
private fun openBatteryOptimizationSettings() { private fun openBatteryOptimizationSettings() {
try { try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
@@ -944,6 +957,7 @@ class SettingsActivity : AppCompatActivity() {
} }
// Info Text // Info Text
@Suppress("SetTextI18n") // Programmatically generated dialog text
val infoText = android.widget.TextView(this).apply { val infoText = android.widget.TextView(this).apply {
text = "Quelle: $sourceText\n\nWiederherstellungs-Modus:" text = "Quelle: $sourceText\n\nWiederherstellungs-Modus:"
textSize = 16f textSize = 16f
@@ -952,7 +966,7 @@ class SettingsActivity : AppCompatActivity() {
// Hinweis Text // Hinweis Text
val hintText = android.widget.TextView(this).apply { val hintText = android.widget.TextView(this).apply {
text = "\n Ein Sicherheits-Backup wird vor dem Wiederherstellen automatisch erstellt." text = getString(R.string.backup_restore_info)
textSize = 14f textSize = 14f
setTypeface(null, android.graphics.Typeface.ITALIC) setTypeface(null, android.graphics.Typeface.ITALIC)
setPadding(0, 20, 0, 0) setPadding(0, 20, 0, 0)

View File

@@ -6,6 +6,10 @@ import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.sync.NetworkMonitor import dev.dettmer.simplenotes.sync.NetworkMonitor
import dev.dettmer.simplenotes.utils.NotificationHelper import dev.dettmer.simplenotes.utils.NotificationHelper
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.di.appModule
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
class SimpleNotesApplication : Application() { class SimpleNotesApplication : Application() {
@@ -15,11 +19,35 @@ class SimpleNotesApplication : Application() {
lateinit var networkMonitor: NetworkMonitor // Public access für SettingsActivity 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() { override fun onCreate() {
super.onCreate() super.onCreate()
// File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!) startKoin {
androidLogger() // Log Koin events
androidContext(this@SimpleNotesApplication) // Provide context to modules
modules(appModule)
}
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) 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)) { if (prefs.getBoolean("file_logging_enabled", false)) {
Logger.enableFileLogging(this) Logger.enableFileLogging(this)
Logger.d(TAG, "📝 File logging enabled at Application startup") Logger.d(TAG, "📝 File logging enabled at Application startup")
@@ -50,4 +78,30 @@ class SimpleNotesApplication : Application() {
// WorkManager läuft weiter auch nach onTerminate! // WorkManager läuft weiter auch nach onTerminate!
// Nur bei deaktiviertem Auto-Sync stoppen wir es // 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

@@ -89,6 +89,7 @@ class NotesAdapter(
SyncStatus.PENDING -> android.R.drawable.ic_popup_sync SyncStatus.PENDING -> android.R.drawable.ic_popup_sync
SyncStatus.CONFLICT -> android.R.drawable.ic_dialog_alert SyncStatus.CONFLICT -> android.R.drawable.ic_dialog_alert
SyncStatus.LOCAL_ONLY -> android.R.drawable.ic_menu_save 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.setImageResource(syncIcon)
imageViewSyncStatus.visibility = View.VISIBLE imageViewSyncStatus.visibility = View.VISIBLE

View File

@@ -31,20 +31,24 @@ class BackupManager(private val context: Context) {
private const val BACKUP_VERSION = 1 private const val BACKUP_VERSION = 1
private const val AUTO_BACKUP_DIR = "auto_backups" private const val AUTO_BACKUP_DIR = "auto_backups"
private const val AUTO_BACKUP_RETENTION_DAYS = 7 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 storage = NotesStorage(context)
private val gson: Gson = GsonBuilder().setPrettyPrinting().create() private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
private val encryptionManager = EncryptionManager() // 🔐 v1.7.0
/** /**
* Erstellt Backup aller Notizen * Erstellt Backup aller Notizen
* *
* @param uri Output-URI (via Storage Access Framework) * @param uri Output-URI (via Storage Access Framework)
* @param password Optional password for encryption (null = unencrypted)
* @return BackupResult mit Erfolg/Fehler Info * @return BackupResult mit Erfolg/Fehler Info
*/ */
suspend fun createBackup(uri: Uri): BackupResult = withContext(Dispatchers.IO) { suspend fun createBackup(uri: Uri, password: String? = null): BackupResult = withContext(Dispatchers.IO) {
return@withContext try { return@withContext try {
Logger.d(TAG, "📦 Creating backup to: $uri") val encryptedSuffix = if (password != null) " (encrypted)" else ""
Logger.d(TAG, "📦 Creating backup$encryptedSuffix to: $uri")
val allNotes = storage.loadAllNotes() val allNotes = storage.loadAllNotes()
Logger.d(TAG, " Found ${allNotes.size} notes to backup") Logger.d(TAG, " Found ${allNotes.size} notes to backup")
@@ -59,15 +63,22 @@ class BackupManager(private val context: Context) {
val jsonString = gson.toJson(backupData) 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 -> context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(jsonString.toByteArray()) outputStream.write(dataToWrite)
Logger.d(TAG, "✅ Backup created successfully") Logger.d(TAG, "✅ Backup created successfully$encryptedSuffix")
} }
BackupResult( BackupResult(
success = true, success = true,
notesCount = allNotes.size, notesCount = allNotes.size,
message = "Backup erstellt: ${allNotes.size} Notizen" message = "Backup erstellt: ${allNotes.size} Notizen$encryptedSuffix"
) )
} catch (e: Exception) { } catch (e: Exception) {
@@ -126,20 +137,42 @@ class BackupManager(private val context: Context) {
* *
* @param uri Backup-Datei URI * @param uri Backup-Datei URI
* @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite) * @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite)
* @param password Optional password if backup is encrypted
* @return RestoreResult mit Details * @return RestoreResult mit Details
*/ */
suspend fun restoreBackup(uri: Uri, mode: RestoreMode): RestoreResult = withContext(Dispatchers.IO) { suspend fun restoreBackup(uri: Uri, mode: RestoreMode, password: String? = null): RestoreResult = withContext(Dispatchers.IO) {
return@withContext try { return@withContext try {
Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)") Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)")
// 1. Backup-Datei lesen // 1. Backup-Datei lesen
val jsonString = context.contentResolver.openInputStream(uri)?.use { inputStream -> val fileData = context.contentResolver.openInputStream(uri)?.use { inputStream ->
inputStream.bufferedReader().use { it.readText() } inputStream.readBytes()
} ?: return@withContext RestoreResult( } ?: return@withContext RestoreResult(
success = false, success = false,
error = "Datei konnte nicht gelesen werden" 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 // 2. Backup validieren & parsen
val validationResult = validateBackup(jsonString) val validationResult = validateBackup(jsonString)
if (!validationResult.isValid) { if (!validationResult.isValid) {
@@ -177,6 +210,22 @@ class BackupManager(private val context: Context) {
} }
} }
/**
* 🔐 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 * Validiert Backup-Datei
*/ */

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,33 @@
package dev.dettmer.simplenotes.di
import android.content.Context
import androidx.room.Room
import dev.dettmer.simplenotes.storage.AppDatabase
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.ui.main.MainViewModel
import dev.dettmer.simplenotes.utils.Constants
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val appModule = module {
single {
Room.databaseBuilder(
androidContext(),
AppDatabase::class.java,
"notes_database"
).build()
}
single { get<AppDatabase>().noteDao() }
single { get<AppDatabase>().deletedNoteDao() }
// Provide SharedPreferences
single {
androidContext().getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
}
single { NotesStorage(androidContext(), get()) }
viewModel { MainViewModel(get(), get()) }
}

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

@@ -24,7 +24,9 @@ data class Note(
val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY, val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY,
// v1.4.0: Checklisten-Felder // v1.4.0: Checklisten-Felder
val noteType: NoteType = NoteType.TEXT, val noteType: NoteType = NoteType.TEXT,
val checklistItems: List<ChecklistItem>? = null val checklistItems: List<ChecklistItem>? = null,
// 🆕 v1.8.1 (IMPL_03): Persistierte Sortierung
val checklistSortOption: String? = null
) { ) {
/** /**
* Serialisiert Note zu JSON * Serialisiert Note zu JSON
@@ -71,13 +73,20 @@ data class Note(
* v1.4.0: Unterstützt jetzt auch Checklisten-Format * v1.4.0: Unterstützt jetzt auch Checklisten-Format
*/ */
fun toMarkdown(): String { fun toMarkdown(): String {
// 🆕 v1.8.1 (IMPL_03): Sortierung im Frontmatter
val sortLine = if (noteType == NoteType.CHECKLIST && checklistSortOption != null) {
"\nsort: $checklistSortOption"
} else {
""
}
val header = """ val header = """
--- ---
id: $id id: $id
created: ${formatISO8601(createdAt)} created: ${formatISO8601(createdAt)}
updated: ${formatISO8601(updatedAt)} updated: ${formatISO8601(updatedAt)}
device: $deviceId device: $deviceId
type: ${noteType.name.lowercase()} type: ${noteType.name.lowercase()}$sortLine
--- ---
# $title # $title
@@ -119,6 +128,14 @@ type: ${noteType.name.lowercase()}
NoteType.TEXT 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 // Parsen der Basis-Note
val rawNote = gson.fromJson(json, NoteRaw::class.java) val rawNote = gson.fromJson(json, NoteRaw::class.java)
@@ -158,7 +175,8 @@ type: ${noteType.name.lowercase()}
deviceId = rawNote.deviceId, deviceId = rawNote.deviceId,
syncStatus = rawNote.syncStatus ?: SyncStatus.LOCAL_ONLY, syncStatus = rawNote.syncStatus ?: SyncStatus.LOCAL_ONLY,
noteType = noteType, noteType = noteType,
checklistItems = checklistItems checklistItems = checklistItems,
checklistSortOption = checklistSortOption // 🆕 v1.8.1 (IMPL_03)
) )
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(TAG, "Failed to parse JSON: ${e.message}") Logger.w(TAG, "Failed to parse JSON: ${e.message}")
@@ -210,11 +228,13 @@ type: ${noteType.name.lowercase()}
/** /**
* Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09) * Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09)
* v1.4.0: Unterstützt jetzt auch Checklisten-Format * 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 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 * @return Note-Objekt oder null bei Parse-Fehler
*/ */
fun fromMarkdown(md: String): Note? { fun fromMarkdown(md: String, serverModifiedTime: Long? = null): Note? {
return try { return try {
// Parse YAML Frontmatter + Markdown Content // Parse YAML Frontmatter + Markdown Content
val frontmatterRegex = Regex("^---\\n(.+?)\\n---\\n(.*)$", RegexOption.DOT_MATCHES_ALL) val frontmatterRegex = Regex("^---\\n(.+?)\\n---\\n(.*)$", RegexOption.DOT_MATCHES_ALL)
@@ -244,6 +264,9 @@ type: ${noteType.name.lowercase()}
else -> NoteType.TEXT 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 // v1.4.0: Parse Content basierend auf Typ
// FIX: Robusteres Parsing - suche nach dem Titel-Header und extrahiere den Rest // FIX: Robusteres Parsing - suche nach dem Titel-Header und extrahiere den Rest
val titleLineIndex = contentBlock.lines().indexOfFirst { it.startsWith("# ") } val titleLineIndex = contentBlock.lines().indexOfFirst { it.startsWith("# ") }
@@ -279,16 +302,27 @@ type: ${noteType.name.lowercase()}
checklistItems = null 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( Note(
id = metadata["id"] ?: UUID.randomUUID().toString(), id = metadata["id"] ?: UUID.randomUUID().toString(),
title = title, title = title,
content = content, content = content,
createdAt = parseISO8601(metadata["created"] ?: ""), createdAt = parseISO8601(metadata["created"] ?: ""),
updatedAt = parseISO8601(metadata["updated"] ?: ""), updatedAt = effectiveUpdatedAt,
deviceId = metadata["device"] ?: "desktop", deviceId = metadata["device"] ?: "desktop",
syncStatus = SyncStatus.SYNCED, // Annahme: Vom Server importiert syncStatus = SyncStatus.SYNCED, // Annahme: Vom Server importiert
noteType = noteType, noteType = noteType,
checklistItems = checklistItems checklistItems = checklistItems,
checklistSortOption = checklistSortOption // 🆕 v1.8.1 (IMPL_03)
) )
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(TAG, "Failed to parse Markdown: ${e.message}") Logger.w(TAG, "Failed to parse Markdown: ${e.message}")
@@ -307,18 +341,99 @@ type: ${noteType.name.lowercase()}
} }
/** /**
* Parst ISO8601 zurück zu Timestamp (Task #1.2.0-10) * 🔧 v1.7.2 (IMPL_002): Robustes ISO8601 Parsing mit Multi-Format Unterstützung
*
* Unterstützte Formate (in Prioritätsreihenfolge):
* 1. 2024-12-21T18:00:00Z (UTC mit Z)
* 2. 2024-12-21T18:00:00+01:00 (mit Offset)
* 3. 2024-12-21T18:00:00+0100 (Offset ohne Doppelpunkt)
* 4. 2024-12-21T18:00:00.123Z (mit Millisekunden)
* 5. 2024-12-21T18:00:00.123+01:00 (Millisekunden + Offset)
* 6. 2024-12-21 18:00:00 (Leerzeichen statt T)
*
* Fallback: Aktueller Timestamp bei Fehler * Fallback: Aktueller Timestamp bei Fehler
*
* @param dateString ISO8601 Datum-String
* @return Unix Timestamp in Millisekunden
*/ */
private fun parseISO8601(dateString: String): Long { private fun parseISO8601(dateString: String): Long {
return try { if (dateString.isBlank()) {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) return System.currentTimeMillis()
sdf.timeZone = TimeZone.getTimeZone("UTC")
sdf.parse(dateString)?.time ?: System.currentTimeMillis()
} catch (e: Exception) {
Logger.w(TAG, "Failed to parse ISO8601 date '$dateString': ${e.message}")
System.currentTimeMillis() // Fallback
} }
// Normalisiere: Leerzeichen → T
val normalized = dateString.trim().replace(' ', 'T')
// Format-Patterns in Prioritätsreihenfolge
val patterns = listOf(
// Mit Timezone Z
"yyyy-MM-dd'T'HH:mm:ss'Z'",
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
// Mit Offset XXX (+01:00)
"yyyy-MM-dd'T'HH:mm:ssXXX",
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
// Mit Offset ohne Doppelpunkt (+0100)
"yyyy-MM-dd'T'HH:mm:ssZ",
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
// Ohne Timezone (interpretiere als UTC)
"yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd'T'HH:mm:ss.SSS"
)
// Versuche alle Patterns nacheinander
for (pattern in patterns) {
@Suppress("SwallowedException") // Intentional: try all patterns before logging
try {
val sdf = SimpleDateFormat(pattern, Locale.US)
// Für Patterns ohne Timezone: UTC annehmen
if (!pattern.contains("XXX") && !pattern.contains("Z")) {
sdf.timeZone = TimeZone.getTimeZone("UTC")
}
val parsed = sdf.parse(normalized)
if (parsed != null) {
return parsed.time
}
} catch (e: Exception) {
// 🔇 Exception intentionally swallowed - try next pattern
// Only log if no pattern matches (see fallback below)
continue
}
}
// Fallback wenn kein Pattern passt
Logger.w(TAG, "Failed to parse ISO8601 date '$dateString' with any pattern, using current time")
return System.currentTimeMillis()
}
}
}
/**
* 🎨 v1.7.0: Note size classification for Staggered Grid Layout
*/
enum class NoteSize {
SMALL, // Compact display (< 80 chars or ≤ 4 checklist items)
LARGE; // Full-width display
companion object {
const val SMALL_TEXT_THRESHOLD = 80 // Max characters for compact text note
const val SMALL_CHECKLIST_THRESHOLD = 4 // Max items for compact checklist
}
}
/**
* 🎨 v1.7.0: Determine note size for grid layout optimization
*/
fun Note.getSize(): NoteSize {
return when (noteType) {
NoteType.TEXT -> {
if (content.length < NoteSize.SMALL_TEXT_THRESHOLD) NoteSize.SMALL else NoteSize.LARGE
}
NoteType.CHECKLIST -> {
val itemCount = checklistItems?.size ?: 0
if (itemCount <= NoteSize.SMALL_CHECKLIST_THRESHOLD) NoteSize.SMALL else NoteSize.LARGE
} }
} }
} }

View File

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

View File

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

View File

@@ -1,8 +1,15 @@
package dev.dettmer.simplenotes.models 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 { enum class SyncStatus {
LOCAL_ONLY, // Noch nie gesynct LOCAL_ONLY, // Noch nie gesynct
SYNCED, // Erfolgreich gesynct SYNCED, // Erfolgreich gesynct
PENDING, // Wartet auf Sync 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

@@ -0,0 +1,14 @@
package dev.dettmer.simplenotes.storage
import androidx.room.Database
import androidx.room.RoomDatabase
import dev.dettmer.simplenotes.storage.dao.DeletedNoteDao
import dev.dettmer.simplenotes.storage.dao.NoteDao
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
import dev.dettmer.simplenotes.storage.entity.NoteEntity
@Database(entities = [NoteEntity::class, DeletedNoteEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
abstract fun deletedNoteDao(): DeletedNoteDao
}

View File

@@ -1,67 +1,58 @@
package dev.dettmer.simplenotes.storage package dev.dettmer.simplenotes.storage
import android.content.Context import android.content.Context
import dev.dettmer.simplenotes.models.DeletionTracker import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
import dev.dettmer.simplenotes.storage.entity.NoteEntity
import dev.dettmer.simplenotes.utils.DeviceIdGenerator import dev.dettmer.simplenotes.utils.DeviceIdGenerator
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import java.io.File
class NotesStorage(private val context: Context) { class NotesStorage(
private val context: Context,
database: AppDatabase
) {
companion object { companion object {
private const val TAG = "NotesStorage" private const val TAG = "NotesStorage"
} }
private val notesDir: File = File(context.filesDir, "notes").apply { private val noteDao = database.noteDao()
if (!exists()) mkdirs() private val deletedNoteDao = database.deletedNoteDao()
suspend fun saveNote(note: NoteEntity) {
noteDao.saveNote(note)
} }
fun saveNote(note: Note) { suspend fun loadNote(id: String): NoteEntity? {
val file = File(notesDir, "${note.id}.json") return noteDao.getNote(id)
file.writeText(note.toJson())
} }
fun loadNote(id: String): Note? { suspend fun loadAllNotes(): List<NoteEntity> {
val file = File(notesDir, "$id.json") return noteDao.getAllNotes()
return if (file.exists()) {
Note.fromJson(file.readText())
} else {
null
}
} }
fun loadAllNotes(): List<Note> { suspend fun deleteNote(id: String): Boolean {
return notesDir.listFiles() val deletedRows = noteDao.deleteNoteById(id)
?.filter { it.extension == "json" }
?.mapNotNull { Note.fromJson(it.readText()) }
?.sortedByDescending { it.updatedAt }
?: emptyList()
}
fun deleteNote(id: String): Boolean { if (deletedRows > 0) {
val file = File(notesDir, "$id.json")
val deleted = file.delete()
if (deleted) {
Logger.d(TAG, "🗑️ Deleted note: $id") Logger.d(TAG, "🗑️ Deleted note: $id")
// Track deletion to prevent zombie notes
val deviceId = DeviceIdGenerator.getDeviceId(context) val deviceId = DeviceIdGenerator.getDeviceId(context)
trackDeletion(id, deviceId) trackDeletionSafe(id, deviceId)
return true
} }
return false
return deleted
} }
fun deleteAllNotes(): Boolean { suspend fun deleteAllNotes(): Boolean {
return try { return try {
val notes = loadAllNotes() val notes = loadAllNotes()
val deviceId = DeviceIdGenerator.getDeviceId(context) val deviceId = DeviceIdGenerator.getDeviceId(context)
for (note in notes) { // Batch tracking and deleting
deleteNote(note.id) // Uses trackDeletion() automatically notes.forEach { note ->
trackDeletionSafe(note.id, deviceId)
} }
noteDao.deleteAllNotes()
Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)") Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)")
true true
@@ -73,57 +64,28 @@ class NotesStorage(private val context: Context) {
// === Deletion Tracking === // === Deletion Tracking ===
private fun getDeletionTrackerFile(): File { suspend fun trackDeletionSafe(noteId: String, deviceId: String) {
return File(context.filesDir, "deleted_notes.json") // Room handles internal transactions and thread-safety natively.
} // The Mutex is no longer required.
deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId))
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)
}
}
fun trackDeletion(noteId: String, deviceId: String) {
val tracker = loadDeletionTracker()
tracker.addDeletion(noteId, deviceId)
saveDeletionTracker(tracker)
Logger.d(TAG, "📝 Tracked deletion: $noteId") Logger.d(TAG, "📝 Tracked deletion: $noteId")
} }
fun isNoteDeleted(noteId: String): Boolean { suspend fun isNoteDeleted(noteId: String): Boolean {
val tracker = loadDeletionTracker() return deletedNoteDao.isNoteDeleted(noteId)
return tracker.isDeleted(noteId)
} }
fun clearDeletionTracker() { suspend fun clearDeletionTracker() {
saveDeletionTracker(DeletionTracker()) deletedNoteDao.clearTracker()
Logger.d(TAG, "🗑️ Deletion tracker cleared") Logger.d(TAG, "🗑️ Deletion tracker cleared")
} }
suspend fun resetAllSyncStatusToPending(): Int {
fun getNotesDir(): File = notesDir val updatedCount = noteDao.updateSyncStatus(
oldStatus = SyncStatus.SYNCED,
newStatus = SyncStatus.PENDING
)
Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING")
return updatedCount
}
} }

View File

@@ -0,0 +1,19 @@
package dev.dettmer.simplenotes.storage.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
@Dao
interface DeletedNoteDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun trackDeletion(deletedNote: DeletedNoteEntity)
@Query("SELECT EXISTS(SELECT 1 FROM deleted_notes WHERE noteId = :noteId)")
suspend fun isNoteDeleted(noteId: String): Boolean
@Query("DELETE FROM deleted_notes")
suspend fun clearTracker()
}

View File

@@ -0,0 +1,29 @@
package dev.dettmer.simplenotes.storage.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.entity.NoteEntity
@Dao
interface NoteDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveNote(note: NoteEntity)
@Query("SELECT * FROM notes WHERE id = :id")
suspend fun getNote(id: String): NoteEntity?
@Query("SELECT * FROM notes")
suspend fun getAllNotes(): List<NoteEntity>
@Query("DELETE FROM notes WHERE id = :id")
suspend fun deleteNoteById(id: String): Int
@Query("DELETE FROM notes")
suspend fun deleteAllNotes(): Int
@Query("UPDATE notes SET syncStatus = :newStatus WHERE syncStatus = :oldStatus")
suspend fun updateSyncStatus(oldStatus: SyncStatus, newStatus: SyncStatus): Int
}

View File

@@ -0,0 +1,11 @@
package dev.dettmer.simplenotes.storage.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "deleted_notes")
data class DeletedNoteEntity(
@PrimaryKey val noteId: String,
val deviceId: String,
val deletedAt: Long = System.currentTimeMillis()
)

View File

@@ -0,0 +1,13 @@
package dev.dettmer.simplenotes.storage.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import dev.dettmer.simplenotes.models.SyncStatus
@Entity(tableName = "notes")
data class NoteEntity(
@PrimaryKey val id: String,
val content: String,
val timestamp: Long,
val syncStatus: SyncStatus
)

View File

@@ -68,15 +68,20 @@ class NetworkMonitor(private val context: Context) {
lastConnectedNetworkId = currentNetworkId lastConnectedNetworkId = currentNetworkId
// Auto-Sync check // WiFi-Connect Trigger prüfen - NICHT KEY_AUTO_SYNC!
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false) // Der Callback ist registriert WEIL KEY_SYNC_TRIGGER_WIFI_CONNECT = true
Logger.d(TAG, " Auto-Sync enabled: $autoSyncEnabled") // 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) { if (wifiConnectEnabled) {
Logger.d(TAG, " ✅ Triggering WorkManager...") Logger.d(TAG, " ✅ Triggering WiFi-Connect sync...")
triggerWifiConnectSync() triggerWifiConnectSync()
} else { } else {
Logger.d(TAG, " ❌ Auto-sync disabled - not triggering") Logger.d(TAG, " ⏭️ WiFi-Connect trigger disabled in settings")
} }
} else { } else {
Logger.d(TAG, " ⚠️ Same WiFi network as before - ignoring (no network change)") Logger.d(TAG, " ⚠️ Same WiFi network as before - ignoring (no network change)")
@@ -140,23 +145,56 @@ class NetworkMonitor(private val context: Context) {
/** /**
* Startet WorkManager mit Network Constraints + NetworkCallback * 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() { fun startMonitoring() {
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false) Logger.d(TAG, "🚀 NetworkMonitor.startMonitoring() called")
if (!autoSyncEnabled) { val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
Logger.d(TAG, "Auto-sync disabled - stopping all monitoring") val periodicEnabled = prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)
stopMonitoring() val wifiConnectEnabled = prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)
return
Logger.d(TAG, " Settings: autoSync=$autoSyncEnabled, periodic=$periodicEnabled, wifiConnect=$wifiConnectEnabled")
// 1. Periodic Sync (nur wenn KEY_AUTO_SYNC UND KEY_SYNC_TRIGGER_PERIODIC aktiv)
if (autoSyncEnabled && periodicEnabled) {
Logger.d(TAG, "📅 Starting periodic sync...")
startPeriodicSync()
} else {
WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
Logger.d(TAG, "⏭️ Periodic sync disabled (autoSync=$autoSyncEnabled, periodic=$periodicEnabled)")
} }
Logger.d(TAG, "🚀 Starting NetworkMonitor (WorkManager + WiFi Callback)") // 2. WiFi-Connect Trigger (🆕 UNABHÄNGIG von KEY_AUTO_SYNC!)
if (wifiConnectEnabled) {
Logger.d(TAG, "📶 Starting WiFi monitoring...")
startWifiMonitoring()
} else {
stopWifiMonitoring()
Logger.d(TAG, "⏭️ WiFi-Connect trigger disabled")
}
// 1. WorkManager für periodic sync // 3. Logging für Debug
startPeriodicSync() if (!autoSyncEnabled && !wifiConnectEnabled) {
Logger.d(TAG, "🛑 No background triggers active")
}
}
// 2. NetworkCallback für WiFi-Connect Detection /**
startWifiMonitoring() * 🆕 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")
}
} }
/** /**

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

View File

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

View File

@@ -1,12 +1,18 @@
@file:Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional, will migrate in v2.0.0
package dev.dettmer.simplenotes.sync package dev.dettmer.simplenotes.sync
import android.app.ActivityManager import android.app.ActivityManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import dev.dettmer.simplenotes.BuildConfig import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.NotificationHelper import dev.dettmer.simplenotes.utils.NotificationHelper
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -23,6 +29,35 @@ class SyncWorker(
const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED" 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. * Prüft ob die App im Vordergrund ist.
* Wenn ja, brauchen wir keine Benachrichtigung - die UI zeigt die Änderungen direkt. * Wenn ja, brauchen wir keine Benachrichtigung - die UI zeigt die Änderungen direkt.
@@ -38,6 +73,7 @@ class SyncWorker(
} }
} }
@Suppress("LongMethod") // Linear sync flow with debug logging — splitting would hurt readability
override suspend fun doWork(): Result = withContext(Dispatchers.IO) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Logger.d(TAG, "═══════════════════════════════════════") Logger.d(TAG, "═══════════════════════════════════════")
@@ -69,7 +105,42 @@ class SyncWorker(
} }
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 2: Checking for unsynced changes (Performance Pre-Check)") Logger.d(TAG, "📍 Step 2: SyncStateManager coordination & global cooldown (v1.8.1)")
}
// 🆕 v1.8.1 (IMPL_08): SyncStateManager-Koordination
// Verhindert dass Foreground und Background gleichzeitig syncing-State haben
val prefs = applicationContext.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
// 🆕 v1.8.1 (IMPL_08B): onSave-Syncs bypassen den globalen Cooldown
// Grund: User hat explizit gespeichert → erwartet zeitnahen Sync
// Der eigene 5s-Throttle + isSyncing-Mutex reichen als Schutz
val isOnSaveSync = tags.contains(Constants.SYNC_ONSAVE_TAG)
// Globaler Cooldown-Check (nicht für onSave-Syncs)
if (!isOnSaveSync && !SyncStateManager.canSyncGlobally(prefs)) {
Logger.d(TAG, "⏭️ SyncWorker: Global sync cooldown active - skipping")
if (BuildConfig.DEBUG) {
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (cooldown)")
Logger.d(TAG, "═══════════════════════════════════════")
}
return@withContext Result.success()
}
if (!SyncStateManager.tryStartSync("worker-${tags.firstOrNull() ?: "unknown"}", silent = true)) {
Logger.d(TAG, "⏭️ SyncWorker: Another sync already in progress - skipping")
if (BuildConfig.DEBUG) {
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (already syncing)")
Logger.d(TAG, "═══════════════════════════════════════")
}
return@withContext Result.success()
}
// Globalen Cooldown markieren
SyncStateManager.markGlobalSyncStarted(prefs)
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 3: Checking for unsynced changes (Performance Pre-Check)")
} }
// 🔥 v1.1.2: Performance-Optimierung - Skip Sync wenn keine lokalen Änderungen // 🔥 v1.1.2: Performance-Optimierung - Skip Sync wenn keine lokalen Änderungen
@@ -87,7 +158,28 @@ class SyncWorker(
} }
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 3: Checking server reachability (Pre-Check)") Logger.d(TAG, "📍 Step 4: Checking sync gate (canSync)")
}
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (WiFi-Only, Offline Mode, Server Config)
val gateResult = syncService.canSync()
if (!gateResult.canSync) {
if (gateResult.isBlockedByWifiOnly) {
Logger.d(TAG, "⏭️ WiFi-only mode enabled, but not on WiFi - skipping sync")
} else {
Logger.d(TAG, "⏭️ Sync blocked by gate: ${gateResult.blockReason ?: "offline/no server"}")
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (gate blocked)")
Logger.d(TAG, "═══════════════════════════════════════")
}
return@withContext Result.success()
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 5: Checking server reachability (Pre-Check)")
} }
// ⭐ KRITISCH: Server-Erreichbarkeits-Check VOR Sync // ⭐ KRITISCH: Server-Erreichbarkeits-Check VOR Sync
@@ -111,7 +203,7 @@ class SyncWorker(
} }
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 3: Server reachable - proceeding with sync") Logger.d(TAG, "📍 Step 6: Server reachable - proceeding with sync")
Logger.d(TAG, " SyncService: $syncService") Logger.d(TAG, " SyncService: $syncService")
} }
@@ -132,7 +224,7 @@ class SyncWorker(
} }
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 4: Processing result") Logger.d(TAG, "📍 Step 7: Processing result")
Logger.d( Logger.d(
TAG, TAG,
"📦 Sync result: success=${result.isSuccess}, " + "📦 Sync result: success=${result.isSuccess}, " +
@@ -142,10 +234,13 @@ class SyncWorker(
if (result.isSuccess) { if (result.isSuccess) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 5: Success path") Logger.d(TAG, "📍 Step 8: Success path")
} }
Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes") 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 // Nur Notification zeigen wenn tatsächlich etwas gesynct wurde
// UND die App nicht im Vordergrund ist (sonst sieht User die Änderungen direkt) // UND die App nicht im Vordergrund ist (sonst sieht User die Änderungen direkt)
if (result.syncedCount > 0) { if (result.syncedCount > 0) {
@@ -171,6 +266,20 @@ class SyncWorker(
} }
broadcastSyncCompleted(true, result.syncedCount) 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) { if (BuildConfig.DEBUG) {
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS") Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS")
Logger.d(TAG, "═══════════════════════════════════════") Logger.d(TAG, "═══════════════════════════════════════")
@@ -178,9 +287,13 @@ class SyncWorker(
Result.success() Result.success()
} else { } else {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 5: Failure path") Logger.d(TAG, "📍 Step 8: Failure path")
} }
Logger.e(TAG, "❌ Sync failed: ${result.errorMessage}") Logger.e(TAG, "❌ Sync failed: ${result.errorMessage}")
// 🆕 v1.8.1 (IMPL_08): SyncStateManager aktualisieren
SyncStateManager.markError(result.errorMessage)
NotificationHelper.showSyncError( NotificationHelper.showSyncError(
applicationContext, applicationContext,
result.errorMessage ?: "Unbekannter Fehler" result.errorMessage ?: "Unbekannter Fehler"
@@ -255,6 +368,7 @@ class SyncWorker(
/** /**
* Sendet Broadcast an MainActivity für UI Refresh * Sendet Broadcast an MainActivity für UI Refresh
*/ */
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but still functional, will migrate in v2.0.0
private fun broadcastSyncCompleted(success: Boolean, count: Int) { private fun broadcastSyncCompleted(success: Boolean, count: Int) {
val intent = Intent(ACTION_SYNC_COMPLETED).apply { val intent = Intent(ACTION_SYNC_COMPLETED).apply {
putExtra("success", success) putExtra("success", success)

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION") // AbstractSavedStateViewModelFactory deprecated, will migrate to viewModelFactory in v2.0.0
package dev.dettmer.simplenotes.ui.editor package dev.dettmer.simplenotes.ui.editor
import android.os.Bundle import android.os.Bundle
@@ -77,6 +79,18 @@ class ComposeNoteEditorActivity : ComponentActivity() {
} }
} }
} }
/**
* 🆕 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()
}
} }
/** /**

View File

@@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@@ -22,6 +23,9 @@ import kotlinx.coroutines.launch
* *
* Native Compose-Implementierung ohne externe Dependencies * Native Compose-Implementierung ohne externe Dependencies
* v1.5.0: NoteEditor Redesign * 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( class DragDropListState(
private val state: LazyListState, private val state: LazyListState,
@@ -33,8 +37,14 @@ class DragDropListState(
private var draggingItemDraggedDelta by mutableFloatStateOf(0f) private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
private var draggingItemInitialOffset 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) 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 val draggingItemOffset: Float
get() = draggingItemLayoutInfo?.let { item -> get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
@@ -44,9 +54,28 @@ class DragDropListState(
get() = state.layoutInfo.visibleItemsInfo get() = state.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == draggingItemIndex } .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) { fun onDragStart(offset: Offset, itemIndex: Int) {
draggingItemIndex = itemIndex draggingItemIndex = itemIndex
draggingItemInitialOffset = draggingItemLayoutInfo?.offset?.toFloat() ?: 0f val info = draggingItemLayoutInfo
draggingItemInitialOffset = info?.offset?.toFloat() ?: 0f
draggingItemSize = info?.size ?: 0
draggingItemDraggedDelta = 0f draggingItemDraggedDelta = 0f
} }
@@ -54,6 +83,7 @@ class DragDropListState(
draggingItemDraggedDelta = 0f draggingItemDraggedDelta = 0f
draggingItemIndex = null draggingItemIndex = null
draggingItemInitialOffset = 0f draggingItemInitialOffset = 0f
draggingItemSize = 0
overscrollJob?.cancel() overscrollJob?.cancel()
} }
@@ -62,13 +92,23 @@ class DragDropListState(
val draggingItem = draggingItemLayoutInfo ?: return val draggingItem = draggingItemLayoutInfo ?: return
val startOffset = draggingItem.offset + draggingItemOffset val startOffset = draggingItem.offset + draggingItemOffset
val endOffset = startOffset + draggingItem.size // 🆕 v1.8.1: Fixierte Item-Größe für stabile Swap-Erkennung
val endOffset = startOffset + draggingItemSize
val middleOffset = startOffset + (endOffset - startOffset) / 2f // 🆕 v1.8.0: IMPL_023b — Straddle-Target-Center + Adjazenz-Filter
// Statt den Mittelpunkt des gezogenen Items zu prüfen ("liegt mein Zentrum im Target?"),
val targetItem = state.layoutInfo.visibleItemsInfo.find { item -> // wird geprüft ob das gezogene Item den MITTELPUNKT des Targets überspannt.
middleOffset.toInt() in item.offset..item.offsetEnd && // Dies verhindert Oszillation bei Items unterschiedlicher Größe.
draggingItem.index != item.index // 🆕 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) { if (targetItem != null) {
@@ -80,16 +120,21 @@ class DragDropListState(
null 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) { if (scrollToIndex != null) {
scope.launch { scope.launch {
state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset) state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
onMove(draggingItem.index, targetItem.index) onMove(fromDataIndex, toDataIndex)
// 🆕 v1.8.0: IMPL_023b — Index-Update NACH dem Move (verhindert Race-Condition)
draggingItemIndex = targetItem.index
} }
} else { } else {
onMove(draggingItem.index, targetItem.index) onMove(fromDataIndex, toDataIndex)
draggingItemIndex = targetItem.index
} }
draggingItemIndex = targetItem.index
} else { } else {
val overscroll = when { val overscroll = when {
draggingItemDraggedDelta > 0 -> draggingItemDraggedDelta > 0 ->
@@ -111,6 +156,27 @@ class DragDropListState(
} }
} }
/**
* 🆕 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 private val LazyListItemInfo.offsetEnd: Int
get() = this.offset + this.size get() = this.offset + this.size
} }
@@ -130,14 +196,16 @@ fun rememberDragDropListState(
} }
} }
@Composable
fun Modifier.dragContainer( fun Modifier.dragContainer(
dragDropState: DragDropListState, dragDropState: DragDropListState,
itemIndex: Int itemIndex: Int
): Modifier { ): Modifier {
return this.pointerInput(dragDropState) { val currentIndex = rememberUpdatedState(itemIndex) // 🆕 v1.8.0: rememberUpdatedState statt Key
return this.pointerInput(dragDropState) { // Nur dragDropState als Key - verhindert Gesture-Restart
detectDragGesturesAfterLongPress( detectDragGesturesAfterLongPress(
onDragStart = { offset -> onDragStart = { offset ->
dragDropState.onDragStart(offset, itemIndex) dragDropState.onDragStart(offset, currentIndex.value) // Aktuellen Wert lesen
}, },
onDragEnd = { onDragEnd = {
dragDropState.onDragInterrupted() dragDropState.onDragInterrupted()

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -13,11 +14,13 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Save
@@ -50,14 +53,24 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.ChecklistSortOption
import dev.dettmer.simplenotes.models.NoteType 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.ChecklistItemRow
import dev.dettmer.simplenotes.ui.editor.components.ChecklistSortDialog
import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import dev.dettmer.simplenotes.utils.showToast import dev.dettmer.simplenotes.utils.showToast
import kotlin.math.roundToInt 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. * Main Composable for the Note Editor screen.
* *
@@ -80,9 +93,16 @@ fun NoteEditorScreen(
val isOfflineMode by viewModel.isOfflineMode.collectAsState() val isOfflineMode by viewModel.isOfflineMode.collectAsState()
var showDeleteDialog by remember { mutableStateOf(false) } 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) } var focusNewItemId by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope() 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 // v1.5.0: Auto-keyboard support
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val titleFocusRequester = remember { FocusRequester() } val titleFocusRequester = remember { FocusRequester() }
@@ -90,7 +110,7 @@ fun NoteEditorScreen(
// v1.5.0: Auto-focus and show keyboard // v1.5.0: Auto-focus and show keyboard
LaunchedEffect(uiState.isNewNote, uiState.noteType) { LaunchedEffect(uiState.isNewNote, uiState.noteType) {
delay(100) // Wait for layout delay(LAYOUT_DELAY_MS) // Wait for layout
when { when {
uiState.isNewNote -> { uiState.isNewNote -> {
// New note: focus title // New note: focus title
@@ -111,9 +131,9 @@ fun NoteEditorScreen(
when (event) { when (event) {
is NoteEditorEvent.ShowToast -> { is NoteEditorEvent.ShowToast -> {
val message = when (event.message) { val message = when (event.message) {
ToastMessage.NOTE_IS_EMPTY -> context.getString(R.string.note_is_empty) ToastMessage.NOTE_IS_EMPTY -> msgNoteIsEmpty
ToastMessage.NOTE_SAVED -> context.getString(R.string.note_saved) ToastMessage.NOTE_SAVED -> msgNoteSaved
ToastMessage.NOTE_DELETED -> context.getString(R.string.note_deleted) ToastMessage.NOTE_DELETED -> msgNoteDeleted
} }
context.showToast(message) context.showToast(message)
} }
@@ -210,6 +230,7 @@ fun NoteEditorScreen(
items = checklistItems, items = checklistItems,
scope = scope, scope = scope,
focusNewItemId = focusNewItemId, focusNewItemId = focusNewItemId,
currentSortOption = lastChecklistSortOption, // 🔀 v1.8.0
onTextChange = { id, text -> viewModel.updateChecklistItemText(id, text) }, onTextChange = { id, text -> viewModel.updateChecklistItemText(id, text) },
onCheckedChange = { id, checked -> viewModel.updateChecklistItemChecked(id, checked) }, onCheckedChange = { id, checked -> viewModel.updateChecklistItemChecked(id, checked) },
onDelete = { id -> viewModel.deleteChecklistItem(id) }, onDelete = { id -> viewModel.deleteChecklistItem(id) },
@@ -223,6 +244,7 @@ fun NoteEditorScreen(
}, },
onMove = { from, to -> viewModel.moveChecklistItem(from, to) }, onMove = { from, to -> viewModel.moveChecklistItem(from, to) },
onFocusHandled = { focusNewItemId = null }, onFocusHandled = { focusNewItemId = null },
onSortClick = { showChecklistSortDialog = true }, // 🔀 v1.8.0
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f) .weight(1f)
@@ -248,6 +270,18 @@ fun NoteEditorScreen(
} }
) )
} }
// 🔀 v1.8.0: Checklist Sort Dialog
if (showChecklistSortDialog) {
ChecklistSortDialog(
currentOption = lastChecklistSortOption,
onOptionSelected = { option ->
viewModel.sortChecklistItems(option)
showChecklistSortDialog = false
},
onDismiss = { showChecklistSortDialog = false }
)
}
} }
@Composable @Composable
@@ -291,11 +325,73 @@ private fun TextNoteContent(
) )
} }
/**
* 🆕 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 @Composable
private fun ChecklistEditor( private fun ChecklistEditor(
items: List<ChecklistItemState>, items: List<ChecklistItemState>,
scope: kotlinx.coroutines.CoroutineScope, scope: kotlinx.coroutines.CoroutineScope,
focusNewItemId: String?, focusNewItemId: String?,
currentSortOption: ChecklistSortOption, // 🔀 v1.8.0: Aktuelle Sortierung
onTextChange: (String, String) -> Unit, onTextChange: (String, String) -> Unit,
onCheckedChange: (String, Boolean) -> Unit, onCheckedChange: (String, Boolean) -> Unit,
onDelete: (String) -> Unit, onDelete: (String) -> Unit,
@@ -303,6 +399,7 @@ private fun ChecklistEditor(
onAddItemAtEnd: () -> Unit, onAddItemAtEnd: () -> Unit,
onMove: (Int, Int) -> Unit, onMove: (Int, Int) -> Unit,
onFocusHandled: () -> Unit, onFocusHandled: () -> Unit,
onSortClick: () -> Unit, // 🔀 v1.8.0
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -312,67 +409,118 @@ private fun ChecklistEditor(
onMove = onMove 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) { 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( LazyColumn(
state = listState, state = listState,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
// 🆕 v1.8.1 IMPL_14: Unchecked Items (Visual Index 0..uncheckedCount-1)
itemsIndexed( itemsIndexed(
items = items, items = if (showSeparator) items.subList(0, uncheckedCount) else items,
key = { _, item -> item.id } key = { _, item -> item.id }
) { index, item -> ) { index, item ->
val isDragging = dragDropState.draggingItemIndex == index DraggableChecklistItem(
val elevation by animateDpAsState( item = item,
targetValue = if (isDragging) 8.dp else 0.dp, visualIndex = index,
label = "elevation" dragDropState = dragDropState,
focusNewItemId = focusNewItemId,
onTextChange = onTextChange,
onCheckedChange = onCheckedChange,
onDelete = onDelete,
onAddNewItemAfter = onAddNewItemAfter,
onFocusHandled = onFocusHandled,
onHeightChanged = { scrollToItemIndex = index } // 🆕 v1.8.1 (IMPL_05)
) )
}
val shouldFocus = item.id == focusNewItemId // 🆕 v1.8.1 IMPL_14: Separator als eigenes LazyColumn-Item
if (showSeparator) {
// v1.5.0: Clear focus request after handling item(key = "separator") {
LaunchedEffect(shouldFocus) { CheckedItemsSeparator(
if (shouldFocus) { checkedCount = checkedCount,
onFocusHandled() isDragActive = dragDropState.draggingItemIndex != null
} )
} }
ChecklistItemRow( // 🆕 v1.8.1 IMPL_14: Checked Items (Visual Index uncheckedCount+1..)
item = item, itemsIndexed(
onTextChange = { onTextChange(item.id, it) }, items = items.subList(uncheckedCount, items.size),
onCheckedChange = { onCheckedChange(item.id, it) }, key = { _, item -> item.id }
onDelete = { onDelete(item.id) }, ) { index, item ->
onAddNewItem = { onAddNewItemAfter(item.id) }, val visualIndex = uncheckedCount + 1 + index // +1 für Separator
requestFocus = shouldFocus, DraggableChecklistItem(
modifier = Modifier item = item,
.dragContainer(dragDropState, index) visualIndex = visualIndex,
.offset { dragDropState = dragDropState,
IntOffset( focusNewItemId = focusNewItemId,
0, onTextChange = onTextChange,
if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 0 onCheckedChange = onCheckedChange,
) onDelete = onDelete,
} onAddNewItemAfter = onAddNewItemAfter,
.shadow(elevation, shape = RoundedCornerShape(8.dp)) onFocusHandled = onFocusHandled,
.background( onHeightChanged = { scrollToItemIndex = visualIndex } // 🆕 v1.8.1 (IMPL_05)
color = MaterialTheme.colorScheme.surface, )
shape = RoundedCornerShape(8.dp) }
)
)
} }
} }
// Add Item Button // 🔀 v1.8.0: Add Item Button + Sort Button
TextButton( Row(
onClick = onAddItemAtEnd, modifier = Modifier
modifier = Modifier.padding(start = 8.dp) .fillMaxWidth()
.padding(start = 8.dp, end = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) { ) {
Icon( TextButton(onClick = onAddItemAtEnd) {
imageVector = Icons.Default.Add, Icon(
contentDescription = null, imageVector = Icons.Default.Add,
modifier = Modifier.padding(end = 8.dp) contentDescription = null,
) modifier = Modifier.padding(end = 8.dp)
Text(stringResource(R.string.add_item)) )
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)
)
}
} }
} }
} }

View File

@@ -8,10 +8,12 @@ import androidx.lifecycle.viewModelScope
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import dev.dettmer.simplenotes.models.ChecklistItem import dev.dettmer.simplenotes.models.ChecklistItem
import dev.dettmer.simplenotes.models.ChecklistSortOption
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncStateManager
import dev.dettmer.simplenotes.sync.SyncWorker import dev.dettmer.simplenotes.sync.SyncWorker
import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
@@ -65,6 +67,10 @@ class NoteEditorViewModel(
) )
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow() 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 // Events
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -85,64 +91,93 @@ class NoteEditorViewModel(
val noteTypeString = savedStateHandle.get<String>(ARG_NOTE_TYPE) ?: NoteType.TEXT.name val noteTypeString = savedStateHandle.get<String>(ARG_NOTE_TYPE) ?: NoteType.TEXT.name
if (noteId != null) { if (noteId != null) {
// Load existing note loadExistingNote(noteId)
existingNote = storage.loadNote(noteId)
existingNote?.let { note ->
currentNoteType = note.noteType
_uiState.update { state ->
state.copy(
title = note.title,
content = note.content,
noteType = note.noteType,
isNewNote = false,
toolbarTitle = if (note.noteType == NoteType.CHECKLIST) {
ToolbarTitle.EDIT_CHECKLIST
} else {
ToolbarTitle.EDIT_NOTE
}
)
}
if (note.noteType == NoteType.CHECKLIST) {
val items = note.checklistItems?.sortedBy { it.order }?.map {
ChecklistItemState(
id = it.id,
text = it.text,
isChecked = it.isChecked,
order = it.order
)
} ?: emptyList()
_checklistItems.value = items
}
}
} else { } else {
// New note initNewNote(noteTypeString)
currentNoteType = try { }
NoteType.valueOf(noteTypeString) }
} catch (e: IllegalArgumentException) {
Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT")
NoteType.TEXT
}
private fun loadExistingNote(noteId: String) {
existingNote = storage.loadNote(noteId)
existingNote?.let { note ->
currentNoteType = note.noteType
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
noteType = currentNoteType, title = note.title,
isNewNote = true, content = note.content,
toolbarTitle = if (currentNoteType == NoteType.CHECKLIST) { noteType = note.noteType,
ToolbarTitle.NEW_CHECKLIST isNewNote = false,
toolbarTitle = if (note.noteType == NoteType.CHECKLIST) {
ToolbarTitle.EDIT_CHECKLIST
} else { } else {
ToolbarTitle.NEW_NOTE ToolbarTitle.EDIT_NOTE
} }
) )
} }
// Add first empty item for new checklists if (note.noteType == NoteType.CHECKLIST) {
if (currentNoteType == NoteType.CHECKLIST) { loadChecklistData(note)
_checklistItems.value = listOf(ChecklistItemState.createEmpty(0))
} }
} }
} }
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 // Actions
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -163,21 +198,80 @@ class NoteEditorViewModel(
} }
} }
/**
* 🆕 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) { fun updateChecklistItemChecked(itemId: String, isChecked: Boolean) {
_checklistItems.update { items -> _checklistItems.update { items ->
items.map { item -> val updatedItems = items.map { item ->
if (item.id == itemId) item.copy(isChecked = isChecked) else 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 { fun addChecklistItemAfter(afterItemId: String): String {
val newItem = ChecklistItemState.createEmpty(0) val newItem = ChecklistItemState.createEmpty(0)
_checklistItems.update { items -> _checklistItems.update { items ->
val index = items.indexOfFirst { it.id == afterItemId } val index = items.indexOfFirst { it.id == afterItemId }
if (index >= 0) { 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() val newList = items.toMutableList()
newList.add(index + 1, newItem) newList.add(effectiveIndex, newItem)
// Update order values // Update order values
newList.mapIndexed { i, item -> item.copy(order = i) } newList.mapIndexed { i, item -> item.copy(order = i) }
} else { } else {
@@ -187,12 +281,46 @@ class NoteEditorViewModel(
return newItem.id 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 { fun addChecklistItemAtEnd(): String {
val newItem = ChecklistItemState.createEmpty(_checklistItems.value.size) val newItem = ChecklistItemState.createEmpty(0)
_checklistItems.update { items -> items + newItem } _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 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) { fun deleteChecklistItem(itemId: String) {
_checklistItems.update { items -> _checklistItems.update { items ->
val filtered = items.filter { it.id != itemId } val filtered = items.filter { it.id != itemId }
@@ -208,14 +336,57 @@ class NoteEditorViewModel(
fun moveChecklistItem(fromIndex: Int, toIndex: Int) { fun moveChecklistItem(fromIndex: Int, toIndex: Int) {
_checklistItems.update { items -> _checklistItems.update { items ->
val fromItem = items.getOrNull(fromIndex) ?: return@update items
val toItem = items.getOrNull(toIndex) ?: return@update items
val mutableList = items.toMutableList() val mutableList = items.toMutableList()
val item = mutableList.removeAt(fromIndex) val item = mutableList.removeAt(fromIndex)
mutableList.add(toIndex, item)
// 🆕 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 // Update order values
mutableList.mapIndexed { index, i -> i.copy(order = index) } 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() { fun saveNote() {
viewModelScope.launch { viewModelScope.launch {
val state = _uiState.value val state = _uiState.value
@@ -231,6 +402,8 @@ class NoteEditorViewModel(
} }
val note = if (existingNote != null) { 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( existingNote!!.copy(
title = title, title = title,
content = content, content = content,
@@ -272,11 +445,14 @@ class NoteEditorViewModel(
} }
val note = if (existingNote != null) { 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( existingNote!!.copy(
title = title, title = title,
content = "", // Empty for checklists content = "", // Empty for checklists
noteType = NoteType.CHECKLIST, noteType = NoteType.CHECKLIST,
checklistItems = validItems, checklistItems = validItems,
checklistSortOption = _lastChecklistSortOption.value.name, // 🆕 v1.8.1 (IMPL_03)
updatedAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING syncStatus = SyncStatus.PENDING
) )
@@ -286,6 +462,7 @@ class NoteEditorViewModel(
content = "", content = "",
noteType = NoteType.CHECKLIST, noteType = NoteType.CHECKLIST,
checklistItems = validItems, checklistItems = validItems,
checklistSortOption = _lastChecklistSortOption.value.name, // 🆕 v1.8.1 (IMPL_03)
deviceId = DeviceIdGenerator.getDeviceId(getApplication()), deviceId = DeviceIdGenerator.getDeviceId(getApplication()),
syncStatus = SyncStatus.LOCAL_ONLY syncStatus = SyncStatus.LOCAL_ONLY
) )
@@ -295,11 +472,22 @@ class NoteEditorViewModel(
} }
} }
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_SAVED)) // 🆕 v1.8.1 (IMPL_12): NOTE_SAVED Toast entfernt — NavigateBack ist ausreichend
// 🌟 v1.6.0: Trigger onSave Sync // 🌟 v1.6.0: Trigger onSave Sync
triggerOnSaveSync() 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) _events.emit(NoteEditorEvent.NavigateBack)
} }
} }
@@ -324,17 +512,33 @@ class NoteEditorViewModel(
val success = withContext(Dispatchers.IO) { val success = withContext(Dispatchers.IO) {
webdavService.deleteNoteFromServer(noteId) webdavService.deleteNoteFromServer(noteId)
} }
// 🆕 v1.8.1 (IMPL_12): Banner-Feedback statt stiller Log-Einträge
if (success) { if (success) {
Logger.d(TAG, "Note $noteId deleted from server") Logger.d(TAG, "Note $noteId deleted from server")
SyncStateManager.showInfo(
getApplication<Application>().getString(
dev.dettmer.simplenotes.R.string.snackbar_deleted_from_server
)
)
} else { } else {
Logger.w(TAG, "Failed to delete note $noteId from server") 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) { } catch (e: Exception) {
Logger.e(TAG, "Error deleting note from server: ${e.message}") 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.ShowToast(ToastMessage.NOTE_DELETED))
_events.emit(NoteEditorEvent.NavigateBack) _events.emit(NoteEditorEvent.NavigateBack)
} }
} }
@@ -348,6 +552,41 @@ class NoteEditorViewModel(
fun canDelete(): Boolean = existingNote != null 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 // 🌟 v1.6.0: Sync Trigger - onSave
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
@@ -355,6 +594,7 @@ class NoteEditorViewModel(
/** /**
* Triggers sync after saving a note (if enabled and server configured) * Triggers sync after saving a note (if enabled and server configured)
* v1.6.0: New configurable sync trigger * 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 * Separate throttling (5 seconds) to prevent spam when saving multiple times
*/ */
@@ -365,14 +605,19 @@ class NoteEditorViewModel(
return return
} }
// Check 2: Server configured? // 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) val syncService = WebDavSyncService(getApplication())
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") { val gateResult = syncService.canSync()
Logger.d(TAG, "⏭️ Offline mode - skipping onSave sync") 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 return
} }
// Check 3: Throttling (5 seconds) to prevent spam // Check 2: Throttling (5 seconds) to prevent spam
val lastOnSaveSyncTime = prefs.getLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, 0) val lastOnSaveSyncTime = prefs.getLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, 0)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val timeSinceLastSync = now - lastOnSaveSyncTime val timeSinceLastSync = now - lastOnSaveSyncTime
@@ -390,6 +635,7 @@ class NoteEditorViewModel(
Logger.d(TAG, "📤 Triggering onSave sync") Logger.d(TAG, "📤 Triggering onSave sync")
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>() val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.addTag(Constants.SYNC_WORK_TAG) .addTag(Constants.SYNC_WORK_TAG)
.addTag(Constants.SYNC_ONSAVE_TAG) // 🆕 v1.8.1 (IMPL_08B): Bypassed globalen Cooldown
.build() .build()
WorkManager.getInstance(getApplication()).enqueue(syncRequest) WorkManager.getInstance(getApplication()).enqueue(syncRequest)
} }

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

@@ -4,12 +4,15 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.DragHandle
@@ -22,6 +25,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -30,13 +34,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.graphics.SolidColor
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration 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 androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.ui.editor.ChecklistItemState import dev.dettmer.simplenotes.ui.editor.ChecklistItemState
@@ -45,7 +53,13 @@ import dev.dettmer.simplenotes.ui.editor.ChecklistItemState
* A single row in the checklist editor with drag handle, checkbox, text input, and delete button. * A single row in the checklist editor with drag handle, checkbox, text input, and delete button.
* *
* v1.5.0: Jetpack Compose NoteEditor Redesign * 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 @Composable
fun ChecklistItemRow( fun ChecklistItemRow(
item: ChecklistItemState, item: ChecklistItemState,
@@ -54,14 +68,40 @@ fun ChecklistItemRow(
onDelete: () -> Unit, onDelete: () -> Unit,
onAddNewItem: () -> Unit, onAddNewItem: () -> Unit,
requestFocus: Boolean = false, 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 modifier: Modifier = Modifier
) { ) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val density = LocalDensity.current
var textFieldValue by remember(item.id) { var textFieldValue by remember(item.id) {
mutableStateOf(TextFieldValue(text = item.text, selection = TextRange(item.text.length))) 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) // v1.5.0: Auto-focus AND show keyboard when requestFocus is true (new items)
LaunchedEffect(requestFocus) { LaunchedEffect(requestFocus) {
if (requestFocus) { if (requestFocus) {
@@ -70,12 +110,21 @@ fun ChecklistItemRow(
} }
} }
// 🆕 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 // Update text field when external state changes
LaunchedEffect(item.text) { LaunchedEffect(item.text) {
if (textFieldValue.text != item.text) { if (textFieldValue.text != item.text) {
textFieldValue = TextFieldValue( textFieldValue = TextFieldValue(
text = item.text, text = item.text,
selection = TextRange(item.text.length) selection = if (isFocused) TextRange(item.text.length) else TextRange(0)
) )
} }
} }
@@ -83,23 +132,31 @@ fun ChecklistItemRow(
val alpha = if (item.isChecked) 0.6f else 1.0f val alpha = if (item.isChecked) 0.6f else 1.0f
val textDecoration = if (item.isChecked) TextDecoration.LineThrough else TextDecoration.None val textDecoration = if (item.isChecked) TextDecoration.LineThrough else TextDecoration.None
@Suppress("MagicNumber") // UI padding values are self-explanatory
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(end = 8.dp, top = 4.dp, bottom = 4.dp), // 🆕 v1.8.0: IMPL_023 - links kein Padding (Handle hat eigene Fläche)
verticalAlignment = Alignment.CenterVertically verticalAlignment = if (hasOverflow) Alignment.Top else Alignment.CenterVertically // 🆕 v1.8.0: Dynamisch
) { ) {
// Drag Handle // 🆕 v1.8.0: IMPL_023 - Vergrößerter Drag Handle (48dp Touch-Target)
Icon( Box(
imageVector = Icons.Default.DragHandle, modifier = dragModifier
contentDescription = stringResource(R.string.drag_to_reorder), .size(48.dp) // Material Design minimum touch target
modifier = Modifier .alpha(if (isDragging) 1.0f else 0.6f), // Visual feedback beim Drag
.size(24.dp) contentAlignment = Alignment.Center
.alpha(0.5f), ) {
tint = MaterialTheme.colorScheme.onSurfaceVariant Icon(
) imageVector = Icons.Default.DragHandle,
contentDescription = stringResource(R.string.drag_to_reorder),
Spacer(modifier = Modifier.width(4.dp)) 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
Checkbox( Checkbox(
@@ -110,62 +167,123 @@ fun ChecklistItemRow(
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
// Text Input with placeholder // 🆕 v1.8.0: Text Input mit dynamischem Overflow-Gradient
BasicTextField( Box(modifier = Modifier.weight(1f)) {
value = textFieldValue, // Scrollbarer Wrapper: begrenzt Höhe auf ~5 Zeilen wenn collapsed
onValueChange = { newValue -> Box(
// Check for newline (Enter key) modifier = if (!isFocused && hasOverflow && collapsedHeightDp != null) {
if (newValue.text.contains("\n")) { Modifier
val cleanText = newValue.text.replace("\n", "") .heightIn(max = collapsedHeightDp!!)
textFieldValue = TextFieldValue( .verticalScroll(scrollState)
text = cleanText,
selection = TextRange(cleanText.length)
)
onTextChange(cleanText)
onAddNewItem()
} else { } else {
textFieldValue = newValue Modifier
onTextChange(newValue.text)
} }
}, ) {
modifier = Modifier BasicTextField(
.weight(1f) value = textFieldValue,
.focusRequester(focusRequester) onValueChange = { newValue ->
.alpha(alpha), // Check for newline (Enter key)
textStyle = LocalTextStyle.current.copy( if (newValue.text.contains("\n")) {
color = MaterialTheme.colorScheme.onSurface, val cleanText = newValue.text.replace("\n", "")
textDecoration = textDecoration textFieldValue = TextFieldValue(
), text = cleanText,
keyboardOptions = KeyboardOptions( selection = TextRange(cleanText.length)
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { onAddNewItem() }
),
singleLine = false,
maxLines = 5,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box {
if (textFieldValue.text.isEmpty()) {
Text(
text = stringResource(R.string.item_placeholder),
style = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
) )
) 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()
}
} }
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)) Spacer(modifier = Modifier.width(4.dp))
// Delete Button // Delete Button
IconButton( IconButton(
onClick = onDelete, onClick = onDelete,
modifier = Modifier.size(36.dp) modifier = Modifier
.size(36.dp)
.padding(top = 4.dp) // 🆕 v1.8.0: Ausrichtung mit Top-aligned Text
) { ) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
@@ -176,3 +294,92 @@ fun ChecklistItemRow(
} }
} }
} }
// 🆕 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

@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION") // LocalBroadcastManager & deprecated lifecycle methods, will migrate in v2.0.0
package dev.dettmer.simplenotes.ui.main package dev.dettmer.simplenotes.ui.main
import android.Manifest import android.Manifest
@@ -42,6 +44,7 @@ import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.NotificationHelper import dev.dettmer.simplenotes.utils.NotificationHelper
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel
/** /**
* Main Activity with Jetpack Compose UI * Main Activity with Jetpack Compose UI
@@ -63,7 +66,7 @@ class ComposeMainActivity : ComponentActivity() {
private const val REQUEST_SETTINGS = 1002 private const val REQUEST_SETTINGS = 1002
} }
private val viewModel: MainViewModel by viewModels() private val viewModel: MainViewModel by viewModel()
private val prefs by lazy { private val prefs by lazy {
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
@@ -168,6 +171,9 @@ class ComposeMainActivity : ComponentActivity() {
onOpenSettings = { openSettings() }, onOpenSettings = { openSettings() },
onCreateNote = { noteType -> createNote(noteType) } onCreateNote = { noteType -> createNote(noteType) }
) )
// v1.8.0: Post-Update Changelog (shows once after update)
UpdateChangelogSheet()
} }
} }
} }
@@ -181,7 +187,11 @@ class ComposeMainActivity : ComponentActivity() {
// This ensures UI reflects current offline mode when returning from Settings // This ensures UI reflects current offline mode when returning from Settings
viewModel.refreshOfflineModeState() viewModel.refreshOfflineModeState()
// 🎨 v1.7.0: Refresh display mode when returning from Settings
viewModel.refreshDisplayMode()
// Register BroadcastReceiver for Background-Sync // Register BroadcastReceiver for Background-Sync
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional
LocalBroadcastManager.getInstance(this).registerReceiver( LocalBroadcastManager.getInstance(this).registerReceiver(
syncCompletedReceiver, syncCompletedReceiver,
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED) IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
@@ -207,29 +217,37 @@ class ComposeMainActivity : ComponentActivity() {
super.onPause() super.onPause()
// Unregister BroadcastReceiver // Unregister BroadcastReceiver
@Suppress("DEPRECATION")
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver) LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
Logger.d(TAG, "📡 BroadcastReceiver unregistered") Logger.d(TAG, "📡 BroadcastReceiver unregistered")
} }
private fun setupSyncStateObserver() { private fun setupSyncStateObserver() {
// 🆕 v1.8.0: SyncStatus nur noch für PullToRefresh-Indikator (intern)
SyncStateManager.syncStatus.observe(this) { status -> SyncStateManager.syncStatus.observe(this) { status ->
viewModel.updateSyncState(status) viewModel.updateSyncState(status)
}
// Hide banner after delay for completed/error states // 🆕 v1.8.0: Auto-Hide via SyncProgress (einziges Banner-System)
when (status.state) { lifecycleScope.launch {
SyncStateManager.SyncState.COMPLETED -> { SyncStateManager.syncProgress.collect { progress ->
lifecycleScope.launch { @Suppress("MagicNumber") // UI timing delays for banner visibility
kotlinx.coroutines.delay(1500L) when (progress.phase) {
dev.dettmer.simplenotes.sync.SyncPhase.COMPLETED -> {
kotlinx.coroutines.delay(2000L)
SyncStateManager.reset() SyncStateManager.reset()
} }
} // 🆕 v1.8.1 (IMPL_12): INFO-Meldungen nach 2.5s ausblenden
SyncStateManager.SyncState.ERROR -> { dev.dettmer.simplenotes.sync.SyncPhase.INFO -> {
lifecycleScope.launch { kotlinx.coroutines.delay(2500L)
kotlinx.coroutines.delay(3000L)
SyncStateManager.reset() SyncStateManager.reset()
} }
dev.dettmer.simplenotes.sync.SyncPhase.ERROR -> {
kotlinx.coroutines.delay(4000L)
SyncStateManager.reset()
}
else -> { /* No action needed */ }
} }
else -> { /* No action needed */ }
} }
} }
} }
@@ -334,6 +352,8 @@ class ComposeMainActivity : ComponentActivity() {
} }
} }
@Deprecated("Deprecated in API 23", ReplaceWith("Use ActivityResultContracts"))
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
override fun onRequestPermissionsResult( override fun onRequestPermissionsResult(
requestCode: Int, requestCode: Int,
permissions: Array<out String>, permissions: Array<out String>,

View File

@@ -8,14 +8,18 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.Settings 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 import androidx.compose.material3.ExperimentalMaterial3Api
// FabPosition nicht mehr benötigt - FAB wird manuell platziert // FabPosition nicht mehr benötigt - FAB wird manuell platziert
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -45,13 +49,19 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.NoteType import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.ui.main.components.SortDialog
import dev.dettmer.simplenotes.sync.SyncStateManager import dev.dettmer.simplenotes.sync.SyncStateManager
import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog
import dev.dettmer.simplenotes.ui.main.components.EmptyState import dev.dettmer.simplenotes.ui.main.components.EmptyState
import dev.dettmer.simplenotes.ui.main.components.NoteTypeFAB import dev.dettmer.simplenotes.ui.main.components.NoteTypeFAB
import dev.dettmer.simplenotes.ui.main.components.NotesList import dev.dettmer.simplenotes.ui.main.components.NotesList
import dev.dettmer.simplenotes.ui.main.components.SyncStatusBanner 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 import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L
/** /**
* Main screen displaying the notes list * Main screen displaying the notes list
@@ -65,16 +75,18 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen( fun MainScreen(
viewModel: MainViewModel, viewModel: MainViewModel = koinViewModel(),
onOpenNote: (String?) -> Unit, onOpenNote: (String?) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onCreateNote: (NoteType) -> Unit onCreateNote: (NoteType) -> Unit
) { ) {
val notes by viewModel.notes.collectAsState() val notes by viewModel.sortedNotes.collectAsState()
val syncState by viewModel.syncState.collectAsState() val syncState by viewModel.syncState.collectAsState()
val syncMessage by viewModel.syncMessage.collectAsState()
val scrollToTop by viewModel.scrollToTop.collectAsState() val scrollToTop by viewModel.scrollToTop.collectAsState()
// 🆕 v1.8.0: Einziges Banner-System
val syncProgress by viewModel.syncProgress.collectAsState()
// Multi-Select State // Multi-Select State
val selectedNotes by viewModel.selectedNotes.collectAsState() val selectedNotes by viewModel.selectedNotes.collectAsState()
val isSelectionMode by viewModel.isSelectionMode.collectAsState() val isSelectionMode by viewModel.isSelectionMode.collectAsState()
@@ -82,12 +94,34 @@ fun MainScreen(
// 🌟 v1.6.0: Reactive offline mode state // 🌟 v1.6.0: Reactive offline mode state
val isOfflineMode by viewModel.isOfflineMode.collectAsState() val isOfflineMode by viewModel.isOfflineMode.collectAsState()
// 🎨 v1.7.0: Display mode (list or grid)
val displayMode by viewModel.displayMode.collectAsState()
// Delete confirmation dialog state // Delete confirmation dialog state
var showBatchDeleteDialog by remember { mutableStateOf(false) } 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 snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = rememberLazyListState() 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 // Compute isSyncing once
val isSyncing = syncState == SyncStateManager.SyncState.SYNCING val isSyncing = syncState == SyncStateManager.SyncState.SYNCING
@@ -116,9 +150,14 @@ fun MainScreen(
} }
// Phase 3: Scroll to top when new note created // Phase 3: Scroll to top when new note created
// 🎨 v1.7.0: Unterstützt beide Display-Modi (list & grid)
LaunchedEffect(scrollToTop) { LaunchedEffect(scrollToTop) {
if (scrollToTop) { if (scrollToTop) {
listState.animateScrollToItem(0) if (displayMode == "grid") {
gridState.animateScrollToItem(0)
} else {
listState.animateScrollToItem(0)
}
viewModel.resetScrollToTop() viewModel.resetScrollToTop()
} }
} }
@@ -147,6 +186,9 @@ fun MainScreen(
) { ) {
MainTopBar( MainTopBar(
syncEnabled = canSync, 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") }, onSyncClick = { viewModel.triggerManualSync("toolbar") },
onSettingsClick = onOpenSettings onSettingsClick = onOpenSettings
) )
@@ -167,32 +209,56 @@ fun MainScreen(
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
// Main content column // Main content column
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Sync Status Banner (not affected by pull-to-refresh) // 🆕 v1.8.0: Einziges Sync Banner (Progress + Ergebnis)
SyncStatusBanner( SyncProgressBanner(
syncState = syncState, progress = syncProgress,
message = syncMessage modifier = Modifier.fillMaxWidth()
) )
// Content: Empty state or notes list // Content: Empty state or notes list
if (notes.isEmpty()) { if (notes.isEmpty()) {
EmptyState(modifier = Modifier.weight(1f)) EmptyState(modifier = Modifier.weight(1f))
} else { } else {
NotesList( // 🎨 v1.7.0: Switch between List and Grid based on display mode
notes = notes, if (displayMode == "grid") {
showSyncStatus = viewModel.isServerConfigured(), NotesStaggeredGrid(
selectedNotes = selectedNotes, notes = notes,
isSelectionMode = isSelectionMode, gridState = gridState,
listState = listState, showSyncStatus = viewModel.isServerConfigured(),
modifier = Modifier.weight(1f), selectedNoteIds = selectedNotes,
onNoteClick = { note -> onOpenNote(note.id) }, isSelectionMode = isSelectionMode,
onNoteLongPress = { note -> timestampTicker = timestampTicker,
// Long-press starts selection mode modifier = Modifier.weight(1f),
viewModel.startSelectionMode(note.id) onNoteClick = { note ->
}, if (isSelectionMode) {
onNoteSelectionToggle = { note -> viewModel.toggleNoteSelection(note.id)
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)
}
)
}
} }
} }
@@ -229,6 +295,28 @@ fun MainScreen(
} }
) )
} }
// 🆕 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 }
)
}
} }
} }
@@ -236,6 +324,9 @@ fun MainScreen(
@Composable @Composable
private fun MainTopBar( private fun MainTopBar(
syncEnabled: Boolean, 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, onSyncClick: () -> Unit,
onSettingsClick: () -> Unit onSettingsClick: () -> Unit
) { ) {
@@ -247,6 +338,23 @@ private fun MainTopBar(
) )
}, },
actions = { 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( IconButton(
onClick = onSyncClick, onClick = onSyncClick,
enabled = syncEnabled enabled = syncEnabled

View File

@@ -2,11 +2,16 @@ package dev.dettmer.simplenotes.ui.main
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.dettmer.simplenotes.models.Note 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.R
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncProgress
import dev.dettmer.simplenotes.sync.SyncStateManager import dev.dettmer.simplenotes.sync.SyncStateManager
import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
@@ -19,6 +24,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -30,7 +36,10 @@ import kotlinx.coroutines.withContext
* *
* Manages notes list, sync state, and deletion with undo. * Manages notes list, sync state, and deletion with undo.
*/ */
class MainViewModel(application: Application) : AndroidViewModel(application) { class MainViewModel(
private val storage: NotesStorage,
private val prefs: SharedPreferences
) : ViewModel() {
companion object { companion object {
private const val TAG = "MainViewModel" private const val TAG = "MainViewModel"
@@ -38,9 +47,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp" 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 // Notes State
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -83,15 +89,69 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Sync State (derived from SyncStateManager) // 🎨 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) private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE)
val syncState: StateFlow<SyncStateManager.SyncState> = _syncState.asStateFlow() val syncState: StateFlow<SyncStateManager.SyncState> = _syncState.asStateFlow()
private val _syncMessage = MutableStateFlow<String?>(null)
val syncMessage: StateFlow<String?> = _syncMessage.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// UI Events // UI Events
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -149,7 +209,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private suspend fun loadNotesAsync() { private suspend fun loadNotesAsync() {
val allNotes = storage.loadAllNotes() val allNotes = storage.loadAllNotes()
val pendingIds = _pendingDeletions.value val pendingIds = _pendingDeletions.value
val filteredNotes = allNotes.filter { it.id !in pendingIds } val filteredNotes = allNotes.filter { it.id !in pendingIds }.map { Note(
id = it.id,
content = it.content
) }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
// Phase 3: Detect if a new note was added at the top // Phase 3: Detect if a new note was added at the top
@@ -271,6 +334,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
)) ))
@Suppress("MagicNumber") // Snackbar timing coordination
// If delete from server, actually delete after a short delay // If delete from server, actually delete after a short delay
// (to allow undo action before server deletion) // (to allow undo action before server deletion)
if (deleteFromServer) { if (deleteFromServer) {
@@ -370,6 +434,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
)) ))
@Suppress("MagicNumber") // Snackbar timing
// If delete from server, actually delete after snackbar timeout // If delete from server, actually delete after snackbar timeout
if (deleteFromServer) { if (deleteFromServer) {
kotlinx.coroutines.delay(3500) // Snackbar shows for ~3s kotlinx.coroutines.delay(3500) // Snackbar shows for ~3s
@@ -410,12 +475,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
if (success) { if (success) {
_showToast.emit(getString(R.string.snackbar_deleted_from_server)) // 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO
SyncStateManager.showInfo(getString(R.string.snackbar_deleted_from_server))
} else { } else {
_showToast.emit(getString(R.string.snackbar_server_delete_failed)) SyncStateManager.showError(getString(R.string.snackbar_server_delete_failed))
} }
} catch (e: Exception) { } catch (e: Exception) {
_showToast.emit(getString(R.string.snackbar_server_error, e.message ?: "")) SyncStateManager.showError(getString(R.string.snackbar_server_error, e.message ?: ""))
} finally { } finally {
// Remove from pending deletions // Remove from pending deletions
_pendingDeletions.value = _pendingDeletions.value - noteId _pendingDeletions.value = _pendingDeletions.value - noteId
@@ -440,13 +506,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
if (success) successCount++ else failCount++ if (success) successCount++ else failCount++
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(TAG, "Failed to delete note $noteId from server: ${e.message}")
failCount++ failCount++
} finally { } finally {
_pendingDeletions.value = _pendingDeletions.value - noteId _pendingDeletions.value = _pendingDeletions.value - noteId
} }
} }
// Show aggregated toast // 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO/ERROR
val message = when { val message = when {
failCount == 0 -> getString(R.string.snackbar_notes_deleted_from_server, successCount) failCount == 0 -> getString(R.string.snackbar_notes_deleted_from_server, successCount)
successCount == 0 -> getString(R.string.snackbar_server_delete_failed) successCount == 0 -> getString(R.string.snackbar_server_delete_failed)
@@ -456,7 +523,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
successCount + failCount successCount + failCount
) )
} }
_showToast.emit(message) if (failCount > 0) {
SyncStateManager.showError(message)
} else {
SyncStateManager.showInfo(message)
}
} }
} }
@@ -473,31 +544,59 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun updateSyncState(status: SyncStateManager.SyncStatus) { fun updateSyncState(status: SyncStateManager.SyncStatus) {
_syncState.value = status.state _syncState.value = status.state
_syncMessage.value = status.message
} }
/** /**
* Trigger manual sync (from toolbar button or pull-to-refresh) * 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") { fun triggerManualSync(source: String = "manual") {
// 🌟 v1.6.0: Block sync in offline mode // 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
if (prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)) { val syncService = WebDavSyncService(getApplication())
Logger.d(TAG, "⏭️ $source Sync blocked: Offline mode enabled") 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 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.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 return
} }
// 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (nach tryStartSync, vor Launch)
SyncStateManager.markGlobalSyncStarted(prefs)
viewModelScope.launch { viewModelScope.launch {
try { try {
val syncService = WebDavSyncService(getApplication()) // Check for unsynced changes (Banner zeigt bereits PREPARING)
// Check for unsynced changes
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ $source Sync: No unsynced changes") Logger.d(TAG, "⏭️ $source Sync: No unsynced changes")
SyncStateManager.markCompleted("Bereits synchronisiert") SyncStateManager.markCompleted(getString(R.string.toast_already_synced))
loadNotes() loadNotes()
return@launch return@launch
} }
@@ -519,10 +618,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
if (result.isSuccess) { if (result.isSuccess) {
val bannerMessage = if (result.syncedCount > 0) { // 🆕 v1.8.0 (IMPL_022): Erweiterte Banner-Nachricht mit Löschungen
getString(R.string.toast_sync_success, result.syncedCount) val bannerMessage = buildString {
} else { if (result.syncedCount > 0) {
getString(R.string.snackbar_nothing_to_sync) 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) SyncStateManager.markCompleted(bannerMessage)
loadNotes() loadNotes()
@@ -540,6 +647,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* Only runs if server is configured and interval has passed * 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.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.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") { fun triggerAutoSync(source: String = "auto") {
// 🌟 v1.6.0: Check if onResume trigger is enabled // 🌟 v1.6.0: Check if onResume trigger is enabled
@@ -548,19 +656,30 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return return
} }
// Throttling check // 🆕 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()) { if (!canTriggerAutoSync()) {
return return
} }
// Check if server is configured // 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) val syncService = WebDavSyncService(getApplication())
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") { val gateResult = syncService.canSync()
Logger.d(TAG, "⏭️ Offline mode - skipping onResume sync") 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 return
} }
// v1.5.0: silent=true - kein Banner bei Auto-Sync, aber Fehler werden trotzdem angezeigt // 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)) { if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress") Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
return return
@@ -571,14 +690,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
// Update last sync timestamp // Update last sync timestamp
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply() 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 { viewModelScope.launch {
try { try {
val syncService = WebDavSyncService(getApplication())
// Check for unsynced changes // Check for unsynced changes
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping") Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
SyncStateManager.reset() SyncStateManager.reset() // Silent → geht direkt auf IDLE
return@launch return@launch
} }
@@ -589,7 +709,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
if (!isReachable) { if (!isReachable) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently") Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
SyncStateManager.reset() SyncStateManager.reset() // Silent → kein Error-Banner
return@launch return@launch
} }
@@ -600,14 +720,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
if (result.isSuccess && result.syncedCount > 0) { if (result.isSuccess && result.syncedCount > 0) {
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes") 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)) SyncStateManager.markCompleted(getString(R.string.toast_sync_success, result.syncedCount))
_showToast.emit(getString(R.string.snackbar_synced_count, result.syncedCount))
loadNotes() loadNotes()
} else if (result.isSuccess) { } else if (result.isSuccess) {
Logger.d(TAG, " Auto-sync ($source): No changes") Logger.d(TAG, " Auto-sync ($source): No changes")
SyncStateManager.markCompleted(getString(R.string.snackbar_nothing_to_sync)) SyncStateManager.markCompleted() // Silent → geht direkt auf IDLE
} else { } else {
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}") Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
// Fehler werden IMMER angezeigt (auch bei Silent-Sync)
SyncStateManager.markError(result.errorMessage) SyncStateManager.markError(result.errorMessage)
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -631,6 +754,58 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return true 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 // Helpers
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════

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

@@ -63,16 +63,21 @@ fun NoteCard(
showSyncStatus: Boolean, showSyncStatus: Boolean,
isSelected: Boolean = false, isSelected: Boolean = false,
isSelectionMode: Boolean = false, isSelectionMode: Boolean = false,
timestampTicker: Long = 0L,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit onLongClick: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
// ⏱️ Reading timestampTicker triggers recomposition only for visible cards
@Suppress("UNUSED_VARIABLE")
val ticker = timestampTicker
Card( Card(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp) // 🎨 v1.7.0: Externes Padding entfernt - Grid/Liste steuert Abstände
.then( .then(
if (isSelected) { if (isSelected) {
Modifier.border( Modifier.border(
@@ -181,11 +186,19 @@ fun NoteCard(
SyncStatus.PENDING -> Icons.Outlined.CloudSync SyncStatus.PENDING -> Icons.Outlined.CloudSync
SyncStatus.CONFLICT -> Icons.Default.Warning SyncStatus.CONFLICT -> Icons.Default.Warning
SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff 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
}, },
contentDescription = null,
tint = when (note.syncStatus) { tint = when (note.syncStatus) {
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
SyncStatus.DELETED_ON_SERVER -> MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) // 🆕 v1.8.0
else -> MaterialTheme.colorScheme.outline else -> MaterialTheme.colorScheme.outline
}, },
modifier = Modifier.size(16.dp) 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

@@ -2,6 +2,7 @@ package dev.dettmer.simplenotes.ui.main.components
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -19,13 +20,16 @@ import dev.dettmer.simplenotes.models.Note
* - NO caching tricks * - NO caching tricks
* - Selection state passed through as parameters * - Selection state passed through as parameters
* - Tap behavior changes based on selection mode * - Tap behavior changes based on selection mode
* - ⏱️ timestampTicker triggers recomposition for relative time updates
*/ */
@Suppress("LongParameterList") // Composable with many UI state parameters
@Composable @Composable
fun NotesList( fun NotesList(
notes: List<Note>, notes: List<Note>,
showSyncStatus: Boolean, showSyncStatus: Boolean,
selectedNotes: Set<String> = emptySet(), selectedNotes: Set<String> = emptySet(),
isSelectionMode: Boolean = false, isSelectionMode: Boolean = false,
timestampTicker: Long = 0L,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(), listState: LazyListState = rememberLazyListState(),
onNoteClick: (Note) -> Unit, onNoteClick: (Note) -> Unit,
@@ -49,6 +53,9 @@ fun NotesList(
showSyncStatus = showSyncStatus, showSyncStatus = showSyncStatus,
isSelected = isSelected, isSelected = isSelected,
isSelectionMode = isSelectionMode, 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 = { onClick = {
if (isSelectionMode) { if (isSelectionMode) {
// In selection mode, tap toggles selection // In selection mode, tap toggles selection

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

@@ -5,12 +5,8 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -25,6 +21,7 @@ import dev.dettmer.simplenotes.sync.SyncStateManager
* Sync status banner shown below the toolbar during sync * Sync status banner shown below the toolbar during sync
* v1.5.0: Jetpack Compose MainActivity Redesign * v1.5.0: Jetpack Compose MainActivity Redesign
* v1.5.0: SYNCING_SILENT ignorieren - Banner nur bei manuellen Syncs oder Fehlern anzeigen * 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 @Composable
fun SyncStatusBanner( fun SyncStatusBanner(
@@ -32,10 +29,10 @@ fun SyncStatusBanner(
message: String?, message: String?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
// v1.5.0: Banner nicht anzeigen bei IDLE oder SYNCING_SILENT (Auto-Sync im Hintergrund) // v1.8.0: Nur COMPLETED/ERROR anzeigen (SYNCING wird von SyncProgressBanner übernommen)
// Fehler werden trotzdem angezeigt (ERROR state nach Silent-Sync wechselt zu ERROR, nicht SYNCING_SILENT) // IDLE und SYNCING_SILENT werden ignoriert
val isVisible = syncState != SyncStateManager.SyncState.IDLE val isVisible = syncState == SyncStateManager.SyncState.COMPLETED
&& syncState != SyncStateManager.SyncState.SYNCING_SILENT || syncState == SyncStateManager.SyncState.ERROR
AnimatedVisibility( AnimatedVisibility(
visible = isVisible, visible = isVisible,
@@ -50,23 +47,13 @@ fun SyncStatusBanner(
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (syncState == SyncStateManager.SyncState.SYNCING) { // v1.8.0: Kein Loading-Icon mehr - wird von SyncProgressBanner übernommen
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.width(12.dp))
Text( Text(
text = when (syncState) { text = when (syncState) {
SyncStateManager.SyncState.SYNCING -> stringResource(R.string.sync_status_syncing)
SyncStateManager.SyncState.SYNCING_SILENT -> "" // v1.5.0: Wird nicht angezeigt (isVisible = false)
SyncStateManager.SyncState.COMPLETED -> message ?: stringResource(R.string.sync_status_completed) SyncStateManager.SyncState.COMPLETED -> message ?: stringResource(R.string.sync_status_completed)
SyncStateManager.SyncState.ERROR -> message ?: stringResource(R.string.sync_status_error) SyncStateManager.SyncState.ERROR -> message ?: stringResource(R.string.sync_status_error)
SyncStateManager.SyncState.IDLE -> "" else -> "" // SYNCING/IDLE/SYNCING_SILENT nicht mehr relevant
}, },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer, color = MaterialTheme.colorScheme.onPrimaryContainer,

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

@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.ui.settings package dev.dettmer.simplenotes.ui.settings
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
@@ -149,7 +150,12 @@ class ComposeSettingsActivity : AppCompatActivity() {
/** /**
* Open system battery optimization settings * Open system battery optimization settings
* v1.5.0: Ported from old SettingsActivity * 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() { private fun openBatteryOptimizationSettings() {
try { try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
@@ -183,4 +189,16 @@ class ComposeSettingsActivity : AppCompatActivity() {
Logger.e(TAG, "❌ Failed to restart NetworkMonitor: ${e.message}") 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

@@ -7,6 +7,7 @@ import androidx.navigation.compose.composable
import dev.dettmer.simplenotes.ui.settings.screens.AboutScreen import dev.dettmer.simplenotes.ui.settings.screens.AboutScreen
import dev.dettmer.simplenotes.ui.settings.screens.BackupSettingsScreen import dev.dettmer.simplenotes.ui.settings.screens.BackupSettingsScreen
import dev.dettmer.simplenotes.ui.settings.screens.DebugSettingsScreen 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.LanguageSettingsScreen
import dev.dettmer.simplenotes.ui.settings.screens.MarkdownSettingsScreen import dev.dettmer.simplenotes.ui.settings.screens.MarkdownSettingsScreen
import dev.dettmer.simplenotes.ui.settings.screens.ServerSettingsScreen import dev.dettmer.simplenotes.ui.settings.screens.ServerSettingsScreen
@@ -95,5 +96,13 @@ fun SettingsNavHost(
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() }
) )
} }
// 🎨 v1.7.0: Display Settings
composable(SettingsRoute.Display.route) {
DisplaySettingsScreen(
viewModel = viewModel,
onBack = { navController.popBackStack() }
)
}
} }
} }

View File

@@ -13,4 +13,5 @@ sealed class SettingsRoute(val route: String) {
data object Backup : SettingsRoute("settings_backup") data object Backup : SettingsRoute("settings_backup")
data object About : SettingsRoute("settings_about") data object About : SettingsRoute("settings_about")
data object Debug : SettingsRoute("settings_debug") data object Debug : SettingsRoute("settings_debug")
data object Display : SettingsRoute("settings_display") // 🎨 v1.7.0
} }

View File

@@ -9,10 +9,12 @@ import androidx.lifecycle.viewModelScope
import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.backup.BackupManager import dev.dettmer.simplenotes.backup.BackupManager
import dev.dettmer.simplenotes.backup.RestoreMode import dev.dettmer.simplenotes.backup.RestoreMode
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@@ -33,15 +35,23 @@ import java.net.URL
* *
* Manages all settings state and actions across the Settings navigation graph. * 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) { class SettingsViewModel(application: Application) : AndroidViewModel(application) {
companion object { companion object {
private const val TAG = "SettingsViewModel" private const val TAG = "SettingsViewModel"
private const val CONNECTION_TIMEOUT_MS = 3000 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) private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val backupManager = BackupManager(application) 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 // Server Settings State
@@ -128,6 +138,12 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
) )
val syncInterval: StateFlow<Long> = _syncInterval.asStateFlow() 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 // 🌟 v1.6.0: Configurable Sync Triggers
private val _triggerOnSave = MutableStateFlow( private val _triggerOnSave = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, Constants.DEFAULT_TRIGGER_ON_SAVE) prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, Constants.DEFAULT_TRIGGER_ON_SAVE)
@@ -154,6 +170,12 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
) )
val triggerBoot: StateFlow<Boolean> = _triggerBoot.asStateFlow() 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 // Markdown Settings State
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -173,6 +195,15 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
) )
val fileLoggingEnabled: StateFlow<Boolean> = _fileLoggingEnabled.asStateFlow() 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 // UI State
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -183,6 +214,10 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
private val _isBackupInProgress = MutableStateFlow(false) private val _isBackupInProgress = MutableStateFlow(false)
val isBackupInProgress: StateFlow<Boolean> = _isBackupInProgress.asStateFlow() 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>() private val _showToast = MutableSharedFlow<String>()
val showToast: SharedFlow<String> = _showToast.asSharedFlow() val showToast: SharedFlow<String> = _showToast.asSharedFlow()
@@ -216,41 +251,130 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
/** /**
* 🌟 v1.6.0: Update only the host part of the server URL * 🌟 v1.6.0: Update only the host part of the server URL
* The protocol prefix is handled separately by updateProtocol() * 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) { fun updateServerHost(host: String) {
_serverHost.value = host _serverHost.value = host
saveServerSettings()
// ✅ 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) { fun updateProtocol(useHttps: Boolean) {
_isHttps.value = useHttps _isHttps.value = useHttps
// 🌟 v1.6.0: Host stays the same, only prefix changes // 🌟 v1.6.0: Host stays the same, only prefix changes
saveServerSettings() // 🔧 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) { fun updateUsername(value: String) {
_username.value = value _username.value = value
saveServerSettings() // 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
prefs.edit().putString(Constants.KEY_USERNAME, value).apply()
} }
fun updatePassword(value: String) { fun updatePassword(value: String) {
_password.value = value _password.value = value
saveServerSettings() // 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
prefs.edit().putString(Constants.KEY_PASSWORD, value).apply()
} }
private fun saveServerSettings() { /**
* 🔧 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 // 🌟 v1.6.0: Construct full URL from prefix + host
val prefix = if (_isHttps.value) "https://" else "http://" val prefix = if (_isHttps.value) "https://" else "http://"
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
prefs.edit().apply { // 🔄 v1.7.0: Detect server change ONLY against last confirmed URL
putString(Constants.KEY_SERVER_URL, fullUrl) val serverChanged = isServerReallyChanged(confirmedServerUrl, fullUrl)
putString(Constants.KEY_USERNAME, _username.value)
putString(Constants.KEY_PASSWORD, _password.value) // ✅ Settings are already saved in updateServerHost/Protocol/Username/Password
apply() // 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() { fun testConnection() {
viewModelScope.launch { viewModelScope.launch {
_serverStatus.value = ServerStatus.Checking _serverStatus.value = ServerStatus.Checking
@@ -318,9 +442,21 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
viewModelScope.launch { viewModelScope.launch {
_isSyncing.value = true _isSyncing.value = true
try { try {
emitToast(getString(R.string.toast_syncing))
val syncService = WebDavSyncService(getApplication()) 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()) { if (!syncService.hasUnsyncedChanges()) {
emitToast(getString(R.string.toast_already_synced)) emitToast(getString(R.string.toast_already_synced))
return@launch return@launch
@@ -374,6 +510,16 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
} }
// 🆕 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 // 🌟 v1.6.0: Configurable Sync Triggers Setters
fun setTriggerOnSave(enabled: Boolean) { fun setTriggerOnSave(enabled: Boolean) {
@@ -412,6 +558,16 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
Logger.d(TAG, "Trigger Boot: $enabled") 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 // Markdown Settings Actions
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -462,6 +618,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
_markdownExportProgress.value = MarkdownExportProgress(noteCount, noteCount, isComplete = true) _markdownExportProgress.value = MarkdownExportProgress(noteCount, noteCount, isComplete = true)
emitToast(getString(R.string.toast_markdown_exported, exportedCount)) emitToast(getString(R.string.toast_markdown_exported, exportedCount))
@Suppress("MagicNumber") // UI progress delay
// Clear progress after short delay // Clear progress after short delay
kotlinx.coroutines.delay(500) kotlinx.coroutines.delay(500)
_markdownExportProgress.value = null _markdownExportProgress.value = null
@@ -518,40 +675,81 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
// Backup Actions // Backup Actions
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
fun createBackup(uri: Uri) { fun createBackup(uri: Uri, password: String? = null) {
viewModelScope.launch { viewModelScope.launch {
_isBackupInProgress.value = true _isBackupInProgress.value = true
_backupStatusText.value = getString(R.string.backup_progress_creating)
try { try {
val result = backupManager.createBackup(uri) val result = backupManager.createBackup(uri, password)
val message = if (result.success) {
getString(R.string.toast_backup_success, result.message ?: "") // Phase 2: Show completion status
_backupStatusText.value = if (result.success) {
getString(R.string.backup_progress_complete)
} else { } else {
getString(R.string.toast_backup_failed, result.error ?: "") getString(R.string.backup_progress_failed)
} }
emitToast(message)
// Phase 3: Clear after delay
delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
} catch (e: Exception) { } catch (e: Exception) {
emitToast(getString(R.string.toast_backup_failed, e.message ?: "")) Logger.e(TAG, "Failed to create backup", e)
_backupStatusText.value = getString(R.string.backup_progress_failed)
delay(STATUS_CLEAR_DELAY_ERROR_MS)
} finally { } finally {
_isBackupInProgress.value = false _isBackupInProgress.value = false
_backupStatusText.value = ""
} }
} }
} }
fun restoreFromFile(uri: Uri, mode: RestoreMode) { fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) {
viewModelScope.launch { viewModelScope.launch {
_isBackupInProgress.value = true _isBackupInProgress.value = true
_backupStatusText.value = getString(R.string.backup_progress_restoring)
try { try {
val result = backupManager.restoreBackup(uri, mode) val result = backupManager.restoreBackup(uri, mode, password)
val message = if (result.success) {
getString(R.string.toast_restore_success, result.importedNotes) // Phase 2: Show completion status
_backupStatusText.value = if (result.success) {
getString(R.string.restore_progress_complete)
} else { } else {
getString(R.string.toast_restore_failed, result.error ?: "") getString(R.string.restore_progress_failed)
} }
emitToast(message)
// Phase 3: Clear after delay
delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
} catch (e: Exception) { } catch (e: Exception) {
emitToast(getString(R.string.toast_restore_failed, e.message ?: "")) 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 { } finally {
_isBackupInProgress.value = false _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
} }
} }
} }
@@ -559,22 +757,30 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun restoreFromServer(mode: RestoreMode) { fun restoreFromServer(mode: RestoreMode) {
viewModelScope.launch { viewModelScope.launch {
_isBackupInProgress.value = true _isBackupInProgress.value = true
_backupStatusText.value = getString(R.string.backup_progress_restoring_server)
try { try {
emitToast(getString(R.string.restore_progress))
val syncService = WebDavSyncService(getApplication()) val syncService = WebDavSyncService(getApplication())
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
syncService.restoreFromServer(mode) syncService.restoreFromServer(mode)
} }
val message = if (result.isSuccess) {
getString(R.string.toast_restore_success, result.restoredCount) // Phase 2: Show completion status
_backupStatusText.value = if (result.isSuccess) {
getString(R.string.restore_server_progress_complete)
} else { } else {
getString(R.string.toast_restore_failed, result.errorMessage ?: "") getString(R.string.restore_server_progress_failed)
} }
emitToast(message)
// Phase 3: Clear after delay
delay(if (result.isSuccess) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
} catch (e: Exception) { } catch (e: Exception) {
emitToast(getString(R.string.toast_error, e.message ?: "")) 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 { } finally {
_isBackupInProgress.value = false _isBackupInProgress.value = false
_backupStatusText.value = ""
} }
} }
} }
@@ -605,6 +811,16 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun getLogFile() = Logger.getLogFile(getApplication()) 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 // Helper
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -623,10 +839,42 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
serverUrl != "https://" serverUrl != "https://"
} }
private fun getString(resId: Int): String = getApplication<android.app.Application>().getString(resId) /**
* 🌍 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 = private fun getString(resId: Int, vararg formatArgs: Any): String {
getApplication<android.app.Application>().getString(resId, *formatArgs) // 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) { private suspend fun emitToast(message: String) {
_showToast.emit(message) _showToast.emit(message)
@@ -663,4 +911,17 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
val total: Int, val total: Int,
val isComplete: Boolean = false 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

@@ -1,9 +1,13 @@
package dev.dettmer.simplenotes.ui.settings.components 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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -11,12 +15,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/** /**
* Primary filled button for settings actions * Primary filled button for settings actions
* v1.5.0: Jetpack Compose Settings Redesign * v1.5.0: Jetpack Compose Settings Redesign
* v1.8.0: Button keeps text during loading, just becomes disabled
*/ */
@Composable @Composable
fun SettingsButton( fun SettingsButton(
@@ -31,20 +37,13 @@ fun SettingsButton(
enabled = enabled && !isLoading, enabled = enabled && !isLoading,
modifier = modifier.fillMaxWidth() modifier = modifier.fillMaxWidth()
) { ) {
if (isLoading) { Text(text)
CircularProgressIndicator(
modifier = Modifier.height(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text(text)
}
} }
} }
/** /**
* Outlined secondary button for settings actions * Outlined secondary button for settings actions
* v1.8.0: Button keeps text during loading, just becomes disabled
*/ */
@Composable @Composable
fun SettingsOutlinedButton( fun SettingsOutlinedButton(
@@ -59,15 +58,7 @@ fun SettingsOutlinedButton(
enabled = enabled && !isLoading, enabled = enabled && !isLoading,
modifier = modifier.fillMaxWidth() modifier = modifier.fillMaxWidth()
) { ) {
if (isLoading) { Text(text)
CircularProgressIndicator(
modifier = Modifier.height(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
} else {
Text(text)
}
} }
} }
@@ -159,3 +150,48 @@ fun SettingsDivider(
) )
Spacer(modifier = Modifier.height(8.dp)) 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

@@ -1,3 +1,4 @@
@file:Suppress("MatchingDeclarationName")
package dev.dettmer.simplenotes.ui.settings.components package dev.dettmer.simplenotes.ui.settings.components
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column

View File

@@ -20,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Code 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.Person
import androidx.compose.material.icons.filled.Policy import androidx.compose.material.icons.filled.Policy
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -56,6 +57,7 @@ fun AboutScreen(
val githubRepoUrl = "https://github.com/inventory69/simple-notes-sync" val githubRepoUrl = "https://github.com/inventory69/simple-notes-sync"
val githubProfileUrl = "https://github.com/inventory69" val githubProfileUrl = "https://github.com/inventory69"
val licenseUrl = "https://github.com/inventory69/simple-notes-sync/blob/main/LICENSE" 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( SettingsScaffold(
title = stringResource(R.string.about_settings_title), title = stringResource(R.string.about_settings_title),
@@ -162,6 +164,17 @@ fun AboutScreen(
} }
) )
// 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() SettingsDivider()
// Data Privacy Info // Data Privacy Info

View File

@@ -15,6 +15,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -26,6 +27,8 @@ import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.backup.RestoreMode import dev.dettmer.simplenotes.backup.RestoreMode
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel 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.RadioOption
import dev.dettmer.simplenotes.ui.settings.components.SettingsButton import dev.dettmer.simplenotes.ui.settings.components.SettingsButton
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
@@ -34,9 +37,14 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsOutlinedButton
import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale 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 * Backup and restore settings screen
@@ -58,11 +66,29 @@ fun BackupSettingsScreen(
var pendingRestoreUri by remember { mutableStateOf<Uri?>(null) } var pendingRestoreUri by remember { mutableStateOf<Uri?>(null) }
var selectedRestoreMode by remember { mutableStateOf(RestoreMode.MERGE) } 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 // File picker launchers
val createBackupLauncher = rememberLauncherForActivityResult( val createBackupLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json") contract = ActivityResultContracts.CreateDocument("application/json")
) { uri -> ) { uri ->
uri?.let { viewModel.createBackup(it) } 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( val restoreFileLauncher = rememberLauncherForActivityResult(
@@ -75,6 +101,15 @@ fun BackupSettingsScreen(
} }
} }
// 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( SettingsScaffold(
title = stringResource(R.string.backup_settings_title), title = stringResource(R.string.backup_settings_title),
onBack = onBack onBack = onBack
@@ -92,6 +127,16 @@ fun BackupSettingsScreen(
text = stringResource(R.string.backup_auto_info) 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)) Spacer(modifier = Modifier.height(16.dp))
// Local Backup Section // Local Backup Section
@@ -99,6 +144,16 @@ fun BackupSettingsScreen(
Spacer(modifier = Modifier.height(8.dp)) 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( SettingsButton(
text = stringResource(R.string.backup_create), text = stringResource(R.string.backup_create),
onClick = { onClick = {
@@ -156,6 +211,47 @@ fun BackupSettingsScreen(
} }
} }
// 🔐 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 // Restore Mode Dialog
if (showRestoreDialog) { if (showRestoreDialog) {
RestoreModeDialog( RestoreModeDialog(
@@ -167,11 +263,29 @@ fun BackupSettingsScreen(
when (restoreSource) { when (restoreSource) {
RestoreSource.LocalFile -> { RestoreSource.LocalFile -> {
pendingRestoreUri?.let { uri -> pendingRestoreUri?.let { uri ->
viewModel.restoreFromFile(uri, selectedRestoreMode) // 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 -> { RestoreSource.Server -> {
viewModel.restoreFromServer(selectedRestoreMode) // v1.8.0: Schedule restore with delay for dialog close
pendingRestoreAction = {
viewModel.restoreFromServer(selectedRestoreMode)
}
triggerRestore++
} }
} }
}, },

View File

@@ -82,6 +82,9 @@ fun DebugSettingsScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Export Logs Button // Export Logs Button
val logsSubject = stringResource(R.string.debug_logs_subject)
val logsShareVia = stringResource(R.string.debug_logs_share_via)
SettingsButton( SettingsButton(
text = stringResource(R.string.debug_export_logs), text = stringResource(R.string.debug_export_logs),
onClick = { onClick = {
@@ -96,11 +99,11 @@ fun DebugSettingsScreen(
val shareIntent = Intent(Intent.ACTION_SEND).apply { val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain" type = "text/plain"
putExtra(Intent.EXTRA_STREAM, logUri) putExtra(Intent.EXTRA_STREAM, logUri)
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.debug_logs_subject)) putExtra(Intent.EXTRA_SUBJECT, logsSubject)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
context.startActivity(Intent.createChooser(shareIntent, context.getString(R.string.debug_logs_share_via))) context.startActivity(Intent.createChooser(shareIntent, logsShareVia))
} }
}, },
modifier = Modifier.padding(horizontal = 16.dp) modifier = Modifier.padding(horizontal = 16.dp)
@@ -116,6 +119,31 @@ fun DebugSettingsScreen(
) )
Spacer(modifier = Modifier.height(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))
} }
} }

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

@@ -1,6 +1,5 @@
package dev.dettmer.simplenotes.ui.settings.screens package dev.dettmer.simplenotes.ui.settings.screens
import android.app.Activity
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -15,7 +14,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
@@ -35,8 +33,6 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
fun LanguageSettingsScreen( fun LanguageSettingsScreen(
onBack: () -> Unit onBack: () -> Unit
) { ) {
val context = LocalContext.current
// Get current app locale - fresh value each time (no remember, always reads current state) // Get current app locale - fresh value each time (no remember, always reads current state)
val currentLocale = AppCompatDelegate.getApplicationLocales() val currentLocale = AppCompatDelegate.getApplicationLocales()
val currentLanguageCode = if (currentLocale.isEmpty) { val currentLanguageCode = if (currentLocale.isEmpty) {
@@ -92,7 +88,7 @@ fun LanguageSettingsScreen(
onValueSelected = { newLanguage -> onValueSelected = { newLanguage ->
if (newLanguage != selectedLanguage) { if (newLanguage != selectedLanguage) {
selectedLanguage = newLanguage selectedLanguage = newLanguage
setAppLanguage(newLanguage, context as Activity) setAppLanguage(newLanguage)
} }
} }
) )
@@ -102,19 +98,19 @@ fun LanguageSettingsScreen(
/** /**
* Set app language using AppCompatDelegate * Set app language using AppCompatDelegate
* Works on Android 13+ natively, falls back to AppCompat on older versions * 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, activity: Activity) { private fun setAppLanguage(languageCode: String) {
val localeList = if (languageCode.isEmpty()) { val localeList = if (languageCode.isEmpty()) {
LocaleListCompat.getEmptyLocaleList() LocaleListCompat.getEmptyLocaleList()
} else { } else {
LocaleListCompat.forLanguageTags(languageCode) LocaleListCompat.forLanguageTags(languageCode)
} }
// Sets the app locale - triggers onConfigurationChanged() instead of recreate()
AppCompatDelegate.setApplicationLocales(localeList) AppCompatDelegate.setApplicationLocales(localeList)
// Restart the activity to apply the change
// On Android 13+ the system handles this automatically for some apps,
// but we need to recreate to ensure our Compose UI recomposes with new locale
activity.recreate()
} }

View File

@@ -33,6 +33,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -56,7 +57,9 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
* Server configuration settings screen * Server configuration settings screen
* v1.5.0: Jetpack Compose Settings Redesign * v1.5.0: Jetpack Compose Settings Redesign
* v1.6.0: Offline Mode Toggle * 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 @Composable
fun ServerSettingsScreen( fun ServerSettingsScreen(
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
@@ -73,6 +76,14 @@ fun ServerSettingsScreen(
var passwordVisible by remember { mutableStateOf(false) } 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) // Check server status on load (only if not in offline mode)
LaunchedEffect(offlineMode) { LaunchedEffect(offlineMode) {
if (!offlineMode) { if (!offlineMode) {

View File

@@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Description 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.Info
import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Sync
@@ -33,6 +34,7 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
* Main Settings overview screen with clickable group cards * Main Settings overview screen with clickable group cards
* v1.5.0: Jetpack Compose Settings Redesign * v1.5.0: Jetpack Compose Settings Redesign
*/ */
@Suppress("MagicNumber") // Color hex values
@Composable @Composable
fun SettingsMainScreen( fun SettingsMainScreen(
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
@@ -89,6 +91,22 @@ fun SettingsMainScreen(
) )
} }
// 🎨 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 // Server-Einstellungen
item { item {
// 🌟 v1.6.0: Check if server is configured (host is not empty) // 🌟 v1.6.0: Check if server is configured (host is not empty)
@@ -99,20 +117,30 @@ fun SettingsMainScreen(
title = stringResource(R.string.settings_server), title = stringResource(R.string.settings_server),
subtitle = if (!offlineMode && isConfigured) serverUrl else null, subtitle = if (!offlineMode && isConfigured) serverUrl else null,
statusText = when { statusText = when {
offlineMode -> stringResource(R.string.settings_server_status_offline_mode) offlineMode ->
serverStatus is SettingsViewModel.ServerStatus.OfflineMode -> stringResource(R.string.settings_server_status_offline_mode) stringResource(R.string.settings_server_status_offline_mode)
serverStatus is SettingsViewModel.ServerStatus.Reachable -> stringResource(R.string.settings_server_status_reachable) serverStatus is SettingsViewModel.ServerStatus.OfflineMode ->
serverStatus is SettingsViewModel.ServerStatus.Unreachable -> stringResource(R.string.settings_server_status_unreachable) stringResource(R.string.settings_server_status_offline_mode)
serverStatus is SettingsViewModel.ServerStatus.Checking -> stringResource(R.string.settings_server_status_checking) serverStatus is SettingsViewModel.ServerStatus.Reachable ->
serverStatus is SettingsViewModel.ServerStatus.NotConfigured -> stringResource(R.string.settings_server_status_offline_mode) 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 else -> null
}, },
statusColor = when { statusColor = when {
offlineMode -> MaterialTheme.colorScheme.tertiary offlineMode -> MaterialTheme.colorScheme.tertiary
serverStatus is SettingsViewModel.ServerStatus.OfflineMode -> MaterialTheme.colorScheme.tertiary serverStatus is SettingsViewModel.ServerStatus.OfflineMode ->
serverStatus is SettingsViewModel.ServerStatus.Reachable -> Color(0xFF4CAF50) MaterialTheme.colorScheme.tertiary
serverStatus is SettingsViewModel.ServerStatus.Unreachable -> Color(0xFFF44336) serverStatus is SettingsViewModel.ServerStatus.Reachable ->
serverStatus is SettingsViewModel.ServerStatus.NotConfigured -> MaterialTheme.colorScheme.tertiary Color(0xFF4CAF50)
serverStatus is SettingsViewModel.ServerStatus.Unreachable ->
Color(0xFFF44336)
serverStatus is SettingsViewModel.ServerStatus.NotConfigured ->
MaterialTheme.colorScheme.tertiary
else -> Color.Gray else -> Color.Gray
}, },
onClick = { onNavigate(SettingsRoute.Server) } onClick = { onNavigate(SettingsRoute.Server) }

View File

@@ -14,7 +14,6 @@ import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.SettingsInputAntenna import androidx.compose.material.icons.filled.SettingsInputAntenna
import androidx.compose.material.icons.filled.Wifi import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -33,9 +32,11 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
/** /**
* Sync settings screen - Configurable Sync Triggers * Sync settings screen — Restructured for v1.8.0
* v1.5.0: Jetpack Compose Settings Redesign *
* v1.6.0: Individual toggle for each sync trigger (onSave, onResume, WiFi-Connect, Periodic, Boot) * Two clear sections:
* 1. Sync Triggers (all 5 triggers grouped logically)
* 2. Network & Performance (WiFi-only + Parallel Downloads)
*/ */
@Composable @Composable
fun SyncSettingsScreen( fun SyncSettingsScreen(
@@ -51,7 +52,9 @@ fun SyncSettingsScreen(
val triggerBoot by viewModel.triggerBoot.collectAsState() val triggerBoot by viewModel.triggerBoot.collectAsState()
val syncInterval by viewModel.syncInterval.collectAsState() val syncInterval by viewModel.syncInterval.collectAsState()
// Check if server is configured val maxParallelDownloads by viewModel.maxParallelDownloads.collectAsState()
val wifiOnlySync by viewModel.wifiOnlySync.collectAsState()
val isServerConfigured = viewModel.isServerConfigured() val isServerConfigured = viewModel.isServerConfigured()
SettingsScaffold( SettingsScaffold(
@@ -66,7 +69,7 @@ fun SyncSettingsScreen(
) { ) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// 🌟 v1.6.0: Offline Mode Warning if server not configured // ── Offline Mode Warning ──
if (!isServerConfigured) { if (!isServerConfigured) {
SettingsInfoCard( SettingsInfoCard(
text = stringResource(R.string.sync_offline_mode_message), text = stringResource(R.string.sync_offline_mode_message),
@@ -84,12 +87,14 @@ fun SyncSettingsScreen(
} }
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// SOFORT-SYNC Section // SECTION 1: SYNC TRIGGERS
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
SettingsSectionHeader(text = stringResource(R.string.sync_section_triggers))
// ── Sofort-Sync ──
SettingsSectionHeader(text = stringResource(R.string.sync_section_instant)) SettingsSectionHeader(text = stringResource(R.string.sync_section_instant))
// onSave Trigger
SettingsSwitch( SettingsSwitch(
title = stringResource(R.string.sync_trigger_on_save_title), title = stringResource(R.string.sync_trigger_on_save_title),
subtitle = stringResource(R.string.sync_trigger_on_save_subtitle), subtitle = stringResource(R.string.sync_trigger_on_save_subtitle),
@@ -99,7 +104,6 @@ fun SyncSettingsScreen(
enabled = isServerConfigured enabled = isServerConfigured
) )
// onResume Trigger
SettingsSwitch( SettingsSwitch(
title = stringResource(R.string.sync_trigger_on_resume_title), title = stringResource(R.string.sync_trigger_on_resume_title),
subtitle = stringResource(R.string.sync_trigger_on_resume_subtitle), subtitle = stringResource(R.string.sync_trigger_on_resume_subtitle),
@@ -109,15 +113,11 @@ fun SyncSettingsScreen(
enabled = isServerConfigured enabled = isServerConfigured
) )
SettingsDivider() Spacer(modifier = Modifier.height(4.dp))
// ═══════════════════════════════════════════════════════════════
// HINTERGRUND-SYNC Section
// ═══════════════════════════════════════════════════════════════
// ── Hintergrund-Sync ──
SettingsSectionHeader(text = stringResource(R.string.sync_section_background)) SettingsSectionHeader(text = stringResource(R.string.sync_section_background))
// WiFi-Connect Trigger
SettingsSwitch( SettingsSwitch(
title = stringResource(R.string.sync_trigger_wifi_connect_title), title = stringResource(R.string.sync_trigger_wifi_connect_title),
subtitle = stringResource(R.string.sync_trigger_wifi_connect_subtitle), subtitle = stringResource(R.string.sync_trigger_wifi_connect_subtitle),
@@ -127,7 +127,6 @@ fun SyncSettingsScreen(
enabled = isServerConfigured enabled = isServerConfigured
) )
// Periodic Trigger
SettingsSwitch( SettingsSwitch(
title = stringResource(R.string.sync_trigger_periodic_title), title = stringResource(R.string.sync_trigger_periodic_title),
subtitle = stringResource(R.string.sync_trigger_periodic_subtitle), subtitle = stringResource(R.string.sync_trigger_periodic_subtitle),
@@ -137,7 +136,7 @@ fun SyncSettingsScreen(
enabled = isServerConfigured enabled = isServerConfigured
) )
// Periodic Interval Selection (only visible if periodic trigger is enabled) // Interval-Auswahl (nur sichtbar wenn Periodic aktiv)
if (triggerPeriodic && isServerConfigured) { if (triggerPeriodic && isServerConfigured) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -168,15 +167,6 @@ fun SyncSettingsScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
SettingsDivider()
// ═══════════════════════════════════════════════════════════════
// ADVANCED Section (Boot Sync)
// ═══════════════════════════════════════════════════════════════
SettingsSectionHeader(text = stringResource(R.string.sync_section_advanced))
// Boot Trigger
SettingsSwitch( SettingsSwitch(
title = stringResource(R.string.sync_trigger_boot_title), title = stringResource(R.string.sync_trigger_boot_title),
subtitle = stringResource(R.string.sync_trigger_boot_subtitle), subtitle = stringResource(R.string.sync_trigger_boot_subtitle),
@@ -186,9 +176,9 @@ fun SyncSettingsScreen(
enabled = isServerConfigured enabled = isServerConfigured
) )
SettingsDivider() Spacer(modifier = Modifier.height(8.dp))
// Manual Sync Info // ── Info Card ──
val manualHintText = if (isServerConfigured) { val manualHintText = if (isServerConfigured) {
stringResource(R.string.sync_manual_hint) stringResource(R.string.sync_manual_hint)
} else { } else {
@@ -199,6 +189,68 @@ fun SyncSettingsScreen(
text = manualHintText 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)) Spacer(modifier = Modifier.height(16.dp))
} }
} }

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import dev.dettmer.simplenotes.MainActivity import dev.dettmer.simplenotes.ui.main.ComposeMainActivity
object NotificationHelper { object NotificationHelper {
@@ -19,6 +19,7 @@ object NotificationHelper {
private const val CHANNEL_ID = "notes_sync_channel" private const val CHANNEL_ID = "notes_sync_channel"
private const val NOTIFICATION_ID = 1001 private const val NOTIFICATION_ID = 1001
private const val SYNC_NOTIFICATION_ID = 2 private const val SYNC_NOTIFICATION_ID = 2
const val SYNC_PROGRESS_NOTIFICATION_ID = 1003 // v1.7.2: For expedited work foreground notification
private const val AUTO_CANCEL_TIMEOUT_MS = 30_000L private const val AUTO_CANCEL_TIMEOUT_MS = 30_000L
/** /**
@@ -54,11 +55,31 @@ object NotificationHelper {
Logger.d(TAG, "🗑️ Cleared old sync notifications") Logger.d(TAG, "🗑️ Cleared old sync notifications")
} }
/**
* 🔧 v1.7.2: Erstellt Notification für Sync-Progress (Expedited Work)
*
* Wird von SyncWorker.getForegroundInfo() aufgerufen auf Android 9-11.
* Muss eine gültige, sichtbare Notification zurückgeben.
*
* @return Notification (nicht anzeigen, nur erstellen)
*/
fun createSyncProgressNotification(context: Context): android.app.Notification {
return NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle(context.getString(R.string.sync_in_progress))
.setContentText(context.getString(R.string.sync_in_progress_text))
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setProgress(0, 0, true) // Indeterminate progress
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.build()
}
/** /**
* Zeigt Erfolgs-Notification nach Sync * Zeigt Erfolgs-Notification nach Sync
*/ */
fun showSyncSuccessNotification(context: Context, syncedCount: Int) { fun showSyncSuccessNotification(context: Context, syncedCount: Int) {
val intent = Intent(context, MainActivity::class.java).apply { val intent = Intent(context, ComposeMainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
} }
@@ -154,7 +175,7 @@ object NotificationHelper {
* Zeigt Notification bei erkanntem Konflikt * Zeigt Notification bei erkanntem Konflikt
*/ */
fun showConflictNotification(context: Context, conflictCount: Int) { fun showConflictNotification(context: Context, conflictCount: Int) {
val intent = Intent(context, MainActivity::class.java) val intent = Intent(context, ComposeMainActivity::class.java)
val pendingIntent = PendingIntent.getActivity( val pendingIntent = PendingIntent.getActivity(
context, 0, intent, context, 0, intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
@@ -229,7 +250,7 @@ object NotificationHelper {
*/ */
fun showSyncSuccess(context: Context, count: Int) { fun showSyncSuccess(context: Context, count: Int) {
// PendingIntent für App-Öffnung // PendingIntent für App-Öffnung
val intent = Intent(context, MainActivity::class.java).apply { val intent = Intent(context, ComposeMainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
} }
val pendingIntent = PendingIntent.getActivity( val pendingIntent = PendingIntent.getActivity(
@@ -260,7 +281,7 @@ object NotificationHelper {
*/ */
fun showSyncError(context: Context, message: String) { fun showSyncError(context: Context, message: String) {
// PendingIntent für App-Öffnung // PendingIntent für App-Öffnung
val intent = Intent(context, MainActivity::class.java).apply { val intent = Intent(context, ComposeMainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
} }
val pendingIntent = PendingIntent.getActivity( val pendingIntent = PendingIntent.getActivity(
@@ -297,7 +318,7 @@ object NotificationHelper {
*/ */
fun showSyncWarning(context: Context, hoursSinceLastSync: Long) { fun showSyncWarning(context: Context, hoursSinceLastSync: Long) {
// PendingIntent für App-Öffnung // PendingIntent für App-Öffnung
val intent = Intent(context, MainActivity::class.java).apply { val intent = Intent(context, ComposeMainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
} }
val pendingIntent = PendingIntent.getActivity( val pendingIntent = PendingIntent.getActivity(

View File

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

View File

@@ -0,0 +1,82 @@
package dev.dettmer.simplenotes.widget
import android.content.Context
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceId
import androidx.glance.GlanceTheme
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
import androidx.glance.currentState
import androidx.glance.state.PreferencesGlanceStateDefinition
import dev.dettmer.simplenotes.storage.NotesStorage
/**
* 🆕 v1.8.0: Homescreen Widget für Notizen und Checklisten
*
* Unterstützt fünf responsive Größen für breite und schmale Layouts:
* - SMALL (110x80dp): Nur Titel
* - NARROW_MEDIUM (110x110dp): Schmal + Vorschau / kompakte Checkliste
* - NARROW_LARGE (110x250dp): Schmal + voller Inhalt
* - WIDE_MEDIUM (250x110dp): Breit + Vorschau
* - WIDE_LARGE (250x250dp): Breit + voller Inhalt / interaktive Checkliste
*
* Features:
* - Material You Dynamic Colors
* - Interaktive Checklist-Checkboxen
* - Sperr-Funktion gegen versehentliches Bearbeiten
* - Tap-to-Edit (öffnet NoteEditor)
* - Einstellbare Hintergrund-Transparenz
* - Permanenter Options-Button (⋮)
* - NoteType-differenzierte Icons
*/
class NoteWidget : GlanceAppWidget() {
companion object {
// Responsive Breakpoints — schmale + breite Spalten
val SIZE_SMALL = DpSize(110.dp, 80.dp) // Schmal+kurz: nur Titel
val SIZE_NARROW_MEDIUM = DpSize(110.dp, 110.dp) // Schmal+mittel: Vorschau
val SIZE_NARROW_SCROLL = DpSize(110.dp, 150.dp) // 🆕 v1.8.1: Schmal+scroll (Standard 3x2)
val SIZE_NARROW_LARGE = DpSize(110.dp, 250.dp) // Schmal+groß: voller Inhalt
val SIZE_WIDE_MEDIUM = DpSize(250.dp, 110.dp) // Breit+mittel: Vorschau
val SIZE_WIDE_SCROLL = DpSize(250.dp, 150.dp) // 🆕 v1.8.1: Breit+scroll (Standard 3x2 breit)
val SIZE_WIDE_LARGE = DpSize(250.dp, 250.dp) // Breit+groß: voller Inhalt
}
override val sizeMode = SizeMode.Responsive(
setOf(
SIZE_SMALL, SIZE_NARROW_MEDIUM, SIZE_NARROW_SCROLL, SIZE_NARROW_LARGE,
SIZE_WIDE_MEDIUM, SIZE_WIDE_SCROLL, SIZE_WIDE_LARGE
)
)
override val stateDefinition = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) {
val storage = NotesStorage(context)
provideContent {
val prefs = currentState<Preferences>()
val noteId = prefs[NoteWidgetState.KEY_NOTE_ID]
val isLocked = prefs[NoteWidgetState.KEY_IS_LOCKED] ?: false
val showOptions = prefs[NoteWidgetState.KEY_SHOW_OPTIONS] ?: false
val bgOpacity = prefs[NoteWidgetState.KEY_BACKGROUND_OPACITY] ?: 1.0f
val note = noteId?.let { nId ->
storage.loadNote(nId)
}
GlanceTheme {
NoteWidgetContent(
note = note,
isLocked = isLocked,
showOptions = showOptions,
bgOpacity = bgOpacity,
glanceId = id
)
}
}
}
}

View File

@@ -0,0 +1,182 @@
package dev.dettmer.simplenotes.widget
import android.content.Context
import androidx.glance.GlanceId
import androidx.glance.action.ActionParameters
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.state.updateAppWidgetState
import dev.dettmer.simplenotes.models.ChecklistSortOption
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.Logger
/**
* 🆕 v1.8.0: ActionParameter Keys für Widget-Interaktionen
*
* Shared Keys für alle ActionCallback-Klassen.
*/
object NoteWidgetActionKeys {
val KEY_NOTE_ID = ActionParameters.Key<String>("noteId")
val KEY_ITEM_ID = ActionParameters.Key<String>("itemId")
val KEY_GLANCE_ID = ActionParameters.Key<String>("glanceId")
}
/**
* 🐛 FIX: Checklist-Item abhaken/enthaken
*
* Top-Level-Klasse (statt nested) für Class.forName()-Kompatibilität.
*
* - Toggelt isChecked im JSON-File
* - Setzt SyncStatus auf PENDING
* - Aktualisiert Widget sofort
*/
class ToggleChecklistItemAction : ActionCallback {
companion object {
private const val TAG = "ToggleChecklistItem"
}
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters
) {
val noteId = parameters[NoteWidgetActionKeys.KEY_NOTE_ID] ?: return
val itemId = parameters[NoteWidgetActionKeys.KEY_ITEM_ID] ?: return
val storage = NotesStorage(context)
val note = storage.loadNote(noteId) ?: return
val updatedItems = note.checklistItems?.map { item ->
if (item.id == itemId) {
item.copy(isChecked = !item.isChecked)
} else item
} ?: return
// 🆕 v1.8.1 (IMPL_04): Auto-Sort nach Toggle
// Konsistent mit NoteEditorViewModel.updateChecklistItemChecked
val sortOption = try {
note.checklistSortOption?.let { ChecklistSortOption.valueOf(it) }
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) { null }
?: ChecklistSortOption.MANUAL
val sortedItems = if (sortOption == ChecklistSortOption.MANUAL ||
sortOption == ChecklistSortOption.UNCHECKED_FIRST) {
val unchecked = updatedItems.filter { !it.isChecked }
val checked = updatedItems.filter { it.isChecked }
(unchecked + checked).mapIndexed { index, item ->
item.copy(order = index)
}
} else {
updatedItems.mapIndexed { index, item -> item.copy(order = index) }
}
val updatedNote = note.copy(
checklistItems = sortedItems,
updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING
)
storage.saveNote(updatedNote)
Logger.d(TAG, "Toggled + auto-sorted checklist item '$itemId' in widget")
// 🐛 FIX: Glance-State ändern um Re-Render zu erzwingen
updateAppWidgetState(context, glanceId) { prefs ->
prefs[NoteWidgetState.KEY_LAST_UPDATED] = System.currentTimeMillis()
}
// Widget aktualisieren — Glance erkennt jetzt den State-Change
NoteWidget().update(context, glanceId)
}
}
/**
* 🐛 FIX: Widget sperren/entsperren
*
* Top-Level-Klasse (statt nested) für Class.forName()-Kompatibilität.
*/
class ToggleLockAction : ActionCallback {
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters
) {
updateAppWidgetState(context, glanceId) { prefs ->
val currentLock = prefs[NoteWidgetState.KEY_IS_LOCKED] ?: false
prefs[NoteWidgetState.KEY_IS_LOCKED] = !currentLock
// Options ausblenden nach Toggle
prefs[NoteWidgetState.KEY_SHOW_OPTIONS] = false
}
NoteWidget().update(context, glanceId)
}
}
/**
* 🐛 FIX: Optionsleiste ein-/ausblenden
*
* Top-Level-Klasse (statt nested) für Class.forName()-Kompatibilität.
*/
class ShowOptionsAction : ActionCallback {
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters
) {
updateAppWidgetState(context, glanceId) { prefs ->
val currentShow = prefs[NoteWidgetState.KEY_SHOW_OPTIONS] ?: false
prefs[NoteWidgetState.KEY_SHOW_OPTIONS] = !currentShow
}
NoteWidget().update(context, glanceId)
}
}
/**
* 🐛 FIX: Widget-Daten neu laden
*
* Top-Level-Klasse (statt nested) für Class.forName()-Kompatibilität.
*/
class RefreshAction : ActionCallback {
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters
) {
// Options ausblenden
updateAppWidgetState(context, glanceId) { prefs ->
prefs[NoteWidgetState.KEY_SHOW_OPTIONS] = false
}
NoteWidget().update(context, glanceId)
}
}
/**
* 🆕 v1.8.0: Widget-Konfiguration öffnen (Reconfigure)
*
* Top-Level-Klasse (statt nested) für Class.forName()-Kompatibilität.
* Öffnet die Config-Activity im Reconfigure-Modus.
*/
class OpenConfigAction : ActionCallback {
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters
) {
// Options ausblenden
updateAppWidgetState(context, glanceId) { prefs ->
prefs[NoteWidgetState.KEY_SHOW_OPTIONS] = false
}
// Config-Activity als Reconfigure öffnen
val glanceManager = androidx.glance.appwidget.GlanceAppWidgetManager(context)
val appWidgetId = glanceManager.getAppWidgetId(glanceId)
val intent = android.content.Intent(context, NoteWidgetConfigActivity::class.java).apply {
putExtra(android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
// 🐛 FIX: Eigener Task, damit finish() nicht die MainActivity zeigt
flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
}
context.startActivity(intent)
}
}

View File

@@ -0,0 +1,159 @@
package dev.dettmer.simplenotes.widget
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.lifecycle.lifecycleScope
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme
import kotlinx.coroutines.launch
/**
* 🆕 v1.8.0: Konfigurations-Activity beim Platzieren eines Widgets
*
* Zeigt eine Liste aller Notizen. User wählt eine aus,
* die dann im Widget angezeigt wird.
*
* Optionen:
* - Notiz auswählen
* - Widget initial sperren (optional)
* - Hintergrund-Transparenz einstellen
*
* Unterstützt Reconfiguration (Android 12+): Beim erneuten Öffnen
* werden die bestehenden Einstellungen als Defaults geladen.
*
* 🆕 v1.8.0 (IMPL_025): Auto-Save bei Back-Navigation + Save-FAB
*/
class NoteWidgetConfigActivity : ComponentActivity() {
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
// 🆕 v1.8.0 (IMPL_025): State-Tracking für Auto-Save bei Back-Navigation
private var currentSelectedNoteId: String? = null
private var currentLockState: Boolean = false
private var currentOpacity: Float = 1.0f
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Default-Result: Cancelled (falls User zurück-navigiert)
setResult(RESULT_CANCELED)
// 🆕 v1.8.0 (IMPL_025): Auto-Save bei Back-Navigation
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
// Auto-Save nur bei Reconfigure (wenn bereits eine Note konfiguriert war)
if (currentSelectedNoteId != null) {
configureWidget(currentSelectedNoteId!!, currentLockState, currentOpacity)
} else {
finish()
}
}
})
// Widget-ID aus Intent
appWidgetId = intent?.extras?.getInt(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID
) ?: AppWidgetManager.INVALID_APPWIDGET_ID
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
val storage = NotesStorage(this)
// Bestehende Konfiguration laden (für Reconfigure)
lifecycleScope.launch {
var existingNoteId: String? = null
var existingLock = false
var existingOpacity = 1.0f
try {
val glanceId = GlanceAppWidgetManager(this@NoteWidgetConfigActivity)
.getGlanceIdBy(appWidgetId)
val prefs = getAppWidgetState(
this@NoteWidgetConfigActivity,
PreferencesGlanceStateDefinition,
glanceId
)
existingNoteId = prefs[NoteWidgetState.KEY_NOTE_ID]
existingLock = prefs[NoteWidgetState.KEY_IS_LOCKED] ?: false
existingOpacity = prefs[NoteWidgetState.KEY_BACKGROUND_OPACITY] ?: 1.0f
} catch (_: Exception) {
// Neues Widget — keine bestehende Konfiguration
}
// 🆕 v1.8.0 (IMPL_025): Initiale State-Werte für Auto-Save setzen
currentSelectedNoteId = existingNoteId
currentLockState = existingLock
currentOpacity = existingOpacity
setContent {
SimpleNotesTheme {
NoteWidgetConfigScreen(
storage = storage,
initialLock = existingLock,
initialOpacity = existingOpacity,
selectedNoteId = existingNoteId,
onNoteSelected = { noteId, isLocked, opacity ->
configureWidget(noteId, isLocked, opacity)
},
// 🆕 v1.8.0 (IMPL_025): Save-FAB Callback
onSave = { noteId, isLocked, opacity ->
configureWidget(noteId, isLocked, opacity)
},
// 🆕 v1.8.0 (IMPL_025): Settings-Änderungen tracken für Auto-Save
onSettingsChanged = { noteId, isLocked, opacity ->
currentSelectedNoteId = noteId
currentLockState = isLocked
currentOpacity = opacity
},
onCancel = { finish() }
)
}
}
}
}
private fun configureWidget(noteId: String, isLocked: Boolean, opacity: Float) {
lifecycleScope.launch {
val glanceId = GlanceAppWidgetManager(this@NoteWidgetConfigActivity)
.getGlanceIdBy(appWidgetId)
// Widget-State speichern
updateAppWidgetState(this@NoteWidgetConfigActivity, glanceId) { prefs ->
prefs[NoteWidgetState.KEY_NOTE_ID] = noteId
prefs[NoteWidgetState.KEY_IS_LOCKED] = isLocked
prefs[NoteWidgetState.KEY_SHOW_OPTIONS] = false
prefs[NoteWidgetState.KEY_BACKGROUND_OPACITY] = opacity
}
// Widget initial rendern
NoteWidget().update(this@NoteWidgetConfigActivity, glanceId)
// Erfolg melden
val resultIntent = Intent().putExtra(
AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId
)
setResult(RESULT_OK, resultIntent)
// 🐛 FIX: Zurück zum Homescreen statt zur MainActivity
// moveTaskToBack() bringt den Task in den Hintergrund → Homescreen wird sichtbar
if (!isTaskRoot) {
finish()
} else {
moveTaskToBack(true)
finish()
}
}
}
}

View File

@@ -0,0 +1,272 @@
package dev.dettmer.simplenotes.widget
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.outlined.Description
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.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.Note
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.storage.NotesStorage
import kotlin.math.roundToInt
/**
* 🆕 v1.8.0: Compose Screen für Widget-Konfiguration
*
* Zeigt alle Notizen als auswählbare Liste.
* Optionen: Widget-Lock, Hintergrund-Transparenz.
* Unterstützt Reconfiguration mit bestehenden Defaults.
*
* 🆕 v1.8.0 (IMPL_025): Save-FAB + onSettingsChanged für Reconfigure-Flow
*/
private const val NOTE_PREVIEW_MAX_LENGTH = 50
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoteWidgetConfigScreen(
storage: NotesStorage,
initialLock: Boolean = false,
initialOpacity: Float = 1.0f,
selectedNoteId: String? = null,
onNoteSelected: (noteId: String, isLocked: Boolean, opacity: Float) -> Unit,
onSave: ((noteId: String, isLocked: Boolean, opacity: Float) -> Unit)? = null,
onSettingsChanged: ((noteId: String?, isLocked: Boolean, opacity: Float) -> Unit)? = null,
@Suppress("UNUSED_PARAMETER") onCancel: () -> Unit // Reserved for future use
) {
val allNotes = remember { storage.loadAllNotes().sortedByDescending { it.updatedAt } }
var lockWidget by remember { mutableStateOf(initialLock) }
var opacity by remember { mutableFloatStateOf(initialOpacity) }
var currentSelectedId by remember { mutableStateOf(selectedNoteId) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.widget_config_title)) }
)
},
floatingActionButton = {
// 🆕 v1.8.0 (IMPL_025): Save-FAB — sichtbar wenn eine Note ausgewählt ist
if (currentSelectedId != null) {
FloatingActionButton(
onClick = {
currentSelectedId?.let { noteId ->
onSave?.invoke(noteId, lockWidget, opacity)
?: onNoteSelected(noteId, lockWidget, opacity)
}
}
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(R.string.widget_config_save)
)
}
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// Lock-Option
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.widget_lock_label),
style = MaterialTheme.typography.bodyLarge
)
Text(
text = stringResource(R.string.widget_lock_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
Switch(
checked = lockWidget,
onCheckedChange = {
lockWidget = it
// 🆕 v1.8.0 (IMPL_025): Settings-Änderung an Activity melden
onSettingsChanged?.invoke(currentSelectedId, lockWidget, opacity)
}
)
}
// Opacity-Slider
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.widget_opacity_label),
style = MaterialTheme.typography.bodyLarge
)
Text(
text = "${(opacity * 100).roundToInt()}%",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
Text(
text = stringResource(R.string.widget_opacity_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(4.dp))
Slider(
value = opacity,
onValueChange = {
opacity = it
// 🆕 v1.8.0 (IMPL_025): Settings-Änderung an Activity melden
onSettingsChanged?.invoke(currentSelectedId, lockWidget, opacity)
},
valueRange = 0f..1f,
steps = 9
)
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
// Hinweis
Text(
text = stringResource(R.string.widget_config_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
)
// Notizen-Liste
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(allNotes, key = { it.id }) { note ->
NoteSelectionCard(
note = note,
isSelected = note.id == currentSelectedId,
onClick = {
currentSelectedId = note.id
// 🐛 FIX: Nur auswählen + Settings-Tracking, NICHT sofort konfigurieren
onSettingsChanged?.invoke(note.id, lockWidget, opacity)
}
)
}
}
}
}
}
@Composable
private fun NoteSelectionCard(
note: Note,
isSelected: Boolean = false,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp)
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerLow
}
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = when (note.noteType) {
NoteType.TEXT -> Icons.Outlined.Description
NoteType.CHECKLIST -> Icons.AutoMirrored.Outlined.List
},
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.primary
}
)
Spacer(modifier = Modifier.size(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = note.title.ifEmpty { "Untitled" },
style = MaterialTheme.typography.bodyLarge,
maxLines = 1
)
Text(
text = when (note.noteType) {
NoteType.TEXT -> note.content.take(NOTE_PREVIEW_MAX_LENGTH).replace("\n", " ")
NoteType.CHECKLIST -> {
val items = note.checklistItems ?: emptyList()
val checked = items.count { it.isChecked }
"$checked/${items.size}"
}
},
style = MaterialTheme.typography.bodySmall,
color = if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
} else {
MaterialTheme.colorScheme.outline
},
maxLines = 1
)
}
}
}
}

View File

@@ -0,0 +1,639 @@
package dev.dettmer.simplenotes.widget
import android.content.ComponentName
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.actionParametersOf
import androidx.glance.action.actionStartActivity
import androidx.glance.action.clickable
import androidx.glance.appwidget.CheckBox
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.components.CircleIconButton
import androidx.glance.appwidget.components.TitleBar
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.background
import androidx.glance.color.ColorProvider
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.ChecklistSortOption
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.ui.editor.ComposeNoteEditorActivity
import dev.dettmer.simplenotes.ui.main.components.sortChecklistItemsForPreview
/**
* 🆕 v1.8.0: Glance Composable Content für das Notiz-Widget
*
* Unterstützt fünf responsive Größenklassen (breit + schmal),
* NoteType-Icons, permanenten Options-Button, und einstellbare Opacity.
*/
// ── Size Classification ──
private val WIDGET_HEIGHT_SMALL_THRESHOLD = 110.dp
private val WIDGET_HEIGHT_SCROLL_THRESHOLD = 150.dp // 🆕 v1.8.1: Scrollbare Ansicht
private val WIDGET_SIZE_MEDIUM_THRESHOLD = 250.dp
// 🆕 v1.8.0: Increased preview lengths for better text visibility
private const val TEXT_PREVIEW_COMPACT_LENGTH = 120
private const val TEXT_PREVIEW_FULL_LENGTH = 300
private fun DpSize.toSizeClass(): WidgetSizeClass = when {
height < WIDGET_HEIGHT_SMALL_THRESHOLD -> WidgetSizeClass.SMALL
// 🆕 v1.8.1: Neue ScrollView-Schwelle bei 150dp Höhe
width < WIDGET_SIZE_MEDIUM_THRESHOLD && height < WIDGET_HEIGHT_SCROLL_THRESHOLD -> WidgetSizeClass.NARROW_MED
width < WIDGET_SIZE_MEDIUM_THRESHOLD && height < WIDGET_SIZE_MEDIUM_THRESHOLD -> WidgetSizeClass.NARROW_SCROLL
width < WIDGET_SIZE_MEDIUM_THRESHOLD -> WidgetSizeClass.NARROW_TALL
height < WIDGET_HEIGHT_SCROLL_THRESHOLD -> WidgetSizeClass.WIDE_MED
height < WIDGET_SIZE_MEDIUM_THRESHOLD -> WidgetSizeClass.WIDE_SCROLL
else -> WidgetSizeClass.WIDE_TALL
}
/**
* 🆕 v1.8.1 (IMPL_04): Separator zwischen erledigten und unerledigten Items im Widget.
* Glance-kompatible Version von CheckedItemsSeparator.
*/
@Composable
private fun WidgetCheckedItemsSeparator(checkedCount: Int) {
Row(
modifier = GlanceModifier
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "── $checkedCount ✔ ──",
style = TextStyle(
color = GlanceTheme.colors.outline,
fontSize = 11.sp
)
)
}
}
@Composable
fun NoteWidgetContent(
note: Note?,
isLocked: Boolean,
showOptions: Boolean,
bgOpacity: Float,
glanceId: GlanceId
) {
val size = LocalSize.current
val context = LocalContext.current
val sizeClass = size.toSizeClass()
if (note == null) {
EmptyWidgetContent(bgOpacity)
return
}
// Background mit Opacity
val bgModifier = if (bgOpacity < 1.0f) {
GlanceModifier.background(
ColorProvider(
day = Color.White.copy(alpha = bgOpacity),
night = Color(0xFF1C1B1F).copy(alpha = bgOpacity)
)
)
} else {
GlanceModifier.background(GlanceTheme.colors.widgetBackground)
}
Box(
modifier = GlanceModifier
.fillMaxSize()
.cornerRadius(16.dp)
.then(bgModifier)
) {
Column(modifier = GlanceModifier.fillMaxSize()) {
// 🆕 v1.8.0 (IMPL_025): Offizielle TitleBar mit CircleIconButton (48dp Hit Area)
TitleBar(
startIcon = ImageProvider(
when {
isLocked -> R.drawable.ic_lock
note.noteType == NoteType.CHECKLIST -> R.drawable.ic_widget_checklist
else -> R.drawable.ic_note
}
),
title = note.title.ifEmpty { "Untitled" },
iconColor = GlanceTheme.colors.onSurface,
textColor = GlanceTheme.colors.onSurface,
actions = {
CircleIconButton(
imageProvider = ImageProvider(R.drawable.ic_more_vert),
contentDescription = "Options",
backgroundColor = null, // Transparent → nur Icon + 48x48dp Hit Area
contentColor = GlanceTheme.colors.onSurface,
onClick = actionRunCallback<ShowOptionsAction>(
actionParametersOf(
NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString()
)
)
)
}
)
// Optionsleiste (ein-/ausblendbar)
if (showOptions) {
OptionsBar(
isLocked = isLocked,
noteId = note.id,
glanceId = glanceId
)
}
// Content-Bereich — Click öffnet Editor (unlocked) oder Options (locked)
val contentClickModifier = GlanceModifier
.fillMaxSize()
.clickable(
onClick = if (!isLocked) {
actionStartActivity(
ComponentName(context, ComposeNoteEditorActivity::class.java),
actionParametersOf(
androidx.glance.action.ActionParameters.Key<String>("extra_note_id") to note.id
)
)
} else {
actionRunCallback<ShowOptionsAction>(
actionParametersOf(
NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString()
)
)
}
)
// Content — abhängig von SizeClass
when (sizeClass) {
WidgetSizeClass.SMALL -> {
// Nur TitleBar, leerer Body als Click-Target
Box(modifier = contentClickModifier) {}
}
WidgetSizeClass.NARROW_MED -> Box(modifier = contentClickModifier) {
when (note.noteType) {
NoteType.TEXT -> TextNotePreview(note, compact = true)
NoteType.CHECKLIST -> ChecklistCompactView(
note = note,
maxItems = 2,
isLocked = isLocked,
glanceId = glanceId
)
}
}
// 🆕 v1.8.1 (IMPL_09): Scrollbare Größe (150dp+ Höhe)
WidgetSizeClass.NARROW_SCROLL,
WidgetSizeClass.NARROW_TALL -> {
when (note.noteType) {
NoteType.TEXT -> Box(modifier = contentClickModifier) {
TextNoteFullView(note)
}
NoteType.CHECKLIST -> {
// 🆕 v1.8.1: Locked: Click -> Options | Unlocked: kein Click -> Scroll frei
val checklistBoxModifier = if (isLocked) {
contentClickModifier
} else {
GlanceModifier.fillMaxSize()
}
Box(modifier = checklistBoxModifier) {
ChecklistFullView(
note = note,
isLocked = isLocked,
glanceId = glanceId
)
}
}
}
}
WidgetSizeClass.WIDE_MED -> Box(modifier = contentClickModifier) {
when (note.noteType) {
NoteType.TEXT -> TextNotePreview(note, compact = false)
NoteType.CHECKLIST -> ChecklistCompactView(
note = note,
maxItems = 3,
isLocked = isLocked,
glanceId = glanceId
)
}
}
// 🆕 v1.8.1 (IMPL_09): Scrollbare Größe (150dp+ Höhe)
WidgetSizeClass.WIDE_SCROLL,
WidgetSizeClass.WIDE_TALL -> {
when (note.noteType) {
NoteType.TEXT -> Box(modifier = contentClickModifier) {
TextNoteFullView(note)
}
NoteType.CHECKLIST -> {
// 🆕 v1.8.1: Locked: Click -> Options | Unlocked: kein Click -> Scroll frei
val checklistBoxModifier = if (isLocked) {
contentClickModifier
} else {
GlanceModifier.fillMaxSize()
}
Box(modifier = checklistBoxModifier) {
ChecklistFullView(
note = note,
isLocked = isLocked,
glanceId = glanceId
)
}
}
}
}
}
}
}
}
/**
* Optionsleiste — Lock/Unlock + Refresh + Open in App
*/
@Composable
private fun OptionsBar(
isLocked: Boolean,
noteId: String,
glanceId: GlanceId
) {
val context = LocalContext.current
Row(
modifier = GlanceModifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp)
.background(GlanceTheme.colors.secondaryContainer),
horizontalAlignment = Alignment.End,
verticalAlignment = Alignment.CenterVertically
) {
// Lock/Unlock Toggle
Image(
provider = ImageProvider(
if (isLocked) R.drawable.ic_lock_open else R.drawable.ic_lock
),
contentDescription = if (isLocked) "Unlock" else "Lock",
modifier = GlanceModifier
.size(36.dp)
.padding(6.dp)
.clickable(
onClick = actionRunCallback<ToggleLockAction>(
actionParametersOf(
NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString()
)
)
)
)
Spacer(modifier = GlanceModifier.width(4.dp))
// Refresh
Image(
provider = ImageProvider(R.drawable.ic_refresh),
contentDescription = "Refresh",
modifier = GlanceModifier
.size(36.dp)
.padding(6.dp)
.clickable(
onClick = actionRunCallback<RefreshAction>(
actionParametersOf(
NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString()
)
)
)
)
Spacer(modifier = GlanceModifier.width(4.dp))
// Settings (Reconfigure)
Image(
provider = ImageProvider(R.drawable.ic_settings),
contentDescription = "Settings",
modifier = GlanceModifier
.size(36.dp)
.padding(6.dp)
.clickable(
onClick = actionRunCallback<OpenConfigAction>(
actionParametersOf(
NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString()
)
)
)
)
Spacer(modifier = GlanceModifier.width(4.dp))
// Open in App
Image(
provider = ImageProvider(R.drawable.ic_open_in_new),
contentDescription = "Open",
modifier = GlanceModifier
.size(36.dp)
.padding(6.dp)
.clickable(
onClick = actionStartActivity(
ComponentName(context, ComposeNoteEditorActivity::class.java),
actionParametersOf(
androidx.glance.action.ActionParameters.Key<String>("extra_note_id") to noteId
)
)
)
)
}
}
// ── Text Note Views ──
@Composable
private fun TextNotePreview(note: Note, compact: Boolean) {
Text(
text = note.content.take(
if (compact) TEXT_PREVIEW_COMPACT_LENGTH else TEXT_PREVIEW_FULL_LENGTH
),
style = TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = if (compact) 13.sp else 14.sp
),
maxLines = if (compact) 3 else 5, // 🆕 v1.8.0: Increased for better preview
modifier = GlanceModifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
@Composable
private fun TextNoteFullView(note: Note) {
LazyColumn(
modifier = GlanceModifier
.fillMaxSize()
.padding(horizontal = 12.dp)
) {
// 🆕 v1.8.0 Fix: Split text into individual lines instead of paragraphs.
// This ensures each line is a separate LazyColumn item that can scroll properly.
// Empty lines are preserved as small spacers for visual paragraph separation.
val lines = note.content.split("\n")
items(lines.size) { index ->
val line = lines[index]
if (line.isBlank()) {
// Preserve empty lines as spacing (paragraph separator)
Spacer(modifier = GlanceModifier.height(8.dp))
} else {
Text(
text = line,
style = TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = 14.sp
),
maxLines = 5, // Allow wrapping but prevent single-item overflow
modifier = GlanceModifier.padding(bottom = 2.dp)
)
}
}
}
}
// ── Checklist Views ──
/**
* Kompakte Checklist-Ansicht für MEDIUM-Größen.
* Zeigt maxItems interaktive Checkboxen + Zusammenfassung.
*/
@Composable
private fun ChecklistCompactView(
note: Note,
maxItems: Int,
isLocked: Boolean,
glanceId: GlanceId
) {
// 🆕 v1.8.1 (IMPL_04): Sortierung aus Editor übernehmen
val items = note.checklistItems?.let { rawItems ->
sortChecklistItemsForPreview(rawItems, note.checklistSortOption)
} ?: return
// 🆕 v1.8.1 (IMPL_04): Separator-Logik
val uncheckedCount = items.count { !it.isChecked }
val checkedCount = items.count { it.isChecked }
val sortOption = try {
note.checklistSortOption?.let { ChecklistSortOption.valueOf(it) }
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) { null }
?: ChecklistSortOption.MANUAL
val showSeparator = (sortOption == ChecklistSortOption.MANUAL ||
sortOption == ChecklistSortOption.UNCHECKED_FIRST) &&
uncheckedCount > 0 && checkedCount > 0
val visibleItems = items.take(maxItems)
val remainingCount = items.size - visibleItems.size
Column(modifier = GlanceModifier.padding(horizontal = 8.dp, vertical = 2.dp)) {
var separatorShown = false
visibleItems.forEach { item ->
// 🆕 v1.8.1: Separator vor dem ersten checked Item anzeigen
if (showSeparator && !separatorShown && item.isChecked) {
WidgetCheckedItemsSeparator(checkedCount = checkedCount)
separatorShown = true
}
if (isLocked) {
Row(
modifier = GlanceModifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (item.isChecked) "☑️" else "", // 🆕 v1.8.1 (IMPL_06)
style = TextStyle(fontSize = 14.sp)
)
Spacer(modifier = GlanceModifier.width(6.dp))
Text(
text = item.text,
style = TextStyle(
color = if (item.isChecked) GlanceTheme.colors.outline
else GlanceTheme.colors.onSurface,
fontSize = 13.sp
),
maxLines = 1
)
}
} else {
CheckBox(
checked = item.isChecked,
onCheckedChange = actionRunCallback<ToggleChecklistItemAction>(
actionParametersOf(
NoteWidgetActionKeys.KEY_NOTE_ID to note.id,
NoteWidgetActionKeys.KEY_ITEM_ID to item.id,
NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString()
)
),
text = item.text,
style = TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = 13.sp
),
modifier = GlanceModifier
.fillMaxWidth()
.padding(vertical = 1.dp)
)
}
}
if (remainingCount > 0) {
Text(
text = "+$remainingCount more · ✔ $checkedCount/${items.size}",
style = TextStyle(
color = GlanceTheme.colors.outline,
fontSize = 12.sp
),
modifier = GlanceModifier.padding(top = 2.dp, start = 4.dp)
)
}
}
}
/**
* Vollständige Checklist-Ansicht für LARGE-Größen.
*/
@Composable
private fun ChecklistFullView(
note: Note,
isLocked: Boolean,
glanceId: GlanceId
) {
// 🆕 v1.8.1 (IMPL_04): Sortierung aus Editor übernehmen
val items = note.checklistItems?.let { rawItems ->
sortChecklistItemsForPreview(rawItems, note.checklistSortOption)
} ?: return
// 🆕 v1.8.1 (IMPL_04): Separator-Logik
val uncheckedCount = items.count { !it.isChecked }
val checkedCount = items.count { it.isChecked }
val sortOption = try {
note.checklistSortOption?.let { ChecklistSortOption.valueOf(it) }
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) { null }
?: ChecklistSortOption.MANUAL
val showSeparator = (sortOption == ChecklistSortOption.MANUAL ||
sortOption == ChecklistSortOption.UNCHECKED_FIRST) &&
uncheckedCount > 0 && checkedCount > 0
// 🆕 v1.8.1: Berechne die Gesamtanzahl der Elemente inklusive Separator
val totalItems = items.size + if (showSeparator) 1 else 0
LazyColumn(
modifier = GlanceModifier
.fillMaxSize()
.padding(horizontal = 8.dp)
) {
items(totalItems) { index ->
// 🆕 v1.8.1: Separator an Position uncheckedCount einfügen
if (showSeparator && index == uncheckedCount) {
WidgetCheckedItemsSeparator(checkedCount = checkedCount)
return@items
}
// Tatsächlichen Item-Index berechnen (nach Separator um 1 verschoben)
val itemIndex = if (showSeparator && index > uncheckedCount) index - 1 else index
val item = items.getOrNull(itemIndex) ?: return@items
if (isLocked) {
Row(
modifier = GlanceModifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (item.isChecked) "☑️" else "", // 🆕 v1.8.1 (IMPL_06)
style = TextStyle(fontSize = 16.sp)
)
Spacer(modifier = GlanceModifier.width(8.dp))
Text(
text = item.text,
style = TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = 14.sp
),
maxLines = 2
)
}
} else {
CheckBox(
checked = item.isChecked,
onCheckedChange = actionRunCallback<ToggleChecklistItemAction>(
actionParametersOf(
NoteWidgetActionKeys.KEY_NOTE_ID to note.id,
NoteWidgetActionKeys.KEY_ITEM_ID to item.id,
NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString()
)
),
text = item.text,
style = TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = 14.sp
),
modifier = GlanceModifier
.fillMaxWidth()
.padding(vertical = 1.dp)
)
}
}
}
}
// ── Empty State ──
@Composable
private fun EmptyWidgetContent(bgOpacity: Float) {
val bgModifier = if (bgOpacity < 1.0f) {
GlanceModifier.background(
ColorProvider(
day = Color.White.copy(alpha = bgOpacity),
night = Color(0xFF1C1B1F).copy(alpha = bgOpacity)
)
)
} else {
GlanceModifier.background(GlanceTheme.colors.widgetBackground)
}
Box(
modifier = GlanceModifier
.fillMaxSize()
.cornerRadius(16.dp)
.then(bgModifier)
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Note not found",
style = TextStyle(
color = GlanceTheme.colors.outline,
fontSize = 14.sp
)
)
}
}

View File

@@ -0,0 +1,13 @@
package dev.dettmer.simplenotes.widget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
/**
* 🆕 v1.8.0: BroadcastReceiver für das Notiz-Widget
*
* Muss im AndroidManifest.xml registriert werden.
* Delegiert alle Widget-Updates an NoteWidget.
*/
class NoteWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: NoteWidget = NoteWidget()
}

View File

@@ -0,0 +1,29 @@
package dev.dettmer.simplenotes.widget
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
/**
* 🆕 v1.8.0: Widget-State Keys (per Widget-Instance)
*
* Gespeichert via PreferencesGlanceStateDefinition (DataStore).
* Jede Widget-Instanz hat eigene Preferences.
*/
object NoteWidgetState {
/** ID der angezeigten Notiz */
val KEY_NOTE_ID = stringPreferencesKey("widget_note_id")
/** Ob das Widget gesperrt ist (keine Bearbeitung möglich) */
val KEY_IS_LOCKED = booleanPreferencesKey("widget_is_locked")
/** Ob die Optionsleiste angezeigt wird */
val KEY_SHOW_OPTIONS = booleanPreferencesKey("widget_show_options")
/** Hintergrund-Transparenz (0.0 = vollständig transparent, 1.0 = opak) */
val KEY_BACKGROUND_OPACITY = floatPreferencesKey("widget_bg_opacity")
/** Timestamp des letzten Updates — erzwingt Widget-Recomposition */
val KEY_LAST_UPDATED = longPreferencesKey("widget_last_updated")
}

View File

@@ -0,0 +1,17 @@
package dev.dettmer.simplenotes.widget
/**
* 🆕 v1.8.0: Size classification for responsive Note Widget layouts
*
* Determines which layout variant to use based on widget dimensions.
* 🆕 v1.8.1: Added NARROW_SCROLL and WIDE_SCROLL for scrollable mid-size widgets
*/
enum class WidgetSizeClass {
SMALL, // Nur Titel
NARROW_MED, // Schmal, Vorschau (CompactView)
NARROW_SCROLL, // 🆕 v1.8.1: Schmal, scrollbare Liste (150dp+)
NARROW_TALL, // Schmal, voller Inhalt
WIDE_MED, // Breit, Vorschau (CompactView)
WIDE_SCROLL, // 🆕 v1.8.1: Breit, scrollbare Liste (150dp+)
WIDE_TALL // Breit, voller Inhalt
}

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M18,8h-1L17,6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6v2L6,8c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,10c0,-1.1 -0.9,-2 -2,-2zM12,17c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2zM15.1,8L8.9,8L8.9,6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,17c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM18,8h-1L17,6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6h1.9c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2L6,8c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,10c0,-1.1 -0.9,-2 -2,-2zM18,20L6,20L6,10h12v10z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M14,2L6,2c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6zM16,18L8,18v-2h8v2zM16,14L8,14v-2h8v2zM13,9L13,3.5L18.5,9L13,9z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19,19H5V5h7V3H5c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2v-7h-2v7zM14,3v2h3.59l-9.83,9.83 1.41,1.41L19,6.41V10h2V3h-7z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>

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