Compare commits
19 Commits
v1.5.0
...
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 |
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
|
||||||
75
.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
|
||||||
@@ -127,25 +127,30 @@ jobs:
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 Downloads
|
## 📦 Downloads
|
||||||
|
|
||||||
| Variante | Datei | Info |
|
| Variant | File | Info |
|
||||||
|----------|-------|------|
|
|---------|------|------|
|
||||||
| **🏆 Empfohlen** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk` | Standard-Version (funktioniert auf allen Geraeten) |
|
| **🏆 Recommended** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk` | Standard version (works on all devices) |
|
||||||
| F-Droid | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk` | Fuer F-Droid Store |
|
| F-Droid | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk` | For F-Droid Store |
|
||||||
|
|
||||||
---
|
## 📊 Build Info
|
||||||
|
|
||||||
## 📊 Build-Info
|
|
||||||
|
|
||||||
- **Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.BUILD_NUMBER }})
|
- **Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.BUILD_NUMBER }})
|
||||||
- **Datum:** ${{ env.COMMIT_DATE }}
|
- **Date:** ${{ env.COMMIT_DATE }}
|
||||||
- **Commit:** ${{ env.SHORT_SHA }}
|
- **Commit:** ${{ env.SHORT_SHA }}
|
||||||
|
|
||||||
---
|
## 🔐 APK Signature Verification
|
||||||
|
|
||||||
**[📖 Dokumentation](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md)** · **[🐛 Issue melden](https://github.com/inventory69/simple-notes-sync/issues)**
|
All APKs are signed with the official release certificate.
|
||||||
|
|
||||||
|
**Recommended:** Verify with [AppVerifier](https://github.com/nicholson-lab/AppVerifier) (Android app)
|
||||||
|
|
||||||
|
**Expected SHA-256:**
|
||||||
|
```
|
||||||
|
42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
|
||||||
|
```
|
||||||
|
|
||||||
|
**[📖 Documentation](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md)** · **[🐛 Report Bug](https://github.com/inventory69/simple-notes-sync/issues)**
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
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
|
||||||
|
|||||||
6
.gitignore
vendored
@@ -43,3 +43,9 @@ Thumbs.db
|
|||||||
*.swp
|
*.swp
|
||||||
*~
|
*~
|
||||||
test-apks/
|
test-apks/
|
||||||
|
server-test/
|
||||||
|
|
||||||
|
# F-Droid metadata (managed in fdroiddata repo)
|
||||||
|
# Exclude fastlane metadata (we want to track those screenshots)
|
||||||
|
metadata/
|
||||||
|
!fastlane/metadata/
|
||||||
|
|||||||
124
CHANGELOG.de.md
@@ -8,6 +8,130 @@ Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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
|
## [1.5.0] - 2026-01-15
|
||||||
|
|
||||||
### 🎉 Major: Jetpack Compose UI Redesign
|
### 🎉 Major: Jetpack Compose UI Redesign
|
||||||
|
|||||||
123
CHANGELOG.md
@@ -8,6 +8,129 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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
|
## [1.5.0] - 2026-01-15
|
||||||
|
|
||||||
### 🎉 Major: Jetpack Compose UI Redesign
|
### 🎉 Major: Jetpack Compose UI Redesign
|
||||||
|
|||||||
109
README.de.md
@@ -1,48 +1,80 @@
|
|||||||
# 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">Minimalistische Offline-Notizen mit intelligentem Sync - Einfachheit trifft smarte Synchronisation.</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">
|
||||||
[<img src="https://f-droid.org/badge/get-it-on-de.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/dev.dettmer.simplenotes/)
|
|
||||||
|
|
||||||
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](docs/DOCS.de.md)** · **🚀 [Quick Start](QUICKSTART.de.md)**
|
[](https://www.android.com/)
|
||||||
|
[](https://kotlinlang.org/)
|
||||||
|
[](https://developer.android.com/compose/)
|
||||||
|
[](https://m3.material.io/)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
**🌍 Sprachen:** **Deutsch** · [English](README.md)
|
</div>
|
||||||
|
|
||||||
---
|
<div align="center">
|
||||||
|
|
||||||
|
<a href="https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes">
|
||||||
|
<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png"
|
||||||
|
alt="Get it on IzzyOnDroid" align="center" height="80" /></a>
|
||||||
|
|
||||||
|
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/inventory69/simple-notes-sync">
|
||||||
|
<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png"
|
||||||
|
alt="Get it on Obtainium" align="center" height="54" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="https://f-droid.org/packages/dev.dettmer.simplenotes">
|
||||||
|
<img src="https://f-droid.org/badge/get-it-on.png"
|
||||||
|
alt="Get it on F-Droid" align="center" height="80" /></a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<strong>SHA-256 Hash des Signaturzertifikats:</strong><br /> 42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<br />[📱 APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest) · [📖 Dokumentation](docs/DOCS.de.md) · [🚀 Schnellstart](QUICKSTART.de.md)<br />
|
||||||
|
**🌍** Deutsch · [English](README.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
## 📱 Screenshots
|
## 📱 Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.png" width="250" alt="Notizliste">
|
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.png" width="250" alt="Sync status">
|
||||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.png" width="250" alt="Notiz bearbeiten">
|
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.png" width="250" alt="Edit note">
|
||||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.png" width="250" alt="Checkliste bearbeiten">
|
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.png" width="250" alt="Edit checklist">
|
||||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/4.png" width="250" alt="Einstellungen">
|
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/4.png" width="250" alt="Settings">
|
||||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/5.png" width="250" alt="Server-Einstellungen">
|
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/5.png" width="250" alt="Server settings">
|
||||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/6.png" width="250" alt="Sync-Status">
|
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/7.png" width="250" alt="Sync settings">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
<div align="center">
|
||||||
|
|
||||||
|
📝 Offline-first • 🔄 Smart Sync • 🔒 Self-hosted • 🔋 Akkuschonend
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
## ✨ Highlights
|
## ✨ Highlights
|
||||||
|
|
||||||
- ✅ **NEU: Checklisten** - Tap-to-Check, Drag & Drop
|
- 📝 **Offline-first** – Funktioniert ohne Internet
|
||||||
- 🌍 **NEU: Mehrsprachig** - Deutsch/Englisch mit Sprachauswahl
|
- 📊 **Flexible Ansichten** – Listen- und Grid-Layout
|
||||||
- 📝 **Offline-First** - Funktioniert ohne Internet
|
- ✅ **Checklisten** – Tap-to-Check, Drag & Drop
|
||||||
- 🔄 **Auto-Sync** - WLAN-Verbindung, regelmäßige Intervalle (15/30/60 Min) & Multi-Geräte-Sync
|
- 🌍 **Mehrsprachig** – Deutsch/Englisch mit Sprachauswahl
|
||||||
- 🔒 **Self-Hosted** - Deine Daten bleiben bei dir (WebDAV)
|
- 🔄 **Konfigurierbare Sync-Trigger** – onSave, onResume, WiFi, periodisch (15/30/60 Min), Boot
|
||||||
- 💾 **Lokales Backup** - Export/Import als JSON-Datei
|
- 🔒 **Self-hosted** – Deine Daten bleiben bei dir (WebDAV)
|
||||||
- 🖥️ **Desktop-Integration** - Markdown-Export für Obsidian, VS Code, Typora
|
- 💾 **Lokales Backup** – Export/Import als JSON-Datei (optional verschlüsselt)
|
||||||
- 🔋 **Akkuschonend** - ~0.2-0.8% pro Tag
|
- 🖥️ **Desktop-Integration** – Markdown-Export für Obsidian, VS Code, Typora
|
||||||
- 🎨 **Material Design 3** - Dark Mode & Dynamic Colors
|
- 🔋 **Akkuschonend** – ~0.2% mit Defaults, bis zu ~1.0% mit Periodic Sync
|
||||||
|
- 🎨 **Material Design 3** – Dynamischer Dark/Light Mode & Farben
|
||||||
|
|
||||||
➡️ **Vollständige Feature-Liste:** [FEATURES.de.md](docs/FEATURES.de.md)
|
➡️ **Vollständige Feature-Liste:** [docs/FEATURES.de.md](docs/FEATURES.de.md)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Schnellstart
|
## 🚀 Schnellstart
|
||||||
|
|
||||||
@@ -72,8 +104,6 @@ docker compose up -d
|
|||||||
|
|
||||||
➡️ **Ausführliche Anleitung:** [QUICKSTART.de.md](QUICKSTART.de.md)
|
➡️ **Ausführliche Anleitung:** [QUICKSTART.de.md](QUICKSTART.de.md)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Dokumentation
|
## 📚 Dokumentation
|
||||||
|
|
||||||
| Dokument | Inhalt |
|
| Dokument | Inhalt |
|
||||||
@@ -82,13 +112,12 @@ docker compose up -d
|
|||||||
| **[FEATURES.de.md](docs/FEATURES.de.md)** | Vollständige Feature-Liste |
|
| **[FEATURES.de.md](docs/FEATURES.de.md)** | Vollständige Feature-Liste |
|
||||||
| **[BACKUP.de.md](docs/BACKUP.de.md)** | Backup & Wiederherstellung |
|
| **[BACKUP.de.md](docs/BACKUP.de.md)** | Backup & Wiederherstellung |
|
||||||
| **[DESKTOP.de.md](docs/DESKTOP.de.md)** | Desktop-Integration (Markdown) |
|
| **[DESKTOP.de.md](docs/DESKTOP.de.md)** | Desktop-Integration (Markdown) |
|
||||||
|
| **[SELF_SIGNED_SSL.md](docs/SELF_SIGNED_SSL.md)** | Self-signed SSL Zertifikat Setup |
|
||||||
| **[DOCS.de.md](docs/DOCS.de.md)** | Technische Details & Troubleshooting |
|
| **[DOCS.de.md](docs/DOCS.de.md)** | Technische Details & Troubleshooting |
|
||||||
| **[CHANGELOG.de.md](CHANGELOG.de.md)** | Versionshistorie |
|
| **[CHANGELOG.de.md](CHANGELOG.de.md)** | Versionshistorie |
|
||||||
| **[UPCOMING.de.md](docs/UPCOMING.de.md)** | Geplante Features 🚀 |
|
| **[UPCOMING.de.md](docs/UPCOMING.de.md)** | Geplante Features 🚀 |
|
||||||
| **[ÜBERSETZEN.md](docs/TRANSLATING.de.md)** | Übersetzungsanleitung 🌍 |
|
| **[ÜBERSETZEN.md](docs/TRANSLATING.de.md)** | Übersetzungsanleitung 🌍 |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Entwicklung
|
## 🛠️ Entwicklung
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -96,20 +125,18 @@ cd android
|
|||||||
./gradlew assembleStandardRelease
|
./gradlew assembleStandardRelease
|
||||||
```
|
```
|
||||||
|
|
||||||
➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md#-build--deployment)
|
➡️ **Build-Anleitung:** [docs/DOCS.de.md#-build--deployment](docs/DOCS.de.md#-build--deployment)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
Beiträge willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md)
|
Beiträge willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📄 Lizenz
|
## 📄 Lizenz
|
||||||
|
|
||||||
MIT License - siehe [LICENSE](LICENSE)
|
MIT License – siehe [LICENSE](LICENSE)
|
||||||
|
|
||||||
---
|
<div align="center">
|
||||||
|
<br /><br />
|
||||||
|
**v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
||||||
|
|
||||||
**v1.4.1** · Built with ❤️ using Kotlin + Material Design 3
|
</div>
|
||||||
|
|||||||
87
README.md
@@ -1,49 +1,81 @@
|
|||||||
# Simple Notes Sync 📝
|
<div align="center">
|
||||||
|
<img src="android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png" alt="Logo" />
|
||||||
|
</div>
|
||||||
|
|
||||||
> Minimalist offline notes with auto-sync to your own server
|
<h1 align="center">Simple Notes Sync</h1>
|
||||||
|
|
||||||
[](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">
|
||||||
[<img src="https://f-droid.org/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/dev.dettmer.simplenotes/)
|
|
||||||
|
|
||||||
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Documentation](docs/DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)**
|
[](https://www.android.com/)
|
||||||
|
[](https://kotlinlang.org/)
|
||||||
|
[](https://developer.android.com/compose/)
|
||||||
|
[](https://m3.material.io/)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
**🌍 Languages:** [Deutsch](README.de.md) · **English**
|
</div>
|
||||||
|
|
||||||
---
|
<div align="center">
|
||||||
|
|
||||||
|
<a href="https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes">
|
||||||
|
<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png"
|
||||||
|
alt="Get it on IzzyOnDroid" align="center" height="80" /></a>
|
||||||
|
|
||||||
|
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/inventory69/simple-notes-sync">
|
||||||
|
<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png"
|
||||||
|
alt="Get it on Obtainium" align="center" height="54" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="https://f-droid.org/packages/dev.dettmer.simplenotes">
|
||||||
|
<img src="https://f-droid.org/badge/get-it-on.png"
|
||||||
|
alt="Get it on F-Droid" align="center" height="80" /></a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<strong>SHA-256 hash of the signing certificate:</strong><br />42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<br />[📱 APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest) · [📖 Documentation](docs/DOCS.md) · [🚀 Quick Start](QUICKSTART.md)<br />
|
||||||
|
**🌍** [Deutsch](README.de.md) · **English**
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
## 📱 Screenshots
|
## 📱 Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="250" alt="Notes list">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="250" alt="Sync status">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="250" alt="Edit note">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="250" alt="Edit note">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="250" alt="Edit checklist">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="250" alt="Edit checklist">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="250" alt="Settings">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="250" alt="Settings">
|
||||||
<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/5.png" width="250" alt="Server settings">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/6.png" width="250" alt="Sync status">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/7.png" width="250" alt="Sync settings">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
<div align="center">
|
||||||
|
|
||||||
|
📝 Offline-first • 🔄 Smart Sync • 🔒 Self-hosted • 🔋 Battery-friendly
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
## ✨ Highlights
|
## ✨ Highlights
|
||||||
|
|
||||||
- ✅ **NEW: Checklists** - Tap-to-check, drag & drop
|
|
||||||
- 🌍 **NEW: Multilingual** - English/German with language selector
|
|
||||||
- 📝 **Offline-first** - Works without internet
|
- 📝 **Offline-first** - Works without internet
|
||||||
- 🔄 **Auto-sync** - WiFi reconnect, periodic intervals (15/30/60 min) & multi-device sync
|
- 📊 **Flexible views** - Switch between list and grid layout
|
||||||
|
- ✅ **Checklists** - Tap-to-check, drag & drop
|
||||||
|
- 🌍 **Multilingual** - English/German with language selector
|
||||||
|
- 🔄 **Configurable sync triggers** - onSave, onResume, WiFi-connect, periodic (15/30/60 min), boot
|
||||||
- 🔒 **Self-hosted** - Your data stays with you (WebDAV)
|
- 🔒 **Self-hosted** - Your data stays with you (WebDAV)
|
||||||
- 💾 **Local backup** - Export/Import as JSON file
|
- 💾 **Local backup** - Export/Import as JSON file (encryption available)
|
||||||
- 🖥️ **Desktop integration** - Markdown export for Obsidian, VS Code, Typora
|
- 🖥️ **Desktop integration** - Markdown export for Obsidian, VS Code, Typora
|
||||||
- 🔋 **Battery-friendly** - ~0.2-0.8% per day
|
- 🔋 **Battery-friendly** - ~0.2% with defaults, up to ~1.0% with periodic sync
|
||||||
- 🎨 **Material Design 3** - Dark mode & dynamic colors
|
- 🎨 **Material Design 3** - Dynamic dark/light mode & colors based on system settings
|
||||||
|
|
||||||
➡️ **Complete feature list:** [FEATURES.md](docs/FEATURES.md)
|
➡️ **Complete feature list:** [FEATURES.md](docs/FEATURES.md)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### 1. Server Setup (5 minutes)
|
### 1. Server Setup (5 minutes)
|
||||||
@@ -72,8 +104,6 @@ docker compose up -d
|
|||||||
|
|
||||||
➡️ **Detailed guide:** [QUICKSTART.md](QUICKSTART.md)
|
➡️ **Detailed guide:** [QUICKSTART.md](QUICKSTART.md)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Documentation
|
## 📚 Documentation
|
||||||
|
|
||||||
| Document | Content |
|
| Document | Content |
|
||||||
@@ -82,6 +112,7 @@ docker compose up -d
|
|||||||
| **[FEATURES.md](docs/FEATURES.md)** | Complete feature list |
|
| **[FEATURES.md](docs/FEATURES.md)** | Complete feature list |
|
||||||
| **[BACKUP.md](docs/BACKUP.md)** | Backup & restore guide |
|
| **[BACKUP.md](docs/BACKUP.md)** | Backup & restore guide |
|
||||||
| **[DESKTOP.md](docs/DESKTOP.md)** | Desktop integration (Markdown) |
|
| **[DESKTOP.md](docs/DESKTOP.md)** | Desktop integration (Markdown) |
|
||||||
|
| **[SELF_SIGNED_SSL.md](docs/SELF_SIGNED_SSL.md)** | Self-signed SSL certificate setup |
|
||||||
| **[DOCS.md](docs/DOCS.md)** | Technical details & troubleshooting |
|
| **[DOCS.md](docs/DOCS.md)** | Technical details & troubleshooting |
|
||||||
| **[CHANGELOG.md](CHANGELOG.md)** | Version history |
|
| **[CHANGELOG.md](CHANGELOG.md)** | Version history |
|
||||||
| **[UPCOMING.md](docs/UPCOMING.md)** | Upcoming features 🚀 |
|
| **[UPCOMING.md](docs/UPCOMING.md)** | Upcoming features 🚀 |
|
||||||
@@ -94,18 +125,16 @@ cd android
|
|||||||
|
|
||||||
➡️ **Build guide:** [DOCS.md](docs/DOCS.md#-build--deployment)
|
➡️ **Build guide:** [DOCS.md](docs/DOCS.md#-build--deployment)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)
|
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
MIT License - see [LICENSE](LICENSE)
|
MIT License - see [LICENSE](LICENSE)
|
||||||
|
|
||||||
---
|
<div align="center">
|
||||||
|
<br /><br />
|
||||||
|
**v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
||||||
|
|
||||||
**v1.5.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ plugins {
|
|||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler
|
alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler
|
||||||
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen, aktivieren in v1.4.0
|
alias(libs.plugins.ktlint) // ✅ v1.6.1: Reaktiviert nach Code-Cleanup
|
||||||
// alias(libs.plugins.ktlint)
|
|
||||||
alias(libs.plugins.detekt)
|
alias(libs.plugins.detekt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,8 +20,8 @@ android {
|
|||||||
applicationId = "dev.dettmer.simplenotes"
|
applicationId = "dev.dettmer.simplenotes"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 13 // 🔧 v1.5.0: Jetpack Compose Settings Redesign
|
versionCode = 17 // 🎨 v1.7.0: Grid Layout + Backup Encryption
|
||||||
versionName = "1.5.0" // 🔧 v1.5.0: Jetpack Compose Settings Redesign
|
versionName = "1.7.0" // 🎨 v1.7.0: Grid Layout + Backup Encryption
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -100,11 +99,15 @@ android {
|
|||||||
compose = true // v1.5.0: Jetpack Compose für Settings Redesign
|
compose = true // v1.5.0: Jetpack Compose für Settings Redesign
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.5.0 Hotfix: Strong Skipping Mode für bessere 120Hz Performance
|
// v1.7.0: Mock Android framework classes in unit tests (Log, etc.)
|
||||||
composeCompiler {
|
testOptions {
|
||||||
enableStrongSkippingMode = true
|
unitTests.isReturnDefaultValues = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.5.0 Hotfix: Strong Skipping Mode für bessere 120Hz Performance
|
||||||
|
// v1.6.1: Feature ist ab dieser Kotlin/Compose Version bereits Standard
|
||||||
|
// composeCompiler { }
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
@@ -142,6 +145,9 @@ dependencies {
|
|||||||
// SwipeRefreshLayout für Pull-to-Refresh
|
// SwipeRefreshLayout für Pull-to-Refresh
|
||||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||||
|
|
||||||
|
// 🔐 v1.7.0: AndroidX Security Crypto für Backup-Verschlüsselung
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// v1.5.0: Jetpack Compose für Settings Redesign
|
// v1.5.0: Jetpack Compose für Settings Redesign
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -162,18 +168,21 @@ dependencies {
|
|||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen
|
// ✅ v1.6.1: ktlint reaktiviert nach Code-Cleanup
|
||||||
// Aktivieren in v1.4.0 wenn Code-Stil bereinigt wurde
|
ktlint {
|
||||||
// ktlint {
|
android = true
|
||||||
// android = true
|
outputToConsole = true
|
||||||
// outputToConsole = true
|
ignoreFailures = true // Parser-Probleme in WebDavSyncService.kt und build.gradle.kts
|
||||||
// ignoreFailures = true
|
enableExperimentalRules = false
|
||||||
// enableExperimentalRules = false
|
|
||||||
// filter {
|
filter {
|
||||||
// exclude("**/generated/**")
|
exclude("**/generated/**")
|
||||||
// exclude("**/build/**")
|
exclude("**/build/**")
|
||||||
// }
|
// Legacy adapters with ktlint parser issues
|
||||||
// }
|
exclude("**/adapters/NotesAdapter.kt")
|
||||||
|
exclude("**/SettingsActivity.kt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ⚡ v1.3.1: detekt-Konfiguration
|
// ⚡ v1.3.1: detekt-Konfiguration
|
||||||
detekt {
|
detekt {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -48,6 +50,11 @@ import android.view.Gravity
|
|||||||
import android.widget.PopupMenu
|
import android.widget.PopupMenu
|
||||||
import dev.dettmer.simplenotes.models.NoteType
|
import dev.dettmer.simplenotes.models.NoteType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy MainActivity - DEPRECATED seit v1.5.0, wird entfernt in v2.0.0
|
||||||
|
* Ersetzt durch ComposeMainActivity
|
||||||
|
*/
|
||||||
|
@Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private lateinit var recyclerViewNotes: RecyclerView
|
private lateinit var recyclerViewNotes: RecyclerView
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,20 +31,24 @@ class BackupManager(private val context: Context) {
|
|||||||
private const val BACKUP_VERSION = 1
|
private const val BACKUP_VERSION = 1
|
||||||
private const val AUTO_BACKUP_DIR = "auto_backups"
|
private const val AUTO_BACKUP_DIR = "auto_backups"
|
||||||
private const val AUTO_BACKUP_RETENTION_DAYS = 7
|
private const val AUTO_BACKUP_RETENTION_DAYS = 7
|
||||||
|
private const val MAGIC_BYTES_LENGTH = 4 // v1.7.0: For encryption check
|
||||||
}
|
}
|
||||||
|
|
||||||
private val storage = NotesStorage(context)
|
private val storage = NotesStorage(context)
|
||||||
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
||||||
|
private val encryptionManager = EncryptionManager() // 🔐 v1.7.0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt Backup aller Notizen
|
* Erstellt Backup aller Notizen
|
||||||
*
|
*
|
||||||
* @param uri Output-URI (via Storage Access Framework)
|
* @param uri Output-URI (via Storage Access Framework)
|
||||||
|
* @param password Optional password for encryption (null = unencrypted)
|
||||||
* @return BackupResult mit Erfolg/Fehler Info
|
* @return BackupResult mit Erfolg/Fehler Info
|
||||||
*/
|
*/
|
||||||
suspend fun createBackup(uri: Uri): BackupResult = withContext(Dispatchers.IO) {
|
suspend fun createBackup(uri: Uri, password: String? = null): BackupResult = withContext(Dispatchers.IO) {
|
||||||
return@withContext try {
|
return@withContext try {
|
||||||
Logger.d(TAG, "📦 Creating backup to: $uri")
|
val encryptedSuffix = if (password != null) " (encrypted)" else ""
|
||||||
|
Logger.d(TAG, "📦 Creating backup$encryptedSuffix to: $uri")
|
||||||
|
|
||||||
val allNotes = storage.loadAllNotes()
|
val allNotes = storage.loadAllNotes()
|
||||||
Logger.d(TAG, " Found ${allNotes.size} notes to backup")
|
Logger.d(TAG, " Found ${allNotes.size} notes to backup")
|
||||||
@@ -59,15 +63,22 @@ class BackupManager(private val context: Context) {
|
|||||||
|
|
||||||
val jsonString = gson.toJson(backupData)
|
val jsonString = gson.toJson(backupData)
|
||||||
|
|
||||||
|
// 🔐 v1.7.0: Encrypt if password is provided
|
||||||
|
val dataToWrite = if (password != null) {
|
||||||
|
encryptionManager.encrypt(jsonString.toByteArray(), password)
|
||||||
|
} else {
|
||||||
|
jsonString.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
outputStream.write(jsonString.toByteArray())
|
outputStream.write(dataToWrite)
|
||||||
Logger.d(TAG, "✅ Backup created successfully")
|
Logger.d(TAG, "✅ Backup created successfully$encryptedSuffix")
|
||||||
}
|
}
|
||||||
|
|
||||||
BackupResult(
|
BackupResult(
|
||||||
success = true,
|
success = true,
|
||||||
notesCount = allNotes.size,
|
notesCount = allNotes.size,
|
||||||
message = "Backup erstellt: ${allNotes.size} Notizen"
|
message = "Backup erstellt: ${allNotes.size} Notizen$encryptedSuffix"
|
||||||
)
|
)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -126,20 +137,42 @@ class BackupManager(private val context: Context) {
|
|||||||
*
|
*
|
||||||
* @param uri Backup-Datei URI
|
* @param uri Backup-Datei URI
|
||||||
* @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite)
|
* @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite)
|
||||||
|
* @param password Optional password if backup is encrypted
|
||||||
* @return RestoreResult mit Details
|
* @return RestoreResult mit Details
|
||||||
*/
|
*/
|
||||||
suspend fun restoreBackup(uri: Uri, mode: RestoreMode): RestoreResult = withContext(Dispatchers.IO) {
|
suspend fun restoreBackup(uri: Uri, mode: RestoreMode, password: String? = null): RestoreResult = withContext(Dispatchers.IO) {
|
||||||
return@withContext try {
|
return@withContext try {
|
||||||
Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)")
|
Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)")
|
||||||
|
|
||||||
// 1. Backup-Datei lesen
|
// 1. Backup-Datei lesen
|
||||||
val jsonString = context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
val fileData = context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||||
inputStream.bufferedReader().use { it.readText() }
|
inputStream.readBytes()
|
||||||
} ?: return@withContext RestoreResult(
|
} ?: return@withContext RestoreResult(
|
||||||
success = false,
|
success = false,
|
||||||
error = "Datei konnte nicht gelesen werden"
|
error = "Datei konnte nicht gelesen werden"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 🔐 v1.7.0: Check if encrypted and decrypt if needed
|
||||||
|
val jsonString = try {
|
||||||
|
if (encryptionManager.isEncrypted(fileData)) {
|
||||||
|
if (password == null) {
|
||||||
|
return@withContext RestoreResult(
|
||||||
|
success = false,
|
||||||
|
error = "Backup ist verschlüsselt. Bitte Passwort eingeben."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val decrypted = encryptionManager.decrypt(fileData, password)
|
||||||
|
String(decrypted)
|
||||||
|
} else {
|
||||||
|
String(fileData)
|
||||||
|
}
|
||||||
|
} catch (e: EncryptionException) {
|
||||||
|
return@withContext RestoreResult(
|
||||||
|
success = false,
|
||||||
|
error = "Entschlüsselung fehlgeschlagen: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Backup validieren & parsen
|
// 2. Backup validieren & parsen
|
||||||
val validationResult = validateBackup(jsonString)
|
val validationResult = validateBackup(jsonString)
|
||||||
if (!validationResult.isValid) {
|
if (!validationResult.isValid) {
|
||||||
@@ -177,6 +210,22 @@ class BackupManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔐 v1.7.0: Check if backup file is encrypted
|
||||||
|
*/
|
||||||
|
suspend fun isBackupEncrypted(uri: Uri): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
return@withContext try {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||||
|
val header = ByteArray(MAGIC_BYTES_LENGTH)
|
||||||
|
val bytesRead = inputStream.read(header)
|
||||||
|
bytesRead == MAGIC_BYTES_LENGTH && encryptionManager.isEncrypted(header)
|
||||||
|
} ?: false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to check encryption status", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validiert Backup-Datei
|
* Validiert Backup-Datei
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -323,6 +323,34 @@ type: ${noteType.name.lowercase()}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🎨 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
|
||||||
|
|||||||
@@ -124,6 +124,26 @@ class NotesStorage(private val context: Context) {
|
|||||||
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) {
|
||||||
// 1. WorkManager für periodic sync
|
Logger.d(TAG, "📶 Starting WiFi monitoring...")
|
||||||
startPeriodicSync()
|
|
||||||
|
|
||||||
// 2. NetworkCallback für WiFi-Connect Detection
|
|
||||||
startWifiMonitoring()
|
startWifiMonitoring()
|
||||||
|
} else {
|
||||||
|
stopWifiMonitoring()
|
||||||
|
Logger.d(TAG, "⏭️ WiFi-Connect trigger disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Logging für Debug
|
||||||
|
if (!autoSyncEnabled && !wifiConnectEnabled) {
|
||||||
|
Logger.d(TAG, "🛑 No background triggers active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.7.0: Stoppt nur WiFi-Monitoring, nicht den gesamten NetworkMonitor
|
||||||
|
*/
|
||||||
|
@Suppress("SwallowedException")
|
||||||
|
private fun stopWifiMonitoring() {
|
||||||
|
try {
|
||||||
|
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||||||
|
Logger.d(TAG, "🛑 WiFi NetworkCallback unregistered")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Already unregistered - das ist OK
|
||||||
|
Logger.d(TAG, " WiFi callback already unregistered")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Startet WorkManager periodic sync
|
* 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,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@file:Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional, will migrate in v2.0.0
|
||||||
|
|
||||||
package dev.dettmer.simplenotes.sync
|
package dev.dettmer.simplenotes.sync
|
||||||
|
|
||||||
import android.app.ActivityManager
|
import android.app.ActivityManager
|
||||||
@@ -7,6 +9,7 @@ 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
|
||||||
@@ -86,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)")
|
||||||
}
|
}
|
||||||
@@ -255,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)
|
||||||
|
|||||||
@@ -35,11 +35,13 @@ 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
|
private const val CONTENT_PREVIEW_LENGTH = 50
|
||||||
@@ -128,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
|
||||||
@@ -136,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()) {
|
||||||
@@ -553,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(
|
||||||
@@ -780,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()
|
||||||
@@ -1022,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,
|
||||||
@@ -1541,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...")
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@file:Suppress("DEPRECATION") // AbstractSavedStateViewModelFactory deprecated, will migrate to viewModelFactory in v2.0.0
|
||||||
|
|
||||||
package dev.dettmer.simplenotes.ui.editor
|
package dev.dettmer.simplenotes.ui.editor
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
|||||||
@@ -76,10 +76,18 @@ fun NoteEditorScreen(
|
|||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val checklistItems by viewModel.checklistItems.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 showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
var focusNewItemId by remember { mutableStateOf<String?>(null) }
|
var focusNewItemId by remember { mutableStateOf<String?>(null) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Strings for toast messages (avoid LocalContextGetResourceValueCall lint)
|
||||||
|
val msgNoteIsEmpty = stringResource(R.string.note_is_empty)
|
||||||
|
val msgNoteSaved = stringResource(R.string.note_saved)
|
||||||
|
val msgNoteDeleted = stringResource(R.string.note_deleted)
|
||||||
|
|
||||||
// v1.5.0: Auto-keyboard support
|
// v1.5.0: Auto-keyboard support
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
val titleFocusRequester = remember { FocusRequester() }
|
val titleFocusRequester = remember { FocusRequester() }
|
||||||
@@ -108,9 +116,9 @@ fun NoteEditorScreen(
|
|||||||
when (event) {
|
when (event) {
|
||||||
is NoteEditorEvent.ShowToast -> {
|
is NoteEditorEvent.ShowToast -> {
|
||||||
val message = when (event.message) {
|
val message = when (event.message) {
|
||||||
ToastMessage.NOTE_IS_EMPTY -> context.getString(R.string.note_is_empty)
|
ToastMessage.NOTE_IS_EMPTY -> msgNoteIsEmpty
|
||||||
ToastMessage.NOTE_SAVED -> context.getString(R.string.note_saved)
|
ToastMessage.NOTE_SAVED -> msgNoteSaved
|
||||||
ToastMessage.NOTE_DELETED -> context.getString(R.string.note_deleted)
|
ToastMessage.NOTE_DELETED -> msgNoteDeleted
|
||||||
}
|
}
|
||||||
context.showToast(message)
|
context.showToast(message)
|
||||||
}
|
}
|
||||||
@@ -233,6 +241,7 @@ fun NoteEditorScreen(
|
|||||||
if (showDeleteDialog) {
|
if (showDeleteDialog) {
|
||||||
DeleteConfirmationDialog(
|
DeleteConfirmationDialog(
|
||||||
noteCount = 1,
|
noteCount = 1,
|
||||||
|
isOfflineMode = isOfflineMode,
|
||||||
onDismiss = { showDeleteDialog = false },
|
onDismiss = { showDeleteDialog = false },
|
||||||
onDeleteLocal = {
|
onDeleteLocal = {
|
||||||
showDeleteDialog = false
|
showDeleteDialog = false
|
||||||
@@ -287,6 +296,7 @@ private fun TextNoteContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList") // Compose functions commonly have many callback parameters
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChecklistEditor(
|
private fun ChecklistEditor(
|
||||||
items: List<ChecklistItemState>,
|
items: List<ChecklistItemState>,
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
package dev.dettmer.simplenotes.ui.editor
|
package dev.dettmer.simplenotes.ui.editor
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
import dev.dettmer.simplenotes.models.ChecklistItem
|
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.NoteType
|
||||||
import dev.dettmer.simplenotes.models.SyncStatus
|
import dev.dettmer.simplenotes.models.SyncStatus
|
||||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
|
import dev.dettmer.simplenotes.sync.SyncWorker
|
||||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||||
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -42,6 +47,7 @@ class NoteEditorViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val storage = NotesStorage(application)
|
private val storage = NotesStorage(application)
|
||||||
|
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// State
|
// State
|
||||||
@@ -53,6 +59,12 @@ class NoteEditorViewModel(
|
|||||||
private val _checklistItems = MutableStateFlow<List<ChecklistItemState>>(emptyList())
|
private val _checklistItems = MutableStateFlow<List<ChecklistItemState>>(emptyList())
|
||||||
val checklistItems: StateFlow<List<ChecklistItemState>> = _checklistItems.asStateFlow()
|
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
|
// Events
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -108,7 +120,7 @@ class NoteEditorViewModel(
|
|||||||
currentNoteType = try {
|
currentNoteType = try {
|
||||||
NoteType.valueOf(noteTypeString)
|
NoteType.valueOf(noteTypeString)
|
||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT")
|
Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT: ${e.message}")
|
||||||
NoteType.TEXT
|
NoteType.TEXT
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +296,10 @@ class NoteEditorViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_SAVED))
|
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_SAVED))
|
||||||
|
|
||||||
|
// 🌟 v1.6.0: Trigger onSave Sync
|
||||||
|
triggerOnSaveSync()
|
||||||
|
|
||||||
_events.emit(NoteEditorEvent.NavigateBack)
|
_events.emit(NoteEditorEvent.NavigateBack)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,6 +347,58 @@ class NoteEditorViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun canDelete(): Boolean = existingNote != null
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ fun ChecklistItemRow(
|
|||||||
val alpha = if (item.isChecked) 0.6f else 1.0f
|
val alpha = if (item.isChecked) 0.6f else 1.0f
|
||||||
val textDecoration = if (item.isChecked) TextDecoration.LineThrough else TextDecoration.None
|
val textDecoration = if (item.isChecked) TextDecoration.LineThrough else TextDecoration.None
|
||||||
|
|
||||||
|
@Suppress("MagicNumber") // UI padding values are self-explanatory
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@file:Suppress("DEPRECATION") // LocalBroadcastManager & deprecated lifecycle methods, will migrate in v2.0.0
|
||||||
|
|
||||||
package dev.dettmer.simplenotes.ui.main
|
package dev.dettmer.simplenotes.ui.main
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
@@ -177,7 +179,15 @@ class ComposeMainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
Logger.d(TAG, "📱 ComposeMainActivity.onResume() - Registering receivers")
|
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
|
// Register BroadcastReceiver for Background-Sync
|
||||||
|
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional
|
||||||
LocalBroadcastManager.getInstance(this).registerReceiver(
|
LocalBroadcastManager.getInstance(this).registerReceiver(
|
||||||
syncCompletedReceiver,
|
syncCompletedReceiver,
|
||||||
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
|
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
|
||||||
@@ -203,6 +213,7 @@ class ComposeMainActivity : ComponentActivity() {
|
|||||||
super.onPause()
|
super.onPause()
|
||||||
|
|
||||||
// Unregister BroadcastReceiver
|
// Unregister BroadcastReceiver
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
|
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
|
||||||
Logger.d(TAG, "📡 BroadcastReceiver unregistered")
|
Logger.d(TAG, "📡 BroadcastReceiver unregistered")
|
||||||
}
|
}
|
||||||
@@ -211,6 +222,7 @@ class ComposeMainActivity : ComponentActivity() {
|
|||||||
SyncStateManager.syncStatus.observe(this) { status ->
|
SyncStateManager.syncStatus.observe(this) { status ->
|
||||||
viewModel.updateSyncState(status)
|
viewModel.updateSyncState(status)
|
||||||
|
|
||||||
|
@Suppress("MagicNumber") // UI timing delays for banner visibility
|
||||||
// Hide banner after delay for completed/error states
|
// Hide banner after delay for completed/error states
|
||||||
when (status.state) {
|
when (status.state) {
|
||||||
SyncStateManager.SyncState.COMPLETED -> {
|
SyncStateManager.SyncState.COMPLETED -> {
|
||||||
@@ -330,6 +342,8 @@ class ComposeMainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in API 23", ReplaceWith("Use ActivityResultContracts"))
|
||||||
|
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
|
||||||
override fun onRequestPermissionsResult(
|
override fun onRequestPermissionsResult(
|
||||||
requestCode: Int,
|
requestCode: Int,
|
||||||
permissions: Array<out String>,
|
permissions: Array<out String>,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
@@ -50,6 +51,7 @@ import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog
|
|||||||
import dev.dettmer.simplenotes.ui.main.components.EmptyState
|
import dev.dettmer.simplenotes.ui.main.components.EmptyState
|
||||||
import dev.dettmer.simplenotes.ui.main.components.NoteTypeFAB
|
import dev.dettmer.simplenotes.ui.main.components.NoteTypeFAB
|
||||||
import dev.dettmer.simplenotes.ui.main.components.NotesList
|
import dev.dettmer.simplenotes.ui.main.components.NotesList
|
||||||
|
import dev.dettmer.simplenotes.ui.main.components.NotesStaggeredGrid
|
||||||
import dev.dettmer.simplenotes.ui.main.components.SyncStatusBanner
|
import dev.dettmer.simplenotes.ui.main.components.SyncStatusBanner
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -79,16 +81,31 @@ fun MainScreen(
|
|||||||
val selectedNotes by viewModel.selectedNotes.collectAsState()
|
val selectedNotes by viewModel.selectedNotes.collectAsState()
|
||||||
val isSelectionMode by viewModel.isSelectionMode.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
|
// Delete confirmation dialog state
|
||||||
var showBatchDeleteDialog by remember { mutableStateOf(false) }
|
var showBatchDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
// 🎨 v1.7.0: gridState für Staggered Grid Layout
|
||||||
|
val gridState = rememberLazyStaggeredGridState()
|
||||||
|
|
||||||
// Compute isSyncing once
|
// Compute isSyncing once
|
||||||
val isSyncing = syncState == SyncStateManager.SyncState.SYNCING
|
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
|
// Handle snackbar events from ViewModel
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.showSnackbar.collect { data ->
|
viewModel.showSnackbar.collect { data ->
|
||||||
@@ -106,9 +123,14 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: Scroll to top when new note created
|
// Phase 3: Scroll to top when new note created
|
||||||
|
// 🎨 v1.7.0: Unterstützt beide Display-Modi (list & grid)
|
||||||
LaunchedEffect(scrollToTop) {
|
LaunchedEffect(scrollToTop) {
|
||||||
if (scrollToTop) {
|
if (scrollToTop) {
|
||||||
|
if (displayMode == "grid") {
|
||||||
|
gridState.animateScrollToItem(0)
|
||||||
|
} else {
|
||||||
listState.animateScrollToItem(0)
|
listState.animateScrollToItem(0)
|
||||||
|
}
|
||||||
viewModel.resetScrollToTop()
|
viewModel.resetScrollToTop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,7 +158,7 @@ fun MainScreen(
|
|||||||
exit = slideOutVertically() + fadeOut()
|
exit = slideOutVertically() + fadeOut()
|
||||||
) {
|
) {
|
||||||
MainTopBar(
|
MainTopBar(
|
||||||
syncEnabled = !isSyncing,
|
syncEnabled = canSync,
|
||||||
onSyncClick = { viewModel.triggerManualSync("toolbar") },
|
onSyncClick = { viewModel.triggerManualSync("toolbar") },
|
||||||
onSettingsClick = onOpenSettings
|
onSettingsClick = onOpenSettings
|
||||||
)
|
)
|
||||||
@@ -146,10 +168,10 @@ fun MainScreen(
|
|||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
// PullToRefreshBox wraps the content with pull-to-refresh capability
|
// 🌟 v1.6.0: PullToRefreshBox only enabled when sync available
|
||||||
PullToRefreshBox(
|
PullToRefreshBox(
|
||||||
isRefreshing = isSyncing,
|
isRefreshing = isSyncing,
|
||||||
onRefresh = { viewModel.triggerManualSync("pullToRefresh") },
|
onRefresh = { if (isSyncAvailable) viewModel.triggerManualSync("pullToRefresh") },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
@@ -166,6 +188,27 @@ fun MainScreen(
|
|||||||
// Content: Empty state or notes list
|
// Content: Empty state or notes list
|
||||||
if (notes.isEmpty()) {
|
if (notes.isEmpty()) {
|
||||||
EmptyState(modifier = Modifier.weight(1f))
|
EmptyState(modifier = Modifier.weight(1f))
|
||||||
|
} else {
|
||||||
|
// 🎨 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 {
|
} else {
|
||||||
NotesList(
|
NotesList(
|
||||||
notes = notes,
|
notes = notes,
|
||||||
@@ -185,6 +228,7 @@ fun MainScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// FAB als TOP-LAYER - nur anzeigen wenn nicht im Selection Mode
|
// FAB als TOP-LAYER - nur anzeigen wenn nicht im Selection Mode
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
@@ -207,6 +251,7 @@ fun MainScreen(
|
|||||||
if (showBatchDeleteDialog) {
|
if (showBatchDeleteDialog) {
|
||||||
DeleteConfirmationDialog(
|
DeleteConfirmationDialog(
|
||||||
noteCount = selectedNotes.size,
|
noteCount = selectedNotes.size,
|
||||||
|
isOfflineMode = isOfflineMode,
|
||||||
onDismiss = { showBatchDeleteDialog = false },
|
onDismiss = { showBatchDeleteDialog = false },
|
||||||
onDeleteLocal = {
|
onDeleteLocal = {
|
||||||
viewModel.deleteSelectedNotes(deleteFromServer = false)
|
viewModel.deleteSelectedNotes(deleteFromServer = false)
|
||||||
|
|||||||
@@ -62,6 +62,45 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
.map { it.isNotEmpty() }
|
.map { it.isNotEmpty() }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
.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)
|
// Sync State (derived from SyncStateManager)
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -251,6 +290,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
|
@Suppress("MagicNumber") // Snackbar timing coordination
|
||||||
// If delete from server, actually delete after a short delay
|
// If delete from server, actually delete after a short delay
|
||||||
// (to allow undo action before server deletion)
|
// (to allow undo action before server deletion)
|
||||||
if (deleteFromServer) {
|
if (deleteFromServer) {
|
||||||
@@ -350,6 +390,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
|
@Suppress("MagicNumber") // Snackbar timing
|
||||||
// If delete from server, actually delete after snackbar timeout
|
// If delete from server, actually delete after snackbar timeout
|
||||||
if (deleteFromServer) {
|
if (deleteFromServer) {
|
||||||
kotlinx.coroutines.delay(3500) // Snackbar shows for ~3s
|
kotlinx.coroutines.delay(3500) // Snackbar shows for ~3s
|
||||||
@@ -420,6 +461,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
if (success) successCount++ else failCount++
|
if (success) successCount++ else failCount++
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
Logger.w(TAG, "Failed to delete note $noteId from server: ${e.message}")
|
||||||
failCount++
|
failCount++
|
||||||
} finally {
|
} finally {
|
||||||
_pendingDeletions.value = _pendingDeletions.value - noteId
|
_pendingDeletions.value = _pendingDeletions.value - noteId
|
||||||
@@ -458,16 +500,39 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger manual sync (from toolbar button or pull-to-refresh)
|
* Trigger manual sync (from toolbar button or pull-to-refresh)
|
||||||
|
* v1.7.0: Uses central canSync() gate for WiFi-only check
|
||||||
*/
|
*/
|
||||||
fun triggerManualSync(source: String = "manual") {
|
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.tryStartSync(source)) {
|
||||||
|
if (SyncStateManager.isSyncing) {
|
||||||
|
Logger.d(TAG, "⏭️ $source Sync blocked: Another sync in progress")
|
||||||
|
viewModelScope.launch {
|
||||||
|
_showSnackbar.emit(SnackbarData(
|
||||||
|
message = getString(R.string.sync_already_running),
|
||||||
|
actionLabel = "",
|
||||||
|
onAction = {}
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val syncService = WebDavSyncService(getApplication())
|
|
||||||
|
|
||||||
// Check for unsynced changes
|
// Check for unsynced changes
|
||||||
if (!syncService.hasUnsyncedChanges()) {
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
Logger.d(TAG, "⏭️ $source Sync: No unsynced changes")
|
Logger.d(TAG, "⏭️ $source Sync: No unsynced changes")
|
||||||
@@ -513,16 +578,30 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
* Trigger auto-sync (onResume)
|
* Trigger auto-sync (onResume)
|
||||||
* Only runs if server is configured and interval has passed
|
* Only runs if server is configured and interval has passed
|
||||||
* v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt
|
* v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt
|
||||||
|
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_ON_RESUME
|
||||||
|
* v1.7.0: Uses central canSync() gate for WiFi-only check
|
||||||
*/
|
*/
|
||||||
fun triggerAutoSync(source: String = "auto") {
|
fun triggerAutoSync(source: String = "auto") {
|
||||||
|
// 🌟 v1.6.0: Check if onResume trigger is enabled
|
||||||
|
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, Constants.DEFAULT_TRIGGER_ON_RESUME)) {
|
||||||
|
Logger.d(TAG, "⏭️ onResume sync disabled - skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Throttling check
|
// Throttling check
|
||||||
if (!canTriggerAutoSync()) {
|
if (!canTriggerAutoSync()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if server is configured
|
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
|
||||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
val syncService = WebDavSyncService(getApplication())
|
||||||
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
|
val gateResult = syncService.canSync()
|
||||||
|
if (!gateResult.canSync) {
|
||||||
|
if (gateResult.isBlockedByWifiOnly) {
|
||||||
|
Logger.d(TAG, "⏭️ Auto-sync ($source) blocked: WiFi-only mode, not on WiFi")
|
||||||
|
} else {
|
||||||
|
Logger.d(TAG, "⏭️ Auto-sync ($source) blocked: ${gateResult.blockReason ?: "offline/no server"}")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,8 +618,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val syncService = WebDavSyncService(getApplication())
|
|
||||||
|
|
||||||
// Check for unsynced changes
|
// Check for unsynced changes
|
||||||
if (!syncService.hasUnsyncedChanges()) {
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
|
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
|
||||||
@@ -607,6 +684,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
getApplication<android.app.Application>().getString(resId, *formatArgs)
|
getApplication<android.app.Application>().getString(resId, *formatArgs)
|
||||||
|
|
||||||
fun isServerConfigured(): Boolean {
|
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)
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||||
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
|
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,24 @@ package dev.dettmer.simplenotes.ui.main.components
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CloudOff
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -19,10 +28,12 @@ import dev.dettmer.simplenotes.R
|
|||||||
/**
|
/**
|
||||||
* Delete confirmation dialog with server/local options
|
* Delete confirmation dialog with server/local options
|
||||||
* v1.5.0: Multi-Select Feature
|
* v1.5.0: Multi-Select Feature
|
||||||
|
* v1.6.0: Offline mode support - disables server deletion option
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun DeleteConfirmationDialog(
|
fun DeleteConfirmationDialog(
|
||||||
noteCount: Int = 1,
|
noteCount: Int = 1,
|
||||||
|
isOfflineMode: Boolean = false,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onDeleteLocal: () -> Unit,
|
onDeleteLocal: () -> Unit,
|
||||||
onDeleteEverywhere: () -> Unit
|
onDeleteEverywhere: () -> Unit
|
||||||
@@ -59,16 +70,56 @@ fun DeleteConfirmationDialog(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
// Delete everywhere (server + local) - primary action
|
// Delete everywhere (server + local) - primary action
|
||||||
|
// 🌟 v1.6.0: Disabled in offline mode with visual hint
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = onDeleteEverywhere,
|
onClick = onDeleteEverywhere,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isOfflineMode,
|
||||||
colors = ButtonDefaults.textButtonColors(
|
colors = ButtonDefaults.textButtonColors(
|
||||||
contentColor = MaterialTheme.colorScheme.error
|
contentColor = MaterialTheme.colorScheme.error,
|
||||||
|
disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.delete_everywhere))
|
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
|
// Delete local only
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = onDeleteLocal,
|
onClick = onDeleteLocal,
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ fun NoteCard(
|
|||||||
Card(
|
Card(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
// 🎨 v1.7.0: Externes Padding entfernt - Grid/Liste steuert Abstände
|
||||||
.then(
|
.then(
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
Modifier.border(
|
Modifier.border(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package dev.dettmer.simplenotes.ui.main.components
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@@ -49,6 +50,8 @@ fun NotesList(
|
|||||||
showSyncStatus = showSyncStatus,
|
showSyncStatus = showSyncStatus,
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
isSelectionMode = isSelectionMode,
|
isSelectionMode = isSelectionMode,
|
||||||
|
// 🎨 v1.7.0: Padding hier in Liste (nicht in Card selbst)
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
onClick = {
|
onClick = {
|
||||||
if (isSelectionMode) {
|
if (isSelectionMode) {
|
||||||
// In selection mode, tap toggles selection
|
// In selection mode, tap toggles selection
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import androidx.navigation.compose.composable
|
|||||||
import dev.dettmer.simplenotes.ui.settings.screens.AboutScreen
|
import dev.dettmer.simplenotes.ui.settings.screens.AboutScreen
|
||||||
import dev.dettmer.simplenotes.ui.settings.screens.BackupSettingsScreen
|
import dev.dettmer.simplenotes.ui.settings.screens.BackupSettingsScreen
|
||||||
import dev.dettmer.simplenotes.ui.settings.screens.DebugSettingsScreen
|
import dev.dettmer.simplenotes.ui.settings.screens.DebugSettingsScreen
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.screens.DisplaySettingsScreen
|
||||||
import dev.dettmer.simplenotes.ui.settings.screens.LanguageSettingsScreen
|
import dev.dettmer.simplenotes.ui.settings.screens.LanguageSettingsScreen
|
||||||
import dev.dettmer.simplenotes.ui.settings.screens.MarkdownSettingsScreen
|
import dev.dettmer.simplenotes.ui.settings.screens.MarkdownSettingsScreen
|
||||||
import dev.dettmer.simplenotes.ui.settings.screens.ServerSettingsScreen
|
import dev.dettmer.simplenotes.ui.settings.screens.ServerSettingsScreen
|
||||||
@@ -55,7 +56,13 @@ fun SettingsNavHost(
|
|||||||
composable(SettingsRoute.Sync.route) {
|
composable(SettingsRoute.Sync.route) {
|
||||||
SyncSettingsScreen(
|
SyncSettingsScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onBack = { navController.popBackStack() }
|
onBack = { navController.popBackStack() },
|
||||||
|
onNavigateToServerSettings = {
|
||||||
|
navController.navigate(SettingsRoute.Server.route) {
|
||||||
|
// Avoid multiple copies of server settings in back stack
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,5 +96,13 @@ fun SettingsNavHost(
|
|||||||
onBack = { navController.popBackStack() }
|
onBack = { navController.popBackStack() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🎨 v1.7.0: Display Settings
|
||||||
|
composable(SettingsRoute.Display.route) {
|
||||||
|
DisplaySettingsScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ sealed class SettingsRoute(val route: String) {
|
|||||||
data object Backup : SettingsRoute("settings_backup")
|
data object Backup : SettingsRoute("settings_backup")
|
||||||
data object About : SettingsRoute("settings_about")
|
data object About : SettingsRoute("settings_about")
|
||||||
data object Debug : SettingsRoute("settings_debug")
|
data object Debug : SettingsRoute("settings_debug")
|
||||||
|
data object Display : SettingsRoute("settings_display") // 🎨 v1.7.0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dev.dettmer.simplenotes.R
|
import dev.dettmer.simplenotes.R
|
||||||
import dev.dettmer.simplenotes.backup.BackupManager
|
import dev.dettmer.simplenotes.backup.BackupManager
|
||||||
import dev.dettmer.simplenotes.backup.RestoreMode
|
import dev.dettmer.simplenotes.backup.RestoreMode
|
||||||
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||||
import dev.dettmer.simplenotes.utils.Constants
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
@@ -16,9 +17,12 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
@@ -30,6 +34,7 @@ import java.net.URL
|
|||||||
*
|
*
|
||||||
* Manages all settings state and actions across the Settings navigation graph.
|
* Manages all settings state and actions across the Settings navigation graph.
|
||||||
*/
|
*/
|
||||||
|
@Suppress("TooManyFunctions") // v1.7.0: 35 Funktionen durch viele kleine Setter (setTrigger*, set*)
|
||||||
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
|
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -39,6 +44,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
|
|
||||||
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
val backupManager = BackupManager(application)
|
val backupManager = BackupManager(application)
|
||||||
|
private val notesStorage = NotesStorage(application) // v1.7.0: For server change detection
|
||||||
|
|
||||||
|
// 🔧 v1.7.0 Hotfix: Track last confirmed server URL for change detection
|
||||||
|
// This prevents false-positive "server changed" toasts during text input
|
||||||
|
private var confirmedServerUrl: String = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Server Settings State
|
// Server Settings State
|
||||||
@@ -46,10 +56,30 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
|
|
||||||
// v1.5.0 Fix: Initialize URL with protocol prefix if empty
|
// v1.5.0 Fix: Initialize URL with protocol prefix if empty
|
||||||
private val storedUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
|
private val storedUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
|
||||||
private val initialUrl = if (storedUrl.isEmpty()) "http://" else storedUrl
|
|
||||||
|
|
||||||
private val _serverUrl = MutableStateFlow(initialUrl)
|
// 🌟 v1.6.0: Separate host from prefix for better UX
|
||||||
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
|
// 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, "") ?: "")
|
private val _username = MutableStateFlow(prefs.getString(Constants.KEY_USERNAME, "") ?: "")
|
||||||
val username: StateFlow<String> = _username.asStateFlow()
|
val username: StateFlow<String> = _username.asStateFlow()
|
||||||
@@ -57,13 +87,28 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
private val _password = MutableStateFlow(prefs.getString(Constants.KEY_PASSWORD, "") ?: "")
|
private val _password = MutableStateFlow(prefs.getString(Constants.KEY_PASSWORD, "") ?: "")
|
||||||
val password: StateFlow<String> = _password.asStateFlow()
|
val password: StateFlow<String> = _password.asStateFlow()
|
||||||
|
|
||||||
// v1.5.0 Fix: isHttps based on stored URL (false = HTTP if empty)
|
|
||||||
private val _isHttps = MutableStateFlow(storedUrl.startsWith("https://"))
|
|
||||||
val isHttps: StateFlow<Boolean> = _isHttps.asStateFlow()
|
|
||||||
|
|
||||||
private val _serverStatus = MutableStateFlow<ServerStatus>(ServerStatus.Unknown)
|
private val _serverStatus = MutableStateFlow<ServerStatus>(ServerStatus.Unknown)
|
||||||
val serverStatus: StateFlow<ServerStatus> = _serverStatus.asStateFlow()
|
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)
|
// Events (for Activity-level actions like dialogs, intents)
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -90,6 +135,38 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
)
|
)
|
||||||
val syncInterval: StateFlow<Long> = _syncInterval.asStateFlow()
|
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
|
// Markdown Settings State
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -109,6 +186,15 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
)
|
)
|
||||||
val fileLoggingEnabled: StateFlow<Boolean> = _fileLoggingEnabled.asStateFlow()
|
val fileLoggingEnabled: StateFlow<Boolean> = _fileLoggingEnabled.asStateFlow()
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// 🎨 v1.7.0: Display Settings State
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private val _displayMode = MutableStateFlow(
|
||||||
|
prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
|
||||||
|
)
|
||||||
|
val displayMode: StateFlow<String> = _displayMode.asStateFlow()
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// UI State
|
// UI State
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -126,52 +212,154 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
// Server Settings Actions
|
// 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) {
|
fun updateServerUrl(url: String) {
|
||||||
_serverUrl.value = url
|
// 🌟 v1.6.0: Deprecated - use updateServerHost instead
|
||||||
saveServerSettings()
|
// 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) {
|
fun updateProtocol(useHttps: Boolean) {
|
||||||
_isHttps.value = useHttps
|
_isHttps.value = useHttps
|
||||||
val currentUrl = _serverUrl.value
|
// 🌟 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)
|
||||||
|
|
||||||
// v1.5.0 Fix: Automatisch Prefix setzen, auch bei leerem Feld
|
// ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection
|
||||||
val newUrl = if (useHttps) {
|
val prefix = if (useHttps) "https://" else "http://"
|
||||||
when {
|
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
|
||||||
currentUrl.isEmpty() || currentUrl == "http://" -> "https://"
|
prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
|
||||||
currentUrl.startsWith("http://") -> currentUrl.replace("http://", "https://")
|
|
||||||
!currentUrl.startsWith("https://") -> "https://$currentUrl"
|
|
||||||
else -> currentUrl
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
when {
|
|
||||||
currentUrl.isEmpty() || currentUrl == "https://" -> "http://"
|
|
||||||
currentUrl.startsWith("https://") -> currentUrl.replace("https://", "http://")
|
|
||||||
!currentUrl.startsWith("http://") -> "http://$currentUrl"
|
|
||||||
else -> currentUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_serverUrl.value = newUrl
|
|
||||||
saveServerSettings()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateUsername(value: String) {
|
fun updateUsername(value: String) {
|
||||||
_username.value = value
|
_username.value = value
|
||||||
saveServerSettings()
|
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
|
||||||
|
prefs.edit().putString(Constants.KEY_USERNAME, value).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updatePassword(value: String) {
|
fun updatePassword(value: String) {
|
||||||
_password.value = value
|
_password.value = value
|
||||||
saveServerSettings()
|
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
|
||||||
|
prefs.edit().putString(Constants.KEY_PASSWORD, value).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveServerSettings() {
|
/**
|
||||||
prefs.edit().apply {
|
* 🔧 v1.7.0 Hotfix: Manual save function - only called when leaving settings screen
|
||||||
putString(Constants.KEY_SERVER_URL, _serverUrl.value)
|
* This prevents false "server changed" detection during text input
|
||||||
putString(Constants.KEY_USERNAME, _username.value)
|
* 🔧 v1.7.0 Regression Fix: Settings are now saved IMMEDIATELY in update functions.
|
||||||
putString(Constants.KEY_PASSWORD, _password.value)
|
* This function now ONLY handles server-change detection and sync reset.
|
||||||
apply()
|
*/
|
||||||
|
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() {
|
fun testConnection() {
|
||||||
@@ -199,13 +387,23 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun checkServerStatus() {
|
fun checkServerStatus() {
|
||||||
val serverUrl = _serverUrl.value
|
// 🌟 v1.6.0: Respect offline mode first
|
||||||
// v1.5.0 Fix: URL mit nur Prefix gilt als "nicht konfiguriert"
|
if (_offlineMode.value) {
|
||||||
if (serverUrl.isEmpty() || serverUrl == "http://" || serverUrl == "https://") {
|
_serverStatus.value = ServerStatus.OfflineMode
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🌟 v1.6.0: Check if host is configured
|
||||||
|
val serverHost = _serverHost.value
|
||||||
|
if (serverHost.isEmpty()) {
|
||||||
_serverStatus.value = ServerStatus.NotConfigured
|
_serverStatus.value = ServerStatus.NotConfigured
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Construct full URL
|
||||||
|
val prefix = if (_isHttps.value) "https://" else "http://"
|
||||||
|
val serverUrl = prefix + serverHost
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_serverStatus.value = ServerStatus.Checking
|
_serverStatus.value = ServerStatus.Checking
|
||||||
val isReachable = withContext(Dispatchers.IO) {
|
val isReachable = withContext(Dispatchers.IO) {
|
||||||
@@ -231,9 +429,21 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isSyncing.value = true
|
_isSyncing.value = true
|
||||||
try {
|
try {
|
||||||
emitToast(getString(R.string.toast_syncing))
|
|
||||||
val syncService = WebDavSyncService(getApplication())
|
val syncService = WebDavSyncService(getApplication())
|
||||||
|
|
||||||
|
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung
|
||||||
|
val gateResult = syncService.canSync()
|
||||||
|
if (!gateResult.canSync) {
|
||||||
|
if (gateResult.isBlockedByWifiOnly) {
|
||||||
|
emitToast(getString(R.string.sync_wifi_only_hint))
|
||||||
|
} else {
|
||||||
|
emitToast(getString(R.string.toast_sync_failed, "Offline mode"))
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
emitToast(getString(R.string.toast_syncing))
|
||||||
|
|
||||||
if (!syncService.hasUnsyncedChanges()) {
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
emitToast(getString(R.string.toast_already_synced))
|
emitToast(getString(R.string.toast_already_synced))
|
||||||
return@launch
|
return@launch
|
||||||
@@ -287,6 +497,54 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🌟 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
|
// Markdown Settings Actions
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -337,6 +595,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
_markdownExportProgress.value = MarkdownExportProgress(noteCount, noteCount, isComplete = true)
|
_markdownExportProgress.value = MarkdownExportProgress(noteCount, noteCount, isComplete = true)
|
||||||
emitToast(getString(R.string.toast_markdown_exported, exportedCount))
|
emitToast(getString(R.string.toast_markdown_exported, exportedCount))
|
||||||
|
|
||||||
|
@Suppress("MagicNumber") // UI progress delay
|
||||||
// Clear progress after short delay
|
// Clear progress after short delay
|
||||||
kotlinx.coroutines.delay(500)
|
kotlinx.coroutines.delay(500)
|
||||||
_markdownExportProgress.value = null
|
_markdownExportProgress.value = null
|
||||||
@@ -371,6 +630,12 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun performManualMarkdownSync() {
|
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 {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
emitToast(getString(R.string.toast_markdown_syncing))
|
emitToast(getString(R.string.toast_markdown_syncing))
|
||||||
@@ -387,11 +652,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
// Backup Actions
|
// Backup Actions
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
fun createBackup(uri: Uri) {
|
fun createBackup(uri: Uri, password: String? = null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isBackupInProgress.value = true
|
_isBackupInProgress.value = true
|
||||||
try {
|
try {
|
||||||
val result = backupManager.createBackup(uri)
|
val result = backupManager.createBackup(uri, password)
|
||||||
val message = if (result.success) {
|
val message = if (result.success) {
|
||||||
getString(R.string.toast_backup_success, result.message ?: "")
|
getString(R.string.toast_backup_success, result.message ?: "")
|
||||||
} else {
|
} else {
|
||||||
@@ -406,11 +671,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun restoreFromFile(uri: Uri, mode: RestoreMode) {
|
fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isBackupInProgress.value = true
|
_isBackupInProgress.value = true
|
||||||
try {
|
try {
|
||||||
val result = backupManager.restoreBackup(uri, mode)
|
val result = backupManager.restoreBackup(uri, mode, password)
|
||||||
val message = if (result.success) {
|
val message = if (result.success) {
|
||||||
getString(R.string.toast_restore_success, result.importedNotes)
|
getString(R.string.toast_restore_success, result.importedNotes)
|
||||||
} else {
|
} else {
|
||||||
@@ -425,6 +690,29 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔐 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) {
|
fun restoreFromServer(mode: RestoreMode) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isBackupInProgress.value = true
|
_isBackupInProgress.value = true
|
||||||
@@ -478,6 +766,20 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
// Helper
|
// 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): String = getApplication<android.app.Application>().getString(resId)
|
||||||
|
|
||||||
private fun getString(resId: Int, vararg formatArgs: Any): String =
|
private fun getString(resId: Int, vararg formatArgs: Any): String =
|
||||||
@@ -489,9 +791,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Server status states
|
* Server status states
|
||||||
|
* v1.6.0: Added OfflineMode state
|
||||||
*/
|
*/
|
||||||
sealed class ServerStatus {
|
sealed class ServerStatus {
|
||||||
data object Unknown : ServerStatus()
|
data object Unknown : ServerStatus()
|
||||||
|
data object OfflineMode : ServerStatus() // 🌟 v1.6.0
|
||||||
data object NotConfigured : ServerStatus()
|
data object NotConfigured : ServerStatus()
|
||||||
data object Checking : ServerStatus()
|
data object Checking : ServerStatus()
|
||||||
data object Reachable : ServerStatus()
|
data object Reachable : ServerStatus()
|
||||||
@@ -516,4 +820,17 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
val total: Int,
|
val total: Int,
|
||||||
val isComplete: Boolean = false
|
val isComplete: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// 🎨 v1.7.0: Display Mode Functions
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set display mode (list or grid)
|
||||||
|
*/
|
||||||
|
fun setDisplayMode(mode: String) {
|
||||||
|
_displayMode.value = mode
|
||||||
|
prefs.edit().putString(Constants.KEY_DISPLAY_MODE, mode).apply()
|
||||||
|
Logger.d(TAG, "Display mode changed to: $mode")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,24 +95,34 @@ fun SettingsDangerButton(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Info card with description text
|
* Info card with description text
|
||||||
|
* v1.6.0: Added isWarning parameter for offline mode warning
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsInfoCard(
|
fun SettingsInfoCard(
|
||||||
text: String,
|
text: String,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
isWarning: Boolean = false
|
||||||
) {
|
) {
|
||||||
androidx.compose.material3.Card(
|
androidx.compose.material3.Card(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
colors = androidx.compose.material3.CardDefaults.cardColors(
|
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
containerColor = if (isWarning) {
|
||||||
|
MaterialTheme.colorScheme.errorContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
|
}
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = if (isWarning) {
|
||||||
|
MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
lineHeight = MaterialTheme.typography.bodySmall.lineHeight * 1.3f
|
lineHeight = MaterialTheme.typography.bodySmall.lineHeight * 1.3f
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@file:Suppress("MatchingDeclarationName")
|
||||||
package dev.dettmer.simplenotes.ui.settings.components
|
package dev.dettmer.simplenotes.ui.settings.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package dev.dettmer.simplenotes.ui.settings.components
|
package dev.dettmer.simplenotes.ui.settings.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -34,6 +35,7 @@ fun SettingsSwitch(
|
|||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.clickable(enabled = enabled) { onCheckedChange(!checked) }
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import dev.dettmer.simplenotes.R
|
import dev.dettmer.simplenotes.R
|
||||||
import dev.dettmer.simplenotes.backup.RestoreMode
|
import dev.dettmer.simplenotes.backup.RestoreMode
|
||||||
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
|
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.BackupPasswordDialog
|
||||||
import dev.dettmer.simplenotes.ui.settings.components.RadioOption
|
import dev.dettmer.simplenotes.ui.settings.components.RadioOption
|
||||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsButton
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsButton
|
||||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
|
||||||
@@ -34,6 +35,7 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsOutlinedButton
|
|||||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup
|
||||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
||||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -49,17 +51,34 @@ fun BackupSettingsScreen(
|
|||||||
) {
|
) {
|
||||||
val isBackupInProgress by viewModel.isBackupInProgress.collectAsState()
|
val isBackupInProgress by viewModel.isBackupInProgress.collectAsState()
|
||||||
|
|
||||||
|
// 🌟 v1.6.0: Check if server restore is available
|
||||||
|
val isServerConfigured = viewModel.isServerConfigured()
|
||||||
|
|
||||||
// Restore dialog state
|
// Restore dialog state
|
||||||
var showRestoreDialog by remember { mutableStateOf(false) }
|
var showRestoreDialog by remember { mutableStateOf(false) }
|
||||||
var restoreSource by remember { mutableStateOf<RestoreSource>(RestoreSource.LocalFile) }
|
var restoreSource by remember { mutableStateOf<RestoreSource>(RestoreSource.LocalFile) }
|
||||||
var pendingRestoreUri by remember { mutableStateOf<Uri?>(null) }
|
var pendingRestoreUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
var selectedRestoreMode by remember { mutableStateOf(RestoreMode.MERGE) }
|
var selectedRestoreMode by remember { mutableStateOf(RestoreMode.MERGE) }
|
||||||
|
|
||||||
|
// 🔐 v1.7.0: Encryption state
|
||||||
|
var encryptBackup by remember { mutableStateOf(false) }
|
||||||
|
var showEncryptionPasswordDialog by remember { mutableStateOf(false) }
|
||||||
|
var showDecryptionPasswordDialog by remember { mutableStateOf(false) }
|
||||||
|
var pendingBackupUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
|
||||||
// File picker launchers
|
// File picker launchers
|
||||||
val createBackupLauncher = rememberLauncherForActivityResult(
|
val createBackupLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.CreateDocument("application/json")
|
contract = ActivityResultContracts.CreateDocument("application/json")
|
||||||
) { uri ->
|
) { uri ->
|
||||||
uri?.let { viewModel.createBackup(it) }
|
uri?.let {
|
||||||
|
// 🔐 v1.7.0: If encryption enabled, show password dialog first
|
||||||
|
if (encryptBackup) {
|
||||||
|
pendingBackupUri = it
|
||||||
|
showEncryptionPasswordDialog = true
|
||||||
|
} else {
|
||||||
|
viewModel.createBackup(it, password = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val restoreFileLauncher = rememberLauncherForActivityResult(
|
val restoreFileLauncher = rememberLauncherForActivityResult(
|
||||||
@@ -96,6 +115,16 @@ fun BackupSettingsScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// 🔐 v1.7.0: Encryption toggle
|
||||||
|
SettingsSwitch(
|
||||||
|
title = stringResource(R.string.backup_encryption_title),
|
||||||
|
subtitle = stringResource(R.string.backup_encryption_subtitle),
|
||||||
|
checked = encryptBackup,
|
||||||
|
onCheckedChange = { encryptBackup = it }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
SettingsButton(
|
SettingsButton(
|
||||||
text = stringResource(R.string.backup_create),
|
text = stringResource(R.string.backup_create),
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -126,6 +155,7 @@ fun BackupSettingsScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// 🌟 v1.6.0: Disabled when offline mode active
|
||||||
SettingsOutlinedButton(
|
SettingsOutlinedButton(
|
||||||
text = stringResource(R.string.backup_restore_server),
|
text = stringResource(R.string.backup_restore_server),
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -133,13 +163,66 @@ fun BackupSettingsScreen(
|
|||||||
showRestoreDialog = true
|
showRestoreDialog = true
|
||||||
},
|
},
|
||||||
isLoading = isBackupInProgress,
|
isLoading = isBackupInProgress,
|
||||||
|
enabled = isServerConfigured,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp)
|
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))
|
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
|
// Restore Mode Dialog
|
||||||
if (showRestoreDialog) {
|
if (showRestoreDialog) {
|
||||||
RestoreModeDialog(
|
RestoreModeDialog(
|
||||||
@@ -151,7 +234,17 @@ fun BackupSettingsScreen(
|
|||||||
when (restoreSource) {
|
when (restoreSource) {
|
||||||
RestoreSource.LocalFile -> {
|
RestoreSource.LocalFile -> {
|
||||||
pendingRestoreUri?.let { uri ->
|
pendingRestoreUri?.let { uri ->
|
||||||
viewModel.restoreFromFile(uri, selectedRestoreMode)
|
// 🔐 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 -> {
|
RestoreSource.Server -> {
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ fun DebugSettingsScreen(
|
|||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
// Export Logs Button
|
// Export Logs Button
|
||||||
|
val logsSubject = stringResource(R.string.debug_logs_subject)
|
||||||
|
val logsShareVia = stringResource(R.string.debug_logs_share_via)
|
||||||
|
|
||||||
SettingsButton(
|
SettingsButton(
|
||||||
text = stringResource(R.string.debug_export_logs),
|
text = stringResource(R.string.debug_export_logs),
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -96,11 +99,11 @@ fun DebugSettingsScreen(
|
|||||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||||
type = "text/plain"
|
type = "text/plain"
|
||||||
putExtra(Intent.EXTRA_STREAM, logUri)
|
putExtra(Intent.EXTRA_STREAM, logUri)
|
||||||
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.debug_logs_subject))
|
putExtra(Intent.EXTRA_SUBJECT, logsSubject)
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
}
|
}
|
||||||
|
|
||||||
context.startActivity(Intent.createChooser(shareIntent, context.getString(R.string.debug_logs_share_via)))
|
context.startActivity(Intent.createChooser(shareIntent, logsShareVia))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.padding(horizontal = 16.dp)
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ fun MarkdownSettingsScreen(
|
|||||||
val markdownAutoSync by viewModel.markdownAutoSync.collectAsState()
|
val markdownAutoSync by viewModel.markdownAutoSync.collectAsState()
|
||||||
val exportProgress by viewModel.markdownExportProgress.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
|
// v1.5.0 Fix: Progress Dialog for initial export
|
||||||
exportProgress?.let { progress ->
|
exportProgress?.let { progress ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
@@ -96,15 +100,22 @@ fun MarkdownSettingsScreen(
|
|||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
// Markdown Auto-Sync Toggle
|
// Markdown Auto-Sync Toggle
|
||||||
|
// 🌟 v1.6.0: Disabled when offline mode active
|
||||||
SettingsSwitch(
|
SettingsSwitch(
|
||||||
title = stringResource(R.string.markdown_auto_sync_title),
|
title = stringResource(R.string.markdown_auto_sync_title),
|
||||||
subtitle = stringResource(R.string.markdown_auto_sync_subtitle),
|
subtitle = if (!isServerConfigured) {
|
||||||
|
stringResource(R.string.settings_sync_offline_mode)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.markdown_auto_sync_subtitle)
|
||||||
|
},
|
||||||
checked = markdownAutoSync,
|
checked = markdownAutoSync,
|
||||||
onCheckedChange = { viewModel.setMarkdownAutoSync(it) },
|
onCheckedChange = { viewModel.setMarkdownAutoSync(it) },
|
||||||
icon = Icons.Default.Description
|
icon = Icons.Default.Description,
|
||||||
|
enabled = isServerConfigured
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manual sync button (only visible when auto-sync is off)
|
// Manual sync button (only visible when auto-sync is off)
|
||||||
|
// 🌟 v1.6.0: Also disabled in offline mode
|
||||||
if (!markdownAutoSync) {
|
if (!markdownAutoSync) {
|
||||||
SettingsDivider()
|
SettingsDivider()
|
||||||
|
|
||||||
@@ -117,8 +128,20 @@ fun MarkdownSettingsScreen(
|
|||||||
SettingsButton(
|
SettingsButton(
|
||||||
text = stringResource(R.string.markdown_manual_sync_button),
|
text = stringResource(R.string.markdown_manual_sync_button),
|
||||||
onClick = { viewModel.performManualMarkdownSync() },
|
onClick = { viewModel.performManualMarkdownSync() },
|
||||||
|
enabled = isServerConfigured,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp)
|
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))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package dev.dettmer.simplenotes.ui.settings.screens
|
package dev.dettmer.simplenotes.ui.settings.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -29,8 +30,10 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -39,6 +42,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -52,13 +56,18 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
|||||||
/**
|
/**
|
||||||
* Server configuration settings screen
|
* Server configuration settings screen
|
||||||
* v1.5.0: Jetpack Compose Settings Redesign
|
* v1.5.0: Jetpack Compose Settings Redesign
|
||||||
|
* v1.6.0: Offline Mode Toggle
|
||||||
|
* v1.7.0 Hotfix: Save settings on screen exit (not on every keystroke)
|
||||||
*/
|
*/
|
||||||
|
@Suppress("LongMethod", "MagicNumber") // Compose UI + Color hex values
|
||||||
@Composable
|
@Composable
|
||||||
fun ServerSettingsScreen(
|
fun ServerSettingsScreen(
|
||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit
|
onBack: () -> Unit
|
||||||
) {
|
) {
|
||||||
val serverUrl by viewModel.serverUrl.collectAsState()
|
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 username by viewModel.username.collectAsState()
|
||||||
val password by viewModel.password.collectAsState()
|
val password by viewModel.password.collectAsState()
|
||||||
val isHttps by viewModel.isHttps.collectAsState()
|
val isHttps by viewModel.isHttps.collectAsState()
|
||||||
@@ -67,10 +76,20 @@ fun ServerSettingsScreen(
|
|||||||
|
|
||||||
var passwordVisible by remember { mutableStateOf(false) }
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Check server status on load
|
// 🔧 v1.7.0 Hotfix: Save server settings when leaving this screen
|
||||||
LaunchedEffect(Unit) {
|
// 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()
|
viewModel.checkServerStatus()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsScaffold(
|
SettingsScaffold(
|
||||||
title = stringResource(R.string.server_settings_title),
|
title = stringResource(R.string.server_settings_title),
|
||||||
@@ -83,6 +102,57 @@ fun ServerSettingsScreen(
|
|||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(16.dp)
|
.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
|
// Verbindungstyp
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.server_connection_type),
|
text = stringResource(R.string.server_connection_type),
|
||||||
@@ -98,12 +168,14 @@ fun ServerSettingsScreen(
|
|||||||
selected = !isHttps,
|
selected = !isHttps,
|
||||||
onClick = { viewModel.updateProtocol(false) },
|
onClick = { viewModel.updateProtocol(false) },
|
||||||
label = { Text(stringResource(R.string.server_connection_http)) },
|
label = { Text(stringResource(R.string.server_connection_http)) },
|
||||||
|
enabled = fieldsEnabled,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = isHttps,
|
selected = isHttps,
|
||||||
onClick = { viewModel.updateProtocol(true) },
|
onClick = { viewModel.updateProtocol(true) },
|
||||||
label = { Text(stringResource(R.string.server_connection_https)) },
|
label = { Text(stringResource(R.string.server_connection_https)) },
|
||||||
|
enabled = fieldsEnabled,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -119,15 +191,28 @@ fun ServerSettingsScreen(
|
|||||||
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp)
|
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server-Adresse
|
// 🌟 v1.6.0: Server-Adresse with non-editable prefix
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = serverUrl,
|
value = serverHost, // Only host part is editable
|
||||||
onValueChange = { viewModel.updateServerUrl(it) },
|
onValueChange = { viewModel.updateServerHost(it) },
|
||||||
label = { Text(stringResource(R.string.server_address)) },
|
label = { Text(stringResource(R.string.server_address)) },
|
||||||
supportingText = { Text(stringResource(R.string.server_address_hint)) },
|
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) },
|
leadingIcon = { Icon(Icons.Default.Language, null) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
enabled = fieldsEnabled,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -140,7 +225,8 @@ fun ServerSettingsScreen(
|
|||||||
label = { Text(stringResource(R.string.username)) },
|
label = { Text(stringResource(R.string.username)) },
|
||||||
leadingIcon = { Icon(Icons.Default.Person, null) },
|
leadingIcon = { Icon(Icons.Default.Person, null) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true
|
singleLine = true,
|
||||||
|
enabled = fieldsEnabled
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
@@ -174,8 +260,10 @@ fun ServerSettingsScreen(
|
|||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
enabled = fieldsEnabled,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
@@ -196,16 +284,18 @@ fun ServerSettingsScreen(
|
|||||||
Text(stringResource(R.string.server_status_label), style = MaterialTheme.typography.labelLarge)
|
Text(stringResource(R.string.server_status_label), style = MaterialTheme.typography.labelLarge)
|
||||||
Text(
|
Text(
|
||||||
text = when (serverStatus) {
|
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.Reachable -> stringResource(R.string.server_status_reachable)
|
||||||
is SettingsViewModel.ServerStatus.Unreachable -> stringResource(R.string.server_status_unreachable)
|
is SettingsViewModel.ServerStatus.Unreachable -> stringResource(R.string.server_status_unreachable)
|
||||||
is SettingsViewModel.ServerStatus.Checking -> stringResource(R.string.server_status_checking)
|
is SettingsViewModel.ServerStatus.Checking -> stringResource(R.string.server_status_checking)
|
||||||
is SettingsViewModel.ServerStatus.NotConfigured -> stringResource(R.string.server_status_not_configured)
|
is SettingsViewModel.ServerStatus.NotConfigured -> stringResource(R.string.server_status_offline_mode)
|
||||||
else -> stringResource(R.string.server_status_unknown)
|
else -> stringResource(R.string.server_status_unknown)
|
||||||
},
|
},
|
||||||
color = when (serverStatus) {
|
color = when (serverStatus) {
|
||||||
|
is SettingsViewModel.ServerStatus.OfflineMode -> MaterialTheme.colorScheme.tertiary
|
||||||
is SettingsViewModel.ServerStatus.Reachable -> Color(0xFF4CAF50)
|
is SettingsViewModel.ServerStatus.Reachable -> Color(0xFF4CAF50)
|
||||||
is SettingsViewModel.ServerStatus.Unreachable -> Color(0xFFF44336)
|
is SettingsViewModel.ServerStatus.Unreachable -> Color(0xFFF44336)
|
||||||
is SettingsViewModel.ServerStatus.NotConfigured -> Color(0xFFFF9800)
|
is SettingsViewModel.ServerStatus.NotConfigured -> MaterialTheme.colorScheme.tertiary
|
||||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -214,13 +304,16 @@ fun ServerSettingsScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
// Action Buttons
|
// Action Buttons (disabled in offline mode)
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.alpha(fieldsAlpha),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { viewModel.testConnection() },
|
onClick = { viewModel.testConnection() },
|
||||||
|
enabled = fieldsEnabled,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.test_connection))
|
Text(stringResource(R.string.test_connection))
|
||||||
@@ -228,7 +321,7 @@ fun ServerSettingsScreen(
|
|||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = { viewModel.syncNow() },
|
onClick = { viewModel.syncNow() },
|
||||||
enabled = !isSyncing,
|
enabled = fieldsEnabled && !isSyncing,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
) {
|
) {
|
||||||
if (isSyncing) {
|
if (isSyncing) {
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import androidx.compose.material.icons.filled.Backup
|
|||||||
import androidx.compose.material.icons.filled.BugReport
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
import androidx.compose.material.icons.filled.Cloud
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
import androidx.compose.material.icons.filled.Description
|
import androidx.compose.material.icons.filled.Description
|
||||||
|
import androidx.compose.material.icons.filled.GridView
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
import androidx.compose.material.icons.filled.Sync
|
import androidx.compose.material.icons.filled.Sync
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -32,6 +34,7 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
|||||||
* Main Settings overview screen with clickable group cards
|
* Main Settings overview screen with clickable group cards
|
||||||
* v1.5.0: Jetpack Compose Settings Redesign
|
* v1.5.0: Jetpack Compose Settings Redesign
|
||||||
*/
|
*/
|
||||||
|
@Suppress("MagicNumber") // Color hex values
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsMainScreen(
|
fun SettingsMainScreen(
|
||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
@@ -45,6 +48,14 @@ fun SettingsMainScreen(
|
|||||||
val markdownAutoSync by viewModel.markdownAutoSync.collectAsState()
|
val markdownAutoSync by viewModel.markdownAutoSync.collectAsState()
|
||||||
val fileLoggingEnabled by viewModel.fileLoggingEnabled.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
|
// Check server status on first load
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.checkServerStatus()
|
viewModel.checkServerStatus()
|
||||||
@@ -80,28 +91,56 @@ fun SettingsMainScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🎨 v1.7.0: Display Settings
|
||||||
|
item {
|
||||||
|
val displayMode by viewModel.displayMode.collectAsState()
|
||||||
|
val displaySubtitle = when (displayMode) {
|
||||||
|
"grid" -> stringResource(R.string.display_mode_grid)
|
||||||
|
else -> stringResource(R.string.display_mode_list)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard(
|
||||||
|
icon = Icons.Default.GridView,
|
||||||
|
title = stringResource(R.string.display_settings_title),
|
||||||
|
subtitle = displaySubtitle,
|
||||||
|
onClick = { onNavigate(SettingsRoute.Display) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Server-Einstellungen
|
// Server-Einstellungen
|
||||||
item {
|
item {
|
||||||
// v1.5.0 Fix: Nur Prefix-URLs gelten als "nicht konfiguriert"
|
// 🌟 v1.6.0: Check if server is configured (host is not empty)
|
||||||
val isConfigured = serverUrl.isNotEmpty() &&
|
val isConfigured = serverUrl.isNotEmpty()
|
||||||
serverUrl != "http://" &&
|
|
||||||
serverUrl != "https://"
|
|
||||||
|
|
||||||
SettingsCard(
|
SettingsCard(
|
||||||
icon = Icons.Default.Cloud,
|
icon = Icons.Default.Cloud,
|
||||||
title = stringResource(R.string.settings_server),
|
title = stringResource(R.string.settings_server),
|
||||||
subtitle = if (isConfigured) serverUrl else null,
|
subtitle = if (!offlineMode && isConfigured) serverUrl else null,
|
||||||
statusText = when (serverStatus) {
|
statusText = when {
|
||||||
is SettingsViewModel.ServerStatus.Reachable -> stringResource(R.string.settings_server_status_reachable)
|
offlineMode ->
|
||||||
is SettingsViewModel.ServerStatus.Unreachable -> stringResource(R.string.settings_server_status_unreachable)
|
stringResource(R.string.settings_server_status_offline_mode)
|
||||||
is SettingsViewModel.ServerStatus.Checking -> stringResource(R.string.settings_server_status_checking)
|
serverStatus is SettingsViewModel.ServerStatus.OfflineMode ->
|
||||||
is SettingsViewModel.ServerStatus.NotConfigured -> stringResource(R.string.settings_server_status_not_configured)
|
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
|
else -> null
|
||||||
},
|
},
|
||||||
statusColor = when (serverStatus) {
|
statusColor = when {
|
||||||
is SettingsViewModel.ServerStatus.Reachable -> Color(0xFF4CAF50)
|
offlineMode -> MaterialTheme.colorScheme.tertiary
|
||||||
is SettingsViewModel.ServerStatus.Unreachable -> Color(0xFFF44336)
|
serverStatus is SettingsViewModel.ServerStatus.OfflineMode ->
|
||||||
is SettingsViewModel.ServerStatus.NotConfigured -> Color(0xFFFF9800)
|
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
|
else -> Color.Gray
|
||||||
},
|
},
|
||||||
onClick = { onNavigate(SettingsRoute.Server) }
|
onClick = { onNavigate(SettingsRoute.Server) }
|
||||||
@@ -110,33 +149,52 @@ fun SettingsMainScreen(
|
|||||||
|
|
||||||
// Sync-Einstellungen
|
// Sync-Einstellungen
|
||||||
item {
|
item {
|
||||||
val intervalText = when (syncInterval) {
|
// 🌟 v1.6.0: Build dynamic subtitle based on active triggers
|
||||||
15L -> stringResource(R.string.settings_interval_15min)
|
val isServerConfigured = viewModel.isServerConfigured()
|
||||||
60L -> stringResource(R.string.settings_interval_60min)
|
val activeTriggersCount = listOf(
|
||||||
else -> stringResource(R.string.settings_interval_30min)
|
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(
|
SettingsCard(
|
||||||
icon = Icons.Default.Sync,
|
icon = Icons.Default.Sync,
|
||||||
title = stringResource(R.string.settings_sync),
|
title = stringResource(R.string.settings_sync),
|
||||||
subtitle = if (autoSyncEnabled) {
|
subtitle = syncSubtitle,
|
||||||
stringResource(R.string.settings_sync_auto_on, intervalText)
|
statusText = if (!isServerConfigured) stringResource(R.string.settings_sync_offline_mode) else null,
|
||||||
} else {
|
statusColor = if (!isServerConfigured) MaterialTheme.colorScheme.tertiary else Color.Gray,
|
||||||
stringResource(R.string.settings_sync_auto_off)
|
|
||||||
},
|
|
||||||
onClick = { onNavigate(SettingsRoute.Sync) }
|
onClick = { onNavigate(SettingsRoute.Sync) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Markdown-Integration
|
// Markdown-Integration
|
||||||
item {
|
item {
|
||||||
|
// 🌟 v1.6.0 Fix: Use statusText for offline mode (consistent with Server card)
|
||||||
|
val isServerConfiguredForMarkdown = viewModel.isServerConfigured()
|
||||||
|
|
||||||
SettingsCard(
|
SettingsCard(
|
||||||
icon = Icons.Default.Description,
|
icon = Icons.Default.Description,
|
||||||
title = stringResource(R.string.settings_markdown),
|
title = stringResource(R.string.settings_markdown),
|
||||||
subtitle = if (markdownAutoSync) {
|
subtitle = if (isServerConfiguredForMarkdown) {
|
||||||
|
if (markdownAutoSync) {
|
||||||
stringResource(R.string.settings_markdown_auto_on)
|
stringResource(R.string.settings_markdown_auto_on)
|
||||||
} else {
|
} else {
|
||||||
stringResource(R.string.settings_markdown_auto_off)
|
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) }
|
onClick = { onNavigate(SettingsRoute.Markdown) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Sync
|
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.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -26,17 +32,30 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
|
|||||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync settings screen (Auto-Sync toggle and interval selection)
|
* Sync settings screen - Configurable Sync Triggers
|
||||||
* v1.5.0: Jetpack Compose Settings Redesign
|
* v1.5.0: Jetpack Compose Settings Redesign
|
||||||
|
* v1.6.0: Individual toggle for each sync trigger (onSave, onResume, WiFi-Connect, Periodic, Boot)
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SyncSettingsScreen(
|
fun SyncSettingsScreen(
|
||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit
|
onBack: () -> Unit,
|
||||||
|
onNavigateToServerSettings: () -> Unit
|
||||||
) {
|
) {
|
||||||
val autoSyncEnabled by viewModel.autoSyncEnabled.collectAsState()
|
// Collect all trigger states
|
||||||
|
val triggerOnSave by viewModel.triggerOnSave.collectAsState()
|
||||||
|
val triggerOnResume by viewModel.triggerOnResume.collectAsState()
|
||||||
|
val triggerWifiConnect by viewModel.triggerWifiConnect.collectAsState()
|
||||||
|
val triggerPeriodic by viewModel.triggerPeriodic.collectAsState()
|
||||||
|
val triggerBoot by viewModel.triggerBoot.collectAsState()
|
||||||
val syncInterval by viewModel.syncInterval.collectAsState()
|
val 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(
|
SettingsScaffold(
|
||||||
title = stringResource(R.string.sync_settings_title),
|
title = stringResource(R.string.sync_settings_title),
|
||||||
onBack = onBack
|
onBack = onBack
|
||||||
@@ -49,48 +68,121 @@ fun SyncSettingsScreen(
|
|||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
// Auto-Sync Info
|
// 🌟 v1.6.0: Offline Mode Warning if server not configured
|
||||||
|
if (!isServerConfigured) {
|
||||||
SettingsInfoCard(
|
SettingsInfoCard(
|
||||||
text = stringResource(R.string.sync_auto_sync_info)
|
text = stringResource(R.string.sync_offline_mode_message),
|
||||||
|
isWarning = true
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Button(
|
||||||
|
onClick = onNavigateToServerSettings,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.sync_offline_mode_button))
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-Sync Toggle
|
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(
|
SettingsSwitch(
|
||||||
title = stringResource(R.string.sync_auto_sync_enabled),
|
title = stringResource(R.string.sync_wifi_only_title),
|
||||||
checked = autoSyncEnabled,
|
subtitle = stringResource(R.string.sync_wifi_only_subtitle),
|
||||||
onCheckedChange = { viewModel.setAutoSync(it) },
|
checked = wifiOnlySync,
|
||||||
icon = Icons.Default.Sync
|
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()
|
SettingsDivider()
|
||||||
|
|
||||||
// Sync Interval Section
|
// ═══════════════════════════════════════════════════════════════
|
||||||
SettingsSectionHeader(text = stringResource(R.string.sync_interval_section))
|
// HINTERGRUND-SYNC Section
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
SettingsInfoCard(
|
SettingsSectionHeader(text = stringResource(R.string.sync_section_background))
|
||||||
text = stringResource(R.string.sync_interval_info)
|
|
||||||
|
// 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))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
// Interval Radio Group
|
|
||||||
val intervalOptions = listOf(
|
val intervalOptions = listOf(
|
||||||
RadioOption(
|
RadioOption(
|
||||||
value = 15L,
|
value = 15L,
|
||||||
title = stringResource(R.string.sync_interval_15min_title),
|
title = stringResource(R.string.sync_interval_15min_title),
|
||||||
subtitle = stringResource(R.string.sync_interval_15min_subtitle)
|
subtitle = null
|
||||||
),
|
),
|
||||||
RadioOption(
|
RadioOption(
|
||||||
value = 30L,
|
value = 30L,
|
||||||
title = stringResource(R.string.sync_interval_30min_title),
|
title = stringResource(R.string.sync_interval_30min_title),
|
||||||
subtitle = stringResource(R.string.sync_interval_30min_subtitle)
|
subtitle = null
|
||||||
),
|
),
|
||||||
RadioOption(
|
RadioOption(
|
||||||
value = 60L,
|
value = 60L,
|
||||||
title = stringResource(R.string.sync_interval_60min_title),
|
title = stringResource(R.string.sync_interval_60min_title),
|
||||||
subtitle = stringResource(R.string.sync_interval_60min_subtitle)
|
subtitle = null
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,6 +192,40 @@ fun SyncSettingsScreen(
|
|||||||
onValueSelected = { viewModel.setSyncInterval(it) }
|
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))
|
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
|
||||||
|
}
|
||||||
@@ -29,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
|
||||||
@@ -36,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import dev.dettmer.simplenotes.MainActivity
|
import dev.dettmer.simplenotes.ui.main.ComposeMainActivity
|
||||||
|
|
||||||
object NotificationHelper {
|
object NotificationHelper {
|
||||||
|
|
||||||
@@ -58,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,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
|
||||||
@@ -229,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(
|
||||||
@@ -260,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(
|
||||||
@@ -297,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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
<!-- EMPTY STATE -->
|
<!-- EMPTY STATE -->
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
|
<string name="empty_state_emoji">📝</string>
|
||||||
<string name="empty_state_title">Noch keine Notizen</string>
|
<string name="empty_state_title">Noch keine Notizen</string>
|
||||||
<string name="empty_state_message">Tippe + um eine neue Notiz zu erstellen</string>
|
<string name="empty_state_message">Tippe + um eine neue Notiz zu erstellen</string>
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@
|
|||||||
<string name="delete_note_message">Wie möchtest du diese Notiz 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_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">Ü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_local_only">Nur lokal löschen</string>
|
||||||
<string name="delete">Löschen</string>
|
<string name="delete">Löschen</string>
|
||||||
<string name="cancel">Abbrechen</string>
|
<string name="cancel">Abbrechen</string>
|
||||||
@@ -135,9 +137,13 @@
|
|||||||
<string name="settings_server_status_unreachable">❌ Nicht 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_checking">🔍 Prüfe…</string>
|
||||||
<string name="settings_server_status_not_configured">⚠️ Nicht konfiguriert</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">Sync-Einstellungen</string>
|
||||||
<string name="settings_sync_auto_on">Auto-Sync: An • %s</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_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_15min">15 Min</string>
|
||||||
<string name="settings_interval_30min">30 Min</string>
|
<string name="settings_interval_30min">30 Min</string>
|
||||||
<string name="settings_interval_60min">60 Min</string>
|
<string name="settings_interval_60min">60 Min</string>
|
||||||
@@ -173,7 +179,10 @@
|
|||||||
<string name="server_status_unreachable">❌ Nicht erreichbar</string>
|
<string name="server_status_unreachable">❌ Nicht erreichbar</string>
|
||||||
<string name="server_status_checking">🔍 Prüfe…</string>
|
<string name="server_status_checking">🔍 Prüfe…</string>
|
||||||
<string name="server_status_not_configured">⚠️ Nicht konfiguriert</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_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="test_connection">Verbindung testen</string>
|
||||||
<string name="sync_now">Jetzt synchronisieren</string>
|
<string name="sync_now">Jetzt synchronisieren</string>
|
||||||
|
|
||||||
@@ -188,14 +197,49 @@
|
|||||||
<string name="sync_interval_section">Sync-Intervall</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_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_title">⚡ Alle 15 Minuten</string>
|
||||||
<string name="sync_interval_15min_subtitle">Schnellste Synchronisation • ~0.8% Akku/Tag (~23 mAh)</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_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_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_title">🔋 Alle 60 Minuten</string>
|
||||||
<string name="sync_interval_60min_subtitle">Maximale Akkulaufzeit • ~0.2% Akku/Tag (~6 mAh geschätzt)</string>
|
<string name="sync_interval_60min_subtitle">Maximale Akkulaufzeit • ~0.2%% Akku/Tag (~6 mAh geschätzt)</string>
|
||||||
<!-- Legacy -->
|
<!-- 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>
|
<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 -->
|
<!-- SETTINGS - MARKDOWN -->
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
@@ -218,6 +262,20 @@
|
|||||||
<string name="backup_local_section">Lokales Backup</string>
|
<string name="backup_local_section">Lokales Backup</string>
|
||||||
<string name="backup_create">💾 Backup erstellen</string>
|
<string name="backup_create">💾 Backup erstellen</string>
|
||||||
<string name="backup_restore_file">📂 Aus Datei wiederherstellen</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_server_section">Server-Backup</string>
|
||||||
<string name="backup_restore_server">☁️ Vom Server wiederherstellen</string>
|
<string name="backup_restore_server">☁️ Vom Server wiederherstellen</string>
|
||||||
<string name="backup_restore_dialog_title">⚠️ Backup wiederherstellen?</string>
|
<string name="backup_restore_dialog_title">⚠️ Backup wiederherstellen?</string>
|
||||||
@@ -273,6 +331,15 @@
|
|||||||
<string name="language_info">ℹ️ Wähle deine bevorzugte Sprache. Die App wird neu gestartet, um die Änderung anzuwenden.</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>
|
<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 -->
|
<!-- SETTINGS - ABOUT -->
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
@@ -322,6 +389,8 @@
|
|||||||
<string name="toast_logs_deleted">🗑️ Logs gelöscht</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_no_logs_to_delete">📭 Keine Logs zum Löschen</string>
|
||||||
<string name="toast_logs_delete_error">❌ Fehler beim Löschen: %s</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_link_error">❌ Fehler beim Öffnen des Links</string>
|
||||||
<string name="toast_file_logging_enabled">📝 Datei-Logging aktiviert</string>
|
<string name="toast_file_logging_enabled">📝 Datei-Logging aktiviert</string>
|
||||||
<string name="toast_file_logging_disabled">📝 Datei-Logging deaktiviert</string>
|
<string name="toast_file_logging_disabled">📝 Datei-Logging deaktiviert</string>
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
<string name="delete_note_message">How do you want to delete this note?</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_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">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_local_only">Delete local only</string>
|
||||||
<string name="delete">Delete</string>
|
<string name="delete">Delete</string>
|
||||||
<string name="cancel">Cancel</string>
|
<string name="cancel">Cancel</string>
|
||||||
@@ -136,9 +137,13 @@
|
|||||||
<string name="settings_server_status_unreachable">❌ Not reachable</string>
|
<string name="settings_server_status_unreachable">❌ Not reachable</string>
|
||||||
<string name="settings_server_status_checking">🔍 Checking…</string>
|
<string name="settings_server_status_checking">🔍 Checking…</string>
|
||||||
<string name="settings_server_status_not_configured">⚠️ Not configured</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">Sync Settings</string>
|
||||||
<string name="settings_sync_auto_on">Auto-Sync: On • %s</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_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_15min">15 min</string>
|
||||||
<string name="settings_interval_30min">30 min</string>
|
<string name="settings_interval_30min">30 min</string>
|
||||||
<string name="settings_interval_60min">60 min</string>
|
<string name="settings_interval_60min">60 min</string>
|
||||||
@@ -174,7 +179,10 @@
|
|||||||
<string name="server_status_unreachable">❌ Not reachable</string>
|
<string name="server_status_unreachable">❌ Not reachable</string>
|
||||||
<string name="server_status_checking">🔍 Checking…</string>
|
<string name="server_status_checking">🔍 Checking…</string>
|
||||||
<string name="server_status_not_configured">⚠️ Not configured</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_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="test_connection">Test Connection</string>
|
||||||
<string name="sync_now">Sync now</string>
|
<string name="sync_now">Sync now</string>
|
||||||
|
|
||||||
@@ -189,14 +197,49 @@
|
|||||||
<string name="sync_interval_section">Sync Interval</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_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_title">⚡ Every 15 minutes</string>
|
||||||
<string name="sync_interval_15min_subtitle">Fastest sync • ~0.8% battery/day (~23 mAh)</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_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_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_title">🔋 Every 60 minutes</string>
|
||||||
<string name="sync_interval_60min_subtitle">Maximum battery life • ~0.2% battery/day (~6 mAh est.)</string>
|
<string name="sync_interval_60min_subtitle">Maximum battery life • ~0.2%% battery/day (~6 mAh est.)</string>
|
||||||
<!-- Legacy -->
|
<!-- 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>
|
<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>
|
||||||
|
|
||||||
|
<!-- 🌟 v1.6.0: Configurable Sync Triggers -->
|
||||||
|
<string name="sync_section_network">📶 Network Restriction</string>
|
||||||
|
<string name="sync_section_instant">📲 Instant Sync</string>
|
||||||
|
<string name="sync_section_background">📡 Background Sync</string>
|
||||||
|
<string name="sync_section_advanced">⚙️ Advanced</string>
|
||||||
|
|
||||||
|
<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_trigger_on_save_title">After Saving</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 -->
|
<!-- SETTINGS - MARKDOWN -->
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
@@ -219,6 +262,20 @@
|
|||||||
<string name="backup_local_section">Local Backup</string>
|
<string name="backup_local_section">Local Backup</string>
|
||||||
<string name="backup_create">💾 Create Backup</string>
|
<string name="backup_create">💾 Create Backup</string>
|
||||||
<string name="backup_restore_file">📂 Restore from File</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_server_section">Server Backup</string>
|
||||||
<string name="backup_restore_server">☁️ Restore from Server</string>
|
<string name="backup_restore_server">☁️ Restore from Server</string>
|
||||||
<string name="backup_restore_dialog_title">⚠️ Restore Backup?</string>
|
<string name="backup_restore_dialog_title">⚠️ Restore Backup?</string>
|
||||||
@@ -274,6 +331,15 @@
|
|||||||
<string name="language_info">ℹ️ Choose your preferred language. The app will restart to apply the change.</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>
|
<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 -->
|
<!-- SETTINGS - ABOUT -->
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
@@ -323,6 +389,8 @@
|
|||||||
<string name="toast_logs_deleted">🗑️ Logs deleted</string>
|
<string name="toast_logs_deleted">🗑️ Logs deleted</string>
|
||||||
<string name="toast_no_logs_to_delete">📭 No logs to delete</string>
|
<string name="toast_no_logs_to_delete">📭 No logs to delete</string>
|
||||||
<string name="toast_logs_delete_error">❌ Error deleting: %s</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_link_error">❌ Error opening link</string>
|
||||||
<string name="toast_file_logging_enabled">📝 File logging enabled</string>
|
<string name="toast_file_logging_enabled">📝 File logging enabled</string>
|
||||||
<string name="toast_file_logging_disabled">📝 File logging disabled</string>
|
<string name="toast_file_logging_disabled">📝 File logging disabled</string>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
<base-config cleartextTrafficPermitted="true">
|
<base-config cleartextTrafficPermitted="true">
|
||||||
<trust-anchors>
|
<trust-anchors>
|
||||||
<certificates src="system" />
|
<certificates src="system" />
|
||||||
|
<!-- 🔐 v1.7.0: Trust user-installed CA certificates for self-signed SSL support -->
|
||||||
|
<certificates src="user" />
|
||||||
</trust-anchors>
|
</trust-anchors>
|
||||||
</base-config>
|
</base-config>
|
||||||
</network-security-config>
|
</network-security-config>
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package dev.dettmer.simplenotes.models
|
||||||
|
|
||||||
|
import dev.dettmer.simplenotes.models.NoteSize.Companion.SMALL_CHECKLIST_THRESHOLD
|
||||||
|
import dev.dettmer.simplenotes.models.NoteSize.Companion.SMALL_TEXT_THRESHOLD
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🎨 v1.7.0: Tests for Note Size Classification (Staggered Grid Layout)
|
||||||
|
*/
|
||||||
|
class NoteSizeTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `text note with less than 80 chars is SMALL`() {
|
||||||
|
val note = Note(
|
||||||
|
id = "test1",
|
||||||
|
title = "Test",
|
||||||
|
content = "Short content", // 13 chars
|
||||||
|
deviceId = "test-device",
|
||||||
|
noteType = NoteType.TEXT
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(NoteSize.SMALL, note.getSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `text note with exactly 79 chars is SMALL`() {
|
||||||
|
val content = "x".repeat(79) // Exactly threshold - 1
|
||||||
|
val note = Note(
|
||||||
|
id = "test2",
|
||||||
|
title = "Test",
|
||||||
|
content = content,
|
||||||
|
deviceId = "test-device",
|
||||||
|
noteType = NoteType.TEXT
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(NoteSize.SMALL, note.getSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `text note with exactly 80 chars is LARGE`() {
|
||||||
|
val content = "x".repeat(SMALL_TEXT_THRESHOLD) // Exactly at threshold
|
||||||
|
val note = Note(
|
||||||
|
id = "test3",
|
||||||
|
title = "Test",
|
||||||
|
content = content,
|
||||||
|
deviceId = "test-device",
|
||||||
|
noteType = NoteType.TEXT
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(NoteSize.LARGE, note.getSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `text note with more than 80 chars is LARGE`() {
|
||||||
|
val content = "This is a long note with more than 80 characters. " +
|
||||||
|
"It should be classified as LARGE for grid layout display."
|
||||||
|
val note = Note(
|
||||||
|
id = "test4",
|
||||||
|
title = "Test",
|
||||||
|
content = content,
|
||||||
|
deviceId = "test-device",
|
||||||
|
noteType = NoteType.TEXT
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(NoteSize.LARGE, note.getSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `checklist with 1 item is SMALL`() {
|
||||||
|
val note = Note(
|
||||||
|
id = "test5",
|
||||||
|
title = "Shopping",
|
||||||
|
content = "",
|
||||||
|
deviceId = "test-device",
|
||||||
|
noteType = NoteType.CHECKLIST,
|
||||||
|
checklistItems = listOf(
|
||||||
|
ChecklistItem("id1", "Milk", false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(NoteSize.SMALL, note.getSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `checklist with 4 items is SMALL`() {
|
||||||
|
val note = Note(
|
||||||
|
id = "test6",
|
||||||
|
title = "Shopping",
|
||||||
|
content = "",
|
||||||
|
deviceId = "test-device",
|
||||||
|
noteType = NoteType.CHECKLIST,
|
||||||
|
checklistItems = listOf(
|
||||||
|
ChecklistItem("id1", "Milk", false),
|
||||||
|
ChecklistItem("id2", "Bread", false),
|
||||||
|
ChecklistItem("id3", "Eggs", false),
|
||||||
|
ChecklistItem("id4", "Butter", false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(NoteSize.SMALL, note.getSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `checklist with 5 items is LARGE`() {
|
||||||
|
val note = Note(
|
||||||
|
id = "test7",
|
||||||
|
title = "Shopping",
|
||||||
|
content = "",
|
||||||
|
deviceId = "test-device",
|
||||||
|
noteType = NoteType.CHECKLIST,
|
||||||
|
checklistItems = listOf(
|
||||||
|
ChecklistItem("id1", "Milk", false),
|
||||||
|
ChecklistItem("id2", "Bread", false),
|
||||||
|
ChecklistItem("id3", "Eggs", false),
|
||||||
|
ChecklistItem("id4", "Butter", false),
|
||||||
|
ChecklistItem("id5", "Cheese", false) // 5th item -> LARGE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(NoteSize.LARGE, note.getSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `checklist with many items is LARGE`() {
|
||||||
|
val items = (1..10).map { ChecklistItem("id$it", "Item $it", false) }
|
||||||
|
val note = Note(
|
||||||
|
id = "test8",
|
||||||
|
title = "Long List",
|
||||||
|
content = "",
|
||||||
|
deviceId = "test-device",
|
||||||
|
noteType = NoteType.CHECKLIST,
|
||||||
|
checklistItems = items
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(NoteSize.LARGE, note.getSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty checklist is SMALL`() {
|
||||||
|
val note = Note(
|
||||||
|
id = "test9",
|
||||||
|
title = "Empty",
|
||||||
|
content = "",
|
||||||
|
deviceId = "test-device",
|
||||||
|
noteType = NoteType.CHECKLIST,
|
||||||
|
checklistItems = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(NoteSize.SMALL, note.getSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `checklist with null items is SMALL`() {
|
||||||
|
val note = Note(
|
||||||
|
id = "test10",
|
||||||
|
title = "Null Items",
|
||||||
|
content = "",
|
||||||
|
deviceId = "test-device",
|
||||||
|
noteType = NoteType.CHECKLIST,
|
||||||
|
checklistItems = null
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(NoteSize.SMALL, note.getSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `constants have expected values`() {
|
||||||
|
assertEquals(80, SMALL_TEXT_THRESHOLD)
|
||||||
|
assertEquals(4, SMALL_CHECKLIST_THRESHOLD)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package dev.dettmer.simplenotes.utils
|
||||||
|
|
||||||
|
import dev.dettmer.simplenotes.backup.EncryptionException
|
||||||
|
import dev.dettmer.simplenotes.backup.EncryptionManager
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import kotlin.text.Charsets.UTF_8
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔒 v1.7.0: Tests for Local Backup Encryption
|
||||||
|
*/
|
||||||
|
class EncryptionManagerTest {
|
||||||
|
|
||||||
|
private val encryptionManager = EncryptionManager()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encrypt and decrypt roundtrip preserves data`() {
|
||||||
|
val originalData = "This is a test backup with UTF-8: äöü 🔒".toByteArray(UTF_8)
|
||||||
|
val password = "TestPassword123"
|
||||||
|
|
||||||
|
val encrypted = encryptionManager.encrypt(originalData, password)
|
||||||
|
val decrypted = encryptionManager.decrypt(encrypted, password)
|
||||||
|
|
||||||
|
assertArrayEquals(originalData, decrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encrypted data has correct header format`() {
|
||||||
|
val data = "Test data".toByteArray(UTF_8)
|
||||||
|
val password = "password123"
|
||||||
|
|
||||||
|
val encrypted = encryptionManager.encrypt(data, password)
|
||||||
|
|
||||||
|
// Check magic bytes "SNE1"
|
||||||
|
val magic = encrypted.copyOfRange(0, 4)
|
||||||
|
assertArrayEquals("SNE1".toByteArray(UTF_8), magic)
|
||||||
|
|
||||||
|
// Check version (1 byte = 0x01)
|
||||||
|
assertEquals(1, encrypted[4].toInt())
|
||||||
|
|
||||||
|
// Check minimum size: magic(4) + version(1) + salt(32) + iv(12) + ciphertext + tag(16)
|
||||||
|
assertTrue("Encrypted data too small: ${encrypted.size}", encrypted.size >= 4 + 1 + 32 + 12 + 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isEncrypted returns true for encrypted data`() {
|
||||||
|
val data = "Test".toByteArray(UTF_8)
|
||||||
|
val encrypted = encryptionManager.encrypt(data, "password")
|
||||||
|
|
||||||
|
assertTrue(encryptionManager.isEncrypted(encrypted))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isEncrypted returns false for plaintext data`() {
|
||||||
|
val plaintext = "This is not encrypted".toByteArray(UTF_8)
|
||||||
|
|
||||||
|
assertFalse(encryptionManager.isEncrypted(plaintext))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isEncrypted returns false for short data`() {
|
||||||
|
val shortData = "SNE".toByteArray(UTF_8) // Less than 4 bytes
|
||||||
|
|
||||||
|
assertFalse(encryptionManager.isEncrypted(shortData))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isEncrypted returns false for wrong magic bytes`() {
|
||||||
|
val wrongMagic = "FAKE1234567890".toByteArray(UTF_8)
|
||||||
|
|
||||||
|
assertFalse(encryptionManager.isEncrypted(wrongMagic))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decrypt with wrong password throws EncryptionException`() {
|
||||||
|
val data = "Sensitive data".toByteArray(UTF_8)
|
||||||
|
val correctPassword = "correct123"
|
||||||
|
val wrongPassword = "wrong123"
|
||||||
|
|
||||||
|
val encrypted = encryptionManager.encrypt(data, correctPassword)
|
||||||
|
|
||||||
|
val exception = assertThrows(EncryptionException::class.java) {
|
||||||
|
encryptionManager.decrypt(encrypted, wrongPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(exception.message?.contains("Decryption failed") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decrypt corrupted data throws EncryptionException`() {
|
||||||
|
val data = "Test".toByteArray(UTF_8)
|
||||||
|
val encrypted = encryptionManager.encrypt(data, "password")
|
||||||
|
|
||||||
|
// Corrupt the ciphertext (skip header: 4 + 1 + 32 + 12 = 49 bytes)
|
||||||
|
val corrupted = encrypted.copyOf()
|
||||||
|
if (corrupted.size > 50) {
|
||||||
|
corrupted[50] = (corrupted[50] + 1).toByte() // Flip one bit
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThrows(EncryptionException::class.java) {
|
||||||
|
encryptionManager.decrypt(corrupted, "password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decrypt data with invalid header throws EncryptionException`() {
|
||||||
|
val invalidData = "This is not encrypted at all".toByteArray(UTF_8)
|
||||||
|
|
||||||
|
assertThrows(EncryptionException::class.java) {
|
||||||
|
encryptionManager.decrypt(invalidData, "password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decrypt truncated data throws EncryptionException`() {
|
||||||
|
val data = "Test".toByteArray(UTF_8)
|
||||||
|
val encrypted = encryptionManager.encrypt(data, "password")
|
||||||
|
|
||||||
|
// Truncate to only header
|
||||||
|
val truncated = encrypted.copyOfRange(0, 20)
|
||||||
|
|
||||||
|
assertThrows(EncryptionException::class.java) {
|
||||||
|
encryptionManager.decrypt(truncated, "password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encrypt with different passwords produces different ciphertexts`() {
|
||||||
|
val data = "Same data".toByteArray(UTF_8)
|
||||||
|
|
||||||
|
val encrypted1 = encryptionManager.encrypt(data, "password1")
|
||||||
|
val encrypted2 = encryptionManager.encrypt(data, "password2")
|
||||||
|
|
||||||
|
// Different passwords should produce different ciphertexts
|
||||||
|
assertFalse(encrypted1.contentEquals(encrypted2))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encrypt same data twice produces different ciphertexts (different IV)`() {
|
||||||
|
val data = "Same data".toByteArray(UTF_8)
|
||||||
|
val password = "same-password"
|
||||||
|
|
||||||
|
val encrypted1 = encryptionManager.encrypt(data, password)
|
||||||
|
val encrypted2 = encryptionManager.encrypt(data, password)
|
||||||
|
|
||||||
|
// Different IVs should produce different ciphertexts
|
||||||
|
assertFalse(encrypted1.contentEquals(encrypted2))
|
||||||
|
|
||||||
|
// But both should decrypt to same original data
|
||||||
|
val decrypted1 = encryptionManager.decrypt(encrypted1, password)
|
||||||
|
val decrypted2 = encryptionManager.decrypt(encrypted2, password)
|
||||||
|
assertArrayEquals(decrypted1, decrypted2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encrypt large data (1MB) succeeds`() {
|
||||||
|
val random = SecureRandom()
|
||||||
|
val largeData = ByteArray(1024 * 1024) // 1 MB
|
||||||
|
random.nextBytes(largeData)
|
||||||
|
val password = "password123"
|
||||||
|
|
||||||
|
val encrypted = encryptionManager.encrypt(largeData, password)
|
||||||
|
val decrypted = encryptionManager.decrypt(encrypted, password)
|
||||||
|
|
||||||
|
assertArrayEquals(largeData, decrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encrypt empty data succeeds`() {
|
||||||
|
val emptyData = ByteArray(0)
|
||||||
|
val password = "password"
|
||||||
|
|
||||||
|
val encrypted = encryptionManager.encrypt(emptyData, password)
|
||||||
|
val decrypted = encryptionManager.decrypt(encrypted, password)
|
||||||
|
|
||||||
|
assertArrayEquals(emptyData, decrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encrypt with empty password succeeds but is unsafe`() {
|
||||||
|
val data = "Test".toByteArray(UTF_8)
|
||||||
|
|
||||||
|
// Crypto library accepts empty passwords (UI prevents this with validation)
|
||||||
|
val encrypted = encryptionManager.encrypt(data, "")
|
||||||
|
val decrypted = encryptionManager.decrypt(encrypted, "")
|
||||||
|
|
||||||
|
assertArrayEquals(data, decrypted)
|
||||||
|
assertTrue("Empty password should still produce encrypted data", encrypted.size > data.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decrypt with unsupported version throws EncryptionException`() {
|
||||||
|
val data = "Test".toByteArray(UTF_8)
|
||||||
|
val encrypted = encryptionManager.encrypt(data, "password")
|
||||||
|
|
||||||
|
// Change version byte to unsupported value (99)
|
||||||
|
val invalidVersion = encrypted.copyOf()
|
||||||
|
invalidVersion[4] = 99.toByte()
|
||||||
|
|
||||||
|
assertThrows(EncryptionException::class.java) {
|
||||||
|
encryptionManager.decrypt(invalidVersion, "password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
docs/DEBUG_APK.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Debug APK für Issue-Testing
|
||||||
|
|
||||||
|
Für Bug-Reports und Testing von Fixes brauchst du eine **Debug-APK**. Diese wird automatisch gebaut, wenn du auf speziellen Branches pushst.
|
||||||
|
|
||||||
|
## 🔧 Branch-Struktur für Debug-APKs
|
||||||
|
|
||||||
|
Debug-APKs werden **automatisch** gebaut für diese Branches:
|
||||||
|
|
||||||
|
| Branch-Typ | Zweck | Beispiel |
|
||||||
|
|-----------|-------|---------|
|
||||||
|
| `debug/*` | Allgemeines Testing | `debug/wifi-only-sync` |
|
||||||
|
| `fix/*` | Bug-Fixes testen | `fix/vpn-connection` |
|
||||||
|
| `feature/*` | Neue Features | `feature/grid-layout` |
|
||||||
|
|
||||||
|
**Andere Branches (main, develop, etc.) bauen KEINE Debug-APKs!**
|
||||||
|
|
||||||
|
## 📥 Debug-APK downloaden
|
||||||
|
|
||||||
|
### 1️⃣ Push zu einem Debug-Branch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Neuen Fix-Branch erstellen
|
||||||
|
git checkout -b fix/my-bug
|
||||||
|
|
||||||
|
# Deine Änderungen machen
|
||||||
|
# ...
|
||||||
|
|
||||||
|
# Commit und Push
|
||||||
|
git add .
|
||||||
|
git commit -m "fix: beschreibung"
|
||||||
|
git push origin fix/my-bug
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2️⃣ GitHub Actions Workflow starten
|
||||||
|
|
||||||
|
- GitHub → **Actions** Tab
|
||||||
|
- **Build Debug APK** Workflow sehen
|
||||||
|
- Warten bis Workflow grün ist ✅
|
||||||
|
|
||||||
|
### 3️⃣ APK herunterladen
|
||||||
|
|
||||||
|
1. Auf den grünen Workflow-Erfolg warten
|
||||||
|
2. **Artifacts** Section oben (oder unten im Workflow)
|
||||||
|
3. `simple-notes-sync-debug-*` herunterladen
|
||||||
|
4. ZIP-Datei entpacken
|
||||||
|
|
||||||
|
**Wichtig:** Artifacts sind nur **30 Tage** verfügbar!
|
||||||
|
|
||||||
|
## 📱 Installation auf Gerät
|
||||||
|
|
||||||
|
## 📱 Installation auf Gerät
|
||||||
|
|
||||||
|
### Mit ADB (Empfohlen - sauberes Testing)
|
||||||
|
```bash
|
||||||
|
# Gerät verbinden
|
||||||
|
adb devices
|
||||||
|
|
||||||
|
# Debug-APK installieren (alte Version wird nicht gelöscht)
|
||||||
|
adb install simple-notes-sync-debug.apk
|
||||||
|
|
||||||
|
# Aus dem Gerät entfernen später:
|
||||||
|
adb uninstall dev.dettmer.simplenotes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manuell auf Gerät
|
||||||
|
1. Datei auf Android-Gerät kopieren
|
||||||
|
2. **Einstellungen → Sicherheit → "Unbekannte Quellen" aktivieren**
|
||||||
|
3. Dateimanager öffnen und APK antippen
|
||||||
|
4. "Installieren" auswählen
|
||||||
|
|
||||||
|
## ⚠️ Debug-APK vs. Release-APK
|
||||||
|
|
||||||
|
| Feature | Debug | Release |
|
||||||
|
|---------|-------|---------|
|
||||||
|
| **Logging** | Voll | Minimal |
|
||||||
|
| **Signatur** | Debug-Key | Release-Key |
|
||||||
|
| **Performance** | Langsamer | Schneller |
|
||||||
|
| **Debugging** | ✅ Möglich | ❌ Nein |
|
||||||
|
| **Installation** | Mehrmals | Kann Probleme geben |
|
||||||
|
|
||||||
|
## 📊 Was zu testen ist
|
||||||
|
|
||||||
|
1. **Neue Features** - Funktionieren wie beschrieben?
|
||||||
|
2. **Bug Fixes** - Ist der Bug wirklich behoben?
|
||||||
|
3. **Kompatibilität** - Funktioniert auf deinem Gerät?
|
||||||
|
4. **Performance** - Läuft die App flüssig?
|
||||||
|
|
||||||
|
## 📝 Feedback geben
|
||||||
|
|
||||||
|
Bitte schreibe einen Kommentar im **Pull Request** oder **GitHub Issue**:
|
||||||
|
- ✅ Was funktioniert
|
||||||
|
- ❌ Was nicht funktioniert
|
||||||
|
- 📋 Fehler-Logs (adb logcat falls relevant)
|
||||||
|
- 📱 Gerät/Android-Version
|
||||||
|
|
||||||
|
## 🐛 Logs sammeln
|
||||||
|
|
||||||
|
Falls der App-Entwickler Debug-Logs braucht:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal öffnen mit adb
|
||||||
|
adb shell pm grant dev.dettmer.simplenotes android.permission.READ_LOGS
|
||||||
|
|
||||||
|
# Logs anschauen (live)
|
||||||
|
adb logcat | grep simplenotes
|
||||||
|
|
||||||
|
# Logs speichern (Datei)
|
||||||
|
adb logcat > debug-log.txt
|
||||||
|
|
||||||
|
# Nach Fehler filtern
|
||||||
|
adb logcat | grep -E "ERROR|Exception|CRASH"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Danke fürs Testing! Dein Feedback hilft uns, die App zu verbessern.** 🙏
|
||||||
@@ -174,30 +174,68 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
|
|||||||
|
|
||||||
## 🔋 Akku-Optimierung
|
## 🔋 Akku-Optimierung
|
||||||
|
|
||||||
### Verbrauchsanalyse
|
### v1.6.0: Konfigurierbare Sync-Trigger
|
||||||
|
|
||||||
|
Seit v1.6.0 kann jeder Sync-Trigger einzeln aktiviert/deaktiviert werden. Das gibt Nutzern feine Kontrolle über den Akkuverbrauch.
|
||||||
|
|
||||||
|
#### Sync-Trigger Übersicht
|
||||||
|
|
||||||
|
| Trigger | Standard | Akku-Impact | Beschreibung |
|
||||||
|
|---------|----------|-------------|--------------|
|
||||||
|
| **Manueller Sync** | Immer an | 0 (nutzer-getriggert) | Toolbar-Button / Pull-to-Refresh |
|
||||||
|
| **onSave Sync** | ✅ AN | ~0.5 mAh/Speichern | Sync sofort nach Speichern einer Notiz |
|
||||||
|
| **onResume Sync** | ✅ AN | ~0.3 mAh/Öffnen | Sync beim App-Öffnen (60s Throttle) |
|
||||||
|
| **WiFi-Connect** | ✅ AN | ~0.5 mAh/Verbindung | Sync bei WiFi-Verbindung |
|
||||||
|
| **Periodic Sync** | ❌ AUS | 0.2-0.8%/Tag | Hintergrund-Sync alle 15/30/60 Min |
|
||||||
|
| **Boot Sync** | ❌ AUS | ~0.1 mAh/Boot | Start Hintergrund-Sync nach Neustart |
|
||||||
|
|
||||||
|
#### Akku-Verbrauchsberechnung
|
||||||
|
|
||||||
|
**Typisches Nutzungsszenario (Standardeinstellungen):**
|
||||||
|
- onSave: ~5 Speichern/Tag × 0.5 mAh = **~2.5 mAh**
|
||||||
|
- onResume: ~10 Öffnen/Tag × 0.3 mAh = **~3 mAh**
|
||||||
|
- WiFi-Connect: ~2 Verbindungen/Tag × 0.5 mAh = **~1 mAh**
|
||||||
|
- **Gesamt: ~6.5 mAh/Tag (~0.2% bei 3000mAh Akku)**
|
||||||
|
|
||||||
|
**Mit aktiviertem Periodic Sync (15/30/60 Min):**
|
||||||
|
|
||||||
|
| Intervall | Syncs/Tag | Akku/Tag | Gesamt (mit Standards) |
|
||||||
|
|-----------|-----------|----------|------------------------|
|
||||||
|
| **15 Min** | ~96 | ~23 mAh | ~30 mAh (~1.0%) |
|
||||||
|
| **30 Min** | ~48 | ~12 mAh | ~19 mAh (~0.6%) |
|
||||||
|
| **60 Min** | ~24 | ~6 mAh | ~13 mAh (~0.4%) |
|
||||||
|
|
||||||
|
#### Komponenten-Aufschlüsselung
|
||||||
|
|
||||||
| Komponente | Frequenz | Verbrauch | Details |
|
| Komponente | Frequenz | Verbrauch | Details |
|
||||||
|------------|----------|-----------|---------|
|
|------------|----------|-----------|---------|
|
||||||
| WorkManager Wakeup | Alle 30 Min | ~0.15 mAh | System wacht auf |
|
| WorkManager Wakeup | Pro Sync | ~0.15 mAh | System wacht auf |
|
||||||
| Network Check | 48x/Tag | ~0.03 mAh | Gateway IP check |
|
| Network Check | Pro Sync | ~0.03 mAh | Gateway IP Check |
|
||||||
| WebDAV Sync | 2-3x/Tag | ~1.5 mAh | Nur bei Änderungen |
|
| WebDAV Sync | Nur bei Änderungen | ~0.25 mAh | HTTP PUT/GET |
|
||||||
| **Total** | - | **~12 mAh/Tag** | **~0.4%** bei 3000mAh |
|
| **Pro-Sync Gesamt** | - | **~0.25 mAh** | Optimiert |
|
||||||
|
|
||||||
### Optimierungen
|
### Optimierungen
|
||||||
|
|
||||||
1. **IP Caching**
|
1. **Pre-Checks vor Sync**
|
||||||
|
```kotlin
|
||||||
|
// Reihenfolge wichtig! Günstigste Checks zuerst
|
||||||
|
if (!hasUnsyncedChanges()) return // Lokaler Check (günstig)
|
||||||
|
if (!isServerReachable()) return // Netzwerk Check (teuer)
|
||||||
|
performSync() // Nur wenn beide bestehen
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Throttling**
|
||||||
|
- onResume: 60 Sekunden Mindestabstand
|
||||||
|
- onSave: 5 Sekunden Mindestabstand
|
||||||
|
- Periodic: 15/30/60 Minuten Intervalle
|
||||||
|
|
||||||
|
3. **IP Caching**
|
||||||
```kotlin
|
```kotlin
|
||||||
private var cachedServerIP: String? = null
|
private var cachedServerIP: String? = null
|
||||||
// DNS lookup nur 1x beim Start, nicht bei jedem Check
|
// DNS lookup nur 1x beim Start, nicht bei jedem Check
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Throttling**
|
4. **Conditional Logging**
|
||||||
```kotlin
|
|
||||||
private var lastSyncTime = 0L
|
|
||||||
private const val MIN_SYNC_INTERVAL_MS = 60_000L // Max 1 Sync/Min
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Conditional Logging**
|
|
||||||
```kotlin
|
```kotlin
|
||||||
object Logger {
|
object Logger {
|
||||||
fun d(tag: String, msg: String) {
|
fun d(tag: String, msg: String) {
|
||||||
@@ -206,7 +244,7 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Network Constraints**
|
5. **Network Constraints**
|
||||||
- Nur WiFi (nicht mobile Daten)
|
- Nur WiFi (nicht mobile Daten)
|
||||||
- Nur wenn Server erreichbar
|
- Nur wenn Server erreichbar
|
||||||
- Keine permanenten Listeners
|
- Keine permanenten Listeners
|
||||||
|
|||||||
68
docs/DOCS.md
@@ -174,30 +174,68 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
|
|||||||
|
|
||||||
## 🔋 Battery Optimization
|
## 🔋 Battery Optimization
|
||||||
|
|
||||||
### Usage Analysis
|
### v1.6.0: Configurable Sync Triggers
|
||||||
|
|
||||||
|
Since v1.6.0, each sync trigger can be individually enabled/disabled. This gives users fine-grained control over battery usage.
|
||||||
|
|
||||||
|
#### Sync Trigger Overview
|
||||||
|
|
||||||
|
| Trigger | Default | Battery Impact | Description |
|
||||||
|
|---------|---------|----------------|-------------|
|
||||||
|
| **Manual Sync** | Always on | 0 (user-triggered) | Toolbar button / Pull-to-refresh |
|
||||||
|
| **onSave Sync** | ✅ ON | ~0.5 mAh/save | Sync immediately after saving a note |
|
||||||
|
| **onResume Sync** | ✅ ON | ~0.3 mAh/resume | Sync when app is opened (60s throttle) |
|
||||||
|
| **WiFi-Connect** | ✅ ON | ~0.5 mAh/connect | Sync when WiFi is connected |
|
||||||
|
| **Periodic Sync** | ❌ OFF | 0.2-0.8%/day | Background sync every 15/30/60 min |
|
||||||
|
| **Boot Sync** | ❌ OFF | ~0.1 mAh/boot | Start background sync after reboot |
|
||||||
|
|
||||||
|
#### Battery Usage Calculation
|
||||||
|
|
||||||
|
**Typical usage scenario (defaults):**
|
||||||
|
- onSave: ~5 saves/day × 0.5 mAh = **~2.5 mAh**
|
||||||
|
- onResume: ~10 opens/day × 0.3 mAh = **~3 mAh**
|
||||||
|
- WiFi-Connect: ~2 connects/day × 0.5 mAh = **~1 mAh**
|
||||||
|
- **Total: ~6.5 mAh/day (~0.2% on 3000mAh battery)**
|
||||||
|
|
||||||
|
**With Periodic Sync enabled (15/30/60 min):**
|
||||||
|
|
||||||
|
| Interval | Syncs/day | Battery/day | Total (with defaults) |
|
||||||
|
|----------|-----------|-------------|----------------------|
|
||||||
|
| **15 min** | ~96 | ~23 mAh | ~30 mAh (~1.0%) |
|
||||||
|
| **30 min** | ~48 | ~12 mAh | ~19 mAh (~0.6%) |
|
||||||
|
| **60 min** | ~24 | ~6 mAh | ~13 mAh (~0.4%) |
|
||||||
|
|
||||||
|
#### Component Breakdown
|
||||||
|
|
||||||
| Component | Frequency | Usage | Details |
|
| Component | Frequency | Usage | Details |
|
||||||
|------------|----------|-----------|---------|
|
|-----------|-----------|-------|---------|
|
||||||
| WorkManager Wakeup | Every 30 min | ~0.15 mAh | System wakes up |
|
| WorkManager Wakeup | Per sync | ~0.15 mAh | System wakes up |
|
||||||
| Network Check | 48x/day | ~0.03 mAh | Gateway IP check |
|
| Network Check | Per sync | ~0.03 mAh | Gateway IP check |
|
||||||
| WebDAV Sync | 2-3x/day | ~1.5 mAh | Only when changes |
|
| WebDAV Sync | Only if changes | ~0.25 mAh | HTTP PUT/GET |
|
||||||
| **Total** | - | **~12 mAh/day** | **~0.4%** at 3000mAh |
|
| **Per-Sync Total** | - | **~0.25 mAh** | Optimized |
|
||||||
|
|
||||||
### Optimizations
|
### Optimizations
|
||||||
|
|
||||||
1. **IP Caching**
|
1. **Pre-Checks before Sync**
|
||||||
|
```kotlin
|
||||||
|
// Order matters! Cheapest checks first
|
||||||
|
if (!hasUnsyncedChanges()) return // Local check (cheap)
|
||||||
|
if (!isServerReachable()) return // Network check (expensive)
|
||||||
|
performSync() // Only if both pass
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Throttling**
|
||||||
|
- onResume: 60 second minimum interval
|
||||||
|
- onSave: 5 second minimum interval
|
||||||
|
- Periodic: 15/30/60 minute intervals
|
||||||
|
|
||||||
|
3. **IP Caching**
|
||||||
```kotlin
|
```kotlin
|
||||||
private var cachedServerIP: String? = null
|
private var cachedServerIP: String? = null
|
||||||
// DNS lookup only once at start, not every check
|
// DNS lookup only once at start, not every check
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Throttling**
|
4. **Conditional Logging**
|
||||||
```kotlin
|
|
||||||
private var lastSyncTime = 0L
|
|
||||||
private const val MIN_SYNC_INTERVAL_MS = 60_000L // Max 1 sync/min
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Conditional Logging**
|
|
||||||
```kotlin
|
```kotlin
|
||||||
object Logger {
|
object Logger {
|
||||||
fun d(tag: String, msg: String) {
|
fun d(tag: String, msg: String) {
|
||||||
@@ -206,7 +244,7 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Network Constraints**
|
5. **Network Constraints**
|
||||||
- WiFi only (not mobile data)
|
- WiFi only (not mobile data)
|
||||||
- Only when server is reachable
|
- Only when server is reachable
|
||||||
- No permanent listeners
|
- No permanent listeners
|
||||||
|
|||||||
@@ -169,16 +169,19 @@
|
|||||||
|
|
||||||
## 🔋 Performance & Optimierung
|
## 🔋 Performance & Optimierung
|
||||||
|
|
||||||
### Akku-Effizienz
|
### Akku-Effizienz (v1.6.0)
|
||||||
- ✅ **Optimierte Sync-Intervalle** - 15/30/60 Min
|
- ✅ **Konfigurierbare Sync-Trigger** - Jeden Trigger einzeln aktivieren/deaktivieren
|
||||||
|
- ✅ **Smarte Defaults** - Nur ereignisbasierte Trigger standardmäßig aktiv
|
||||||
|
- ✅ **Optimierte Periodische Intervalle** - 15/30/60 Min (Standard: AUS)
|
||||||
- ✅ **WiFi-Only** - Kein Mobile Data Sync
|
- ✅ **WiFi-Only** - Kein Mobile Data Sync
|
||||||
- ✅ **Smart Server-Check** - Sync nur wenn Server erreichbar
|
- ✅ **Smart Server-Check** - Sync nur wenn Server erreichbar
|
||||||
- ✅ **WorkManager** - System-optimierte Ausführung
|
- ✅ **WorkManager** - System-optimierte Ausführung
|
||||||
- ✅ **Doze Mode kompatibel** - Sync läuft auch im Standby
|
- ✅ **Doze Mode kompatibel** - Sync läuft auch im Standby
|
||||||
- ✅ **Gemessener Verbrauch:**
|
- ✅ **Gemessener Verbrauch:**
|
||||||
- 15 Min: ~0.8% / Tag (~23 mAh)
|
- Standard (nur ereignisbasiert): ~0.2%/Tag (~6.5 mAh) ⭐ _Optimal_
|
||||||
- 30 Min: ~0.4% / Tag (~12 mAh) ⭐ _Empfohlen_
|
- Mit Periodic 15 Min: ~1.0%/Tag (~30 mAh)
|
||||||
- 60 Min: ~0.2% / Tag (~6 mAh)
|
- Mit Periodic 30 Min: ~0.6%/Tag (~19 mAh)
|
||||||
|
- Mit Periodic 60 Min: ~0.4%/Tag (~13 mAh)
|
||||||
|
|
||||||
### App-Performance
|
### App-Performance
|
||||||
- ✅ **Offline-First** - Funktioniert ohne Internet
|
- ✅ **Offline-First** - Funktioniert ohne Internet
|
||||||
|
|||||||
@@ -169,16 +169,19 @@
|
|||||||
|
|
||||||
## 🔋 Performance & Optimization
|
## 🔋 Performance & Optimization
|
||||||
|
|
||||||
### Battery Efficiency
|
### Battery Efficiency (v1.6.0)
|
||||||
- ✅ **Optimized sync intervals** - 15/30/60 min
|
- ✅ **Configurable sync triggers** - Enable/disable each trigger individually
|
||||||
|
- ✅ **Smart defaults** - Only event-driven triggers active by default
|
||||||
|
- ✅ **Optimized periodic intervals** - 15/30/60 min (default: OFF)
|
||||||
- ✅ **WiFi-only** - No mobile data sync
|
- ✅ **WiFi-only** - No mobile data sync
|
||||||
- ✅ **Smart server check** - Sync only when server is reachable
|
- ✅ **Smart server check** - Sync only when server is reachable
|
||||||
- ✅ **WorkManager** - System-optimized execution
|
- ✅ **WorkManager** - System-optimized execution
|
||||||
- ✅ **Doze mode compatible** - Sync runs even in standby
|
- ✅ **Doze mode compatible** - Sync runs even in standby
|
||||||
- ✅ **Measured consumption:**
|
- ✅ **Measured consumption:**
|
||||||
- 15 min: ~0.8% / day (~23 mAh)
|
- Default (event-driven only): ~0.2%/day (~6.5 mAh) ⭐ _Optimal_
|
||||||
- 30 min: ~0.4% / day (~12 mAh) ⭐ _Recommended_
|
- With periodic 15 min: ~1.0%/day (~30 mAh)
|
||||||
- 60 min: ~0.2% / day (~6 mAh)
|
- With periodic 30 min: ~0.6%/day (~19 mAh)
|
||||||
|
- With periodic 60 min: ~0.4%/day (~13 mAh)
|
||||||
|
|
||||||
### App Performance
|
### App Performance
|
||||||
- ✅ **Offline-first** - Works without internet
|
- ✅ **Offline-first** - Works without internet
|
||||||
|
|||||||
166
docs/SELF_SIGNED_SSL.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# Self-Signed SSL Certificate Support
|
||||||
|
|
||||||
|
**Since:** v1.7.0
|
||||||
|
**Status:** ✅ Supported
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Simple Notes Sync now supports connecting to WebDAV servers with self-signed SSL certificates, such as:
|
||||||
|
- ownCloud/Nextcloud with self-signed certificates
|
||||||
|
- Synology NAS with default certificates
|
||||||
|
- Raspberry Pi or home servers
|
||||||
|
- Internal corporate servers with private CAs
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
### Step 1: Export Your Server's CA Certificate
|
||||||
|
|
||||||
|
**On your server:**
|
||||||
|
|
||||||
|
1. Locate your certificate file (usually `.crt`, `.pem`, or `.der` format)
|
||||||
|
2. If you created the certificate yourself, you already have it
|
||||||
|
3. For Synology NAS: Control Panel → Security → Certificate → Export
|
||||||
|
4. For ownCloud/Nextcloud: Usually in `/etc/ssl/certs/` on the server
|
||||||
|
|
||||||
|
### Step 2: Install Certificate on Android
|
||||||
|
|
||||||
|
**On your Android device:**
|
||||||
|
|
||||||
|
1. **Transfer** the `.crt` or `.pem` file to your phone (via email, USB, etc.)
|
||||||
|
|
||||||
|
2. **Open Settings** → Security → More security settings (or Encryption & credentials)
|
||||||
|
|
||||||
|
3. **Install from storage** / "Install a certificate"
|
||||||
|
- Choose "CA certificate"
|
||||||
|
- **Warning:** Android will display a security warning. This is normal.
|
||||||
|
- Tap "Install anyway"
|
||||||
|
|
||||||
|
4. **Browse** to your certificate file and select it
|
||||||
|
|
||||||
|
5. **Name** it something recognizable (e.g., "My ownCloud CA")
|
||||||
|
|
||||||
|
6. ✅ **Done!** The certificate is now trusted system-wide
|
||||||
|
|
||||||
|
### Step 3: Connect Simple Notes Sync
|
||||||
|
|
||||||
|
1. Open Simple Notes Sync
|
||||||
|
2. Go to **Settings** → **Server Settings**
|
||||||
|
3. Enter your **`https://` server URL** as usual
|
||||||
|
4. The app will now trust your self-signed certificate ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
### ⚠️ Important
|
||||||
|
|
||||||
|
- Installing a CA certificate grants trust to **all** certificates signed by that CA
|
||||||
|
- Only install certificates from sources you trust
|
||||||
|
- Android will warn you before installation – read the warning carefully
|
||||||
|
|
||||||
|
### 🔒 Why This is Safe
|
||||||
|
|
||||||
|
- You **manually** install the certificate (conscious decision)
|
||||||
|
- The app uses Android's native trust store (no custom validation)
|
||||||
|
- You can remove the certificate anytime from Android Settings
|
||||||
|
- F-Droid and Google Play compliant (no "trust all" hack)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Certificate Not Trusted
|
||||||
|
|
||||||
|
**Problem:** App still shows SSL error after installing certificate
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. **Verify installation:** Settings → Security → Trusted credentials → User tab
|
||||||
|
2. **Check certificate type:** Must be a CA certificate, not a server certificate
|
||||||
|
3. **Restart app:** Close and reopen Simple Notes Sync
|
||||||
|
4. **Check URL:** Must use `https://` (not `http://`)
|
||||||
|
|
||||||
|
### "Network Security Policy" Error
|
||||||
|
|
||||||
|
**Problem:** Android 7+ restricts user certificates for apps
|
||||||
|
|
||||||
|
**Solution:** This app is configured to trust user certificates ✅
|
||||||
|
If the problem persists, check:
|
||||||
|
- Certificate is installed in "User" tab (not "System")
|
||||||
|
- Certificate is not expired
|
||||||
|
- Server URL matches certificate's Common Name (CN) or Subject Alternative Name (SAN)
|
||||||
|
|
||||||
|
### Self-Signed vs. CA-Signed
|
||||||
|
|
||||||
|
| Type | Installation Required | Security |
|
||||||
|
|------|---------------------|----------|
|
||||||
|
| **Self-Signed** | ✅ Yes | Manual trust |
|
||||||
|
| **Let's Encrypt** | ❌ No | Automatic |
|
||||||
|
| **Private CA** | ✅ Yes (CA root) | Automatic for all CA-signed certs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternative: Use Let's Encrypt (Recommended)
|
||||||
|
|
||||||
|
If your server is publicly accessible, consider using **Let's Encrypt** for free, automatically-renewed SSL certificates:
|
||||||
|
|
||||||
|
- No manual certificate installation needed
|
||||||
|
- Trusted by all devices automatically
|
||||||
|
- Easier for end users
|
||||||
|
|
||||||
|
**Setup guides:**
|
||||||
|
- [ownCloud Let's Encrypt](https://doc.owncloud.com/server/admin_manual/installation/letsencrypt/)
|
||||||
|
- [Nextcloud Let's Encrypt](https://docs.nextcloud.com/server/latest/admin_manual/installation/letsencrypt.html)
|
||||||
|
- [Synology Let's Encrypt](https://kb.synology.com/en-us/DSM/tutorial/How_to_enable_HTTPS_and_create_a_certificate_signing_request_on_your_Synology_NAS)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- Uses Android's **Network Security Config**
|
||||||
|
- Trusts both system and user CA certificates
|
||||||
|
- No custom TrustManager or hostname verifier
|
||||||
|
- F-Droid and Play Store compliant
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
File: `android/app/src/main/res/xml/network_security_config.xml`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<base-config>
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
<certificates src="user" /> <!-- ← Enables self-signed support -->
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Q: Do I need to reinstall the certificate after app updates?**
|
||||||
|
A: No, certificates are stored system-wide, not per-app.
|
||||||
|
|
||||||
|
**Q: Can I use the same certificate for multiple apps?**
|
||||||
|
A: Yes, once installed, it works for all apps that trust user certificates.
|
||||||
|
|
||||||
|
**Q: How do I remove a certificate?**
|
||||||
|
A: Settings → Security → Trusted credentials → User tab → Tap certificate → Remove
|
||||||
|
|
||||||
|
**Q: Does this work on Android 14+?**
|
||||||
|
A: Yes, tested on Android 7 through 15 (API 24-35).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Issues
|
||||||
|
|
||||||
|
- [GitHub Issue #X](link) - User request for ownCloud support
|
||||||
|
- [Feature Analysis](../project-docs/simple-notes-sync/features/SELF_SIGNED_SSL_CERTIFICATES_ANALYSIS.md) - Technical analysis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Need help?** Open an issue on [GitHub](https://github.com/inventory69/simple-notes-sync/issues)
|
||||||
@@ -31,9 +31,46 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.6.0 - Technische Modernisierung
|
## v1.6.0 - Technische Modernisierung ✅
|
||||||
|
|
||||||
> **Status:** In Planung 📋
|
> **Status:** Released 🎉 (Januar 2026)
|
||||||
|
|
||||||
|
### ⚙️ Konfigurierbare Sync-Trigger
|
||||||
|
|
||||||
|
- ✅ **Individuelle Trigger-Kontrolle** - Jeden Sync-Trigger einzeln aktivieren/deaktivieren
|
||||||
|
- ✅ **Ereignisbasierte Defaults** - onSave, onResume, WiFi-Connect standardmäßig aktiv
|
||||||
|
- ✅ **Periodischer Sync optional** - 15/30/60 Min Intervalle (Standard: AUS)
|
||||||
|
- ✅ **Boot Sync optional** - Periodischen Sync nach Geräteneustart starten (Standard: AUS)
|
||||||
|
- ✅ **Offline-Modus UI** - Ausgegraute Toggles wenn kein Server konfiguriert
|
||||||
|
- ✅ **Akku-optimiert** - ~0.2%/Tag mit Defaults, bis zu ~1.0% mit Periodic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.6.1 - Clean Code ✅
|
||||||
|
|
||||||
|
> **Status:** Released 🎉 (Januar 2026)
|
||||||
|
|
||||||
|
### 🧹 Code-Qualität
|
||||||
|
|
||||||
|
- ✅ **detekt: 0 Issues** - Alle 29 Code-Qualitäts-Issues behoben
|
||||||
|
- ✅ **Zero Build Warnings** - Alle 21 Deprecation Warnings eliminiert
|
||||||
|
- ✅ **ktlint reaktiviert** - Mit Compose-spezifischen Regeln
|
||||||
|
- ✅ **CI/CD Lint-Checks** - In PR Build Workflow integriert
|
||||||
|
- ✅ **Constants Refactoring** - Dimensions.kt, SyncConstants.kt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.7.0 - Staggered Grid Layout
|
||||||
|
|
||||||
|
> **Status:** Geplant 📝
|
||||||
|
|
||||||
|
### 🎨 Adaptives Layout
|
||||||
|
|
||||||
|
- **Staggered Grid** - Pinterest-artiges Layout mit `LazyVerticalStaggeredGrid`
|
||||||
|
- **Intelligente Größen** - Kleine Notizen (kurzer Text, wenige Checklist-Items) kompakt dargestellt
|
||||||
|
- **Layout-Umschalter** - Zwischen Listen- und Grid-Ansicht in Einstellungen wechseln
|
||||||
|
- **Adaptive Spalten** - 2-3 Spalten basierend auf Bildschirmgröße
|
||||||
|
- **120 FPS optimiert** - Lazy Loading für flüssiges Scrollen bei vielen Notizen
|
||||||
|
|
||||||
### 🔧 Server-Ordner Prüfung
|
### 🔧 Server-Ordner Prüfung
|
||||||
|
|
||||||
@@ -43,22 +80,43 @@
|
|||||||
|
|
||||||
### 🔧 Technische Verbesserungen
|
### 🔧 Technische Verbesserungen
|
||||||
|
|
||||||
- **Code-Refactoring** - LongMethod und LargeClass Warnings beheben
|
- **Code-Refactoring** - LargeClass Komponenten aufteilen (WebDavSyncService, SettingsActivity)
|
||||||
- **Modernere Background-Sync Architektur** - Noch zuverlässiger
|
|
||||||
- **Verbesserte Progress-Dialoge** - Material Design 3 konform
|
- **Verbesserte Progress-Dialoge** - Material Design 3 konform
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.7.0 - Community Features
|
## v2.0.0 - Legacy Cleanup
|
||||||
|
|
||||||
> **Status:** Ideen-Sammlung 💡
|
> **Status:** Geplant 📝
|
||||||
|
|
||||||
### Mögliche Features
|
### 🗑️ Legacy Code Entfernung
|
||||||
|
|
||||||
- **Zusätzliche Sprachen** - Community-Übersetzungen (FR, ES, IT, ...)
|
- **SettingsActivity entfernen** - Ersetzt durch ComposeSettingsActivity
|
||||||
|
- **MainActivity entfernen** - Ersetzt durch ComposeMainActivity
|
||||||
|
- **LocalBroadcastManager → SharedFlow** - Moderne Event-Architektur
|
||||||
|
- **ProgressDialog → Material Dialog** - Volle Material 3 Konformität
|
||||||
|
- **AbstractSavedStateViewModelFactory → viewModelFactory** - Moderne ViewModel-Erstellung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Backlog
|
||||||
|
|
||||||
|
> Features für zukünftige Überlegungen
|
||||||
|
|
||||||
|
### 🔐 Sicherheits-Verbesserungen
|
||||||
|
|
||||||
|
- **Passwortgeschützte lokale Backups** - Backup-ZIP mit Passwort verschlüsseln
|
||||||
|
- **Biometrische Entsperrung** - Fingerabdruck/Gesichtserkennung für App
|
||||||
|
|
||||||
|
### 🎨 UI Features
|
||||||
|
|
||||||
|
- **Widget** - Schnellzugriff vom Homescreen
|
||||||
- **Kategorien/Tags** - Notizen organisieren
|
- **Kategorien/Tags** - Notizen organisieren
|
||||||
- **Suche** - Volltextsuche in Notizen
|
- **Suche** - Volltextsuche in Notizen
|
||||||
- **Widget** - Schnellzugriff vom Homescreen
|
|
||||||
|
### 🌍 Community
|
||||||
|
|
||||||
|
- **Zusätzliche Sprachen** - Community-Übersetzungen (FR, ES, IT, ...)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -31,9 +31,46 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.6.0 - Technical Modernization
|
## v1.6.0 - Technical Modernization ✅
|
||||||
|
|
||||||
> **Status:** Planned 📋
|
> **Status:** Released 🎉 (January 2026)
|
||||||
|
|
||||||
|
### ⚙️ Configurable Sync Triggers
|
||||||
|
|
||||||
|
- ✅ **Individual trigger control** - Enable/disable each sync trigger separately
|
||||||
|
- ✅ **Event-driven defaults** - onSave, onResume, WiFi-Connect active by default
|
||||||
|
- ✅ **Periodic sync optional** - 15/30/60 min intervals (default: OFF)
|
||||||
|
- ✅ **Boot sync optional** - Start periodic sync after device restart (default: OFF)
|
||||||
|
- ✅ **Offline mode UI** - Grayed-out toggles when no server configured
|
||||||
|
- ✅ **Battery optimized** - ~0.2%/day with defaults, up to ~1.0% with periodic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.6.1 - Clean Code ✅
|
||||||
|
|
||||||
|
> **Status:** Released 🎉 (January 2026)
|
||||||
|
|
||||||
|
### 🧹 Code Quality
|
||||||
|
|
||||||
|
- ✅ **detekt: 0 issues** - All 29 code quality issues fixed
|
||||||
|
- ✅ **Zero build warnings** - All 21 deprecation warnings eliminated
|
||||||
|
- ✅ **ktlint reactivated** - With Compose-specific rules
|
||||||
|
- ✅ **CI/CD lint checks** - Integrated into PR build workflow
|
||||||
|
- ✅ **Constants refactoring** - Dimensions.kt, SyncConstants.kt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.7.0 - Staggered Grid Layout
|
||||||
|
|
||||||
|
> **Status:** Planned 📝
|
||||||
|
|
||||||
|
### 🎨 Adaptive Layout
|
||||||
|
|
||||||
|
- **Staggered Grid** - Pinterest-style layout using `LazyVerticalStaggeredGrid`
|
||||||
|
- **Smart sizing** - Small notes (short text, few checklist items) displayed compactly
|
||||||
|
- **Layout toggle** - Switch between List and Grid view in settings
|
||||||
|
- **Adaptive columns** - 2-3 columns based on screen size
|
||||||
|
- **120 FPS optimized** - Lazy loading for smooth scrolling with many notes
|
||||||
|
|
||||||
### 🔧 Server Folder Check
|
### 🔧 Server Folder Check
|
||||||
|
|
||||||
@@ -43,22 +80,43 @@
|
|||||||
|
|
||||||
### 🔧 Technical Improvements
|
### 🔧 Technical Improvements
|
||||||
|
|
||||||
- **Code refactoring** - Fix LongMethod and LargeClass warnings
|
- **Code refactoring** - Split LargeClass components (WebDavSyncService, SettingsActivity)
|
||||||
- **Modern background sync architecture** - Even more reliable
|
|
||||||
- **Improved progress dialogs** - Material Design 3 compliant
|
- **Improved progress dialogs** - Material Design 3 compliant
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.7.0 - Community Features
|
## v2.0.0 - Legacy Cleanup
|
||||||
|
|
||||||
> **Status:** Idea Collection 💡
|
> **Status:** Planned 📝
|
||||||
|
|
||||||
### Potential Features
|
### 🗑️ Legacy Code Removal
|
||||||
|
|
||||||
- **Additional languages** - Community translations (FR, ES, IT, ...)
|
- **Remove SettingsActivity** - Replaced by ComposeSettingsActivity
|
||||||
|
- **Remove MainActivity** - Replaced by ComposeMainActivity
|
||||||
|
- **LocalBroadcastManager → SharedFlow** - Modern event architecture
|
||||||
|
- **ProgressDialog → Material Dialog** - Full Material 3 compliance
|
||||||
|
- **AbstractSavedStateViewModelFactory → viewModelFactory** - Modern ViewModel creation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Backlog
|
||||||
|
|
||||||
|
> Features for future consideration
|
||||||
|
|
||||||
|
### 🔐 Security Enhancements
|
||||||
|
|
||||||
|
- **Password-protected local backups** - Encrypt backup ZIP with password
|
||||||
|
- **Biometric unlock option** - Fingerprint/Face unlock for app
|
||||||
|
|
||||||
|
### 🎨 UI Features
|
||||||
|
|
||||||
|
- **Widget** - Quick access from homescreen
|
||||||
- **Categories/Tags** - Organize notes
|
- **Categories/Tags** - Organize notes
|
||||||
- **Search** - Full-text search in notes
|
- **Search** - Full-text search in notes
|
||||||
- **Widget** - Quick access from homescreen
|
|
||||||
|
### 🌍 Community
|
||||||
|
|
||||||
|
- **Additional languages** - Community translations (FR, ES, IT, ...)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
6
fastlane/metadata/android/de-DE/changelogs/14.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
• NEU: Konfigurierbare Sync-Trigger - Jeden einzeln aktivieren/deaktivieren
|
||||||
|
• NEU: Offline-Modus - Alle Netzwerkfunktionen mit einem Schalter aus
|
||||||
|
• 5 Trigger: onSave, onResume, WiFi, Periodic (15/30/60 Min), Boot
|
||||||
|
• Smarte Defaults: Nur ereignisbasiert aktiv (~0.2%/Tag Akku)
|
||||||
|
• Periodischer Sync optional (Standard: AUS)
|
||||||
|
• Verschiedene Fixes und UI-Verbesserungen
|
||||||
2
fastlane/metadata/android/de-DE/changelogs/15.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
• Code Quality Verbesserungen
|
||||||
|
• Bessere Vorbereitung für zukünftige Updates
|
||||||
2
fastlane/metadata/android/de-DE/changelogs/16.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
• Behebt Offline-Modus Problem nach Update von v1.5.0
|
||||||
|
• Nutzer mit konfiguriertem Server werden nicht mehr fälschlicherweise als offline angezeigt
|
||||||
7
fastlane/metadata/android/de-DE/changelogs/17.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
• Neu: Raster-Ansicht - Danke an freemen
|
||||||
|
• Neu: Nur-WLAN Sync Toggle in Einstellungen
|
||||||
|
• Neu: Verschlüsselung bei lokalen Backups - Danke an @SilentCoderHere (#9)
|
||||||
|
• Behoben: Sync funktioniert korrekt bei aktivem VPN - Danke an @roughnecks (#11)
|
||||||
|
• Verbessert: Server-Wechsel - Sync-Status wird für alle Notizen zurückgesetzt
|
||||||
|
• Verbessert: "Sync läuft bereits" Feedback bei weiteren Ausführungen
|
||||||
|
• Verschiedene Fixes und UI-Verbesserungen
|
||||||
@@ -1,60 +1,32 @@
|
|||||||
Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisation.
|
Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisation und modernen Features.
|
||||||
|
|
||||||
HAUPTFUNKTIONEN:
|
Hauptfunktionen:
|
||||||
|
• Text-Notizen und Checklisten (Tap-to-Check, Drag & Drop)
|
||||||
• Text-Notizen und Checklisten erstellen
|
• NEU: Raster-Ansicht (Grid View) für Notizen
|
||||||
• Checklisten mit Tap-to-Check und Drag & Drop
|
|
||||||
• Auswahlmodus: Long-Press zur Mehrfachauswahl für Batch-Aktionen
|
|
||||||
• WebDAV-Synchronisation mit eigenem Server
|
|
||||||
• Multi-Device Sync (Handy, Tablet, Desktop)
|
• Multi-Device Sync (Handy, Tablet, Desktop)
|
||||||
• Markdown-Export für Obsidian/Desktop-Editoren
|
• WebDAV-Synchronisation mit eigenem Server (Nextcloud, ownCloud, etc.)
|
||||||
• Checklisten als GitHub-Style Task-Listen exportieren
|
• Markdown-Export und Import für Desktop-Editoren (Obsidian, VS Code)
|
||||||
• Automatische Synchronisation im Heim-WLAN
|
• NEU: WiFi-only Sync, VPN-Unterstützung, Verschlüsselung für lokale Backups
|
||||||
• Konfigurierbares Sync-Interval (15/30/60 Minuten)
|
• Konfigurierbare Sync-Trigger: onSave, onResume, WiFi, periodisch, Boot
|
||||||
• Material Design 3 mit Dynamic Colors (Android 12+)
|
|
||||||
• Jetpack Compose UI - modern, schnell und flüssig
|
|
||||||
• Komplett offline nutzbar
|
• Komplett offline nutzbar
|
||||||
• Keine Werbung, keine Tracker
|
• Keine Werbung, keine Tracker
|
||||||
|
|
||||||
MEHRSPRACHIG:
|
Datenschutz & Sicherheit:
|
||||||
|
• Alle Daten bleiben bei dir – keine Cloud, keine Tracking-Bibliotheken
|
||||||
|
• Unterstützung für selbstsignierte SSL-Zertifikate (Self-signed SSL)
|
||||||
|
• SHA-256 Hash des Signaturzertifikats in App und Releases sichtbar
|
||||||
|
|
||||||
• Englische und deutsche Sprachunterstützung
|
Synchronisation:
|
||||||
• Per-App Sprachauswahl (Android 13+)
|
• Automatisch oder manuell, optimierte Performance, periodischer Sync optional
|
||||||
• Automatische Systemsprachen-Erkennung
|
• Intelligente Konfliktlösung, Lösch-Tracking, Batch-Aktionen
|
||||||
• Über 400 übersetzte Strings
|
|
||||||
|
|
||||||
DATENSCHUTZ:
|
UI & Design:
|
||||||
|
• Moderne Jetpack Compose Oberfläche
|
||||||
|
• Material Design 3, Dynamic Colors, Dark Mode
|
||||||
|
• Animationen und Live Sync-Status
|
||||||
|
|
||||||
Deine Daten bleiben bei dir! Die App kommuniziert nur mit deinem eigenen WebDAV-Server. Keine Cloud-Dienste, keine Tracking-Bibliotheken, keine Analysetools.
|
Mehrsprachig:
|
||||||
|
• Deutsch und Englisch, automatische Erkennung, App-Sprachauswahl
|
||||||
MULTI-DEVICE SYNC:
|
|
||||||
|
|
||||||
• Notizen synchronisieren automatisch zwischen allen Geräten
|
|
||||||
• Lösch-Tracking verhindert "Zombie-Notizen"
|
|
||||||
• Intelligente Konfliktlösung durch Timestamps
|
|
||||||
• Markdown-Dateien für Desktop-Bearbeitung (Obsidian, VS Code, etc.)
|
|
||||||
• Änderungen von Desktop-Editoren werden automatisch importiert
|
|
||||||
|
|
||||||
SYNCHRONISATION:
|
|
||||||
|
|
||||||
• Unterstützt alle WebDAV-Server (Nextcloud, ownCloud, etc.)
|
|
||||||
• Automatische WiFi-Sync: Synchronisiert automatisch wenn du ein beliebiges WLAN betrittst (wenn Server erreichbar ist)
|
|
||||||
• Konfigurierbares Interval: 15, 30 oder 60 Minuten
|
|
||||||
• Optimierte Performance: überspringt unveränderte Dateien (~2-3s Sync-Zeit)
|
|
||||||
• E-Tag Caching für 20x schnellere "keine Änderungen" Checks
|
|
||||||
• Gemessener Akkuverbrauch: nur ~0.4% pro Tag (bei 30min)
|
|
||||||
• Silent-Sync Modus: kein Banner bei Auto-Sync
|
|
||||||
• Doze Mode optimiert für zuverlässige Background-Syncs
|
|
||||||
• Manuelle Synchronisation jederzeit möglich
|
|
||||||
|
|
||||||
MATERIAL DESIGN 3:
|
|
||||||
|
|
||||||
• Moderne Jetpack Compose Benutzeroberfläche
|
|
||||||
• Dynamic Colors (Material You) auf Android 12+
|
|
||||||
• Dark Mode Support
|
|
||||||
• Auswahlmodus mit Batch-Löschen
|
|
||||||
• Live Sync-Status Anzeige
|
|
||||||
• Flüssige Slide-Animationen
|
|
||||||
|
|
||||||
Open Source unter MIT-Lizenz
|
Open Source unter MIT-Lizenz
|
||||||
Quellcode: https://github.com/inventory69/simple-notes-sync
|
Quellcode: https://github.com/inventory69/simple-notes-sync
|
||||||
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 100 KiB |
BIN
fastlane/metadata/android/de-DE/images/phoneScreenshots/7.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
6
fastlane/metadata/android/en-US/changelogs/14.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
• NEW: Configurable Sync Triggers - Enable/disable each individually
|
||||||
|
• NEW: Offline Mode - Disable all network features with one switch
|
||||||
|
• 5 triggers: onSave, onResume, WiFi, Periodic (15/30/60 min), Boot
|
||||||
|
• Smart defaults: Event-driven only (~0.2%/day battery)
|
||||||
|
• Periodic sync optional (default: OFF)
|
||||||
|
• Various fixes and UI improvements
|
||||||
2
fastlane/metadata/android/en-US/changelogs/15.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
• Code quality improvements
|
||||||
|
• Better preparation for future updates
|
||||||
2
fastlane/metadata/android/en-US/changelogs/16.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
• Fixes offline mode issue after updating from v1.5.0
|
||||||
|
• Users with configured servers are no longer incorrectly shown as offline
|
||||||
7
fastlane/metadata/android/en-US/changelogs/17.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
• New: Grid view - Thanks to freemen
|
||||||
|
• New: WiFi-only sync toggle in settings
|
||||||
|
• New: Encryption for local backups - Thanks to @SilentCoderHere (#9)
|
||||||
|
• Fixed: Sync works correctly when VPN is active - Thanks to @roughnecks (#11)
|
||||||
|
• Improved: Server change - Sync status resets for all notes
|
||||||
|
• Improved: "Sync already running" feedback for additional executions
|
||||||
|
• Various fixes and UI improvements
|
||||||
@@ -1,60 +1,32 @@
|
|||||||
Simple Notes Sync is a minimalist note-taking app with WebDAV synchronization.
|
Simple Notes Sync is a minimalist note-taking app with WebDAV sync and modern features.
|
||||||
|
|
||||||
KEY FEATURES:
|
Key Features:
|
||||||
|
• Text notes and checklists (tap-to-check, drag & drop)
|
||||||
• Create text notes and checklists
|
• NEW: Grid view for notes
|
||||||
• Checklists with tap-to-check, drag & drop reordering
|
|
||||||
• Selection mode: long-press to select multiple notes for batch actions
|
|
||||||
• WebDAV synchronization with your own server
|
|
||||||
• Multi-device sync (phone, tablet, desktop)
|
• Multi-device sync (phone, tablet, desktop)
|
||||||
• Markdown export for Obsidian/desktop editors
|
• WebDAV sync with your own server (Nextcloud, ownCloud, etc.)
|
||||||
• Checklists export as GitHub-style task lists
|
• Markdown export/import for desktop editors (Obsidian, VS Code)
|
||||||
• Automatic synchronization on home WiFi
|
• NEW: WiFi-only sync, VPN support, encryption for local backups
|
||||||
• Configurable sync interval (15/30/60 minutes)
|
• Configurable sync triggers: onSave, onResume, WiFi, periodic, boot
|
||||||
• Material Design 3 with Dynamic Colors (Android 12+)
|
|
||||||
• Jetpack Compose UI - modern, fast, and smooth
|
|
||||||
• Fully usable offline
|
• Fully usable offline
|
||||||
• No ads, no trackers
|
• No ads, no trackers
|
||||||
|
|
||||||
MULTILINGUAL:
|
Privacy & Security:
|
||||||
|
• Your data stays with you – no cloud, no tracking libraries
|
||||||
|
• Support for self-signed SSL certificates
|
||||||
|
• SHA-256 hash of signing certificate shown in app and releases
|
||||||
|
|
||||||
• English and German language support
|
Synchronization:
|
||||||
• Per-App Language selector (Android 13+)
|
• Automatic or manual, optimized performance, optional periodic sync
|
||||||
• Automatic system language detection
|
• Smart conflict resolution, deletion tracking, batch actions
|
||||||
• 400+ translated strings
|
|
||||||
|
|
||||||
PRIVACY:
|
UI & Design:
|
||||||
|
• Modern Jetpack Compose interface
|
||||||
|
• Material Design 3, dynamic colors, dark mode
|
||||||
|
• Animations and live sync status
|
||||||
|
|
||||||
Your data stays with you! The app only communicates with your own WebDAV server. No cloud services, no tracking libraries, no analytics tools.
|
Multilingual:
|
||||||
|
• English and German, automatic detection, in-app language selector
|
||||||
MULTI-DEVICE SYNC:
|
|
||||||
|
|
||||||
• Notes sync automatically between all your devices
|
|
||||||
• Deletion tracking prevents "zombie notes"
|
|
||||||
• Smart conflict resolution through timestamps
|
|
||||||
• Markdown files for desktop editing (Obsidian, VS Code, etc.)
|
|
||||||
• Changes from desktop editors are auto-imported
|
|
||||||
|
|
||||||
SYNCHRONIZATION:
|
|
||||||
|
|
||||||
• Supports all WebDAV servers (Nextcloud, ownCloud, etc.)
|
|
||||||
• Automatic WiFi sync: synchronizes whenever you join any WiFi network (if server is reachable)
|
|
||||||
• Configurable interval: 15, 30, or 60 minutes
|
|
||||||
• Optimized performance: skips unchanged files (~2-3s sync time)
|
|
||||||
• E-Tag caching for 20x faster "no changes" checks
|
|
||||||
• Measured battery consumption: only ~0.4% per day (at 30min)
|
|
||||||
• Silent-Sync mode: no banner during auto-sync
|
|
||||||
• Doze Mode optimized for reliable background syncs
|
|
||||||
• Manual synchronization available anytime
|
|
||||||
|
|
||||||
MATERIAL DESIGN 3:
|
|
||||||
|
|
||||||
• Modern Jetpack Compose user interface
|
|
||||||
• Dynamic Colors (Material You) on Android 12+
|
|
||||||
• Dark Mode support
|
|
||||||
• Selection mode with batch delete
|
|
||||||
• Live sync status indicator
|
|
||||||
• Smooth slide animations
|
|
||||||
|
|
||||||
Open Source under MIT License
|
Open Source under MIT License
|
||||||
Source code: https://github.com/inventory69/simple-notes-sync
|
Source code: https://github.com/inventory69/simple-notes-sync
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 100 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/7.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
@@ -1,30 +0,0 @@
|
|||||||
Categories:
|
|
||||||
- Writing
|
|
||||||
License: MIT
|
|
||||||
AuthorName: inventory69
|
|
||||||
AuthorEmail: admin@dettmer.dev
|
|
||||||
AuthorWebSite: https://dettmer.dev
|
|
||||||
SourceCode: https://github.com/inventory69/simple-notes-sync
|
|
||||||
IssueTracker: https://github.com/inventory69/simple-notes-sync/issues
|
|
||||||
Changelog: https://github.com/inventory69/simple-notes-sync/releases
|
|
||||||
|
|
||||||
AutoName: Simple Notes
|
|
||||||
|
|
||||||
RepoType: git
|
|
||||||
Repo: https://github.com/inventory69/simple-notes-sync.git
|
|
||||||
Binaries: https://github.com/inventory69/simple-notes-sync/releases/download/v%v/simple-notes-sync-v%v-fdroid.apk
|
|
||||||
|
|
||||||
Builds:
|
|
||||||
- versionName: 1.5.0
|
|
||||||
versionCode: 13
|
|
||||||
commit: 65395142fab487e0a286cc5dfe3cf8b76652379d
|
|
||||||
subdir: android/app
|
|
||||||
gradle:
|
|
||||||
- fdroid
|
|
||||||
|
|
||||||
AllowedAPKSigningKeys: 42a1c613bbc673045af3dc8191bf9cb6456ee44c7dce40c7cfb566facb69f16a
|
|
||||||
|
|
||||||
AutoUpdateMode: Version
|
|
||||||
UpdateCheckMode: Tags
|
|
||||||
CurrentVersion: 1.5.0
|
|
||||||
CurrentVersionCode: 13
|
|
||||||