Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5764e8c0ec | ||
|
|
b1e7f1750e | ||
|
|
da371436cd | ||
|
|
cf54f44377 | ||
|
|
bc3f137669 | ||
|
|
1a6617a4ec | ||
|
|
27e6b9d4ac | ||
|
|
3e4b1bd07e | ||
|
|
66d98c0cad | ||
|
|
c72b3fe1c0 | ||
|
|
a1a574a725 | ||
|
|
7dbc06d102 | ||
|
|
2c43b47e96 | ||
|
|
ffe0e46e3d | ||
|
|
fe6935a6b7 | ||
|
|
7b558113cf | ||
|
|
24fe32a973 | ||
|
|
b5a3a3c096 | ||
|
|
63561737e1 | ||
|
|
849e4080d6 | ||
|
|
59c417cc4c | ||
|
|
90f1f48810 | ||
|
|
e2bce099f3 | ||
|
|
661d9e0992 | ||
|
|
3e946edafb | ||
|
|
4a621b622b | ||
|
|
49810ff6f1 | ||
|
|
d045d4d3db | ||
|
|
eaac5a0775 | ||
|
|
68584461b3 | ||
|
|
881c0fd0fa | ||
|
|
1da1a63566 | ||
|
|
96c819b154 | ||
|
|
539987f2ed | ||
|
|
900dad76fe | ||
|
|
538a705def | ||
|
|
3462f93f25 | ||
|
|
bdfc0bf060 | ||
|
|
df37d2a47c | ||
|
|
bf7a74ec30 | ||
|
|
07607fc095 | ||
|
|
40d7c83c84 | ||
|
|
e9e4b87853 | ||
|
|
45f528ea0e | ||
|
|
cb1bc46405 | ||
|
|
0b143e5f0d | ||
|
|
cf9695844c | ||
|
|
24ea7ec59a | ||
|
|
df4ee4bed0 | ||
|
|
68e8490db8 | ||
|
|
614650e37d | ||
|
|
785a6c011a | ||
|
|
a96d373e78 | ||
|
|
a59e89fe91 | ||
|
|
91beee0f8b | ||
|
|
c536ad3177 | ||
|
|
6dba091c03 | ||
|
|
5135c711a5 | ||
|
|
ebab347d4b | ||
|
|
cb63aa1220 | ||
|
|
0df8282eb4 | ||
|
|
b70bc4d8f6 | ||
|
|
217a174478 | ||
|
|
d58d9036cb | ||
|
|
dfdccfe6c7 | ||
|
|
d524bc715d | ||
|
|
2a22e7d88e | ||
|
|
b5cb4e1d96 | ||
|
|
80a35da3ff | ||
|
|
6254758a03 | ||
|
|
ff6510af90 | ||
|
|
ea5c6dae70 | ||
|
|
1d010d0034 | ||
|
|
ef6e939567 | ||
|
|
65395142fa | ||
|
|
0c8f21ef58 | ||
|
|
806194f4e8 | ||
|
|
3bf97dbc14 | ||
|
|
06dda24a64 | ||
|
|
c180fb2416 | ||
|
|
16a3338903 | ||
|
|
67b226a5c3 | ||
|
|
3af99f31b8 | ||
|
|
3ada6c966d | ||
|
|
20ec5ba9f9 | ||
|
|
c33448f841 | ||
|
|
64b2cfaf78 | ||
|
|
b052fb0f0a | ||
|
|
7128c25bd5 | ||
|
|
356ccde627 | ||
|
|
9b37078cce | ||
|
|
dee85233b6 | ||
|
|
fbcca3807d | ||
|
|
e3e64b83e2 | ||
|
|
2324743f43 | ||
|
|
0e96757fab | ||
|
|
547c0a1011 | ||
|
|
b79c0d25e6 | ||
|
|
cf1142afa2 | ||
|
|
359325bf64 | ||
|
|
c7d0f899e7 | ||
|
|
5121a7b2b8 | ||
|
|
04664c8920 | ||
|
|
2a56dd8128 | ||
|
|
63af7d30dc | ||
|
|
62423f5a5b | ||
|
|
9eabc9a5f0 | ||
|
|
015b90d56e | ||
|
|
6d135e8f0d | ||
|
|
5d82431bb6 | ||
|
|
6bb87816f3 | ||
|
|
4802c3d979 | ||
|
|
85625b4f67 | ||
|
|
609da827c5 | ||
|
|
539f17cdda | ||
|
|
0bd686008d | ||
|
|
65ce3746ca | ||
|
|
6079df3b1e | ||
|
|
5f0dc8a981 | ||
|
|
d79a44491d | ||
|
|
4a04b21975 | ||
|
|
881162737b | ||
|
|
1f78953959 | ||
|
|
3092fcc6d3 | ||
|
|
60d6b1effc |
20
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
20
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: 🐛 Bug Report / Fehlerbericht
|
||||
name: "🐛 Bug Report / Fehlerbericht"
|
||||
description: Melde einen Fehler in der App / Report a bug in the app
|
||||
title: "[BUG] "
|
||||
labels: ["bug"]
|
||||
@@ -13,7 +13,7 @@ body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 🐛 Beschreibung / Description
|
||||
label: "🐛 Beschreibung / Description"
|
||||
description: Beschreibe den Fehler kurz und präzise / Describe the bug briefly and precisely
|
||||
placeholder: "z.B. Auto-Sync funktioniert nicht mehr nach App-Update / e.g. Auto-sync stopped working after app update"
|
||||
validations:
|
||||
@@ -22,9 +22,11 @@ body:
|
||||
- type: dropdown
|
||||
id: android-version
|
||||
attributes:
|
||||
label: 📱 Android Version
|
||||
label: "📱 Android Version"
|
||||
description: Welche Android Version verwendest du? / Which Android version are you using?
|
||||
options:
|
||||
- Android 16
|
||||
- Android 15
|
||||
- Android 14
|
||||
- Android 13
|
||||
- Android 12
|
||||
@@ -40,7 +42,7 @@ body:
|
||||
- type: input
|
||||
id: app-version
|
||||
attributes:
|
||||
label: 📲 App Version
|
||||
label: "📲 App Version"
|
||||
description: Welche Version der App verwendest du? (Einstellungen → Über) / Which app version? (Settings → About)
|
||||
placeholder: "z.B. / e.g. v1.1.0"
|
||||
validations:
|
||||
@@ -49,7 +51,7 @@ body:
|
||||
- type: input
|
||||
id: device
|
||||
attributes:
|
||||
label: 📱 Gerät / Device
|
||||
label: "📱 Gerät / Device"
|
||||
description: Welches Gerät verwendest du? / Which device are you using?
|
||||
placeholder: "z.B. Samsung Galaxy S21, Google Pixel 7, etc."
|
||||
validations:
|
||||
@@ -58,7 +60,7 @@ body:
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: 🔄 Schritte zum Reproduzieren / Steps to Reproduce
|
||||
label: "📋 Schritte zum Reproduzieren / Steps to Reproduce"
|
||||
description: Wie kann der Fehler reproduziert werden? / How can the bug be reproduced?
|
||||
placeholder: |
|
||||
1. Öffne die App / Open the app
|
||||
@@ -71,7 +73,7 @@ body:
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: ✅ Erwartetes Verhalten / Expected Behavior
|
||||
label: "✅ Erwartetes Verhalten / Expected Behavior"
|
||||
description: Was sollte passieren? / What should happen?
|
||||
placeholder: "z.B. Notizen sollten alle 30 Min synchronisiert werden / e.g. Notes should sync every 30 min"
|
||||
validations:
|
||||
@@ -80,7 +82,7 @@ body:
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: ❌ Tatsächliches Verhalten / Actual Behavior
|
||||
label: "❌ Tatsächliches Verhalten / Actual Behavior"
|
||||
description: Was passiert stattdessen? / What happens instead?
|
||||
placeholder: "z.B. Sync funktioniert nicht, keine Notification / e.g. Sync doesn't work, no notification"
|
||||
validations:
|
||||
@@ -89,7 +91,7 @@ body:
|
||||
- type: dropdown
|
||||
id: sync-enabled
|
||||
attributes:
|
||||
label: <EFBFBD> Auto-Sync aktiviert? / Auto-Sync enabled?
|
||||
label: Auto-Sync aktiviert? / Auto-Sync enabled?
|
||||
options:
|
||||
- "Ja / Yes"
|
||||
- "Nein / No"
|
||||
|
||||
9
.github/ISSUE_TEMPLATE/config.yml
vendored
9
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,11 +1,14 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 📖 Dokumentation / Documentation
|
||||
- name: "📖 Dokumentation / Documentation"
|
||||
url: https://github.com/inventory69/simple-notes-sync/blob/main/README.md
|
||||
about: Schau zuerst in die Dokumentation / Check documentation first
|
||||
- name: 🚀 Quick Start Guide
|
||||
- name: "🚀 Quick Start Guide"
|
||||
url: https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md
|
||||
about: Schritt-für-Schritt Anleitung / Step-by-step guide
|
||||
- name: 🐛 Troubleshooting
|
||||
- name: "🐛 Troubleshooting"
|
||||
url: https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md#troubleshooting
|
||||
about: Häufige Probleme und Lösungen / Common issues and solutions
|
||||
- name: "✨ Feature Requests & Ideas"
|
||||
url: https://github.com/inventory69/simple-notes-sync/discussions/categories/ideas
|
||||
about: Diskutiere neue Features in Discussions / Discuss new features in Discussions
|
||||
|
||||
84
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
84
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -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
|
||||
14
.github/ISSUE_TEMPLATE/question.yml
vendored
14
.github/ISSUE_TEMPLATE/question.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: ❓ Question / Frage
|
||||
name: "❓ Question / Frage"
|
||||
description: Stelle eine Frage zur Nutzung / Ask a question about usage
|
||||
title: "[QUESTION] "
|
||||
labels: ["question"]
|
||||
@@ -11,7 +11,7 @@ body:
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: ❓ Frage / Question
|
||||
label: "❓ Frage / Question"
|
||||
description: Was möchtest du wissen? / What would you like to know?
|
||||
placeholder: "z.B. Wie kann ich die Sync-URL für einen externen Server konfigurieren? / e.g. How can I configure the sync URL for an external server?"
|
||||
validations:
|
||||
@@ -20,7 +20,7 @@ body:
|
||||
- type: checkboxes
|
||||
id: documentation-checked
|
||||
attributes:
|
||||
label: 📚 Dokumentation gelesen / Documentation checked
|
||||
label: "📚 Dokumentation gelesen / Documentation checked"
|
||||
description: Hast du bereits in der Dokumentation nachgeschaut? / Have you already checked the documentation?
|
||||
options:
|
||||
- label: Ich habe die [README](https://github.com/inventory69/simple-notes-sync/blob/main/README.md) gelesen / I have read the README
|
||||
@@ -33,7 +33,7 @@ body:
|
||||
- type: textarea
|
||||
id: tried
|
||||
attributes:
|
||||
label: 🔍 Was hast du bereits versucht? / What have you already tried?
|
||||
label: "🔍 Was hast du bereits versucht? / What have you already tried?"
|
||||
description: Hilf uns, dir besser zu helfen / Help us help you better
|
||||
placeholder: "z.B. Ich habe versucht die Server-URL anzupassen, aber... / e.g. I tried adjusting the server URL, but..."
|
||||
validations:
|
||||
@@ -42,7 +42,7 @@ body:
|
||||
- type: dropdown
|
||||
id: topic
|
||||
attributes:
|
||||
label: <EFBFBD> Thema / Topic
|
||||
label: "📌 Thema / Topic"
|
||||
description: Um was geht es? / What is this about?
|
||||
options:
|
||||
- Server Setup / Server-Einrichtung
|
||||
@@ -57,7 +57,7 @@ body:
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: <EFBFBD> Kontext / Context
|
||||
label: "💬 Kontext / Context"
|
||||
description: Zusätzliche Informationen die hilfreich sein könnten / Additional information that might be helpful
|
||||
placeholder: |
|
||||
- Android Version: Android 13
|
||||
@@ -70,7 +70,7 @@ body:
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: 🔧 Screenshots / Config
|
||||
label: "🔧 Screenshots / Config"
|
||||
description: Falls hilfreich (KEINE Passwörter!) / If helpful (NO passwords!)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
87
.github/workflows/build-debug-apk.yml
vendored
Normal file
87
.github/workflows/build-debug-apk.yml
vendored
Normal 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
|
||||
139
.github/workflows/build-production-apk.yml
vendored
139
.github/workflows/build-production-apk.yml
vendored
@@ -2,11 +2,11 @@ name: Build Android Production APK
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ] # Nur bei Push/Merge auf main triggern
|
||||
workflow_dispatch: # Ermöglicht manuellen Trigger
|
||||
branches: [ main ] # Only trigger on push/merge to main
|
||||
workflow_dispatch: # Enables manual trigger
|
||||
|
||||
permissions:
|
||||
contents: write # Fuer Release-Erstellung erforderlich
|
||||
contents: write # Required for release creation
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -14,114 +14,94 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Code auschecken
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Java einrichten
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Semantic Versionsnummer aus build.gradle.kts extrahieren
|
||||
- name: Extract semantic version from build.gradle.kts
|
||||
run: |
|
||||
# Version aus build.gradle.kts fuer F-Droid Kompatibilität
|
||||
# Version from build.gradle.kts for F-Droid compatibility
|
||||
VERSION_NAME=$(grep "versionName = " android/app/build.gradle.kts | sed 's/.*versionName = "\(.*\)".*/\1/')
|
||||
VERSION_CODE=$(grep "versionCode = " android/app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
|
||||
|
||||
# Semantische Versionierung (nicht datums-basiert)
|
||||
# Semantic versioning (not date-based)
|
||||
BUILD_NUMBER="$VERSION_CODE"
|
||||
|
||||
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
|
||||
echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV
|
||||
echo "VERSION_TAG=v$VERSION_NAME" >> $GITHUB_ENV
|
||||
|
||||
echo "🚀 Baue Version: $VERSION_NAME (Code: $BUILD_NUMBER)"
|
||||
echo "🚀 Building version: $VERSION_NAME (Code: $BUILD_NUMBER)"
|
||||
|
||||
- name: Version aus build.gradle.kts verifizieren
|
||||
- name: Verify version from build.gradle.kts
|
||||
run: |
|
||||
echo "✅ Verwende Version aus build.gradle.kts:"
|
||||
echo "✅ Using version from build.gradle.kts:"
|
||||
grep -E "versionCode|versionName" android/app/build.gradle.kts
|
||||
|
||||
- name: Android Signing konfigurieren
|
||||
- name: Configure Android signing
|
||||
run: |
|
||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/simple-notes-release.jks
|
||||
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" > android/key.properties
|
||||
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties
|
||||
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/key.properties
|
||||
echo "storeFile=simple-notes-release.jks" >> android/key.properties
|
||||
echo "✅ Signing-Konfiguration erstellt"
|
||||
echo "✅ Signing configuration created"
|
||||
|
||||
- name: Produktions-APK bauen (Standard + F-Droid Flavors)
|
||||
- name: Build production APK (Standard + F-Droid Flavors)
|
||||
run: |
|
||||
cd android
|
||||
./gradlew assembleStandardRelease assembleFdroidRelease --no-daemon --stacktrace
|
||||
|
||||
- name: APK-Varianten mit Versionsnamen kopieren
|
||||
- name: Copy APK variants with version names
|
||||
run: |
|
||||
mkdir -p apk-output
|
||||
|
||||
# === Standard Flavor (mit Google Services) ===
|
||||
# Universal APK (funktioniert auf allen Geraeten)
|
||||
cp android/app/build/outputs/apk/standard/release/app-standard-universal-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard-universal.apk
|
||||
# Standard Flavor
|
||||
cp android/app/build/outputs/apk/standard/release/app-standard-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk
|
||||
|
||||
# ARM64 APK (moderne Geräte 2018+)
|
||||
cp android/app/build/outputs/apk/standard/release/app-standard-arm64-v8a-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard-arm64-v8a.apk
|
||||
# F-Droid Flavor
|
||||
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk
|
||||
|
||||
# ARMv7 APK (ältere Geräte)
|
||||
cp android/app/build/outputs/apk/standard/release/app-standard-armeabi-v7a-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard-armeabi-v7a.apk
|
||||
|
||||
# === F-Droid Flavor (ohne Google Services) ===
|
||||
# Universal APK
|
||||
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-universal-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-universal.apk
|
||||
|
||||
# ARM64 APK
|
||||
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-arm64-v8a-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-arm64-v8a.apk
|
||||
|
||||
# ARMv7 APK
|
||||
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-armeabi-v7a-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-armeabi-v7a.apk
|
||||
|
||||
echo "✅ APK-Dateien vorbereitet (Standard + F-Droid):"
|
||||
echo "✅ APK files prepared:"
|
||||
ls -lh apk-output/
|
||||
|
||||
- name: APK-Artefakte hochladen
|
||||
- name: Upload APK artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: simple-notes-sync-apks-v${{ env.VERSION_NAME }}
|
||||
path: apk-output/*.apk
|
||||
retention-days: 90 # Produktions-Builds länger aufbewahren
|
||||
retention-days: 90 # Keep production builds longer
|
||||
|
||||
- name: Commit-Informationen auslesen
|
||||
- name: Extract commit information
|
||||
run: |
|
||||
echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
echo "COMMIT_DATE=$(git log -1 --format=%cd --date=iso-strict)" >> $GITHUB_ENV
|
||||
|
||||
- name: F-Droid Changelogs lesen
|
||||
- name: Read F-Droid changelogs
|
||||
run: |
|
||||
# Lese deutsche Changelog (Hauptsprache)
|
||||
if [ -f "android/fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
||||
{
|
||||
echo 'CHANGELOG_DE<<EOF'
|
||||
cat "android/fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt"
|
||||
echo 'EOF'
|
||||
} >> $GITHUB_ENV
|
||||
# Read German changelog (main language) - Use printf to ensure proper formatting
|
||||
if [ -f "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
||||
CHANGELOG_CONTENT=$(cat "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt")
|
||||
echo "CHANGELOG_DE<<GHADELIMITER" >> $GITHUB_ENV
|
||||
echo "$CHANGELOG_CONTENT" >> $GITHUB_ENV
|
||||
echo "GHADELIMITER" >> $GITHUB_ENV
|
||||
else
|
||||
echo "CHANGELOG_DE=Keine deutschen Release Notes verfügbar." >> $GITHUB_ENV
|
||||
echo "CHANGELOG_DE=No German release notes available." >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# Lese englische Changelog (optional)
|
||||
if [ -f "android/fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
||||
{
|
||||
echo 'CHANGELOG_EN<<EOF'
|
||||
cat "android/fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt"
|
||||
echo 'EOF'
|
||||
} >> $GITHUB_ENV
|
||||
# Read English changelog (optional)
|
||||
if [ -f "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
||||
CHANGELOG_CONTENT_EN=$(cat "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt")
|
||||
echo "CHANGELOG_EN<<GHADELIMITER" >> $GITHUB_ENV
|
||||
echo "$CHANGELOG_CONTENT_EN" >> $GITHUB_ENV
|
||||
echo "GHADELIMITER" >> $GITHUB_ENV
|
||||
else
|
||||
echo "CHANGELOG_EN=" >> $GITHUB_ENV
|
||||
fi
|
||||
@@ -136,42 +116,41 @@ jobs:
|
||||
prerelease: false
|
||||
generate_release_notes: false
|
||||
body: |
|
||||
## 📦 Downloads
|
||||
|
||||
| Variante | Datei | Info |
|
||||
|----------|-------|------|
|
||||
| **🏆 Empfohlen** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard-universal.apk` | Funktioniert auf allen Android-Geraeten |
|
||||
| Modern (2018+) | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard-arm64-v8a.apk` | Kleinere Dateigröße fuer 64-bit Geräte |
|
||||
| Aelter (<2018) | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard-armeabi-v7a.apk` | Fuer 32-bit ARM Geräte |
|
||||
| F-Droid Universal | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-universal.apk` | Fuer F-Droid Store |
|
||||
| F-Droid ARM64 | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-arm64-v8a.apk` | F-Droid 64-bit |
|
||||
| F-Droid ARMv7 | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-armeabi-v7a.apk` | F-Droid 32-bit |
|
||||
|
||||
💡 **Nicht sicher?** → Nimm die **Universal** APK!
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changelog / Release Notes
|
||||
|
||||
${{ env.CHANGELOG_EN }}
|
||||
|
||||
<details>
|
||||
<summary><EFBFBD>🇪 Deutsche Version (zum Aufklappen)</summary>
|
||||
<summary>🇩🇪 German Version</summary>
|
||||
|
||||
${{ env.CHANGELOG_DE }}
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
## 📦 Downloads
|
||||
|
||||
## 📊 Build-Info
|
||||
| Variant | File | Info |
|
||||
|---------|------|------|
|
||||
| **🏆 Recommended** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk` | Standard version (works on all devices) |
|
||||
| F-Droid | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk` | For F-Droid Store |
|
||||
|
||||
## 📊 Build Info
|
||||
|
||||
- **Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.BUILD_NUMBER }})
|
||||
- **Datum:** ${{ env.COMMIT_DATE }}
|
||||
- **Date:** ${{ env.COMMIT_DATE }}
|
||||
- **Commit:** ${{ env.SHORT_SHA }}
|
||||
|
||||
---
|
||||
## 🔐 APK Signature Verification
|
||||
|
||||
**[📖 Dokumentation](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md)** · **[🐛 Issue melden](https://github.com/inventory69/simple-notes-sync/issues)**
|
||||
All APKs are signed with the official release certificate.
|
||||
|
||||
**Recommended:** Verify with [AppVerifier](https://github.com/nicholson-lab/AppVerifier) (Android app)
|
||||
|
||||
**Expected SHA-256:**
|
||||
```
|
||||
42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
|
||||
```
|
||||
|
||||
**[📖 Documentation](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md)** · **[🐛 Report Bug](https://github.com/inventory69/simple-notes-sync/issues)**
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
47
.github/workflows/pr-build-check.yml
vendored
47
.github/workflows/pr-build-check.yml
vendored
@@ -1,29 +1,24 @@
|
||||
name: PR Build Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'android/**'
|
||||
- '.github/workflows/pr-build-check.yml'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & Test APK
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Code auschecken
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Java einrichten
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Gradle Cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -31,7 +26,6 @@ jobs:
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Version auslesen
|
||||
run: |
|
||||
VERSION_NAME=$(grep "versionName = " android/app/build.gradle.kts | sed 's/.*versionName = "\(.*\)".*/\1/')
|
||||
@@ -40,35 +34,55 @@ jobs:
|
||||
echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV
|
||||
echo "📱 Version: $VERSION_NAME (Code: $VERSION_CODE)"
|
||||
|
||||
# 🔍 Code Quality Checks (v1.6.1)
|
||||
- name: Run detekt (Code Quality)
|
||||
run: |
|
||||
cd android
|
||||
./gradlew detekt --no-daemon
|
||||
continue-on-error: false
|
||||
|
||||
- name: Run ktlint (Code Style)
|
||||
run: |
|
||||
cd android
|
||||
./gradlew ktlintCheck --no-daemon
|
||||
continue-on-error: true # Parser-Probleme in Legacy-Code
|
||||
|
||||
- name: Upload Lint Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: lint-reports-pr-${{ github.event.pull_request.number }}
|
||||
path: |
|
||||
android/app/build/reports/detekt/
|
||||
android/app/build/reports/ktlint/
|
||||
android/app/build/reports/lint-results*.html
|
||||
retention-days: 7
|
||||
|
||||
- name: Debug Build erstellen (ohne Signing)
|
||||
run: |
|
||||
cd android
|
||||
./gradlew assembleStandardDebug assembleFdroidDebug --no-daemon --stacktrace
|
||||
|
||||
- name: Unit Tests ausfuehren
|
||||
run: |
|
||||
cd android
|
||||
./gradlew test --no-daemon --stacktrace
|
||||
continue-on-error: true
|
||||
|
||||
- name: Build-Ergebnis pruefen
|
||||
run: |
|
||||
if [ -f "android/app/build/outputs/apk/standard/debug/app-standard-universal-debug.apk" ]; then
|
||||
if [ -f "android/app/build/outputs/apk/standard/debug/app-standard-debug.apk" ]; then
|
||||
echo "✅ Standard Debug APK erfolgreich gebaut"
|
||||
ls -lh android/app/build/outputs/apk/standard/debug/*.apk
|
||||
else
|
||||
echo "❌ Standard Debug APK Build fehlgeschlagen"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "android/app/build/outputs/apk/fdroid/debug/app-fdroid-universal-debug.apk" ]; then
|
||||
if [ -f "android/app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk" ]; then
|
||||
echo "✅ F-Droid Debug APK erfolgreich gebaut"
|
||||
ls -lh android/app/build/outputs/apk/fdroid/debug/*.apk
|
||||
else
|
||||
echo "❌ F-Droid Debug APK Build fehlgeschlagen"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Debug APKs hochladen (Artefakte)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -77,7 +91,6 @@ jobs:
|
||||
android/app/build/outputs/apk/standard/debug/*.apk
|
||||
android/app/build/outputs/apk/fdroid/debug/*.apk
|
||||
retention-days: 30
|
||||
|
||||
- name: Kommentar zu PR hinzufuegen
|
||||
uses: actions/github-script@v7
|
||||
if: success()
|
||||
@@ -88,7 +101,6 @@ jobs:
|
||||
.filter(f => f.endsWith('.apk'));
|
||||
const fdroidApk = fs.readdirSync('android/app/build/outputs/apk/fdroid/debug/')
|
||||
.filter(f => f.endsWith('.apk'));
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
@@ -98,14 +110,13 @@ jobs:
|
||||
**Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.VERSION_CODE }})
|
||||
|
||||
### 📦 Debug APKs (Test-Builds)
|
||||
|
||||
Die Debug-APKs wurden erfolgreich gebaut und sind als Artefakte verfuegbar:
|
||||
|
||||
**Standard Flavor:**
|
||||
${standardApk.map(f => '- `' + f + '`').join('\n')}
|
||||
${standardApk.map(f => '- \`' + f + '\`').join('\n')}
|
||||
|
||||
**F-Droid Flavor:**
|
||||
${fdroidApk.map(f => '- `' + f + '`').join('\n')}
|
||||
${fdroidApk.map(f => '- \`' + f + '\`').join('\n')}
|
||||
|
||||
> ⚠️ **Hinweis:** Dies sind unsigned Debug-Builds zum Testen. Production Releases werden nur bei Merge auf \`main\` erstellt.
|
||||
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -42,3 +42,10 @@ Thumbs.db
|
||||
*.tmp
|
||||
*.swp
|
||||
*~
|
||||
test-apks/
|
||||
server-test/
|
||||
|
||||
# F-Droid metadata (managed in fdroiddata repo)
|
||||
# Exclude fastlane metadata (we want to track those screenshots)
|
||||
metadata/
|
||||
!fastlane/metadata/
|
||||
|
||||
990
CHANGELOG.de.md
Normal file
990
CHANGELOG.de.md
Normal file
@@ -0,0 +1,990 @@
|
||||
# Changelog
|
||||
|
||||
Alle wichtigen Änderungen an Simple Notes Sync werden in dieser Datei dokumentiert.
|
||||
|
||||
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
**🌍 Sprachen:** **Deutsch** · [English](CHANGELOG.md)
|
||||
|
||||
---
|
||||
|
||||
## [1.8.1] - 2026-02-11
|
||||
|
||||
### 🛠️ Bugfix & Polish Release
|
||||
|
||||
Checklisten-Fixes, Widget-Verbesserungen, Sync-Härtung und Code-Qualität.
|
||||
|
||||
### 🐛 Fehlerbehebungen
|
||||
|
||||
**Checklisten-Sortierung Persistenz** ([7dbc06d](https://github.com/inventory69/simple-notes-sync/commit/7dbc06d))
|
||||
- Sortier-Option wurde beim erneuten Öffnen einer Checkliste nicht angewendet
|
||||
- Ursache: `sortChecklistItems()` sortierte immer unchecked-first statt `_lastChecklistSortOption` zu lesen
|
||||
- Alle Sortier-Modi werden nun korrekt wiederhergestellt (Manuell, Alphabetisch, Unchecked/Checked First)
|
||||
|
||||
**Widget-Scroll bei Standard-Größe** ([c72b3fe](https://github.com/inventory69/simple-notes-sync/commit/c72b3fe))
|
||||
- Scrollen funktionierte nicht bei Standard-3×2-Widget-Größe (110–150dp Höhe)
|
||||
- Neue Größenklassen `NARROW_SCROLL` und `WIDE_SCROLL` mit 150dp-Schwelle
|
||||
- `clickable`-Modifier bei entsperrten Checklisten entfernt, um Scrollen zu ermöglichen
|
||||
|
||||
**Auto-Sync Toast entfernt** ([fe6935a](https://github.com/inventory69/simple-notes-sync/commit/fe6935a))
|
||||
- Unerwartete Toast-Benachrichtigung bei automatischem Hintergrund-Sync entfernt
|
||||
- Stiller Auto-Sync bleibt still; nur Fehler werden angezeigt
|
||||
|
||||
**Gradient- & Drag-Regression** ([24fe32a](https://github.com/inventory69/simple-notes-sync/commit/24fe32a))
|
||||
- Gradient-Overlay-Regression bei langen Checklisten-Items behoben
|
||||
- Drag-and-Drop-Flackern beim Verschieben zwischen Bereichen behoben
|
||||
|
||||
### 🆕 Neue Funktionen
|
||||
|
||||
**Widget-Checklisten: Sortierung & Trennlinien** ([66d98c0](https://github.com/inventory69/simple-notes-sync/commit/66d98c0))
|
||||
- Widgets übernehmen die gespeicherte Sortier-Option aus dem Editor
|
||||
- Visuelle Trennlinie zwischen unerledigten/erledigten Items (MANUAL & UNCHECKED_FIRST)
|
||||
- Auto-Sortierung beim Abhaken von Checkboxen im Widget
|
||||
- Emoji-Änderung: ✅ → ☑️ für erledigte Items
|
||||
|
||||
**Checklisten-Vorschau-Sortierung** ([2c43b47](https://github.com/inventory69/simple-notes-sync/commit/2c43b47))
|
||||
- Hauptbildschirm-Vorschau (NoteCard, NoteCardCompact, NoteCardGrid) zeigt gespeicherte Sortierung
|
||||
- Neuer `ChecklistPreviewHelper` mit geteilter Sortier-Logik
|
||||
|
||||
**Auto-Scroll bei Zeilenumbruch** ([3e4b1bd](https://github.com/inventory69/simple-notes-sync/commit/3e4b1bd))
|
||||
- Checklisten-Editor scrollt automatisch wenn Text in eine neue Zeile umbricht
|
||||
- Cursor bleibt am unteren Rand sichtbar während der Eingabe
|
||||
|
||||
**Separator Drag Cross-Boundary** ([7b55811](https://github.com/inventory69/simple-notes-sync/commit/7b55811))
|
||||
- Drag-and-Drop funktioniert nun über die Checked/Unchecked-Trennlinie hinweg
|
||||
- Items wechseln automatisch ihren Status beim Verschieben über die Grenze
|
||||
- Extrahiertes `DraggableChecklistItem`-Composable für Wiederverwendbarkeit
|
||||
|
||||
### 🔄 Verbesserungen
|
||||
|
||||
**Sync-Ratenlimit & Akkuschutz** ([ffe0e46](https://github.com/inventory69/simple-notes-sync/commit/ffe0e46), [a1a574a](https://github.com/inventory69/simple-notes-sync/commit/a1a574a))
|
||||
- Globaler 30-Sekunden-Cooldown zwischen Sync-Operationen (Auto/WiFi/Periodisch)
|
||||
- onSave-Syncs umgehen den globalen Cooldown (behalten eigenen 5s-Throttle)
|
||||
- Neuer `SyncStateManager`-Singleton für zentrales State-Tracking
|
||||
- Verhindert Akkuverbrauch durch schnelle aufeinanderfolgende Syncs
|
||||
|
||||
**Toast → Banner-Migration** ([27e6b9d](https://github.com/inventory69/simple-notes-sync/commit/27e6b9d))
|
||||
- Alle nicht-interaktiven Benachrichtigungen auf einheitliches Banner-System migriert
|
||||
- Server-Lösch-Ergebnisse als INFO/ERROR-Banner angezeigt
|
||||
- INFO-Phase zu SyncPhase-Enum mit Auto-Hide (2,5s) hinzugefügt
|
||||
- Snackbars mit Undo-Aktionen bleiben unverändert
|
||||
|
||||
**ProGuard-Regeln Audit** ([6356173](https://github.com/inventory69/simple-notes-sync/commit/6356173))
|
||||
- Fehlende Keep-Regeln für Widget-ActionCallback-Klassen hinzugefügt
|
||||
- Compose-spezifische ProGuard-Regeln hinzugefügt
|
||||
- Verhindert ClassNotFoundException in Release-Builds
|
||||
|
||||
### 🧹 Code-Qualität
|
||||
|
||||
**Detekt-Compliance** ([1a6617a](https://github.com/inventory69/simple-notes-sync/commit/1a6617a))
|
||||
- Alle 12 Detekt-Findings behoben (0 Issues verbleibend)
|
||||
- `NoteEditorViewModel.loadNote()` refactored um Verschachtelungstiefe zu reduzieren
|
||||
- Konstanten für Magic Numbers im Editor extrahiert
|
||||
- Unbenutzte Imports aus `UpdateChangelogSheet` entfernt
|
||||
- `maxIssues: 0` in Detekt-Konfiguration gesetzt
|
||||
|
||||
---
|
||||
|
||||
## [1.8.0] - 2026-02-10
|
||||
|
||||
### 🚨 CRITICAL BUGFIX (Tag neu erstellt)
|
||||
|
||||
**R8/ProGuard Obfuscation Fix - Verhindert Datenverlust**
|
||||
- 🔧 **KRITISCH:** Falscher ProGuard-Klassenpfad für `Note$Companion$NoteRaw` korrigiert
|
||||
- Original v1.8.0 hatte spezifische `-keep` Regeln die nicht griffen
|
||||
- R8 obfuskierte alle NoteRaw-Felder (id→a, title→b, ...)
|
||||
- Gson konnte JSON nicht mehr parsen → **ALLE Notizen erschienen verschwunden**
|
||||
- Zurück zur sicheren breiten Regel: `-keep class dev.dettmer.simplenotes.** { *; }`
|
||||
- 🛡️ Safety-Guards in `detectServerDeletions()` hinzugefügt
|
||||
- Verhindert Massenlöschung bei leeren `serverNoteIds` (Netzwerkfehler)
|
||||
- Abort wenn ALLE lokalen Notizen als gelöscht erkannt würden
|
||||
- ✅ Notizen waren nie wirklich verloren (JSON-Dateien intakt auf Disk + Server)
|
||||
- ✅ Downgrade auf v1.7.2 holte alle Notizen zurück
|
||||
|
||||
**⚠️ Falls du v1.8.0 erste Version installiert hattest:** Deine Notizen sind sicher! Einfach updaten.
|
||||
|
||||
### 🎉 Major: Widgets, Sortierung & Erweiterte Sync-Features
|
||||
|
||||
Komplettes Widget-System mit interaktiven Checklisten, Notiz-Sortierung und umfangreiche Sync-Verbesserungen!
|
||||
|
||||
### 🆕 Homescreen-Widgets
|
||||
|
||||
**Vollständiges Jetpack Glance Widget-Framework** ([539987f](https://github.com/inventory69/simple-notes-sync/commit/539987f))
|
||||
- 5 responsive Größenklassen (SMALL, NARROW_MED, NARROW_TALL, WIDE_MED, WIDE_TALL)
|
||||
- Interaktive Checklist-Checkboxen die sofort zum Server synchronisieren
|
||||
- Material You Dynamic Colors mit konfigurierbarer Hintergrund-Transparenz (0-100%)
|
||||
- Widget-Sperre-Toggle zum Verhindern versehentlicher Änderungen
|
||||
- Read-Only-Modus mit permanenter Options-Leiste für gesperrte Widgets
|
||||
- Widget-Konfigurations-Activity mit Notiz-Auswahl und Einstellungen
|
||||
- Auto-Refresh nach Sync-Abschluss
|
||||
- Tippen auf Inhalt öffnet Editor (entsperrt) oder zeigt Optionen (gesperrt)
|
||||
- Vollständige Resource-Cleanup-Fixes für Connection Leaks
|
||||
|
||||
**Widget State Management:**
|
||||
- NoteWidgetState Keys für pro-Instanz-Persistierung via DataStore
|
||||
- Fünf Top-Level ActionCallbacks (Toggle Checkbox, Lock, Options, Refresh, Config)
|
||||
- Type-Safe Parameter-Übergabe mit NoteWidgetActionKeys
|
||||
|
||||
### 📊 Notiz- & Checklisten-Sortierung
|
||||
|
||||
**Notiz-Sortierung** ([96c819b](https://github.com/inventory69/simple-notes-sync/commit/96c819b))
|
||||
- Sortieren nach: Aktualisiert (neueste/älteste), Erstellt, Titel (A-Z/Z-A), Typ
|
||||
- Persistente Sortierungs-Präferenzen (gespeichert in SharedPreferences)
|
||||
- Sortierungs-Dialog im Hauptbildschirm mit Richtungs-Toggle
|
||||
- Kombinierte sortedNotes StateFlow im MainViewModel
|
||||
|
||||
**Checklisten-Sortierung** ([96c819b](https://github.com/inventory69/simple-notes-sync/commit/96c819b), [900dad7](https://github.com/inventory69/simple-notes-sync/commit/900dad7))
|
||||
- Sortieren nach: Manual, Alphabetisch, Offen zuerst, Erledigt zuletzt
|
||||
- Visueller Separator zwischen offenen/erledigten Items mit Anzahl-Anzeige
|
||||
- Auto-Sort bei Item-Toggle und Neuordnung
|
||||
- Drag nur innerhalb gleicher Gruppe (offen/erledigt)
|
||||
- Sanfte Fade/Slide-Animationen für Item-Übergänge
|
||||
- Unit-getestet mit 9 Testfällen für Sortierungs-Logik-Validierung
|
||||
|
||||
### 🔄 Sync-Verbesserungen
|
||||
|
||||
**Server-Löschungs-Erkennung** ([40d7c83](https://github.com/inventory69/simple-notes-sync/commit/40d7c83), [bf7a74e](https://github.com/inventory69/simple-notes-sync/commit/bf7a74e))
|
||||
- Neuer `DELETED_ON_SERVER` Sync-Status für Multi-Device-Szenarien
|
||||
- Erkennt wenn Notizen auf anderen Clients gelöscht wurden
|
||||
- Zero Performance-Impact (nutzt existierende PROPFIND-Daten)
|
||||
- Löschungs-Anzahl im Sync-Banner: "3 synchronisiert · 2 auf Server gelöscht"
|
||||
- Bearbeitete gelöschte Notizen werden automatisch zum Server hochgeladen (Status → PENDING)
|
||||
|
||||
**Sync-Status-Legende** ([07607fc](https://github.com/inventory69/simple-notes-sync/commit/07607fc))
|
||||
- Hilfe-Button (?) in Hauptbildschirm TopAppBar
|
||||
- Dialog erklärt alle 5 Sync-Status-Icons mit Beschreibungen
|
||||
- Nur sichtbar wenn Sync konfiguriert ist
|
||||
|
||||
**Live-Sync-Fortschritts-UI** ([df37d2a](https://github.com/inventory69/simple-notes-sync/commit/df37d2a))
|
||||
- Echtzeit-Phasen-Indikatoren: PREPARING, UPLOADING, DOWNLOADING, IMPORTING_MARKDOWN
|
||||
- Upload-Fortschritt zeigt x/y Counter (bekannte Gesamtzahl)
|
||||
- Download-Fortschritt zeigt Anzahl (unbekannte Gesamtzahl)
|
||||
- Einheitliches SyncProgressBanner (ersetzt Dual-System)
|
||||
- Auto-Hide: COMPLETED (2s), ERROR (4s)
|
||||
- Keine irreführenden Counter wenn nichts zu synchronisieren ist
|
||||
- Stiller Auto-Sync bleibt still, Fehler werden immer angezeigt
|
||||
|
||||
**Parallele Downloads** ([bdfc0bf](https://github.com/inventory69/simple-notes-sync/commit/bdfc0bf))
|
||||
- Konfigurierbare gleichzeitige Downloads (Standard: 3 simultan)
|
||||
- Kotlin Coroutines async/awaitAll Pattern
|
||||
- Individuelle Download-Timeout-Behandlung
|
||||
- Graceful sequentieller Fallback bei gleichzeitigen Fehlern
|
||||
- Optimierte Netzwerk-Auslastung für schnelleren Sync
|
||||
|
||||
### ✨ UX-Verbesserungen
|
||||
|
||||
**Checklisten-Verbesserungen:**
|
||||
- Überlauf-Verlauf für lange Text-Items ([3462f93](https://github.com/inventory69/simple-notes-sync/commit/3462f93))
|
||||
- Auto-Expand bei Fokus, Collapse auf 5 Zeilen bei Fokus-Verlust
|
||||
- Drag & Drop Flackern-Fix mit Straddle-Target-Center-Erkennung ([538a705](https://github.com/inventory69/simple-notes-sync/commit/538a705))
|
||||
- Adjacency-Filter verhindert Item-Sprünge bei schnellem Drag
|
||||
- Race-Condition-Fix für Scroll + Move-Operationen
|
||||
|
||||
**Einstellungs-UI-Polish:**
|
||||
- Sanfter Sprachwechsel ohne Activity-Recreate ([881c0fd](https://github.com/inventory69/simple-notes-sync/commit/881c0fd))
|
||||
- Raster-Ansicht als Standard für Neu-Installationen ([6858446](https://github.com/inventory69/simple-notes-sync/commit/6858446))
|
||||
- Sync-Einstellungen umstrukturiert in klare Sektionen: Auslöser & Performance ([eaac5a0](https://github.com/inventory69/simple-notes-sync/commit/eaac5a0))
|
||||
- Changelog-Link zum About-Screen hinzugefügt ([49810ff](https://github.com/inventory69/simple-notes-sync/commit/49810ff))
|
||||
|
||||
**Post-Update Changelog-Dialog** ([661d9e0](https://github.com/inventory69/simple-notes-sync/commit/661d9e0))
|
||||
- Zeigt lokalisierten Changelog beim ersten Start nach Update
|
||||
- Material 3 ModalBottomSheet mit Slide-up-Animation
|
||||
- Lädt F-Droid Changelogs via Assets (Single Source of Truth)
|
||||
- Einmalige Anzeige pro versionCode (gespeichert in SharedPreferences)
|
||||
- Klickbarer GitHub-Link für vollständigen Changelog
|
||||
- Durch Button oder Swipe-Geste schließbar
|
||||
- Test-Modus in Debug-Einstellungen mit Reset-Option
|
||||
|
||||
**Backup-Einstellungs-Verbesserungen** ([3e946ed](https://github.com/inventory69/simple-notes-sync/commit/3e946ed))
|
||||
- Neue BackupProgressCard mit LinearProgressIndicator
|
||||
- 3-Phasen-Status-System: In Progress → Abschluss → Löschen
|
||||
- Erfolgs-Status für 2s angezeigt, Fehler für 3s
|
||||
- Redundante Toast-Nachrichten entfernt
|
||||
- Buttons bleiben sichtbar und deaktiviert während Operationen
|
||||
- Exception-Logging für besseres Error-Tracking
|
||||
|
||||
### 🐛 Fehlerbehebungen
|
||||
|
||||
**Widget-Text-Anzeige** ([d045d4d](https://github.com/inventory69/simple-notes-sync/commit/d045d4d))
|
||||
- Text-Notizen zeigen nicht mehr nur 3 Zeilen in Widgets
|
||||
- Von Absatz-basiert zu Zeilen-basiertem Rendering geändert
|
||||
- LazyColumn scrollt jetzt korrekt durch gesamten Inhalt
|
||||
- Leere Zeilen als 8dp Spacer beibehalten
|
||||
- Vorschau-Limits erhöht: compact 100→120, full 200→300 Zeichen
|
||||
|
||||
### 🔧 Code-Qualität
|
||||
|
||||
**Detekt-Cleanup** ([1da1a63](https://github.com/inventory69/simple-notes-sync/commit/1da1a63))
|
||||
- Alle 22 Detekt-Warnungen behoben
|
||||
- 7 ungenutzte Imports entfernt
|
||||
- Konstanten für 5 Magic Numbers definiert
|
||||
- State-Reads mit derivedStateOf optimiert
|
||||
- Build: 0 Lint-Fehler + 0 Detekt-Warnungen
|
||||
|
||||
### 📚 Dokumentation
|
||||
|
||||
- Vollständige Implementierungs-Pläne für alle 23 v1.8.0 Features
|
||||
- Widget-System-Architektur und State-Management-Docs
|
||||
- Sortierungs-Logik Unit-Tests mit Edge-Case-Coverage
|
||||
- F-Droid Changelogs (Englisch + Deutsch)
|
||||
|
||||
---
|
||||
|
||||
## [1.7.2] - 2026-02-04
|
||||
|
||||
### 🐛 Kritische Fehlerbehebungen
|
||||
|
||||
#### JSON/Markdown Timestamp-Synchronisation
|
||||
|
||||
**Problem:** Externe Editoren (Obsidian, Typora, VS Code, eigene Editoren) aktualisieren Markdown-Inhalt, aber nicht den YAML `updated:` Timestamp, wodurch die Android-App Änderungen überspringt.
|
||||
|
||||
**Lösung:**
|
||||
- Server-Datei Änderungszeit (`mtime`) wird jetzt als Source of Truth statt YAML-Timestamp verwendet
|
||||
- Inhaltsänderungen werden via Hash-Vergleich erkannt
|
||||
- Notizen nach Markdown-Import als `PENDING` markiert → JSON automatisch beim nächsten Sync hochgeladen
|
||||
- Behebt Sortierungsprobleme nach externen Bearbeitungen
|
||||
|
||||
#### SyncStatus auf Server immer PENDING
|
||||
|
||||
**Problem:** Alle JSON-Dateien auf dem Server enthielten `"syncStatus": "PENDING"` auch nach erfolgreichem Sync, was externe Clients verwirrte.
|
||||
|
||||
**Lösung:**
|
||||
- Status wird jetzt auf `SYNCED` gesetzt **vor** JSON-Serialisierung
|
||||
- Server- und lokale Kopien sind jetzt konsistent
|
||||
- Externe Web/Tauri-Editoren können Sync-Status korrekt interpretieren
|
||||
|
||||
#### Deletion Tracker Race Condition
|
||||
|
||||
**Problem:** Batch-Löschungen konnten Lösch-Einträge verlieren durch konkurrierenden Dateizugriff.
|
||||
|
||||
**Lösung:**
|
||||
- Mutex-basierte Synchronisation für Deletion Tracking
|
||||
- Neue `trackDeletionSafe()` Funktion verhindert Race Conditions
|
||||
- Garantiert Zombie-Note-Prevention auch bei schnellen Mehrfach-Löschungen
|
||||
|
||||
#### ISO8601 Timezone-Parsing
|
||||
|
||||
**Problem:** Markdown-Importe schlugen fehl mit Timezone-Offsets wie `+01:00` oder `-05:00`.
|
||||
|
||||
**Lösung:**
|
||||
- Multi-Format ISO8601 Parser mit Fallback-Kette
|
||||
- Unterstützt UTC (Z), Timezone-Offsets (+01:00, +0100) und Millisekunden
|
||||
- Kompatibel mit Obsidian, Typora, VS Code Timestamps
|
||||
|
||||
### ⚡ Performance-Verbesserungen
|
||||
|
||||
#### E-Tag Batch Caching
|
||||
|
||||
- E-Tags werden jetzt in einer einzigen Batch-Operation geschrieben statt N einzelner Schreibvorgänge
|
||||
- Performance-Gewinn: ~50-100ms pro Sync mit mehreren Notizen
|
||||
- Reduzierte Disk-I/O-Operationen
|
||||
|
||||
#### Memory Leak Prevention
|
||||
|
||||
- `SafeSardineWrapper` implementiert jetzt `Closeable` für explizites Resource-Cleanup
|
||||
- HTTP Connection Pool wird nach Sync korrekt aufgeräumt
|
||||
- Verhindert Socket-Exhaustion bei häufigen Syncs
|
||||
|
||||
### 🔧 Technische Details
|
||||
|
||||
- **IMPL_001:** `kotlinx.coroutines.sync.Mutex` für thread-sicheres Deletion Tracking
|
||||
- **IMPL_002:** Pattern-basierter ISO8601 Parser mit 8 Format-Varianten
|
||||
- **IMPL_003:** Connection Pool Eviction + Dispatcher Shutdown in `close()`
|
||||
- **IMPL_004:** Batch `SharedPreferences.Editor` Updates
|
||||
- **IMPL_014:** Server `mtime` Parameter in `Note.fromMarkdown()`
|
||||
- **IMPL_015:** `syncStatus` vor `toJson()` Aufruf gesetzt
|
||||
|
||||
### 📚 Dokumentation
|
||||
|
||||
- External Editor Specification für Web/Tauri-Editor-Entwickler
|
||||
- Detaillierte Implementierungs-Dokumentation für alle Bugfixes
|
||||
|
||||
---
|
||||
|
||||
## [1.7.1] - 2026-02-02
|
||||
|
||||
### 🐛 Kritische Fehlerbehebungen
|
||||
|
||||
#### Android 9 App-Absturz Fix ([#15](https://github.com/inventory69/simple-notes-sync/issues/15))
|
||||
|
||||
**Problem:** App stürzte auf Android 9 (API 28) ab wenn WorkManager Expedited Work für Hintergrund-Sync verwendet wurde.
|
||||
|
||||
**Root Cause:** Wenn `setExpedited()` in WorkManager verwendet wird, muss die `CoroutineWorker` die Methode `getForegroundInfo()` implementieren um eine Foreground Service Notification zurückzugeben. Auf Android 9-11 ruft WorkManager diese Methode auf, aber die Standard-Implementierung wirft `IllegalStateException: Not implemented`.
|
||||
|
||||
**Lösung:** `getForegroundInfo()` in `SyncWorker` implementiert um eine korrekte `ForegroundInfo` mit Sync-Progress-Notification zurückzugeben.
|
||||
|
||||
**Details:**
|
||||
- `ForegroundInfo` mit Sync-Progress-Notification für Android 9-11 hinzugefügt
|
||||
- Android 10+: Setzt `FOREGROUND_SERVICE_TYPE_DATA_SYNC` für korrekte Service-Typisierung
|
||||
- Foreground Service Permissions in AndroidManifest.xml hinzugefügt
|
||||
- Notification zeigt Sync-Progress mit indeterminiertem Progress Bar
|
||||
- Danke an [@roughnecks](https://github.com/roughnecks) für das detaillierte Debugging!
|
||||
|
||||
#### VPN-Kompatibilitäts-Fix ([#11](https://github.com/inventory69/simple-notes-sync/issues/11))
|
||||
|
||||
- WiFi Socket-Binding erkennt jetzt korrekt Wireguard VPN-Interfaces (tun*, wg*, *-wg-*)
|
||||
- Traffic wird korrekt durch VPN-Tunnel geleitet statt direkt über WiFi
|
||||
- Behebt "Verbindungs-Timeout" beim Sync zu externen Servern über VPN
|
||||
|
||||
### 🔧 Technische Änderungen
|
||||
|
||||
- Neue `SafeSardineWrapper` Klasse stellt korrektes HTTP-Connection-Cleanup sicher
|
||||
- Weniger unnötige 401-Authentifizierungs-Challenges durch preemptive Auth-Header
|
||||
- ProGuard-Regel hinzugefügt um harmlose TextInclusionStrategy-Warnungen zu unterdrücken
|
||||
- VPN-Interface-Erkennung via `NetworkInterface.getNetworkInterfaces()` Pattern-Matching
|
||||
- Foreground Service Erkennung und Notification-System für Hintergrund-Sync-Tasks
|
||||
|
||||
### 🌍 Lokalisierung
|
||||
|
||||
- Hardcodierte deutsche Fehlermeldungen behoben - jetzt String-Resources für korrekte Lokalisierung
|
||||
- Deutsche und englische Strings für Sync-Progress-Notifications hinzugefügt
|
||||
|
||||
---
|
||||
|
||||
## [1.7.0] - 2026-01-26
|
||||
|
||||
### 🎉 Major: Grid-Ansicht, Nur-WLAN Sync & VPN-Unterstützung
|
||||
|
||||
Pinterest-Style Grid, Nur-WLAN Sync-Modus und korrekte VPN-Unterstützung!
|
||||
|
||||
### 🎨 Grid-Layout
|
||||
|
||||
- Pinterest-Style Staggered Grid ohne Lücken
|
||||
- Konsistente 12dp Abstände zwischen Cards
|
||||
- Scroll-Position bleibt erhalten nach Einstellungen
|
||||
- Neue einheitliche `NoteCardGrid` mit dynamischen Vorschauzeilen (3 klein, 6 groß)
|
||||
|
||||
### 📡 Sync-Verbesserungen
|
||||
|
||||
- **Nur-WLAN Sync Toggle** - Sync nur wenn WLAN verbunden
|
||||
- **VPN-Unterstützung** - Sync funktioniert korrekt bei aktivem VPN (Traffic über VPN)
|
||||
- **Server-Wechsel Erkennung** - Alle Notizen auf PENDING zurückgesetzt bei Server-URL Änderung
|
||||
- **Schnellere Server-Prüfung** - Socket-Timeout von 2s auf 1s reduziert
|
||||
- **"Sync läuft bereits" Feedback** - Zeigt Snackbar wenn Sync bereits läuft
|
||||
|
||||
### 🔒 Self-Signed SSL Unterstützung
|
||||
|
||||
- **Dokumentation hinzugefügt** - Anleitung für selbst-signierte Zertifikate
|
||||
- Nutzt Android's eingebauten CA Trust Store
|
||||
- Funktioniert mit ownCloud, Nextcloud, Synology, Home-Servern
|
||||
|
||||
### 🔧 Technisch
|
||||
|
||||
- `NoteCardGrid` Komponente mit dynamischen maxLines
|
||||
- FullLine Spans entfernt für lückenloses Layout
|
||||
- `resetAllSyncStatusToPending()` in NotesStorage
|
||||
- VPN-Erkennung in `getOrCacheWiFiAddress()`
|
||||
|
||||
---
|
||||
|
||||
## [1.6.1] - 2026-01-20
|
||||
|
||||
### 🧹 Code-Qualität & Build-Verbesserungen
|
||||
|
||||
- **detekt: 0 Issues** - Alle 29 Code-Qualitäts-Issues behoben
|
||||
- Triviale Fixes: Unused Imports, MaxLineLength
|
||||
- Datei umbenannt: DragDropState.kt → DragDropListState.kt
|
||||
- MagicNumbers → Constants (Dimensions.kt, SyncConstants.kt)
|
||||
- SwallowedException: Logger.w() für besseres Error-Tracking hinzugefügt
|
||||
- LongParameterList: ChecklistEditorCallbacks data class erstellt
|
||||
- LongMethod: ServerSettingsScreen in Komponenten aufgeteilt
|
||||
- @Suppress Annotationen für Legacy-Code (WebDavSyncService, SettingsActivity)
|
||||
|
||||
- **Zero Build Warnings** - Alle 21 Deprecation Warnings eliminiert
|
||||
- File-level @Suppress für deprecated Imports
|
||||
- ProgressDialog, LocalBroadcastManager, AbstractSavedStateViewModelFactory
|
||||
- onActivityResult, onRequestPermissionsResult
|
||||
- Gradle Compose Config bereinigt (StrongSkipping ist jetzt Standard)
|
||||
|
||||
- **ktlint reaktiviert** - Linting mit Compose-spezifischen Regeln wieder aktiviert
|
||||
- .editorconfig mit Compose Formatierungsregeln erstellt
|
||||
- Legacy-Dateien ausgeschlossen: WebDavSyncService.kt, build.gradle.kts
|
||||
- ignoreFailures=true für graduelle Migration
|
||||
|
||||
- **CI/CD Verbesserungen** - GitHub Actions Lint-Checks integriert
|
||||
- detekt + ktlint + Android Lint laufen vor Build in pr-build-check.yml
|
||||
- Stellt Code-Qualität bei jedem Pull Request sicher
|
||||
|
||||
### 🔧 Technische Verbesserungen
|
||||
|
||||
- **Constants Refactoring** - Bessere Code-Organisation
|
||||
- ui/theme/Dimensions.kt: UI-bezogene Konstanten
|
||||
- utils/SyncConstants.kt: Sync-Operations Konstanten
|
||||
|
||||
- **Vorbereitung für v2.0.0** - Legacy-Code für Entfernung markiert
|
||||
- SettingsActivity und MainActivity (ersetzt durch Compose-Versionen)
|
||||
- Alle deprecated APIs mit Removal-Plan dokumentiert
|
||||
|
||||
---
|
||||
|
||||
## [1.6.0] - 2026-01-19
|
||||
|
||||
### 🎉 Major: Konfigurierbare Sync-Trigger
|
||||
|
||||
Feingranulare Kontrolle darüber, wann deine Notizen synchronisiert werden - wähle die Trigger, die am besten zu deinem Workflow passen!
|
||||
|
||||
### ⚙️ Sync-Trigger System
|
||||
|
||||
- **Individuelle Trigger-Kontrolle** - Jeden Sync-Trigger einzeln in den Einstellungen aktivieren/deaktivieren
|
||||
- **5 Unabhängige Trigger:**
|
||||
- **onSave Sync** - Sync sofort nach dem Speichern einer Notiz (5s Throttle)
|
||||
- **onResume Sync** - Sync beim Öffnen der App (60s Throttle)
|
||||
- **WiFi-Connect Sync** - Sync bei WiFi-Verbindung
|
||||
- **Periodischer Sync** - Hintergrund-Sync alle 15/30/60 Minuten (konfigurierbar)
|
||||
- **Boot Sync** - Startet Hintergrund-Sync nach Geräteneustart
|
||||
|
||||
- **Smarte Defaults** - Nur ereignisbasierte Trigger standardmäßig aktiv (onSave, onResume, WiFi-Connect)
|
||||
- **Akku-optimiert** - ~0.2%/Tag mit Defaults, bis zu ~1.0% mit aktiviertem periodischen Sync
|
||||
- **Offline-Modus UI** - Ausgegraute Sync-Toggles wenn kein Server konfiguriert
|
||||
- **Dynamischer Settings-Subtitle** - Zeigt Anzahl aktiver Trigger im Haupteinstellungs-Screen
|
||||
|
||||
### 🔧 Server-Konfiguration Verbesserungen
|
||||
|
||||
- **Offline-Modus Toggle** - Alle Netzwerkfunktionen mit einem Schalter deaktivieren
|
||||
- **Getrennte Protokoll & Host Eingabe** - Protokoll (http/https) als nicht-editierbares Präfix angezeigt
|
||||
- **Klickbare Settings-Cards** - Gesamte Card klickbar für bessere UX
|
||||
- **Klickbare Toggle-Zeilen** - Text/Icon klicken um Switches zu bedienen (nicht nur der Switch selbst)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Fix:** Fehlender 5. Sync-Trigger (Boot) in der Haupteinstellungs-Screen Subtitle-Zählung
|
||||
- **Fix:** Offline-Modus Status wird nicht aktualisiert beim Zurückkehren aus Einstellungen
|
||||
- **Fix:** Pull-to-Refresh funktioniert auch im Offline-Modus
|
||||
|
||||
### 🔧 Technische Verbesserungen
|
||||
|
||||
- **Reaktiver Offline-Modus Status** - StateFlow stellt sicher, dass UI korrekt aktualisiert wird
|
||||
- **Getrennte Server-Config Checks** - `hasServerConfig()` vs `isServerConfigured()` (offline-aware)
|
||||
- **Verbesserte Konstanten** - Alle Sync-Trigger Keys und Defaults in Constants.kt
|
||||
- **Bessere Code-Organisation** - Settings-Screens für Klarheit refactored
|
||||
|
||||
### Looking Ahead
|
||||
|
||||
> 🚀 **v1.7.0** wird Server-Ordner Prüfung und weitere Community-Features bringen.
|
||||
> Feature-Requests sind willkommen als [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues).
|
||||
|
||||
---
|
||||
|
||||
## [1.5.0] - 2026-01-15
|
||||
|
||||
### 🎉 Major: Jetpack Compose UI Redesign
|
||||
|
||||
Das komplette UI wurde von XML-Views auf Jetpack Compose migriert. Die App ist jetzt moderner, schneller und flüssiger.
|
||||
|
||||
### 🌍 New Feature: Internationalization (i18n)
|
||||
|
||||
- **Englische Sprachunterstützung** - Alle 400+ Strings übersetzt
|
||||
- **Automatische Spracherkennung** - Folgt der System-Sprache
|
||||
- **Manuelle Sprachauswahl** - In den Einstellungen umschaltbar
|
||||
- **Per-App Language (Android 13+)** - Native Spracheinstellung über System-Settings
|
||||
- **locales_config.xml** - Vollständige Android-Integration
|
||||
|
||||
### ⚙️ Modernized Settings
|
||||
|
||||
- **7 kategorisierte Settings-Screens** - Übersichtlicher und intuitiver
|
||||
- **Compose Navigation** - Flüssige Übergänge zwischen Screens
|
||||
- **Konsistentes Design** - Material Design 3 durchgängig
|
||||
|
||||
### ✨ UI Improvements
|
||||
|
||||
- **Selection Mode** - Long-Press für Mehrfachauswahl statt Swipe-to-Delete
|
||||
- **Batch Delete** - Mehrere Notizen gleichzeitig löschen
|
||||
- **Silent-Sync Mode** - Kein Banner bei Auto-Sync (nur bei manuellem Sync)
|
||||
- **App Icon in About Screen** - Hochwertige Darstellung
|
||||
- **App Icon in Empty State** - Statt Emoji bei leerer Notizliste
|
||||
- **Splash Screen Update** - Verwendet App-Foreground-Icon
|
||||
- **Slide Animations** - Flüssige Animationen im NoteEditor
|
||||
|
||||
### 🔧 Technical Improvements
|
||||
|
||||
- **Jetpack Compose** - Komplette UI-Migration
|
||||
- **Compose ViewModel Integration** - StateFlow für reactive UI
|
||||
- **Improved Code Quality** - Detekt/Lint Warnings behoben
|
||||
- **Unused Imports Cleanup** - Sauberer Codebase
|
||||
|
||||
### Looking Ahead
|
||||
|
||||
> 🚀 **v1.6.0** wird Server-Ordner Prüfung und weitere technische Modernisierungen bringen.
|
||||
> Feature-Requests gerne als [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues) einreichen.
|
||||
|
||||
---
|
||||
|
||||
## [1.4.1] - 2026-01-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **🗑️ Löschen älterer Notizen (v1.2.0 Kompatibilität)**
|
||||
- Notizen aus App-Version v1.2.0 oder früher werden jetzt korrekt vom Server gelöscht
|
||||
- Behebt Problem bei Multi-Device-Nutzung mit älteren Notizen
|
||||
|
||||
- **🔄 Checklisten-Sync Abwärtskompatibilität**
|
||||
- Checklisten werden jetzt auch als Text-Fallback im `content`-Feld gespeichert
|
||||
- Ältere App-Versionen (v1.3.x) zeigen Checklisten als lesbaren Text
|
||||
- Format: GitHub-Style Task-Listen (`[ ] Item` / `[x] Item`)
|
||||
- Recovery-Mode: Falls Checklisten-Items verloren gehen, werden sie aus dem Content wiederhergestellt
|
||||
|
||||
### Improved
|
||||
|
||||
- **📝 Checklisten Auto-Zeilenumbruch**
|
||||
- Lange Checklisten-Texte werden jetzt automatisch umgebrochen
|
||||
- Keine Begrenzung auf 3 Zeilen mehr
|
||||
- Enter-Taste erstellt weiterhin ein neues Item
|
||||
|
||||
### Looking Ahead
|
||||
|
||||
> 🚀 **v1.5.0** wird das nächste größere Release. Wir sammeln Ideen und Feedback!
|
||||
> Feature-Requests gerne als [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues) einreichen.
|
||||
|
||||
---
|
||||
|
||||
## [1.4.0] - 2026-01-10
|
||||
|
||||
### 🎉 New Feature: Checklists
|
||||
|
||||
- **✅ Checklist Notes**
|
||||
- New note type: Checklists with tap-to-toggle items
|
||||
- Add items via dedicated input field with "+" button
|
||||
- Drag & drop reordering (long-press to activate)
|
||||
- Swipe-to-delete items
|
||||
- Visual distinction: Checked items get strikethrough styling
|
||||
- Type selector when creating new notes (Text or Checklist)
|
||||
|
||||
- **📝 Markdown Integration**
|
||||
- Checklists export as GitHub-style task lists (`- [ ]` / `- [x]`)
|
||||
- Compatible with Obsidian, Notion, and other Markdown editors
|
||||
- Full round-trip: Edit in Obsidian → Sync back to app
|
||||
- YAML frontmatter includes `type: checklist` for identification
|
||||
|
||||
### Fixed
|
||||
|
||||
- **<2A> Markdown Parsing Robustness**
|
||||
- Fixed content extraction after title (was returning empty for some formats)
|
||||
- Now handles single newline after title (was requiring double newline)
|
||||
- Protection: Skips import if parsed content is empty but local has content
|
||||
|
||||
- **📂 Duplicate Filename Handling**
|
||||
- Notes with identical titles now get unique Markdown filenames
|
||||
- Format: `title_shortid.md` (e.g., `test_71540ca9.md`)
|
||||
- Prevents data loss from filename collisions
|
||||
|
||||
- **🔔 Notification UX**
|
||||
- No sync notifications when app is in foreground
|
||||
- User sees changes directly in UI - no redundant notification
|
||||
- Background syncs still show notifications as expected
|
||||
|
||||
### Privacy Improvements
|
||||
|
||||
- **🔒 WiFi Permissions Removed**
|
||||
- Removed `ACCESS_WIFI_STATE` permission
|
||||
- Removed `CHANGE_WIFI_STATE` permission
|
||||
- WiFi binding now works via IP detection instead of SSID matching
|
||||
- Cleaned up all SSID-related code from codebase and documentation
|
||||
|
||||
### Technical Improvements
|
||||
|
||||
- **📦 New Data Model**
|
||||
- `NoteType` enum: `TEXT`, `CHECKLIST`
|
||||
- `ChecklistItem` data class with id, text, isChecked, order
|
||||
- `Note.kt` extended with `noteType` and `checklistItems` fields
|
||||
|
||||
- **🔄 Sync Protocol v1.4.0**
|
||||
- JSON format updated to include checklist fields
|
||||
- Full backward compatibility with v1.3.x notes
|
||||
- Robust JSON parsing with manual field extraction
|
||||
|
||||
---
|
||||
|
||||
## [1.3.2] - 2026-01-10
|
||||
|
||||
### Changed
|
||||
- **🧹 Code-Qualität: "Clean Slate" Release**
|
||||
- Alle einfachen Lint-Issues behoben (Phase 1-7 des Cleanup-Plans)
|
||||
- Unused Imports und Members entfernt
|
||||
- Magic Numbers durch benannte Konstanten ersetzt
|
||||
- SwallowedExceptions mit Logger.w() versehen
|
||||
- MaxLineLength-Verstöße reformatiert
|
||||
- ConstructorParameterNaming (snake_case → camelCase mit @SerializedName)
|
||||
- Custom Exceptions: SyncException.kt und ValidationException.kt erstellt
|
||||
|
||||
### Added
|
||||
- **📝 F-Droid Privacy Notice**
|
||||
- Datenschutz-Hinweis für die Datei-Logging-Funktion
|
||||
- Erklärt dass Logs nur lokal gespeichert werden
|
||||
- Erfüllt F-Droid Opt-in Consent-Anforderungen
|
||||
|
||||
### Technical Improvements
|
||||
- **⚡ Neue Konstanten für bessere Wartbarkeit**
|
||||
- `SYNC_COMPLETED_DELAY_MS`, `ERROR_DISPLAY_DELAY_MS` (MainActivity)
|
||||
- `CONNECTION_TIMEOUT_MS` (SettingsActivity)
|
||||
- `SOCKET_TIMEOUT_MS`, `MAX_FILENAME_LENGTH`, `ETAG_PREVIEW_LENGTH` (WebDavSyncService)
|
||||
- `AUTO_CANCEL_TIMEOUT_MS` (NotificationHelper)
|
||||
- RFC 1918 IP-Range Konstanten (UrlValidator)
|
||||
- `DAYS_THRESHOLD`, `TRUNCATE_SUFFIX_LENGTH` (Extensions)
|
||||
|
||||
- **🔒 @Suppress Annotations für legitime Patterns**
|
||||
- ReturnCount: Frühe Returns für Validierung sind idiomatisch
|
||||
- LoopWithTooManyJumpStatements: Komplexe Sync-Logik dokumentiert
|
||||
|
||||
### Notes
|
||||
- Komplexe Refactorings (LargeClass, LongMethod) für v1.3.3+ geplant
|
||||
- Deprecation-Warnungen (LocalBroadcastManager, ProgressDialog) bleiben bestehen
|
||||
|
||||
---
|
||||
|
||||
## [1.3.1] - 2026-01-08
|
||||
|
||||
### Fixed
|
||||
- **🔧 Multi-Device JSON Sync (Danke an Thomas aus Bielefeld)**
|
||||
- JSON-Dateien werden jetzt korrekt zwischen Geräten synchronisiert
|
||||
- Funktioniert auch ohne aktiviertes Markdown
|
||||
- Hybrid-Optimierung: Server-Timestamp (Primary) + E-Tag (Secondary) Checks
|
||||
- E-Tag wird nach Upload gecached um Re-Download zu vermeiden
|
||||
|
||||
### Performance Improvements
|
||||
- **⚡ JSON Sync Performance-Parität**
|
||||
- JSON-Sync erreicht jetzt gleiche Performance wie Markdown (~2-3 Sekunden)
|
||||
- Timestamp-basierte Skip-Logik für unveränderte Dateien (~500ms pro Datei gespart)
|
||||
- E-Tag-Matching als Fallback für Dateien die seit letztem Sync modifiziert wurden
|
||||
- **Beispiel:** 24 Dateien von 12-14s auf ~2.7s reduziert (keine Änderungen)
|
||||
|
||||
- **⏭️ Skip unveränderte Dateien** (Haupt-Performance-Fix!)
|
||||
- JSON-Dateien: Überspringt alle Notizen, die seit letztem Sync nicht geändert wurden
|
||||
- Markdown-Dateien: Überspringt unveränderte MD-Dateien basierend auf Server-Timestamp
|
||||
- **Spart ~500ms pro Datei** bei Nextcloud (~20 Dateien = 10 Sekunden gespart!)
|
||||
- Von 21 Sekunden Sync-Zeit auf 2-3 Sekunden reduziert
|
||||
|
||||
- **⚡ Session-Caching für WebDAV**
|
||||
- Sardine-Client wird pro Sync-Session wiederverwendet (~600ms gespart)
|
||||
- WiFi-IP-Adresse wird gecacht statt bei jeder Anfrage neu ermittelt (~300ms gespart)
|
||||
- `/notes/` Ordner-Existenz wird nur einmal pro Sync geprüft (~500ms gespart)
|
||||
- **Gesamt: ~1.4 Sekunden zusätzlich gespart**
|
||||
|
||||
- **📝 Content-basierte Markdown-Erkennung**
|
||||
- Extern bearbeitete Markdown-Dateien werden auch erkannt wenn YAML-Timestamp nicht aktualisiert wurde
|
||||
- Löst das Problem: Obsidian/Texteditor-Änderungen wurden nicht importiert
|
||||
- Hybridansatz: Erst Timestamp-Check (schnell), dann Content-Vergleich (zuverlässig)
|
||||
|
||||
### Added
|
||||
- **🔄 Sync-Status-Anzeige (UI)**
|
||||
- Sichtbares Banner "Synchronisiere..." mit ProgressBar während Sync läuft
|
||||
- Sync-Button und Pull-to-Refresh werden deaktiviert während Sync aktiv
|
||||
- Verhindert versehentliche Doppel-Syncs durch visuelle Rückmeldung
|
||||
- Auch in Einstellungen: "Jetzt synchronisieren" Button wird deaktiviert
|
||||
|
||||
### Fixed
|
||||
- **🔧 Sync-Mutex verhindert doppelte Syncs**
|
||||
- Keine doppelten Toast-Nachrichten mehr bei schnellem Pull-to-Refresh
|
||||
- Concurrent Sync-Requests werden korrekt blockiert
|
||||
|
||||
- **🐛 Lint-Fehler behoben**
|
||||
- `View.generateViewId()` statt hardcodierte IDs in RadioButtons
|
||||
- `app:tint` statt `android:tint` für AppCompat-Kompatibilität
|
||||
|
||||
### Added
|
||||
- **🔍 detekt Code-Analyse**
|
||||
- Statische Code-Analyse mit detekt 1.23.4 integriert
|
||||
- Pragmatische Konfiguration für Sync-intensive Codebasis
|
||||
- 91 Issues identifiziert (als Baseline für v1.4.0)
|
||||
|
||||
- **🏗️ Debug Build mit separatem Package**
|
||||
- Debug-APK kann parallel zur Release-Version installiert werden
|
||||
- Package: `dev.dettmer.simplenotes.debug` (Debug) vs `dev.dettmer.simplenotes` (Release)
|
||||
- App-Name zeigt "Simple Notes (Debug)" für einfache Unterscheidung
|
||||
|
||||
- **📊 Debug-Logging UI**
|
||||
- Neuer "Debug Log" Button in Einstellungen → Erweitert
|
||||
- Zeigt letzte Sync-Logs mit Zeitstempeln
|
||||
- Export-Funktion für Fehlerberichte
|
||||
|
||||
### Technical
|
||||
- `WebDavSyncService`: Hybrid-Optimierung für JSON-Downloads (Timestamp PRIMARY, E-Tag SECONDARY)
|
||||
- `WebDavSyncService`: E-Tag refresh nach Upload statt Invalidierung (verhindert Re-Download)
|
||||
- E-Tag Caching: `SharedPreferences` mit Key-Pattern `etag_json_{noteId}`
|
||||
- Skip-Logik: `if (serverModified <= lastSync) skip` → ~1ms pro Datei
|
||||
- Fallback E-Tag: `if (serverETag == cachedETag) skip` → für Dateien modifiziert nach lastSync
|
||||
- PROPFIND nach PUT: Fetch E-Tag nach Upload für korrektes Caching
|
||||
- `SyncStateManager`: Neuer Singleton mit `StateFlow<Boolean>` für Sync-Status
|
||||
- `MainActivity`: Observer auf `SyncStateManager.isSyncing` für UI-Updates
|
||||
- Layout: `sync_status_banner` mit `ProgressBar` + `TextView`
|
||||
- `WebDavSyncService`: Skip-Logik für unveränderte JSON/MD Dateien basierend auf `lastSyncTimestamp`
|
||||
- `WebDavSyncService`: Neue Session-Cache-Variablen (`sessionSardine`, `sessionWifiAddress`, `notesDirEnsured`)
|
||||
- `getOrCreateSardine()`: Cached Sardine-Client mit automatischer Credentials-Konfiguration
|
||||
- `getOrCacheWiFiAddress()`: WiFi-Adresse wird nur einmal pro Sync ermittelt
|
||||
- `clearSessionCache()`: Aufräumen am Ende jeder Sync-Session
|
||||
- `ensureNotesDirectoryExists()`: Cached Directory-Check
|
||||
- Content-basierter Import: Vergleicht MD-Content mit lokaler Note wenn Timestamps gleich
|
||||
- Build-Tooling: detekt aktiviert, ktlint vorbereitet (deaktiviert wegen Parser-Problemen)
|
||||
- Debug BuildType: `applicationIdSuffix = ".debug"`, `versionNameSuffix = "-debug"`
|
||||
|
||||
---
|
||||
|
||||
## [1.3.0] - 2026-01-07
|
||||
|
||||
### Added
|
||||
- **🚀 Multi-Device Sync** (Thanks to Thomas from Bielefeld for reporting!)
|
||||
- Automatic download of new notes from other devices
|
||||
- Deletion tracking prevents "zombie notes" (deleted notes don't come back)
|
||||
- Smart cleanup: Re-created notes (newer timestamp) are downloaded
|
||||
- Works with all devices: v1.2.0, v1.2.1, v1.2.2, and v1.3.0
|
||||
|
||||
- **🗑️ Server Deletion via Swipe Gesture**
|
||||
- Swipe left on notes to delete from server (requires confirmation)
|
||||
- Prevents duplicate notes on other devices
|
||||
- Works with deletion tracking system
|
||||
- Material Design confirmation dialog
|
||||
|
||||
- **⚡ E-Tag Performance Optimization**
|
||||
- Smart server checking with E-Tag caching (~150ms vs 3000ms for "no changes")
|
||||
- 20x faster when server has no updates
|
||||
- E-Tag hybrid approach: E-Tag for JSON (fast), timestamp for Markdown (reliable)
|
||||
- Battery-friendly with minimal server requests
|
||||
|
||||
- **📥 Markdown Auto-Sync Toggle**
|
||||
- NEW: Unified Auto-Sync toggle in Settings (replaces separate Export/Auto-Import toggles)
|
||||
- When enabled: Notes export to Markdown AND import changes automatically
|
||||
- When disabled: Manual sync button appears for on-demand synchronization
|
||||
- Performance: Auto-Sync OFF = 0ms overhead
|
||||
|
||||
- **🔘 Manual Markdown Sync Button**
|
||||
- Manual sync button for performance-conscious users
|
||||
- Shows import/export counts after completion
|
||||
- Only visible when Auto-Sync is disabled
|
||||
- On-demand synchronization (~150-200ms only when triggered)
|
||||
|
||||
- **⚙️ Server-Restore Modes**
|
||||
- MERGE: Keep local notes + add server notes
|
||||
- REPLACE: Delete all local + download from server
|
||||
- OVERWRITE: Update duplicates, keep non-duplicates
|
||||
- Restore modes now work correctly for WebDAV restore
|
||||
|
||||
### Technical
|
||||
- New `DeletionTracker` model with JSON persistence
|
||||
- `NotesStorage`: Added deletion tracking methods
|
||||
- `WebDavSyncService.hasUnsyncedChanges()`: Intelligent server checks with E-Tag caching
|
||||
- `WebDavSyncService.downloadRemoteNotes()`: Deletion-aware downloads
|
||||
- `WebDavSyncService.restoreFromServer()`: Support for restore modes
|
||||
- `WebDavSyncService.deleteNoteFromServer()`: Server deletion with YAML frontmatter scanning
|
||||
- `WebDavSyncService.importMarkdownFiles()`: Automatic Markdown import during sync
|
||||
- `WebDavSyncService.manualMarkdownSync()`: Manual sync with result counts
|
||||
- `MainActivity.setupSwipeToDelete()`: Two-stage swipe deletion with confirmation
|
||||
- E-Tag caching in SharedPreferences for performance
|
||||
|
||||
---
|
||||
|
||||
## [1.2.2] - 2026-01-06
|
||||
|
||||
### Fixed
|
||||
- **Backward Compatibility for v1.2.0 Users (Critical)**
|
||||
- App now reads BOTH old (Root) AND new (`/notes/`) folder structures
|
||||
- Users upgrading from v1.2.0 no longer lose their existing notes
|
||||
- Server-Restore now finds notes from v1.2.0 stored in Root folder
|
||||
- Automatic deduplication prevents loading the same note twice
|
||||
- Graceful error handling if Root folder is not accessible
|
||||
|
||||
### Technical
|
||||
- `WebDavSyncService.downloadRemoteNotes()` - Dual-mode download (Root + /notes/)
|
||||
- `WebDavSyncService.restoreFromServer()` - Now uses dual-mode download
|
||||
- Migration happens naturally: new uploads go to `/notes/`, old notes stay readable
|
||||
|
||||
---
|
||||
|
||||
## [1.2.1] - 2026-01-05
|
||||
|
||||
### Fixed
|
||||
- **Markdown Initial Export Bugfix**
|
||||
- Existing notes are now exported as Markdown when Desktop Integration is activated
|
||||
- Previously, only new notes created after activation were exported
|
||||
- Progress dialog shows export status with current/total counter
|
||||
- Error handling for network issues during export
|
||||
- Individual note failures don't abort the entire export
|
||||
|
||||
- **Markdown Directory Structure Fix**
|
||||
- Markdown files now correctly land in `/notes-md/` folder
|
||||
- Smart URL detection supports both Root-URL and `/notes` URL structures
|
||||
- Previously, MD files were incorrectly placed in the root directory
|
||||
- Markdown import now finds files correctly
|
||||
|
||||
- **JSON URL Normalization**
|
||||
- Simplified server configuration: enter only base URL (e.g., `http://server:8080/`)
|
||||
- App automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown
|
||||
- Smart detection: both `http://server:8080/` and `http://server:8080/notes/` work correctly
|
||||
- Backward compatible: existing setups with `/notes` in URL continue to work
|
||||
- No migration required for existing users
|
||||
|
||||
### Changed
|
||||
- **Markdown Directory Creation**
|
||||
- `notes-md/` folder is now created on first sync (regardless of Desktop Integration setting)
|
||||
- Prevents 404 errors when mounting WebDAV folder
|
||||
- Better user experience: folder is visible before enabling the feature
|
||||
|
||||
- **Settings UI Improvements**
|
||||
- Updated example URL from `/webdav` to `/notes` to match app behavior
|
||||
- Example now shows: `http://192.168.0.188:8080/notes`
|
||||
|
||||
### Technical
|
||||
- `WebDavSyncService.ensureMarkdownDirectoryExists()` - Creates MD folder early
|
||||
- `WebDavSyncService.getMarkdownUrl()` - Smart URL detection for both structures
|
||||
- `WebDavSyncService.exportAllNotesToMarkdown()` - Exports all local notes with progress callback
|
||||
- `SettingsActivity.onMarkdownExportToggled()` - Triggers initial export with ProgressDialog
|
||||
|
||||
---
|
||||
|
||||
## [1.2.0] - 2026-01-04
|
||||
|
||||
### Added
|
||||
- **Local Backup System**
|
||||
- Export all notes as JSON file to any location (Downloads, SD card, cloud folder)
|
||||
- Import backup with 3 modes: Merge, Replace, or Overwrite duplicates
|
||||
- Automatic safety backup created before every restore
|
||||
- Backup validation (format and version check)
|
||||
|
||||
- **Markdown Desktop Integration**
|
||||
- Optional Markdown export parallel to JSON sync
|
||||
- `.md` files synced to `notes-md/` folder on WebDAV
|
||||
- YAML frontmatter with `id`, `created`, `updated`, `device`
|
||||
- Manual import button to pull Markdown changes from server
|
||||
- Last-Write-Wins conflict resolution via timestamps
|
||||
|
||||
- **Settings UI Extensions**
|
||||
- New "Backup & Restore" section with local + server restore
|
||||
- New "Desktop Integration" section with Markdown toggle
|
||||
- Universal restore dialog with radio button mode selection
|
||||
|
||||
### Changed
|
||||
- **Server Restore Behavior**: Users now choose restore mode (Merge/Replace/Overwrite) instead of hard-coded replace-all
|
||||
|
||||
### Technical
|
||||
- `BackupManager.kt` - Complete backup/restore logic
|
||||
- `Note.toMarkdown()` / `Note.fromMarkdown()` - Markdown conversion with YAML frontmatter
|
||||
- `WebDavSyncService` - Extended for dual-format sync (JSON master + Markdown mirror)
|
||||
- ISO8601 timestamp formatting for desktop compatibility
|
||||
- Filename sanitization for safe Markdown file names
|
||||
|
||||
### Documentation
|
||||
- Added WebDAV mount instructions (Windows, macOS, Linux)
|
||||
- Complete sync architecture documentation
|
||||
- Desktop integration analysis
|
||||
|
||||
---
|
||||
|
||||
## [1.1.2] - 2025-12-28
|
||||
|
||||
### Fixed
|
||||
- **"Job was cancelled" Error**
|
||||
- Fixed coroutine cancellation in sync worker
|
||||
- Proper error handling for interrupted syncs
|
||||
|
||||
- **UI Improvements**
|
||||
- Back arrow instead of X in note editor (better UX)
|
||||
- Pull-to-refresh for manual sync trigger
|
||||
- HTTP/HTTPS protocol selection with radio buttons
|
||||
- Inline error display (no toast spam)
|
||||
|
||||
- **Performance & Battery**
|
||||
- Sync only on actual changes (saves battery)
|
||||
- Auto-save notifications removed
|
||||
- 24-hour server offline warning instead of instant error
|
||||
|
||||
### Changed
|
||||
- Settings grouped into "Auto-Sync" and "Sync Interval" sections
|
||||
- HTTP only allowed for local networks (RFC 1918 IPs)
|
||||
- Swipe-to-delete without UI flicker
|
||||
|
||||
---
|
||||
|
||||
## [1.1.1] - 2025-12-27
|
||||
|
||||
### Fixed
|
||||
- **WiFi Connect Sync**
|
||||
- No error notifications in foreign WiFi networks
|
||||
- Server reachability check before sync (2s timeout)
|
||||
- Silent abort when server offline
|
||||
- Pre-check waits until network is ready
|
||||
- No errors during network initialization
|
||||
|
||||
### Changed
|
||||
- **Notifications**
|
||||
- Old sync notifications cleared on app start
|
||||
- Error notifications auto-dismiss after 30 seconds
|
||||
|
||||
### UI
|
||||
- Sync icon only shown when sync is configured
|
||||
- Swipe-to-delete without flicker
|
||||
- Scroll to top after saving note
|
||||
|
||||
### Technical
|
||||
- Server check with 2-second timeout before sync attempts
|
||||
- Network readiness check in WiFi connect trigger
|
||||
- Notification cleanup on MainActivity.onCreate()
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] - 2025-12-26
|
||||
|
||||
### Added
|
||||
- **Configurable Sync Intervals**
|
||||
- User choice: 15, 30, or 60 minutes
|
||||
- Real-world battery impact displayed (15min: ~0.8%/day, 30min: ~0.4%/day, 60min: ~0.2%/day)
|
||||
- Radio button selection in settings
|
||||
- Doze Mode optimization (syncs batched in maintenance windows)
|
||||
|
||||
- **About Section**
|
||||
- App version from BuildConfig
|
||||
- Links to GitHub repository and developer profile
|
||||
- MIT license information
|
||||
- Material 3 card design
|
||||
|
||||
### Changed
|
||||
- Settings UI redesigned with grouped sections
|
||||
- Periodic sync updated dynamically when interval changes
|
||||
- WorkManager uses selected interval for background sync
|
||||
|
||||
### Removed
|
||||
- Debug/Logs section from settings (cleaner UI)
|
||||
|
||||
### Technical
|
||||
- `PREF_SYNC_INTERVAL_MINUTES` preference key
|
||||
- NetworkMonitor reads interval from SharedPreferences
|
||||
- `ExistingPeriodicWorkPolicy.UPDATE` for live interval changes
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2025-12-25
|
||||
|
||||
### Added
|
||||
- Initial release
|
||||
- WebDAV synchronization
|
||||
- Note creation, editing, deletion
|
||||
- 6 sync triggers:
|
||||
- Periodic sync (configurable interval)
|
||||
- App start sync
|
||||
- WiFi connect sync
|
||||
- Manual sync (menu button)
|
||||
- Pull-to-refresh
|
||||
- Settings "Sync Now" button
|
||||
- Material 3 design
|
||||
- Light/Dark theme support
|
||||
- F-Droid compatible (100% FOSS)
|
||||
|
||||
---
|
||||
|
||||
[1.8.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.8.1
|
||||
[1.8.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.8.0
|
||||
[1.7.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.2
|
||||
[1.7.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.1
|
||||
[1.7.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.0
|
||||
[1.6.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.6.1
|
||||
[1.6.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.6.0
|
||||
[1.5.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.5.0
|
||||
[1.4.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.4.1
|
||||
[1.4.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.4.0
|
||||
[1.3.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.2
|
||||
[1.3.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.1
|
||||
[1.3.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.0
|
||||
[1.2.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.2
|
||||
[1.2.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.1
|
||||
[1.2.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.0
|
||||
[1.1.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.2
|
||||
[1.1.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.1
|
||||
[1.1.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.0
|
||||
[1.0.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.0.0
|
||||
989
CHANGELOG.md
Normal file
989
CHANGELOG.md
Normal file
@@ -0,0 +1,989 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Simple Notes Sync will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
**🌍 Languages:** [Deutsch](CHANGELOG.de.md) · **English**
|
||||
|
||||
---
|
||||
|
||||
## [1.8.1] - 2026-02-11
|
||||
|
||||
### 🛠️ Bugfix & Polish Release
|
||||
|
||||
Checklist fixes, widget improvements, sync hardening, and code quality cleanup.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
**Checklist Sort Persistence** ([7dbc06d](https://github.com/inventory69/simple-notes-sync/commit/7dbc06d))
|
||||
- Fixed sort option not applied when reopening a checklist
|
||||
- Root cause: `sortChecklistItems()` always sorted unchecked-first instead of reading `_lastChecklistSortOption`
|
||||
- Now correctly restores all sort modes (Manual, Alphabetical, Unchecked/Checked First)
|
||||
|
||||
**Widget Scroll on Standard Size** ([c72b3fe](https://github.com/inventory69/simple-notes-sync/commit/c72b3fe))
|
||||
- Fixed scroll not working on standard 3×2 widget size (110–150dp height)
|
||||
- Added `NARROW_SCROLL` and `WIDE_SCROLL` size classes with 150dp threshold
|
||||
- Removed `clickable` modifier from unlocked checklists to enable scrolling
|
||||
|
||||
**Auto-Sync Toast Removed** ([fe6935a](https://github.com/inventory69/simple-notes-sync/commit/fe6935a))
|
||||
- Removed unexpected toast notification on automatic background sync
|
||||
- Silent auto-sync stays silent; only errors are shown
|
||||
|
||||
**Gradient & Drag Regression** ([24fe32a](https://github.com/inventory69/simple-notes-sync/commit/24fe32a))
|
||||
- Fixed gradient overlay regression on long checklist items
|
||||
- Fixed drag-and-drop flicker when moving items between boundaries
|
||||
|
||||
### 🆕 New Features
|
||||
|
||||
**Widget Checklist Sorting & Separators** ([66d98c0](https://github.com/inventory69/simple-notes-sync/commit/66d98c0))
|
||||
- Widgets now apply saved sort option from the editor
|
||||
- Visual separator between unchecked/checked items (MANUAL & UNCHECKED_FIRST modes)
|
||||
- Auto-sort when toggling checkboxes in the widget
|
||||
- Changed ✅ → ☑️ emoji for checked items
|
||||
|
||||
**Checklist Preview Sorting** ([2c43b47](https://github.com/inventory69/simple-notes-sync/commit/2c43b47))
|
||||
- Main screen preview (NoteCard, NoteCardCompact, NoteCardGrid) now respects saved sort option
|
||||
- New `ChecklistPreviewHelper` with shared sorting logic
|
||||
|
||||
**Auto-Scroll on Line Wrap** ([3e4b1bd](https://github.com/inventory69/simple-notes-sync/commit/3e4b1bd))
|
||||
- Checklist editor auto-scrolls when typing causes text to wrap to a new line
|
||||
- Keeps cursor visible at bottom of list during editing
|
||||
|
||||
**Separator Drag Cross-Boundary** ([7b55811](https://github.com/inventory69/simple-notes-sync/commit/7b55811))
|
||||
- Drag-and-drop now works across the checked/unchecked separator
|
||||
- Items auto-toggle their checked state when dragged across boundaries
|
||||
- Extracted `DraggableChecklistItem` composable for reusability
|
||||
|
||||
### 🔄 Improvements
|
||||
|
||||
**Sync Rate-Limiting & Battery Protection** ([ffe0e46](https://github.com/inventory69/simple-notes-sync/commit/ffe0e46), [a1a574a](https://github.com/inventory69/simple-notes-sync/commit/a1a574a))
|
||||
- Global 30-second cooldown between sync operations (auto/WiFi/periodic)
|
||||
- onSave syncs bypass global cooldown (retain own 5s throttle)
|
||||
- New `SyncStateManager` singleton for centralized state tracking
|
||||
- Prevents battery drain from rapid successive syncs
|
||||
|
||||
**Toast → Banner Migration** ([27e6b9d](https://github.com/inventory69/simple-notes-sync/commit/27e6b9d))
|
||||
- All non-interactive notifications migrated to unified Banner system
|
||||
- Server-delete results show as INFO/ERROR banners
|
||||
- Added INFO phase to SyncPhase enum with auto-hide (2.5s)
|
||||
- Snackbars with Undo actions remain unchanged
|
||||
|
||||
**ProGuard Rules Audit** ([6356173](https://github.com/inventory69/simple-notes-sync/commit/6356173))
|
||||
- Added missing keep rules for Widget ActionCallback classes
|
||||
- Added Compose-specific ProGuard rules
|
||||
- Prevents ClassNotFoundException in release builds
|
||||
|
||||
### 🧹 Code Quality
|
||||
|
||||
**Detekt Compliance** ([1a6617a](https://github.com/inventory69/simple-notes-sync/commit/1a6617a))
|
||||
- Resolved all 12 detekt findings (0 issues remaining)
|
||||
- Refactored `NoteEditorViewModel.loadNote()` to reduce nesting depth
|
||||
- Extracted constants for magic numbers in editor
|
||||
- Removed unused imports from `UpdateChangelogSheet`
|
||||
- Set `maxIssues: 0` in detekt config
|
||||
|
||||
---
|
||||
|
||||
## [1.8.0] - 2026-02-10
|
||||
|
||||
### 🚨 CRITICAL BUGFIX (Tag recreated)
|
||||
|
||||
**R8/ProGuard Obfuscation Fix - Prevents Data Loss**
|
||||
- 🔧 **CRITICAL:** Fixed incorrect ProGuard class path for `Note$Companion$NoteRaw`
|
||||
- Original v1.8.0 had specific `-keep` rules that didn't match
|
||||
- R8 obfuscated all NoteRaw fields (id→a, title→b, ...)
|
||||
- Gson couldn't parse JSON anymore → **ALL notes appeared lost**
|
||||
- Reverted to safe broad rule: `-keep class dev.dettmer.simplenotes.** { *; }`
|
||||
- 🛡️ Added safety-guards in `detectServerDeletions()`
|
||||
- Prevents mass deletion when `serverNoteIds` is empty (network errors)
|
||||
- Aborts if ALL local notes would be marked as deleted
|
||||
- ✅ Notes were never actually lost (JSON files intact on disk + server)
|
||||
- ✅ Downgrade to v1.7.2 restored all notes
|
||||
|
||||
**⚠️ If you installed original v1.8.0:** Your notes are safe! Just update.
|
||||
|
||||
### 🎉 Major: Widgets, Sorting & Advanced Sync
|
||||
|
||||
Complete widget system with interactive checklists, note sorting, and major sync improvements!
|
||||
|
||||
### 🆕 Homescreen Widgets
|
||||
|
||||
**Full Jetpack Glance Widget Framework** ([539987f](https://github.com/inventory69/simple-notes-sync/commit/539987f))
|
||||
- 5 responsive size classes (SMALL, NARROW_MED, NARROW_TALL, WIDE_MED, WIDE_TALL)
|
||||
- Interactive checklist checkboxes that sync immediately to server
|
||||
- Material You dynamic colors with configurable background opacity (0-100%)
|
||||
- Lock widget toggle to prevent accidental edits
|
||||
- Read-only mode with permanent options bar for locked widgets
|
||||
- Widget configuration activity with note selection and settings
|
||||
- Auto-refresh after sync completion
|
||||
- Tap content to open editor (unlocked) or show options (locked)
|
||||
- Complete resource cleanup fixes for connection leaks
|
||||
|
||||
**Widget State Management:**
|
||||
- NoteWidgetState keys for per-instance persistence via DataStore
|
||||
- Five top-level ActionCallbacks (Toggle Checkbox, Lock, Options, Refresh, Config)
|
||||
- Type-safe parameter passing with NoteWidgetActionKeys
|
||||
|
||||
### 📊 Note & Checklist Sorting
|
||||
|
||||
**Note Sorting** ([96c819b](https://github.com/inventory69/simple-notes-sync/commit/96c819b))
|
||||
- Sort by: Updated (newest/oldest), Created, Title (A-Z/Z-A), Type
|
||||
- Persistent sort preferences (saved in SharedPreferences)
|
||||
- Sort dialog in main screen with direction toggle
|
||||
- Combined sortedNotes StateFlow in MainViewModel
|
||||
|
||||
**Checklist Sorting** ([96c819b](https://github.com/inventory69/simple-notes-sync/commit/96c819b), [900dad7](https://github.com/inventory69/simple-notes-sync/commit/900dad7))
|
||||
- Sort by: Manual, Alphabetical, Unchecked First, Checked Last
|
||||
- Visual separator between unchecked/checked items with count display
|
||||
- Auto-sort on item toggle and reordering
|
||||
- Drag-only within same group (unchecked/checked)
|
||||
- Smooth fade/slide animations for item transitions
|
||||
- Unit tested with 9 test cases for sorting logic validation
|
||||
|
||||
### 🔄 Sync Improvements
|
||||
|
||||
**Server Deletion Detection** ([40d7c83](https://github.com/inventory69/simple-notes-sync/commit/40d7c83), [bf7a74e](https://github.com/inventory69/simple-notes-sync/commit/bf7a74e))
|
||||
- New `DELETED_ON_SERVER` sync status for multi-device scenarios
|
||||
- Detects when notes are deleted on other clients
|
||||
- Zero performance impact (uses existing PROPFIND data)
|
||||
- Deletion count shown in sync banner: "3 synced · 2 deleted on server"
|
||||
- Edited deleted notes automatically re-upload to server (status → PENDING)
|
||||
|
||||
**Sync Status Legend** ([07607fc](https://github.com/inventory69/simple-notes-sync/commit/07607fc))
|
||||
- Help button (?) in main screen TopAppBar
|
||||
- Dialog explaining all 5 sync status icons with descriptions
|
||||
- Only visible when sync is configured
|
||||
|
||||
**Live Sync Progress UI** ([df37d2a](https://github.com/inventory69/simple-notes-sync/commit/df37d2a))
|
||||
- Real-time phase indicators: PREPARING, UPLOADING, DOWNLOADING, IMPORTING_MARKDOWN
|
||||
- Upload progress shows x/y counter (known total)
|
||||
- Download progress shows count (unknown total)
|
||||
- Single unified SyncProgressBanner (replaces dual system)
|
||||
- Auto-hide: COMPLETED (2s), ERROR (4s)
|
||||
- No misleading counters when nothing to sync
|
||||
- Silent auto-sync stays silent, errors always shown
|
||||
|
||||
**Parallel Downloads** ([bdfc0bf](https://github.com/inventory69/simple-notes-sync/commit/bdfc0bf))
|
||||
- Configurable concurrent downloads (default: 3 simultaneous)
|
||||
- Kotlin coroutines async/awaitAll pattern
|
||||
- Individual download timeout handling
|
||||
- Graceful sequential fallback on concurrent errors
|
||||
- Optimized network utilization for faster sync
|
||||
|
||||
### ✨ UX Improvements
|
||||
|
||||
**Checklist Enhancements:**
|
||||
- Overflow gradient for long text items ([3462f93](https://github.com/inventory69/simple-notes-sync/commit/3462f93))
|
||||
- Auto-expand on focus, collapse to 5 lines when unfocused
|
||||
- Drag & Drop flicker fix with straddle-target-center detection ([538a705](https://github.com/inventory69/simple-notes-sync/commit/538a705))
|
||||
- Adjacency filter prevents item jumps during fast drag
|
||||
- Race-condition fix for scroll + move operations
|
||||
|
||||
**Settings UI Polish:**
|
||||
- Smooth language switching without activity recreate ([881c0fd](https://github.com/inventory69/simple-notes-sync/commit/881c0fd))
|
||||
- Grid view as default for new installations ([6858446](https://github.com/inventory69/simple-notes-sync/commit/6858446))
|
||||
- Sync settings restructured into clear sections: Triggers & Performance ([eaac5a0](https://github.com/inventory69/simple-notes-sync/commit/eaac5a0))
|
||||
- Changelog link added to About screen ([49810ff](https://github.com/inventory69/simple-notes-sync/commit/49810ff))
|
||||
|
||||
**Post-Update Changelog Dialog** ([661d9e0](https://github.com/inventory69/simple-notes-sync/commit/661d9e0))
|
||||
- Shows localized changelog on first launch after update
|
||||
- Material 3 ModalBottomSheet with slide-up animation
|
||||
- Loads F-Droid changelogs via assets (single source of truth)
|
||||
- One-time display per versionCode (stored in SharedPreferences)
|
||||
- Clickable GitHub link for full changelog
|
||||
- Dismissable via button or swipe gesture
|
||||
- Test mode in Debug Settings with reset option
|
||||
|
||||
**Backup Settings Improvements** ([3e946ed](https://github.com/inventory69/simple-notes-sync/commit/3e946ed))
|
||||
- New BackupProgressCard with LinearProgressIndicator
|
||||
- 3-phase status system: In Progress → Completion → Clear
|
||||
- Success status shown for 2s, errors for 3s
|
||||
- Removed redundant toast messages
|
||||
- Buttons stay visible and disabled during operations
|
||||
- Exception logging for better error tracking
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
**Widget Text Display** ([d045d4d](https://github.com/inventory69/simple-notes-sync/commit/d045d4d))
|
||||
- Fixed text notes showing only 3 lines in widgets
|
||||
- Changed from paragraph-based to line-based rendering
|
||||
- LazyColumn now properly scrolls through all content
|
||||
- Empty lines preserved as 8dp spacers
|
||||
- Preview limits increased: compact 100→120, full 200→300 chars
|
||||
|
||||
### 🔧 Code Quality
|
||||
|
||||
**Detekt Cleanup** ([1da1a63](https://github.com/inventory69/simple-notes-sync/commit/1da1a63))
|
||||
- Resolved all 22 Detekt warnings
|
||||
- Removed 7 unused imports
|
||||
- Defined constants for 5 magic numbers
|
||||
- Optimized state reads with derivedStateOf
|
||||
- Build: 0 Lint errors + 0 Detekt warnings
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- Complete implementation plans for all 23 v1.8.0 features
|
||||
- Widget system architecture and state management docs
|
||||
- Sorting logic unit tests with edge case coverage
|
||||
- F-Droid changelogs (English + German)
|
||||
|
||||
---
|
||||
|
||||
## [1.7.2] - 2026-02-04
|
||||
|
||||
### 🐛 Critical Bug Fixes
|
||||
|
||||
#### JSON/Markdown Timestamp Sync
|
||||
|
||||
**Problem:** External editors (Obsidian, Typora, VS Code, custom editors) update Markdown content but don't update YAML `updated:` timestamp, causing the Android app to skip changes.
|
||||
|
||||
**Solution:**
|
||||
- Server file modification time (`mtime`) is now used as source of truth instead of YAML timestamp
|
||||
- Content changes detected via hash comparison
|
||||
- Notes marked as `PENDING` after Markdown import → JSON automatically re-uploaded on next sync
|
||||
- Fixes sorting issues after external edits
|
||||
|
||||
#### SyncStatus on Server Always PENDING
|
||||
|
||||
**Problem:** All JSON files on server contained `"syncStatus": "PENDING"` even after successful sync, confusing external clients.
|
||||
|
||||
**Solution:**
|
||||
- Status is now set to `SYNCED` **before** JSON serialization
|
||||
- Server and local copies are now consistent
|
||||
- External web/Tauri editors can correctly interpret sync state
|
||||
|
||||
#### Deletion Tracker Race Condition
|
||||
|
||||
**Problem:** Batch deletes could lose deletion records due to concurrent file access.
|
||||
|
||||
**Solution:**
|
||||
- Mutex-based synchronization for deletion tracking
|
||||
- New `trackDeletionSafe()` function prevents race conditions
|
||||
- Guarantees zombie note prevention even with rapid deletes
|
||||
|
||||
#### ISO8601 Timezone Parsing
|
||||
|
||||
**Problem:** Markdown imports failed with timezone offsets like `+01:00` or `-05:00`.
|
||||
|
||||
**Solution:**
|
||||
- Multi-format ISO8601 parser with fallback chain
|
||||
- Supports UTC (Z), timezone offsets (+01:00, +0100), and milliseconds
|
||||
- Compatible with Obsidian, Typora, VS Code timestamps
|
||||
|
||||
### ⚡ Performance Improvements
|
||||
|
||||
#### E-Tag Batch Caching
|
||||
|
||||
- E-Tags are now written in single batch operation instead of N individual writes
|
||||
- Performance gain: ~50-100ms per sync with multiple notes
|
||||
- Reduced disk I/O operations
|
||||
|
||||
#### Memory Leak Prevention
|
||||
|
||||
- `SafeSardineWrapper` now implements `Closeable` for explicit resource cleanup
|
||||
- HTTP connection pool is properly evicted after sync
|
||||
- Prevents socket exhaustion during frequent syncs
|
||||
|
||||
### 🔧 Technical Details
|
||||
|
||||
- **IMPL_001:** `kotlinx.coroutines.sync.Mutex` for thread-safe deletion tracking
|
||||
- **IMPL_002:** Pattern-based ISO8601 parser with 8 format variants
|
||||
- **IMPL_003:** Connection pool eviction + dispatcher shutdown in `close()`
|
||||
- **IMPL_004:** Batch `SharedPreferences.Editor` updates
|
||||
- **IMPL_014:** Server `mtime` parameter in `Note.fromMarkdown()`
|
||||
- **IMPL_015:** `syncStatus` set before `toJson()` call
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- External Editor Specification for web/Tauri editor developers
|
||||
- Detailed implementation documentation for all bugfixes
|
||||
|
||||
---
|
||||
|
||||
## [1.7.1] - 2026-02-02
|
||||
|
||||
### 🐛 Critical Bug Fixes
|
||||
|
||||
#### Android 9 App Crash Fix ([#15](https://github.com/inventory69/simple-notes-sync/issues/15))
|
||||
|
||||
**Problem:** App crashed on Android 9 (API 28) when using WorkManager Expedited Work for background sync.
|
||||
|
||||
**Root Cause:** When `setExpedited()` is used in WorkManager, the `CoroutineWorker` must implement `getForegroundInfo()` to return a Foreground Service notification. On Android 9-11, WorkManager calls this method, but the default implementation throws `IllegalStateException: Not implemented`.
|
||||
|
||||
**Solution:** Implemented `getForegroundInfo()` in `SyncWorker` to return a proper `ForegroundInfo` with sync progress notification.
|
||||
|
||||
**Details:**
|
||||
- Added `ForegroundInfo` with sync progress notification for Android 9-11
|
||||
- Android 10+: Sets `FOREGROUND_SERVICE_TYPE_DATA_SYNC` for proper service typing
|
||||
- Added Foreground Service permissions to AndroidManifest.xml
|
||||
- Notification shows sync progress with indeterminate progress bar
|
||||
- Thanks to [@roughnecks](https://github.com/roughnecks) for the detailed debugging!
|
||||
|
||||
#### VPN Compatibility Fix ([#11](https://github.com/inventory69/simple-notes-sync/issues/11))
|
||||
|
||||
- WiFi socket binding now correctly detects Wireguard VPN interfaces (tun*, wg*, *-wg-*)
|
||||
- Traffic routes through VPN tunnel instead of bypassing it directly to WiFi
|
||||
- Fixes "Connection timeout" when syncing to external servers via VPN
|
||||
|
||||
### 🔧 Technical Changes
|
||||
|
||||
- New `SafeSardineWrapper` class ensures proper HTTP connection cleanup
|
||||
- Reduced unnecessary 401 authentication challenges with preemptive auth headers
|
||||
- Added ProGuard rule to suppress harmless TextInclusionStrategy warnings on older Android versions
|
||||
- VPN interface detection via `NetworkInterface.getNetworkInterfaces()` pattern matching
|
||||
- Foreground Service detection and notification system for background sync tasks
|
||||
|
||||
### 🌍 Localization
|
||||
|
||||
- Fixed hardcoded German error messages - now uses string resources for proper localization
|
||||
- Added German and English strings for sync progress notifications
|
||||
|
||||
---
|
||||
|
||||
## [1.7.0] - 2026-01-26
|
||||
|
||||
### 🎉 Major: Grid View, WiFi-Only Sync & VPN Support
|
||||
|
||||
Pinterest-style grid, WiFi-only sync mode, and proper VPN support!
|
||||
|
||||
### 🎨 Grid Layout
|
||||
|
||||
- Pinterest-style staggered grid without gaps
|
||||
- Consistent 12dp spacing between cards
|
||||
- Scroll position preserved when returning from settings
|
||||
- New unified `NoteCardGrid` with dynamic preview lines (3 small, 6 large)
|
||||
|
||||
### 📡 Sync Improvements
|
||||
|
||||
- **WiFi-only sync toggle** - Sync only when connected to WiFi
|
||||
- **VPN support** - Sync works correctly when VPN is active (traffic routes through VPN)
|
||||
- **Server change detection** - All notes reset to PENDING when server URL changes
|
||||
- **Faster server check** - Socket timeout reduced from 2s to 1s
|
||||
- **"Sync already running" feedback** - Shows snackbar when sync is in progress
|
||||
|
||||
### 🔒 Self-Signed SSL Support
|
||||
|
||||
- **Documentation added** - Guide for using self-signed certificates
|
||||
- Uses Android's built-in CA trust store
|
||||
- Works with ownCloud, Nextcloud, Synology, home servers
|
||||
|
||||
### 🔧 Technical
|
||||
|
||||
- `NoteCardGrid` component with dynamic maxLines
|
||||
- Removed FullLine spans for gapless layout
|
||||
- `resetAllSyncStatusToPending()` in NotesStorage
|
||||
- VPN detection in `getOrCacheWiFiAddress()`
|
||||
|
||||
---
|
||||
|
||||
## [1.6.1] - 2026-01-20
|
||||
|
||||
### 🧹 Code Quality & Build Improvements
|
||||
|
||||
- **detekt: 0 issues** - All 29 code quality issues resolved
|
||||
- Trivial fixes: Unused imports, MaxLineLength
|
||||
- File rename: DragDropState.kt → DragDropListState.kt
|
||||
- MagicNumbers → Constants (Dimensions.kt, SyncConstants.kt)
|
||||
- SwallowedException: Logger.w() added for better error tracking
|
||||
- LongParameterList: ChecklistEditorCallbacks data class created
|
||||
- LongMethod: ServerSettingsScreen split into components
|
||||
- @Suppress annotations for legacy code (WebDavSyncService, SettingsActivity)
|
||||
|
||||
- **Zero build warnings** - All 21 deprecation warnings eliminated
|
||||
- File-level @Suppress for deprecated imports
|
||||
- ProgressDialog, LocalBroadcastManager, AbstractSavedStateViewModelFactory
|
||||
- onActivityResult, onRequestPermissionsResult
|
||||
- Gradle Compose config cleaned up (StrongSkipping is now default)
|
||||
|
||||
- **ktlint reactivated** - Linting re-enabled with Compose-specific rules
|
||||
- .editorconfig created with Compose formatting rules
|
||||
- Legacy files excluded: WebDavSyncService.kt, build.gradle.kts
|
||||
- ignoreFailures=true for gradual migration
|
||||
|
||||
- **CI/CD improvements** - GitHub Actions lint checks integrated
|
||||
- detekt + ktlint + Android Lint run before build in pr-build-check.yml
|
||||
- Ensures code quality on every pull request
|
||||
|
||||
### 🔧 Technical Improvements
|
||||
|
||||
- **Constants refactoring** - Better code organization
|
||||
- ui/theme/Dimensions.kt: UI-related constants
|
||||
- utils/SyncConstants.kt: Sync operation constants
|
||||
|
||||
- **Preparation for v2.0.0** - Legacy code marked for removal
|
||||
- SettingsActivity and MainActivity (replaced by Compose versions)
|
||||
- All deprecated APIs documented with removal plan
|
||||
|
||||
---
|
||||
|
||||
## [1.6.0] - 2026-01-19
|
||||
|
||||
### 🎉 Major: Configurable Sync Triggers
|
||||
|
||||
Fine-grained control over when your notes sync - choose which triggers fit your workflow best!
|
||||
|
||||
### ⚙️ Sync Trigger System
|
||||
|
||||
- **Individual trigger control** - Enable/disable each sync trigger separately in settings
|
||||
- **5 Independent Triggers:**
|
||||
- **onSave Sync** - Sync immediately after saving a note (5s throttle)
|
||||
- **onResume Sync** - Sync when app is opened (60s throttle)
|
||||
- **WiFi-Connect Sync** - Sync when WiFi is connected
|
||||
- **Periodic Sync** - Background sync every 15/30/60 minutes (configurable)
|
||||
- **Boot Sync** - Start background sync after device restart
|
||||
|
||||
- **Smart Defaults** - Only event-driven triggers active by default (onSave, onResume, WiFi-Connect)
|
||||
- **Battery Optimized** - ~0.2%/day with defaults, up to ~1.0% with periodic sync enabled
|
||||
- **Offline Mode UI** - Grayed-out sync toggles when no server configured
|
||||
- **Dynamic Settings Subtitle** - Shows count of active triggers on main settings screen
|
||||
|
||||
### 🔧 Server Configuration Improvements
|
||||
|
||||
- **Offline Mode Toggle** - Disable all network features with one switch
|
||||
- **Split Protocol & Host** - Protocol (http/https) shown as non-editable prefix
|
||||
- **Clickable Settings Cards** - Full card clickable for better UX
|
||||
- **Clickable Toggle Rows** - Click text/icon to toggle switches (not just the switch itself)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Fixed:** Missing 5th sync trigger (Boot) in main settings screen subtitle count
|
||||
- **Various fixes** - UI improvements and stability enhancements
|
||||
|
||||
### 🔧 Technical Improvements
|
||||
|
||||
- **Reactive offline mode state** - StateFlow ensures UI updates correctly
|
||||
- **Separated server config checks** - `hasServerConfig()` vs `isServerConfigured()` (offline-aware)
|
||||
- **Improved constants** - All sync trigger keys and defaults in Constants.kt
|
||||
- **Better code organization** - Settings screens refactored for clarity
|
||||
|
||||
### Looking Ahead
|
||||
|
||||
> 🚀 **v1.7.0** will bring server folder checking and additional community features.
|
||||
> Feature requests welcome as [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues).
|
||||
|
||||
---
|
||||
|
||||
## [1.5.0] - 2026-01-15
|
||||
|
||||
### 🎉 Major: Jetpack Compose UI Redesign
|
||||
|
||||
The complete UI has been migrated from XML Views to Jetpack Compose. The app is now more modern, faster, and smoother.
|
||||
|
||||
### 🌍 New Feature: Internationalization (i18n)
|
||||
|
||||
- **English language support** - All 400+ strings translated
|
||||
- **Automatic language detection** - Follows system language
|
||||
- **Manual language selection** - Switchable in settings
|
||||
- **Per-App Language (Android 13+)** - Native language setting via system settings
|
||||
- **locales_config.xml** - Complete Android integration
|
||||
|
||||
### ⚙️ Modernized Settings
|
||||
|
||||
- **7 categorized settings screens** - Clearer and more intuitive
|
||||
- **Compose Navigation** - Smooth transitions between screens
|
||||
- **Consistent design** - Material Design 3 throughout
|
||||
|
||||
### ✨ UI Improvements
|
||||
|
||||
- **Selection Mode** - Long-press for multi-select instead of swipe-to-delete
|
||||
- **Batch Delete** - Delete multiple notes at once
|
||||
- **Silent-Sync Mode** - No banner during auto-sync (only for manual sync)
|
||||
- **App Icon in About Screen** - High-quality display
|
||||
- **App Icon in Empty State** - Instead of emoji when note list is empty
|
||||
- **Splash Screen Update** - Uses app foreground icon
|
||||
- **Slide Animations** - Smooth animations in NoteEditor
|
||||
|
||||
### 🔧 Technical Improvements
|
||||
|
||||
- **Jetpack Compose** - Complete UI migration
|
||||
- **Compose ViewModel Integration** - StateFlow for reactive UI
|
||||
- **Improved Code Quality** - Detekt/Lint warnings fixed
|
||||
- **Unused Imports Cleanup** - Cleaner codebase
|
||||
|
||||
### Looking Ahead
|
||||
|
||||
> 🚀 **v1.6.0** will bring server folder checking and further technical modernizations.
|
||||
> Feature requests welcome as [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues).
|
||||
|
||||
---
|
||||
|
||||
## [1.4.1] - 2026-01-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **🗑️ Deleting older notes (v1.2.0 compatibility)**
|
||||
- Notes from app version v1.2.0 or earlier are now correctly deleted from the server
|
||||
- Fixes issue with multi-device usage with older notes
|
||||
|
||||
- **🔄 Checklist sync backward compatibility**
|
||||
- Checklists now also saved as text fallback in the `content` field
|
||||
- Older app versions (v1.3.x) display checklists as readable text
|
||||
- Format: GitHub-style task lists (`[ ] Item` / `[x] Item`)
|
||||
- Recovery mode: If checklist items are lost, they are recovered from content
|
||||
|
||||
### Improved
|
||||
|
||||
- **📝 Checklist auto line-wrap**
|
||||
- Long checklist texts now automatically wrap
|
||||
- No more limit to 3 lines
|
||||
- Enter key still creates a new item
|
||||
|
||||
### Looking Ahead
|
||||
|
||||
> 🚀 **v1.5.0** will be the next major release. We're collecting ideas and feedback!
|
||||
> Feature requests welcome as [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues).
|
||||
|
||||
---
|
||||
|
||||
## [1.4.0] - 2026-01-10
|
||||
|
||||
### 🎉 New Feature: Checklists
|
||||
|
||||
- **✅ Checklist Notes**
|
||||
- New note type: Checklists with tap-to-toggle items
|
||||
- Add items via dedicated input field with "+" button
|
||||
- Drag & drop reordering (long-press to activate)
|
||||
- Swipe-to-delete items
|
||||
- Visual distinction: Checked items get strikethrough styling
|
||||
- Type selector when creating new notes (Text or Checklist)
|
||||
|
||||
- **📝 Markdown Integration**
|
||||
- Checklists export as GitHub-style task lists (`- [ ]` / `- [x]`)
|
||||
- Compatible with Obsidian, Notion, and other Markdown editors
|
||||
- Full round-trip: Edit in Obsidian → Sync back to app
|
||||
- YAML frontmatter includes `type: checklist` for identification
|
||||
|
||||
### Fixed
|
||||
|
||||
- **<2A> Markdown Parsing Robustness**
|
||||
- Fixed content extraction after title (was returning empty for some formats)
|
||||
- Now handles single newline after title (was requiring double newline)
|
||||
- Protection: Skips import if parsed content is empty but local has content
|
||||
|
||||
- **📂 Duplicate Filename Handling**
|
||||
- Notes with identical titles now get unique Markdown filenames
|
||||
- Format: `title_shortid.md` (e.g., `test_71540ca9.md`)
|
||||
- Prevents data loss from filename collisions
|
||||
|
||||
- **🔔 Notification UX**
|
||||
- No sync notifications when app is in foreground
|
||||
- User sees changes directly in UI - no redundant notification
|
||||
- Background syncs still show notifications as expected
|
||||
|
||||
### Privacy Improvements
|
||||
|
||||
- **🔒 WiFi Permissions Removed**
|
||||
- Removed `ACCESS_WIFI_STATE` permission
|
||||
- Removed `CHANGE_WIFI_STATE` permission
|
||||
- WiFi binding now works via IP detection instead of SSID matching
|
||||
- Cleaned up all SSID-related code from codebase and documentation
|
||||
|
||||
### Technical Improvements
|
||||
|
||||
- **📦 New Data Model**
|
||||
- `NoteType` enum: `TEXT`, `CHECKLIST`
|
||||
- `ChecklistItem` data class with id, text, isChecked, order
|
||||
- `Note.kt` extended with `noteType` and `checklistItems` fields
|
||||
|
||||
- **🔄 Sync Protocol v1.4.0**
|
||||
- JSON format updated to include checklist fields
|
||||
- Full backward compatibility with v1.3.x notes
|
||||
- Robust JSON parsing with manual field extraction
|
||||
|
||||
---
|
||||
|
||||
## [1.3.2] - 2026-01-10
|
||||
|
||||
### Changed
|
||||
- **🧹 Code-Qualität: "Clean Slate" Release**
|
||||
- Alle einfachen Lint-Issues behoben (Phase 1-7 des Cleanup-Plans)
|
||||
- Unused Imports und Members entfernt
|
||||
- Magic Numbers durch benannte Konstanten ersetzt
|
||||
- SwallowedExceptions mit Logger.w() versehen
|
||||
- MaxLineLength-Verstöße reformatiert
|
||||
- ConstructorParameterNaming (snake_case → camelCase mit @SerializedName)
|
||||
- Custom Exceptions: SyncException.kt und ValidationException.kt erstellt
|
||||
|
||||
### Added
|
||||
- **📝 F-Droid Privacy Notice**
|
||||
- Datenschutz-Hinweis für die Datei-Logging-Funktion
|
||||
- Erklärt dass Logs nur lokal gespeichert werden
|
||||
- Erfüllt F-Droid Opt-in Consent-Anforderungen
|
||||
|
||||
### Technical Improvements
|
||||
- **⚡ Neue Konstanten für bessere Wartbarkeit**
|
||||
- `SYNC_COMPLETED_DELAY_MS`, `ERROR_DISPLAY_DELAY_MS` (MainActivity)
|
||||
- `CONNECTION_TIMEOUT_MS` (SettingsActivity)
|
||||
- `SOCKET_TIMEOUT_MS`, `MAX_FILENAME_LENGTH`, `ETAG_PREVIEW_LENGTH` (WebDavSyncService)
|
||||
- `AUTO_CANCEL_TIMEOUT_MS` (NotificationHelper)
|
||||
- RFC 1918 IP-Range Konstanten (UrlValidator)
|
||||
- `DAYS_THRESHOLD`, `TRUNCATE_SUFFIX_LENGTH` (Extensions)
|
||||
|
||||
- **🔒 @Suppress Annotations für legitime Patterns**
|
||||
- ReturnCount: Frühe Returns für Validierung sind idiomatisch
|
||||
- LoopWithTooManyJumpStatements: Komplexe Sync-Logik dokumentiert
|
||||
|
||||
### Notes
|
||||
- Komplexe Refactorings (LargeClass, LongMethod) für v1.3.3+ geplant
|
||||
- Deprecation-Warnungen (LocalBroadcastManager, ProgressDialog) bleiben bestehen
|
||||
|
||||
---
|
||||
|
||||
## [1.3.1] - 2026-01-08
|
||||
|
||||
### Fixed
|
||||
- **🔧 Multi-Device JSON Sync (Danke an Thomas aus Bielefeld)**
|
||||
- JSON-Dateien werden jetzt korrekt zwischen Geräten synchronisiert
|
||||
- Funktioniert auch ohne aktiviertes Markdown
|
||||
- Hybrid-Optimierung: Server-Timestamp (Primary) + E-Tag (Secondary) Checks
|
||||
- E-Tag wird nach Upload gecached um Re-Download zu vermeiden
|
||||
|
||||
### Performance Improvements
|
||||
- **⚡ JSON Sync Performance-Parität**
|
||||
- JSON-Sync erreicht jetzt gleiche Performance wie Markdown (~2-3 Sekunden)
|
||||
- Timestamp-basierte Skip-Logik für unveränderte Dateien (~500ms pro Datei gespart)
|
||||
- E-Tag-Matching als Fallback für Dateien die seit letztem Sync modifiziert wurden
|
||||
- **Beispiel:** 24 Dateien von 12-14s auf ~2.7s reduziert (keine Änderungen)
|
||||
|
||||
- **⏭️ Skip unveränderte Dateien** (Haupt-Performance-Fix!)
|
||||
- JSON-Dateien: Überspringt alle Notizen, die seit letztem Sync nicht geändert wurden
|
||||
- Markdown-Dateien: Überspringt unveränderte MD-Dateien basierend auf Server-Timestamp
|
||||
- **Spart ~500ms pro Datei** bei Nextcloud (~20 Dateien = 10 Sekunden gespart!)
|
||||
- Von 21 Sekunden Sync-Zeit auf 2-3 Sekunden reduziert
|
||||
|
||||
- **⚡ Session-Caching für WebDAV**
|
||||
- Sardine-Client wird pro Sync-Session wiederverwendet (~600ms gespart)
|
||||
- WiFi-IP-Adresse wird gecacht statt bei jeder Anfrage neu ermittelt (~300ms gespart)
|
||||
- `/notes/` Ordner-Existenz wird nur einmal pro Sync geprüft (~500ms gespart)
|
||||
- **Gesamt: ~1.4 Sekunden zusätzlich gespart**
|
||||
|
||||
- **📝 Content-basierte Markdown-Erkennung**
|
||||
- Extern bearbeitete Markdown-Dateien werden auch erkannt wenn YAML-Timestamp nicht aktualisiert wurde
|
||||
- Löst das Problem: Obsidian/Texteditor-Änderungen wurden nicht importiert
|
||||
- Hybridansatz: Erst Timestamp-Check (schnell), dann Content-Vergleich (zuverlässig)
|
||||
|
||||
### Added
|
||||
- **🔄 Sync-Status-Anzeige (UI)**
|
||||
- Sichtbares Banner "Synchronisiere..." mit ProgressBar während Sync läuft
|
||||
- Sync-Button und Pull-to-Refresh werden deaktiviert während Sync aktiv
|
||||
- Verhindert versehentliche Doppel-Syncs durch visuelle Rückmeldung
|
||||
- Auch in Einstellungen: "Jetzt synchronisieren" Button wird deaktiviert
|
||||
|
||||
### Fixed
|
||||
- **🔧 Sync-Mutex verhindert doppelte Syncs**
|
||||
- Keine doppelten Toast-Nachrichten mehr bei schnellem Pull-to-Refresh
|
||||
- Concurrent Sync-Requests werden korrekt blockiert
|
||||
|
||||
- **🐛 Lint-Fehler behoben**
|
||||
- `View.generateViewId()` statt hardcodierte IDs in RadioButtons
|
||||
- `app:tint` statt `android:tint` für AppCompat-Kompatibilität
|
||||
|
||||
### Added
|
||||
- **🔍 detekt Code-Analyse**
|
||||
- Statische Code-Analyse mit detekt 1.23.4 integriert
|
||||
- Pragmatische Konfiguration für Sync-intensive Codebasis
|
||||
- 91 Issues identifiziert (als Baseline für v1.4.0)
|
||||
|
||||
- **🏗️ Debug Build mit separatem Package**
|
||||
- Debug-APK kann parallel zur Release-Version installiert werden
|
||||
- Package: `dev.dettmer.simplenotes.debug` (Debug) vs `dev.dettmer.simplenotes` (Release)
|
||||
- App-Name zeigt "Simple Notes (Debug)" für einfache Unterscheidung
|
||||
|
||||
- **📊 Debug-Logging UI**
|
||||
- Neuer "Debug Log" Button in Einstellungen → Erweitert
|
||||
- Zeigt letzte Sync-Logs mit Zeitstempeln
|
||||
- Export-Funktion für Fehlerberichte
|
||||
|
||||
### Technical
|
||||
- `WebDavSyncService`: Hybrid-Optimierung für JSON-Downloads (Timestamp PRIMARY, E-Tag SECONDARY)
|
||||
- `WebDavSyncService`: E-Tag refresh nach Upload statt Invalidierung (verhindert Re-Download)
|
||||
- E-Tag Caching: `SharedPreferences` mit Key-Pattern `etag_json_{noteId}`
|
||||
- Skip-Logik: `if (serverModified <= lastSync) skip` → ~1ms pro Datei
|
||||
- Fallback E-Tag: `if (serverETag == cachedETag) skip` → für Dateien modifiziert nach lastSync
|
||||
- PROPFIND nach PUT: Fetch E-Tag nach Upload für korrektes Caching
|
||||
- `SyncStateManager`: Neuer Singleton mit `StateFlow<Boolean>` für Sync-Status
|
||||
- `MainActivity`: Observer auf `SyncStateManager.isSyncing` für UI-Updates
|
||||
- Layout: `sync_status_banner` mit `ProgressBar` + `TextView`
|
||||
- `WebDavSyncService`: Skip-Logik für unveränderte JSON/MD Dateien basierend auf `lastSyncTimestamp`
|
||||
- `WebDavSyncService`: Neue Session-Cache-Variablen (`sessionSardine`, `sessionWifiAddress`, `notesDirEnsured`)
|
||||
- `getOrCreateSardine()`: Cached Sardine-Client mit automatischer Credentials-Konfiguration
|
||||
- `getOrCacheWiFiAddress()`: WiFi-Adresse wird nur einmal pro Sync ermittelt
|
||||
- `clearSessionCache()`: Aufräumen am Ende jeder Sync-Session
|
||||
- `ensureNotesDirectoryExists()`: Cached Directory-Check
|
||||
- Content-basierter Import: Vergleicht MD-Content mit lokaler Note wenn Timestamps gleich
|
||||
- Build-Tooling: detekt aktiviert, ktlint vorbereitet (deaktiviert wegen Parser-Problemen)
|
||||
- Debug BuildType: `applicationIdSuffix = ".debug"`, `versionNameSuffix = "-debug"`
|
||||
|
||||
---
|
||||
|
||||
## [1.3.0] - 2026-01-07
|
||||
|
||||
### Added
|
||||
- **🚀 Multi-Device Sync** (Thanks to Thomas from Bielefeld for reporting!)
|
||||
- Automatic download of new notes from other devices
|
||||
- Deletion tracking prevents "zombie notes" (deleted notes don't come back)
|
||||
- Smart cleanup: Re-created notes (newer timestamp) are downloaded
|
||||
- Works with all devices: v1.2.0, v1.2.1, v1.2.2, and v1.3.0
|
||||
|
||||
- **🗑️ Server Deletion via Swipe Gesture**
|
||||
- Swipe left on notes to delete from server (requires confirmation)
|
||||
- Prevents duplicate notes on other devices
|
||||
- Works with deletion tracking system
|
||||
- Material Design confirmation dialog
|
||||
|
||||
- **⚡ E-Tag Performance Optimization**
|
||||
- Smart server checking with E-Tag caching (~150ms vs 3000ms for "no changes")
|
||||
- 20x faster when server has no updates
|
||||
- E-Tag hybrid approach: E-Tag for JSON (fast), timestamp for Markdown (reliable)
|
||||
- Battery-friendly with minimal server requests
|
||||
|
||||
- **📥 Markdown Auto-Sync Toggle**
|
||||
- NEW: Unified Auto-Sync toggle in Settings (replaces separate Export/Auto-Import toggles)
|
||||
- When enabled: Notes export to Markdown AND import changes automatically
|
||||
- When disabled: Manual sync button appears for on-demand synchronization
|
||||
- Performance: Auto-Sync OFF = 0ms overhead
|
||||
|
||||
- **🔘 Manual Markdown Sync Button**
|
||||
- Manual sync button for performance-conscious users
|
||||
- Shows import/export counts after completion
|
||||
- Only visible when Auto-Sync is disabled
|
||||
- On-demand synchronization (~150-200ms only when triggered)
|
||||
|
||||
- **⚙️ Server-Restore Modes**
|
||||
- MERGE: Keep local notes + add server notes
|
||||
- REPLACE: Delete all local + download from server
|
||||
- OVERWRITE: Update duplicates, keep non-duplicates
|
||||
- Restore modes now work correctly for WebDAV restore
|
||||
|
||||
### Technical
|
||||
- New `DeletionTracker` model with JSON persistence
|
||||
- `NotesStorage`: Added deletion tracking methods
|
||||
- `WebDavSyncService.hasUnsyncedChanges()`: Intelligent server checks with E-Tag caching
|
||||
- `WebDavSyncService.downloadRemoteNotes()`: Deletion-aware downloads
|
||||
- `WebDavSyncService.restoreFromServer()`: Support for restore modes
|
||||
- `WebDavSyncService.deleteNoteFromServer()`: Server deletion with YAML frontmatter scanning
|
||||
- `WebDavSyncService.importMarkdownFiles()`: Automatic Markdown import during sync
|
||||
- `WebDavSyncService.manualMarkdownSync()`: Manual sync with result counts
|
||||
- `MainActivity.setupSwipeToDelete()`: Two-stage swipe deletion with confirmation
|
||||
- E-Tag caching in SharedPreferences for performance
|
||||
|
||||
---
|
||||
|
||||
## [1.2.2] - 2026-01-06
|
||||
|
||||
### Fixed
|
||||
- **Backward Compatibility for v1.2.0 Users (Critical)**
|
||||
- App now reads BOTH old (Root) AND new (`/notes/`) folder structures
|
||||
- Users upgrading from v1.2.0 no longer lose their existing notes
|
||||
- Server-Restore now finds notes from v1.2.0 stored in Root folder
|
||||
- Automatic deduplication prevents loading the same note twice
|
||||
- Graceful error handling if Root folder is not accessible
|
||||
|
||||
### Technical
|
||||
- `WebDavSyncService.downloadRemoteNotes()` - Dual-mode download (Root + /notes/)
|
||||
- `WebDavSyncService.restoreFromServer()` - Now uses dual-mode download
|
||||
- Migration happens naturally: new uploads go to `/notes/`, old notes stay readable
|
||||
|
||||
---
|
||||
|
||||
## [1.2.1] - 2026-01-05
|
||||
|
||||
### Fixed
|
||||
- **Markdown Initial Export Bugfix**
|
||||
- Existing notes are now exported as Markdown when Desktop Integration is activated
|
||||
- Previously, only new notes created after activation were exported
|
||||
- Progress dialog shows export status with current/total counter
|
||||
- Error handling for network issues during export
|
||||
- Individual note failures don't abort the entire export
|
||||
|
||||
- **Markdown Directory Structure Fix**
|
||||
- Markdown files now correctly land in `/notes-md/` folder
|
||||
- Smart URL detection supports both Root-URL and `/notes` URL structures
|
||||
- Previously, MD files were incorrectly placed in the root directory
|
||||
- Markdown import now finds files correctly
|
||||
|
||||
- **JSON URL Normalization**
|
||||
- Simplified server configuration: enter only base URL (e.g., `http://server:8080/`)
|
||||
- App automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown
|
||||
- Smart detection: both `http://server:8080/` and `http://server:8080/notes/` work correctly
|
||||
- Backward compatible: existing setups with `/notes` in URL continue to work
|
||||
- No migration required for existing users
|
||||
|
||||
### Changed
|
||||
- **Markdown Directory Creation**
|
||||
- `notes-md/` folder is now created on first sync (regardless of Desktop Integration setting)
|
||||
- Prevents 404 errors when mounting WebDAV folder
|
||||
- Better user experience: folder is visible before enabling the feature
|
||||
|
||||
- **Settings UI Improvements**
|
||||
- Updated example URL from `/webdav` to `/notes` to match app behavior
|
||||
- Example now shows: `http://192.168.0.188:8080/notes`
|
||||
|
||||
### Technical
|
||||
- `WebDavSyncService.ensureMarkdownDirectoryExists()` - Creates MD folder early
|
||||
- `WebDavSyncService.getMarkdownUrl()` - Smart URL detection for both structures
|
||||
- `WebDavSyncService.exportAllNotesToMarkdown()` - Exports all local notes with progress callback
|
||||
- `SettingsActivity.onMarkdownExportToggled()` - Triggers initial export with ProgressDialog
|
||||
|
||||
---
|
||||
|
||||
## [1.2.0] - 2026-01-04
|
||||
|
||||
### Added
|
||||
- **Local Backup System**
|
||||
- Export all notes as JSON file to any location (Downloads, SD card, cloud folder)
|
||||
- Import backup with 3 modes: Merge, Replace, or Overwrite duplicates
|
||||
- Automatic safety backup created before every restore
|
||||
- Backup validation (format and version check)
|
||||
|
||||
- **Markdown Desktop Integration**
|
||||
- Optional Markdown export parallel to JSON sync
|
||||
- `.md` files synced to `notes-md/` folder on WebDAV
|
||||
- YAML frontmatter with `id`, `created`, `updated`, `device`
|
||||
- Manual import button to pull Markdown changes from server
|
||||
- Last-Write-Wins conflict resolution via timestamps
|
||||
|
||||
- **Settings UI Extensions**
|
||||
- New "Backup & Restore" section with local + server restore
|
||||
- New "Desktop Integration" section with Markdown toggle
|
||||
- Universal restore dialog with radio button mode selection
|
||||
|
||||
### Changed
|
||||
- **Server Restore Behavior**: Users now choose restore mode (Merge/Replace/Overwrite) instead of hard-coded replace-all
|
||||
|
||||
### Technical
|
||||
- `BackupManager.kt` - Complete backup/restore logic
|
||||
- `Note.toMarkdown()` / `Note.fromMarkdown()` - Markdown conversion with YAML frontmatter
|
||||
- `WebDavSyncService` - Extended for dual-format sync (JSON master + Markdown mirror)
|
||||
- ISO8601 timestamp formatting for desktop compatibility
|
||||
- Filename sanitization for safe Markdown file names
|
||||
|
||||
### Documentation
|
||||
- Added WebDAV mount instructions (Windows, macOS, Linux)
|
||||
- Complete sync architecture documentation
|
||||
- Desktop integration analysis
|
||||
|
||||
---
|
||||
|
||||
## [1.1.2] - 2025-12-28
|
||||
|
||||
### Fixed
|
||||
- **"Job was cancelled" Error**
|
||||
- Fixed coroutine cancellation in sync worker
|
||||
- Proper error handling for interrupted syncs
|
||||
|
||||
- **UI Improvements**
|
||||
- Back arrow instead of X in note editor (better UX)
|
||||
- Pull-to-refresh for manual sync trigger
|
||||
- HTTP/HTTPS protocol selection with radio buttons
|
||||
- Inline error display (no toast spam)
|
||||
|
||||
- **Performance & Battery**
|
||||
- Sync only on actual changes (saves battery)
|
||||
- Auto-save notifications removed
|
||||
- 24-hour server offline warning instead of instant error
|
||||
|
||||
### Changed
|
||||
- Settings grouped into "Auto-Sync" and "Sync Interval" sections
|
||||
- HTTP only allowed for local networks (RFC 1918 IPs)
|
||||
- Swipe-to-delete without UI flicker
|
||||
|
||||
---
|
||||
|
||||
## [1.1.1] - 2025-12-27
|
||||
|
||||
### Fixed
|
||||
- **WiFi Connect Sync**
|
||||
- No error notifications in foreign WiFi networks
|
||||
- Server reachability check before sync (2s timeout)
|
||||
- Silent abort when server offline
|
||||
- Pre-check waits until network is ready
|
||||
- No errors during network initialization
|
||||
|
||||
### Changed
|
||||
- **Notifications**
|
||||
- Old sync notifications cleared on app start
|
||||
- Error notifications auto-dismiss after 30 seconds
|
||||
|
||||
### UI
|
||||
- Sync icon only shown when sync is configured
|
||||
- Swipe-to-delete without flicker
|
||||
- Scroll to top after saving note
|
||||
|
||||
### Technical
|
||||
- Server check with 2-second timeout before sync attempts
|
||||
- Network readiness check in WiFi connect trigger
|
||||
- Notification cleanup on MainActivity.onCreate()
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] - 2025-12-26
|
||||
|
||||
### Added
|
||||
- **Configurable Sync Intervals**
|
||||
- User choice: 15, 30, or 60 minutes
|
||||
- Real-world battery impact displayed (15min: ~0.8%/day, 30min: ~0.4%/day, 60min: ~0.2%/day)
|
||||
- Radio button selection in settings
|
||||
- Doze Mode optimization (syncs batched in maintenance windows)
|
||||
|
||||
- **About Section**
|
||||
- App version from BuildConfig
|
||||
- Links to GitHub repository and developer profile
|
||||
- MIT license information
|
||||
- Material 3 card design
|
||||
|
||||
### Changed
|
||||
- Settings UI redesigned with grouped sections
|
||||
- Periodic sync updated dynamically when interval changes
|
||||
- WorkManager uses selected interval for background sync
|
||||
|
||||
### Removed
|
||||
- Debug/Logs section from settings (cleaner UI)
|
||||
|
||||
### Technical
|
||||
- `PREF_SYNC_INTERVAL_MINUTES` preference key
|
||||
- NetworkMonitor reads interval from SharedPreferences
|
||||
- `ExistingPeriodicWorkPolicy.UPDATE` for live interval changes
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2025-12-25
|
||||
|
||||
### Added
|
||||
- Initial release
|
||||
- WebDAV synchronization
|
||||
- Note creation, editing, deletion
|
||||
- 6 sync triggers:
|
||||
- Periodic sync (configurable interval)
|
||||
- App start sync
|
||||
- WiFi connect sync
|
||||
- Manual sync (menu button)
|
||||
- Pull-to-refresh
|
||||
- Settings "Sync Now" button
|
||||
- Material 3 design
|
||||
- Light/Dark theme support
|
||||
- F-Droid compatible (100% FOSS)
|
||||
|
||||
---
|
||||
|
||||
[1.8.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.8.1
|
||||
[1.8.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.8.0
|
||||
[1.7.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.2
|
||||
[1.7.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.1
|
||||
[1.7.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.0
|
||||
[1.6.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.6.1
|
||||
[1.6.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.6.0
|
||||
[1.5.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.5.0
|
||||
[1.4.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.4.1
|
||||
[1.4.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.4.0
|
||||
[1.3.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.2
|
||||
[1.3.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.1
|
||||
[1.3.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.0
|
||||
[1.2.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.2
|
||||
[1.2.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.1
|
||||
[1.2.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.0
|
||||
[1.1.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.2
|
||||
[1.1.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.1
|
||||
[1.1.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.0
|
||||
[1.0.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.0.0
|
||||
@@ -14,7 +14,7 @@ Danke, dass du zu Simple Notes Sync beitragen möchtest!
|
||||
|
||||
1. **Fork & Clone**
|
||||
```bash
|
||||
git clone https://github.com/DEIN-USERNAME/simple-notes-sync.git
|
||||
git clone https://github.com/inventory69/simple-notes-sync.git
|
||||
cd simple-notes-sync
|
||||
```
|
||||
|
||||
@@ -94,10 +94,10 @@ Nutze die [Feature Request Template](https://github.com/inventory69/simple-notes
|
||||
Dokumentations-Verbesserungen sind auch Contributions!
|
||||
|
||||
**Dateien:**
|
||||
- `README.md` / `README.en.md` - Übersicht
|
||||
- `QUICKSTART.md` / `QUICKSTART.en.md` - Schritt-für-Schritt Anleitung
|
||||
- `DOCS.md` / `DOCS.en.md` - Technische Details
|
||||
- `server/README.md` / `server/README.en.md` - Server Setup
|
||||
- `README.de.md` / `README.md` - Übersicht
|
||||
- `QUICKSTART.de.md` / `QUICKSTART.md` - Schritt-für-Schritt Anleitung
|
||||
- `docs/DOCS.de.md` / `docs/DOCS.md` - Technische Details
|
||||
- `server/README.de.md` / `server/README.md` - Server Setup
|
||||
|
||||
**Bitte:** Halte beide Sprachen (DE/EN) synchron!
|
||||
|
||||
@@ -139,7 +139,7 @@ Thanks for wanting to contribute to Simple Notes Sync!
|
||||
|
||||
1. **Fork & Clone**
|
||||
```bash
|
||||
git clone https://github.com/YOUR-USERNAME/simple-notes-sync.git
|
||||
git clone https://github.com/inventory69/simple-notes-sync.git
|
||||
cd simple-notes-sync
|
||||
```
|
||||
|
||||
@@ -219,10 +219,10 @@ Use the [Feature Request Template](https://github.com/inventory69/simple-notes-s
|
||||
Documentation improvements are also contributions!
|
||||
|
||||
**Files:**
|
||||
- `README.md` / `README.en.md` - Overview
|
||||
- `QUICKSTART.md` / `QUICKSTART.en.md` - Step-by-step guide
|
||||
- `DOCS.md` / `DOCS.en.md` - Technical details
|
||||
- `server/README.md` / `server/README.en.md` - Server setup
|
||||
- `README.de.md` / `README.md` - Overview
|
||||
- `QUICKSTART.de.md` / `QUICKSTART.md` - Step-by-step guide
|
||||
- `docs/DOCS.de.md` / `docs/DOCS.md` - Technical details
|
||||
- `server/README.de.md` / `server/README.md` - Server setup
|
||||
|
||||
**Please:** Keep both languages (DE/EN) in sync!
|
||||
|
||||
@@ -260,4 +260,4 @@ By contributing, you agree that your code will be published under the [MIT Licen
|
||||
|
||||
Öffne ein [Issue](https://github.com/inventory69/simple-notes-sync/issues) oder nutze die [Question Template](https://github.com/inventory69/simple-notes-sync/issues/new/choose).
|
||||
|
||||
**Frohe Weihnachten & Happy Coding! 🎄**
|
||||
**Happy Coding! 🚀**
|
||||
|
||||
269
QUICKSTART.de.md
Normal file
269
QUICKSTART.de.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# Quick Start Guide - Simple Notes Sync 📝
|
||||
|
||||
> Schritt-für-Schritt Anleitung zur Installation und Einrichtung
|
||||
|
||||
**🌍 Sprachen:** **Deutsch** · [English](QUICKSTART.md)
|
||||
|
||||
---
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- ✅ Android 7.0+ Smartphone/Tablet
|
||||
- ✅ WLAN-Verbindung
|
||||
- ✅ Eigener Server mit Docker (optional - für Self-Hosting)
|
||||
|
||||
---
|
||||
|
||||
## Option 1: Mit eigenem Server (Self-Hosted) 🏠
|
||||
|
||||
### Schritt 1: WebDAV Server einrichten
|
||||
|
||||
Auf deinem Server (z.B. Raspberry Pi, NAS, VPS):
|
||||
|
||||
```bash
|
||||
# Repository klonen
|
||||
git clone https://github.com/inventory69/simple-notes-sync.git
|
||||
cd simple-notes-sync/server
|
||||
|
||||
# Umgebungsvariablen konfigurieren
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
**In `.env` anpassen:**
|
||||
```env
|
||||
WEBDAV_PASSWORD=dein-sicheres-passwort-hier
|
||||
```
|
||||
|
||||
**Server starten:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**IP-Adresse finden:**
|
||||
```bash
|
||||
ip addr show | grep "inet " | grep -v 127.0.0.1
|
||||
```
|
||||
|
||||
➡️ **Notiere dir:** `http://DEINE-SERVER-IP:8080/`
|
||||
|
||||
---
|
||||
|
||||
### Schritt 2: App installieren
|
||||
|
||||
1. **APK herunterladen:** [Neueste Version](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||
- Wähle: `simple-notes-sync-vX.X.X-standard.apk`
|
||||
|
||||
2. **Installation erlauben:**
|
||||
- Android: Einstellungen → Sicherheit → "Unbekannte Quellen" für deinen Browser aktivieren
|
||||
|
||||
3. **APK öffnen und installieren**
|
||||
|
||||
---
|
||||
|
||||
### Schritt 3: App konfigurieren
|
||||
|
||||
1. **App öffnen**
|
||||
|
||||
2. **Einstellungen öffnen** (⚙️ Icon oben rechts)
|
||||
|
||||
3. **Server-Einstellungen konfigurieren:**
|
||||
|
||||
| Feld | Wert |
|
||||
|------|------|
|
||||
| **WebDAV Server URL** | `http://DEINE-SERVER-IP:8080/` |
|
||||
| **Benutzername** | `noteuser` |
|
||||
| **Passwort** | (dein Passwort aus `.env`) |
|
||||
|
||||
> **💡 Hinweis:** Gib nur die Base-URL ein (ohne `/notes`). Die App erstellt automatisch `/notes/` für JSON-Dateien und `/notes-md/` für Markdown-Export.
|
||||
|
||||
4. **"Verbindung testen"** drücken
|
||||
- ✅ Erfolg? → Weiter zu Schritt 4
|
||||
- ❌ Fehler? → Siehe [Troubleshooting](#troubleshooting)
|
||||
|
||||
5. **Auto-Sync aktivieren** (Toggle Switch)
|
||||
|
||||
6. **Sync-Intervall wählen:**
|
||||
- **15 Min** - Maximale Aktualität (~0.8% Akku/Tag)
|
||||
- **30 Min** - Empfohlen (~0.4% Akku/Tag) ⭐
|
||||
- **60 Min** - Maximale Akkulaufzeit (~0.2% Akku/Tag)
|
||||
|
||||
---
|
||||
|
||||
### Schritt 4: Erste Notiz erstellen
|
||||
|
||||
1. Zurück zur Hauptansicht (← Pfeil)
|
||||
|
||||
2. **"Notiz hinzufügen"** (+ Icon)
|
||||
|
||||
3. Titel und Text eingeben
|
||||
|
||||
4. **Speichern** (💾 Icon)
|
||||
|
||||
5. **Warten auf Auto-Sync** (oder manuell: ⚙️ → "Jetzt synchronisieren")
|
||||
|
||||
🎉 **Fertig!** Deine Notizen werden automatisch synchronisiert!
|
||||
|
||||
---
|
||||
|
||||
## Option 2: Nur lokale Notizen (kein Server) 📱
|
||||
|
||||
Du kannst Simple Notes auch **ohne Server** nutzen:
|
||||
|
||||
1. **App installieren** (siehe Schritt 2 oben)
|
||||
|
||||
2. **Ohne Server-Konfiguration verwenden:**
|
||||
- Notizen werden nur lokal gespeichert
|
||||
- Kein Auto-Sync
|
||||
- Perfekt für reine Offline-Nutzung
|
||||
|
||||
---
|
||||
|
||||
## 🔋 Akku-Optimierung deaktivieren
|
||||
|
||||
Für zuverlässigen Auto-Sync:
|
||||
|
||||
1. **Einstellungen** → **Apps** → **Simple Notes Sync**
|
||||
|
||||
2. **Akku** → **Akkuverbrauch**
|
||||
|
||||
3. Wähle: **"Nicht optimieren"** oder **"Unbeschränkt"**
|
||||
|
||||
💡 **Hinweis:** Android Doze Mode kann trotzdem Sync im Standby verzögern (~60 Min). Das ist normal und betrifft alle Apps.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Sync-Intervalle im Detail
|
||||
|
||||
| Intervall | Syncs/Tag | Akku/Tag | Akku/Sync | Anwendungsfall |
|
||||
|-----------|-----------|----------|-----------|----------------|
|
||||
| **15 Min** | ~96 | ~0.8% (~23 mAh) | ~0.008% | ⚡ Maximal aktuell (mehrere Geräte) |
|
||||
| **30 Min** | ~48 | ~0.4% (~12 mAh) | ~0.008% | ✓ **Empfohlen** - ausgewogen |
|
||||
| **60 Min** | ~24 | ~0.2% (~6 mAh) | ~0.008% | 🔋 Maximale Akkulaufzeit |
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Verbindungstest schlägt fehl
|
||||
|
||||
**Problem:** "Verbindung fehlgeschlagen" beim Test
|
||||
|
||||
**Lösungen:**
|
||||
|
||||
1. **Server läuft?**
|
||||
```bash
|
||||
docker compose ps
|
||||
# Sollte "Up" zeigen
|
||||
```
|
||||
|
||||
2. **Gleiches Netzwerk?**
|
||||
- Smartphone und Server müssen im selben Netzwerk sein
|
||||
|
||||
3. **IP-Adresse korrekt?**
|
||||
```bash
|
||||
ip addr show | grep "inet "
|
||||
# Prüfe ob IP in URL stimmt
|
||||
```
|
||||
|
||||
4. **Firewall?**
|
||||
```bash
|
||||
# Port 8080 öffnen (falls Firewall aktiv)
|
||||
sudo ufw allow 8080/tcp
|
||||
```
|
||||
|
||||
5. **Server-Logs prüfen:**
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Auto-Sync funktioniert nicht
|
||||
|
||||
**Problem:** Notizen werden nicht automatisch synchronisiert
|
||||
|
||||
**Lösungen:**
|
||||
|
||||
1. **Auto-Sync aktiviert?**
|
||||
- ⚙️ Einstellungen → Toggle "Auto-Sync" muss **AN** sein
|
||||
|
||||
2. **Akku-Optimierung deaktiviert?**
|
||||
- Siehe [Akku-Optimierung](#-akku-optimierung-deaktivieren)
|
||||
|
||||
3. **Mit WiFi verbunden?**
|
||||
- Auto-Sync triggert bei jeder WiFi-Verbindung
|
||||
- Prüfe, ob du mit einem WLAN verbunden bist
|
||||
|
||||
4. **Manuell testen:**
|
||||
- ⚙️ Einstellungen → "Jetzt synchronisieren"
|
||||
- Funktioniert das? → Auto-Sync sollte auch funktionieren
|
||||
|
||||
---
|
||||
|
||||
### Notizen werden nicht angezeigt
|
||||
|
||||
**Problem:** Nach Installation sind keine Notizen da, obwohl welche auf dem Server liegen
|
||||
|
||||
**Lösung:**
|
||||
|
||||
1. **Einmalig manuell synchronisieren:**
|
||||
- ⚙️ Einstellungen → "Jetzt synchronisieren"
|
||||
|
||||
2. **Server-Daten prüfen:**
|
||||
```bash
|
||||
docker compose exec webdav ls -la /data/
|
||||
# Sollte .json Dateien zeigen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Fehler beim Sync
|
||||
|
||||
**Problem:** Fehlermeldung beim Synchronisieren
|
||||
|
||||
**Lösungen:**
|
||||
|
||||
1. **"401 Unauthorized"** → Passwort falsch
|
||||
- Prüfe Passwort in App-Einstellungen
|
||||
- Vergleiche mit `.env` auf Server
|
||||
|
||||
2. **"404 Not Found"** → URL falsch
|
||||
- Sollte enden mit `/` (z.B. `http://192.168.1.100:8080/`)
|
||||
|
||||
3. **"Network error"** → Keine Verbindung
|
||||
- Siehe [Verbindungstest schlägt fehl](#verbindungstest-schlägt-fehl)
|
||||
|
||||
---
|
||||
|
||||
## 📱 Updates
|
||||
|
||||
### Automatisch mit Obtainium (empfohlen)
|
||||
|
||||
1. **[Obtainium installieren](https://github.com/ImranR98/Obtanium/releases/latest)**
|
||||
|
||||
2. **App hinzufügen:**
|
||||
- URL: `https://github.com/inventory69/simple-notes-sync`
|
||||
- Auto-Update aktivieren
|
||||
|
||||
3. **Fertig!** Obtainium benachrichtigt dich bei neuen Versionen
|
||||
|
||||
### Manuell
|
||||
|
||||
1. Neue APK von [Releases](https://github.com/inventory69/simple-notes-sync/releases/latest) herunterladen
|
||||
|
||||
2. Installieren (überschreibt alte Version)
|
||||
|
||||
3. Alle Daten bleiben erhalten!
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Weitere Hilfe
|
||||
|
||||
- **GitHub Issues:** [Problem melden](https://github.com/inventory69/simple-notes-sync/issues)
|
||||
- **Vollständige Docs:** [DOCS.md](docs/DOCS.md)
|
||||
- **Server Setup Details:** [server/README.md](server/README.md)
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.1.0 · **Erstellt:** Dezember 2025
|
||||
269
QUICKSTART.en.md
269
QUICKSTART.en.md
@@ -1,269 +0,0 @@
|
||||
# Quick Start Guide - Simple Notes Sync 📝
|
||||
|
||||
> Step-by-step installation and setup guide
|
||||
|
||||
**🌍 Languages:** [Deutsch](QUICKSTART.md) · **English**
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- ✅ Android 8.0+ smartphone/tablet
|
||||
- ✅ WiFi connection
|
||||
- ✅ Own server with Docker (optional - for self-hosting)
|
||||
|
||||
---
|
||||
|
||||
## Option 1: With own server (Self-Hosted) 🏠
|
||||
|
||||
### Step 1: Setup WebDAV Server
|
||||
|
||||
On your server (e.g. Raspberry Pi, NAS, VPS):
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/inventory69/simple-notes-sync.git
|
||||
cd simple-notes-sync/server
|
||||
|
||||
# Configure environment variables
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
**Adjust in `.env`:**
|
||||
```env
|
||||
WEBDAV_PASSWORD=your-secure-password-here
|
||||
```
|
||||
|
||||
**Start server:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**Find IP address:**
|
||||
```bash
|
||||
ip addr show | grep "inet " | grep -v 127.0.0.1
|
||||
```
|
||||
|
||||
➡️ **Note down:** `http://YOUR-SERVER-IP:8080/`
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Install App
|
||||
|
||||
1. **Download APK:** [Latest version](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||
- Choose: `simple-notes-sync-vX.X.X-standard-universal.apk`
|
||||
|
||||
2. **Allow installation:**
|
||||
- Android: Settings → Security → Enable "Unknown sources" for your browser
|
||||
|
||||
3. **Open and install APK**
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Configure App
|
||||
|
||||
1. **Open app**
|
||||
|
||||
2. **Open settings** (⚙️ icon top right)
|
||||
|
||||
3. **Configure server settings:**
|
||||
|
||||
| Field | Value |
|
||||
|------|------|
|
||||
| **WebDAV Server URL** | `http://YOUR-SERVER-IP:8080/` |
|
||||
| **Username** | `noteuser` |
|
||||
| **Password** | (your password from `.env`) |
|
||||
| **Gateway SSID** | Name of your WiFi network |
|
||||
|
||||
4. **Press "Test connection"**
|
||||
- ✅ Success? → Continue to step 4
|
||||
- ❌ Error? → See [Troubleshooting](#troubleshooting)
|
||||
|
||||
5. **Enable auto-sync** (toggle switch)
|
||||
|
||||
6. **Choose sync interval:**
|
||||
- **15 min** - Maximum currency (~0.8% battery/day)
|
||||
- **30 min** - Recommended (~0.4% battery/day) ⭐
|
||||
- **60 min** - Maximum battery life (~0.2% battery/day)
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Create First Note
|
||||
|
||||
1. Back to main view (← arrow)
|
||||
|
||||
2. **"Add note"** (+ icon)
|
||||
|
||||
3. Enter title and text
|
||||
|
||||
4. **Save** (💾 icon)
|
||||
|
||||
5. **Wait for auto-sync** (or manually: ⚙️ → "Sync now")
|
||||
|
||||
🎉 **Done!** Your notes will be automatically synchronized!
|
||||
|
||||
---
|
||||
|
||||
## Option 2: Local notes only (no server) 📱
|
||||
|
||||
You can also use Simple Notes **without a server**:
|
||||
|
||||
1. **Install app** (see step 2 above)
|
||||
|
||||
2. **Use without server configuration:**
|
||||
- Notes are only stored locally
|
||||
- No auto-sync
|
||||
- Perfect for offline-only use
|
||||
|
||||
---
|
||||
|
||||
## 🔋 Disable Battery Optimization
|
||||
|
||||
For reliable auto-sync:
|
||||
|
||||
1. **Settings** → **Apps** → **Simple Notes Sync**
|
||||
|
||||
2. **Battery** → **Battery usage**
|
||||
|
||||
3. Select: **"Don't optimize"** or **"Unrestricted"**
|
||||
|
||||
💡 **Note:** Android Doze Mode may still delay sync in standby (~60 min). This is normal and affects all apps.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Sync Intervals in Detail
|
||||
|
||||
| Interval | Syncs/day | Battery/day | Battery/sync | Use case |
|
||||
|-----------|-----------|----------|-----------|----------------|
|
||||
| **15 min** | ~96 | ~0.8% (~23 mAh) | ~0.008% | ⚡ Maximum currency (multiple devices) |
|
||||
| **30 min** | ~48 | ~0.4% (~12 mAh) | ~0.008% | ✓ **Recommended** - balanced |
|
||||
| **60 min** | ~24 | ~0.2% (~6 mAh) | ~0.008% | 🔋 Maximum battery life |
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Connection test fails
|
||||
|
||||
**Problem:** "Connection failed" during test
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Server running?**
|
||||
```bash
|
||||
docker compose ps
|
||||
# Should show "Up"
|
||||
```
|
||||
|
||||
2. **Same WiFi?**
|
||||
- Smartphone and server must be on same network
|
||||
- Check SSID in app settings
|
||||
|
||||
3. **IP address correct?**
|
||||
```bash
|
||||
ip addr show | grep "inet "
|
||||
# Check if IP in URL matches
|
||||
```
|
||||
|
||||
4. **Firewall?**
|
||||
```bash
|
||||
# Open port 8080 (if firewall active)
|
||||
sudo ufw allow 8080/tcp
|
||||
```
|
||||
|
||||
5. **Check server logs:**
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Auto-sync not working
|
||||
|
||||
**Problem:** Notes are not automatically synchronized
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Auto-sync enabled?**
|
||||
- ⚙️ Settings → Toggle "Auto-sync" must be **ON**
|
||||
|
||||
2. **Battery optimization disabled?**
|
||||
- See [Disable Battery Optimization](#-disable-battery-optimization)
|
||||
|
||||
3. **On correct WiFi?**
|
||||
- Sync only works when SSID = Gateway SSID
|
||||
- Check current SSID in Android settings
|
||||
|
||||
4. **Test manually:**
|
||||
- ⚙️ Settings → "Sync now"
|
||||
- Works? → Auto-sync should work too
|
||||
|
||||
---
|
||||
|
||||
### Notes not showing up
|
||||
|
||||
**Problem:** After installation, no notes visible even though they exist on server
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. **Manually sync once:**
|
||||
- ⚙️ Settings → "Sync now"
|
||||
|
||||
2. **Check server data:**
|
||||
```bash
|
||||
docker compose exec webdav ls -la /data/
|
||||
# Should show .json files
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Sync errors
|
||||
|
||||
**Problem:** Error message during sync
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **"401 Unauthorized"** → Wrong password
|
||||
- Check password in app settings
|
||||
- Compare with `.env` on server
|
||||
|
||||
2. **"404 Not Found"** → Wrong URL
|
||||
- Should end with `/` (e.g. `http://192.168.1.100:8080/`)
|
||||
|
||||
3. **"Network error"** → No connection
|
||||
- See [Connection test fails](#connection-test-fails)
|
||||
|
||||
---
|
||||
|
||||
## 📱 Updates
|
||||
|
||||
### Automatic with Obtainium (recommended)
|
||||
|
||||
1. **[Install Obtainium](https://github.com/ImranR98/Obtanium/releases/latest)**
|
||||
|
||||
2. **Add app:**
|
||||
- URL: `https://github.com/inventory69/simple-notes-sync`
|
||||
- Enable auto-update
|
||||
|
||||
3. **Done!** Obtainium notifies you of new versions
|
||||
|
||||
### Manual
|
||||
|
||||
1. Download new APK from [Releases](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||
|
||||
2. Install (overwrites old version)
|
||||
|
||||
3. All data remains intact!
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Further Help
|
||||
|
||||
- **GitHub Issues:** [Report problem](https://github.com/inventory69/simple-notes-sync/issues)
|
||||
- **Complete docs:** [DOCS.en.md](DOCS.en.md)
|
||||
- **Server setup details:** [server/README.en.md](server/README.en.md)
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.1.0 · **Created:** December 2025
|
||||
240
QUICKSTART.md
240
QUICKSTART.md
@@ -1,269 +1,269 @@
|
||||
# Quick Start Guide - Simple Notes Sync 📝
|
||||
|
||||
> Schritt-für-Schritt Anleitung zur Installation und Einrichtung
|
||||
> Step-by-step installation and setup guide
|
||||
|
||||
**🌍 Sprachen:** **Deutsch** · [English](QUICKSTART.en.md)
|
||||
**🌍 Languages:** [Deutsch](QUICKSTART.de.md) · **English**
|
||||
|
||||
---
|
||||
|
||||
## Voraussetzungen
|
||||
## Prerequisites
|
||||
|
||||
- ✅ Android 8.0+ Smartphone/Tablet
|
||||
- ✅ WLAN-Verbindung
|
||||
- ✅ Eigener Server mit Docker (optional - für Self-Hosting)
|
||||
- ✅ Android 7.0+ smartphone/tablet
|
||||
- ✅ WiFi connection
|
||||
- ✅ Own server with Docker (optional - for self-hosting)
|
||||
|
||||
---
|
||||
|
||||
## Option 1: Mit eigenem Server (Self-Hosted) 🏠
|
||||
## Option 1: With own server (Self-Hosted) 🏠
|
||||
|
||||
### Schritt 1: WebDAV Server einrichten
|
||||
### Step 1: Setup WebDAV Server
|
||||
|
||||
Auf deinem Server (z.B. Raspberry Pi, NAS, VPS):
|
||||
On your server (e.g. Raspberry Pi, NAS, VPS):
|
||||
|
||||
```bash
|
||||
# Repository klonen
|
||||
# Clone repository
|
||||
git clone https://github.com/inventory69/simple-notes-sync.git
|
||||
cd simple-notes-sync/server
|
||||
|
||||
# Umgebungsvariablen konfigurieren
|
||||
# Configure environment variables
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
**In `.env` anpassen:**
|
||||
**Adjust in `.env`:**
|
||||
```env
|
||||
WEBDAV_PASSWORD=dein-sicheres-passwort-hier
|
||||
WEBDAV_PASSWORD=your-secure-password-here
|
||||
```
|
||||
|
||||
**Server starten:**
|
||||
**Start server:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**IP-Adresse finden:**
|
||||
**Find IP address:**
|
||||
```bash
|
||||
ip addr show | grep "inet " | grep -v 127.0.0.1
|
||||
```
|
||||
|
||||
➡️ **Notiere dir:** `http://DEINE-SERVER-IP:8080/`
|
||||
➡️ **Note down:** `http://YOUR-SERVER-IP:8080/`
|
||||
|
||||
---
|
||||
|
||||
### Schritt 2: App installieren
|
||||
### Step 2: Install App
|
||||
|
||||
1. **APK herunterladen:** [Neueste Version](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||
- Wähle: `simple-notes-sync-vX.X.X-standard-universal.apk`
|
||||
1. **Download APK:** [Latest version](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||
- Choose: `simple-notes-sync-vX.X.X-standard.apk`
|
||||
|
||||
2. **Installation erlauben:**
|
||||
- Android: Einstellungen → Sicherheit → "Unbekannte Quellen" für deinen Browser aktivieren
|
||||
2. **Allow installation:**
|
||||
- Android: Settings → Security → Enable "Unknown sources" for your browser
|
||||
|
||||
3. **APK öffnen und installieren**
|
||||
3. **Open and install APK**
|
||||
|
||||
---
|
||||
|
||||
### Schritt 3: App konfigurieren
|
||||
### Step 3: Configure App
|
||||
|
||||
1. **App öffnen**
|
||||
1. **Open app**
|
||||
|
||||
2. **Einstellungen öffnen** (⚙️ Icon oben rechts)
|
||||
2. **Open settings** (⚙️ icon top right)
|
||||
|
||||
3. **Server-Einstellungen konfigurieren:**
|
||||
3. **Configure server settings:**
|
||||
|
||||
| Feld | Wert |
|
||||
| Field | Value |
|
||||
|------|------|
|
||||
| **WebDAV Server URL** | `http://DEINE-SERVER-IP:8080/` |
|
||||
| **Benutzername** | `noteuser` |
|
||||
| **Passwort** | (dein Passwort aus `.env`) |
|
||||
| **Gateway SSID** | Name deines WLAN-Netzwerks |
|
||||
| **WebDAV Server URL** | `http://YOUR-SERVER-IP:8080/` |
|
||||
| **Username** | `noteuser` |
|
||||
| **Password** | (your password from `.env`) |
|
||||
|
||||
4. **"Verbindung testen"** drücken
|
||||
- ✅ Erfolg? → Weiter zu Schritt 4
|
||||
- ❌ Fehler? → Siehe [Troubleshooting](#troubleshooting)
|
||||
> **💡 Note:** Enter only the base URL (without `/notes`). The app automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown export.
|
||||
|
||||
5. **Auto-Sync aktivieren** (Toggle Switch)
|
||||
4. **Press "Test connection"**
|
||||
- ✅ Success? → Continue to step 4
|
||||
- ❌ Error? → See [Troubleshooting](#troubleshooting)
|
||||
|
||||
6. **Sync-Intervall wählen:**
|
||||
- **15 Min** - Maximale Aktualität (~0.8% Akku/Tag)
|
||||
- **30 Min** - Empfohlen (~0.4% Akku/Tag) ⭐
|
||||
- **60 Min** - Maximale Akkulaufzeit (~0.2% Akku/Tag)
|
||||
5. **Enable auto-sync** (toggle switch)
|
||||
|
||||
6. **Choose sync interval:**
|
||||
- **15 min** - Maximum currency (~0.8% battery/day)
|
||||
- **30 min** - Recommended (~0.4% battery/day) ⭐
|
||||
- **60 min** - Maximum battery life (~0.2% battery/day)
|
||||
|
||||
---
|
||||
|
||||
### Schritt 4: Erste Notiz erstellen
|
||||
### Step 4: Create First Note
|
||||
|
||||
1. Zurück zur Hauptansicht (← Pfeil)
|
||||
1. Back to main view (← arrow)
|
||||
|
||||
2. **"Notiz hinzufügen"** (+ Icon)
|
||||
2. **"Add note"** (+ icon)
|
||||
|
||||
3. Titel und Text eingeben
|
||||
3. Enter title and text
|
||||
|
||||
4. **Speichern** (💾 Icon)
|
||||
4. **Save** (💾 icon)
|
||||
|
||||
5. **Warten auf Auto-Sync** (oder manuell: ⚙️ → "Jetzt synchronisieren")
|
||||
5. **Wait for auto-sync** (or manually: ⚙️ → "Sync now")
|
||||
|
||||
🎉 **Fertig!** Deine Notizen werden automatisch synchronisiert!
|
||||
🎉 **Done!** Your notes will be automatically synchronized!
|
||||
|
||||
---
|
||||
|
||||
## Option 2: Nur lokale Notizen (kein Server) 📱
|
||||
## Option 2: Local notes only (no server) 📱
|
||||
|
||||
Du kannst Simple Notes auch **ohne Server** nutzen:
|
||||
You can also use Simple Notes **without a server**:
|
||||
|
||||
1. **App installieren** (siehe Schritt 2 oben)
|
||||
1. **Install app** (see step 2 above)
|
||||
|
||||
2. **Ohne Server-Konfiguration verwenden:**
|
||||
- Notizen werden nur lokal gespeichert
|
||||
- Kein Auto-Sync
|
||||
- Perfekt für reine Offline-Nutzung
|
||||
2. **Use without server configuration:**
|
||||
- Notes are only stored locally
|
||||
- No auto-sync
|
||||
- Perfect for offline-only use
|
||||
|
||||
---
|
||||
|
||||
## 🔋 Akku-Optimierung deaktivieren
|
||||
## 🔋 Disable Battery Optimization
|
||||
|
||||
Für zuverlässigen Auto-Sync:
|
||||
For reliable auto-sync:
|
||||
|
||||
1. **Einstellungen** → **Apps** → **Simple Notes Sync**
|
||||
1. **Settings** → **Apps** → **Simple Notes Sync**
|
||||
|
||||
2. **Akku** → **Akkuverbrauch**
|
||||
2. **Battery** → **Battery usage**
|
||||
|
||||
3. Wähle: **"Nicht optimieren"** oder **"Unbeschränkt"**
|
||||
3. Select: **"Don't optimize"** or **"Unrestricted"**
|
||||
|
||||
💡 **Hinweis:** Android Doze Mode kann trotzdem Sync im Standby verzögern (~60 Min). Das ist normal und betrifft alle Apps.
|
||||
💡 **Note:** Android Doze Mode may still delay sync in standby (~60 min). This is normal and affects all apps.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Sync-Intervalle im Detail
|
||||
## 📊 Sync Intervals in Detail
|
||||
|
||||
| Intervall | Syncs/Tag | Akku/Tag | Akku/Sync | Anwendungsfall |
|
||||
| Interval | Syncs/day | Battery/day | Battery/sync | Use case |
|
||||
|-----------|-----------|----------|-----------|----------------|
|
||||
| **15 Min** | ~96 | ~0.8% (~23 mAh) | ~0.008% | ⚡ Maximal aktuell (mehrere Geräte) |
|
||||
| **30 Min** | ~48 | ~0.4% (~12 mAh) | ~0.008% | ✓ **Empfohlen** - ausgewogen |
|
||||
| **60 Min** | ~24 | ~0.2% (~6 mAh) | ~0.008% | 🔋 Maximale Akkulaufzeit |
|
||||
| **15 min** | ~96 | ~0.8% (~23 mAh) | ~0.008% | ⚡ Maximum currency (multiple devices) |
|
||||
| **30 min** | ~48 | ~0.4% (~12 mAh) | ~0.008% | ✓ **Recommended** - balanced |
|
||||
| **60 min** | ~24 | ~0.2% (~6 mAh) | ~0.008% | 🔋 Maximum battery life |
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Verbindungstest schlägt fehl
|
||||
### Connection test fails
|
||||
|
||||
**Problem:** "Verbindung fehlgeschlagen" beim Test
|
||||
**Problem:** "Connection failed" during test
|
||||
|
||||
**Lösungen:**
|
||||
**Solutions:**
|
||||
|
||||
1. **Server läuft?**
|
||||
1. **Server running?**
|
||||
```bash
|
||||
docker compose ps
|
||||
# Sollte "Up" zeigen
|
||||
# Should show "Up"
|
||||
```
|
||||
|
||||
2. **Gleiche WLAN?**
|
||||
- Smartphone und Server müssen im selben Netzwerk sein
|
||||
- Prüfe SSID in App-Einstellungen
|
||||
2. **Same network?**
|
||||
- Smartphone and server must be on same network
|
||||
|
||||
3. **IP-Adresse korrekt?**
|
||||
3. **IP address correct?**
|
||||
```bash
|
||||
ip addr show | grep "inet "
|
||||
# Prüfe ob IP in URL stimmt
|
||||
# Check if IP in URL matches
|
||||
```
|
||||
|
||||
4. **Firewall?**
|
||||
```bash
|
||||
# Port 8080 öffnen (falls Firewall aktiv)
|
||||
# Open port 8080 (if firewall active)
|
||||
sudo ufw allow 8080/tcp
|
||||
```
|
||||
|
||||
5. **Server-Logs prüfen:**
|
||||
5. **Check server logs:**
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Auto-Sync funktioniert nicht
|
||||
### Auto-sync not working
|
||||
|
||||
**Problem:** Notizen werden nicht automatisch synchronisiert
|
||||
**Problem:** Notes are not automatically synchronized
|
||||
|
||||
**Lösungen:**
|
||||
**Solutions:**
|
||||
|
||||
1. **Auto-Sync aktiviert?**
|
||||
- ⚙️ Einstellungen → Toggle "Auto-Sync" muss **AN** sein
|
||||
1. **Auto-sync enabled?**
|
||||
- ⚙️ Settings → Toggle "Auto-sync" must be **ON**
|
||||
|
||||
2. **Akku-Optimierung deaktiviert?**
|
||||
- Siehe [Akku-Optimierung](#-akku-optimierung-deaktivieren)
|
||||
2. **Battery optimization disabled?**
|
||||
- See [Disable Battery Optimization](#-disable-battery-optimization)
|
||||
|
||||
3. **Im richtigen WLAN?**
|
||||
- Sync funktioniert nur wenn SSID = Gateway SSID
|
||||
- Prüfe aktuelle SSID in Android-Einstellungen
|
||||
3. **Connected to WiFi?**
|
||||
- Auto-sync triggers on any WiFi connection
|
||||
- Check if you're connected to a WiFi network
|
||||
|
||||
4. **Manuell testen:**
|
||||
- ⚙️ Einstellungen → "Jetzt synchronisieren"
|
||||
- Funktioniert das? → Auto-Sync sollte auch funktionieren
|
||||
4. **Test manually:**
|
||||
- ⚙️ Settings → "Sync now"
|
||||
- Works? → Auto-sync should work too
|
||||
|
||||
---
|
||||
|
||||
### Notizen werden nicht angezeigt
|
||||
### Notes not showing up
|
||||
|
||||
**Problem:** Nach Installation sind keine Notizen da, obwohl welche auf dem Server liegen
|
||||
**Problem:** After installation, no notes visible even though they exist on server
|
||||
|
||||
**Lösung:**
|
||||
**Solution:**
|
||||
|
||||
1. **Einmalig manuell synchronisieren:**
|
||||
- ⚙️ Einstellungen → "Jetzt synchronisieren"
|
||||
1. **Manually sync once:**
|
||||
- ⚙️ Settings → "Sync now"
|
||||
|
||||
2. **Server-Daten prüfen:**
|
||||
2. **Check server data:**
|
||||
```bash
|
||||
docker compose exec webdav ls -la /data/
|
||||
# Sollte .json Dateien zeigen
|
||||
# Should show .json files
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Fehler beim Sync
|
||||
### Sync errors
|
||||
|
||||
**Problem:** Fehlermeldung beim Synchronisieren
|
||||
**Problem:** Error message during sync
|
||||
|
||||
**Lösungen:**
|
||||
**Solutions:**
|
||||
|
||||
1. **"401 Unauthorized"** → Passwort falsch
|
||||
- Prüfe Passwort in App-Einstellungen
|
||||
- Vergleiche mit `.env` auf Server
|
||||
1. **"401 Unauthorized"** → Wrong password
|
||||
- Check password in app settings
|
||||
- Compare with `.env` on server
|
||||
|
||||
2. **"404 Not Found"** → URL falsch
|
||||
- Sollte enden mit `/` (z.B. `http://192.168.1.100:8080/`)
|
||||
2. **"404 Not Found"** → Wrong URL
|
||||
- Should end with `/` (e.g. `http://192.168.1.100:8080/`)
|
||||
|
||||
3. **"Network error"** → Keine Verbindung
|
||||
- Siehe [Verbindungstest schlägt fehl](#verbindungstest-schlägt-fehl)
|
||||
3. **"Network error"** → No connection
|
||||
- See [Connection test fails](#connection-test-fails)
|
||||
|
||||
---
|
||||
|
||||
## 📱 Updates
|
||||
|
||||
### Automatisch mit Obtainium (empfohlen)
|
||||
### Automatic with Obtainium (recommended)
|
||||
|
||||
1. **[Obtainium installieren](https://github.com/ImranR98/Obtanium/releases/latest)**
|
||||
1. **[Install Obtainium](https://github.com/ImranR98/Obtanium/releases/latest)**
|
||||
|
||||
2. **App hinzufügen:**
|
||||
2. **Add app:**
|
||||
- URL: `https://github.com/inventory69/simple-notes-sync`
|
||||
- Auto-Update aktivieren
|
||||
- Enable auto-update
|
||||
|
||||
3. **Fertig!** Obtainium benachrichtigt dich bei neuen Versionen
|
||||
3. **Done!** Obtainium notifies you of new versions
|
||||
|
||||
### Manuell
|
||||
### Manual
|
||||
|
||||
1. Neue APK von [Releases](https://github.com/inventory69/simple-notes-sync/releases/latest) herunterladen
|
||||
1. Download new APK from [Releases](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||
|
||||
2. Installieren (überschreibt alte Version)
|
||||
2. Install (overwrites old version)
|
||||
|
||||
3. Alle Daten bleiben erhalten!
|
||||
3. All data remains intact!
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Weitere Hilfe
|
||||
## 🆘 Further Help
|
||||
|
||||
- **GitHub Issues:** [Problem melden](https://github.com/inventory69/simple-notes-sync/issues)
|
||||
- **Vollständige Docs:** [DOCS.md](DOCS.md)
|
||||
- **Server Setup Details:** [server/README.md](server/README.md)
|
||||
- **GitHub Issues:** [Report problem](https://github.com/inventory69/simple-notes-sync/issues)
|
||||
- **Complete docs:** [DOCS.md](docs/DOCS.md)
|
||||
- **Server setup details:** [server/README.md](server/README.md)
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.1.0 · **Erstellt:** Dezember 2025
|
||||
**Version:** 1.1.0 · **Created:** December 2025
|
||||
|
||||
145
README.de.md
Normal file
145
README.de.md
Normal file
@@ -0,0 +1,145 @@
|
||||
<div align="center">
|
||||
<img src="android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png" alt="Logo" />
|
||||
</div>
|
||||
|
||||
<h1 align="center">Simple Notes Sync</h1>
|
||||
|
||||
<h4 align="center">Minimalistische Offline-Notizen mit intelligentem Sync - Einfachheit trifft smarte Synchronisation.</h4>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://www.android.com/)
|
||||
[](https://kotlinlang.org/)
|
||||
[](https://developer.android.com/compose/)
|
||||
[](https://m3.material.io/)
|
||||
[](LICENSE)
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes">
|
||||
<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png"
|
||||
alt="Get it on IzzyOnDroid" align="center" height="80" /></a>
|
||||
|
||||
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/inventory69/simple-notes-sync">
|
||||
<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png"
|
||||
alt="Get it on Obtainium" align="center" height="54" />
|
||||
</a>
|
||||
|
||||
<a href="https://f-droid.org/packages/dev.dettmer.simplenotes">
|
||||
<img src="https://f-droid.org/badge/get-it-on.png"
|
||||
alt="Get it on F-Droid" align="center" height="80" /></a>
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<strong>SHA-256 Hash des Signaturzertifikats:</strong><br /> 42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<br />[📱 APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest) · [📖 Dokumentation](docs/DOCS.de.md) · [🚀 Schnellstart](QUICKSTART.de.md)<br />
|
||||
**🌍** Deutsch · [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
## 📱 Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.png" width="250" alt="Sync status">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.png" width="250" alt="Edit note">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.png" width="250" alt="Edit checklist">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/4.png" width="250" alt="Settings">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/5.png" width="250" alt="Server settings">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/7.png" width="250" alt="Sync settings">
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
📝 Offline-first • 🔄 Smart Sync • 🔒 Self-hosted • 🔋 Akkuschonend
|
||||
|
||||
</div>
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- 📝 **Offline-first** – Funktioniert ohne Internet
|
||||
- 📊 **Flexible Ansichten** – Listen- und Grid-Layout
|
||||
- ✅ **Checklisten** – Tap-to-Check, Drag & Drop
|
||||
- 🔄 **Konfigurierbare Sync-Trigger** – onSave, onResume, WiFi, periodisch (15/30/60 Min), Boot
|
||||
- 📌 **Widgets** – Home-Screen Quick-Note und Notizlisten-Widget
|
||||
- 🔀 **Smartes Sortieren** – Nach Titel, Änderungsdatum, Erstelldatum, Farbe
|
||||
- ⚡ **Paralleler Sync** – Lädt bis zu 5 Notizen gleichzeitig herunter
|
||||
- 🌍 **Mehrsprachig** – Deutsch/Englisch mit Sprachauswahl
|
||||
- 🔒 **Self-hosted** – Deine Daten bleiben bei dir (WebDAV)
|
||||
- 💾 **Lokales Backup** – Export/Import als JSON-Datei (optional verschlüsselt)
|
||||
- 🖥️ **Desktop-Integration** – Markdown-Export für Obsidian, VS Code, Typora
|
||||
- 🎨 **Material Design 3** – Dynamischer Dark/Light Mode & Farben
|
||||
|
||||
➡️ **Vollständige Feature-Liste:** [docs/FEATURES.de.md](docs/FEATURES.de.md)
|
||||
|
||||
## 🚀 Schnellstart
|
||||
|
||||
### 1. Server Setup (5 Minuten)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/inventory69/simple-notes-sync.git
|
||||
cd simple-notes-sync/server
|
||||
cp .env.example .env
|
||||
# Passwort in .env setzen
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
➡️ **Details:** [Server Setup Guide](server/README.de.md)
|
||||
|
||||
### 2. App Installation (2 Minuten)
|
||||
|
||||
1. [APK herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||
2. Installieren & öffnen
|
||||
3. ⚙️ Einstellungen → Server konfigurieren:
|
||||
- **URL:** `http://DEINE-SERVER-IP:8080/` _(nur Base-URL!)_
|
||||
- **User:** `noteuser`
|
||||
- **Passwort:** _(aus .env)_
|
||||
- **WLAN:** _(dein Netzwerk-Name)_
|
||||
4. **Verbindung testen** → Auto-Sync aktivieren
|
||||
5. Fertig! 🎉
|
||||
|
||||
➡️ **Ausführliche Anleitung:** [QUICKSTART.de.md](QUICKSTART.de.md)
|
||||
|
||||
## 📚 Dokumentation
|
||||
|
||||
| Dokument | Inhalt |
|
||||
|----------|--------|
|
||||
| **[QUICKSTART.de.md](QUICKSTART.de.md)** | Schritt-für-Schritt Installation |
|
||||
| **[FEATURES.de.md](docs/FEATURES.de.md)** | Vollständige Feature-Liste |
|
||||
| **[BACKUP.de.md](docs/BACKUP.de.md)** | Backup & Wiederherstellung |
|
||||
| **[DESKTOP.de.md](docs/DESKTOP.de.md)** | Desktop-Integration (Markdown) |
|
||||
| **[SELF_SIGNED_SSL.md](docs/SELF_SIGNED_SSL.md)** | Self-signed SSL Zertifikat Setup |
|
||||
| **[DOCS.de.md](docs/DOCS.de.md)** | Technische Details & Troubleshooting |
|
||||
| **[CHANGELOG.de.md](CHANGELOG.de.md)** | Versionshistorie |
|
||||
| **[UPCOMING.de.md](docs/UPCOMING.de.md)** | Geplante Features 🚀 |
|
||||
| **[ÜBERSETZEN.md](docs/TRANSLATING.de.md)** | Übersetzungsanleitung 🌍 |
|
||||
|
||||
## 🛠️ Entwicklung
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleStandardRelease
|
||||
```
|
||||
|
||||
➡️ **Build-Anleitung:** [docs/DOCS.de.md#-build--deployment](docs/DOCS.de.md#-build--deployment)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Beiträge willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
## 📄 Lizenz
|
||||
|
||||
MIT License – siehe [LICENSE](LICENSE)
|
||||
|
||||
<div align="center">
|
||||
<br /><br />
|
||||
|
||||
**v1.8.1** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
||||
|
||||
</div>
|
||||
78
README.en.md
78
README.en.md
@@ -1,78 +0,0 @@
|
||||
# Simple Notes Sync 📝
|
||||
|
||||
> Minimalist offline notes with auto-sync to your own server
|
||||
|
||||
[](https://www.android.com/)
|
||||
[](https://m3.material.io/)
|
||||
[](LICENSE)
|
||||
|
||||
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Documentation](DOCS.en.md)** · **🚀 [Quick Start](QUICKSTART.en.md)**
|
||||
|
||||
**🌍 Languages:** [Deutsch](README.md) · **English**
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- 📝 Offline-First - Notes always available
|
||||
- 🔄 Auto-Sync - Configurable intervals (15/30/60 min)
|
||||
- 🏠 Self-Hosted - WebDAV on your server
|
||||
- 🔐 Privacy-First - No cloud, no tracking
|
||||
- 🔋 Battery-friendly - ~0.2-0.8% per day
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Server Setup
|
||||
|
||||
```bash
|
||||
cd server
|
||||
cp .env.example .env
|
||||
# Set password in .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
➡️ **Details:** [Server Setup Guide](server/README.en.md)
|
||||
|
||||
### 2. App Installation
|
||||
|
||||
1. [Download APK](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||
2. Install & open
|
||||
3. ⚙️ Settings → Configure server
|
||||
4. Enable auto-sync
|
||||
|
||||
➡️ **Details:** [Complete guide](QUICKSTART.en.md)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **[Quick Start Guide](QUICKSTART.en.md)** - Step-by-step guide for end users
|
||||
- **[Server Setup](server/README.en.md)** - Configure WebDAV server
|
||||
- **[Complete Docs](DOCS.en.md)** - Features, troubleshooting, build instructions
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleStandardRelease
|
||||
```
|
||||
|
||||
➡️ **Details:** [Build instructions in DOCS.en.md](DOCS.en.md)
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE)
|
||||
|
||||
**v1.1.0** · Built with Kotlin + Material Design 3
|
||||
159
README.md
159
README.md
@@ -1,78 +1,155 @@
|
||||
# Simple Notes Sync 📝
|
||||
<div align="center">
|
||||
<img src="android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png" alt="Logo" />
|
||||
</div>
|
||||
|
||||
> Minimalistische Offline-Notizen mit Auto-Sync zu deinem eigenen Server
|
||||
<h1 align="center">Simple Notes Sync</h1>
|
||||
|
||||
[](https://www.android.com/)
|
||||
[](https://m3.material.io/)
|
||||
[](LICENSE)
|
||||
<h4 align="center">Clean, offline-first notes with intelligent sync - simplicity meets smart synchronization.</h4>
|
||||
|
||||
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)**
|
||||
<div align="center">
|
||||
|
||||
**🌍 Sprachen:** **Deutsch** · [English](README.en.md)
|
||||
[](https://www.android.com/)
|
||||
[](https://kotlinlang.org/)
|
||||
[](https://developer.android.com/compose/)
|
||||
[](https://m3.material.io/)
|
||||
[](LICENSE)
|
||||
|
||||
---
|
||||
</div>
|
||||
|
||||
## Features
|
||||
<div align="center">
|
||||
|
||||
- 📝 Offline-First - Notizen immer verfügbar
|
||||
- 🔄 Auto-Sync - Konfigurierbare Intervalle (15/30/60 Min)
|
||||
- 🏠 Self-Hosted - WebDAV auf deinem Server
|
||||
- 🔐 Privacy-First - Keine Cloud, kein Tracking
|
||||
- 🔋 Akkuschonend - ~0.2-0.8% pro Tag
|
||||
<a href="https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes">
|
||||
<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png"
|
||||
alt="Get it on IzzyOnDroid" align="center" height="80" /></a>
|
||||
|
||||
---
|
||||
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/inventory69/simple-notes-sync">
|
||||
<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png"
|
||||
alt="Get it on Obtainium" align="center" height="54" />
|
||||
</a>
|
||||
|
||||
<a href="https://f-droid.org/packages/dev.dettmer.simplenotes">
|
||||
<img src="https://f-droid.org/badge/get-it-on.png"
|
||||
alt="Get it on F-Droid" align="center" height="80" /></a>
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<strong>SHA-256 hash of the signing certificate:</strong><br />42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<br />[📱 APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest) · [📖 Documentation](docs/DOCS.md) · [🚀 Quick Start](QUICKSTART.md)<br />
|
||||
**🌍** [Deutsch](README.de.md) · **English**
|
||||
|
||||
</div>
|
||||
|
||||
## 📱 Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="250" alt="Sync status">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="250" alt="Edit note">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="250" alt="Edit checklist">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="250" alt="Settings">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="250" alt="Server settings">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/7.png" width="250" alt="Sync settings">
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
📝 Offline-first • 🔄 Smart Sync • 🔒 Self-hosted • 🔋 Battery-friendly
|
||||
|
||||
</div>
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- 📝 **Offline-first** - Works without internet
|
||||
- 📊 **Flexible views** - Switch between list and grid layout
|
||||
- ✅ **Checklists** - Tap-to-check, drag & drop
|
||||
- 🔄 **Configurable sync triggers** - onSave, onResume, WiFi-connect, periodic (15/30/60 min), boot
|
||||
- 📌 **Widgets** - Home screen quick-note and note list widgets
|
||||
- 🔀 **Smart sorting** - By title, date modified, date created, color
|
||||
- ⚡ **Parallel sync** - Downloads up to 5 notes simultaneously
|
||||
- 🌍 **Multilingual** - English/German with language selector
|
||||
- 🔒 **Self-hosted** - Your data stays with you (WebDAV)
|
||||
- 💾 **Local backup** - Export/Import as JSON file (encryption available)
|
||||
- 🖥️ **Desktop integration** - Markdown export for Obsidian, VS Code, Typora
|
||||
- 🎨 **Material Design 3** - Dynamic dark/light mode & colors based on system settings
|
||||
|
||||
➡️ **Complete feature list:** [FEATURES.md](docs/FEATURES.md)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Server Setup
|
||||
### 1. Server Setup (5 minutes)
|
||||
|
||||
```bash
|
||||
cd server
|
||||
git clone https://github.com/inventory69/simple-notes-sync.git
|
||||
cd simple-notes-sync/server
|
||||
cp .env.example .env
|
||||
# Passwort in .env setzen
|
||||
# Set password in .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
➡️ **Details:** [Server Setup Guide](server/README.md)
|
||||
|
||||
### 2. App Installation
|
||||
### 2. App Installation (2 minutes)
|
||||
|
||||
1. [APK herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||
2. Installieren & öffnen
|
||||
3. ⚙️ Einstellungen → Server konfigurieren
|
||||
4. Auto-Sync aktivieren
|
||||
1. [Download APK](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||
2. Install & open
|
||||
3. ⚙️ Settings → Configure server:
|
||||
- **URL:** `http://YOUR-SERVER-IP:8080/` _(base URL only!)_
|
||||
- **User:** `noteuser`
|
||||
- **Password:** _(from .env)_
|
||||
- **WiFi:** _(your network name)_
|
||||
4. **Test connection** → Enable auto-sync
|
||||
5. Done! 🎉
|
||||
|
||||
➡️ **Details:** [Vollständige Anleitung](QUICKSTART.md)
|
||||
➡️ **Detailed guide:** [QUICKSTART.md](QUICKSTART.md)
|
||||
|
||||
---
|
||||
## 📚 Documentation
|
||||
|
||||
## 📚 Dokumentation
|
||||
|
||||
- **[Quick Start Guide](QUICKSTART.md)** - Schritt-für-Schritt Anleitung für Endbenutzer
|
||||
- **[Server Setup](server/README.md)** - WebDAV Server konfigurieren
|
||||
- **[Vollständige Docs](DOCS.md)** - Features, Troubleshooting, Build-Anleitung
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Entwicklung
|
||||
| Document | Content |
|
||||
|----------|---------|
|
||||
| **[QUICKSTART.md](QUICKSTART.md)** | Step-by-step installation |
|
||||
| **[FEATURES.md](docs/FEATURES.md)** | Complete feature list |
|
||||
| **[BACKUP.md](docs/BACKUP.md)** | Backup & restore guide |
|
||||
| **[DESKTOP.md](docs/DESKTOP.md)** | Desktop integration (Markdown) |
|
||||
| **[SELF_SIGNED_SSL.md](docs/SELF_SIGNED_SSL.md)** | Self-signed SSL certificate setup |
|
||||
| **[DOCS.md](docs/DOCS.md)** | Technical details & troubleshooting |
|
||||
| **[CHANGELOG.md](CHANGELOG.md)** | Version history |
|
||||
| **[UPCOMING.md](docs/UPCOMING.md)** | Upcoming features 🚀 |
|
||||
| **[TRANSLATING.md](docs/TRANSLATING.md)** | Translation guide 🌍 |
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleStandardRelease
|
||||
```
|
||||
|
||||
➡️ **Details:** [Build-Anleitung in DOCS.md](DOCS.md)
|
||||
➡️ **Build guide:** [DOCS.md](docs/DOCS.md#-build--deployment)
|
||||
|
||||
---
|
||||
## 💡 Feature Requests & Ideas
|
||||
|
||||
Have an idea for a new feature or improvement? We'd love to hear it!
|
||||
|
||||
➡️ **How to suggest features:**
|
||||
|
||||
1. Check [existing discussions](https://github.com/inventory69/simple-notes-sync/discussions) to see if someone already suggested it
|
||||
2. If not, start a new discussion in the "Feature Requests / Ideas" category
|
||||
3. Upvote (👍) features you'd like to see
|
||||
|
||||
Features with enough community support will be considered for implementation. Please keep in mind that this app is designed to stay simple and user-friendly.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Beiträge sind willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md) für Details.
|
||||
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
---
|
||||
## 📄 License
|
||||
|
||||
## 📄 Lizenz
|
||||
MIT License - see [LICENSE](LICENSE)
|
||||
|
||||
MIT License - siehe [LICENSE](LICENSE)
|
||||
<div align="center">
|
||||
<br /><br />
|
||||
|
||||
**v1.1.0** · Gebaut mit Kotlin + Material Design 3
|
||||
**v1.8.1** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
||||
|
||||
</div>
|
||||
|
||||
61
android/.editorconfig
Normal file
61
android/.editorconfig
Normal file
@@ -0,0 +1,61 @@
|
||||
# ⚡ v1.3.1: EditorConfig for ktlint
|
||||
# https://editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
max_line_length = 120
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{kt,kts}]
|
||||
# ktlint rules
|
||||
ktlint_code_style = android_studio
|
||||
ktlint_standard_no-wildcard-imports = disabled
|
||||
ktlint_standard_package-name = enabled
|
||||
ktlint_standard_filename = enabled
|
||||
ktlint_standard_class-naming = enabled
|
||||
ktlint_standard_function-naming = enabled
|
||||
ktlint_standard_property-naming = enabled
|
||||
ktlint_standard_backing-property-naming = enabled
|
||||
ktlint_standard_enum-entry-name-case = enabled
|
||||
ktlint_standard_multiline-if-else = enabled
|
||||
ktlint_standard_no-empty-class-body = enabled
|
||||
ktlint_standard_no-empty-first-line-in-class-body = enabled
|
||||
ktlint_standard_blank-line-before-declaration = enabled
|
||||
ktlint_standard_context-receiver-wrapping = enabled
|
||||
ktlint_standard_function-expression-body = disabled
|
||||
ktlint_standard_function-literal = enabled
|
||||
ktlint_standard_function-type-modifier-spacing = enabled
|
||||
ktlint_standard_kdoc-wrapping = enabled
|
||||
ktlint_standard_modifier-list-spacing = enabled
|
||||
ktlint_standard_no-blank-line-in-list = enabled
|
||||
ktlint_standard_no-consecutive-comments = disabled
|
||||
ktlint_standard_no-single-line-block-comment = enabled
|
||||
ktlint_standard_parameter-list-spacing = enabled
|
||||
ktlint_standard_parameter-list-wrapping = enabled
|
||||
ktlint_standard_property-wrapping = enabled
|
||||
ktlint_standard_spacing-between-function-name-and-opening-parenthesis = enabled
|
||||
ktlint_standard_statement-wrapping = enabled
|
||||
ktlint_standard_string-template-indent = disabled
|
||||
ktlint_standard_try-catch-finally-spacing = enabled
|
||||
ktlint_standard_type-argument-list-spacing = enabled
|
||||
ktlint_standard_type-parameter-list-spacing = enabled
|
||||
ktlint_standard_value-argument-comment = enabled
|
||||
ktlint_standard_value-parameter-comment = enabled
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{xml,json}]
|
||||
indent_size = 2
|
||||
|
||||
[*.yml]
|
||||
indent_size = 2
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
1
android/.gitignore
vendored
1
android/.gitignore
vendored
@@ -18,3 +18,4 @@ local.properties
|
||||
key.properties
|
||||
*.jks
|
||||
*.keystore
|
||||
/app/src/main/assets/changelogs/
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler
|
||||
alias(libs.plugins.ktlint) // ✅ v1.6.1: Reaktiviert nach Code-Cleanup
|
||||
alias(libs.plugins.detekt)
|
||||
}
|
||||
|
||||
import java.util.Properties
|
||||
@@ -17,32 +20,27 @@ android {
|
||||
applicationId = "dev.dettmer.simplenotes"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 3 // 🔥 Bugfix: Spurious Sync Error Notifications + Sync Icon Bug
|
||||
versionName = "1.1.1" // 🔥 Bugfix: Server-Erreichbarkeits-Check + Notification-Improvements
|
||||
versionCode = 21 // 🐛 v1.8.1: Checklist Fixes, Widget Sorting, ProGuard Audit
|
||||
versionName = "1.8.1" // 🐛 v1.8.1: Bugfix & Polish Release
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
// 🔥 NEU: Build Date für About Screen
|
||||
buildConfigField("String", "BUILD_DATE", "\"${getBuildDate()}\"")
|
||||
}
|
||||
|
||||
// Enable multiple APKs per ABI for smaller downloads
|
||||
splits {
|
||||
abi {
|
||||
isEnable = true
|
||||
reset()
|
||||
include("armeabi-v7a", "arm64-v8a")
|
||||
isUniversalApk = true // Also generate universal APK
|
||||
}
|
||||
// Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility
|
||||
dependenciesInfo {
|
||||
includeInApk = false // Removes DEPENDENCY_INFO_BLOCK from APK
|
||||
includeInBundle = false // Also disable for AAB (Google Play)
|
||||
}
|
||||
|
||||
// Product Flavors for F-Droid and standard builds
|
||||
// Note: APK splits are disabled to ensure single APK output
|
||||
flavorDimensions += "distribution"
|
||||
productFlavors {
|
||||
create("fdroid") {
|
||||
dimension = "distribution"
|
||||
// F-Droid builds have no proprietary dependencies
|
||||
// All dependencies in this project are already FOSS-compatible
|
||||
// No APK splits - F-Droid expects single universal APK
|
||||
}
|
||||
|
||||
create("standard") {
|
||||
@@ -69,6 +67,16 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
// ⚡ v1.3.1: Debug-Builds können parallel zur Release-App installiert werden
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
isDebuggable = true
|
||||
|
||||
// Optionales separates Icon-Label für Debug-Builds
|
||||
resValue("string", "app_name_debug", "Simple Notes (Debug)")
|
||||
}
|
||||
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
@@ -88,8 +96,18 @@ android {
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
buildConfig = true // Enable BuildConfig generation
|
||||
compose = true // v1.5.0: Jetpack Compose für Settings Redesign
|
||||
}
|
||||
|
||||
// v1.7.0: Mock Android framework classes in unit tests (Log, etc.)
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
}
|
||||
|
||||
// v1.5.0 Hotfix: Strong Skipping Mode für bessere 120Hz Performance
|
||||
// v1.6.1: Feature ist ab dieser Kotlin/Compose Version bereits Standard
|
||||
// composeCompiler { }
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
@@ -124,14 +142,91 @@ dependencies {
|
||||
// LocalBroadcastManager für UI Refresh
|
||||
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
|
||||
|
||||
// SwipeRefreshLayout für Pull-to-Refresh
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||
|
||||
// 🔐 v1.7.0: AndroidX Security Crypto für Backup-Verschlüsselung
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// v1.5.0: Jetpack Compose für Settings Redesign
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material.icons)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🆕 v1.8.0: Homescreen Widgets
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
implementation("androidx.glance:glance-appwidget:1.1.1")
|
||||
implementation("androidx.glance:glance-material3:1.1.1")
|
||||
|
||||
// Testing (bleiben so)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
}
|
||||
|
||||
// 🔥 NEU: Helper function für Build Date
|
||||
fun getBuildDate(): String {
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
return dateFormat.format(Date())
|
||||
// ✅ v1.6.1: ktlint reaktiviert nach Code-Cleanup
|
||||
ktlint {
|
||||
android = true
|
||||
outputToConsole = true
|
||||
ignoreFailures = true // Parser-Probleme in WebDavSyncService.kt und build.gradle.kts
|
||||
enableExperimentalRules = false
|
||||
|
||||
filter {
|
||||
exclude("**/generated/**")
|
||||
exclude("**/build/**")
|
||||
// Legacy adapters with ktlint parser issues
|
||||
exclude("**/adapters/NotesAdapter.kt")
|
||||
exclude("**/SettingsActivity.kt")
|
||||
}
|
||||
}
|
||||
|
||||
// ⚡ v1.3.1: detekt-Konfiguration
|
||||
detekt {
|
||||
buildUponDefaultConfig = true
|
||||
allRules = false
|
||||
config.setFrom(files("$rootDir/config/detekt/detekt.yml"))
|
||||
baseline = file("$rootDir/config/detekt/baseline.xml")
|
||||
|
||||
// Parallel-Verarbeitung für schnellere Checks
|
||||
parallel = true
|
||||
}
|
||||
|
||||
// 📋 v1.8.0: Copy F-Droid changelogs to assets for post-update dialog
|
||||
// Single source of truth: F-Droid changelogs are reused in the app
|
||||
tasks.register<Copy>("copyChangelogsToAssets") {
|
||||
description = "Copies F-Droid changelogs to app assets for post-update dialog"
|
||||
|
||||
from("$rootDir/../fastlane/metadata/android") {
|
||||
include("*/changelogs/*.txt")
|
||||
}
|
||||
|
||||
into("$projectDir/src/main/assets/changelogs")
|
||||
|
||||
// Preserve directory structure: en-US/20.txt, de-DE/20.txt
|
||||
eachFile {
|
||||
val parts = relativePath.segments
|
||||
if (parts.size >= 3) {
|
||||
// parts[0] = locale (en-US, de-DE)
|
||||
// parts[1] = "changelogs"
|
||||
// parts[2] = version file (20.txt)
|
||||
relativePath = RelativePath(true, parts[0], parts[2])
|
||||
}
|
||||
}
|
||||
|
||||
includeEmptyDirs = false
|
||||
}
|
||||
|
||||
// Run before preBuild to ensure changelogs are available
|
||||
tasks.named("preBuild") {
|
||||
dependsOn("copyChangelogsToAssets")
|
||||
}
|
||||
38
android/app/proguard-rules.pro
vendored
38
android/app/proguard-rules.pro
vendored
@@ -59,5 +59,41 @@
|
||||
-keep class * implements com.google.gson.JsonSerializer
|
||||
-keep class * implements com.google.gson.JsonDeserializer
|
||||
|
||||
# Keep your app's data classes
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# App-specific rules: Only keep what Gson/reflection needs
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
# 🔧 v1.8.1 FIX: Breite Regel verwenden statt spezifischer Klassen
|
||||
#
|
||||
# GRUND: NoteRaw ist eine private data class innerhalb von Note.Companion.
|
||||
# Der JVM-Klassenname ist Note$Companion$NoteRaw, NICHT Note$NoteRaw.
|
||||
# Die spezifische Regel griff nicht → R8 obfuskierte NoteRaw-Felder
|
||||
# → Gson konnte keine JSON-Felder matchen → ALLE Notizen unlesbar!
|
||||
#
|
||||
# Sichere Lösung: Alle App-Klassen behalten (wie in v1.7.2).
|
||||
# APK-Größenoptimierung kann in v1.9.0 sicher evaluiert werden.
|
||||
-keep class dev.dettmer.simplenotes.** { *; }
|
||||
|
||||
# v1.7.1: Suppress TextInclusionStrategy warnings on older Android versions
|
||||
# This class only exists on API 35+ but Compose handles the fallback gracefully
|
||||
-dontwarn android.text.Layout$TextInclusionStrategy
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# v1.8.1: Widget & Compose Fixes
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Glance Widget ActionCallbacks (instanziiert via Reflection durch actionRunCallback<T>())
|
||||
# Ohne diese Rule findet R8 die Klassen nicht zur Laufzeit → Widget-Crash
|
||||
-keep class dev.dettmer.simplenotes.widget.*Action { *; }
|
||||
-keep class dev.dettmer.simplenotes.widget.*Receiver { *; }
|
||||
|
||||
# Glance Widget State (Preferences-basiert, intern via Reflection)
|
||||
-keep class androidx.glance.appwidget.state.** { *; }
|
||||
-keep class androidx.datastore.preferences.** { *; }
|
||||
|
||||
# Compose Text Layout: Verhindert dass R8 onTextLayout-Callbacks
|
||||
# als Side-Effect-Free optimiert (behebt Gradient-Regression)
|
||||
-keepclassmembers class androidx.compose.foundation.text.** {
|
||||
<methods>;
|
||||
}
|
||||
-keep class androidx.compose.ui.text.TextLayoutResult { *; }
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Debug overlay for launcher icon - original icon + red "DEBUG" badge -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Original foreground icon from mipmap -->
|
||||
<item android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
|
||||
<!-- Debug Badge: Red circle with "D" in top-right corner -->
|
||||
<item>
|
||||
<inset
|
||||
android:insetLeft="60dp"
|
||||
android:insetTop="10dp"
|
||||
android:insetRight="10dp"
|
||||
android:insetBottom="60dp">
|
||||
<layer-list>
|
||||
<!-- Red circle background -->
|
||||
<item>
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="#E53935" />
|
||||
<size android:width="28dp" android:height="28dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
</inset>
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Debug version of adaptive icon with badge -->
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Different background color for debug (darker/orange tint) -->
|
||||
<background android:drawable="@color/ic_launcher_background_debug"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground_debug"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Debug version of adaptive icon with badge (round) -->
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Different background color for debug (darker/orange tint) -->
|
||||
<background android:drawable="@color/ic_launcher_background_debug"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground_debug"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
5
android/app/src/debug/res/values/colors_debug.xml
Normal file
5
android/app/src/debug/res/values/colors_debug.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Debug version: Orange-tinted background to distinguish from release -->
|
||||
<color name="ic_launcher_background_debug">#FFB74D</color>
|
||||
</resources>
|
||||
@@ -5,8 +5,6 @@
|
||||
<!-- Network & Sync Permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||
|
||||
<!-- Notifications -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
@@ -14,6 +12,11 @@
|
||||
<!-- Battery Optimization (for WorkManager background sync) -->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<!-- v1.7.1: Foreground Service for Expedited Work (Android 9-11) -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<!-- v1.7.1: Foreground Service Type for Android 10+ -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<!-- BOOT Permission - CRITICAL für Auto-Sync nach Neustart! -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
@@ -24,13 +27,15 @@
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SimpleNotes"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
tools:targetApi="31">
|
||||
<!-- MainActivity v1.5.0 (Jetpack Compose) - Launcher -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:name=".ui.main.ComposeMainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.SimpleNotes.Splash">
|
||||
<intent-filter>
|
||||
@@ -39,16 +44,37 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Editor Activity -->
|
||||
<!-- Legacy MainActivity (XML-based) - kept for reference -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.SimpleNotes" />
|
||||
|
||||
<!-- Editor Activity (Legacy - XML-based) -->
|
||||
<activity
|
||||
android:name=".NoteEditorActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:parentActivityName=".MainActivity" />
|
||||
android:parentActivityName=".ui.main.ComposeMainActivity" />
|
||||
|
||||
<!-- Settings Activity -->
|
||||
<!-- Editor Activity v1.5.0 (Jetpack Compose) -->
|
||||
<activity
|
||||
android:name=".ui.editor.ComposeNoteEditorActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:parentActivityName=".ui.main.ComposeMainActivity"
|
||||
android:theme="@style/Theme.SimpleNotes" />
|
||||
|
||||
<!-- Settings Activity (Legacy - XML-based) -->
|
||||
<activity
|
||||
android:name=".SettingsActivity"
|
||||
android:parentActivityName=".MainActivity" />
|
||||
android:parentActivityName=".ui.main.ComposeMainActivity" />
|
||||
|
||||
<!-- Settings Activity v1.5.0 (Jetpack Compose) -->
|
||||
<!-- v1.8.0: Handle locale changes without recreate for smooth language switching -->
|
||||
<activity
|
||||
android:name=".ui.settings.ComposeSettingsActivity"
|
||||
android:configChanges="locale|layoutDirection"
|
||||
android:parentActivityName=".ui.main.ComposeMainActivity"
|
||||
android:theme="@style/Theme.SimpleNotes" />
|
||||
|
||||
<!-- Boot Receiver - Startet WorkManager nach Reboot -->
|
||||
<receiver
|
||||
@@ -72,6 +98,31 @@
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- v1.7.1: WorkManager SystemForegroundService for Expedited Work -->
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
tools:node="merge" />
|
||||
|
||||
<!-- 🆕 v1.8.0: Widget Receiver -->
|
||||
<receiver
|
||||
android:name=".widget.NoteWidgetReceiver"
|
||||
android:exported="true"
|
||||
android:label="@string/widget_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/note_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- 🆕 v1.8.0: Widget Config Activity -->
|
||||
<activity
|
||||
android:name=".widget.NoteWidgetConfigActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.SimpleNotes" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
|
||||
|
||||
package dev.dettmer.simplenotes
|
||||
|
||||
import android.Manifest
|
||||
@@ -21,30 +23,56 @@ import com.google.android.material.color.DynamicColors
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dev.dettmer.simplenotes.adapters.NotesAdapter
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.sync.SyncWorker
|
||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||
import dev.dettmer.simplenotes.utils.showToast
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import android.widget.TextView
|
||||
import android.widget.CheckBox
|
||||
import android.widget.Toast
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import android.view.Gravity
|
||||
import android.widget.PopupMenu
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
|
||||
/**
|
||||
* Legacy MainActivity - DEPRECATED seit v1.5.0, wird entfernt in v2.0.0
|
||||
* Ersetzt durch ComposeMainActivity
|
||||
*/
|
||||
@Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var recyclerViewNotes: RecyclerView
|
||||
private lateinit var emptyStateCard: MaterialCardView
|
||||
private lateinit var fabAddNote: FloatingActionButton
|
||||
private lateinit var toolbar: MaterialToolbar
|
||||
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
|
||||
|
||||
// 🔄 v1.3.1: Sync Status Banner
|
||||
private lateinit var syncStatusBanner: LinearLayout
|
||||
private lateinit var syncStatusText: TextView
|
||||
|
||||
private lateinit var adapter: NotesAdapter
|
||||
private val storage by lazy { NotesStorage(this) }
|
||||
|
||||
// Menu reference for sync button state
|
||||
private var optionsMenu: Menu? = null
|
||||
|
||||
// Track pending deletions to prevent flicker when notes reload
|
||||
private val pendingDeletions = mutableSetOf<String>()
|
||||
|
||||
@@ -58,6 +86,8 @@ class MainActivity : AppCompatActivity() {
|
||||
private const val REQUEST_SETTINGS = 1002
|
||||
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
|
||||
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
|
||||
private const val SYNC_COMPLETED_DELAY_MS = 1500L
|
||||
private const val ERROR_DISPLAY_DELAY_MS = 3000L
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,9 +119,10 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
// File Logging aktivieren wenn eingestellt
|
||||
if (prefs.getBoolean("file_logging_enabled", false)) {
|
||||
Logger.enableFileLogging(this)
|
||||
// Logger initialisieren und File-Logging aktivieren wenn eingestellt
|
||||
Logger.init(this)
|
||||
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
|
||||
Logger.setFileLoggingEnabled(true)
|
||||
}
|
||||
|
||||
// Alte Sync-Notifications beim App-Start löschen
|
||||
@@ -102,12 +133,82 @@ class MainActivity : AppCompatActivity() {
|
||||
requestNotificationPermission()
|
||||
}
|
||||
|
||||
// 🌍 v1.7.2: Debug Locale für Fehlersuche
|
||||
logLocaleInfo()
|
||||
|
||||
findViews()
|
||||
setupToolbar()
|
||||
setupRecyclerView()
|
||||
setupFab()
|
||||
|
||||
// v1.4.1: Migrate checklists for backwards compatibility
|
||||
migrateChecklistsForBackwardsCompat()
|
||||
|
||||
loadNotes()
|
||||
|
||||
// 🔄 v1.3.1: Observe sync state for UI updates
|
||||
setupSyncStateObserver()
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔄 v1.3.1: Beobachtet Sync-Status für UI-Feedback
|
||||
*/
|
||||
private fun setupSyncStateObserver() {
|
||||
SyncStateManager.syncStatus.observe(this) { status ->
|
||||
when (status.state) {
|
||||
SyncStateManager.SyncState.SYNCING -> {
|
||||
// Disable sync controls
|
||||
setSyncControlsEnabled(false)
|
||||
// 🔄 v1.3.1: Show sync status banner (ersetzt SwipeRefresh-Animation)
|
||||
syncStatusText.text = getString(R.string.sync_status_syncing)
|
||||
syncStatusBanner.visibility = View.VISIBLE
|
||||
}
|
||||
SyncStateManager.SyncState.COMPLETED -> {
|
||||
// Re-enable sync controls
|
||||
setSyncControlsEnabled(true)
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
// Show completed briefly, then hide
|
||||
syncStatusText.text = status.message ?: getString(R.string.sync_status_completed)
|
||||
lifecycleScope.launch {
|
||||
kotlinx.coroutines.delay(SYNC_COMPLETED_DELAY_MS)
|
||||
syncStatusBanner.visibility = View.GONE
|
||||
SyncStateManager.reset()
|
||||
}
|
||||
}
|
||||
SyncStateManager.SyncState.ERROR -> {
|
||||
// Re-enable sync controls
|
||||
setSyncControlsEnabled(true)
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
// Show error briefly, then hide
|
||||
syncStatusText.text = status.message ?: getString(R.string.sync_status_error)
|
||||
lifecycleScope.launch {
|
||||
kotlinx.coroutines.delay(ERROR_DISPLAY_DELAY_MS)
|
||||
syncStatusBanner.visibility = View.GONE
|
||||
SyncStateManager.reset()
|
||||
}
|
||||
}
|
||||
SyncStateManager.SyncState.IDLE -> {
|
||||
setSyncControlsEnabled(true)
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
syncStatusBanner.visibility = View.GONE
|
||||
}
|
||||
// v1.5.0: Silent-Sync - Banner nicht anzeigen, aber Sync-Controls deaktivieren
|
||||
SyncStateManager.SyncState.SYNCING_SILENT -> {
|
||||
setSyncControlsEnabled(false)
|
||||
// Kein Banner anzeigen bei Silent-Sync (z.B. onResume Auto-Sync)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔄 v1.3.1: Aktiviert/deaktiviert Sync-Controls (Button + SwipeRefresh)
|
||||
*/
|
||||
private fun setSyncControlsEnabled(enabled: Boolean) {
|
||||
// Menu Sync-Button
|
||||
optionsMenu?.findItem(R.id.action_sync)?.isEnabled = enabled
|
||||
// SwipeRefresh
|
||||
swipeRefreshLayout.isEnabled = enabled
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -136,6 +237,7 @@ class MainActivity : AppCompatActivity() {
|
||||
* - Nur Success-Toast (kein "Auto-Sync..." Toast)
|
||||
*
|
||||
* NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!)
|
||||
* v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt
|
||||
*/
|
||||
private fun triggerAutoSync(source: String = "unknown") {
|
||||
// Throttling: Max 1 Sync pro Minute
|
||||
@@ -143,6 +245,13 @@ class MainActivity : AppCompatActivity() {
|
||||
return
|
||||
}
|
||||
|
||||
// 🔄 v1.3.1: Check if sync already running
|
||||
// v1.5.0: silent=true - kein Banner bei Auto-Sync
|
||||
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
|
||||
|
||||
// Update last sync timestamp
|
||||
@@ -152,6 +261,13 @@ class MainActivity : AppCompatActivity() {
|
||||
try {
|
||||
val syncService = WebDavSyncService(this@MainActivity)
|
||||
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
|
||||
SyncStateManager.reset()
|
||||
return@launch
|
||||
}
|
||||
|
||||
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
|
||||
val isReachable = withContext(Dispatchers.IO) {
|
||||
syncService.isServerReachable()
|
||||
@@ -159,6 +275,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
if (!isReachable) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
|
||||
SyncStateManager.reset()
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -170,6 +287,7 @@ class MainActivity : AppCompatActivity() {
|
||||
// Feedback abhängig von Source
|
||||
if (result.isSuccess && result.syncedCount > 0) {
|
||||
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
|
||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||
|
||||
// onResume: Nur Success-Toast
|
||||
showToast("✅ Gesynct: ${result.syncedCount} Notizen")
|
||||
@@ -177,14 +295,17 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
} else if (result.isSuccess) {
|
||||
Logger.d(TAG, "ℹ️ Auto-sync ($source): No changes")
|
||||
SyncStateManager.markCompleted()
|
||||
|
||||
} else {
|
||||
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
// Kein Toast - App ist im Hintergrund
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
|
||||
SyncStateManager.markError(e.message)
|
||||
// Kein Toast - App ist im Hintergrund
|
||||
}
|
||||
}
|
||||
@@ -220,6 +341,11 @@ class MainActivity : AppCompatActivity() {
|
||||
emptyStateCard = findViewById(R.id.emptyStateCard)
|
||||
fabAddNote = findViewById(R.id.fabAddNote)
|
||||
toolbar = findViewById(R.id.toolbar)
|
||||
swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
|
||||
|
||||
// 🔄 v1.3.1: Sync Status Banner
|
||||
syncStatusBanner = findViewById(R.id.syncStatusBanner)
|
||||
syncStatusText = findViewById(R.id.syncStatusText)
|
||||
}
|
||||
|
||||
private fun setupToolbar() {
|
||||
@@ -233,10 +359,74 @@ class MainActivity : AppCompatActivity() {
|
||||
recyclerViewNotes.adapter = adapter
|
||||
recyclerViewNotes.layoutManager = LinearLayoutManager(this)
|
||||
|
||||
// 🔥 v1.1.2: Setup Pull-to-Refresh
|
||||
setupPullToRefresh()
|
||||
|
||||
// Setup Swipe-to-Delete
|
||||
setupSwipeToDelete()
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Pull-to-Refresh für manuellen Sync (v1.1.2)
|
||||
*/
|
||||
private fun setupPullToRefresh() {
|
||||
swipeRefreshLayout.setOnRefreshListener {
|
||||
Logger.d(TAG, "🔄 Pull-to-Refresh triggered - starting manual sync")
|
||||
|
||||
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
|
||||
if (!SyncStateManager.tryStartSync("pullToRefresh")) {
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
return@setOnRefreshListener
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
|
||||
if (serverUrl.isNullOrEmpty()) {
|
||||
showToast("⚠️ Server noch nicht konfiguriert")
|
||||
SyncStateManager.reset()
|
||||
return@launch
|
||||
}
|
||||
|
||||
val syncService = WebDavSyncService(this@MainActivity)
|
||||
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
|
||||
SyncStateManager.markCompleted(getString(R.string.snackbar_already_synced))
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Check if server is reachable
|
||||
if (!syncService.isServerReachable()) {
|
||||
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Perform sync
|
||||
val result = syncService.syncNotes()
|
||||
|
||||
if (result.isSuccess) {
|
||||
SyncStateManager.markCompleted(getString(R.string.snackbar_synced_count, result.syncedCount))
|
||||
loadNotes()
|
||||
} else {
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Pull-to-Refresh sync failed", e)
|
||||
SyncStateManager.markError(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set Material 3 color scheme
|
||||
swipeRefreshLayout.setColorSchemeResources(
|
||||
com.google.android.material.R.color.material_dynamic_primary50
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupSwipeToDelete() {
|
||||
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
||||
0, // No drag
|
||||
@@ -249,36 +439,20 @@ class MainActivity : AppCompatActivity() {
|
||||
): Boolean = false
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
val position = viewHolder.adapterPosition
|
||||
val note = adapter.currentList[position]
|
||||
val notesCopy = adapter.currentList.toMutableList()
|
||||
val position = viewHolder.bindingAdapterPosition
|
||||
val swipedNote = adapter.currentList[position]
|
||||
|
||||
// Track pending deletion to prevent flicker
|
||||
pendingDeletions.add(note.id)
|
||||
// Store original list BEFORE removing note
|
||||
val originalList = adapter.currentList.toList()
|
||||
|
||||
// Remove from list immediately for visual feedback
|
||||
notesCopy.removeAt(position)
|
||||
adapter.submitList(notesCopy)
|
||||
|
||||
// Show Snackbar with UNDO
|
||||
Snackbar.make(
|
||||
recyclerViewNotes,
|
||||
"Notiz gelöscht",
|
||||
Snackbar.LENGTH_LONG
|
||||
).setAction("RÜCKGÄNGIG") {
|
||||
// UNDO: Remove from pending deletions and restore
|
||||
pendingDeletions.remove(note.id)
|
||||
loadNotes()
|
||||
}.addCallback(object : Snackbar.Callback() {
|
||||
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
|
||||
if (event != DISMISS_EVENT_ACTION) {
|
||||
// Snackbar dismissed without UNDO → Actually delete the note
|
||||
storage.deleteNote(note.id)
|
||||
pendingDeletions.remove(note.id)
|
||||
loadNotes()
|
||||
// Remove from list for visual feedback (NOT from storage yet!)
|
||||
val listWithoutNote = originalList.toMutableList().apply {
|
||||
removeAt(position)
|
||||
}
|
||||
}
|
||||
}).show()
|
||||
adapter.submitList(listWithoutNote)
|
||||
|
||||
// Show dialog with ability to restore
|
||||
showServerDeletionDialog(swipedNote, originalList)
|
||||
}
|
||||
|
||||
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
|
||||
@@ -290,10 +464,163 @@ class MainActivity : AppCompatActivity() {
|
||||
itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
|
||||
}
|
||||
|
||||
private fun setupFab() {
|
||||
fabAddNote.setOnClickListener {
|
||||
openNoteEditor(null)
|
||||
private fun showServerDeletionDialog(note: Note, originalList: List<Note>) {
|
||||
val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
|
||||
|
||||
if (alwaysDeleteFromServer) {
|
||||
// Auto-delete from server without asking
|
||||
deleteNoteLocally(note, deleteFromServer = true)
|
||||
return
|
||||
}
|
||||
|
||||
val dialogView = layoutInflater.inflate(R.layout.dialog_server_deletion, null)
|
||||
val checkboxAlways = dialogView.findViewById<CheckBox>(R.id.checkboxAlwaysDeleteFromServer)
|
||||
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(getString(R.string.legacy_delete_dialog_title))
|
||||
.setMessage(getString(R.string.legacy_delete_dialog_message, note.title))
|
||||
.setView(dialogView)
|
||||
.setNeutralButton(getString(R.string.cancel)) { _, _ ->
|
||||
// RESTORE: Re-submit original list (note is NOT deleted from storage)
|
||||
adapter.submitList(originalList)
|
||||
}
|
||||
.setOnCancelListener {
|
||||
// User pressed back - also restore
|
||||
adapter.submitList(originalList)
|
||||
}
|
||||
.setPositiveButton("Nur lokal") { _, _ ->
|
||||
if (checkboxAlways.isChecked) {
|
||||
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false).apply()
|
||||
}
|
||||
// NOW actually delete from storage
|
||||
deleteNoteLocally(note, deleteFromServer = false)
|
||||
}
|
||||
.setNegativeButton(getString(R.string.legacy_delete_from_server)) { _, _ ->
|
||||
if (checkboxAlways.isChecked) {
|
||||
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, true).apply()
|
||||
}
|
||||
deleteNoteLocally(note, deleteFromServer = true)
|
||||
}
|
||||
.setCancelable(true)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun deleteNoteLocally(note: Note, deleteFromServer: Boolean) {
|
||||
// Track pending deletion to prevent flicker
|
||||
pendingDeletions.add(note.id)
|
||||
|
||||
// Delete from storage
|
||||
storage.deleteNote(note.id)
|
||||
|
||||
// Reload to reflect changes
|
||||
loadNotes()
|
||||
|
||||
// Show Snackbar with UNDO option
|
||||
val message = if (deleteFromServer) {
|
||||
getString(R.string.legacy_delete_with_server, note.title)
|
||||
} else {
|
||||
getString(R.string.legacy_delete_local_only, note.title)
|
||||
}
|
||||
|
||||
Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG)
|
||||
.setAction(getString(R.string.snackbar_undo)) {
|
||||
// UNDO: Restore note
|
||||
storage.saveNote(note)
|
||||
pendingDeletions.remove(note.id)
|
||||
loadNotes()
|
||||
}
|
||||
.addCallback(object : Snackbar.Callback() {
|
||||
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
|
||||
if (event != DISMISS_EVENT_ACTION) {
|
||||
// Snackbar dismissed without UNDO
|
||||
pendingDeletions.remove(note.id)
|
||||
|
||||
// Delete from server if requested
|
||||
if (deleteFromServer) {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val webdavService = WebDavSyncService(this@MainActivity)
|
||||
val success = webdavService.deleteNoteFromServer(note.id)
|
||||
if (success) {
|
||||
runOnUiThread {
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
getString(R.string.snackbar_deleted_from_server),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
} else {
|
||||
runOnUiThread {
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
getString(R.string.snackbar_server_delete_failed),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
runOnUiThread {
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
"Server-Fehler: ${e.message}",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.0: Setup FAB mit Dropdown für Notiz-Typ Auswahl
|
||||
*/
|
||||
private fun setupFab() {
|
||||
fabAddNote.setOnClickListener { view ->
|
||||
showNoteTypePopup(view)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.0: Zeigt Popup-Menü zur Auswahl des Notiz-Typs
|
||||
*/
|
||||
private fun showNoteTypePopup(anchor: View) {
|
||||
val popupMenu = PopupMenu(this, anchor, Gravity.END)
|
||||
popupMenu.inflate(R.menu.menu_fab_note_types)
|
||||
|
||||
// Icons im Popup anzeigen (via Reflection, da standardmäßig ausgeblendet)
|
||||
try {
|
||||
val fields = popupMenu.javaClass.declaredFields
|
||||
for (field in fields) {
|
||||
if ("mPopup" == field.name) {
|
||||
field.isAccessible = true
|
||||
val menuPopupHelper = field.get(popupMenu)
|
||||
val classPopupHelper = Class.forName(menuPopupHelper.javaClass.name)
|
||||
val setForceIcons = classPopupHelper.getMethod("setForceShowIcon", Boolean::class.java)
|
||||
setForceIcons.invoke(menuPopupHelper, true)
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Could not force show icons in popup menu: ${e.message}")
|
||||
}
|
||||
|
||||
popupMenu.setOnMenuItemClickListener { menuItem ->
|
||||
val noteType = when (menuItem.itemId) {
|
||||
R.id.action_create_text_note -> NoteType.TEXT
|
||||
R.id.action_create_checklist -> NoteType.CHECKLIST
|
||||
else -> return@setOnMenuItemClickListener false
|
||||
}
|
||||
|
||||
val intent = Intent(this, NoteEditorActivity::class.java)
|
||||
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
|
||||
startActivity(intent)
|
||||
true
|
||||
}
|
||||
|
||||
popupMenu.show()
|
||||
}
|
||||
|
||||
private fun loadNotes() {
|
||||
@@ -328,19 +655,31 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun openSettings() {
|
||||
val intent = Intent(this, SettingsActivity::class.java)
|
||||
// v1.5.0: Use new Jetpack Compose Settings
|
||||
val intent = Intent(this, dev.dettmer.simplenotes.ui.settings.ComposeSettingsActivity::class.java)
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityForResult(intent, REQUEST_SETTINGS)
|
||||
}
|
||||
|
||||
private fun triggerManualSync() {
|
||||
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
|
||||
if (!SyncStateManager.tryStartSync("manual")) {
|
||||
return
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
showToast("Starte Synchronisation...")
|
||||
|
||||
// Create sync service
|
||||
val syncService = WebDavSyncService(this@MainActivity)
|
||||
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
|
||||
val message = getString(R.string.toast_already_synced)
|
||||
SyncStateManager.markCompleted(message)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
|
||||
val isReachable = withContext(Dispatchers.IO) {
|
||||
syncService.isServerReachable()
|
||||
@@ -348,7 +687,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
if (!isReachable) {
|
||||
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
|
||||
showToast("Server nicht erreichbar")
|
||||
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -359,20 +698,21 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
// Show result
|
||||
if (result.isSuccess) {
|
||||
showToast("Sync erfolgreich: ${result.syncedCount} Notizen")
|
||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||
loadNotes() // Reload notes
|
||||
} else {
|
||||
showToast("Sync Fehler: ${result.errorMessage}")
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
showToast("Sync Fehler: ${e.message}")
|
||||
SyncStateManager.markError(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.menu_main, menu)
|
||||
optionsMenu = menu // 🔄 v1.3.1: Store reference for sync button state
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -412,6 +752,54 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.1: Migriert bestehende Checklisten für Abwärtskompatibilität.
|
||||
*
|
||||
* Problem: v1.4.0 Checklisten haben leeren "content", was auf älteren
|
||||
* App-Versionen (v1.3.x) als leere Notiz angezeigt wird.
|
||||
*
|
||||
* Lösung: Alle Checklisten ohne Fallback-Content als PENDING markieren,
|
||||
* damit sie beim nächsten Sync mit Fallback-Content hochgeladen werden.
|
||||
*
|
||||
* TODO: Diese Migration kann entfernt werden, sobald v1.4.0 nicht mehr
|
||||
* im Umlauf ist (ca. 6 Monate nach v1.4.1 Release, also ~Juli 2026).
|
||||
* Tracking: https://github.com/inventory69/simple-notes-sync/issues/XXX
|
||||
*/
|
||||
private fun migrateChecklistsForBackwardsCompat() {
|
||||
val migrationKey = "v1.4.1_checklist_migration_done"
|
||||
|
||||
// Nur einmal ausführen
|
||||
if (prefs.getBoolean(migrationKey, false)) {
|
||||
return
|
||||
}
|
||||
|
||||
val allNotes = storage.loadAllNotes()
|
||||
val checklistsToMigrate = allNotes.filter { note ->
|
||||
note.noteType == NoteType.CHECKLIST &&
|
||||
note.content.isBlank() &&
|
||||
note.checklistItems?.isNotEmpty() == true
|
||||
}
|
||||
|
||||
if (checklistsToMigrate.isNotEmpty()) {
|
||||
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
|
||||
|
||||
for (note in checklistsToMigrate) {
|
||||
// Als PENDING markieren, damit beim nächsten Sync der Fallback-Content
|
||||
// generiert und hochgeladen wird
|
||||
val updatedNote = note.copy(
|
||||
syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING
|
||||
)
|
||||
storage.saveNote(updatedNote)
|
||||
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
|
||||
}
|
||||
|
||||
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
|
||||
}
|
||||
|
||||
// Migration als erledigt markieren
|
||||
prefs.edit().putBoolean(migrationKey, true).apply()
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
@@ -423,12 +811,46 @@ class MainActivity : AppCompatActivity() {
|
||||
REQUEST_NOTIFICATION_PERMISSION -> {
|
||||
if (grantResults.isNotEmpty() &&
|
||||
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
showToast("Benachrichtigungen aktiviert")
|
||||
showToast(getString(R.string.toast_notifications_enabled))
|
||||
} else {
|
||||
showToast("Benachrichtigungen deaktiviert. " +
|
||||
"Du kannst sie in den Einstellungen aktivieren.")
|
||||
showToast(getString(R.string.toast_notifications_disabled))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🌍 v1.7.2: Debug-Logging für Locale-Problem
|
||||
* Hilft zu identifizieren warum deutsche Strings trotz englischer App angezeigt werden
|
||||
*/
|
||||
private fun logLocaleInfo() {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
|
||||
Logger.d(TAG, "╔═══════════════════════════════════════════════════")
|
||||
Logger.d(TAG, "║ 🌍 LOCALE DEBUG INFO")
|
||||
Logger.d(TAG, "╠═══════════════════════════════════════════════════")
|
||||
|
||||
// System Locale
|
||||
val systemLocale = java.util.Locale.getDefault()
|
||||
Logger.d(TAG, "║ System Locale (Locale.getDefault()): $systemLocale")
|
||||
|
||||
// Resources Locale
|
||||
val resourcesLocale = resources.configuration.locales[0]
|
||||
Logger.d(TAG, "║ Resources Locale: $resourcesLocale")
|
||||
|
||||
// Context Locale (API 24+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
val contextLocales = resources.configuration.locales
|
||||
Logger.d(TAG, "║ Context Locales (all): $contextLocales")
|
||||
}
|
||||
|
||||
// Test String Loading
|
||||
val testString = getString(R.string.toast_already_synced)
|
||||
Logger.d(TAG, "║ Test: getString(R.string.toast_already_synced)")
|
||||
Logger.d(TAG, "║ Result: '$testString'")
|
||||
Logger.d(TAG, "║ Expected EN: '✅ Already synced'")
|
||||
Logger.d(TAG, "║ Is German?: ${testString.contains("Bereits") || testString.contains("synchronisiert")}")
|
||||
|
||||
Logger.d(TAG, "╚═══════════════════════════════════════════════════")
|
||||
}
|
||||
}
|
||||
@@ -3,27 +3,58 @@ package dev.dettmer.simplenotes
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import dev.dettmer.simplenotes.adapters.ChecklistEditorAdapter
|
||||
import dev.dettmer.simplenotes.models.ChecklistItem
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import dev.dettmer.simplenotes.utils.showToast
|
||||
|
||||
/**
|
||||
* Editor Activity für Notizen und Checklisten
|
||||
*
|
||||
* v1.4.0: Unterstützt jetzt sowohl TEXT als auch CHECKLIST Notizen
|
||||
*/
|
||||
class NoteEditorActivity : AppCompatActivity() {
|
||||
|
||||
// Views
|
||||
private lateinit var toolbar: MaterialToolbar
|
||||
private lateinit var tilTitle: TextInputLayout
|
||||
private lateinit var editTextTitle: TextInputEditText
|
||||
private lateinit var tilContent: TextInputLayout
|
||||
private lateinit var editTextContent: TextInputEditText
|
||||
private lateinit var checklistContainer: LinearLayout
|
||||
private lateinit var rvChecklistItems: RecyclerView
|
||||
private lateinit var btnAddItem: MaterialButton
|
||||
|
||||
private lateinit var storage: NotesStorage
|
||||
|
||||
// State
|
||||
private var existingNote: Note? = null
|
||||
private var currentNoteType: NoteType = NoteType.TEXT
|
||||
private val checklistItems = mutableListOf<ChecklistItem>()
|
||||
private var checklistAdapter: ChecklistEditorAdapter? = null
|
||||
private var itemTouchHelper: ItemTouchHelper? = null
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NoteEditorActivity"
|
||||
const val EXTRA_NOTE_ID = "extra_note_id"
|
||||
const val EXTRA_NOTE_TYPE = "extra_note_type"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -36,38 +67,172 @@ class NoteEditorActivity : AppCompatActivity() {
|
||||
|
||||
storage = NotesStorage(this)
|
||||
|
||||
// Setup toolbar
|
||||
val toolbar = findViewById<MaterialToolbar>(R.id.toolbar)
|
||||
setSupportActionBar(toolbar)
|
||||
supportActionBar?.apply {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setHomeAsUpIndicator(android.R.drawable.ic_menu_close_clear_cancel)
|
||||
findViews()
|
||||
setupToolbar()
|
||||
loadNoteOrDetermineType()
|
||||
setupUIForNoteType()
|
||||
}
|
||||
|
||||
// Find views
|
||||
private fun findViews() {
|
||||
toolbar = findViewById(R.id.toolbar)
|
||||
tilTitle = findViewById(R.id.tilTitle)
|
||||
editTextTitle = findViewById(R.id.editTextTitle)
|
||||
tilContent = findViewById(R.id.tilContent)
|
||||
editTextContent = findViewById(R.id.editTextContent)
|
||||
checklistContainer = findViewById(R.id.checklistContainer)
|
||||
rvChecklistItems = findViewById(R.id.rvChecklistItems)
|
||||
btnAddItem = findViewById(R.id.btnAddItem)
|
||||
}
|
||||
|
||||
// Load existing note if editing
|
||||
private fun setupToolbar() {
|
||||
setSupportActionBar(toolbar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
}
|
||||
|
||||
private fun loadNoteOrDetermineType() {
|
||||
val noteId = intent.getStringExtra(EXTRA_NOTE_ID)
|
||||
|
||||
if (noteId != null) {
|
||||
// Existierende Notiz laden
|
||||
existingNote = storage.loadNote(noteId)
|
||||
existingNote?.let {
|
||||
editTextTitle.setText(it.title)
|
||||
editTextContent.setText(it.content)
|
||||
supportActionBar?.title = "Notiz bearbeiten"
|
||||
existingNote?.let { note ->
|
||||
editTextTitle.setText(note.title)
|
||||
currentNoteType = note.noteType
|
||||
|
||||
when (note.noteType) {
|
||||
NoteType.TEXT -> {
|
||||
editTextContent.setText(note.content)
|
||||
supportActionBar?.title = getString(R.string.edit_note)
|
||||
}
|
||||
NoteType.CHECKLIST -> {
|
||||
note.checklistItems?.let { items ->
|
||||
checklistItems.clear()
|
||||
checklistItems.addAll(items.sortedBy { it.order })
|
||||
}
|
||||
supportActionBar?.title = getString(R.string.edit_checklist)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
supportActionBar?.title = "Neue Notiz"
|
||||
// Neue Notiz - Typ aus Intent
|
||||
val typeString = intent.getStringExtra(EXTRA_NOTE_TYPE) ?: NoteType.TEXT.name
|
||||
currentNoteType = try {
|
||||
NoteType.valueOf(typeString)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Logger.w(TAG, "Invalid note type '$typeString', defaulting to TEXT: ${e.message}")
|
||||
NoteType.TEXT
|
||||
}
|
||||
|
||||
when (currentNoteType) {
|
||||
NoteType.TEXT -> {
|
||||
supportActionBar?.title = getString(R.string.new_note)
|
||||
}
|
||||
NoteType.CHECKLIST -> {
|
||||
supportActionBar?.title = getString(R.string.new_checklist)
|
||||
// Erstes leeres Item hinzufügen
|
||||
checklistItems.add(ChecklistItem.createEmpty(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupUIForNoteType() {
|
||||
when (currentNoteType) {
|
||||
NoteType.TEXT -> {
|
||||
tilContent.visibility = View.VISIBLE
|
||||
checklistContainer.visibility = View.GONE
|
||||
}
|
||||
NoteType.CHECKLIST -> {
|
||||
tilContent.visibility = View.GONE
|
||||
checklistContainer.visibility = View.VISIBLE
|
||||
setupChecklistRecyclerView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupChecklistRecyclerView() {
|
||||
checklistAdapter = ChecklistEditorAdapter(
|
||||
items = checklistItems,
|
||||
onItemCheckedChanged = { position, isChecked ->
|
||||
if (position in checklistItems.indices) {
|
||||
checklistItems[position].isChecked = isChecked
|
||||
}
|
||||
},
|
||||
onItemTextChanged = { position, newText ->
|
||||
if (position in checklistItems.indices) {
|
||||
checklistItems[position] = checklistItems[position].copy(text = newText)
|
||||
}
|
||||
},
|
||||
onItemDeleted = { position ->
|
||||
deleteChecklistItem(position)
|
||||
},
|
||||
onAddNewItem = { position ->
|
||||
addChecklistItemAt(position)
|
||||
},
|
||||
onStartDrag = { viewHolder ->
|
||||
itemTouchHelper?.startDrag(viewHolder)
|
||||
}
|
||||
)
|
||||
|
||||
rvChecklistItems.apply {
|
||||
layoutManager = LinearLayoutManager(this@NoteEditorActivity)
|
||||
adapter = checklistAdapter
|
||||
}
|
||||
|
||||
// Drag & Drop Setup
|
||||
val callback = object : ItemTouchHelper.SimpleCallback(
|
||||
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
|
||||
0 // Kein Swipe
|
||||
) {
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean {
|
||||
val from = viewHolder.bindingAdapterPosition
|
||||
val to = target.bindingAdapterPosition
|
||||
checklistAdapter?.moveItem(from, to)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
// Nicht verwendet
|
||||
}
|
||||
|
||||
override fun isLongPressDragEnabled(): Boolean = false // Nur via Handle
|
||||
}
|
||||
|
||||
itemTouchHelper = ItemTouchHelper(callback)
|
||||
itemTouchHelper?.attachToRecyclerView(rvChecklistItems)
|
||||
|
||||
// Add Item Button
|
||||
btnAddItem.setOnClickListener {
|
||||
addChecklistItemAt(checklistItems.size)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addChecklistItemAt(position: Int) {
|
||||
val newItem = ChecklistItem.createEmpty(position)
|
||||
checklistAdapter?.insertItem(position, newItem)
|
||||
|
||||
// Zum neuen Item scrollen und fokussieren
|
||||
rvChecklistItems.scrollToPosition(position)
|
||||
checklistAdapter?.focusItem(rvChecklistItems, position)
|
||||
}
|
||||
|
||||
private fun deleteChecklistItem(position: Int) {
|
||||
checklistAdapter?.removeItem(position)
|
||||
|
||||
// Wenn letztes Item gelöscht, automatisch neues hinzufügen
|
||||
if (checklistItems.isEmpty()) {
|
||||
addChecklistItemAt(0)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.menu_editor, menu)
|
||||
|
||||
// Show delete only for existing notes
|
||||
// Delete nur für existierende Notizen
|
||||
menu.findItem(R.id.action_delete)?.isVisible = existingNote != null
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -91,51 +256,96 @@ class NoteEditorActivity : AppCompatActivity() {
|
||||
|
||||
private fun saveNote() {
|
||||
val title = editTextTitle.text?.toString()?.trim() ?: ""
|
||||
|
||||
when (currentNoteType) {
|
||||
NoteType.TEXT -> {
|
||||
val content = editTextContent.text?.toString()?.trim() ?: ""
|
||||
|
||||
if (title.isEmpty() && content.isEmpty()) {
|
||||
showToast("Notiz ist leer")
|
||||
showToast(getString(R.string.note_is_empty))
|
||||
return
|
||||
}
|
||||
|
||||
val note = if (existingNote != null) {
|
||||
// Update existing note
|
||||
existingNote!!.copy(
|
||||
title = title,
|
||||
content = content,
|
||||
noteType = NoteType.TEXT,
|
||||
checklistItems = null,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
syncStatus = SyncStatus.PENDING
|
||||
)
|
||||
} else {
|
||||
// Create new note
|
||||
Note(
|
||||
title = title,
|
||||
content = content,
|
||||
noteType = NoteType.TEXT,
|
||||
checklistItems = null,
|
||||
deviceId = DeviceIdGenerator.getDeviceId(this),
|
||||
syncStatus = SyncStatus.LOCAL_ONLY
|
||||
)
|
||||
}
|
||||
|
||||
storage.saveNote(note)
|
||||
showToast("Notiz gespeichert")
|
||||
}
|
||||
|
||||
NoteType.CHECKLIST -> {
|
||||
// Leere Items filtern
|
||||
val validItems = checklistItems.filter { it.text.isNotBlank() }
|
||||
|
||||
if (title.isEmpty() && validItems.isEmpty()) {
|
||||
showToast(getString(R.string.note_is_empty))
|
||||
return
|
||||
}
|
||||
|
||||
// Order neu setzen
|
||||
val orderedItems = validItems.mapIndexed { index, item ->
|
||||
item.copy(order = index)
|
||||
}
|
||||
|
||||
val note = if (existingNote != null) {
|
||||
existingNote!!.copy(
|
||||
title = title,
|
||||
content = "", // Leer für Checklisten
|
||||
noteType = NoteType.CHECKLIST,
|
||||
checklistItems = orderedItems,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
syncStatus = SyncStatus.PENDING
|
||||
)
|
||||
} else {
|
||||
Note(
|
||||
title = title,
|
||||
content = "",
|
||||
noteType = NoteType.CHECKLIST,
|
||||
checklistItems = orderedItems,
|
||||
deviceId = DeviceIdGenerator.getDeviceId(this),
|
||||
syncStatus = SyncStatus.LOCAL_ONLY
|
||||
)
|
||||
}
|
||||
|
||||
storage.saveNote(note)
|
||||
}
|
||||
}
|
||||
|
||||
showToast(getString(R.string.note_saved))
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun confirmDelete() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Notiz löschen?")
|
||||
.setMessage("Diese Aktion kann nicht rückgängig gemacht werden.")
|
||||
.setPositiveButton("Löschen") { _, _ ->
|
||||
.setTitle(getString(R.string.delete_note_title))
|
||||
.setMessage(getString(R.string.delete_note_message))
|
||||
.setPositiveButton(getString(R.string.delete)) { _, _ ->
|
||||
deleteNote()
|
||||
}
|
||||
.setNegativeButton("Abbrechen", null)
|
||||
.setNegativeButton(getString(R.string.cancel), null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun deleteNote() {
|
||||
existingNote?.let {
|
||||
storage.deleteNote(it.id)
|
||||
showToast("Notiz gelöscht")
|
||||
showToast(getString(R.string.note_deleted))
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,11 +15,29 @@ class SimpleNotesApplication : Application() {
|
||||
|
||||
lateinit var networkMonitor: NetworkMonitor // Public access für SettingsActivity
|
||||
|
||||
/**
|
||||
* 🌍 v1.7.1: Apply app locale to Application Context
|
||||
*
|
||||
* This ensures ViewModels and other components using Application Context
|
||||
* get the correct locale-specific strings.
|
||||
*/
|
||||
override fun attachBaseContext(base: Context) {
|
||||
// Apply the app locale before calling super
|
||||
// This is handled by AppCompatDelegate which reads from system storage
|
||||
super.attachBaseContext(base)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!)
|
||||
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
// 🔧 Hotfix v1.6.2: Migrate offline mode setting BEFORE any ViewModel initialization
|
||||
// This prevents the offline mode bug where users updating from v1.5.0 incorrectly
|
||||
// appear as offline even though they have a configured server
|
||||
migrateOfflineModeSetting(prefs)
|
||||
|
||||
// File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!)
|
||||
if (prefs.getBoolean("file_logging_enabled", false)) {
|
||||
Logger.enableFileLogging(this)
|
||||
Logger.d(TAG, "📝 File logging enabled at Application startup")
|
||||
@@ -50,4 +68,30 @@ class SimpleNotesApplication : Application() {
|
||||
// WorkManager läuft weiter auch nach onTerminate!
|
||||
// Nur bei deaktiviertem Auto-Sync stoppen wir es
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔧 Hotfix v1.6.2: Migrate offline mode setting for updates from v1.5.0
|
||||
*
|
||||
* Problem: KEY_OFFLINE_MODE didn't exist in v1.5.0, but MainViewModel
|
||||
* and NoteEditorViewModel use `true` as default, causing existing users
|
||||
* with configured servers to appear in offline mode after update.
|
||||
*
|
||||
* Fix: Set the key BEFORE any ViewModel is initialized based on whether
|
||||
* a server was already configured.
|
||||
*/
|
||||
private fun migrateOfflineModeSetting(prefs: android.content.SharedPreferences) {
|
||||
if (!prefs.contains(Constants.KEY_OFFLINE_MODE)) {
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
val hasServerConfig = !serverUrl.isNullOrEmpty() &&
|
||||
serverUrl != "http://" &&
|
||||
serverUrl != "https://"
|
||||
|
||||
// If server was configured → offlineMode = false (continue syncing)
|
||||
// If no server → offlineMode = true (new users / offline users)
|
||||
val offlineModeValue = !hasServerConfig
|
||||
prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, offlineModeValue).apply()
|
||||
|
||||
Logger.i(TAG, "🔄 Migrated offline_mode_enabled: hasServer=$hasServerConfig → offlineMode=$offlineModeValue")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package dev.dettmer.simplenotes.adapters
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.checkbox.MaterialCheckBox
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.models.ChecklistItem
|
||||
|
||||
/**
|
||||
* Adapter für die Bearbeitung von Checklist-Items im Editor
|
||||
*
|
||||
* v1.4.0: Checklisten-Feature
|
||||
*/
|
||||
class ChecklistEditorAdapter(
|
||||
private val items: MutableList<ChecklistItem>,
|
||||
private val onItemCheckedChanged: (Int, Boolean) -> Unit,
|
||||
private val onItemTextChanged: (Int, String) -> Unit,
|
||||
private val onItemDeleted: (Int) -> Unit,
|
||||
private val onAddNewItem: (Int) -> Unit,
|
||||
private val onStartDrag: (RecyclerView.ViewHolder) -> Unit
|
||||
) : RecyclerView.Adapter<ChecklistEditorAdapter.ViewHolder>() {
|
||||
|
||||
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val dragHandle: ImageView = view.findViewById(R.id.ivDragHandle)
|
||||
val checkbox: MaterialCheckBox = view.findViewById(R.id.cbItem)
|
||||
val editText: EditText = view.findViewById(R.id.etItemText)
|
||||
val deleteButton: ImageButton = view.findViewById(R.id.btnDeleteItem)
|
||||
|
||||
private var textWatcher: TextWatcher? = null
|
||||
|
||||
@Suppress("NestedBlockDepth", "UNUSED_PARAMETER")
|
||||
fun bind(item: ChecklistItem, position: Int) {
|
||||
// Vorherigen TextWatcher entfernen um Loops zu vermeiden
|
||||
textWatcher?.let { editText.removeTextChangedListener(it) }
|
||||
|
||||
// Checkbox
|
||||
checkbox.isChecked = item.isChecked
|
||||
checkbox.setOnCheckedChangeListener { _, isChecked ->
|
||||
onItemCheckedChanged(bindingAdapterPosition, isChecked)
|
||||
updateStrikethrough(isChecked)
|
||||
}
|
||||
|
||||
// Text
|
||||
editText.setText(item.text)
|
||||
updateStrikethrough(item.isChecked)
|
||||
|
||||
// v1.4.1: TextWatcher für Änderungen + Enter-Erkennung für neues Item
|
||||
textWatcher = object : TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
val pos = bindingAdapterPosition
|
||||
if (pos == RecyclerView.NO_POSITION) return
|
||||
|
||||
val text = s?.toString() ?: ""
|
||||
|
||||
// Prüfe ob ein Newline eingegeben wurde
|
||||
if (text.contains("\n")) {
|
||||
// Newline entfernen und neues Item erstellen
|
||||
val cleanText = text.replace("\n", "")
|
||||
editText.setText(cleanText)
|
||||
editText.setSelection(cleanText.length)
|
||||
onItemTextChanged(pos, cleanText)
|
||||
onAddNewItem(pos + 1)
|
||||
} else {
|
||||
onItemTextChanged(pos, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
editText.addTextChangedListener(textWatcher)
|
||||
|
||||
// Delete Button
|
||||
deleteButton.setOnClickListener {
|
||||
val pos = bindingAdapterPosition
|
||||
if (pos != RecyclerView.NO_POSITION) {
|
||||
onItemDeleted(pos)
|
||||
}
|
||||
}
|
||||
|
||||
// Drag Handle Touch Listener
|
||||
dragHandle.setOnTouchListener { _, event ->
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
onStartDrag(this)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateStrikethrough(isChecked: Boolean) {
|
||||
if (isChecked) {
|
||||
editText.paintFlags = editText.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
|
||||
editText.alpha = CHECKED_ITEM_ALPHA
|
||||
} else {
|
||||
editText.paintFlags = editText.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv()
|
||||
editText.alpha = UNCHECKED_ITEM_ALPHA
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_checklist_editor, parent, false)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(items[position], position)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
/**
|
||||
* Bewegt ein Item von einer Position zu einer anderen (für Drag & Drop)
|
||||
*/
|
||||
fun moveItem(fromPosition: Int, toPosition: Int) {
|
||||
val item = items.removeAt(fromPosition)
|
||||
items.add(toPosition, item)
|
||||
notifyItemMoved(fromPosition, toPosition)
|
||||
|
||||
// Order-Werte aktualisieren
|
||||
items.forEachIndexed { index, checklistItem ->
|
||||
checklistItem.order = index
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt ein Item an der angegebenen Position
|
||||
*/
|
||||
fun removeItem(position: Int) {
|
||||
if (position in items.indices) {
|
||||
items.removeAt(position)
|
||||
notifyItemRemoved(position)
|
||||
// Order-Werte aktualisieren
|
||||
items.forEachIndexed { index, checklistItem ->
|
||||
checklistItem.order = index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt ein neues Item an der angegebenen Position ein
|
||||
*/
|
||||
fun insertItem(position: Int, item: ChecklistItem) {
|
||||
items.add(position, item)
|
||||
notifyItemInserted(position)
|
||||
// Order-Werte aktualisieren
|
||||
items.forEachIndexed { index, checklistItem ->
|
||||
checklistItem.order = index
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fokussiert das EditText des Items an der angegebenen Position
|
||||
*/
|
||||
fun focusItem(recyclerView: RecyclerView, position: Int) {
|
||||
recyclerView.post {
|
||||
val viewHolder = recyclerView.findViewHolderForAdapterPosition(position) as? ViewHolder
|
||||
viewHolder?.editText?.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Alpha-Wert für abgehakte Items (durchgestrichen) */
|
||||
private const val CHECKED_ITEM_ALPHA = 0.6f
|
||||
/** Alpha-Wert für nicht abgehakte Items */
|
||||
private const val UNCHECKED_ITEM_ALPHA = 1.0f
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,17 @@ import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.toReadableTime
|
||||
import dev.dettmer.simplenotes.utils.truncate
|
||||
|
||||
/**
|
||||
* Adapter für die Notizen-Liste
|
||||
*
|
||||
* v1.4.0: Unterstützt jetzt TEXT und CHECKLIST Notizen
|
||||
*/
|
||||
class NotesAdapter(
|
||||
private val onNoteClick: (Note) -> Unit
|
||||
) : ListAdapter<Note, NotesAdapter.NoteViewHolder>(NoteDiffCallback()) {
|
||||
@@ -31,16 +37,46 @@ class NotesAdapter(
|
||||
}
|
||||
|
||||
inner class NoteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val ivNoteTypeIcon: ImageView = itemView.findViewById(R.id.ivNoteTypeIcon)
|
||||
private val textViewTitle: TextView = itemView.findViewById(R.id.textViewTitle)
|
||||
private val textViewContent: TextView = itemView.findViewById(R.id.textViewContent)
|
||||
private val textViewChecklistPreview: TextView = itemView.findViewById(R.id.textViewChecklistPreview)
|
||||
private val textViewTimestamp: TextView = itemView.findViewById(R.id.textViewTimestamp)
|
||||
private val imageViewSyncStatus: ImageView = itemView.findViewById(R.id.imageViewSyncStatus)
|
||||
|
||||
fun bind(note: Note) {
|
||||
textViewTitle.text = note.title.ifEmpty { "Ohne Titel" }
|
||||
textViewContent.text = note.content.truncate(100)
|
||||
// Titel
|
||||
textViewTitle.text = note.title.ifEmpty {
|
||||
itemView.context.getString(R.string.untitled)
|
||||
}
|
||||
textViewTimestamp.text = note.updatedAt.toReadableTime()
|
||||
|
||||
// v1.4.0: Typ-spezifische Anzeige
|
||||
when (note.noteType) {
|
||||
NoteType.TEXT -> {
|
||||
ivNoteTypeIcon.setImageResource(R.drawable.ic_note_24)
|
||||
textViewContent.text = note.content.truncate(100)
|
||||
textViewContent.visibility = View.VISIBLE
|
||||
textViewChecklistPreview.visibility = View.GONE
|
||||
}
|
||||
NoteType.CHECKLIST -> {
|
||||
ivNoteTypeIcon.setImageResource(R.drawable.ic_checklist_24)
|
||||
textViewContent.visibility = View.GONE
|
||||
textViewChecklistPreview.visibility = View.VISIBLE
|
||||
|
||||
// Fortschritt berechnen
|
||||
val items = note.checklistItems ?: emptyList()
|
||||
val checkedCount = items.count { it.isChecked }
|
||||
val totalCount = items.size
|
||||
|
||||
textViewChecklistPreview.text = if (totalCount > 0) {
|
||||
itemView.context.getString(R.string.checklist_progress, checkedCount, totalCount)
|
||||
} else {
|
||||
itemView.context.getString(R.string.empty_checklist)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync Icon nur zeigen wenn Sync konfiguriert ist
|
||||
val prefs = itemView.context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
@@ -53,6 +89,7 @@ class NotesAdapter(
|
||||
SyncStatus.PENDING -> android.R.drawable.ic_popup_sync
|
||||
SyncStatus.CONFLICT -> android.R.drawable.ic_dialog_alert
|
||||
SyncStatus.LOCAL_ONLY -> android.R.drawable.ic_menu_save
|
||||
SyncStatus.DELETED_ON_SERVER -> android.R.drawable.ic_menu_delete // 🆕 v1.8.0
|
||||
}
|
||||
imageViewSyncStatus.setImageResource(syncIcon)
|
||||
imageViewSyncStatus.visibility = View.VISIBLE
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
package dev.dettmer.simplenotes.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import dev.dettmer.simplenotes.BuildConfig
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* BackupManager: Lokale Backup & Restore Funktionalität
|
||||
*
|
||||
* Features:
|
||||
* - Backup aller Notizen in JSON-Datei
|
||||
* - Restore mit 3 Modi (Merge, Replace, Overwrite Duplicates)
|
||||
* - Auto-Backup vor Restore (Sicherheitsnetz)
|
||||
* - Backup-Validierung
|
||||
*/
|
||||
class BackupManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BackupManager"
|
||||
private const val BACKUP_VERSION = 1
|
||||
private const val AUTO_BACKUP_DIR = "auto_backups"
|
||||
private const val AUTO_BACKUP_RETENTION_DAYS = 7
|
||||
private const val MAGIC_BYTES_LENGTH = 4 // v1.7.0: For encryption check
|
||||
}
|
||||
|
||||
private val storage = NotesStorage(context)
|
||||
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
||||
private val encryptionManager = EncryptionManager() // 🔐 v1.7.0
|
||||
|
||||
/**
|
||||
* Erstellt Backup aller Notizen
|
||||
*
|
||||
* @param uri Output-URI (via Storage Access Framework)
|
||||
* @param password Optional password for encryption (null = unencrypted)
|
||||
* @return BackupResult mit Erfolg/Fehler Info
|
||||
*/
|
||||
suspend fun createBackup(uri: Uri, password: String? = null): BackupResult = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
val encryptedSuffix = if (password != null) " (encrypted)" else ""
|
||||
Logger.d(TAG, "📦 Creating backup$encryptedSuffix to: $uri")
|
||||
|
||||
val allNotes = storage.loadAllNotes()
|
||||
Logger.d(TAG, " Found ${allNotes.size} notes to backup")
|
||||
|
||||
val backupData = BackupData(
|
||||
backupVersion = BACKUP_VERSION,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
notesCount = allNotes.size,
|
||||
appVersion = BuildConfig.VERSION_NAME,
|
||||
notes = allNotes
|
||||
)
|
||||
|
||||
val jsonString = gson.toJson(backupData)
|
||||
|
||||
// 🔐 v1.7.0: Encrypt if password is provided
|
||||
val dataToWrite = if (password != null) {
|
||||
encryptionManager.encrypt(jsonString.toByteArray(), password)
|
||||
} else {
|
||||
jsonString.toByteArray()
|
||||
}
|
||||
|
||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
outputStream.write(dataToWrite)
|
||||
Logger.d(TAG, "✅ Backup created successfully$encryptedSuffix")
|
||||
}
|
||||
|
||||
BackupResult(
|
||||
success = true,
|
||||
notesCount = allNotes.size,
|
||||
message = "Backup erstellt: ${allNotes.size} Notizen$encryptedSuffix"
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to create backup", e)
|
||||
BackupResult(
|
||||
success = false,
|
||||
error = "Backup fehlgeschlagen: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt automatisches Backup (vor Restore)
|
||||
* Gespeichert in app-internem Storage
|
||||
*
|
||||
* @return Uri des Auto-Backups oder null bei Fehler
|
||||
*/
|
||||
suspend fun createAutoBackup(): Uri? = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
val autoBackupDir = File(context.filesDir, AUTO_BACKUP_DIR).apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
|
||||
val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||
.format(Date())
|
||||
val filename = "auto_backup_before_restore_$timestamp.json"
|
||||
val file = File(autoBackupDir, filename)
|
||||
|
||||
Logger.d(TAG, "📦 Creating auto-backup: ${file.absolutePath}")
|
||||
|
||||
val allNotes = storage.loadAllNotes()
|
||||
val backupData = BackupData(
|
||||
backupVersion = BACKUP_VERSION,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
notesCount = allNotes.size,
|
||||
appVersion = BuildConfig.VERSION_NAME,
|
||||
notes = allNotes
|
||||
)
|
||||
|
||||
file.writeText(gson.toJson(backupData))
|
||||
|
||||
// Cleanup alte Auto-Backups
|
||||
cleanupOldAutoBackups(autoBackupDir)
|
||||
|
||||
Logger.d(TAG, "✅ Auto-backup created: ${file.absolutePath}")
|
||||
Uri.fromFile(file)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to create auto-backup", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stellt Notizen aus Backup wieder her
|
||||
*
|
||||
* @param uri Backup-Datei URI
|
||||
* @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite)
|
||||
* @param password Optional password if backup is encrypted
|
||||
* @return RestoreResult mit Details
|
||||
*/
|
||||
suspend fun restoreBackup(uri: Uri, mode: RestoreMode, password: String? = null): RestoreResult = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)")
|
||||
|
||||
// 1. Backup-Datei lesen
|
||||
val fileData = context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
inputStream.readBytes()
|
||||
} ?: return@withContext RestoreResult(
|
||||
success = false,
|
||||
error = "Datei konnte nicht gelesen werden"
|
||||
)
|
||||
|
||||
// 🔐 v1.7.0: Check if encrypted and decrypt if needed
|
||||
val jsonString = try {
|
||||
if (encryptionManager.isEncrypted(fileData)) {
|
||||
if (password == null) {
|
||||
return@withContext RestoreResult(
|
||||
success = false,
|
||||
error = "Backup ist verschlüsselt. Bitte Passwort eingeben."
|
||||
)
|
||||
}
|
||||
val decrypted = encryptionManager.decrypt(fileData, password)
|
||||
String(decrypted)
|
||||
} else {
|
||||
String(fileData)
|
||||
}
|
||||
} catch (e: EncryptionException) {
|
||||
return@withContext RestoreResult(
|
||||
success = false,
|
||||
error = "Entschlüsselung fehlgeschlagen: ${e.message}"
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Backup validieren & parsen
|
||||
val validationResult = validateBackup(jsonString)
|
||||
if (!validationResult.isValid) {
|
||||
return@withContext RestoreResult(
|
||||
success = false,
|
||||
error = validationResult.errorMessage ?: context.getString(R.string.error_invalid_backup_file)
|
||||
)
|
||||
}
|
||||
|
||||
val backupData = gson.fromJson(jsonString, BackupData::class.java)
|
||||
Logger.d(TAG, " Backup valid: ${backupData.notesCount} notes, version ${backupData.backupVersion}")
|
||||
|
||||
// 3. Auto-Backup erstellen (Sicherheitsnetz)
|
||||
val autoBackupUri = createAutoBackup()
|
||||
if (autoBackupUri == null) {
|
||||
Logger.w(TAG, "⚠️ Auto-backup failed, but continuing with restore")
|
||||
}
|
||||
|
||||
// 4. Restore durchführen (je nach Modus)
|
||||
val result = when (mode) {
|
||||
RestoreMode.MERGE -> restoreMerge(backupData.notes)
|
||||
RestoreMode.REPLACE -> restoreReplace(backupData.notes)
|
||||
RestoreMode.OVERWRITE_DUPLICATES -> restoreOverwriteDuplicates(backupData.notes)
|
||||
}
|
||||
|
||||
Logger.d(TAG, "✅ Restore completed: ${result.importedNotes} imported, ${result.skippedNotes} skipped")
|
||||
result
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to restore backup", e)
|
||||
RestoreResult(
|
||||
success = false,
|
||||
error = context.getString(R.string.error_restore_failed, e.message ?: "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔐 v1.7.0: Check if backup file is encrypted
|
||||
*/
|
||||
suspend fun isBackupEncrypted(uri: Uri): Boolean = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
val header = ByteArray(MAGIC_BYTES_LENGTH)
|
||||
val bytesRead = inputStream.read(header)
|
||||
bytesRead == MAGIC_BYTES_LENGTH && encryptionManager.isEncrypted(header)
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to check encryption status", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert Backup-Datei
|
||||
*/
|
||||
private fun validateBackup(jsonString: String): ValidationResult {
|
||||
return try {
|
||||
val backupData = gson.fromJson(jsonString, BackupData::class.java)
|
||||
|
||||
// Version kompatibel?
|
||||
if (backupData.backupVersion > BACKUP_VERSION) {
|
||||
return ValidationResult(
|
||||
isValid = false,
|
||||
errorMessage = context.getString(R.string.error_backup_version_unsupported, backupData.backupVersion, BACKUP_VERSION)
|
||||
)
|
||||
}
|
||||
|
||||
// Notizen-Array vorhanden?
|
||||
if (backupData.notes.isEmpty()) {
|
||||
return ValidationResult(
|
||||
isValid = false,
|
||||
errorMessage = context.getString(R.string.error_backup_empty)
|
||||
)
|
||||
}
|
||||
|
||||
// Alle Notizen haben ID, title, content?
|
||||
val invalidNotes = backupData.notes.filter { note ->
|
||||
note.id.isBlank() || note.title.isBlank()
|
||||
}
|
||||
|
||||
if (invalidNotes.isNotEmpty()) {
|
||||
return ValidationResult(
|
||||
isValid = false,
|
||||
errorMessage = context.getString(R.string.error_backup_invalid_notes, invalidNotes.size)
|
||||
)
|
||||
}
|
||||
|
||||
ValidationResult(isValid = true)
|
||||
|
||||
} catch (e: Exception) {
|
||||
ValidationResult(
|
||||
isValid = false,
|
||||
errorMessage = context.getString(R.string.error_backup_corrupt, e.message ?: "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore-Modus: MERGE
|
||||
* Fügt neue Notizen hinzu, behält bestehende
|
||||
*/
|
||||
private fun restoreMerge(backupNotes: List<Note>): RestoreResult {
|
||||
val existingNotes = storage.loadAllNotes()
|
||||
val existingIds = existingNotes.map { it.id }.toSet()
|
||||
|
||||
val newNotes = backupNotes.filter { it.id !in existingIds }
|
||||
val skippedNotes = backupNotes.size - newNotes.size
|
||||
|
||||
newNotes.forEach { note ->
|
||||
storage.saveNote(note)
|
||||
}
|
||||
|
||||
return RestoreResult(
|
||||
success = true,
|
||||
importedNotes = newNotes.size,
|
||||
skippedNotes = skippedNotes,
|
||||
message = context.getString(R.string.restore_merge_result, newNotes.size, skippedNotes)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore-Modus: REPLACE
|
||||
* Löscht alle bestehenden Notizen, importiert Backup
|
||||
*/
|
||||
private fun restoreReplace(backupNotes: List<Note>): RestoreResult {
|
||||
// Alle bestehenden Notizen löschen
|
||||
storage.deleteAllNotes()
|
||||
|
||||
// Backup-Notizen importieren
|
||||
backupNotes.forEach { note ->
|
||||
storage.saveNote(note)
|
||||
}
|
||||
|
||||
return RestoreResult(
|
||||
success = true,
|
||||
importedNotes = backupNotes.size,
|
||||
skippedNotes = 0,
|
||||
message = context.getString(R.string.restore_replace_result, backupNotes.size)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore-Modus: OVERWRITE_DUPLICATES
|
||||
* Backup überschreibt bei ID-Konflikten
|
||||
*/
|
||||
private fun restoreOverwriteDuplicates(backupNotes: List<Note>): RestoreResult {
|
||||
val existingNotes = storage.loadAllNotes()
|
||||
val existingIds = existingNotes.map { it.id }.toSet()
|
||||
|
||||
val newNotes = backupNotes.filter { it.id !in existingIds }
|
||||
val overwrittenNotes = backupNotes.filter { it.id in existingIds }
|
||||
|
||||
// Alle Backup-Notizen speichern (überschreibt automatisch)
|
||||
backupNotes.forEach { note ->
|
||||
storage.saveNote(note)
|
||||
}
|
||||
|
||||
return RestoreResult(
|
||||
success = true,
|
||||
importedNotes = newNotes.size,
|
||||
skippedNotes = 0,
|
||||
overwrittenNotes = overwrittenNotes.size,
|
||||
message = context.getString(R.string.restore_overwrite_result, newNotes.size, overwrittenNotes.size)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht Auto-Backups älter als RETENTION_DAYS
|
||||
*/
|
||||
private fun cleanupOldAutoBackups(autoBackupDir: File) {
|
||||
try {
|
||||
val retentionTimeMs = AUTO_BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000L
|
||||
val cutoffTime = System.currentTimeMillis() - retentionTimeMs
|
||||
|
||||
autoBackupDir.listFiles()?.forEach { file ->
|
||||
if (file.lastModified() < cutoffTime) {
|
||||
Logger.d(TAG, "🗑️ Deleting old auto-backup: ${file.name}")
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to cleanup old backups", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup-Daten Struktur (JSON)
|
||||
* NOTE: Property names use @SerializedName for JSON compatibility with snake_case
|
||||
*/
|
||||
data class BackupData(
|
||||
@com.google.gson.annotations.SerializedName("backup_version")
|
||||
val backupVersion: Int,
|
||||
@com.google.gson.annotations.SerializedName("created_at")
|
||||
val createdAt: Long,
|
||||
@com.google.gson.annotations.SerializedName("notes_count")
|
||||
val notesCount: Int,
|
||||
@com.google.gson.annotations.SerializedName("app_version")
|
||||
val appVersion: String,
|
||||
val notes: List<Note>
|
||||
)
|
||||
|
||||
/**
|
||||
* Wiederherstellungs-Modi
|
||||
*/
|
||||
enum class RestoreMode {
|
||||
MERGE, // Bestehende + Neue (Standard)
|
||||
REPLACE, // Alles löschen + Importieren
|
||||
OVERWRITE_DUPLICATES // Backup überschreibt bei ID-Konflikten
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup-Ergebnis
|
||||
*/
|
||||
data class BackupResult(
|
||||
val success: Boolean,
|
||||
val notesCount: Int = 0,
|
||||
val message: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Restore-Ergebnis
|
||||
*/
|
||||
data class RestoreResult(
|
||||
val success: Boolean,
|
||||
val importedNotes: Int = 0,
|
||||
val skippedNotes: Int = 0,
|
||||
val overwrittenNotes: Int = 0,
|
||||
val message: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Validierungs-Ergebnis
|
||||
*/
|
||||
data class ValidationResult(
|
||||
val isValid: Boolean,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,34 @@
|
||||
package dev.dettmer.simplenotes.models
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Repräsentiert ein einzelnes Item in einer Checkliste
|
||||
*
|
||||
* v1.4.0: Checklisten-Feature
|
||||
*
|
||||
* @property id Eindeutige ID für Sync-Konflikterkennung
|
||||
* @property text Der Text des Items
|
||||
* @property isChecked Ob das Item abgehakt ist
|
||||
* @property order Sortierreihenfolge (0-basiert)
|
||||
*/
|
||||
data class ChecklistItem(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val text: String = "",
|
||||
var isChecked: Boolean = false,
|
||||
var order: Int = 0
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Erstellt ein neues leeres ChecklistItem
|
||||
*/
|
||||
fun createEmpty(order: Int): ChecklistItem {
|
||||
return ChecklistItem(
|
||||
id = UUID.randomUUID().toString(),
|
||||
text = "",
|
||||
isChecked = false,
|
||||
order = order
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package dev.dettmer.simplenotes.models
|
||||
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
data class DeletionRecord(
|
||||
val id: String,
|
||||
val deletedAt: Long,
|
||||
val deviceId: String
|
||||
)
|
||||
|
||||
data class DeletionTracker(
|
||||
val version: Int = 1,
|
||||
val deletedNotes: MutableList<DeletionRecord> = mutableListOf()
|
||||
) {
|
||||
fun addDeletion(noteId: String, deviceId: String) {
|
||||
if (!deletedNotes.any { it.id == noteId }) {
|
||||
deletedNotes.add(DeletionRecord(noteId, System.currentTimeMillis(), deviceId))
|
||||
}
|
||||
}
|
||||
|
||||
fun isDeleted(noteId: String): Boolean {
|
||||
return deletedNotes.any { it.id == noteId }
|
||||
}
|
||||
|
||||
fun getDeletionTimestamp(noteId: String): Long? {
|
||||
return deletedNotes.find { it.id == noteId }?.deletedAt
|
||||
}
|
||||
|
||||
fun removeDeletion(noteId: String) {
|
||||
deletedNotes.removeIf { it.id == noteId }
|
||||
}
|
||||
|
||||
fun toJson(): String {
|
||||
val jsonObject = JSONObject()
|
||||
jsonObject.put("version", version)
|
||||
|
||||
val notesArray = JSONArray()
|
||||
for (record in deletedNotes) {
|
||||
val recordObj = JSONObject()
|
||||
recordObj.put("id", record.id)
|
||||
recordObj.put("deletedAt", record.deletedAt)
|
||||
recordObj.put("deviceId", record.deviceId)
|
||||
notesArray.put(recordObj)
|
||||
}
|
||||
jsonObject.put("deletedNotes", notesArray)
|
||||
|
||||
return jsonObject.toString(2) // Pretty print with 2-space indent
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DeletionTracker"
|
||||
|
||||
fun fromJson(json: String): DeletionTracker? {
|
||||
return try {
|
||||
val jsonObject = JSONObject(json)
|
||||
val version = jsonObject.optInt("version", 1)
|
||||
val deletedNotes = mutableListOf<DeletionRecord>()
|
||||
|
||||
val notesArray = jsonObject.optJSONArray("deletedNotes")
|
||||
if (notesArray != null) {
|
||||
for (i in 0 until notesArray.length()) {
|
||||
val recordObj = notesArray.getJSONObject(i)
|
||||
val record = DeletionRecord(
|
||||
id = recordObj.getString("id"),
|
||||
deletedAt = recordObj.getLong("deletedAt"),
|
||||
deviceId = recordObj.getString("deviceId")
|
||||
)
|
||||
deletedNotes.add(record)
|
||||
}
|
||||
}
|
||||
|
||||
DeletionTracker(version, deletedNotes)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to parse DeletionTracker JSON: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,19 @@
|
||||
package dev.dettmer.simplenotes.models
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Note data class with Compose stability annotation.
|
||||
* @Immutable tells Compose this class is stable and won't change unexpectedly,
|
||||
* enabling skip optimizations during recomposition.
|
||||
*/
|
||||
@Immutable
|
||||
data class Note(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val title: String,
|
||||
@@ -9,30 +21,419 @@ data class Note(
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
val updatedAt: Long = System.currentTimeMillis(),
|
||||
val deviceId: String,
|
||||
val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY
|
||||
val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY,
|
||||
// v1.4.0: Checklisten-Felder
|
||||
val noteType: NoteType = NoteType.TEXT,
|
||||
val checklistItems: List<ChecklistItem>? = null,
|
||||
// 🆕 v1.8.1 (IMPL_03): Persistierte Sortierung
|
||||
val checklistSortOption: String? = null
|
||||
) {
|
||||
/**
|
||||
* Serialisiert Note zu JSON
|
||||
* v1.4.0: Nutzt Gson für komplexe Strukturen
|
||||
* v1.4.1: Für Checklisten wird ein Fallback-Content generiert, damit ältere
|
||||
* App-Versionen (v1.3.x) die Notiz als Text anzeigen können.
|
||||
*/
|
||||
fun toJson(): String {
|
||||
return """
|
||||
{
|
||||
"id": "$id",
|
||||
"title": "${title.escapeJson()}",
|
||||
"content": "${content.escapeJson()}",
|
||||
"createdAt": $createdAt,
|
||||
"updatedAt": $updatedAt,
|
||||
"deviceId": "$deviceId",
|
||||
"syncStatus": "${syncStatus.name}"
|
||||
val gson = com.google.gson.GsonBuilder()
|
||||
.setPrettyPrinting()
|
||||
.create()
|
||||
|
||||
// v1.4.1: Für Checklisten den Fallback-Content generieren
|
||||
val noteToSerialize = if (noteType == NoteType.CHECKLIST && checklistItems != null) {
|
||||
this.copy(content = generateChecklistFallbackContent())
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
return gson.toJson(noteToSerialize)
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.1: Generiert einen lesbaren Text-Fallback aus Checklist-Items.
|
||||
* Format: GitHub-Style Task-Listen (kompatibel mit Markdown)
|
||||
*
|
||||
* Beispiel:
|
||||
* [ ] Milch kaufen
|
||||
* [x] Brot gekauft
|
||||
* [ ] Eier
|
||||
*
|
||||
* Wird von älteren App-Versionen (v1.3.x) als normaler Text angezeigt.
|
||||
*/
|
||||
private fun generateChecklistFallbackContent(): String {
|
||||
return checklistItems?.sortedBy { it.order }?.joinToString("\n") { item ->
|
||||
val checkbox = if (item.isChecked) "[x]" else "[ ]"
|
||||
"$checkbox ${item.text}"
|
||||
} ?: ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert Note zu Markdown mit YAML Frontmatter (Task #1.2.0-08)
|
||||
* Format kompatibel mit Obsidian, Joplin, Typora
|
||||
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
|
||||
*/
|
||||
fun toMarkdown(): String {
|
||||
// 🆕 v1.8.1 (IMPL_03): Sortierung im Frontmatter
|
||||
val sortLine = if (noteType == NoteType.CHECKLIST && checklistSortOption != null) {
|
||||
"\nsort: $checklistSortOption"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val header = """
|
||||
---
|
||||
id: $id
|
||||
created: ${formatISO8601(createdAt)}
|
||||
updated: ${formatISO8601(updatedAt)}
|
||||
device: $deviceId
|
||||
type: ${noteType.name.lowercase()}$sortLine
|
||||
---
|
||||
|
||||
# $title
|
||||
|
||||
""".trimIndent()
|
||||
|
||||
return when (noteType) {
|
||||
NoteType.TEXT -> header + content
|
||||
NoteType.CHECKLIST -> {
|
||||
val checklistMarkdown = checklistItems?.sortedBy { it.order }?.joinToString("\n") { item ->
|
||||
val checkbox = if (item.isChecked) "[x]" else "[ ]"
|
||||
"- $checkbox ${item.text}"
|
||||
} ?: ""
|
||||
header + checklistMarkdown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "Note"
|
||||
|
||||
/**
|
||||
* Parst JSON zu Note-Objekt mit Backward Compatibility für alte Notizen ohne noteType
|
||||
*/
|
||||
fun fromJson(json: String): Note? {
|
||||
return try {
|
||||
val gson = com.google.gson.Gson()
|
||||
gson.fromJson(json, Note::class.java)
|
||||
val jsonObject = com.google.gson.JsonParser.parseString(json).asJsonObject
|
||||
|
||||
// Backward Compatibility: Alte Notizen ohne noteType bekommen TEXT
|
||||
val noteType = if (jsonObject.has("noteType") && !jsonObject.get("noteType").isJsonNull) {
|
||||
try {
|
||||
NoteType.valueOf(jsonObject.get("noteType").asString)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Unknown noteType, defaulting to TEXT: ${e.message}")
|
||||
NoteType.TEXT
|
||||
}
|
||||
} else {
|
||||
NoteType.TEXT
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_03): Gespeicherte Sortierung laden
|
||||
val checklistSortOption = if (jsonObject.has("checklistSortOption") &&
|
||||
!jsonObject.get("checklistSortOption").isJsonNull) {
|
||||
jsonObject.get("checklistSortOption").asString
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// Parsen der Basis-Note
|
||||
val rawNote = gson.fromJson(json, NoteRaw::class.java)
|
||||
|
||||
// Checklist-Items parsen (kann null sein)
|
||||
val checklistItemsType = object : com.google.gson.reflect.TypeToken<List<ChecklistItem>>() {}.type
|
||||
var checklistItems: List<ChecklistItem>? = if (jsonObject.has("checklistItems") &&
|
||||
!jsonObject.get("checklistItems").isJsonNull
|
||||
) {
|
||||
gson.fromJson<List<ChecklistItem>>(
|
||||
jsonObject.get("checklistItems"),
|
||||
checklistItemsType
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// v1.4.1: Recovery-Mode - Falls Checkliste aber keine Items,
|
||||
// versuche Content als Fallback zu parsen
|
||||
if (noteType == NoteType.CHECKLIST &&
|
||||
(checklistItems == null || checklistItems.isEmpty()) &&
|
||||
rawNote.content.isNotBlank()) {
|
||||
|
||||
val recoveredItems = parseChecklistFromContent(rawNote.content)
|
||||
if (recoveredItems.isNotEmpty()) {
|
||||
Logger.d(TAG, "🔄 Recovered ${recoveredItems.size} checklist items from content fallback")
|
||||
checklistItems = recoveredItems
|
||||
}
|
||||
}
|
||||
|
||||
// Note mit korrekten Werten erstellen
|
||||
Note(
|
||||
id = rawNote.id,
|
||||
title = rawNote.title,
|
||||
content = rawNote.content,
|
||||
createdAt = rawNote.createdAt,
|
||||
updatedAt = rawNote.updatedAt,
|
||||
deviceId = rawNote.deviceId,
|
||||
syncStatus = rawNote.syncStatus ?: SyncStatus.LOCAL_ONLY,
|
||||
noteType = noteType,
|
||||
checklistItems = checklistItems,
|
||||
checklistSortOption = checklistSortOption // 🆕 v1.8.1 (IMPL_03)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to parse JSON: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hilfsklasse für Gson-Parsing mit nullable Feldern
|
||||
*/
|
||||
private data class NoteRaw(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val title: String = "",
|
||||
val content: String = "",
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
val updatedAt: Long = System.currentTimeMillis(),
|
||||
val deviceId: String = "",
|
||||
val syncStatus: SyncStatus? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* v1.4.1: Parst GitHub-Style Checklisten aus Text (Recovery-Mode).
|
||||
*
|
||||
* Unterstützte Formate:
|
||||
* - [ ] Unchecked item
|
||||
* - [x] Checked item
|
||||
* - [X] Checked item (case insensitive)
|
||||
*
|
||||
* Wird verwendet, wenn eine v1.4.0 Checkliste von einer älteren
|
||||
* App-Version (v1.3.x) bearbeitet wurde und die checklistItems verloren gingen.
|
||||
*
|
||||
* @param content Der Text-Content der Notiz
|
||||
* @return Liste von ChecklistItems oder leere Liste
|
||||
*/
|
||||
private fun parseChecklistFromContent(content: String): List<ChecklistItem> {
|
||||
val pattern = Regex("""^\s*\[([ xX])\]\s*(.+)$""", RegexOption.MULTILINE)
|
||||
return pattern.findAll(content).mapIndexed { index, match ->
|
||||
val checked = match.groupValues[1].lowercase() == "x"
|
||||
val text = match.groupValues[2].trim()
|
||||
ChecklistItem(
|
||||
id = UUID.randomUUID().toString(),
|
||||
text = text,
|
||||
isChecked = checked,
|
||||
order = index
|
||||
)
|
||||
}.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09)
|
||||
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
|
||||
* 🔧 v1.7.2 (IMPL_014): Optional serverModifiedTime für korrekte Timestamp-Sync
|
||||
*
|
||||
* @param md Markdown-String mit YAML Frontmatter
|
||||
* @param serverModifiedTime Optionaler Server-Datei mtime (Priorität über YAML timestamp)
|
||||
* @return Note-Objekt oder null bei Parse-Fehler
|
||||
*/
|
||||
fun fromMarkdown(md: String, serverModifiedTime: Long? = null): Note? {
|
||||
return try {
|
||||
// Parse YAML Frontmatter + Markdown Content
|
||||
val frontmatterRegex = Regex("^---\\n(.+?)\\n---\\n(.*)$", RegexOption.DOT_MATCHES_ALL)
|
||||
val match = frontmatterRegex.find(md) ?: return null
|
||||
|
||||
val yamlBlock = match.groupValues[1]
|
||||
val contentBlock = match.groupValues[2]
|
||||
|
||||
// Parse YAML (einfach per String-Split für MVP)
|
||||
val metadata = yamlBlock.lines()
|
||||
.mapNotNull { line ->
|
||||
val parts = line.split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
parts[0].trim() to parts[1].trim()
|
||||
} else null
|
||||
}.toMap()
|
||||
|
||||
// Extract title from first # heading
|
||||
val title = contentBlock.lines()
|
||||
.firstOrNull { it.startsWith("# ") }
|
||||
?.removePrefix("# ")?.trim() ?: "Untitled"
|
||||
|
||||
// v1.4.0: Prüfe ob type: checklist im Frontmatter
|
||||
val noteTypeStr = metadata["type"]?.lowercase() ?: "text"
|
||||
val noteType = when (noteTypeStr) {
|
||||
"checklist" -> NoteType.CHECKLIST
|
||||
else -> NoteType.TEXT
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_03): Gespeicherte Sortierung aus YAML laden
|
||||
val checklistSortOption = metadata["sort"]
|
||||
|
||||
// v1.4.0: Parse Content basierend auf Typ
|
||||
// FIX: Robusteres Parsing - suche nach dem Titel-Header und extrahiere den Rest
|
||||
val titleLineIndex = contentBlock.lines().indexOfFirst { it.startsWith("# ") }
|
||||
val contentAfterTitle = if (titleLineIndex >= 0) {
|
||||
// Alles nach der Titel-Zeile, überspringe führende Leerzeilen
|
||||
contentBlock.lines()
|
||||
.drop(titleLineIndex + 1)
|
||||
.dropWhile { it.isBlank() }
|
||||
.joinToString("\n")
|
||||
.trim()
|
||||
} else {
|
||||
// Fallback: Gesamter Content (kein Titel gefunden)
|
||||
contentBlock.trim()
|
||||
}
|
||||
|
||||
val content: String
|
||||
val checklistItems: List<ChecklistItem>?
|
||||
|
||||
if (noteType == NoteType.CHECKLIST) {
|
||||
// Parse Checklist Items
|
||||
val checklistRegex = Regex("^- \\[([ xX])\\] (.*)$", RegexOption.MULTILINE)
|
||||
checklistItems = checklistRegex.findAll(contentAfterTitle).mapIndexed { index, matchResult ->
|
||||
ChecklistItem(
|
||||
id = UUID.randomUUID().toString(),
|
||||
text = matchResult.groupValues[2].trim(),
|
||||
isChecked = matchResult.groupValues[1].lowercase() == "x",
|
||||
order = index
|
||||
)
|
||||
}.toList().ifEmpty { null }
|
||||
content = "" // Checklisten haben keinen "content"
|
||||
} else {
|
||||
content = contentAfterTitle
|
||||
checklistItems = null
|
||||
}
|
||||
|
||||
// 🔧 v1.7.2 (IMPL_014): Server mtime hat Priorität über YAML timestamp
|
||||
val yamlUpdatedAt = parseISO8601(metadata["updated"] ?: "")
|
||||
val effectiveUpdatedAt = when {
|
||||
serverModifiedTime != null && serverModifiedTime > yamlUpdatedAt -> {
|
||||
Logger.d(TAG, "Using server mtime ($serverModifiedTime) over YAML ($yamlUpdatedAt)")
|
||||
serverModifiedTime
|
||||
}
|
||||
else -> yamlUpdatedAt
|
||||
}
|
||||
|
||||
Note(
|
||||
id = metadata["id"] ?: UUID.randomUUID().toString(),
|
||||
title = title,
|
||||
content = content,
|
||||
createdAt = parseISO8601(metadata["created"] ?: ""),
|
||||
updatedAt = effectiveUpdatedAt,
|
||||
deviceId = metadata["device"] ?: "desktop",
|
||||
syncStatus = SyncStatus.SYNCED, // Annahme: Vom Server importiert
|
||||
noteType = noteType,
|
||||
checklistItems = checklistItems,
|
||||
checklistSortOption = checklistSortOption // 🆕 v1.8.1 (IMPL_03)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to parse Markdown: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert Timestamp zu ISO8601 (Task #1.2.0-10)
|
||||
* Format: 2024-12-21T18:00:00Z (UTC)
|
||||
*/
|
||||
private fun formatISO8601(timestamp: Long): String {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||
return sdf.format(Date(timestamp))
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔧 v1.7.2 (IMPL_002): Robustes ISO8601 Parsing mit Multi-Format Unterstützung
|
||||
*
|
||||
* Unterstützte Formate (in Prioritätsreihenfolge):
|
||||
* 1. 2024-12-21T18:00:00Z (UTC mit Z)
|
||||
* 2. 2024-12-21T18:00:00+01:00 (mit Offset)
|
||||
* 3. 2024-12-21T18:00:00+0100 (Offset ohne Doppelpunkt)
|
||||
* 4. 2024-12-21T18:00:00.123Z (mit Millisekunden)
|
||||
* 5. 2024-12-21T18:00:00.123+01:00 (Millisekunden + Offset)
|
||||
* 6. 2024-12-21 18:00:00 (Leerzeichen statt T)
|
||||
*
|
||||
* Fallback: Aktueller Timestamp bei Fehler
|
||||
*
|
||||
* @param dateString ISO8601 Datum-String
|
||||
* @return Unix Timestamp in Millisekunden
|
||||
*/
|
||||
private fun parseISO8601(dateString: String): Long {
|
||||
if (dateString.isBlank()) {
|
||||
return System.currentTimeMillis()
|
||||
}
|
||||
|
||||
// Normalisiere: Leerzeichen → T
|
||||
val normalized = dateString.trim().replace(' ', 'T')
|
||||
|
||||
// Format-Patterns in Prioritätsreihenfolge
|
||||
val patterns = listOf(
|
||||
// Mit Timezone Z
|
||||
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
|
||||
|
||||
// Mit Offset XXX (+01:00)
|
||||
"yyyy-MM-dd'T'HH:mm:ssXXX",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
|
||||
|
||||
// Mit Offset ohne Doppelpunkt (+0100)
|
||||
"yyyy-MM-dd'T'HH:mm:ssZ",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
|
||||
|
||||
// Ohne Timezone (interpretiere als UTC)
|
||||
"yyyy-MM-dd'T'HH:mm:ss",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSS"
|
||||
)
|
||||
|
||||
// Versuche alle Patterns nacheinander
|
||||
for (pattern in patterns) {
|
||||
@Suppress("SwallowedException") // Intentional: try all patterns before logging
|
||||
try {
|
||||
val sdf = SimpleDateFormat(pattern, Locale.US)
|
||||
// Für Patterns ohne Timezone: UTC annehmen
|
||||
if (!pattern.contains("XXX") && !pattern.contains("Z")) {
|
||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
val parsed = sdf.parse(normalized)
|
||||
if (parsed != null) {
|
||||
return parsed.time
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 🔇 Exception intentionally swallowed - try next pattern
|
||||
// Only log if no pattern matches (see fallback below)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback wenn kein Pattern passt
|
||||
Logger.w(TAG, "Failed to parse ISO8601 date '$dateString' with any pattern, using current time")
|
||||
return System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎨 v1.7.0: Note size classification for Staggered Grid Layout
|
||||
*/
|
||||
enum class NoteSize {
|
||||
SMALL, // Compact display (< 80 chars or ≤ 4 checklist items)
|
||||
LARGE; // Full-width display
|
||||
|
||||
companion object {
|
||||
const val SMALL_TEXT_THRESHOLD = 80 // Max characters for compact text note
|
||||
const val SMALL_CHECKLIST_THRESHOLD = 4 // Max items for compact checklist
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎨 v1.7.0: Determine note size for grid layout optimization
|
||||
*/
|
||||
fun Note.getSize(): NoteSize {
|
||||
return when (noteType) {
|
||||
NoteType.TEXT -> {
|
||||
if (content.length < NoteSize.SMALL_TEXT_THRESHOLD) NoteSize.SMALL else NoteSize.LARGE
|
||||
}
|
||||
NoteType.CHECKLIST -> {
|
||||
val itemCount = checklistItems?.size ?: 0
|
||||
if (itemCount <= NoteSize.SMALL_CHECKLIST_THRESHOLD) NoteSize.SMALL else NoteSize.LARGE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package dev.dettmer.simplenotes.models
|
||||
|
||||
/**
|
||||
* Definiert die verschiedenen Notiz-Typen
|
||||
*
|
||||
* v1.4.0: Checklisten-Feature
|
||||
*/
|
||||
enum class NoteType {
|
||||
TEXT, // Normale Text-Notiz (Standard)
|
||||
CHECKLIST // Checkliste mit abhakbaren Items
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
package dev.dettmer.simplenotes.models
|
||||
|
||||
/**
|
||||
* Sync-Status einer Notiz
|
||||
*
|
||||
* v1.4.0: Initial (LOCAL_ONLY, SYNCED, PENDING, CONFLICT)
|
||||
* v1.8.0: DELETED_ON_SERVER hinzugefügt
|
||||
*/
|
||||
enum class SyncStatus {
|
||||
LOCAL_ONLY, // Noch nie gesynct
|
||||
SYNCED, // Erfolgreich gesynct
|
||||
PENDING, // Wartet auf Sync
|
||||
CONFLICT // Konflikt erkannt
|
||||
CONFLICT, // Konflikt erkannt
|
||||
DELETED_ON_SERVER // 🆕 v1.8.0: Server hat gelöscht, lokal noch vorhanden
|
||||
}
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
package dev.dettmer.simplenotes.storage
|
||||
|
||||
import android.content.Context
|
||||
import dev.dettmer.simplenotes.models.DeletionTracker
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.File
|
||||
|
||||
class NotesStorage(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NotesStorage"
|
||||
// 🔒 v1.7.2 (IMPL_001): Mutex für thread-sichere Deletion Tracker Operationen
|
||||
private val deletionTrackerMutex = Mutex()
|
||||
}
|
||||
|
||||
private val notesDir: File = File(context.filesDir, "notes").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
@@ -24,29 +35,148 @@ class NotesStorage(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt alle Notizen aus dem lokalen Speicher.
|
||||
*
|
||||
* 🔀 v1.8.0: Sortierung entfernt — wird jetzt im ViewModel durchgeführt,
|
||||
* damit der User die Sortierung konfigurieren kann.
|
||||
*/
|
||||
fun loadAllNotes(): List<Note> {
|
||||
return notesDir.listFiles()
|
||||
?.filter { it.extension == "json" }
|
||||
?.mapNotNull { Note.fromJson(it.readText()) }
|
||||
?.sortedByDescending { it.updatedAt }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
fun deleteNote(id: String): Boolean {
|
||||
val file = File(notesDir, "$id.json")
|
||||
return file.delete()
|
||||
val deleted = file.delete()
|
||||
|
||||
if (deleted) {
|
||||
Logger.d(TAG, "🗑️ Deleted note: $id")
|
||||
|
||||
// Track deletion to prevent zombie notes
|
||||
val deviceId = DeviceIdGenerator.getDeviceId(context)
|
||||
trackDeletion(id, deviceId)
|
||||
}
|
||||
|
||||
return deleted
|
||||
}
|
||||
|
||||
fun deleteAllNotes(): Boolean {
|
||||
return try {
|
||||
notesDir.listFiles()
|
||||
?.filter { it.extension == "json" }
|
||||
?.forEach { it.delete() }
|
||||
val notes = loadAllNotes()
|
||||
val deviceId = DeviceIdGenerator.getDeviceId(context)
|
||||
|
||||
for (note in notes) {
|
||||
deleteNote(note.id) // Uses trackDeletion() automatically
|
||||
}
|
||||
|
||||
Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to delete all notes", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// === Deletion Tracking ===
|
||||
|
||||
private fun getDeletionTrackerFile(): File {
|
||||
return File(context.filesDir, "deleted_notes.json")
|
||||
}
|
||||
|
||||
fun loadDeletionTracker(): DeletionTracker {
|
||||
val file = getDeletionTrackerFile()
|
||||
if (!file.exists()) {
|
||||
return DeletionTracker()
|
||||
}
|
||||
|
||||
return try {
|
||||
val json = file.readText()
|
||||
DeletionTracker.fromJson(json) ?: DeletionTracker()
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to load deletion tracker", e)
|
||||
DeletionTracker()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveDeletionTracker(tracker: DeletionTracker) {
|
||||
try {
|
||||
val file = getDeletionTrackerFile()
|
||||
file.writeText(tracker.toJson())
|
||||
|
||||
if (tracker.deletedNotes.size > 1000) {
|
||||
Logger.w(TAG, "⚠️ Deletion tracker large: ${tracker.deletedNotes.size} entries")
|
||||
}
|
||||
|
||||
Logger.d(TAG, "✅ Deletion tracker saved (${tracker.deletedNotes.size} entries)")
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to save deletion tracker", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔒 v1.7.2 (IMPL_001): Thread-sichere Deletion-Tracking mit Mutex
|
||||
*
|
||||
* Verhindert Race Conditions bei Batch-Deletes durch exklusiven Zugriff
|
||||
* auf den Deletion Tracker.
|
||||
*
|
||||
* @param noteId ID der gelöschten Notiz
|
||||
* @param deviceId Geräte-ID für Konflikt-Erkennung
|
||||
*/
|
||||
suspend fun trackDeletionSafe(noteId: String, deviceId: String) {
|
||||
deletionTrackerMutex.withLock {
|
||||
val tracker = loadDeletionTracker()
|
||||
tracker.addDeletion(noteId, deviceId)
|
||||
saveDeletionTracker(tracker)
|
||||
Logger.d(TAG, "📝 Tracked deletion (mutex-protected): $noteId")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy-Methode ohne Mutex-Schutz.
|
||||
* Verwendet für synchrone Aufrufe wo Coroutines nicht verfügbar sind.
|
||||
*
|
||||
* @deprecated Verwende trackDeletionSafe() für Thread-Safety wo möglich
|
||||
*/
|
||||
fun trackDeletion(noteId: String, deviceId: String) {
|
||||
val tracker = loadDeletionTracker()
|
||||
tracker.addDeletion(noteId, deviceId)
|
||||
saveDeletionTracker(tracker)
|
||||
Logger.d(TAG, "📝 Tracked deletion: $noteId")
|
||||
}
|
||||
|
||||
fun isNoteDeleted(noteId: String): Boolean {
|
||||
val tracker = loadDeletionTracker()
|
||||
return tracker.isDeleted(noteId)
|
||||
}
|
||||
|
||||
fun clearDeletionTracker() {
|
||||
saveDeletionTracker(DeletionTracker())
|
||||
Logger.d(TAG, "🗑️ Deletion tracker cleared")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔄 v1.7.0: Reset all sync statuses to PENDING when server changes
|
||||
* This ensures notes are uploaded to the new server on next sync
|
||||
*/
|
||||
fun resetAllSyncStatusToPending(): Int {
|
||||
val notes = loadAllNotes()
|
||||
var updatedCount = 0
|
||||
|
||||
notes.forEach { note ->
|
||||
if (note.syncStatus == dev.dettmer.simplenotes.models.SyncStatus.SYNCED) {
|
||||
val updatedNote = note.copy(syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING)
|
||||
saveNote(updatedNote)
|
||||
updatedCount++
|
||||
}
|
||||
}
|
||||
|
||||
Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING")
|
||||
return updatedCount
|
||||
}
|
||||
|
||||
|
||||
fun getNotesDir(): File = notesDir
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import dev.dettmer.simplenotes.utils.Logger
|
||||
/**
|
||||
* BootReceiver: Startet WorkManager nach Device Reboot
|
||||
* CRITICAL: Ohne diesen Receiver funktioniert Auto-Sync nach Reboot NICHT!
|
||||
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_BOOT
|
||||
*/
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
@@ -24,16 +25,22 @@ class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
Logger.d(TAG, "📱 BOOT_COMPLETED received")
|
||||
|
||||
// Prüfe ob Auto-Sync aktiviert ist
|
||||
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
||||
|
||||
if (!autoSyncEnabled) {
|
||||
Logger.d(TAG, "❌ Auto-sync disabled - not starting WorkManager")
|
||||
// 🌟 v1.6.0: Check if Boot trigger is enabled
|
||||
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, Constants.DEFAULT_TRIGGER_BOOT)) {
|
||||
Logger.d(TAG, "⏭️ Boot sync disabled - not starting WorkManager")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.d(TAG, "🚀 Auto-sync enabled - starting WorkManager")
|
||||
// Check if server is configured
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
|
||||
Logger.d(TAG, "⏭️ Offline mode - not starting WorkManager")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.d(TAG, "🚀 Boot sync enabled - starting WorkManager")
|
||||
|
||||
// WorkManager neu starten
|
||||
val networkMonitor = NetworkMonitor(context.applicationContext)
|
||||
|
||||
@@ -68,15 +68,20 @@ class NetworkMonitor(private val context: Context) {
|
||||
|
||||
lastConnectedNetworkId = currentNetworkId
|
||||
|
||||
// Auto-Sync check
|
||||
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
||||
Logger.d(TAG, " Auto-Sync enabled: $autoSyncEnabled")
|
||||
// WiFi-Connect Trigger prüfen - NICHT KEY_AUTO_SYNC!
|
||||
// Der Callback ist registriert WEIL KEY_SYNC_TRIGGER_WIFI_CONNECT = true
|
||||
// Aber defensive Prüfung für den Fall, dass Settings sich geändert haben
|
||||
val wifiConnectEnabled = prefs.getBoolean(
|
||||
Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT,
|
||||
Constants.DEFAULT_TRIGGER_WIFI_CONNECT
|
||||
)
|
||||
Logger.d(TAG, " WiFi-Connect trigger enabled: $wifiConnectEnabled")
|
||||
|
||||
if (autoSyncEnabled) {
|
||||
Logger.d(TAG, " ✅ Triggering WorkManager...")
|
||||
if (wifiConnectEnabled) {
|
||||
Logger.d(TAG, " ✅ Triggering WiFi-Connect sync...")
|
||||
triggerWifiConnectSync()
|
||||
} else {
|
||||
Logger.d(TAG, " ❌ Auto-sync disabled - not triggering")
|
||||
Logger.d(TAG, " ⏭️ WiFi-Connect trigger disabled in settings")
|
||||
}
|
||||
} else {
|
||||
Logger.d(TAG, " ⚠️ Same WiFi network as before - ignoring (no network change)")
|
||||
@@ -102,8 +107,22 @@ class NetworkMonitor(private val context: Context) {
|
||||
/**
|
||||
* Triggert WiFi-Connect Sync via WorkManager
|
||||
* WorkManager wacht App auf (funktioniert auch wenn App geschlossen!)
|
||||
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_WIFI_CONNECT
|
||||
*/
|
||||
private fun triggerWifiConnectSync() {
|
||||
// 🌟 v1.6.0: Check if WiFi-Connect trigger is enabled
|
||||
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)) {
|
||||
Logger.d(TAG, "⏭️ WiFi-Connect sync disabled - skipping")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if server is configured
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
|
||||
Logger.d(TAG, "⏭️ Offline mode - skipping WiFi-Connect sync")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.d(TAG, "📡 Scheduling WiFi-Connect sync via WorkManager")
|
||||
|
||||
// 🔥 WICHTIG: NetworkType.UNMETERED constraint!
|
||||
@@ -126,30 +145,80 @@ class NetworkMonitor(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Startet WorkManager mit Network Constraints + NetworkCallback
|
||||
*
|
||||
* 🆕 v1.7.0: Überarbeitete Logik - WiFi-Connect Trigger funktioniert UNABHÄNGIG von KEY_AUTO_SYNC
|
||||
* - KEY_AUTO_SYNC + KEY_SYNC_TRIGGER_PERIODIC → Periodic Sync
|
||||
* - KEY_SYNC_TRIGGER_WIFI_CONNECT → WiFi-Connect Trigger (unabhängig!)
|
||||
*/
|
||||
fun startMonitoring() {
|
||||
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
||||
Logger.d(TAG, "🚀 NetworkMonitor.startMonitoring() called")
|
||||
|
||||
if (!autoSyncEnabled) {
|
||||
Logger.d(TAG, "Auto-sync disabled - stopping all monitoring")
|
||||
stopMonitoring()
|
||||
return
|
||||
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
||||
val periodicEnabled = prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)
|
||||
val wifiConnectEnabled = prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)
|
||||
|
||||
Logger.d(TAG, " Settings: autoSync=$autoSyncEnabled, periodic=$periodicEnabled, wifiConnect=$wifiConnectEnabled")
|
||||
|
||||
// 1. Periodic Sync (nur wenn KEY_AUTO_SYNC UND KEY_SYNC_TRIGGER_PERIODIC aktiv)
|
||||
if (autoSyncEnabled && periodicEnabled) {
|
||||
Logger.d(TAG, "📅 Starting periodic sync...")
|
||||
startPeriodicSync()
|
||||
} else {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
|
||||
Logger.d(TAG, "⏭️ Periodic sync disabled (autoSync=$autoSyncEnabled, periodic=$periodicEnabled)")
|
||||
}
|
||||
|
||||
Logger.d(TAG, "🚀 Starting NetworkMonitor (WorkManager + WiFi Callback)")
|
||||
|
||||
// 1. WorkManager für periodic sync
|
||||
startPeriodicSync()
|
||||
|
||||
// 2. NetworkCallback für WiFi-Connect Detection
|
||||
// 2. WiFi-Connect Trigger (🆕 UNABHÄNGIG von KEY_AUTO_SYNC!)
|
||||
if (wifiConnectEnabled) {
|
||||
Logger.d(TAG, "📶 Starting WiFi monitoring...")
|
||||
startWifiMonitoring()
|
||||
} else {
|
||||
stopWifiMonitoring()
|
||||
Logger.d(TAG, "⏭️ WiFi-Connect trigger disabled")
|
||||
}
|
||||
|
||||
// 3. Logging für Debug
|
||||
if (!autoSyncEnabled && !wifiConnectEnabled) {
|
||||
Logger.d(TAG, "🛑 No background triggers active")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.7.0: Stoppt nur WiFi-Monitoring, nicht den gesamten NetworkMonitor
|
||||
*/
|
||||
@Suppress("SwallowedException")
|
||||
private fun stopWifiMonitoring() {
|
||||
try {
|
||||
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||||
Logger.d(TAG, "🛑 WiFi NetworkCallback unregistered")
|
||||
} catch (e: Exception) {
|
||||
// Already unregistered - das ist OK
|
||||
Logger.d(TAG, " WiFi callback already unregistered")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet WorkManager periodic sync
|
||||
* 🔥 Interval aus SharedPrefs konfigurierbar (15/30/60 min)
|
||||
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_PERIODIC
|
||||
*/
|
||||
private fun startPeriodicSync() {
|
||||
// 🌟 v1.6.0: Check if Periodic trigger is enabled
|
||||
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)) {
|
||||
Logger.d(TAG, "⏭️ Periodic sync disabled - skipping")
|
||||
// Cancel existing periodic work if disabled
|
||||
WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if server is configured
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
|
||||
Logger.d(TAG, "⏭️ Offline mode - skipping Periodic sync")
|
||||
WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// 🔥 Interval aus SharedPrefs lesen
|
||||
val intervalMinutes = prefs.getLong(
|
||||
Constants.PREF_SYNC_INTERVAL_MINUTES,
|
||||
@@ -227,7 +296,11 @@ class NetworkMonitor(private val context: Context) {
|
||||
if (isWifi) {
|
||||
lastConnectedNetworkId = activeNetwork.toString()
|
||||
Logger.d(TAG, " ✅ Initial WiFi network: $lastConnectedNetworkId")
|
||||
Logger.d(TAG, " 📡 WiFi already connected at startup - onAvailable() will only trigger on network change")
|
||||
Logger.d(
|
||||
TAG,
|
||||
" 📡 WiFi already connected at startup - " +
|
||||
"onAvailable() will only trigger on network change"
|
||||
)
|
||||
} else {
|
||||
lastConnectedNetworkId = null
|
||||
Logger.d(TAG, " ⚠️ Not on WiFi at startup")
|
||||
@@ -268,7 +341,7 @@ class NetworkMonitor(private val context: Context) {
|
||||
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||||
Logger.d(TAG, "✅ WiFi monitoring stopped")
|
||||
} catch (e: Exception) {
|
||||
// Already unregistered
|
||||
Logger.w(TAG, "NetworkCallback already unregistered: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
package dev.dettmer.simplenotes.sync
|
||||
|
||||
/**
|
||||
* Ergebnis eines Sync-Vorgangs
|
||||
*
|
||||
* v1.7.0: Initial
|
||||
* v1.8.0: deletedOnServerCount hinzugefügt
|
||||
*/
|
||||
data class SyncResult(
|
||||
val isSuccess: Boolean,
|
||||
val syncedCount: Int = 0,
|
||||
val conflictCount: Int = 0,
|
||||
val deletedOnServerCount: Int = 0, // 🆕 v1.8.0
|
||||
val errorMessage: String? = null
|
||||
) {
|
||||
val hasConflicts: Boolean
|
||||
get() = conflictCount > 0
|
||||
val hasConflicts: Boolean get() = conflictCount > 0
|
||||
val hasServerDeletions: Boolean get() = deletedOnServerCount > 0 // 🆕 v1.8.0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
package dev.dettmer.simplenotes.sync
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* 🔄 v1.3.1: Zentrale Verwaltung des Sync-Status
|
||||
* 🆕 v1.8.0: Komplett überarbeitet - SyncProgress ist jetzt das einzige Banner-System
|
||||
*
|
||||
* SyncProgress (StateFlow) steuert den gesamten Sync-Lebenszyklus:
|
||||
* PREPARING → [UPLOADING] → [DOWNLOADING] → [IMPORTING_MARKDOWN] → COMPLETED/ERROR → IDLE
|
||||
*
|
||||
* SyncStatus (LiveData) wird nur noch intern für Mutex/Silent-Tracking verwendet.
|
||||
*/
|
||||
object SyncStateManager {
|
||||
|
||||
private const val TAG = "SyncStateManager"
|
||||
|
||||
/**
|
||||
* Mögliche Sync-Zustände (intern für Mutex + PullToRefresh)
|
||||
*/
|
||||
enum class SyncState {
|
||||
IDLE,
|
||||
SYNCING,
|
||||
SYNCING_SILENT,
|
||||
COMPLETED,
|
||||
ERROR
|
||||
}
|
||||
|
||||
/**
|
||||
* Interne Sync-Informationen (für Mutex-Management + Silent-Tracking)
|
||||
*/
|
||||
data class SyncStatus(
|
||||
val state: SyncState = SyncState.IDLE,
|
||||
val message: String? = null,
|
||||
val source: String? = null,
|
||||
val silent: Boolean = false,
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
// Intern: Mutex + PullToRefresh State
|
||||
private val _syncStatus = MutableLiveData(SyncStatus())
|
||||
val syncStatus: LiveData<SyncStatus> = _syncStatus
|
||||
|
||||
// 🆕 v1.8.0: Einziges Banner-System - SyncProgress
|
||||
private val _syncProgress = MutableStateFlow(SyncProgress.IDLE)
|
||||
val syncProgress: StateFlow<SyncProgress> = _syncProgress.asStateFlow()
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
/**
|
||||
* Prüft ob gerade ein Sync läuft (inkl. Silent-Sync)
|
||||
*/
|
||||
val isSyncing: Boolean
|
||||
get() {
|
||||
val state = _syncStatus.value?.state
|
||||
return state == SyncState.SYNCING || state == SyncState.SYNCING_SILENT
|
||||
}
|
||||
|
||||
/**
|
||||
* Versucht einen Sync zu starten.
|
||||
* Bei silent=false: Setzt sofort PREPARING-Phase → Banner erscheint instant
|
||||
* Bei silent=true: Setzt silent-Flag → kein Banner wird angezeigt
|
||||
*/
|
||||
fun tryStartSync(source: String, silent: Boolean = false): Boolean {
|
||||
synchronized(lock) {
|
||||
if (isSyncing) {
|
||||
Logger.d(TAG, "⚠️ Sync already in progress, rejecting from: $source")
|
||||
return false
|
||||
}
|
||||
|
||||
val syncState = if (silent) SyncState.SYNCING_SILENT else SyncState.SYNCING
|
||||
Logger.d(TAG, "🔄 Starting sync from: $source (silent=$silent)")
|
||||
|
||||
_syncStatus.postValue(
|
||||
SyncStatus(
|
||||
state = syncState,
|
||||
source = source,
|
||||
silent = silent
|
||||
)
|
||||
)
|
||||
|
||||
// 🆕 v1.8.0: Sofort PREPARING-Phase setzen (Banner erscheint instant)
|
||||
_syncProgress.value = SyncProgress(
|
||||
phase = SyncPhase.PREPARING,
|
||||
silent = silent,
|
||||
startTime = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Markiert Sync als erfolgreich abgeschlossen
|
||||
* Bei Silent-Sync: direkt auf IDLE (kein Banner)
|
||||
* Bei normalem Sync: COMPLETED mit Nachricht (auto-hide durch UI)
|
||||
*/
|
||||
fun markCompleted(message: String? = null) {
|
||||
synchronized(lock) {
|
||||
val current = _syncStatus.value
|
||||
val wasSilent = current?.silent == true
|
||||
val currentSource = current?.source
|
||||
|
||||
Logger.d(TAG, "✅ Sync completed from: $currentSource (silent=$wasSilent)")
|
||||
|
||||
if (wasSilent) {
|
||||
// Silent-Sync: Direkt auf IDLE - kein Banner
|
||||
_syncStatus.postValue(SyncStatus())
|
||||
_syncProgress.value = SyncProgress.IDLE
|
||||
} else {
|
||||
// Normaler Sync: COMPLETED mit Nachricht anzeigen
|
||||
_syncStatus.postValue(
|
||||
SyncStatus(state = SyncState.COMPLETED, message = message, source = currentSource)
|
||||
)
|
||||
_syncProgress.value = SyncProgress(
|
||||
phase = SyncPhase.COMPLETED,
|
||||
resultMessage = message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Markiert Sync als fehlgeschlagen
|
||||
* Bei Silent-Sync: Fehler trotzdem anzeigen (wichtig für User)
|
||||
*/
|
||||
fun markError(errorMessage: String?) {
|
||||
synchronized(lock) {
|
||||
val current = _syncStatus.value
|
||||
val wasSilent = current?.silent == true
|
||||
val currentSource = current?.source
|
||||
|
||||
Logger.e(TAG, "❌ Sync failed from: $currentSource - $errorMessage")
|
||||
|
||||
_syncStatus.postValue(
|
||||
SyncStatus(state = SyncState.ERROR, message = errorMessage, source = currentSource)
|
||||
)
|
||||
|
||||
// Fehler immer anzeigen (auch bei Silent-Sync)
|
||||
_syncProgress.value = SyncProgress(
|
||||
phase = SyncPhase.ERROR,
|
||||
resultMessage = errorMessage,
|
||||
silent = false // Fehler nie silent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt alles zurück auf IDLE
|
||||
*/
|
||||
fun reset() {
|
||||
synchronized(lock) {
|
||||
_syncStatus.postValue(SyncStatus())
|
||||
_syncProgress.value = SyncProgress.IDLE
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🆕 v1.8.0: Detailliertes Progress-Tracking (während syncNotes())
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Aktualisiert den detaillierten Sync-Fortschritt
|
||||
* Behält silent-Flag und startTime der aktuellen Session bei
|
||||
*/
|
||||
fun updateProgress(
|
||||
phase: SyncPhase,
|
||||
current: Int = 0,
|
||||
total: Int = 0,
|
||||
currentFileName: String? = null
|
||||
) {
|
||||
synchronized(lock) {
|
||||
val existing = _syncProgress.value
|
||||
_syncProgress.value = SyncProgress(
|
||||
phase = phase,
|
||||
current = current,
|
||||
total = total,
|
||||
currentFileName = currentFileName,
|
||||
silent = existing.silent,
|
||||
startTime = existing.startTime
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inkrementiert den Fortschritt um 1
|
||||
* Praktisch für Schleifen: nach jedem tatsächlichen Download
|
||||
*/
|
||||
fun incrementProgress(currentFileName: String? = null) {
|
||||
synchronized(lock) {
|
||||
val current = _syncProgress.value
|
||||
_syncProgress.value = current.copy(
|
||||
current = current.current + 1,
|
||||
currentFileName = currentFileName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt Progress zurück auf IDLE (am Ende von syncNotes())
|
||||
* Wird NICHT für COMPLETED/ERROR verwendet - nur für Cleanup
|
||||
*/
|
||||
fun resetProgress() {
|
||||
// Nicht zurücksetzen wenn COMPLETED/ERROR - die UI braucht den State noch für auto-hide
|
||||
synchronized(lock) {
|
||||
val current = _syncProgress.value
|
||||
if (current.phase != SyncPhase.COMPLETED && current.phase != SyncPhase.ERROR) {
|
||||
_syncProgress.value = SyncProgress.IDLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🆕 v1.8.1 (IMPL_08): Globaler Sync-Cooldown
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Prüft ob seit dem letzten erfolgreichen Sync-Start genügend Zeit vergangen ist.
|
||||
* Wird von ALLEN Sync-Triggern als erste Prüfung aufgerufen.
|
||||
*
|
||||
* @return true wenn ein neuer Sync erlaubt ist
|
||||
*/
|
||||
fun canSyncGlobally(prefs: android.content.SharedPreferences): Boolean {
|
||||
val lastGlobalSync = prefs.getLong(dev.dettmer.simplenotes.utils.Constants.KEY_LAST_GLOBAL_SYNC_TIME, 0)
|
||||
val now = System.currentTimeMillis()
|
||||
val elapsed = now - lastGlobalSync
|
||||
|
||||
if (elapsed < dev.dettmer.simplenotes.utils.Constants.MIN_GLOBAL_SYNC_INTERVAL_MS) {
|
||||
val remainingSec = (dev.dettmer.simplenotes.utils.Constants.MIN_GLOBAL_SYNC_INTERVAL_MS - elapsed) / 1000
|
||||
dev.dettmer.simplenotes.utils.Logger.d(TAG, "⏳ Global sync cooldown active - wait ${remainingSec}s")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Markiert den aktuellen Zeitpunkt als letzten Sync-Start (global).
|
||||
* Aufzurufen wenn ein Sync tatsächlich startet (nach allen Checks).
|
||||
*/
|
||||
fun markGlobalSyncStarted(prefs: android.content.SharedPreferences) {
|
||||
prefs.edit().putLong(dev.dettmer.simplenotes.utils.Constants.KEY_LAST_GLOBAL_SYNC_TIME, System.currentTimeMillis()).apply()
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🆕 v1.8.1 (IMPL_12): Info-Meldungen über das Banner-System
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Zeigt eine kurzfristige Info-Meldung im Banner an.
|
||||
* Wird für nicht-sync-bezogene Benachrichtigungen verwendet
|
||||
* (z.B. Server-Delete-Ergebnisse).
|
||||
*
|
||||
* ACHTUNG: Wenn gerade ein Sync läuft (isSyncing), wird die Meldung
|
||||
* ignoriert — der Sync-Progress hat Vorrang.
|
||||
*
|
||||
* Auto-Hide erfolgt über ComposeMainActivity (2.5s).
|
||||
*/
|
||||
fun showInfo(message: String) {
|
||||
synchronized(lock) {
|
||||
// Nicht während aktivem Sync anzeigen — Sync-Fortschritt hat Vorrang
|
||||
if (isSyncing) {
|
||||
Logger.d(TAG, "ℹ️ Info suppressed during sync: $message")
|
||||
return
|
||||
}
|
||||
|
||||
_syncProgress.value = SyncProgress(
|
||||
phase = SyncPhase.INFO,
|
||||
resultMessage = message,
|
||||
silent = false // INFO ist nie silent
|
||||
)
|
||||
|
||||
Logger.d(TAG, "ℹ️ Showing info: $message")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt eine Fehlermeldung im Banner an, auch außerhalb eines Syncs.
|
||||
* Für nicht-sync-bezogene Fehler (z.B. Server-Delete fehlgeschlagen).
|
||||
*
|
||||
* Auto-Hide erfolgt über ComposeMainActivity (4s).
|
||||
*/
|
||||
fun showError(message: String?) {
|
||||
synchronized(lock) {
|
||||
// Nicht während aktivem Sync anzeigen
|
||||
if (isSyncing) {
|
||||
Logger.d(TAG, "❌ Error suppressed during sync: $message")
|
||||
return
|
||||
}
|
||||
|
||||
_syncProgress.value = SyncProgress(
|
||||
phase = SyncPhase.ERROR,
|
||||
resultMessage = message,
|
||||
silent = false
|
||||
)
|
||||
|
||||
Logger.e(TAG, "❌ Showing error: $message")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
@file:Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional, will migrate in v2.0.0
|
||||
|
||||
package dev.dettmer.simplenotes.sync
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.WorkerParameters
|
||||
import dev.dettmer.simplenotes.BuildConfig
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -21,6 +29,51 @@ class SyncWorker(
|
||||
const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED"
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔧 v1.7.2: Required for expedited work on Android 9-11
|
||||
*
|
||||
* WorkManager ruft diese Methode auf um die Foreground-Notification zu erstellen
|
||||
* wenn der Worker als Expedited Work gestartet wird.
|
||||
*
|
||||
* Ab Android 12+ wird diese Methode NICHT aufgerufen (neue Expedited API).
|
||||
* Auf Android 9-11 MUSS diese Methode implementiert sein!
|
||||
*
|
||||
* @see https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#foregroundinfo
|
||||
*/
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||
val notification = NotificationHelper.createSyncProgressNotification(applicationContext)
|
||||
|
||||
// Android 10+ benötigt foregroundServiceType
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ForegroundInfo(
|
||||
NotificationHelper.SYNC_PROGRESS_NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
)
|
||||
} else {
|
||||
ForegroundInfo(
|
||||
NotificationHelper.SYNC_PROGRESS_NOTIFICATION_ID,
|
||||
notification
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob die App im Vordergrund ist.
|
||||
* Wenn ja, brauchen wir keine Benachrichtigung - die UI zeigt die Änderungen direkt.
|
||||
*/
|
||||
private fun isAppInForeground(): Boolean {
|
||||
val activityManager = applicationContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val appProcesses = activityManager.runningAppProcesses ?: return false
|
||||
val packageName = applicationContext.packageName
|
||||
|
||||
return appProcesses.any { process ->
|
||||
process.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND &&
|
||||
process.processName == packageName
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod") // Linear sync flow with debug logging — splitting would hurt readability
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
@@ -52,7 +105,81 @@ class SyncWorker(
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 2: Checking server reachability (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
|
||||
// Spart Batterie + Netzwerk-Traffic + Server-Last
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ No local changes - skipping sync (performance optimization)")
|
||||
Logger.d(TAG, " Saves battery, network traffic, and server load")
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (no changes to sync)")
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
}
|
||||
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 4: Checking sync gate (canSync)")
|
||||
}
|
||||
|
||||
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (WiFi-Only, Offline Mode, Server Config)
|
||||
val gateResult = syncService.canSync()
|
||||
if (!gateResult.canSync) {
|
||||
if (gateResult.isBlockedByWifiOnly) {
|
||||
Logger.d(TAG, "⏭️ WiFi-only mode enabled, but not on WiFi - skipping sync")
|
||||
} else {
|
||||
Logger.d(TAG, "⏭️ Sync blocked by gate: ${gateResult.blockReason ?: "offline/no server"}")
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (gate blocked)")
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
}
|
||||
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 5: Checking server reachability (Pre-Check)")
|
||||
}
|
||||
|
||||
// ⭐ KRITISCH: Server-Erreichbarkeits-Check VOR Sync
|
||||
@@ -63,6 +190,9 @@ class SyncWorker(
|
||||
Logger.d(TAG, " Reason: Server offline/wrong network/network not ready/not configured")
|
||||
Logger.d(TAG, " This is normal in foreign WiFi or during network initialization")
|
||||
|
||||
// 🔥 v1.1.2: Check if we should show warning (server unreachable for >24h)
|
||||
checkAndShowSyncWarning(syncService)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (silent skip)")
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
@@ -73,7 +203,7 @@ class SyncWorker(
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 3: Server reachable - proceeding with sync")
|
||||
Logger.d(TAG, "📍 Step 6: Server reachable - proceeding with sync")
|
||||
Logger.d(TAG, " SyncService: $syncService")
|
||||
}
|
||||
|
||||
@@ -94,18 +224,30 @@ class SyncWorker(
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 4: Processing result")
|
||||
Logger.d(TAG, "📦 Sync result: success=${result.isSuccess}, count=${result.syncedCount}, error=${result.errorMessage}")
|
||||
Logger.d(TAG, "📍 Step 7: Processing result")
|
||||
Logger.d(
|
||||
TAG,
|
||||
"📦 Sync result: success=${result.isSuccess}, " +
|
||||
"count=${result.syncedCount}, error=${result.errorMessage}"
|
||||
)
|
||||
}
|
||||
|
||||
if (result.isSuccess) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 5: Success path")
|
||||
Logger.d(TAG, "📍 Step 8: Success path")
|
||||
}
|
||||
Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes")
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_08): SyncStateManager aktualisieren
|
||||
SyncStateManager.markCompleted()
|
||||
|
||||
// Nur Notification zeigen wenn tatsächlich etwas gesynct wurde
|
||||
// UND die App nicht im Vordergrund ist (sonst sieht User die Änderungen direkt)
|
||||
if (result.syncedCount > 0) {
|
||||
val appInForeground = isAppInForeground()
|
||||
if (appInForeground) {
|
||||
Logger.d(TAG, "ℹ️ App in foreground - skipping notification (UI shows changes)")
|
||||
} else {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, " Showing success notification...")
|
||||
}
|
||||
@@ -113,6 +255,7 @@ class SyncWorker(
|
||||
applicationContext,
|
||||
result.syncedCount
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Logger.d(TAG, "ℹ️ No changes to sync - no notification")
|
||||
}
|
||||
@@ -123,6 +266,20 @@ class SyncWorker(
|
||||
}
|
||||
broadcastSyncCompleted(true, result.syncedCount)
|
||||
|
||||
// 🆕 v1.8.0: Alle Widgets aktualisieren nach Sync
|
||||
try {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, " Updating widgets...")
|
||||
}
|
||||
val glanceManager = androidx.glance.appwidget.GlanceAppWidgetManager(applicationContext)
|
||||
val glanceIds = glanceManager.getGlanceIds(dev.dettmer.simplenotes.widget.NoteWidget::class.java)
|
||||
glanceIds.forEach { id ->
|
||||
dev.dettmer.simplenotes.widget.NoteWidget().update(applicationContext, id)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to update widgets: ${e.message}")
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS")
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
@@ -130,9 +287,13 @@ class SyncWorker(
|
||||
Result.success()
|
||||
} else {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 5: Failure path")
|
||||
Logger.d(TAG, "📍 Step 8: Failure path")
|
||||
}
|
||||
Logger.e(TAG, "❌ Sync failed: ${result.errorMessage}")
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_08): SyncStateManager aktualisieren
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
|
||||
NotificationHelper.showSyncError(
|
||||
applicationContext,
|
||||
result.errorMessage ?: "Unbekannter Fehler"
|
||||
@@ -147,6 +308,32 @@ class SyncWorker(
|
||||
}
|
||||
Result.failure()
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// ⭐ Job wurde gecancelt - KEIN FEHLER!
|
||||
// Gründe: App-Update, Doze Mode, Battery Optimization, Network Constraint, etc.
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
}
|
||||
Logger.d(TAG, "⏹️ Job was cancelled (normal - update/doze/constraints)")
|
||||
Logger.d(TAG, " Reason could be: App update, Doze mode, Battery opt, Network disconnect")
|
||||
Logger.d(TAG, " This is expected Android behavior - not an error!")
|
||||
|
||||
try {
|
||||
// UI-Refresh trotzdem triggern (falls MainActivity geöffnet)
|
||||
broadcastSyncCompleted(false, 0)
|
||||
} catch (broadcastError: Exception) {
|
||||
Logger.e(TAG, "Failed to broadcast after cancellation", broadcastError)
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (cancelled, no error)")
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
}
|
||||
|
||||
// ⚠️ WICHTIG: Result.success() zurückgeben!
|
||||
// Cancellation ist KEIN Fehler, WorkManager soll nicht retries machen
|
||||
Result.success()
|
||||
|
||||
} catch (e: Exception) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
@@ -181,6 +368,7 @@ class SyncWorker(
|
||||
/**
|
||||
* Sendet Broadcast an MainActivity für UI Refresh
|
||||
*/
|
||||
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but still functional, will migrate in v2.0.0
|
||||
private fun broadcastSyncCompleted(success: Boolean, count: Int) {
|
||||
val intent = Intent(ACTION_SYNC_COMPLETED).apply {
|
||||
putExtra("success", success)
|
||||
@@ -189,4 +377,69 @@ class SyncWorker(
|
||||
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent)
|
||||
Logger.d(TAG, "📡 Broadcast sent: success=$success, count=$count")
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob Server längere Zeit unreachable und zeigt ggf. Warnung (v1.1.2)
|
||||
* - Nur wenn Auto-Sync aktiviert
|
||||
* - Nur wenn schon mal erfolgreich gesynct
|
||||
* - Nur wenn >24h seit letztem erfolgreichen Sync
|
||||
* - Throttling: Max. 1 Warnung pro 24h
|
||||
*/
|
||||
private fun checkAndShowSyncWarning(syncService: WebDavSyncService) {
|
||||
try {
|
||||
val prefs = applicationContext.getSharedPreferences(
|
||||
dev.dettmer.simplenotes.utils.Constants.PREFS_NAME,
|
||||
android.content.Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
// Check 1: Auto-Sync aktiviert?
|
||||
val autoSyncEnabled = prefs.getBoolean(
|
||||
dev.dettmer.simplenotes.utils.Constants.KEY_AUTO_SYNC,
|
||||
false
|
||||
)
|
||||
if (!autoSyncEnabled) {
|
||||
Logger.d(TAG, "⏭️ Auto-Sync disabled - no warning needed")
|
||||
return
|
||||
}
|
||||
|
||||
// Check 2: Schon mal erfolgreich gesynct?
|
||||
val lastSuccessfulSync = syncService.getLastSuccessfulSyncTimestamp()
|
||||
if (lastSuccessfulSync == 0L) {
|
||||
Logger.d(TAG, "⏭️ Never synced successfully - no warning needed")
|
||||
return
|
||||
}
|
||||
|
||||
// Check 3: >24h seit letztem erfolgreichen Sync?
|
||||
val now = System.currentTimeMillis()
|
||||
val timeSinceLastSync = now - lastSuccessfulSync
|
||||
if (timeSinceLastSync < dev.dettmer.simplenotes.utils.Constants.SYNC_WARNING_THRESHOLD_MS) {
|
||||
Logger.d(TAG, "⏭️ Last successful sync <24h ago - no warning needed")
|
||||
return
|
||||
}
|
||||
|
||||
// Check 4: Throttling - schon Warnung in letzten 24h gezeigt?
|
||||
val lastWarningShown = prefs.getLong(
|
||||
dev.dettmer.simplenotes.utils.Constants.KEY_LAST_SYNC_WARNING_SHOWN,
|
||||
0L
|
||||
)
|
||||
if (now - lastWarningShown < dev.dettmer.simplenotes.utils.Constants.SYNC_WARNING_THRESHOLD_MS) {
|
||||
Logger.d(TAG, "⏭️ Warning already shown in last 24h - throttling")
|
||||
return
|
||||
}
|
||||
|
||||
// Zeige Warnung
|
||||
val hoursSinceLastSync = timeSinceLastSync / (1000 * 60 * 60)
|
||||
NotificationHelper.showSyncWarning(applicationContext, hoursSinceLastSync)
|
||||
|
||||
// Speichere Zeitpunkt der Warnung
|
||||
prefs.edit()
|
||||
.putLong(dev.dettmer.simplenotes.utils.Constants.KEY_LAST_SYNC_WARNING_SHOWN, now)
|
||||
.apply()
|
||||
|
||||
Logger.d(TAG, "⚠️ Sync warning shown: Server unreachable for ${hoursSinceLastSync}h")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to check/show sync warning", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,12 +5,17 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.wifi.WifiManager
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* WiFi-Sync BroadcastReceiver
|
||||
*
|
||||
* Triggert Sync wenn WiFi verbunden wird (jedes WiFi, keine SSID-Prüfung mehr)
|
||||
* Die eigentliche Server-Erreichbarkeitsprüfung erfolgt im SyncWorker.
|
||||
*/
|
||||
class WifiSyncReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
@@ -22,33 +27,34 @@ class WifiSyncReceiver : BroadcastReceiver() {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if connected to home WiFi
|
||||
if (isConnectedToHomeWifi(context)) {
|
||||
// 🆕 v1.8.1 (IMPL_08): Globaler Cooldown (verhindert Doppel-Trigger mit NetworkMonitor)
|
||||
if (!SyncStateManager.canSyncGlobally(prefs)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_08): Auch KEY_SYNC_TRIGGER_WIFI_CONNECT prüfen (Konsistenz mit NetworkMonitor)
|
||||
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if connected to any WiFi (SSID-Prüfung entfernt in v1.4.0)
|
||||
if (isConnectedToWifi(context)) {
|
||||
scheduleSyncWork(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isConnectedToHomeWifi(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val homeSSID = prefs.getString(Constants.KEY_HOME_SSID, null) ?: return false
|
||||
|
||||
/**
|
||||
* Prüft ob ein WiFi-Netzwerk verbunden ist (beliebiges WiFi)
|
||||
* Die Server-Erreichbarkeitsprüfung erfolgt erst im SyncWorker.
|
||||
*/
|
||||
private fun isConnectedToWifi(context: Context): Boolean {
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
|
||||
as ConnectivityManager
|
||||
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
|
||||
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get current SSID
|
||||
val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE)
|
||||
as WifiManager
|
||||
val wifiInfo = wifiManager.connectionInfo
|
||||
val currentSSID = wifiInfo.ssid.replace("\"", "")
|
||||
|
||||
return currentSSID == homeSSID
|
||||
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
}
|
||||
|
||||
private fun scheduleSyncWork(context: Context) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
@file:Suppress("DEPRECATION") // AbstractSavedStateViewModelFactory deprecated, will migrate to viewModelFactory in v2.0.0
|
||||
|
||||
package dev.dettmer.simplenotes.ui.editor
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.lifecycle.AbstractSavedStateViewModelFactory
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.savedstate.SavedStateRegistryOwner
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme
|
||||
|
||||
/**
|
||||
* Compose-based Note Editor Activity
|
||||
*
|
||||
* v1.5.0: Jetpack Compose NoteEditor Redesign
|
||||
* Replaces the old NoteEditorActivity with a modern Compose implementation.
|
||||
*
|
||||
* Supports:
|
||||
* - TEXT notes with title and content
|
||||
* - CHECKLIST notes with drag & drop reordering
|
||||
* - Auto-keyboard focus for new checklist items
|
||||
*/
|
||||
class ComposeNoteEditorActivity : ComponentActivity() {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_NOTE_ID = "extra_note_id"
|
||||
const val EXTRA_NOTE_TYPE = "extra_note_type"
|
||||
}
|
||||
|
||||
private val viewModel: NoteEditorViewModel by viewModels {
|
||||
NoteEditorViewModelFactory(
|
||||
application = application,
|
||||
owner = this,
|
||||
noteId = intent.getStringExtra(EXTRA_NOTE_ID),
|
||||
noteType = intent.getStringExtra(EXTRA_NOTE_TYPE) ?: NoteType.TEXT.name
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Apply Dynamic Colors for Android 12+ (Material You)
|
||||
DynamicColors.applyToActivityIfAvailable(this)
|
||||
|
||||
enableEdgeToEdge()
|
||||
|
||||
// v1.5.0: Handle back button with slide animation
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
finish()
|
||||
@Suppress("DEPRECATION")
|
||||
overridePendingTransition(
|
||||
dev.dettmer.simplenotes.R.anim.slide_in_left,
|
||||
dev.dettmer.simplenotes.R.anim.slide_out_right
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
setContent {
|
||||
SimpleNotesTheme {
|
||||
NoteEditorScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = {
|
||||
finish()
|
||||
@Suppress("DEPRECATION")
|
||||
overridePendingTransition(
|
||||
dev.dettmer.simplenotes.R.anim.slide_in_left,
|
||||
dev.dettmer.simplenotes.R.anim.slide_out_right
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.0 (IMPL_025): Reload Checklist-State falls Widget Änderungen gemacht hat.
|
||||
*
|
||||
* Wenn die Activity aus dem Hintergrund zurückkehrt (z.B. nach Widget-Toggle),
|
||||
* wird der aktuelle Note-Stand von Disk geladen und der ViewModel-State
|
||||
* für Checklist-Items aktualisiert.
|
||||
*/
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.reloadFromStorage()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom ViewModelFactory to pass SavedStateHandle with intent extras
|
||||
*/
|
||||
class NoteEditorViewModelFactory(
|
||||
private val application: android.app.Application,
|
||||
owner: SavedStateRegistryOwner,
|
||||
private val noteId: String?,
|
||||
private val noteType: String
|
||||
) : AbstractSavedStateViewModelFactory(owner, null) {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(
|
||||
key: String,
|
||||
modelClass: Class<T>,
|
||||
handle: SavedStateHandle
|
||||
): T {
|
||||
// Populate SavedStateHandle with intent extras
|
||||
handle[NoteEditorViewModel.ARG_NOTE_ID] = noteId
|
||||
handle[NoteEditorViewModel.ARG_NOTE_TYPE] = noteType
|
||||
|
||||
return NoteEditorViewModel(application, handle) as T
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package dev.dettmer.simplenotes.ui.editor
|
||||
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* FOSS Drag & Drop State für LazyList
|
||||
*
|
||||
* Native Compose-Implementierung ohne externe Dependencies
|
||||
* v1.5.0: NoteEditor Redesign
|
||||
* v1.8.0: IMPL_023 - Drag & Drop Fix (pointerInput key + Handle-only drag)
|
||||
* v1.8.0: IMPL_023b - Flicker-Fix (Straddle-Target-Center-Erkennung statt Mittelpunkt)
|
||||
* v1.8.1: IMPL_14 - Separator als eigenes Item, Cross-Boundary-Drag mit Auto-Toggle
|
||||
*/
|
||||
class DragDropListState(
|
||||
private val state: LazyListState,
|
||||
private val scope: CoroutineScope,
|
||||
private val onMove: (Int, Int) -> Unit
|
||||
) {
|
||||
var draggingItemIndex by mutableStateOf<Int?>(null)
|
||||
private set
|
||||
|
||||
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
|
||||
private var draggingItemInitialOffset by mutableFloatStateOf(0f)
|
||||
// 🆕 v1.8.1: Item-Größe beim Drag-Start fixieren
|
||||
// Verhindert dass Höhenänderungen die Swap-Erkennung destabilisieren
|
||||
private var draggingItemSize by mutableStateOf(0)
|
||||
private var overscrollJob by mutableStateOf<Job?>(null)
|
||||
|
||||
// 🆕 v1.8.1 IMPL_14: Visual-Index des Separators (-1 = kein Separator)
|
||||
var separatorVisualIndex by mutableStateOf(-1)
|
||||
|
||||
val draggingItemOffset: Float
|
||||
get() = draggingItemLayoutInfo?.let { item ->
|
||||
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
|
||||
} ?: 0f
|
||||
|
||||
private val draggingItemLayoutInfo: LazyListItemInfo?
|
||||
get() = state.layoutInfo.visibleItemsInfo
|
||||
.firstOrNull { it.index == draggingItemIndex }
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 IMPL_14: Visual-Index → Data-Index Konvertierung.
|
||||
* Wenn ein Separator existiert, sind alle Items nach dem Separator um 1 verschoben.
|
||||
*/
|
||||
fun visualToDataIndex(visualIndex: Int): Int {
|
||||
if (separatorVisualIndex < 0) return visualIndex
|
||||
return if (visualIndex > separatorVisualIndex) visualIndex - 1 else visualIndex
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 IMPL_14: Data-Index → Visual-Index Konvertierung.
|
||||
*/
|
||||
fun dataToVisualIndex(dataIndex: Int): Int {
|
||||
if (separatorVisualIndex < 0) return dataIndex
|
||||
return if (dataIndex >= separatorVisualIndex) dataIndex + 1 else dataIndex
|
||||
}
|
||||
|
||||
fun onDragStart(offset: Offset, itemIndex: Int) {
|
||||
draggingItemIndex = itemIndex
|
||||
val info = draggingItemLayoutInfo
|
||||
draggingItemInitialOffset = info?.offset?.toFloat() ?: 0f
|
||||
draggingItemSize = info?.size ?: 0
|
||||
draggingItemDraggedDelta = 0f
|
||||
}
|
||||
|
||||
fun onDragInterrupted() {
|
||||
draggingItemDraggedDelta = 0f
|
||||
draggingItemIndex = null
|
||||
draggingItemInitialOffset = 0f
|
||||
draggingItemSize = 0
|
||||
overscrollJob?.cancel()
|
||||
}
|
||||
|
||||
fun onDrag(offset: Offset) {
|
||||
draggingItemDraggedDelta += offset.y
|
||||
|
||||
val draggingItem = draggingItemLayoutInfo ?: return
|
||||
val startOffset = draggingItem.offset + draggingItemOffset
|
||||
// 🆕 v1.8.1: Fixierte Item-Größe für stabile Swap-Erkennung
|
||||
val endOffset = startOffset + draggingItemSize
|
||||
|
||||
// 🆕 v1.8.0: IMPL_023b — Straddle-Target-Center + Adjazenz-Filter
|
||||
// Statt den Mittelpunkt des gezogenen Items zu prüfen ("liegt mein Zentrum im Target?"),
|
||||
// wird geprüft ob das gezogene Item den MITTELPUNKT des Targets überspannt.
|
||||
// Dies verhindert Oszillation bei Items unterschiedlicher Größe.
|
||||
// 🆕 v1.8.1 IMPL_14: Separator überspringen, Adjazenz berücksichtigt Separator-Lücke
|
||||
val targetItem = state.layoutInfo.visibleItemsInfo.firstOrNull { item ->
|
||||
// Separator überspringen
|
||||
item.index != separatorVisualIndex &&
|
||||
// Nur adjazente Items (Separator-Lücke wird übersprungen)
|
||||
isAdjacentSkippingSeparator(draggingItem.index, item.index) &&
|
||||
run {
|
||||
val targetCenter = item.offset + item.size / 2
|
||||
startOffset < targetCenter && endOffset > targetCenter
|
||||
}
|
||||
}
|
||||
|
||||
if (targetItem != null) {
|
||||
val scrollToIndex = if (targetItem.index == state.firstVisibleItemIndex) {
|
||||
draggingItem.index
|
||||
} else if (draggingItem.index == state.firstVisibleItemIndex) {
|
||||
targetItem.index
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 IMPL_14: Visual-Indizes zu Data-Indizes konvertieren für onMove
|
||||
val fromDataIndex = visualToDataIndex(draggingItem.index)
|
||||
val toDataIndex = visualToDataIndex(targetItem.index)
|
||||
|
||||
if (scrollToIndex != null) {
|
||||
scope.launch {
|
||||
state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
|
||||
onMove(fromDataIndex, toDataIndex)
|
||||
// 🆕 v1.8.0: IMPL_023b — Index-Update NACH dem Move (verhindert Race-Condition)
|
||||
draggingItemIndex = targetItem.index
|
||||
}
|
||||
} else {
|
||||
onMove(fromDataIndex, toDataIndex)
|
||||
draggingItemIndex = targetItem.index
|
||||
}
|
||||
} else {
|
||||
val overscroll = when {
|
||||
draggingItemDraggedDelta > 0 ->
|
||||
(endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
|
||||
draggingItemDraggedDelta < 0 ->
|
||||
(startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
if (overscroll != 0f) {
|
||||
if (overscrollJob?.isActive != true) {
|
||||
overscrollJob = scope.launch {
|
||||
state.scrollBy(overscroll)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
overscrollJob?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 IMPL_14: Prüft ob zwei Visual-Indizes adjazent sind,
|
||||
* wobei der Separator übersprungen wird.
|
||||
* Beispiel: Items bei Visual 1 und Visual 3 sind adjazent wenn Separator bei Visual 2 liegt.
|
||||
*/
|
||||
private fun isAdjacentSkippingSeparator(indexA: Int, indexB: Int): Boolean {
|
||||
val diff = kotlin.math.abs(indexA - indexB)
|
||||
if (diff == 1) {
|
||||
// Direkt benachbart — aber NICHT wenn der Separator dazwischen liegt
|
||||
val between = minOf(indexA, indexB) + 1
|
||||
return between != separatorVisualIndex || separatorVisualIndex < 0
|
||||
}
|
||||
if (diff == 2 && separatorVisualIndex >= 0) {
|
||||
// 2 Positionen entfernt — adjazent wenn Separator dazwischen
|
||||
val between = minOf(indexA, indexB) + 1
|
||||
return between == separatorVisualIndex
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@Suppress("UnusedPrivateProperty")
|
||||
private val LazyListItemInfo.offsetEnd: Int
|
||||
get() = this.offset + this.size
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberDragDropListState(
|
||||
lazyListState: LazyListState,
|
||||
scope: CoroutineScope,
|
||||
onMove: (Int, Int) -> Unit
|
||||
): DragDropListState {
|
||||
return remember(lazyListState, scope) {
|
||||
DragDropListState(
|
||||
state = lazyListState,
|
||||
scope = scope,
|
||||
onMove = onMove
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.dragContainer(
|
||||
dragDropState: DragDropListState,
|
||||
itemIndex: Int
|
||||
): Modifier {
|
||||
val currentIndex = rememberUpdatedState(itemIndex) // 🆕 v1.8.0: rememberUpdatedState statt Key
|
||||
return this.pointerInput(dragDropState) { // Nur dragDropState als Key - verhindert Gesture-Restart
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { offset ->
|
||||
dragDropState.onDragStart(offset, currentIndex.value) // Aktuellen Wert lesen
|
||||
},
|
||||
onDragEnd = {
|
||||
dragDropState.onDragInterrupted()
|
||||
},
|
||||
onDragCancel = {
|
||||
dragDropState.onDragInterrupted()
|
||||
},
|
||||
onDrag = { change, offset ->
|
||||
change.consume()
|
||||
dragDropState.onDrag(offset)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
package dev.dettmer.simplenotes.ui.editor
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.outlined.Sort
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.models.ChecklistSortOption
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
import dev.dettmer.simplenotes.ui.editor.components.CheckedItemsSeparator
|
||||
import dev.dettmer.simplenotes.ui.editor.components.ChecklistItemRow
|
||||
import dev.dettmer.simplenotes.ui.editor.components.ChecklistSortDialog
|
||||
import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog
|
||||
import kotlinx.coroutines.delay
|
||||
import dev.dettmer.simplenotes.utils.showToast
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val LAYOUT_DELAY_MS = 100L
|
||||
private const val AUTO_SCROLL_DELAY_MS = 50L
|
||||
private const val ITEM_CORNER_RADIUS_DP = 8
|
||||
private const val DRAGGING_ITEM_Z_INDEX = 10f
|
||||
private val DRAGGING_ELEVATION_DP = 8.dp
|
||||
|
||||
/**
|
||||
* Main Composable for the Note Editor screen.
|
||||
*
|
||||
* v1.5.0: Jetpack Compose NoteEditor Redesign
|
||||
* - Supports both TEXT and CHECKLIST notes
|
||||
* - Drag & Drop reordering for checklist items
|
||||
* - Auto-keyboard focus for new items
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NoteEditorScreen(
|
||||
viewModel: NoteEditorViewModel,
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val checklistItems by viewModel.checklistItems.collectAsState()
|
||||
|
||||
// 🌟 v1.6.0: Offline mode state
|
||||
val isOfflineMode by viewModel.isOfflineMode.collectAsState()
|
||||
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
var showChecklistSortDialog by remember { mutableStateOf(false) } // 🔀 v1.8.0
|
||||
val lastChecklistSortOption by viewModel.lastChecklistSortOption.collectAsState() // 🔀 v1.8.0
|
||||
var focusNewItemId by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Strings for toast messages (avoid LocalContextGetResourceValueCall lint)
|
||||
val msgNoteIsEmpty = stringResource(R.string.note_is_empty)
|
||||
val msgNoteSaved = stringResource(R.string.note_saved)
|
||||
val msgNoteDeleted = stringResource(R.string.note_deleted)
|
||||
|
||||
// v1.5.0: Auto-keyboard support
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val titleFocusRequester = remember { FocusRequester() }
|
||||
val contentFocusRequester = remember { FocusRequester() }
|
||||
|
||||
// v1.5.0: Auto-focus and show keyboard
|
||||
LaunchedEffect(uiState.isNewNote, uiState.noteType) {
|
||||
delay(LAYOUT_DELAY_MS) // Wait for layout
|
||||
when {
|
||||
uiState.isNewNote -> {
|
||||
// New note: focus title
|
||||
titleFocusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
}
|
||||
!uiState.isNewNote && uiState.noteType == NoteType.TEXT -> {
|
||||
// Editing text note: focus content
|
||||
contentFocusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle events
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collect { event ->
|
||||
when (event) {
|
||||
is NoteEditorEvent.ShowToast -> {
|
||||
val message = when (event.message) {
|
||||
ToastMessage.NOTE_IS_EMPTY -> msgNoteIsEmpty
|
||||
ToastMessage.NOTE_SAVED -> msgNoteSaved
|
||||
ToastMessage.NOTE_DELETED -> msgNoteDeleted
|
||||
}
|
||||
context.showToast(message)
|
||||
}
|
||||
is NoteEditorEvent.NavigateBack -> onNavigateBack()
|
||||
is NoteEditorEvent.ShowDeleteConfirmation -> showDeleteDialog = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = when (uiState.toolbarTitle) {
|
||||
ToolbarTitle.NEW_NOTE -> stringResource(R.string.new_note)
|
||||
ToolbarTitle.EDIT_NOTE -> stringResource(R.string.edit_note)
|
||||
ToolbarTitle.NEW_CHECKLIST -> stringResource(R.string.new_checklist)
|
||||
ToolbarTitle.EDIT_CHECKLIST -> stringResource(R.string.edit_checklist)
|
||||
}
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Delete button (only for existing notes)
|
||||
if (viewModel.canDelete()) {
|
||||
IconButton(onClick = { showDeleteDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.delete)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Save button
|
||||
IconButton(onClick = { viewModel.saveNote() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Save,
|
||||
contentDescription = stringResource(R.string.save)
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier.imePadding()
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Title Input (for both types)
|
||||
OutlinedTextField(
|
||||
value = uiState.title,
|
||||
onValueChange = { viewModel.updateTitle(it) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(titleFocusRequester),
|
||||
label = { Text(stringResource(R.string.title)) },
|
||||
singleLine = false,
|
||||
maxLines = 2,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
when (uiState.noteType) {
|
||||
NoteType.TEXT -> {
|
||||
// Content Input for TEXT notes
|
||||
TextNoteContent(
|
||||
content = uiState.content,
|
||||
onContentChange = { viewModel.updateContent(it) },
|
||||
focusRequester = contentFocusRequester,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
NoteType.CHECKLIST -> {
|
||||
// Checklist Editor
|
||||
ChecklistEditor(
|
||||
items = checklistItems,
|
||||
scope = scope,
|
||||
focusNewItemId = focusNewItemId,
|
||||
currentSortOption = lastChecklistSortOption, // 🔀 v1.8.0
|
||||
onTextChange = { id, text -> viewModel.updateChecklistItemText(id, text) },
|
||||
onCheckedChange = { id, checked -> viewModel.updateChecklistItemChecked(id, checked) },
|
||||
onDelete = { id -> viewModel.deleteChecklistItem(id) },
|
||||
onAddNewItemAfter = { id ->
|
||||
val newId = viewModel.addChecklistItemAfter(id)
|
||||
focusNewItemId = newId
|
||||
},
|
||||
onAddItemAtEnd = {
|
||||
val newId = viewModel.addChecklistItemAtEnd()
|
||||
focusNewItemId = newId
|
||||
},
|
||||
onMove = { from, to -> viewModel.moveChecklistItem(from, to) },
|
||||
onFocusHandled = { focusNewItemId = null },
|
||||
onSortClick = { showChecklistSortDialog = true }, // 🔀 v1.8.0
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete Confirmation Dialog - v1.5.0: Use shared component with server/local options
|
||||
if (showDeleteDialog) {
|
||||
DeleteConfirmationDialog(
|
||||
noteCount = 1,
|
||||
isOfflineMode = isOfflineMode,
|
||||
onDismiss = { showDeleteDialog = false },
|
||||
onDeleteLocal = {
|
||||
showDeleteDialog = false
|
||||
viewModel.deleteNote(deleteOnServer = false)
|
||||
},
|
||||
onDeleteEverywhere = {
|
||||
showDeleteDialog = false
|
||||
viewModel.deleteNote(deleteOnServer = true)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 🔀 v1.8.0: Checklist Sort Dialog
|
||||
if (showChecklistSortDialog) {
|
||||
ChecklistSortDialog(
|
||||
currentOption = lastChecklistSortOption,
|
||||
onOptionSelected = { option ->
|
||||
viewModel.sortChecklistItems(option)
|
||||
showChecklistSortDialog = false
|
||||
},
|
||||
onDismiss = { showChecklistSortDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextNoteContent(
|
||||
content: String,
|
||||
onContentChange: (String) -> Unit,
|
||||
focusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// v1.5.0: Use TextFieldValue to control cursor position
|
||||
// Track if initial cursor position has been set (only set to end once on first load)
|
||||
var initialCursorSet by remember { mutableStateOf(false) }
|
||||
|
||||
var textFieldValue by remember {
|
||||
mutableStateOf(TextFieldValue(
|
||||
text = content,
|
||||
selection = TextRange(content.length)
|
||||
))
|
||||
}
|
||||
|
||||
// Set initial cursor position only once when content first loads
|
||||
LaunchedEffect(Unit) {
|
||||
if (!initialCursorSet && content.isNotEmpty()) {
|
||||
textFieldValue = TextFieldValue(
|
||||
text = content,
|
||||
selection = TextRange(content.length)
|
||||
)
|
||||
initialCursorSet = true
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = { newValue ->
|
||||
textFieldValue = newValue
|
||||
onContentChange(newValue.text)
|
||||
},
|
||||
modifier = modifier.focusRequester(focusRequester),
|
||||
label = { Text(stringResource(R.string.content)) },
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 IMPL_14: Extrahiertes Composable für ein einzelnes draggbares Checklist-Item.
|
||||
* Entkoppelt von der Separator-Logik — wiederverwendbar für unchecked und checked Items.
|
||||
*/
|
||||
@Suppress("LongParameterList") // Compose callbacks — cannot be reduced without wrapper class
|
||||
@Composable
|
||||
private fun LazyItemScope.DraggableChecklistItem(
|
||||
item: ChecklistItemState,
|
||||
visualIndex: Int,
|
||||
dragDropState: DragDropListState,
|
||||
focusNewItemId: String?,
|
||||
onTextChange: (String, String) -> Unit,
|
||||
onCheckedChange: (String, Boolean) -> Unit,
|
||||
onDelete: (String) -> Unit,
|
||||
onAddNewItemAfter: (String) -> Unit,
|
||||
onFocusHandled: () -> Unit,
|
||||
onHeightChanged: () -> Unit, // 🆕 v1.8.1 (IMPL_05)
|
||||
) {
|
||||
val isDragging = dragDropState.draggingItemIndex == visualIndex
|
||||
val elevation by animateDpAsState(
|
||||
targetValue = if (isDragging) DRAGGING_ELEVATION_DP else 0.dp,
|
||||
label = "elevation"
|
||||
)
|
||||
|
||||
val shouldFocus = item.id == focusNewItemId
|
||||
|
||||
LaunchedEffect(shouldFocus) {
|
||||
if (shouldFocus) {
|
||||
onFocusHandled()
|
||||
}
|
||||
}
|
||||
|
||||
ChecklistItemRow(
|
||||
item = item,
|
||||
onTextChange = { onTextChange(item.id, it) },
|
||||
onCheckedChange = { onCheckedChange(item.id, it) },
|
||||
onDelete = { onDelete(item.id) },
|
||||
onAddNewItem = { onAddNewItemAfter(item.id) },
|
||||
requestFocus = shouldFocus,
|
||||
isDragging = isDragging,
|
||||
isAnyItemDragging = dragDropState.draggingItemIndex != null,
|
||||
dragModifier = Modifier.dragContainer(dragDropState, visualIndex),
|
||||
onHeightChanged = onHeightChanged, // 🆕 v1.8.1 (IMPL_05)
|
||||
modifier = Modifier
|
||||
.then(if (!isDragging) Modifier.animateItem() else Modifier)
|
||||
.offset {
|
||||
IntOffset(
|
||||
0,
|
||||
if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 0
|
||||
)
|
||||
}
|
||||
.zIndex(if (isDragging) DRAGGING_ITEM_Z_INDEX else 0f)
|
||||
.shadow(elevation, shape = RoundedCornerShape(ITEM_CORNER_RADIUS_DP.dp))
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(ITEM_CORNER_RADIUS_DP.dp)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList") // Compose functions commonly have many callback parameters
|
||||
@Composable
|
||||
private fun ChecklistEditor(
|
||||
items: List<ChecklistItemState>,
|
||||
scope: kotlinx.coroutines.CoroutineScope,
|
||||
focusNewItemId: String?,
|
||||
currentSortOption: ChecklistSortOption, // 🔀 v1.8.0: Aktuelle Sortierung
|
||||
onTextChange: (String, String) -> Unit,
|
||||
onCheckedChange: (String, Boolean) -> Unit,
|
||||
onDelete: (String) -> Unit,
|
||||
onAddNewItemAfter: (String) -> Unit,
|
||||
onAddItemAtEnd: () -> Unit,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
onFocusHandled: () -> Unit,
|
||||
onSortClick: () -> Unit, // 🔀 v1.8.0
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val dragDropState = rememberDragDropListState(
|
||||
lazyListState = listState,
|
||||
scope = scope,
|
||||
onMove = onMove
|
||||
)
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_05): Auto-Scroll bei Zeilenumbruch
|
||||
var scrollToItemIndex by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
// 🆕 v1.8.0 (IMPL_017 + IMPL_020): Separator nur bei MANUAL und UNCHECKED_FIRST anzeigen
|
||||
val uncheckedCount = items.count { !it.isChecked }
|
||||
val checkedCount = items.count { it.isChecked }
|
||||
val shouldShowSeparator = currentSortOption == ChecklistSortOption.MANUAL ||
|
||||
currentSortOption == ChecklistSortOption.UNCHECKED_FIRST
|
||||
val showSeparator = shouldShowSeparator && uncheckedCount > 0 && checkedCount > 0
|
||||
|
||||
Column(modifier = modifier) {
|
||||
// 🆕 v1.8.1 IMPL_14: Separator-Position für DragDropState aktualisieren
|
||||
val separatorVisualIndex = if (showSeparator) uncheckedCount else -1
|
||||
LaunchedEffect(separatorVisualIndex) {
|
||||
dragDropState.separatorVisualIndex = separatorVisualIndex
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_05): Auto-Scroll wenn ein Item durch Zeilenumbruch wächst
|
||||
LaunchedEffect(scrollToItemIndex) {
|
||||
scrollToItemIndex?.let { index ->
|
||||
delay(AUTO_SCROLL_DELAY_MS) // Warten bis Layout-Pass abgeschlossen
|
||||
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||
if (index >= lastVisibleIndex - 1) {
|
||||
listState.animateScrollToItem(
|
||||
index = minOf(index + 1, items.size + if (showSeparator) 1 else 0),
|
||||
scrollOffset = 0
|
||||
)
|
||||
}
|
||||
scrollToItemIndex = null
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
// 🆕 v1.8.1 IMPL_14: Unchecked Items (Visual Index 0..uncheckedCount-1)
|
||||
itemsIndexed(
|
||||
items = if (showSeparator) items.subList(0, uncheckedCount) else items,
|
||||
key = { _, item -> item.id }
|
||||
) { index, item ->
|
||||
DraggableChecklistItem(
|
||||
item = item,
|
||||
visualIndex = index,
|
||||
dragDropState = dragDropState,
|
||||
focusNewItemId = focusNewItemId,
|
||||
onTextChange = onTextChange,
|
||||
onCheckedChange = onCheckedChange,
|
||||
onDelete = onDelete,
|
||||
onAddNewItemAfter = onAddNewItemAfter,
|
||||
onFocusHandled = onFocusHandled,
|
||||
onHeightChanged = { scrollToItemIndex = index } // 🆕 v1.8.1 (IMPL_05)
|
||||
)
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 IMPL_14: Separator als eigenes LazyColumn-Item
|
||||
if (showSeparator) {
|
||||
item(key = "separator") {
|
||||
CheckedItemsSeparator(
|
||||
checkedCount = checkedCount,
|
||||
isDragActive = dragDropState.draggingItemIndex != null
|
||||
)
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 IMPL_14: Checked Items (Visual Index uncheckedCount+1..)
|
||||
itemsIndexed(
|
||||
items = items.subList(uncheckedCount, items.size),
|
||||
key = { _, item -> item.id }
|
||||
) { index, item ->
|
||||
val visualIndex = uncheckedCount + 1 + index // +1 für Separator
|
||||
DraggableChecklistItem(
|
||||
item = item,
|
||||
visualIndex = visualIndex,
|
||||
dragDropState = dragDropState,
|
||||
focusNewItemId = focusNewItemId,
|
||||
onTextChange = onTextChange,
|
||||
onCheckedChange = onCheckedChange,
|
||||
onDelete = onDelete,
|
||||
onAddNewItemAfter = onAddNewItemAfter,
|
||||
onFocusHandled = onFocusHandled,
|
||||
onHeightChanged = { scrollToItemIndex = visualIndex } // 🆕 v1.8.1 (IMPL_05)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔀 v1.8.0: Add Item Button + Sort Button
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, end = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = onAddItemAtEnd) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
Text(stringResource(R.string.add_item))
|
||||
}
|
||||
|
||||
IconButton(onClick = onSortClick) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.Sort,
|
||||
contentDescription = stringResource(R.string.sort_checklist),
|
||||
modifier = androidx.compose.ui.Modifier.padding(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v1.5.0: Local DeleteConfirmationDialog removed - now using shared component from ui/main/components/
|
||||
@@ -0,0 +1,691 @@
|
||||
package dev.dettmer.simplenotes.ui.editor
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import dev.dettmer.simplenotes.models.ChecklistItem
|
||||
import dev.dettmer.simplenotes.models.ChecklistSortOption
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||
import dev.dettmer.simplenotes.sync.SyncWorker
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* ViewModel for NoteEditor Compose Screen
|
||||
* v1.5.0: Jetpack Compose NoteEditor Redesign
|
||||
*
|
||||
* Manages note editing state including title, content, and checklist items.
|
||||
*/
|
||||
class NoteEditorViewModel(
|
||||
application: Application,
|
||||
private val savedStateHandle: SavedStateHandle
|
||||
) : AndroidViewModel(application) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NoteEditorViewModel"
|
||||
const val ARG_NOTE_ID = "noteId"
|
||||
const val ARG_NOTE_TYPE = "noteType"
|
||||
}
|
||||
|
||||
private val storage = NotesStorage(application)
|
||||
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _uiState = MutableStateFlow(NoteEditorUiState())
|
||||
val uiState: StateFlow<NoteEditorUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _checklistItems = MutableStateFlow<List<ChecklistItemState>>(emptyList())
|
||||
val checklistItems: StateFlow<List<ChecklistItemState>> = _checklistItems.asStateFlow()
|
||||
|
||||
// 🌟 v1.6.0: Offline Mode State
|
||||
private val _isOfflineMode = MutableStateFlow(
|
||||
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
|
||||
)
|
||||
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow()
|
||||
|
||||
// 🔀 v1.8.0 (IMPL_020): Letzte Checklist-Sortierung (Session-Scope)
|
||||
private val _lastChecklistSortOption = MutableStateFlow(ChecklistSortOption.MANUAL)
|
||||
val lastChecklistSortOption: StateFlow<ChecklistSortOption> = _lastChecklistSortOption.asStateFlow()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Events
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _events = MutableSharedFlow<NoteEditorEvent>()
|
||||
val events: SharedFlow<NoteEditorEvent> = _events.asSharedFlow()
|
||||
|
||||
// Internal state
|
||||
private var existingNote: Note? = null
|
||||
private var currentNoteType: NoteType = NoteType.TEXT
|
||||
|
||||
init {
|
||||
loadNote()
|
||||
}
|
||||
|
||||
private fun loadNote() {
|
||||
val noteId = savedStateHandle.get<String>(ARG_NOTE_ID)
|
||||
val noteTypeString = savedStateHandle.get<String>(ARG_NOTE_TYPE) ?: NoteType.TEXT.name
|
||||
|
||||
if (noteId != null) {
|
||||
loadExistingNote(noteId)
|
||||
} else {
|
||||
initNewNote(noteTypeString)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadExistingNote(noteId: String) {
|
||||
existingNote = storage.loadNote(noteId)
|
||||
existingNote?.let { note ->
|
||||
currentNoteType = note.noteType
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
title = note.title,
|
||||
content = note.content,
|
||||
noteType = note.noteType,
|
||||
isNewNote = false,
|
||||
toolbarTitle = if (note.noteType == NoteType.CHECKLIST) {
|
||||
ToolbarTitle.EDIT_CHECKLIST
|
||||
} else {
|
||||
ToolbarTitle.EDIT_NOTE
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (note.noteType == NoteType.CHECKLIST) {
|
||||
loadChecklistData(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadChecklistData(note: Note) {
|
||||
// 🆕 v1.8.1 (IMPL_03): Gespeicherte Sortierung laden
|
||||
note.checklistSortOption?.let { sortName ->
|
||||
_lastChecklistSortOption.value = parseSortOption(sortName)
|
||||
}
|
||||
|
||||
val items = note.checklistItems?.sortedBy { it.order }?.map {
|
||||
ChecklistItemState(
|
||||
id = it.id,
|
||||
text = it.text,
|
||||
isChecked = it.isChecked,
|
||||
order = it.order
|
||||
)
|
||||
} ?: emptyList()
|
||||
// 🆕 v1.8.0 (IMPL_017): Sortierung sicherstellen (falls alte Daten unsortiert sind)
|
||||
_checklistItems.value = sortChecklistItems(items)
|
||||
}
|
||||
|
||||
private fun initNewNote(noteTypeString: String) {
|
||||
currentNoteType = try {
|
||||
NoteType.valueOf(noteTypeString)
|
||||
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) {
|
||||
Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT")
|
||||
NoteType.TEXT
|
||||
}
|
||||
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
noteType = currentNoteType,
|
||||
isNewNote = true,
|
||||
toolbarTitle = if (currentNoteType == NoteType.CHECKLIST) {
|
||||
ToolbarTitle.NEW_CHECKLIST
|
||||
} else {
|
||||
ToolbarTitle.NEW_NOTE
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Add first empty item for new checklists
|
||||
if (currentNoteType == NoteType.CHECKLIST) {
|
||||
_checklistItems.value = listOf(ChecklistItemState.createEmpty(0))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse a ChecklistSortOption from its string name.
|
||||
* Falls back to MANUAL if the name is unknown (e.g., from older app versions).
|
||||
*/
|
||||
private fun parseSortOption(sortName: String): ChecklistSortOption {
|
||||
return try {
|
||||
ChecklistSortOption.valueOf(sortName)
|
||||
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) {
|
||||
Logger.w(TAG, "Unknown sort option '$sortName', using MANUAL")
|
||||
ChecklistSortOption.MANUAL
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Actions
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
fun updateTitle(title: String) {
|
||||
_uiState.update { it.copy(title = title) }
|
||||
}
|
||||
|
||||
fun updateContent(content: String) {
|
||||
_uiState.update { it.copy(content = content) }
|
||||
}
|
||||
|
||||
fun updateChecklistItemText(itemId: String, newText: String) {
|
||||
_checklistItems.update { items ->
|
||||
items.map { item ->
|
||||
if (item.id == itemId) item.copy(text = newText) else item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.0 (IMPL_017): Sortiert Checklist-Items mit Unchecked oben, Checked unten.
|
||||
* Stabile Sortierung: Relative Reihenfolge innerhalb jeder Gruppe bleibt erhalten.
|
||||
*/
|
||||
/**
|
||||
* Sortiert Checklist-Items basierend auf der aktuellen Sortier-Option.
|
||||
* 🆕 v1.8.1 (IMPL_03-FIX): Berücksichtigt jetzt _lastChecklistSortOption
|
||||
* anstatt immer unchecked-first zu sortieren.
|
||||
*/
|
||||
private fun sortChecklistItems(items: List<ChecklistItemState>): List<ChecklistItemState> {
|
||||
val sorted = when (_lastChecklistSortOption.value) {
|
||||
ChecklistSortOption.MANUAL,
|
||||
ChecklistSortOption.UNCHECKED_FIRST -> {
|
||||
val unchecked = items.filter { !it.isChecked }
|
||||
val checked = items.filter { it.isChecked }
|
||||
unchecked + checked
|
||||
}
|
||||
ChecklistSortOption.CHECKED_FIRST ->
|
||||
items.sortedByDescending { it.isChecked }
|
||||
ChecklistSortOption.ALPHABETICAL_ASC ->
|
||||
items.sortedBy { it.text.lowercase() }
|
||||
ChecklistSortOption.ALPHABETICAL_DESC ->
|
||||
items.sortedByDescending { it.text.lowercase() }
|
||||
}
|
||||
|
||||
return sorted.mapIndexed { index, item ->
|
||||
item.copy(order = index)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateChecklistItemChecked(itemId: String, isChecked: Boolean) {
|
||||
_checklistItems.update { items ->
|
||||
val updatedItems = items.map { item ->
|
||||
if (item.id == itemId) item.copy(isChecked = isChecked) else item
|
||||
}
|
||||
// 🆕 v1.8.0 (IMPL_017 + IMPL_020): Auto-Sort nur bei MANUAL und UNCHECKED_FIRST
|
||||
val currentSort = _lastChecklistSortOption.value
|
||||
if (currentSort == ChecklistSortOption.MANUAL || currentSort == ChecklistSortOption.UNCHECKED_FIRST) {
|
||||
sortChecklistItems(updatedItems)
|
||||
} else {
|
||||
// Bei anderen Sortierungen (alphabetisch, checked first) nicht auto-sortieren
|
||||
updatedItems.mapIndexed { index, item -> item.copy(order = index) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 (IMPL_15): Fügt ein neues Item nach dem angegebenen Item ein.
|
||||
*
|
||||
* Guard: Bei MANUAL/UNCHECKED_FIRST wird sichergestellt, dass das neue (unchecked)
|
||||
* Item nicht innerhalb der checked-Sektion eingefügt wird. Falls das Trigger-Item
|
||||
* checked ist, wird stattdessen vor dem ersten checked Item eingefügt.
|
||||
*/
|
||||
fun addChecklistItemAfter(afterItemId: String): String {
|
||||
val newItem = ChecklistItemState.createEmpty(0)
|
||||
_checklistItems.update { items ->
|
||||
val index = items.indexOfFirst { it.id == afterItemId }
|
||||
if (index >= 0) {
|
||||
val currentSort = _lastChecklistSortOption.value
|
||||
val hasSeparator = currentSort == ChecklistSortOption.MANUAL ||
|
||||
currentSort == ChecklistSortOption.UNCHECKED_FIRST
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_15): Wenn das Trigger-Item checked ist und ein Separator
|
||||
// existiert, darf das neue unchecked Item nicht in die checked-Sektion.
|
||||
// → Stattdessen vor dem ersten checked Item einfügen.
|
||||
val effectiveIndex = if (hasSeparator && items[index].isChecked) {
|
||||
val firstCheckedIndex = items.indexOfFirst { it.isChecked }
|
||||
if (firstCheckedIndex >= 0) firstCheckedIndex else index + 1
|
||||
} else {
|
||||
index + 1
|
||||
}
|
||||
|
||||
val newList = items.toMutableList()
|
||||
newList.add(effectiveIndex, newItem)
|
||||
// Update order values
|
||||
newList.mapIndexed { i, item -> item.copy(order = i) }
|
||||
} else {
|
||||
items + newItem.copy(order = items.size)
|
||||
}
|
||||
}
|
||||
return newItem.id
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 (IMPL_15): Fügt ein neues Item an der semantisch korrekten Position ein.
|
||||
*
|
||||
* Bei MANUAL/UNCHECKED_FIRST: Vor dem ersten checked Item (= direkt über dem Separator).
|
||||
* Bei allen anderen Modi: Am Ende der Liste (kein Separator sichtbar).
|
||||
*
|
||||
* Verhindert, dass checked Items über den Separator springen oder das neue Item
|
||||
* unter dem Separator erscheint.
|
||||
*/
|
||||
fun addChecklistItemAtEnd(): String {
|
||||
val newItem = ChecklistItemState.createEmpty(0)
|
||||
_checklistItems.update { items ->
|
||||
val insertIndex = calculateInsertIndexForNewItem(items)
|
||||
val newList = items.toMutableList()
|
||||
newList.add(insertIndex, newItem)
|
||||
newList.mapIndexed { i, item -> item.copy(order = i) }
|
||||
}
|
||||
return newItem.id
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 (IMPL_15): Berechnet die korrekte Insert-Position für ein neues unchecked Item.
|
||||
*
|
||||
* - MANUAL / UNCHECKED_FIRST: Vor dem ersten checked Item (direkt über dem Separator)
|
||||
* - Alle anderen Modi: Am Ende der Liste (kein Separator, kein visuelles Problem)
|
||||
*
|
||||
* Falls keine checked Items existieren, wird am Ende eingefügt.
|
||||
*/
|
||||
private fun calculateInsertIndexForNewItem(items: List<ChecklistItemState>): Int {
|
||||
val currentSort = _lastChecklistSortOption.value
|
||||
return when (currentSort) {
|
||||
ChecklistSortOption.MANUAL,
|
||||
ChecklistSortOption.UNCHECKED_FIRST -> {
|
||||
val firstCheckedIndex = items.indexOfFirst { it.isChecked }
|
||||
if (firstCheckedIndex >= 0) firstCheckedIndex else items.size
|
||||
}
|
||||
else -> items.size
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteChecklistItem(itemId: String) {
|
||||
_checklistItems.update { items ->
|
||||
val filtered = items.filter { it.id != itemId }
|
||||
// Ensure at least one item exists
|
||||
if (filtered.isEmpty()) {
|
||||
listOf(ChecklistItemState.createEmpty(0))
|
||||
} else {
|
||||
// Update order values
|
||||
filtered.mapIndexed { index, item -> item.copy(order = index) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun moveChecklistItem(fromIndex: Int, toIndex: Int) {
|
||||
_checklistItems.update { items ->
|
||||
val fromItem = items.getOrNull(fromIndex) ?: return@update items
|
||||
val toItem = items.getOrNull(toIndex) ?: return@update items
|
||||
|
||||
val mutableList = items.toMutableList()
|
||||
val item = mutableList.removeAt(fromIndex)
|
||||
|
||||
// 🆕 v1.8.1 IMPL_14: Cross-Boundary Move mit Auto-Toggle
|
||||
// Wenn ein Item die Grenze überschreitet, wird es automatisch checked/unchecked.
|
||||
val movedItem = if (fromItem.isChecked != toItem.isChecked) {
|
||||
item.copy(isChecked = toItem.isChecked)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
|
||||
mutableList.add(toIndex, movedItem)
|
||||
// Update order values
|
||||
mutableList.mapIndexed { index, i -> i.copy(order = index) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔀 v1.8.0 (IMPL_020): Sortiert Checklist-Items nach gewählter Option.
|
||||
* Einmalige Aktion (nicht persistiert) — User kann danach per Drag & Drop feinjustieren.
|
||||
*/
|
||||
fun sortChecklistItems(option: ChecklistSortOption) {
|
||||
// Merke die Auswahl für diesen Editor-Session
|
||||
_lastChecklistSortOption.value = option
|
||||
|
||||
_checklistItems.update { items ->
|
||||
val sorted = when (option) {
|
||||
// Bei MANUAL: Sortiere nach checked/unchecked, damit Separator korrekt platziert wird
|
||||
ChecklistSortOption.MANUAL -> items.sortedBy { it.isChecked }
|
||||
|
||||
ChecklistSortOption.ALPHABETICAL_ASC ->
|
||||
items.sortedBy { it.text.lowercase() }
|
||||
|
||||
ChecklistSortOption.ALPHABETICAL_DESC ->
|
||||
items.sortedByDescending { it.text.lowercase() }
|
||||
|
||||
ChecklistSortOption.UNCHECKED_FIRST ->
|
||||
items.sortedBy { it.isChecked }
|
||||
|
||||
ChecklistSortOption.CHECKED_FIRST ->
|
||||
items.sortedByDescending { it.isChecked }
|
||||
}
|
||||
|
||||
// Order-Werte neu zuweisen
|
||||
sorted.mapIndexed { index, item -> item.copy(order = index) }
|
||||
}
|
||||
}
|
||||
|
||||
fun saveNote() {
|
||||
viewModelScope.launch {
|
||||
val state = _uiState.value
|
||||
val title = state.title.trim()
|
||||
|
||||
when (currentNoteType) {
|
||||
NoteType.TEXT -> {
|
||||
val content = state.content.trim()
|
||||
|
||||
if (title.isEmpty() && content.isEmpty()) {
|
||||
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_IS_EMPTY))
|
||||
return@launch
|
||||
}
|
||||
|
||||
val note = if (existingNote != null) {
|
||||
// 🆕 v1.8.0 (IMPL_022): syncStatus wird immer auf PENDING gesetzt
|
||||
// beim Bearbeiten - gilt für SYNCED, CONFLICT, DELETED_ON_SERVER, etc.
|
||||
existingNote!!.copy(
|
||||
title = title,
|
||||
content = content,
|
||||
noteType = NoteType.TEXT,
|
||||
checklistItems = null,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
syncStatus = SyncStatus.PENDING
|
||||
)
|
||||
} else {
|
||||
Note(
|
||||
title = title,
|
||||
content = content,
|
||||
noteType = NoteType.TEXT,
|
||||
checklistItems = null,
|
||||
deviceId = DeviceIdGenerator.getDeviceId(getApplication()),
|
||||
syncStatus = SyncStatus.LOCAL_ONLY
|
||||
)
|
||||
}
|
||||
|
||||
storage.saveNote(note)
|
||||
}
|
||||
|
||||
NoteType.CHECKLIST -> {
|
||||
// Filter empty items
|
||||
val validItems = _checklistItems.value
|
||||
.filter { it.text.isNotBlank() }
|
||||
.mapIndexed { index, item ->
|
||||
ChecklistItem(
|
||||
id = item.id,
|
||||
text = item.text,
|
||||
isChecked = item.isChecked,
|
||||
order = index
|
||||
)
|
||||
}
|
||||
|
||||
if (title.isEmpty() && validItems.isEmpty()) {
|
||||
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_IS_EMPTY))
|
||||
return@launch
|
||||
}
|
||||
|
||||
val note = if (existingNote != null) {
|
||||
// 🆕 v1.8.0 (IMPL_022): syncStatus wird immer auf PENDING gesetzt
|
||||
// beim Bearbeiten - gilt für SYNCED, CONFLICT, DELETED_ON_SERVER, etc.
|
||||
existingNote!!.copy(
|
||||
title = title,
|
||||
content = "", // Empty for checklists
|
||||
noteType = NoteType.CHECKLIST,
|
||||
checklistItems = validItems,
|
||||
checklistSortOption = _lastChecklistSortOption.value.name, // 🆕 v1.8.1 (IMPL_03)
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
syncStatus = SyncStatus.PENDING
|
||||
)
|
||||
} else {
|
||||
Note(
|
||||
title = title,
|
||||
content = "",
|
||||
noteType = NoteType.CHECKLIST,
|
||||
checklistItems = validItems,
|
||||
checklistSortOption = _lastChecklistSortOption.value.name, // 🆕 v1.8.1 (IMPL_03)
|
||||
deviceId = DeviceIdGenerator.getDeviceId(getApplication()),
|
||||
syncStatus = SyncStatus.LOCAL_ONLY
|
||||
)
|
||||
}
|
||||
|
||||
storage.saveNote(note)
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_12): NOTE_SAVED Toast entfernt — NavigateBack ist ausreichend
|
||||
|
||||
// 🌟 v1.6.0: Trigger onSave Sync
|
||||
triggerOnSaveSync()
|
||||
|
||||
// 🆕 v1.8.0: Betroffene Widgets aktualisieren
|
||||
try {
|
||||
val glanceManager = androidx.glance.appwidget.GlanceAppWidgetManager(getApplication())
|
||||
val glanceIds = glanceManager.getGlanceIds(dev.dettmer.simplenotes.widget.NoteWidget::class.java)
|
||||
glanceIds.forEach { id ->
|
||||
dev.dettmer.simplenotes.widget.NoteWidget().update(getApplication(), id)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to update widgets: ${e.message}")
|
||||
}
|
||||
|
||||
_events.emit(NoteEditorEvent.NavigateBack)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the current note
|
||||
* @param deleteOnServer if true, also triggers server deletion; if false, only deletes locally
|
||||
* v1.5.0: Added deleteOnServer parameter for unified delete dialog
|
||||
*/
|
||||
fun deleteNote(deleteOnServer: Boolean = true) {
|
||||
viewModelScope.launch {
|
||||
existingNote?.let { note ->
|
||||
val noteId = note.id
|
||||
|
||||
// Delete locally first
|
||||
storage.deleteNote(noteId)
|
||||
|
||||
// Delete from server if requested
|
||||
if (deleteOnServer) {
|
||||
try {
|
||||
val webdavService = WebDavSyncService(getApplication())
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
webdavService.deleteNoteFromServer(noteId)
|
||||
}
|
||||
// 🆕 v1.8.1 (IMPL_12): Banner-Feedback statt stiller Log-Einträge
|
||||
if (success) {
|
||||
Logger.d(TAG, "Note $noteId deleted from server")
|
||||
SyncStateManager.showInfo(
|
||||
getApplication<Application>().getString(
|
||||
dev.dettmer.simplenotes.R.string.snackbar_deleted_from_server
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Logger.w(TAG, "Failed to delete note $noteId from server")
|
||||
SyncStateManager.showError(
|
||||
getApplication<Application>().getString(
|
||||
dev.dettmer.simplenotes.R.string.snackbar_server_delete_failed
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Error deleting note from server: ${e.message}")
|
||||
SyncStateManager.showError(
|
||||
getApplication<Application>().getString(
|
||||
dev.dettmer.simplenotes.R.string.snackbar_server_error,
|
||||
e.message ?: ""
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
_events.emit(NoteEditorEvent.NavigateBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showDeleteConfirmation() {
|
||||
viewModelScope.launch {
|
||||
_events.emit(NoteEditorEvent.ShowDeleteConfirmation)
|
||||
}
|
||||
}
|
||||
|
||||
fun canDelete(): Boolean = existingNote != null
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.0 (IMPL_025): Reload Note aus Storage nach Resume
|
||||
*
|
||||
* Wird aufgerufen wenn die Activity aus dem Hintergrund zurückkehrt.
|
||||
* Liest den aktuellen Note-Stand von Disk und aktualisiert den ViewModel-State.
|
||||
*
|
||||
* Wird nur für existierende Checklist-Notes benötigt (neue Notes haben keinen
|
||||
* externen Schreiber). Relevant für Widget-Checklist-Toggles.
|
||||
*
|
||||
* Nur checklistItems werden aktualisiert — nicht title oder content,
|
||||
* damit ungespeicherte Text-Änderungen im Editor nicht verloren gehen.
|
||||
*/
|
||||
fun reloadFromStorage() {
|
||||
val noteId = savedStateHandle.get<String>(ARG_NOTE_ID) ?: return
|
||||
|
||||
val freshNote = storage.loadNote(noteId) ?: return
|
||||
|
||||
// Nur Checklist-Items aktualisieren
|
||||
if (freshNote.noteType == NoteType.CHECKLIST) {
|
||||
val freshItems = freshNote.checklistItems?.sortedBy { it.order }?.map {
|
||||
ChecklistItemState(
|
||||
id = it.id,
|
||||
text = it.text,
|
||||
isChecked = it.isChecked,
|
||||
order = it.order
|
||||
)
|
||||
} ?: return
|
||||
|
||||
_checklistItems.value = sortChecklistItems(freshItems)
|
||||
// existingNote aktualisieren damit beim Speichern der richtige
|
||||
// Basis-State verwendet wird
|
||||
existingNote = freshNote
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 🌟 v1.6.0: Sync Trigger - onSave
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Triggers sync after saving a note (if enabled and server configured)
|
||||
* v1.6.0: New configurable sync trigger
|
||||
* v1.7.0: Uses central canSync() gate for WiFi-only check
|
||||
*
|
||||
* Separate throttling (5 seconds) to prevent spam when saving multiple times
|
||||
*/
|
||||
private fun triggerOnSaveSync() {
|
||||
// Check 1: Trigger enabled?
|
||||
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, Constants.DEFAULT_TRIGGER_ON_SAVE)) {
|
||||
Logger.d(TAG, "⏭️ onSave sync disabled - skipping")
|
||||
return
|
||||
}
|
||||
|
||||
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
|
||||
val syncService = WebDavSyncService(getApplication())
|
||||
val gateResult = syncService.canSync()
|
||||
if (!gateResult.canSync) {
|
||||
if (gateResult.isBlockedByWifiOnly) {
|
||||
Logger.d(TAG, "⏭️ onSave sync blocked: WiFi-only mode, not on WiFi")
|
||||
} else {
|
||||
Logger.d(TAG, "⏭️ onSave sync blocked: ${gateResult.blockReason ?: "offline/no server"}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check 2: Throttling (5 seconds) to prevent spam
|
||||
val lastOnSaveSyncTime = prefs.getLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, 0)
|
||||
val now = System.currentTimeMillis()
|
||||
val timeSinceLastSync = now - lastOnSaveSyncTime
|
||||
|
||||
if (timeSinceLastSync < Constants.MIN_ON_SAVE_SYNC_INTERVAL_MS) {
|
||||
val remainingSeconds = (Constants.MIN_ON_SAVE_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
|
||||
Logger.d(TAG, "⏳ onSave sync throttled - wait ${remainingSeconds}s")
|
||||
return
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
prefs.edit().putLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, now).apply()
|
||||
|
||||
// Trigger sync via WorkManager
|
||||
Logger.d(TAG, "📤 Triggering onSave sync")
|
||||
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
|
||||
.addTag(Constants.SYNC_WORK_TAG)
|
||||
.addTag(Constants.SYNC_ONSAVE_TAG) // 🆕 v1.8.1 (IMPL_08B): Bypassed globalen Cooldown
|
||||
.build()
|
||||
WorkManager.getInstance(getApplication()).enqueue(syncRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// State Classes
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
data class NoteEditorUiState(
|
||||
val title: String = "",
|
||||
val content: String = "",
|
||||
val noteType: NoteType = NoteType.TEXT,
|
||||
val isNewNote: Boolean = true,
|
||||
val toolbarTitle: ToolbarTitle = ToolbarTitle.NEW_NOTE
|
||||
)
|
||||
|
||||
data class ChecklistItemState(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val text: String = "",
|
||||
val isChecked: Boolean = false,
|
||||
val order: Int = 0
|
||||
) {
|
||||
companion object {
|
||||
fun createEmpty(order: Int): ChecklistItemState {
|
||||
return ChecklistItemState(
|
||||
id = UUID.randomUUID().toString(),
|
||||
text = "",
|
||||
isChecked = false,
|
||||
order = order
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class ToolbarTitle {
|
||||
NEW_NOTE,
|
||||
EDIT_NOTE,
|
||||
NEW_CHECKLIST,
|
||||
EDIT_CHECKLIST
|
||||
}
|
||||
|
||||
enum class ToastMessage {
|
||||
NOTE_IS_EMPTY,
|
||||
NOTE_SAVED,
|
||||
NOTE_DELETED
|
||||
}
|
||||
|
||||
sealed interface NoteEditorEvent {
|
||||
data class ShowToast(val message: ToastMessage) : NoteEditorEvent
|
||||
data object NavigateBack : NoteEditorEvent
|
||||
data object ShowDeleteConfirmation : NoteEditorEvent
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package dev.dettmer.simplenotes.ui.editor.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.DragHandle
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.ui.editor.ChecklistItemState
|
||||
|
||||
/**
|
||||
* A single row in the checklist editor with drag handle, checkbox, text input, and delete button.
|
||||
*
|
||||
* v1.5.0: Jetpack Compose NoteEditor Redesign
|
||||
* v1.8.0: Long text UX improvements (gradient fade, auto-expand on focus)
|
||||
* v1.8.0: IMPL_023 - Enlarged drag handle (48dp touch target) + drag modifier
|
||||
*
|
||||
* Note: Using 10 parameters for Composable is acceptable for complex UI components.
|
||||
* @suppress LongParameterList - Composables naturally have many parameters
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
fun ChecklistItemRow(
|
||||
item: ChecklistItemState,
|
||||
onTextChange: (String) -> Unit,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onAddNewItem: () -> Unit,
|
||||
requestFocus: Boolean = false,
|
||||
isDragging: Boolean = false, // 🆕 v1.8.0: IMPL_023 - Drag state
|
||||
isAnyItemDragging: Boolean = false, // 🆕 v1.8.0: IMPL_023 - Hide gradient during any drag
|
||||
dragModifier: Modifier = Modifier, // 🆕 v1.8.0: IMPL_023 - Drag modifier for handle
|
||||
onHeightChanged: (() -> Unit)? = null, // 🆕 v1.8.1: IMPL_05 - Auto-scroll callback
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val density = LocalDensity.current
|
||||
var textFieldValue by remember(item.id) {
|
||||
mutableStateOf(TextFieldValue(text = item.text, selection = TextRange(0)))
|
||||
}
|
||||
|
||||
// 🆕 v1.8.0: Focus-State tracken für Expand/Collapse
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
// 🆕 v1.8.0: Overflow erkennen (Text länger als maxLines)
|
||||
var hasOverflow by remember { mutableStateOf(false) }
|
||||
|
||||
// 🆕 v1.8.0: Höhe für collapsed-Ansicht (aus TextLayout berechnet)
|
||||
var collapsedHeightDp by remember { mutableStateOf<Dp?>(null) }
|
||||
|
||||
// 🆕 v1.8.0: ScrollState für dynamischen Gradient
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
// 🆕 v1.8.1: IMPL_05 - Letzte Zeilenanzahl tracken für Auto-Scroll
|
||||
var lastLineCount by remember { mutableIntStateOf(0) }
|
||||
|
||||
// 🆕 v1.8.1: Gradient-Sichtbarkeit direkt berechnet (kein derivedStateOf)
|
||||
// derivedStateOf mit remember{} fängt showGradient als stale val — nie aktualisiert.
|
||||
val showGradient = hasOverflow && collapsedHeightDp != null && !isFocused && !isAnyItemDragging
|
||||
val showTopGradient = showGradient && scrollState.value > 0
|
||||
val showBottomGradient = showGradient && scrollState.value < scrollState.maxValue
|
||||
|
||||
// v1.5.0: Auto-focus AND show keyboard when requestFocus is true (new items)
|
||||
LaunchedEffect(requestFocus) {
|
||||
if (requestFocus) {
|
||||
focusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 v1.8.0: Cursor ans Ende setzen wenn fokussiert (für Bearbeitung)
|
||||
LaunchedEffect(isFocused) {
|
||||
if (isFocused && textFieldValue.selection.start == 0) {
|
||||
textFieldValue = textFieldValue.copy(
|
||||
selection = TextRange(textFieldValue.text.length)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Update text field when external state changes
|
||||
LaunchedEffect(item.text) {
|
||||
if (textFieldValue.text != item.text) {
|
||||
textFieldValue = TextFieldValue(
|
||||
text = item.text,
|
||||
selection = if (isFocused) TextRange(item.text.length) else TextRange(0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val alpha = if (item.isChecked) 0.6f else 1.0f
|
||||
val textDecoration = if (item.isChecked) TextDecoration.LineThrough else TextDecoration.None
|
||||
|
||||
@Suppress("MagicNumber") // UI padding values are self-explanatory
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = 8.dp, top = 4.dp, bottom = 4.dp), // 🆕 v1.8.0: IMPL_023 - links kein Padding (Handle hat eigene Fläche)
|
||||
verticalAlignment = if (hasOverflow) Alignment.Top else Alignment.CenterVertically // 🆕 v1.8.0: Dynamisch
|
||||
) {
|
||||
// 🆕 v1.8.0: IMPL_023 - Vergrößerter Drag Handle (48dp Touch-Target)
|
||||
Box(
|
||||
modifier = dragModifier
|
||||
.size(48.dp) // Material Design minimum touch target
|
||||
.alpha(if (isDragging) 1.0f else 0.6f), // Visual feedback beim Drag
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.DragHandle,
|
||||
contentDescription = stringResource(R.string.drag_to_reorder),
|
||||
modifier = Modifier.size(28.dp), // Icon größer als vorher (24dp → 28dp)
|
||||
tint = if (isDragging) {
|
||||
MaterialTheme.colorScheme.primary // Primary color während Drag
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Checkbox
|
||||
Checkbox(
|
||||
checked = item.isChecked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
modifier = Modifier.alpha(alpha)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
// 🆕 v1.8.0: Text Input mit dynamischem Overflow-Gradient
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
// Scrollbarer Wrapper: begrenzt Höhe auf ~5 Zeilen wenn collapsed
|
||||
Box(
|
||||
modifier = if (!isFocused && hasOverflow && collapsedHeightDp != null) {
|
||||
Modifier
|
||||
.heightIn(max = collapsedHeightDp!!)
|
||||
.verticalScroll(scrollState)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
) {
|
||||
BasicTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = { newValue ->
|
||||
// Check for newline (Enter key)
|
||||
if (newValue.text.contains("\n")) {
|
||||
val cleanText = newValue.text.replace("\n", "")
|
||||
textFieldValue = TextFieldValue(
|
||||
text = cleanText,
|
||||
selection = TextRange(cleanText.length)
|
||||
)
|
||||
onTextChange(cleanText)
|
||||
onAddNewItem()
|
||||
} else {
|
||||
textFieldValue = newValue
|
||||
onTextChange(newValue.text)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { focusState ->
|
||||
isFocused = focusState.isFocused
|
||||
}
|
||||
.alpha(alpha),
|
||||
textStyle = LocalTextStyle.current.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textDecoration = textDecoration
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { onAddNewItem() }
|
||||
),
|
||||
singleLine = false,
|
||||
// 🆕 v1.8.1: maxLines IMMER Int.MAX_VALUE — keine Oszillation möglich
|
||||
// Höhenbegrenzung erfolgt ausschließlich über heightIn-Modifier oben.
|
||||
// Vorher: maxLines=5 → lineCount gedeckelt → Overflow nie erkannt → Deadlock
|
||||
maxLines = Int.MAX_VALUE,
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
onTextLayout = { textLayoutResult ->
|
||||
// 🆕 v1.8.1: lineCount ist jetzt akkurat (maxLines=MAX_VALUE deckelt nicht)
|
||||
val lineCount = textLayoutResult.lineCount
|
||||
if (!isAnyItemDragging) {
|
||||
val overflow = lineCount > COLLAPSED_MAX_LINES
|
||||
hasOverflow = overflow
|
||||
// Höhe der ersten 5 Zeilen berechnen (einmalig)
|
||||
if (overflow && collapsedHeightDp == null) {
|
||||
collapsedHeightDp = with(density) {
|
||||
textLayoutResult.getLineBottom(COLLAPSED_MAX_LINES - 1).toDp()
|
||||
}
|
||||
}
|
||||
// Reset wenn Text gekürzt wird
|
||||
if (!overflow) {
|
||||
collapsedHeightDp = null
|
||||
}
|
||||
}
|
||||
// 🆕 v1.8.1 (IMPL_05): Höhenänderung bei Zeilenumbruch melden
|
||||
if (isFocused && lineCount > lastLineCount && lastLineCount > 0) {
|
||||
onHeightChanged?.invoke()
|
||||
}
|
||||
lastLineCount = lineCount
|
||||
},
|
||||
decorationBox = { innerTextField ->
|
||||
Box {
|
||||
if (textFieldValue.text.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.item_placeholder),
|
||||
style = LocalTextStyle.current.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 🆕 v1.8.0: Dynamischer Gradient basierend auf Scroll-Position
|
||||
// Oben: sichtbar wenn nach unten gescrollt (Text oberhalb versteckt)
|
||||
if (showTopGradient) {
|
||||
OverflowGradient(
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
isTopGradient = true
|
||||
)
|
||||
}
|
||||
|
||||
// Unten: sichtbar wenn noch Text unterhalb vorhanden
|
||||
if (showBottomGradient) {
|
||||
OverflowGradient(
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
isTopGradient = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
// Delete Button
|
||||
IconButton(
|
||||
onClick = onDelete,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(top = 4.dp) // 🆕 v1.8.0: Ausrichtung mit Top-aligned Text
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.delete_item),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 v1.8.0: Maximum lines when collapsed (not focused)
|
||||
private const val COLLAPSED_MAX_LINES = 5
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// 🆕 v1.8.0: Preview Composables for Manual Testing
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
|
||||
@Suppress("UnusedPrivateMember")
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun ChecklistItemRowShortTextPreview() {
|
||||
ChecklistItemRow(
|
||||
item = ChecklistItemState(
|
||||
id = "preview-1",
|
||||
text = "Kurzer Text",
|
||||
isChecked = false
|
||||
),
|
||||
onTextChange = {},
|
||||
onCheckedChange = {},
|
||||
onDelete = {},
|
||||
onAddNewItem = {},
|
||||
isDragging = false,
|
||||
dragModifier = Modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("UnusedPrivateMember")
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun ChecklistItemRowLongTextPreview() {
|
||||
ChecklistItemRow(
|
||||
item = ChecklistItemState(
|
||||
id = "preview-2",
|
||||
text = "Dies ist ein sehr langer Text der sich über viele Zeilen erstreckt " +
|
||||
"und dazu dient den Overflow-Gradient zu demonstrieren. Er hat deutlich " +
|
||||
"mehr als fünf Zeilen wenn er in der normalen Breite eines Smartphones " +
|
||||
"angezeigt wird und sollte einen schönen Fade-Effekt am unteren Rand zeigen. " +
|
||||
"Dieser zusätzliche Text sorgt dafür, dass wir wirklich genug Zeilen haben " +
|
||||
"um den Gradient sichtbar zu machen.",
|
||||
isChecked = false
|
||||
),
|
||||
onTextChange = {},
|
||||
onCheckedChange = {},
|
||||
onDelete = {},
|
||||
onAddNewItem = {},
|
||||
isDragging = false,
|
||||
dragModifier = Modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("UnusedPrivateMember")
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun ChecklistItemRowCheckedPreview() {
|
||||
ChecklistItemRow(
|
||||
item = ChecklistItemState(
|
||||
id = "preview-3",
|
||||
text = "Erledigte Aufgabe mit durchgestrichenem Text",
|
||||
isChecked = true
|
||||
),
|
||||
onTextChange = {},
|
||||
onCheckedChange = {},
|
||||
onDelete = {},
|
||||
onAddNewItem = {},
|
||||
isDragging = false,
|
||||
dragModifier = Modifier
|
||||
)
|
||||
}
|
||||
|
||||
// 🆕 v1.8.0: IMPL_023 - Preview for dragging state
|
||||
@Suppress("UnusedPrivateMember")
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun ChecklistItemRowDraggingPreview() {
|
||||
ChecklistItemRow(
|
||||
item = ChecklistItemState(
|
||||
id = "preview-4",
|
||||
text = "Wird gerade verschoben - Handle ist highlighted",
|
||||
isChecked = false
|
||||
),
|
||||
onTextChange = {},
|
||||
onCheckedChange = {},
|
||||
onDelete = {},
|
||||
onAddNewItem = {},
|
||||
isDragging = true,
|
||||
dragModifier = Modifier
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,409 @@
|
||||
@file:Suppress("DEPRECATION") // LocalBroadcastManager & deprecated lifecycle methods, will migrate in v2.0.0
|
||||
|
||||
package dev.dettmer.simplenotes.ui.main
|
||||
|
||||
import android.Manifest
|
||||
import android.app.ActivityOptions
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.ui.editor.ComposeNoteEditorActivity
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||
import dev.dettmer.simplenotes.sync.SyncWorker
|
||||
import dev.dettmer.simplenotes.ui.settings.ComposeSettingsActivity
|
||||
import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Main Activity with Jetpack Compose UI
|
||||
* v1.5.0: Complete MainActivity Redesign with Compose
|
||||
*
|
||||
* Replaces the old 805-line MainActivity.kt with a modern
|
||||
* Compose-based implementation featuring:
|
||||
* - Notes list with swipe-to-delete
|
||||
* - Pull-to-refresh for sync
|
||||
* - FAB with note type selection
|
||||
* - Material 3 Design with Dynamic Colors (Material You)
|
||||
* - Design consistent with ComposeSettingsActivity
|
||||
*/
|
||||
class ComposeMainActivity : ComponentActivity() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ComposeMainActivity"
|
||||
private const val REQUEST_NOTIFICATION_PERMISSION = 1001
|
||||
private const val REQUEST_SETTINGS = 1002
|
||||
}
|
||||
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
|
||||
private val prefs by lazy {
|
||||
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
// Phase 3: Track if coming from editor to scroll to top
|
||||
private var cameFromEditor = false
|
||||
|
||||
/**
|
||||
* BroadcastReceiver for Background-Sync Completion (Periodic Sync)
|
||||
*/
|
||||
private val syncCompletedReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val success = intent?.getBooleanExtra("success", false) ?: false
|
||||
val count = intent?.getIntExtra("count", 0) ?: 0
|
||||
|
||||
Logger.d(TAG, "📡 Sync completed broadcast received: success=$success, count=$count")
|
||||
|
||||
// UI refresh
|
||||
if (success && count > 0) {
|
||||
viewModel.loadNotes()
|
||||
Logger.d(TAG, "🔄 Notes reloaded after background sync")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Install Splash Screen (Android 12+)
|
||||
installSplashScreen()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Apply Dynamic Colors for Material You (Android 12+)
|
||||
DynamicColors.applyToActivityIfAvailable(this)
|
||||
|
||||
// Enable edge-to-edge display
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Initialize Logger and enable file logging if configured
|
||||
Logger.init(this)
|
||||
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
|
||||
Logger.setFileLoggingEnabled(true)
|
||||
}
|
||||
|
||||
// Clear old sync notifications on app start
|
||||
NotificationHelper.clearSyncNotifications(this)
|
||||
|
||||
// Request notification permission (Android 13+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
requestNotificationPermission()
|
||||
}
|
||||
|
||||
// v1.4.1: Migrate checklists for backwards compatibility
|
||||
migrateChecklistsForBackwardsCompat()
|
||||
|
||||
// Setup Sync State Observer
|
||||
setupSyncStateObserver()
|
||||
|
||||
setContent {
|
||||
SimpleNotesTheme {
|
||||
val context = LocalContext.current
|
||||
|
||||
// Dialog state for delete confirmation
|
||||
var deleteDialogData by remember { mutableStateOf<MainViewModel.DeleteDialogData?>(null) }
|
||||
|
||||
// Handle delete dialog events
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.showDeleteDialog.collect { data ->
|
||||
deleteDialogData = data
|
||||
}
|
||||
}
|
||||
|
||||
// Handle toast events
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.showToast.collect { message ->
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
// Delete confirmation dialog
|
||||
deleteDialogData?.let { data ->
|
||||
DeleteConfirmationDialog(
|
||||
noteTitle = data.note.title,
|
||||
onDismiss = {
|
||||
viewModel.restoreNoteAfterSwipe(data.originalList)
|
||||
deleteDialogData = null
|
||||
},
|
||||
onDeleteLocal = {
|
||||
viewModel.deleteNoteConfirmed(data.note, deleteFromServer = false)
|
||||
deleteDialogData = null
|
||||
},
|
||||
onDeleteFromServer = {
|
||||
viewModel.deleteNoteConfirmed(data.note, deleteFromServer = true)
|
||||
deleteDialogData = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
MainScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenNote = { noteId -> openNoteEditor(noteId) },
|
||||
onOpenSettings = { openSettings() },
|
||||
onCreateNote = { noteType -> createNote(noteType) }
|
||||
)
|
||||
|
||||
// v1.8.0: Post-Update Changelog (shows once after update)
|
||||
UpdateChangelogSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
Logger.d(TAG, "📱 ComposeMainActivity.onResume() - Registering receivers")
|
||||
|
||||
// 🌟 v1.6.0: Refresh offline mode state FIRST (before any sync checks)
|
||||
// This ensures UI reflects current offline mode when returning from Settings
|
||||
viewModel.refreshOfflineModeState()
|
||||
|
||||
// 🎨 v1.7.0: Refresh display mode when returning from Settings
|
||||
viewModel.refreshDisplayMode()
|
||||
|
||||
// Register BroadcastReceiver for Background-Sync
|
||||
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional
|
||||
LocalBroadcastManager.getInstance(this).registerReceiver(
|
||||
syncCompletedReceiver,
|
||||
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
|
||||
)
|
||||
|
||||
Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)")
|
||||
|
||||
// Reload notes
|
||||
viewModel.loadNotes()
|
||||
|
||||
// Phase 3: Scroll to top if coming from editor (new/edited note)
|
||||
if (cameFromEditor) {
|
||||
viewModel.scrollToTop()
|
||||
cameFromEditor = false
|
||||
Logger.d(TAG, "📜 Came from editor - scrolling to top")
|
||||
}
|
||||
|
||||
// Trigger Auto-Sync on app resume
|
||||
viewModel.triggerAutoSync("onResume")
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
// Unregister BroadcastReceiver
|
||||
@Suppress("DEPRECATION")
|
||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
|
||||
Logger.d(TAG, "📡 BroadcastReceiver unregistered")
|
||||
}
|
||||
|
||||
private fun setupSyncStateObserver() {
|
||||
// 🆕 v1.8.0: SyncStatus nur noch für PullToRefresh-Indikator (intern)
|
||||
SyncStateManager.syncStatus.observe(this) { status ->
|
||||
viewModel.updateSyncState(status)
|
||||
}
|
||||
|
||||
// 🆕 v1.8.0: Auto-Hide via SyncProgress (einziges Banner-System)
|
||||
lifecycleScope.launch {
|
||||
SyncStateManager.syncProgress.collect { progress ->
|
||||
@Suppress("MagicNumber") // UI timing delays for banner visibility
|
||||
when (progress.phase) {
|
||||
dev.dettmer.simplenotes.sync.SyncPhase.COMPLETED -> {
|
||||
kotlinx.coroutines.delay(2000L)
|
||||
SyncStateManager.reset()
|
||||
}
|
||||
// 🆕 v1.8.1 (IMPL_12): INFO-Meldungen nach 2.5s ausblenden
|
||||
dev.dettmer.simplenotes.sync.SyncPhase.INFO -> {
|
||||
kotlinx.coroutines.delay(2500L)
|
||||
SyncStateManager.reset()
|
||||
}
|
||||
dev.dettmer.simplenotes.sync.SyncPhase.ERROR -> {
|
||||
kotlinx.coroutines.delay(4000L)
|
||||
SyncStateManager.reset()
|
||||
}
|
||||
else -> { /* No action needed */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openNoteEditor(noteId: String?) {
|
||||
cameFromEditor = true
|
||||
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
|
||||
noteId?.let {
|
||||
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_ID, it)
|
||||
}
|
||||
|
||||
// v1.5.0: Add slide animation
|
||||
val options = ActivityOptions.makeCustomAnimation(
|
||||
this,
|
||||
dev.dettmer.simplenotes.R.anim.slide_in_right,
|
||||
dev.dettmer.simplenotes.R.anim.slide_out_left
|
||||
)
|
||||
startActivity(intent, options.toBundle())
|
||||
}
|
||||
|
||||
private fun createNote(noteType: NoteType) {
|
||||
cameFromEditor = true
|
||||
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
|
||||
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
|
||||
|
||||
// v1.5.0: Add slide animation
|
||||
val options = ActivityOptions.makeCustomAnimation(
|
||||
this,
|
||||
dev.dettmer.simplenotes.R.anim.slide_in_right,
|
||||
dev.dettmer.simplenotes.R.anim.slide_out_left
|
||||
)
|
||||
startActivity(intent, options.toBundle())
|
||||
}
|
||||
|
||||
private fun openSettings() {
|
||||
val intent = Intent(this, ComposeSettingsActivity::class.java)
|
||||
val options = ActivityOptions.makeCustomAnimation(
|
||||
this,
|
||||
dev.dettmer.simplenotes.R.anim.slide_in_right,
|
||||
dev.dettmer.simplenotes.R.anim.slide_out_left
|
||||
)
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityForResult(intent, REQUEST_SETTINGS, options.toBundle())
|
||||
}
|
||||
|
||||
private fun requestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
REQUEST_NOTIFICATION_PERMISSION
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.1: Migrates existing checklists for backwards compatibility.
|
||||
*/
|
||||
private fun migrateChecklistsForBackwardsCompat() {
|
||||
val migrationKey = "v1.4.1_checklist_migration_done"
|
||||
|
||||
// Only run once
|
||||
if (prefs.getBoolean(migrationKey, false)) {
|
||||
return
|
||||
}
|
||||
|
||||
val storage = NotesStorage(this)
|
||||
val allNotes = storage.loadAllNotes()
|
||||
val checklistsToMigrate = allNotes.filter { note ->
|
||||
note.noteType == NoteType.CHECKLIST &&
|
||||
note.content.isBlank() &&
|
||||
note.checklistItems?.isNotEmpty() == true
|
||||
}
|
||||
|
||||
if (checklistsToMigrate.isNotEmpty()) {
|
||||
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
|
||||
|
||||
for (note in checklistsToMigrate) {
|
||||
val updatedNote = note.copy(
|
||||
syncStatus = SyncStatus.PENDING
|
||||
)
|
||||
storage.saveNote(updatedNote)
|
||||
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
|
||||
}
|
||||
|
||||
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
|
||||
}
|
||||
|
||||
// Mark migration as done
|
||||
prefs.edit().putBoolean(migrationKey, true).apply()
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
|
||||
if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) {
|
||||
// Settings changed, reload notes
|
||||
viewModel.loadNotes()
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in API 23", ReplaceWith("Use ActivityResultContracts"))
|
||||
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
|
||||
when (requestCode) {
|
||||
REQUEST_NOTIFICATION_PERMISSION -> {
|
||||
if (grantResults.isNotEmpty() &&
|
||||
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
Toast.makeText(this, getString(R.string.toast_notifications_enabled), Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(this,
|
||||
getString(R.string.toast_notifications_disabled),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete confirmation dialog
|
||||
*/
|
||||
@Composable
|
||||
private fun DeleteConfirmationDialog(
|
||||
noteTitle: String,
|
||||
onDismiss: () -> Unit,
|
||||
onDeleteLocal: () -> Unit,
|
||||
onDeleteFromServer: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.legacy_delete_dialog_title)) },
|
||||
text = {
|
||||
Text(stringResource(R.string.legacy_delete_dialog_message, noteTitle))
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDeleteLocal) {
|
||||
Text(stringResource(R.string.delete_local_only))
|
||||
}
|
||||
TextButton(onClick = onDeleteFromServer) {
|
||||
Text(stringResource(R.string.legacy_delete_from_server))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
package dev.dettmer.simplenotes.ui.main
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.SelectAll
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
|
||||
import androidx.compose.material.icons.automirrored.outlined.Sort
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
// FabPosition nicht mehr benötigt - FAB wird manuell platziert
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
import dev.dettmer.simplenotes.ui.main.components.SortDialog
|
||||
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||
import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog
|
||||
import dev.dettmer.simplenotes.ui.main.components.EmptyState
|
||||
import dev.dettmer.simplenotes.ui.main.components.NoteTypeFAB
|
||||
import dev.dettmer.simplenotes.ui.main.components.NotesList
|
||||
import dev.dettmer.simplenotes.ui.main.components.NotesStaggeredGrid
|
||||
import dev.dettmer.simplenotes.ui.main.components.SyncProgressBanner
|
||||
import dev.dettmer.simplenotes.ui.main.components.SyncStatusLegendDialog
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L
|
||||
|
||||
/**
|
||||
* Main screen displaying the notes list
|
||||
* v1.5.0: Jetpack Compose MainActivity Redesign
|
||||
*
|
||||
* Performance optimized with proper state handling:
|
||||
* - LazyListState for scroll control
|
||||
* - Scaffold FAB slot for proper z-ordering
|
||||
* - Scroll-to-top on new note
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
viewModel: MainViewModel,
|
||||
onOpenNote: (String?) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onCreateNote: (NoteType) -> Unit
|
||||
) {
|
||||
val notes by viewModel.sortedNotes.collectAsState()
|
||||
val syncState by viewModel.syncState.collectAsState()
|
||||
val scrollToTop by viewModel.scrollToTop.collectAsState()
|
||||
|
||||
// 🆕 v1.8.0: Einziges Banner-System
|
||||
val syncProgress by viewModel.syncProgress.collectAsState()
|
||||
|
||||
// Multi-Select State
|
||||
val selectedNotes by viewModel.selectedNotes.collectAsState()
|
||||
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
|
||||
|
||||
// 🌟 v1.6.0: Reactive offline mode state
|
||||
val isOfflineMode by viewModel.isOfflineMode.collectAsState()
|
||||
|
||||
// 🎨 v1.7.0: Display mode (list or grid)
|
||||
val displayMode by viewModel.displayMode.collectAsState()
|
||||
|
||||
// Delete confirmation dialog state
|
||||
var showBatchDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// 🆕 v1.8.0: Sync status legend dialog
|
||||
var showSyncLegend by remember { mutableStateOf(false) }
|
||||
|
||||
// 🔀 v1.8.0: Sort dialog state
|
||||
var showSortDialog by remember { mutableStateOf(false) }
|
||||
val sortOption by viewModel.sortOption.collectAsState()
|
||||
val sortDirection by viewModel.sortDirection.collectAsState()
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
// 🎨 v1.7.0: gridState für Staggered Grid Layout
|
||||
val gridState = rememberLazyStaggeredGridState()
|
||||
|
||||
// ⏱️ Timestamp ticker - increments every 30 seconds to trigger recomposition of relative times
|
||||
var timestampTicker by remember { mutableStateOf(0L) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
kotlinx.coroutines.delay(TIMESTAMP_UPDATE_INTERVAL_MS)
|
||||
timestampTicker = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
// Compute isSyncing once
|
||||
val isSyncing = syncState == SyncStateManager.SyncState.SYNCING
|
||||
|
||||
// 🌟 v1.6.0: Reactive sync availability (recomposes when offline mode changes)
|
||||
// Note: isOfflineMode is updated via StateFlow from MainViewModel.refreshOfflineModeState()
|
||||
// which is called in ComposeMainActivity.onResume() when returning from Settings
|
||||
val hasServerConfig = viewModel.hasServerConfig()
|
||||
val isSyncAvailable = !isOfflineMode && hasServerConfig
|
||||
val canSync = isSyncAvailable && !isSyncing
|
||||
|
||||
// Handle snackbar events from ViewModel
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.showSnackbar.collect { data ->
|
||||
scope.launch {
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = data.message,
|
||||
actionLabel = data.actionLabel,
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) {
|
||||
data.onAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Scroll to top when new note created
|
||||
// 🎨 v1.7.0: Unterstützt beide Display-Modi (list & grid)
|
||||
LaunchedEffect(scrollToTop) {
|
||||
if (scrollToTop) {
|
||||
if (displayMode == "grid") {
|
||||
gridState.animateScrollToItem(0)
|
||||
} else {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
viewModel.resetScrollToTop()
|
||||
}
|
||||
}
|
||||
|
||||
// v1.5.0 Hotfix: FAB manuell mit zIndex platzieren für garantierte Sichtbarkeit
|
||||
Scaffold(
|
||||
topBar = {
|
||||
// Animated switch between normal and selection TopBar
|
||||
AnimatedVisibility(
|
||||
visible = isSelectionMode,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut()
|
||||
) {
|
||||
SelectionTopBar(
|
||||
selectedCount = selectedNotes.size,
|
||||
totalCount = notes.size,
|
||||
onCloseSelection = { viewModel.clearSelection() },
|
||||
onSelectAll = { viewModel.selectAllNotes() },
|
||||
onDeleteSelected = { showBatchDeleteDialog = true }
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = !isSelectionMode,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut()
|
||||
) {
|
||||
MainTopBar(
|
||||
syncEnabled = canSync,
|
||||
showSyncLegend = isSyncAvailable, // 🆕 v1.8.0: Nur wenn Sync verfügbar
|
||||
onSyncLegendClick = { showSyncLegend = true }, // 🆕 v1.8.0
|
||||
onSortClick = { showSortDialog = true }, // 🔀 v1.8.0
|
||||
onSyncClick = { viewModel.triggerManualSync("toolbar") },
|
||||
onSettingsClick = onOpenSettings
|
||||
)
|
||||
}
|
||||
},
|
||||
// FAB wird manuell in Box platziert für korrekten z-Index
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
) { paddingValues ->
|
||||
// 🌟 v1.6.0: PullToRefreshBox only enabled when sync available
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isSyncing,
|
||||
onRefresh = { if (isSyncAvailable) viewModel.triggerManualSync("pullToRefresh") },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Main content column
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// 🆕 v1.8.0: Einziges Sync Banner (Progress + Ergebnis)
|
||||
SyncProgressBanner(
|
||||
progress = syncProgress,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Content: Empty state or notes list
|
||||
if (notes.isEmpty()) {
|
||||
EmptyState(modifier = Modifier.weight(1f))
|
||||
} else {
|
||||
// 🎨 v1.7.0: Switch between List and Grid based on display mode
|
||||
if (displayMode == "grid") {
|
||||
NotesStaggeredGrid(
|
||||
notes = notes,
|
||||
gridState = gridState,
|
||||
showSyncStatus = viewModel.isServerConfigured(),
|
||||
selectedNoteIds = selectedNotes,
|
||||
isSelectionMode = isSelectionMode,
|
||||
timestampTicker = timestampTicker,
|
||||
modifier = Modifier.weight(1f),
|
||||
onNoteClick = { note ->
|
||||
if (isSelectionMode) {
|
||||
viewModel.toggleNoteSelection(note.id)
|
||||
} else {
|
||||
onOpenNote(note.id)
|
||||
}
|
||||
},
|
||||
onNoteLongClick = { note ->
|
||||
viewModel.startSelectionMode(note.id)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
NotesList(
|
||||
notes = notes,
|
||||
showSyncStatus = viewModel.isServerConfigured(),
|
||||
selectedNotes = selectedNotes,
|
||||
isSelectionMode = isSelectionMode,
|
||||
timestampTicker = timestampTicker,
|
||||
listState = listState,
|
||||
modifier = Modifier.weight(1f),
|
||||
onNoteClick = { note -> onOpenNote(note.id) },
|
||||
onNoteLongPress = { note ->
|
||||
// Long-press starts selection mode
|
||||
viewModel.startSelectionMode(note.id)
|
||||
},
|
||||
onNoteSelectionToggle = { note ->
|
||||
viewModel.toggleNoteSelection(note.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FAB als TOP-LAYER - nur anzeigen wenn nicht im Selection Mode
|
||||
AnimatedVisibility(
|
||||
visible = !isSelectionMode,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(16.dp)
|
||||
.zIndex(Float.MAX_VALUE)
|
||||
) {
|
||||
NoteTypeFAB(
|
||||
onCreateNote = onCreateNote
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch Delete Confirmation Dialog
|
||||
if (showBatchDeleteDialog) {
|
||||
DeleteConfirmationDialog(
|
||||
noteCount = selectedNotes.size,
|
||||
isOfflineMode = isOfflineMode,
|
||||
onDismiss = { showBatchDeleteDialog = false },
|
||||
onDeleteLocal = {
|
||||
viewModel.deleteSelectedNotes(deleteFromServer = false)
|
||||
showBatchDeleteDialog = false
|
||||
},
|
||||
onDeleteEverywhere = {
|
||||
viewModel.deleteSelectedNotes(deleteFromServer = true)
|
||||
showBatchDeleteDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 🆕 v1.8.0: Sync Status Legend Dialog
|
||||
if (showSyncLegend) {
|
||||
SyncStatusLegendDialog(
|
||||
onDismiss = { showSyncLegend = false }
|
||||
)
|
||||
}
|
||||
|
||||
// 🔀 v1.8.0: Sort Dialog
|
||||
if (showSortDialog) {
|
||||
SortDialog(
|
||||
currentOption = sortOption,
|
||||
currentDirection = sortDirection,
|
||||
onOptionSelected = { option ->
|
||||
viewModel.setSortOption(option)
|
||||
},
|
||||
onDirectionToggled = {
|
||||
viewModel.toggleSortDirection()
|
||||
},
|
||||
onDismiss = { showSortDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun MainTopBar(
|
||||
syncEnabled: Boolean,
|
||||
showSyncLegend: Boolean, // 🆕 v1.8.0: Ob der Hilfe-Button sichtbar sein soll
|
||||
onSyncLegendClick: () -> Unit, // 🆕 v1.8.0
|
||||
onSortClick: () -> Unit, // 🔀 v1.8.0: Sort-Button
|
||||
onSyncClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.main_title),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
// 🔀 v1.8.0: Sort Button
|
||||
IconButton(onClick = onSortClick) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.Sort,
|
||||
contentDescription = stringResource(R.string.sort_notes)
|
||||
)
|
||||
}
|
||||
|
||||
// 🆕 v1.8.0: Sync Status Legend Button (nur wenn Sync verfügbar)
|
||||
if (showSyncLegend) {
|
||||
IconButton(onClick = onSyncLegendClick) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.HelpOutline,
|
||||
contentDescription = stringResource(R.string.sync_legend_button)
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = onSyncClick,
|
||||
enabled = syncEnabled
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = stringResource(R.string.action_sync)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onSettingsClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = stringResource(R.string.action_settings)
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection mode TopBar - shows selected count and actions
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SelectionTopBar(
|
||||
selectedCount: Int,
|
||||
totalCount: Int,
|
||||
onCloseSelection: () -> Unit,
|
||||
onSelectAll: () -> Unit,
|
||||
onDeleteSelected: () -> Unit
|
||||
) {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onCloseSelection) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.action_close_selection)
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.selection_count, selectedCount),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
// Select All button (only if not all selected)
|
||||
if (selectedCount < totalCount) {
|
||||
IconButton(onClick = onSelectAll) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SelectAll,
|
||||
contentDescription = stringResource(R.string.action_select_all)
|
||||
)
|
||||
}
|
||||
}
|
||||
// Delete button
|
||||
IconButton(
|
||||
onClick = onDeleteSelected,
|
||||
enabled = selectedCount > 0
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.action_delete_selected),
|
||||
tint = if (selectedCount > 0) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
package dev.dettmer.simplenotes.ui.main
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.SortDirection
|
||||
import dev.dettmer.simplenotes.models.SortOption
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.sync.SyncProgress
|
||||
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* ViewModel for MainActivity Compose
|
||||
* v1.5.0: Jetpack Compose MainActivity Redesign
|
||||
*
|
||||
* Manages notes list, sync state, and deletion with undo.
|
||||
*/
|
||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainViewModel"
|
||||
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
|
||||
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
|
||||
}
|
||||
|
||||
private val storage = NotesStorage(application)
|
||||
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Notes State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _notes = MutableStateFlow<List<Note>>(emptyList())
|
||||
val notes: StateFlow<List<Note>> = _notes.asStateFlow()
|
||||
|
||||
private val _pendingDeletions = MutableStateFlow<Set<String>>(emptySet())
|
||||
val pendingDeletions: StateFlow<Set<String>> = _pendingDeletions.asStateFlow()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Multi-Select State (v1.5.0)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _selectedNotes = MutableStateFlow<Set<String>>(emptySet())
|
||||
val selectedNotes: StateFlow<Set<String>> = _selectedNotes.asStateFlow()
|
||||
|
||||
val isSelectionMode: StateFlow<Boolean> = _selectedNotes
|
||||
.map { it.isNotEmpty() }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🌟 v1.6.0: Offline Mode State (reactive)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _isOfflineMode = MutableStateFlow(
|
||||
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
|
||||
)
|
||||
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow()
|
||||
|
||||
/**
|
||||
* Refresh offline mode state from SharedPreferences
|
||||
* Called when returning from Settings screen (in onResume)
|
||||
*/
|
||||
fun refreshOfflineModeState() {
|
||||
val oldValue = _isOfflineMode.value
|
||||
val newValue = prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
|
||||
_isOfflineMode.value = newValue
|
||||
Logger.d(TAG, "🔄 refreshOfflineModeState: offlineMode=$oldValue → $newValue")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🎨 v1.7.0: Display Mode State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _displayMode = MutableStateFlow(
|
||||
prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
|
||||
)
|
||||
val displayMode: StateFlow<String> = _displayMode.asStateFlow()
|
||||
|
||||
/**
|
||||
* Refresh display mode from SharedPreferences
|
||||
* Called when returning from Settings screen
|
||||
*/
|
||||
fun refreshDisplayMode() {
|
||||
val newValue = prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
|
||||
_displayMode.value = newValue
|
||||
Logger.d(TAG, "🔄 refreshDisplayMode: displayMode=${_displayMode.value} → $newValue")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🔀 v1.8.0: Sort State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _sortOption = MutableStateFlow(
|
||||
SortOption.fromPrefsValue(
|
||||
prefs.getString(Constants.KEY_SORT_OPTION, Constants.DEFAULT_SORT_OPTION) ?: Constants.DEFAULT_SORT_OPTION
|
||||
)
|
||||
)
|
||||
val sortOption: StateFlow<SortOption> = _sortOption.asStateFlow()
|
||||
|
||||
private val _sortDirection = MutableStateFlow(
|
||||
SortDirection.fromPrefsValue(
|
||||
prefs.getString(Constants.KEY_SORT_DIRECTION, Constants.DEFAULT_SORT_DIRECTION) ?: Constants.DEFAULT_SORT_DIRECTION
|
||||
)
|
||||
)
|
||||
val sortDirection: StateFlow<SortDirection> = _sortDirection.asStateFlow()
|
||||
|
||||
/**
|
||||
* 🔀 v1.8.0: Sortierte Notizen — kombiniert aus Notes + SortOption + SortDirection.
|
||||
* Reagiert automatisch auf Änderungen in allen drei Flows.
|
||||
*/
|
||||
val sortedNotes: StateFlow<List<Note>> = combine(
|
||||
_notes,
|
||||
_sortOption,
|
||||
_sortDirection
|
||||
) { notes, option, direction ->
|
||||
sortNotes(notes, option, direction)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Sync State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
// 🆕 v1.8.0: Einziges Banner-System - SyncProgress
|
||||
val syncProgress: StateFlow<SyncProgress> = SyncStateManager.syncProgress
|
||||
|
||||
// Intern: SyncState für PullToRefresh-Indikator
|
||||
private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE)
|
||||
val syncState: StateFlow<SyncStateManager.SyncState> = _syncState.asStateFlow()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// UI Events
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _showToast = MutableSharedFlow<String>()
|
||||
val showToast: SharedFlow<String> = _showToast.asSharedFlow()
|
||||
|
||||
private val _showDeleteDialog = MutableSharedFlow<DeleteDialogData>()
|
||||
val showDeleteDialog: SharedFlow<DeleteDialogData> = _showDeleteDialog.asSharedFlow()
|
||||
|
||||
private val _showSnackbar = MutableSharedFlow<SnackbarData>()
|
||||
val showSnackbar: SharedFlow<SnackbarData> = _showSnackbar.asSharedFlow()
|
||||
|
||||
// Phase 3: Scroll-to-top when new note is created
|
||||
private val _scrollToTop = MutableStateFlow(false)
|
||||
val scrollToTop: StateFlow<Boolean> = _scrollToTop.asStateFlow()
|
||||
|
||||
// Track first note ID to detect new notes
|
||||
private var previousFirstNoteId: String? = null
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Data Classes
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
data class DeleteDialogData(
|
||||
val note: Note,
|
||||
val originalList: List<Note>
|
||||
)
|
||||
|
||||
data class SnackbarData(
|
||||
val message: String,
|
||||
val actionLabel: String,
|
||||
val onAction: () -> Unit
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Initialization
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
init {
|
||||
// v1.5.0 Performance: Load notes asynchronously to avoid blocking UI
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
loadNotesAsync()
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Notes Actions
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Load notes asynchronously on IO dispatcher
|
||||
* This prevents UI blocking during app startup
|
||||
*/
|
||||
private suspend fun loadNotesAsync() {
|
||||
val allNotes = storage.loadAllNotes()
|
||||
val pendingIds = _pendingDeletions.value
|
||||
val filteredNotes = allNotes.filter { it.id !in pendingIds }
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
// Phase 3: Detect if a new note was added at the top
|
||||
val newFirstNoteId = filteredNotes.firstOrNull()?.id
|
||||
if (newFirstNoteId != null &&
|
||||
previousFirstNoteId != null &&
|
||||
newFirstNoteId != previousFirstNoteId) {
|
||||
// New note at top → trigger scroll
|
||||
_scrollToTop.value = true
|
||||
Logger.d(TAG, "📜 New note detected at top, triggering scroll-to-top")
|
||||
}
|
||||
previousFirstNoteId = newFirstNoteId
|
||||
|
||||
_notes.value = filteredNotes
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public loadNotes - delegates to async version
|
||||
*/
|
||||
fun loadNotes() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
loadNotesAsync()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset scroll-to-top flag after scroll completed
|
||||
*/
|
||||
fun resetScrollToTop() {
|
||||
_scrollToTop.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Force scroll to top (e.g., after returning from editor)
|
||||
*/
|
||||
fun scrollToTop() {
|
||||
_scrollToTop.value = true
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Multi-Select Actions (v1.5.0)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Toggle selection of a note
|
||||
*/
|
||||
fun toggleNoteSelection(noteId: String) {
|
||||
_selectedNotes.value = if (noteId in _selectedNotes.value) {
|
||||
_selectedNotes.value - noteId
|
||||
} else {
|
||||
_selectedNotes.value + noteId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start selection mode with initial note
|
||||
*/
|
||||
fun startSelectionMode(noteId: String) {
|
||||
_selectedNotes.value = setOf(noteId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select all notes
|
||||
*/
|
||||
fun selectAllNotes() {
|
||||
_selectedNotes.value = _notes.value.map { it.id }.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear selection and exit selection mode
|
||||
*/
|
||||
fun clearSelection() {
|
||||
_selectedNotes.value = emptySet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of selected notes
|
||||
*/
|
||||
fun getSelectedCount(): Int = _selectedNotes.value.size
|
||||
|
||||
/**
|
||||
* Delete all selected notes
|
||||
*/
|
||||
fun deleteSelectedNotes(deleteFromServer: Boolean) {
|
||||
val selectedIds = _selectedNotes.value.toList()
|
||||
val selectedNotes = _notes.value.filter { it.id in selectedIds }
|
||||
|
||||
if (selectedNotes.isEmpty()) return
|
||||
|
||||
// Add to pending deletions
|
||||
_pendingDeletions.value = _pendingDeletions.value + selectedIds.toSet()
|
||||
|
||||
// Delete from storage
|
||||
selectedNotes.forEach { note ->
|
||||
storage.deleteNote(note.id)
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
clearSelection()
|
||||
|
||||
// Reload notes
|
||||
loadNotes()
|
||||
|
||||
// Show snackbar with undo
|
||||
val count = selectedNotes.size
|
||||
val message = if (deleteFromServer) {
|
||||
getString(R.string.snackbar_notes_deleted_server, count)
|
||||
} else {
|
||||
getString(R.string.snackbar_notes_deleted_local, count)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_showSnackbar.emit(SnackbarData(
|
||||
message = message,
|
||||
actionLabel = getString(R.string.snackbar_undo),
|
||||
onAction = {
|
||||
undoDeleteMultiple(selectedNotes)
|
||||
}
|
||||
))
|
||||
|
||||
@Suppress("MagicNumber") // Snackbar timing coordination
|
||||
// If delete from server, actually delete after a short delay
|
||||
// (to allow undo action before server deletion)
|
||||
if (deleteFromServer) {
|
||||
kotlinx.coroutines.delay(3500) // Snackbar shows for ~3s
|
||||
// Only delete if not restored (check if still in pending)
|
||||
val idsToDelete = selectedIds.filter { it in _pendingDeletions.value }
|
||||
if (idsToDelete.isNotEmpty()) {
|
||||
deleteMultipleNotesFromServer(idsToDelete)
|
||||
}
|
||||
} else {
|
||||
// Just finalize local deletion
|
||||
selectedIds.forEach { noteId ->
|
||||
finalizeDeletion(noteId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo deletion of multiple notes
|
||||
*/
|
||||
private fun undoDeleteMultiple(notes: List<Note>) {
|
||||
// Remove from pending deletions
|
||||
_pendingDeletions.value = _pendingDeletions.value - notes.map { it.id }.toSet()
|
||||
|
||||
// Restore to storage
|
||||
notes.forEach { note ->
|
||||
storage.saveNote(note)
|
||||
}
|
||||
|
||||
// Reload notes
|
||||
loadNotes()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when user long-presses a note to delete
|
||||
* Shows dialog for delete confirmation (replaces swipe-to-delete for performance)
|
||||
*/
|
||||
fun onNoteLongPressDelete(note: Note) {
|
||||
val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
|
||||
|
||||
// Store original list for potential restore
|
||||
val originalList = _notes.value.toList()
|
||||
|
||||
if (alwaysDeleteFromServer) {
|
||||
// Auto-delete without dialog
|
||||
deleteNoteConfirmed(note, deleteFromServer = true)
|
||||
} else {
|
||||
// Show dialog - don't remove from UI yet (user can cancel)
|
||||
viewModelScope.launch {
|
||||
_showDeleteDialog.emit(DeleteDialogData(note, originalList))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when user swipes to delete a note (legacy - kept for compatibility)
|
||||
* Shows dialog if "always delete from server" is not enabled
|
||||
*/
|
||||
fun onNoteSwipedToDelete(note: Note) {
|
||||
onNoteLongPressDelete(note) // Delegate to long-press handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore note after swipe (user cancelled dialog)
|
||||
*/
|
||||
fun restoreNoteAfterSwipe(originalList: List<Note>) {
|
||||
_notes.value = originalList
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm note deletion (from dialog or auto-delete)
|
||||
*/
|
||||
fun deleteNoteConfirmed(note: Note, deleteFromServer: Boolean) {
|
||||
// Add to pending deletions
|
||||
_pendingDeletions.value = _pendingDeletions.value + note.id
|
||||
|
||||
// Delete from storage
|
||||
storage.deleteNote(note.id)
|
||||
|
||||
// Reload notes
|
||||
loadNotes()
|
||||
|
||||
// Show snackbar with undo
|
||||
val message = if (deleteFromServer) {
|
||||
getString(R.string.snackbar_note_deleted_server, note.title)
|
||||
} else {
|
||||
getString(R.string.snackbar_note_deleted_local, note.title)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_showSnackbar.emit(SnackbarData(
|
||||
message = message,
|
||||
actionLabel = getString(R.string.snackbar_undo),
|
||||
onAction = {
|
||||
undoDelete(note)
|
||||
}
|
||||
))
|
||||
|
||||
@Suppress("MagicNumber") // Snackbar timing
|
||||
// If delete from server, actually delete after snackbar timeout
|
||||
if (deleteFromServer) {
|
||||
kotlinx.coroutines.delay(3500) // Snackbar shows for ~3s
|
||||
// Only delete if not restored (check if still in pending)
|
||||
if (note.id in _pendingDeletions.value) {
|
||||
deleteNoteFromServer(note.id)
|
||||
}
|
||||
} else {
|
||||
// Just finalize local deletion
|
||||
finalizeDeletion(note.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo note deletion
|
||||
*/
|
||||
fun undoDelete(note: Note) {
|
||||
// Remove from pending deletions
|
||||
_pendingDeletions.value = _pendingDeletions.value - note.id
|
||||
|
||||
// Restore to storage
|
||||
storage.saveNote(note)
|
||||
|
||||
// Reload notes
|
||||
loadNotes()
|
||||
}
|
||||
|
||||
/**
|
||||
* Actually delete note from server after snackbar dismissed
|
||||
*/
|
||||
fun deleteNoteFromServer(noteId: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val webdavService = WebDavSyncService(getApplication())
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
webdavService.deleteNoteFromServer(noteId)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO
|
||||
SyncStateManager.showInfo(getString(R.string.snackbar_deleted_from_server))
|
||||
} else {
|
||||
SyncStateManager.showError(getString(R.string.snackbar_server_delete_failed))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
SyncStateManager.showError(getString(R.string.snackbar_server_error, e.message ?: ""))
|
||||
} finally {
|
||||
// Remove from pending deletions
|
||||
_pendingDeletions.value = _pendingDeletions.value - noteId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple notes from server with aggregated toast
|
||||
* Shows single toast at the end instead of one per note
|
||||
*/
|
||||
private fun deleteMultipleNotesFromServer(noteIds: List<String>) {
|
||||
viewModelScope.launch {
|
||||
val webdavService = WebDavSyncService(getApplication())
|
||||
var successCount = 0
|
||||
var failCount = 0
|
||||
|
||||
noteIds.forEach { noteId ->
|
||||
try {
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
webdavService.deleteNoteFromServer(noteId)
|
||||
}
|
||||
if (success) successCount++ else failCount++
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to delete note $noteId from server: ${e.message}")
|
||||
failCount++
|
||||
} finally {
|
||||
_pendingDeletions.value = _pendingDeletions.value - noteId
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO/ERROR
|
||||
val message = when {
|
||||
failCount == 0 -> getString(R.string.snackbar_notes_deleted_from_server, successCount)
|
||||
successCount == 0 -> getString(R.string.snackbar_server_delete_failed)
|
||||
else -> getString(
|
||||
R.string.snackbar_notes_deleted_from_server_partial,
|
||||
successCount,
|
||||
successCount + failCount
|
||||
)
|
||||
}
|
||||
if (failCount > 0) {
|
||||
SyncStateManager.showError(message)
|
||||
} else {
|
||||
SyncStateManager.showInfo(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize deletion (remove from pending set)
|
||||
*/
|
||||
fun finalizeDeletion(noteId: String) {
|
||||
_pendingDeletions.value = _pendingDeletions.value - noteId
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Sync Actions
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
fun updateSyncState(status: SyncStateManager.SyncStatus) {
|
||||
_syncState.value = status.state
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger manual sync (from toolbar button or pull-to-refresh)
|
||||
* v1.7.0: Uses central canSync() gate for WiFi-only check
|
||||
* v1.8.0: Banner erscheint sofort beim Klick (PREPARING-Phase)
|
||||
*/
|
||||
fun triggerManualSync(source: String = "manual") {
|
||||
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
|
||||
val syncService = WebDavSyncService(getApplication())
|
||||
val gateResult = syncService.canSync()
|
||||
if (!gateResult.canSync) {
|
||||
if (gateResult.isBlockedByWifiOnly) {
|
||||
Logger.d(TAG, "⏭️ $source Sync blocked: WiFi-only mode, not on WiFi")
|
||||
SyncStateManager.markError(getString(R.string.sync_wifi_only_error))
|
||||
} else {
|
||||
Logger.d(TAG, "⏭️ $source Sync blocked: ${gateResult.blockReason ?: "offline/no server"}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (verhindert Auto-Sync direkt danach)
|
||||
// Manueller Sync prüft NICHT den globalen Cooldown (User will explizit synchronisieren)
|
||||
val prefs = getApplication<android.app.Application>().getSharedPreferences(
|
||||
Constants.PREFS_NAME,
|
||||
android.content.Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
// 🆕 v1.7.0: Feedback wenn Sync bereits läuft
|
||||
// 🆕 v1.8.0: tryStartSync setzt sofort PREPARING → Banner erscheint instant
|
||||
if (!SyncStateManager.tryStartSync(source)) {
|
||||
if (SyncStateManager.isSyncing) {
|
||||
Logger.d(TAG, "⏭️ $source Sync blocked: Another sync in progress")
|
||||
viewModelScope.launch {
|
||||
_showSnackbar.emit(SnackbarData(
|
||||
message = getString(R.string.sync_already_running),
|
||||
actionLabel = "",
|
||||
onAction = {}
|
||||
))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (nach tryStartSync, vor Launch)
|
||||
SyncStateManager.markGlobalSyncStarted(prefs)
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// Check for unsynced changes (Banner zeigt bereits PREPARING)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ $source Sync: No unsynced changes")
|
||||
SyncStateManager.markCompleted(getString(R.string.toast_already_synced))
|
||||
loadNotes()
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Check server reachability
|
||||
val isReachable = withContext(Dispatchers.IO) {
|
||||
syncService.isServerReachable()
|
||||
}
|
||||
|
||||
if (!isReachable) {
|
||||
Logger.d(TAG, "⏭️ $source Sync: Server not reachable")
|
||||
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Perform sync
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
syncService.syncNotes()
|
||||
}
|
||||
|
||||
if (result.isSuccess) {
|
||||
// 🆕 v1.8.0 (IMPL_022): Erweiterte Banner-Nachricht mit Löschungen
|
||||
val bannerMessage = buildString {
|
||||
if (result.syncedCount > 0) {
|
||||
append(getString(R.string.toast_sync_success, result.syncedCount))
|
||||
}
|
||||
if (result.deletedOnServerCount > 0) {
|
||||
if (isNotEmpty()) append(" · ")
|
||||
append(getString(R.string.sync_deleted_on_server_count, result.deletedOnServerCount))
|
||||
}
|
||||
if (isEmpty()) {
|
||||
append(getString(R.string.snackbar_nothing_to_sync))
|
||||
}
|
||||
}
|
||||
SyncStateManager.markCompleted(bannerMessage)
|
||||
loadNotes()
|
||||
} else {
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
SyncStateManager.markError(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger auto-sync (onResume)
|
||||
* Only runs if server is configured and interval has passed
|
||||
* v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt
|
||||
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_ON_RESUME
|
||||
* v1.7.0: Uses central canSync() gate for WiFi-only check
|
||||
*/
|
||||
fun triggerAutoSync(source: String = "auto") {
|
||||
// 🌟 v1.6.0: Check if onResume trigger is enabled
|
||||
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, Constants.DEFAULT_TRIGGER_ON_RESUME)) {
|
||||
Logger.d(TAG, "⏭️ onResume sync disabled - skipping")
|
||||
return
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_08): Globaler Sync-Cooldown (alle Trigger teilen sich diesen)
|
||||
if (!SyncStateManager.canSyncGlobally(prefs)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Throttling check (eigener 60s-Cooldown für onResume)
|
||||
if (!canTriggerAutoSync()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
|
||||
val syncService = WebDavSyncService(getApplication())
|
||||
val gateResult = syncService.canSync()
|
||||
if (!gateResult.canSync) {
|
||||
if (gateResult.isBlockedByWifiOnly) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source) blocked: WiFi-only mode, not on WiFi")
|
||||
} else {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source) blocked: ${gateResult.blockReason ?: "offline/no server"}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// v1.5.0: silent=true → kein Banner bei Auto-Sync
|
||||
// 🆕 v1.8.0: tryStartSync mit silent=true → SyncProgress.silent=true → Banner unsichtbar
|
||||
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
|
||||
|
||||
// Update last sync timestamp
|
||||
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_08): Globalen Sync-Cooldown markieren
|
||||
SyncStateManager.markGlobalSyncStarted(prefs)
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// Check for unsynced changes
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
|
||||
SyncStateManager.reset() // Silent → geht direkt auf IDLE
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Check server reachability
|
||||
val isReachable = withContext(Dispatchers.IO) {
|
||||
syncService.isServerReachable()
|
||||
}
|
||||
|
||||
if (!isReachable) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
|
||||
SyncStateManager.reset() // Silent → kein Error-Banner
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Perform sync
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
syncService.syncNotes()
|
||||
}
|
||||
|
||||
if (result.isSuccess && result.syncedCount > 0) {
|
||||
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
|
||||
// 🆕 v1.8.1 (IMPL_11): Kein Toast bei Silent-Sync
|
||||
// Das Banner-System respektiert silent=true korrekt (markCompleted → IDLE)
|
||||
// Toast wurde fälschlicherweise trotzdem angezeigt
|
||||
SyncStateManager.markCompleted(getString(R.string.toast_sync_success, result.syncedCount))
|
||||
loadNotes()
|
||||
} else if (result.isSuccess) {
|
||||
Logger.d(TAG, "ℹ️ Auto-sync ($source): No changes")
|
||||
SyncStateManager.markCompleted() // Silent → geht direkt auf IDLE
|
||||
} else {
|
||||
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
|
||||
// Fehler werden IMMER angezeigt (auch bei Silent-Sync)
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
|
||||
SyncStateManager.markError(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun canTriggerAutoSync(): Boolean {
|
||||
val lastSyncTime = prefs.getLong(PREF_LAST_AUTO_SYNC_TIME, 0)
|
||||
val now = System.currentTimeMillis()
|
||||
val timeSinceLastSync = now - lastSyncTime
|
||||
|
||||
if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) {
|
||||
val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
|
||||
Logger.d(TAG, "⏳ Auto-sync throttled - wait ${remainingSeconds}s")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🔀 v1.8.0: Sortierung
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 🔀 v1.8.0: Sortiert Notizen nach gewählter Option und Richtung.
|
||||
*/
|
||||
private fun sortNotes(
|
||||
notes: List<Note>,
|
||||
option: SortOption,
|
||||
direction: SortDirection
|
||||
): List<Note> {
|
||||
val comparator: Comparator<Note> = when (option) {
|
||||
SortOption.UPDATED_AT -> compareBy { it.updatedAt }
|
||||
SortOption.CREATED_AT -> compareBy { it.createdAt }
|
||||
SortOption.TITLE -> compareBy(String.CASE_INSENSITIVE_ORDER) { it.title }
|
||||
SortOption.NOTE_TYPE -> compareBy<Note> { it.noteType.ordinal }
|
||||
.thenByDescending { it.updatedAt } // Sekundär: Datum innerhalb gleicher Typen
|
||||
}
|
||||
|
||||
return when (direction) {
|
||||
SortDirection.ASCENDING -> notes.sortedWith(comparator)
|
||||
SortDirection.DESCENDING -> notes.sortedWith(comparator.reversed())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔀 v1.8.0: Setzt die Sortieroption und speichert in SharedPreferences.
|
||||
*/
|
||||
fun setSortOption(option: SortOption) {
|
||||
_sortOption.value = option
|
||||
prefs.edit().putString(Constants.KEY_SORT_OPTION, option.prefsValue).apply()
|
||||
Logger.d(TAG, "🔀 Sort option changed to: ${option.prefsValue}")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔀 v1.8.0: Setzt die Sortierrichtung und speichert in SharedPreferences.
|
||||
*/
|
||||
fun setSortDirection(direction: SortDirection) {
|
||||
_sortDirection.value = direction
|
||||
prefs.edit().putString(Constants.KEY_SORT_DIRECTION, direction.prefsValue).apply()
|
||||
Logger.d(TAG, "🔀 Sort direction changed to: ${direction.prefsValue}")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔀 v1.8.0: Toggelt die Sortierrichtung.
|
||||
*/
|
||||
fun toggleSortDirection() {
|
||||
val newDirection = _sortDirection.value.toggle()
|
||||
setSortDirection(newDirection)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private fun getString(resId: Int): String = getApplication<android.app.Application>().getString(resId)
|
||||
|
||||
private fun getString(resId: Int, vararg formatArgs: Any): String =
|
||||
getApplication<android.app.Application>().getString(resId, *formatArgs)
|
||||
|
||||
fun isServerConfigured(): Boolean {
|
||||
// 🌟 v1.6.0: Use reactive offline mode state
|
||||
if (_isOfflineMode.value) {
|
||||
return false
|
||||
}
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
|
||||
}
|
||||
|
||||
/**
|
||||
* 🌟 v1.6.0: Check if server has a configured URL (ignores offline mode)
|
||||
* Used for determining if sync would be available when offline mode is disabled
|
||||
*/
|
||||
fun hasServerConfig(): Boolean {
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package dev.dettmer.simplenotes.ui.main.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CloudOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.dettmer.simplenotes.R
|
||||
|
||||
/**
|
||||
* Delete confirmation dialog with server/local options
|
||||
* v1.5.0: Multi-Select Feature
|
||||
* v1.6.0: Offline mode support - disables server deletion option
|
||||
*/
|
||||
@Composable
|
||||
fun DeleteConfirmationDialog(
|
||||
noteCount: Int = 1,
|
||||
isOfflineMode: Boolean = false,
|
||||
onDismiss: () -> Unit,
|
||||
onDeleteLocal: () -> Unit,
|
||||
onDeleteEverywhere: () -> Unit
|
||||
) {
|
||||
val title = if (noteCount == 1) {
|
||||
stringResource(R.string.delete_note_title)
|
||||
} else {
|
||||
stringResource(R.string.delete_notes_title, noteCount)
|
||||
}
|
||||
|
||||
val message = if (noteCount == 1) {
|
||||
stringResource(R.string.delete_note_message)
|
||||
} else {
|
||||
stringResource(R.string.delete_notes_message, noteCount)
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Delete everywhere (server + local) - primary action
|
||||
// 🌟 v1.6.0: Disabled in offline mode with visual hint
|
||||
TextButton(
|
||||
onClick = onDeleteEverywhere,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isOfflineMode,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
)
|
||||
) {
|
||||
Text(stringResource(R.string.delete_everywhere))
|
||||
}
|
||||
|
||||
// 🌟 v1.6.0: Show offline hint in a subtle Surface container
|
||||
if (isOfflineMode) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(horizontal = 8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CloudOff,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.delete_everywhere_offline_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
// Delete local only
|
||||
TextButton(
|
||||
onClick = onDeleteLocal,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.delete_local_only))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Cancel button
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = null // All buttons in confirmButton column
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package dev.dettmer.simplenotes.ui.main.components
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import dev.dettmer.simplenotes.R
|
||||
|
||||
/**
|
||||
* Empty state card shown when no notes exist
|
||||
* v1.5.0: Jetpack Compose MainActivity Redesign
|
||||
*/
|
||||
@Composable
|
||||
fun EmptyState(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// App icon foreground (transparent background)
|
||||
val context = LocalContext.current
|
||||
val appIcon = remember {
|
||||
val drawable = ContextCompat.getDrawable(context, R.mipmap.ic_launcher_foreground)
|
||||
drawable?.let {
|
||||
val size = 256
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
it.setBounds(0, 0, size, size)
|
||||
it.draw(canvas)
|
||||
bitmap.asImageBitmap()
|
||||
}
|
||||
}
|
||||
|
||||
appIcon?.let {
|
||||
Image(
|
||||
bitmap = it,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(96.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Title
|
||||
Text(
|
||||
text = stringResource(R.string.empty_state_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Message
|
||||
Text(
|
||||
text = stringResource(R.string.empty_state_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package dev.dettmer.simplenotes.ui.main.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.List
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.outlined.CloudDone
|
||||
import androidx.compose.material.icons.outlined.CloudOff
|
||||
import androidx.compose.material.icons.outlined.CloudSync
|
||||
import androidx.compose.material.icons.outlined.Description
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.utils.toReadableTime
|
||||
|
||||
/**
|
||||
* Note card - v1.5.0 with Multi-Select Support
|
||||
*
|
||||
* ULTRA SIMPLE + SELECTION:
|
||||
* - NO remember() anywhere
|
||||
* - Direct MaterialTheme access
|
||||
* - Selection indicator via border + checkbox overlay
|
||||
* - Long-press starts selection mode
|
||||
* - Tap in selection mode toggles selection
|
||||
*/
|
||||
@Composable
|
||||
fun NoteCard(
|
||||
note: Note,
|
||||
showSyncStatus: Boolean,
|
||||
isSelected: Boolean = false,
|
||||
isSelectionMode: Boolean = false,
|
||||
timestampTicker: Long = 0L,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// ⏱️ Reading timestampTicker triggers recomposition only for visible cards
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val ticker = timestampTicker
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
// 🎨 v1.7.0: Externes Padding entfernt - Grid/Liste steuert Abstände
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.border(
|
||||
width = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.pointerInput(note.id, isSelectionMode) {
|
||||
detectTapGestures(
|
||||
onTap = { onClick() },
|
||||
onLongPress = { onLongClick() }
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
)
|
||||
) {
|
||||
Box {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Header row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Type icon
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (note.noteType == NoteType.TEXT)
|
||||
Icons.Outlined.Description
|
||||
else
|
||||
Icons.AutoMirrored.Outlined.List,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// Title
|
||||
Text(
|
||||
text = note.title.ifEmpty { stringResource(R.string.untitled) },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Preview
|
||||
Text(
|
||||
text = when (note.noteType) {
|
||||
NoteType.TEXT -> note.content.take(100)
|
||||
NoteType.CHECKLIST -> {
|
||||
val items = note.checklistItems ?: emptyList()
|
||||
stringResource(R.string.checklist_progress, items.count { it.isChecked }, items.size)
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Footer
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = note.updatedAt.toReadableTime(context),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
if (showSyncStatus) {
|
||||
Icon(
|
||||
imageVector = when (note.syncStatus) {
|
||||
SyncStatus.SYNCED -> Icons.Outlined.CloudDone
|
||||
SyncStatus.PENDING -> Icons.Outlined.CloudSync
|
||||
SyncStatus.CONFLICT -> Icons.Default.Warning
|
||||
SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff
|
||||
SyncStatus.DELETED_ON_SERVER -> Icons.Outlined.CloudOff // 🆕 v1.8.0
|
||||
},
|
||||
contentDescription = when (note.syncStatus) {
|
||||
SyncStatus.SYNCED -> stringResource(R.string.sync_status_synced)
|
||||
SyncStatus.PENDING -> stringResource(R.string.sync_status_pending)
|
||||
SyncStatus.CONFLICT -> stringResource(R.string.sync_status_conflict)
|
||||
SyncStatus.LOCAL_ONLY -> stringResource(R.string.sync_status_local_only)
|
||||
SyncStatus.DELETED_ON_SERVER -> stringResource(R.string.sync_status_deleted_on_server) // 🆕 v1.8.0
|
||||
},
|
||||
tint = when (note.syncStatus) {
|
||||
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
|
||||
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
|
||||
SyncStatus.DELETED_ON_SERVER -> MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) // 🆕 v1.8.0
|
||||
else -> MaterialTheme.colorScheme.outline
|
||||
},
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Selection indicator checkbox (top-right)
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = isSelectionMode,
|
||||
enter = fadeIn() + scaleIn(),
|
||||
exit = fadeOut() + scaleOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
}
|
||||
)
|
||||
.border(
|
||||
width = 2.dp,
|
||||
color = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
shape = CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = stringResource(R.string.selection_count, 1),
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package dev.dettmer.simplenotes.ui.main.components
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.List
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.outlined.Description
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
|
||||
/**
|
||||
* FAB with dropdown menu for note type selection
|
||||
* v1.5.0: PERFORMANCE FIX - No Box wrapper for proper elevation
|
||||
*
|
||||
* Uses consistent icons with NoteCard:
|
||||
* - TEXT: Description (document icon)
|
||||
* - CHECKLIST: List (bullet list icon)
|
||||
*/
|
||||
@Composable
|
||||
fun NoteTypeFAB(
|
||||
modifier: Modifier = Modifier,
|
||||
onCreateNote: (NoteType) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
// FAB directly without Box wrapper - elevation works correctly
|
||||
FloatingActionButton(
|
||||
onClick = { expanded = true },
|
||||
modifier = modifier,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = stringResource(R.string.fab_new_note)
|
||||
)
|
||||
|
||||
// Dropdown inside FAB - renders as popup overlay
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.fab_text_note)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Description,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
expanded = false
|
||||
onCreateNote(NoteType.TEXT)
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.fab_checklist)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.List,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
expanded = false
|
||||
onCreateNote(NoteType.CHECKLIST)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package dev.dettmer.simplenotes.ui.main.components
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
|
||||
/**
|
||||
* Notes list - v1.5.0 with Multi-Select Support
|
||||
*
|
||||
* ULTRA SIMPLE + SELECTION:
|
||||
* - NO remember() anywhere
|
||||
* - NO caching tricks
|
||||
* - Selection state passed through as parameters
|
||||
* - Tap behavior changes based on selection mode
|
||||
* - ⏱️ timestampTicker triggers recomposition for relative time updates
|
||||
*/
|
||||
@Suppress("LongParameterList") // Composable with many UI state parameters
|
||||
@Composable
|
||||
fun NotesList(
|
||||
notes: List<Note>,
|
||||
showSyncStatus: Boolean,
|
||||
selectedNotes: Set<String> = emptySet(),
|
||||
isSelectionMode: Boolean = false,
|
||||
timestampTicker: Long = 0L,
|
||||
modifier: Modifier = Modifier,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
onNoteClick: (Note) -> Unit,
|
||||
onNoteLongPress: (Note) -> Unit,
|
||||
onNoteSelectionToggle: (Note) -> Unit = {}
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp)
|
||||
) {
|
||||
items(
|
||||
items = notes,
|
||||
key = { it.id },
|
||||
contentType = { "NoteCard" }
|
||||
) { note ->
|
||||
val isSelected = note.id in selectedNotes
|
||||
|
||||
NoteCard(
|
||||
note = note,
|
||||
showSyncStatus = showSyncStatus,
|
||||
isSelected = isSelected,
|
||||
isSelectionMode = isSelectionMode,
|
||||
timestampTicker = timestampTicker,
|
||||
// 🎨 v1.7.0: Padding hier in Liste (nicht in Card selbst)
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
onClick = {
|
||||
if (isSelectionMode) {
|
||||
// In selection mode, tap toggles selection
|
||||
onNoteSelectionToggle(note)
|
||||
} else {
|
||||
// Normal mode, open note
|
||||
onNoteClick(note)
|
||||
}
|
||||
},
|
||||
onLongClick = { onNoteLongPress(note) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package dev.dettmer.simplenotes.ui.main.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||
|
||||
/**
|
||||
* Sync status banner shown below the toolbar during sync
|
||||
* v1.5.0: Jetpack Compose MainActivity Redesign
|
||||
* v1.5.0: SYNCING_SILENT ignorieren - Banner nur bei manuellen Syncs oder Fehlern anzeigen
|
||||
* v1.8.0: Nur noch COMPLETED/ERROR States - SYNCING wird von SyncProgressBanner übernommen
|
||||
*/
|
||||
@Composable
|
||||
fun SyncStatusBanner(
|
||||
syncState: SyncStateManager.SyncState,
|
||||
message: String?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// v1.8.0: Nur COMPLETED/ERROR anzeigen (SYNCING wird von SyncProgressBanner übernommen)
|
||||
// IDLE und SYNCING_SILENT werden ignoriert
|
||||
val isVisible = syncState == SyncStateManager.SyncState.COMPLETED
|
||||
|| syncState == SyncStateManager.SyncState.ERROR
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
modifier = modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// v1.8.0: Kein Loading-Icon mehr - wird von SyncProgressBanner übernommen
|
||||
|
||||
Text(
|
||||
text = when (syncState) {
|
||||
SyncStateManager.SyncState.COMPLETED -> message ?: stringResource(R.string.sync_status_completed)
|
||||
SyncStateManager.SyncState.ERROR -> message ?: stringResource(R.string.sync_status_error)
|
||||
else -> "" // SYNCING/IDLE/SYNCING_SILENT nicht mehr relevant
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package dev.dettmer.simplenotes.ui.settings
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.SimpleNotesApplication
|
||||
import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Settings Activity with Jetpack Compose UI
|
||||
* v1.5.0: Complete Settings Redesign with grouped screens
|
||||
*
|
||||
* Replaces the old 1147-line SettingsActivity.kt with a modern
|
||||
* Compose-based implementation featuring:
|
||||
* - 6 logical settings groups as separate screens
|
||||
* - Material 3 Design with Dynamic Colors (Material You)
|
||||
* - Navigation with back button in each screen
|
||||
* - Clean separation of concerns with SettingsViewModel
|
||||
*/
|
||||
class ComposeSettingsActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ComposeSettingsActivity"
|
||||
}
|
||||
|
||||
private val viewModel: SettingsViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Apply Dynamic Colors for Material You (Android 12+)
|
||||
DynamicColors.applyToActivityIfAvailable(this)
|
||||
|
||||
// Enable edge-to-edge display
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Handle back button with slide animation
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
@Suppress("DEPRECATION")
|
||||
overridePendingTransition(
|
||||
dev.dettmer.simplenotes.R.anim.slide_in_left,
|
||||
dev.dettmer.simplenotes.R.anim.slide_out_right
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Collect events from ViewModel (for Activity-level actions)
|
||||
collectViewModelEvents()
|
||||
|
||||
setContent {
|
||||
SimpleNotesTheme {
|
||||
val navController = rememberNavController()
|
||||
val context = LocalContext.current
|
||||
|
||||
// Toast handling from ViewModel
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.showToast.collect { message ->
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsNavHost(
|
||||
navController = navController,
|
||||
viewModel = viewModel,
|
||||
onFinish = {
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
@Suppress("DEPRECATION")
|
||||
overridePendingTransition(
|
||||
dev.dettmer.simplenotes.R.anim.slide_in_left,
|
||||
dev.dettmer.simplenotes.R.anim.slide_out_right
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect events from ViewModel for Activity-level actions
|
||||
* v1.5.0: Ported from old SettingsActivity
|
||||
*/
|
||||
private fun collectViewModelEvents() {
|
||||
lifecycleScope.launch {
|
||||
viewModel.events.collect { event ->
|
||||
when (event) {
|
||||
is SettingsViewModel.SettingsEvent.RequestBatteryOptimization -> {
|
||||
checkBatteryOptimization()
|
||||
}
|
||||
is SettingsViewModel.SettingsEvent.RestartNetworkMonitor -> {
|
||||
restartNetworkMonitor()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if battery optimization is disabled for this app
|
||||
* v1.5.0: Ported from old SettingsActivity
|
||||
*/
|
||||
private fun checkBatteryOptimization() {
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
|
||||
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
|
||||
showBatteryOptimizationDialog()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show dialog asking user to disable battery optimization
|
||||
* v1.5.0: Ported from old SettingsActivity
|
||||
*/
|
||||
private fun showBatteryOptimizationDialog() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(getString(R.string.battery_optimization_dialog_title))
|
||||
.setMessage(getString(R.string.battery_optimization_dialog_full_message))
|
||||
.setPositiveButton(getString(R.string.battery_optimization_open_settings)) { _, _ ->
|
||||
openBatteryOptimizationSettings()
|
||||
}
|
||||
.setNegativeButton(getString(R.string.battery_optimization_later)) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Open system battery optimization settings
|
||||
* v1.5.0: Ported from old SettingsActivity
|
||||
*
|
||||
* Note: REQUEST_IGNORE_BATTERY_OPTIMIZATIONS is acceptable for F-Droid builds.
|
||||
* For Play Store builds, this would need to be changed to
|
||||
* ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS (shows list, doesn't request directly).
|
||||
*/
|
||||
@SuppressLint("BatteryLife")
|
||||
private fun openBatteryOptimizationSettings() {
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
|
||||
intent.data = Uri.parse("package:$packageName")
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to open battery optimization settings: ${e.message}")
|
||||
// Fallback: Open general battery settings
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
||||
startActivity(intent)
|
||||
} catch (e2: Exception) {
|
||||
Logger.w(TAG, "Failed to open fallback battery settings: ${e2.message}")
|
||||
Toast.makeText(this, "Bitte Akku-Optimierung manuell deaktivieren", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the network monitor after sync settings change
|
||||
* v1.5.0: Ported from old SettingsActivity
|
||||
*/
|
||||
private fun restartNetworkMonitor() {
|
||||
try {
|
||||
val app = application as SimpleNotesApplication
|
||||
Logger.d(TAG, "🔄 Restarting NetworkMonitor with new settings")
|
||||
app.networkMonitor.stopMonitoring()
|
||||
app.networkMonitor.startMonitoring()
|
||||
Logger.d(TAG, "✅ NetworkMonitor restarted successfully")
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "❌ Failed to restart NetworkMonitor: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle configuration changes (e.g., locale) without recreating activity
|
||||
* v1.8.0: Prevents flickering during language changes by avoiding full recreate
|
||||
* Compose automatically recomposes when configuration changes
|
||||
*/
|
||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
Logger.d(TAG, "📱 Configuration changed (likely locale switch) - Compose will recompose")
|
||||
// Compose handles UI updates automatically via recomposition
|
||||
// No manual action needed - stringResource() etc. will pick up new locale
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package dev.dettmer.simplenotes.ui.settings
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import dev.dettmer.simplenotes.ui.settings.screens.AboutScreen
|
||||
import dev.dettmer.simplenotes.ui.settings.screens.BackupSettingsScreen
|
||||
import dev.dettmer.simplenotes.ui.settings.screens.DebugSettingsScreen
|
||||
import dev.dettmer.simplenotes.ui.settings.screens.DisplaySettingsScreen
|
||||
import dev.dettmer.simplenotes.ui.settings.screens.LanguageSettingsScreen
|
||||
import dev.dettmer.simplenotes.ui.settings.screens.MarkdownSettingsScreen
|
||||
import dev.dettmer.simplenotes.ui.settings.screens.ServerSettingsScreen
|
||||
import dev.dettmer.simplenotes.ui.settings.screens.SettingsMainScreen
|
||||
import dev.dettmer.simplenotes.ui.settings.screens.SyncSettingsScreen
|
||||
|
||||
/**
|
||||
* Settings navigation host with all routes
|
||||
* v1.5.0: Jetpack Compose Settings Redesign
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsNavHost(
|
||||
navController: NavHostController,
|
||||
viewModel: SettingsViewModel,
|
||||
onFinish: () -> Unit
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = SettingsRoute.Main.route
|
||||
) {
|
||||
// Main Settings Overview
|
||||
composable(SettingsRoute.Main.route) {
|
||||
SettingsMainScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigate = { route -> navController.navigate(route.route) },
|
||||
onBack = onFinish
|
||||
)
|
||||
}
|
||||
|
||||
// Language Settings
|
||||
composable(SettingsRoute.Language.route) {
|
||||
LanguageSettingsScreen(
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
// Server Settings
|
||||
composable(SettingsRoute.Server.route) {
|
||||
ServerSettingsScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
// Sync Settings
|
||||
composable(SettingsRoute.Sync.route) {
|
||||
SyncSettingsScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
onNavigateToServerSettings = {
|
||||
navController.navigate(SettingsRoute.Server.route) {
|
||||
// Avoid multiple copies of server settings in back stack
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Markdown Settings
|
||||
composable(SettingsRoute.Markdown.route) {
|
||||
MarkdownSettingsScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
// Backup Settings
|
||||
composable(SettingsRoute.Backup.route) {
|
||||
BackupSettingsScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
// About Screen
|
||||
composable(SettingsRoute.About.route) {
|
||||
AboutScreen(
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
// Debug Settings
|
||||
composable(SettingsRoute.Debug.route) {
|
||||
DebugSettingsScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
// 🎨 v1.7.0: Display Settings
|
||||
composable(SettingsRoute.Display.route) {
|
||||
DisplaySettingsScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package dev.dettmer.simplenotes.ui.settings
|
||||
|
||||
/**
|
||||
* Navigation routes for Settings screens
|
||||
* v1.5.0: Jetpack Compose Settings Redesign
|
||||
*/
|
||||
sealed class SettingsRoute(val route: String) {
|
||||
data object Main : SettingsRoute("settings_main")
|
||||
data object Language : SettingsRoute("settings_language")
|
||||
data object Server : SettingsRoute("settings_server")
|
||||
data object Sync : SettingsRoute("settings_sync")
|
||||
data object Markdown : SettingsRoute("settings_markdown")
|
||||
data object Backup : SettingsRoute("settings_backup")
|
||||
data object About : SettingsRoute("settings_about")
|
||||
data object Debug : SettingsRoute("settings_debug")
|
||||
data object Display : SettingsRoute("settings_display") // 🎨 v1.7.0
|
||||
}
|
||||
@@ -0,0 +1,927 @@
|
||||
package dev.dettmer.simplenotes.ui.settings
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.backup.BackupManager
|
||||
import dev.dettmer.simplenotes.backup.RestoreMode
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* ViewModel for Settings screens
|
||||
* v1.5.0: Jetpack Compose Settings Redesign
|
||||
*
|
||||
* Manages all settings state and actions across the Settings navigation graph.
|
||||
*/
|
||||
@Suppress("TooManyFunctions") // v1.7.0: 35 Funktionen durch viele kleine Setter (setTrigger*, set*)
|
||||
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SettingsViewModel"
|
||||
private const val CONNECTION_TIMEOUT_MS = 3000
|
||||
private const val STATUS_CLEAR_DELAY_SUCCESS_MS = 2000L // 2s for successful operations
|
||||
private const val STATUS_CLEAR_DELAY_ERROR_MS = 3000L // 3s for errors (more important)
|
||||
}
|
||||
|
||||
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val backupManager = BackupManager(application)
|
||||
private val notesStorage = NotesStorage(application) // v1.7.0: For server change detection
|
||||
|
||||
// 🔧 v1.7.0 Hotfix: Track last confirmed server URL for change detection
|
||||
// This prevents false-positive "server changed" toasts during text input
|
||||
private var confirmedServerUrl: String = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Server Settings State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
// v1.5.0 Fix: Initialize URL with protocol prefix if empty
|
||||
private val storedUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
|
||||
|
||||
// 🌟 v1.6.0: Separate host from prefix for better UX
|
||||
// isHttps determines the prefix, serverHost is the editable part
|
||||
private val _isHttps = MutableStateFlow(storedUrl.startsWith("https://"))
|
||||
val isHttps: StateFlow<Boolean> = _isHttps.asStateFlow()
|
||||
|
||||
// Extract host part (everything after http:// or https://)
|
||||
private fun extractHostFromUrl(url: String): String {
|
||||
return when {
|
||||
url.startsWith("https://") -> url.removePrefix("https://")
|
||||
url.startsWith("http://") -> url.removePrefix("http://")
|
||||
else -> url
|
||||
}
|
||||
}
|
||||
|
||||
// 🌟 v1.6.0: Only the host part is editable (without protocol prefix)
|
||||
private val _serverHost = MutableStateFlow(extractHostFromUrl(storedUrl))
|
||||
val serverHost: StateFlow<String> = _serverHost.asStateFlow()
|
||||
|
||||
// 🌟 v1.6.0: Full URL for display purposes (computed from prefix + host)
|
||||
val serverUrl: StateFlow<String> = combine(_isHttps, _serverHost) { https, host ->
|
||||
val prefix = if (https) "https://" else "http://"
|
||||
if (host.isEmpty()) "" else prefix + host
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, storedUrl)
|
||||
|
||||
private val _username = MutableStateFlow(prefs.getString(Constants.KEY_USERNAME, "") ?: "")
|
||||
val username: StateFlow<String> = _username.asStateFlow()
|
||||
|
||||
private val _password = MutableStateFlow(prefs.getString(Constants.KEY_PASSWORD, "") ?: "")
|
||||
val password: StateFlow<String> = _password.asStateFlow()
|
||||
|
||||
private val _serverStatus = MutableStateFlow<ServerStatus>(ServerStatus.Unknown)
|
||||
val serverStatus: StateFlow<ServerStatus> = _serverStatus.asStateFlow()
|
||||
|
||||
// 🌟 v1.6.0: Offline Mode Toggle
|
||||
// Default: true for new users (no server), false for existing users (has server config)
|
||||
private val _offlineMode = MutableStateFlow(
|
||||
if (prefs.contains(Constants.KEY_OFFLINE_MODE)) {
|
||||
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
|
||||
} else {
|
||||
// Migration: auto-detect based on existing server config
|
||||
!hasExistingServerConfig()
|
||||
}
|
||||
)
|
||||
val offlineMode: StateFlow<Boolean> = _offlineMode.asStateFlow()
|
||||
|
||||
private fun hasExistingServerConfig(): Boolean {
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
return !serverUrl.isNullOrEmpty() &&
|
||||
serverUrl != "http://" &&
|
||||
serverUrl != "https://"
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Events (for Activity-level actions like dialogs, intents)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _events = MutableSharedFlow<SettingsEvent>()
|
||||
val events: SharedFlow<SettingsEvent> = _events.asSharedFlow()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Markdown Export Progress State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _markdownExportProgress = MutableStateFlow<MarkdownExportProgress?>(null)
|
||||
val markdownExportProgress: StateFlow<MarkdownExportProgress?> = _markdownExportProgress.asStateFlow()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Sync Settings State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _autoSyncEnabled = MutableStateFlow(prefs.getBoolean(Constants.KEY_AUTO_SYNC, false))
|
||||
val autoSyncEnabled: StateFlow<Boolean> = _autoSyncEnabled.asStateFlow()
|
||||
|
||||
private val _syncInterval = MutableStateFlow(
|
||||
prefs.getLong(Constants.PREF_SYNC_INTERVAL_MINUTES, Constants.DEFAULT_SYNC_INTERVAL_MINUTES)
|
||||
)
|
||||
val syncInterval: StateFlow<Long> = _syncInterval.asStateFlow()
|
||||
|
||||
// 🆕 v1.8.0: Max Parallel Downloads
|
||||
private val _maxParallelDownloads = MutableStateFlow(
|
||||
prefs.getInt(Constants.KEY_MAX_PARALLEL_DOWNLOADS, Constants.DEFAULT_MAX_PARALLEL_DOWNLOADS)
|
||||
)
|
||||
val maxParallelDownloads: StateFlow<Int> = _maxParallelDownloads.asStateFlow()
|
||||
|
||||
// 🌟 v1.6.0: Configurable Sync Triggers
|
||||
private val _triggerOnSave = MutableStateFlow(
|
||||
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, Constants.DEFAULT_TRIGGER_ON_SAVE)
|
||||
)
|
||||
val triggerOnSave: StateFlow<Boolean> = _triggerOnSave.asStateFlow()
|
||||
|
||||
private val _triggerOnResume = MutableStateFlow(
|
||||
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, Constants.DEFAULT_TRIGGER_ON_RESUME)
|
||||
)
|
||||
val triggerOnResume: StateFlow<Boolean> = _triggerOnResume.asStateFlow()
|
||||
|
||||
private val _triggerWifiConnect = MutableStateFlow(
|
||||
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)
|
||||
)
|
||||
val triggerWifiConnect: StateFlow<Boolean> = _triggerWifiConnect.asStateFlow()
|
||||
|
||||
private val _triggerPeriodic = MutableStateFlow(
|
||||
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)
|
||||
)
|
||||
val triggerPeriodic: StateFlow<Boolean> = _triggerPeriodic.asStateFlow()
|
||||
|
||||
private val _triggerBoot = MutableStateFlow(
|
||||
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, Constants.DEFAULT_TRIGGER_BOOT)
|
||||
)
|
||||
val triggerBoot: StateFlow<Boolean> = _triggerBoot.asStateFlow()
|
||||
|
||||
// 🎉 v1.7.0: WiFi-Only Sync Toggle
|
||||
private val _wifiOnlySync = MutableStateFlow(
|
||||
prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC)
|
||||
)
|
||||
val wifiOnlySync: StateFlow<Boolean> = _wifiOnlySync.asStateFlow()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Markdown Settings State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _markdownAutoSync = MutableStateFlow(
|
||||
prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false) &&
|
||||
prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
|
||||
)
|
||||
val markdownAutoSync: StateFlow<Boolean> = _markdownAutoSync.asStateFlow()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Debug Settings State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _fileLoggingEnabled = MutableStateFlow(
|
||||
prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)
|
||||
)
|
||||
val fileLoggingEnabled: StateFlow<Boolean> = _fileLoggingEnabled.asStateFlow()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🎨 v1.7.0: Display Settings State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _displayMode = MutableStateFlow(
|
||||
prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
|
||||
)
|
||||
val displayMode: StateFlow<String> = _displayMode.asStateFlow()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// UI State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _isSyncing = MutableStateFlow(false)
|
||||
val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow()
|
||||
|
||||
private val _isBackupInProgress = MutableStateFlow(false)
|
||||
val isBackupInProgress: StateFlow<Boolean> = _isBackupInProgress.asStateFlow()
|
||||
|
||||
// v1.8.0: Descriptive backup status text
|
||||
private val _backupStatusText = MutableStateFlow("")
|
||||
val backupStatusText: StateFlow<String> = _backupStatusText.asStateFlow()
|
||||
|
||||
private val _showToast = MutableSharedFlow<String>()
|
||||
val showToast: SharedFlow<String> = _showToast.asSharedFlow()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Server Settings Actions
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* v1.6.0: Set offline mode on/off
|
||||
* When enabled, all network features are disabled
|
||||
*/
|
||||
fun setOfflineMode(enabled: Boolean) {
|
||||
_offlineMode.value = enabled
|
||||
prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, enabled).apply()
|
||||
|
||||
if (enabled) {
|
||||
_serverStatus.value = ServerStatus.OfflineMode
|
||||
} else {
|
||||
// Re-check server status when disabling offline mode
|
||||
checkServerStatus()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateServerUrl(url: String) {
|
||||
// 🌟 v1.6.0: Deprecated - use updateServerHost instead
|
||||
// This function is kept for compatibility but now delegates to updateServerHost
|
||||
val host = extractHostFromUrl(url)
|
||||
updateServerHost(host)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🌟 v1.6.0: Update only the host part of the server URL
|
||||
* The protocol prefix is handled separately by updateProtocol()
|
||||
* 🔧 v1.7.0 Hotfix: Removed auto-save to prevent false server-change detection
|
||||
* 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
|
||||
* but WITHOUT server-change detection (detection happens only on screen exit)
|
||||
*/
|
||||
fun updateServerHost(host: String) {
|
||||
_serverHost.value = host
|
||||
|
||||
// ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection
|
||||
val prefix = if (_isHttps.value) "https://" else "http://"
|
||||
val fullUrl = if (host.isEmpty()) "" else prefix + host
|
||||
prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
|
||||
}
|
||||
|
||||
fun updateProtocol(useHttps: Boolean) {
|
||||
_isHttps.value = useHttps
|
||||
// 🌟 v1.6.0: Host stays the same, only prefix changes
|
||||
// 🔧 v1.7.0 Hotfix: Removed auto-save to prevent false server-change detection
|
||||
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
|
||||
|
||||
// ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection
|
||||
val prefix = if (useHttps) "https://" else "http://"
|
||||
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
|
||||
prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
|
||||
}
|
||||
|
||||
fun updateUsername(value: String) {
|
||||
_username.value = value
|
||||
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
|
||||
prefs.edit().putString(Constants.KEY_USERNAME, value).apply()
|
||||
}
|
||||
|
||||
fun updatePassword(value: String) {
|
||||
_password.value = value
|
||||
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
|
||||
prefs.edit().putString(Constants.KEY_PASSWORD, value).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔧 v1.7.0 Hotfix: Manual save function - only called when leaving settings screen
|
||||
* This prevents false "server changed" detection during text input
|
||||
* 🔧 v1.7.0 Regression Fix: Settings are now saved IMMEDIATELY in update functions.
|
||||
* This function now ONLY handles server-change detection and sync reset.
|
||||
*/
|
||||
fun saveServerSettingsManually() {
|
||||
// 🌟 v1.6.0: Construct full URL from prefix + host
|
||||
val prefix = if (_isHttps.value) "https://" else "http://"
|
||||
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
|
||||
|
||||
// 🔄 v1.7.0: Detect server change ONLY against last confirmed URL
|
||||
val serverChanged = isServerReallyChanged(confirmedServerUrl, fullUrl)
|
||||
|
||||
// ✅ Settings are already saved in updateServerHost/Protocol/Username/Password
|
||||
// This function now ONLY handles server-change detection
|
||||
|
||||
// Reset sync status if server actually changed
|
||||
if (serverChanged) {
|
||||
viewModelScope.launch {
|
||||
val count = notesStorage.resetAllSyncStatusToPending()
|
||||
Logger.d(TAG, "🔄 Server changed from '$confirmedServerUrl' to '$fullUrl': Reset $count notes to PENDING")
|
||||
emitToast(getString(R.string.toast_server_changed_sync_reset, count))
|
||||
}
|
||||
// Update confirmed state after reset
|
||||
confirmedServerUrl = fullUrl
|
||||
} else {
|
||||
Logger.d(TAG, "💾 Server settings check complete (no server change detected)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <20> v1.7.0 Hotfix: Improved server change detection
|
||||
*
|
||||
* Only returns true if the server URL actually changed in a meaningful way.
|
||||
* Handles edge cases:
|
||||
* - First setup (empty → filled) = NOT a change
|
||||
* - Protocol only (http → https) = NOT a change
|
||||
* - Server removed (filled → empty) = NOT a change
|
||||
* - Trailing slashes, case differences = NOT a change
|
||||
* - Different hostname/port/path = IS a change ✓
|
||||
*/
|
||||
private fun isServerReallyChanged(confirmedUrl: String, newUrl: String): Boolean {
|
||||
// Empty → Non-empty = First setup, NOT a change
|
||||
if (confirmedUrl.isEmpty() && newUrl.isNotEmpty()) {
|
||||
Logger.d(TAG, "First server setup detected (no reset needed)")
|
||||
return false
|
||||
}
|
||||
|
||||
// Both empty = No change
|
||||
if (confirmedUrl.isEmpty() && newUrl.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Non-empty → Empty = Server removed (keep notes local, no reset)
|
||||
if (confirmedUrl.isNotEmpty() && newUrl.isEmpty()) {
|
||||
Logger.d(TAG, "Server removed (notes stay local, no reset needed)")
|
||||
return false
|
||||
}
|
||||
|
||||
// Same URL = No change
|
||||
if (confirmedUrl == newUrl) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Normalize URLs for comparison (ignore protocol, trailing slash, case)
|
||||
val normalize = { url: String ->
|
||||
url.trim()
|
||||
.removePrefix("http://")
|
||||
.removePrefix("https://")
|
||||
.removeSuffix("/")
|
||||
.lowercase()
|
||||
}
|
||||
|
||||
val confirmedNormalized = normalize(confirmedUrl)
|
||||
val newNormalized = normalize(newUrl)
|
||||
|
||||
// Check if normalized URLs differ
|
||||
val changed = confirmedNormalized != newNormalized
|
||||
|
||||
if (changed) {
|
||||
Logger.d(TAG, "Server URL changed: '$confirmedNormalized' → '$newNormalized'")
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
fun testConnection() {
|
||||
viewModelScope.launch {
|
||||
_serverStatus.value = ServerStatus.Checking
|
||||
try {
|
||||
val syncService = WebDavSyncService(getApplication())
|
||||
val result = syncService.testConnection()
|
||||
_serverStatus.value = if (result.isSuccess) {
|
||||
ServerStatus.Reachable
|
||||
} else {
|
||||
ServerStatus.Unreachable(result.errorMessage)
|
||||
}
|
||||
val message = if (result.isSuccess) {
|
||||
getString(R.string.toast_connection_success)
|
||||
} else {
|
||||
getString(R.string.toast_connection_failed, result.errorMessage ?: "")
|
||||
}
|
||||
emitToast(message)
|
||||
} catch (e: Exception) {
|
||||
_serverStatus.value = ServerStatus.Unreachable(e.message)
|
||||
emitToast(getString(R.string.toast_error, e.message ?: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun checkServerStatus() {
|
||||
// 🌟 v1.6.0: Respect offline mode first
|
||||
if (_offlineMode.value) {
|
||||
_serverStatus.value = ServerStatus.OfflineMode
|
||||
return
|
||||
}
|
||||
|
||||
// 🌟 v1.6.0: Check if host is configured
|
||||
val serverHost = _serverHost.value
|
||||
if (serverHost.isEmpty()) {
|
||||
_serverStatus.value = ServerStatus.NotConfigured
|
||||
return
|
||||
}
|
||||
|
||||
// Construct full URL
|
||||
val prefix = if (_isHttps.value) "https://" else "http://"
|
||||
val serverUrl = prefix + serverHost
|
||||
|
||||
viewModelScope.launch {
|
||||
_serverStatus.value = ServerStatus.Checking
|
||||
val isReachable = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = URL(serverUrl)
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = CONNECTION_TIMEOUT_MS
|
||||
connection.readTimeout = CONNECTION_TIMEOUT_MS
|
||||
val code = connection.responseCode
|
||||
connection.disconnect()
|
||||
code in 200..299 || code == 401
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Server check failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
_serverStatus.value = if (isReachable) ServerStatus.Reachable else ServerStatus.Unreachable(null)
|
||||
}
|
||||
}
|
||||
|
||||
fun syncNow() {
|
||||
if (_isSyncing.value) return
|
||||
viewModelScope.launch {
|
||||
_isSyncing.value = true
|
||||
try {
|
||||
val syncService = WebDavSyncService(getApplication())
|
||||
|
||||
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung
|
||||
val gateResult = syncService.canSync()
|
||||
if (!gateResult.canSync) {
|
||||
if (gateResult.isBlockedByWifiOnly) {
|
||||
emitToast(getString(R.string.sync_wifi_only_hint))
|
||||
} else {
|
||||
emitToast(getString(R.string.toast_sync_failed, "Offline mode"))
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
emitToast(getString(R.string.toast_syncing))
|
||||
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
emitToast(getString(R.string.toast_already_synced))
|
||||
return@launch
|
||||
}
|
||||
|
||||
val result = syncService.syncNotes()
|
||||
if (result.isSuccess) {
|
||||
emitToast(getString(R.string.toast_sync_success, result.syncedCount))
|
||||
} else {
|
||||
emitToast(getString(R.string.toast_sync_failed, result.errorMessage ?: ""))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
emitToast(getString(R.string.toast_error, e.message ?: ""))
|
||||
} finally {
|
||||
_isSyncing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Sync Settings Actions
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
fun setAutoSync(enabled: Boolean) {
|
||||
_autoSyncEnabled.value = enabled
|
||||
prefs.edit().putBoolean(Constants.KEY_AUTO_SYNC, enabled).apply()
|
||||
|
||||
viewModelScope.launch {
|
||||
if (enabled) {
|
||||
// v1.5.0 Fix: Trigger battery optimization check and network monitor restart
|
||||
_events.emit(SettingsEvent.RequestBatteryOptimization)
|
||||
_events.emit(SettingsEvent.RestartNetworkMonitor)
|
||||
emitToast(getString(R.string.toast_auto_sync_enabled))
|
||||
} else {
|
||||
_events.emit(SettingsEvent.RestartNetworkMonitor)
|
||||
emitToast(getString(R.string.toast_auto_sync_disabled))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSyncInterval(minutes: Long) {
|
||||
_syncInterval.value = minutes
|
||||
prefs.edit().putLong(Constants.PREF_SYNC_INTERVAL_MINUTES, minutes).apply()
|
||||
viewModelScope.launch {
|
||||
val text = when (minutes) {
|
||||
15L -> getString(R.string.toast_sync_interval_15min)
|
||||
60L -> getString(R.string.toast_sync_interval_60min)
|
||||
else -> getString(R.string.toast_sync_interval_30min)
|
||||
}
|
||||
emitToast(getString(R.string.toast_sync_interval, text))
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 v1.8.0: Max Parallel Downloads Setter
|
||||
fun setMaxParallelDownloads(count: Int) {
|
||||
val validCount = count.coerceIn(
|
||||
Constants.MIN_PARALLEL_DOWNLOADS,
|
||||
Constants.MAX_PARALLEL_DOWNLOADS
|
||||
)
|
||||
_maxParallelDownloads.value = validCount
|
||||
prefs.edit().putInt(Constants.KEY_MAX_PARALLEL_DOWNLOADS, validCount).apply()
|
||||
}
|
||||
|
||||
// 🌟 v1.6.0: Configurable Sync Triggers Setters
|
||||
|
||||
fun setTriggerOnSave(enabled: Boolean) {
|
||||
_triggerOnSave.value = enabled
|
||||
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, enabled).apply()
|
||||
Logger.d(TAG, "Trigger onSave: $enabled")
|
||||
}
|
||||
|
||||
fun setTriggerOnResume(enabled: Boolean) {
|
||||
_triggerOnResume.value = enabled
|
||||
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, enabled).apply()
|
||||
Logger.d(TAG, "Trigger onResume: $enabled")
|
||||
}
|
||||
|
||||
fun setTriggerWifiConnect(enabled: Boolean) {
|
||||
_triggerWifiConnect.value = enabled
|
||||
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, enabled).apply()
|
||||
viewModelScope.launch {
|
||||
_events.emit(SettingsEvent.RestartNetworkMonitor)
|
||||
}
|
||||
Logger.d(TAG, "Trigger WiFi-Connect: $enabled")
|
||||
}
|
||||
|
||||
fun setTriggerPeriodic(enabled: Boolean) {
|
||||
_triggerPeriodic.value = enabled
|
||||
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, enabled).apply()
|
||||
viewModelScope.launch {
|
||||
_events.emit(SettingsEvent.RestartNetworkMonitor)
|
||||
}
|
||||
Logger.d(TAG, "Trigger Periodic: $enabled")
|
||||
}
|
||||
|
||||
fun setTriggerBoot(enabled: Boolean) {
|
||||
_triggerBoot.value = enabled
|
||||
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, enabled).apply()
|
||||
Logger.d(TAG, "Trigger Boot: $enabled")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎉 v1.7.0: Set WiFi-only sync mode
|
||||
* When enabled, sync only happens when connected to WiFi
|
||||
*/
|
||||
fun setWifiOnlySync(enabled: Boolean) {
|
||||
_wifiOnlySync.value = enabled
|
||||
prefs.edit().putBoolean(Constants.KEY_WIFI_ONLY_SYNC, enabled).apply()
|
||||
Logger.d(TAG, "📡 WiFi-only sync: $enabled")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Markdown Settings Actions
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
fun setMarkdownAutoSync(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
// v1.5.0 Fix: Perform initial export when enabling (like old SettingsActivity)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// Check server configuration first
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
|
||||
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
|
||||
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
|
||||
|
||||
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
|
||||
emitToast(getString(R.string.toast_configure_server_first))
|
||||
// Don't enable - revert state
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Check if there are notes to export
|
||||
val noteStorage = dev.dettmer.simplenotes.storage.NotesStorage(getApplication())
|
||||
val noteCount = noteStorage.loadAllNotes().size
|
||||
|
||||
if (noteCount > 0) {
|
||||
// Show progress and perform initial export
|
||||
_markdownExportProgress.value = MarkdownExportProgress(0, noteCount)
|
||||
|
||||
val syncService = WebDavSyncService(getApplication())
|
||||
val exportedCount = withContext(Dispatchers.IO) {
|
||||
syncService.exportAllNotesToMarkdown(
|
||||
serverUrl = serverUrl,
|
||||
username = username,
|
||||
password = password,
|
||||
onProgress = { current, total ->
|
||||
_markdownExportProgress.value = MarkdownExportProgress(current, total)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Export successful - save settings
|
||||
_markdownAutoSync.value = true
|
||||
prefs.edit()
|
||||
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, true)
|
||||
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, true)
|
||||
.apply()
|
||||
|
||||
_markdownExportProgress.value = MarkdownExportProgress(noteCount, noteCount, isComplete = true)
|
||||
emitToast(getString(R.string.toast_markdown_exported, exportedCount))
|
||||
|
||||
@Suppress("MagicNumber") // UI progress delay
|
||||
// Clear progress after short delay
|
||||
kotlinx.coroutines.delay(500)
|
||||
_markdownExportProgress.value = null
|
||||
|
||||
} else {
|
||||
// No notes - just enable the feature
|
||||
_markdownAutoSync.value = true
|
||||
prefs.edit()
|
||||
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, true)
|
||||
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, true)
|
||||
.apply()
|
||||
emitToast(getString(R.string.toast_markdown_enabled))
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
_markdownExportProgress.value = null
|
||||
emitToast(getString(R.string.toast_export_failed, e.message ?: ""))
|
||||
// Don't enable on error
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Disable - simple
|
||||
_markdownAutoSync.value = false
|
||||
prefs.edit()
|
||||
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, false)
|
||||
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
|
||||
.apply()
|
||||
viewModelScope.launch {
|
||||
emitToast(getString(R.string.toast_markdown_disabled))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun performManualMarkdownSync() {
|
||||
// 🌟 v1.6.0: Block in offline mode
|
||||
if (_offlineMode.value) {
|
||||
Logger.d(TAG, "⏭️ Manual Markdown sync blocked: Offline mode enabled")
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
emitToast(getString(R.string.toast_markdown_syncing))
|
||||
val syncService = WebDavSyncService(getApplication())
|
||||
val result = syncService.manualMarkdownSync()
|
||||
emitToast(getString(R.string.toast_markdown_result, result.exportedCount, result.importedCount))
|
||||
} catch (e: Exception) {
|
||||
emitToast(getString(R.string.toast_error, e.message ?: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Backup Actions
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
fun createBackup(uri: Uri, password: String? = null) {
|
||||
viewModelScope.launch {
|
||||
_isBackupInProgress.value = true
|
||||
_backupStatusText.value = getString(R.string.backup_progress_creating)
|
||||
try {
|
||||
val result = backupManager.createBackup(uri, password)
|
||||
|
||||
// Phase 2: Show completion status
|
||||
_backupStatusText.value = if (result.success) {
|
||||
getString(R.string.backup_progress_complete)
|
||||
} else {
|
||||
getString(R.string.backup_progress_failed)
|
||||
}
|
||||
|
||||
// Phase 3: Clear after delay
|
||||
delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to create backup", e)
|
||||
_backupStatusText.value = getString(R.string.backup_progress_failed)
|
||||
delay(STATUS_CLEAR_DELAY_ERROR_MS)
|
||||
} finally {
|
||||
_isBackupInProgress.value = false
|
||||
_backupStatusText.value = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) {
|
||||
viewModelScope.launch {
|
||||
_isBackupInProgress.value = true
|
||||
_backupStatusText.value = getString(R.string.backup_progress_restoring)
|
||||
try {
|
||||
val result = backupManager.restoreBackup(uri, mode, password)
|
||||
|
||||
// Phase 2: Show completion status
|
||||
_backupStatusText.value = if (result.success) {
|
||||
getString(R.string.restore_progress_complete)
|
||||
} else {
|
||||
getString(R.string.restore_progress_failed)
|
||||
}
|
||||
|
||||
// Phase 3: Clear after delay
|
||||
delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to restore backup from file", e)
|
||||
_backupStatusText.value = getString(R.string.restore_progress_failed)
|
||||
delay(STATUS_CLEAR_DELAY_ERROR_MS)
|
||||
} finally {
|
||||
_isBackupInProgress.value = false
|
||||
_backupStatusText.value = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔐 v1.7.0: Check if backup is encrypted and call appropriate callback
|
||||
*/
|
||||
fun checkBackupEncryption(
|
||||
uri: Uri,
|
||||
onEncrypted: () -> Unit,
|
||||
onPlaintext: () -> Unit
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val isEncrypted = backupManager.isBackupEncrypted(uri)
|
||||
if (isEncrypted) {
|
||||
onEncrypted()
|
||||
} else {
|
||||
onPlaintext()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to check encryption status", e)
|
||||
onPlaintext() // Assume plaintext on error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreFromServer(mode: RestoreMode) {
|
||||
viewModelScope.launch {
|
||||
_isBackupInProgress.value = true
|
||||
_backupStatusText.value = getString(R.string.backup_progress_restoring_server)
|
||||
try {
|
||||
val syncService = WebDavSyncService(getApplication())
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
syncService.restoreFromServer(mode)
|
||||
}
|
||||
|
||||
// Phase 2: Show completion status
|
||||
_backupStatusText.value = if (result.isSuccess) {
|
||||
getString(R.string.restore_server_progress_complete)
|
||||
} else {
|
||||
getString(R.string.restore_server_progress_failed)
|
||||
}
|
||||
|
||||
// Phase 3: Clear after delay
|
||||
delay(if (result.isSuccess) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to restore from server", e)
|
||||
_backupStatusText.value = getString(R.string.restore_server_progress_failed)
|
||||
delay(STATUS_CLEAR_DELAY_ERROR_MS)
|
||||
} finally {
|
||||
_isBackupInProgress.value = false
|
||||
_backupStatusText.value = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Debug Settings Actions
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
fun setFileLogging(enabled: Boolean) {
|
||||
_fileLoggingEnabled.value = enabled
|
||||
prefs.edit().putBoolean(Constants.KEY_FILE_LOGGING_ENABLED, enabled).apply()
|
||||
Logger.setFileLoggingEnabled(enabled)
|
||||
viewModelScope.launch {
|
||||
emitToast(if (enabled) getString(R.string.toast_file_logging_enabled) else getString(R.string.toast_file_logging_disabled))
|
||||
}
|
||||
}
|
||||
|
||||
fun clearLogs() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val cleared = Logger.clearLogFile(getApplication())
|
||||
emitToast(if (cleared) getString(R.string.toast_logs_deleted) else getString(R.string.toast_logs_deleted))
|
||||
} catch (e: Exception) {
|
||||
emitToast(getString(R.string.toast_error, e.message ?: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getLogFile() = Logger.getLogFile(getApplication())
|
||||
|
||||
/**
|
||||
* v1.8.0: Reset changelog version to force showing the changelog dialog on next start
|
||||
* Used for testing the post-update changelog feature
|
||||
*/
|
||||
fun resetChangelogVersion() {
|
||||
prefs.edit()
|
||||
.putInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, 0)
|
||||
.apply()
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Helper
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Check if server is configured AND not in offline mode
|
||||
* v1.6.0: Returns false if offline mode is enabled
|
||||
*/
|
||||
fun isServerConfigured(): Boolean {
|
||||
// Offline mode takes priority
|
||||
if (_offlineMode.value) return false
|
||||
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
return !serverUrl.isNullOrEmpty() &&
|
||||
serverUrl != "http://" &&
|
||||
serverUrl != "https://"
|
||||
}
|
||||
|
||||
/**
|
||||
* 🌍 v1.7.1: Get string resources with correct app locale
|
||||
*
|
||||
* AndroidViewModel uses Application context which may not have the correct locale
|
||||
* applied when using per-app language settings. We need to get a Context that
|
||||
* respects AppCompatDelegate.getApplicationLocales().
|
||||
*/
|
||||
private fun getString(resId: Int): String {
|
||||
// Get context with correct locale configuration from AppCompatDelegate
|
||||
val appLocales = androidx.appcompat.app.AppCompatDelegate.getApplicationLocales()
|
||||
val context = if (!appLocales.isEmpty) {
|
||||
// Create configuration with app locale
|
||||
val config = android.content.res.Configuration(getApplication<Application>().resources.configuration)
|
||||
config.setLocale(appLocales.get(0))
|
||||
getApplication<Application>().createConfigurationContext(config)
|
||||
} else {
|
||||
// Use system locale (default)
|
||||
getApplication<Application>()
|
||||
}
|
||||
return context.getString(resId)
|
||||
}
|
||||
|
||||
private fun getString(resId: Int, vararg formatArgs: Any): String {
|
||||
// Get context with correct locale configuration from AppCompatDelegate
|
||||
val appLocales = androidx.appcompat.app.AppCompatDelegate.getApplicationLocales()
|
||||
val context = if (!appLocales.isEmpty) {
|
||||
// Create configuration with app locale
|
||||
val config = android.content.res.Configuration(getApplication<Application>().resources.configuration)
|
||||
config.setLocale(appLocales.get(0))
|
||||
getApplication<Application>().createConfigurationContext(config)
|
||||
} else {
|
||||
// Use system locale (default)
|
||||
getApplication<Application>()
|
||||
}
|
||||
return context.getString(resId, *formatArgs)
|
||||
}
|
||||
|
||||
private suspend fun emitToast(message: String) {
|
||||
_showToast.emit(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Server status states
|
||||
* v1.6.0: Added OfflineMode state
|
||||
*/
|
||||
sealed class ServerStatus {
|
||||
data object Unknown : ServerStatus()
|
||||
data object OfflineMode : ServerStatus() // 🌟 v1.6.0
|
||||
data object NotConfigured : ServerStatus()
|
||||
data object Checking : ServerStatus()
|
||||
data object Reachable : ServerStatus()
|
||||
data class Unreachable(val error: String?) : ServerStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Events for Activity-level actions (dialogs, intents, etc.)
|
||||
* v1.5.0: Ported from old SettingsActivity
|
||||
*/
|
||||
sealed class SettingsEvent {
|
||||
data object RequestBatteryOptimization : SettingsEvent()
|
||||
data object RestartNetworkMonitor : SettingsEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress state for Markdown export
|
||||
* v1.5.0: For initial export progress dialog
|
||||
*/
|
||||
data class MarkdownExportProgress(
|
||||
val current: Int,
|
||||
val total: Int,
|
||||
val isComplete: Boolean = false
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🎨 v1.7.0: Display Mode Functions
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Set display mode (list or grid)
|
||||
*/
|
||||
fun setDisplayMode(mode: String) {
|
||||
_displayMode.value = mode
|
||||
prefs.edit().putString(Constants.KEY_DISPLAY_MODE, mode).apply()
|
||||
Logger.d(TAG, "Display mode changed to: $mode")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package dev.dettmer.simplenotes.ui.settings.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Clickable Settings group card with icon, title, subtitle and optional status
|
||||
* v1.5.0: Jetpack Compose Settings Redesign
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsCard(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
statusText: String? = null,
|
||||
statusColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Icon with circle background
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
// Content
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (statusText != null) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = statusColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package dev.dettmer.simplenotes.ui.settings.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Primary filled button for settings actions
|
||||
* v1.5.0: Jetpack Compose Settings Redesign
|
||||
* v1.8.0: Button keeps text during loading, just becomes disabled
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
isLoading: Boolean = false
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled && !isLoading,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Outlined secondary button for settings actions
|
||||
* v1.8.0: Button keeps text during loading, just becomes disabled
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsOutlinedButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
isLoading: Boolean = false
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled && !isLoading,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Danger/destructive button for settings actions
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsDangerButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Info card with description text
|
||||
* v1.6.0: Added isWarning parameter for offline mode warning
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsInfoCard(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
isWarning: Boolean = false
|
||||
) {
|
||||
androidx.compose.material3.Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||
containerColor = if (isWarning) {
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
}
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (isWarning) {
|
||||
MaterialTheme.colorScheme.onErrorContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(16.dp),
|
||||
lineHeight = MaterialTheme.typography.bodySmall.lineHeight * 1.3f
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Section header text
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsSectionHeader(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Divider between settings groups
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsDivider(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Spacer(modifier = modifier.height(8.dp))
|
||||
androidx.compose.material3.HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.0: Backup progress indicator shown above buttons
|
||||
* Replaces the ugly in-button spinner with a clear status display
|
||||
*/
|
||||
@Composable
|
||||
fun BackupProgressCard(
|
||||
statusText: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
androidx.compose.material3.Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
androidx.compose.material3.LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
@file:Suppress("MatchingDeclarationName")
|
||||
package dev.dettmer.simplenotes.ui.settings.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Data class for radio option
|
||||
*/
|
||||
data class RadioOption<T>(
|
||||
val value: T,
|
||||
val title: String,
|
||||
val subtitle: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Settings radio group for selecting one option
|
||||
* v1.5.0: Jetpack Compose Settings Redesign
|
||||
*/
|
||||
@Composable
|
||||
fun <T> SettingsRadioGroup(
|
||||
options: List<RadioOption<T>>,
|
||||
selectedValue: T,
|
||||
onValueSelected: (T) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.selectableGroup()
|
||||
) {
|
||||
if (title != null) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
options.forEach { option ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = option.value == selectedValue,
|
||||
onClick = { onValueSelected(option.value) },
|
||||
role = Role.RadioButton
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = option.value == selectedValue,
|
||||
onClick = null // handled by selectable
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = option.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
if (option.subtitle != null) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = option.subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package dev.dettmer.simplenotes.ui.settings.components
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import dev.dettmer.simplenotes.R
|
||||
|
||||
/**
|
||||
* Reusable Scaffold with back-navigation TopAppBar
|
||||
* v1.5.0: Jetpack Compose Settings Redesign
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScaffold(
|
||||
title: String,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (PaddingValues) -> Unit
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.content_description_back)
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
) { paddingValues ->
|
||||
content(paddingValues)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package dev.dettmer.simplenotes.ui.settings.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Settings switch item with title, optional subtitle and icon
|
||||
* v1.5.0: Jetpack Compose Settings Redesign
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsSwitch(
|
||||
title: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
icon: ImageVector? = null,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = enabled) { onCheckedChange(!checked) }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = if (enabled) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
|
||||
},
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
}
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (enabled) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
enabled = enabled
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package dev.dettmer.simplenotes.ui.settings.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Policy
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import dev.dettmer.simplenotes.BuildConfig
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
|
||||
|
||||
/**
|
||||
* About app information screen
|
||||
* v1.5.0: Jetpack Compose Settings Redesign
|
||||
*/
|
||||
@Composable
|
||||
fun AboutScreen(
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val githubRepoUrl = "https://github.com/inventory69/simple-notes-sync"
|
||||
val githubProfileUrl = "https://github.com/inventory69"
|
||||
val licenseUrl = "https://github.com/inventory69/simple-notes-sync/blob/main/LICENSE"
|
||||
val changelogUrl = "https://github.com/inventory69/simple-notes-sync/blob/main/CHANGELOG.md" // v1.8.0
|
||||
|
||||
SettingsScaffold(
|
||||
title = stringResource(R.string.about_settings_title),
|
||||
onBack = onBack
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// App Info Card
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// v1.5.0: App icon foreground loaded directly for better quality
|
||||
val context = LocalContext.current
|
||||
val appIcon = remember {
|
||||
val drawable = ContextCompat.getDrawable(context, R.mipmap.ic_launcher_foreground)
|
||||
drawable?.let {
|
||||
// Use fixed size for consistent quality (256x256)
|
||||
val size = 256
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
it.setBounds(0, 0, size, size)
|
||||
it.draw(canvas)
|
||||
bitmap.asImageBitmap()
|
||||
}
|
||||
}
|
||||
|
||||
appIcon?.let {
|
||||
Image(
|
||||
bitmap = it,
|
||||
contentDescription = stringResource(R.string.about_app_name),
|
||||
modifier = Modifier.size(96.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.about_app_name),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.about_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
SettingsSectionHeader(text = stringResource(R.string.about_links_section))
|
||||
|
||||
// GitHub Repository
|
||||
AboutLinkItem(
|
||||
icon = Icons.Default.Code,
|
||||
title = stringResource(R.string.about_github_title),
|
||||
subtitle = stringResource(R.string.about_github_subtitle),
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(githubRepoUrl))
|
||||
context.startActivity(intent)
|
||||
}
|
||||
)
|
||||
|
||||
// Developer
|
||||
AboutLinkItem(
|
||||
icon = Icons.Default.Person,
|
||||
title = stringResource(R.string.about_developer_title),
|
||||
subtitle = stringResource(R.string.about_developer_subtitle),
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(githubProfileUrl))
|
||||
context.startActivity(intent)
|
||||
}
|
||||
)
|
||||
|
||||
// License
|
||||
AboutLinkItem(
|
||||
icon = Icons.Default.Policy,
|
||||
title = stringResource(R.string.about_license_title),
|
||||
subtitle = stringResource(R.string.about_license_subtitle),
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(licenseUrl))
|
||||
context.startActivity(intent)
|
||||
}
|
||||
)
|
||||
|
||||
// v1.8.0: Changelog
|
||||
AboutLinkItem(
|
||||
icon = Icons.Default.History,
|
||||
title = stringResource(R.string.about_changelog_title),
|
||||
subtitle = stringResource(R.string.about_changelog_subtitle),
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(changelogUrl))
|
||||
context.startActivity(intent)
|
||||
}
|
||||
)
|
||||
|
||||
SettingsDivider()
|
||||
|
||||
// Data Privacy Info
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.about_privacy_title),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.about_privacy_text),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clickable link item for About section
|
||||
*/
|
||||
@Composable
|
||||
private fun AboutLinkItem(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package dev.dettmer.simplenotes.ui.settings.screens
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.backup.RestoreMode
|
||||
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
|
||||
import dev.dettmer.simplenotes.ui.settings.components.BackupPasswordDialog
|
||||
import dev.dettmer.simplenotes.ui.settings.components.BackupProgressCard
|
||||
import dev.dettmer.simplenotes.ui.settings.components.RadioOption
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsButton
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsInfoCard
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsOutlinedButton
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
// v1.8.0: Delay for dialog close animation before starting restore
|
||||
private const val DIALOG_CLOSE_DELAY_MS = 200L
|
||||
|
||||
/**
|
||||
* Backup and restore settings screen
|
||||
* v1.5.0: Jetpack Compose Settings Redesign
|
||||
*/
|
||||
@Composable
|
||||
fun BackupSettingsScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val isBackupInProgress by viewModel.isBackupInProgress.collectAsState()
|
||||
|
||||
// 🌟 v1.6.0: Check if server restore is available
|
||||
val isServerConfigured = viewModel.isServerConfigured()
|
||||
|
||||
// Restore dialog state
|
||||
var showRestoreDialog by remember { mutableStateOf(false) }
|
||||
var restoreSource by remember { mutableStateOf<RestoreSource>(RestoreSource.LocalFile) }
|
||||
var pendingRestoreUri by remember { mutableStateOf<Uri?>(null) }
|
||||
var selectedRestoreMode by remember { mutableStateOf(RestoreMode.MERGE) }
|
||||
|
||||
// v1.8.0: Trigger for delayed restore execution (after dialog closes)
|
||||
var triggerRestore by remember { mutableStateOf(0) }
|
||||
var pendingRestoreAction by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
|
||||
// 🔐 v1.7.0: Encryption state
|
||||
var encryptBackup by remember { mutableStateOf(false) }
|
||||
var showEncryptionPasswordDialog by remember { mutableStateOf(false) }
|
||||
var showDecryptionPasswordDialog by remember { mutableStateOf(false) }
|
||||
var pendingBackupUri by remember { mutableStateOf<Uri?>(null) }
|
||||
|
||||
// File picker launchers
|
||||
val createBackupLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("application/json")
|
||||
) { uri ->
|
||||
uri?.let {
|
||||
// 🔐 v1.7.0: If encryption enabled, show password dialog first
|
||||
if (encryptBackup) {
|
||||
pendingBackupUri = it
|
||||
showEncryptionPasswordDialog = true
|
||||
} else {
|
||||
viewModel.createBackup(it, password = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val restoreFileLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument()
|
||||
) { uri ->
|
||||
uri?.let {
|
||||
pendingRestoreUri = it
|
||||
restoreSource = RestoreSource.LocalFile
|
||||
showRestoreDialog = true
|
||||
}
|
||||
}
|
||||
|
||||
// v1.8.0: Delayed restore execution after dialog closes
|
||||
LaunchedEffect(triggerRestore) {
|
||||
if (triggerRestore > 0) {
|
||||
delay(DIALOG_CLOSE_DELAY_MS) // Wait for dialog close animation
|
||||
pendingRestoreAction?.invoke()
|
||||
pendingRestoreAction = null
|
||||
}
|
||||
}
|
||||
|
||||
SettingsScaffold(
|
||||
title = stringResource(R.string.backup_settings_title),
|
||||
onBack = onBack
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Info Card
|
||||
SettingsInfoCard(
|
||||
text = stringResource(R.string.backup_auto_info)
|
||||
)
|
||||
|
||||
// v1.8.0: Progress indicator (visible during backup/restore)
|
||||
if (isBackupInProgress) {
|
||||
val backupStatus by viewModel.backupStatusText.collectAsState()
|
||||
BackupProgressCard(
|
||||
statusText = backupStatus.ifEmpty {
|
||||
stringResource(R.string.backup_progress_creating)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Local Backup Section
|
||||
SettingsSectionHeader(text = stringResource(R.string.backup_local_section))
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 🔐 v1.7.0: Encryption toggle
|
||||
SettingsSwitch(
|
||||
title = stringResource(R.string.backup_encryption_title),
|
||||
subtitle = stringResource(R.string.backup_encryption_subtitle),
|
||||
checked = encryptBackup,
|
||||
onCheckedChange = { encryptBackup = it }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
SettingsButton(
|
||||
text = stringResource(R.string.backup_create),
|
||||
onClick = {
|
||||
val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||
.format(Date())
|
||||
val filename = "simplenotes_backup_$timestamp.json"
|
||||
createBackupLauncher.launch(filename)
|
||||
},
|
||||
isLoading = isBackupInProgress,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
SettingsOutlinedButton(
|
||||
text = stringResource(R.string.backup_restore_file),
|
||||
onClick = {
|
||||
restoreFileLauncher.launch(arrayOf("application/json"))
|
||||
},
|
||||
isLoading = isBackupInProgress,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
SettingsDivider()
|
||||
|
||||
// Server Backup Section
|
||||
SettingsSectionHeader(text = stringResource(R.string.backup_server_section))
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 🌟 v1.6.0: Disabled when offline mode active
|
||||
SettingsOutlinedButton(
|
||||
text = stringResource(R.string.backup_restore_server),
|
||||
onClick = {
|
||||
restoreSource = RestoreSource.Server
|
||||
showRestoreDialog = true
|
||||
},
|
||||
isLoading = isBackupInProgress,
|
||||
enabled = isServerConfigured,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
// 🌟 v1.6.0: Show hint when offline
|
||||
if (!isServerConfigured) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.settings_sync_offline_mode),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// 🔐 v1.7.0: Encryption password dialog (for backup creation)
|
||||
if (showEncryptionPasswordDialog) {
|
||||
BackupPasswordDialog(
|
||||
title = stringResource(R.string.backup_encryption_title),
|
||||
onDismiss = {
|
||||
showEncryptionPasswordDialog = false
|
||||
pendingBackupUri = null
|
||||
},
|
||||
onConfirm = { password ->
|
||||
showEncryptionPasswordDialog = false
|
||||
pendingBackupUri?.let { uri ->
|
||||
viewModel.createBackup(uri, password)
|
||||
}
|
||||
pendingBackupUri = null
|
||||
},
|
||||
requireConfirmation = true
|
||||
)
|
||||
}
|
||||
|
||||
// 🔐 v1.7.0: Decryption password dialog (for restore)
|
||||
if (showDecryptionPasswordDialog) {
|
||||
BackupPasswordDialog(
|
||||
title = stringResource(R.string.backup_decryption_required),
|
||||
onDismiss = {
|
||||
showDecryptionPasswordDialog = false
|
||||
pendingRestoreUri = null
|
||||
},
|
||||
onConfirm = { password ->
|
||||
showDecryptionPasswordDialog = false
|
||||
pendingRestoreUri?.let { uri ->
|
||||
when (restoreSource) {
|
||||
RestoreSource.LocalFile -> viewModel.restoreFromFile(uri, selectedRestoreMode, password)
|
||||
RestoreSource.Server -> { /* Server restore doesn't support encryption */ }
|
||||
}
|
||||
}
|
||||
pendingRestoreUri = null
|
||||
},
|
||||
requireConfirmation = false
|
||||
)
|
||||
}
|
||||
|
||||
// Restore Mode Dialog
|
||||
if (showRestoreDialog) {
|
||||
RestoreModeDialog(
|
||||
source = restoreSource,
|
||||
selectedMode = selectedRestoreMode,
|
||||
onModeSelected = { selectedRestoreMode = it },
|
||||
onConfirm = {
|
||||
showRestoreDialog = false
|
||||
when (restoreSource) {
|
||||
RestoreSource.LocalFile -> {
|
||||
pendingRestoreUri?.let { uri ->
|
||||
// v1.8.0: Schedule restore with delay for dialog close
|
||||
pendingRestoreAction = {
|
||||
// 🔐 v1.7.0: Check if backup is encrypted
|
||||
viewModel.checkBackupEncryption(
|
||||
uri = uri,
|
||||
onEncrypted = {
|
||||
showDecryptionPasswordDialog = true
|
||||
},
|
||||
onPlaintext = {
|
||||
viewModel.restoreFromFile(uri, selectedRestoreMode, password = null)
|
||||
pendingRestoreUri = null
|
||||
}
|
||||
)
|
||||
}
|
||||
triggerRestore++
|
||||
}
|
||||
}
|
||||
RestoreSource.Server -> {
|
||||
// v1.8.0: Schedule restore with delay for dialog close
|
||||
pendingRestoreAction = {
|
||||
viewModel.restoreFromServer(selectedRestoreMode)
|
||||
}
|
||||
triggerRestore++
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
showRestoreDialog = false
|
||||
pendingRestoreUri = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore source enum
|
||||
*/
|
||||
private enum class RestoreSource {
|
||||
LocalFile,
|
||||
Server
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for selecting restore mode
|
||||
*/
|
||||
@Composable
|
||||
private fun RestoreModeDialog(
|
||||
source: RestoreSource,
|
||||
selectedMode: RestoreMode,
|
||||
onModeSelected: (RestoreMode) -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sourceText = when (source) {
|
||||
RestoreSource.LocalFile -> stringResource(R.string.backup_restore_source_file)
|
||||
RestoreSource.Server -> stringResource(R.string.backup_restore_source_server)
|
||||
}
|
||||
|
||||
val modeOptions = listOf(
|
||||
RadioOption(
|
||||
value = RestoreMode.MERGE,
|
||||
title = stringResource(R.string.backup_mode_merge_title),
|
||||
subtitle = stringResource(R.string.backup_mode_merge_subtitle)
|
||||
),
|
||||
RadioOption(
|
||||
value = RestoreMode.REPLACE,
|
||||
title = stringResource(R.string.backup_mode_replace_title),
|
||||
subtitle = stringResource(R.string.backup_mode_replace_subtitle)
|
||||
),
|
||||
RadioOption(
|
||||
value = RestoreMode.OVERWRITE_DUPLICATES,
|
||||
title = stringResource(R.string.backup_mode_overwrite_title),
|
||||
subtitle = stringResource(R.string.backup_mode_overwrite_subtitle)
|
||||
)
|
||||
)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.backup_restore_dialog_title)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.backup_restore_source, sourceText),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.backup_restore_mode_label),
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
SettingsRadioGroup(
|
||||
options = modeOptions,
|
||||
selectedValue = selectedMode,
|
||||
onValueSelected = onModeSelected
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.backup_restore_info),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(stringResource(R.string.backup_restore_button))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package dev.dettmer.simplenotes.ui.settings.screens
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import dev.dettmer.simplenotes.BuildConfig
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsButton
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsDangerButton
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsInfoCard
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
|
||||
|
||||
/**
|
||||
* Debug and diagnostics settings screen
|
||||
* v1.5.0: Jetpack Compose Settings Redesign
|
||||
*/
|
||||
@Composable
|
||||
fun DebugSettingsScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val fileLoggingEnabled by viewModel.fileLoggingEnabled.collectAsState()
|
||||
|
||||
var showClearLogsDialog by remember { mutableStateOf(false) }
|
||||
|
||||
SettingsScaffold(
|
||||
title = stringResource(R.string.debug_settings_title),
|
||||
onBack = onBack
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// File Logging Toggle
|
||||
SettingsSwitch(
|
||||
title = stringResource(R.string.debug_file_logging_title),
|
||||
subtitle = stringResource(R.string.debug_file_logging_subtitle),
|
||||
checked = fileLoggingEnabled,
|
||||
onCheckedChange = { viewModel.setFileLogging(it) },
|
||||
icon = Icons.AutoMirrored.Filled.Notes
|
||||
)
|
||||
|
||||
// Privacy Info
|
||||
SettingsInfoCard(
|
||||
text = stringResource(R.string.debug_privacy_info)
|
||||
)
|
||||
|
||||
SettingsDivider()
|
||||
|
||||
SettingsSectionHeader(text = stringResource(R.string.debug_log_actions_section))
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Export Logs Button
|
||||
val logsSubject = stringResource(R.string.debug_logs_subject)
|
||||
val logsShareVia = stringResource(R.string.debug_logs_share_via)
|
||||
|
||||
SettingsButton(
|
||||
text = stringResource(R.string.debug_export_logs),
|
||||
onClick = {
|
||||
val logFile = viewModel.getLogFile()
|
||||
if (logFile != null && logFile.exists() && logFile.length() > 0L) {
|
||||
val logUri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${BuildConfig.APPLICATION_ID}.fileprovider",
|
||||
logFile
|
||||
)
|
||||
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_STREAM, logUri)
|
||||
putExtra(Intent.EXTRA_SUBJECT, logsSubject)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
context.startActivity(Intent.createChooser(shareIntent, logsShareVia))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Clear Logs Button
|
||||
SettingsDangerButton(
|
||||
text = stringResource(R.string.debug_delete_logs),
|
||||
onClick = { showClearLogsDialog = true },
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
SettingsDivider()
|
||||
|
||||
// v1.8.0: Test Mode Section
|
||||
SettingsSectionHeader(text = stringResource(R.string.debug_test_section))
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Info about test mode
|
||||
SettingsInfoCard(
|
||||
text = stringResource(R.string.debug_reset_changelog_desc)
|
||||
)
|
||||
|
||||
val changelogResetToast = stringResource(R.string.debug_changelog_reset)
|
||||
|
||||
SettingsButton(
|
||||
text = stringResource(R.string.debug_reset_changelog),
|
||||
onClick = {
|
||||
viewModel.resetChangelogVersion()
|
||||
android.widget.Toast.makeText(context, changelogResetToast, android.widget.Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Clear Logs Confirmation Dialog
|
||||
if (showClearLogsDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showClearLogsDialog = false },
|
||||
title = { Text(stringResource(R.string.debug_delete_logs_title)) },
|
||||
text = {
|
||||
Text(stringResource(R.string.debug_delete_logs_message))
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showClearLogsDialog = false
|
||||
viewModel.clearLogs()
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.delete))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showClearLogsDialog = false }) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package dev.dettmer.simplenotes.ui.settings.screens
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.ui.settings.components.RadioOption
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsInfoCard
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
||||
|
||||
/**
|
||||
* Language selection settings screen
|
||||
* v1.5.0: Internationalization feature
|
||||
*
|
||||
* Uses Android's Per-App Language API (Android 13+) with AppCompat fallback
|
||||
*/
|
||||
@Composable
|
||||
fun LanguageSettingsScreen(
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
// Get current app locale - fresh value each time (no remember, always reads current state)
|
||||
val currentLocale = AppCompatDelegate.getApplicationLocales()
|
||||
val currentLanguageCode = if (currentLocale.isEmpty) {
|
||||
"" // System default
|
||||
} else {
|
||||
currentLocale.get(0)?.language ?: ""
|
||||
}
|
||||
|
||||
var selectedLanguage by remember(currentLanguageCode) { mutableStateOf(currentLanguageCode) }
|
||||
|
||||
// Language options
|
||||
val languageOptions = listOf(
|
||||
RadioOption(
|
||||
value = "",
|
||||
title = stringResource(R.string.language_system_default),
|
||||
subtitle = null
|
||||
),
|
||||
RadioOption(
|
||||
value = "en",
|
||||
title = stringResource(R.string.language_english),
|
||||
subtitle = "English"
|
||||
),
|
||||
RadioOption(
|
||||
value = "de",
|
||||
title = stringResource(R.string.language_german),
|
||||
subtitle = "German"
|
||||
)
|
||||
)
|
||||
|
||||
SettingsScaffold(
|
||||
title = stringResource(R.string.language_settings_title),
|
||||
onBack = onBack
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Info card
|
||||
SettingsInfoCard(
|
||||
text = stringResource(R.string.language_info)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Language selection radio group
|
||||
SettingsRadioGroup(
|
||||
options = languageOptions,
|
||||
selectedValue = selectedLanguage,
|
||||
onValueSelected = { newLanguage ->
|
||||
if (newLanguage != selectedLanguage) {
|
||||
selectedLanguage = newLanguage
|
||||
setAppLanguage(newLanguage)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set app language using AppCompatDelegate
|
||||
* v1.8.0: Smooth language change without activity recreate
|
||||
*
|
||||
* ComposeSettingsActivity handles locale changes via android:configChanges="locale"
|
||||
* in AndroidManifest.xml, preventing full activity recreate and eliminating flicker.
|
||||
* Compose automatically recomposes when the configuration changes.
|
||||
*/
|
||||
private fun setAppLanguage(languageCode: String) {
|
||||
val localeList = if (languageCode.isEmpty()) {
|
||||
LocaleListCompat.getEmptyLocaleList()
|
||||
} else {
|
||||
LocaleListCompat.forLanguageTags(languageCode)
|
||||
}
|
||||
|
||||
// Sets the app locale - triggers onConfigurationChanged() instead of recreate()
|
||||
AppCompatDelegate.setApplicationLocales(localeList)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package dev.dettmer.simplenotes.ui.settings.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsButton
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsInfoCard
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
|
||||
|
||||
/**
|
||||
* Markdown Desktop integration settings screen
|
||||
* v1.5.0: Jetpack Compose Settings Redesign
|
||||
*/
|
||||
@Composable
|
||||
fun MarkdownSettingsScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val markdownAutoSync by viewModel.markdownAutoSync.collectAsState()
|
||||
val exportProgress by viewModel.markdownExportProgress.collectAsState()
|
||||
|
||||
// 🌟 v1.6.0: Check offline mode
|
||||
val offlineMode by viewModel.offlineMode.collectAsState()
|
||||
val isServerConfigured = viewModel.isServerConfigured()
|
||||
|
||||
// v1.5.0 Fix: Progress Dialog for initial export
|
||||
exportProgress?.let { progress ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { /* Not dismissable */ },
|
||||
title = { Text(stringResource(R.string.markdown_dialog_title)) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (progress.isComplete) {
|
||||
stringResource(R.string.markdown_export_complete)
|
||||
} else {
|
||||
stringResource(R.string.markdown_export_progress, progress.current, progress.total)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = {
|
||||
if (progress.total > 0) {
|
||||
progress.current.toFloat() / progress.total.toFloat()
|
||||
} else 0f
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { /* No button - auto dismiss */ }
|
||||
)
|
||||
}
|
||||
|
||||
SettingsScaffold(
|
||||
title = stringResource(R.string.markdown_settings_title),
|
||||
onBack = onBack
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Info Card
|
||||
SettingsInfoCard(
|
||||
text = stringResource(R.string.markdown_info)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Markdown Auto-Sync Toggle
|
||||
// 🌟 v1.6.0: Disabled when offline mode active
|
||||
SettingsSwitch(
|
||||
title = stringResource(R.string.markdown_auto_sync_title),
|
||||
subtitle = if (!isServerConfigured) {
|
||||
stringResource(R.string.settings_sync_offline_mode)
|
||||
} else {
|
||||
stringResource(R.string.markdown_auto_sync_subtitle)
|
||||
},
|
||||
checked = markdownAutoSync,
|
||||
onCheckedChange = { viewModel.setMarkdownAutoSync(it) },
|
||||
icon = Icons.Default.Description,
|
||||
enabled = isServerConfigured
|
||||
)
|
||||
|
||||
// Manual sync button (only visible when auto-sync is off)
|
||||
// 🌟 v1.6.0: Also disabled in offline mode
|
||||
if (!markdownAutoSync) {
|
||||
SettingsDivider()
|
||||
|
||||
SettingsInfoCard(
|
||||
text = stringResource(R.string.markdown_manual_sync_info)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
SettingsButton(
|
||||
text = stringResource(R.string.markdown_manual_sync_button),
|
||||
onClick = { viewModel.performManualMarkdownSync() },
|
||||
enabled = isServerConfigured,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
// 🌟 v1.6.0: Show hint when offline
|
||||
if (!isServerConfigured) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.settings_sync_offline_mode),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package dev.dettmer.simplenotes.ui.settings.screens
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
||||
|
||||
/**
|
||||
* Server configuration settings screen
|
||||
* v1.5.0: Jetpack Compose Settings Redesign
|
||||
* v1.6.0: Offline Mode Toggle
|
||||
* v1.7.0 Hotfix: Save settings on screen exit (not on every keystroke)
|
||||
*/
|
||||
@Suppress("LongMethod", "MagicNumber") // Compose UI + Color hex values
|
||||
@Composable
|
||||
fun ServerSettingsScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val offlineMode by viewModel.offlineMode.collectAsState()
|
||||
val serverHost by viewModel.serverHost.collectAsState() // 🌟 v1.6.0: Only host part
|
||||
val serverUrl by viewModel.serverUrl.collectAsState() // Full URL for display
|
||||
val username by viewModel.username.collectAsState()
|
||||
val password by viewModel.password.collectAsState()
|
||||
val isHttps by viewModel.isHttps.collectAsState()
|
||||
val serverStatus by viewModel.serverStatus.collectAsState()
|
||||
val isSyncing by viewModel.isSyncing.collectAsState()
|
||||
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
// 🔧 v1.7.0 Hotfix: Save server settings when leaving this screen
|
||||
// This prevents false "server changed" detection during text input
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
viewModel.saveServerSettingsManually()
|
||||
}
|
||||
}
|
||||
|
||||
// Check server status on load (only if not in offline mode)
|
||||
LaunchedEffect(offlineMode) {
|
||||
if (!offlineMode) {
|
||||
viewModel.checkServerStatus()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsScaffold(
|
||||
title = stringResource(R.string.server_settings_title),
|
||||
onBack = onBack
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 🌟 v1.6.0: Offline-Modus Toggle (TOP)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { viewModel.setOfflineMode(!offlineMode) },
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (offlineMode) {
|
||||
MaterialTheme.colorScheme.tertiaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
}
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.server_offline_mode_title),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.server_offline_mode_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = offlineMode,
|
||||
onCheckedChange = { viewModel.setOfflineMode(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Server Configuration (grayed out when offline mode)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
val fieldsEnabled = !offlineMode
|
||||
val fieldsAlpha = if (offlineMode) 0.5f else 1f
|
||||
|
||||
Column(modifier = Modifier.alpha(fieldsAlpha)) {
|
||||
// Verbindungstyp
|
||||
Text(
|
||||
text = stringResource(R.string.server_connection_type),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = !isHttps,
|
||||
onClick = { viewModel.updateProtocol(false) },
|
||||
label = { Text(stringResource(R.string.server_connection_http)) },
|
||||
enabled = fieldsEnabled,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
FilterChip(
|
||||
selected = isHttps,
|
||||
onClick = { viewModel.updateProtocol(true) },
|
||||
label = { Text(stringResource(R.string.server_connection_https)) },
|
||||
enabled = fieldsEnabled,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = if (!isHttps) {
|
||||
stringResource(R.string.server_connection_http_hint)
|
||||
} else {
|
||||
stringResource(R.string.server_connection_https_hint)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp)
|
||||
)
|
||||
|
||||
// 🌟 v1.6.0: Server-Adresse with non-editable prefix
|
||||
OutlinedTextField(
|
||||
value = serverHost, // Only host part is editable
|
||||
onValueChange = { viewModel.updateServerHost(it) },
|
||||
label = { Text(stringResource(R.string.server_address)) },
|
||||
supportingText = { Text(stringResource(R.string.server_address_hint)) },
|
||||
prefix = {
|
||||
// Protocol prefix is displayed but not editable
|
||||
Text(
|
||||
text = if (isHttps) "https://" else "http://",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (fieldsEnabled) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
}
|
||||
)
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Language, null) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
enabled = fieldsEnabled,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Benutzername
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { viewModel.updateUsername(it) },
|
||||
label = { Text(stringResource(R.string.username)) },
|
||||
leadingIcon = { Icon(Icons.Default.Person, null) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
enabled = fieldsEnabled
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Passwort
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { viewModel.updatePassword(it) },
|
||||
label = { Text(stringResource(R.string.password)) },
|
||||
leadingIcon = { Icon(Icons.Default.Lock, null) },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) {
|
||||
Icons.Default.VisibilityOff
|
||||
} else {
|
||||
Icons.Default.Visibility
|
||||
},
|
||||
contentDescription = if (passwordVisible) {
|
||||
stringResource(R.string.server_password_hide)
|
||||
} else {
|
||||
stringResource(R.string.server_password_show)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
visualTransformation = if (passwordVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
enabled = fieldsEnabled,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Server-Status
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(R.string.server_status_label), style = MaterialTheme.typography.labelLarge)
|
||||
Text(
|
||||
text = when (serverStatus) {
|
||||
is SettingsViewModel.ServerStatus.OfflineMode -> stringResource(R.string.server_status_offline_mode)
|
||||
is SettingsViewModel.ServerStatus.Reachable -> stringResource(R.string.server_status_reachable)
|
||||
is SettingsViewModel.ServerStatus.Unreachable -> stringResource(R.string.server_status_unreachable)
|
||||
is SettingsViewModel.ServerStatus.Checking -> stringResource(R.string.server_status_checking)
|
||||
is SettingsViewModel.ServerStatus.NotConfigured -> stringResource(R.string.server_status_offline_mode)
|
||||
else -> stringResource(R.string.server_status_unknown)
|
||||
},
|
||||
color = when (serverStatus) {
|
||||
is SettingsViewModel.ServerStatus.OfflineMode -> MaterialTheme.colorScheme.tertiary
|
||||
is SettingsViewModel.ServerStatus.Reachable -> Color(0xFF4CAF50)
|
||||
is SettingsViewModel.ServerStatus.Unreachable -> Color(0xFFF44336)
|
||||
is SettingsViewModel.ServerStatus.NotConfigured -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Action Buttons (disabled in offline mode)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.alpha(fieldsAlpha),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.testConnection() },
|
||||
enabled = fieldsEnabled,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(stringResource(R.string.test_connection))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.syncNow() },
|
||||
enabled = fieldsEnabled && !isSyncing,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
if (isSyncing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(stringResource(R.string.sync_now))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package dev.dettmer.simplenotes.ui.settings.screens
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Backup
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.GridView
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.dettmer.simplenotes.BuildConfig
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.ui.settings.SettingsRoute
|
||||
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsCard
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
||||
|
||||
/**
|
||||
* Main Settings overview screen with clickable group cards
|
||||
* v1.5.0: Jetpack Compose Settings Redesign
|
||||
*/
|
||||
@Suppress("MagicNumber") // Color hex values
|
||||
@Composable
|
||||
fun SettingsMainScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onNavigate: (SettingsRoute) -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val serverUrl by viewModel.serverUrl.collectAsState()
|
||||
val serverStatus by viewModel.serverStatus.collectAsState()
|
||||
val autoSyncEnabled by viewModel.autoSyncEnabled.collectAsState()
|
||||
val syncInterval by viewModel.syncInterval.collectAsState()
|
||||
val markdownAutoSync by viewModel.markdownAutoSync.collectAsState()
|
||||
val fileLoggingEnabled by viewModel.fileLoggingEnabled.collectAsState()
|
||||
|
||||
// 🌟 v1.6.0: Collect offline mode and trigger states
|
||||
val offlineMode by viewModel.offlineMode.collectAsState()
|
||||
val triggerOnSave by viewModel.triggerOnSave.collectAsState()
|
||||
val triggerOnResume by viewModel.triggerOnResume.collectAsState()
|
||||
val triggerWifiConnect by viewModel.triggerWifiConnect.collectAsState()
|
||||
val triggerPeriodic by viewModel.triggerPeriodic.collectAsState()
|
||||
val triggerBoot by viewModel.triggerBoot.collectAsState()
|
||||
|
||||
// Check server status on first load
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.checkServerStatus()
|
||||
}
|
||||
|
||||
// Get current language for display (no remember - always fresh value after activity recreate)
|
||||
val locales = AppCompatDelegate.getApplicationLocales()
|
||||
val currentLanguageName = if (locales.isEmpty) {
|
||||
null // System default
|
||||
} else {
|
||||
locales[0]?.displayLanguage?.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
val systemDefaultText = stringResource(R.string.language_system_default)
|
||||
val languageSubtitle = currentLanguageName ?: systemDefaultText
|
||||
|
||||
SettingsScaffold(
|
||||
title = stringResource(R.string.settings_title),
|
||||
onBack = onBack
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
// Language Settings
|
||||
item {
|
||||
SettingsCard(
|
||||
icon = Icons.Default.Language,
|
||||
title = stringResource(R.string.settings_language),
|
||||
subtitle = languageSubtitle,
|
||||
onClick = { onNavigate(SettingsRoute.Language) }
|
||||
)
|
||||
}
|
||||
|
||||
// 🎨 v1.7.0: Display Settings
|
||||
item {
|
||||
val displayMode by viewModel.displayMode.collectAsState()
|
||||
val displaySubtitle = when (displayMode) {
|
||||
"grid" -> stringResource(R.string.display_mode_grid)
|
||||
else -> stringResource(R.string.display_mode_list)
|
||||
}
|
||||
|
||||
SettingsCard(
|
||||
icon = Icons.Default.GridView,
|
||||
title = stringResource(R.string.display_settings_title),
|
||||
subtitle = displaySubtitle,
|
||||
onClick = { onNavigate(SettingsRoute.Display) }
|
||||
)
|
||||
}
|
||||
|
||||
// Server-Einstellungen
|
||||
item {
|
||||
// 🌟 v1.6.0: Check if server is configured (host is not empty)
|
||||
val isConfigured = serverUrl.isNotEmpty()
|
||||
|
||||
SettingsCard(
|
||||
icon = Icons.Default.Cloud,
|
||||
title = stringResource(R.string.settings_server),
|
||||
subtitle = if (!offlineMode && isConfigured) serverUrl else null,
|
||||
statusText = when {
|
||||
offlineMode ->
|
||||
stringResource(R.string.settings_server_status_offline_mode)
|
||||
serverStatus is SettingsViewModel.ServerStatus.OfflineMode ->
|
||||
stringResource(R.string.settings_server_status_offline_mode)
|
||||
serverStatus is SettingsViewModel.ServerStatus.Reachable ->
|
||||
stringResource(R.string.settings_server_status_reachable)
|
||||
serverStatus is SettingsViewModel.ServerStatus.Unreachable ->
|
||||
stringResource(R.string.settings_server_status_unreachable)
|
||||
serverStatus is SettingsViewModel.ServerStatus.Checking ->
|
||||
stringResource(R.string.settings_server_status_checking)
|
||||
serverStatus is SettingsViewModel.ServerStatus.NotConfigured ->
|
||||
stringResource(R.string.settings_server_status_offline_mode)
|
||||
else -> null
|
||||
},
|
||||
statusColor = when {
|
||||
offlineMode -> MaterialTheme.colorScheme.tertiary
|
||||
serverStatus is SettingsViewModel.ServerStatus.OfflineMode ->
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
serverStatus is SettingsViewModel.ServerStatus.Reachable ->
|
||||
Color(0xFF4CAF50)
|
||||
serverStatus is SettingsViewModel.ServerStatus.Unreachable ->
|
||||
Color(0xFFF44336)
|
||||
serverStatus is SettingsViewModel.ServerStatus.NotConfigured ->
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
else -> Color.Gray
|
||||
},
|
||||
onClick = { onNavigate(SettingsRoute.Server) }
|
||||
)
|
||||
}
|
||||
|
||||
// Sync-Einstellungen
|
||||
item {
|
||||
// 🌟 v1.6.0: Build dynamic subtitle based on active triggers
|
||||
val isServerConfigured = viewModel.isServerConfigured()
|
||||
val activeTriggersCount = listOf(
|
||||
triggerOnSave,
|
||||
triggerOnResume,
|
||||
triggerWifiConnect,
|
||||
triggerPeriodic,
|
||||
triggerBoot
|
||||
).count { it }
|
||||
|
||||
// 🌟 v1.6.0 Fix: Use statusText for offline mode (consistent with Server card)
|
||||
val syncSubtitle = if (isServerConfigured) {
|
||||
if (activeTriggersCount == 0) {
|
||||
stringResource(R.string.settings_sync_manual_only)
|
||||
} else {
|
||||
stringResource(R.string.settings_sync_triggers_active, activeTriggersCount)
|
||||
}
|
||||
} else null
|
||||
|
||||
SettingsCard(
|
||||
icon = Icons.Default.Sync,
|
||||
title = stringResource(R.string.settings_sync),
|
||||
subtitle = syncSubtitle,
|
||||
statusText = if (!isServerConfigured) stringResource(R.string.settings_sync_offline_mode) else null,
|
||||
statusColor = if (!isServerConfigured) MaterialTheme.colorScheme.tertiary else Color.Gray,
|
||||
onClick = { onNavigate(SettingsRoute.Sync) }
|
||||
)
|
||||
}
|
||||
|
||||
// Markdown-Integration
|
||||
item {
|
||||
// 🌟 v1.6.0 Fix: Use statusText for offline mode (consistent with Server card)
|
||||
val isServerConfiguredForMarkdown = viewModel.isServerConfigured()
|
||||
|
||||
SettingsCard(
|
||||
icon = Icons.Default.Description,
|
||||
title = stringResource(R.string.settings_markdown),
|
||||
subtitle = if (isServerConfiguredForMarkdown) {
|
||||
if (markdownAutoSync) {
|
||||
stringResource(R.string.settings_markdown_auto_on)
|
||||
} else {
|
||||
stringResource(R.string.settings_markdown_auto_off)
|
||||
}
|
||||
} else null,
|
||||
statusText = if (!isServerConfiguredForMarkdown) stringResource(R.string.settings_sync_offline_mode) else null,
|
||||
statusColor = if (!isServerConfiguredForMarkdown) MaterialTheme.colorScheme.tertiary else Color.Gray,
|
||||
onClick = { onNavigate(SettingsRoute.Markdown) }
|
||||
)
|
||||
}
|
||||
|
||||
// Backup & Wiederherstellung
|
||||
item {
|
||||
SettingsCard(
|
||||
icon = Icons.Default.Backup,
|
||||
title = stringResource(R.string.settings_backup),
|
||||
subtitle = stringResource(R.string.settings_backup_subtitle),
|
||||
onClick = { onNavigate(SettingsRoute.Backup) }
|
||||
)
|
||||
}
|
||||
|
||||
// Über diese App
|
||||
item {
|
||||
SettingsCard(
|
||||
icon = Icons.Default.Info,
|
||||
title = stringResource(R.string.settings_about),
|
||||
subtitle = stringResource(R.string.about_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE),
|
||||
onClick = { onNavigate(SettingsRoute.About) }
|
||||
)
|
||||
}
|
||||
|
||||
// Debug & Diagnose
|
||||
item {
|
||||
SettingsCard(
|
||||
icon = Icons.Default.BugReport,
|
||||
title = stringResource(R.string.settings_debug),
|
||||
subtitle = if (fileLoggingEnabled) {
|
||||
stringResource(R.string.settings_debug_logging_on)
|
||||
} else {
|
||||
stringResource(R.string.settings_debug_logging_off)
|
||||
},
|
||||
onClick = { onNavigate(SettingsRoute.Debug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package dev.dettmer.simplenotes.ui.settings.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PhonelinkRing
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.SettingsInputAntenna
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
|
||||
import dev.dettmer.simplenotes.ui.settings.components.RadioOption
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsInfoCard
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
|
||||
|
||||
/**
|
||||
* Sync settings screen — Restructured for v1.8.0
|
||||
*
|
||||
* Two clear sections:
|
||||
* 1. Sync Triggers (all 5 triggers grouped logically)
|
||||
* 2. Network & Performance (WiFi-only + Parallel Downloads)
|
||||
*/
|
||||
@Composable
|
||||
fun SyncSettingsScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToServerSettings: () -> Unit
|
||||
) {
|
||||
// Collect all trigger states
|
||||
val triggerOnSave by viewModel.triggerOnSave.collectAsState()
|
||||
val triggerOnResume by viewModel.triggerOnResume.collectAsState()
|
||||
val triggerWifiConnect by viewModel.triggerWifiConnect.collectAsState()
|
||||
val triggerPeriodic by viewModel.triggerPeriodic.collectAsState()
|
||||
val triggerBoot by viewModel.triggerBoot.collectAsState()
|
||||
val syncInterval by viewModel.syncInterval.collectAsState()
|
||||
|
||||
val maxParallelDownloads by viewModel.maxParallelDownloads.collectAsState()
|
||||
val wifiOnlySync by viewModel.wifiOnlySync.collectAsState()
|
||||
|
||||
val isServerConfigured = viewModel.isServerConfigured()
|
||||
|
||||
SettingsScaffold(
|
||||
title = stringResource(R.string.sync_settings_title),
|
||||
onBack = onBack
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// ── Offline Mode Warning ──
|
||||
if (!isServerConfigured) {
|
||||
SettingsInfoCard(
|
||||
text = stringResource(R.string.sync_offline_mode_message),
|
||||
isWarning = true
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onNavigateToServerSettings,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.sync_offline_mode_button))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SECTION 1: SYNC TRIGGERS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
SettingsSectionHeader(text = stringResource(R.string.sync_section_triggers))
|
||||
|
||||
// ── Sofort-Sync ──
|
||||
SettingsSectionHeader(text = stringResource(R.string.sync_section_instant))
|
||||
|
||||
SettingsSwitch(
|
||||
title = stringResource(R.string.sync_trigger_on_save_title),
|
||||
subtitle = stringResource(R.string.sync_trigger_on_save_subtitle),
|
||||
checked = triggerOnSave,
|
||||
onCheckedChange = { viewModel.setTriggerOnSave(it) },
|
||||
icon = Icons.Default.Save,
|
||||
enabled = isServerConfigured
|
||||
)
|
||||
|
||||
SettingsSwitch(
|
||||
title = stringResource(R.string.sync_trigger_on_resume_title),
|
||||
subtitle = stringResource(R.string.sync_trigger_on_resume_subtitle),
|
||||
checked = triggerOnResume,
|
||||
onCheckedChange = { viewModel.setTriggerOnResume(it) },
|
||||
icon = Icons.Default.PhonelinkRing,
|
||||
enabled = isServerConfigured
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// ── Hintergrund-Sync ──
|
||||
SettingsSectionHeader(text = stringResource(R.string.sync_section_background))
|
||||
|
||||
SettingsSwitch(
|
||||
title = stringResource(R.string.sync_trigger_wifi_connect_title),
|
||||
subtitle = stringResource(R.string.sync_trigger_wifi_connect_subtitle),
|
||||
checked = triggerWifiConnect,
|
||||
onCheckedChange = { viewModel.setTriggerWifiConnect(it) },
|
||||
icon = Icons.Default.Wifi,
|
||||
enabled = isServerConfigured
|
||||
)
|
||||
|
||||
SettingsSwitch(
|
||||
title = stringResource(R.string.sync_trigger_periodic_title),
|
||||
subtitle = stringResource(R.string.sync_trigger_periodic_subtitle),
|
||||
checked = triggerPeriodic,
|
||||
onCheckedChange = { viewModel.setTriggerPeriodic(it) },
|
||||
icon = Icons.Default.Schedule,
|
||||
enabled = isServerConfigured
|
||||
)
|
||||
|
||||
// Interval-Auswahl (nur sichtbar wenn Periodic aktiv)
|
||||
if (triggerPeriodic && isServerConfigured) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
val intervalOptions = listOf(
|
||||
RadioOption(
|
||||
value = 15L,
|
||||
title = stringResource(R.string.sync_interval_15min_title),
|
||||
subtitle = null
|
||||
),
|
||||
RadioOption(
|
||||
value = 30L,
|
||||
title = stringResource(R.string.sync_interval_30min_title),
|
||||
subtitle = null
|
||||
),
|
||||
RadioOption(
|
||||
value = 60L,
|
||||
title = stringResource(R.string.sync_interval_60min_title),
|
||||
subtitle = null
|
||||
)
|
||||
)
|
||||
|
||||
SettingsRadioGroup(
|
||||
options = intervalOptions,
|
||||
selectedValue = syncInterval,
|
||||
onValueSelected = { viewModel.setSyncInterval(it) }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
SettingsSwitch(
|
||||
title = stringResource(R.string.sync_trigger_boot_title),
|
||||
subtitle = stringResource(R.string.sync_trigger_boot_subtitle),
|
||||
checked = triggerBoot,
|
||||
onCheckedChange = { viewModel.setTriggerBoot(it) },
|
||||
icon = Icons.Default.SettingsInputAntenna,
|
||||
enabled = isServerConfigured
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// ── Info Card ──
|
||||
val manualHintText = if (isServerConfigured) {
|
||||
stringResource(R.string.sync_manual_hint)
|
||||
} else {
|
||||
stringResource(R.string.sync_manual_hint_disabled)
|
||||
}
|
||||
|
||||
SettingsInfoCard(
|
||||
text = manualHintText
|
||||
)
|
||||
|
||||
SettingsDivider()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SECTION 2: NETZWERK & PERFORMANCE
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
SettingsSectionHeader(text = stringResource(R.string.sync_section_network_performance))
|
||||
|
||||
// WiFi-Only Toggle
|
||||
SettingsSwitch(
|
||||
title = stringResource(R.string.sync_wifi_only_title),
|
||||
subtitle = stringResource(R.string.sync_wifi_only_subtitle),
|
||||
checked = wifiOnlySync,
|
||||
onCheckedChange = { viewModel.setWifiOnlySync(it) },
|
||||
icon = Icons.Default.Wifi,
|
||||
enabled = isServerConfigured
|
||||
)
|
||||
|
||||
if (wifiOnlySync && isServerConfigured) {
|
||||
SettingsInfoCard(
|
||||
text = stringResource(R.string.sync_wifi_only_hint)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Parallel Downloads
|
||||
val parallelOptions = listOf(
|
||||
RadioOption(
|
||||
value = 1,
|
||||
title = "1 ${stringResource(R.string.sync_parallel_downloads_unit)}",
|
||||
subtitle = stringResource(R.string.sync_parallel_downloads_desc_1)
|
||||
),
|
||||
RadioOption(
|
||||
value = 3,
|
||||
title = "3 ${stringResource(R.string.sync_parallel_downloads_unit)}",
|
||||
subtitle = stringResource(R.string.sync_parallel_downloads_desc_3)
|
||||
),
|
||||
RadioOption(
|
||||
value = 5,
|
||||
title = "5 ${stringResource(R.string.sync_parallel_downloads_unit)}",
|
||||
subtitle = stringResource(R.string.sync_parallel_downloads_desc_5)
|
||||
),
|
||||
RadioOption(
|
||||
value = 7,
|
||||
title = "7 ${stringResource(R.string.sync_parallel_downloads_unit)}",
|
||||
subtitle = stringResource(R.string.sync_parallel_downloads_desc_7)
|
||||
),
|
||||
RadioOption(
|
||||
value = 10,
|
||||
title = "10 ${stringResource(R.string.sync_parallel_downloads_unit)}",
|
||||
subtitle = stringResource(R.string.sync_parallel_downloads_desc_10)
|
||||
)
|
||||
)
|
||||
|
||||
SettingsRadioGroup(
|
||||
title = stringResource(R.string.sync_parallel_downloads_title),
|
||||
options = parallelOptions,
|
||||
selectedValue = maxParallelDownloads,
|
||||
onValueSelected = { viewModel.setMaxParallelDownloads(it) }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package dev.dettmer.simplenotes.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/**
|
||||
* Shared Material 3 Theme with Dynamic Colors (Material You) support
|
||||
* v1.5.0: Unified theme for MainActivity and Settings
|
||||
*
|
||||
* Used by:
|
||||
* - ComposeMainActivity (Notes list)
|
||||
* - ComposeSettingsActivity (Settings screens)
|
||||
*/
|
||||
@Composable
|
||||
fun SimpleNotesTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val colorScheme = when {
|
||||
// Dynamic colors are available on Android 12+
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
if (darkTheme) {
|
||||
dynamicDarkColorScheme(context)
|
||||
} else {
|
||||
dynamicLightColorScheme(context)
|
||||
}
|
||||
}
|
||||
// Fallback to static Material 3 colors
|
||||
darkTheme -> darkColorScheme()
|
||||
else -> lightColorScheme()
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user