Compare commits
41 Commits
v1.3.2
...
debug/v1.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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
|
description: Melde einen Fehler in der App / Report a bug in the app
|
||||||
title: "[BUG] "
|
title: "[BUG] "
|
||||||
labels: ["bug"]
|
labels: ["bug"]
|
||||||
@@ -13,7 +13,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
label: 🐛 Beschreibung / Description
|
label: "🐛 Beschreibung / Description"
|
||||||
description: Beschreibe den Fehler kurz und präzise / Describe the bug briefly and precisely
|
description: Beschreibe den Fehler kurz und präzise / Describe the bug briefly and precisely
|
||||||
placeholder: "z.B. Auto-Sync funktioniert nicht mehr nach App-Update / e.g. Auto-sync stopped working after app update"
|
placeholder: "z.B. Auto-Sync funktioniert nicht mehr nach App-Update / e.g. Auto-sync stopped working after app update"
|
||||||
validations:
|
validations:
|
||||||
@@ -22,9 +22,11 @@ body:
|
|||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: android-version
|
id: android-version
|
||||||
attributes:
|
attributes:
|
||||||
label: 📱 Android Version
|
label: "📱 Android Version"
|
||||||
description: Welche Android Version verwendest du? / Which Android version are you using?
|
description: Welche Android Version verwendest du? / Which Android version are you using?
|
||||||
options:
|
options:
|
||||||
|
- Android 16
|
||||||
|
- Android 15
|
||||||
- Android 14
|
- Android 14
|
||||||
- Android 13
|
- Android 13
|
||||||
- Android 12
|
- Android 12
|
||||||
@@ -40,7 +42,7 @@ body:
|
|||||||
- type: input
|
- type: input
|
||||||
id: app-version
|
id: app-version
|
||||||
attributes:
|
attributes:
|
||||||
label: 📲 App Version
|
label: "📲 App Version"
|
||||||
description: Welche Version der App verwendest du? (Einstellungen → Über) / Which app version? (Settings → About)
|
description: Welche Version der App verwendest du? (Einstellungen → Über) / Which app version? (Settings → About)
|
||||||
placeholder: "z.B. / e.g. v1.1.0"
|
placeholder: "z.B. / e.g. v1.1.0"
|
||||||
validations:
|
validations:
|
||||||
@@ -49,7 +51,7 @@ body:
|
|||||||
- type: input
|
- type: input
|
||||||
id: device
|
id: device
|
||||||
attributes:
|
attributes:
|
||||||
label: 📱 Gerät / Device
|
label: "📱 Gerät / Device"
|
||||||
description: Welches Gerät verwendest du? / Which device are you using?
|
description: Welches Gerät verwendest du? / Which device are you using?
|
||||||
placeholder: "z.B. Samsung Galaxy S21, Google Pixel 7, etc."
|
placeholder: "z.B. Samsung Galaxy S21, Google Pixel 7, etc."
|
||||||
validations:
|
validations:
|
||||||
@@ -58,7 +60,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
id: steps
|
id: steps
|
||||||
attributes:
|
attributes:
|
||||||
label: 🔄 Schritte zum Reproduzieren / Steps to Reproduce
|
label: "📋 Schritte zum Reproduzieren / Steps to Reproduce"
|
||||||
description: Wie kann der Fehler reproduziert werden? / How can the bug be reproduced?
|
description: Wie kann der Fehler reproduziert werden? / How can the bug be reproduced?
|
||||||
placeholder: |
|
placeholder: |
|
||||||
1. Öffne die App / Open the app
|
1. Öffne die App / Open the app
|
||||||
@@ -71,7 +73,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
id: expected
|
id: expected
|
||||||
attributes:
|
attributes:
|
||||||
label: ✅ Erwartetes Verhalten / Expected Behavior
|
label: "✅ Erwartetes Verhalten / Expected Behavior"
|
||||||
description: Was sollte passieren? / What should happen?
|
description: Was sollte passieren? / What should happen?
|
||||||
placeholder: "z.B. Notizen sollten alle 30 Min synchronisiert werden / e.g. Notes should sync every 30 min"
|
placeholder: "z.B. Notizen sollten alle 30 Min synchronisiert werden / e.g. Notes should sync every 30 min"
|
||||||
validations:
|
validations:
|
||||||
@@ -80,7 +82,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
id: actual
|
id: actual
|
||||||
attributes:
|
attributes:
|
||||||
label: ❌ Tatsächliches Verhalten / Actual Behavior
|
label: "❌ Tatsächliches Verhalten / Actual Behavior"
|
||||||
description: Was passiert stattdessen? / What happens instead?
|
description: Was passiert stattdessen? / What happens instead?
|
||||||
placeholder: "z.B. Sync funktioniert nicht, keine Notification / e.g. Sync doesn't work, no notification"
|
placeholder: "z.B. Sync funktioniert nicht, keine Notification / e.g. Sync doesn't work, no notification"
|
||||||
validations:
|
validations:
|
||||||
@@ -89,7 +91,7 @@ body:
|
|||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: sync-enabled
|
id: sync-enabled
|
||||||
attributes:
|
attributes:
|
||||||
label: <EFBFBD> Auto-Sync aktiviert? / Auto-Sync enabled?
|
label: Auto-Sync aktiviert? / Auto-Sync enabled?
|
||||||
options:
|
options:
|
||||||
- "Ja / Yes"
|
- "Ja / Yes"
|
||||||
- "Nein / No"
|
- "Nein / No"
|
||||||
|
|||||||
6
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,11 +1,11 @@
|
|||||||
blank_issues_enabled: true
|
blank_issues_enabled: true
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: 📖 Dokumentation / Documentation
|
- name: "📖 Dokumentation / Documentation"
|
||||||
url: https://github.com/inventory69/simple-notes-sync/blob/main/README.md
|
url: https://github.com/inventory69/simple-notes-sync/blob/main/README.md
|
||||||
about: Schau zuerst in die Dokumentation / Check documentation first
|
about: Schau zuerst in die Dokumentation / Check documentation first
|
||||||
- name: 🚀 Quick Start Guide
|
- name: "🚀 Quick Start Guide"
|
||||||
url: https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md
|
url: https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md
|
||||||
about: Schritt-für-Schritt Anleitung / Step-by-step guide
|
about: Schritt-für-Schritt Anleitung / Step-by-step guide
|
||||||
- name: 🐛 Troubleshooting
|
- name: "🐛 Troubleshooting"
|
||||||
url: https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md#troubleshooting
|
url: https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md#troubleshooting
|
||||||
about: Häufige Probleme und Lösungen / Common issues and solutions
|
about: Häufige Probleme und Lösungen / Common issues and solutions
|
||||||
|
|||||||
18
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
18
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: 💡 Feature Request / Feature-Wunsch
|
name: "💡 Feature Request / Feature-Wunsch"
|
||||||
description: Schlage eine neue Funktion vor / Suggest a new feature
|
description: Schlage eine neue Funktion vor / Suggest a new feature
|
||||||
title: "[FEATURE] "
|
title: "[FEATURE] "
|
||||||
labels: ["enhancement"]
|
labels: ["enhancement"]
|
||||||
@@ -11,7 +11,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
id: feature-description
|
id: feature-description
|
||||||
attributes:
|
attributes:
|
||||||
label: 💡 Feature-Beschreibung / Feature Description
|
label: "💡 Feature-Beschreibung / Feature Description"
|
||||||
description: Was möchtest du hinzugefügt haben? / What would you like to be added?
|
description: Was möchtest du hinzugefügt haben? / What would you like to be added?
|
||||||
placeholder: "z.B. Notizen sollten Markdown-Formatierung unterstützen / e.g. Notes should support markdown formatting"
|
placeholder: "z.B. Notizen sollten Markdown-Formatierung unterstützen / e.g. Notes should support markdown formatting"
|
||||||
validations:
|
validations:
|
||||||
@@ -20,7 +20,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
id: problem
|
id: problem
|
||||||
attributes:
|
attributes:
|
||||||
label: 🎯 Problem / Motivation
|
label: "🎯 Problem / Motivation"
|
||||||
description: Welches Problem würde dieses Feature lösen? / What problem would this feature solve?
|
description: Welches Problem würde dieses Feature lösen? / What problem would this feature solve?
|
||||||
placeholder: "z.B. Ich möchte Code-Snippets und Listen in meinen Notizen formatieren / e.g. I want to format code snippets and lists in my notes"
|
placeholder: "z.B. Ich möchte Code-Snippets und Listen in meinen Notizen formatieren / e.g. I want to format code snippets and lists in my notes"
|
||||||
validations:
|
validations:
|
||||||
@@ -29,7 +29,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
id: solution
|
id: solution
|
||||||
attributes:
|
attributes:
|
||||||
label: 📝 Vorgeschlagene Lösung / Proposed Solution
|
label: "📝 Vorgeschlagene Lösung / Proposed Solution"
|
||||||
description: Wie könnte das Feature funktionieren? / How could the feature work?
|
description: Wie könnte das Feature funktionieren? / How could the feature work?
|
||||||
placeholder: "z.B. Markdown-Editor mit Live-Preview / e.g. Markdown editor with live preview"
|
placeholder: "z.B. Markdown-Editor mit Live-Preview / e.g. Markdown editor with live preview"
|
||||||
validations:
|
validations:
|
||||||
@@ -38,7 +38,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
id: alternatives
|
id: alternatives
|
||||||
attributes:
|
attributes:
|
||||||
label: 🔄 Alternativen / Alternatives
|
label: "🔄 Alternativen / Alternatives"
|
||||||
description: Hast du andere Lösungsansätze in Betracht gezogen? / Have you considered other solutions?
|
description: Hast du andere Lösungsansätze in Betracht gezogen? / Have you considered other solutions?
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
@@ -46,7 +46,7 @@ body:
|
|||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: platform
|
id: platform
|
||||||
attributes:
|
attributes:
|
||||||
label: 📱 Plattform / Platform
|
label: "📱 Plattform / Platform"
|
||||||
description: Für welche Komponente ist das Feature? / For which component is the feature?
|
description: Für welche Komponente ist das Feature? / For which component is the feature?
|
||||||
options:
|
options:
|
||||||
- Android App
|
- Android App
|
||||||
@@ -59,7 +59,7 @@ body:
|
|||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: priority
|
id: priority
|
||||||
attributes:
|
attributes:
|
||||||
label: 🌟 Priorität (aus deiner Sicht) / Priority (from your perspective)
|
label: "⭐ Priorität (aus deiner Sicht) / Priority (from your perspective)"
|
||||||
options:
|
options:
|
||||||
- Nice to have
|
- Nice to have
|
||||||
- Wichtig / Important
|
- Wichtig / Important
|
||||||
@@ -70,7 +70,7 @@ body:
|
|||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: willing-to-contribute
|
id: willing-to-contribute
|
||||||
attributes:
|
attributes:
|
||||||
label: 🤝 Beitragen / Contribute
|
label: "🤝 Beitragen / Contribute"
|
||||||
options:
|
options:
|
||||||
- label: Ich würde gerne bei der Implementierung helfen / I would like to help with implementation
|
- label: Ich würde gerne bei der Implementierung helfen / I would like to help with implementation
|
||||||
required: false
|
required: false
|
||||||
@@ -78,7 +78,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
id: additional
|
id: additional
|
||||||
attributes:
|
attributes:
|
||||||
label: 🔧 Zusätzliche Informationen / Additional Context
|
label: "🔧 Zusätzliche Informationen / Additional Context"
|
||||||
description: Screenshots, Mockups, Links, ähnliche Apps, etc.
|
description: Screenshots, Mockups, Links, ähnliche Apps, etc.
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
|
|||||||
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
|
description: Stelle eine Frage zur Nutzung / Ask a question about usage
|
||||||
title: "[QUESTION] "
|
title: "[QUESTION] "
|
||||||
labels: ["question"]
|
labels: ["question"]
|
||||||
@@ -11,7 +11,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
id: question
|
id: question
|
||||||
attributes:
|
attributes:
|
||||||
label: ❓ Frage / Question
|
label: "❓ Frage / Question"
|
||||||
description: Was möchtest du wissen? / What would you like to know?
|
description: Was möchtest du wissen? / What would you like to know?
|
||||||
placeholder: "z.B. Wie kann ich die Sync-URL für einen externen Server konfigurieren? / e.g. How can I configure the sync URL for an external server?"
|
placeholder: "z.B. Wie kann ich die Sync-URL für einen externen Server konfigurieren? / e.g. How can I configure the sync URL for an external server?"
|
||||||
validations:
|
validations:
|
||||||
@@ -20,7 +20,7 @@ body:
|
|||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: documentation-checked
|
id: documentation-checked
|
||||||
attributes:
|
attributes:
|
||||||
label: 📚 Dokumentation gelesen / Documentation checked
|
label: "📚 Dokumentation gelesen / Documentation checked"
|
||||||
description: Hast du bereits in der Dokumentation nachgeschaut? / Have you already checked the documentation?
|
description: Hast du bereits in der Dokumentation nachgeschaut? / Have you already checked the documentation?
|
||||||
options:
|
options:
|
||||||
- label: Ich habe die [README](https://github.com/inventory69/simple-notes-sync/blob/main/README.md) gelesen / I have read the README
|
- label: Ich habe die [README](https://github.com/inventory69/simple-notes-sync/blob/main/README.md) gelesen / I have read the README
|
||||||
@@ -33,7 +33,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
id: tried
|
id: tried
|
||||||
attributes:
|
attributes:
|
||||||
label: 🔍 Was hast du bereits versucht? / What have you already tried?
|
label: "🔍 Was hast du bereits versucht? / What have you already tried?"
|
||||||
description: Hilf uns, dir besser zu helfen / Help us help you better
|
description: Hilf uns, dir besser zu helfen / Help us help you better
|
||||||
placeholder: "z.B. Ich habe versucht die Server-URL anzupassen, aber... / e.g. I tried adjusting the server URL, but..."
|
placeholder: "z.B. Ich habe versucht die Server-URL anzupassen, aber... / e.g. I tried adjusting the server URL, but..."
|
||||||
validations:
|
validations:
|
||||||
@@ -42,7 +42,7 @@ body:
|
|||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: topic
|
id: topic
|
||||||
attributes:
|
attributes:
|
||||||
label: <EFBFBD> Thema / Topic
|
label: "📌 Thema / Topic"
|
||||||
description: Um was geht es? / What is this about?
|
description: Um was geht es? / What is this about?
|
||||||
options:
|
options:
|
||||||
- Server Setup / Server-Einrichtung
|
- Server Setup / Server-Einrichtung
|
||||||
@@ -57,7 +57,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
id: context
|
id: context
|
||||||
attributes:
|
attributes:
|
||||||
label: <EFBFBD> Kontext / Context
|
label: "💬 Kontext / Context"
|
||||||
description: Zusätzliche Informationen die hilfreich sein könnten / Additional information that might be helpful
|
description: Zusätzliche Informationen die hilfreich sein könnten / Additional information that might be helpful
|
||||||
placeholder: |
|
placeholder: |
|
||||||
- Android Version: Android 13
|
- Android Version: Android 13
|
||||||
@@ -70,7 +70,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
id: additional
|
id: additional
|
||||||
attributes:
|
attributes:
|
||||||
label: 🔧 Screenshots / Config
|
label: "🔧 Screenshots / Config"
|
||||||
description: Falls hilfreich (KEINE Passwörter!) / If helpful (NO passwords!)
|
description: Falls hilfreich (KEINE Passwörter!) / If helpful (NO passwords!)
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
|
|||||||
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
|
||||||
89
.github/workflows/build-production-apk.yml
vendored
89
.github/workflows/build-production-apk.yml
vendored
@@ -2,11 +2,11 @@ name: Build Android Production APK
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ] # Nur bei Push/Merge auf main triggern
|
branches: [ main ] # Only trigger on push/merge to main
|
||||||
workflow_dispatch: # Ermöglicht manuellen Trigger
|
workflow_dispatch: # Enables manual trigger
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write # Fuer Release-Erstellung erforderlich
|
contents: write # Required for release creation
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -14,50 +14,50 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Code auschecken
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Java einrichten
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
- name: Semantic Versionsnummer aus build.gradle.kts extrahieren
|
- name: Extract semantic version from build.gradle.kts
|
||||||
run: |
|
run: |
|
||||||
# Version aus build.gradle.kts fuer F-Droid Kompatibilität
|
# Version from build.gradle.kts for F-Droid compatibility
|
||||||
VERSION_NAME=$(grep "versionName = " android/app/build.gradle.kts | sed 's/.*versionName = "\(.*\)".*/\1/')
|
VERSION_NAME=$(grep "versionName = " android/app/build.gradle.kts | sed 's/.*versionName = "\(.*\)".*/\1/')
|
||||||
VERSION_CODE=$(grep "versionCode = " android/app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
|
VERSION_CODE=$(grep "versionCode = " android/app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
|
||||||
|
|
||||||
# Semantische Versionierung (nicht datums-basiert)
|
# Semantic versioning (not date-based)
|
||||||
BUILD_NUMBER="$VERSION_CODE"
|
BUILD_NUMBER="$VERSION_CODE"
|
||||||
|
|
||||||
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
|
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
|
||||||
echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV
|
echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV
|
||||||
echo "VERSION_TAG=v$VERSION_NAME" >> $GITHUB_ENV
|
echo "VERSION_TAG=v$VERSION_NAME" >> $GITHUB_ENV
|
||||||
|
|
||||||
echo "🚀 Baue Version: $VERSION_NAME (Code: $BUILD_NUMBER)"
|
echo "🚀 Building version: $VERSION_NAME (Code: $BUILD_NUMBER)"
|
||||||
|
|
||||||
- name: Version aus build.gradle.kts verifizieren
|
- name: Verify version from build.gradle.kts
|
||||||
run: |
|
run: |
|
||||||
echo "✅ Verwende Version aus build.gradle.kts:"
|
echo "✅ Using version from build.gradle.kts:"
|
||||||
grep -E "versionCode|versionName" android/app/build.gradle.kts
|
grep -E "versionCode|versionName" android/app/build.gradle.kts
|
||||||
|
|
||||||
- name: Android Signing konfigurieren
|
- name: Configure Android signing
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/simple-notes-release.jks
|
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/simple-notes-release.jks
|
||||||
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" > android/key.properties
|
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" > android/key.properties
|
||||||
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties
|
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties
|
||||||
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/key.properties
|
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/key.properties
|
||||||
echo "storeFile=simple-notes-release.jks" >> android/key.properties
|
echo "storeFile=simple-notes-release.jks" >> android/key.properties
|
||||||
echo "✅ Signing-Konfiguration erstellt"
|
echo "✅ Signing configuration created"
|
||||||
|
|
||||||
- name: Produktions-APK bauen (Standard + F-Droid Flavors)
|
- name: Build production APK (Standard + F-Droid Flavors)
|
||||||
run: |
|
run: |
|
||||||
cd android
|
cd android
|
||||||
./gradlew assembleStandardRelease assembleFdroidRelease --no-daemon --stacktrace
|
./gradlew assembleStandardRelease assembleFdroidRelease --no-daemon --stacktrace
|
||||||
|
|
||||||
- name: APK-Varianten mit Versionsnamen kopieren
|
- name: Copy APK variants with version names
|
||||||
run: |
|
run: |
|
||||||
mkdir -p apk-output
|
mkdir -p apk-output
|
||||||
|
|
||||||
@@ -69,34 +69,34 @@ jobs:
|
|||||||
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-release.apk \
|
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-release.apk \
|
||||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk
|
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk
|
||||||
|
|
||||||
echo "✅ APK-Dateien vorbereitet:"
|
echo "✅ APK files prepared:"
|
||||||
ls -lh apk-output/
|
ls -lh apk-output/
|
||||||
|
|
||||||
- name: APK-Artefakte hochladen
|
- name: Upload APK artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: simple-notes-sync-apks-v${{ env.VERSION_NAME }}
|
name: simple-notes-sync-apks-v${{ env.VERSION_NAME }}
|
||||||
path: apk-output/*.apk
|
path: apk-output/*.apk
|
||||||
retention-days: 90 # Produktions-Builds länger aufbewahren
|
retention-days: 90 # Keep production builds longer
|
||||||
|
|
||||||
- name: Commit-Informationen auslesen
|
- name: Extract commit information
|
||||||
run: |
|
run: |
|
||||||
echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||||
echo "COMMIT_DATE=$(git log -1 --format=%cd --date=iso-strict)" >> $GITHUB_ENV
|
echo "COMMIT_DATE=$(git log -1 --format=%cd --date=iso-strict)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: F-Droid Changelogs lesen
|
- name: Read F-Droid changelogs
|
||||||
run: |
|
run: |
|
||||||
# Lese deutsche Changelog (Hauptsprache) - Use printf to ensure proper formatting
|
# Read German changelog (main language) - Use printf to ensure proper formatting
|
||||||
if [ -f "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
if [ -f "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
||||||
CHANGELOG_CONTENT=$(cat "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt")
|
CHANGELOG_CONTENT=$(cat "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt")
|
||||||
echo "CHANGELOG_DE<<GHADELIMITER" >> $GITHUB_ENV
|
echo "CHANGELOG_DE<<GHADELIMITER" >> $GITHUB_ENV
|
||||||
echo "$CHANGELOG_CONTENT" >> $GITHUB_ENV
|
echo "$CHANGELOG_CONTENT" >> $GITHUB_ENV
|
||||||
echo "GHADELIMITER" >> $GITHUB_ENV
|
echo "GHADELIMITER" >> $GITHUB_ENV
|
||||||
else
|
else
|
||||||
echo "CHANGELOG_DE=Keine deutschen Release Notes verfügbar." >> $GITHUB_ENV
|
echo "CHANGELOG_DE=No German release notes available." >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Lese englische Changelog (optional)
|
# Read English changelog (optional)
|
||||||
if [ -f "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
if [ -f "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
||||||
CHANGELOG_CONTENT_EN=$(cat "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt")
|
CHANGELOG_CONTENT_EN=$(cat "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt")
|
||||||
echo "CHANGELOG_EN<<GHADELIMITER" >> $GITHUB_ENV
|
echo "CHANGELOG_EN<<GHADELIMITER" >> $GITHUB_ENV
|
||||||
@@ -116,36 +116,41 @@ jobs:
|
|||||||
prerelease: false
|
prerelease: false
|
||||||
generate_release_notes: false
|
generate_release_notes: false
|
||||||
body: |
|
body: |
|
||||||
## 📦 Downloads
|
|
||||||
|
|
||||||
| Variante | Datei | Info |
|
|
||||||
|----------|-------|------|
|
|
||||||
| **🏆 Empfohlen** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk` | Standard-Version (funktioniert auf allen Geraeten) |
|
|
||||||
| F-Droid | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk` | Fuer F-Droid Store |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Changelog / Release Notes
|
## 📋 Changelog / Release Notes
|
||||||
|
|
||||||
${{ env.CHANGELOG_DE }}
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>🌍 English Version</summary>
|
|
||||||
|
|
||||||
${{ env.CHANGELOG_EN }}
|
${{ env.CHANGELOG_EN }}
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>🇩🇪 German Version</summary>
|
||||||
|
|
||||||
|
${{ env.CHANGELOG_DE }}
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
## 📦 Downloads
|
||||||
|
|
||||||
---
|
| Variant | File | Info |
|
||||||
|
|---------|------|------|
|
||||||
|
| **🏆 Recommended** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk` | Standard version (works on all devices) |
|
||||||
|
| F-Droid | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk` | For F-Droid Store |
|
||||||
|
|
||||||
## 📊 Build-Info
|
## 📊 Build Info
|
||||||
|
|
||||||
- **Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.BUILD_NUMBER }})
|
- **Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.BUILD_NUMBER }})
|
||||||
- **Datum:** ${{ env.COMMIT_DATE }}
|
- **Date:** ${{ env.COMMIT_DATE }}
|
||||||
- **Commit:** ${{ env.SHORT_SHA }}
|
- **Commit:** ${{ env.SHORT_SHA }}
|
||||||
|
|
||||||
---
|
## 🔐 APK Signature Verification
|
||||||
|
|
||||||
**[📖 Dokumentation](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md)** · **[🐛 Issue melden](https://github.com/inventory69/simple-notes-sync/issues)**
|
All APKs are signed with the official release certificate.
|
||||||
|
|
||||||
|
**Recommended:** Verify with [AppVerifier](https://github.com/nicholson-lab/AppVerifier) (Android app)
|
||||||
|
|
||||||
|
**Expected SHA-256:**
|
||||||
|
```
|
||||||
|
42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
|
||||||
|
```
|
||||||
|
|
||||||
|
**[📖 Documentation](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md)** · **[🐛 Report Bug](https://github.com/inventory69/simple-notes-sync/issues)**
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
25
.github/workflows/pr-build-check.yml
vendored
25
.github/workflows/pr-build-check.yml
vendored
@@ -33,6 +33,31 @@ jobs:
|
|||||||
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
|
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
|
||||||
echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV
|
echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV
|
||||||
echo "📱 Version: $VERSION_NAME (Code: $VERSION_CODE)"
|
echo "📱 Version: $VERSION_NAME (Code: $VERSION_CODE)"
|
||||||
|
|
||||||
|
# 🔍 Code Quality Checks (v1.6.1)
|
||||||
|
- name: Run detekt (Code Quality)
|
||||||
|
run: |
|
||||||
|
cd android
|
||||||
|
./gradlew detekt --no-daemon
|
||||||
|
continue-on-error: false
|
||||||
|
|
||||||
|
- name: Run ktlint (Code Style)
|
||||||
|
run: |
|
||||||
|
cd android
|
||||||
|
./gradlew ktlintCheck --no-daemon
|
||||||
|
continue-on-error: true # Parser-Probleme in Legacy-Code
|
||||||
|
|
||||||
|
- name: Upload Lint Reports
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: lint-reports-pr-${{ github.event.pull_request.number }}
|
||||||
|
path: |
|
||||||
|
android/app/build/reports/detekt/
|
||||||
|
android/app/build/reports/ktlint/
|
||||||
|
android/app/build/reports/lint-results*.html
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
- name: Debug Build erstellen (ohne Signing)
|
- name: Debug Build erstellen (ohne Signing)
|
||||||
run: |
|
run: |
|
||||||
cd android
|
cd android
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -42,3 +42,10 @@ Thumbs.db
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.swp
|
*.swp
|
||||||
*~
|
*~
|
||||||
|
test-apks/
|
||||||
|
server-test/
|
||||||
|
|
||||||
|
# F-Droid metadata (managed in fdroiddata repo)
|
||||||
|
# Exclude fastlane metadata (we want to track those screenshots)
|
||||||
|
metadata/
|
||||||
|
!fastlane/metadata/
|
||||||
|
|||||||
642
CHANGELOG.de.md
Normal file
642
CHANGELOG.de.md
Normal file
@@ -0,0 +1,642 @@
|
|||||||
|
# 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.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)
|
||||||
|
- Created [SYNC_ARCHITECTURE.md](../project-docs/simple-notes-sync/architecture/SYNC_ARCHITECTURE.md) - Complete sync documentation
|
||||||
|
- Created [MARKDOWN_DESKTOP_REALITY_CHECK.md](../project-docs/simple-notes-sync/markdown-desktop-plan/MARKDOWN_DESKTOP_REALITY_CHECK.md) - Desktop integration analysis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.1.2] - 2025-12-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **"Job was cancelled" Error**
|
||||||
|
- Fixed coroutine cancellation in sync worker
|
||||||
|
- Proper error handling for interrupted syncs
|
||||||
|
|
||||||
|
- **UI Improvements**
|
||||||
|
- Back arrow instead of X in note editor (better UX)
|
||||||
|
- Pull-to-refresh for manual sync trigger
|
||||||
|
- HTTP/HTTPS protocol selection with radio buttons
|
||||||
|
- Inline error display (no toast spam)
|
||||||
|
|
||||||
|
- **Performance & Battery**
|
||||||
|
- Sync only on actual changes (saves battery)
|
||||||
|
- Auto-save notifications removed
|
||||||
|
- 24-hour server offline warning instead of instant error
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Settings grouped into "Auto-Sync" and "Sync Interval" sections
|
||||||
|
- HTTP only allowed for local networks (RFC 1918 IPs)
|
||||||
|
- Swipe-to-delete without UI flicker
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.1.1] - 2025-12-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **WiFi Connect Sync**
|
||||||
|
- No error notifications in foreign WiFi networks
|
||||||
|
- Server reachability check before sync (2s timeout)
|
||||||
|
- Silent abort when server offline
|
||||||
|
- Pre-check waits until network is ready
|
||||||
|
- No errors during network initialization
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Notifications**
|
||||||
|
- Old sync notifications cleared on app start
|
||||||
|
- Error notifications auto-dismiss after 30 seconds
|
||||||
|
|
||||||
|
### UI
|
||||||
|
- Sync icon only shown when sync is configured
|
||||||
|
- Swipe-to-delete without flicker
|
||||||
|
- Scroll to top after saving note
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- Server check with 2-second timeout before sync attempts
|
||||||
|
- Network readiness check in WiFi connect trigger
|
||||||
|
- Notification cleanup on MainActivity.onCreate()
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.1.0] - 2025-12-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Configurable Sync Intervals**
|
||||||
|
- User choice: 15, 30, or 60 minutes
|
||||||
|
- Real-world battery impact displayed (15min: ~0.8%/day, 30min: ~0.4%/day, 60min: ~0.2%/day)
|
||||||
|
- Radio button selection in settings
|
||||||
|
- Doze Mode optimization (syncs batched in maintenance windows)
|
||||||
|
|
||||||
|
- **About Section**
|
||||||
|
- App version from BuildConfig
|
||||||
|
- Links to GitHub repository and developer profile
|
||||||
|
- MIT license information
|
||||||
|
- Material 3 card design
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Settings UI redesigned with grouped sections
|
||||||
|
- Periodic sync updated dynamically when interval changes
|
||||||
|
- WorkManager uses selected interval for background sync
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Debug/Logs section from settings (cleaner UI)
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- `PREF_SYNC_INTERVAL_MINUTES` preference key
|
||||||
|
- NetworkMonitor reads interval from SharedPreferences
|
||||||
|
- `ExistingPeriodicWorkPolicy.UPDATE` for live interval changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.0] - 2025-12-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release
|
||||||
|
- WebDAV synchronization
|
||||||
|
- Note creation, editing, deletion
|
||||||
|
- 6 sync triggers:
|
||||||
|
- Periodic sync (configurable interval)
|
||||||
|
- App start sync
|
||||||
|
- WiFi connect sync
|
||||||
|
- Manual sync (menu button)
|
||||||
|
- Pull-to-refresh
|
||||||
|
- Settings "Sync Now" button
|
||||||
|
- Material 3 design
|
||||||
|
- Light/Dark theme support
|
||||||
|
- F-Droid compatible (100% FOSS)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[1.2.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.0
|
||||||
|
[1.1.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.2
|
||||||
|
[1.1.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.1
|
||||||
|
[1.1.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.0
|
||||||
|
[1.0.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.0.0
|
||||||
254
CHANGELOG.md
254
CHANGELOG.md
@@ -4,6 +4,260 @@ All notable changes to Simple Notes Sync will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
|
**🌍 Languages:** [Deutsch](CHANGELOG.de.md) · **English**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.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
|
## [1.3.2] - 2026-01-10
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Danke, dass du zu Simple Notes Sync beitragen möchtest!
|
|||||||
|
|
||||||
1. **Fork & Clone**
|
1. **Fork & Clone**
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/DEIN-USERNAME/simple-notes-sync.git
|
git clone https://github.com/inventory69/simple-notes-sync.git
|
||||||
cd simple-notes-sync
|
cd simple-notes-sync
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ Thanks for wanting to contribute to Simple Notes Sync!
|
|||||||
|
|
||||||
1. **Fork & Clone**
|
1. **Fork & Clone**
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/YOUR-USERNAME/simple-notes-sync.git
|
git clone https://github.com/inventory69/simple-notes-sync.git
|
||||||
cd simple-notes-sync
|
cd simple-notes-sync
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
269
QUICKSTART.de.md
Normal file
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 8.0+ Smartphone/Tablet
|
||||||
|
- ✅ WLAN-Verbindung
|
||||||
|
- ✅ Eigener Server mit Docker (optional - für Self-Hosting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 1: Mit eigenem Server (Self-Hosted) 🏠
|
||||||
|
|
||||||
|
### Schritt 1: WebDAV Server einrichten
|
||||||
|
|
||||||
|
Auf deinem Server (z.B. Raspberry Pi, NAS, VPS):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Repository klonen
|
||||||
|
git clone https://github.com/inventory69/simple-notes-sync.git
|
||||||
|
cd simple-notes-sync/server
|
||||||
|
|
||||||
|
# Umgebungsvariablen konfigurieren
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
**In `.env` anpassen:**
|
||||||
|
```env
|
||||||
|
WEBDAV_PASSWORD=dein-sicheres-passwort-hier
|
||||||
|
```
|
||||||
|
|
||||||
|
**Server starten:**
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**IP-Adresse finden:**
|
||||||
|
```bash
|
||||||
|
ip addr show | grep "inet " | grep -v 127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
➡️ **Notiere dir:** `http://DEINE-SERVER-IP:8080/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Schritt 2: App installieren
|
||||||
|
|
||||||
|
1. **APK herunterladen:** [Neueste Version](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||||
|
- Wähle: `simple-notes-sync-vX.X.X-standard-universal.apk`
|
||||||
|
|
||||||
|
2. **Installation erlauben:**
|
||||||
|
- Android: Einstellungen → Sicherheit → "Unbekannte Quellen" für deinen Browser aktivieren
|
||||||
|
|
||||||
|
3. **APK öffnen und installieren**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Schritt 3: App konfigurieren
|
||||||
|
|
||||||
|
1. **App öffnen**
|
||||||
|
|
||||||
|
2. **Einstellungen öffnen** (⚙️ Icon oben rechts)
|
||||||
|
|
||||||
|
3. **Server-Einstellungen konfigurieren:**
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|------|------|
|
||||||
|
| **WebDAV Server URL** | `http://DEINE-SERVER-IP:8080/` |
|
||||||
|
| **Benutzername** | `noteuser` |
|
||||||
|
| **Passwort** | (dein Passwort aus `.env`) |
|
||||||
|
|
||||||
|
> **💡 Hinweis:** Gib nur die Base-URL ein (ohne `/notes`). Die App erstellt automatisch `/notes/` für JSON-Dateien und `/notes-md/` für Markdown-Export.
|
||||||
|
|
||||||
|
4. **"Verbindung testen"** drücken
|
||||||
|
- ✅ Erfolg? → Weiter zu Schritt 4
|
||||||
|
- ❌ Fehler? → Siehe [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
5. **Auto-Sync aktivieren** (Toggle Switch)
|
||||||
|
|
||||||
|
6. **Sync-Intervall wählen:**
|
||||||
|
- **15 Min** - Maximale Aktualität (~0.8% Akku/Tag)
|
||||||
|
- **30 Min** - Empfohlen (~0.4% Akku/Tag) ⭐
|
||||||
|
- **60 Min** - Maximale Akkulaufzeit (~0.2% Akku/Tag)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Schritt 4: Erste Notiz erstellen
|
||||||
|
|
||||||
|
1. Zurück zur Hauptansicht (← Pfeil)
|
||||||
|
|
||||||
|
2. **"Notiz hinzufügen"** (+ Icon)
|
||||||
|
|
||||||
|
3. Titel und Text eingeben
|
||||||
|
|
||||||
|
4. **Speichern** (💾 Icon)
|
||||||
|
|
||||||
|
5. **Warten auf Auto-Sync** (oder manuell: ⚙️ → "Jetzt synchronisieren")
|
||||||
|
|
||||||
|
🎉 **Fertig!** Deine Notizen werden automatisch synchronisiert!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 2: Nur lokale Notizen (kein Server) 📱
|
||||||
|
|
||||||
|
Du kannst Simple Notes auch **ohne Server** nutzen:
|
||||||
|
|
||||||
|
1. **App installieren** (siehe Schritt 2 oben)
|
||||||
|
|
||||||
|
2. **Ohne Server-Konfiguration verwenden:**
|
||||||
|
- Notizen werden nur lokal gespeichert
|
||||||
|
- Kein Auto-Sync
|
||||||
|
- Perfekt für reine Offline-Nutzung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔋 Akku-Optimierung deaktivieren
|
||||||
|
|
||||||
|
Für zuverlässigen Auto-Sync:
|
||||||
|
|
||||||
|
1. **Einstellungen** → **Apps** → **Simple Notes Sync**
|
||||||
|
|
||||||
|
2. **Akku** → **Akkuverbrauch**
|
||||||
|
|
||||||
|
3. Wähle: **"Nicht optimieren"** oder **"Unbeschränkt"**
|
||||||
|
|
||||||
|
💡 **Hinweis:** Android Doze Mode kann trotzdem Sync im Standby verzögern (~60 Min). Das ist normal und betrifft alle Apps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Sync-Intervalle im Detail
|
||||||
|
|
||||||
|
| Intervall | Syncs/Tag | Akku/Tag | Akku/Sync | Anwendungsfall |
|
||||||
|
|-----------|-----------|----------|-----------|----------------|
|
||||||
|
| **15 Min** | ~96 | ~0.8% (~23 mAh) | ~0.008% | ⚡ Maximal aktuell (mehrere Geräte) |
|
||||||
|
| **30 Min** | ~48 | ~0.4% (~12 mAh) | ~0.008% | ✓ **Empfohlen** - ausgewogen |
|
||||||
|
| **60 Min** | ~24 | ~0.2% (~6 mAh) | ~0.008% | 🔋 Maximale Akkulaufzeit |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### Verbindungstest schlägt fehl
|
||||||
|
|
||||||
|
**Problem:** "Verbindung fehlgeschlagen" beim Test
|
||||||
|
|
||||||
|
**Lösungen:**
|
||||||
|
|
||||||
|
1. **Server läuft?**
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
# Sollte "Up" zeigen
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Gleiches Netzwerk?**
|
||||||
|
- Smartphone und Server müssen im selben Netzwerk sein
|
||||||
|
|
||||||
|
3. **IP-Adresse korrekt?**
|
||||||
|
```bash
|
||||||
|
ip addr show | grep "inet "
|
||||||
|
# Prüfe ob IP in URL stimmt
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Firewall?**
|
||||||
|
```bash
|
||||||
|
# Port 8080 öffnen (falls Firewall aktiv)
|
||||||
|
sudo ufw allow 8080/tcp
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Server-Logs prüfen:**
|
||||||
|
```bash
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Auto-Sync funktioniert nicht
|
||||||
|
|
||||||
|
**Problem:** Notizen werden nicht automatisch synchronisiert
|
||||||
|
|
||||||
|
**Lösungen:**
|
||||||
|
|
||||||
|
1. **Auto-Sync aktiviert?**
|
||||||
|
- ⚙️ Einstellungen → Toggle "Auto-Sync" muss **AN** sein
|
||||||
|
|
||||||
|
2. **Akku-Optimierung deaktiviert?**
|
||||||
|
- Siehe [Akku-Optimierung](#-akku-optimierung-deaktivieren)
|
||||||
|
|
||||||
|
3. **Mit WiFi verbunden?**
|
||||||
|
- Auto-Sync triggert bei jeder WiFi-Verbindung
|
||||||
|
- Prüfe, ob du mit einem WLAN verbunden bist
|
||||||
|
|
||||||
|
4. **Manuell testen:**
|
||||||
|
- ⚙️ Einstellungen → "Jetzt synchronisieren"
|
||||||
|
- Funktioniert das? → Auto-Sync sollte auch funktionieren
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Notizen werden nicht angezeigt
|
||||||
|
|
||||||
|
**Problem:** Nach Installation sind keine Notizen da, obwohl welche auf dem Server liegen
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
|
||||||
|
1. **Einmalig manuell synchronisieren:**
|
||||||
|
- ⚙️ Einstellungen → "Jetzt synchronisieren"
|
||||||
|
|
||||||
|
2. **Server-Daten prüfen:**
|
||||||
|
```bash
|
||||||
|
docker compose exec webdav ls -la /data/
|
||||||
|
# Sollte .json Dateien zeigen
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fehler beim Sync
|
||||||
|
|
||||||
|
**Problem:** Fehlermeldung beim Synchronisieren
|
||||||
|
|
||||||
|
**Lösungen:**
|
||||||
|
|
||||||
|
1. **"401 Unauthorized"** → Passwort falsch
|
||||||
|
- Prüfe Passwort in App-Einstellungen
|
||||||
|
- Vergleiche mit `.env` auf Server
|
||||||
|
|
||||||
|
2. **"404 Not Found"** → URL falsch
|
||||||
|
- Sollte enden mit `/` (z.B. `http://192.168.1.100:8080/`)
|
||||||
|
|
||||||
|
3. **"Network error"** → Keine Verbindung
|
||||||
|
- Siehe [Verbindungstest schlägt fehl](#verbindungstest-schlägt-fehl)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 Updates
|
||||||
|
|
||||||
|
### Automatisch mit Obtainium (empfohlen)
|
||||||
|
|
||||||
|
1. **[Obtainium installieren](https://github.com/ImranR98/Obtanium/releases/latest)**
|
||||||
|
|
||||||
|
2. **App hinzufügen:**
|
||||||
|
- URL: `https://github.com/inventory69/simple-notes-sync`
|
||||||
|
- Auto-Update aktivieren
|
||||||
|
|
||||||
|
3. **Fertig!** Obtainium benachrichtigt dich bei neuen Versionen
|
||||||
|
|
||||||
|
### Manuell
|
||||||
|
|
||||||
|
1. Neue APK von [Releases](https://github.com/inventory69/simple-notes-sync/releases/latest) herunterladen
|
||||||
|
|
||||||
|
2. Installieren (überschreibt alte Version)
|
||||||
|
|
||||||
|
3. Alle Daten bleiben erhalten!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆘 Weitere Hilfe
|
||||||
|
|
||||||
|
- **GitHub Issues:** [Problem melden](https://github.com/inventory69/simple-notes-sync/issues)
|
||||||
|
- **Vollständige Docs:** [DOCS.md](DOCS.md)
|
||||||
|
- **Server Setup Details:** [server/README.md](server/README.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Version:** 1.1.0 · **Erstellt:** Dezember 2025
|
||||||
271
QUICKSTART.en.md
271
QUICKSTART.en.md
@@ -1,271 +0,0 @@
|
|||||||
# Quick Start Guide - Simple Notes Sync 📝
|
|
||||||
|
|
||||||
> Step-by-step installation and setup guide
|
|
||||||
|
|
||||||
**🌍 Languages:** [Deutsch](QUICKSTART.md) · **English**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- ✅ Android 8.0+ smartphone/tablet
|
|
||||||
- ✅ WiFi connection
|
|
||||||
- ✅ Own server with Docker (optional - for self-hosting)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option 1: With own server (Self-Hosted) 🏠
|
|
||||||
|
|
||||||
### Step 1: Setup WebDAV Server
|
|
||||||
|
|
||||||
On your server (e.g. Raspberry Pi, NAS, VPS):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone repository
|
|
||||||
git clone https://github.com/inventory69/simple-notes-sync.git
|
|
||||||
cd simple-notes-sync/server
|
|
||||||
|
|
||||||
# Configure environment variables
|
|
||||||
cp .env.example .env
|
|
||||||
nano .env
|
|
||||||
```
|
|
||||||
|
|
||||||
**Adjust in `.env`:**
|
|
||||||
```env
|
|
||||||
WEBDAV_PASSWORD=your-secure-password-here
|
|
||||||
```
|
|
||||||
|
|
||||||
**Start server:**
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
**Find IP address:**
|
|
||||||
```bash
|
|
||||||
ip addr show | grep "inet " | grep -v 127.0.0.1
|
|
||||||
```
|
|
||||||
|
|
||||||
➡️ **Note down:** `http://YOUR-SERVER-IP:8080/`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 2: Install App
|
|
||||||
|
|
||||||
1. **Download APK:** [Latest version](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
|
||||||
- Choose: `simple-notes-sync-vX.X.X-standard-universal.apk`
|
|
||||||
|
|
||||||
2. **Allow installation:**
|
|
||||||
- Android: Settings → Security → Enable "Unknown sources" for your browser
|
|
||||||
|
|
||||||
3. **Open and install APK**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 3: Configure App
|
|
||||||
|
|
||||||
1. **Open app**
|
|
||||||
|
|
||||||
2. **Open settings** (⚙️ icon top right)
|
|
||||||
|
|
||||||
3. **Configure server settings:**
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|------|------|
|
|
||||||
| **WebDAV Server URL** | `http://YOUR-SERVER-IP:8080/` |
|
|
||||||
| **Username** | `noteuser` |
|
|
||||||
| **Password** | (your password from `.env`) |
|
|
||||||
| **Gateway SSID** | Name of your WiFi network |
|
|
||||||
|
|
||||||
> **💡 Note:** Enter only the base URL (without `/notes`). The app automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown export.
|
|
||||||
|
|
||||||
4. **Press "Test connection"****
|
|
||||||
- ✅ Success? → Continue to step 4
|
|
||||||
- ❌ Error? → See [Troubleshooting](#troubleshooting)
|
|
||||||
|
|
||||||
5. **Enable auto-sync** (toggle switch)
|
|
||||||
|
|
||||||
6. **Choose sync interval:**
|
|
||||||
- **15 min** - Maximum currency (~0.8% battery/day)
|
|
||||||
- **30 min** - Recommended (~0.4% battery/day) ⭐
|
|
||||||
- **60 min** - Maximum battery life (~0.2% battery/day)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 4: Create First Note
|
|
||||||
|
|
||||||
1. Back to main view (← arrow)
|
|
||||||
|
|
||||||
2. **"Add note"** (+ icon)
|
|
||||||
|
|
||||||
3. Enter title and text
|
|
||||||
|
|
||||||
4. **Save** (💾 icon)
|
|
||||||
|
|
||||||
5. **Wait for auto-sync** (or manually: ⚙️ → "Sync now")
|
|
||||||
|
|
||||||
🎉 **Done!** Your notes will be automatically synchronized!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option 2: Local notes only (no server) 📱
|
|
||||||
|
|
||||||
You can also use Simple Notes **without a server**:
|
|
||||||
|
|
||||||
1. **Install app** (see step 2 above)
|
|
||||||
|
|
||||||
2. **Use without server configuration:**
|
|
||||||
- Notes are only stored locally
|
|
||||||
- No auto-sync
|
|
||||||
- Perfect for offline-only use
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔋 Disable Battery Optimization
|
|
||||||
|
|
||||||
For reliable auto-sync:
|
|
||||||
|
|
||||||
1. **Settings** → **Apps** → **Simple Notes Sync**
|
|
||||||
|
|
||||||
2. **Battery** → **Battery usage**
|
|
||||||
|
|
||||||
3. Select: **"Don't optimize"** or **"Unrestricted"**
|
|
||||||
|
|
||||||
💡 **Note:** Android Doze Mode may still delay sync in standby (~60 min). This is normal and affects all apps.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Sync Intervals in Detail
|
|
||||||
|
|
||||||
| Interval | Syncs/day | Battery/day | Battery/sync | Use case |
|
|
||||||
|-----------|-----------|----------|-----------|----------------|
|
|
||||||
| **15 min** | ~96 | ~0.8% (~23 mAh) | ~0.008% | ⚡ Maximum currency (multiple devices) |
|
|
||||||
| **30 min** | ~48 | ~0.4% (~12 mAh) | ~0.008% | ✓ **Recommended** - balanced |
|
|
||||||
| **60 min** | ~24 | ~0.2% (~6 mAh) | ~0.008% | 🔋 Maximum battery life |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
|
||||||
|
|
||||||
### Connection test fails
|
|
||||||
|
|
||||||
**Problem:** "Connection failed" during test
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Server running?**
|
|
||||||
```bash
|
|
||||||
docker compose ps
|
|
||||||
# Should show "Up"
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Same WiFi?**
|
|
||||||
- Smartphone and server must be on same network
|
|
||||||
- Check SSID in app settings
|
|
||||||
|
|
||||||
3. **IP address correct?**
|
|
||||||
```bash
|
|
||||||
ip addr show | grep "inet "
|
|
||||||
# Check if IP in URL matches
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Firewall?**
|
|
||||||
```bash
|
|
||||||
# Open port 8080 (if firewall active)
|
|
||||||
sudo ufw allow 8080/tcp
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Check server logs:**
|
|
||||||
```bash
|
|
||||||
docker compose logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Auto-sync not working
|
|
||||||
|
|
||||||
**Problem:** Notes are not automatically synchronized
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Auto-sync enabled?**
|
|
||||||
- ⚙️ Settings → Toggle "Auto-sync" must be **ON**
|
|
||||||
|
|
||||||
2. **Battery optimization disabled?**
|
|
||||||
- See [Disable Battery Optimization](#-disable-battery-optimization)
|
|
||||||
|
|
||||||
3. **On correct WiFi?**
|
|
||||||
- Sync only works when SSID = Gateway SSID
|
|
||||||
- Check current SSID in Android settings
|
|
||||||
|
|
||||||
4. **Test manually:**
|
|
||||||
- ⚙️ Settings → "Sync now"
|
|
||||||
- Works? → Auto-sync should work too
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Notes not showing up
|
|
||||||
|
|
||||||
**Problem:** After installation, no notes visible even though they exist on server
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
|
|
||||||
1. **Manually sync once:**
|
|
||||||
- ⚙️ Settings → "Sync now"
|
|
||||||
|
|
||||||
2. **Check server data:**
|
|
||||||
```bash
|
|
||||||
docker compose exec webdav ls -la /data/
|
|
||||||
# Should show .json files
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Sync errors
|
|
||||||
|
|
||||||
**Problem:** Error message during sync
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **"401 Unauthorized"** → Wrong password
|
|
||||||
- Check password in app settings
|
|
||||||
- Compare with `.env` on server
|
|
||||||
|
|
||||||
2. **"404 Not Found"** → Wrong URL
|
|
||||||
- Should end with `/` (e.g. `http://192.168.1.100:8080/`)
|
|
||||||
|
|
||||||
3. **"Network error"** → No connection
|
|
||||||
- See [Connection test fails](#connection-test-fails)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📱 Updates
|
|
||||||
|
|
||||||
### Automatic with Obtainium (recommended)
|
|
||||||
|
|
||||||
1. **[Install Obtainium](https://github.com/ImranR98/Obtanium/releases/latest)**
|
|
||||||
|
|
||||||
2. **Add app:**
|
|
||||||
- URL: `https://github.com/inventory69/simple-notes-sync`
|
|
||||||
- Enable auto-update
|
|
||||||
|
|
||||||
3. **Done!** Obtainium notifies you of new versions
|
|
||||||
|
|
||||||
### Manual
|
|
||||||
|
|
||||||
1. Download new APK from [Releases](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
|
||||||
|
|
||||||
2. Install (overwrites old version)
|
|
||||||
|
|
||||||
3. All data remains intact!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🆘 Further Help
|
|
||||||
|
|
||||||
- **GitHub Issues:** [Report problem](https://github.com/inventory69/simple-notes-sync/issues)
|
|
||||||
- **Complete docs:** [DOCS.en.md](DOCS.en.md)
|
|
||||||
- **Server setup details:** [server/README.en.md](server/README.en.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Version:** 1.1.0 · **Created:** December 2025
|
|
||||||
240
QUICKSTART.md
240
QUICKSTART.md
@@ -1,271 +1,269 @@
|
|||||||
# Quick Start Guide - Simple Notes Sync 📝
|
# Quick Start Guide - Simple Notes Sync 📝
|
||||||
|
|
||||||
> Schritt-für-Schritt Anleitung zur Installation und Einrichtung
|
> Step-by-step installation and setup guide
|
||||||
|
|
||||||
**🌍 Sprachen:** **Deutsch** · [English](QUICKSTART.en.md)
|
**🌍 Languages:** [Deutsch](QUICKSTART.de.md) · **English**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Voraussetzungen
|
## Prerequisites
|
||||||
|
|
||||||
- ✅ Android 8.0+ Smartphone/Tablet
|
- ✅ Android 8.0+ smartphone/tablet
|
||||||
- ✅ WLAN-Verbindung
|
- ✅ WiFi connection
|
||||||
- ✅ Eigener Server mit Docker (optional - für Self-Hosting)
|
- ✅ Own server with Docker (optional - for self-hosting)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Option 1: Mit eigenem Server (Self-Hosted) 🏠
|
## Option 1: With own server (Self-Hosted) 🏠
|
||||||
|
|
||||||
### Schritt 1: WebDAV Server einrichten
|
### Step 1: Setup WebDAV Server
|
||||||
|
|
||||||
Auf deinem Server (z.B. Raspberry Pi, NAS, VPS):
|
On your server (e.g. Raspberry Pi, NAS, VPS):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Repository klonen
|
# Clone repository
|
||||||
git clone https://github.com/inventory69/simple-notes-sync.git
|
git clone https://github.com/inventory69/simple-notes-sync.git
|
||||||
cd simple-notes-sync/server
|
cd simple-notes-sync/server
|
||||||
|
|
||||||
# Umgebungsvariablen konfigurieren
|
# Configure environment variables
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
nano .env
|
nano .env
|
||||||
```
|
```
|
||||||
|
|
||||||
**In `.env` anpassen:**
|
**Adjust in `.env`:**
|
||||||
```env
|
```env
|
||||||
WEBDAV_PASSWORD=dein-sicheres-passwort-hier
|
WEBDAV_PASSWORD=your-secure-password-here
|
||||||
```
|
```
|
||||||
|
|
||||||
**Server starten:**
|
**Start server:**
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
**IP-Adresse finden:**
|
**Find IP address:**
|
||||||
```bash
|
```bash
|
||||||
ip addr show | grep "inet " | grep -v 127.0.0.1
|
ip addr show | grep "inet " | grep -v 127.0.0.1
|
||||||
```
|
```
|
||||||
|
|
||||||
➡️ **Notiere dir:** `http://DEINE-SERVER-IP:8080/`
|
➡️ **Note down:** `http://YOUR-SERVER-IP:8080/`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Schritt 2: App installieren
|
### Step 2: Install App
|
||||||
|
|
||||||
1. **APK herunterladen:** [Neueste Version](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
1. **Download APK:** [Latest version](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||||
- Wähle: `simple-notes-sync-vX.X.X-standard-universal.apk`
|
- Choose: `simple-notes-sync-vX.X.X-standard-universal.apk`
|
||||||
|
|
||||||
2. **Installation erlauben:**
|
2. **Allow installation:**
|
||||||
- Android: Einstellungen → Sicherheit → "Unbekannte Quellen" für deinen Browser aktivieren
|
- Android: Settings → 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/` |
|
| **WebDAV Server URL** | `http://YOUR-SERVER-IP:8080/` |
|
||||||
| **Benutzername** | `noteuser` |
|
| **Username** | `noteuser` |
|
||||||
| **Passwort** | (dein Passwort aus `.env`) |
|
| **Password** | (your password from `.env`) |
|
||||||
| **Gateway SSID** | Name deines WLAN-Netzwerks |
|
|
||||||
|
|
||||||
> **💡 Hinweis:** Gib nur die Base-URL ein (ohne `/notes`). Die App erstellt automatisch `/notes/` für JSON-Dateien und `/notes-md/` für Markdown-Export.
|
> **💡 Note:** Enter only the base URL (without `/notes`). The app automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown export.
|
||||||
|
|
||||||
4. **"Verbindung testen"** drücken
|
4. **Press "Test connection"****
|
||||||
- ✅ Erfolg? → Weiter zu Schritt 4
|
- ✅ Success? → Continue to step 4
|
||||||
- ❌ Fehler? → Siehe [Troubleshooting](#troubleshooting)
|
- ❌ Error? → See [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
5. **Auto-Sync aktivieren** (Toggle Switch)
|
5. **Enable auto-sync** (toggle switch)
|
||||||
|
|
||||||
6. **Sync-Intervall wählen:**
|
6. **Choose sync interval:**
|
||||||
- **15 Min** - Maximale Aktualität (~0.8% Akku/Tag)
|
- **15 min** - Maximum currency (~0.8% battery/day)
|
||||||
- **30 Min** - Empfohlen (~0.4% Akku/Tag) ⭐
|
- **30 min** - Recommended (~0.4% battery/day) ⭐
|
||||||
- **60 Min** - Maximale Akkulaufzeit (~0.2% Akku/Tag)
|
- **60 min** - Maximum battery life (~0.2% battery/day)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Schritt 4: Erste Notiz erstellen
|
### Step 4: Create First Note
|
||||||
|
|
||||||
1. Zurück zur Hauptansicht (← Pfeil)
|
1. Back to main view (← arrow)
|
||||||
|
|
||||||
2. **"Notiz hinzufügen"** (+ Icon)
|
2. **"Add note"** (+ icon)
|
||||||
|
|
||||||
3. Titel und Text eingeben
|
3. Enter title and text
|
||||||
|
|
||||||
4. **Speichern** (💾 Icon)
|
4. **Save** (💾 icon)
|
||||||
|
|
||||||
5. **Warten auf Auto-Sync** (oder manuell: ⚙️ → "Jetzt synchronisieren")
|
5. **Wait for auto-sync** (or manually: ⚙️ → "Sync now")
|
||||||
|
|
||||||
🎉 **Fertig!** Deine Notizen werden automatisch synchronisiert!
|
🎉 **Done!** Your notes will be automatically synchronized!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Option 2: Nur lokale Notizen (kein Server) 📱
|
## Option 2: Local notes only (no server) 📱
|
||||||
|
|
||||||
Du kannst Simple Notes auch **ohne Server** nutzen:
|
You can also use Simple Notes **without a server**:
|
||||||
|
|
||||||
1. **App installieren** (siehe Schritt 2 oben)
|
1. **Install app** (see step 2 above)
|
||||||
|
|
||||||
2. **Ohne Server-Konfiguration verwenden:**
|
2. **Use without server configuration:**
|
||||||
- Notizen werden nur lokal gespeichert
|
- Notes are only stored locally
|
||||||
- Kein Auto-Sync
|
- No auto-sync
|
||||||
- Perfekt für reine Offline-Nutzung
|
- Perfect for offline-only use
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔋 Akku-Optimierung deaktivieren
|
## 🔋 Disable Battery Optimization
|
||||||
|
|
||||||
Für zuverlässigen Auto-Sync:
|
For reliable auto-sync:
|
||||||
|
|
||||||
1. **Einstellungen** → **Apps** → **Simple Notes Sync**
|
1. **Settings** → **Apps** → **Simple Notes Sync**
|
||||||
|
|
||||||
2. **Akku** → **Akkuverbrauch**
|
2. **Battery** → **Battery usage**
|
||||||
|
|
||||||
3. Wähle: **"Nicht optimieren"** oder **"Unbeschränkt"**
|
3. Select: **"Don't optimize"** or **"Unrestricted"**
|
||||||
|
|
||||||
💡 **Hinweis:** Android Doze Mode kann trotzdem Sync im Standby verzögern (~60 Min). Das ist normal und betrifft alle Apps.
|
💡 **Note:** Android Doze Mode may still delay sync in standby (~60 min). This is normal and affects all apps.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📊 Sync-Intervalle im Detail
|
## 📊 Sync Intervals in Detail
|
||||||
|
|
||||||
| Intervall | Syncs/Tag | Akku/Tag | Akku/Sync | Anwendungsfall |
|
| Interval | Syncs/day | Battery/day | Battery/sync | Use case |
|
||||||
|-----------|-----------|----------|-----------|----------------|
|
|-----------|-----------|----------|-----------|----------------|
|
||||||
| **15 Min** | ~96 | ~0.8% (~23 mAh) | ~0.008% | ⚡ Maximal aktuell (mehrere Geräte) |
|
| **15 min** | ~96 | ~0.8% (~23 mAh) | ~0.008% | ⚡ Maximum currency (multiple devices) |
|
||||||
| **30 Min** | ~48 | ~0.4% (~12 mAh) | ~0.008% | ✓ **Empfohlen** - ausgewogen |
|
| **30 min** | ~48 | ~0.4% (~12 mAh) | ~0.008% | ✓ **Recommended** - balanced |
|
||||||
| **60 Min** | ~24 | ~0.2% (~6 mAh) | ~0.008% | 🔋 Maximale Akkulaufzeit |
|
| **60 min** | ~24 | ~0.2% (~6 mAh) | ~0.008% | 🔋 Maximum battery life |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
### Verbindungstest schlägt fehl
|
### Connection test fails
|
||||||
|
|
||||||
**Problem:** "Verbindung fehlgeschlagen" beim Test
|
**Problem:** "Connection failed" during test
|
||||||
|
|
||||||
**Lösungen:**
|
**Solutions:**
|
||||||
|
|
||||||
1. **Server läuft?**
|
1. **Server running?**
|
||||||
```bash
|
```bash
|
||||||
docker compose ps
|
docker compose ps
|
||||||
# Sollte "Up" zeigen
|
# Should show "Up"
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Gleiche WLAN?**
|
2. **Same network?**
|
||||||
- Smartphone und Server müssen im selben Netzwerk sein
|
- Smartphone and server must be on same network
|
||||||
- Prüfe SSID in App-Einstellungen
|
|
||||||
|
|
||||||
3. **IP-Adresse korrekt?**
|
3. **IP address correct?**
|
||||||
```bash
|
```bash
|
||||||
ip addr show | grep "inet "
|
ip addr show | grep "inet "
|
||||||
# Prüfe ob IP in URL stimmt
|
# Check if IP in URL matches
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Firewall?**
|
4. **Firewall?**
|
||||||
```bash
|
```bash
|
||||||
# Port 8080 öffnen (falls Firewall aktiv)
|
# Open port 8080 (if firewall active)
|
||||||
sudo ufw allow 8080/tcp
|
sudo ufw allow 8080/tcp
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Server-Logs prüfen:**
|
5. **Check server logs:**
|
||||||
```bash
|
```bash
|
||||||
docker compose logs -f
|
docker compose logs -f
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Auto-Sync funktioniert nicht
|
### Auto-sync not working
|
||||||
|
|
||||||
**Problem:** Notizen werden nicht automatisch synchronisiert
|
**Problem:** Notes are not automatically synchronized
|
||||||
|
|
||||||
**Lösungen:**
|
**Solutions:**
|
||||||
|
|
||||||
1. **Auto-Sync aktiviert?**
|
1. **Auto-sync enabled?**
|
||||||
- ⚙️ Einstellungen → Toggle "Auto-Sync" muss **AN** sein
|
- ⚙️ Settings → Toggle "Auto-sync" must be **ON**
|
||||||
|
|
||||||
2. **Akku-Optimierung deaktiviert?**
|
2. **Battery optimization disabled?**
|
||||||
- Siehe [Akku-Optimierung](#-akku-optimierung-deaktivieren)
|
- See [Disable Battery Optimization](#-disable-battery-optimization)
|
||||||
|
|
||||||
3. **Im richtigen WLAN?**
|
3. **Connected to WiFi?**
|
||||||
- Sync funktioniert nur wenn SSID = Gateway SSID
|
- Auto-sync triggers on any WiFi connection
|
||||||
- Prüfe aktuelle SSID in Android-Einstellungen
|
- Check if you're connected to a WiFi network
|
||||||
|
|
||||||
4. **Manuell testen:**
|
4. **Test manually:**
|
||||||
- ⚙️ Einstellungen → "Jetzt synchronisieren"
|
- ⚙️ Settings → "Sync now"
|
||||||
- Funktioniert das? → Auto-Sync sollte auch funktionieren
|
- Works? → Auto-sync should work too
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Notizen werden nicht angezeigt
|
### Notes not showing up
|
||||||
|
|
||||||
**Problem:** Nach Installation sind keine Notizen da, obwohl welche auf dem Server liegen
|
**Problem:** After installation, no notes visible even though they exist on server
|
||||||
|
|
||||||
**Lösung:**
|
**Solution:**
|
||||||
|
|
||||||
1. **Einmalig manuell synchronisieren:**
|
1. **Manually sync once:**
|
||||||
- ⚙️ Einstellungen → "Jetzt synchronisieren"
|
- ⚙️ Settings → "Sync now"
|
||||||
|
|
||||||
2. **Server-Daten prüfen:**
|
2. **Check server data:**
|
||||||
```bash
|
```bash
|
||||||
docker compose exec webdav ls -la /data/
|
docker compose exec webdav ls -la /data/
|
||||||
# Sollte .json Dateien zeigen
|
# Should show .json files
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Fehler beim Sync
|
### Sync errors
|
||||||
|
|
||||||
**Problem:** Fehlermeldung beim Synchronisieren
|
**Problem:** Error message during sync
|
||||||
|
|
||||||
**Lösungen:**
|
**Solutions:**
|
||||||
|
|
||||||
1. **"401 Unauthorized"** → Passwort falsch
|
1. **"401 Unauthorized"** → Wrong password
|
||||||
- Prüfe Passwort in App-Einstellungen
|
- Check password in app settings
|
||||||
- Vergleiche mit `.env` auf Server
|
- Compare with `.env` on server
|
||||||
|
|
||||||
2. **"404 Not Found"** → URL falsch
|
2. **"404 Not Found"** → Wrong URL
|
||||||
- Sollte enden mit `/` (z.B. `http://192.168.1.100:8080/`)
|
- Should end with `/` (e.g. `http://192.168.1.100:8080/`)
|
||||||
|
|
||||||
3. **"Network error"** → Keine Verbindung
|
3. **"Network error"** → No connection
|
||||||
- Siehe [Verbindungstest schlägt fehl](#verbindungstest-schlägt-fehl)
|
- See [Connection test fails](#connection-test-fails)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📱 Updates
|
## 📱 Updates
|
||||||
|
|
||||||
### Automatisch mit Obtainium (empfohlen)
|
### Automatic with Obtainium (recommended)
|
||||||
|
|
||||||
1. **[Obtainium installieren](https://github.com/ImranR98/Obtanium/releases/latest)**
|
1. **[Install Obtainium](https://github.com/ImranR98/Obtanium/releases/latest)**
|
||||||
|
|
||||||
2. **App hinzufügen:**
|
2. **Add app:**
|
||||||
- URL: `https://github.com/inventory69/simple-notes-sync`
|
- URL: `https://github.com/inventory69/simple-notes-sync`
|
||||||
- Auto-Update aktivieren
|
- Enable auto-update
|
||||||
|
|
||||||
3. **Fertig!** Obtainium benachrichtigt dich bei neuen Versionen
|
3. **Done!** Obtainium notifies you of new versions
|
||||||
|
|
||||||
### Manuell
|
### Manual
|
||||||
|
|
||||||
1. Neue APK von [Releases](https://github.com/inventory69/simple-notes-sync/releases/latest) herunterladen
|
1. Download new APK from [Releases](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||||
|
|
||||||
2. Installieren (überschreibt alte Version)
|
2. Install (overwrites old version)
|
||||||
|
|
||||||
3. Alle Daten bleiben erhalten!
|
3. All data remains intact!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🆘 Weitere Hilfe
|
## 🆘 Further Help
|
||||||
|
|
||||||
- **GitHub Issues:** [Problem melden](https://github.com/inventory69/simple-notes-sync/issues)
|
- **GitHub Issues:** [Report problem](https://github.com/inventory69/simple-notes-sync/issues)
|
||||||
- **Vollständige Docs:** [DOCS.md](DOCS.md)
|
- **Complete docs:** [DOCS.en.md](DOCS.en.md)
|
||||||
- **Server Setup Details:** [server/README.md](server/README.md)
|
- **Server setup details:** [server/README.en.md](server/README.en.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Version:** 1.1.0 · **Erstellt:** Dezember 2025
|
**Version:** 1.1.0 · **Created:** December 2025
|
||||||
|
|||||||
142
README.de.md
Normal file
142
README.de.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<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
|
||||||
|
- 🌍 **Mehrsprachig** – Deutsch/Englisch mit Sprachauswahl
|
||||||
|
- 🔄 **Konfigurierbare Sync-Trigger** – onSave, onResume, WiFi, periodisch (15/30/60 Min), Boot
|
||||||
|
- 🔒 **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
|
||||||
|
- 🔋 **Akkuschonend** – ~0.2% mit Defaults, bis zu ~1.0% mit Periodic Sync
|
||||||
|
- 🎨 **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.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
||||||
|
|
||||||
|
</div>
|
||||||
104
README.en.md
104
README.en.md
@@ -1,104 +0,0 @@
|
|||||||
# Simple Notes Sync 📝
|
|
||||||
|
|
||||||
> Minimalist offline notes with auto-sync to your own server
|
|
||||||
|
|
||||||
[](https://www.android.com/)
|
|
||||||
[](https://m3.material.io/)
|
|
||||||
[](LICENSE)
|
|
||||||
|
|
||||||
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes)
|
|
||||||
|
|
||||||
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Documentation](docs/DOCS.en.md)** · **🚀 [Quick Start](QUICKSTART.en.md)**
|
|
||||||
|
|
||||||
**🌍 Languages:** [Deutsch](README.md) · **English**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📱 Screenshots
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.jpg" width="250" alt="Notes list">
|
|
||||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.jpg" width="250" alt="Edit note">
|
|
||||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.jpg" width="250" alt="Settings">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ Highlights
|
|
||||||
|
|
||||||
- 📝 **Offline-first** - Works without internet
|
|
||||||
- 🔄 **Auto-sync** - Home WiFi only (15/30/60 min)
|
|
||||||
- 🔒 **Self-hosted** - Your data stays with you (WebDAV)
|
|
||||||
- 💾 **Local backup** - Export/Import as JSON file
|
|
||||||
- 🖥️ **Desktop integration** - Markdown export for VS Code, Typora, etc.
|
|
||||||
- 🔋 **Battery-friendly** - ~0.2-0.8% per day
|
|
||||||
- 🎨 **Material Design 3** - Dark mode & dynamic colors
|
|
||||||
|
|
||||||
➡️ **Complete feature list:** [FEATURES.en.md](docs/FEATURES.en.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
### 1. Server Setup (5 minutes)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/inventory69/simple-notes-sync.git
|
|
||||||
cd simple-notes-sync/server
|
|
||||||
cp .env.example .env
|
|
||||||
# Set password in .env
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
➡️ **Details:** [Server Setup Guide](server/README.en.md)
|
|
||||||
|
|
||||||
### 2. App Installation (2 minutes)
|
|
||||||
|
|
||||||
1. [Download APK](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
|
||||||
2. Install & open
|
|
||||||
3. ⚙️ Settings → Configure server:
|
|
||||||
- **URL:** `http://YOUR-SERVER-IP:8080/` _(base URL only!)_
|
|
||||||
- **User:** `noteuser`
|
|
||||||
- **Password:** _(from .env)_
|
|
||||||
- **WiFi:** _(your network name)_
|
|
||||||
4. **Test connection** → Enable auto-sync
|
|
||||||
5. Done! 🎉
|
|
||||||
|
|
||||||
➡️ **Detailed guide:** [QUICKSTART.en.md](QUICKSTART.en.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Documentation
|
|
||||||
|
|
||||||
| Document | Content |
|
|
||||||
|----------|---------|
|
|
||||||
| **[QUICKSTART.en.md](QUICKSTART.en.md)** | Step-by-step installation |
|
|
||||||
| **[FEATURES.en.md](docs/FEATURES.en.md)** | Complete feature list |
|
|
||||||
| **[BACKUP.en.md](docs/BACKUP.en.md)** | Backup & restore guide |
|
|
||||||
| **[DESKTOP.en.md](docs/DESKTOP.en.md)** | Desktop integration (Markdown) |
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd android
|
|
||||||
./gradlew assembleStandardRelease
|
|
||||||
```
|
|
||||||
|
|
||||||
➡️ **Build guide:** [DOCS.en.md](docs/DOCS.en.md#-build--deployment)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
|
||||||
|
|
||||||
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📄 License
|
|
||||||
|
|
||||||
MIT License - see [LICENSE](LICENSE)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**v1.3.2** · Built with ❤️ using Kotlin + Material Design 3
|
|
||||||
155
README.md
155
README.md
@@ -1,107 +1,140 @@
|
|||||||
# 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/)
|
<h4 align="center">Clean, offline-first notes with intelligent sync - simplicity meets smart synchronization.</h4>
|
||||||
[](https://m3.material.io/)
|
|
||||||
[](LICENSE)
|
|
||||||
|
|
||||||
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes)
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://www.android.com/)
|
||||||
|
[](https://kotlinlang.org/)
|
||||||
|
[](https://developer.android.com/compose/)
|
||||||
|
[](https://m3.material.io/)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](docs/DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)**
|
</div>
|
||||||
|
|
||||||
**🌍 Sprachen:** **Deutsch** · [English](README.en.md)
|
<div align="center">
|
||||||
|
|
||||||
---
|
<a href="https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes">
|
||||||
|
<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png"
|
||||||
|
alt="Get it on IzzyOnDroid" align="center" height="80" /></a>
|
||||||
|
|
||||||
|
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/inventory69/simple-notes-sync">
|
||||||
|
<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png"
|
||||||
|
alt="Get it on Obtainium" align="center" height="54" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="https://f-droid.org/packages/dev.dettmer.simplenotes">
|
||||||
|
<img src="https://f-droid.org/badge/get-it-on.png"
|
||||||
|
alt="Get it on F-Droid" align="center" height="80" /></a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<strong>SHA-256 hash of the signing certificate:</strong><br />42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<br />[📱 APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest) · [📖 Documentation](docs/DOCS.md) · [🚀 Quick Start](QUICKSTART.md)<br />
|
||||||
|
**🌍** [Deutsch](README.de.md) · **English**
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
## 📱 Screenshots
|
## 📱 Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.jpg" width="250" alt="Notizliste">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="250" alt="Sync status">
|
||||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.jpg" width="250" alt="Notiz bearbeiten">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="250" alt="Edit note">
|
||||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.jpg" width="250" alt="Einstellungen">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="250" alt="Edit checklist">
|
||||||
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="250" alt="Settings">
|
||||||
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="250" alt="Server settings">
|
||||||
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/7.png" width="250" alt="Sync settings">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
<div align="center">
|
||||||
|
|
||||||
|
📝 Offline-first • 🔄 Smart Sync • 🔒 Self-hosted • 🔋 Battery-friendly
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
## ✨ Highlights
|
## ✨ Highlights
|
||||||
|
|
||||||
- 📝 **Offline-First** - Funktioniert ohne Internet
|
- 📝 **Offline-first** - Works without internet
|
||||||
- 🔄 **Auto-Sync** - Nur im Heim-WLAN (15/30/60 Min)
|
- 📊 **Flexible views** - Switch between list and grid layout
|
||||||
- 🔒 **Self-Hosted** - Deine Daten bleiben bei dir (WebDAV)
|
- ✅ **Checklists** - Tap-to-check, drag & drop
|
||||||
- 💾 **Lokales Backup** - Export/Import als JSON-Datei
|
- 🌍 **Multilingual** - English/German with language selector
|
||||||
- 🖥️ **Desktop-Integration** - Markdown-Export für VS Code, Typora, etc.
|
- 🔄 **Configurable sync triggers** - onSave, onResume, WiFi-connect, periodic (15/30/60 min), boot
|
||||||
- 🔋 **Akkuschonend** - ~0.2-0.8% pro Tag
|
- 🔒 **Self-hosted** - Your data stays with you (WebDAV)
|
||||||
- 🎨 **Material Design 3** - Dark Mode & Dynamic Colors
|
- 💾 **Local backup** - Export/Import as JSON file (encryption available)
|
||||||
|
- 🖥️ **Desktop integration** - Markdown export for Obsidian, VS Code, Typora
|
||||||
|
- 🔋 **Battery-friendly** - ~0.2% with defaults, up to ~1.0% with periodic sync
|
||||||
|
- 🎨 **Material Design 3** - Dynamic dark/light mode & colors based on system settings
|
||||||
|
|
||||||
➡️ **Vollständige Feature-Liste:** [FEATURES.md](docs/FEATURES.md)
|
➡️ **Complete feature list:** [FEATURES.md](docs/FEATURES.md)
|
||||||
|
|
||||||
---
|
## 🚀 Quick Start
|
||||||
|
|
||||||
## 🚀 Schnellstart
|
### 1. Server Setup (5 minutes)
|
||||||
|
|
||||||
### 1. Server Setup (5 Minuten)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/inventory69/simple-notes-sync.git
|
git clone https://github.com/inventory69/simple-notes-sync.git
|
||||||
cd simple-notes-sync/server
|
cd simple-notes-sync/server
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Passwort in .env setzen
|
# Set password in .env
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
➡️ **Details:** [Server Setup Guide](server/README.md)
|
➡️ **Details:** [Server Setup Guide](server/README.md)
|
||||||
|
|
||||||
### 2. App Installation (2 Minuten)
|
### 2. App Installation (2 minutes)
|
||||||
|
|
||||||
1. [APK herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
1. [Download APK](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||||
2. Installieren & öffnen
|
2. Install & open
|
||||||
3. ⚙️ Einstellungen → Server konfigurieren:
|
3. ⚙️ Settings → Configure server:
|
||||||
- **URL:** `http://DEINE-SERVER-IP:8080/` _(nur Base-URL!)_
|
- **URL:** `http://YOUR-SERVER-IP:8080/` _(base URL only!)_
|
||||||
- **User:** `noteuser`
|
- **User:** `noteuser`
|
||||||
- **Passwort:** _(aus .env)_
|
- **Password:** _(from .env)_
|
||||||
- **WLAN:** _(dein Netzwerk-Name)_
|
- **WiFi:** _(your network name)_
|
||||||
4. **Verbindung testen** → Auto-Sync aktivieren
|
4. **Test connection** → Enable auto-sync
|
||||||
5. Fertig! 🎉
|
5. Done! 🎉
|
||||||
|
|
||||||
➡️ **Ausführliche Anleitung:** [QUICKSTART.md](QUICKSTART.md)
|
➡️ **Detailed guide:** [QUICKSTART.md](QUICKSTART.md)
|
||||||
|
|
||||||
---
|
## 📚 Documentation
|
||||||
|
|
||||||
## 📚 Dokumentation
|
| Document | Content |
|
||||||
|
|----------|---------|
|
||||||
| Dokument | Inhalt |
|
| **[QUICKSTART.md](QUICKSTART.md)** | Step-by-step installation |
|
||||||
|----------|--------|
|
| **[FEATURES.md](docs/FEATURES.md)** | Complete feature list |
|
||||||
| **[QUICKSTART.md](QUICKSTART.md)** | Schritt-für-Schritt Installation |
|
| **[BACKUP.md](docs/BACKUP.md)** | Backup & restore guide |
|
||||||
| **[FEATURES.md](docs/FEATURES.md)** | Vollständige Feature-Liste |
|
| **[DESKTOP.md](docs/DESKTOP.md)** | Desktop integration (Markdown) |
|
||||||
| **[BACKUP.md](docs/BACKUP.md)** | Backup & Wiederherstellung |
|
| **[SELF_SIGNED_SSL.md](docs/SELF_SIGNED_SSL.md)** | Self-signed SSL certificate setup |
|
||||||
| **[DESKTOP.md](docs/DESKTOP.md)** | Desktop-Integration (Markdown) |
|
| **[DOCS.md](docs/DOCS.md)** | Technical details & troubleshooting |
|
||||||
| **[DOCS.md](docs/DOCS.md)** | Technische Details & Troubleshooting |
|
| **[CHANGELOG.md](CHANGELOG.md)** | Version history |
|
||||||
| **[CHANGELOG.md](CHANGELOG.md)** | Versionshistorie |
|
| **[UPCOMING.md](docs/UPCOMING.md)** | Upcoming features 🚀 |
|
||||||
|
| **[TRANSLATING.md](docs/TRANSLATING.md)** | Translation guide 🌍 |
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Entwicklung
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd android
|
cd android
|
||||||
./gradlew assembleStandardRelease
|
./gradlew assembleStandardRelease
|
||||||
```
|
```
|
||||||
|
|
||||||
➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md#-build--deployment)
|
➡️ **Build guide:** [DOCS.md](docs/DOCS.md#-build--deployment)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
Beiträge willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md)
|
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
|
|
||||||
---
|
## 📄 License
|
||||||
|
|
||||||
## 📄 Lizenz
|
MIT License - see [LICENSE](LICENSE)
|
||||||
|
|
||||||
MIT License - siehe [LICENSE](LICENSE)
|
<div align="center">
|
||||||
|
<br /><br />
|
||||||
|
**v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
||||||
|
|
||||||
---
|
</div>
|
||||||
|
|
||||||
**v1.3.2** · Built with ❤️ using Kotlin + Material Design 3
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen, aktivieren in v1.4.0
|
alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler
|
||||||
// alias(libs.plugins.ktlint)
|
alias(libs.plugins.ktlint) // ✅ v1.6.1: Reaktiviert nach Code-Cleanup
|
||||||
alias(libs.plugins.detekt)
|
alias(libs.plugins.detekt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,13 +20,10 @@ android {
|
|||||||
applicationId = "dev.dettmer.simplenotes"
|
applicationId = "dev.dettmer.simplenotes"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 10 // 🚀 v1.3.2: Lint-Cleanup "Clean Slate"
|
versionCode = 17 // 🎨 v1.7.0: Grid Layout + Backup Encryption
|
||||||
versionName = "1.3.2" // 🚀 v1.3.2: Code-Qualität-Release (alle einfachen Lint-Issues behoben)
|
versionName = "1.7.0" // 🎨 v1.7.0: Grid Layout + Backup Encryption
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
// 🔥 NEU: Build Date für About Screen
|
|
||||||
buildConfigField("String", "BUILD_DATE", "\"${getBuildDate()}\"")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility
|
// Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility
|
||||||
@@ -99,7 +96,17 @@ android {
|
|||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding = true
|
viewBinding = true
|
||||||
buildConfig = true // Enable BuildConfig generation
|
buildConfig = true // Enable BuildConfig generation
|
||||||
|
compose = true // v1.5.0: Jetpack Compose für Settings Redesign
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.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 {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
@@ -138,31 +145,45 @@ dependencies {
|
|||||||
// SwipeRefreshLayout für Pull-to-Refresh
|
// SwipeRefreshLayout für Pull-to-Refresh
|
||||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||||
|
|
||||||
|
// 🔐 v1.7.0: AndroidX Security Crypto für Backup-Verschlüsselung
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// v1.5.0: Jetpack Compose für Settings Redesign
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.compose.ui)
|
||||||
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
|
||||||
// Testing (bleiben so)
|
// Testing (bleiben so)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 NEU: Helper function für Build Date
|
// ✅ v1.6.1: ktlint reaktiviert nach Code-Cleanup
|
||||||
fun getBuildDate(): String {
|
ktlint {
|
||||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
android = true
|
||||||
return dateFormat.format(Date())
|
outputToConsole = true
|
||||||
|
ignoreFailures = true // Parser-Probleme in WebDavSyncService.kt und build.gradle.kts
|
||||||
|
enableExperimentalRules = false
|
||||||
|
|
||||||
|
filter {
|
||||||
|
exclude("**/generated/**")
|
||||||
|
exclude("**/build/**")
|
||||||
|
// Legacy adapters with ktlint parser issues
|
||||||
|
exclude("**/adapters/NotesAdapter.kt")
|
||||||
|
exclude("**/SettingsActivity.kt")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen
|
|
||||||
// Aktivieren in v1.4.0 wenn Code-Stil bereinigt wurde
|
|
||||||
// ktlint {
|
|
||||||
// android = true
|
|
||||||
// outputToConsole = true
|
|
||||||
// ignoreFailures = true
|
|
||||||
// enableExperimentalRules = false
|
|
||||||
// filter {
|
|
||||||
// exclude("**/generated/**")
|
|
||||||
// exclude("**/build/**")
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// ⚡ v1.3.1: detekt-Konfiguration
|
// ⚡ v1.3.1: detekt-Konfiguration
|
||||||
detekt {
|
detekt {
|
||||||
buildUponDefaultConfig = true
|
buildUponDefaultConfig = true
|
||||||
|
|||||||
@@ -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 -->
|
<!-- Network & Sync Permissions -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
@@ -24,13 +22,15 @@
|
|||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:localeConfig="@xml/locales_config"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.SimpleNotes"
|
android:theme="@style/Theme.SimpleNotes"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
|
<!-- MainActivity v1.5.0 (Jetpack Compose) - Launcher -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".ui.main.ComposeMainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.SimpleNotes.Splash">
|
android:theme="@style/Theme.SimpleNotes.Splash">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -39,16 +39,35 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- Editor Activity -->
|
<!-- Legacy MainActivity (XML-based) - kept for reference -->
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.SimpleNotes" />
|
||||||
|
|
||||||
|
<!-- Editor Activity (Legacy - XML-based) -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".NoteEditorActivity"
|
android:name=".NoteEditorActivity"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:parentActivityName=".MainActivity" />
|
android:parentActivityName=".ui.main.ComposeMainActivity" />
|
||||||
|
|
||||||
<!-- Settings Activity -->
|
<!-- Editor Activity v1.5.0 (Jetpack Compose) -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.editor.ComposeNoteEditorActivity"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:parentActivityName=".ui.main.ComposeMainActivity"
|
||||||
|
android:theme="@style/Theme.SimpleNotes" />
|
||||||
|
|
||||||
|
<!-- Settings Activity (Legacy - XML-based) -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".SettingsActivity"
|
android:name=".SettingsActivity"
|
||||||
android:parentActivityName=".MainActivity" />
|
android:parentActivityName=".ui.main.ComposeMainActivity" />
|
||||||
|
|
||||||
|
<!-- Settings Activity v1.5.0 (Jetpack Compose) -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.settings.ComposeSettingsActivity"
|
||||||
|
android:parentActivityName=".ui.main.ComposeMainActivity"
|
||||||
|
android:theme="@style/Theme.SimpleNotes" />
|
||||||
|
|
||||||
<!-- Boot Receiver - Startet WorkManager nach Reboot -->
|
<!-- Boot Receiver - Startet WorkManager nach Reboot -->
|
||||||
<receiver
|
<receiver
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@file:Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
|
||||||
|
|
||||||
package dev.dettmer.simplenotes
|
package dev.dettmer.simplenotes
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
@@ -44,7 +46,15 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
|||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.widget.PopupMenu
|
||||||
|
import dev.dettmer.simplenotes.models.NoteType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy MainActivity - DEPRECATED seit v1.5.0, wird entfernt in v2.0.0
|
||||||
|
* Ersetzt durch ComposeMainActivity
|
||||||
|
*/
|
||||||
|
@Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private lateinit var recyclerViewNotes: RecyclerView
|
private lateinit var recyclerViewNotes: RecyclerView
|
||||||
@@ -128,6 +138,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
setupRecyclerView()
|
setupRecyclerView()
|
||||||
setupFab()
|
setupFab()
|
||||||
|
|
||||||
|
// v1.4.1: Migrate checklists for backwards compatibility
|
||||||
|
migrateChecklistsForBackwardsCompat()
|
||||||
|
|
||||||
loadNotes()
|
loadNotes()
|
||||||
|
|
||||||
// 🔄 v1.3.1: Observe sync state for UI updates
|
// 🔄 v1.3.1: Observe sync state for UI updates
|
||||||
@@ -176,6 +189,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
swipeRefreshLayout.isRefreshing = false
|
swipeRefreshLayout.isRefreshing = false
|
||||||
syncStatusBanner.visibility = View.GONE
|
syncStatusBanner.visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
// v1.5.0: Silent-Sync - Banner nicht anzeigen, aber Sync-Controls deaktivieren
|
||||||
|
SyncStateManager.SyncState.SYNCING_SILENT -> {
|
||||||
|
setSyncControlsEnabled(false)
|
||||||
|
// Kein Banner anzeigen bei Silent-Sync (z.B. onResume Auto-Sync)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,6 +234,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
* - Nur Success-Toast (kein "Auto-Sync..." Toast)
|
* - Nur Success-Toast (kein "Auto-Sync..." Toast)
|
||||||
*
|
*
|
||||||
* NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!)
|
* NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!)
|
||||||
|
* v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt
|
||||||
*/
|
*/
|
||||||
private fun triggerAutoSync(source: String = "unknown") {
|
private fun triggerAutoSync(source: String = "unknown") {
|
||||||
// Throttling: Max 1 Sync pro Minute
|
// Throttling: Max 1 Sync pro Minute
|
||||||
@@ -224,7 +243,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🔄 v1.3.1: Check if sync already running
|
// 🔄 v1.3.1: Check if sync already running
|
||||||
if (!SyncStateManager.tryStartSync("auto-$source")) {
|
// v1.5.0: silent=true - kein Banner bei Auto-Sync
|
||||||
|
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
|
||||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
|
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -454,10 +474,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
val checkboxAlways = dialogView.findViewById<CheckBox>(R.id.checkboxAlwaysDeleteFromServer)
|
val checkboxAlways = dialogView.findViewById<CheckBox>(R.id.checkboxAlwaysDeleteFromServer)
|
||||||
|
|
||||||
MaterialAlertDialogBuilder(this)
|
MaterialAlertDialogBuilder(this)
|
||||||
.setTitle("Notiz löschen")
|
.setTitle(getString(R.string.legacy_delete_dialog_title))
|
||||||
.setMessage("\"${note.title}\" wird lokal gelöscht.\n\nAuch vom Server löschen?")
|
.setMessage(getString(R.string.legacy_delete_dialog_message, note.title))
|
||||||
.setView(dialogView)
|
.setView(dialogView)
|
||||||
.setNeutralButton("Abbrechen") { _, _ ->
|
.setNeutralButton(getString(R.string.cancel)) { _, _ ->
|
||||||
// RESTORE: Re-submit original list (note is NOT deleted from storage)
|
// RESTORE: Re-submit original list (note is NOT deleted from storage)
|
||||||
adapter.submitList(originalList)
|
adapter.submitList(originalList)
|
||||||
}
|
}
|
||||||
@@ -472,7 +492,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// NOW actually delete from storage
|
// NOW actually delete from storage
|
||||||
deleteNoteLocally(note, deleteFromServer = false)
|
deleteNoteLocally(note, deleteFromServer = false)
|
||||||
}
|
}
|
||||||
.setNegativeButton("Vom Server löschen") { _, _ ->
|
.setNegativeButton(getString(R.string.legacy_delete_from_server)) { _, _ ->
|
||||||
if (checkboxAlways.isChecked) {
|
if (checkboxAlways.isChecked) {
|
||||||
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, true).apply()
|
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, true).apply()
|
||||||
}
|
}
|
||||||
@@ -494,13 +514,13 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
// Show Snackbar with UNDO option
|
// Show Snackbar with UNDO option
|
||||||
val message = if (deleteFromServer) {
|
val message = if (deleteFromServer) {
|
||||||
"\"${note.title}\" wird lokal und vom Server gelöscht"
|
getString(R.string.legacy_delete_with_server, note.title)
|
||||||
} else {
|
} else {
|
||||||
"\"${note.title}\" lokal gelöscht (Server bleibt)"
|
getString(R.string.legacy_delete_local_only, note.title)
|
||||||
}
|
}
|
||||||
|
|
||||||
Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG)
|
Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG)
|
||||||
.setAction("RÜCKGÄNGIG") {
|
.setAction(getString(R.string.snackbar_undo)) {
|
||||||
// UNDO: Restore note
|
// UNDO: Restore note
|
||||||
storage.saveNote(note)
|
storage.saveNote(note)
|
||||||
pendingDeletions.remove(note.id)
|
pendingDeletions.remove(note.id)
|
||||||
@@ -522,7 +542,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
this@MainActivity,
|
this@MainActivity,
|
||||||
"Vom Server gelöscht",
|
getString(R.string.snackbar_deleted_from_server),
|
||||||
Toast.LENGTH_SHORT
|
Toast.LENGTH_SHORT
|
||||||
).show()
|
).show()
|
||||||
}
|
}
|
||||||
@@ -530,7 +550,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
this@MainActivity,
|
this@MainActivity,
|
||||||
"Server-Löschung fehlgeschlagen",
|
getString(R.string.snackbar_server_delete_failed),
|
||||||
Toast.LENGTH_LONG
|
Toast.LENGTH_LONG
|
||||||
).show()
|
).show()
|
||||||
}
|
}
|
||||||
@@ -551,12 +571,55 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}).show()
|
}).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.4.0: Setup FAB mit Dropdown für Notiz-Typ Auswahl
|
||||||
|
*/
|
||||||
private fun setupFab() {
|
private fun setupFab() {
|
||||||
fabAddNote.setOnClickListener {
|
fabAddNote.setOnClickListener { view ->
|
||||||
openNoteEditor(null)
|
showNoteTypePopup(view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.4.0: Zeigt Popup-Menü zur Auswahl des Notiz-Typs
|
||||||
|
*/
|
||||||
|
private fun showNoteTypePopup(anchor: View) {
|
||||||
|
val popupMenu = PopupMenu(this, anchor, Gravity.END)
|
||||||
|
popupMenu.inflate(R.menu.menu_fab_note_types)
|
||||||
|
|
||||||
|
// Icons im Popup anzeigen (via Reflection, da standardmäßig ausgeblendet)
|
||||||
|
try {
|
||||||
|
val fields = popupMenu.javaClass.declaredFields
|
||||||
|
for (field in fields) {
|
||||||
|
if ("mPopup" == field.name) {
|
||||||
|
field.isAccessible = true
|
||||||
|
val menuPopupHelper = field.get(popupMenu)
|
||||||
|
val classPopupHelper = Class.forName(menuPopupHelper.javaClass.name)
|
||||||
|
val setForceIcons = classPopupHelper.getMethod("setForceShowIcon", Boolean::class.java)
|
||||||
|
setForceIcons.invoke(menuPopupHelper, true)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.w(TAG, "Could not force show icons in popup menu: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
popupMenu.setOnMenuItemClickListener { menuItem ->
|
||||||
|
val noteType = when (menuItem.itemId) {
|
||||||
|
R.id.action_create_text_note -> NoteType.TEXT
|
||||||
|
R.id.action_create_checklist -> NoteType.CHECKLIST
|
||||||
|
else -> return@setOnMenuItemClickListener false
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(this, NoteEditorActivity::class.java)
|
||||||
|
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
|
||||||
|
startActivity(intent)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
popupMenu.show()
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadNotes() {
|
private fun loadNotes() {
|
||||||
val notes = storage.loadAllNotes()
|
val notes = storage.loadAllNotes()
|
||||||
|
|
||||||
@@ -589,7 +652,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun openSettings() {
|
private fun openSettings() {
|
||||||
val intent = Intent(this, SettingsActivity::class.java)
|
// v1.5.0: Use new Jetpack Compose Settings
|
||||||
|
val intent = Intent(this, dev.dettmer.simplenotes.ui.settings.ComposeSettingsActivity::class.java)
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
startActivityForResult(intent, REQUEST_SETTINGS)
|
startActivityForResult(intent, REQUEST_SETTINGS)
|
||||||
}
|
}
|
||||||
@@ -684,6 +748,54 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.4.1: Migriert bestehende Checklisten für Abwärtskompatibilität.
|
||||||
|
*
|
||||||
|
* Problem: v1.4.0 Checklisten haben leeren "content", was auf älteren
|
||||||
|
* App-Versionen (v1.3.x) als leere Notiz angezeigt wird.
|
||||||
|
*
|
||||||
|
* Lösung: Alle Checklisten ohne Fallback-Content als PENDING markieren,
|
||||||
|
* damit sie beim nächsten Sync mit Fallback-Content hochgeladen werden.
|
||||||
|
*
|
||||||
|
* TODO: Diese Migration kann entfernt werden, sobald v1.4.0 nicht mehr
|
||||||
|
* im Umlauf ist (ca. 6 Monate nach v1.4.1 Release, also ~Juli 2026).
|
||||||
|
* Tracking: https://github.com/inventory69/simple-notes-sync/issues/XXX
|
||||||
|
*/
|
||||||
|
private fun migrateChecklistsForBackwardsCompat() {
|
||||||
|
val migrationKey = "v1.4.1_checklist_migration_done"
|
||||||
|
|
||||||
|
// Nur einmal ausführen
|
||||||
|
if (prefs.getBoolean(migrationKey, false)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val allNotes = storage.loadAllNotes()
|
||||||
|
val checklistsToMigrate = allNotes.filter { note ->
|
||||||
|
note.noteType == NoteType.CHECKLIST &&
|
||||||
|
note.content.isBlank() &&
|
||||||
|
note.checklistItems?.isNotEmpty() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checklistsToMigrate.isNotEmpty()) {
|
||||||
|
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
|
||||||
|
|
||||||
|
for (note in checklistsToMigrate) {
|
||||||
|
// Als PENDING markieren, damit beim nächsten Sync der Fallback-Content
|
||||||
|
// generiert und hochgeladen wird
|
||||||
|
val updatedNote = note.copy(
|
||||||
|
syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING
|
||||||
|
)
|
||||||
|
storage.saveNote(updatedNote)
|
||||||
|
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration als erledigt markieren
|
||||||
|
prefs.edit().putBoolean(migrationKey, true).apply()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onRequestPermissionsResult(
|
override fun onRequestPermissionsResult(
|
||||||
requestCode: Int,
|
requestCode: Int,
|
||||||
permissions: Array<out String>,
|
permissions: Array<out String>,
|
||||||
@@ -695,10 +807,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
REQUEST_NOTIFICATION_PERMISSION -> {
|
REQUEST_NOTIFICATION_PERMISSION -> {
|
||||||
if (grantResults.isNotEmpty() &&
|
if (grantResults.isNotEmpty() &&
|
||||||
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
showToast("Benachrichtigungen aktiviert")
|
showToast(getString(R.string.toast_notifications_enabled))
|
||||||
} else {
|
} else {
|
||||||
showToast("Benachrichtigungen deaktiviert. " +
|
showToast(getString(R.string.toast_notifications_disabled))
|
||||||
"Du kannst sie in den Einstellungen aktivieren.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,27 +3,58 @@ package dev.dettmer.simplenotes
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.LinearLayout
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.appbar.MaterialToolbar
|
import com.google.android.material.appbar.MaterialToolbar
|
||||||
|
import com.google.android.material.button.MaterialButton
|
||||||
import com.google.android.material.color.DynamicColors
|
import com.google.android.material.color.DynamicColors
|
||||||
import com.google.android.material.textfield.TextInputEditText
|
import com.google.android.material.textfield.TextInputEditText
|
||||||
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
|
import dev.dettmer.simplenotes.adapters.ChecklistEditorAdapter
|
||||||
|
import dev.dettmer.simplenotes.models.ChecklistItem
|
||||||
import dev.dettmer.simplenotes.models.Note
|
import dev.dettmer.simplenotes.models.Note
|
||||||
|
import dev.dettmer.simplenotes.models.NoteType
|
||||||
import dev.dettmer.simplenotes.models.SyncStatus
|
import dev.dettmer.simplenotes.models.SyncStatus
|
||||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||||
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
import dev.dettmer.simplenotes.utils.showToast
|
import dev.dettmer.simplenotes.utils.showToast
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor Activity für Notizen und Checklisten
|
||||||
|
*
|
||||||
|
* v1.4.0: Unterstützt jetzt sowohl TEXT als auch CHECKLIST Notizen
|
||||||
|
*/
|
||||||
class NoteEditorActivity : AppCompatActivity() {
|
class NoteEditorActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
// Views
|
||||||
|
private lateinit var toolbar: MaterialToolbar
|
||||||
|
private lateinit var tilTitle: TextInputLayout
|
||||||
private lateinit var editTextTitle: TextInputEditText
|
private lateinit var editTextTitle: TextInputEditText
|
||||||
|
private lateinit var tilContent: TextInputLayout
|
||||||
private lateinit var editTextContent: TextInputEditText
|
private lateinit var editTextContent: TextInputEditText
|
||||||
|
private lateinit var checklistContainer: LinearLayout
|
||||||
|
private lateinit var rvChecklistItems: RecyclerView
|
||||||
|
private lateinit var btnAddItem: MaterialButton
|
||||||
|
|
||||||
private lateinit var storage: NotesStorage
|
private lateinit var storage: NotesStorage
|
||||||
|
|
||||||
|
// State
|
||||||
private var existingNote: Note? = null
|
private var existingNote: Note? = null
|
||||||
|
private var currentNoteType: NoteType = NoteType.TEXT
|
||||||
|
private val checklistItems = mutableListOf<ChecklistItem>()
|
||||||
|
private var checklistAdapter: ChecklistEditorAdapter? = null
|
||||||
|
private var itemTouchHelper: ItemTouchHelper? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val TAG = "NoteEditorActivity"
|
||||||
const val EXTRA_NOTE_ID = "extra_note_id"
|
const val EXTRA_NOTE_ID = "extra_note_id"
|
||||||
|
const val EXTRA_NOTE_TYPE = "extra_note_type"
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -36,39 +67,172 @@ class NoteEditorActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
storage = NotesStorage(this)
|
storage = NotesStorage(this)
|
||||||
|
|
||||||
// Setup toolbar
|
findViews()
|
||||||
val toolbar = findViewById<MaterialToolbar>(R.id.toolbar)
|
setupToolbar()
|
||||||
setSupportActionBar(toolbar)
|
loadNoteOrDetermineType()
|
||||||
supportActionBar?.apply {
|
setupUIForNoteType()
|
||||||
setDisplayHomeAsUpEnabled(true)
|
}
|
||||||
// 🔥 v1.1.2: Use default back arrow (Material Design) instead of X icon
|
|
||||||
// Icon is set in XML: app:navigationIcon="?attr/homeAsUpIndicator"
|
private fun findViews() {
|
||||||
}
|
toolbar = findViewById(R.id.toolbar)
|
||||||
|
tilTitle = findViewById(R.id.tilTitle)
|
||||||
// Find views
|
|
||||||
editTextTitle = findViewById(R.id.editTextTitle)
|
editTextTitle = findViewById(R.id.editTextTitle)
|
||||||
|
tilContent = findViewById(R.id.tilContent)
|
||||||
editTextContent = findViewById(R.id.editTextContent)
|
editTextContent = findViewById(R.id.editTextContent)
|
||||||
|
checklistContainer = findViewById(R.id.checklistContainer)
|
||||||
// Load existing note if editing
|
rvChecklistItems = findViewById(R.id.rvChecklistItems)
|
||||||
|
btnAddItem = findViewById(R.id.btnAddItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupToolbar() {
|
||||||
|
setSupportActionBar(toolbar)
|
||||||
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadNoteOrDetermineType() {
|
||||||
val noteId = intent.getStringExtra(EXTRA_NOTE_ID)
|
val noteId = intent.getStringExtra(EXTRA_NOTE_ID)
|
||||||
|
|
||||||
if (noteId != null) {
|
if (noteId != null) {
|
||||||
|
// Existierende Notiz laden
|
||||||
existingNote = storage.loadNote(noteId)
|
existingNote = storage.loadNote(noteId)
|
||||||
existingNote?.let {
|
existingNote?.let { note ->
|
||||||
editTextTitle.setText(it.title)
|
editTextTitle.setText(note.title)
|
||||||
editTextContent.setText(it.content)
|
currentNoteType = note.noteType
|
||||||
supportActionBar?.title = "Notiz bearbeiten"
|
|
||||||
|
when (note.noteType) {
|
||||||
|
NoteType.TEXT -> {
|
||||||
|
editTextContent.setText(note.content)
|
||||||
|
supportActionBar?.title = getString(R.string.edit_note)
|
||||||
|
}
|
||||||
|
NoteType.CHECKLIST -> {
|
||||||
|
note.checklistItems?.let { items ->
|
||||||
|
checklistItems.clear()
|
||||||
|
checklistItems.addAll(items.sortedBy { it.order })
|
||||||
|
}
|
||||||
|
supportActionBar?.title = getString(R.string.edit_checklist)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
supportActionBar?.title = "Neue Notiz"
|
// Neue Notiz - Typ aus Intent
|
||||||
|
val typeString = intent.getStringExtra(EXTRA_NOTE_TYPE) ?: NoteType.TEXT.name
|
||||||
|
currentNoteType = try {
|
||||||
|
NoteType.valueOf(typeString)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
Logger.w(TAG, "Invalid note type '$typeString', defaulting to TEXT: ${e.message}")
|
||||||
|
NoteType.TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
when (currentNoteType) {
|
||||||
|
NoteType.TEXT -> {
|
||||||
|
supportActionBar?.title = getString(R.string.new_note)
|
||||||
|
}
|
||||||
|
NoteType.CHECKLIST -> {
|
||||||
|
supportActionBar?.title = getString(R.string.new_checklist)
|
||||||
|
// Erstes leeres Item hinzufügen
|
||||||
|
checklistItems.add(ChecklistItem.createEmpty(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupUIForNoteType() {
|
||||||
|
when (currentNoteType) {
|
||||||
|
NoteType.TEXT -> {
|
||||||
|
tilContent.visibility = View.VISIBLE
|
||||||
|
checklistContainer.visibility = View.GONE
|
||||||
|
}
|
||||||
|
NoteType.CHECKLIST -> {
|
||||||
|
tilContent.visibility = View.GONE
|
||||||
|
checklistContainer.visibility = View.VISIBLE
|
||||||
|
setupChecklistRecyclerView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupChecklistRecyclerView() {
|
||||||
|
checklistAdapter = ChecklistEditorAdapter(
|
||||||
|
items = checklistItems,
|
||||||
|
onItemCheckedChanged = { position, isChecked ->
|
||||||
|
if (position in checklistItems.indices) {
|
||||||
|
checklistItems[position].isChecked = isChecked
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onItemTextChanged = { position, newText ->
|
||||||
|
if (position in checklistItems.indices) {
|
||||||
|
checklistItems[position] = checklistItems[position].copy(text = newText)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onItemDeleted = { position ->
|
||||||
|
deleteChecklistItem(position)
|
||||||
|
},
|
||||||
|
onAddNewItem = { position ->
|
||||||
|
addChecklistItemAt(position)
|
||||||
|
},
|
||||||
|
onStartDrag = { viewHolder ->
|
||||||
|
itemTouchHelper?.startDrag(viewHolder)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
rvChecklistItems.apply {
|
||||||
|
layoutManager = LinearLayoutManager(this@NoteEditorActivity)
|
||||||
|
adapter = checklistAdapter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag & Drop Setup
|
||||||
|
val callback = object : ItemTouchHelper.SimpleCallback(
|
||||||
|
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
|
||||||
|
0 // Kein Swipe
|
||||||
|
) {
|
||||||
|
override fun onMove(
|
||||||
|
recyclerView: RecyclerView,
|
||||||
|
viewHolder: RecyclerView.ViewHolder,
|
||||||
|
target: RecyclerView.ViewHolder
|
||||||
|
): Boolean {
|
||||||
|
val from = viewHolder.bindingAdapterPosition
|
||||||
|
val to = target.bindingAdapterPosition
|
||||||
|
checklistAdapter?.moveItem(from, to)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||||
|
// Nicht verwendet
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isLongPressDragEnabled(): Boolean = false // Nur via Handle
|
||||||
|
}
|
||||||
|
|
||||||
|
itemTouchHelper = ItemTouchHelper(callback)
|
||||||
|
itemTouchHelper?.attachToRecyclerView(rvChecklistItems)
|
||||||
|
|
||||||
|
// Add Item Button
|
||||||
|
btnAddItem.setOnClickListener {
|
||||||
|
addChecklistItemAt(checklistItems.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addChecklistItemAt(position: Int) {
|
||||||
|
val newItem = ChecklistItem.createEmpty(position)
|
||||||
|
checklistAdapter?.insertItem(position, newItem)
|
||||||
|
|
||||||
|
// Zum neuen Item scrollen und fokussieren
|
||||||
|
rvChecklistItems.scrollToPosition(position)
|
||||||
|
checklistAdapter?.focusItem(rvChecklistItems, position)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteChecklistItem(position: Int) {
|
||||||
|
checklistAdapter?.removeItem(position)
|
||||||
|
|
||||||
|
// Wenn letztes Item gelöscht, automatisch neues hinzufügen
|
||||||
|
if (checklistItems.isEmpty()) {
|
||||||
|
addChecklistItemAt(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
menuInflater.inflate(R.menu.menu_editor, menu)
|
menuInflater.inflate(R.menu.menu_editor, menu)
|
||||||
|
// Delete nur für existierende Notizen
|
||||||
// Show delete only for existing notes
|
|
||||||
menu.findItem(R.id.action_delete)?.isVisible = existingNote != null
|
menu.findItem(R.id.action_delete)?.isVisible = existingNote != null
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,51 +256,96 @@ class NoteEditorActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun saveNote() {
|
private fun saveNote() {
|
||||||
val title = editTextTitle.text?.toString()?.trim() ?: ""
|
val title = editTextTitle.text?.toString()?.trim() ?: ""
|
||||||
val content = editTextContent.text?.toString()?.trim() ?: ""
|
|
||||||
|
|
||||||
if (title.isEmpty() && content.isEmpty()) {
|
when (currentNoteType) {
|
||||||
showToast("Notiz ist leer")
|
NoteType.TEXT -> {
|
||||||
return
|
val content = editTextContent.text?.toString()?.trim() ?: ""
|
||||||
|
|
||||||
|
if (title.isEmpty() && content.isEmpty()) {
|
||||||
|
showToast(getString(R.string.note_is_empty))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val note = if (existingNote != null) {
|
||||||
|
existingNote!!.copy(
|
||||||
|
title = title,
|
||||||
|
content = content,
|
||||||
|
noteType = NoteType.TEXT,
|
||||||
|
checklistItems = null,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
syncStatus = SyncStatus.PENDING
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Note(
|
||||||
|
title = title,
|
||||||
|
content = content,
|
||||||
|
noteType = NoteType.TEXT,
|
||||||
|
checklistItems = null,
|
||||||
|
deviceId = DeviceIdGenerator.getDeviceId(this),
|
||||||
|
syncStatus = SyncStatus.LOCAL_ONLY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.saveNote(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val note = if (existingNote != null) {
|
showToast(getString(R.string.note_saved))
|
||||||
// Update existing note
|
|
||||||
existingNote!!.copy(
|
|
||||||
title = title,
|
|
||||||
content = content,
|
|
||||||
updatedAt = System.currentTimeMillis(),
|
|
||||||
syncStatus = SyncStatus.PENDING
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Create new note
|
|
||||||
Note(
|
|
||||||
title = title,
|
|
||||||
content = content,
|
|
||||||
deviceId = DeviceIdGenerator.getDeviceId(this),
|
|
||||||
syncStatus = SyncStatus.LOCAL_ONLY
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
storage.saveNote(note)
|
|
||||||
showToast("Notiz gespeichert")
|
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun confirmDelete() {
|
private fun confirmDelete() {
|
||||||
AlertDialog.Builder(this)
|
AlertDialog.Builder(this)
|
||||||
.setTitle("Notiz löschen?")
|
.setTitle(getString(R.string.delete_note_title))
|
||||||
.setMessage("Diese Aktion kann nicht rückgängig gemacht werden.")
|
.setMessage(getString(R.string.delete_note_message))
|
||||||
.setPositiveButton("Löschen") { _, _ ->
|
.setPositiveButton(getString(R.string.delete)) { _, _ ->
|
||||||
deleteNote()
|
deleteNote()
|
||||||
}
|
}
|
||||||
.setNegativeButton("Abbrechen", null)
|
.setNegativeButton(getString(R.string.cancel), null)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteNote() {
|
private fun deleteNote() {
|
||||||
existingNote?.let {
|
existingNote?.let {
|
||||||
storage.deleteNote(it.id)
|
storage.deleteNote(it.id)
|
||||||
showToast("Notiz gelöscht")
|
showToast(getString(R.string.note_deleted))
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@file:Suppress("DEPRECATION") // Legacy code using ProgressDialog & LocalBroadcastManager, will be removed in v2.0.0
|
||||||
|
|
||||||
package dev.dettmer.simplenotes
|
package dev.dettmer.simplenotes
|
||||||
|
|
||||||
import android.app.ProgressDialog
|
import android.app.ProgressDialog
|
||||||
@@ -42,6 +44,7 @@ import java.net.URL
|
|||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Suppress("LargeClass", "DEPRECATION") // Legacy code using ProgressDialog & LocalBroadcastManager, will be removed in v2.0.0
|
||||||
class SettingsActivity : AppCompatActivity() {
|
class SettingsActivity : AppCompatActivity() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -227,9 +230,9 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
*/
|
*/
|
||||||
private fun updateProtocolHint() {
|
private fun updateProtocolHint() {
|
||||||
protocolHintText.text = if (radioHttp.isChecked) {
|
protocolHintText.text = if (radioHttp.isChecked) {
|
||||||
"HTTP nur für lokale Netzwerke (z.B. 192.168.x.x, 10.x.x.x)"
|
getString(R.string.server_connection_http_hint)
|
||||||
} else {
|
} else {
|
||||||
"HTTPS für sichere Verbindungen über das Internet"
|
getString(R.string.server_connection_https_hint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,7 +362,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
60L -> "60 Minuten"
|
60L -> "60 Minuten"
|
||||||
else -> "$newInterval Minuten"
|
else -> "$newInterval Minuten"
|
||||||
}
|
}
|
||||||
showToast("⏱️ Sync-Intervall auf $intervalText geändert")
|
showToast(getString(R.string.toast_sync_interval_changed, intervalText))
|
||||||
Logger.i(TAG, "Sync interval changed to $newInterval minutes, restarted periodic sync")
|
Logger.i(TAG, "Sync interval changed to $newInterval minutes, restarted periodic sync")
|
||||||
} else {
|
} else {
|
||||||
showToast("⏱️ Sync-Intervall gespeichert (Auto-Sync ist deaktiviert)")
|
showToast("⏱️ Sync-Intervall gespeichert (Auto-Sync ist deaktiviert)")
|
||||||
@@ -371,16 +374,15 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
* Setup about section with version info and clickable cards
|
* Setup about section with version info and clickable cards
|
||||||
*/
|
*/
|
||||||
private fun setupAboutSection() {
|
private fun setupAboutSection() {
|
||||||
// Display app version with build date
|
// Display app version
|
||||||
try {
|
try {
|
||||||
val versionName = BuildConfig.VERSION_NAME
|
val versionName = BuildConfig.VERSION_NAME
|
||||||
val versionCode = BuildConfig.VERSION_CODE
|
val versionCode = BuildConfig.VERSION_CODE
|
||||||
val buildDate = BuildConfig.BUILD_DATE
|
|
||||||
|
|
||||||
textViewAppVersion.text = "Version $versionName ($versionCode)\nErstellt am: $buildDate"
|
textViewAppVersion.text = "Version $versionName ($versionCode)"
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "Failed to load version info", e)
|
Logger.e(TAG, "Failed to load version info", e)
|
||||||
textViewAppVersion.text = "Version nicht verfügbar"
|
textViewAppVersion.text = getString(R.string.version_not_available)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GitHub Repository Card
|
// GitHub Repository Card
|
||||||
@@ -476,12 +478,12 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
*/
|
*/
|
||||||
private fun showClearLogsConfirmation() {
|
private fun showClearLogsConfirmation() {
|
||||||
AlertDialog.Builder(this)
|
AlertDialog.Builder(this)
|
||||||
.setTitle("Logs löschen?")
|
.setTitle(getString(R.string.debug_delete_logs_title))
|
||||||
.setMessage("Alle gespeicherten Sync-Logs werden unwiderruflich gelöscht.")
|
.setMessage(getString(R.string.debug_delete_logs_message))
|
||||||
.setPositiveButton("Löschen") { _, _ ->
|
.setPositiveButton(getString(R.string.delete)) { _, _ ->
|
||||||
clearLogs()
|
clearLogs()
|
||||||
}
|
}
|
||||||
.setNegativeButton("Abbrechen", null)
|
.setNegativeButton(getString(R.string.cancel), null)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,13 +494,13 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
try {
|
try {
|
||||||
val cleared = Logger.clearLogFile(this)
|
val cleared = Logger.clearLogFile(this)
|
||||||
if (cleared) {
|
if (cleared) {
|
||||||
showToast("🗑️ Logs gelöscht")
|
showToast(getString(R.string.toast_logs_deleted))
|
||||||
} else {
|
} else {
|
||||||
showToast("📭 Keine Logs zum Löschen")
|
showToast(getString(R.string.toast_no_logs_to_delete))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "Failed to clear logs", e)
|
Logger.e(TAG, "Failed to clear logs", e)
|
||||||
showToast("❌ Fehler beim Löschen: ${e.message}")
|
showToast(getString(R.string.toast_logs_delete_error, e.message ?: ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,7 +513,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "Failed to open URL: $url", e)
|
Logger.e(TAG, "Failed to open URL: $url", e)
|
||||||
showToast("❌ Fehler beim Öffnen des Links")
|
showToast(getString(R.string.toast_link_error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,7 +527,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
// 🔥 v1.1.2: Validate HTTP URL (only allow for local networks)
|
// 🔥 v1.1.2: Validate HTTP URL (only allow for local networks)
|
||||||
if (fullUrl.isNotEmpty()) {
|
if (fullUrl.isNotEmpty()) {
|
||||||
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(fullUrl)
|
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(this, fullUrl)
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
// Only show error in TextField (no Toast)
|
// Only show error in TextField (no Toast)
|
||||||
textInputLayoutServerUrl.isErrorEnabled = true
|
textInputLayoutServerUrl.isErrorEnabled = true
|
||||||
@@ -553,7 +555,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
// 🔥 v1.1.2: Validate before testing
|
// 🔥 v1.1.2: Validate before testing
|
||||||
if (fullUrl.isNotEmpty()) {
|
if (fullUrl.isNotEmpty()) {
|
||||||
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(fullUrl)
|
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(this, fullUrl)
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
// Only show error in TextField (no Toast)
|
// Only show error in TextField (no Toast)
|
||||||
textInputLayoutServerUrl.isErrorEnabled = true
|
textInputLayoutServerUrl.isErrorEnabled = true
|
||||||
@@ -647,7 +649,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
textViewServerStatus.text = "🔍 Prüfe..."
|
textViewServerStatus.text = getString(R.string.status_checking)
|
||||||
textViewServerStatus.setTextColor(getColor(android.R.color.darker_gray))
|
textViewServerStatus.setTextColor(getColor(android.R.color.darker_gray))
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
@@ -804,12 +806,12 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
.setMessage(
|
.setMessage(
|
||||||
"Damit die App im Hintergrund synchronisieren kann, " +
|
"Damit die App im Hintergrund synchronisieren kann, " +
|
||||||
"muss die Akku-Optimierung deaktiviert werden.\n\n" +
|
"muss die Akku-Optimierung deaktiviert werden.\n\n" +
|
||||||
"Bitte wähle 'Nicht optimieren' für Simple Notes."
|
getString(R.string.battery_optimization_dialog_message)
|
||||||
)
|
)
|
||||||
.setPositiveButton("Einstellungen öffnen") { _, _ ->
|
.setPositiveButton(getString(R.string.battery_optimization_open_settings)) { _, _ ->
|
||||||
openBatteryOptimizationSettings()
|
openBatteryOptimizationSettings()
|
||||||
}
|
}
|
||||||
.setNegativeButton("Später") { dialog, _ ->
|
.setNegativeButton(getString(R.string.battery_optimization_later)) { dialog, _ ->
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
}
|
}
|
||||||
.setCancelable(false)
|
.setCancelable(false)
|
||||||
@@ -916,20 +918,20 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
// Radio Buttons erstellen
|
// Radio Buttons erstellen
|
||||||
val radioMerge = android.widget.RadioButton(this).apply {
|
val radioMerge = android.widget.RadioButton(this).apply {
|
||||||
text = "⚪ Zusammenführen (Standard)\n → Neue hinzufügen, Bestehende behalten"
|
text = getString(R.string.backup_mode_merge_full)
|
||||||
id = android.view.View.generateViewId()
|
id = android.view.View.generateViewId()
|
||||||
isChecked = true
|
isChecked = true
|
||||||
setPadding(10, 10, 10, 10)
|
setPadding(10, 10, 10, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
val radioReplace = android.widget.RadioButton(this).apply {
|
val radioReplace = android.widget.RadioButton(this).apply {
|
||||||
text = "⚪ Ersetzen\n → Alle löschen & Backup importieren"
|
text = getString(R.string.backup_mode_replace_full)
|
||||||
id = android.view.View.generateViewId()
|
id = android.view.View.generateViewId()
|
||||||
setPadding(10, 10, 10, 10)
|
setPadding(10, 10, 10, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
val radioOverwrite = android.widget.RadioButton(this).apply {
|
val radioOverwrite = android.widget.RadioButton(this).apply {
|
||||||
text = "⚪ Duplikate überschreiben\n → Backup gewinnt bei Konflikten"
|
text = getString(R.string.backup_mode_overwrite_full)
|
||||||
id = android.view.View.generateViewId()
|
id = android.view.View.generateViewId()
|
||||||
setPadding(10, 10, 10, 10)
|
setPadding(10, 10, 10, 10)
|
||||||
}
|
}
|
||||||
@@ -979,7 +981,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
RestoreSource.WEBDAV_SERVER -> performRestoreFromServer(selectedMode)
|
RestoreSource.WEBDAV_SERVER -> performRestoreFromServer(selectedMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.setNegativeButton("Abbrechen", null)
|
.setNegativeButton(getString(R.string.cancel), null)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,14 @@ class SimpleNotesApplication : Application() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
// File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!)
|
|
||||||
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
// 🔧 Hotfix v1.6.2: Migrate offline mode setting BEFORE any ViewModel initialization
|
||||||
|
// This prevents the offline mode bug where users updating from v1.5.0 incorrectly
|
||||||
|
// appear as offline even though they have a configured server
|
||||||
|
migrateOfflineModeSetting(prefs)
|
||||||
|
|
||||||
|
// File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!)
|
||||||
if (prefs.getBoolean("file_logging_enabled", false)) {
|
if (prefs.getBoolean("file_logging_enabled", false)) {
|
||||||
Logger.enableFileLogging(this)
|
Logger.enableFileLogging(this)
|
||||||
Logger.d(TAG, "📝 File logging enabled at Application startup")
|
Logger.d(TAG, "📝 File logging enabled at Application startup")
|
||||||
@@ -50,4 +56,30 @@ class SimpleNotesApplication : Application() {
|
|||||||
// WorkManager läuft weiter auch nach onTerminate!
|
// WorkManager läuft weiter auch nach onTerminate!
|
||||||
// Nur bei deaktiviertem Auto-Sync stoppen wir es
|
// Nur bei deaktiviertem Auto-Sync stoppen wir es
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔧 Hotfix v1.6.2: Migrate offline mode setting for updates from v1.5.0
|
||||||
|
*
|
||||||
|
* Problem: KEY_OFFLINE_MODE didn't exist in v1.5.0, but MainViewModel
|
||||||
|
* and NoteEditorViewModel use `true` as default, causing existing users
|
||||||
|
* with configured servers to appear in offline mode after update.
|
||||||
|
*
|
||||||
|
* Fix: Set the key BEFORE any ViewModel is initialized based on whether
|
||||||
|
* a server was already configured.
|
||||||
|
*/
|
||||||
|
private fun migrateOfflineModeSetting(prefs: android.content.SharedPreferences) {
|
||||||
|
if (!prefs.contains(Constants.KEY_OFFLINE_MODE)) {
|
||||||
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||||
|
val hasServerConfig = !serverUrl.isNullOrEmpty() &&
|
||||||
|
serverUrl != "http://" &&
|
||||||
|
serverUrl != "https://"
|
||||||
|
|
||||||
|
// If server was configured → offlineMode = false (continue syncing)
|
||||||
|
// If no server → offlineMode = true (new users / offline users)
|
||||||
|
val offlineModeValue = !hasServerConfig
|
||||||
|
prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, offlineModeValue).apply()
|
||||||
|
|
||||||
|
Logger.i(TAG, "🔄 Migrated offline_mode_enabled: hasServer=$hasServerConfig → offlineMode=$offlineModeValue")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 androidx.recyclerview.widget.RecyclerView
|
||||||
import dev.dettmer.simplenotes.R
|
import dev.dettmer.simplenotes.R
|
||||||
import dev.dettmer.simplenotes.models.Note
|
import dev.dettmer.simplenotes.models.Note
|
||||||
|
import dev.dettmer.simplenotes.models.NoteType
|
||||||
import dev.dettmer.simplenotes.models.SyncStatus
|
import dev.dettmer.simplenotes.models.SyncStatus
|
||||||
import dev.dettmer.simplenotes.utils.Constants
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
import dev.dettmer.simplenotes.utils.toReadableTime
|
import dev.dettmer.simplenotes.utils.toReadableTime
|
||||||
import dev.dettmer.simplenotes.utils.truncate
|
import dev.dettmer.simplenotes.utils.truncate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter für die Notizen-Liste
|
||||||
|
*
|
||||||
|
* v1.4.0: Unterstützt jetzt TEXT und CHECKLIST Notizen
|
||||||
|
*/
|
||||||
class NotesAdapter(
|
class NotesAdapter(
|
||||||
private val onNoteClick: (Note) -> Unit
|
private val onNoteClick: (Note) -> Unit
|
||||||
) : ListAdapter<Note, NotesAdapter.NoteViewHolder>(NoteDiffCallback()) {
|
) : ListAdapter<Note, NotesAdapter.NoteViewHolder>(NoteDiffCallback()) {
|
||||||
@@ -31,16 +37,46 @@ class NotesAdapter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
inner class NoteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
inner class NoteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||||
|
private val ivNoteTypeIcon: ImageView = itemView.findViewById(R.id.ivNoteTypeIcon)
|
||||||
private val textViewTitle: TextView = itemView.findViewById(R.id.textViewTitle)
|
private val textViewTitle: TextView = itemView.findViewById(R.id.textViewTitle)
|
||||||
private val textViewContent: TextView = itemView.findViewById(R.id.textViewContent)
|
private val textViewContent: TextView = itemView.findViewById(R.id.textViewContent)
|
||||||
|
private val textViewChecklistPreview: TextView = itemView.findViewById(R.id.textViewChecklistPreview)
|
||||||
private val textViewTimestamp: TextView = itemView.findViewById(R.id.textViewTimestamp)
|
private val textViewTimestamp: TextView = itemView.findViewById(R.id.textViewTimestamp)
|
||||||
private val imageViewSyncStatus: ImageView = itemView.findViewById(R.id.imageViewSyncStatus)
|
private val imageViewSyncStatus: ImageView = itemView.findViewById(R.id.imageViewSyncStatus)
|
||||||
|
|
||||||
fun bind(note: Note) {
|
fun bind(note: Note) {
|
||||||
textViewTitle.text = note.title.ifEmpty { "Ohne Titel" }
|
// Titel
|
||||||
textViewContent.text = note.content.truncate(100)
|
textViewTitle.text = note.title.ifEmpty {
|
||||||
|
itemView.context.getString(R.string.untitled)
|
||||||
|
}
|
||||||
textViewTimestamp.text = note.updatedAt.toReadableTime()
|
textViewTimestamp.text = note.updatedAt.toReadableTime()
|
||||||
|
|
||||||
|
// v1.4.0: Typ-spezifische Anzeige
|
||||||
|
when (note.noteType) {
|
||||||
|
NoteType.TEXT -> {
|
||||||
|
ivNoteTypeIcon.setImageResource(R.drawable.ic_note_24)
|
||||||
|
textViewContent.text = note.content.truncate(100)
|
||||||
|
textViewContent.visibility = View.VISIBLE
|
||||||
|
textViewChecklistPreview.visibility = View.GONE
|
||||||
|
}
|
||||||
|
NoteType.CHECKLIST -> {
|
||||||
|
ivNoteTypeIcon.setImageResource(R.drawable.ic_checklist_24)
|
||||||
|
textViewContent.visibility = View.GONE
|
||||||
|
textViewChecklistPreview.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// Fortschritt berechnen
|
||||||
|
val items = note.checklistItems ?: emptyList()
|
||||||
|
val checkedCount = items.count { it.isChecked }
|
||||||
|
val totalCount = items.size
|
||||||
|
|
||||||
|
textViewChecklistPreview.text = if (totalCount > 0) {
|
||||||
|
itemView.context.getString(R.string.checklist_progress, checkedCount, totalCount)
|
||||||
|
} else {
|
||||||
|
itemView.context.getString(R.string.empty_checklist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sync Icon nur zeigen wenn Sync konfiguriert ist
|
// Sync Icon nur zeigen wenn Sync konfiguriert ist
|
||||||
val prefs = itemView.context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = itemView.context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.net.Uri
|
|||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.GsonBuilder
|
import com.google.gson.GsonBuilder
|
||||||
import dev.dettmer.simplenotes.BuildConfig
|
import dev.dettmer.simplenotes.BuildConfig
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
import dev.dettmer.simplenotes.models.Note
|
import dev.dettmer.simplenotes.models.Note
|
||||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
@@ -30,20 +31,24 @@ class BackupManager(private val context: Context) {
|
|||||||
private const val BACKUP_VERSION = 1
|
private const val BACKUP_VERSION = 1
|
||||||
private const val AUTO_BACKUP_DIR = "auto_backups"
|
private const val AUTO_BACKUP_DIR = "auto_backups"
|
||||||
private const val AUTO_BACKUP_RETENTION_DAYS = 7
|
private const val AUTO_BACKUP_RETENTION_DAYS = 7
|
||||||
|
private const val MAGIC_BYTES_LENGTH = 4 // v1.7.0: For encryption check
|
||||||
}
|
}
|
||||||
|
|
||||||
private val storage = NotesStorage(context)
|
private val storage = NotesStorage(context)
|
||||||
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
||||||
|
private val encryptionManager = EncryptionManager() // 🔐 v1.7.0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt Backup aller Notizen
|
* Erstellt Backup aller Notizen
|
||||||
*
|
*
|
||||||
* @param uri Output-URI (via Storage Access Framework)
|
* @param uri Output-URI (via Storage Access Framework)
|
||||||
|
* @param password Optional password for encryption (null = unencrypted)
|
||||||
* @return BackupResult mit Erfolg/Fehler Info
|
* @return BackupResult mit Erfolg/Fehler Info
|
||||||
*/
|
*/
|
||||||
suspend fun createBackup(uri: Uri): BackupResult = withContext(Dispatchers.IO) {
|
suspend fun createBackup(uri: Uri, password: String? = null): BackupResult = withContext(Dispatchers.IO) {
|
||||||
return@withContext try {
|
return@withContext try {
|
||||||
Logger.d(TAG, "📦 Creating backup to: $uri")
|
val encryptedSuffix = if (password != null) " (encrypted)" else ""
|
||||||
|
Logger.d(TAG, "📦 Creating backup$encryptedSuffix to: $uri")
|
||||||
|
|
||||||
val allNotes = storage.loadAllNotes()
|
val allNotes = storage.loadAllNotes()
|
||||||
Logger.d(TAG, " Found ${allNotes.size} notes to backup")
|
Logger.d(TAG, " Found ${allNotes.size} notes to backup")
|
||||||
@@ -58,15 +63,22 @@ class BackupManager(private val context: Context) {
|
|||||||
|
|
||||||
val jsonString = gson.toJson(backupData)
|
val jsonString = gson.toJson(backupData)
|
||||||
|
|
||||||
|
// 🔐 v1.7.0: Encrypt if password is provided
|
||||||
|
val dataToWrite = if (password != null) {
|
||||||
|
encryptionManager.encrypt(jsonString.toByteArray(), password)
|
||||||
|
} else {
|
||||||
|
jsonString.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
outputStream.write(jsonString.toByteArray())
|
outputStream.write(dataToWrite)
|
||||||
Logger.d(TAG, "✅ Backup created successfully")
|
Logger.d(TAG, "✅ Backup created successfully$encryptedSuffix")
|
||||||
}
|
}
|
||||||
|
|
||||||
BackupResult(
|
BackupResult(
|
||||||
success = true,
|
success = true,
|
||||||
notesCount = allNotes.size,
|
notesCount = allNotes.size,
|
||||||
message = "Backup erstellt: ${allNotes.size} Notizen"
|
message = "Backup erstellt: ${allNotes.size} Notizen$encryptedSuffix"
|
||||||
)
|
)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -125,26 +137,48 @@ class BackupManager(private val context: Context) {
|
|||||||
*
|
*
|
||||||
* @param uri Backup-Datei URI
|
* @param uri Backup-Datei URI
|
||||||
* @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite)
|
* @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite)
|
||||||
|
* @param password Optional password if backup is encrypted
|
||||||
* @return RestoreResult mit Details
|
* @return RestoreResult mit Details
|
||||||
*/
|
*/
|
||||||
suspend fun restoreBackup(uri: Uri, mode: RestoreMode): RestoreResult = withContext(Dispatchers.IO) {
|
suspend fun restoreBackup(uri: Uri, mode: RestoreMode, password: String? = null): RestoreResult = withContext(Dispatchers.IO) {
|
||||||
return@withContext try {
|
return@withContext try {
|
||||||
Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)")
|
Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)")
|
||||||
|
|
||||||
// 1. Backup-Datei lesen
|
// 1. Backup-Datei lesen
|
||||||
val jsonString = context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
val fileData = context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||||
inputStream.bufferedReader().use { it.readText() }
|
inputStream.readBytes()
|
||||||
} ?: return@withContext RestoreResult(
|
} ?: return@withContext RestoreResult(
|
||||||
success = false,
|
success = false,
|
||||||
error = "Datei konnte nicht gelesen werden"
|
error = "Datei konnte nicht gelesen werden"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 🔐 v1.7.0: Check if encrypted and decrypt if needed
|
||||||
|
val jsonString = try {
|
||||||
|
if (encryptionManager.isEncrypted(fileData)) {
|
||||||
|
if (password == null) {
|
||||||
|
return@withContext RestoreResult(
|
||||||
|
success = false,
|
||||||
|
error = "Backup ist verschlüsselt. Bitte Passwort eingeben."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val decrypted = encryptionManager.decrypt(fileData, password)
|
||||||
|
String(decrypted)
|
||||||
|
} else {
|
||||||
|
String(fileData)
|
||||||
|
}
|
||||||
|
} catch (e: EncryptionException) {
|
||||||
|
return@withContext RestoreResult(
|
||||||
|
success = false,
|
||||||
|
error = "Entschlüsselung fehlgeschlagen: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Backup validieren & parsen
|
// 2. Backup validieren & parsen
|
||||||
val validationResult = validateBackup(jsonString)
|
val validationResult = validateBackup(jsonString)
|
||||||
if (!validationResult.isValid) {
|
if (!validationResult.isValid) {
|
||||||
return@withContext RestoreResult(
|
return@withContext RestoreResult(
|
||||||
success = false,
|
success = false,
|
||||||
error = validationResult.errorMessage ?: "Ungültige Backup-Datei"
|
error = validationResult.errorMessage ?: context.getString(R.string.error_invalid_backup_file)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,11 +205,27 @@ class BackupManager(private val context: Context) {
|
|||||||
Logger.e(TAG, "Failed to restore backup", e)
|
Logger.e(TAG, "Failed to restore backup", e)
|
||||||
RestoreResult(
|
RestoreResult(
|
||||||
success = false,
|
success = false,
|
||||||
error = "Wiederherstellung fehlgeschlagen: ${e.message}"
|
error = context.getString(R.string.error_restore_failed, e.message ?: "")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔐 v1.7.0: Check if backup file is encrypted
|
||||||
|
*/
|
||||||
|
suspend fun isBackupEncrypted(uri: Uri): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
return@withContext try {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||||
|
val header = ByteArray(MAGIC_BYTES_LENGTH)
|
||||||
|
val bytesRead = inputStream.read(header)
|
||||||
|
bytesRead == MAGIC_BYTES_LENGTH && encryptionManager.isEncrypted(header)
|
||||||
|
} ?: false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to check encryption status", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validiert Backup-Datei
|
* Validiert Backup-Datei
|
||||||
*/
|
*/
|
||||||
@@ -187,8 +237,7 @@ class BackupManager(private val context: Context) {
|
|||||||
if (backupData.backupVersion > BACKUP_VERSION) {
|
if (backupData.backupVersion > BACKUP_VERSION) {
|
||||||
return ValidationResult(
|
return ValidationResult(
|
||||||
isValid = false,
|
isValid = false,
|
||||||
errorMessage = "Backup-Version nicht unterstützt " +
|
errorMessage = context.getString(R.string.error_backup_version_unsupported, backupData.backupVersion, BACKUP_VERSION)
|
||||||
"(v${backupData.backupVersion} benötigt v${BACKUP_VERSION}+)"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +245,7 @@ class BackupManager(private val context: Context) {
|
|||||||
if (backupData.notes.isEmpty()) {
|
if (backupData.notes.isEmpty()) {
|
||||||
return ValidationResult(
|
return ValidationResult(
|
||||||
isValid = false,
|
isValid = false,
|
||||||
errorMessage = "Backup enthält keine Notizen"
|
errorMessage = context.getString(R.string.error_backup_empty)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +257,7 @@ class BackupManager(private val context: Context) {
|
|||||||
if (invalidNotes.isNotEmpty()) {
|
if (invalidNotes.isNotEmpty()) {
|
||||||
return ValidationResult(
|
return ValidationResult(
|
||||||
isValid = false,
|
isValid = false,
|
||||||
errorMessage = "Backup enthält ${invalidNotes.size} ungültige Notizen"
|
errorMessage = context.getString(R.string.error_backup_invalid_notes, invalidNotes.size)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +266,7 @@ class BackupManager(private val context: Context) {
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
ValidationResult(
|
ValidationResult(
|
||||||
isValid = false,
|
isValid = false,
|
||||||
errorMessage = "Backup-Datei beschädigt oder ungültig: ${e.message}"
|
errorMessage = context.getString(R.string.error_backup_corrupt, e.message ?: "")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,7 +290,7 @@ class BackupManager(private val context: Context) {
|
|||||||
success = true,
|
success = true,
|
||||||
importedNotes = newNotes.size,
|
importedNotes = newNotes.size,
|
||||||
skippedNotes = skippedNotes,
|
skippedNotes = skippedNotes,
|
||||||
message = "${newNotes.size} neue Notizen importiert, $skippedNotes übersprungen"
|
message = context.getString(R.string.restore_merge_result, newNotes.size, skippedNotes)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,10 +311,10 @@ class BackupManager(private val context: Context) {
|
|||||||
success = true,
|
success = true,
|
||||||
importedNotes = backupNotes.size,
|
importedNotes = backupNotes.size,
|
||||||
skippedNotes = 0,
|
skippedNotes = 0,
|
||||||
message = "Alle Notizen ersetzt: ${backupNotes.size} importiert"
|
message = context.getString(R.string.restore_replace_result, backupNotes.size)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore-Modus: OVERWRITE_DUPLICATES
|
* Restore-Modus: OVERWRITE_DUPLICATES
|
||||||
* Backup überschreibt bei ID-Konflikten
|
* Backup überschreibt bei ID-Konflikten
|
||||||
@@ -287,7 +336,7 @@ class BackupManager(private val context: Context) {
|
|||||||
importedNotes = newNotes.size,
|
importedNotes = newNotes.size,
|
||||||
skippedNotes = 0,
|
skippedNotes = 0,
|
||||||
overwrittenNotes = overwrittenNotes.size,
|
overwrittenNotes = overwrittenNotes.size,
|
||||||
message = "${newNotes.size} neu, ${overwrittenNotes.size} überschrieben"
|
message = context.getString(R.string.restore_overwrite_result, newNotes.size, overwrittenNotes.size)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package dev.dettmer.simplenotes.models
|
package dev.dettmer.simplenotes.models
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@@ -7,6 +8,12 @@ import java.util.Locale
|
|||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note data class with Compose stability annotation.
|
||||||
|
* @Immutable tells Compose this class is stable and won't change unexpectedly,
|
||||||
|
* enabling skip optimizations during recomposition.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
data class Note(
|
data class Note(
|
||||||
val id: String = UUID.randomUUID().toString(),
|
val id: String = UUID.randomUUID().toString(),
|
||||||
val title: String,
|
val title: String,
|
||||||
@@ -14,56 +21,195 @@ data class Note(
|
|||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
val updatedAt: Long = System.currentTimeMillis(),
|
val updatedAt: Long = System.currentTimeMillis(),
|
||||||
val deviceId: String,
|
val deviceId: String,
|
||||||
val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY
|
val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY,
|
||||||
|
// v1.4.0: Checklisten-Felder
|
||||||
|
val noteType: NoteType = NoteType.TEXT,
|
||||||
|
val checklistItems: List<ChecklistItem>? = null
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* Serialisiert Note zu JSON
|
||||||
|
* v1.4.0: Nutzt Gson für komplexe Strukturen
|
||||||
|
* v1.4.1: Für Checklisten wird ein Fallback-Content generiert, damit ältere
|
||||||
|
* App-Versionen (v1.3.x) die Notiz als Text anzeigen können.
|
||||||
|
*/
|
||||||
fun toJson(): String {
|
fun toJson(): String {
|
||||||
return """
|
val gson = com.google.gson.GsonBuilder()
|
||||||
{
|
.setPrettyPrinting()
|
||||||
"id": "$id",
|
.create()
|
||||||
"title": "${title.escapeJson()}",
|
|
||||||
"content": "${content.escapeJson()}",
|
// v1.4.1: Für Checklisten den Fallback-Content generieren
|
||||||
"createdAt": $createdAt,
|
val noteToSerialize = if (noteType == NoteType.CHECKLIST && checklistItems != null) {
|
||||||
"updatedAt": $updatedAt,
|
this.copy(content = generateChecklistFallbackContent())
|
||||||
"deviceId": "$deviceId",
|
} else {
|
||||||
"syncStatus": "${syncStatus.name}"
|
this
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
|
||||||
|
return gson.toJson(noteToSerialize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.4.1: Generiert einen lesbaren Text-Fallback aus Checklist-Items.
|
||||||
|
* Format: GitHub-Style Task-Listen (kompatibel mit Markdown)
|
||||||
|
*
|
||||||
|
* Beispiel:
|
||||||
|
* [ ] Milch kaufen
|
||||||
|
* [x] Brot gekauft
|
||||||
|
* [ ] Eier
|
||||||
|
*
|
||||||
|
* Wird von älteren App-Versionen (v1.3.x) als normaler Text angezeigt.
|
||||||
|
*/
|
||||||
|
private fun generateChecklistFallbackContent(): String {
|
||||||
|
return checklistItems?.sortedBy { it.order }?.joinToString("\n") { item ->
|
||||||
|
val checkbox = if (item.isChecked) "[x]" else "[ ]"
|
||||||
|
"$checkbox ${item.text}"
|
||||||
|
} ?: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Konvertiert Note zu Markdown mit YAML Frontmatter (Task #1.2.0-08)
|
* Konvertiert Note zu Markdown mit YAML Frontmatter (Task #1.2.0-08)
|
||||||
* Format kompatibel mit Obsidian, Joplin, Typora
|
* Format kompatibel mit Obsidian, Joplin, Typora
|
||||||
|
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
|
||||||
*/
|
*/
|
||||||
fun toMarkdown(): String {
|
fun toMarkdown(): String {
|
||||||
return """
|
val header = """
|
||||||
---
|
---
|
||||||
id: $id
|
id: $id
|
||||||
created: ${formatISO8601(createdAt)}
|
created: ${formatISO8601(createdAt)}
|
||||||
updated: ${formatISO8601(updatedAt)}
|
updated: ${formatISO8601(updatedAt)}
|
||||||
device: $deviceId
|
device: $deviceId
|
||||||
|
type: ${noteType.name.lowercase()}
|
||||||
---
|
---
|
||||||
|
|
||||||
# $title
|
# $title
|
||||||
|
|
||||||
$content
|
""".trimIndent()
|
||||||
""".trimIndent()
|
|
||||||
|
return when (noteType) {
|
||||||
|
NoteType.TEXT -> header + content
|
||||||
|
NoteType.CHECKLIST -> {
|
||||||
|
val checklistMarkdown = checklistItems?.sortedBy { it.order }?.joinToString("\n") { item ->
|
||||||
|
val checkbox = if (item.isChecked) "[x]" else "[ ]"
|
||||||
|
"- $checkbox ${item.text}"
|
||||||
|
} ?: ""
|
||||||
|
header + checklistMarkdown
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "Note"
|
private const val TAG = "Note"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst JSON zu Note-Objekt mit Backward Compatibility für alte Notizen ohne noteType
|
||||||
|
*/
|
||||||
fun fromJson(json: String): Note? {
|
fun fromJson(json: String): Note? {
|
||||||
return try {
|
return try {
|
||||||
val gson = com.google.gson.Gson()
|
val gson = com.google.gson.Gson()
|
||||||
gson.fromJson(json, Note::class.java)
|
val jsonObject = com.google.gson.JsonParser.parseString(json).asJsonObject
|
||||||
|
|
||||||
|
// Backward Compatibility: Alte Notizen ohne noteType bekommen TEXT
|
||||||
|
val noteType = if (jsonObject.has("noteType") && !jsonObject.get("noteType").isJsonNull) {
|
||||||
|
try {
|
||||||
|
NoteType.valueOf(jsonObject.get("noteType").asString)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.w(TAG, "Unknown noteType, defaulting to TEXT: ${e.message}")
|
||||||
|
NoteType.TEXT
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NoteType.TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsen der Basis-Note
|
||||||
|
val rawNote = gson.fromJson(json, NoteRaw::class.java)
|
||||||
|
|
||||||
|
// Checklist-Items parsen (kann null sein)
|
||||||
|
val checklistItemsType = object : com.google.gson.reflect.TypeToken<List<ChecklistItem>>() {}.type
|
||||||
|
var checklistItems: List<ChecklistItem>? = if (jsonObject.has("checklistItems") &&
|
||||||
|
!jsonObject.get("checklistItems").isJsonNull
|
||||||
|
) {
|
||||||
|
gson.fromJson<List<ChecklistItem>>(
|
||||||
|
jsonObject.get("checklistItems"),
|
||||||
|
checklistItemsType
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.4.1: Recovery-Mode - Falls Checkliste aber keine Items,
|
||||||
|
// versuche Content als Fallback zu parsen
|
||||||
|
if (noteType == NoteType.CHECKLIST &&
|
||||||
|
(checklistItems == null || checklistItems.isEmpty()) &&
|
||||||
|
rawNote.content.isNotBlank()) {
|
||||||
|
|
||||||
|
val recoveredItems = parseChecklistFromContent(rawNote.content)
|
||||||
|
if (recoveredItems.isNotEmpty()) {
|
||||||
|
Logger.d(TAG, "🔄 Recovered ${recoveredItems.size} checklist items from content fallback")
|
||||||
|
checklistItems = recoveredItems
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note mit korrekten Werten erstellen
|
||||||
|
Note(
|
||||||
|
id = rawNote.id,
|
||||||
|
title = rawNote.title,
|
||||||
|
content = rawNote.content,
|
||||||
|
createdAt = rawNote.createdAt,
|
||||||
|
updatedAt = rawNote.updatedAt,
|
||||||
|
deviceId = rawNote.deviceId,
|
||||||
|
syncStatus = rawNote.syncStatus ?: SyncStatus.LOCAL_ONLY,
|
||||||
|
noteType = noteType,
|
||||||
|
checklistItems = checklistItems
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.w(TAG, "Failed to parse JSON: ${e.message}")
|
Logger.w(TAG, "Failed to parse JSON: ${e.message}")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hilfsklasse für Gson-Parsing mit nullable Feldern
|
||||||
|
*/
|
||||||
|
private data class NoteRaw(
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
val title: String = "",
|
||||||
|
val content: String = "",
|
||||||
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
val updatedAt: Long = System.currentTimeMillis(),
|
||||||
|
val deviceId: String = "",
|
||||||
|
val syncStatus: SyncStatus? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.4.1: Parst GitHub-Style Checklisten aus Text (Recovery-Mode).
|
||||||
|
*
|
||||||
|
* Unterstützte Formate:
|
||||||
|
* - [ ] Unchecked item
|
||||||
|
* - [x] Checked item
|
||||||
|
* - [X] Checked item (case insensitive)
|
||||||
|
*
|
||||||
|
* Wird verwendet, wenn eine v1.4.0 Checkliste von einer älteren
|
||||||
|
* App-Version (v1.3.x) bearbeitet wurde und die checklistItems verloren gingen.
|
||||||
|
*
|
||||||
|
* @param content Der Text-Content der Notiz
|
||||||
|
* @return Liste von ChecklistItems oder leere Liste
|
||||||
|
*/
|
||||||
|
private fun parseChecklistFromContent(content: String): List<ChecklistItem> {
|
||||||
|
val pattern = Regex("""^\s*\[([ xX])\]\s*(.+)$""", RegexOption.MULTILINE)
|
||||||
|
return pattern.findAll(content).mapIndexed { index, match ->
|
||||||
|
val checked = match.groupValues[1].lowercase() == "x"
|
||||||
|
val text = match.groupValues[2].trim()
|
||||||
|
ChecklistItem(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
text = text,
|
||||||
|
isChecked = checked,
|
||||||
|
order = index
|
||||||
|
)
|
||||||
|
}.toList()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09)
|
* Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09)
|
||||||
|
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
|
||||||
*
|
*
|
||||||
* @param md Markdown-String mit YAML Frontmatter
|
* @param md Markdown-String mit YAML Frontmatter
|
||||||
* @return Note-Objekt oder null bei Parse-Fehler
|
* @return Note-Objekt oder null bei Parse-Fehler
|
||||||
@@ -91,10 +237,47 @@ $content
|
|||||||
.firstOrNull { it.startsWith("# ") }
|
.firstOrNull { it.startsWith("# ") }
|
||||||
?.removePrefix("# ")?.trim() ?: "Untitled"
|
?.removePrefix("# ")?.trim() ?: "Untitled"
|
||||||
|
|
||||||
// Extract content (everything after heading)
|
// v1.4.0: Prüfe ob type: checklist im Frontmatter
|
||||||
val content = contentBlock
|
val noteTypeStr = metadata["type"]?.lowercase() ?: "text"
|
||||||
.substringAfter("# $title\n\n", "")
|
val noteType = when (noteTypeStr) {
|
||||||
.trim()
|
"checklist" -> NoteType.CHECKLIST
|
||||||
|
else -> NoteType.TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.4.0: Parse Content basierend auf Typ
|
||||||
|
// FIX: Robusteres Parsing - suche nach dem Titel-Header und extrahiere den Rest
|
||||||
|
val titleLineIndex = contentBlock.lines().indexOfFirst { it.startsWith("# ") }
|
||||||
|
val contentAfterTitle = if (titleLineIndex >= 0) {
|
||||||
|
// Alles nach der Titel-Zeile, überspringe führende Leerzeilen
|
||||||
|
contentBlock.lines()
|
||||||
|
.drop(titleLineIndex + 1)
|
||||||
|
.dropWhile { it.isBlank() }
|
||||||
|
.joinToString("\n")
|
||||||
|
.trim()
|
||||||
|
} else {
|
||||||
|
// Fallback: Gesamter Content (kein Titel gefunden)
|
||||||
|
contentBlock.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
val content: String
|
||||||
|
val checklistItems: List<ChecklistItem>?
|
||||||
|
|
||||||
|
if (noteType == NoteType.CHECKLIST) {
|
||||||
|
// Parse Checklist Items
|
||||||
|
val checklistRegex = Regex("^- \\[([ xX])\\] (.*)$", RegexOption.MULTILINE)
|
||||||
|
checklistItems = checklistRegex.findAll(contentAfterTitle).mapIndexed { index, matchResult ->
|
||||||
|
ChecklistItem(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
text = matchResult.groupValues[2].trim(),
|
||||||
|
isChecked = matchResult.groupValues[1].lowercase() == "x",
|
||||||
|
order = index
|
||||||
|
)
|
||||||
|
}.toList().ifEmpty { null }
|
||||||
|
content = "" // Checklisten haben keinen "content"
|
||||||
|
} else {
|
||||||
|
content = contentAfterTitle
|
||||||
|
checklistItems = null
|
||||||
|
}
|
||||||
|
|
||||||
Note(
|
Note(
|
||||||
id = metadata["id"] ?: UUID.randomUUID().toString(),
|
id = metadata["id"] ?: UUID.randomUUID().toString(),
|
||||||
@@ -103,7 +286,9 @@ $content
|
|||||||
createdAt = parseISO8601(metadata["created"] ?: ""),
|
createdAt = parseISO8601(metadata["created"] ?: ""),
|
||||||
updatedAt = parseISO8601(metadata["updated"] ?: ""),
|
updatedAt = parseISO8601(metadata["updated"] ?: ""),
|
||||||
deviceId = metadata["device"] ?: "desktop",
|
deviceId = metadata["device"] ?: "desktop",
|
||||||
syncStatus = SyncStatus.SYNCED // Annahme: Vom Server importiert
|
syncStatus = SyncStatus.SYNCED, // Annahme: Vom Server importiert
|
||||||
|
noteType = noteType,
|
||||||
|
checklistItems = checklistItems
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.w(TAG, "Failed to parse Markdown: ${e.message}")
|
Logger.w(TAG, "Failed to parse Markdown: ${e.message}")
|
||||||
@@ -138,6 +323,34 @@ $content
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🎨 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Extension für JSON-Escaping
|
// Extension für JSON-Escaping
|
||||||
fun String.escapeJson(): String {
|
fun String.escapeJson(): String {
|
||||||
return this
|
return this
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -123,6 +123,26 @@ class NotesStorage(private val context: Context) {
|
|||||||
saveDeletionTracker(DeletionTracker())
|
saveDeletionTracker(DeletionTracker())
|
||||||
Logger.d(TAG, "🗑️ Deletion tracker cleared")
|
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
|
fun getNotesDir(): File = notesDir
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import dev.dettmer.simplenotes.utils.Logger
|
|||||||
/**
|
/**
|
||||||
* BootReceiver: Startet WorkManager nach Device Reboot
|
* BootReceiver: Startet WorkManager nach Device Reboot
|
||||||
* CRITICAL: Ohne diesen Receiver funktioniert Auto-Sync nach Reboot NICHT!
|
* CRITICAL: Ohne diesen Receiver funktioniert Auto-Sync nach Reboot NICHT!
|
||||||
|
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_BOOT
|
||||||
*/
|
*/
|
||||||
class BootReceiver : BroadcastReceiver() {
|
class BootReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
@@ -24,16 +25,22 @@ class BootReceiver : BroadcastReceiver() {
|
|||||||
|
|
||||||
Logger.d(TAG, "📱 BOOT_COMPLETED received")
|
Logger.d(TAG, "📱 BOOT_COMPLETED received")
|
||||||
|
|
||||||
// Prüfe ob Auto-Sync aktiviert ist
|
|
||||||
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
|
||||||
|
|
||||||
if (!autoSyncEnabled) {
|
// 🌟 v1.6.0: Check if Boot trigger is enabled
|
||||||
Logger.d(TAG, "❌ Auto-sync disabled - not starting WorkManager")
|
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, Constants.DEFAULT_TRIGGER_BOOT)) {
|
||||||
|
Logger.d(TAG, "⏭️ Boot sync disabled - not starting WorkManager")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.d(TAG, "🚀 Auto-sync enabled - starting WorkManager")
|
// Check if server is configured
|
||||||
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||||
|
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
|
||||||
|
Logger.d(TAG, "⏭️ Offline mode - not starting WorkManager")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.d(TAG, "🚀 Boot sync enabled - starting WorkManager")
|
||||||
|
|
||||||
// WorkManager neu starten
|
// WorkManager neu starten
|
||||||
val networkMonitor = NetworkMonitor(context.applicationContext)
|
val networkMonitor = NetworkMonitor(context.applicationContext)
|
||||||
|
|||||||
@@ -68,15 +68,20 @@ class NetworkMonitor(private val context: Context) {
|
|||||||
|
|
||||||
lastConnectedNetworkId = currentNetworkId
|
lastConnectedNetworkId = currentNetworkId
|
||||||
|
|
||||||
// Auto-Sync check
|
// WiFi-Connect Trigger prüfen - NICHT KEY_AUTO_SYNC!
|
||||||
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
// Der Callback ist registriert WEIL KEY_SYNC_TRIGGER_WIFI_CONNECT = true
|
||||||
Logger.d(TAG, " Auto-Sync enabled: $autoSyncEnabled")
|
// Aber defensive Prüfung für den Fall, dass Settings sich geändert haben
|
||||||
|
val wifiConnectEnabled = prefs.getBoolean(
|
||||||
|
Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT,
|
||||||
|
Constants.DEFAULT_TRIGGER_WIFI_CONNECT
|
||||||
|
)
|
||||||
|
Logger.d(TAG, " WiFi-Connect trigger enabled: $wifiConnectEnabled")
|
||||||
|
|
||||||
if (autoSyncEnabled) {
|
if (wifiConnectEnabled) {
|
||||||
Logger.d(TAG, " ✅ Triggering WorkManager...")
|
Logger.d(TAG, " ✅ Triggering WiFi-Connect sync...")
|
||||||
triggerWifiConnectSync()
|
triggerWifiConnectSync()
|
||||||
} else {
|
} else {
|
||||||
Logger.d(TAG, " ❌ Auto-sync disabled - not triggering")
|
Logger.d(TAG, " ⏭️ WiFi-Connect trigger disabled in settings")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Logger.d(TAG, " ⚠️ Same WiFi network as before - ignoring (no network change)")
|
Logger.d(TAG, " ⚠️ Same WiFi network as before - ignoring (no network change)")
|
||||||
@@ -102,8 +107,22 @@ class NetworkMonitor(private val context: Context) {
|
|||||||
/**
|
/**
|
||||||
* Triggert WiFi-Connect Sync via WorkManager
|
* Triggert WiFi-Connect Sync via WorkManager
|
||||||
* WorkManager wacht App auf (funktioniert auch wenn App geschlossen!)
|
* WorkManager wacht App auf (funktioniert auch wenn App geschlossen!)
|
||||||
|
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_WIFI_CONNECT
|
||||||
*/
|
*/
|
||||||
private fun triggerWifiConnectSync() {
|
private fun triggerWifiConnectSync() {
|
||||||
|
// 🌟 v1.6.0: Check if WiFi-Connect trigger is enabled
|
||||||
|
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)) {
|
||||||
|
Logger.d(TAG, "⏭️ WiFi-Connect sync disabled - skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if server is configured
|
||||||
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||||
|
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
|
||||||
|
Logger.d(TAG, "⏭️ Offline mode - skipping WiFi-Connect sync")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Logger.d(TAG, "📡 Scheduling WiFi-Connect sync via WorkManager")
|
Logger.d(TAG, "📡 Scheduling WiFi-Connect sync via WorkManager")
|
||||||
|
|
||||||
// 🔥 WICHTIG: NetworkType.UNMETERED constraint!
|
// 🔥 WICHTIG: NetworkType.UNMETERED constraint!
|
||||||
@@ -126,30 +145,80 @@ class NetworkMonitor(private val context: Context) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Startet WorkManager mit Network Constraints + NetworkCallback
|
* Startet WorkManager mit Network Constraints + NetworkCallback
|
||||||
|
*
|
||||||
|
* 🆕 v1.7.0: Überarbeitete Logik - WiFi-Connect Trigger funktioniert UNABHÄNGIG von KEY_AUTO_SYNC
|
||||||
|
* - KEY_AUTO_SYNC + KEY_SYNC_TRIGGER_PERIODIC → Periodic Sync
|
||||||
|
* - KEY_SYNC_TRIGGER_WIFI_CONNECT → WiFi-Connect Trigger (unabhängig!)
|
||||||
*/
|
*/
|
||||||
fun startMonitoring() {
|
fun startMonitoring() {
|
||||||
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
Logger.d(TAG, "🚀 NetworkMonitor.startMonitoring() called")
|
||||||
|
|
||||||
if (!autoSyncEnabled) {
|
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
||||||
Logger.d(TAG, "Auto-sync disabled - stopping all monitoring")
|
val periodicEnabled = prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)
|
||||||
stopMonitoring()
|
val wifiConnectEnabled = prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)
|
||||||
return
|
|
||||||
|
Logger.d(TAG, " Settings: autoSync=$autoSyncEnabled, periodic=$periodicEnabled, wifiConnect=$wifiConnectEnabled")
|
||||||
|
|
||||||
|
// 1. Periodic Sync (nur wenn KEY_AUTO_SYNC UND KEY_SYNC_TRIGGER_PERIODIC aktiv)
|
||||||
|
if (autoSyncEnabled && periodicEnabled) {
|
||||||
|
Logger.d(TAG, "📅 Starting periodic sync...")
|
||||||
|
startPeriodicSync()
|
||||||
|
} else {
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
|
||||||
|
Logger.d(TAG, "⏭️ Periodic sync disabled (autoSync=$autoSyncEnabled, periodic=$periodicEnabled)")
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.d(TAG, "🚀 Starting NetworkMonitor (WorkManager + WiFi Callback)")
|
// 2. WiFi-Connect Trigger (🆕 UNABHÄNGIG von KEY_AUTO_SYNC!)
|
||||||
|
if (wifiConnectEnabled) {
|
||||||
|
Logger.d(TAG, "📶 Starting WiFi monitoring...")
|
||||||
|
startWifiMonitoring()
|
||||||
|
} else {
|
||||||
|
stopWifiMonitoring()
|
||||||
|
Logger.d(TAG, "⏭️ WiFi-Connect trigger disabled")
|
||||||
|
}
|
||||||
|
|
||||||
// 1. WorkManager für periodic sync
|
// 3. Logging für Debug
|
||||||
startPeriodicSync()
|
if (!autoSyncEnabled && !wifiConnectEnabled) {
|
||||||
|
Logger.d(TAG, "🛑 No background triggers active")
|
||||||
// 2. NetworkCallback für WiFi-Connect Detection
|
}
|
||||||
startWifiMonitoring()
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.7.0: Stoppt nur WiFi-Monitoring, nicht den gesamten NetworkMonitor
|
||||||
|
*/
|
||||||
|
@Suppress("SwallowedException")
|
||||||
|
private fun stopWifiMonitoring() {
|
||||||
|
try {
|
||||||
|
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||||||
|
Logger.d(TAG, "🛑 WiFi NetworkCallback unregistered")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Already unregistered - das ist OK
|
||||||
|
Logger.d(TAG, " WiFi callback already unregistered")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Startet WorkManager periodic sync
|
* Startet WorkManager periodic sync
|
||||||
* 🔥 Interval aus SharedPrefs konfigurierbar (15/30/60 min)
|
* 🔥 Interval aus SharedPrefs konfigurierbar (15/30/60 min)
|
||||||
|
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_PERIODIC
|
||||||
*/
|
*/
|
||||||
private fun startPeriodicSync() {
|
private fun startPeriodicSync() {
|
||||||
|
// 🌟 v1.6.0: Check if Periodic trigger is enabled
|
||||||
|
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)) {
|
||||||
|
Logger.d(TAG, "⏭️ Periodic sync disabled - skipping")
|
||||||
|
// Cancel existing periodic work if disabled
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if server is configured
|
||||||
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||||
|
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
|
||||||
|
Logger.d(TAG, "⏭️ Offline mode - skipping Periodic sync")
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 Interval aus SharedPrefs lesen
|
// 🔥 Interval aus SharedPrefs lesen
|
||||||
val intervalMinutes = prefs.getLong(
|
val intervalMinutes = prefs.getLong(
|
||||||
Constants.PREF_SYNC_INTERVAL_MINUTES,
|
Constants.PREF_SYNC_INTERVAL_MINUTES,
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ object SyncStateManager {
|
|||||||
*/
|
*/
|
||||||
enum class SyncState {
|
enum class SyncState {
|
||||||
IDLE, // Kein Sync aktiv
|
IDLE, // Kein Sync aktiv
|
||||||
SYNCING, // Sync läuft gerade
|
SYNCING, // Sync läuft gerade (Banner sichtbar)
|
||||||
|
SYNCING_SILENT, // v1.5.0: Sync läuft im Hintergrund (kein Banner, z.B. onResume)
|
||||||
COMPLETED, // Sync erfolgreich abgeschlossen (kurz anzeigen)
|
COMPLETED, // Sync erfolgreich abgeschlossen (kurz anzeigen)
|
||||||
ERROR // Sync fehlgeschlagen (kurz anzeigen)
|
ERROR // Sync fehlgeschlagen (kurz anzeigen)
|
||||||
}
|
}
|
||||||
@@ -31,6 +32,7 @@ object SyncStateManager {
|
|||||||
val state: SyncState = SyncState.IDLE,
|
val state: SyncState = SyncState.IDLE,
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
val source: String? = null, // "manual", "auto", "pullToRefresh", "background"
|
val source: String? = null, // "manual", "auto", "pullToRefresh", "background"
|
||||||
|
val silent: Boolean = false, // v1.5.0: Wenn true, wird nach Completion kein Banner angezeigt
|
||||||
val timestamp: Long = System.currentTimeMillis()
|
val timestamp: Long = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,28 +46,35 @@ object SyncStateManager {
|
|||||||
private val lock = Any()
|
private val lock = Any()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prüft ob gerade ein Sync läuft
|
* Prüft ob gerade ein Sync läuft (inkl. Silent-Sync)
|
||||||
*/
|
*/
|
||||||
val isSyncing: Boolean
|
val isSyncing: Boolean
|
||||||
get() = _syncStatus.value?.state == SyncState.SYNCING
|
get() {
|
||||||
|
val state = _syncStatus.value?.state
|
||||||
|
return state == SyncState.SYNCING || state == SyncState.SYNCING_SILENT
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Versucht einen Sync zu starten.
|
* Versucht einen Sync zu starten.
|
||||||
|
* @param source Quelle des Syncs (für Logging)
|
||||||
|
* @param silent v1.5.0: Wenn true, wird kein Banner angezeigt (z.B. bei onResume Auto-Sync)
|
||||||
* @return true wenn Sync gestartet werden kann, false wenn bereits einer läuft
|
* @return true wenn Sync gestartet werden kann, false wenn bereits einer läuft
|
||||||
*/
|
*/
|
||||||
fun tryStartSync(source: String): Boolean {
|
fun tryStartSync(source: String, silent: Boolean = false): Boolean {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
if (isSyncing) {
|
if (isSyncing) {
|
||||||
Logger.d(TAG, "⚠️ Sync already in progress, rejecting new sync from: $source")
|
Logger.d(TAG, "⚠️ Sync already in progress, rejecting new sync from: $source")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.d(TAG, "🔄 Starting sync from: $source")
|
val syncState = if (silent) SyncState.SYNCING_SILENT else SyncState.SYNCING
|
||||||
|
Logger.d(TAG, "🔄 Starting sync from: $source (silent=$silent)")
|
||||||
_syncStatus.postValue(
|
_syncStatus.postValue(
|
||||||
SyncStatus(
|
SyncStatus(
|
||||||
state = SyncState.SYNCING,
|
state = syncState,
|
||||||
message = "Synchronisiere...",
|
message = "Synchronisiere...",
|
||||||
source = source
|
source = source,
|
||||||
|
silent = silent // v1.5.0: Merkt sich ob silent für markCompleted()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return true
|
return true
|
||||||
@@ -74,18 +83,29 @@ object SyncStateManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Markiert Sync als erfolgreich abgeschlossen
|
* Markiert Sync als erfolgreich abgeschlossen
|
||||||
|
* v1.5.0: Bei Silent-Sync direkt auf IDLE wechseln (kein Banner)
|
||||||
*/
|
*/
|
||||||
fun markCompleted(message: String? = null) {
|
fun markCompleted(message: String? = null) {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
val currentSource = _syncStatus.value?.source
|
val current = _syncStatus.value
|
||||||
Logger.d(TAG, "✅ Sync completed from: $currentSource")
|
val currentSource = current?.source
|
||||||
_syncStatus.postValue(
|
val wasSilent = current?.silent == true
|
||||||
SyncStatus(
|
|
||||||
state = SyncState.COMPLETED,
|
Logger.d(TAG, "✅ Sync completed from: $currentSource (silent=$wasSilent)")
|
||||||
message = message,
|
|
||||||
source = currentSource
|
if (wasSilent) {
|
||||||
|
// v1.5.0: Silent-Sync - direkt auf IDLE, kein Banner anzeigen
|
||||||
|
_syncStatus.postValue(SyncStatus())
|
||||||
|
} else {
|
||||||
|
// Normaler Sync - COMPLETED State anzeigen
|
||||||
|
_syncStatus.postValue(
|
||||||
|
SyncStatus(
|
||||||
|
state = SyncState.COMPLETED,
|
||||||
|
message = message,
|
||||||
|
source = currentSource
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
@file:Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional, will migrate in v2.0.0
|
||||||
|
|
||||||
package dev.dettmer.simplenotes.sync
|
package dev.dettmer.simplenotes.sync
|
||||||
|
|
||||||
|
import android.app.ActivityManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import dev.dettmer.simplenotes.BuildConfig
|
import dev.dettmer.simplenotes.BuildConfig
|
||||||
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
@@ -22,6 +26,21 @@ class SyncWorker(
|
|||||||
const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED"
|
const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft ob die App im Vordergrund ist.
|
||||||
|
* Wenn ja, brauchen wir keine Benachrichtigung - die UI zeigt die Änderungen direkt.
|
||||||
|
*/
|
||||||
|
private fun isAppInForeground(): Boolean {
|
||||||
|
val activityManager = applicationContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||||
|
val appProcesses = activityManager.runningAppProcesses ?: return false
|
||||||
|
val packageName = applicationContext.packageName
|
||||||
|
|
||||||
|
return appProcesses.any { process ->
|
||||||
|
process.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND &&
|
||||||
|
process.processName == packageName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Logger.d(TAG, "═══════════════════════════════════════")
|
Logger.d(TAG, "═══════════════════════════════════════")
|
||||||
@@ -70,6 +89,27 @@ class SyncWorker(
|
|||||||
return@withContext Result.success()
|
return@withContext Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Logger.d(TAG, "📍 Step 2.5: 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) {
|
if (BuildConfig.DEBUG) {
|
||||||
Logger.d(TAG, "📍 Step 3: Checking server reachability (Pre-Check)")
|
Logger.d(TAG, "📍 Step 3: Checking server reachability (Pre-Check)")
|
||||||
}
|
}
|
||||||
@@ -131,14 +171,20 @@ class SyncWorker(
|
|||||||
Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes")
|
Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes")
|
||||||
|
|
||||||
// Nur Notification zeigen wenn tatsächlich etwas gesynct wurde
|
// Nur Notification zeigen wenn tatsächlich etwas gesynct wurde
|
||||||
|
// UND die App nicht im Vordergrund ist (sonst sieht User die Änderungen direkt)
|
||||||
if (result.syncedCount > 0) {
|
if (result.syncedCount > 0) {
|
||||||
if (BuildConfig.DEBUG) {
|
val appInForeground = isAppInForeground()
|
||||||
Logger.d(TAG, " Showing success notification...")
|
if (appInForeground) {
|
||||||
|
Logger.d(TAG, "ℹ️ App in foreground - skipping notification (UI shows changes)")
|
||||||
|
} else {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Logger.d(TAG, " Showing success notification...")
|
||||||
|
}
|
||||||
|
NotificationHelper.showSyncSuccess(
|
||||||
|
applicationContext,
|
||||||
|
result.syncedCount
|
||||||
|
)
|
||||||
}
|
}
|
||||||
NotificationHelper.showSyncSuccess(
|
|
||||||
applicationContext,
|
|
||||||
result.syncedCount
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Logger.d(TAG, "ℹ️ No changes to sync - no notification")
|
Logger.d(TAG, "ℹ️ No changes to sync - no notification")
|
||||||
}
|
}
|
||||||
@@ -233,6 +279,7 @@ class SyncWorker(
|
|||||||
/**
|
/**
|
||||||
* Sendet Broadcast an MainActivity für UI Refresh
|
* Sendet Broadcast an MainActivity für UI Refresh
|
||||||
*/
|
*/
|
||||||
|
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but still functional, will migrate in v2.0.0
|
||||||
private fun broadcastSyncCompleted(success: Boolean, count: Int) {
|
private fun broadcastSyncCompleted(success: Boolean, count: Int) {
|
||||||
val intent = Intent(ACTION_SYNC_COMPLETED).apply {
|
val intent = Intent(ACTION_SYNC_COMPLETED).apply {
|
||||||
putExtra("success", success)
|
putExtra("success", success)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.net.NetworkCapabilities
|
|||||||
import com.thegrizzlylabs.sardineandroid.Sardine
|
import com.thegrizzlylabs.sardineandroid.Sardine
|
||||||
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
|
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
|
||||||
import dev.dettmer.simplenotes.BuildConfig
|
import dev.dettmer.simplenotes.BuildConfig
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
import dev.dettmer.simplenotes.models.DeletionTracker
|
import dev.dettmer.simplenotes.models.DeletionTracker
|
||||||
import dev.dettmer.simplenotes.models.Note
|
import dev.dettmer.simplenotes.models.Note
|
||||||
import dev.dettmer.simplenotes.models.SyncStatus
|
import dev.dettmer.simplenotes.models.SyncStatus
|
||||||
@@ -34,13 +35,16 @@ data class ManualMarkdownSyncResult(
|
|||||||
val importedCount: Int
|
val importedCount: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Suppress("LargeClass")
|
||||||
|
// TODO v2.0.0: Split into SyncOrchestrator, NoteUploader, NoteDownloader, ConflictResolver
|
||||||
class WebDavSyncService(private val context: Context) {
|
class WebDavSyncService(private val context: Context) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "WebDavSyncService"
|
private const val TAG = "WebDavSyncService"
|
||||||
private const val SOCKET_TIMEOUT_MS = 2000
|
private const val SOCKET_TIMEOUT_MS = 1000 // 🆕 v1.7.0: Reduziert von 2s auf 1s
|
||||||
private const val MAX_FILENAME_LENGTH = 200
|
private const val MAX_FILENAME_LENGTH = 200
|
||||||
private const val ETAG_PREVIEW_LENGTH = 8
|
private const val ETAG_PREVIEW_LENGTH = 8
|
||||||
|
private const val CONTENT_PREVIEW_LENGTH = 50
|
||||||
|
|
||||||
// 🔒 v1.3.1: Mutex um parallele Syncs zu verhindern
|
// 🔒 v1.3.1: Mutex um parallele Syncs zu verhindern
|
||||||
private val syncMutex = Mutex()
|
private val syncMutex = Mutex()
|
||||||
@@ -126,7 +130,14 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nur wenn WiFi aktiv
|
// 🔒 v1.7.0: VPN-Detection - Skip WiFi binding when VPN is active
|
||||||
|
// When VPN is active, traffic should route through VPN, not directly via WiFi
|
||||||
|
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
|
||||||
|
Logger.d(TAG, "🔒 VPN detected - using default routing (traffic will go through VPN)")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur wenn WiFi aktiv (und kein VPN)
|
||||||
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||||
Logger.d(TAG, "⚠️ Not on WiFi, using default routing")
|
Logger.d(TAG, "⚠️ Not on WiFi, using default routing")
|
||||||
return null
|
return null
|
||||||
@@ -134,6 +145,7 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
|
|
||||||
Logger.d(TAG, "✅ Network is WiFi, searching for interface...")
|
Logger.d(TAG, "✅ Network is WiFi, searching for interface...")
|
||||||
|
|
||||||
|
@Suppress("LoopWithTooManyJumpStatements") // Network interface filtering requires multiple conditions
|
||||||
// Finde WiFi Interface
|
// Finde WiFi Interface
|
||||||
val interfaces = NetworkInterface.getNetworkInterfaces()
|
val interfaces = NetworkInterface.getNetworkInterfaces()
|
||||||
while (interfaces.hasMoreElements()) {
|
while (interfaces.hasMoreElements()) {
|
||||||
@@ -551,6 +563,63 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.7.0: Prüft ob Gerät aktuell im WLAN ist
|
||||||
|
* Für schnellen Pre-Check VOR dem langsamen Socket-Check
|
||||||
|
*
|
||||||
|
* @return true wenn WLAN verbunden, false sonst (mobil oder kein Netzwerk)
|
||||||
|
*/
|
||||||
|
fun isOnWiFi(): Boolean {
|
||||||
|
return try {
|
||||||
|
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
|
||||||
|
as? ConnectivityManager ?: return false
|
||||||
|
val network = connectivityManager.activeNetwork ?: return false
|
||||||
|
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||||
|
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to check WiFi state", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.7.0: Zentrale Sync-Gate Prüfung
|
||||||
|
* Prüft ALLE Voraussetzungen bevor ein Sync gestartet wird.
|
||||||
|
* Diese Funktion sollte VOR jedem syncNotes() Aufruf verwendet werden.
|
||||||
|
*
|
||||||
|
* @return SyncGateResult mit canSync flag und optionalem Blockierungsgrund
|
||||||
|
*/
|
||||||
|
fun canSync(): SyncGateResult {
|
||||||
|
// 1. Offline Mode Check
|
||||||
|
if (prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)) {
|
||||||
|
return SyncGateResult(canSync = false, blockReason = null) // Silent skip
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Server configured?
|
||||||
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||||
|
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
|
||||||
|
return SyncGateResult(canSync = false, blockReason = null) // Silent skip
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. WiFi-Only Check
|
||||||
|
val wifiOnlySync = prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC)
|
||||||
|
if (wifiOnlySync && !isOnWiFi()) {
|
||||||
|
return SyncGateResult(canSync = false, blockReason = "wifi_only")
|
||||||
|
}
|
||||||
|
|
||||||
|
return SyncGateResult(canSync = true, blockReason = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.7.0: Result-Klasse für canSync()
|
||||||
|
*/
|
||||||
|
data class SyncGateResult(
|
||||||
|
val canSync: Boolean,
|
||||||
|
val blockReason: String? = null
|
||||||
|
) {
|
||||||
|
val isBlockedByWifiOnly: Boolean get() = blockReason == "wifi_only"
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun testConnection(): SyncResult = withContext(Dispatchers.IO) {
|
suspend fun testConnection(): SyncResult = withContext(Dispatchers.IO) {
|
||||||
return@withContext try {
|
return@withContext try {
|
||||||
val sardine = getOrCreateSardine() ?: return@withContext SyncResult(
|
val sardine = getOrCreateSardine() ?: return@withContext SyncResult(
|
||||||
@@ -778,6 +847,8 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
|
||||||
|
// Sync logic requires nested conditions for comprehensive error handling and state management
|
||||||
private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int {
|
private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int {
|
||||||
var uploadedCount = 0
|
var uploadedCount = 0
|
||||||
val localNotes = storage.loadAllNotes()
|
val localNotes = storage.loadAllNotes()
|
||||||
@@ -858,8 +929,31 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize Filename (Task #1.2.0-12)
|
// Sanitize Filename (Task #1.2.0-12)
|
||||||
val filename = sanitizeFilename(note.title) + ".md"
|
val baseFilename = sanitizeFilename(note.title)
|
||||||
val noteUrl = "$mdUrl/$filename"
|
var filename = "$baseFilename.md"
|
||||||
|
var noteUrl = "$mdUrl/$filename"
|
||||||
|
|
||||||
|
// Prüfe ob Datei bereits existiert und von anderer Note stammt
|
||||||
|
try {
|
||||||
|
if (sardine.exists(noteUrl)) {
|
||||||
|
// Lese existierende Datei und prüfe ID im YAML-Header
|
||||||
|
val existingContent = sardine.get(noteUrl).bufferedReader().use { it.readText() }
|
||||||
|
val existingIdMatch = Regex("^---\\n.*?\\nid:\\s*([a-f0-9-]+)", RegexOption.DOT_MATCHES_ALL)
|
||||||
|
.find(existingContent)
|
||||||
|
val existingId = existingIdMatch?.groupValues?.get(1)
|
||||||
|
|
||||||
|
if (existingId != null && existingId != note.id) {
|
||||||
|
// Andere Note hat gleichen Titel - verwende ID-Suffix
|
||||||
|
val shortId = note.id.take(8)
|
||||||
|
filename = "${baseFilename}_$shortId.md"
|
||||||
|
noteUrl = "$mdUrl/$filename"
|
||||||
|
Logger.d(TAG, "📝 Duplicate title, using: $filename")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.w(TAG, "⚠️ Could not check existing file: ${e.message}")
|
||||||
|
// Continue with default filename
|
||||||
|
}
|
||||||
|
|
||||||
// Konvertiere zu Markdown
|
// Konvertiere zu Markdown
|
||||||
val mdContent = note.toMarkdown().toByteArray()
|
val mdContent = note.toMarkdown().toByteArray()
|
||||||
@@ -884,6 +978,29 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
.trim('_', ' ') // Trim Underscores/Spaces
|
.trim('_', ' ') // Trim Underscores/Spaces
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generiert eindeutigen Markdown-Dateinamen für eine Notiz.
|
||||||
|
* Bei Duplikaten wird die Note-ID als Suffix angehängt.
|
||||||
|
*
|
||||||
|
* @param note Die Notiz
|
||||||
|
* @param usedFilenames Set der bereits verwendeten Dateinamen (ohne .md)
|
||||||
|
* @return Eindeutiger Dateiname (ohne .md Extension)
|
||||||
|
*/
|
||||||
|
private fun getUniqueMarkdownFilename(note: Note, usedFilenames: MutableSet<String>): String {
|
||||||
|
val baseFilename = sanitizeFilename(note.title)
|
||||||
|
|
||||||
|
return if (usedFilenames.contains(baseFilename)) {
|
||||||
|
// Duplikat - hänge gekürzte ID an
|
||||||
|
val shortId = note.id.take(8)
|
||||||
|
val uniqueFilename = "${baseFilename}_$shortId"
|
||||||
|
usedFilenames.add(uniqueFilename)
|
||||||
|
uniqueFilename
|
||||||
|
} else {
|
||||||
|
usedFilenames.add(baseFilename)
|
||||||
|
baseFilename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exportiert ALLE lokalen Notizen als Markdown (Initial-Export)
|
* Exportiert ALLE lokalen Notizen als Markdown (Initial-Export)
|
||||||
*
|
*
|
||||||
@@ -927,6 +1044,9 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
val totalCount = allNotes.size
|
val totalCount = allNotes.size
|
||||||
var exportedCount = 0
|
var exportedCount = 0
|
||||||
|
|
||||||
|
// Track used filenames to handle duplicates
|
||||||
|
val usedFilenames = mutableSetOf<String>()
|
||||||
|
|
||||||
Logger.d(TAG, "📝 Found $totalCount notes to export")
|
Logger.d(TAG, "📝 Found $totalCount notes to export")
|
||||||
|
|
||||||
allNotes.forEachIndexed { index, note ->
|
allNotes.forEachIndexed { index, note ->
|
||||||
@@ -934,8 +1054,8 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
// Progress-Callback
|
// Progress-Callback
|
||||||
onProgress(index + 1, totalCount)
|
onProgress(index + 1, totalCount)
|
||||||
|
|
||||||
// Sanitize Filename
|
// Eindeutiger Filename (mit Duplikat-Handling)
|
||||||
val filename = sanitizeFilename(note.title) + ".md"
|
val filename = getUniqueMarkdownFilename(note, usedFilenames) + ".md"
|
||||||
val noteUrl = "$mdUrl/$filename"
|
val noteUrl = "$mdUrl/$filename"
|
||||||
|
|
||||||
// Konvertiere zu Markdown
|
// Konvertiere zu Markdown
|
||||||
@@ -945,7 +1065,7 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
sardine.put(noteUrl, mdContent, "text/markdown")
|
sardine.put(noteUrl, mdContent, "text/markdown")
|
||||||
|
|
||||||
exportedCount++
|
exportedCount++
|
||||||
Logger.d(TAG, " ✅ Exported [${index + 1}/$totalCount]: ${note.title}")
|
Logger.d(TAG, " ✅ Exported [${index + 1}/$totalCount]: ${note.title} -> $filename")
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "❌ Failed to export ${note.title}: ${e.message}")
|
Logger.e(TAG, "❌ Failed to export ${note.title}: ${e.message}")
|
||||||
@@ -971,6 +1091,8 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
val conflictCount: Int
|
val conflictCount: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
|
||||||
|
// Sync logic requires nested conditions for comprehensive error handling and conflict resolution
|
||||||
private fun downloadRemoteNotes(
|
private fun downloadRemoteNotes(
|
||||||
sardine: Sardine,
|
sardine: Sardine,
|
||||||
serverUrl: String,
|
serverUrl: String,
|
||||||
@@ -1490,6 +1612,8 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
*
|
*
|
||||||
* ⚡ v1.3.1: Performance-Optimierung - Skip unveränderte Dateien
|
* ⚡ v1.3.1: Performance-Optimierung - Skip unveränderte Dateien
|
||||||
*/
|
*/
|
||||||
|
@Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
|
||||||
|
// Import logic requires nested conditions for file validation and duplicate handling
|
||||||
private fun importMarkdownFiles(sardine: Sardine, serverUrl: String): Int {
|
private fun importMarkdownFiles(sardine: Sardine, serverUrl: String): Int {
|
||||||
return try {
|
return try {
|
||||||
Logger.d(TAG, "📝 Importing Markdown files...")
|
Logger.d(TAG, "📝 Importing Markdown files...")
|
||||||
@@ -1539,13 +1663,27 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
Logger.w(TAG, " ⚠️ Failed to parse ${resource.name} - fromMarkdown returned null")
|
Logger.w(TAG, " ⚠️ Failed to parse ${resource.name} - fromMarkdown returned null")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.4.0 FIX: Validierung - leere TEXT-Notizen nicht importieren wenn lokal Content existiert
|
||||||
|
val localNote = storage.loadNote(mdNote.id)
|
||||||
|
if (mdNote.noteType == dev.dettmer.simplenotes.models.NoteType.TEXT &&
|
||||||
|
mdNote.content.isBlank() &&
|
||||||
|
localNote != null && localNote.content.isNotBlank()) {
|
||||||
|
Logger.w(
|
||||||
|
TAG,
|
||||||
|
" ⚠️ Skipping ${resource.name}: " +
|
||||||
|
"MD content empty but local has content - likely parse error!"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
Logger.d(
|
Logger.d(
|
||||||
TAG,
|
TAG,
|
||||||
" Parsed: id=${mdNote.id}, title=${mdNote.title}, " +
|
" Parsed: id=${mdNote.id}, title=${mdNote.title}, " +
|
||||||
"updatedAt=${Date(mdNote.updatedAt)}"
|
"updatedAt=${Date(mdNote.updatedAt)}, " +
|
||||||
|
"content=${mdNote.content.take(CONTENT_PREVIEW_LENGTH)}..."
|
||||||
)
|
)
|
||||||
|
|
||||||
val localNote = storage.loadNote(mdNote.id)
|
|
||||||
Logger.d(
|
Logger.d(
|
||||||
TAG,
|
TAG,
|
||||||
" Local note: " + if (localNote == null) {
|
" Local note: " + if (localNote == null) {
|
||||||
@@ -1700,6 +1838,9 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
* Deletes a note from the server (JSON + Markdown)
|
* Deletes a note from the server (JSON + Markdown)
|
||||||
* Does NOT delete from local storage!
|
* Does NOT delete from local storage!
|
||||||
*
|
*
|
||||||
|
* v1.4.1: Now supports v1.2.0 compatibility mode - also checks ROOT folder
|
||||||
|
* for notes that were created before the /notes/ directory structure.
|
||||||
|
*
|
||||||
* @param noteId The ID of the note to delete
|
* @param noteId The ID of the note to delete
|
||||||
* @return true if at least one file was deleted, false otherwise
|
* @return true if at least one file was deleted, false otherwise
|
||||||
*/
|
*/
|
||||||
@@ -1711,12 +1852,21 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
var deletedJson = false
|
var deletedJson = false
|
||||||
var deletedMd = false
|
var deletedMd = false
|
||||||
|
|
||||||
// Delete JSON
|
// v1.4.1: Try to delete JSON from /notes/ first (standard path)
|
||||||
val jsonUrl = getNotesUrl(serverUrl) + "$noteId.json"
|
val jsonUrl = getNotesUrl(serverUrl) + "$noteId.json"
|
||||||
if (sardine.exists(jsonUrl)) {
|
if (sardine.exists(jsonUrl)) {
|
||||||
sardine.delete(jsonUrl)
|
sardine.delete(jsonUrl)
|
||||||
deletedJson = true
|
deletedJson = true
|
||||||
Logger.d(TAG, "🗑️ Deleted from server: $noteId.json")
|
Logger.d(TAG, "🗑️ Deleted from server: $noteId.json (from /notes/)")
|
||||||
|
} else {
|
||||||
|
// v1.4.1: Fallback - check ROOT folder for v1.2.0 compatibility
|
||||||
|
val rootJsonUrl = serverUrl.trimEnd('/') + "/$noteId.json"
|
||||||
|
Logger.d(TAG, "🔍 JSON not in /notes/, checking ROOT: $rootJsonUrl")
|
||||||
|
if (sardine.exists(rootJsonUrl)) {
|
||||||
|
sardine.delete(rootJsonUrl)
|
||||||
|
deletedJson = true
|
||||||
|
Logger.d(TAG, "🗑️ Deleted from server: $noteId.json (from ROOT - v1.2.0 compat)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete Markdown (v1.3.0: YAML-scan based approach)
|
// Delete Markdown (v1.3.0: YAML-scan based approach)
|
||||||
@@ -1776,15 +1926,15 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
suspend fun manualMarkdownSync(): ManualMarkdownSyncResult = withContext(Dispatchers.IO) {
|
suspend fun manualMarkdownSync(): ManualMarkdownSyncResult = withContext(Dispatchers.IO) {
|
||||||
return@withContext try {
|
return@withContext try {
|
||||||
val sardine = getOrCreateSardine()
|
val sardine = getOrCreateSardine()
|
||||||
?: throw SyncException("Sardine client konnte nicht erstellt werden")
|
?: throw SyncException(context.getString(R.string.error_sardine_client_failed))
|
||||||
val serverUrl = getServerUrl()
|
val serverUrl = getServerUrl()
|
||||||
?: throw SyncException("Server-URL nicht konfiguriert")
|
?: throw SyncException(context.getString(R.string.error_server_url_not_configured))
|
||||||
|
|
||||||
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
|
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
|
||||||
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
|
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
|
||||||
|
|
||||||
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
|
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
|
||||||
throw SyncException("WebDAV-Server nicht vollständig konfiguriert")
|
throw SyncException(context.getString(R.string.error_server_not_configured))
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.d(TAG, "🔄 Manual Markdown Sync START")
|
Logger.d(TAG, "🔄 Manual Markdown Sync START")
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.wifi.WifiManager
|
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import dev.dettmer.simplenotes.utils.Constants
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WiFi-Sync BroadcastReceiver
|
||||||
|
*
|
||||||
|
* Triggert Sync wenn WiFi verbunden wird (jedes WiFi, keine SSID-Prüfung mehr)
|
||||||
|
* Die eigentliche Server-Erreichbarkeitsprüfung erfolgt im SyncWorker.
|
||||||
|
*/
|
||||||
class WifiSyncReceiver : BroadcastReceiver() {
|
class WifiSyncReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
@@ -22,34 +27,24 @@ class WifiSyncReceiver : BroadcastReceiver() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if connected to home WiFi
|
// Check if connected to any WiFi (SSID-Prüfung entfernt in v1.4.0)
|
||||||
if (isConnectedToHomeWifi(context)) {
|
if (isConnectedToWifi(context)) {
|
||||||
scheduleSyncWork(context)
|
scheduleSyncWork(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("ReturnCount") // Early returns for WiFi validation checks
|
/**
|
||||||
private fun isConnectedToHomeWifi(context: Context): Boolean {
|
* Prüft ob ein WiFi-Netzwerk verbunden ist (beliebiges WiFi)
|
||||||
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
* Die Server-Erreichbarkeitsprüfung erfolgt erst im SyncWorker.
|
||||||
val homeSSID = prefs.getString(Constants.KEY_HOME_SSID, null) ?: return false
|
*/
|
||||||
|
private fun isConnectedToWifi(context: Context): Boolean {
|
||||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
|
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
|
||||||
as ConnectivityManager
|
as ConnectivityManager
|
||||||
|
|
||||||
val network = connectivityManager.activeNetwork ?: return false
|
val network = connectivityManager.activeNetwork ?: return false
|
||||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||||
|
|
||||||
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current SSID
|
|
||||||
val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE)
|
|
||||||
as WifiManager
|
|
||||||
val wifiInfo = wifiManager.connectionInfo
|
|
||||||
val currentSSID = wifiInfo.ssid.replace("\"", "")
|
|
||||||
|
|
||||||
return currentSSID == homeSSID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun scheduleSyncWork(context: Context) {
|
private fun scheduleSyncWork(context: Context) {
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
@file:Suppress("DEPRECATION") // AbstractSavedStateViewModelFactory deprecated, will migrate to viewModelFactory in v2.0.0
|
||||||
|
|
||||||
|
package dev.dettmer.simplenotes.ui.editor
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.lifecycle.AbstractSavedStateViewModelFactory
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.savedstate.SavedStateRegistryOwner
|
||||||
|
import com.google.android.material.color.DynamicColors
|
||||||
|
import dev.dettmer.simplenotes.models.NoteType
|
||||||
|
import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose-based Note Editor Activity
|
||||||
|
*
|
||||||
|
* v1.5.0: Jetpack Compose NoteEditor Redesign
|
||||||
|
* Replaces the old NoteEditorActivity with a modern Compose implementation.
|
||||||
|
*
|
||||||
|
* Supports:
|
||||||
|
* - TEXT notes with title and content
|
||||||
|
* - CHECKLIST notes with drag & drop reordering
|
||||||
|
* - Auto-keyboard focus for new checklist items
|
||||||
|
*/
|
||||||
|
class ComposeNoteEditorActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_NOTE_ID = "extra_note_id"
|
||||||
|
const val EXTRA_NOTE_TYPE = "extra_note_type"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val viewModel: NoteEditorViewModel by viewModels {
|
||||||
|
NoteEditorViewModelFactory(
|
||||||
|
application = application,
|
||||||
|
owner = this,
|
||||||
|
noteId = intent.getStringExtra(EXTRA_NOTE_ID),
|
||||||
|
noteType = intent.getStringExtra(EXTRA_NOTE_TYPE) ?: NoteType.TEXT.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Apply Dynamic Colors for Android 12+ (Material You)
|
||||||
|
DynamicColors.applyToActivityIfAvailable(this)
|
||||||
|
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
// v1.5.0: Handle back button with slide animation
|
||||||
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
finish()
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
overridePendingTransition(
|
||||||
|
dev.dettmer.simplenotes.R.anim.slide_in_left,
|
||||||
|
dev.dettmer.simplenotes.R.anim.slide_out_right
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
SimpleNotesTheme {
|
||||||
|
NoteEditorScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onNavigateBack = {
|
||||||
|
finish()
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
overridePendingTransition(
|
||||||
|
dev.dettmer.simplenotes.R.anim.slide_in_left,
|
||||||
|
dev.dettmer.simplenotes.R.anim.slide_out_right
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom ViewModelFactory to pass SavedStateHandle with intent extras
|
||||||
|
*/
|
||||||
|
class NoteEditorViewModelFactory(
|
||||||
|
private val application: android.app.Application,
|
||||||
|
owner: SavedStateRegistryOwner,
|
||||||
|
private val noteId: String?,
|
||||||
|
private val noteType: String
|
||||||
|
) : AbstractSavedStateViewModelFactory(owner, null) {
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(
|
||||||
|
key: String,
|
||||||
|
modelClass: Class<T>,
|
||||||
|
handle: SavedStateHandle
|
||||||
|
): T {
|
||||||
|
// Populate SavedStateHandle with intent extras
|
||||||
|
handle[NoteEditorViewModel.ARG_NOTE_ID] = noteId
|
||||||
|
handle[NoteEditorViewModel.ARG_NOTE_TYPE] = noteType
|
||||||
|
|
||||||
|
return NoteEditorViewModel(application, handle) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.editor
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
|
import androidx.compose.foundation.gestures.scrollBy
|
||||||
|
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FOSS Drag & Drop State für LazyList
|
||||||
|
*
|
||||||
|
* Native Compose-Implementierung ohne externe Dependencies
|
||||||
|
* v1.5.0: NoteEditor Redesign
|
||||||
|
*/
|
||||||
|
class DragDropListState(
|
||||||
|
private val state: LazyListState,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val onMove: (Int, Int) -> Unit
|
||||||
|
) {
|
||||||
|
var draggingItemIndex by mutableStateOf<Int?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
|
||||||
|
private var draggingItemInitialOffset by mutableFloatStateOf(0f)
|
||||||
|
private var overscrollJob by mutableStateOf<Job?>(null)
|
||||||
|
|
||||||
|
val draggingItemOffset: Float
|
||||||
|
get() = draggingItemLayoutInfo?.let { item ->
|
||||||
|
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
|
||||||
|
} ?: 0f
|
||||||
|
|
||||||
|
private val draggingItemLayoutInfo: LazyListItemInfo?
|
||||||
|
get() = state.layoutInfo.visibleItemsInfo
|
||||||
|
.firstOrNull { it.index == draggingItemIndex }
|
||||||
|
|
||||||
|
fun onDragStart(offset: Offset, itemIndex: Int) {
|
||||||
|
draggingItemIndex = itemIndex
|
||||||
|
draggingItemInitialOffset = draggingItemLayoutInfo?.offset?.toFloat() ?: 0f
|
||||||
|
draggingItemDraggedDelta = 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDragInterrupted() {
|
||||||
|
draggingItemDraggedDelta = 0f
|
||||||
|
draggingItemIndex = null
|
||||||
|
draggingItemInitialOffset = 0f
|
||||||
|
overscrollJob?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDrag(offset: Offset) {
|
||||||
|
draggingItemDraggedDelta += offset.y
|
||||||
|
|
||||||
|
val draggingItem = draggingItemLayoutInfo ?: return
|
||||||
|
val startOffset = draggingItem.offset + draggingItemOffset
|
||||||
|
val endOffset = startOffset + draggingItem.size
|
||||||
|
|
||||||
|
val middleOffset = startOffset + (endOffset - startOffset) / 2f
|
||||||
|
|
||||||
|
val targetItem = state.layoutInfo.visibleItemsInfo.find { item ->
|
||||||
|
middleOffset.toInt() in item.offset..item.offsetEnd &&
|
||||||
|
draggingItem.index != item.index
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetItem != null) {
|
||||||
|
val scrollToIndex = if (targetItem.index == state.firstVisibleItemIndex) {
|
||||||
|
draggingItem.index
|
||||||
|
} else if (draggingItem.index == state.firstVisibleItemIndex) {
|
||||||
|
targetItem.index
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrollToIndex != null) {
|
||||||
|
scope.launch {
|
||||||
|
state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
|
||||||
|
onMove(draggingItem.index, targetItem.index)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onMove(draggingItem.index, targetItem.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
draggingItemIndex = targetItem.index
|
||||||
|
} else {
|
||||||
|
val overscroll = when {
|
||||||
|
draggingItemDraggedDelta > 0 ->
|
||||||
|
(endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
|
||||||
|
draggingItemDraggedDelta < 0 ->
|
||||||
|
(startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overscroll != 0f) {
|
||||||
|
if (overscrollJob?.isActive != true) {
|
||||||
|
overscrollJob = scope.launch {
|
||||||
|
state.scrollBy(overscroll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
overscrollJob?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val LazyListItemInfo.offsetEnd: Int
|
||||||
|
get() = this.offset + this.size
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberDragDropListState(
|
||||||
|
lazyListState: LazyListState,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
onMove: (Int, Int) -> Unit
|
||||||
|
): DragDropListState {
|
||||||
|
return remember(lazyListState, scope) {
|
||||||
|
DragDropListState(
|
||||||
|
state = lazyListState,
|
||||||
|
scope = scope,
|
||||||
|
onMove = onMove
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Modifier.dragContainer(
|
||||||
|
dragDropState: DragDropListState,
|
||||||
|
itemIndex: Int
|
||||||
|
): Modifier {
|
||||||
|
return this.pointerInput(dragDropState) {
|
||||||
|
detectDragGesturesAfterLongPress(
|
||||||
|
onDragStart = { offset ->
|
||||||
|
dragDropState.onDragStart(offset, itemIndex)
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
dragDropState.onDragInterrupted()
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
dragDropState.onDragInterrupted()
|
||||||
|
},
|
||||||
|
onDrag = { change, offset ->
|
||||||
|
change.consume()
|
||||||
|
dragDropState.onDrag(offset)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.editor
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Save
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.models.NoteType
|
||||||
|
import dev.dettmer.simplenotes.ui.editor.components.ChecklistItemRow
|
||||||
|
import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import dev.dettmer.simplenotes.utils.showToast
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Composable for the Note Editor screen.
|
||||||
|
*
|
||||||
|
* v1.5.0: Jetpack Compose NoteEditor Redesign
|
||||||
|
* - Supports both TEXT and CHECKLIST notes
|
||||||
|
* - Drag & Drop reordering for checklist items
|
||||||
|
* - Auto-keyboard focus for new items
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun NoteEditorScreen(
|
||||||
|
viewModel: NoteEditorViewModel,
|
||||||
|
onNavigateBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val checklistItems by viewModel.checklistItems.collectAsState()
|
||||||
|
|
||||||
|
// 🌟 v1.6.0: Offline mode state
|
||||||
|
val isOfflineMode by viewModel.isOfflineMode.collectAsState()
|
||||||
|
|
||||||
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
var focusNewItemId by remember { mutableStateOf<String?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// 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(100) // Wait for layout
|
||||||
|
when {
|
||||||
|
uiState.isNewNote -> {
|
||||||
|
// New note: focus title
|
||||||
|
titleFocusRequester.requestFocus()
|
||||||
|
keyboardController?.show()
|
||||||
|
}
|
||||||
|
!uiState.isNewNote && uiState.noteType == NoteType.TEXT -> {
|
||||||
|
// Editing text note: focus content
|
||||||
|
contentFocusRequester.requestFocus()
|
||||||
|
keyboardController?.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle events
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.events.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is NoteEditorEvent.ShowToast -> {
|
||||||
|
val message = when (event.message) {
|
||||||
|
ToastMessage.NOTE_IS_EMPTY -> 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,
|
||||||
|
onTextChange = { id, text -> viewModel.updateChecklistItemText(id, text) },
|
||||||
|
onCheckedChange = { id, checked -> viewModel.updateChecklistItemChecked(id, checked) },
|
||||||
|
onDelete = { id -> viewModel.deleteChecklistItem(id) },
|
||||||
|
onAddNewItemAfter = { id ->
|
||||||
|
val newId = viewModel.addChecklistItemAfter(id)
|
||||||
|
focusNewItemId = newId
|
||||||
|
},
|
||||||
|
onAddItemAtEnd = {
|
||||||
|
val newId = viewModel.addChecklistItemAtEnd()
|
||||||
|
focusNewItemId = newId
|
||||||
|
},
|
||||||
|
onMove = { from, to -> viewModel.moveChecklistItem(from, to) },
|
||||||
|
onFocusHandled = { focusNewItemId = null },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete Confirmation Dialog - v1.5.0: Use shared component with server/local options
|
||||||
|
if (showDeleteDialog) {
|
||||||
|
DeleteConfirmationDialog(
|
||||||
|
noteCount = 1,
|
||||||
|
isOfflineMode = isOfflineMode,
|
||||||
|
onDismiss = { showDeleteDialog = false },
|
||||||
|
onDeleteLocal = {
|
||||||
|
showDeleteDialog = false
|
||||||
|
viewModel.deleteNote(deleteOnServer = false)
|
||||||
|
},
|
||||||
|
onDeleteEverywhere = {
|
||||||
|
showDeleteDialog = false
|
||||||
|
viewModel.deleteNote(deleteOnServer = true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TextNoteContent(
|
||||||
|
content: String,
|
||||||
|
onContentChange: (String) -> Unit,
|
||||||
|
focusRequester: FocusRequester,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
// v1.5.0: Use TextFieldValue to control cursor position
|
||||||
|
// Track if initial cursor position has been set (only set to end once on first load)
|
||||||
|
var initialCursorSet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
var textFieldValue by remember {
|
||||||
|
mutableStateOf(TextFieldValue(
|
||||||
|
text = content,
|
||||||
|
selection = TextRange(content.length)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial cursor position only once when content first loads
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (!initialCursorSet && content.isNotEmpty()) {
|
||||||
|
textFieldValue = TextFieldValue(
|
||||||
|
text = content,
|
||||||
|
selection = TextRange(content.length)
|
||||||
|
)
|
||||||
|
initialCursorSet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = textFieldValue,
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
textFieldValue = newValue
|
||||||
|
onContentChange(newValue.text)
|
||||||
|
},
|
||||||
|
modifier = modifier.focusRequester(focusRequester),
|
||||||
|
label = { Text(stringResource(R.string.content)) },
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList") // Compose functions commonly have many callback parameters
|
||||||
|
@Composable
|
||||||
|
private fun ChecklistEditor(
|
||||||
|
items: List<ChecklistItemState>,
|
||||||
|
scope: kotlinx.coroutines.CoroutineScope,
|
||||||
|
focusNewItemId: String?,
|
||||||
|
onTextChange: (String, String) -> Unit,
|
||||||
|
onCheckedChange: (String, Boolean) -> Unit,
|
||||||
|
onDelete: (String) -> Unit,
|
||||||
|
onAddNewItemAfter: (String) -> Unit,
|
||||||
|
onAddItemAtEnd: () -> Unit,
|
||||||
|
onMove: (Int, Int) -> Unit,
|
||||||
|
onFocusHandled: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val dragDropState = rememberDragDropListState(
|
||||||
|
lazyListState = listState,
|
||||||
|
scope = scope,
|
||||||
|
onMove = onMove
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
itemsIndexed(
|
||||||
|
items = items,
|
||||||
|
key = { _, item -> item.id }
|
||||||
|
) { index, item ->
|
||||||
|
val isDragging = dragDropState.draggingItemIndex == index
|
||||||
|
val elevation by animateDpAsState(
|
||||||
|
targetValue = if (isDragging) 8.dp else 0.dp,
|
||||||
|
label = "elevation"
|
||||||
|
)
|
||||||
|
|
||||||
|
val shouldFocus = item.id == focusNewItemId
|
||||||
|
|
||||||
|
// v1.5.0: Clear focus request after handling
|
||||||
|
LaunchedEffect(shouldFocus) {
|
||||||
|
if (shouldFocus) {
|
||||||
|
onFocusHandled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ChecklistItemRow(
|
||||||
|
item = item,
|
||||||
|
onTextChange = { onTextChange(item.id, it) },
|
||||||
|
onCheckedChange = { onCheckedChange(item.id, it) },
|
||||||
|
onDelete = { onDelete(item.id) },
|
||||||
|
onAddNewItem = { onAddNewItemAfter(item.id) },
|
||||||
|
requestFocus = shouldFocus,
|
||||||
|
modifier = Modifier
|
||||||
|
.dragContainer(dragDropState, index)
|
||||||
|
.offset {
|
||||||
|
IntOffset(
|
||||||
|
0,
|
||||||
|
if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.shadow(elevation, shape = RoundedCornerShape(8.dp))
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Item Button
|
||||||
|
TextButton(
|
||||||
|
onClick = onAddItemAtEnd,
|
||||||
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.padding(end = 8.dp)
|
||||||
|
)
|
||||||
|
Text(stringResource(R.string.add_item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.5.0: Local DeleteConfirmationDialog removed - now using shared component from ui/main/components/
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.editor
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import dev.dettmer.simplenotes.models.ChecklistItem
|
||||||
|
import dev.dettmer.simplenotes.models.Note
|
||||||
|
import dev.dettmer.simplenotes.models.NoteType
|
||||||
|
import dev.dettmer.simplenotes.models.SyncStatus
|
||||||
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
|
import dev.dettmer.simplenotes.sync.SyncWorker
|
||||||
|
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||||
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
|
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||||
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewModel for NoteEditor Compose Screen
|
||||||
|
* v1.5.0: Jetpack Compose NoteEditor Redesign
|
||||||
|
*
|
||||||
|
* Manages note editing state including title, content, and checklist items.
|
||||||
|
*/
|
||||||
|
class NoteEditorViewModel(
|
||||||
|
application: Application,
|
||||||
|
private val savedStateHandle: SavedStateHandle
|
||||||
|
) : AndroidViewModel(application) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "NoteEditorViewModel"
|
||||||
|
const val ARG_NOTE_ID = "noteId"
|
||||||
|
const val ARG_NOTE_TYPE = "noteType"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val storage = NotesStorage(application)
|
||||||
|
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// State
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(NoteEditorUiState())
|
||||||
|
val uiState: StateFlow<NoteEditorUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _checklistItems = MutableStateFlow<List<ChecklistItemState>>(emptyList())
|
||||||
|
val checklistItems: StateFlow<List<ChecklistItemState>> = _checklistItems.asStateFlow()
|
||||||
|
|
||||||
|
// 🌟 v1.6.0: Offline Mode State
|
||||||
|
private val _isOfflineMode = MutableStateFlow(
|
||||||
|
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
|
||||||
|
)
|
||||||
|
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow()
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Events
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<NoteEditorEvent>()
|
||||||
|
val events: SharedFlow<NoteEditorEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
// Internal state
|
||||||
|
private var existingNote: Note? = null
|
||||||
|
private var currentNoteType: NoteType = NoteType.TEXT
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadNote()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadNote() {
|
||||||
|
val noteId = savedStateHandle.get<String>(ARG_NOTE_ID)
|
||||||
|
val noteTypeString = savedStateHandle.get<String>(ARG_NOTE_TYPE) ?: NoteType.TEXT.name
|
||||||
|
|
||||||
|
if (noteId != null) {
|
||||||
|
// Load existing note
|
||||||
|
existingNote = storage.loadNote(noteId)
|
||||||
|
existingNote?.let { note ->
|
||||||
|
currentNoteType = note.noteType
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
title = note.title,
|
||||||
|
content = note.content,
|
||||||
|
noteType = note.noteType,
|
||||||
|
isNewNote = false,
|
||||||
|
toolbarTitle = if (note.noteType == NoteType.CHECKLIST) {
|
||||||
|
ToolbarTitle.EDIT_CHECKLIST
|
||||||
|
} else {
|
||||||
|
ToolbarTitle.EDIT_NOTE
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note.noteType == NoteType.CHECKLIST) {
|
||||||
|
val items = note.checklistItems?.sortedBy { it.order }?.map {
|
||||||
|
ChecklistItemState(
|
||||||
|
id = it.id,
|
||||||
|
text = it.text,
|
||||||
|
isChecked = it.isChecked,
|
||||||
|
order = it.order
|
||||||
|
)
|
||||||
|
} ?: emptyList()
|
||||||
|
_checklistItems.value = items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// New note
|
||||||
|
currentNoteType = try {
|
||||||
|
NoteType.valueOf(noteTypeString)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT: ${e.message}")
|
||||||
|
NoteType.TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
noteType = currentNoteType,
|
||||||
|
isNewNote = true,
|
||||||
|
toolbarTitle = if (currentNoteType == NoteType.CHECKLIST) {
|
||||||
|
ToolbarTitle.NEW_CHECKLIST
|
||||||
|
} else {
|
||||||
|
ToolbarTitle.NEW_NOTE
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add first empty item for new checklists
|
||||||
|
if (currentNoteType == NoteType.CHECKLIST) {
|
||||||
|
_checklistItems.value = listOf(ChecklistItemState.createEmpty(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Actions
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
fun updateTitle(title: String) {
|
||||||
|
_uiState.update { it.copy(title = title) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateContent(content: String) {
|
||||||
|
_uiState.update { it.copy(content = content) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateChecklistItemText(itemId: String, newText: String) {
|
||||||
|
_checklistItems.update { items ->
|
||||||
|
items.map { item ->
|
||||||
|
if (item.id == itemId) item.copy(text = newText) else item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateChecklistItemChecked(itemId: String, isChecked: Boolean) {
|
||||||
|
_checklistItems.update { items ->
|
||||||
|
items.map { item ->
|
||||||
|
if (item.id == itemId) item.copy(isChecked = isChecked) else item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addChecklistItemAfter(afterItemId: String): String {
|
||||||
|
val newItem = ChecklistItemState.createEmpty(0)
|
||||||
|
_checklistItems.update { items ->
|
||||||
|
val index = items.indexOfFirst { it.id == afterItemId }
|
||||||
|
if (index >= 0) {
|
||||||
|
val newList = items.toMutableList()
|
||||||
|
newList.add(index + 1, newItem)
|
||||||
|
// Update order values
|
||||||
|
newList.mapIndexed { i, item -> item.copy(order = i) }
|
||||||
|
} else {
|
||||||
|
items + newItem.copy(order = items.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newItem.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addChecklistItemAtEnd(): String {
|
||||||
|
val newItem = ChecklistItemState.createEmpty(_checklistItems.value.size)
|
||||||
|
_checklistItems.update { items -> items + newItem }
|
||||||
|
return newItem.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteChecklistItem(itemId: String) {
|
||||||
|
_checklistItems.update { items ->
|
||||||
|
val filtered = items.filter { it.id != itemId }
|
||||||
|
// Ensure at least one item exists
|
||||||
|
if (filtered.isEmpty()) {
|
||||||
|
listOf(ChecklistItemState.createEmpty(0))
|
||||||
|
} else {
|
||||||
|
// Update order values
|
||||||
|
filtered.mapIndexed { index, item -> item.copy(order = index) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun moveChecklistItem(fromIndex: Int, toIndex: Int) {
|
||||||
|
_checklistItems.update { items ->
|
||||||
|
val mutableList = items.toMutableList()
|
||||||
|
val item = mutableList.removeAt(fromIndex)
|
||||||
|
mutableList.add(toIndex, item)
|
||||||
|
// Update order values
|
||||||
|
mutableList.mapIndexed { index, i -> i.copy(order = index) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveNote() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val state = _uiState.value
|
||||||
|
val title = state.title.trim()
|
||||||
|
|
||||||
|
when (currentNoteType) {
|
||||||
|
NoteType.TEXT -> {
|
||||||
|
val content = state.content.trim()
|
||||||
|
|
||||||
|
if (title.isEmpty() && content.isEmpty()) {
|
||||||
|
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_IS_EMPTY))
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val note = if (existingNote != null) {
|
||||||
|
existingNote!!.copy(
|
||||||
|
title = title,
|
||||||
|
content = content,
|
||||||
|
noteType = NoteType.TEXT,
|
||||||
|
checklistItems = null,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
syncStatus = SyncStatus.PENDING
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Note(
|
||||||
|
title = title,
|
||||||
|
content = content,
|
||||||
|
noteType = NoteType.TEXT,
|
||||||
|
checklistItems = null,
|
||||||
|
deviceId = DeviceIdGenerator.getDeviceId(getApplication()),
|
||||||
|
syncStatus = SyncStatus.LOCAL_ONLY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.saveNote(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
NoteType.CHECKLIST -> {
|
||||||
|
// Filter empty items
|
||||||
|
val validItems = _checklistItems.value
|
||||||
|
.filter { it.text.isNotBlank() }
|
||||||
|
.mapIndexed { index, item ->
|
||||||
|
ChecklistItem(
|
||||||
|
id = item.id,
|
||||||
|
text = item.text,
|
||||||
|
isChecked = item.isChecked,
|
||||||
|
order = index
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title.isEmpty() && validItems.isEmpty()) {
|
||||||
|
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_IS_EMPTY))
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val note = if (existingNote != null) {
|
||||||
|
existingNote!!.copy(
|
||||||
|
title = title,
|
||||||
|
content = "", // Empty for checklists
|
||||||
|
noteType = NoteType.CHECKLIST,
|
||||||
|
checklistItems = validItems,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
syncStatus = SyncStatus.PENDING
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Note(
|
||||||
|
title = title,
|
||||||
|
content = "",
|
||||||
|
noteType = NoteType.CHECKLIST,
|
||||||
|
checklistItems = validItems,
|
||||||
|
deviceId = DeviceIdGenerator.getDeviceId(getApplication()),
|
||||||
|
syncStatus = SyncStatus.LOCAL_ONLY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.saveNote(note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_SAVED))
|
||||||
|
|
||||||
|
// 🌟 v1.6.0: Trigger onSave Sync
|
||||||
|
triggerOnSaveSync()
|
||||||
|
|
||||||
|
_events.emit(NoteEditorEvent.NavigateBack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the current note
|
||||||
|
* @param deleteOnServer if true, also triggers server deletion; if false, only deletes locally
|
||||||
|
* v1.5.0: Added deleteOnServer parameter for unified delete dialog
|
||||||
|
*/
|
||||||
|
fun deleteNote(deleteOnServer: Boolean = true) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
existingNote?.let { note ->
|
||||||
|
val noteId = note.id
|
||||||
|
|
||||||
|
// Delete locally first
|
||||||
|
storage.deleteNote(noteId)
|
||||||
|
|
||||||
|
// Delete from server if requested
|
||||||
|
if (deleteOnServer) {
|
||||||
|
try {
|
||||||
|
val webdavService = WebDavSyncService(getApplication())
|
||||||
|
val success = withContext(Dispatchers.IO) {
|
||||||
|
webdavService.deleteNoteFromServer(noteId)
|
||||||
|
}
|
||||||
|
if (success) {
|
||||||
|
Logger.d(TAG, "Note $noteId deleted from server")
|
||||||
|
} else {
|
||||||
|
Logger.w(TAG, "Failed to delete note $noteId from server")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Error deleting note from server: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_DELETED))
|
||||||
|
_events.emit(NoteEditorEvent.NavigateBack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showDeleteConfirmation() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_events.emit(NoteEditorEvent.ShowDeleteConfirmation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun canDelete(): Boolean = existingNote != null
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 🌟 v1.6.0: Sync Trigger - onSave
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers sync after saving a note (if enabled and server configured)
|
||||||
|
* v1.6.0: New configurable sync trigger
|
||||||
|
* 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)
|
||||||
|
.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,179 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.editor.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.DragHandle
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.ui.editor.ChecklistItemState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single row in the checklist editor with drag handle, checkbox, text input, and delete button.
|
||||||
|
*
|
||||||
|
* v1.5.0: Jetpack Compose NoteEditor Redesign
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ChecklistItemRow(
|
||||||
|
item: ChecklistItemState,
|
||||||
|
onTextChange: (String) -> Unit,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
onAddNewItem: () -> Unit,
|
||||||
|
requestFocus: Boolean = false,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
var textFieldValue by remember(item.id) {
|
||||||
|
mutableStateOf(TextFieldValue(text = item.text, selection = TextRange(item.text.length)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.5.0: Auto-focus AND show keyboard when requestFocus is true (new items)
|
||||||
|
LaunchedEffect(requestFocus) {
|
||||||
|
if (requestFocus) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
keyboardController?.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update text field when external state changes
|
||||||
|
LaunchedEffect(item.text) {
|
||||||
|
if (textFieldValue.text != item.text) {
|
||||||
|
textFieldValue = TextFieldValue(
|
||||||
|
text = item.text,
|
||||||
|
selection = TextRange(item.text.length)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val alpha = if (item.isChecked) 0.6f else 1.0f
|
||||||
|
val textDecoration = if (item.isChecked) TextDecoration.LineThrough else TextDecoration.None
|
||||||
|
|
||||||
|
@Suppress("MagicNumber") // UI padding values are self-explanatory
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Drag Handle
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.DragHandle,
|
||||||
|
contentDescription = stringResource(R.string.drag_to_reorder),
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.alpha(0.5f),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
||||||
|
// Checkbox
|
||||||
|
Checkbox(
|
||||||
|
checked = item.isChecked,
|
||||||
|
onCheckedChange = onCheckedChange,
|
||||||
|
modifier = Modifier.alpha(alpha)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
||||||
|
// Text Input with placeholder
|
||||||
|
BasicTextField(
|
||||||
|
value = textFieldValue,
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
// Check for newline (Enter key)
|
||||||
|
if (newValue.text.contains("\n")) {
|
||||||
|
val cleanText = newValue.text.replace("\n", "")
|
||||||
|
textFieldValue = TextFieldValue(
|
||||||
|
text = cleanText,
|
||||||
|
selection = TextRange(cleanText.length)
|
||||||
|
)
|
||||||
|
onTextChange(cleanText)
|
||||||
|
onAddNewItem()
|
||||||
|
} else {
|
||||||
|
textFieldValue = newValue
|
||||||
|
onTextChange(newValue.text)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.alpha(alpha),
|
||||||
|
textStyle = LocalTextStyle.current.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textDecoration = textDecoration
|
||||||
|
),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { onAddNewItem() }
|
||||||
|
),
|
||||||
|
singleLine = false,
|
||||||
|
maxLines = 5,
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
|
decorationBox = { innerTextField ->
|
||||||
|
Box {
|
||||||
|
if (textFieldValue.text.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.item_placeholder),
|
||||||
|
style = LocalTextStyle.current.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerTextField()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
||||||
|
// Delete Button
|
||||||
|
IconButton(
|
||||||
|
onClick = onDelete,
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.delete_item),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
@file:Suppress("DEPRECATION") // LocalBroadcastManager & deprecated lifecycle methods, will migrate in v2.0.0
|
||||||
|
|
||||||
|
package dev.dettmer.simplenotes.ui.main
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.ActivityOptions
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
|
import com.google.android.material.color.DynamicColors
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.ui.editor.ComposeNoteEditorActivity
|
||||||
|
import dev.dettmer.simplenotes.models.NoteType
|
||||||
|
import dev.dettmer.simplenotes.models.SyncStatus
|
||||||
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
|
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||||
|
import dev.dettmer.simplenotes.sync.SyncWorker
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.ComposeSettingsActivity
|
||||||
|
import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme
|
||||||
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
|
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Activity with Jetpack Compose UI
|
||||||
|
* v1.5.0: Complete MainActivity Redesign with Compose
|
||||||
|
*
|
||||||
|
* Replaces the old 805-line MainActivity.kt with a modern
|
||||||
|
* Compose-based implementation featuring:
|
||||||
|
* - Notes list with swipe-to-delete
|
||||||
|
* - Pull-to-refresh for sync
|
||||||
|
* - FAB with note type selection
|
||||||
|
* - Material 3 Design with Dynamic Colors (Material You)
|
||||||
|
* - Design consistent with ComposeSettingsActivity
|
||||||
|
*/
|
||||||
|
class ComposeMainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ComposeMainActivity"
|
||||||
|
private const val REQUEST_NOTIFICATION_PERMISSION = 1001
|
||||||
|
private const val REQUEST_SETTINGS = 1002
|
||||||
|
}
|
||||||
|
|
||||||
|
private val viewModel: MainViewModel by viewModels()
|
||||||
|
|
||||||
|
private val prefs by lazy {
|
||||||
|
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: Track if coming from editor to scroll to top
|
||||||
|
private var cameFromEditor = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BroadcastReceiver for Background-Sync Completion (Periodic Sync)
|
||||||
|
*/
|
||||||
|
private val syncCompletedReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
val success = intent?.getBooleanExtra("success", false) ?: false
|
||||||
|
val count = intent?.getIntExtra("count", 0) ?: 0
|
||||||
|
|
||||||
|
Logger.d(TAG, "📡 Sync completed broadcast received: success=$success, count=$count")
|
||||||
|
|
||||||
|
// UI refresh
|
||||||
|
if (success && count > 0) {
|
||||||
|
viewModel.loadNotes()
|
||||||
|
Logger.d(TAG, "🔄 Notes reloaded after background sync")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
// Install Splash Screen (Android 12+)
|
||||||
|
installSplashScreen()
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Apply Dynamic Colors for Material You (Android 12+)
|
||||||
|
DynamicColors.applyToActivityIfAvailable(this)
|
||||||
|
|
||||||
|
// Enable edge-to-edge display
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
// Initialize Logger and enable file logging if configured
|
||||||
|
Logger.init(this)
|
||||||
|
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
|
||||||
|
Logger.setFileLoggingEnabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear old sync notifications on app start
|
||||||
|
NotificationHelper.clearSyncNotifications(this)
|
||||||
|
|
||||||
|
// Request notification permission (Android 13+)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
requestNotificationPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.4.1: Migrate checklists for backwards compatibility
|
||||||
|
migrateChecklistsForBackwardsCompat()
|
||||||
|
|
||||||
|
// Setup Sync State Observer
|
||||||
|
setupSyncStateObserver()
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
SimpleNotesTheme {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// Dialog state for delete confirmation
|
||||||
|
var deleteDialogData by remember { mutableStateOf<MainViewModel.DeleteDialogData?>(null) }
|
||||||
|
|
||||||
|
// Handle delete dialog events
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.showDeleteDialog.collect { data ->
|
||||||
|
deleteDialogData = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle toast events
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.showToast.collect { message ->
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete confirmation dialog
|
||||||
|
deleteDialogData?.let { data ->
|
||||||
|
DeleteConfirmationDialog(
|
||||||
|
noteTitle = data.note.title,
|
||||||
|
onDismiss = {
|
||||||
|
viewModel.restoreNoteAfterSwipe(data.originalList)
|
||||||
|
deleteDialogData = null
|
||||||
|
},
|
||||||
|
onDeleteLocal = {
|
||||||
|
viewModel.deleteNoteConfirmed(data.note, deleteFromServer = false)
|
||||||
|
deleteDialogData = null
|
||||||
|
},
|
||||||
|
onDeleteFromServer = {
|
||||||
|
viewModel.deleteNoteConfirmed(data.note, deleteFromServer = true)
|
||||||
|
deleteDialogData = null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
MainScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onOpenNote = { noteId -> openNoteEditor(noteId) },
|
||||||
|
onOpenSettings = { openSettings() },
|
||||||
|
onCreateNote = { noteType -> createNote(noteType) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
|
||||||
|
Logger.d(TAG, "📱 ComposeMainActivity.onResume() - Registering receivers")
|
||||||
|
|
||||||
|
// 🌟 v1.6.0: Refresh offline mode state FIRST (before any sync checks)
|
||||||
|
// This ensures UI reflects current offline mode when returning from Settings
|
||||||
|
viewModel.refreshOfflineModeState()
|
||||||
|
|
||||||
|
// 🎨 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() {
|
||||||
|
SyncStateManager.syncStatus.observe(this) { status ->
|
||||||
|
viewModel.updateSyncState(status)
|
||||||
|
|
||||||
|
@Suppress("MagicNumber") // UI timing delays for banner visibility
|
||||||
|
// Hide banner after delay for completed/error states
|
||||||
|
when (status.state) {
|
||||||
|
SyncStateManager.SyncState.COMPLETED -> {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
kotlinx.coroutines.delay(1500L)
|
||||||
|
SyncStateManager.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SyncStateManager.SyncState.ERROR -> {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
kotlinx.coroutines.delay(3000L)
|
||||||
|
SyncStateManager.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> { /* No action needed */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openNoteEditor(noteId: String?) {
|
||||||
|
cameFromEditor = true
|
||||||
|
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
|
||||||
|
noteId?.let {
|
||||||
|
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_ID, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.5.0: Add slide animation
|
||||||
|
val options = ActivityOptions.makeCustomAnimation(
|
||||||
|
this,
|
||||||
|
dev.dettmer.simplenotes.R.anim.slide_in_right,
|
||||||
|
dev.dettmer.simplenotes.R.anim.slide_out_left
|
||||||
|
)
|
||||||
|
startActivity(intent, options.toBundle())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNote(noteType: NoteType) {
|
||||||
|
cameFromEditor = true
|
||||||
|
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
|
||||||
|
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
|
||||||
|
|
||||||
|
// v1.5.0: Add slide animation
|
||||||
|
val options = ActivityOptions.makeCustomAnimation(
|
||||||
|
this,
|
||||||
|
dev.dettmer.simplenotes.R.anim.slide_in_right,
|
||||||
|
dev.dettmer.simplenotes.R.anim.slide_out_left
|
||||||
|
)
|
||||||
|
startActivity(intent, options.toBundle())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openSettings() {
|
||||||
|
val intent = Intent(this, ComposeSettingsActivity::class.java)
|
||||||
|
val options = ActivityOptions.makeCustomAnimation(
|
||||||
|
this,
|
||||||
|
dev.dettmer.simplenotes.R.anim.slide_in_right,
|
||||||
|
dev.dettmer.simplenotes.R.anim.slide_out_left
|
||||||
|
)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
startActivityForResult(intent, REQUEST_SETTINGS, options.toBundle())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestNotificationPermission() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
|
requestPermissions(
|
||||||
|
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||||
|
REQUEST_NOTIFICATION_PERMISSION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.4.1: Migrates existing checklists for backwards compatibility.
|
||||||
|
*/
|
||||||
|
private fun migrateChecklistsForBackwardsCompat() {
|
||||||
|
val migrationKey = "v1.4.1_checklist_migration_done"
|
||||||
|
|
||||||
|
// Only run once
|
||||||
|
if (prefs.getBoolean(migrationKey, false)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val storage = NotesStorage(this)
|
||||||
|
val allNotes = storage.loadAllNotes()
|
||||||
|
val checklistsToMigrate = allNotes.filter { note ->
|
||||||
|
note.noteType == NoteType.CHECKLIST &&
|
||||||
|
note.content.isBlank() &&
|
||||||
|
note.checklistItems?.isNotEmpty() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checklistsToMigrate.isNotEmpty()) {
|
||||||
|
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
|
||||||
|
|
||||||
|
for (note in checklistsToMigrate) {
|
||||||
|
val updatedNote = note.copy(
|
||||||
|
syncStatus = SyncStatus.PENDING
|
||||||
|
)
|
||||||
|
storage.saveNote(updatedNote)
|
||||||
|
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark migration as done
|
||||||
|
prefs.edit().putBoolean(migrationKey, true).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
|
|
||||||
|
if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) {
|
||||||
|
// Settings changed, reload notes
|
||||||
|
viewModel.loadNotes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in API 23", ReplaceWith("Use ActivityResultContracts"))
|
||||||
|
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
|
||||||
|
override fun onRequestPermissionsResult(
|
||||||
|
requestCode: Int,
|
||||||
|
permissions: Array<out String>,
|
||||||
|
grantResults: IntArray
|
||||||
|
) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
|
|
||||||
|
when (requestCode) {
|
||||||
|
REQUEST_NOTIFICATION_PERMISSION -> {
|
||||||
|
if (grantResults.isNotEmpty() &&
|
||||||
|
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
Toast.makeText(this, getString(R.string.toast_notifications_enabled), Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(this,
|
||||||
|
getString(R.string.toast_notifications_disabled),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete confirmation dialog
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DeleteConfirmationDialog(
|
||||||
|
noteTitle: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onDeleteLocal: () -> Unit,
|
||||||
|
onDeleteFromServer: () -> Unit
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.legacy_delete_dialog_title)) },
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.legacy_delete_dialog_message, noteTitle))
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDeleteLocal) {
|
||||||
|
Text(stringResource(R.string.delete_local_only))
|
||||||
|
}
|
||||||
|
TextButton(onClick = onDeleteFromServer) {
|
||||||
|
Text(stringResource(R.string.legacy_delete_from_server))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.main
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.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.material3.ExperimentalMaterial3Api
|
||||||
|
// FabPosition nicht mehr benötigt - FAB wird manuell platziert
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.models.NoteType
|
||||||
|
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||||
|
import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog
|
||||||
|
import dev.dettmer.simplenotes.ui.main.components.EmptyState
|
||||||
|
import dev.dettmer.simplenotes.ui.main.components.NoteTypeFAB
|
||||||
|
import dev.dettmer.simplenotes.ui.main.components.NotesList
|
||||||
|
import dev.dettmer.simplenotes.ui.main.components.NotesStaggeredGrid
|
||||||
|
import dev.dettmer.simplenotes.ui.main.components.SyncStatusBanner
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main screen displaying the notes list
|
||||||
|
* v1.5.0: Jetpack Compose MainActivity Redesign
|
||||||
|
*
|
||||||
|
* Performance optimized with proper state handling:
|
||||||
|
* - LazyListState for scroll control
|
||||||
|
* - Scaffold FAB slot for proper z-ordering
|
||||||
|
* - Scroll-to-top on new note
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(
|
||||||
|
viewModel: MainViewModel,
|
||||||
|
onOpenNote: (String?) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
onCreateNote: (NoteType) -> Unit
|
||||||
|
) {
|
||||||
|
val notes by viewModel.notes.collectAsState()
|
||||||
|
val syncState by viewModel.syncState.collectAsState()
|
||||||
|
val syncMessage by viewModel.syncMessage.collectAsState()
|
||||||
|
val scrollToTop by viewModel.scrollToTop.collectAsState()
|
||||||
|
|
||||||
|
// Multi-Select State
|
||||||
|
val selectedNotes by viewModel.selectedNotes.collectAsState()
|
||||||
|
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
|
||||||
|
|
||||||
|
// 🌟 v1.6.0: Reactive offline mode state
|
||||||
|
val isOfflineMode by viewModel.isOfflineMode.collectAsState()
|
||||||
|
|
||||||
|
// 🎨 v1.7.0: Display mode (list or grid)
|
||||||
|
val displayMode by viewModel.displayMode.collectAsState()
|
||||||
|
|
||||||
|
// Delete confirmation dialog state
|
||||||
|
var showBatchDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
// 🎨 v1.7.0: gridState für Staggered Grid Layout
|
||||||
|
val gridState = rememberLazyStaggeredGridState()
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
onSyncClick = { viewModel.triggerManualSync("toolbar") },
|
||||||
|
onSettingsClick = onOpenSettings
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// FAB wird manuell in Box platziert für korrekten z-Index
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
) { paddingValues ->
|
||||||
|
// 🌟 v1.6.0: PullToRefreshBox only enabled when sync available
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = isSyncing,
|
||||||
|
onRefresh = { if (isSyncAvailable) viewModel.triggerManualSync("pullToRefresh") },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Main content column
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Sync Status Banner (not affected by pull-to-refresh)
|
||||||
|
SyncStatusBanner(
|
||||||
|
syncState = syncState,
|
||||||
|
message = syncMessage
|
||||||
|
)
|
||||||
|
|
||||||
|
// Content: Empty state or notes list
|
||||||
|
if (notes.isEmpty()) {
|
||||||
|
EmptyState(modifier = Modifier.weight(1f))
|
||||||
|
} else {
|
||||||
|
// 🎨 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,
|
||||||
|
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,
|
||||||
|
listState = listState,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
onNoteClick = { note -> onOpenNote(note.id) },
|
||||||
|
onNoteLongPress = { note ->
|
||||||
|
// Long-press starts selection mode
|
||||||
|
viewModel.startSelectionMode(note.id)
|
||||||
|
},
|
||||||
|
onNoteSelectionToggle = { note ->
|
||||||
|
viewModel.toggleNoteSelection(note.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FAB als TOP-LAYER - nur anzeigen wenn nicht im Selection Mode
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = !isSelectionMode,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(16.dp)
|
||||||
|
.zIndex(Float.MAX_VALUE)
|
||||||
|
) {
|
||||||
|
NoteTypeFAB(
|
||||||
|
onCreateNote = onCreateNote
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch Delete Confirmation Dialog
|
||||||
|
if (showBatchDeleteDialog) {
|
||||||
|
DeleteConfirmationDialog(
|
||||||
|
noteCount = selectedNotes.size,
|
||||||
|
isOfflineMode = isOfflineMode,
|
||||||
|
onDismiss = { showBatchDeleteDialog = false },
|
||||||
|
onDeleteLocal = {
|
||||||
|
viewModel.deleteSelectedNotes(deleteFromServer = false)
|
||||||
|
showBatchDeleteDialog = false
|
||||||
|
},
|
||||||
|
onDeleteEverywhere = {
|
||||||
|
viewModel.deleteSelectedNotes(deleteFromServer = true)
|
||||||
|
showBatchDeleteDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun MainTopBar(
|
||||||
|
syncEnabled: Boolean,
|
||||||
|
onSyncClick: () -> Unit,
|
||||||
|
onSettingsClick: () -> Unit
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.main_title),
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = onSyncClick,
|
||||||
|
enabled = syncEnabled
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Refresh,
|
||||||
|
contentDescription = stringResource(R.string.action_sync)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onSettingsClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Settings,
|
||||||
|
contentDescription = stringResource(R.string.action_settings)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selection mode TopBar - shows selected count and actions
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun SelectionTopBar(
|
||||||
|
selectedCount: Int,
|
||||||
|
totalCount: Int,
|
||||||
|
onCloseSelection: () -> Unit,
|
||||||
|
onSelectAll: () -> Unit,
|
||||||
|
onDeleteSelected: () -> Unit
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onCloseSelection) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.action_close_selection)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.selection_count, selectedCount),
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
// Select All button (only if not all selected)
|
||||||
|
if (selectedCount < totalCount) {
|
||||||
|
IconButton(onClick = onSelectAll) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.SelectAll,
|
||||||
|
contentDescription = stringResource(R.string.action_select_all)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Delete button
|
||||||
|
IconButton(
|
||||||
|
onClick = onDeleteSelected,
|
||||||
|
enabled = selectedCount > 0
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = stringResource(R.string.action_delete_selected),
|
||||||
|
tint = if (selectedCount > 0) {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,703 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.main
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dev.dettmer.simplenotes.models.Note
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
|
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||||
|
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||||
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewModel for MainActivity Compose
|
||||||
|
* v1.5.0: Jetpack Compose MainActivity Redesign
|
||||||
|
*
|
||||||
|
* Manages notes list, sync state, and deletion with undo.
|
||||||
|
*/
|
||||||
|
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "MainViewModel"
|
||||||
|
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
|
||||||
|
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val storage = NotesStorage(application)
|
||||||
|
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Notes State
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private val _notes = MutableStateFlow<List<Note>>(emptyList())
|
||||||
|
val notes: StateFlow<List<Note>> = _notes.asStateFlow()
|
||||||
|
|
||||||
|
private val _pendingDeletions = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
val pendingDeletions: StateFlow<Set<String>> = _pendingDeletions.asStateFlow()
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Multi-Select State (v1.5.0)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private val _selectedNotes = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
val selectedNotes: StateFlow<Set<String>> = _selectedNotes.asStateFlow()
|
||||||
|
|
||||||
|
val isSelectionMode: StateFlow<Boolean> = _selectedNotes
|
||||||
|
.map { it.isNotEmpty() }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// 🌟 v1.6.0: Offline Mode State (reactive)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private val _isOfflineMode = MutableStateFlow(
|
||||||
|
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
|
||||||
|
)
|
||||||
|
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh offline mode state from SharedPreferences
|
||||||
|
* Called when returning from Settings screen (in onResume)
|
||||||
|
*/
|
||||||
|
fun refreshOfflineModeState() {
|
||||||
|
val oldValue = _isOfflineMode.value
|
||||||
|
val newValue = prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
|
||||||
|
_isOfflineMode.value = newValue
|
||||||
|
Logger.d(TAG, "🔄 refreshOfflineModeState: offlineMode=$oldValue → $newValue")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// 🎨 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Sync State (derived from SyncStateManager)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE)
|
||||||
|
val syncState: StateFlow<SyncStateManager.SyncState> = _syncState.asStateFlow()
|
||||||
|
|
||||||
|
private val _syncMessage = MutableStateFlow<String?>(null)
|
||||||
|
val syncMessage: StateFlow<String?> = _syncMessage.asStateFlow()
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// UI Events
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private val _showToast = MutableSharedFlow<String>()
|
||||||
|
val showToast: SharedFlow<String> = _showToast.asSharedFlow()
|
||||||
|
|
||||||
|
private val _showDeleteDialog = MutableSharedFlow<DeleteDialogData>()
|
||||||
|
val showDeleteDialog: SharedFlow<DeleteDialogData> = _showDeleteDialog.asSharedFlow()
|
||||||
|
|
||||||
|
private val _showSnackbar = MutableSharedFlow<SnackbarData>()
|
||||||
|
val showSnackbar: SharedFlow<SnackbarData> = _showSnackbar.asSharedFlow()
|
||||||
|
|
||||||
|
// Phase 3: Scroll-to-top when new note is created
|
||||||
|
private val _scrollToTop = MutableStateFlow(false)
|
||||||
|
val scrollToTop: StateFlow<Boolean> = _scrollToTop.asStateFlow()
|
||||||
|
|
||||||
|
// Track first note ID to detect new notes
|
||||||
|
private var previousFirstNoteId: String? = null
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Data Classes
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
data class DeleteDialogData(
|
||||||
|
val note: Note,
|
||||||
|
val originalList: List<Note>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SnackbarData(
|
||||||
|
val message: String,
|
||||||
|
val actionLabel: String,
|
||||||
|
val onAction: () -> Unit
|
||||||
|
)
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Initialization
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
init {
|
||||||
|
// v1.5.0 Performance: Load notes asynchronously to avoid blocking UI
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
loadNotesAsync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Notes Actions
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load notes asynchronously on IO dispatcher
|
||||||
|
* This prevents UI blocking during app startup
|
||||||
|
*/
|
||||||
|
private suspend fun loadNotesAsync() {
|
||||||
|
val allNotes = storage.loadAllNotes()
|
||||||
|
val pendingIds = _pendingDeletions.value
|
||||||
|
val filteredNotes = allNotes.filter { it.id !in pendingIds }
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
// Phase 3: Detect if a new note was added at the top
|
||||||
|
val newFirstNoteId = filteredNotes.firstOrNull()?.id
|
||||||
|
if (newFirstNoteId != null &&
|
||||||
|
previousFirstNoteId != null &&
|
||||||
|
newFirstNoteId != previousFirstNoteId) {
|
||||||
|
// New note at top → trigger scroll
|
||||||
|
_scrollToTop.value = true
|
||||||
|
Logger.d(TAG, "📜 New note detected at top, triggering scroll-to-top")
|
||||||
|
}
|
||||||
|
previousFirstNoteId = newFirstNoteId
|
||||||
|
|
||||||
|
_notes.value = filteredNotes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public loadNotes - delegates to async version
|
||||||
|
*/
|
||||||
|
fun loadNotes() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
loadNotesAsync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset scroll-to-top flag after scroll completed
|
||||||
|
*/
|
||||||
|
fun resetScrollToTop() {
|
||||||
|
_scrollToTop.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force scroll to top (e.g., after returning from editor)
|
||||||
|
*/
|
||||||
|
fun scrollToTop() {
|
||||||
|
_scrollToTop.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Multi-Select Actions (v1.5.0)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle selection of a note
|
||||||
|
*/
|
||||||
|
fun toggleNoteSelection(noteId: String) {
|
||||||
|
_selectedNotes.value = if (noteId in _selectedNotes.value) {
|
||||||
|
_selectedNotes.value - noteId
|
||||||
|
} else {
|
||||||
|
_selectedNotes.value + noteId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start selection mode with initial note
|
||||||
|
*/
|
||||||
|
fun startSelectionMode(noteId: String) {
|
||||||
|
_selectedNotes.value = setOf(noteId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select all notes
|
||||||
|
*/
|
||||||
|
fun selectAllNotes() {
|
||||||
|
_selectedNotes.value = _notes.value.map { it.id }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear selection and exit selection mode
|
||||||
|
*/
|
||||||
|
fun clearSelection() {
|
||||||
|
_selectedNotes.value = emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of selected notes
|
||||||
|
*/
|
||||||
|
fun getSelectedCount(): Int = _selectedNotes.value.size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all selected notes
|
||||||
|
*/
|
||||||
|
fun deleteSelectedNotes(deleteFromServer: Boolean) {
|
||||||
|
val selectedIds = _selectedNotes.value.toList()
|
||||||
|
val selectedNotes = _notes.value.filter { it.id in selectedIds }
|
||||||
|
|
||||||
|
if (selectedNotes.isEmpty()) return
|
||||||
|
|
||||||
|
// Add to pending deletions
|
||||||
|
_pendingDeletions.value = _pendingDeletions.value + selectedIds.toSet()
|
||||||
|
|
||||||
|
// Delete from storage
|
||||||
|
selectedNotes.forEach { note ->
|
||||||
|
storage.deleteNote(note.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
clearSelection()
|
||||||
|
|
||||||
|
// Reload notes
|
||||||
|
loadNotes()
|
||||||
|
|
||||||
|
// Show snackbar with undo
|
||||||
|
val count = selectedNotes.size
|
||||||
|
val message = if (deleteFromServer) {
|
||||||
|
getString(R.string.snackbar_notes_deleted_server, count)
|
||||||
|
} else {
|
||||||
|
getString(R.string.snackbar_notes_deleted_local, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_showSnackbar.emit(SnackbarData(
|
||||||
|
message = message,
|
||||||
|
actionLabel = getString(R.string.snackbar_undo),
|
||||||
|
onAction = {
|
||||||
|
undoDeleteMultiple(selectedNotes)
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
@Suppress("MagicNumber") // Snackbar timing coordination
|
||||||
|
// If delete from server, actually delete after a short delay
|
||||||
|
// (to allow undo action before server deletion)
|
||||||
|
if (deleteFromServer) {
|
||||||
|
kotlinx.coroutines.delay(3500) // Snackbar shows for ~3s
|
||||||
|
// Only delete if not restored (check if still in pending)
|
||||||
|
val idsToDelete = selectedIds.filter { it in _pendingDeletions.value }
|
||||||
|
if (idsToDelete.isNotEmpty()) {
|
||||||
|
deleteMultipleNotesFromServer(idsToDelete)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Just finalize local deletion
|
||||||
|
selectedIds.forEach { noteId ->
|
||||||
|
finalizeDeletion(noteId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo deletion of multiple notes
|
||||||
|
*/
|
||||||
|
private fun undoDeleteMultiple(notes: List<Note>) {
|
||||||
|
// Remove from pending deletions
|
||||||
|
_pendingDeletions.value = _pendingDeletions.value - notes.map { it.id }.toSet()
|
||||||
|
|
||||||
|
// Restore to storage
|
||||||
|
notes.forEach { note ->
|
||||||
|
storage.saveNote(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload notes
|
||||||
|
loadNotes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when user long-presses a note to delete
|
||||||
|
* Shows dialog for delete confirmation (replaces swipe-to-delete for performance)
|
||||||
|
*/
|
||||||
|
fun onNoteLongPressDelete(note: Note) {
|
||||||
|
val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
|
||||||
|
|
||||||
|
// Store original list for potential restore
|
||||||
|
val originalList = _notes.value.toList()
|
||||||
|
|
||||||
|
if (alwaysDeleteFromServer) {
|
||||||
|
// Auto-delete without dialog
|
||||||
|
deleteNoteConfirmed(note, deleteFromServer = true)
|
||||||
|
} else {
|
||||||
|
// Show dialog - don't remove from UI yet (user can cancel)
|
||||||
|
viewModelScope.launch {
|
||||||
|
_showDeleteDialog.emit(DeleteDialogData(note, originalList))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when user swipes to delete a note (legacy - kept for compatibility)
|
||||||
|
* Shows dialog if "always delete from server" is not enabled
|
||||||
|
*/
|
||||||
|
fun onNoteSwipedToDelete(note: Note) {
|
||||||
|
onNoteLongPressDelete(note) // Delegate to long-press handler
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore note after swipe (user cancelled dialog)
|
||||||
|
*/
|
||||||
|
fun restoreNoteAfterSwipe(originalList: List<Note>) {
|
||||||
|
_notes.value = originalList
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm note deletion (from dialog or auto-delete)
|
||||||
|
*/
|
||||||
|
fun deleteNoteConfirmed(note: Note, deleteFromServer: Boolean) {
|
||||||
|
// Add to pending deletions
|
||||||
|
_pendingDeletions.value = _pendingDeletions.value + note.id
|
||||||
|
|
||||||
|
// Delete from storage
|
||||||
|
storage.deleteNote(note.id)
|
||||||
|
|
||||||
|
// Reload notes
|
||||||
|
loadNotes()
|
||||||
|
|
||||||
|
// Show snackbar with undo
|
||||||
|
val message = if (deleteFromServer) {
|
||||||
|
getString(R.string.snackbar_note_deleted_server, note.title)
|
||||||
|
} else {
|
||||||
|
getString(R.string.snackbar_note_deleted_local, note.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_showSnackbar.emit(SnackbarData(
|
||||||
|
message = message,
|
||||||
|
actionLabel = getString(R.string.snackbar_undo),
|
||||||
|
onAction = {
|
||||||
|
undoDelete(note)
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
@Suppress("MagicNumber") // Snackbar timing
|
||||||
|
// If delete from server, actually delete after snackbar timeout
|
||||||
|
if (deleteFromServer) {
|
||||||
|
kotlinx.coroutines.delay(3500) // Snackbar shows for ~3s
|
||||||
|
// Only delete if not restored (check if still in pending)
|
||||||
|
if (note.id in _pendingDeletions.value) {
|
||||||
|
deleteNoteFromServer(note.id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Just finalize local deletion
|
||||||
|
finalizeDeletion(note.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo note deletion
|
||||||
|
*/
|
||||||
|
fun undoDelete(note: Note) {
|
||||||
|
// Remove from pending deletions
|
||||||
|
_pendingDeletions.value = _pendingDeletions.value - note.id
|
||||||
|
|
||||||
|
// Restore to storage
|
||||||
|
storage.saveNote(note)
|
||||||
|
|
||||||
|
// Reload notes
|
||||||
|
loadNotes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actually delete note from server after snackbar dismissed
|
||||||
|
*/
|
||||||
|
fun deleteNoteFromServer(noteId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val webdavService = WebDavSyncService(getApplication())
|
||||||
|
val success = withContext(Dispatchers.IO) {
|
||||||
|
webdavService.deleteNoteFromServer(noteId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
_showToast.emit(getString(R.string.snackbar_deleted_from_server))
|
||||||
|
} else {
|
||||||
|
_showToast.emit(getString(R.string.snackbar_server_delete_failed))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_showToast.emit(getString(R.string.snackbar_server_error, e.message ?: ""))
|
||||||
|
} finally {
|
||||||
|
// Remove from pending deletions
|
||||||
|
_pendingDeletions.value = _pendingDeletions.value - noteId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete multiple notes from server with aggregated toast
|
||||||
|
* Shows single toast at the end instead of one per note
|
||||||
|
*/
|
||||||
|
private fun deleteMultipleNotesFromServer(noteIds: List<String>) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val webdavService = WebDavSyncService(getApplication())
|
||||||
|
var successCount = 0
|
||||||
|
var failCount = 0
|
||||||
|
|
||||||
|
noteIds.forEach { noteId ->
|
||||||
|
try {
|
||||||
|
val success = withContext(Dispatchers.IO) {
|
||||||
|
webdavService.deleteNoteFromServer(noteId)
|
||||||
|
}
|
||||||
|
if (success) successCount++ else failCount++
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.w(TAG, "Failed to delete note $noteId from server: ${e.message}")
|
||||||
|
failCount++
|
||||||
|
} finally {
|
||||||
|
_pendingDeletions.value = _pendingDeletions.value - noteId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show aggregated toast
|
||||||
|
val message = when {
|
||||||
|
failCount == 0 -> getString(R.string.snackbar_notes_deleted_from_server, successCount)
|
||||||
|
successCount == 0 -> getString(R.string.snackbar_server_delete_failed)
|
||||||
|
else -> getString(
|
||||||
|
R.string.snackbar_notes_deleted_from_server_partial,
|
||||||
|
successCount,
|
||||||
|
successCount + failCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_showToast.emit(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize deletion (remove from pending set)
|
||||||
|
*/
|
||||||
|
fun finalizeDeletion(noteId: String) {
|
||||||
|
_pendingDeletions.value = _pendingDeletions.value - noteId
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Sync Actions
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
fun updateSyncState(status: SyncStateManager.SyncStatus) {
|
||||||
|
_syncState.value = status.state
|
||||||
|
_syncMessage.value = status.message
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger manual sync (from toolbar button or pull-to-refresh)
|
||||||
|
* v1.7.0: Uses central canSync() gate for WiFi-only check
|
||||||
|
*/
|
||||||
|
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_hint))
|
||||||
|
} else {
|
||||||
|
Logger.d(TAG, "⏭️ $source Sync blocked: ${gateResult.blockReason ?: "offline/no server"}")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 v1.7.0: Feedback wenn Sync bereits läuft
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
// Check for unsynced changes
|
||||||
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
|
Logger.d(TAG, "⏭️ $source Sync: No unsynced changes")
|
||||||
|
SyncStateManager.markCompleted("Bereits synchronisiert")
|
||||||
|
loadNotes()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check server reachability
|
||||||
|
val isReachable = withContext(Dispatchers.IO) {
|
||||||
|
syncService.isServerReachable()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isReachable) {
|
||||||
|
Logger.d(TAG, "⏭️ $source Sync: Server not reachable")
|
||||||
|
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform sync
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
syncService.syncNotes()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.isSuccess) {
|
||||||
|
val bannerMessage = if (result.syncedCount > 0) {
|
||||||
|
getString(R.string.toast_sync_success, result.syncedCount)
|
||||||
|
} else {
|
||||||
|
getString(R.string.snackbar_nothing_to_sync)
|
||||||
|
}
|
||||||
|
SyncStateManager.markCompleted(bannerMessage)
|
||||||
|
loadNotes()
|
||||||
|
} else {
|
||||||
|
SyncStateManager.markError(result.errorMessage)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
SyncStateManager.markError(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger auto-sync (onResume)
|
||||||
|
* Only runs if server is configured and interval has passed
|
||||||
|
* v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt
|
||||||
|
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_ON_RESUME
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttling check
|
||||||
|
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, aber Fehler werden trotzdem angezeigt
|
||||||
|
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
|
||||||
|
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
|
||||||
|
|
||||||
|
// Update last sync timestamp
|
||||||
|
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
// Check for unsynced changes
|
||||||
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
|
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
|
||||||
|
SyncStateManager.reset()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check server reachability
|
||||||
|
val isReachable = withContext(Dispatchers.IO) {
|
||||||
|
syncService.isServerReachable()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isReachable) {
|
||||||
|
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
|
||||||
|
SyncStateManager.reset()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform sync
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
syncService.syncNotes()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.isSuccess && result.syncedCount > 0) {
|
||||||
|
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
|
||||||
|
SyncStateManager.markCompleted(getString(R.string.toast_sync_success, result.syncedCount))
|
||||||
|
_showToast.emit(getString(R.string.snackbar_synced_count, result.syncedCount))
|
||||||
|
loadNotes()
|
||||||
|
} else if (result.isSuccess) {
|
||||||
|
Logger.d(TAG, "ℹ️ Auto-sync ($source): No changes")
|
||||||
|
SyncStateManager.markCompleted(getString(R.string.snackbar_nothing_to_sync))
|
||||||
|
} else {
|
||||||
|
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
|
||||||
|
SyncStateManager.markError(result.errorMessage)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
|
||||||
|
SyncStateManager.markError(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canTriggerAutoSync(): Boolean {
|
||||||
|
val lastSyncTime = prefs.getLong(PREF_LAST_AUTO_SYNC_TIME, 0)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val timeSinceLastSync = now - lastSyncTime
|
||||||
|
|
||||||
|
if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) {
|
||||||
|
val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
|
||||||
|
Logger.d(TAG, "⏳ Auto-sync throttled - wait ${remainingSeconds}s")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Helpers
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private fun getString(resId: Int): String = getApplication<android.app.Application>().getString(resId)
|
||||||
|
|
||||||
|
private fun getString(resId: Int, vararg formatArgs: Any): String =
|
||||||
|
getApplication<android.app.Application>().getString(resId, *formatArgs)
|
||||||
|
|
||||||
|
fun isServerConfigured(): Boolean {
|
||||||
|
// 🌟 v1.6.0: Use reactive offline mode state
|
||||||
|
if (_isOfflineMode.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||||
|
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🌟 v1.6.0: Check if server has a configured URL (ignores offline mode)
|
||||||
|
* Used for determining if sync would be available when offline mode is disabled
|
||||||
|
*/
|
||||||
|
fun hasServerConfig(): Boolean {
|
||||||
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||||
|
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,240 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.main.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.List
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material.icons.outlined.CloudDone
|
||||||
|
import androidx.compose.material.icons.outlined.CloudOff
|
||||||
|
import androidx.compose.material.icons.outlined.CloudSync
|
||||||
|
import androidx.compose.material.icons.outlined.Description
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.models.Note
|
||||||
|
import dev.dettmer.simplenotes.models.NoteType
|
||||||
|
import dev.dettmer.simplenotes.models.SyncStatus
|
||||||
|
import dev.dettmer.simplenotes.utils.toReadableTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note card - v1.5.0 with Multi-Select Support
|
||||||
|
*
|
||||||
|
* ULTRA SIMPLE + SELECTION:
|
||||||
|
* - NO remember() anywhere
|
||||||
|
* - Direct MaterialTheme access
|
||||||
|
* - Selection indicator via border + checkbox overlay
|
||||||
|
* - Long-press starts selection mode
|
||||||
|
* - Tap in selection mode toggles selection
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NoteCard(
|
||||||
|
note: Note,
|
||||||
|
showSyncStatus: Boolean,
|
||||||
|
isSelected: Boolean = false,
|
||||||
|
isSelectionMode: Boolean = false,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
// 🎨 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
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
tint = when (note.syncStatus) {
|
||||||
|
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
|
||||||
|
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
|
||||||
|
else -> MaterialTheme.colorScheme.outline
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection indicator checkbox (top-right)
|
||||||
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
visible = isSelectionMode,
|
||||||
|
enter = fadeIn() + scaleIn(),
|
||||||
|
exit = fadeOut() + scaleOut(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.border(
|
||||||
|
width = 2.dp,
|
||||||
|
color = if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.outline
|
||||||
|
},
|
||||||
|
shape = CircleShape
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (isSelected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = stringResource(R.string.selection_count, 1),
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
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 -> {
|
||||||
|
note.checklistItems
|
||||||
|
?.joinToString("\n") { item ->
|
||||||
|
val prefix = if (item.isChecked) "✅" else "☐"
|
||||||
|
"$prefix ${item.text}"
|
||||||
|
} ?: ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
tint = when (note.syncStatus) {
|
||||||
|
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
|
||||||
|
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
|
||||||
|
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,250 @@
|
|||||||
|
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.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,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val noteSize = note.getSize()
|
||||||
|
|
||||||
|
// 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 -> {
|
||||||
|
note.checklistItems
|
||||||
|
?.joinToString("\n") { item ->
|
||||||
|
val prefix = if (item.isChecked) "✅" else "☐"
|
||||||
|
"$prefix ${item.text}"
|
||||||
|
} ?: ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
tint = when (note.syncStatus) {
|
||||||
|
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
|
||||||
|
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
|
||||||
|
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,68 @@
|
|||||||
|
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
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NotesList(
|
||||||
|
notes: List<Note>,
|
||||||
|
showSyncStatus: Boolean,
|
||||||
|
selectedNotes: Set<String> = emptySet(),
|
||||||
|
isSelectionMode: Boolean = false,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
listState: LazyListState = rememberLazyListState(),
|
||||||
|
onNoteClick: (Note) -> Unit,
|
||||||
|
onNoteLongPress: (Note) -> Unit,
|
||||||
|
onNoteSelectionToggle: (Note) -> Unit = {}
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp)
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = notes,
|
||||||
|
key = { it.id },
|
||||||
|
contentType = { "NoteCard" }
|
||||||
|
) { note ->
|
||||||
|
val isSelected = note.id in selectedNotes
|
||||||
|
|
||||||
|
NoteCard(
|
||||||
|
note = note,
|
||||||
|
showSyncStatus = showSyncStatus,
|
||||||
|
isSelected = isSelected,
|
||||||
|
isSelectionMode = isSelectionMode,
|
||||||
|
// 🎨 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,70 @@
|
|||||||
|
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
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NotesStaggeredGrid(
|
||||||
|
notes: List<Note>,
|
||||||
|
gridState: LazyStaggeredGridState,
|
||||||
|
showSyncStatus: Boolean,
|
||||||
|
selectedNoteIds: Set<String>,
|
||||||
|
isSelectionMode: Boolean,
|
||||||
|
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 }
|
||||||
|
// 🎨 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,
|
||||||
|
onClick = { onNoteClick(note) },
|
||||||
|
onLongClick = { onNoteLongClick(note) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.main.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync status banner shown below the toolbar during sync
|
||||||
|
* v1.5.0: Jetpack Compose MainActivity Redesign
|
||||||
|
* v1.5.0: SYNCING_SILENT ignorieren - Banner nur bei manuellen Syncs oder Fehlern anzeigen
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SyncStatusBanner(
|
||||||
|
syncState: SyncStateManager.SyncState,
|
||||||
|
message: String?,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
// v1.5.0: Banner nicht anzeigen bei IDLE oder SYNCING_SILENT (Auto-Sync im Hintergrund)
|
||||||
|
// Fehler werden trotzdem angezeigt (ERROR state nach Silent-Sync wechselt zu ERROR, nicht SYNCING_SILENT)
|
||||||
|
val isVisible = syncState != SyncStateManager.SyncState.IDLE
|
||||||
|
&& syncState != SyncStateManager.SyncState.SYNCING_SILENT
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isVisible,
|
||||||
|
enter = expandVertically(),
|
||||||
|
exit = shrinkVertically(),
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (syncState == SyncStateManager.SyncState.SYNCING) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 3.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = when (syncState) {
|
||||||
|
SyncStateManager.SyncState.SYNCING -> stringResource(R.string.sync_status_syncing)
|
||||||
|
SyncStateManager.SyncState.SYNCING_SILENT -> "" // v1.5.0: Wird nicht angezeigt (isVisible = false)
|
||||||
|
SyncStateManager.SyncState.COMPLETED -> message ?: stringResource(R.string.sync_status_completed)
|
||||||
|
SyncStateManager.SyncState.ERROR -> message ?: stringResource(R.string.sync_status_error)
|
||||||
|
SyncStateManager.SyncState.IDLE -> ""
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import com.google.android.material.color.DynamicColors
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.SimpleNotesApplication
|
||||||
|
import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme
|
||||||
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings Activity with Jetpack Compose UI
|
||||||
|
* v1.5.0: Complete Settings Redesign with grouped screens
|
||||||
|
*
|
||||||
|
* Replaces the old 1147-line SettingsActivity.kt with a modern
|
||||||
|
* Compose-based implementation featuring:
|
||||||
|
* - 6 logical settings groups as separate screens
|
||||||
|
* - Material 3 Design with Dynamic Colors (Material You)
|
||||||
|
* - Navigation with back button in each screen
|
||||||
|
* - Clean separation of concerns with SettingsViewModel
|
||||||
|
*/
|
||||||
|
class ComposeSettingsActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ComposeSettingsActivity"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val viewModel: SettingsViewModel by viewModels()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Apply Dynamic Colors for Material You (Android 12+)
|
||||||
|
DynamicColors.applyToActivityIfAvailable(this)
|
||||||
|
|
||||||
|
// Enable edge-to-edge display
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
// Handle back button with slide animation
|
||||||
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
setResult(RESULT_OK)
|
||||||
|
finish()
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
overridePendingTransition(
|
||||||
|
dev.dettmer.simplenotes.R.anim.slide_in_left,
|
||||||
|
dev.dettmer.simplenotes.R.anim.slide_out_right
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Collect events from ViewModel (for Activity-level actions)
|
||||||
|
collectViewModelEvents()
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
SimpleNotesTheme {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// Toast handling from ViewModel
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.showToast.collect { message ->
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsNavHost(
|
||||||
|
navController = navController,
|
||||||
|
viewModel = viewModel,
|
||||||
|
onFinish = {
|
||||||
|
setResult(RESULT_OK)
|
||||||
|
finish()
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
overridePendingTransition(
|
||||||
|
dev.dettmer.simplenotes.R.anim.slide_in_left,
|
||||||
|
dev.dettmer.simplenotes.R.anim.slide_out_right
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect events from ViewModel for Activity-level actions
|
||||||
|
* v1.5.0: Ported from old SettingsActivity
|
||||||
|
*/
|
||||||
|
private fun collectViewModelEvents() {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
viewModel.events.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is SettingsViewModel.SettingsEvent.RequestBatteryOptimization -> {
|
||||||
|
checkBatteryOptimization()
|
||||||
|
}
|
||||||
|
is SettingsViewModel.SettingsEvent.RestartNetworkMonitor -> {
|
||||||
|
restartNetworkMonitor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if battery optimization is disabled for this app
|
||||||
|
* v1.5.0: Ported from old SettingsActivity
|
||||||
|
*/
|
||||||
|
private fun checkBatteryOptimization() {
|
||||||
|
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
|
||||||
|
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
|
||||||
|
showBatteryOptimizationDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show dialog asking user to disable battery optimization
|
||||||
|
* v1.5.0: Ported from old SettingsActivity
|
||||||
|
*/
|
||||||
|
private fun showBatteryOptimizationDialog() {
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle(getString(R.string.battery_optimization_dialog_title))
|
||||||
|
.setMessage(getString(R.string.battery_optimization_dialog_full_message))
|
||||||
|
.setPositiveButton(getString(R.string.battery_optimization_open_settings)) { _, _ ->
|
||||||
|
openBatteryOptimizationSettings()
|
||||||
|
}
|
||||||
|
.setNegativeButton(getString(R.string.battery_optimization_later)) { dialog, _ ->
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
.setCancelable(false)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open system battery optimization settings
|
||||||
|
* v1.5.0: Ported from old SettingsActivity
|
||||||
|
*/
|
||||||
|
private fun openBatteryOptimizationSettings() {
|
||||||
|
try {
|
||||||
|
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
|
||||||
|
intent.data = Uri.parse("package:$packageName")
|
||||||
|
startActivity(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.w(TAG, "Failed to open battery optimization settings: ${e.message}")
|
||||||
|
// Fallback: Open general battery settings
|
||||||
|
try {
|
||||||
|
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
||||||
|
startActivity(intent)
|
||||||
|
} catch (e2: Exception) {
|
||||||
|
Logger.w(TAG, "Failed to open fallback battery settings: ${e2.message}")
|
||||||
|
Toast.makeText(this, "Bitte Akku-Optimierung manuell deaktivieren", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restart the network monitor after sync settings change
|
||||||
|
* v1.5.0: Ported from old SettingsActivity
|
||||||
|
*/
|
||||||
|
private fun restartNetworkMonitor() {
|
||||||
|
try {
|
||||||
|
val app = application as SimpleNotesApplication
|
||||||
|
Logger.d(TAG, "🔄 Restarting NetworkMonitor with new settings")
|
||||||
|
app.networkMonitor.stopMonitoring()
|
||||||
|
app.networkMonitor.startMonitoring()
|
||||||
|
Logger.d(TAG, "✅ NetworkMonitor restarted successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "❌ Failed to restart NetworkMonitor: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,836 @@
|
|||||||
|
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.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 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.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()
|
||||||
|
|
||||||
|
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.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
|
||||||
|
try {
|
||||||
|
val result = backupManager.createBackup(uri, password)
|
||||||
|
val message = if (result.success) {
|
||||||
|
getString(R.string.toast_backup_success, result.message ?: "")
|
||||||
|
} else {
|
||||||
|
getString(R.string.toast_backup_failed, result.error ?: "")
|
||||||
|
}
|
||||||
|
emitToast(message)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emitToast(getString(R.string.toast_backup_failed, e.message ?: ""))
|
||||||
|
} finally {
|
||||||
|
_isBackupInProgress.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isBackupInProgress.value = true
|
||||||
|
try {
|
||||||
|
val result = backupManager.restoreBackup(uri, mode, password)
|
||||||
|
val message = if (result.success) {
|
||||||
|
getString(R.string.toast_restore_success, result.importedNotes)
|
||||||
|
} else {
|
||||||
|
getString(R.string.toast_restore_failed, result.error ?: "")
|
||||||
|
}
|
||||||
|
emitToast(message)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emitToast(getString(R.string.toast_restore_failed, e.message ?: ""))
|
||||||
|
} finally {
|
||||||
|
_isBackupInProgress.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔐 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
|
||||||
|
try {
|
||||||
|
emitToast(getString(R.string.restore_progress))
|
||||||
|
val syncService = WebDavSyncService(getApplication())
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
syncService.restoreFromServer(mode)
|
||||||
|
}
|
||||||
|
val message = if (result.isSuccess) {
|
||||||
|
getString(R.string.toast_restore_success, result.restoredCount)
|
||||||
|
} else {
|
||||||
|
getString(R.string.toast_restore_failed, result.errorMessage ?: "")
|
||||||
|
}
|
||||||
|
emitToast(message)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emitToast(getString(R.string.toast_error, e.message ?: ""))
|
||||||
|
} finally {
|
||||||
|
_isBackupInProgress.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Debug Settings Actions
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
fun setFileLogging(enabled: Boolean) {
|
||||||
|
_fileLoggingEnabled.value = enabled
|
||||||
|
prefs.edit().putBoolean(Constants.KEY_FILE_LOGGING_ENABLED, enabled).apply()
|
||||||
|
Logger.setFileLoggingEnabled(enabled)
|
||||||
|
viewModelScope.launch {
|
||||||
|
emitToast(if (enabled) getString(R.string.toast_file_logging_enabled) else getString(R.string.toast_file_logging_disabled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearLogs() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val cleared = Logger.clearLogFile(getApplication())
|
||||||
|
emitToast(if (cleared) getString(R.string.toast_logs_deleted) else getString(R.string.toast_logs_deleted))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emitToast(getString(R.string.toast_error, e.message ?: ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLogFile() = Logger.getLogFile(getApplication())
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Helper
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if server is configured AND not in offline mode
|
||||||
|
* v1.6.0: Returns false if offline mode is enabled
|
||||||
|
*/
|
||||||
|
fun isServerConfigured(): Boolean {
|
||||||
|
// Offline mode takes priority
|
||||||
|
if (_offlineMode.value) return false
|
||||||
|
|
||||||
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||||
|
return !serverUrl.isNullOrEmpty() &&
|
||||||
|
serverUrl != "http://" &&
|
||||||
|
serverUrl != "https://"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getString(resId: Int): String = getApplication<android.app.Application>().getString(resId)
|
||||||
|
|
||||||
|
private fun getString(resId: Int, vararg formatArgs: Any): String =
|
||||||
|
getApplication<android.app.Application>().getString(resId, *formatArgs)
|
||||||
|
|
||||||
|
private suspend fun emitToast(message: String) {
|
||||||
|
_showToast.emit(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server status states
|
||||||
|
* v1.6.0: Added OfflineMode state
|
||||||
|
*/
|
||||||
|
sealed class ServerStatus {
|
||||||
|
data object Unknown : ServerStatus()
|
||||||
|
data object OfflineMode : ServerStatus() // 🌟 v1.6.0
|
||||||
|
data object NotConfigured : ServerStatus()
|
||||||
|
data object Checking : ServerStatus()
|
||||||
|
data object Reachable : ServerStatus()
|
||||||
|
data class Unreachable(val error: String?) : ServerStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events for Activity-level actions (dialogs, intents, etc.)
|
||||||
|
* v1.5.0: Ported from old SettingsActivity
|
||||||
|
*/
|
||||||
|
sealed class SettingsEvent {
|
||||||
|
data object RequestBatteryOptimization : SettingsEvent()
|
||||||
|
data object RestartNetworkMonitor : SettingsEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress state for Markdown export
|
||||||
|
* v1.5.0: For initial export progress dialog
|
||||||
|
*/
|
||||||
|
data class MarkdownExportProgress(
|
||||||
|
val current: Int,
|
||||||
|
val total: Int,
|
||||||
|
val isComplete: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// 🎨 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,161 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.settings.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary filled button for settings actions
|
||||||
|
* v1.5.0: Jetpack Compose Settings Redesign
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SettingsButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
isLoading: Boolean = false
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled && !isLoading,
|
||||||
|
modifier = modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.height(20.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outlined secondary button for settings actions
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SettingsOutlinedButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
isLoading: Boolean = false
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled && !isLoading,
|
||||||
|
modifier = modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.height(20.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Danger/destructive button for settings actions
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SettingsDangerButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Info card with description text
|
||||||
|
* v1.6.0: Added isWarning parameter for offline mode warning
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SettingsInfoCard(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
isWarning: Boolean = false
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.Card(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||||
|
containerColor = if (isWarning) {
|
||||||
|
MaterialTheme.colorScheme.errorContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (isWarning) {
|
||||||
|
MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
lineHeight = MaterialTheme.typography.bodySmall.lineHeight * 1.3f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Section header text
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SettingsSectionHeader(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Divider between settings groups
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SettingsDivider(
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Spacer(modifier = modifier.height(8.dp))
|
||||||
|
androidx.compose.material3.HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
@@ -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,242 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.settings.screens
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.Code
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.Policy
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dev.dettmer.simplenotes.BuildConfig
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
|
||||||
|
|
||||||
|
/**
|
||||||
|
* About app information screen
|
||||||
|
* v1.5.0: Jetpack Compose Settings Redesign
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AboutScreen(
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val githubRepoUrl = "https://github.com/inventory69/simple-notes-sync"
|
||||||
|
val githubProfileUrl = "https://github.com/inventory69"
|
||||||
|
val licenseUrl = "https://github.com/inventory69/simple-notes-sync/blob/main/LICENSE"
|
||||||
|
|
||||||
|
SettingsScaffold(
|
||||||
|
title = stringResource(R.string.about_settings_title),
|
||||||
|
onBack = onBack
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// App Info Card
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
// v1.5.0: App icon foreground loaded directly for better quality
|
||||||
|
val context = LocalContext.current
|
||||||
|
val appIcon = remember {
|
||||||
|
val drawable = ContextCompat.getDrawable(context, R.mipmap.ic_launcher_foreground)
|
||||||
|
drawable?.let {
|
||||||
|
// Use fixed size for consistent quality (256x256)
|
||||||
|
val size = 256
|
||||||
|
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bitmap)
|
||||||
|
it.setBounds(0, 0, size, size)
|
||||||
|
it.draw(canvas)
|
||||||
|
bitmap.asImageBitmap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appIcon?.let {
|
||||||
|
Image(
|
||||||
|
bitmap = it,
|
||||||
|
contentDescription = stringResource(R.string.about_app_name),
|
||||||
|
modifier = Modifier.size(96.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about_app_name),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
SettingsSectionHeader(text = stringResource(R.string.about_links_section))
|
||||||
|
|
||||||
|
// GitHub Repository
|
||||||
|
AboutLinkItem(
|
||||||
|
icon = Icons.Default.Code,
|
||||||
|
title = stringResource(R.string.about_github_title),
|
||||||
|
subtitle = stringResource(R.string.about_github_subtitle),
|
||||||
|
onClick = {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(githubRepoUrl))
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Developer
|
||||||
|
AboutLinkItem(
|
||||||
|
icon = Icons.Default.Person,
|
||||||
|
title = stringResource(R.string.about_developer_title),
|
||||||
|
subtitle = stringResource(R.string.about_developer_subtitle),
|
||||||
|
onClick = {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(githubProfileUrl))
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// License
|
||||||
|
AboutLinkItem(
|
||||||
|
icon = Icons.Default.Policy,
|
||||||
|
title = stringResource(R.string.about_license_title),
|
||||||
|
subtitle = stringResource(R.string.about_license_subtitle),
|
||||||
|
onClick = {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(licenseUrl))
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingsDivider()
|
||||||
|
|
||||||
|
// Data Privacy Info
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about_privacy_title),
|
||||||
|
style = MaterialTheme.typography.titleSmall
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about_privacy_text),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clickable link item for About section
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AboutLinkItem(
|
||||||
|
icon: ImageVector,
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.settings.screens
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.backup.RestoreMode
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.BackupPasswordDialog
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsScaffold(
|
||||||
|
title = stringResource(R.string.backup_settings_title),
|
||||||
|
onBack = onBack
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Info Card
|
||||||
|
SettingsInfoCard(
|
||||||
|
text = stringResource(R.string.backup_auto_info)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Local Backup Section
|
||||||
|
SettingsSectionHeader(text = stringResource(R.string.backup_local_section))
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// 🔐 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.7.0: Check if backup is encrypted
|
||||||
|
viewModel.checkBackupEncryption(
|
||||||
|
uri = uri,
|
||||||
|
onEncrypted = {
|
||||||
|
showDecryptionPasswordDialog = true
|
||||||
|
},
|
||||||
|
onPlaintext = {
|
||||||
|
viewModel.restoreFromFile(uri, selectedRestoreMode, password = null)
|
||||||
|
pendingRestoreUri = null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RestoreSource.Server -> {
|
||||||
|
viewModel.restoreFromServer(selectedRestoreMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
showRestoreDialog = false
|
||||||
|
pendingRestoreUri = null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore source enum
|
||||||
|
*/
|
||||||
|
private enum class RestoreSource {
|
||||||
|
LocalFile,
|
||||||
|
Server
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog for selecting restore mode
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun RestoreModeDialog(
|
||||||
|
source: RestoreSource,
|
||||||
|
selectedMode: RestoreMode,
|
||||||
|
onModeSelected: (RestoreMode) -> Unit,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val sourceText = when (source) {
|
||||||
|
RestoreSource.LocalFile -> stringResource(R.string.backup_restore_source_file)
|
||||||
|
RestoreSource.Server -> stringResource(R.string.backup_restore_source_server)
|
||||||
|
}
|
||||||
|
|
||||||
|
val modeOptions = listOf(
|
||||||
|
RadioOption(
|
||||||
|
value = RestoreMode.MERGE,
|
||||||
|
title = stringResource(R.string.backup_mode_merge_title),
|
||||||
|
subtitle = stringResource(R.string.backup_mode_merge_subtitle)
|
||||||
|
),
|
||||||
|
RadioOption(
|
||||||
|
value = RestoreMode.REPLACE,
|
||||||
|
title = stringResource(R.string.backup_mode_replace_title),
|
||||||
|
subtitle = stringResource(R.string.backup_mode_replace_subtitle)
|
||||||
|
),
|
||||||
|
RadioOption(
|
||||||
|
value = RestoreMode.OVERWRITE_DUPLICATES,
|
||||||
|
title = stringResource(R.string.backup_mode_overwrite_title),
|
||||||
|
subtitle = stringResource(R.string.backup_mode_overwrite_subtitle)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.backup_restore_dialog_title)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.backup_restore_source, sourceText),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.backup_restore_mode_label),
|
||||||
|
style = MaterialTheme.typography.labelLarge
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
SettingsRadioGroup(
|
||||||
|
options = modeOptions,
|
||||||
|
selectedValue = selectedMode,
|
||||||
|
onValueSelected = onModeSelected
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.backup_restore_info),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm) {
|
||||||
|
Text(stringResource(R.string.backup_restore_button))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,120 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.settings.screens
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.RadioOption
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsInfoCard
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Language selection settings screen
|
||||||
|
* v1.5.0: Internationalization feature
|
||||||
|
*
|
||||||
|
* Uses Android's Per-App Language API (Android 13+) with AppCompat fallback
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LanguageSettingsScreen(
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// Get current app locale - fresh value each time (no remember, always reads current state)
|
||||||
|
val currentLocale = AppCompatDelegate.getApplicationLocales()
|
||||||
|
val currentLanguageCode = if (currentLocale.isEmpty) {
|
||||||
|
"" // System default
|
||||||
|
} else {
|
||||||
|
currentLocale.get(0)?.language ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedLanguage by remember(currentLanguageCode) { mutableStateOf(currentLanguageCode) }
|
||||||
|
|
||||||
|
// Language options
|
||||||
|
val languageOptions = listOf(
|
||||||
|
RadioOption(
|
||||||
|
value = "",
|
||||||
|
title = stringResource(R.string.language_system_default),
|
||||||
|
subtitle = null
|
||||||
|
),
|
||||||
|
RadioOption(
|
||||||
|
value = "en",
|
||||||
|
title = stringResource(R.string.language_english),
|
||||||
|
subtitle = "English"
|
||||||
|
),
|
||||||
|
RadioOption(
|
||||||
|
value = "de",
|
||||||
|
title = stringResource(R.string.language_german),
|
||||||
|
subtitle = "German"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingsScaffold(
|
||||||
|
title = stringResource(R.string.language_settings_title),
|
||||||
|
onBack = onBack
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Info card
|
||||||
|
SettingsInfoCard(
|
||||||
|
text = stringResource(R.string.language_info)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Language selection radio group
|
||||||
|
SettingsRadioGroup(
|
||||||
|
options = languageOptions,
|
||||||
|
selectedValue = selectedLanguage,
|
||||||
|
onValueSelected = { newLanguage ->
|
||||||
|
if (newLanguage != selectedLanguage) {
|
||||||
|
selectedLanguage = newLanguage
|
||||||
|
setAppLanguage(newLanguage, context as Activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set app language using AppCompatDelegate
|
||||||
|
* Works on Android 13+ natively, falls back to AppCompat on older versions
|
||||||
|
*/
|
||||||
|
private fun setAppLanguage(languageCode: String, activity: Activity) {
|
||||||
|
val localeList = if (languageCode.isEmpty()) {
|
||||||
|
LocaleListCompat.getEmptyLocaleList()
|
||||||
|
} else {
|
||||||
|
LocaleListCompat.forLanguageTags(languageCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
AppCompatDelegate.setApplicationLocales(localeList)
|
||||||
|
|
||||||
|
// Restart the activity to apply the change
|
||||||
|
// On Android 13+ the system handles this automatically for some apps,
|
||||||
|
// but we need to recreate to ensure our Compose UI recomposes with new locale
|
||||||
|
activity.recreate()
|
||||||
|
}
|
||||||
@@ -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,232 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.settings.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.PhonelinkRing
|
||||||
|
import androidx.compose.material.icons.filled.Save
|
||||||
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
|
import androidx.compose.material.icons.filled.SettingsInputAntenna
|
||||||
|
import androidx.compose.material.icons.filled.Wifi
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.RadioOption
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsInfoCard
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync settings screen - Configurable Sync Triggers
|
||||||
|
* v1.5.0: Jetpack Compose Settings Redesign
|
||||||
|
* v1.6.0: Individual toggle for each sync trigger (onSave, onResume, WiFi-Connect, Periodic, Boot)
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SyncSettingsScreen(
|
||||||
|
viewModel: SettingsViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onNavigateToServerSettings: () -> Unit
|
||||||
|
) {
|
||||||
|
// Collect all trigger states
|
||||||
|
val triggerOnSave by viewModel.triggerOnSave.collectAsState()
|
||||||
|
val triggerOnResume by viewModel.triggerOnResume.collectAsState()
|
||||||
|
val triggerWifiConnect by viewModel.triggerWifiConnect.collectAsState()
|
||||||
|
val triggerPeriodic by viewModel.triggerPeriodic.collectAsState()
|
||||||
|
val triggerBoot by viewModel.triggerBoot.collectAsState()
|
||||||
|
val syncInterval by viewModel.syncInterval.collectAsState()
|
||||||
|
|
||||||
|
// 🆕 v1.7.0: WiFi-only sync
|
||||||
|
val wifiOnlySync by viewModel.wifiOnlySync.collectAsState()
|
||||||
|
|
||||||
|
// Check if server is configured
|
||||||
|
val isServerConfigured = viewModel.isServerConfigured()
|
||||||
|
|
||||||
|
SettingsScaffold(
|
||||||
|
title = stringResource(R.string.sync_settings_title),
|
||||||
|
onBack = onBack
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// 🌟 v1.6.0: Offline Mode Warning if server not configured
|
||||||
|
if (!isServerConfigured) {
|
||||||
|
SettingsInfoCard(
|
||||||
|
text = stringResource(R.string.sync_offline_mode_message),
|
||||||
|
isWarning = true
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onNavigateToServerSettings,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.sync_offline_mode_button))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 🆕 v1.7.0: NETZWERK-EINSCHRÄNKUNG Section (Global für alle Trigger)
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
SettingsSectionHeader(text = stringResource(R.string.sync_section_network))
|
||||||
|
|
||||||
|
// WiFi-Only Sync Toggle - Gilt für ALLE Trigger außer WiFi-Connect
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
// Info-Hinweis dass WiFi-Connect davon ausgenommen ist
|
||||||
|
if (wifiOnlySync && isServerConfigured) {
|
||||||
|
SettingsInfoCard(
|
||||||
|
text = stringResource(R.string.sync_wifi_only_hint)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsDivider()
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// SOFORT-SYNC Section
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
SettingsSectionHeader(text = stringResource(R.string.sync_section_instant))
|
||||||
|
|
||||||
|
// onSave Trigger
|
||||||
|
SettingsSwitch(
|
||||||
|
title = stringResource(R.string.sync_trigger_on_save_title),
|
||||||
|
subtitle = stringResource(R.string.sync_trigger_on_save_subtitle),
|
||||||
|
checked = triggerOnSave,
|
||||||
|
onCheckedChange = { viewModel.setTriggerOnSave(it) },
|
||||||
|
icon = Icons.Default.Save,
|
||||||
|
enabled = isServerConfigured
|
||||||
|
)
|
||||||
|
|
||||||
|
// onResume Trigger
|
||||||
|
SettingsSwitch(
|
||||||
|
title = stringResource(R.string.sync_trigger_on_resume_title),
|
||||||
|
subtitle = stringResource(R.string.sync_trigger_on_resume_subtitle),
|
||||||
|
checked = triggerOnResume,
|
||||||
|
onCheckedChange = { viewModel.setTriggerOnResume(it) },
|
||||||
|
icon = Icons.Default.PhonelinkRing,
|
||||||
|
enabled = isServerConfigured
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingsDivider()
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// HINTERGRUND-SYNC Section
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
SettingsSectionHeader(text = stringResource(R.string.sync_section_background))
|
||||||
|
|
||||||
|
// WiFi-Connect Trigger
|
||||||
|
SettingsSwitch(
|
||||||
|
title = stringResource(R.string.sync_trigger_wifi_connect_title),
|
||||||
|
subtitle = stringResource(R.string.sync_trigger_wifi_connect_subtitle),
|
||||||
|
checked = triggerWifiConnect,
|
||||||
|
onCheckedChange = { viewModel.setTriggerWifiConnect(it) },
|
||||||
|
icon = Icons.Default.Wifi,
|
||||||
|
enabled = isServerConfigured
|
||||||
|
)
|
||||||
|
|
||||||
|
// Periodic Trigger
|
||||||
|
SettingsSwitch(
|
||||||
|
title = stringResource(R.string.sync_trigger_periodic_title),
|
||||||
|
subtitle = stringResource(R.string.sync_trigger_periodic_subtitle),
|
||||||
|
checked = triggerPeriodic,
|
||||||
|
onCheckedChange = { viewModel.setTriggerPeriodic(it) },
|
||||||
|
icon = Icons.Default.Schedule,
|
||||||
|
enabled = isServerConfigured
|
||||||
|
)
|
||||||
|
|
||||||
|
// Periodic Interval Selection (only visible if periodic trigger is enabled)
|
||||||
|
if (triggerPeriodic && isServerConfigured) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
val intervalOptions = listOf(
|
||||||
|
RadioOption(
|
||||||
|
value = 15L,
|
||||||
|
title = stringResource(R.string.sync_interval_15min_title),
|
||||||
|
subtitle = null
|
||||||
|
),
|
||||||
|
RadioOption(
|
||||||
|
value = 30L,
|
||||||
|
title = stringResource(R.string.sync_interval_30min_title),
|
||||||
|
subtitle = null
|
||||||
|
),
|
||||||
|
RadioOption(
|
||||||
|
value = 60L,
|
||||||
|
title = stringResource(R.string.sync_interval_60min_title),
|
||||||
|
subtitle = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingsRadioGroup(
|
||||||
|
options = intervalOptions,
|
||||||
|
selectedValue = syncInterval,
|
||||||
|
onValueSelected = { viewModel.setSyncInterval(it) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsDivider()
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// ADVANCED Section (Boot Sync)
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
SettingsSectionHeader(text = stringResource(R.string.sync_section_advanced))
|
||||||
|
|
||||||
|
// Boot Trigger
|
||||||
|
SettingsSwitch(
|
||||||
|
title = stringResource(R.string.sync_trigger_boot_title),
|
||||||
|
subtitle = stringResource(R.string.sync_trigger_boot_subtitle),
|
||||||
|
checked = triggerBoot,
|
||||||
|
onCheckedChange = { viewModel.setTriggerBoot(it) },
|
||||||
|
icon = Icons.Default.SettingsInputAntenna,
|
||||||
|
enabled = isServerConfigured
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingsDivider()
|
||||||
|
|
||||||
|
// Manual Sync Info
|
||||||
|
val manualHintText = if (isServerConfigured) {
|
||||||
|
stringResource(R.string.sync_manual_hint)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.sync_manual_hint_disabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsInfoCard(
|
||||||
|
text = manualHintText
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ object Constants {
|
|||||||
const val KEY_SERVER_URL = "server_url"
|
const val KEY_SERVER_URL = "server_url"
|
||||||
const val KEY_USERNAME = "username"
|
const val KEY_USERNAME = "username"
|
||||||
const val KEY_PASSWORD = "password"
|
const val KEY_PASSWORD = "password"
|
||||||
const val KEY_HOME_SSID = "home_ssid"
|
|
||||||
const val KEY_AUTO_SYNC = "auto_sync_enabled"
|
const val KEY_AUTO_SYNC = "auto_sync_enabled"
|
||||||
const val KEY_LAST_SYNC = "last_sync_timestamp"
|
const val KEY_LAST_SYNC = "last_sync_timestamp"
|
||||||
|
|
||||||
@@ -30,6 +29,31 @@ object Constants {
|
|||||||
// 🔥 v1.3.1: Debug & Logging
|
// 🔥 v1.3.1: Debug & Logging
|
||||||
const val KEY_FILE_LOGGING_ENABLED = "file_logging_enabled"
|
const val KEY_FILE_LOGGING_ENABLED = "file_logging_enabled"
|
||||||
|
|
||||||
|
// 🔥 v1.6.0: Offline Mode Toggle
|
||||||
|
const val KEY_OFFLINE_MODE = "offline_mode_enabled"
|
||||||
|
|
||||||
|
// 🔥 v1.7.0: WiFi-Only Sync Toggle
|
||||||
|
const val KEY_WIFI_ONLY_SYNC = "wifi_only_sync_enabled"
|
||||||
|
const val DEFAULT_WIFI_ONLY_SYNC = false // Standardmäßig auch mobil syncen
|
||||||
|
|
||||||
|
// 🔥 v1.6.0: Configurable Sync Triggers
|
||||||
|
const val KEY_SYNC_TRIGGER_ON_SAVE = "sync_trigger_on_save"
|
||||||
|
const val KEY_SYNC_TRIGGER_ON_RESUME = "sync_trigger_on_resume"
|
||||||
|
const val KEY_SYNC_TRIGGER_WIFI_CONNECT = "sync_trigger_wifi_connect"
|
||||||
|
const val KEY_SYNC_TRIGGER_PERIODIC = "sync_trigger_periodic"
|
||||||
|
const val KEY_SYNC_TRIGGER_BOOT = "sync_trigger_boot"
|
||||||
|
|
||||||
|
// Sync Trigger Defaults (active after server configuration)
|
||||||
|
const val DEFAULT_TRIGGER_ON_SAVE = true
|
||||||
|
const val DEFAULT_TRIGGER_ON_RESUME = true
|
||||||
|
const val DEFAULT_TRIGGER_WIFI_CONNECT = true
|
||||||
|
const val DEFAULT_TRIGGER_PERIODIC = false
|
||||||
|
const val DEFAULT_TRIGGER_BOOT = false
|
||||||
|
|
||||||
|
// Throttling for onSave sync (5 seconds)
|
||||||
|
const val MIN_ON_SAVE_SYNC_INTERVAL_MS = 5_000L
|
||||||
|
const val PREF_LAST_ON_SAVE_SYNC_TIME = "last_on_save_sync_time"
|
||||||
|
|
||||||
// WorkManager
|
// WorkManager
|
||||||
const val SYNC_WORK_TAG = "notes_sync"
|
const val SYNC_WORK_TAG = "notes_sync"
|
||||||
const val SYNC_DELAY_SECONDS = 5L
|
const val SYNC_DELAY_SECONDS = 5L
|
||||||
@@ -37,4 +61,10 @@ object Constants {
|
|||||||
// Notifications
|
// Notifications
|
||||||
const val NOTIFICATION_CHANNEL_ID = "notes_sync_channel"
|
const val NOTIFICATION_CHANNEL_ID = "notes_sync_channel"
|
||||||
const val NOTIFICATION_ID = 1001
|
const val NOTIFICATION_ID = 1001
|
||||||
|
|
||||||
|
// 🎨 v1.7.0: Staggered Grid Layout
|
||||||
|
const val KEY_DISPLAY_MODE = "display_mode" // "list" or "grid"
|
||||||
|
const val DEFAULT_DISPLAY_MODE = "list"
|
||||||
|
const val GRID_COLUMNS = 2
|
||||||
|
const val GRID_SPACING_DP = 8
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package dev.dettmer.simplenotes.utils
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -15,7 +16,7 @@ fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) {
|
|||||||
Toast.makeText(this, message, duration).show()
|
Toast.makeText(this, message, duration).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timestamp to readable format
|
// Timestamp to readable format (legacy - without context, uses German)
|
||||||
fun Long.toReadableTime(): String {
|
fun Long.toReadableTime(): String {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val diff = now - this
|
val diff = now - this
|
||||||
@@ -41,6 +42,32 @@ fun Long.toReadableTime(): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Timestamp to readable format (with context for i18n)
|
||||||
|
fun Long.toReadableTime(context: Context): String {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val diff = now - this
|
||||||
|
|
||||||
|
return when {
|
||||||
|
diff < TimeUnit.MINUTES.toMillis(1) -> context.getString(R.string.time_just_now)
|
||||||
|
diff < TimeUnit.HOURS.toMillis(1) -> {
|
||||||
|
val minutes = TimeUnit.MILLISECONDS.toMinutes(diff).toInt()
|
||||||
|
context.getString(R.string.time_minutes_ago, minutes)
|
||||||
|
}
|
||||||
|
diff < TimeUnit.DAYS.toMillis(1) -> {
|
||||||
|
val hours = TimeUnit.MILLISECONDS.toHours(diff).toInt()
|
||||||
|
context.getString(R.string.time_hours_ago, hours)
|
||||||
|
}
|
||||||
|
diff < TimeUnit.DAYS.toMillis(DAYS_THRESHOLD) -> {
|
||||||
|
val days = TimeUnit.MILLISECONDS.toDays(diff).toInt()
|
||||||
|
context.getString(R.string.time_days_ago, days)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val sdf = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault())
|
||||||
|
sdf.format(Date(this))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Truncate long strings
|
// Truncate long strings
|
||||||
fun String.truncate(maxLength: Int): String {
|
fun String.truncate(maxLength: Int): String {
|
||||||
return if (length > maxLength) {
|
return if (length > maxLength) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package dev.dettmer.simplenotes.utils
|
|||||||
|
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -10,14 +11,12 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import dev.dettmer.simplenotes.MainActivity
|
import dev.dettmer.simplenotes.ui.main.ComposeMainActivity
|
||||||
|
|
||||||
object NotificationHelper {
|
object NotificationHelper {
|
||||||
|
|
||||||
private const val TAG = "NotificationHelper"
|
private const val TAG = "NotificationHelper"
|
||||||
private const val CHANNEL_ID = "notes_sync_channel"
|
private const val CHANNEL_ID = "notes_sync_channel"
|
||||||
private const val CHANNEL_NAME = "Notizen Synchronisierung"
|
|
||||||
private const val CHANNEL_DESCRIPTION = "Benachrichtigungen über Sync-Status"
|
|
||||||
private const val NOTIFICATION_ID = 1001
|
private const val NOTIFICATION_ID = 1001
|
||||||
private const val SYNC_NOTIFICATION_ID = 2
|
private const val SYNC_NOTIFICATION_ID = 2
|
||||||
private const val AUTO_CANCEL_TIMEOUT_MS = 30_000L
|
private const val AUTO_CANCEL_TIMEOUT_MS = 30_000L
|
||||||
@@ -29,9 +28,11 @@ object NotificationHelper {
|
|||||||
fun createNotificationChannel(context: Context) {
|
fun createNotificationChannel(context: Context) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
val channelName = context.getString(R.string.notification_channel_name)
|
||||||
|
val channelDescription = context.getString(R.string.notification_channel_desc)
|
||||||
|
|
||||||
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, importance).apply {
|
val channel = NotificationChannel(CHANNEL_ID, channelName, importance).apply {
|
||||||
description = CHANNEL_DESCRIPTION
|
description = channelDescription
|
||||||
enableVibration(true)
|
enableVibration(true)
|
||||||
enableLights(true)
|
enableLights(true)
|
||||||
}
|
}
|
||||||
@@ -57,7 +58,7 @@ object NotificationHelper {
|
|||||||
* Zeigt Erfolgs-Notification nach Sync
|
* Zeigt Erfolgs-Notification nach Sync
|
||||||
*/
|
*/
|
||||||
fun showSyncSuccessNotification(context: Context, syncedCount: Int) {
|
fun showSyncSuccessNotification(context: Context, syncedCount: Int) {
|
||||||
val intent = Intent(context, MainActivity::class.java).apply {
|
val intent = Intent(context, ComposeMainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,8 +69,8 @@ object NotificationHelper {
|
|||||||
|
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(android.R.drawable.ic_menu_upload)
|
.setSmallIcon(android.R.drawable.ic_menu_upload)
|
||||||
.setContentTitle("Sync erfolgreich")
|
.setContentTitle(context.getString(R.string.notification_sync_success_title))
|
||||||
.setContentText("$syncedCount Notiz(en) synchronisiert")
|
.setContentText(context.getString(R.string.notification_sync_success_message, syncedCount))
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
@@ -96,7 +97,7 @@ object NotificationHelper {
|
|||||||
fun showSyncFailureNotification(context: Context, errorMessage: String) {
|
fun showSyncFailureNotification(context: Context, errorMessage: String) {
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(android.R.drawable.ic_dialog_alert)
|
.setSmallIcon(android.R.drawable.ic_dialog_alert)
|
||||||
.setContentTitle("Sync fehlgeschlagen")
|
.setContentTitle(context.getString(R.string.notification_sync_failed_title))
|
||||||
.setContentText(errorMessage)
|
.setContentText(errorMessage)
|
||||||
.setStyle(NotificationCompat.BigTextStyle()
|
.setStyle(NotificationCompat.BigTextStyle()
|
||||||
.bigText(errorMessage))
|
.bigText(errorMessage))
|
||||||
@@ -125,8 +126,8 @@ object NotificationHelper {
|
|||||||
fun showSyncProgressNotification(context: Context): Int {
|
fun showSyncProgressNotification(context: Context): Int {
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(android.R.drawable.ic_popup_sync)
|
.setSmallIcon(android.R.drawable.ic_popup_sync)
|
||||||
.setContentTitle("Synchronisiere...")
|
.setContentTitle(context.getString(R.string.notification_sync_progress_title))
|
||||||
.setContentText("Notizen werden synchronisiert")
|
.setContentText(context.getString(R.string.notification_sync_progress_message))
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setProgress(0, 0, true)
|
.setProgress(0, 0, true)
|
||||||
@@ -153,7 +154,7 @@ object NotificationHelper {
|
|||||||
* Zeigt Notification bei erkanntem Konflikt
|
* Zeigt Notification bei erkanntem Konflikt
|
||||||
*/
|
*/
|
||||||
fun showConflictNotification(context: Context, conflictCount: Int) {
|
fun showConflictNotification(context: Context, conflictCount: Int) {
|
||||||
val intent = Intent(context, MainActivity::class.java)
|
val intent = Intent(context, ComposeMainActivity::class.java)
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
context, 0, intent,
|
context, 0, intent,
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
@@ -161,8 +162,8 @@ object NotificationHelper {
|
|||||||
|
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
.setContentTitle("Sync-Konflikt erkannt")
|
.setContentTitle(context.getString(R.string.notification_sync_conflict_title))
|
||||||
.setContentText("$conflictCount Notiz(en) haben Konflikte")
|
.setContentText(context.getString(R.string.notification_sync_conflict_message, conflictCount))
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
@@ -212,8 +213,8 @@ object NotificationHelper {
|
|||||||
fun showSyncInProgress(context: Context) {
|
fun showSyncInProgress(context: Context) {
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||||
.setContentTitle("Synchronisierung läuft")
|
.setContentTitle(context.getString(R.string.notification_sync_in_progress_title))
|
||||||
.setContentText("Notizen werden synchronisiert...")
|
.setContentText(context.getString(R.string.notification_sync_in_progress_message))
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.build()
|
.build()
|
||||||
@@ -228,7 +229,7 @@ object NotificationHelper {
|
|||||||
*/
|
*/
|
||||||
fun showSyncSuccess(context: Context, count: Int) {
|
fun showSyncSuccess(context: Context, count: Int) {
|
||||||
// PendingIntent für App-Öffnung
|
// PendingIntent für App-Öffnung
|
||||||
val intent = Intent(context, MainActivity::class.java).apply {
|
val intent = Intent(context, ComposeMainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
}
|
}
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
@@ -240,8 +241,8 @@ object NotificationHelper {
|
|||||||
|
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||||
.setContentTitle("Sync erfolgreich")
|
.setContentTitle(context.getString(R.string.notification_sync_success_title))
|
||||||
.setContentText("$count Notizen synchronisiert")
|
.setContentText(context.getString(R.string.notification_sync_success_message, count))
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||||
.setContentIntent(pendingIntent) // Click öffnet App
|
.setContentIntent(pendingIntent) // Click öffnet App
|
||||||
@@ -259,7 +260,7 @@ object NotificationHelper {
|
|||||||
*/
|
*/
|
||||||
fun showSyncError(context: Context, message: String) {
|
fun showSyncError(context: Context, message: String) {
|
||||||
// PendingIntent für App-Öffnung
|
// PendingIntent für App-Öffnung
|
||||||
val intent = Intent(context, MainActivity::class.java).apply {
|
val intent = Intent(context, ComposeMainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
}
|
}
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
@@ -271,7 +272,7 @@ object NotificationHelper {
|
|||||||
|
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
.setContentTitle("Sync Fehler")
|
.setContentTitle(context.getString(R.string.notification_sync_error_title))
|
||||||
.setContentText(message)
|
.setContentText(message)
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||||
@@ -296,7 +297,7 @@ object NotificationHelper {
|
|||||||
*/
|
*/
|
||||||
fun showSyncWarning(context: Context, hoursSinceLastSync: Long) {
|
fun showSyncWarning(context: Context, hoursSinceLastSync: Long) {
|
||||||
// PendingIntent für App-Öffnung
|
// PendingIntent für App-Öffnung
|
||||||
val intent = Intent(context, MainActivity::class.java).apply {
|
val intent = Intent(context, ComposeMainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
}
|
}
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
@@ -308,11 +309,10 @@ object NotificationHelper {
|
|||||||
|
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
.setContentTitle("⚠️ Sync-Warnung")
|
.setContentTitle(context.getString(R.string.notification_sync_warning_title))
|
||||||
.setContentText("Server seit ${hoursSinceLastSync}h nicht erreichbar")
|
.setContentText(context.getString(R.string.notification_sync_warning_message, hoursSinceLastSync.toInt()))
|
||||||
.setStyle(NotificationCompat.BigTextStyle()
|
.setStyle(NotificationCompat.BigTextStyle()
|
||||||
.bigText("Der WebDAV-Server ist seit ${hoursSinceLastSync} Stunden nicht erreichbar. " +
|
.bigText(context.getString(R.string.notification_sync_warning_detail, hoursSinceLastSync.toInt())))
|
||||||
"Bitte prüfe deine Netzwerkverbindung oder Server-Einstellungen."))
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package dev.dettmer.simplenotes.utils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konstanten für Sync-Operationen
|
||||||
|
*/
|
||||||
|
object SyncConstants {
|
||||||
|
// Debounce Delays
|
||||||
|
const val SEARCH_DEBOUNCE_MS = 300L
|
||||||
|
const val SYNC_DEBOUNCE_MS = 500L
|
||||||
|
|
||||||
|
// Connection Timeouts
|
||||||
|
const val CONNECTION_TEST_TIMEOUT_MS = 5000L
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package dev.dettmer.simplenotes.utils
|
package dev.dettmer.simplenotes.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,7 +93,7 @@ object UrlValidator {
|
|||||||
* Validiert ob HTTP URL erlaubt ist
|
* Validiert ob HTTP URL erlaubt ist
|
||||||
* @return Pair<Boolean, String?> - (isValid, errorMessage)
|
* @return Pair<Boolean, String?> - (isValid, errorMessage)
|
||||||
*/
|
*/
|
||||||
fun validateHttpUrl(url: String): Pair<Boolean, String?> {
|
fun validateHttpUrl(context: Context, url: String): Pair<Boolean, String?> {
|
||||||
return try {
|
return try {
|
||||||
val parsedUrl = URL(url)
|
val parsedUrl = URL(url)
|
||||||
|
|
||||||
@@ -107,16 +109,15 @@ object UrlValidator {
|
|||||||
} else {
|
} else {
|
||||||
return Pair(
|
return Pair(
|
||||||
false,
|
false,
|
||||||
"HTTP ist nur für lokale Server erlaubt (z.B. 192.168.x.x, 10.x.x.x, nas.local). " +
|
context.getString(R.string.error_http_local_only)
|
||||||
"Für öffentliche Server verwende bitte HTTPS."
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anderes Protokoll
|
// Anderes Protokoll
|
||||||
Pair(false, "Ungültiges Protokoll: ${parsedUrl.protocol}. Bitte verwende HTTP oder HTTPS.")
|
Pair(false, context.getString(R.string.error_invalid_protocol, parsedUrl.protocol))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Pair(false, "Ungültige URL: ${e.message}")
|
Pair(false, context.getString(R.string.error_invalid_url, e.message ?: ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
android/app/src/main/res/anim/slide_in_left.xml
Normal file
9
android/app/src/main/res/anim/slide_in_left.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Slide in from left - Main screen return animation -->
|
||||||
|
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:interpolator="@android:interpolator/accelerate_decelerate">
|
||||||
|
<translate
|
||||||
|
android:fromXDelta="-100%"
|
||||||
|
android:toXDelta="0%"
|
||||||
|
android:duration="300" />
|
||||||
|
</set>
|
||||||
9
android/app/src/main/res/anim/slide_in_right.xml
Normal file
9
android/app/src/main/res/anim/slide_in_right.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Slide in from right - Settings opening animation -->
|
||||||
|
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:interpolator="@android:interpolator/accelerate_decelerate">
|
||||||
|
<translate
|
||||||
|
android:fromXDelta="100%"
|
||||||
|
android:toXDelta="0%"
|
||||||
|
android:duration="300" />
|
||||||
|
</set>
|
||||||
9
android/app/src/main/res/anim/slide_out_left.xml
Normal file
9
android/app/src/main/res/anim/slide_out_left.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Slide out to left - Main screen exit when opening Settings -->
|
||||||
|
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:interpolator="@android:interpolator/accelerate_decelerate">
|
||||||
|
<translate
|
||||||
|
android:fromXDelta="0%"
|
||||||
|
android:toXDelta="-100%"
|
||||||
|
android:duration="300" />
|
||||||
|
</set>
|
||||||
9
android/app/src/main/res/anim/slide_out_right.xml
Normal file
9
android/app/src/main/res/anim/slide_out_right.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Slide out to right - Settings closing animation -->
|
||||||
|
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:interpolator="@android:interpolator/accelerate_decelerate">
|
||||||
|
<translate
|
||||||
|
android:fromXDelta="0%"
|
||||||
|
android:toXDelta="100%"
|
||||||
|
android:duration="300" />
|
||||||
|
</set>
|
||||||
10
android/app/src/main/res/drawable/ic_checklist_24.xml
Normal file
10
android/app/src/main/res/drawable/ic_checklist_24.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorOnSurface">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M22,7h-9v2h9V7zM22,15h-9v2h9V15zM5.54,11L2,7.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L5.54,11zM5.54,19L2,15.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L5.54,19z"/>
|
||||||
|
</vector>
|
||||||
10
android/app/src/main/res/drawable/ic_delete_24.xml
Normal file
10
android/app/src/main/res/drawable/ic_delete_24.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorOnSurfaceVariant">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
|
||||||
|
</vector>
|
||||||
10
android/app/src/main/res/drawable/ic_drag_handle_24.xml
Normal file
10
android/app/src/main/res/drawable/ic_drag_handle_24.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorOnSurfaceVariant">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M20,9H4v2h16V9zM4,15h16v-2H4v2z"/>
|
||||||
|
</vector>
|
||||||
10
android/app/src/main/res/drawable/ic_note_24.xml
Normal file
10
android/app/src/main/res/drawable/ic_note_24.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorOnSurface">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M14,2H6C4.9,2 4.01,2.9 4.01,4L4,20c0,1.1 0.89,2 1.99,2H18c1.1,0 2,-0.9 2,-2V8L14,2zM16,18H8v-2h8v2zM16,14H8v-2h8v2zM13,9V3.5L18.5,9H13z"/>
|
||||||
|
</vector>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
<LinearLayout
|
<LinearLayout
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
@@ -18,8 +19,9 @@
|
|||||||
app:title="@string/edit_note"
|
app:title="@string/edit_note"
|
||||||
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
|
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
|
||||||
|
|
||||||
<!-- Material 3 Outlined TextInputLayout with 16dp corners -->
|
<!-- Title Input (für beide Typen) -->
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilTitle"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_margin="16dp"
|
android:layout_margin="16dp"
|
||||||
@@ -44,8 +46,9 @@
|
|||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
<!-- Material 3 Outlined TextInputLayout for Content -->
|
<!-- Content Input (nur für TEXT sichtbar) -->
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilContent"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
@@ -74,4 +77,39 @@
|
|||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<!-- v1.4.0: Checklist Container (nur für CHECKLIST sichtbar) -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/checklistContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible">
|
||||||
|
|
||||||
|
<!-- Checklist Items RecyclerView -->
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/rvChecklistItems"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginHorizontal="8dp"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:paddingBottom="8dp"
|
||||||
|
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||||
|
tools:listitem="@layout/item_checklist_editor" />
|
||||||
|
|
||||||
|
<!-- Add Item Button -->
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnAddItem"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton.Icon"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:text="@string/add_item"
|
||||||
|
app:icon="@android:drawable/ic_input_add" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
56
android/app/src/main/res/layout/item_checklist_editor.xml
Normal file
56
android/app/src/main/res/layout/item_checklist_editor.xml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- v1.4.0: Checklist Item Layout für Editor -->
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingVertical="4dp"
|
||||||
|
android:paddingHorizontal="8dp"
|
||||||
|
android:minHeight="48dp">
|
||||||
|
|
||||||
|
<!-- Drag Handle -->
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ivDragHandle"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:src="@drawable/ic_drag_handle_24"
|
||||||
|
android:contentDescription="@string/reorder_item"
|
||||||
|
android:importantForAccessibility="yes" />
|
||||||
|
|
||||||
|
<!-- Checkbox -->
|
||||||
|
<com.google.android.material.checkbox.MaterialCheckBox
|
||||||
|
android:id="@+id/cbItem"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minWidth="0dp"
|
||||||
|
android:minHeight="0dp" />
|
||||||
|
|
||||||
|
<!-- Text Input (ohne Box, nur transparent) -->
|
||||||
|
<!-- v1.4.1: Auto-Zeilenumbruch für lange Texte -->
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/etItemText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@null"
|
||||||
|
android:hint="@string/item_placeholder"
|
||||||
|
android:inputType="textMultiLine|textCapSentences"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
|
||||||
|
tools:text="Milch kaufen" />
|
||||||
|
|
||||||
|
<!-- Delete Button -->
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnDeleteItem"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@drawable/ic_delete_24"
|
||||||
|
android:contentDescription="@string/delete_item"
|
||||||
|
app:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Material 3: Filled Card Style (Flat, No Shadow) -->
|
<!-- Material 3: Filled Card Style (Flat, No Shadow) -->
|
||||||
|
<!-- v1.4.0: Unterstützt jetzt TEXT und CHECKLIST Notizen -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="8dp"
|
android:layout_marginHorizontal="8dp"
|
||||||
@@ -17,17 +19,37 @@
|
|||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="20dp">
|
android:padding="20dp">
|
||||||
|
|
||||||
<!-- Material 3 Typography: TitleMedium -->
|
<!-- v1.4.0: Header Row mit Icon und Titel -->
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@+id/textViewTitle"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/note_title_placeholder"
|
android:orientation="horizontal"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
android:gravity="center_vertical">
|
||||||
android:maxLines="2"
|
|
||||||
android:ellipsize="end" />
|
|
||||||
|
|
||||||
<!-- Material 3 Typography: BodyMedium -->
|
<!-- v1.4.0: Note Type Icon -->
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ivNoteTypeIcon"
|
||||||
|
android:layout_width="20dp"
|
||||||
|
android:layout_height="20dp"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:src="@drawable/ic_note_24"
|
||||||
|
app:tint="?attr/colorPrimary"
|
||||||
|
android:contentDescription="@null" />
|
||||||
|
|
||||||
|
<!-- Material 3 Typography: TitleMedium -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textViewTitle"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/note_title_placeholder"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||||
|
android:maxLines="2"
|
||||||
|
android:ellipsize="end" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Content Preview (für TEXT Notizen) -->
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/textViewContent"
|
android:id="@+id/textViewContent"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -39,6 +61,18 @@
|
|||||||
android:maxLines="3"
|
android:maxLines="3"
|
||||||
android:ellipsize="end" />
|
android:ellipsize="end" />
|
||||||
|
|
||||||
|
<!-- v1.4.0: Checklist Preview (für CHECKLIST Notizen) -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textViewChecklistPreview"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
tools:visibility="visible"
|
||||||
|
tools:text="2/5 erledigt" />
|
||||||
|
|
||||||
<!-- Metadata Row mit Timestamp und Sync-Status -->
|
<!-- Metadata Row mit Timestamp und Sync-Status -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
11
android/app/src/main/res/menu/menu_fab_note_types.xml
Normal file
11
android/app/src/main/res/menu/menu_fab_note_types.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_create_text_note"
|
||||||
|
android:icon="@drawable/ic_note_24"
|
||||||
|
android:title="@string/create_text_note" />
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_create_checklist"
|
||||||
|
android:icon="@drawable/ic_checklist_24"
|
||||||
|
android:title="@string/create_checklist" />
|
||||||
|
</menu>
|
||||||
465
android/app/src/main/res/values-de/strings.xml
Normal file
465
android/app/src/main/res/values-de/strings.xml
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- APP IDENTITY -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="app_name">Simple Notes</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- MAIN SCREEN -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="main_title">Simple Notes</string>
|
||||||
|
<string name="no_notes_yet">Noch keine Notizen.\nTippe + um eine zu erstellen.</string>
|
||||||
|
<string name="add_note">Notiz hinzufügen</string>
|
||||||
|
<string name="sync">Synchronisieren</string>
|
||||||
|
<string name="settings">Einstellungen</string>
|
||||||
|
<string name="action_sync">Synchronisieren</string>
|
||||||
|
<string name="action_settings">Einstellungen</string>
|
||||||
|
<string name="action_close_selection">Auswahl beenden</string>
|
||||||
|
<string name="action_select_all">Alle auswählen</string>
|
||||||
|
<string name="action_delete_selected">Ausgewählte löschen</string>
|
||||||
|
<string name="selection_count">%d ausgewählt</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- EMPTY STATE -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="empty_state_emoji">📝</string>
|
||||||
|
<string name="empty_state_title">Noch keine Notizen</string>
|
||||||
|
<string name="empty_state_message">Tippe + um eine neue Notiz zu erstellen</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- FAB MENU -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="fab_new_note">Neue Notiz</string>
|
||||||
|
<string name="fab_text_note">Text-Notiz</string>
|
||||||
|
<string name="fab_checklist">Checkliste</string>
|
||||||
|
<string name="create_text_note">Notiz</string>
|
||||||
|
<string name="create_checklist">Liste</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- NOTE CARD -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="note_title_placeholder">Notiz-Titel</string>
|
||||||
|
<string name="note_content_placeholder">Notiz-Vorschau…</string>
|
||||||
|
<string name="note_timestamp_placeholder">Vor 2 Std</string>
|
||||||
|
<string name="untitled">Ohne Titel</string>
|
||||||
|
<string name="checklist_progress">%1$d/%2$d erledigt</string>
|
||||||
|
<string name="empty_checklist">Keine Einträge</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SYNC STATUS BANNER -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="sync_status">Sync-Status</string>
|
||||||
|
<string name="sync_syncing">Synchronisiere…</string>
|
||||||
|
<string name="sync_completed">Synchronisiert</string>
|
||||||
|
<string name="sync_error">Fehler</string>
|
||||||
|
<string name="sync_status_syncing">Synchronisiere…</string>
|
||||||
|
<string name="sync_status_completed">Synchronisierung abgeschlossen</string>
|
||||||
|
<string name="sync_status_error">Synchronisierung fehlgeschlagen</string>
|
||||||
|
<string name="sync_already_running">Synchronisierung läuft bereits</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- DELETE DIALOGS -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="delete_note_title">Notiz löschen?</string>
|
||||||
|
<string name="delete_notes_title">%d Notizen löschen?</string>
|
||||||
|
<string name="delete_note_message">Wie möchtest du diese Notiz löschen?</string>
|
||||||
|
<string name="delete_notes_message">Wie möchtest du diese %d Notizen löschen?</string>
|
||||||
|
<string name="delete_everywhere">Überall löschen (auch Server)</string>
|
||||||
|
<string name="delete_everywhere_offline_hint">Nicht verfügbar im Offline-Modus</string>
|
||||||
|
<string name="delete_local_only">Nur lokal löschen</string>
|
||||||
|
<string name="delete">Löschen</string>
|
||||||
|
<string name="cancel">Abbrechen</string>
|
||||||
|
<string name="ok">OK</string>
|
||||||
|
<!-- Legacy delete dialogs -->
|
||||||
|
<string name="legacy_delete_dialog_title">Notiz löschen</string>
|
||||||
|
<string name="legacy_delete_dialog_message">\"%s\" wird lokal gelöscht.\n\nAuch vom Server löschen?</string>
|
||||||
|
<string name="legacy_delete_from_server">Vom Server löschen</string>
|
||||||
|
<string name="legacy_delete_with_server">\"%s\" wird lokal und vom Server gelöscht</string>
|
||||||
|
<string name="legacy_delete_local_only">\"%s\" lokal gelöscht (Server bleibt)</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SNACKBAR MESSAGES -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="snackbar_undo">RÜCKGÄNGIG</string>
|
||||||
|
<string name="snackbar_note_deleted_local">\"%s\" lokal gelöscht</string>
|
||||||
|
<string name="snackbar_note_deleted_server">\"%s\" wird vom Server gelöscht</string>
|
||||||
|
<string name="snackbar_notes_deleted_local">%d Notiz(en) lokal gelöscht</string>
|
||||||
|
<string name="snackbar_notes_deleted_server">%d Notiz(en) werden vom Server gelöscht</string>
|
||||||
|
<string name="snackbar_deleted_from_server">Vom Server gelöscht</string>
|
||||||
|
<string name="snackbar_notes_deleted_from_server">%d Notiz(en) vom Server gelöscht</string>
|
||||||
|
<string name="snackbar_notes_deleted_from_server_partial">%1$d von %2$d Notizen vom Server gelöscht</string>
|
||||||
|
<string name="snackbar_server_delete_failed">Server-Löschung fehlgeschlagen</string>
|
||||||
|
<string name="snackbar_server_error">Server-Fehler: %s</string>
|
||||||
|
<string name="snackbar_already_synced">Bereits synchronisiert</string>
|
||||||
|
<string name="snackbar_server_unreachable">Server nicht erreichbar</string>
|
||||||
|
<string name="snackbar_synced_count">✅ Gesynct: %d Notizen</string>
|
||||||
|
<string name="snackbar_nothing_to_sync">ℹ️ Nichts zu syncen</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- URL VALIDATION ERRORS -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="error_http_local_only">HTTP ist nur für lokale Server erlaubt (z.B. 192.168.x.x, 10.x.x.x, nas.local). Für öffentliche Server verwende bitte HTTPS.</string>
|
||||||
|
<string name="error_invalid_protocol">Ungültiges Protokoll: %s. Bitte verwende HTTP oder HTTPS.</string>
|
||||||
|
<string name="error_invalid_url">Ungültige URL: %s</string>
|
||||||
|
<string name="error_server_not_configured">WebDAV-Server nicht vollständig konfiguriert</string>
|
||||||
|
<string name="error_sardine_client_failed">Sardine Client konnte nicht erstellt werden</string>
|
||||||
|
<string name="error_server_url_not_configured">Server-URL nicht konfiguriert</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- NOTE EDITOR -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="new_note">Neue Notiz</string>
|
||||||
|
<string name="edit_note">Notiz bearbeiten</string>
|
||||||
|
<string name="new_checklist">Neue Liste</string>
|
||||||
|
<string name="edit_checklist">Liste bearbeiten</string>
|
||||||
|
<string name="title">Titel</string>
|
||||||
|
<string name="content">Inhalt</string>
|
||||||
|
<string name="back">Zurück</string>
|
||||||
|
<string name="save">Speichern</string>
|
||||||
|
<string name="add_item">Element hinzufügen</string>
|
||||||
|
<string name="item_placeholder">Neues Element…</string>
|
||||||
|
<string name="reorder_item">Element verschieben</string>
|
||||||
|
<string name="drag_to_reorder">Ziehen zum Sortieren</string>
|
||||||
|
<string name="delete_item">Element löschen</string>
|
||||||
|
<string name="note_is_empty">Notiz ist leer</string>
|
||||||
|
<string name="note_saved">Notiz gespeichert</string>
|
||||||
|
<string name="note_deleted">Notiz gelöscht</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SETTINGS - MAIN -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="settings_title">Einstellungen</string>
|
||||||
|
<string name="settings_language">Sprache</string>
|
||||||
|
<string name="settings_language_subtitle">%s</string>
|
||||||
|
<string name="settings_server">Server-Einstellungen</string>
|
||||||
|
<string name="settings_server_status_reachable">✅ Erreichbar</string>
|
||||||
|
<string name="settings_server_status_unreachable">❌ Nicht erreichbar</string>
|
||||||
|
<string name="settings_server_status_checking">🔍 Prüfe…</string>
|
||||||
|
<string name="settings_server_status_not_configured">⚠️ Nicht konfiguriert</string>
|
||||||
|
<string name="settings_server_status_offline_mode">📴 Offline-Modus</string>
|
||||||
|
<string name="settings_sync">Sync-Einstellungen</string>
|
||||||
|
<string name="settings_sync_auto_on">Auto-Sync: An • %s</string>
|
||||||
|
<string name="settings_sync_auto_off">Auto-Sync: Aus</string>
|
||||||
|
<string name="settings_sync_offline_mode">📴 Offline-Modus</string>
|
||||||
|
<string name="settings_sync_manual_only">Nur manueller Sync</string>
|
||||||
|
<string name="settings_sync_triggers_active">%d Trigger aktiv</string>
|
||||||
|
<string name="settings_interval_15min">15 Min</string>
|
||||||
|
<string name="settings_interval_30min">30 Min</string>
|
||||||
|
<string name="settings_interval_60min">60 Min</string>
|
||||||
|
<string name="settings_markdown">Markdown Desktop-Integration</string>
|
||||||
|
<string name="settings_markdown_auto_on">Auto-Sync: An</string>
|
||||||
|
<string name="settings_markdown_auto_off">Auto-Sync: Aus</string>
|
||||||
|
<string name="settings_backup">Backup & Wiederherstellung</string>
|
||||||
|
<string name="settings_backup_subtitle">Lokales oder Server-Backup</string>
|
||||||
|
<string name="settings_about">Über diese App</string>
|
||||||
|
<string name="settings_debug">Debug & Diagnose</string>
|
||||||
|
<string name="settings_debug_logging_on">Logging: An</string>
|
||||||
|
<string name="settings_debug_logging_off">Logging: Aus</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SETTINGS - SERVER -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="server_settings">Server-Einstellungen</string>
|
||||||
|
<string name="server_settings_title">Server-Einstellungen</string>
|
||||||
|
<string name="server_connection_type">Verbindungstyp</string>
|
||||||
|
<string name="server_connection_http">🏠 Intern (HTTP)</string>
|
||||||
|
<string name="server_connection_https">🌐 Extern (HTTPS)</string>
|
||||||
|
<string name="server_connection_http_hint">HTTP nur für lokale Netzwerke (z.B. 192.168.x.x, 10.x.x.x)</string>
|
||||||
|
<string name="server_connection_https_hint">HTTPS für sichere Verbindungen über das Internet</string>
|
||||||
|
<string name="server_address">Server-Adresse</string>
|
||||||
|
<string name="server_address_hint">z.B. http://192.168.0.188:8080/notes</string>
|
||||||
|
<string name="server_url">Server URL</string>
|
||||||
|
<string name="username">Benutzername</string>
|
||||||
|
<string name="password">Passwort</string>
|
||||||
|
<string name="server_password_show">Anzeigen</string>
|
||||||
|
<string name="server_password_hide">Verstecken</string>
|
||||||
|
<string name="server_status_label">Server-Status:</string>
|
||||||
|
<string name="server_status_reachable">✅ Erreichbar</string>
|
||||||
|
<string name="server_status_unreachable">❌ Nicht erreichbar</string>
|
||||||
|
<string name="server_status_checking">🔍 Prüfe…</string>
|
||||||
|
<string name="server_status_not_configured">⚠️ Nicht konfiguriert</string>
|
||||||
|
<string name="server_status_offline_mode">📴 Offline-Modus aktiv</string>
|
||||||
|
<string name="server_status_unknown">❓ Unbekannt</string>
|
||||||
|
<string name="server_offline_mode_title">📴 Offline-Modus</string>
|
||||||
|
<string name="server_offline_mode_subtitle">Alle Netzwerkfunktionen deaktivieren</string>
|
||||||
|
<string name="test_connection">Verbindung testen</string>
|
||||||
|
<string name="sync_now">Jetzt synchronisieren</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SETTINGS - SYNC -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="sync_settings">Sync-Einstellungen</string>
|
||||||
|
<string name="sync_settings_title">Sync-Einstellungen</string>
|
||||||
|
<string name="auto_sync">Auto-Sync aktiviert</string>
|
||||||
|
<string name="sync_auto_sync_info">🔄 Auto-Sync:\n• Prüft alle 30 Min. ob Server erreichbar\n• Funktioniert bei jeder WiFi-Verbindung\n• Läuft auch im Hintergrund\n• Minimaler Akkuverbrauch (~0.4%/Tag)</string>
|
||||||
|
<string name="sync_auto_sync_enabled">Auto-Sync aktiviert</string>
|
||||||
|
<string name="sync_interval_section">Sync-Intervall</string>
|
||||||
|
<string name="sync_interval_info">Legt fest, wie oft die App im Hintergrund synchronisiert. Kürzere Intervalle bedeuten aktuellere Daten, verbrauchen aber etwas mehr Akku.\n\n⏱️ Hinweis: Wenn dein Smartphone im Standby ist, kann Android die Synchronisation verzögern (bis zu 60 Min.), um Akku zu sparen. Das ist normal und betrifft alle Hintergrund-Apps.</string>
|
||||||
|
<string name="sync_interval_15min_title">⚡ Alle 15 Minuten</string>
|
||||||
|
<string name="sync_interval_15min_subtitle">Schnellste Synchronisation • ~0.8%% Akku/Tag (~23 mAh)</string>
|
||||||
|
<string name="sync_interval_30min_title">✓ Alle 30 Minuten (Empfohlen)</string>
|
||||||
|
<string name="sync_interval_30min_subtitle">Ausgewogenes Verhältnis • ~0.4%% Akku/Tag (~12 mAh)</string>
|
||||||
|
<string name="sync_interval_60min_title">🔋 Alle 60 Minuten</string>
|
||||||
|
<string name="sync_interval_60min_subtitle">Maximale Akkulaufzeit • ~0.2%% Akku/Tag (~6 mAh geschätzt)</string>
|
||||||
|
<!-- Legacy -->
|
||||||
|
<string name="auto_sync_info">ℹ️ Auto-Sync:\n\n• Prüft alle 30 Min ob Server erreichbar\n• Funktioniert bei jeder WiFi-Verbindung\n• Läuft auch im Hintergrund\n• Minimaler Akkuverbrauch (~0.4%%/Tag)</string>
|
||||||
|
|
||||||
|
<!-- 🌟 v1.6.0: Configurable Sync Triggers -->
|
||||||
|
<string name="sync_section_network">📶 Netzwerk-Einschränkung</string>
|
||||||
|
<string name="sync_section_instant">📲 Sofort-Sync</string>
|
||||||
|
<string name="sync_section_background">📡 Hintergrund-Sync</string>
|
||||||
|
<string name="sync_section_advanced">⚙️ Erweitert</string>
|
||||||
|
|
||||||
|
<string name="sync_wifi_only_hint">💡 Der WiFi-Connect Trigger ist davon nicht betroffen \u2013 er synchronisiert immer wenn WiFi verbunden wird.</string>
|
||||||
|
|
||||||
|
<string name="sync_trigger_on_save_title">Nach dem Speichern</string>
|
||||||
|
<string name="sync_trigger_on_save_subtitle">Sync sofort nach jeder Änderung</string>
|
||||||
|
|
||||||
|
<string name="sync_trigger_on_resume_title">Beim App-Start</string>
|
||||||
|
<string name="sync_trigger_on_resume_subtitle">Sync wenn die App geöffnet wird</string>
|
||||||
|
|
||||||
|
<string name="sync_trigger_wifi_connect_title">Bei WiFi-Verbindung</string>
|
||||||
|
<string name="sync_trigger_wifi_connect_subtitle">Sync wenn WiFi verbunden wird</string>
|
||||||
|
|
||||||
|
<string name="sync_trigger_periodic_title">Automatisch alle X Minuten</string>
|
||||||
|
<string name="sync_trigger_periodic_subtitle">Regelmäßiger Hintergrund-Sync</string>
|
||||||
|
|
||||||
|
<string name="sync_trigger_boot_title">Nach Gerät-Neustart</string>
|
||||||
|
<string name="sync_trigger_boot_subtitle">Startet Hintergrund-Sync nach Reboot</string>
|
||||||
|
|
||||||
|
<!-- 🆕 v1.7.0: WiFi-Only Sync -->
|
||||||
|
<string name="sync_wifi_only_title">Sync nur im WLAN</string>
|
||||||
|
<string name="sync_wifi_only_subtitle">Sync wird nur durchgeführt wenn WLAN verbunden ist. Spart mobiles Datenvolumen und verhindert lange Wartezeit.</string>
|
||||||
|
<string name="sync_wifi_only_blocked">Sync nur im WLAN möglich</string>
|
||||||
|
|
||||||
|
<string name="sync_manual_hint">Manueller Sync (Toolbar/Pull-to-Refresh) ist ebenfalls verfügbar.</string>
|
||||||
|
<string name="sync_manual_hint_disabled">Sync ist im Offline-Modus nicht verfügbar.</string>
|
||||||
|
|
||||||
|
<string name="sync_offline_mode_title">Offline-Modus</string>
|
||||||
|
<string name="sync_offline_mode_message">Du nutzt die App im Offline-Modus. Richte einen Server ein, um Notizen zu synchronisieren.</string>
|
||||||
|
<string name="sync_offline_mode_button">Server einrichten</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SETTINGS - MARKDOWN -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="markdown_settings_title">Markdown Desktop-Integration</string>
|
||||||
|
<string name="markdown_dialog_title">Markdown Auto-Sync</string>
|
||||||
|
<string name="markdown_export_complete">✅ Export abgeschlossen</string>
|
||||||
|
<string name="markdown_export_progress">Exportiere %1$d/%2$d Notizen…</string>
|
||||||
|
<string name="markdown_info">📝 Exportiert Notizen zusätzlich als .md-Dateien. Mounte WebDAV als Netzlaufwerk um mit VS Code, Typora oder jedem Markdown-Editor zu bearbeiten. JSON-Sync bleibt primäres Format.</string>
|
||||||
|
<string name="markdown_auto_sync_title">Markdown Auto-Sync</string>
|
||||||
|
<string name="markdown_auto_sync_subtitle">Synchronisiert Notizen automatisch als .md-Dateien (Upload + Download bei jedem Sync)</string>
|
||||||
|
<string name="markdown_manual_sync_info">Manueller Sync exportiert alle Notizen als .md-Dateien und importiert .md-Dateien vom Server. Nützlich für einmalige Synchronisation.</string>
|
||||||
|
<string name="markdown_manual_sync_button">📝 Manueller Markdown-Sync</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SETTINGS - BACKUP -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="backup_settings_title">Backup & Wiederherstellung</string>
|
||||||
|
<string name="backup_restore_title">Backup & Wiederherstellung</string>
|
||||||
|
<string name="backup_auto_info">📦 Bei jeder Wiederherstellung wird automatisch ein Sicherheits-Backup erstellt.</string>
|
||||||
|
<string name="backup_local_section">Lokales Backup</string>
|
||||||
|
<string name="backup_create">💾 Backup erstellen</string>
|
||||||
|
<string name="backup_restore_file">📂 Aus Datei wiederherstellen</string>
|
||||||
|
|
||||||
|
<!-- 🔐 v1.7.0: Verschlüsselung -->
|
||||||
|
<string name="backup_encryption_title">Backup verschlüsseln</string>
|
||||||
|
<string name="backup_encryption_subtitle">Schütze deine Backup-Datei mit Passwort</string>
|
||||||
|
<string name="backup_encryption_password">Passwort</string>
|
||||||
|
<string name="backup_encryption_password_hint">Passwort eingeben (min. 8 Zeichen)</string>
|
||||||
|
<string name="backup_encryption_confirm">Passwort bestätigen</string>
|
||||||
|
<string name="backup_encryption_confirm_hint">Passwort erneut eingeben</string>
|
||||||
|
<string name="backup_encryption_error_mismatch">Passwörter stimmen nicht überein</string>
|
||||||
|
<string name="backup_encryption_error_too_short">Passwort zu kurz (min. 8 Zeichen)</string>
|
||||||
|
<string name="backup_decryption_required">🔒 Verschlüsseltes Backup</string>
|
||||||
|
<string name="backup_decryption_password">Passwort zum Entschlüsseln eingeben</string>
|
||||||
|
<string name="backup_decryption_error">❌ Entschlüsselung fehlgeschlagen. Falsches Passwort?</string>
|
||||||
|
|
||||||
|
<string name="backup_server_section">Server-Backup</string>
|
||||||
|
<string name="backup_restore_server">☁️ Vom Server wiederherstellen</string>
|
||||||
|
<string name="backup_restore_dialog_title">⚠️ Backup wiederherstellen?</string>
|
||||||
|
<string name="backup_restore_source">Quelle: %s</string>
|
||||||
|
<string name="backup_restore_source_file">Lokale Datei</string>
|
||||||
|
<string name="backup_restore_source_server">WebDAV Server</string>
|
||||||
|
<string name="backup_restore_mode_label">Wiederherstellungs-Modus:</string>
|
||||||
|
<string name="backup_mode_merge_title">⚪ Zusammenführen (Standard)</string>
|
||||||
|
<string name="backup_mode_merge_subtitle">Neue hinzufügen, Bestehende behalten</string>
|
||||||
|
<string name="backup_mode_merge_full">⚪ Zusammenführen (Standard)\n → Neue hinzufügen, Bestehende behalten</string>
|
||||||
|
<string name="backup_mode_replace_title">⚪ Ersetzen</string>
|
||||||
|
<string name="backup_mode_replace_subtitle">Alle löschen & Backup importieren</string>
|
||||||
|
<string name="backup_mode_replace_full">⚪ Ersetzen\n → Alle löschen & Backup importieren</string>
|
||||||
|
<string name="backup_mode_overwrite_title">⚪ Duplikate überschreiben</string>
|
||||||
|
<string name="backup_mode_overwrite_subtitle">Backup gewinnt bei Konflikten</string>
|
||||||
|
<string name="backup_mode_overwrite_full">⚪ Duplikate überschreiben\n → Backup gewinnt bei Konflikten</string>
|
||||||
|
<string name="backup_restore_info">ℹ️ Ein Sicherheits-Backup wird vor dem Wiederherstellen automatisch erstellt.</string>
|
||||||
|
<string name="backup_restore_button">Wiederherstellen</string>
|
||||||
|
<!-- Legacy -->
|
||||||
|
<string name="backup_restore_warning">⚠️ Achtung:\n\nDie Wiederherstellung überschreibt ALLE lokalen Notizen mit den Daten vom Server. Diese Aktion kann nicht rückgängig gemacht werden!</string>
|
||||||
|
<string name="restore_from_server">Vom Server wiederherstellen</string>
|
||||||
|
<string name="restore_confirmation_title">⚠️ Vom Server wiederherstellen?</string>
|
||||||
|
<string name="restore_confirmation_message">WARNUNG: Alle lokalen Notizen werden gelöscht und durch die Notizen vom Server ersetzt.\n\nDieser Vorgang kann nicht rückgängig gemacht werden!</string>
|
||||||
|
<string name="restore_button">Wiederherstellen</string>
|
||||||
|
<string name="restore_progress">Stelle Notizen wieder her…</string>
|
||||||
|
<string name="restore_success">✓ %d Notizen wiederhergestellt</string>
|
||||||
|
<string name="restore_error">Fehler: %s</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SETTINGS - DEBUG -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="debug_settings_title">Debug & Diagnose</string>
|
||||||
|
<string name="debug_file_logging_title">Datei-Logging</string>
|
||||||
|
<string name="debug_file_logging_subtitle">Sync-Logs in Datei speichern</string>
|
||||||
|
<string name="debug_privacy_info">🔒 Datenschutz: Logs werden nur lokal auf deinem Gerät gespeichert und niemals an externe Server gesendet. Die Logs enthalten Sync-Aktivitäten zur Fehlerdiagnose. Du kannst sie jederzeit löschen oder exportieren.</string>
|
||||||
|
<string name="debug_log_actions_section">Log-Aktionen</string>
|
||||||
|
<string name="debug_export_logs">📤 Logs exportieren & teilen</string>
|
||||||
|
<string name="debug_logs_subject">SimpleNotes Sync Logs</string>
|
||||||
|
<string name="debug_logs_share_via">Logs teilen via…</string>
|
||||||
|
<string name="debug_delete_logs">🗑️ Logs löschen</string>
|
||||||
|
<string name="debug_delete_logs_title">Logs löschen?</string>
|
||||||
|
<string name="debug_delete_logs_message">Alle gespeicherten Sync-Logs werden unwiderruflich gelöscht.</string>
|
||||||
|
<!-- Legacy -->
|
||||||
|
<string name="file_logging_privacy_notice">ℹ️ Datenschutz: Logs werden nur lokal auf deinem Gerät gespeichert und niemals an externe Server gesendet. Die Logs enthalten Sync-Aktivitäten zur Fehlerdiagnose. Du kannst sie jederzeit löschen oder exportieren.</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SETTINGS - LANGUAGE -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="language_settings_title">Sprache</string>
|
||||||
|
<string name="language_system_default">Systemstandard</string>
|
||||||
|
<string name="language_english">English</string>
|
||||||
|
<string name="language_german">Deutsch</string>
|
||||||
|
<string name="language_info">ℹ️ Wähle deine bevorzugte Sprache. Die App wird neu gestartet, um die Änderung anzuwenden.</string>
|
||||||
|
<string name="language_changed_restart">Sprache geändert. Neustart…</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SETTINGS - DISPLAY -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="display_settings_title">Anzeige</string>
|
||||||
|
<string name="display_mode_title">Notizen-Ansicht</string>
|
||||||
|
<string name="display_mode_list">📋 Listen-Ansicht</string>
|
||||||
|
<string name="display_mode_grid">🎨 Raster-Ansicht</string>
|
||||||
|
<string name="display_mode_info">Die Raster-Ansicht zeigt Notizen im Pinterest-Stil. Kurze Notizen erscheinen nebeneinander, lange Notizen nehmen die volle Breite ein.</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SETTINGS - ABOUT -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="about_settings_title">Über diese App</string>
|
||||||
|
<string name="about_app_name">Simple Notes Sync</string>
|
||||||
|
<string name="about_version">Version %1$s (%2$d)</string>
|
||||||
|
<string name="about_links_section">Links</string>
|
||||||
|
<string name="about_github_title">GitHub Repository</string>
|
||||||
|
<string name="about_github_subtitle">Quellcode, Issues & Dokumentation</string>
|
||||||
|
<string name="about_developer_title">Entwickler</string>
|
||||||
|
<string name="about_developer_subtitle">GitHub Profil: @inventory69</string>
|
||||||
|
<string name="about_license_title">Lizenz</string>
|
||||||
|
<string name="about_license_subtitle">MIT License - Open Source</string>
|
||||||
|
<string name="about_privacy_title">🔒 Datenschutz</string>
|
||||||
|
<string name="about_privacy_text">Diese App sammelt keine Daten. Alle Notizen werden nur lokal auf deinem Gerät und auf deinem eigenen WebDAV-Server gespeichert. Keine Telemetrie, keine Werbung.</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- TOAST MESSAGES -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="toast_connection_success">✅ Verbindung erfolgreich!</string>
|
||||||
|
<string name="toast_connection_failed">❌ %s</string>
|
||||||
|
<string name="toast_error">❌ Fehler: %s</string>
|
||||||
|
<string name="toast_syncing">🔄 Synchronisiere…</string>
|
||||||
|
<string name="toast_already_synced">✅ Bereits synchronisiert</string>
|
||||||
|
<string name="toast_sync_success">✅ %d Notizen synchronisiert</string>
|
||||||
|
<string name="toast_sync_failed">❌ %s</string>
|
||||||
|
<string name="toast_auto_sync_enabled">✅ Auto-Sync aktiviert</string>
|
||||||
|
<string name="toast_auto_sync_disabled">Auto-Sync deaktiviert</string>
|
||||||
|
<string name="toast_sync_interval">⏱️ Sync-Intervall: %s</string>
|
||||||
|
<string name="toast_sync_interval_15min">15 Minuten</string>
|
||||||
|
<string name="toast_sync_interval_30min">30 Minuten</string>
|
||||||
|
<string name="toast_sync_interval_60min">60 Minuten</string>
|
||||||
|
<string name="toast_configure_server_first">⚠️ Bitte zuerst WebDAV-Server konfigurieren</string>
|
||||||
|
<string name="toast_markdown_exported">✅ %d Notizen nach Markdown exportiert</string>
|
||||||
|
<string name="toast_markdown_enabled">📝 Markdown Auto-Sync aktiviert</string>
|
||||||
|
<string name="toast_markdown_disabled">📝 Markdown Auto-Sync deaktiviert</string>
|
||||||
|
<string name="toast_markdown_syncing">📝 Markdown-Sync läuft…</string>
|
||||||
|
<string name="toast_markdown_result">✅ Export: %1$d • Import: %2$d</string>
|
||||||
|
<string name="toast_export_failed">❌ Export fehlgeschlagen: %s</string>
|
||||||
|
<string name="toast_backup_success">✅ %s</string>
|
||||||
|
<string name="toast_backup_failed">❌ Backup fehlgeschlagen: %s</string>
|
||||||
|
<string name="toast_restore_success">✅ %d Notizen wiederhergestellt</string>
|
||||||
|
<string name="toast_restore_failed">❌ Wiederherstellung fehlgeschlagen: %s</string>
|
||||||
|
<string name="toast_notifications_enabled">Benachrichtigungen aktiviert</string>
|
||||||
|
<string name="toast_notifications_disabled">Benachrichtigungen deaktiviert. Du kannst sie in den Einstellungen aktivieren.</string>
|
||||||
|
<string name="toast_battery_optimization">Bitte Akku-Optimierung manuell deaktivieren</string>
|
||||||
|
<string name="toast_logs_deleted">🗑️ Logs gelöscht</string>
|
||||||
|
<string name="toast_no_logs_to_delete">📭 Keine Logs zum Löschen</string>
|
||||||
|
<string name="toast_logs_delete_error">❌ Fehler beim Löschen: %s</string>
|
||||||
|
<!-- 🔄 v1.7.0: Server change notification -->
|
||||||
|
<string name="toast_server_changed_sync_reset">🔄 Server geändert. %d Notizen werden beim nächsten Sync hochgeladen.</string>
|
||||||
|
<string name="toast_link_error">❌ Fehler beim Öffnen des Links</string>
|
||||||
|
<string name="toast_file_logging_enabled">📝 Datei-Logging aktiviert</string>
|
||||||
|
<string name="toast_file_logging_disabled">📝 Datei-Logging deaktiviert</string>
|
||||||
|
<string name="toast_sync_interval_changed">⏱️ Sync-Intervall auf %s geändert</string>
|
||||||
|
<string name="version_not_available">Version nicht verfügbar</string>
|
||||||
|
<string name="status_checking">🔍 Prüfe…</string>
|
||||||
|
<string name="battery_optimization_dialog_message">Bitte wähle \'Nicht optimieren\' für Simple Notes.</string>
|
||||||
|
<string name="battery_optimization_dialog_title">Hintergrund-Synchronisation</string>
|
||||||
|
<string name="battery_optimization_dialog_full_message">Damit die App im Hintergrund synchronisieren kann, muss die Akku-Optimierung deaktiviert werden.\n\nBitte wähle \'Nicht optimieren\' für Simple Notes.</string>
|
||||||
|
<string name="battery_optimization_open_settings">Einstellungen öffnen</string>
|
||||||
|
<string name="battery_optimization_later">Später</string>
|
||||||
|
<string name="content_description_back">Zurück</string>
|
||||||
|
<string name="error_invalid_backup_file">Ungültige Backup-Datei</string>
|
||||||
|
<string name="error_backup_version_unsupported">Backup-Version nicht unterstützt (v%1$d benötigt v%2$d+)</string>
|
||||||
|
<string name="error_backup_empty">Backup enthält keine Notizen</string>
|
||||||
|
<string name="error_backup_invalid_notes">Backup enthält %d ungültige Notizen</string>
|
||||||
|
<string name="error_backup_corrupt">Backup-Datei beschädigt oder ungültig: %s</string>
|
||||||
|
<string name="error_restore_failed">Wiederherstellung fehlgeschlagen: %s</string>
|
||||||
|
<string name="restore_merge_result">%1$d neue Notizen importiert, %2$d übersprungen</string>
|
||||||
|
<string name="restore_overwrite_result">%1$d neu, %2$d überschrieben</string>
|
||||||
|
<string name="restore_replace_result">Alle Notizen ersetzt: %d importiert</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- RELATIVE TIME -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="time_just_now">Gerade eben</string>
|
||||||
|
<string name="time_minutes_ago">Vor %d Min</string>
|
||||||
|
<string name="time_hours_ago">Vor %d Std</string>
|
||||||
|
<string name="time_days_ago">Vor %d Tagen</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- NOTIFICATIONS -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="notification_channel_name">Notizen Synchronisierung</string>
|
||||||
|
<string name="notification_channel_desc">Benachrichtigungen über Sync-Status</string>
|
||||||
|
<string name="notification_sync_success_title">Sync erfolgreich</string>
|
||||||
|
<string name="notification_sync_success_message">%d Notiz(en) synchronisiert</string>
|
||||||
|
<string name="notification_sync_failed_title">Sync fehlgeschlagen</string>
|
||||||
|
<string name="notification_sync_progress_title">Synchronisiere…</string>
|
||||||
|
<string name="notification_sync_progress_message">Notizen werden synchronisiert</string>
|
||||||
|
<string name="notification_sync_conflict_title">Sync-Konflikt erkannt</string>
|
||||||
|
<string name="notification_sync_conflict_message">%d Notiz(en) haben Konflikte</string>
|
||||||
|
<string name="notification_sync_warning_title">⚠️ Sync-Warnung</string>
|
||||||
|
<string name="notification_sync_warning_message">Server seit %dh nicht erreichbar</string>
|
||||||
|
<string name="notification_sync_warning_detail">Der WebDAV-Server ist seit %d Stunden nicht erreichbar. Bitte prüfe deine Netzwerkverbindung oder Server-Einstellungen.</string>
|
||||||
|
<string name="notification_sync_in_progress_title">Synchronisierung läuft</string>
|
||||||
|
<string name="notification_sync_in_progress_message">Notizen werden synchronisiert…</string>
|
||||||
|
<string name="notification_sync_error_title">Sync Fehler</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- PLURALS -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<plurals name="notes_count">
|
||||||
|
<item quantity="one">%d Notiz</item>
|
||||||
|
<item quantity="other">%d Notizen</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="notes_deleted_local">
|
||||||
|
<item quantity="one">%d Notiz lokal gelöscht</item>
|
||||||
|
<item quantity="other">%d Notizen lokal gelöscht</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="notes_deleted_server">
|
||||||
|
<item quantity="one">%d Notiz wird vom Server gelöscht</item>
|
||||||
|
<item quantity="other">%d Notizen werden vom Server gelöscht</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="notes_synced">
|
||||||
|
<item quantity="one">%d Notiz synchronisiert</item>
|
||||||
|
<item quantity="other">%d Notizen synchronisiert</item>
|
||||||
|
</plurals>
|
||||||
|
</resources>
|
||||||
@@ -1,69 +1,465 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- APP IDENTITY -->
|
||||||
|
<!-- ============================= -->
|
||||||
<string name="app_name">Simple Notes</string>
|
<string name="app_name">Simple Notes</string>
|
||||||
|
|
||||||
<!-- Main Activity -->
|
<!-- ============================= -->
|
||||||
<string name="no_notes_yet">Noch keine Notizen.\nTippe + um eine zu erstellen.</string>
|
<!-- MAIN SCREEN -->
|
||||||
<string name="add_note">Notiz hinzufügen</string>
|
<!-- ============================= -->
|
||||||
<string name="sync">Synchronisieren</string>
|
<string name="main_title">Simple Notes</string>
|
||||||
<string name="settings">Einstellungen</string>
|
<string name="no_notes_yet">No notes yet.\nTap + to create one.</string>
|
||||||
|
<string name="add_note">Add note</string>
|
||||||
|
<string name="sync">Sync</string>
|
||||||
|
<string name="settings">Settings</string>
|
||||||
|
<string name="action_sync">Sync</string>
|
||||||
|
<string name="action_settings">Settings</string>
|
||||||
|
<string name="action_close_selection">Close selection</string>
|
||||||
|
<string name="action_select_all">Select all</string>
|
||||||
|
<string name="action_delete_selected">Delete selected</string>
|
||||||
|
<string name="selection_count">%d selected</string>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- ============================= -->
|
||||||
|
<!-- EMPTY STATE -->
|
||||||
|
<!-- ============================= -->
|
||||||
<string name="empty_state_emoji">📝</string>
|
<string name="empty_state_emoji">📝</string>
|
||||||
<string name="empty_state_title">Noch keine Notizen</string>
|
<string name="empty_state_title">No notes yet</string>
|
||||||
<string name="empty_state_message">Tippe auf ➕ um deine erste Notiz zu erstellen</string>
|
<string name="empty_state_message">Tap + to create a new note</string>
|
||||||
|
|
||||||
<!-- Note Editor -->
|
<!-- ============================= -->
|
||||||
<string name="edit_note">Notiz bearbeiten</string>
|
<!-- FAB MENU -->
|
||||||
<string name="new_note">Neue Notiz</string>
|
<!-- ============================= -->
|
||||||
<string name="title">Titel</string>
|
<string name="fab_new_note">New note</string>
|
||||||
<string name="content">Inhalt</string>
|
<string name="fab_text_note">Text note</string>
|
||||||
<string name="save">Speichern</string>
|
<string name="fab_checklist">Checklist</string>
|
||||||
<string name="delete">Löschen</string>
|
<string name="create_text_note">Note</string>
|
||||||
|
<string name="create_checklist">List</string>
|
||||||
|
|
||||||
<!-- Note List Item (Preview placeholders) -->
|
<!-- ============================= -->
|
||||||
|
<!-- NOTE CARD -->
|
||||||
|
<!-- ============================= -->
|
||||||
<string name="note_title_placeholder">Note Title</string>
|
<string name="note_title_placeholder">Note Title</string>
|
||||||
<string name="note_content_placeholder">Note content preview…</string>
|
<string name="note_content_placeholder">Note content preview…</string>
|
||||||
<string name="note_timestamp_placeholder">Vor 2 Std</string>
|
<string name="note_timestamp_placeholder">2 hours ago</string>
|
||||||
|
<string name="untitled">Untitled</string>
|
||||||
|
<string name="checklist_progress">%1$d/%2$d done</string>
|
||||||
|
<string name="empty_checklist">No entries</string>
|
||||||
|
|
||||||
<!-- Delete Confirmation Dialog -->
|
<!-- ============================= -->
|
||||||
<string name="delete_note_title">Notiz löschen?</string>
|
<!-- SYNC STATUS BANNER -->
|
||||||
<string name="delete_note_message">Diese Aktion kann nicht rückgängig gemacht werden.</string>
|
<!-- ============================= -->
|
||||||
<string name="cancel">Abbrechen</string>
|
<string name="sync_status">Sync Status</string>
|
||||||
|
<string name="sync_syncing">Syncing…</string>
|
||||||
|
<string name="sync_completed">Synced</string>
|
||||||
|
<string name="sync_error">Error</string>
|
||||||
|
<string name="sync_status_syncing">Syncing…</string>
|
||||||
|
<string name="sync_status_completed">Sync completed</string>
|
||||||
|
<string name="sync_status_error">Sync failed</string>
|
||||||
|
<string name="sync_already_running">Sync already in progress</string>
|
||||||
|
|
||||||
<!-- Settings -->
|
<!-- ============================= -->
|
||||||
<string name="server_settings">Server-Einstellungen</string>
|
<!-- DELETE DIALOGS -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="delete_note_title">Delete note?</string>
|
||||||
|
<string name="delete_notes_title">Delete %d notes?</string>
|
||||||
|
<string name="delete_note_message">How do you want to delete this note?</string>
|
||||||
|
<string name="delete_notes_message">How do you want to delete these %d notes?</string>
|
||||||
|
<string name="delete_everywhere">Delete everywhere (also server)</string>
|
||||||
|
<string name="delete_everywhere_offline_hint">Not available in offline mode</string>
|
||||||
|
<string name="delete_local_only">Delete local only</string>
|
||||||
|
<string name="delete">Delete</string>
|
||||||
|
<string name="cancel">Cancel</string>
|
||||||
|
<string name="ok">OK</string>
|
||||||
|
<!-- Legacy delete dialogs -->
|
||||||
|
<string name="legacy_delete_dialog_title">Delete note</string>
|
||||||
|
<string name="legacy_delete_dialog_message">\"%s\" will be deleted locally.\n\nAlso delete from server?</string>
|
||||||
|
<string name="legacy_delete_from_server">Delete from server</string>
|
||||||
|
<string name="legacy_delete_with_server">\"%s\" will be deleted locally and from server</string>
|
||||||
|
<string name="legacy_delete_local_only">\"%s\" deleted locally (server remains)</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SNACKBAR MESSAGES -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="snackbar_undo">UNDO</string>
|
||||||
|
<string name="snackbar_note_deleted_local">\"%s\" deleted locally</string>
|
||||||
|
<string name="snackbar_note_deleted_server">\"%s\" will be deleted from server</string>
|
||||||
|
<string name="snackbar_notes_deleted_local">%d note(s) deleted locally</string>
|
||||||
|
<string name="snackbar_notes_deleted_server">%d note(s) will be deleted from server</string>
|
||||||
|
<string name="snackbar_deleted_from_server">Deleted from server</string>
|
||||||
|
<string name="snackbar_notes_deleted_from_server">%d note(s) deleted from server</string>
|
||||||
|
<string name="snackbar_notes_deleted_from_server_partial">%1$d of %2$d notes deleted from server</string>
|
||||||
|
<string name="snackbar_server_delete_failed">Server deletion failed</string>
|
||||||
|
<string name="snackbar_server_error">Server error: %s</string>
|
||||||
|
<string name="snackbar_already_synced">Already synced</string>
|
||||||
|
<string name="snackbar_server_unreachable">Server not reachable</string>
|
||||||
|
<string name="snackbar_synced_count">✅ Synced: %d notes</string>
|
||||||
|
<string name="snackbar_nothing_to_sync">ℹ️ Nothing to sync</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- URL VALIDATION ERRORS -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="error_http_local_only">HTTP is only allowed for local servers (e.g. 192.168.x.x, 10.x.x.x, nas.local). For public servers, please use HTTPS.</string>
|
||||||
|
<string name="error_invalid_protocol">Invalid protocol: %s. Please use HTTP or HTTPS.</string>
|
||||||
|
<string name="error_invalid_url">Invalid URL: %s</string>
|
||||||
|
<string name="error_server_not_configured">WebDAV server not fully configured</string>
|
||||||
|
<string name="error_sardine_client_failed">Sardine client could not be created</string>
|
||||||
|
<string name="error_server_url_not_configured">Server URL not configured</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- NOTE EDITOR -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="new_note">New Note</string>
|
||||||
|
<string name="edit_note">Edit Note</string>
|
||||||
|
<string name="new_checklist">New List</string>
|
||||||
|
<string name="edit_checklist">Edit List</string>
|
||||||
|
<string name="title">Title</string>
|
||||||
|
<string name="content">Content</string>
|
||||||
|
<string name="back">Back</string>
|
||||||
|
<string name="save">Save</string>
|
||||||
|
<string name="add_item">Add item</string>
|
||||||
|
<string name="item_placeholder">New item…</string>
|
||||||
|
<string name="reorder_item">Reorder item</string>
|
||||||
|
<string name="drag_to_reorder">Drag to reorder</string>
|
||||||
|
<string name="delete_item">Delete item</string>
|
||||||
|
<string name="note_is_empty">Note is empty</string>
|
||||||
|
<string name="note_saved">Note saved</string>
|
||||||
|
<string name="note_deleted">Note deleted</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SETTINGS - MAIN -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="settings_title">Settings</string>
|
||||||
|
<string name="settings_language">Language</string>
|
||||||
|
<string name="settings_language_subtitle">%s</string>
|
||||||
|
<string name="settings_server">Server Settings</string>
|
||||||
|
<string name="settings_server_status_reachable">✅ Reachable</string>
|
||||||
|
<string name="settings_server_status_unreachable">❌ Not reachable</string>
|
||||||
|
<string name="settings_server_status_checking">🔍 Checking…</string>
|
||||||
|
<string name="settings_server_status_not_configured">⚠️ Not configured</string>
|
||||||
|
<string name="settings_server_status_offline_mode">📴 Offline Mode</string>
|
||||||
|
<string name="settings_sync">Sync Settings</string>
|
||||||
|
<string name="settings_sync_auto_on">Auto-Sync: On • %s</string>
|
||||||
|
<string name="settings_sync_auto_off">Auto-Sync: Off</string>
|
||||||
|
<string name="settings_sync_offline_mode">📴 Offline Mode</string>
|
||||||
|
<string name="settings_sync_manual_only">Manual sync only</string>
|
||||||
|
<string name="settings_sync_triggers_active">%d triggers active</string>
|
||||||
|
<string name="settings_interval_15min">15 min</string>
|
||||||
|
<string name="settings_interval_30min">30 min</string>
|
||||||
|
<string name="settings_interval_60min">60 min</string>
|
||||||
|
<string name="settings_markdown">Markdown Desktop Integration</string>
|
||||||
|
<string name="settings_markdown_auto_on">Auto-Sync: On</string>
|
||||||
|
<string name="settings_markdown_auto_off">Auto-Sync: Off</string>
|
||||||
|
<string name="settings_backup">Backup & Restore</string>
|
||||||
|
<string name="settings_backup_subtitle">Local or server backup</string>
|
||||||
|
<string name="settings_about">About this App</string>
|
||||||
|
<string name="settings_debug">Debug & Diagnostics</string>
|
||||||
|
<string name="settings_debug_logging_on">Logging: On</string>
|
||||||
|
<string name="settings_debug_logging_off">Logging: Off</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SETTINGS - SERVER -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="server_settings">Server Settings</string>
|
||||||
|
<string name="server_settings_title">Server Settings</string>
|
||||||
|
<string name="server_connection_type">Connection Type</string>
|
||||||
|
<string name="server_connection_http">🏠 Internal (HTTP)</string>
|
||||||
|
<string name="server_connection_https">🌐 External (HTTPS)</string>
|
||||||
|
<string name="server_connection_http_hint">HTTP only for local networks (e.g. 192.168.x.x, 10.x.x.x)</string>
|
||||||
|
<string name="server_connection_https_hint">HTTPS for secure connections over the internet</string>
|
||||||
|
<string name="server_address">Server Address</string>
|
||||||
|
<string name="server_address_hint">e.g. http://192.168.0.188:8080/notes</string>
|
||||||
<string name="server_url">Server URL</string>
|
<string name="server_url">Server URL</string>
|
||||||
<string name="username">Benutzername</string>
|
<string name="username">Username</string>
|
||||||
<string name="password">Passwort</string>
|
<string name="password">Password</string>
|
||||||
<string name="server_status_label">Server-Status:</string>
|
<string name="server_password_show">Show</string>
|
||||||
<string name="server_status_checking">Prüfe…</string>
|
<string name="server_password_hide">Hide</string>
|
||||||
<string name="test_connection">Verbindung testen</string>
|
<string name="server_status_label">Server Status:</string>
|
||||||
<string name="sync_now">Jetzt synchronisieren</string>
|
<string name="server_status_reachable">✅ Reachable</string>
|
||||||
|
<string name="server_status_unreachable">❌ Not reachable</string>
|
||||||
|
<string name="server_status_checking">🔍 Checking…</string>
|
||||||
|
<string name="server_status_not_configured">⚠️ Not configured</string>
|
||||||
|
<string name="server_status_offline_mode">📴 Offline mode active</string>
|
||||||
|
<string name="server_status_unknown">❓ Unknown</string>
|
||||||
|
<string name="server_offline_mode_title">📴 Offline Mode</string>
|
||||||
|
<string name="server_offline_mode_subtitle">Disable all network features</string>
|
||||||
|
<string name="test_connection">Test Connection</string>
|
||||||
|
<string name="sync_now">Sync now</string>
|
||||||
|
|
||||||
<!-- Auto-Sync Settings -->
|
<!-- ============================= -->
|
||||||
<string name="sync_settings">Sync-Einstellungen</string>
|
<!-- SETTINGS - SYNC -->
|
||||||
<string name="home_ssid">Heim-WLAN SSID</string>
|
<!-- ============================= -->
|
||||||
<string name="auto_sync">Auto-Sync aktiviert</string>
|
<string name="sync_settings">Sync Settings</string>
|
||||||
<string name="sync_status">Sync-Status</string>
|
<string name="sync_settings_title">Sync Settings</string>
|
||||||
<string name="auto_sync_info">ℹ️ Auto-Sync:\n\n• Prüft alle 30 Min ob Server erreichbar\n• Funktioniert nur im selben Netzwerk\n• Läuft auch im Hintergrund\n• Minimaler Akkuverbrauch (~0.4%%/Tag)</string>
|
<string name="auto_sync">Auto-Sync enabled</string>
|
||||||
|
<string name="sync_auto_sync_info">🔄 Auto-Sync:\n• Checks every 30 min if server is reachable\n• Works on any WiFi connection\n• Runs in background\n• Minimal battery usage (~0.4%/day)</string>
|
||||||
|
<string name="sync_auto_sync_enabled">Auto-Sync enabled</string>
|
||||||
|
<string name="sync_interval_section">Sync Interval</string>
|
||||||
|
<string name="sync_interval_info">Determines how often the app syncs in the background. Shorter intervals mean more up-to-date data, but use slightly more battery.\n\n⏱️ Note: When your phone is in standby, Android may delay syncs (up to 60 min) to save battery. This is normal and affects all background apps.</string>
|
||||||
|
<string name="sync_interval_15min_title">⚡ Every 15 minutes</string>
|
||||||
|
<string name="sync_interval_15min_subtitle">Fastest sync • ~0.8%% battery/day (~23 mAh)</string>
|
||||||
|
<string name="sync_interval_30min_title">✓ Every 30 minutes (Recommended)</string>
|
||||||
|
<string name="sync_interval_30min_subtitle">Balanced ratio • ~0.4%% battery/day (~12 mAh)</string>
|
||||||
|
<string name="sync_interval_60min_title">🔋 Every 60 minutes</string>
|
||||||
|
<string name="sync_interval_60min_subtitle">Maximum battery life • ~0.2%% battery/day (~6 mAh est.)</string>
|
||||||
|
<!-- Legacy -->
|
||||||
|
<string name="auto_sync_info">ℹ️ Auto-Sync:\n\n• Checks every 30 min if server is reachable\n• Works on any WiFi connection\n• Runs in background\n• Minimal battery usage (~0.4%%/day)</string>
|
||||||
|
|
||||||
<!-- Backup & Restore -->
|
<!-- 🌟 v1.6.0: Configurable Sync Triggers -->
|
||||||
<string name="backup_restore_title">Backup & Wiederherstellung</string>
|
<string name="sync_section_network">📶 Network Restriction</string>
|
||||||
<string name="backup_restore_warning">⚠️ Achtung:\n\nDie Wiederherstellung überschreibt ALLE lokalen Notizen mit den Daten vom Server. Diese Aktion kann nicht rückgängig gemacht werden!</string>
|
<string name="sync_section_instant">📲 Instant Sync</string>
|
||||||
<string name="restore_from_server">Vom Server wiederherstellen</string>
|
<string name="sync_section_background">📡 Background Sync</string>
|
||||||
<string name="restore_confirmation_title">⚠️ Vom Server wiederherstellen?</string>
|
<string name="sync_section_advanced">⚙️ Advanced</string>
|
||||||
<string name="restore_confirmation_message">WARNUNG: Alle lokalen Notizen werden gelöscht und durch die Notizen vom Server ersetzt.\n\nDieser Vorgang kann nicht rückgängig gemacht werden!</string>
|
|
||||||
<string name="restore_button">Wiederherstellen</string>
|
|
||||||
<string name="restore_progress">Stelle Notizen wieder her…</string>
|
|
||||||
<string name="restore_success">✓ %d Notizen wiederhergestellt</string>
|
|
||||||
<string name="restore_error">Fehler: %s</string>
|
|
||||||
|
|
||||||
<!-- Sync Status Banner (v1.3.1) -->
|
<string name="sync_wifi_only_hint">💡 WiFi-Connect Trigger is not affected by this setting \u2013 it always syncs when WiFi is connected.</string>
|
||||||
<string name="sync_status_syncing">Synchronisiere…</string>
|
|
||||||
<string name="sync_status_completed">Synchronisierung abgeschlossen</string>
|
|
||||||
<string name="sync_status_error">Synchronisierung fehlgeschlagen</string>
|
|
||||||
<string name="sync_already_running">Synchronisierung läuft bereits</string>
|
|
||||||
|
|
||||||
<!-- Debug/Logging Section (v1.3.2) -->
|
<string name="sync_trigger_on_save_title">After Saving</string>
|
||||||
<string name="file_logging_privacy_notice">ℹ️ Datenschutz: Logs werden nur lokal auf deinem Gerät gespeichert und niemals an externe Server gesendet. Die Logs enthalten Sync-Aktivitäten zur Fehlerdiagnose. Du kannst sie jederzeit löschen oder exportieren.</string>
|
<string name="sync_trigger_on_save_subtitle">Sync immediately after each change</string>
|
||||||
|
|
||||||
|
<string name="sync_trigger_on_resume_title">On App Start</string>
|
||||||
|
<string name="sync_trigger_on_resume_subtitle">Sync when the app is opened</string>
|
||||||
|
|
||||||
|
<string name="sync_trigger_wifi_connect_title">On WiFi Connection</string>
|
||||||
|
<string name="sync_trigger_wifi_connect_subtitle">Sync when WiFi is connected</string>
|
||||||
|
|
||||||
|
<string name="sync_trigger_periodic_title">Automatically every X minutes</string>
|
||||||
|
<string name="sync_trigger_periodic_subtitle">Regular background sync</string>
|
||||||
|
|
||||||
|
<string name="sync_trigger_boot_title">After Device Restart</string>
|
||||||
|
<string name="sync_trigger_boot_subtitle">Starts background sync after reboot</string>
|
||||||
|
|
||||||
|
<!-- 🆕 v1.7.0: WiFi-Only Sync -->
|
||||||
|
<string name="sync_wifi_only_title">WiFi-only sync</string>
|
||||||
|
<string name="sync_wifi_only_subtitle">Sync only when connected to WiFi. Saves mobile data and prevents long wait times.</string>
|
||||||
|
<string name="sync_wifi_only_blocked">Sync only works when connected to WiFi</string>
|
||||||
|
|
||||||
|
<string name="sync_manual_hint">Manual sync (toolbar/pull-to-refresh) is also available.</string>
|
||||||
|
<string name="sync_manual_hint_disabled">Sync is not available in offline mode.</string>
|
||||||
|
|
||||||
|
<string name="sync_offline_mode_title">Offline Mode</string>
|
||||||
|
<string name="sync_offline_mode_message">You are using the app in offline mode. Set up a server to synchronize notes.</string>
|
||||||
|
<string name="sync_offline_mode_button">Set Up Server</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SETTINGS - MARKDOWN -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="markdown_settings_title">Markdown Desktop Integration</string>
|
||||||
|
<string name="markdown_dialog_title">Markdown Auto-Sync</string>
|
||||||
|
<string name="markdown_export_complete">✅ Export complete</string>
|
||||||
|
<string name="markdown_export_progress">Exporting %1$d/%2$d notes…</string>
|
||||||
|
<string name="markdown_info">📝 Exports notes additionally as .md files. Mount WebDAV as network drive to edit with VS Code, Typora, or any Markdown editor. JSON sync remains primary format.</string>
|
||||||
|
<string name="markdown_auto_sync_title">Markdown Auto-Sync</string>
|
||||||
|
<string name="markdown_auto_sync_subtitle">Automatically syncs notes as .md files (upload + download on each sync)</string>
|
||||||
|
<string name="markdown_manual_sync_info">Manual sync exports all notes as .md files and imports .md files from the server. Useful for one-time sync.</string>
|
||||||
|
<string name="markdown_manual_sync_button">📝 Manual Markdown Sync</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SETTINGS - BACKUP -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="backup_settings_title">Backup & Restore</string>
|
||||||
|
<string name="backup_restore_title">Backup & Restore</string>
|
||||||
|
<string name="backup_auto_info">📦 A safety backup is automatically created before each restore.</string>
|
||||||
|
<string name="backup_local_section">Local Backup</string>
|
||||||
|
<string name="backup_create">💾 Create Backup</string>
|
||||||
|
<string name="backup_restore_file">📂 Restore from File</string>
|
||||||
|
|
||||||
|
<!-- 🔐 v1.7.0: Encryption -->
|
||||||
|
<string name="backup_encryption_title">Encrypt Backup</string>
|
||||||
|
<string name="backup_encryption_subtitle">Password-protect your backup file</string>
|
||||||
|
<string name="backup_encryption_password">Password</string>
|
||||||
|
<string name="backup_encryption_password_hint">Enter password (min. 8 characters)</string>
|
||||||
|
<string name="backup_encryption_confirm">Confirm Password</string>
|
||||||
|
<string name="backup_encryption_confirm_hint">Re-enter password</string>
|
||||||
|
<string name="backup_encryption_error_mismatch">Passwords don\'t match</string>
|
||||||
|
<string name="backup_encryption_error_too_short">Password too short (min. 8 characters)</string>
|
||||||
|
<string name="backup_decryption_required">🔒 Encrypted Backup</string>
|
||||||
|
<string name="backup_decryption_password">Enter password to decrypt</string>
|
||||||
|
<string name="backup_decryption_error">❌ Decryption failed. Wrong password?</string>
|
||||||
|
|
||||||
|
<string name="backup_server_section">Server Backup</string>
|
||||||
|
<string name="backup_restore_server">☁️ Restore from Server</string>
|
||||||
|
<string name="backup_restore_dialog_title">⚠️ Restore Backup?</string>
|
||||||
|
<string name="backup_restore_source">Source: %s</string>
|
||||||
|
<string name="backup_restore_source_file">Local File</string>
|
||||||
|
<string name="backup_restore_source_server">WebDAV Server</string>
|
||||||
|
<string name="backup_restore_mode_label">Restore Mode:</string>
|
||||||
|
<string name="backup_mode_merge_title">⚪ Merge (Default)</string>
|
||||||
|
<string name="backup_mode_merge_subtitle">Add new, keep existing</string>
|
||||||
|
<string name="backup_mode_merge_full">⚪ Merge (Default)\n → Add new, keep existing</string>
|
||||||
|
<string name="backup_mode_replace_title">⚪ Replace</string>
|
||||||
|
<string name="backup_mode_replace_subtitle">Delete all & import backup</string>
|
||||||
|
<string name="backup_mode_replace_full">⚪ Replace\n → Delete all & import backup</string>
|
||||||
|
<string name="backup_mode_overwrite_title">⚪ Overwrite duplicates</string>
|
||||||
|
<string name="backup_mode_overwrite_subtitle">Backup wins on conflicts</string>
|
||||||
|
<string name="backup_mode_overwrite_full">⚪ Overwrite duplicates\n → Backup wins on conflicts</string>
|
||||||
|
<string name="backup_restore_info">ℹ️ A safety backup will be automatically created before restoring.</string>
|
||||||
|
<string name="backup_restore_button">Restore</string>
|
||||||
|
<!-- Legacy -->
|
||||||
|
<string name="backup_restore_warning">⚠️ Warning:\n\nRestoring will overwrite ALL local notes with data from the server. This action cannot be undone!</string>
|
||||||
|
<string name="restore_from_server">Restore from Server</string>
|
||||||
|
<string name="restore_confirmation_title">⚠️ Restore from Server?</string>
|
||||||
|
<string name="restore_confirmation_message">WARNING: All local notes will be deleted and replaced with notes from the server.\n\nThis action cannot be undone!</string>
|
||||||
|
<string name="restore_button">Restore</string>
|
||||||
|
<string name="restore_progress">Restoring notes…</string>
|
||||||
|
<string name="restore_success">✓ %d notes restored</string>
|
||||||
|
<string name="restore_error">Error: %s</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SETTINGS - DEBUG -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="debug_settings_title">Debug & Diagnostics</string>
|
||||||
|
<string name="debug_file_logging_title">File Logging</string>
|
||||||
|
<string name="debug_file_logging_subtitle">Save sync logs to file</string>
|
||||||
|
<string name="debug_privacy_info">🔒 Privacy: Logs are only stored locally on your device and are never sent to external servers. Logs contain sync activities for troubleshooting. You can delete or export them at any time.</string>
|
||||||
|
<string name="debug_log_actions_section">Log Actions</string>
|
||||||
|
<string name="debug_export_logs">📤 Export & Share Logs</string>
|
||||||
|
<string name="debug_logs_subject">SimpleNotes Sync Logs</string>
|
||||||
|
<string name="debug_logs_share_via">Share logs via…</string>
|
||||||
|
<string name="debug_delete_logs">🗑️ Delete Logs</string>
|
||||||
|
<string name="debug_delete_logs_title">Delete logs?</string>
|
||||||
|
<string name="debug_delete_logs_message">All saved sync logs will be permanently deleted.</string>
|
||||||
|
<!-- Legacy -->
|
||||||
|
<string name="file_logging_privacy_notice">ℹ️ Privacy: Logs are only stored locally on your device and are never sent to external servers. Logs contain sync activities for troubleshooting. You can delete or export them at any time.</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SETTINGS - LANGUAGE -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="language_settings_title">Language</string>
|
||||||
|
<string name="language_system_default">System Default</string>
|
||||||
|
<string name="language_english">English</string>
|
||||||
|
<string name="language_german">Deutsch</string>
|
||||||
|
<string name="language_info">ℹ️ Choose your preferred language. The app will restart to apply the change.</string>
|
||||||
|
<string name="language_changed_restart">Language changed. Restarting…</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SETTINGS - DISPLAY -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="display_settings_title">Display</string>
|
||||||
|
<string name="display_mode_title">Note Display Mode</string>
|
||||||
|
<string name="display_mode_list">📋 List View</string>
|
||||||
|
<string name="display_mode_grid">🎨 Grid View</string>
|
||||||
|
<string name="display_mode_info">Grid view shows notes in a staggered Pinterest-style layout. Small notes appear side-by-side, large notes take full width.</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SETTINGS - ABOUT -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="about_settings_title">About this App</string>
|
||||||
|
<string name="about_app_name">Simple Notes Sync</string>
|
||||||
|
<string name="about_version">Version %1$s (%2$d)</string>
|
||||||
|
<string name="about_links_section">Links</string>
|
||||||
|
<string name="about_github_title">GitHub Repository</string>
|
||||||
|
<string name="about_github_subtitle">Source code, issues & documentation</string>
|
||||||
|
<string name="about_developer_title">Developer</string>
|
||||||
|
<string name="about_developer_subtitle">GitHub Profile: @inventory69</string>
|
||||||
|
<string name="about_license_title">License</string>
|
||||||
|
<string name="about_license_subtitle">MIT License - Open Source</string>
|
||||||
|
<string name="about_privacy_title">🔒 Privacy</string>
|
||||||
|
<string name="about_privacy_text">This app collects no data. All notes are stored only locally on your device and on your own WebDAV server. No telemetry, no ads.</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- TOAST MESSAGES -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="toast_connection_success">✅ Connection successful!</string>
|
||||||
|
<string name="toast_connection_failed">❌ %s</string>
|
||||||
|
<string name="toast_error">❌ Error: %s</string>
|
||||||
|
<string name="toast_syncing">🔄 Syncing…</string>
|
||||||
|
<string name="toast_already_synced">✅ Already synced</string>
|
||||||
|
<string name="toast_sync_success">✅ %d notes synced</string>
|
||||||
|
<string name="toast_sync_failed">❌ %s</string>
|
||||||
|
<string name="toast_auto_sync_enabled">✅ Auto-Sync enabled</string>
|
||||||
|
<string name="toast_auto_sync_disabled">Auto-Sync disabled</string>
|
||||||
|
<string name="toast_sync_interval">⏱️ Sync interval: %s</string>
|
||||||
|
<string name="toast_sync_interval_15min">15 minutes</string>
|
||||||
|
<string name="toast_sync_interval_30min">30 minutes</string>
|
||||||
|
<string name="toast_sync_interval_60min">60 minutes</string>
|
||||||
|
<string name="toast_configure_server_first">⚠️ Please configure WebDAV server first</string>
|
||||||
|
<string name="toast_markdown_exported">✅ %d notes exported to Markdown</string>
|
||||||
|
<string name="toast_markdown_enabled">📝 Markdown Auto-Sync enabled</string>
|
||||||
|
<string name="toast_markdown_disabled">📝 Markdown Auto-Sync disabled</string>
|
||||||
|
<string name="toast_markdown_syncing">📝 Markdown sync running…</string>
|
||||||
|
<string name="toast_markdown_result">✅ Export: %1$d • Import: %2$d</string>
|
||||||
|
<string name="toast_export_failed">❌ Export failed: %s</string>
|
||||||
|
<string name="toast_backup_success">✅ %s</string>
|
||||||
|
<string name="toast_backup_failed">❌ Backup failed: %s</string>
|
||||||
|
<string name="toast_restore_success">✅ %d notes restored</string>
|
||||||
|
<string name="toast_restore_failed">❌ Restore failed: %s</string>
|
||||||
|
<string name="toast_notifications_enabled">Notifications enabled</string>
|
||||||
|
<string name="toast_notifications_disabled">Notifications disabled. You can enable them in settings.</string>
|
||||||
|
<string name="toast_battery_optimization">Please disable battery optimization manually</string>
|
||||||
|
<string name="toast_logs_deleted">🗑️ Logs deleted</string>
|
||||||
|
<string name="toast_no_logs_to_delete">📭 No logs to delete</string>
|
||||||
|
<string name="toast_logs_delete_error">❌ Error deleting: %s</string>
|
||||||
|
<!-- 🔄 v1.7.0: Server change notification -->
|
||||||
|
<string name="toast_server_changed_sync_reset">🔄 Server changed. %d notes will be uploaded on next sync.</string>
|
||||||
|
<string name="toast_link_error">❌ Error opening link</string>
|
||||||
|
<string name="toast_file_logging_enabled">📝 File logging enabled</string>
|
||||||
|
<string name="toast_file_logging_disabled">📝 File logging disabled</string>
|
||||||
|
<string name="toast_sync_interval_changed">⏱️ Sync interval changed to %s</string>
|
||||||
|
<string name="version_not_available">Version not available</string>
|
||||||
|
<string name="status_checking">🔍 Checking…</string>
|
||||||
|
<string name="battery_optimization_dialog_message">Please select \'Not optimized\' for Simple Notes.</string>
|
||||||
|
<string name="battery_optimization_dialog_title">Background Synchronization</string>
|
||||||
|
<string name="battery_optimization_dialog_full_message">For the app to sync in the background, battery optimization must be disabled.\n\nPlease select \'Not optimized\' for Simple Notes.</string>
|
||||||
|
<string name="battery_optimization_open_settings">Open Settings</string>
|
||||||
|
<string name="battery_optimization_later">Later</string>
|
||||||
|
<string name="content_description_back">Back</string>
|
||||||
|
<string name="error_invalid_backup_file">Invalid backup file</string>
|
||||||
|
<string name="error_backup_version_unsupported">Backup version not supported (v%1$d requires v%2$d+)</string>
|
||||||
|
<string name="error_backup_empty">Backup contains no notes</string>
|
||||||
|
<string name="error_backup_invalid_notes">Backup contains %d invalid notes</string>
|
||||||
|
<string name="error_backup_corrupt">Backup file corrupt or invalid: %s</string>
|
||||||
|
<string name="error_restore_failed">Restore failed: %s</string>
|
||||||
|
<string name="restore_merge_result">%1$d new notes imported, %2$d skipped</string>
|
||||||
|
<string name="restore_overwrite_result">%1$d new, %2$d overwritten</string>
|
||||||
|
<string name="restore_replace_result">All notes replaced: %d imported</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- RELATIVE TIME -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="time_just_now">Just now</string>
|
||||||
|
<string name="time_minutes_ago">%d min ago</string>
|
||||||
|
<string name="time_hours_ago">%d hours ago</string>
|
||||||
|
<string name="time_days_ago">%d days ago</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- NOTIFICATIONS -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="notification_channel_name">Notes Synchronization</string>
|
||||||
|
<string name="notification_channel_desc">Notifications about sync status</string>
|
||||||
|
<string name="notification_sync_success_title">Sync successful</string>
|
||||||
|
<string name="notification_sync_success_message">%d note(s) synchronized</string>
|
||||||
|
<string name="notification_sync_failed_title">Sync failed</string>
|
||||||
|
<string name="notification_sync_progress_title">Syncing…</string>
|
||||||
|
<string name="notification_sync_progress_message">Notes are being synchronized</string>
|
||||||
|
<string name="notification_sync_conflict_title">Sync conflict detected</string>
|
||||||
|
<string name="notification_sync_conflict_message">%d note(s) have conflicts</string>
|
||||||
|
<string name="notification_sync_warning_title">⚠️ Sync Warning</string>
|
||||||
|
<string name="notification_sync_warning_message">Server unreachable for %dh</string>
|
||||||
|
<string name="notification_sync_warning_detail">The WebDAV server has been unreachable for %d hours. Please check your network connection or server settings.</string>
|
||||||
|
<string name="notification_sync_in_progress_title">Synchronization in progress</string>
|
||||||
|
<string name="notification_sync_in_progress_message">Notes are being synchronized…</string>
|
||||||
|
<string name="notification_sync_error_title">Sync Error</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- PLURALS -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<plurals name="notes_count">
|
||||||
|
<item quantity="one">%d note</item>
|
||||||
|
<item quantity="other">%d notes</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="notes_deleted_local">
|
||||||
|
<item quantity="one">%d note deleted locally</item>
|
||||||
|
<item quantity="other">%d notes deleted locally</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="notes_deleted_server">
|
||||||
|
<item quantity="one">%d note will be deleted from server</item>
|
||||||
|
<item quantity="other">%d notes will be deleted from server</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
|
<plurals name="notes_synced">
|
||||||
|
<item quantity="one">%d note synced</item>
|
||||||
|
<item quantity="other">%d notes synced</item>
|
||||||
|
</plurals>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<!-- Splash Screen Theme (Android 12+) -->
|
<!-- Splash Screen Theme (Android 12+) -->
|
||||||
<style name="Theme.SimpleNotes.Splash" parent="Theme.SplashScreen">
|
<style name="Theme.SimpleNotes.Splash" parent="Theme.SplashScreen">
|
||||||
<item name="windowSplashScreenBackground">?attr/colorPrimary</item>
|
<item name="windowSplashScreenBackground">?attr/colorPrimary</item>
|
||||||
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_icon</item>
|
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher_foreground</item>
|
||||||
<item name="windowSplashScreenAnimationDuration">500</item>
|
<item name="windowSplashScreenAnimationDuration">500</item>
|
||||||
<item name="postSplashScreenTheme">@style/Theme.SimpleNotes</item>
|
<item name="postSplashScreenTheme">@style/Theme.SimpleNotes</item>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
7
android/app/src/main/res/xml/locales_config.xml
Normal file
7
android/app/src/main/res/xml/locales_config.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Default/Fallback language -->
|
||||||
|
<locale android:name="en" />
|
||||||
|
<!-- Supported languages -->
|
||||||
|
<locale android:name="de" />
|
||||||
|
</locale-config>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user