feat: Konfigurierbare Sync-Intervalle + Über-Sektion (v1.1.0) (#1)

* feat: WiFi-Connect Auto-Sync + Debug Logging [skip ci]

- WiFi-Connect Auto-Sync via NetworkCallback + Broadcast (statt WorkManager)
- onResume Auto-Sync mit Toast-Feedback (nur Success)
- File-Logging Feature für Debugging (letzte 500 Einträge)
- Settings: Debug/Logs Section mit Test-Button
- FileProvider für Log-Sharing
- Extensive Debug-Logs für NetworkMonitor + MainActivity
- Material Design 3 Migration (alle 17 Tasks)
- Bug-Fixes: Input underlines, section rename, swipe-to-delete, flat cards

PROBLEM: WiFi-Connect sendet Broadcast aber MainActivity empfängt nicht
→ Benötigt logcat debugging auf anderem Gerät

* 🐛 fix: Remove WiFi-Connect related code and UI elements to streamline sync process

* feat: Konfigurierbare Sync-Intervalle + Über-Sektion (v1.1.0)

## Neue Features

### Konfigurierbare Sync-Intervalle
- Wählbare Intervalle: 15/30/60 Minuten in Settings
- Transparente Akkuverbrauchs-Anzeige (0.2-0.8% pro Tag)
- Sofortige Anwendung ohne App-Neustart
- NetworkMonitor liest Intervall dynamisch aus SharedPreferences

### Über-Sektion
- App-Version & Build-Datum Anzeige
- Klickbare Links zu GitHub Repository & Entwickler-Profil
- Lizenz-Information (MIT License)
- Ersetzt alte Debug/Logs Sektion

## Verbesserungen

- Benutzerfreundliche Doze-Mode Erklärung in Settings
- Keine störenden Sync-Fehler Toasts mehr im Hintergrund
- Modernisierte README mit Badges und kompakter Struktur
- F-Droid Metadaten aktualisiert (changelogs + descriptions)

## Technische Änderungen

- Version Bump: 1.0 → 1.1.0 (versionCode: 1 → 2)
- BUILD_DATE buildConfigField hinzugefügt
- PREF_SYNC_INTERVAL_MINUTES Konstante in Constants.kt
- NetworkMonitor.startPeriodicSync() nutzt konfigurierbare Intervalle
- SettingsActivity: setupSyncIntervalPicker() + setupAboutSection()
- activity_settings.xml: RadioGroup für Intervalle + About Cards
This commit is contained in:
Inventory69
2025-12-22 00:49:24 +01:00
committed by GitHub
parent 86c5e62fd6
commit c55b64dab3
33 changed files with 4687 additions and 466 deletions

View File

@@ -102,24 +102,24 @@ jobs:
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
with: with:
tag_name: ${{ env.VERSION_TAG }} tag_name: ${{ env.VERSION_TAG }}
name: "📝 Simple Notes Sync v${{ env.VERSION_NAME }} (Production)" name: "📝 Simple Notes Sync v${{ env.VERSION_NAME }} (Produktions-Release)"
files: apk-output/*.apk files: apk-output/*.apk
draft: false draft: false
prerelease: false prerelease: false
generate_release_notes: false generate_release_notes: false
body: | body: |
# 📝 Production Release: Simple Notes Sync v${{ env.VERSION_NAME }} # 📝 Produktions-Release: Simple Notes Sync v${{ env.VERSION_NAME }}
## Build Information ## Build-Informationen
- **Version:** ${{ env.VERSION_NAME }}+${{ env.BUILD_NUMBER }} - **Version:** ${{ env.VERSION_NAME }}+${{ env.BUILD_NUMBER }}
- **Build Date:** ${{ env.COMMIT_DATE }} - **Build-Datum:** ${{ env.COMMIT_DATE }}
- **Commit:** ${{ env.SHORT_SHA }} - **Commit:** ${{ env.SHORT_SHA }}
- **Environment:** 🟢 **PRODUCTION** - **Umgebung:** 🟢 **PRODUKTION**
--- ---
## 📋 Changes ## 📋 Änderungen
${{ env.COMMIT_MSG }} ${{ env.COMMIT_MSG }}
@@ -127,69 +127,69 @@ jobs:
## 📦 Download & Installation ## 📦 Download & Installation
### Which APK should I download? ### Welche APK soll ich herunterladen?
| Your Device | Download This APK | Size | Compatibility | | Dein Gerät | Lade diese APK herunter | Größe | Kompatibilität |
|-------------|------------------|------|---------------| |------------|------------------------|-------|----------------|
| 🤷 Not sure? | `simple-notes-sync-v${{ env.VERSION_NAME }}-universal.apk` | ~5 MB | Works on all devices | | 🤷 Nicht sicher? | `simple-notes-sync-v${{ env.VERSION_NAME }}-universal.apk` | ~5 MB | Funktioniert auf allen Geräten |
| Modern (2018+) | `simple-notes-sync-v${{ env.VERSION_NAME }}-arm64-v8a.apk` | ~3 MB | Faster, smaller | | Modern (2018+) | `simple-notes-sync-v${{ env.VERSION_NAME }}-arm64-v8a.apk` | ~3 MB | Schneller, kleiner |
| Older devices | `simple-notes-sync-v${{ env.VERSION_NAME }}-armeabi-v7a.apk` | ~3 MB | Older ARM chips | | Ältere Geräte | `simple-notes-sync-v${{ env.VERSION_NAME }}-armeabi-v7a.apk` | ~3 MB | Ältere ARM-Chips |
### Installation Steps ### Installationsschritte
1. Download the appropriate APK from the assets below 1. Lade die passende APK aus den Assets unten herunter
2. Enable "Install from unknown sources" in Android settings 2. Aktiviere "Installation aus unbekannten Quellen" in den Android-Einstellungen
3. Open the downloaded APK file 3. Öffne die heruntergeladene APK-Datei
4. Follow the installation prompts 4. Folge den Installationsanweisungen
5. Configure WebDAV settings in the app 5. Konfiguriere die WebDAV-Einstellungen in der App
--- ---
## ⚙️ Features ## ⚙️ Funktionen
- ✅ Automatic WebDAV sync every 30 minutes (~0.4% battery/day) - ✅ Automatische WebDAV-Synchronisation alle 30 Minuten (~0,4% Akku/Tag)
- ✅ Smart gateway detection (home network auto-detection) - ✅ Intelligente Gateway-Erkennung (automatische Heimnetzwerk-Erkennung)
- ✅ Material Design 3 UI - ✅ Material Design 3 Oberfläche
- ✅ Privacy-focused (no tracking, no analytics) - ✅ Datenschutzorientiert (kein Tracking, keine Analysen)
- ✅ Offline-first architecture - ✅ Offline-First Architektur
--- ---
## 🔄 Updating from Previous Version ## 🔄 Update von vorheriger Version
Simply install this APK over the existing installation - all data and settings will be preserved. Installiere diese APK einfach über die bestehende Installation - alle Daten und Einstellungen bleiben erhalten.
--- ---
## 📱 Obtanium - Auto-Update App ## 📱 Obtanium - Auto-Update App
Get automatic updates with [Obtanium](https://github.com/ImranR98/Obtanium/releases/latest). Erhalte automatische Updates mit [Obtanium](https://github.com/ImranR98/Obtanium/releases/latest).
**Setup:** **Einrichtung:**
1. Install Obtanium from the link above 1. Installiere Obtanium über den Link oben
2. Add app with this URL: `https://github.com/dettmersLiq/simple-notes-sync` 2. Füge die App mit dieser URL hinzu: `https://github.com/dettmersLiq/simple-notes-sync`
3. Enable auto-updates 3. Aktiviere Auto-Updates
--- ---
## 🆘 Support ## 🆘 Support
For issues or questions, please open an issue on GitHub. Bei Problemen oder Fragen öffne bitte ein Issue auf GitHub.
--- ---
## 🔒 Privacy & Security ## 🔒 Datenschutz & Sicherheit
- All data synced via your own WebDAV server - Alle Daten werden über deinen eigenen WebDAV-Server synchronisiert
- No third-party analytics or tracking - Keine Drittanbieter-Analysen oder Tracking
- No internet permissions except for WebDAV sync - Keine Internet-Berechtigungen außer für WebDAV-Sync
- All sync operations encrypted (HTTPS) - Alle Synchronisationsvorgänge verschlüsselt (HTTPS)
- Open source - audit the code yourself - Open Source - prüfe den Code selbst
--- ---
## 🛠️ Built With ## 🛠️ Erstellt mit
- **Language:** Kotlin - **Sprache:** Kotlin
- **UI:** Material Design 3 - **UI:** Material Design 3
- **Sync:** WorkManager + WebDAV - **Sync:** WorkManager + WebDAV
- **Target SDK:** Android 16 (API 36) - **Target SDK:** Android 16 (API 36)

2338
IMPROVEMENT_PLAN.md Normal file

File diff suppressed because it is too large Load Diff

174
README.md
View File

@@ -1,143 +1,128 @@
# Simple Notes Sync 📝 # Simple Notes Sync 📝
> Minimalistische Android-App für Offline-Notizen mit automatischer WLAN-Synchronisierung > **Minimalistische Android Notiz-App mit automatischer WLAN-Synchronisierung**
Eine schlanke Notiz-App ohne Schnickschnack - perfekt für schnelle Gedanken, die automatisch zu Hause synchronisiert werden. [![Android](https://img.shields.io/badge/Android-8.0%2B-green.svg)](https://www.android.com/)
[![Kotlin](https://img.shields.io/badge/Kotlin-1.9%2B-blue.svg)](https://kotlinlang.org/)
[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
--- Schlanke Offline-Notizen ohne Schnickschnack - deine Daten bleiben bei dir. Automatische Synchronisierung zu deinem eigenen WebDAV-Server, kein Google, kein Microsoft, keine Cloud.
## ✨ Features ## ✨ Features
- 📝 **Offline-first** - Notizen werden lokal gespeichert und sind immer verfügbar - 📝 **Offline-First** - Notizen lokal gespeichert, immer verfügbar
- 🔄 **Auto-Sync** - Automatische Synchronisierung wenn du im Heimnetzwerk bist - 🔄 **Auto-Sync** - Konfigurierbare Intervalle (15/30/60 Min.) mit ~0.2-0.8% Akku/Tag
- 🏠 **WebDAV Server** - Deine Daten bleiben bei dir (Docker-Container) - 🏠 **Self-Hosted** - Deine Daten auf deinem Server (WebDAV)
- 🔋 **Akkuschonend** - Nur ~0.4% Akkuverbrauch pro Tag - 🎨 **Material Design 3** - Modern & Dynamic Theming
- 🚫 **Keine Cloud** - Keine Google, keine Microsoft, keine Drittanbieter - 🔋 **Akkuschonend** - Optimiert für Hintergrund-Synchronisierung
- 🔐 **Privacy** - Keine Tracking, keine Analytics, keine Standort-Berechtigungen - 🔐 **Privacy-First** - Kein Tracking, keine Analytics, keine Cloud
- 🚫 **Keine Berechtigungen** - Nur Internet für WebDAV Sync
--- ## 📥 Quick Download
## 📥 Installation **Android APK:** [📱 Neueste Version herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest)
### Android App 💡 **Tipp:** Nutze [Obtainium](https://github.com/ImranR98/Obtainium) für automatische Updates!
**Option 1: APK herunterladen**
1. Neueste [Release](../../releases/latest) öffnen
2. `app-debug.apk` herunterladen
3. APK auf dem Handy installieren
**Option 2: Selbst bauen**
```bash
cd android
./gradlew assembleDebug
# APK: android/app/build/outputs/apk/debug/app-debug.apk
```
### WebDAV Server
Der Server läuft als Docker-Container und speichert deine Notizen.
```bash
cd server
cp .env.example .env
nano .env # Passwort anpassen!
docker-compose up -d
```
**Server testen:**
```bash
curl -u noteuser:dein_passwort http://192.168.0.XXX:8080/
```
--- ---
## 🚀 Schnellstart ## 🚀 Schnellstart
1. **Server starten** (siehe oben) ### 1⃣ WebDAV Server starten
2. **App installieren** und öffnen
3. **Einstellungen öffnen** (⚙️ Symbol oben rechts) ```fish
4. **Server konfigurieren:** cd server
- Server-URL: `http://192.168.0.XXX:8080/notes` cp .env.example .env
# Passwort in .env anpassen
docker compose up -d
```
### 2⃣ App installieren & konfigurieren
1. APK herunterladen und installieren
2. App öffnen → **Einstellungen** (⚙️)
3. Server konfigurieren:
- URL: `http://192.168.0.XXX:8080/notes`
- Benutzername: `noteuser` - Benutzername: `noteuser`
- Passwort: (aus `.env` Datei) - Passwort: (aus `.env`)
- Auto-Sync: **AN** 4. **Auto-Sync aktivieren**
5. **Fertig!** Notizen werden jetzt automatisch synchronisiert 5. **Sync-Intervall wählen** (15/30/60 Min.)
**Fertig!** Notizen werden automatisch synchronisiert 🎉
--- ---
## 💡 Wie funktioniert Auto-Sync? ## ⚙️ Sync-Intervalle
Die App prüft **alle 30 Minuten**, ob: | Intervall | Akku/Tag | Anwendungsfall |
- ✅ WLAN verbunden ist |-----------|----------|----------------|
- ✅ Server im gleichen Netzwerk erreichbar ist | **15 Min** | ~0.8% (~23 mAh) | ⚡ Maximale Aktualität |
- ✅ Neue Notizen vorhanden sind | **30 Min** | ~0.4% (~12 mAh) | ✓ Empfohlen - Ausgewogen |
| **60 Min** | ~0.2% (~6 mAh) | 🔋 Maximale Akkulaufzeit |
Wenn alle Bedingungen erfüllt → **Automatische Synchronisierung** 💡 **Hinweis:** Android Doze Mode kann Sync im Standby auf ~60 Min. verzögern (betrifft alle Apps).
**Wichtig:** Funktioniert nur im selben Netzwerk wie der Server (kein Internet-Zugriff nötig!)
--- ---
## 🔋 Akkuverbrauch ## <EFBFBD> Neue Features in v1.1.0
| Komponente | Verbrauch/Tag | ### Konfigurierbare Sync-Intervalle
|------------|---------------| - ⏱️ Wählbare Intervalle: 15/30/60 Minuten
| WorkManager (alle 30 Min) | ~0.3% | - 📊 Transparente Akkuverbrauchs-Anzeige
| Netzwerk-Checks | ~0.1% | - <20> Sofortige Anwendung ohne App-Neustart
| **Total** | **~0.4%** |
Bei einem 3000 mAh Akku entspricht das ~12 mAh pro Tag. ### Über-Sektion
- <20> App-Version & Build-Datum
- 🌐 Links zu GitHub Repo & Entwickler
- ⚖️ Lizenz-Information
### Verbesserungen
- 🎯 Benutzerfreundliche Doze-Mode Erklärung
- 🔕 Keine störenden Sync-Fehler Toasts im Hintergrund
- 📝 Erweiterte Debug-Logs für Troubleshooting
--- ---
## 📱 Screenshots ## 🛠️ Selbst bauen
_TODO: Screenshots hinzufügen_ ```fish
cd android
--- ./gradlew assembleStandardRelease
# APK: android/app/build/outputs/apk/standard/release/
## 🛠️ Technische Details ```
Mehr Infos zur Architektur und Implementierung findest du in der [technischen Dokumentation](DOCS.md).
**Stack:**
- **Android:** Kotlin, Material Design 3, WorkManager
- **Server:** Docker, WebDAV (bytemark/webdav)
- **Sync:** Sardine Android (WebDAV Client)
--- ---
## 🐛 Troubleshooting ## 🐛 Troubleshooting
### Auto-Sync funktioniert nicht
1. **Akku-Optimierung deaktivieren**
- Einstellungen → Apps → Simple Notes → Akku → Nicht optimieren
2. **WLAN-Verbindung prüfen**
- Funktioniert nur im selben Netzwerk wie Server
3. **Server-Status checken**
- Settings → "Verbindung testen"
### Server nicht erreichbar ### Server nicht erreichbar
```bash ```fish
# Server Status prüfen # Status prüfen
docker-compose ps docker compose ps
# Logs ansehen # Logs ansehen
docker-compose logs -f docker compose logs -f
# IP-Adresse finden # IP-Adresse finden
ip addr show | grep "inet " | grep -v 127.0.0.1 ip addr show | grep "inet " | grep -v 127.0.0.1
``` ```
### Auto-Sync funktioniert nicht Mehr Details: [📖 Dokumentation](DOCS.md)
1. **Akku-Optimierung deaktivieren**
- Einstellungen → Apps → Simple Notes → Akku → Nicht optimieren
2. **WLAN Verbindung prüfen**
- App funktioniert nur im selben Netzwerk wie der Server
3. **Server-Status in App prüfen**
- Settings → Server-Status sollte "Erreichbar" zeigen
Mehr Details in der [Dokumentation](DOCS.md).
--- ---
## 🤝 Beitragen ## 🤝 Contributing
Contributions sind willkommen! Bitte öffne ein Issue oder Pull Request. Contributions sind willkommen! Bitte öffne ein Issue oder Pull Request.
@@ -149,5 +134,4 @@ MIT License - siehe [LICENSE](LICENSE)
--- ---
**Projekt Start:** 19. Dezember 2025 **Version:** 1.1.0 · **Status:** ✅ Produktiv · **Gebaut mit:** Kotlin + Material Design 3
**Status:** ✅ Funktional & Produktiv

View File

@@ -5,6 +5,9 @@ plugins {
import java.util.Properties import java.util.Properties
import java.io.FileInputStream import java.io.FileInputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
android { android {
namespace = "dev.dettmer.simplenotes" namespace = "dev.dettmer.simplenotes"
@@ -14,10 +17,13 @@ android {
applicationId = "dev.dettmer.simplenotes" applicationId = "dev.dettmer.simplenotes"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 2 // 🔥 F-Droid Release v1.1.0
versionName = "1.0" versionName = "1.1.0" // 🔥 Configurable Sync Interval + About Section
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// 🔥 NEU: Build Date für About Screen
buildConfigField("String", "BUILD_DATE", "\"${getBuildDate()}\"")
} }
// Enable multiple APKs per ABI for smaller downloads // Enable multiple APKs per ABI for smaller downloads
@@ -30,6 +36,21 @@ android {
} }
} }
// Product Flavors for F-Droid and standard builds
flavorDimensions += "distribution"
productFlavors {
create("fdroid") {
dimension = "distribution"
// F-Droid builds have no proprietary dependencies
// All dependencies in this project are already FOSS-compatible
}
create("standard") {
dimension = "distribution"
// Standard builds can include Play Services in the future if needed
}
}
// Signing configuration for release builds // Signing configuration for release builds
signingConfigs { signingConfigs {
create("release") { create("release") {
@@ -86,6 +107,9 @@ dependencies {
implementation(libs.androidx.activity) implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout) implementation(libs.androidx.constraintlayout)
// Splash Screen API (Android 12+)
implementation("androidx.core:core-splashscreen:1.0.1")
// Unsere Dependencies (DIREKT mit Versionen - viel einfacher!) // Unsere Dependencies (DIREKT mit Versionen - viel einfacher!)
implementation("com.github.thegrizzlylabs:sardine-android:0.8") { implementation("com.github.thegrizzlylabs:sardine-android:0.8") {
exclude(group = "xpp3", module = "xpp3") exclude(group = "xpp3", module = "xpp3")
@@ -105,3 +129,9 @@ dependencies {
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
} }
// 🔥 NEU: Helper function für Build Date
fun getBuildDate(): String {
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
return dateFormat.format(Date())
}

View File

@@ -31,7 +31,8 @@
tools:targetApi="31"> tools:targetApi="31">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true"
android:theme="@style/Theme.SimpleNotes.Splash">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@@ -60,6 +61,17 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- FileProvider für Log-Sharing -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>

View File

@@ -13,39 +13,52 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.color.DynamicColors
import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.card.MaterialCardView
import dev.dettmer.simplenotes.adapters.NotesAdapter import dev.dettmer.simplenotes.adapters.NotesAdapter
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncWorker import dev.dettmer.simplenotes.sync.SyncWorker
import dev.dettmer.simplenotes.utils.NotificationHelper import dev.dettmer.simplenotes.utils.NotificationHelper
import dev.dettmer.simplenotes.utils.showToast import dev.dettmer.simplenotes.utils.showToast
import dev.dettmer.simplenotes.utils.Constants
import android.widget.TextView import android.widget.TextView
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.WebDavSyncService
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var recyclerViewNotes: RecyclerView private lateinit var recyclerViewNotes: RecyclerView
private lateinit var textViewEmpty: TextView private lateinit var emptyStateCard: MaterialCardView
private lateinit var fabAddNote: FloatingActionButton private lateinit var fabAddNote: FloatingActionButton
private lateinit var toolbar: MaterialToolbar private lateinit var toolbar: MaterialToolbar
private lateinit var adapter: NotesAdapter private lateinit var adapter: NotesAdapter
private val storage by lazy { NotesStorage(this) } private val storage by lazy { NotesStorage(this) }
private val prefs by lazy {
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
}
companion object { companion object {
private const val TAG = "MainActivity" private const val TAG = "MainActivity"
private const val REQUEST_NOTIFICATION_PERMISSION = 1001 private const val REQUEST_NOTIFICATION_PERMISSION = 1001
private const val REQUEST_SETTINGS = 1002
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
} }
/** /**
* BroadcastReceiver für Background-Sync Completion * BroadcastReceiver für Background-Sync Completion (Periodic Sync)
*/ */
private val syncCompletedReceiver = object : BroadcastReceiver() { private val syncCompletedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
@@ -63,9 +76,21 @@ class MainActivity : AppCompatActivity() {
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
// Install Splash Screen (Android 12+)
installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Apply Dynamic Colors for Android 12+ (Material You)
DynamicColors.applyToActivityIfAvailable(this)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
// File Logging aktivieren wenn eingestellt
if (prefs.getBoolean("file_logging_enabled", false)) {
Logger.enableFileLogging(this)
}
// Permission für Notifications (Android 13+) // Permission für Notifications (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestNotificationPermission() requestNotificationPermission()
@@ -82,14 +107,87 @@ class MainActivity : AppCompatActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
Logger.d(TAG, "📱 MainActivity.onResume() - Registering receivers")
// Register BroadcastReceiver für Background-Sync // Register BroadcastReceiver für Background-Sync
LocalBroadcastManager.getInstance(this).registerReceiver( LocalBroadcastManager.getInstance(this).registerReceiver(
syncCompletedReceiver, syncCompletedReceiver,
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED) IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
) )
Logger.d(TAG, "📡 BroadcastReceiver registered")
Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)")
// Reload notes
loadNotes() loadNotes()
// Trigger Auto-Sync beim App-Wechsel in Vordergrund (Toast)
triggerAutoSync("onResume")
}
/**
* Automatischer Sync (onResume)
* - Nutzt WiFi-gebundenen Socket (VPN Fix!)
* - Nur Success-Toast (kein "Auto-Sync..." Toast)
*
* NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!)
*/
private fun triggerAutoSync(source: String = "unknown") {
// Throttling: Max 1 Sync pro Minute
if (!canTriggerAutoSync()) {
return
}
Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
// Update last sync timestamp
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
// GLEICHER Sync-Code wie manueller Sync (funktioniert!)
lifecycleScope.launch {
try {
val syncService = WebDavSyncService(this@MainActivity)
val result = withContext(Dispatchers.IO) {
syncService.syncNotes()
}
// Feedback abhängig von Source
if (result.isSuccess && result.syncedCount > 0) {
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
// onResume: Nur Success-Toast
showToast("✅ Gesynct: ${result.syncedCount} Notizen")
loadNotes()
} else if (result.isSuccess) {
Logger.d(TAG, " Auto-sync ($source): No changes")
} else {
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
// Kein Toast - App ist im Hintergrund
}
} catch (e: Exception) {
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
// Kein Toast - App ist im Hintergrund
}
}
}
/**
* Prüft ob Auto-Sync getriggert werden darf (Throttling)
*/
private fun canTriggerAutoSync(): Boolean {
val lastSyncTime = prefs.getLong(PREF_LAST_AUTO_SYNC_TIME, 0)
val now = System.currentTimeMillis()
val timeSinceLastSync = now - lastSyncTime
if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) {
val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
Logger.d(TAG, "⏳ Auto-sync throttled - wait ${remainingSeconds}s")
return false
}
return true
} }
override fun onPause() { override fun onPause() {
@@ -102,7 +200,7 @@ class MainActivity : AppCompatActivity() {
private fun findViews() { private fun findViews() {
recyclerViewNotes = findViewById(R.id.recyclerViewNotes) recyclerViewNotes = findViewById(R.id.recyclerViewNotes)
textViewEmpty = findViewById(R.id.textViewEmpty) emptyStateCard = findViewById(R.id.emptyStateCard)
fabAddNote = findViewById(R.id.fabAddNote) fabAddNote = findViewById(R.id.fabAddNote)
toolbar = findViewById(R.id.toolbar) toolbar = findViewById(R.id.toolbar)
} }
@@ -117,6 +215,57 @@ class MainActivity : AppCompatActivity() {
} }
recyclerViewNotes.adapter = adapter recyclerViewNotes.adapter = adapter
recyclerViewNotes.layoutManager = LinearLayoutManager(this) recyclerViewNotes.layoutManager = LinearLayoutManager(this)
// Setup Swipe-to-Delete
setupSwipeToDelete()
}
private fun setupSwipeToDelete() {
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
0, // No drag
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT // Swipe left or right
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean = false
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.adapterPosition
val note = adapter.currentList[position]
val notesCopy = adapter.currentList.toMutableList()
// Remove from list immediately for visual feedback
notesCopy.removeAt(position)
adapter.submitList(notesCopy)
// Show Snackbar with UNDO
Snackbar.make(
recyclerViewNotes,
"Notiz gelöscht",
Snackbar.LENGTH_LONG
).setAction("RÜCKGÄNGIG") {
// UNDO: Restore note in list
loadNotes()
}.addCallback(object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
if (event != DISMISS_EVENT_ACTION) {
// Snackbar dismissed without UNDO → Actually delete the note
storage.deleteNote(note.id)
loadNotes()
}
}
}).show()
}
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
// Require 80% swipe to trigger
return 0.8f
}
})
itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
} }
private fun setupFab() { private fun setupFab() {
@@ -129,8 +278,8 @@ class MainActivity : AppCompatActivity() {
val notes = storage.loadAllNotes() val notes = storage.loadAllNotes()
adapter.submitList(notes) adapter.submitList(notes)
// Empty state // Material 3 Empty State Card
textViewEmpty.visibility = if (notes.isEmpty()) { emptyStateCard.visibility = if (notes.isEmpty()) {
android.view.View.VISIBLE android.view.View.VISIBLE
} else { } else {
android.view.View.GONE android.view.View.GONE
@@ -146,7 +295,9 @@ class MainActivity : AppCompatActivity() {
} }
private fun openSettings() { private fun openSettings() {
startActivity(Intent(this, SettingsActivity::class.java)) val intent = Intent(this, SettingsActivity::class.java)
@Suppress("DEPRECATION")
startActivityForResult(intent, REQUEST_SETTINGS)
} }
private fun triggerManualSync() { private fun triggerManualSync() {
@@ -205,6 +356,16 @@ class MainActivity : AppCompatActivity() {
} }
} }
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) {
// Restore was successful, reload notes
loadNotes()
}
}
override fun onRequestPermissionsResult( override fun onRequestPermissionsResult(
requestCode: Int, requestCode: Int,
permissions: Array<out String>, permissions: Array<out String>,

View File

@@ -6,6 +6,7 @@ import android.view.MenuItem
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.color.DynamicColors
import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputEditText
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.models.SyncStatus
@@ -27,6 +28,10 @@ class NoteEditorActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Apply Dynamic Colors for Android 12+ (Material You)
DynamicColors.applyToActivityIfAvailable(this)
setContentView(R.layout.activity_editor) setContentView(R.layout.activity_editor)
storage = NotesStorage(this) storage = NotesStorage(this)
@@ -89,7 +94,7 @@ class NoteEditorActivity : AppCompatActivity() {
val content = editTextContent.text?.toString()?.trim() ?: "" val content = editTextContent.text?.toString()?.trim() ?: ""
if (title.isEmpty() && content.isEmpty()) { if (title.isEmpty() && content.isEmpty()) {
showToast("Titel oder Inhalt darf nicht leer sein") showToast("Notiz ist leer")
return return
} }

View File

@@ -10,25 +10,43 @@ import android.util.Log
import android.view.MenuItem import android.view.MenuItem
import android.widget.Button import android.widget.Button
import android.widget.EditText import android.widget.EditText
import android.widget.RadioGroup
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SwitchCompat import androidx.appcompat.widget.SwitchCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.appbar.MaterialToolbar
import dev.dettmer.simplenotes.sync.WebDavSyncService import com.google.android.material.card.MaterialCardView
import dev.dettmer.simplenotes.utils.Constants import com.google.android.material.chip.Chip
import dev.dettmer.simplenotes.utils.showToast import com.google.android.material.color.DynamicColors
import com.google.android.material.switchmaterial.SwitchMaterial
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.sync.NetworkMonitor
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.showToast
import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.text.SimpleDateFormat
import java.util.Locale
class SettingsActivity : AppCompatActivity() { class SettingsActivity : AppCompatActivity() {
companion object { companion object {
private const val TAG = "SettingsActivity" private const val TAG = "SettingsActivity"
private const val GITHUB_REPO_URL = "https://github.com/inventory69/simple-notes-sync"
private const val GITHUB_PROFILE_URL = "https://github.com/inventory69"
private const val LICENSE_URL = "https://github.com/inventory69/simple-notes-sync/blob/main/LICENSE"
} }
private lateinit var editTextServerUrl: EditText private lateinit var editTextServerUrl: EditText
@@ -37,7 +55,20 @@ class SettingsActivity : AppCompatActivity() {
private lateinit var switchAutoSync: SwitchCompat private lateinit var switchAutoSync: SwitchCompat
private lateinit var buttonTestConnection: Button private lateinit var buttonTestConnection: Button
private lateinit var buttonSyncNow: Button private lateinit var buttonSyncNow: Button
private lateinit var buttonRestoreFromServer: Button
private lateinit var textViewServerStatus: TextView private lateinit var textViewServerStatus: TextView
private lateinit var chipAutoSaveStatus: Chip
// Sync Interval UI
private lateinit var radioGroupSyncInterval: RadioGroup
// About Section UI
private lateinit var textViewAppVersion: TextView
private lateinit var cardGitHubRepo: MaterialCardView
private lateinit var cardDeveloperProfile: MaterialCardView
private lateinit var cardLicense: MaterialCardView
private var autoSaveIndicatorJob: Job? = null
private val prefs by lazy { private val prefs by lazy {
getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE) getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE)
@@ -45,6 +76,10 @@ class SettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Apply Dynamic Colors for Android 12+ (Material You)
DynamicColors.applyToActivityIfAvailable(this)
setContentView(R.layout.activity_settings) setContentView(R.layout.activity_settings)
// Setup toolbar // Setup toolbar
@@ -58,6 +93,8 @@ class SettingsActivity : AppCompatActivity() {
findViews() findViews()
loadSettings() loadSettings()
setupListeners() setupListeners()
setupSyncIntervalPicker()
setupAboutSection()
} }
private fun findViews() { private fun findViews() {
@@ -67,7 +104,18 @@ class SettingsActivity : AppCompatActivity() {
switchAutoSync = findViewById(R.id.switchAutoSync) switchAutoSync = findViewById(R.id.switchAutoSync)
buttonTestConnection = findViewById(R.id.buttonTestConnection) buttonTestConnection = findViewById(R.id.buttonTestConnection)
buttonSyncNow = findViewById(R.id.buttonSyncNow) buttonSyncNow = findViewById(R.id.buttonSyncNow)
buttonRestoreFromServer = findViewById(R.id.buttonRestoreFromServer)
textViewServerStatus = findViewById(R.id.textViewServerStatus) textViewServerStatus = findViewById(R.id.textViewServerStatus)
chipAutoSaveStatus = findViewById(R.id.chipAutoSaveStatus)
// Sync Interval UI
radioGroupSyncInterval = findViewById(R.id.radioGroupSyncInterval)
// About Section UI
textViewAppVersion = findViewById(R.id.textViewAppVersion)
cardGitHubRepo = findViewById(R.id.cardGitHubRepo)
cardDeveloperProfile = findViewById(R.id.cardDeveloperProfile)
cardLicense = findViewById(R.id.cardLicense)
} }
private fun loadSettings() { private fun loadSettings() {
@@ -91,16 +139,122 @@ class SettingsActivity : AppCompatActivity() {
syncNow() syncNow()
} }
buttonRestoreFromServer.setOnClickListener {
saveSettings()
showRestoreConfirmation()
}
switchAutoSync.setOnCheckedChangeListener { _, isChecked -> switchAutoSync.setOnCheckedChangeListener { _, isChecked ->
onAutoSyncToggled(isChecked) onAutoSyncToggled(isChecked)
showAutoSaveIndicator()
} }
// Server Status Check bei Settings-Änderung // Server Status Check bei Settings-Änderung
editTextServerUrl.setOnFocusChangeListener { _, hasFocus -> editTextServerUrl.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) { if (!hasFocus) {
checkServerStatus() checkServerStatus()
showAutoSaveIndicator()
} }
} }
editTextUsername.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) showAutoSaveIndicator()
}
editTextPassword.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) showAutoSaveIndicator()
}
}
/**
* Setup sync interval picker with radio buttons
*/
private fun setupSyncIntervalPicker() {
// Load current interval from preferences
val currentInterval = prefs.getLong(Constants.PREF_SYNC_INTERVAL_MINUTES, Constants.DEFAULT_SYNC_INTERVAL_MINUTES)
// Set checked radio button based on current interval
val checkedId = when (currentInterval) {
15L -> R.id.radioInterval15
30L -> R.id.radioInterval30
60L -> R.id.radioInterval60
else -> R.id.radioInterval30 // Default
}
radioGroupSyncInterval.check(checkedId)
// Listen for interval changes
radioGroupSyncInterval.setOnCheckedChangeListener { _, checkedId ->
val newInterval = when (checkedId) {
R.id.radioInterval15 -> 15L
R.id.radioInterval60 -> 60L
else -> 30L // R.id.radioInterval30 or fallback
}
// Save new interval to preferences
prefs.edit().putLong(Constants.PREF_SYNC_INTERVAL_MINUTES, newInterval).apply()
// Restart periodic sync with new interval (only if auto-sync is enabled)
if (prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)) {
val networkMonitor = NetworkMonitor(this)
networkMonitor.startMonitoring()
val intervalText = when (newInterval) {
15L -> "15 Minuten"
30L -> "30 Minuten"
60L -> "60 Minuten"
else -> "$newInterval Minuten"
}
showToast("⏱️ Sync-Intervall auf $intervalText geändert")
Logger.i(TAG, "Sync interval changed to $newInterval minutes, restarted periodic sync")
} else {
showToast("⏱️ Sync-Intervall gespeichert (Auto-Sync ist deaktiviert)")
}
}
}
/**
* Setup about section with version info and clickable cards
*/
private fun setupAboutSection() {
// Display app version with build date
try {
val versionName = BuildConfig.VERSION_NAME
val versionCode = BuildConfig.VERSION_CODE
val buildDate = BuildConfig.BUILD_DATE
textViewAppVersion.text = "Version $versionName ($versionCode)\nErstellt am: $buildDate"
} catch (e: Exception) {
Logger.e(TAG, "Failed to load version info", e)
textViewAppVersion.text = "Version nicht verfügbar"
}
// GitHub Repository Card
cardGitHubRepo.setOnClickListener {
openUrl(GITHUB_REPO_URL)
}
// Developer Profile Card
cardDeveloperProfile.setOnClickListener {
openUrl(GITHUB_PROFILE_URL)
}
// License Card
cardLicense.setOnClickListener {
openUrl(LICENSE_URL)
}
}
/**
* Opens URL in browser
*/
private fun openUrl(url: String) {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
startActivity(intent)
} catch (e: Exception) {
Logger.e(TAG, "Failed to open URL: $url", e)
showToast("❌ Fehler beim Öffnen des Links")
}
} }
private fun saveSettings() { private fun saveSettings() {
@@ -122,11 +276,14 @@ class SettingsActivity : AppCompatActivity() {
if (result.isSuccess) { if (result.isSuccess) {
showToast("Verbindung erfolgreich!") showToast("Verbindung erfolgreich!")
checkServerStatus() // ✅ Server-Status sofort aktualisieren
} else { } else {
showToast("Verbindung fehlgeschlagen: ${result.errorMessage}") showToast("Verbindung fehlgeschlagen: ${result.errorMessage}")
checkServerStatus() // ✅ Auch bei Fehler aktualisieren
} }
} catch (e: Exception) { } catch (e: Exception) {
showToast("Fehler: ${e.message}") showToast("Fehler: ${e.message}")
checkServerStatus() // ✅ Auch bei Exception aktualisieren
} }
} }
} }
@@ -144,11 +301,14 @@ class SettingsActivity : AppCompatActivity() {
} else { } else {
showToast("Erfolgreich! ${result.syncedCount} Notizen synchronisiert") showToast("Erfolgreich! ${result.syncedCount} Notizen synchronisiert")
} }
checkServerStatus() // ✅ Server-Status nach Sync aktualisieren
} else { } else {
showToast("Sync fehlgeschlagen: ${result.errorMessage}") showToast("Sync fehlgeschlagen: ${result.errorMessage}")
checkServerStatus() // ✅ Auch bei Fehler aktualisieren
} }
} catch (e: Exception) { } catch (e: Exception) {
showToast("Fehler: ${e.message}") showToast("Fehler: ${e.message}")
checkServerStatus() // ✅ Auch bei Exception aktualisieren
} }
} }
} }
@@ -260,6 +420,75 @@ class SettingsActivity : AppCompatActivity() {
} }
} }
private fun showAutoSaveIndicator() {
// Cancel previous job if still running
autoSaveIndicatorJob?.cancel()
// Show saving indicator
chipAutoSaveStatus.apply {
visibility = android.view.View.VISIBLE
text = "💾 Speichere..."
setChipBackgroundColorResource(android.R.color.darker_gray)
}
// Save settings
saveSettings()
// Show saved confirmation after short delay
autoSaveIndicatorJob = lifecycleScope.launch {
delay(300) // Short delay to show "Speichere..."
chipAutoSaveStatus.apply {
text = "✓ Gespeichert"
setChipBackgroundColorResource(android.R.color.holo_green_light)
}
delay(2000) // Show for 2 seconds
chipAutoSaveStatus.visibility = android.view.View.GONE
}
}
private fun showRestoreConfirmation() {
android.app.AlertDialog.Builder(this)
.setTitle(R.string.restore_confirmation_title)
.setMessage(R.string.restore_confirmation_message)
.setPositiveButton(R.string.restore_button) { _, _ ->
performRestore()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun performRestore() {
val progressDialog = android.app.ProgressDialog(this).apply {
setMessage(getString(R.string.restore_progress))
setCancelable(false)
show()
}
CoroutineScope(Dispatchers.Main).launch {
try {
val webdavService = WebDavSyncService(this@SettingsActivity)
val result = withContext(Dispatchers.IO) {
webdavService.restoreFromServer()
}
progressDialog.dismiss()
if (result.isSuccess) {
showToast(getString(R.string.restore_success, result.restoredCount))
// Refresh MainActivity's note list
setResult(RESULT_OK)
} else {
showToast(getString(R.string.restore_error, result.errorMessage))
}
checkServerStatus()
} catch (e: Exception) {
progressDialog.dismiss()
showToast(getString(R.string.restore_error, e.message))
checkServerStatus()
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
android.R.id.home -> { android.R.id.home -> {

View File

@@ -1,9 +1,11 @@
package dev.dettmer.simplenotes package dev.dettmer.simplenotes
import android.app.Application import android.app.Application
import android.content.Context
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.sync.NetworkMonitor import dev.dettmer.simplenotes.sync.NetworkMonitor
import dev.dettmer.simplenotes.utils.NotificationHelper import dev.dettmer.simplenotes.utils.NotificationHelper
import dev.dettmer.simplenotes.utils.Constants
class SimpleNotesApplication : Application() { class SimpleNotesApplication : Application() {
@@ -16,6 +18,13 @@ 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)
if (prefs.getBoolean("file_logging_enabled", false)) {
Logger.enableFileLogging(this)
Logger.d(TAG, "📝 File logging enabled at Application startup")
}
Logger.d(TAG, "🚀 Application onCreate()") Logger.d(TAG, "🚀 Application onCreate()")
// Initialize notification channel // Initialize notification channel

View File

@@ -37,5 +37,16 @@ class NotesStorage(private val context: Context) {
return file.delete() return file.delete()
} }
fun deleteAllNotes(): Boolean {
return try {
notesDir.listFiles()
?.filter { it.extension == "json" }
?.forEach { it.delete() }
true
} catch (e: Exception) {
false
}
}
fun getNotesDir(): File = notesDir fun getNotesDir(): File = notesDir
} }

View File

@@ -1,15 +1,19 @@
package dev.dettmer.simplenotes.sync package dev.dettmer.simplenotes.sync
import android.content.Context import android.content.Context
import android.net.wifi.WifiManager import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import androidx.work.* import androidx.work.*
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
* NetworkMonitor: Verwaltet WorkManager-basiertes Auto-Sync * NetworkMonitor: Verwaltet Auto-Sync
* WICHTIG: Kein NetworkCallback mehr - WorkManager macht das für uns! * - Periodic WorkManager für Auto-Sync alle 30min
* - NetworkCallback für WiFi-Connect Detection → WorkManager OneTime Sync
*/ */
class NetworkMonitor(private val context: Context) { class NetworkMonitor(private val context: Context) {
@@ -22,30 +26,145 @@ class NetworkMonitor(private val context: Context) {
context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
} }
private val connectivityManager by lazy {
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
}
// 🔥 Track last connected network ID to detect network changes (SSID wechsel, WiFi an/aus)
// null = kein Netzwerk, sonst Network.toString() als eindeutiger Identifier
private var lastConnectedNetworkId: String? = null
/** /**
* Startet WorkManager mit Network Constraints * NetworkCallback: Erkennt WiFi-Verbindung und triggert WorkManager
* WorkManager kümmert sich automatisch um WiFi-Erkennung! * WorkManager funktioniert auch wenn App geschlossen ist!
*/
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
Logger.d(TAG, "🌐 NetworkCallback.onAvailable() triggered")
val capabilities = connectivityManager.getNetworkCapabilities(network)
Logger.d(TAG, " Network capabilities: $capabilities")
val isWifi = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
Logger.d(TAG, " Is WiFi: $isWifi")
if (isWifi) {
val currentNetworkId = network.toString()
Logger.d(TAG, "📶 WiFi network connected: $currentNetworkId")
// 🔥 Trigger bei:
// 1. WiFi aus -> WiFi an (lastConnectedNetworkId == null)
// 2. SSID-Wechsel (lastConnectedNetworkId != currentNetworkId)
// NICHT triggern bei: App-Restart mit gleichem WiFi
if (lastConnectedNetworkId != currentNetworkId) {
if (lastConnectedNetworkId == null) {
Logger.d(TAG, " 🎯 WiFi state changed: OFF -> ON (network: $currentNetworkId)")
} else {
Logger.d(TAG, " 🎯 WiFi network changed: $lastConnectedNetworkId -> $currentNetworkId")
}
lastConnectedNetworkId = currentNetworkId
// Auto-Sync check
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
Logger.d(TAG, " Auto-Sync enabled: $autoSyncEnabled")
if (autoSyncEnabled) {
Logger.d(TAG, " ✅ Triggering WorkManager...")
triggerWifiConnectSync()
} else {
Logger.d(TAG, " ❌ Auto-sync disabled - not triggering")
}
} else {
Logger.d(TAG, " ⚠️ Same WiFi network as before - ignoring (no network change)")
}
} else {
Logger.d(TAG, " ⚠️ Not WiFi - ignoring")
}
}
override fun onLost(network: Network) {
super.onLost(network)
val lostNetworkId = network.toString()
Logger.d(TAG, "🔴 NetworkCallback.onLost() - Network disconnected: $lostNetworkId")
if (lastConnectedNetworkId == lostNetworkId) {
Logger.d(TAG, " Last WiFi network lost - resetting state")
lastConnectedNetworkId = null
}
}
}
/**
* Triggert WiFi-Connect Sync via WorkManager
* WorkManager wacht App auf (funktioniert auch wenn App geschlossen!)
*/
private fun triggerWifiConnectSync() {
Logger.d(TAG, "📡 Scheduling WiFi-Connect sync via WorkManager")
// 🔥 WICHTIG: NetworkType.UNMETERED constraint!
// Ohne Constraint könnte WorkManager den Job auf Cellular ausführen
// (z.B. wenn WiFi disconnected bevor Job startet)
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // WiFi only!
.build()
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.setConstraints(constraints) // 🔥 Constraints hinzugefügt
.addTag(Constants.SYNC_WORK_TAG)
.addTag("wifi-connect")
.build()
WorkManager.getInstance(context).enqueue(syncRequest)
Logger.d(TAG, "✅ WiFi-Connect sync scheduled (WIFI ONLY, WorkManager will wake app if needed)")
}
/**
* Startet WorkManager mit Network Constraints + NetworkCallback
*/ */
fun startMonitoring() { fun startMonitoring() {
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false) val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
if (!autoSyncEnabled) { if (!autoSyncEnabled) {
Logger.d(TAG, "Auto-sync disabled - stopping periodic work") Logger.d(TAG, "Auto-sync disabled - stopping all monitoring")
stopMonitoring() stopMonitoring()
return return
} }
Logger.d(TAG, "🚀 Starting WorkManager-based auto-sync") Logger.d(TAG, "🚀 Starting NetworkMonitor (WorkManager + WiFi Callback)")
// 1. WorkManager für periodic sync
startPeriodicSync()
// 2. NetworkCallback für WiFi-Connect Detection
startWifiMonitoring()
}
/**
* Startet WorkManager periodic sync
* 🔥 Interval aus SharedPrefs konfigurierbar (15/30/60 min)
*/
private fun startPeriodicSync() {
// 🔥 Interval aus SharedPrefs lesen
val intervalMinutes = prefs.getLong(
Constants.PREF_SYNC_INTERVAL_MINUTES,
Constants.DEFAULT_SYNC_INTERVAL_MINUTES
)
Logger.d(TAG, "📅 Configuring periodic sync: ${intervalMinutes}min interval")
// Constraints: Nur wenn WiFi connected
val constraints = Constraints.Builder() val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // WiFi only .setRequiredNetworkType(NetworkType.UNMETERED) // WiFi only
.build() .build()
// Periodic Work Request - prüft alle 30 Minuten (Battery optimized)
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>( val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
30, TimeUnit.MINUTES, // Optimiert: 30 Min statt 15 Min intervalMinutes, TimeUnit.MINUTES, // 🔥 Dynamisch!
10, TimeUnit.MINUTES // Flex interval 5, TimeUnit.MINUTES // Flex interval
) )
.setConstraints(constraints) .setConstraints(constraints)
.addTag(Constants.SYNC_WORK_TAG) .addTag(Constants.SYNC_WORK_TAG)
@@ -53,107 +172,103 @@ class NetworkMonitor(private val context: Context) {
WorkManager.getInstance(context).enqueueUniquePeriodicWork( WorkManager.getInstance(context).enqueueUniquePeriodicWork(
AUTO_SYNC_WORK_NAME, AUTO_SYNC_WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE, // UPDATE statt KEEP für immediate trigger ExistingPeriodicWorkPolicy.UPDATE, // 🔥 Update bei Interval-Änderung
syncRequest syncRequest
) )
Logger.d(TAG, "✅ Periodic auto-sync scheduled (every 30min when on WiFi)") Logger.d(TAG, "✅ Periodic sync scheduled (every ${intervalMinutes}min)")
// Trigger sofortigen Sync wenn WiFi bereits connected
triggerImmediateSync()
} }
/** /**
* Stoppt WorkManager Auto-Sync * Startet NetworkCallback für WiFi-Connect Detection
*/
private fun startWifiMonitoring() {
try {
Logger.d(TAG, "🚀 Starting WiFi monitoring...")
val request = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
Logger.d(TAG, " NetworkRequest built: WIFI + INTERNET capability")
connectivityManager.registerNetworkCallback(request, networkCallback)
Logger.d(TAG, "✅✅✅ WiFi NetworkCallback registered successfully")
Logger.d(TAG, " Callback will trigger on WiFi connect/disconnect")
// 🔥 FIX: Initialisiere wasWifiConnected State beim Start
// onAvailable() wird nur bei NEUEN Verbindungen getriggert!
initializeWifiState()
} catch (e: Exception) {
Logger.e(TAG, "❌❌❌ Failed to register NetworkCallback", e)
}
}
/**
* Initialisiert lastConnectedNetworkId beim App-Start
* Wichtig damit wir echte Netzwerk-Wechsel von App-Restarts unterscheiden können
*/
private fun initializeWifiState() {
try {
Logger.d(TAG, "🔍 Initializing WiFi state...")
val activeNetwork = connectivityManager.activeNetwork
if (activeNetwork == null) {
Logger.d(TAG, " ❌ No active network - lastConnectedNetworkId = null")
lastConnectedNetworkId = null
return
}
val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
val isWifi = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
if (isWifi) {
lastConnectedNetworkId = activeNetwork.toString()
Logger.d(TAG, " ✅ Initial WiFi network: $lastConnectedNetworkId")
Logger.d(TAG, " 📡 WiFi already connected at startup - onAvailable() will only trigger on network change")
} else {
lastConnectedNetworkId = null
Logger.d(TAG, " ⚠️ Not on WiFi at startup")
}
} catch (e: Exception) {
Logger.e(TAG, "❌ Error initializing WiFi state", e)
lastConnectedNetworkId = null
}
}
/**
* Prüft ob WiFi aktuell verbunden ist
* @return true wenn WiFi verbunden, false sonst (Cellular, offline, etc.)
*/
fun isWiFiConnected(): Boolean {
return try {
val activeNetwork = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
} catch (e: Exception) {
Logger.e(TAG, "Error checking WiFi status", e)
false
}
}
/**
* Stoppt WorkManager Auto-Sync + NetworkCallback
*/ */
fun stopMonitoring() { fun stopMonitoring() {
Logger.d(TAG, "🛑 Stopping auto-sync") Logger.d(TAG, "🛑 Stopping auto-sync")
// Stop WorkManager
WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME) WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
}
/** // Unregister NetworkCallback
* Trigger sofortigen Sync (z.B. nach Settings-Änderung) try {
*/ connectivityManager.unregisterNetworkCallback(networkCallback)
private fun triggerImmediateSync() { Logger.d(TAG, "✅ WiFi monitoring stopped")
if (!isConnectedToHomeWifi()) {
Logger.d(TAG, "Not on home WiFi - skipping immediate sync")
return
}
Logger.d(TAG, "<EFBFBD> Triggering immediate sync...")
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.addTag(Constants.SYNC_WORK_TAG)
.build()
WorkManager.getInstance(context).enqueue(syncRequest)
}
/**
* Prüft ob connected zu Home WiFi via Gateway IP Check
*/
private fun isConnectedToHomeWifi(): Boolean {
val gatewayIP = getGatewayIP() ?: return false
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty()) return false
val serverIP = extractIPFromUrl(serverUrl)
if (serverIP == null) return false
val sameNetwork = isSameNetwork(gatewayIP, serverIP)
Logger.d(TAG, "Gateway: $gatewayIP, Server: $serverIP → Same network: $sameNetwork")
return sameNetwork
}
private fun getGatewayIP(): String? {
return try {
val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE)
as WifiManager
val dhcpInfo = wifiManager.dhcpInfo
val gateway = dhcpInfo.gateway
val ip = String.format(
"%d.%d.%d.%d",
gateway and 0xFF,
(gateway shr 8) and 0xFF,
(gateway shr 16) and 0xFF,
(gateway shr 24) and 0xFF
)
ip
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "Failed to get gateway IP: ${e.message}") // Already unregistered
null
} }
} }
private fun extractIPFromUrl(url: String): String? {
return try {
val urlObj = java.net.URL(url)
val host = urlObj.host
if (host.matches(Regex("\\d+\\.\\d+\\.\\d+\\.\\d+"))) {
host
} else {
val addr = java.net.InetAddress.getByName(host)
addr.hostAddress
}
} catch (e: Exception) {
Logger.e(TAG, "Failed to extract IP: ${e.message}")
null
}
}
private fun isSameNetwork(ip1: String, ip2: String): Boolean {
val parts1 = ip1.split(".")
val parts2 = ip2.split(".")
if (parts1.size != 4 || parts2.size != 4) return false
return parts1[0] == parts2[0] &&
parts1[1] == parts2[1] &&
parts1[2] == parts2[2]
}
} }

View File

@@ -5,6 +5,7 @@ import android.content.Intent
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.NotificationHelper import dev.dettmer.simplenotes.utils.NotificationHelper
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -21,25 +22,72 @@ class SyncWorker(
} }
override suspend fun doWork(): Result = withContext(Dispatchers.IO) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
Logger.d(TAG, "🔄 SyncWorker started") if (BuildConfig.DEBUG) {
Logger.d(TAG, "Context: ${applicationContext.javaClass.simpleName}") Logger.d(TAG, "═══════════════════════════════════════")
Logger.d(TAG, "Thread: ${Thread.currentThread().name}") Logger.d(TAG, "🔄 SyncWorker.doWork() ENTRY")
Logger.d(TAG, "Context: ${applicationContext.javaClass.simpleName}")
Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
Logger.d(TAG, "RunAttempt: $runAttemptCount")
}
return@withContext try { return@withContext try {
// Start sync (kein "in progress" notification mehr) if (BuildConfig.DEBUG) {
val syncService = WebDavSyncService(applicationContext) Logger.d(TAG, "📍 Step 1: Before WebDavSyncService creation")
Logger.d(TAG, "🚀 Starting sync...") }
Logger.d(TAG, "📊 Attempt: ${runAttemptCount}")
val result = syncService.syncNotes() // Try-catch um Service-Creation
val syncService = try {
if (BuildConfig.DEBUG) {
Logger.d(TAG, " Creating WebDavSyncService with applicationContext...")
}
WebDavSyncService(applicationContext).also {
if (BuildConfig.DEBUG) {
Logger.d(TAG, " ✅ WebDavSyncService created successfully")
}
}
} catch (e: Exception) {
Logger.e(TAG, "💥 CRASH in WebDavSyncService constructor!", e)
Logger.e(TAG, "Exception: ${e.javaClass.name}: ${e.message}")
throw e
}
Logger.d(TAG, "📦 Sync result: success=${result.isSuccess}, count=${result.syncedCount}, error=${result.errorMessage}") if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 2: Before syncNotes() call")
Logger.d(TAG, " SyncService: $syncService")
}
// Try-catch um syncNotes
val result = try {
if (BuildConfig.DEBUG) {
Logger.d(TAG, " Calling syncService.syncNotes()...")
}
syncService.syncNotes().also {
if (BuildConfig.DEBUG) {
Logger.d(TAG, " ✅ syncNotes() returned")
}
}
} catch (e: Exception) {
Logger.e(TAG, "💥 CRASH in syncNotes()!", e)
Logger.e(TAG, "Exception: ${e.javaClass.name}: ${e.message}")
throw e
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 3: Processing result")
Logger.d(TAG, "📦 Sync result: success=${result.isSuccess}, count=${result.syncedCount}, error=${result.errorMessage}")
}
if (result.isSuccess) { if (result.isSuccess) {
Logger.d(TAG, "✅ Sync successful: ${result.syncedCount} notes") if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 4: Success path")
}
Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes")
// Nur Notification zeigen wenn tatsächlich etwas gesynct wurde // Nur Notification zeigen wenn tatsächlich etwas gesynct wurde
if (result.syncedCount > 0) { if (result.syncedCount > 0) {
if (BuildConfig.DEBUG) {
Logger.d(TAG, " Showing success notification...")
}
NotificationHelper.showSyncSuccess( NotificationHelper.showSyncSuccess(
applicationContext, applicationContext,
result.syncedCount result.syncedCount
@@ -49,10 +97,20 @@ class SyncWorker(
} }
// **UI REFRESH**: Broadcast für MainActivity // **UI REFRESH**: Broadcast für MainActivity
if (BuildConfig.DEBUG) {
Logger.d(TAG, " Broadcasting sync completed...")
}
broadcastSyncCompleted(true, result.syncedCount) broadcastSyncCompleted(true, result.syncedCount)
if (BuildConfig.DEBUG) {
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS")
Logger.d(TAG, "═══════════════════════════════════════")
}
Result.success() Result.success()
} else { } else {
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 4: Failure path")
}
Logger.e(TAG, "❌ Sync failed: ${result.errorMessage}") Logger.e(TAG, "❌ Sync failed: ${result.errorMessage}")
NotificationHelper.showSyncError( NotificationHelper.showSyncError(
applicationContext, applicationContext,
@@ -62,19 +120,39 @@ class SyncWorker(
// Broadcast auch bei Fehler (damit UI refresht) // Broadcast auch bei Fehler (damit UI refresht)
broadcastSyncCompleted(false, 0) broadcastSyncCompleted(false, 0)
if (BuildConfig.DEBUG) {
Logger.d(TAG, "❌ SyncWorker.doWork() FAILURE")
Logger.d(TAG, "═══════════════════════════════════════")
}
Result.failure() Result.failure()
} }
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "💥 Sync exception: ${e.message}", e) if (BuildConfig.DEBUG) {
Logger.d(TAG, "═══════════════════════════════════════")
}
Logger.e(TAG, "💥💥💥 FATAL EXCEPTION in doWork() 💥💥💥")
Logger.e(TAG, "Exception type: ${e.javaClass.name}") Logger.e(TAG, "Exception type: ${e.javaClass.name}")
Logger.e(TAG, "Exception message: ${e.message}")
Logger.e(TAG, "Stack trace:", e) Logger.e(TAG, "Stack trace:", e)
NotificationHelper.showSyncError(
applicationContext,
e.message ?: "Unknown error"
)
broadcastSyncCompleted(false, 0) try {
NotificationHelper.showSyncError(
applicationContext,
e.message ?: "Unknown error"
)
} catch (notifError: Exception) {
Logger.e(TAG, "Failed to show error notification", notifError)
}
try {
broadcastSyncCompleted(false, 0)
} catch (broadcastError: Exception) {
Logger.e(TAG, "Failed to broadcast", broadcastError)
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "═══════════════════════════════════════")
}
Result.failure() Result.failure()
} }
} }

View File

@@ -1,8 +1,11 @@
package dev.dettmer.simplenotes.sync package dev.dettmer.simplenotes.sync
import android.content.Context import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import com.thegrizzlylabs.sardineandroid.Sardine import com.thegrizzlylabs.sardineandroid.Sardine
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
@@ -10,6 +13,14 @@ import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.net.Inet4Address
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.NetworkInterface
import java.net.Proxy
import java.net.Socket
import javax.net.SocketFactory
class WebDavSyncService(private val context: Context) { class WebDavSyncService(private val context: Context) {
@@ -17,17 +28,158 @@ class WebDavSyncService(private val context: Context) {
private const val TAG = "WebDavSyncService" private const val TAG = "WebDavSyncService"
} }
private val storage = NotesStorage(context) private val storage: NotesStorage
private val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
init {
if (BuildConfig.DEBUG) {
Logger.d(TAG, "═══════════════════════════════════════")
Logger.d(TAG, "🏗️ WebDavSyncService INIT")
Logger.d(TAG, "Context: ${context.javaClass.simpleName}")
Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
}
try {
if (BuildConfig.DEBUG) {
Logger.d(TAG, " Creating NotesStorage...")
}
storage = NotesStorage(context)
if (BuildConfig.DEBUG) {
Logger.d(TAG, " ✅ NotesStorage created successfully")
Logger.d(TAG, " Notes dir: ${storage.getNotesDir()}")
}
} catch (e: Exception) {
Logger.e(TAG, "💥 CRASH in NotesStorage creation!", e)
Logger.e(TAG, "Exception: ${e.javaClass.name}: ${e.message}")
throw e
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, " SharedPreferences: $prefs")
Logger.d(TAG, "✅ WebDavSyncService INIT complete")
Logger.d(TAG, "═══════════════════════════════════════")
}
}
/**
* Findet WiFi Interface IP-Adresse (um VPN zu umgehen)
*/
private fun getWiFiInetAddress(): InetAddress? {
try {
Logger.d(TAG, "🔍 getWiFiInetAddress() called")
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetwork
Logger.d(TAG, " Active network: $network")
if (network == null) {
Logger.d(TAG, "❌ No active network")
return null
}
val capabilities = connectivityManager.getNetworkCapabilities(network)
Logger.d(TAG, " Network capabilities: $capabilities")
if (capabilities == null) {
Logger.d(TAG, "❌ No network capabilities")
return null
}
// Nur wenn WiFi aktiv
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
Logger.d(TAG, "⚠️ Not on WiFi, using default routing")
return null
}
Logger.d(TAG, "✅ Network is WiFi, searching for interface...")
// Finde WiFi Interface
val interfaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) {
val iface = interfaces.nextElement()
Logger.d(TAG, " Checking interface: ${iface.name}, isUp=${iface.isUp}")
// WiFi Interfaces: wlan0, wlan1, etc.
if (!iface.name.startsWith("wlan")) continue
if (!iface.isUp) continue
val addresses = iface.inetAddresses
while (addresses.hasMoreElements()) {
val addr = addresses.nextElement()
Logger.d(TAG, " Address: ${addr.hostAddress}, IPv4=${addr is Inet4Address}, loopback=${addr.isLoopbackAddress}, linkLocal=${addr.isLinkLocalAddress}")
// Nur IPv4, nicht loopback, nicht link-local
if (addr is Inet4Address && !addr.isLoopbackAddress && !addr.isLinkLocalAddress) {
Logger.d(TAG, "✅ Found WiFi IP: ${addr.hostAddress} on ${iface.name}")
return addr
}
}
}
Logger.w(TAG, "⚠️ No WiFi interface found, using default routing")
return null
} catch (e: Exception) {
Logger.e(TAG, "❌ Failed to get WiFi interface", e)
return null
}
}
/**
* Custom SocketFactory die an WiFi-IP bindet (VPN Fix)
*/
private inner class WiFiSocketFactory(private val wifiAddress: InetAddress) : SocketFactory() {
override fun createSocket(): Socket {
val socket = Socket()
socket.bind(InetSocketAddress(wifiAddress, 0))
Logger.d(TAG, "🔌 Socket bound to WiFi IP: ${wifiAddress.hostAddress}")
return socket
}
override fun createSocket(host: String, port: Int): Socket {
val socket = createSocket()
socket.connect(InetSocketAddress(host, port))
return socket
}
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket {
return createSocket(host, port)
}
override fun createSocket(host: InetAddress, port: Int): Socket {
val socket = createSocket()
socket.connect(InetSocketAddress(host, port))
return socket
}
override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket {
return createSocket(address, port)
}
}
private fun getSardine(): Sardine? { private fun getSardine(): Sardine? {
val username = prefs.getString(Constants.KEY_USERNAME, null) ?: return null val username = prefs.getString(Constants.KEY_USERNAME, null) ?: return null
val password = prefs.getString(Constants.KEY_PASSWORD, null) ?: return null val password = prefs.getString(Constants.KEY_PASSWORD, null) ?: return null
// Einfach standard OkHttpSardine - funktioniert im manuellen Sync! Logger.d(TAG, "🔧 Creating OkHttpSardine with WiFi binding")
android.util.Log.d(TAG, "🔧 Creating OkHttpSardine") Logger.d(TAG, " Context: ${context.javaClass.simpleName}")
return OkHttpSardine().apply { // Versuche WiFi-IP zu finden
val wifiAddress = getWiFiInetAddress()
val okHttpClient = if (wifiAddress != null) {
Logger.d(TAG, "✅ Using WiFi-bound socket factory")
OkHttpClient.Builder()
.socketFactory(WiFiSocketFactory(wifiAddress))
.build()
} else {
Logger.d(TAG, "⚠️ Using default OkHttpClient (no WiFi binding)")
OkHttpClient.Builder().build()
}
return OkHttpSardine(okHttpClient).apply {
setCredentials(username, password) setCredentials(username, password)
} }
} }
@@ -83,58 +235,102 @@ class WebDavSyncService(private val context: Context) {
} }
suspend fun syncNotes(): SyncResult = withContext(Dispatchers.IO) { suspend fun syncNotes(): SyncResult = withContext(Dispatchers.IO) {
android.util.Log.d(TAG, "🔄 syncNotes() called") Logger.d(TAG, "═══════════════════════════════════════")
android.util.Log.d(TAG, "Context: ${context.javaClass.simpleName}") Logger.d(TAG, "🔄 syncNotes() ENTRY")
Logger.d(TAG, "Context: ${context.javaClass.simpleName}")
Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
return@withContext try { return@withContext try {
val sardine = getSardine() Logger.d(TAG, "📍 Step 1: Getting Sardine client")
val sardine = try {
getSardine()
} catch (e: Exception) {
Logger.e(TAG, "💥 CRASH in getSardine()!", e)
e.printStackTrace()
throw e
}
if (sardine == null) { if (sardine == null) {
android.util.Log.e(TAG, "❌ Sardine is null - credentials missing") Logger.e(TAG, "❌ Sardine is null - credentials missing")
return@withContext SyncResult( return@withContext SyncResult(
isSuccess = false, isSuccess = false,
errorMessage = "Server-Zugangsdaten nicht konfiguriert" errorMessage = "Server-Zugangsdaten nicht konfiguriert"
) )
} }
Logger.d(TAG, " ✅ Sardine client created")
Logger.d(TAG, "📍 Step 2: Getting server URL")
val serverUrl = getServerUrl() val serverUrl = getServerUrl()
if (serverUrl == null) { if (serverUrl == null) {
android.util.Log.e(TAG, "❌ Server URL is null") Logger.e(TAG, "❌ Server URL is null")
return@withContext SyncResult( return@withContext SyncResult(
isSuccess = false, isSuccess = false,
errorMessage = "Server-URL nicht konfiguriert" errorMessage = "Server-URL nicht konfiguriert"
) )
} }
android.util.Log.d(TAG, "📡 Server URL: $serverUrl") Logger.d(TAG, "📡 Server URL: $serverUrl")
android.util.Log.d(TAG, "🔐 Credentials configured: ${prefs.getString(Constants.KEY_USERNAME, null) != null}") Logger.d(TAG, "🔐 Credentials configured: ${prefs.getString(Constants.KEY_USERNAME, null) != null}")
var syncedCount = 0 var syncedCount = 0
var conflictCount = 0 var conflictCount = 0
Logger.d(TAG, "📍 Step 3: Checking server directory")
// Ensure server directory exists // Ensure server directory exists
android.util.Log.d(TAG, "🔍 Checking if server directory exists...") try {
if (!sardine.exists(serverUrl)) { Logger.d(TAG, "🔍 Checking if server directory exists...")
android.util.Log.d(TAG, "📁 Creating server directory...") if (!sardine.exists(serverUrl)) {
sardine.createDirectory(serverUrl) Logger.d(TAG, "📁 Creating server directory...")
sardine.createDirectory(serverUrl)
}
Logger.d(TAG, " ✅ Server directory ready")
} catch (e: Exception) {
Logger.e(TAG, "💥 CRASH checking/creating server directory!", e)
e.printStackTrace()
throw e
} }
Logger.d(TAG, "📍 Step 4: Uploading local notes")
// Upload local notes // Upload local notes
android.util.Log.d(TAG, "⬆️ Uploading local notes...") try {
val uploadedCount = uploadLocalNotes(sardine, serverUrl) Logger.d(TAG, "⬆️ Uploading local notes...")
syncedCount += uploadedCount val uploadedCount = uploadLocalNotes(sardine, serverUrl)
android.util.Log.d(TAG, "✅ Uploaded: $uploadedCount notes") syncedCount += uploadedCount
Logger.d(TAG, "✅ Uploaded: $uploadedCount notes")
} catch (e: Exception) {
Logger.e(TAG, "💥 CRASH in uploadLocalNotes()!", e)
e.printStackTrace()
throw e
}
Logger.d(TAG, "📍 Step 5: Downloading remote notes")
// Download remote notes // Download remote notes
android.util.Log.d(TAG, "⬇️ Downloading remote notes...") try {
val downloadResult = downloadRemoteNotes(sardine, serverUrl) Logger.d(TAG, "⬇️ Downloading remote notes...")
syncedCount += downloadResult.downloadedCount val downloadResult = downloadRemoteNotes(sardine, serverUrl)
conflictCount += downloadResult.conflictCount syncedCount += downloadResult.downloadedCount
android.util.Log.d(TAG, "✅ Downloaded: ${downloadResult.downloadedCount} notes, Conflicts: ${downloadResult.conflictCount}") conflictCount += downloadResult.conflictCount
Logger.d(TAG, "✅ Downloaded: ${downloadResult.downloadedCount} notes, Conflicts: ${downloadResult.conflictCount}")
} catch (e: Exception) {
Logger.e(TAG, "💥 CRASH in downloadRemoteNotes()!", e)
e.printStackTrace()
throw e
}
Logger.d(TAG, "📍 Step 6: Saving sync timestamp")
// Update last sync timestamp // Update last sync timestamp
saveLastSyncTimestamp() try {
saveLastSyncTimestamp()
Logger.d(TAG, " ✅ Timestamp saved")
} catch (e: Exception) {
Logger.e(TAG, "💥 CRASH saving timestamp!", e)
e.printStackTrace()
// Non-fatal, continue
}
android.util.Log.d(TAG, "🎉 Sync completed successfully - Total synced: $syncedCount") Logger.d(TAG, "🎉 Sync completed successfully - Total synced: $syncedCount")
Logger.d(TAG, "═══════════════════════════════════════")
SyncResult( SyncResult(
isSuccess = true, isSuccess = true,
@@ -143,8 +339,13 @@ class WebDavSyncService(private val context: Context) {
) )
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e(TAG, "💥 Sync exception: ${e.message}", e) Logger.e(TAG, "═══════════════════════════════════════")
android.util.Log.e(TAG, "Exception type: ${e.javaClass.name}") Logger.e(TAG, "💥💥💥 FATAL EXCEPTION in syncNotes() 💥💥💥")
Logger.e(TAG, "Exception type: ${e.javaClass.name}")
Logger.e(TAG, "Exception message: ${e.message}")
Logger.e(TAG, "Stack trace:")
e.printStackTrace()
Logger.e(TAG, "═══════════════════════════════════════")
SyncResult( SyncResult(
isSuccess = false, isSuccess = false,
@@ -253,4 +454,95 @@ class WebDavSyncService(private val context: Context) {
fun getLastSyncTimestamp(): Long { fun getLastSyncTimestamp(): Long {
return prefs.getLong(Constants.KEY_LAST_SYNC, 0) return prefs.getLong(Constants.KEY_LAST_SYNC, 0)
} }
/**
* Restore all notes from server - overwrites local storage
* @return RestoreResult with count of restored notes
*/
suspend fun restoreFromServer(): RestoreResult = withContext(Dispatchers.IO) {
return@withContext try {
val sardine = getSardine() ?: return@withContext RestoreResult(
isSuccess = false,
errorMessage = "Server-Zugangsdaten nicht konfiguriert",
restoredCount = 0
)
val serverUrl = getServerUrl() ?: return@withContext RestoreResult(
isSuccess = false,
errorMessage = "Server-URL nicht konfiguriert",
restoredCount = 0
)
Logger.d(TAG, "🔄 Starting restore from server...")
// List all files on server
val resources = sardine.list(serverUrl)
val jsonFiles = resources.filter {
!it.isDirectory && it.name.endsWith(".json")
}
Logger.d(TAG, "📂 Found ${jsonFiles.size} files on server")
val restoredNotes = mutableListOf<Note>()
// Download and parse each file
for (resource in jsonFiles) {
try {
val fileUrl = serverUrl.trimEnd('/') + "/" + resource.name
val content = sardine.get(fileUrl).bufferedReader().use { it.readText() }
val note = Note.fromJson(content)
if (note != null) {
restoredNotes.add(note)
Logger.d(TAG, "✅ Downloaded: ${note.title}")
} else {
Logger.e(TAG, "❌ Failed to parse ${resource.name}: Note.fromJson returned null")
}
} catch (e: Exception) {
Logger.e(TAG, "❌ Failed to download ${resource.name}", e)
// Continue with other files
}
}
if (restoredNotes.isEmpty()) {
return@withContext RestoreResult(
isSuccess = false,
errorMessage = "Keine Notizen auf Server gefunden",
restoredCount = 0
)
}
// Clear local storage
Logger.d(TAG, "🗑️ Clearing local storage...")
storage.deleteAllNotes()
// Save all restored notes
Logger.d(TAG, "💾 Saving ${restoredNotes.size} notes...")
restoredNotes.forEach { note ->
storage.saveNote(note.copy(syncStatus = SyncStatus.SYNCED))
}
Logger.d(TAG, "✅ Restore completed: ${restoredNotes.size} notes")
RestoreResult(
isSuccess = true,
errorMessage = null,
restoredCount = restoredNotes.size
)
} catch (e: Exception) {
Logger.e(TAG, "❌ Restore failed", e)
RestoreResult(
isSuccess = false,
errorMessage = e.message ?: "Unbekannter Fehler",
restoredCount = 0
)
}
}
} }
data class RestoreResult(
val isSuccess: Boolean,
val errorMessage: String?,
val restoredCount: Int
)

View File

@@ -10,6 +10,10 @@ object Constants {
const val KEY_AUTO_SYNC = "auto_sync_enabled" const val KEY_AUTO_SYNC = "auto_sync_enabled"
const val KEY_LAST_SYNC = "last_sync_timestamp" const val KEY_LAST_SYNC = "last_sync_timestamp"
// 🔥 NEU: Sync Interval Configuration
const val PREF_SYNC_INTERVAL_MINUTES = "sync_interval_minutes"
const val DEFAULT_SYNC_INTERVAL_MINUTES = 30L
// 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

View File

@@ -1,30 +1,122 @@
package dev.dettmer.simplenotes.utils package dev.dettmer.simplenotes.utils
import android.content.Context
import android.util.Log import android.util.Log
import dev.dettmer.simplenotes.BuildConfig import dev.dettmer.simplenotes.BuildConfig
import java.io.File
import java.io.FileWriter
import java.io.PrintWriter
import java.text.SimpleDateFormat
import java.util.*
/** /**
* Logger: Debug logs nur bei DEBUG builds * Logger: Debug logs nur bei DEBUG builds + File Logging
* Release builds zeigen nur Errors/Warnings * Release builds zeigen nur Errors/Warnings
*/ */
object Logger { object Logger {
private var fileLoggingEnabled = false
private var logFile: File? = null
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
private val maxLogEntries = 500 // Nur letzte 500 Einträge
/**
* Aktiviert File-Logging für Debugging
*/
fun enableFileLogging(context: Context) {
try {
logFile = File(context.filesDir, "simplenotes_debug.log")
fileLoggingEnabled = true
// Clear old log
logFile?.writeText("")
i("Logger", "📝 File logging enabled: ${logFile?.absolutePath}")
} catch (e: Exception) {
Log.e("Logger", "Failed to enable file logging", e)
}
}
/**
* Deaktiviert File-Logging
*/
fun disableFileLogging() {
fileLoggingEnabled = false
i("Logger", "📝 File logging disabled")
}
/**
* Gibt Log-Datei zurück
*/
fun getLogFile(): File? = logFile
/**
* Schreibt Log-Eintrag in Datei
*/
private fun writeToFile(level: String, tag: String, message: String, throwable: Throwable? = null) {
if (!fileLoggingEnabled || logFile == null) return
try {
val timestamp = dateFormat.format(Date())
val logEntry = buildString {
append("$timestamp [$level] $tag: $message\n")
throwable?.let {
append(" Exception: ${it.message}\n")
append(" ${it.stackTraceToString()}\n")
}
}
// Append to file
FileWriter(logFile, true).use { writer ->
writer.write(logEntry)
}
// Trim file if too large
trimLogFile()
} catch (e: Exception) {
Log.e("Logger", "Failed to write to log file", e)
}
}
/**
* Begrenzt Log-Datei auf maxLogEntries
*/
private fun trimLogFile() {
try {
val lines = logFile?.readLines() ?: return
if (lines.size > maxLogEntries) {
val trimmed = lines.takeLast(maxLogEntries)
logFile?.writeText(trimmed.joinToString("\n") + "\n")
}
} catch (e: Exception) {
Log.e("Logger", "Failed to trim log file", e)
}
}
fun d(tag: String, message: String) { fun d(tag: String, message: String) {
// Logcat nur in DEBUG builds
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.d(tag, message) Log.d(tag, message)
} }
// File-Logging IMMER (wenn enabled)
writeToFile("DEBUG", tag, message)
} }
fun v(tag: String, message: String) { fun v(tag: String, message: String) {
// Logcat nur in DEBUG builds
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.v(tag, message) Log.v(tag, message)
} }
// File-Logging IMMER (wenn enabled)
writeToFile("VERBOSE", tag, message)
} }
fun i(tag: String, message: String) { fun i(tag: String, message: String) {
if (BuildConfig.DEBUG) { // INFO logs IMMER zeigen (auch in Release) - wichtige Events
Log.i(tag, message) Log.i(tag, message)
} // File-Logging IMMER (wenn enabled)
writeToFile("INFO", tag, message)
} }
// Errors und Warnings IMMER zeigen (auch in Release) // Errors und Warnings IMMER zeigen (auch in Release)
@@ -34,9 +126,11 @@ object Logger {
} else { } else {
Log.e(tag, message) Log.e(tag, message)
} }
writeToFile("ERROR", tag, message, throwable)
} }
fun w(tag: String, message: String) { fun w(tag: String, message: String) {
Log.w(tag, message) Log.w(tag, message)
writeToFile("WARN", tag, message)
} }
} }

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Simple Note Icon for Splash Screen -->
<path
android:fillColor="@android:color/white"
android:pathData="M32,24h44c4.4,0 8,3.6 8,8v44c0,4.4 -3.6,8 -8,8H32c-4.4,0 -8,-3.6 -8,-8V32c0,-4.4 3.6,-8 8,-8zM40,40v6h28v-6H40zM40,52v6h28v-6H40zM40,64v6h20v-6H40z"/>
</vector>

View File

@@ -5,31 +5,46 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:background="?attr/colorSurface"
android:fitsSystemWindows="true"> android:fitsSystemWindows="true">
<!-- Material 3 Toolbar -->
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar" android:id="@+id/toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" android:layout_height="?attr/actionBarSize"
android:elevation="0dp"
app:navigationIcon="@android:drawable/ic_menu_close_clear_cancel" app:navigationIcon="@android:drawable/ic_menu_close_clear_cancel"
app:title="@string/edit_note" /> app:title="@string/edit_note"
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
<!-- Material 3 Outlined TextInputLayout with 16dp corners -->
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="16dp" android:layout_margin="16dp"
android:hint="@string/title" android:hint="@string/title"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"> style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
app:boxCornerRadiusTopStart="16dp"
app:boxCornerRadiusTopEnd="16dp"
app:boxCornerRadiusBottomStart="16dp"
app:boxCornerRadiusBottomEnd="16dp"
app:endIconMode="clear_text"
app:counterEnabled="true"
app:counterMaxLength="100">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTextTitle" android:id="@+id/editTextTitle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:inputType="text" android:inputType="text"
android:maxLines="2" /> android:maxLines="2"
android:maxLength="100"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<!-- Material 3 Outlined TextInputLayout for Content -->
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
@@ -38,7 +53,14 @@
android:layout_marginEnd="16dp" android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
android:hint="@string/content" android:hint="@string/content"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"> style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
app:boxCornerRadiusTopStart="16dp"
app:boxCornerRadiusTopEnd="16dp"
app:boxCornerRadiusBottomStart="16dp"
app:boxCornerRadiusBottomEnd="16dp"
app:endIconMode="clear_text"
app:counterEnabled="true"
app:counterMaxLength="10000">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTextContent" android:id="@+id/editTextContent"
@@ -46,7 +68,9 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:gravity="top|start" android:gravity="top|start"
android:inputType="textMultiLine" android:inputType="textMultiLine"
android:scrollbars="vertical" /> android:scrollbars="vertical"
android:maxLength="10000"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>

View File

@@ -4,39 +4,83 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:fitsSystemWindows="true"> android:fitsSystemWindows="true">
<!-- Material 3: AppBarLayout ohne Elevation für bessere Material You Integration -->
<com.google.android.material.appbar.AppBarLayout <com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content"
app:elevation="0dp"
android:background="?attr/colorSurface">
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar" android:id="@+id/toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" android:layout_height="?attr/actionBarSize"
app:title="@string/app_name" /> android:elevation="0dp"
app:title="@string/app_name"
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>
<!-- RecyclerView mit größerem Padding für Material 3 -->
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerViewNotes" android:id="@+id/recyclerViewNotes"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:clipToPadding="false" android:clipToPadding="false"
android:padding="8dp" android:padding="16dp"
app:layout_behavior="@string/appbar_scrolling_view_behavior" /> app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<TextView <!-- Material 3 Empty State Card -->
android:id="@+id/textViewEmpty" <com.google.android.material.card.MaterialCardView
android:layout_width="wrap_content" android:id="@+id/emptyStateCard"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
android:text="@string/no_notes_yet" android:layout_marginStart="32dp"
android:textSize="18sp" android:layout_marginEnd="32dp"
android:textColor="?android:attr/textColorSecondary" android:visibility="gone"
android:gravity="center" app:cardElevation="0dp"
android:visibility="gone" /> app:cardCornerRadius="16dp"
style="@style/Widget.Material3.CardView.Filled">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="32dp"
android:gravity="center">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/empty_state_emoji"
android:textSize="64sp"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/empty_state_title"
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
android:layout_marginBottom="8dp"
android:gravity="center" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/empty_state_message"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:gravity="center" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Material 3: Extended FAB (wird später mit Text erweitert in Task 11) -->
<com.google.android.material.floatingactionbutton.FloatingActionButton <com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabAddNote" android:id="@+id/fabAddNote"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -44,6 +88,7 @@
android:layout_gravity="bottom|end" android:layout_gravity="bottom|end"
android:layout_margin="16dp" android:layout_margin="16dp"
android:contentDescription="@string/add_note" android:contentDescription="@string/add_note"
app:srcCompat="@android:drawable/ic_input_add" /> app:srcCompat="@android:drawable/ic_input_add"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Material3.Corner.Large" />
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -5,19 +5,24 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:fitsSystemWindows="true"> android:fitsSystemWindows="true"
android:background="?attr/colorSurface">
<!-- Material 3 Toolbar -->
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar" android:id="@+id/toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" android:layout_height="?attr/actionBarSize"
android:elevation="0dp"
app:title="@string/settings" app:title="@string/settings"
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge"
app:navigationIcon="?attr/homeAsUpIndicator" /> app:navigationIcon="?attr/homeAsUpIndicator" />
<ScrollView <ScrollView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1"> android:layout_weight="1"
android:fillViewport="true">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -25,145 +30,554 @@
android:orientation="vertical" android:orientation="vertical"
android:padding="16dp"> android:padding="16dp">
<!-- Server Settings --> <!-- Auto-Save Status Indicator -->
<TextView <com.google.android.material.chip.Chip
android:layout_width="match_parent" android:id="@+id/chipAutoSaveStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/server_settings" android:layout_gravity="center_horizontal"
android:textSize="18sp" android:layout_marginBottom="12dp"
android:textStyle="bold" android:visibility="gone"
android:layout_marginBottom="16dp" /> android:textSize="12sp"
app:chipIconEnabled="false" />
<com.google.android.material.textfield.TextInputLayout <!-- Material 3 Card: Server Configuration -->
android:layout_width="match_parent" <com.google.android.material.card.MaterialCardView
android:layout_height="wrap_content"
android:hint="@string/server_url"
android:layout_marginBottom="8dp"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTextServerUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/username"
android:layout_marginBottom="8dp"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTextUsername"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/password"
android:layout_marginBottom="16dp"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
app:endIconMode="password_toggle">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTextPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<!-- WiFi Settings -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/wifi_settings"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp" />
<!-- Server Status -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Server-Status:"
android:textStyle="bold"
android:textSize="14sp" />
<TextView
android:id="@+id/textViewServerStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Prüfe..."
android:textSize="14sp"
android:textColor="@android:color/darker_gray" />
</LinearLayout>
<!-- Info Box -->
<TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
android:padding="12dp" style="@style/Widget.Material3.CardView.Elevated"
android:background="@drawable/info_background" app:cardCornerRadius="16dp">
android:text=" Auto-Sync:\n\n• Prüft alle 30 Min ob Server erreichbar\n• Funktioniert nur im selben Netzwerk\n• Läuft auch im Hintergrund\n• Minimaler Akkuverbrauch (~0.4%/Tag)"
android:textSize="14sp"
android:lineSpacingMultiplier="1.2"
android:textColor="@android:color/black" />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="16dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:orientation="vertical"
android:text="@string/auto_sync" android:padding="20dp">
android:textSize="16sp"
android:layout_gravity="center_vertical" />
<androidx.appcompat.widget.SwitchCompat <!-- Section Header -->
android:id="@+id/switchAutoSync" <TextView
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" /> android:layout_height="wrap_content"
android:text="@string/server_settings"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:layout_marginBottom="16dp" />
</LinearLayout> <!-- Server URL with Icon -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/server_url"
android:layout_marginBottom="12dp"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
app:startIconDrawable="@android:drawable/ic_menu_compass"
app:endIconMode="clear_text"
app:boxCornerRadiusTopStart="12dp"
app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusBottomStart="12dp"
app:boxCornerRadiusBottomEnd="12dp">
<!-- Actions --> <com.google.android.material.textfield.TextInputEditText
<Button android:id="@+id/editTextServerUrl"
android:id="@+id/buttonTestConnection" android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Username with Icon -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/username"
android:layout_marginBottom="12dp"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
app:startIconDrawable="@android:drawable/ic_menu_myplaces"
app:endIconMode="clear_text"
app:boxCornerRadiusTopStart="12dp"
app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusBottomStart="12dp"
app:boxCornerRadiusBottomEnd="12dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTextUsername"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Password with Icon -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/password"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
app:startIconDrawable="@android:drawable/ic_lock_lock"
app:endIconMode="password_toggle"
app:boxCornerRadiusTopStart="12dp"
app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusBottomStart="12dp"
app:boxCornerRadiusBottomEnd="12dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTextPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Server Status Chip -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/server_status_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
<TextView
android:id="@+id/textViewServerStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_status_checking"
android:textAppearance="@style/TextAppearance.Material3.LabelMedium"
android:textColor="?attr/colorOutline" />
</LinearLayout>
<!-- Action Buttons -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="horizontal"
android:gravity="end">
<Button
android:id="@+id/buttonTestConnection"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/test_connection"
android:layout_marginEnd="8dp"
style="@style/Widget.Material3.Button.OutlinedButton" />
<Button
android:id="@+id/buttonSyncNow"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/sync_now"
style="@style/Widget.Material3.Button" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: Auto-Sync Settings -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/test_connection" android:layout_marginBottom="16dp"
android:layout_marginTop="8dp" style="@style/Widget.Material3.CardView.Elevated"
style="@style/Widget.Material3.Button.OutlinedButton" /> app:cardCornerRadius="16dp">
<Button <LinearLayout
android:id="@+id/buttonSyncNow" android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Section Header -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/sync_settings"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:layout_marginBottom="12dp" />
<!-- Info Card -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="?attr/colorSurfaceVariant"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="@string/auto_sync_info"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:lineSpacingMultiplier="1.3" />
</com.google.android.material.card.MaterialCardView>
<!-- Auto-Sync Switch with MaterialSwitch -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/auto_sync"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switchAutoSync"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: Backup & Restore -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/sync_now" android:layout_marginBottom="16dp"
android:layout_marginTop="8dp" /> style="@style/Widget.Material3.CardView.Elevated"
app:cardCornerRadius="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Section Header -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/backup_restore_title"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:layout_marginBottom="12dp" />
<!-- Warning Info Card -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="?attr/colorErrorContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="@string/backup_restore_warning"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnErrorContainer"
android:lineSpacingMultiplier="1.3" />
</com.google.android.material.card.MaterialCardView>
<!-- Restore Button -->
<Button
android:id="@+id/buttonRestoreFromServer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/restore_from_server"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: Sync Interval Configuration -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
style="@style/Widget.Material3.CardView.Elevated"
app:cardCornerRadius="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Section Header -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Sync-Intervall"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:layout_marginBottom="12dp" />
<!-- Info Card -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="?attr/colorSurfaceVariant"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="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."
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:lineSpacingMultiplier="1.3" />
</com.google.android.material.card.MaterialCardView>
<!-- Sync Interval Radio Group -->
<RadioGroup
android:id="@+id/radioGroupSyncInterval"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- 15 Minutes Option -->
<RadioButton
android:id="@+id/radioInterval15"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="⚡ Alle 15 Minuten"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:paddingVertical="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Schnellste Synchronisation • ~0.8% Akku/Tag (~23 mAh)"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOutline"
android:paddingStart="48dp"
android:paddingBottom="12dp" />
<!-- 30 Minutes Option (Recommended) -->
<RadioButton
android:id="@+id/radioInterval30"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="✓ Alle 30 Minuten (Empfohlen)"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:paddingVertical="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Ausgewogenes Verhältnis • ~0.4% Akku/Tag (~12 mAh)"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOutline"
android:paddingStart="48dp"
android:paddingBottom="12dp" />
<!-- 60 Minutes Option -->
<RadioButton
android:id="@+id/radioInterval60"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🔋 Alle 60 Minuten"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:paddingVertical="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Maximale Akkulaufzeit • ~0.2% Akku/Tag (~6 mAh geschätzt)"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOutline"
android:paddingStart="48dp" />
</RadioGroup>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: About Section -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
style="@style/Widget.Material3.CardView.Elevated"
app:cardCornerRadius="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Section Header -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Über diese App"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:layout_marginBottom="16dp" />
<!-- App Info Card -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardBackgroundColor="?attr/colorSurfaceVariant"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📱 App-Version"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?attr/colorPrimary"
android:layout_marginBottom="4dp" />
<TextView
android:id="@+id/textViewAppVersion"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Version wird geladen..."
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- GitHub Repository Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardGitHubRepo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="?attr/colorSurfaceVariant"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🌐 GitHub Repository"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?attr/colorPrimary"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Quellcode, Issues &amp; Dokumentation"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Developer Profile Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardDeveloperProfile"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="?attr/colorSurfaceVariant"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="👤 Entwickler"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?attr/colorPrimary"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="GitHub Profil: @inventory69"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- License Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardLicense"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="?attr/colorSurfaceVariant"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="⚖️ Lizenz"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?attr/colorPrimary"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="MIT License - Open Source"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout> </LinearLayout>

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material 3 Bottom Sheet Dialog für Lösch-Bestätigung -->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<!-- Icon -->
<ImageView
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="16dp"
android:src="@android:drawable/ic_menu_delete"
android:tint="?attr/colorError"
android:contentDescription="@string/delete" />
<!-- Title -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/delete_note_title"
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
android:gravity="center"
android:layout_marginBottom="8dp" />
<!-- Message -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/delete_note_message"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:gravity="center"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="24dp" />
<!-- Buttons -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="end">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnCancel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/cancel"
android:layout_marginEnd="8dp"
style="@style/Widget.Material3.Button.TextButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDelete"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/delete"
app:backgroundTint="?attr/colorError"
style="@style/Widget.Material3.Button" />
</LinearLayout>
</LinearLayout>

View File

@@ -1,61 +1,70 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Material 3: Filled Card Style (Flat, No Shadow) -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="4dp" android:layout_marginHorizontal="8dp"
app:cardElevation="2dp" android:layout_marginVertical="6dp"
app:cardCornerRadius="8dp"> style="@style/Widget.Material3.CardView.Filled"
app:cardCornerRadius="16dp"
app:cardElevation="0dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:padding="16dp"> android:padding="20dp">
<!-- Material 3 Typography: TitleMedium -->
<TextView <TextView
android:id="@+id/textViewTitle" android:id="@+id/textViewTitle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Note Title" android:text="@string/note_title_placeholder"
android:textSize="18sp" android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:textStyle="bold"
android:maxLines="2" android:maxLines="2"
android:ellipsize="end" /> android:ellipsize="end" />
<!-- Material 3 Typography: BodyMedium -->
<TextView <TextView
android:id="@+id/textViewContent" android:id="@+id/textViewContent"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:layout_marginTop="6dp"
android:text="Note content preview..." android:text="@string/note_content_placeholder"
android:textSize="14sp" android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?android:attr/textColorSecondary" android:textColor="?attr/colorOnSurfaceVariant"
android:maxLines="3" android:maxLines="3"
android:ellipsize="end" /> android:ellipsize="end" />
<!-- Metadata Row mit Timestamp und Sync-Status -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="12dp"
android:orientation="horizontal"> android:orientation="horizontal"
android:gravity="center_vertical">
<!-- Material 3 Typography: LabelSmall -->
<TextView <TextView
android:id="@+id/textViewTimestamp" android:id="@+id/textViewTimestamp"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:text="Vor 2 Std" android:text="@string/note_timestamp_placeholder"
android:textSize="12sp" android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
android:textColor="?android:attr/textColorTertiary" /> android:textColor="?attr/colorOutline" />
<!-- Sync-Status Icon mit Theme-Farbe -->
<ImageView <ImageView
android:id="@+id/imageViewSyncStatus" android:id="@+id/imageViewSyncStatus"
android:layout_width="16dp" android:layout_width="18dp"
android:layout_height="16dp" android:layout_height="18dp"
android:layout_gravity="center_vertical" android:layout_gravity="center_vertical"
android:src="@android:drawable/ic_popup_sync" android:src="@android:drawable/ic_popup_sync"
android:tint="?attr/colorPrimary"
android:contentDescription="@string/sync_status" /> android:contentDescription="@string/sync_status" />
</LinearLayout> </LinearLayout>

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Material 3 Dark Theme Colors -->
<color name="md_theme_dark_primary">#D0BCFF</color>
<color name="md_theme_dark_onPrimary">#381E72</color>
<color name="md_theme_dark_primaryContainer">#4F378B</color>
<color name="md_theme_dark_onPrimaryContainer">#EADDFF</color>
<color name="md_theme_dark_secondary">#CCC2DC</color>
<color name="md_theme_dark_onSecondary">#332D41</color>
<color name="md_theme_dark_secondaryContainer">#4A4458</color>
<color name="md_theme_dark_onSecondaryContainer">#E8DEF8</color>
<color name="md_theme_dark_tertiary">#EFB8C8</color>
<color name="md_theme_dark_onTertiary">#492532</color>
<color name="md_theme_dark_tertiaryContainer">#633B48</color>
<color name="md_theme_dark_onTertiaryContainer">#FFD8E4</color>
<color name="md_theme_dark_error">#F2B8B5</color>
<color name="md_theme_dark_onError">#601410</color>
<color name="md_theme_dark_errorContainer">#8C1D18</color>
<color name="md_theme_dark_onErrorContainer">#F9DEDC</color>
<color name="md_theme_dark_background">#1C1B1F</color>
<color name="md_theme_dark_onBackground">#E6E1E5</color>
<color name="md_theme_dark_surface">#1C1B1F</color>
<color name="md_theme_dark_onSurface">#E6E1E5</color>
<color name="md_theme_dark_surfaceVariant">#49454F</color>
<color name="md_theme_dark_onSurfaceVariant">#CAC4D0</color>
<color name="md_theme_dark_outline">#938F99</color>
<color name="md_theme_dark_outlineVariant">#49454F</color>
</resources>

View File

@@ -1,7 +1,35 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. --> <!-- Base application theme. -->
<style name="Base.Theme.SimpleNotes" parent="Theme.Material3.DayNight.NoActionBar"> <style name="Base.Theme.SimpleNotes" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your dark theme here. --> <!-- Material 3 Dark Color System -->
<!-- <item name="colorPrimary">@color/my_dark_primary</item> --> <item name="colorPrimary">@color/md_theme_dark_primary</item>
<item name="colorOnPrimary">@color/md_theme_dark_onPrimary</item>
<item name="colorPrimaryContainer">@color/md_theme_dark_primaryContainer</item>
<item name="colorOnPrimaryContainer">@color/md_theme_dark_onPrimaryContainer</item>
<item name="colorSecondary">@color/md_theme_dark_secondary</item>
<item name="colorOnSecondary">@color/md_theme_dark_onSecondary</item>
<item name="colorSecondaryContainer">@color/md_theme_dark_secondaryContainer</item>
<item name="colorOnSecondaryContainer">@color/md_theme_dark_onSecondaryContainer</item>
<item name="colorTertiary">@color/md_theme_dark_tertiary</item>
<item name="colorOnTertiary">@color/md_theme_dark_onTertiary</item>
<item name="colorTertiaryContainer">@color/md_theme_dark_tertiaryContainer</item>
<item name="colorOnTertiaryContainer">@color/md_theme_dark_onTertiaryContainer</item>
<item name="colorError">@color/md_theme_dark_error</item>
<item name="colorOnError">@color/md_theme_dark_onError</item>
<item name="colorErrorContainer">@color/md_theme_dark_errorContainer</item>
<item name="colorOnErrorContainer">@color/md_theme_dark_onErrorContainer</item>
<item name="android:colorBackground">@color/md_theme_dark_background</item>
<item name="colorOnBackground">@color/md_theme_dark_onBackground</item>
<item name="colorSurface">@color/md_theme_dark_surface</item>
<item name="colorOnSurface">@color/md_theme_dark_onSurface</item>
<item name="colorSurfaceVariant">@color/md_theme_dark_surfaceVariant</item>
<item name="colorOnSurfaceVariant">@color/md_theme_dark_onSurfaceVariant</item>
<item name="colorOutline">@color/md_theme_dark_outline</item>
<item name="colorOutlineVariant">@color/md_theme_dark_outlineVariant</item>
</style> </style>
</resources> </resources>

View File

@@ -1,5 +1,68 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Base colors -->
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
<!-- Material 3 Light Theme Colors -->
<color name="md_theme_light_primary">#6750A4</color>
<color name="md_theme_light_onPrimary">#FFFFFF</color>
<color name="md_theme_light_primaryContainer">#EADDFF</color>
<color name="md_theme_light_onPrimaryContainer">#21005D</color>
<color name="md_theme_light_secondary">#625B71</color>
<color name="md_theme_light_onSecondary">#FFFFFF</color>
<color name="md_theme_light_secondaryContainer">#E8DEF8</color>
<color name="md_theme_light_onSecondaryContainer">#1D192B</color>
<color name="md_theme_light_tertiary">#7D5260</color>
<color name="md_theme_light_onTertiary">#FFFFFF</color>
<color name="md_theme_light_tertiaryContainer">#FFD8E4</color>
<color name="md_theme_light_onTertiaryContainer">#31111D</color>
<color name="md_theme_light_error">#B3261E</color>
<color name="md_theme_light_onError">#FFFFFF</color>
<color name="md_theme_light_errorContainer">#F9DEDC</color>
<color name="md_theme_light_onErrorContainer">#410E0B</color>
<color name="md_theme_light_background">#FFFBFE</color>
<color name="md_theme_light_onBackground">#1C1B1F</color>
<color name="md_theme_light_surface">#FFFBFE</color>
<color name="md_theme_light_onSurface">#1C1B1F</color>
<color name="md_theme_light_surfaceVariant">#E7E0EC</color>
<color name="md_theme_light_onSurfaceVariant">#49454F</color>
<color name="md_theme_light_outline">#79747E</color>
<color name="md_theme_light_outlineVariant">#CAC4D0</color>
<!-- Material 3 Dark Theme Colors (base declarations for lint) -->
<color name="md_theme_dark_primary">#D0BCFF</color>
<color name="md_theme_dark_onPrimary">#381E72</color>
<color name="md_theme_dark_primaryContainer">#4F378B</color>
<color name="md_theme_dark_onPrimaryContainer">#EADDFF</color>
<color name="md_theme_dark_secondary">#CCC2DC</color>
<color name="md_theme_dark_onSecondary">#332D41</color>
<color name="md_theme_dark_secondaryContainer">#4A4458</color>
<color name="md_theme_dark_onSecondaryContainer">#E8DEF8</color>
<color name="md_theme_dark_tertiary">#EFB8C8</color>
<color name="md_theme_dark_onTertiary">#492532</color>
<color name="md_theme_dark_tertiaryContainer">#633B48</color>
<color name="md_theme_dark_onTertiaryContainer">#FFD8E4</color>
<color name="md_theme_dark_error">#F2B8B5</color>
<color name="md_theme_dark_onError">#601410</color>
<color name="md_theme_dark_errorContainer">#8C1D18</color>
<color name="md_theme_dark_onErrorContainer">#F9DEDC</color>
<color name="md_theme_dark_background">#1C1B1F</color>
<color name="md_theme_dark_onBackground">#E6E1E5</color>
<color name="md_theme_dark_surface">#1C1B1F</color>
<color name="md_theme_dark_onSurface">#E6E1E5</color>
<color name="md_theme_dark_surfaceVariant">#49454F</color>
<color name="md_theme_dark_onSurfaceVariant">#CAC4D0</color>
<color name="md_theme_dark_outline">#938F99</color>
<color name="md_theme_dark_outlineVariant">#49454F</color>
</resources> </resources>

View File

@@ -1,23 +1,60 @@
<resources> <resources>
<string name="app_name">Simple Notes</string> <string name="app_name">Simple Notes</string>
<!-- Main Activity -->
<string name="no_notes_yet">Noch keine Notizen.\nTippe + um eine zu erstellen.</string> <string name="no_notes_yet">Noch keine Notizen.\nTippe + um eine zu erstellen.</string>
<string name="add_note">Notiz hinzufügen</string> <string name="add_note">Notiz hinzufügen</string>
<string name="sync">Synchronisieren</string> <string name="sync">Synchronisieren</string>
<string name="settings">Einstellungen</string> <string name="settings">Einstellungen</string>
<!-- Empty State -->
<string name="empty_state_emoji">📝</string>
<string name="empty_state_title">Noch keine Notizen</string>
<string name="empty_state_message">Tippe auf um deine erste Notiz zu erstellen</string>
<!-- Note Editor -->
<string name="edit_note">Notiz bearbeiten</string> <string name="edit_note">Notiz bearbeiten</string>
<string name="new_note">Neue Notiz</string> <string name="new_note">Neue Notiz</string>
<string name="title">Titel</string> <string name="title">Titel</string>
<string name="content">Inhalt</string> <string name="content">Inhalt</string>
<string name="save">Speichern</string> <string name="save">Speichern</string>
<string name="delete">Löschen</string> <string name="delete">Löschen</string>
<!-- Note List Item (Preview placeholders) -->
<string name="note_title_placeholder">Note Title</string>
<string name="note_content_placeholder">Note content preview…</string>
<string name="note_timestamp_placeholder">Vor 2 Std</string>
<!-- Delete Confirmation Dialog -->
<string name="delete_note_title">Notiz löschen?</string>
<string name="delete_note_message">Diese Aktion kann nicht rückgängig gemacht werden.</string>
<string name="cancel">Abbrechen</string>
<!-- Settings -->
<string name="server_settings">Server-Einstellungen</string> <string name="server_settings">Server-Einstellungen</string>
<string name="server_url">Server URL</string> <string name="server_url">Server URL</string>
<string name="username">Benutzername</string> <string name="username">Benutzername</string>
<string name="password">Passwort</string> <string name="password">Passwort</string>
<string name="wifi_settings">WLAN-Einstellungen</string> <string name="server_status_label">Server-Status:</string>
<string name="home_ssid">Heim-WLAN SSID</string> <string name="server_status_checking">Prüfe…</string>
<string name="auto_sync">Auto-Sync aktiviert</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>
<!-- Auto-Sync Settings -->
<string name="sync_settings">Sync-Einstellungen</string>
<string name="home_ssid">Heim-WLAN SSID</string>
<string name="auto_sync">Auto-Sync aktiviert</string>
<string name="sync_status">Sync-Status</string> <string name="sync_status">Sync-Status</string>
<string name="auto_sync_info"> Auto-Sync:\n\n• Prüft alle 30 Min ob Server erreichbar\n• Funktioniert nur im selben Netzwerk\n• Läuft auch im Hintergrund\n• Minimaler Akkuverbrauch (~0.4%%/Tag)</string>
<!-- Backup & Restore -->
<string name="backup_restore_title">Backup &amp; Wiederherstellung</string>
<string name="backup_restore_warning">⚠️ Achtung:\n\nDie Wiederherstellung überschreibt ALLE lokalen Notizen mit den Daten vom Server. Diese Aktion kann nicht rückgängig gemacht werden!</string>
<string name="restore_from_server">Vom Server wiederherstellen</string>
<string name="restore_confirmation_title">⚠️ Vom Server wiederherstellen?</string>
<string name="restore_confirmation_message">WARNUNG: Alle lokalen Notizen werden gelöscht und durch die Notizen vom Server ersetzt.\n\nDieser Vorgang kann nicht rückgängig gemacht werden!</string>
<string name="restore_button">Wiederherstellen</string>
<string name="restore_progress">Stelle Notizen wieder her…</string>
<string name="restore_success">✓ %d Notizen wiederhergestellt</string>
<string name="restore_error">Fehler: %s</string>
</resources> </resources>

View File

@@ -1,9 +1,45 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. --> <!-- Base application theme. -->
<style name="Base.Theme.SimpleNotes" parent="Theme.Material3.DayNight.NoActionBar"> <style name="Base.Theme.SimpleNotes" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your light theme here. --> <!-- Material 3 Color System -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> --> <item name="colorPrimary">@color/md_theme_light_primary</item>
<item name="colorOnPrimary">@color/md_theme_light_onPrimary</item>
<item name="colorPrimaryContainer">@color/md_theme_light_primaryContainer</item>
<item name="colorOnPrimaryContainer">@color/md_theme_light_onPrimaryContainer</item>
<item name="colorSecondary">@color/md_theme_light_secondary</item>
<item name="colorOnSecondary">@color/md_theme_light_onSecondary</item>
<item name="colorSecondaryContainer">@color/md_theme_light_secondaryContainer</item>
<item name="colorOnSecondaryContainer">@color/md_theme_light_onSecondaryContainer</item>
<item name="colorTertiary">@color/md_theme_light_tertiary</item>
<item name="colorOnTertiary">@color/md_theme_light_onTertiary</item>
<item name="colorTertiaryContainer">@color/md_theme_light_tertiaryContainer</item>
<item name="colorOnTertiaryContainer">@color/md_theme_light_onTertiaryContainer</item>
<item name="colorError">@color/md_theme_light_error</item>
<item name="colorOnError">@color/md_theme_light_onError</item>
<item name="colorErrorContainer">@color/md_theme_light_errorContainer</item>
<item name="colorOnErrorContainer">@color/md_theme_light_onErrorContainer</item>
<item name="android:colorBackground">@color/md_theme_light_background</item>
<item name="colorOnBackground">@color/md_theme_light_onBackground</item>
<item name="colorSurface">@color/md_theme_light_surface</item>
<item name="colorOnSurface">@color/md_theme_light_onSurface</item>
<item name="colorSurfaceVariant">@color/md_theme_light_surfaceVariant</item>
<item name="colorOnSurfaceVariant">@color/md_theme_light_onSurfaceVariant</item>
<item name="colorOutline">@color/md_theme_light_outline</item>
<item name="colorOutlineVariant">@color/md_theme_light_outlineVariant</item>
</style> </style>
<style name="Theme.SimpleNotes" parent="Base.Theme.SimpleNotes" /> <style name="Theme.SimpleNotes" parent="Base.Theme.SimpleNotes" />
<!-- Splash Screen Theme (Android 12+) -->
<style name="Theme.SimpleNotes.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">?attr/colorPrimary</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_icon</item>
<item name="windowSplashScreenAnimationDuration">500</item>
<item name="postSplashScreenTheme">@style/Theme.SimpleNotes</item>
</style>
</resources> </resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- App-internal files directory -->
<files-path name="logs" path="." />
</paths>

View File

@@ -0,0 +1,38 @@
# F-Droid Metadata
Diese Verzeichnisstruktur enthält alle Metadaten für die F-Droid-Veröffentlichung.
## Struktur
```
fastlane/metadata/android/de-DE/
├── title.txt # App-Name (max 50 Zeichen)
├── short_description.txt # Kurzbeschreibung (max 80 Zeichen)
├── full_description.txt # Vollständige Beschreibung (max 4000 Zeichen)
├── changelogs/
│ └── 1.txt # Changelog für Version 1
└── images/
└── phoneScreenshots/ # Screenshots (PNG/JPG, 320-3840px breit)
├── 1.png # Hauptansicht (Notizliste)
├── 2.png # Notiz-Editor
├── 3.png # Settings
└── 4.png # Empty State
```
## Screenshots erstellen
Verwende einen Android Emulator oder physisches Gerät mit:
- Material You Theme aktiviert
- Deutsche Sprache
- Screenshots in hoher Auflösung (1080x2400 empfohlen)
### Screenshot-Reihenfolge:
1. **Notizliste** - Mit mehreren Beispiel-Notizen, Sync-Status sichtbar
2. **Editor** - Zeige eine bearbeitete Notiz mit Titel und Inhalt
3. **Settings** - Server-Konfiguration mit erfolgreichem Server-Status
4. **Empty State** - Schöne leere Ansicht mit Material 3 Card
## F-Droid Build-Konfiguration
Die App verwendet den `fdroid` Build-Flavor ohne proprietäre Dependencies.
Siehe `build.gradle.kts` für Details.

View File

@@ -0,0 +1,8 @@
• Material Design 3 mit Dynamic Colors
• Swipe-to-Delete mit Bestätigungsdialog
• Server Backup & Restore Funktion
• Verbesserte Empty State Ansicht
• Deutsche Lokalisierung
• Splash Screen Support (Android 12+)
• Performance-Verbesserungen
• Bug-Fixes

View File

@@ -0,0 +1,5 @@
• Konfigurierbares Sync-Interval (15/30/60 Minuten)
• Transparente Batterie-Verbrauchsanzeige (gemessen: 0.4%/Tag bei 30min)
• Doze Mode Optimierungen für zuverlässigere Background-Syncs
• About-Section mit App-Informationen und GitHub-Links
• Diverse Bugfixes und Performance-Verbesserungen

View File

@@ -0,0 +1,37 @@
Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisation.
HAUPTFUNKTIONEN:
• Einfache Notizen erstellen und bearbeiten
• WebDAV-Synchronisation mit eigenem Server
• Automatische Synchronisation im Heim-WLAN
• Konfigurierbares Sync-Interval (15/30/60 Minuten)
• Transparente Batterie-Verbrauchsanzeige
• Material Design 3 mit Dynamic Colors (Android 12+)
• Swipe-to-Delete mit Bestätigungsdialog
• Server-Backup & Wiederherstellung
• Komplett offline nutzbar
• Keine Werbung, keine Tracker
DATENSCHUTZ:
Deine Daten bleiben bei dir! Die App kommuniziert nur mit deinem eigenen WebDAV-Server. Keine Cloud-Dienste, keine Tracking-Bibliotheken, keine Analysetools.
SYNCHRONISATION:
• Unterstützt alle WebDAV-Server (Nextcloud, ownCloud, etc.)
• Konfigurierbares Interval: 15, 30 oder 60 Minuten
• Gemessener Akkuverbrauch: nur ~0.4% pro Tag (bei 30min)
• Doze Mode optimiert für zuverlässige Background-Syncs
• Manuelle Synchronisation jederzeit möglich
• Konfliktfreie Zusammenführung durch Timestamps
MATERIAL DESIGN 3:
• Moderne Benutzeroberfläche
• Dynamic Colors (Material You) auf Android 12+
• Dark Mode Support
• Intuitive Gesten (Swipe-to-Delete)
Open Source unter MIT-Lizenz
Quellcode: https://github.com/inventory69/simple-notes-sync

View File

@@ -0,0 +1 @@
Einfache Notizen-App mit WebDAV-Synchronisation

View File

@@ -0,0 +1 @@
Simple Notes Sync