From 44263880630f08fdce9a6574897d06a2c41a2ca1 Mon Sep 17 00:00:00 2001 From: erik Date: Fri, 13 Feb 2026 21:07:55 +0100 Subject: [PATCH] Update MEMORY.md, MEMORY.md, bachelor-thesis-summary.md, and 76 more files --- .../bachelor-thesis-summary/MEMORY.md | 37 + .../agent-memory/gemini-delegator/MEMORY.md | 118 ++ .claude/agents/bachelor-thesis-summary.md | 108 ++ .claude/agents/gemini-cli-runner.md | 84 ++ .claude/agents/gemini-delegator.md | 118 ++ .claude/settings.local.json | 12 + App v2/.gitignore | 15 + App v2/.idea/.gitignore | 3 + App v2/.idea/.name | 1 + App v2/.idea/AndroidProjectSystem.xml | 6 + App v2/.idea/codeStyles/Project.xml | 123 ++ App v2/.idea/codeStyles/codeStyleConfig.xml | 5 + App v2/.idea/compiler.xml | 6 + App v2/.idea/deploymentTargetSelector.xml | 18 + App v2/.idea/deviceManager.xml | 13 + App v2/.idea/gradle.xml | 19 + .../inspectionProfiles/Project_Default.xml | 61 + App v2/.idea/migrations.xml | 10 + App v2/.idea/misc.xml | 9 + App v2/.idea/runConfigurations.xml | 17 + App v2/app/.gitignore | 1 + App v2/app/build.gradle.kts | 77 ++ App v2/app/proguard-rules.pro | 21 + .../ExampleInstrumentedTest.kt | 24 + App v2/app/src/main/AndroidManifest.xml | 53 + .../erik/bleindoorpositioning/MainActivity.kt | 76 ++ .../bleindoorpositioning/ble/BeaconData.kt | 33 + .../bleindoorpositioning/ble/BleScanner.kt | 268 ++++ .../bleindoorpositioning/data/AppConfig.kt | 22 + .../data/ConfigRepository.kt | 116 ++ .../map/CoordinateMapper.kt | 69 + .../bleindoorpositioning/map/FloorplanView.kt | 203 +++ .../erik/bleindoorpositioning/model/Beacon.kt | 44 + .../bleindoorpositioning/model/Position.kt | 18 + .../positioning/PositionSmoother.kt | 42 + .../positioning/PositioningCalculator.kt | 211 ++++ .../positioning/RssiFilter.kt | 158 +++ .../ui/screens/ConfigScreen.kt | 632 ++++++++++ .../ui/screens/MainScreen.kt | 117 ++ .../ui/screens/PositioningScreen.kt | 282 +++++ .../bleindoorpositioning/ui/theme/Color.kt | 19 + .../bleindoorpositioning/ui/theme/Theme.kt | 43 + .../bleindoorpositioning/ui/theme/Type.kt | 34 + .../res/drawable/ic_launcher_background.xml | 170 +++ .../res/drawable/ic_launcher_foreground.xml | 30 + .../main/res/mipmap-anydpi/ic_launcher.xml | 6 + .../res/mipmap-anydpi/ic_launcher_round.xml | 6 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes App v2/app/src/main/res/values/colors.xml | 10 + App v2/app/src/main/res/values/strings.xml | 3 + App v2/app/src/main/res/values/themes.xml | 5 + App v2/app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + .../bleindoorpositioning/ExampleUnitTest.kt | 17 + App v2/build.gradle.kts | 5 + App v2/gradle.properties | 23 + App v2/gradle/libs.versions.toml | 51 + App v2/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes .../gradle/wrapper/gradle-wrapper.properties | 9 + App v2/gradlew | 251 ++++ App v2/gradlew.bat | 94 ++ App v2/settings.gradle.kts | 23 + CLAUDE.md | 181 +++ .../ESP32_iBeacon/ESP32_iBeacon.ino | 124 ++ .../ESP32_iBeacon_v2/ESP32_iBeacon_v2.ino | 146 +++ ESP32-Code-Analyse.md | 161 +++ Expose | 139 ++ Literaturrecherche.md | 152 +++ RSSI-Stabilisierung.md | 67 + chat.md | 1118 +++++++++++++++++ 79 files changed, 6169 insertions(+) create mode 100644 .claude/agent-memory/bachelor-thesis-summary/MEMORY.md create mode 100644 .claude/agent-memory/gemini-delegator/MEMORY.md create mode 100644 .claude/agents/bachelor-thesis-summary.md create mode 100644 .claude/agents/gemini-cli-runner.md create mode 100644 .claude/agents/gemini-delegator.md create mode 100644 .claude/settings.local.json create mode 100644 App v2/.gitignore create mode 100644 App v2/.idea/.gitignore create mode 100644 App v2/.idea/.name create mode 100644 App v2/.idea/AndroidProjectSystem.xml create mode 100644 App v2/.idea/codeStyles/Project.xml create mode 100644 App v2/.idea/codeStyles/codeStyleConfig.xml create mode 100644 App v2/.idea/compiler.xml create mode 100644 App v2/.idea/deploymentTargetSelector.xml create mode 100644 App v2/.idea/deviceManager.xml create mode 100644 App v2/.idea/gradle.xml create mode 100644 App v2/.idea/inspectionProfiles/Project_Default.xml create mode 100644 App v2/.idea/migrations.xml create mode 100644 App v2/.idea/misc.xml create mode 100644 App v2/.idea/runConfigurations.xml create mode 100644 App v2/app/.gitignore create mode 100644 App v2/app/build.gradle.kts create mode 100644 App v2/app/proguard-rules.pro create mode 100644 App v2/app/src/androidTest/java/com/erik/bleindoorpositioning/ExampleInstrumentedTest.kt create mode 100644 App v2/app/src/main/AndroidManifest.xml create mode 100644 App v2/app/src/main/java/com/erik/bleindoorpositioning/MainActivity.kt create mode 100644 App v2/app/src/main/java/com/erik/bleindoorpositioning/ble/BeaconData.kt create mode 100644 App v2/app/src/main/java/com/erik/bleindoorpositioning/ble/BleScanner.kt create mode 100644 App v2/app/src/main/java/com/erik/bleindoorpositioning/data/AppConfig.kt create mode 100644 App v2/app/src/main/java/com/erik/bleindoorpositioning/data/ConfigRepository.kt create mode 100644 App v2/app/src/main/java/com/erik/bleindoorpositioning/map/CoordinateMapper.kt create mode 100644 App v2/app/src/main/java/com/erik/bleindoorpositioning/map/FloorplanView.kt create mode 100644 App v2/app/src/main/java/com/erik/bleindoorpositioning/model/Beacon.kt create mode 100644 App v2/app/src/main/java/com/erik/bleindoorpositioning/model/Position.kt create mode 100644 App v2/app/src/main/java/com/erik/bleindoorpositioning/positioning/PositionSmoother.kt create mode 100644 App v2/app/src/main/java/com/erik/bleindoorpositioning/positioning/PositioningCalculator.kt create mode 100644 App v2/app/src/main/java/com/erik/bleindoorpositioning/positioning/RssiFilter.kt create mode 100644 App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/screens/ConfigScreen.kt create mode 100644 App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/screens/MainScreen.kt create mode 100644 App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/screens/PositioningScreen.kt create mode 100644 App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/theme/Color.kt create mode 100644 App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/theme/Theme.kt create mode 100644 App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/theme/Type.kt create mode 100644 App v2/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 App v2/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 App v2/app/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 App v2/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml create mode 100644 App v2/app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 App v2/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 App v2/app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 App v2/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 App v2/app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 App v2/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 App v2/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 App v2/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 App v2/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 App v2/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 App v2/app/src/main/res/values/colors.xml create mode 100644 App v2/app/src/main/res/values/strings.xml create mode 100644 App v2/app/src/main/res/values/themes.xml create mode 100644 App v2/app/src/main/res/xml/backup_rules.xml create mode 100644 App v2/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 App v2/app/src/test/java/com/erik/bleindoorpositioning/ExampleUnitTest.kt create mode 100644 App v2/build.gradle.kts create mode 100644 App v2/gradle.properties create mode 100644 App v2/gradle/libs.versions.toml create mode 100644 App v2/gradle/wrapper/gradle-wrapper.jar create mode 100644 App v2/gradle/wrapper/gradle-wrapper.properties create mode 100755 App v2/gradlew create mode 100644 App v2/gradlew.bat create mode 100644 App v2/settings.gradle.kts create mode 100644 CLAUDE.md create mode 100644 ESP32 iBeacon Code/ESP32_iBeacon/ESP32_iBeacon.ino create mode 100644 ESP32 iBeacon Code/ESP32_iBeacon_v2/ESP32_iBeacon_v2.ino create mode 100644 ESP32-Code-Analyse.md create mode 100644 Expose create mode 100644 Literaturrecherche.md create mode 100644 RSSI-Stabilisierung.md create mode 100644 chat.md diff --git a/.claude/agent-memory/bachelor-thesis-summary/MEMORY.md b/.claude/agent-memory/bachelor-thesis-summary/MEMORY.md new file mode 100644 index 0000000..39468de --- /dev/null +++ b/.claude/agent-memory/bachelor-thesis-summary/MEMORY.md @@ -0,0 +1,37 @@ +# BLE Indoor Positioning - Bachelor Thesis Summary Agent Memory + +## Projekt-Kontext +- Bachelorarbeit an einer Fachhochschule, ca. 50 Seiten +- Thema: BLE-basierte Indoor-Lokalisierung mit ESP32 iBeacons + Android-App (Kotlin) +- Forschungsfrage: Zuverlaessigkeit BLE-basierter Indoor-Navigation fuer sehbehinderte Personen +- Fokus: Elektrotechnische Betrachtung, RSSI-Modellierung, Machbarkeitsstudie +- Abgrenzung: Keine sicherheitskritische/medizinische Anwendung + +## Technische Architektur (Key Details) +- 5-stufige Pipeline: BleScanner -> BeaconFilterManager -> PositionCalculator -> PositionSmoother -> UI +- RSSI-Filter: Kalman (primaer), EMA, MovingAverage als Alternativen (RssiFilter-Interface) +- Positionsberechnung: IDW (Exponent 4) -> Gauss-Newton -> Blending (0.5-1.5m Uebergang) +- Ausreisser-Schwellwert: 20 dBm; Beacon-Frische: 3s +- Kalibrierung: RSSI_AT_ONE_METER=-65 dBm, PATH_LOSS_EXPONENT=5.5 +- Raum: 3.5x4.5m, 3 Beacons + +## Akademische Konventionen +- Expose-Struktur: Projektvorstellung (~40%) -> Umsetzungskonzept (~50%) -> Fazit/Ausblick (~10%) +- Sprache: Deutsch, akademisch aber verstaendlich, keine Umlaute (ASCII-kompatibel fuer Textdateien) +- Umfang einseitige Zusammenfassung: ca. 400-500 Woerter +- Keine Code-Snippets in Zusammenfassungen, technische Aspekte in Prosa + +## Gliederung der Bachelorarbeit +1. Einleitung & Motivation (4-5 S.) +2. Grundlagen (10-12 S.) +3. Stand der Technik (8-9 S.) +4. Systemkonzept (8-9 S.) +5. Implementierung (7-8 S.) +6. Evaluation & Messergebnisse (8-9 S.) +7. Fazit & Ausblick (3-4 S.) + +## Lessons Learned +- Quellcode ist komplett deutsch kommentiert +- Kein DI-Framework, direkte Instanziierung in MainActivity +- StateFlow fuer reaktive UI-Updates (Compose) +- Expose als reine Textdatei (kein Markdown) mit ASCII-Umlauten (ae, oe, ue, ss) diff --git a/.claude/agent-memory/gemini-delegator/MEMORY.md b/.claude/agent-memory/gemini-delegator/MEMORY.md new file mode 100644 index 0000000..e2b30df --- /dev/null +++ b/.claude/agent-memory/gemini-delegator/MEMORY.md @@ -0,0 +1,118 @@ +# Gemini-Delegator Memory + +## Bug-Patterns + +### State-Initialisierung bei Scan-Start +**Problem identifiziert:** In BleScanner wurde `PositionCalculator` nur beim `startScanning()` initialisiert. Config-Änderungen während eines laufenden Scans wurden nicht übernommen. + +**Lösung:** Extrahiere Initialisierungs-Logik in separate Methode (`initializePositionCalculator()`), biete öffentliche `reloadConfig()` Methode an. UI ruft diese nach Config-Änderung auf. + +**Pattern:** Bei Langlebigen Objekten (Scanner) die State von kurzlebiger Config abhängt → Reload-Mechanismus vorsehen. + +### KeyboardType für negative Zahlen +**Problem:** `KeyboardType.Decimal` und `KeyboardType.Number` erlauben in Android Compose **kein Minuszeichen**. + +**Lösung:** `KeyboardType.Text` für Felder verwenden, die negative Werte akzeptieren müssen (Koordinaten, RSSI). Validierung via `toDoubleOrNull()` bleibt bestehen. + +**Betroffene Felder:** Beacon-Koordinaten (X/Y), Raum-Positionen (X/Y), RSSI-Werte (immer negativ). + +## Hardware-Debugging + +### BLE-Unsichtbarkeit (ESP32 + Android) +**Problem:** Ein spezifischer ESP32-C3 wird auf einem spezifischen Android-Handy NICHT gefunden (auch nicht mit nRF Connect), auf anderen Handys aber schon. Andere ESP32s funktionieren auf dem problematischen Handy. + +**Ursache (wahrscheinlich):** MAC-Adress-Kollision oder Android BLE-Controller-Blacklisting auf Hardware-Level. + +**Diagnose-Schritte:** +1. Original BLE-MAC auslesen (`esp_bt_dev_get_address()` nach `BLEDevice::init()`) +2. Mit MAC auf funktionierendem Handy (nRF Connect) vergleichen +3. Custom-MAC setzen via `esp_base_mac_addr_set()` **VOR** `BLEDevice::init()` + +**Lösung:** Custom-MAC flashen mit locally administered Address (Byte 0 = `0x02`), eindeutige Werte wählen. + +**Code-Pattern (ESP32):** +```cpp +#include "esp_bt_device.h" // Für MAC-Ausgabe +#include "esp_mac.h" // Für MAC-Setzen + +uint8_t custom_mac[6] = {0x02, 0xAA, 0xBB, 0xCC, 0xDD, 0x05}; +esp_base_mac_addr_set(custom_mac); // VOR BLEDevice::init() +BLEDevice::init("Name"); +const uint8_t* ble_mac = esp_bt_dev_get_address(); // Ausgeben +``` + +**Quellen:** [Random Nerd Tutorials](https://randomnerdtutorials.com/get-change-esp32-esp8266-mac-address-arduino/), [Nordic DevZone BLE Invisibility](https://devzone.nordicsemi.com/f/nordic-q-a/77366/nrf-ble-android---my-blescannercompat-doesn-t-detect-a-device-but-nrf-connect-does) + +## Projekt-Spezifisches + +### BLE Indoor Positioning App +- **Aktive Version:** `/App v2/` (vorher `/App v1_2/`) +- **Config-Persistierung:** SharedPreferences via `ConfigRepository` +- **Datenfluss:** ConfigRepository → BleScanner → PositionCalculator → UI +- **Keine DI:** Direkte Instanziierung, StateFlow für Reaktivität +- **Sprache:** Kotlin, Compose UI, Kommentare auf Deutsch +- **Koordinaten:** Beliebige Werte möglich (auch negativ) – Bounding Box wird aus Räumen + Beacons berechnet + +### Kritische Klassen +- `BleScanner`: Zentrale BLE-Logik, verwaltet Scanner, Filter, Calculator, Smoother +- `PositionCalculator`: Trilateration (IDW + Gauss-Newton + Blending) +- `ConfigRepository`: Lädt/Speichert AppConfig (Raumgröße + Beacon-Positionen) +- `BeaconFilterManager`: Verwaltet RSSI-Filter pro Beacon (Kalman/EMA/MA) + +### Häufige Fehlerquellen +- Config-Änderungen während laufender Operationen +- Initialisierung nur bei Start ohne Reload-Mechanismus +- Fehlende Synchronisation zwischen UI-State und Scanner-State + +### Config-Modell Pattern (erweitert) +**Gelernt bei:** Individuelle pathLossExponent + rssiAtOneMeter pro Beacon +- `@Serializable data class` mit Default-Werten → Abwärtskompatibilität (alte Configs ohne neue Felder laden mit Defaults) +- Neue Felder in **beiden** Dialogen: + - **AddBeaconDialog:** Neue Beacons (Default-Werte vorbelegen: `mutableStateOf("5.5")`) + - **EditBeaconDialog:** Bearbeitung (beacon.field.toString() initialisieren) +- Config-Anzeige: LazyColumn erweitern (neue Felder anzeigen) +- Beacon-Liste klickbar: `.clickable { showEditBeaconDialog = beacon }` +- Berechnungs-Methoden: Per-Beacon-Lookup mit Fallback → `beacons.find { it.minor == beaconMinor }?.field ?: globalDefault` + +## Gemini-Interaktion + +### Rate Limits +Gemini: 429 RESOURCE_EXHAUSTED möglich, liefert oft trotzdem Ergebnisse nach Retries (am Ende der Ausgabe). + +### Gemini Prompt Best Practices +- Kontext vollständig angeben (relevante Code-Ausschnitte mit Line-Nummern) +- Aufgabe strukturiert (nummeriert), Dateipfade explizit +- Output-Format: "OUTPUT ONLY unified diff, no explanations" + +### Wann selbst fixen vs. Gemini +- **Selbst:** Klare Anforderung, minimale Änderung (z.B. KeyboardType-Wechsel) +- **Gemini:** Große Refactorings, komplexe Algorithmen +- **Nach Gemini:** IMMER fachlich prüfen (Typ, Logik, Patterns) + +### Gemini Fehler-Pattern +**Problem:** Gemini generiert Diffs mit falschen Annahmen über Datenstrukturen +- **Beispiel (2026-02-10):** Negative Koordinaten – Gemini verwendete `.corners` (nicht existent) statt `.x/.y/.width/.height` +- **Lösung:** Bei strukturellen Änderungen (Datenmodell, API) relevante Klassen vollständig in Prompt inkludieren +- **Warnung:** Gemini erfindet manchmal Pfade (`com.example` statt `com.erik`) → Diff-Pfade immer prüfen + +### Color-Parameter in @Composable mit Canvas (2026-02-10) +**Problem:** Gemini versuchte `beaconColor: Color = MaterialTheme.colorScheme.primary` als Default-Parameter in Funktionssignatur zu setzen – funktioniert NICHT, da kein @Composable-Context in Parameter-Defaults verfügbar ist. + +**Lösung:** Color-Parameter MIT Defaults direkt in der @Composable-Funktion deklarieren (wo MaterialTheme verfügbar ist). Bei Helper-Funktionen (z.B. drawRooms) Parameter ohne Defaults durchreichen. + +**Korrektur:** Nach Gemini-Output selbst gefixt – Color-Parameter in FloorplanView mit MaterialTheme-Defaults versehen, drawRooms()-Signatur manuell erweitert. + +### Material Icons Verfügbarkeit (2026-02-10) +**Problem:** Gemini generiert oft nicht-existierende Material Icons (z.B. `DarkMode`, `LightMode`, `Brightness4`, `Brightness7`, `Nightlight`, `WbSunny`). + +**Lösung:** Nach Gemini-Output IMMER Icon-Verfügbarkeit prüfen: +- Nur Icons aus `Icons.Default.*` verwenden, die in bestehenden Dateien bereits verwendet werden (z.B. `Settings`, `Place`, `Info`) +- Falls spezielle Icons benötigt werden: Im Projekt nach verwendeten Icons greppen (`Icons\.Default\.`) +- Fallback: Generische Icons wie `Info`, `Check`, `Star` verwenden + +**Warnung:** `Icons.Outlined`/`Icons.Filled` sind oft nicht vollständig in älteren Material Icons Versionen verfügbar. + +### remember() außerhalb @Composable (2026-02-10) +**Problem:** Gemini verwendete `var isDarkMode by remember { mutableStateOf(...) }` in `MainActivity.onCreate()` – `remember` funktioniert NICHT außerhalb von `@Composable` Funktionen. + +**Lösung:** Direkt `mutableStateOf()` als Property verwenden (`private var isDarkMode by mutableStateOf(false)`) oder `rememberSaveable` INNERHALB `setContent { }` Block nutzen. diff --git a/.claude/agents/bachelor-thesis-summary.md b/.claude/agents/bachelor-thesis-summary.md new file mode 100644 index 0000000..df52722 --- /dev/null +++ b/.claude/agents/bachelor-thesis-summary.md @@ -0,0 +1,108 @@ +--- +name: bachelor-thesis-summary +description: "Use this agent when the user needs a structured, one-page summary of a Bachelor thesis project, including key points, implementation concept, and project overview suitable for academic presentation at a Fachhochschule (University of Applied Sciences). This includes summarizing code, documentation, and technical concepts into a concise academic overview.\\n\\nExamples:\\n\\n- User: \"Hier ist mein Exposé und der aktuelle Code, kannst du mir eine Zusammenfassung erstellen?\"\\n Assistant: \"Ich verwende den bachelor-thesis-summary Agenten, um eine strukturierte einseitige Zusammenfassung mit Konzept und Projektvorstellung zu erstellen.\"\\n\\n- User: \"Ich brauche eine Übersicht meiner Bachelorarbeit für die Präsentation beim Kolloquium.\"\\n Assistant: \"Ich starte den bachelor-thesis-summary Agenten, um die Kernpunkte der Arbeit zusammenzufassen und ein Umsetzungskonzept zu formulieren.\"\\n\\n- User: \"Fasse mir den Code und die Doku zusammen für mein Poster.\"\\n Assistant: \"Ich nutze den bachelor-thesis-summary Agenten, um aus Code und Dokumentation eine kompakte Projektvorstellung zu generieren.\"\\n\\n- Context: The user has just shared or referenced thesis-related documents, code, or research notes and needs them distilled into a presentable academic summary.\\n User: \"Kannst du das hier für mich auf eine Seite zusammenfassen?\"\\n Assistant: \"Ich verwende jetzt den bachelor-thesis-summary Agenten, um eine einseitige Übersicht mit Konzept und Projektvorstellung zu erstellen.\"" +model: opus +memory: project +--- + +Du bist ein erfahrener wissenschaftlicher Mitarbeiter an einer Fachhochschule mit Expertise in der Betreuung von Bachelorarbeiten, insbesondere im Bereich Informatik, Embedded Systems und Mobile App-Entwicklung. Du hast jahrelange Erfahrung darin, komplexe technische Projekte prägnant und verständlich zusammenzufassen und für akademische Zwecke aufzubereiten. + +## Deine Aufgabe + +Du erstellst aus den dir gegebenen Texten, Code-Dokumentationen und technischen Beschreibungen eine **einseitige Übersicht** einer Bachelorarbeit. Diese Übersicht besteht aus zwei Hauptteilen: + +1. **Projektvorstellung** — Was ist das Projekt, warum ist es relevant, was sind die Kernpunkte? +2. **Konzept zur Umsetzung** — Wie wird das Projekt technisch umgesetzt, welche Methoden und Technologien kommen zum Einsatz, und wie ist die Umsetzung im Rahmen einer Fachhochschule geplant? + +## Vorgehen + +### Schritt 1: Analyse +- Lies alle bereitgestellten Texte, Code-Dateien und Dokumentationen sorgfältig durch. +- Identifiziere die **Forschungsfrage / Problemstellung** der Arbeit. +- Extrahiere die **Kernpunkte** (Key Points): Welche Technologien? Welche Methoden? Welche Ergebnisse oder erwarteten Ergebnisse? +- Verstehe die **Architektur** und den **Datenfluss** des Systems. + +### Schritt 2: Strukturierung +Gliedere die Zusammenfassung wie folgt: + +**Titel der Arbeit** +(Wenn nicht explizit angegeben, formuliere einen passenden akademischen Titel) + +**1. Projektvorstellung** (~40% des Platzes) +- Problemstellung und Motivation (1-2 Sätze) +- Zielsetzung der Arbeit (1-2 Sätze) +- Kernpunkte / Key Points als kompakte Aufzählung (3-5 Punkte) +- Relevanz und Anwendungskontext + +**2. Konzept zur Umsetzung** (~50% des Platzes) +- Technischer Ansatz und Methodik +- Systemarchitektur (kompakt beschrieben) +- Verwendete Technologien und Frameworks +- Umsetzungsplan im Fachhochschul-Kontext (z.B. verfügbare Infrastruktur, Testumgebung, zeitlicher Rahmen) +- Evaluationsstrategie + +**3. Fazit / Ausblick** (~10% des Platzes) +- Erwarteter Mehrwert +- Mögliche Erweiterungen + +### Schritt 3: Qualitätskontrolle +- Prüfe, ob die Zusammenfassung tatsächlich auf **eine Seite** passt (ca. 400-500 Wörter). +- Stelle sicher, dass **Fachbegriffe** korrekt verwendet werden. +- Achte auf einen **akademischen, aber verständlichen** Schreibstil. +- Vermeide zu viele technische Details — die Übersicht soll auch für Nicht-Experten (z.B. Prüfungsausschuss) verständlich sein. +- Stelle sicher, dass alle Kernpunkte der Arbeit klar werden. + +## Stilrichtlinien + +- **Sprache**: Deutsch, akademischer Stil, aber klar und prägnant. +- **Ton**: Sachlich, professionell, informativ. +- **Länge**: Strikt eine Seite (ca. 400-500 Wörter). Lieber präzise als ausschweifend. +- **Formatierung**: Verwende Überschriften, kurze Absätze und ggf. Aufzählungen für bessere Lesbarkeit. +- Verwende **keine** Code-Snippets in der Zusammenfassung — beschreibe technische Aspekte in Prosa oder als Aufzählung. +- Wenn Diagramme oder Abbildungen sinnvoll wären, beschreibe diese als Platzhalter (z.B. "[Abbildung: Systemarchitektur]"). + +## Umgang mit unvollständigen Informationen + +- Wenn wichtige Informationen fehlen (z.B. Forschungsfrage nicht klar, Ergebnisse noch ausstehend), formuliere den entsprechenden Abschnitt als **geplantes Vorgehen** oder **erwartetes Ergebnis**. +- Kennzeichne Annahmen, die du triffst, transparent. +- Frage nach, wenn zentrale Informationen für eine sinnvolle Zusammenfassung fehlen. + +## Kontext: Fachhochschule + +Berücksichtige bei der Umsetzungsplanung typische Rahmenbedingungen einer Fachhochschule: +- Praxisorientierter Ansatz (Prototyp-Entwicklung, nicht nur theoretische Analyse) +- Verfügbare Laborräume und Hardware (z.B. für BLE-Beacons, Testaufbauten) +- Bearbeitungszeitraum einer Bachelorarbeit (typisch 3-4 Monate) +- Betreuung durch Professor*in und ggf. externe Partner + +## Wichtig + +Dein Output ist die fertige, einseitige Zusammenfassung — kein Entwurf, keine Diskussion. Liefere direkt das fertige Dokument ab, das so verwendet werden kann. Wenn du Rückfragen hast, stelle diese **vor** der Erstellung, nicht danach. + +**Update your agent memory** as you discover thesis topics, project structures, recurring academic patterns, terminology conventions, and Fachhochschule-specific requirements. This builds up institutional knowledge across conversations. Write concise notes about what you found. + +Examples of what to record: +- Thesis topic areas and their typical structure +- Common technology stacks in student projects +- Preferred academic phrasing and formatting conventions +- Fachhochschule-specific evaluation criteria and presentation formats +- Calibration parameters and technical details that recur across projects + +# Persistent Agent Memory + +You have a persistent Persistent Agent Memory directory at `/home/erik/Nextcloud/Uni/Bachelorarbeit/BleIndoorPositioning/Claude Test/.claude/agent-memory/bachelor-thesis-summary/`. Its contents persist across conversations. + +As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned. + +Guidelines: +- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise +- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md +- Record insights about problem constraints, strategies that worked or failed, and lessons learned +- Update or remove memories that turn out to be wrong or outdated +- Organize memory semantically by topic, not chronologically +- Use the Write and Edit tools to update your memory files +- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project + +## MEMORY.md + +Your MEMORY.md is currently empty. As you complete tasks, write down key learnings, patterns, and insights so you can be more effective in future conversations. Anything saved in MEMORY.md will be included in your system prompt next time. diff --git a/.claude/agents/gemini-cli-runner.md b/.claude/agents/gemini-cli-runner.md new file mode 100644 index 0000000..9b5a9c6 --- /dev/null +++ b/.claude/agents/gemini-cli-runner.md @@ -0,0 +1,84 @@ +--- +name: gemini-cli-runner +description: "Use this agent when the user wants to query Google's Gemini AI model via the command-line interface, needs a second AI opinion, wants to leverage Gemini for code generation, analysis, translation, or any task that benefits from consulting Gemini. This agent runs Gemini in headless mode using the `gemini -p` command.\\n\\nExamples:\\n\\n\\nContext: The user asks for a Gemini perspective on a code implementation.\\nuser: \"Frag mal Gemini, wie man eine Singleton-Klasse in Kotlin implementiert.\"\\nassistant: \"Ich nutze den Gemini-CLI-Agent, um Gemini dazu zu befragen.\"\\n\\nSince the user wants to consult Gemini, use the Task tool to launch the gemini-cli-runner agent to query Gemini via the CLI.\\n\\n\\n\\n\\nContext: The user wants Gemini to review or analyze something.\\nuser: \"Lass Gemini mal diesen Algorithmus bewerten.\"\\nassistant: \"Ich starte den Gemini-CLI-Agent, um Gemini den Algorithmus bewerten zu lassen.\"\\n\\nThe user explicitly wants Gemini's input, so use the Task tool to launch the gemini-cli-runner agent.\\n\\n\\n\\n\\nContext: The user wants to compare Claude's and Gemini's answers.\\nuser: \"Was sagt Gemini zu diesem Thema?\"\\nassistant: \"Ich frage Gemini über den CLI-Agent.\"\\n\\nThe user wants Gemini's perspective, so use the Task tool to launch the gemini-cli-runner agent to get Gemini's response.\\n\\n" +model: sonnet +--- + +Du bist ein spezialisierter Agent für die Ausführung von Google Gemini CLI-Abfragen im Headless-Modus. Deine einzige Aufgabe ist es, Gemini über die Kommandozeile aufzurufen, die Ergebnisse zu erfassen und sie dem Benutzer klar und strukturiert zurückzugeben. + +## Kernaufgabe + +Du führst Gemini-Abfragen aus, indem du den folgenden Befehl verwendest: + +``` +gemini -p "" +``` + +## Arbeitsablauf + +1. **Prompt vorbereiten**: Nimm die Anfrage des Benutzers und formuliere einen klaren, präzisen Prompt für Gemini. Wenn der Benutzer bereits einen fertigen Prompt liefert, verwende diesen direkt. Wenn der Benutzer eine allgemeine Anfrage stellt, formuliere einen optimalen Prompt. + +2. **Befehl ausführen**: Führe den Befehl `gemini -p ""` über das Terminal aus. + - **WICHTIG**: Escape alle doppelten Anführungszeichen im Prompt mit Backslash (`\"`) oder verwende einfache Anführungszeichen als äußere Begrenzung, wenn der Prompt doppelte Anführungszeichen enthält. + - Bei sehr langen Prompts: Verwende `gemini -p ''` mit einfachen Anführungszeichen. + - Wenn der Prompt sowohl einfache als auch doppelte Anführungszeichen enthält, verwende einen Heredoc-Ansatz oder escape entsprechend. + +3. **Ergebnis zurückgeben**: Gib die Antwort von Gemini vollständig und unverändert zurück. Kennzeichne sie klar als Gemini-Antwort. + +4. **Fehlerbehandlung**: + - Wenn der Befehl fehlschlägt, melde den Fehler und versuche es ggf. mit einer angepassten Syntax erneut. + - Wenn Gemini eine leere Antwort gibt, weise darauf hin und schlage einen umformulierten Prompt vor. + - Wenn der Befehl `gemini` nicht gefunden wird, informiere den Benutzer, dass Gemini CLI nicht installiert oder nicht im PATH ist. + +## Formatierung der Ausgabe + +Strukturiere deine Antwort so: + +``` +📡 Gemini-Abfrage: +[Der verwendete Prompt] + +🤖 Gemini-Antwort: +[Die vollständige Antwort von Gemini] +``` + +Falls relevant, füge eine kurze Zusammenfassung oder Einordnung der Antwort hinzu. + +## Wichtige Regeln + +- Führe **immer** den tatsächlichen `gemini -p` Befehl aus. Simuliere oder erfinde niemals eine Gemini-Antwort. +- Verändere die Gemini-Antwort nicht. Gib sie originalgetreu wieder. +- Wenn der Benutzer auf Deutsch kommuniziert, formuliere den Prompt ebenfalls auf Deutsch, es sei denn, der Benutzer wünscht explizit eine andere Sprache. +- Wenn der Benutzer Code oder Dateien analysieren lassen möchte, lies die relevanten Dateien zuerst ein und inkludiere den Inhalt im Prompt an Gemini. +- Halte Prompts fokussiert und klar – Gemini im Headless-Modus hat keinen Konversationskontext, daher muss jeder Prompt eigenständig verständlich sein. + +## Kontextanreicherung + +Wenn der Benutzer möchte, dass Gemini Code aus dem Projekt analysiert: +1. Lies die relevante(n) Datei(en) ein +2. Integriere den Code in den Prompt mit klarer Kennzeichnung +3. Formuliere die Frage so, dass Gemini genügend Kontext hat + +Beispiel: +``` +gemini -p "Analysiere den folgenden Kotlin-Code und schlage Verbesserungen vor:\n\n$(cat path/to/file.kt)" +``` + +# Persistent Agent Memory + +You have a persistent Persistent Agent Memory directory at `/home/erik/Nextcloud/Uni/Bachelorarbeit/BleIndoorPositioning/Claude Test/.claude/agent-memory/gemini-cli-runner/`. Its contents persist across conversations. + +As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned. + +Guidelines: +- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise +- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md +- Record insights about problem constraints, strategies that worked or failed, and lessons learned +- Update or remove memories that turn out to be wrong or outdated +- Organize memory semantically by topic, not chronologically +- Use the Write and Edit tools to update your memory files +- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project + +## MEMORY.md + +Your MEMORY.md is currently empty. As you complete tasks, write down key learnings, patterns, and insights so you can be more effective in future conversations. Anything saved in MEMORY.md will be included in your system prompt next time. diff --git a/.claude/agents/gemini-delegator.md b/.claude/agents/gemini-delegator.md new file mode 100644 index 0000000..ff01b22 --- /dev/null +++ b/.claude/agents/gemini-delegator.md @@ -0,0 +1,118 @@ +--- +name: gemini-delegator +description: "Use this agent when the user wants a task to be delegated to Gemini via the `gemini -p` command. This agent translates the user's request into a clear, well-structured prompt for Gemini, executes it, reviews Gemini's output for technical correctness, and lets Gemini write files if the task requires it — but only after verifying the content is correct.\\n\\nExamples:\\n\\n\\nContext: The user asks for a new Kotlin data class to be created.\\nuser: \"Erstelle eine neue Data-Klasse für Beacon-Kalibrierungsdaten mit Feldern für UUID, RSSI und Pfadverlust-Exponent.\"\\nassistant: \"Ich delegiere diese Aufgabe an Gemini über den gemini-delegator Agent.\"\\n\\nSince the user wants code to be written, use the Task tool to launch the gemini-delegator agent. The agent will formulate a precise prompt for Gemini, execute it via `gemini -p`, review the output for correctness, and then let Gemini write the file if the result is technically sound.\\n\\n\\n\\n\\nContext: The user wants documentation generated for an existing module.\\nuser: \"Schreib mir eine README für das positioning-Modul.\"\\nassistant: \"Ich nutze den gemini-delegator Agent, um diese Aufgabe an Gemini weiterzugeben und das Ergebnis zu prüfen.\"\\n\\nSince the user wants documentation written, use the Task tool to launch the gemini-delegator agent. The agent will craft an appropriate prompt, delegate to Gemini, verify the documentation is factually accurate regarding the codebase, and let Gemini write the file.\\n\\n\\n\\n\\nContext: The user wants a unit test written for an existing class.\\nuser: \"Erstelle Unit-Tests für den PositionSmoother.\"\\nassistant: \"Ich delegiere das an den gemini-delegator Agent, der Gemini die Tests schreiben lässt und sie vorher auf Korrektheit prüft.\"\\n\\nSince the user wants tests written, use the Task tool to launch the gemini-delegator agent. The agent will understand the PositionSmoother class, formulate a clear prompt for Gemini, review the generated tests for technical accuracy (correct assertions, proper test structure, matching the actual API), and then let Gemini write the test file.\\n\\n" +model: sonnet +color: red +memory: project +--- + +Du bist ein erfahrener Software-Architekt und technischer Projektleiter, der als Vermittler zwischen dem Benutzer und Gemini fungiert. Deine Kernkompetenz liegt darin, Aufgaben präzise zu erfassen, sie in klare und vollständige Prompts für Gemini zu übersetzen, und die Ergebnisse fachlich zu überprüfen, bevor sie finalisiert werden. + +## Deine Rolle + +Du bist der **Qualitätssicherungs-Layer** zwischen dem Benutzer und Gemini. Du stellst sicher, dass: +1. Die Aufgabe korrekt verstanden und vollständig an Gemini kommuniziert wird +2. Gemini alle nötigen Kontextinformationen erhält +3. Die Ergebnisse fachlich korrekt sind, bevor sie geschrieben werden + +## Arbeitsablauf + +### Schritt 1: Aufgabe erfassen +- Lies die Aufgabe des Benutzers sorgfältig durch +- Identifiziere: Was genau soll erstellt/geändert werden? Welche Dateien sind betroffen? Welche fachlichen Anforderungen gibt es? +- Falls die Aufgabe unklar ist, stelle Rückfragen an den Benutzer + +### Schritt 2: Kontext sammeln +- Lies relevante bestehende Dateien im Projekt, um den Kontext zu verstehen +- Beachte die Projektstruktur, Coding-Konventionen, und bestehende Patterns +- Sammle alle Informationen, die Gemini braucht, um die Aufgabe korrekt zu erledigen + +### Schritt 3: Prompt für Gemini formulieren +- Formuliere einen **klaren, präzisen und vollständigen** Prompt auf Deutsch +- Inkludiere relevanten Kontext (z.B. bestehende Interfaces, Klassennamen, Architektur-Entscheidungen) +- Gib explizit an, was Gemini tun soll (Code schreiben, Datei erstellen, etc.) +- Verwende das Format: `gemini -p ""` +- **Wichtig**: Der Prompt muss in Anführungszeichen stehen und darf keine unescapten Sonderzeichen enthalten, die die Shell brechen könnten. Escape innere Anführungszeichen mit Backslash. +- Wenn die Aufgabe das Schreiben von Dateien verlangt, sage Gemini explizit, dass es die Dateien schreiben soll, und gib den genauen Dateipfad an. + +### Schritt 4: Gemini ausführen +- Führe den `gemini -p "..."` Befehl aus +- Warte auf das Ergebnis + +### Schritt 5: Ergebnis prüfen (KRITISCH) +Bevor du das Ergebnis akzeptierst oder Gemini Dateien schreiben lässt, prüfe: + +**Fachliche Korrektheit:** +- Ist die Logik korrekt? Stimmen Algorithmen, Berechnungen, Datenflüsse? +- Werden bestehende APIs und Interfaces korrekt verwendet? +- Stimmen Typen, Parameter, Rückgabewerte? +- Sind Edge-Cases berücksichtigt? + +**Code-Qualität:** +- Passt der Code zum bestehenden Stil des Projekts? +- Sind Namenskonventionen eingehalten? +- Ist der Code in der richtigen Sprache (Kotlin, Deutsch für Kommentare)? + +**Projektkonformität:** +- Passt der Code zur bestehenden Architektur? +- Werden die richtigen Dependencies verwendet? +- Stimmen Paketstruktur und Dateipfade? + +### Schritt 6: Korrigieren (falls nötig) +- Wenn du Fehler findest, korrigiere sie **selbst** direkt im Code +- Dokumentiere kurz, was du korrigiert hast und warum +- Bei gravierenden Fehlern: Formuliere einen verbesserten Prompt und lass Gemini es nochmal versuchen + +### Schritt 7: Dateien schreiben lassen +- Wenn die Ergebnisse korrekt sind (oder nach deiner Korrektur korrekt sind), und die Aufgabe das Schreiben von Dateien verlangt, lass Gemini die Dateien schreiben oder schreibe sie selbst mit den korrigierten Inhalten +- Bestätige dem Benutzer, was erstellt/geändert wurde + +## Wichtige Regeln + +1. **Niemals blind weiterleiten**: Verstehe die Aufgabe IMMER selbst, bevor du sie an Gemini delegierst +2. **Niemals ungeprüft schreiben lassen**: Prüfe JEDES Ergebnis von Gemini, bevor Dateien geschrieben werden +3. **Kontext ist König**: Lies immer relevante bestehende Dateien, bevor du den Prompt formulierst +4. **Transparenz**: Teile dem Benutzer mit, was du an Gemini delegierst, was das Ergebnis war, und was du ggf. korrigiert hast +5. **Sprache**: Kommuniziere auf Deutsch mit dem Benutzer und formuliere Prompts an Gemini auf Deutsch +6. **Bei Unsicherheit**: Frage lieber beim Benutzer nach, als falsche Annahmen zu treffen + +## Prompt-Formulierung Best Practices + +- Sei spezifisch: Nenne genaue Dateinamen, Klassennamen, Methodensignaturen +- Gib Kontext: Füge relevante Code-Snippets oder Architektur-Beschreibungen in den Prompt ein +- Definiere das Format: Sage Gemini genau, in welchem Format du die Ausgabe erwartest +- Setze Grenzen: Sage Gemini, was es NICHT tun soll (z.B. keine bestehenden Dateien verändern, wenn nur neue erstellt werden sollen) + +## Fehlerbehandlung + +- Wenn `gemini -p` fehlschlägt: Versuche den Prompt zu vereinfachen oder aufzuteilen +- Wenn Geminis Ergebnis komplett unbrauchbar ist: Formuliere den Prompt um mit mehr Kontext +- Wenn du dir bei der fachlichen Prüfung unsicher bist: Recherchiere im Projekt-Code und in der Dokumentation +- Nach maximal 3 Versuchen mit Gemini: Informiere den Benutzer über das Problem und biete an, die Aufgabe selbst zu lösen + +**Update dein Agent-Memory** wenn du Muster entdeckst, wie Gemini am besten promptet wird, welche Art von Fehlern Gemini häufig macht, und welche Kontextinformationen besonders wichtig für gute Ergebnisse sind. Notiere auch projektspezifische Erkenntnisse. + +Beispiele für Memory-Einträge: +- Gemini-Prompt-Patterns die gut funktionieren +- Häufige Fehler in Geminis Output (z.B. falsche API-Nutzung, fehlende Imports) +- Projektspezifische Konventionen die Gemini oft vergisst +- Optimale Prompt-Längen und Detailgrade für verschiedene Aufgabentypen + +# Persistent Agent Memory + +You have a persistent Persistent Agent Memory directory at `/home/erik/Nextcloud/Uni/Bachelorarbeit/BleIndoorPositioning/Claude Test/.claude/agent-memory/gemini-delegator/`. Its contents persist across conversations. + +As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned. + +Guidelines: +- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise +- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md +- Record insights about problem constraints, strategies that worked or failed, and lessons learned +- Update or remove memories that turn out to be wrong or outdated +- Organize memory semantically by topic, not chronologically +- Use the Write and Edit tools to update your memory files +- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project + +## MEMORY.md + +Your MEMORY.md is currently empty. As you complete tasks, write down key learnings, patterns, and insights so you can be more effective in future conversations. Anything saved in MEMORY.md will be included in your system prompt next time. diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..42d55de --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "WebSearch", + "Bash(gemini -p:*)", + "Bash(./gradlew assembleDebug:*)", + "Bash(./gradlew compileDebugKotlin:*)", + "Bash(timeout 30 tail:*)", + "WebFetch(domain:www.espressif.com)" + ] + } +} diff --git a/App v2/.gitignore b/App v2/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/App v2/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/App v2/.idea/.gitignore b/App v2/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/App v2/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/App v2/.idea/.name b/App v2/.idea/.name new file mode 100644 index 0000000..bca9395 --- /dev/null +++ b/App v2/.idea/.name @@ -0,0 +1 @@ +BleIndoorPositioning \ No newline at end of file diff --git a/App v2/.idea/AndroidProjectSystem.xml b/App v2/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/App v2/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/App v2/.idea/codeStyles/Project.xml b/App v2/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/App v2/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/App v2/.idea/codeStyles/codeStyleConfig.xml b/App v2/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/App v2/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/App v2/.idea/compiler.xml b/App v2/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/App v2/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/App v2/.idea/deploymentTargetSelector.xml b/App v2/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..53f4ede --- /dev/null +++ b/App v2/.idea/deploymentTargetSelector.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/App v2/.idea/deviceManager.xml b/App v2/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/App v2/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/App v2/.idea/gradle.xml b/App v2/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/App v2/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/App v2/.idea/inspectionProfiles/Project_Default.xml b/App v2/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..7061a0d --- /dev/null +++ b/App v2/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,61 @@ + + + + \ No newline at end of file diff --git a/App v2/.idea/migrations.xml b/App v2/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/App v2/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/App v2/.idea/misc.xml b/App v2/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/App v2/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/App v2/.idea/runConfigurations.xml b/App v2/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/App v2/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/App v2/app/.gitignore b/App v2/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/App v2/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/App v2/app/build.gradle.kts b/App v2/app/build.gradle.kts new file mode 100644 index 0000000..22758c2 --- /dev/null +++ b/App v2/app/build.gradle.kts @@ -0,0 +1,77 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + kotlin("plugin.serialization") version "2.0.21" +} + +android { + namespace = "com.erik.bleindoorpositioning" + compileSdk { + version = release(36) + } + + defaultConfig { + applicationId = "com.erik.bleindoorpositioning" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + + implementation("androidx.compose.material:material-icons-extended:1.7.6") + + + // Bestehende Einträge beibehalten, dann ergänzen: + + + // Lifecycle und ViewModel + implementation(libs.androidx.lifecycle.viewmodel.compose) + + // Permissions Handling + implementation(libs.accompanist.permissions) + + // Coroutines für asynchrones BLE-Scanning + implementation(libs.kotlinx.coroutines.android) + + // Kotlinx Serialization für Config-Persistierung + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0") + + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} \ No newline at end of file diff --git a/App v2/app/proguard-rules.pro b/App v2/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/App v2/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/App v2/app/src/androidTest/java/com/erik/bleindoorpositioning/ExampleInstrumentedTest.kt b/App v2/app/src/androidTest/java/com/erik/bleindoorpositioning/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..01987f8 --- /dev/null +++ b/App v2/app/src/androidTest/java/com/erik/bleindoorpositioning/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.erik.bleindoorpositioning + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.erik.bleindoorpositioning", appContext.packageName) + } +} \ No newline at end of file diff --git a/App v2/app/src/main/AndroidManifest.xml b/App v2/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..aa47874 --- /dev/null +++ b/App v2/app/src/main/AndroidManifest.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/MainActivity.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/MainActivity.kt new file mode 100644 index 0000000..32322b2 --- /dev/null +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/MainActivity.kt @@ -0,0 +1,76 @@ +package com.erik.bleindoorpositioning + +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import com.erik.bleindoorpositioning.ble.BleScanner +import com.erik.bleindoorpositioning.data.ConfigRepository +import com.erik.bleindoorpositioning.ui.screens.MainScreen +import com.erik.bleindoorpositioning.ui.theme.BleIndoorPositioningTheme + +class MainActivity : ComponentActivity() { + + private lateinit var bleScanner: BleScanner + private var isDarkMode by mutableStateOf(false) + private var wasScanningOnPause = false + + companion object { + private const val TAG = "MainActivity" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val configRepository = ConfigRepository(applicationContext) + bleScanner = BleScanner(this, configRepository, lifecycleScope) + + // Lade gespeicherten Dark Mode Status + isDarkMode = configRepository.getIsDarkMode() + + setContent { + BleIndoorPositioningTheme(darkTheme = isDarkMode) { + Surface(modifier = Modifier.fillMaxSize()) { + MainScreen( + bleScanner = bleScanner, + configRepository = configRepository, + isDarkMode = isDarkMode, + onToggleDarkMode = { + isDarkMode = !isDarkMode + configRepository.setIsDarkMode(isDarkMode) + } + ) + } + } + } + } + + override fun onPause() { + super.onPause() + wasScanningOnPause = bleScanner.isScanning.value + if (bleScanner.isScanning.value) { + Log.d(TAG, "onPause: Stopping BLE scan.") + bleScanner.stopScanning() + } + } + + override fun onResume() { + super.onResume() + if (wasScanningOnPause) { + Log.d(TAG, "onResume: Restarting BLE scan.") + bleScanner.startScanning() + } + } + + override fun onDestroy() { + super.onDestroy() + bleScanner.stopScanning() + } +} diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/ble/BeaconData.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/ble/BeaconData.kt new file mode 100644 index 0000000..a3263a9 --- /dev/null +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/ble/BeaconData.kt @@ -0,0 +1,33 @@ +package com.erik.bleindoorpositioning.ble + +object BeaconData { + + const val BEACON_UUID = "87b99b2c-9080-4d04-8f00-a5d9a3f9a0e7" + + /** + * NICHT auf 0x004C ändern. + */ + const val IBEACON_MANUFACTURER_ID = 0x4C00 + + /** + * RSSI bei 1 m Abstand – MUSS kalibriert werden. + * + * Vorgehensweise: + * 1. Smartphone in 1 m Abstand zum Beacon halten + * 2. 60 Sekunden RSSI loggen + * 3. Mittelwert = RSSI_AT_ONE_METER + * + * Typische Werte für ESP32: -59 bis -72 dBm. + * Der aktuelle Default von -55 ist wahrscheinlich ZU OPTIMISTISCH + * und eine wahrscheinliche Mitursache für die 100m-Distanzen. + */ + const val RSSI_AT_ONE_METER = -65.0 // Geändert von -55 auf realistischeren Wert + + /** + * Path Loss Exponent – MUSS kalibriert werden. + * + * 2.0 = Freifeld, unrealistisch für Innenräume. + * Für einen 3.5x4.5m Raum mit Wänden/Möbeln ist 2.5-3.5 realistischer. + */ + const val PATH_LOSS_EXPONENT = 5.5 // Geändert von 2.0 auf realistischeren Wert +} diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/ble/BleScanner.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/ble/BleScanner.kt new file mode 100644 index 0000000..b50b500 --- /dev/null +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/ble/BleScanner.kt @@ -0,0 +1,268 @@ +package com.erik.bleindoorpositioning.ble + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothManager +import android.bluetooth.le.BluetoothLeScanner +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.os.SystemClock +import android.util.Log +import com.erik.bleindoorpositioning.data.ConfigRepository +import com.erik.bleindoorpositioning.model.PositionResult +import com.erik.bleindoorpositioning.model.ScannedBeacon +import com.erik.bleindoorpositioning.positioning.BeaconFilterManager +import com.erik.bleindoorpositioning.positioning.KalmanFilter +import com.erik.bleindoorpositioning.positioning.PositionCalculator +import com.erik.bleindoorpositioning.positioning.PositionSmoother +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.TimeUnit + +class BleScanner(context: Context, private val configRepository: ConfigRepository, private val coroutineScope: CoroutineScope) { + + companion object { + private const val TAG = "BleScanner" + private const val BEACON_MAX_AGE_MS = 3000L // Beacons älter als 3s ignorieren + private const val SCAN_RESTART_INTERVAL_MS = 25 * 60 * 1000L // 25 Minuten + + // Exponential backoff delays for scan failures + private val RETRY_DELAYS = arrayOf( + 0L, // Immediate retry + TimeUnit.SECONDS.toMillis(1), + TimeUnit.SECONDS.toMillis(5), + TimeUnit.SECONDS.toMillis(10), + TimeUnit.SECONDS.toMillis(30), + TimeUnit.MINUTES.toMillis(1), + TimeUnit.MINUTES.toMillis(2), + TimeUnit.MINUTES.toMillis(5) + ) + } + + private val bluetoothManager = + context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + private val bluetoothAdapter = bluetoothManager.adapter + private var scanner: BluetoothLeScanner? = null + + private var scanRestartJob: Job? = null + private var retryJob: Job? = null + private var retryAttempt = 0 + + // RSSI-Filterung + private val filterManager = BeaconFilterManager { + KalmanFilter( + processNoise = 0.5, // TODO: Kalibrieren + measurementNoise = 10.0 // TODO: Kalibrieren + ) + } + + // Positionsberechnung (wird bei jedem Scan-Start neu initialisiert) + private var positionCalculator: PositionCalculator? = null + + // Positions-Glättung + private val positionSmoother = PositionSmoother(alpha = 0.15) + + // Öffentliche State Flows + private val _beacons = MutableStateFlow>(emptyMap()) + val beacons: StateFlow> = _beacons.asStateFlow() + + private val _isScanning = MutableStateFlow(false) + val isScanning: StateFlow = _isScanning.asStateFlow() + + private val _position = MutableStateFlow(null) + val position: StateFlow = _position.asStateFlow() + + private val scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + // Reset retry attempt and cancel retry job on successful scan result + if (retryAttempt > 0) { + Log.d(TAG, "Scan recovered after $retryAttempt retries") + retryAttempt = 0 + } + retryJob?.cancel() + retryJob = null + + parseIBeacon(result)?.let { beacon -> + val now = SystemClock.elapsedRealtime() + + // RSSI filtern (inkl. Ausreißer-Erkennung) + val filteredRssi = filterManager.update(beacon.minor, beacon.rssi) + val distance = positionCalculator?.estimateDistance(filteredRssi, beacon.minor) ?: 0.0 + + // Beacon-State mit Timestamp aktualisieren + val updatedBeacon = beacon.copy( + filteredRssi = filteredRssi, + estimatedDistance = distance, + lastSeen = now + ) + _beacons.value = _beacons.value.toMutableMap().apply { + put(updatedBeacon.minor, updatedBeacon) + } + + // Nur frische Beacons für Positionsberechnung verwenden + val freshBeacons = _beacons.value.filter { + (now - it.value.lastSeen) < BEACON_MAX_AGE_MS + } + val rssiMap = freshBeacons.mapValues { it.value.filteredRssi } + val rawResult = positionCalculator?.calculate(rssiMap) + + // Position glätten + _position.value = rawResult?.let { raw -> + val smoothed = positionSmoother.update(raw.position) + raw.copy(position = smoothed) + } + + Log.d(TAG, "Beacon ${beacon.minor}: " + + "raw=${beacon.rssi}, " + + "filtered=${"%.1f".format(filteredRssi)}, " + + "dist=${"%.2f".format(distance)}m") + + _position.value?.let { pos -> + Log.d(TAG, "Position: (%.2f, %.2f) m".format( + pos.position.x, pos.position.y + )) + } + } + } + + override fun onScanFailed(errorCode: Int) { + Log.e(TAG, "Scan failed with error code: $errorCode. Retrying...") + _isScanning.value = false + + retryJob?.cancel() + + retryJob = coroutineScope.launch { + val delayTime = RETRY_DELAYS.getOrElse(retryAttempt) { + RETRY_DELAYS.last() + } + Log.d(TAG, "Next scan retry in ${delayTime / 1000} seconds (attempt ${retryAttempt + 1})") + delay(delayTime) + retryAttempt++ + startScanning() + } + } + } + + private fun parseIBeacon(result: ScanResult): ScannedBeacon? { + val scanRecord = result.scanRecord ?: return null + val manufacturerData = + scanRecord.getManufacturerSpecificData(BeaconData.IBEACON_MANUFACTURER_ID) + ?: return null + + // iBeacon-Prefix validieren: 0x02 0x15 + if (manufacturerData.size < 23 || + manufacturerData[0] != 0x02.toByte() || + manufacturerData[1] != 0x15.toByte() + ) { + return null + } + + // UUID extrahieren (Bytes 2–17), Format 8-4-4-4-12 + val uuid = buildString { + for (i in 2..17) { + append(String.format("%02x", manufacturerData[i])) + if (i in listOf(5, 7, 9, 11)) append("-") + } + } + + if (!uuid.equals(BeaconData.BEACON_UUID, ignoreCase = true)) { + return null + } + + val major = ((manufacturerData[18].toInt() and 0xFF) shl 8) or + (manufacturerData[19].toInt() and 0xFF) + val minor = ((manufacturerData[20].toInt() and 0xFF) shl 8) or + (manufacturerData[21].toInt() and 0xFF) + val txPower = manufacturerData[22].toInt() + + return ScannedBeacon( + minor = minor, + rssi = result.rssi, + txPower = txPower, + uuid = uuid + ) + } + + @SuppressLint("MissingPermission") + fun startScanning() { + if (_isScanning.value) return + + // Lade aktuelle Konfiguration und initialisiere PositionCalculator + initializePositionCalculator() + + scanner = bluetoothAdapter?.bluetoothLeScanner + if (scanner == null) { + Log.e(TAG, "BluetoothLeScanner not available") + return + } + + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .setReportDelay(0) + .build() + + // Ensure any previous retry job is cancelled + retryJob?.cancel() + retryJob = null + retryAttempt = 0 + + scanner?.startScan(null, settings, scanCallback) + _isScanning.value = true + Log.d(TAG, "Scanning started") + + // Start proactive scan restart job + scanRestartJob?.cancel() + scanRestartJob = coroutineScope.launch { + while (isActive) { + delay(SCAN_RESTART_INTERVAL_MS) + Log.d(TAG, "Proactively restarting BLE scan.") + stopScanning() + startScanning() + } + } + } + + /** + * Lädt die aktuelle Konfiguration neu und initialisiert den PositionCalculator. + * Kann während eines laufenden Scans aufgerufen werden, um Config-Änderungen zu übernehmen. + */ + fun reloadConfig() { + initializePositionCalculator() + Log.d(TAG, "Config reloaded") + } + + private fun initializePositionCalculator() { + val config = configRepository.loadConfig() + if (config.beacons.size < 3) { + Log.w(TAG, "Weniger als 3 Beacons konfiguriert, Positionsberechnung nicht möglich") + } + positionCalculator = PositionCalculator( + rssiAtOneMeter = BeaconData.RSSI_AT_ONE_METER, + pathLossExponent = BeaconData.PATH_LOSS_EXPONENT, + beacons = config.beacons, + floorWidth = config.floorWidth, + floorHeight = config.floorHeight, + rooms = config.rooms + ) + } + + @SuppressLint("MissingPermission") + fun stopScanning() { + if (!_isScanning.value && retryJob == null && scanRestartJob == null) { + Log.d(TAG, "Scanning not active, no pending retries, and no restart job. No need to stop.") + return + } + scanner?.stopScan(scanCallback) + scanRestartJob?.cancel() + retryJob?.cancel() + retryJob = null + retryAttempt = 0 + _isScanning.value = false + filterManager.reset() + positionSmoother.reset() + Log.d(TAG, "Scanning stopped") + } +} diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/data/AppConfig.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/data/AppConfig.kt new file mode 100644 index 0000000..2b4ac9b --- /dev/null +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/data/AppConfig.kt @@ -0,0 +1,22 @@ +package com.erik.bleindoorpositioning.data + +import com.erik.bleindoorpositioning.model.BeaconConfig +import com.erik.bleindoorpositioning.model.RoomConfig +import kotlinx.serialization.Serializable + +/** + * Datenklasse, die die gesamte App-Konfiguration enthält. + * Dies umfasst die Stockwerk-Abmessungen, die Raumliste und die Liste der konfigurierten Beacons. + * + * @param floorWidth Die Breite des Stockwerks in Metern. + * @param floorHeight Die Höhe des Stockwerks in Metern. + * @param rooms Die Liste der Räume im Stockwerk. + * @param beacons Die Liste der konfigurierten Beacons. + */ +@Serializable +data class AppConfig( + val floorWidth: Double, + val floorHeight: Double, + val rooms: List, + val beacons: List +) diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/data/ConfigRepository.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/data/ConfigRepository.kt new file mode 100644 index 0000000..8bb3b5e --- /dev/null +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/data/ConfigRepository.kt @@ -0,0 +1,116 @@ +package com.erik.bleindoorpositioning.data + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import com.erik.bleindoorpositioning.model.BeaconConfig +import com.erik.bleindoorpositioning.model.RoomConfig +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.SerializationException + +/** + * Repository zur Verwaltung der persistenten App-Konfiguration. + * Speichert und lädt die Konfiguration (Raumgröße, Beacon-Liste) + * in/aus den SharedPreferences im JSON-Format. + */ +class ConfigRepository(context: Context) { + + private val prefs: SharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + /** + * Lädt die App-Konfiguration aus den SharedPreferences. + * Wenn keine Konfiguration gespeichert ist, werden Standardwerte zurückgegeben. + * Unterstützt Migration von alten Configs (roomWidth/roomHeight → floorWidth/floorHeight). + * + * @return Die geladene oder standardmäßige [AppConfig]. + */ + fun loadConfig(): AppConfig { + val jsonString = prefs.getString(KEY_CONFIG, null) ?: return getDefaultConfig() + return try { + // Versuche neues Format zu parsen + Json.decodeFromString(jsonString) + } catch (e: SerializationException) { + // Falls neues Format fehlschlägt, versuche Legacy-Format + try { + Log.d("ConfigRepository", "Could not parse new config format, trying legacy...") + val legacyConfig = Json.decodeFromString(jsonString) + Log.d("ConfigRepository", "Successfully parsed legacy config, migrating.") + // Migration: Alten Raum als einzelnen Raum im Stockwerk + AppConfig( + floorWidth = legacyConfig.roomWidth, + floorHeight = legacyConfig.roomHeight, + rooms = listOf(RoomConfig("Importierter Raum", 0.0, 0.0, legacyConfig.roomWidth, legacyConfig.roomHeight)), + beacons = legacyConfig.beacons + ) + } catch (e2: SerializationException) { + Log.e("ConfigRepository", "Could not parse legacy config either, falling back to default", e2) + getDefaultConfig() + } + } + } + + /** + * Speichert die angegebene App-Konfiguration in den SharedPreferences. + * + * @param config Die zu speichernde [AppConfig]. + */ + fun saveConfig(config: AppConfig) { + val jsonString = Json.encodeToString(config) + prefs.edit().putString(KEY_CONFIG, jsonString).apply() + } + + /** + * Liefert eine Standardkonfiguration. + * Diese wird verwendet, wenn noch keine Konfiguration gespeichert wurde. + */ + private fun getDefaultConfig(): AppConfig { + return AppConfig( + floorWidth = 10.0, + floorHeight = 8.0, + rooms = listOf( + RoomConfig(name = "Wohnzimmer", x = 0.0, y = 0.0, width = 6.0, height = 8.0), + RoomConfig(name = "Küche", x = 6.0, y = 0.0, width = 4.0, height = 4.0), + RoomConfig(name = "Büro", x = 6.0, y = 4.0, width = 4.0, height = 4.0) + ), + beacons = listOf( + BeaconConfig(minor = 1, x = 1.0, y = 1.0), + BeaconConfig(minor = 2, x = 5.0, y = 7.0), + BeaconConfig(minor = 3, x = 9.0, y = 2.0) + ) + ) + } + + /** + * Lädt den gespeicherten Dark Mode Status. + * @return true wenn Dark Mode aktiv ist, sonst false (Default: false). + */ + fun getIsDarkMode(): Boolean { + return prefs.getBoolean(KEY_DARK_MODE, false) + } + + /** + * Speichert den Dark Mode Status. + * @param isDarkMode true für Dark Mode, false für Light Mode. + */ + fun setIsDarkMode(isDarkMode: Boolean) { + prefs.edit().putBoolean(KEY_DARK_MODE, isDarkMode).apply() + } + + companion object { + private const val PREFS_NAME = "ble_indoor_positioning_config" + private const val KEY_CONFIG = "app_config_json" + private const val KEY_DARK_MODE = "is_dark_mode" + } + + /** + * Private Hilfsklasse für Deserialisierung von Legacy-Configs (vor Stockwerk-Support). + */ + @kotlinx.serialization.Serializable + private data class LegacyAppConfig( + val roomWidth: Double, + val roomHeight: Double, + val beacons: List + ) +} diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/map/CoordinateMapper.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/map/CoordinateMapper.kt new file mode 100644 index 0000000..713b279 --- /dev/null +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/map/CoordinateMapper.kt @@ -0,0 +1,69 @@ +package com.erik.bleindoorpositioning.map + +import com.erik.bleindoorpositioning.model.Position + +/** + * Transformiert zwischen realen Raum-Koordinaten (Meter) und + * Pixel-Koordinaten auf dem Bildschirm/Canvas. + * Unterstützt beliebige Koordinaten-Bereiche (auch negative Werte). + * + * @param minX Minimale X-Koordinate der Bounding Box in Metern + * @param minY Minimale Y-Koordinate der Bounding Box in Metern + * @param maxX Maximale X-Koordinate der Bounding Box in Metern + * @param maxY Maximale Y-Koordinate der Bounding Box in Metern + */ +class CoordinateMapper( + private val minX: Double, + private val minY: Double, + private val maxX: Double, + private val maxY: Double +) { + private var canvasWidth: Float = 0f + private var canvasHeight: Float = 0f + private var scale: Float = 1f + private var offsetX: Float = 0f + private var offsetY: Float = 0f + + private val worldWidth = maxX - minX + private val worldHeight = maxY - minY + + /** + * Muss aufgerufen werden, wenn sich die Canvas-Größe ändert. + * Berechnet Skalierung so, dass der gesamte Raum sichtbar ist + * (uniform scaling, Seitenverhältnis bleibt erhalten). + * + * @param padding Rand in Pixeln + */ + fun updateCanvasSize(width: Float, height: Float, padding: Float = 40f) { + canvasWidth = width + canvasHeight = height + + val usableWidth = width - 2 * padding + val usableHeight = height - 2 * padding + + val scaleX = if (worldWidth > 0) usableWidth / worldWidth.toFloat() else 1f + val scaleY = if (worldHeight > 0) usableHeight / worldHeight.toFloat() else 1f + scale = minOf(scaleX, scaleY) + + // Zentrieren + offsetX = (width - worldWidth.toFloat() * scale) / 2f + offsetY = (height - worldHeight.toFloat() * scale) / 2f + } + + /** + * Realwelt-Position (Meter) → Pixel-Position auf Canvas. + * Transformiert relativ zur Bounding Box (minX, minY). + */ + fun toPixel(position: Position): Pair { + val px = ((position.x - minX) * scale + offsetX).toFloat() + val py = ((position.y - minY) * scale + offsetY).toFloat() + return Pair(px, py) + } + + /** + * Meter → Pixel (für Distanzkreise etc.) + */ + fun metersToPixels(meters: Double): Float { + return (meters * scale).toFloat() + } +} diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/map/FloorplanView.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/map/FloorplanView.kt new file mode 100644 index 0000000..764802a --- /dev/null +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/map/FloorplanView.kt @@ -0,0 +1,203 @@ +package com.erik.bleindoorpositioning.map + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.sp +import com.erik.bleindoorpositioning.model.BeaconConfig +import com.erik.bleindoorpositioning.model.Position +import com.erik.bleindoorpositioning.model.PositionResult +import com.erik.bleindoorpositioning.model.RoomConfig +import com.erik.bleindoorpositioning.model.ScannedBeacon + +/** + * Zeichnet eine schematische Draufsicht des Stockwerks mit + * Beacon-Positionen, geschätzten Distanzkreisen und der berechneten Position. + * Unterstützt interaktives Zoomen (Pinch) und Verschieben (Pan). + * + * @param floorWidthMeters Stockwerkbreite in Metern + * @param floorHeightMeters Stockwerkhöhe in Metern + * @param roomConfigs Liste der zu zeichnenden Räume + * @param beaconConfigs Liste der konfigurierten Beacon-Positionen + */ +@Composable +fun FloorplanView( + beacons: Map, + positionResult: PositionResult?, + floorWidthMeters: Double, + floorHeightMeters: Double, + roomConfigs: List, + beaconConfigs: List = emptyList(), + modifier: Modifier = Modifier, + beaconColor: Color = MaterialTheme.colorScheme.primary, + positionColor: Color = MaterialTheme.colorScheme.error, + roomColor: Color = MaterialTheme.colorScheme.outline, + textColor: Color = MaterialTheme.colorScheme.onSurface +) { + // Berechne Bounding Box aus Räumen und Beacons + val mapper = remember(floorWidthMeters, floorHeightMeters, roomConfigs, beaconConfigs) { + val allX = mutableListOf() + val allY = mutableListOf() + + // Räume einbeziehen + roomConfigs.forEach { room -> + allX.add(room.x) + allX.add(room.x + room.width) + allY.add(room.y) + allY.add(room.y + room.height) + } + + // Beacons einbeziehen + beaconConfigs.forEach { beacon -> + allX.add(beacon.x) + allY.add(beacon.y) + } + + // Fallback auf Stockwerk-Abmessungen wenn keine Objekte konfiguriert + val minX = allX.minOrNull() ?: 0.0 + val minY = allY.minOrNull() ?: 0.0 + val maxX = allX.maxOrNull() ?: floorWidthMeters + val maxY = allY.maxOrNull() ?: floorHeightMeters + + CoordinateMapper(minX, minY, maxX, maxY) + } + val textMeasurer = rememberTextMeasurer() + + var scale by remember { mutableStateOf(1f) } + var offset by remember { mutableStateOf(Offset.Zero) } + + Canvas(modifier = modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(0.5f, 5f) + offset += pan + } + } + .pointerInput("doubleTap") { + detectTapGestures(onDoubleTap = { + scale = 1f + offset = Offset.Zero + }) + }) { + mapper.updateCanvasSize(size.width, size.height) + + withTransform({ + translate(left = offset.x, top = offset.y) + scale(scale, scale, pivot = center) + }) { + // Räume zeichnen + drawRooms(mapper, roomConfigs, textMeasurer, scale, roomColor, textColor) + + // Beacon-Positionen und Distanzkreise + beaconConfigs.forEach { config -> + val pixelPos = mapper.toPixel(Position(config.x, config.y)) + val beaconOffset = Offset(pixelPos.first, pixelPos.second) + + // Beacon-Punkt + drawCircle( + color = beaconColor, + radius = 12f / scale, + center = beaconOffset + ) + + // Beacon-Label + drawText( + textMeasurer = textMeasurer, + text = "B${config.minor}", + topLeft = Offset(beaconOffset.x + 16f / scale, beaconOffset.y - 8f / scale), + style = TextStyle(color = textColor) + ) + + // Distanzkreis (wenn Beacon sichtbar) + val scanned = beacons[config.minor] + scanned?.estimatedDistance?.let { dist -> + val radiusPx = mapper.metersToPixels(dist) + drawCircle( + color = beaconColor.copy(alpha = 0.2f), + radius = radiusPx, + center = beaconOffset, + style = Stroke(width = 2f / scale) + ) + } + } + + // Geschätzte Position + positionResult?.let { result -> + val pixelPos = mapper.toPixel(result.position) + val positionOffset = Offset(pixelPos.first, pixelPos.second) + + // Positionspunkt + drawCircle( + color = positionColor, + radius = 10f / scale, + center = positionOffset + ) + + // Kreuz für bessere Sichtbarkeit + drawLine( + color = positionColor, + start = Offset(positionOffset.x - 16f / scale, positionOffset.y), + end = Offset(positionOffset.x + 16f / scale, positionOffset.y), + strokeWidth = 2f / scale + ) + drawLine( + color = positionColor, + start = Offset(positionOffset.x, positionOffset.y - 16f / scale), + end = Offset(positionOffset.x, positionOffset.y + 16f / scale), + strokeWidth = 2f / scale + ) + } + } + } +} + +private fun DrawScope.drawRooms( + mapper: CoordinateMapper, + rooms: List, + textMeasurer: TextMeasurer, + scale: Float, + roomColor: Color, + textColor: Color +) { + rooms.forEach { room -> + val topLeft = mapper.toPixel(Position(room.x, room.y)) + val bottomRight = mapper.toPixel(Position(room.x + room.width, room.y + room.height)) + val size = androidx.compose.ui.geometry.Size( + width = bottomRight.first - topLeft.first, + height = bottomRight.second - topLeft.second + ) + + drawRect( + color = roomColor, + topLeft = Offset(topLeft.first, topLeft.second), + size = size, + style = Stroke(width = 3f / scale) + ) + + drawText( + textMeasurer = textMeasurer, + text = room.name, + topLeft = Offset(topLeft.first + 8f / scale, topLeft.second + 4f / scale), + style = TextStyle(fontSize = (16f / scale).sp, color = textColor) + ) + } +} diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/model/Beacon.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/model/Beacon.kt new file mode 100644 index 0000000..adf6f55 --- /dev/null +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/model/Beacon.kt @@ -0,0 +1,44 @@ +package com.erik.bleindoorpositioning.model + +import kotlinx.serialization.Serializable + +/** + * Konfiguration eines Raums im Stockwerk. + * Koordinaten in Metern relativ zum Stockwerk-Ursprung. + */ +@Serializable +data class RoomConfig( + val name: String, + val x: Double, + val y: Double, + val width: Double, + val height: Double +) + +/** + * Konfiguration eines physisch installierten Beacons. + * Koordinaten in Metern relativ zu einem frei gewählten Ursprung. + */ +@Serializable +data class BeaconConfig( + val minor: Int, + val x: Double, + val y: Double, + val rssiAtOneMeter: Double = -65.0, // RSSI-Wert bei 1m Abstand, individuell pro Beacon + val pathLossExponent: Double = 5.5 // Pfadverlust-Exponent, individuell pro Beacon +) + +/** + * Ein gescannter Beacon mit Roh- und Filterdaten. + * @param lastSeen Zeitstempel (SystemClock.elapsedRealtime) der letzten Messung. + * @param uuid Die UUID des Beacons + */ +data class ScannedBeacon( + val minor: Int, + val rssi: Int, + val txPower: Int, + val uuid: String = "", + val filteredRssi: Double = rssi.toDouble(), + val estimatedDistance: Double? = null, + val lastSeen: Long = 0L +) diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/model/Position.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/model/Position.kt new file mode 100644 index 0000000..663608f --- /dev/null +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/model/Position.kt @@ -0,0 +1,18 @@ +package com.erik.bleindoorpositioning.model + +/** + * 2D-Position in Metern im lokalen Koordinatensystem. + */ +data class Position( + val x: Double, + val y: Double +) + +/** + * Ergebnis einer Positionsberechnung mit Debug-Informationen. + */ +data class PositionResult( + val position: Position, + val distances: Map, + val usedBeacons: List +) diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/positioning/PositionSmoother.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/positioning/PositionSmoother.kt new file mode 100644 index 0000000..6f86e14 --- /dev/null +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/positioning/PositionSmoother.kt @@ -0,0 +1,42 @@ +package com.erik.bleindoorpositioning.positioning + +import com.erik.bleindoorpositioning.model.Position + +/** + * 2D Exponential Moving Average zur Glättung der berechneten Position. + * Reduziert Springen der Positionsanzeige ohne große Verzögerung. + * + * @param alpha Glättungsfaktor (0, 1]. Klein = glatter, groß = reaktiver. + */ +class PositionSmoother(private val alpha: Double = 0.25) { + + init { + require(alpha in 0.0..1.0) { "alpha muss in (0, 1] liegen, war: $alpha" } + } + + private var smoothedX: Double? = null + private var smoothedY: Double? = null + + fun update(newPosition: Position): Position { + val sx = smoothedX + val sy = smoothedY + + if (sx == null || sy == null) { + smoothedX = newPosition.x + smoothedY = newPosition.y + return newPosition + } + + val newX = alpha * newPosition.x + (1.0 - alpha) * sx + val newY = alpha * newPosition.y + (1.0 - alpha) * sy + + smoothedX = newX + smoothedY = newY + return Position(newX, newY) + } + + fun reset() { + smoothedX = null + smoothedY = null + } +} diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/positioning/PositioningCalculator.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/positioning/PositioningCalculator.kt new file mode 100644 index 0000000..9e2fab1 --- /dev/null +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/positioning/PositioningCalculator.kt @@ -0,0 +1,211 @@ +package com.erik.bleindoorpositioning.positioning + +import com.erik.bleindoorpositioning.model.BeaconConfig +import com.erik.bleindoorpositioning.model.Position +import com.erik.bleindoorpositioning.model.PositionResult +import com.erik.bleindoorpositioning.model.RoomConfig +import kotlin.math.abs +import kotlin.math.pow +import kotlin.math.sqrt + +class PositionCalculator( + private val rssiAtOneMeter: Double = -65.0, + private val pathLossExponent: Double = 5.0, + private val beacons: List, + private val floorWidth: Double = Double.MAX_VALUE, + private val floorHeight: Double = Double.MAX_VALUE, + private val rooms: List = emptyList() +) { + + init { + require(beacons.size >= 3) { + "Mindestens 3 Beacons erforderlich, erhalten: ${beacons.size}" + } + } + + fun estimateDistance(filteredRssi: Double, beaconMinor: Int): Double { + val beacon = beacons.find { it.minor == beaconMinor } + val beaconRssi = beacon?.rssiAtOneMeter ?: rssiAtOneMeter + val ple = beacon?.pathLossExponent ?: pathLossExponent + val exponent = (beaconRssi - filteredRssi) / (10.0 * ple) + return 10.0.pow(exponent).coerceAtLeast(0.01) + } + + /** + * Berechnet die Position in zwei Stufen: + * + * 1. Gewichteter Schwerpunkt (IDW) als robuste Basisschätzung + * Gewicht = 1 / d^exponent – der nächste Beacon dominiert + * + * 2. Gauss-Newton-Verfeinerung mit starker Gewichtung naher Beacons + * + * 3. Blending: Bei sehr kleiner Minimaldistanz wird die IDW-Lösung + * bevorzugt, da Trilateration dort instabil ist. + */ + fun calculate(filteredRssiByMinor: Map): PositionResult? { + val available = beacons.filter { it.minor in filteredRssiByMinor } + if (available.size < 3) return null + + val distances = available.associate { beacon -> + beacon.minor to estimateDistance(filteredRssiByMinor[beacon.minor]!!, beacon.minor) + } + + // ============================================================ + // Stufe 1: Inverse Distance Weighting (IDW) + // ============================================================ + // Exponent 4 statt üblichem 2: Naher Beacon dominiert extrem + val idwExponent = 4.0 + val idwPosition = calculateIdw(available, distances, idwExponent) + + // ============================================================ + // Stufe 2: Gauss-Newton-Trilateration + // ============================================================ + val gnPosition = calculateGaussNewton(available, distances, idwPosition) + + // ============================================================ + // Stufe 3: Blending basierend auf Minimaldistanz + // ============================================================ + // Bei Minimaldistanz < blendStart: 100% IDW + // Bei Minimaldistanz > blendEnd: 100% Gauss-Newton + // Dazwischen: linearer Übergang + val minDist = distances.values.min() + val blendStart = 0.5 // Unter 0.5 m → voll IDW + val blendEnd = 1.5 // Über 1.5 m → voll Gauss-Newton + + val gnWeight = if (gnPosition != null) { + ((minDist - blendStart) / (blendEnd - blendStart)).coerceIn(0.0, 1.0) + } else { + 0.0 + } + + val blendedPosition = if (gnPosition != null && gnWeight > 0.0) { + Position( + x = gnWeight * gnPosition.x + (1.0 - gnWeight) * idwPosition.x, + y = gnWeight * gnPosition.y + (1.0 - gnWeight) * idwPosition.y + ) + } else { + idwPosition + } + + // Raumbegrenzung: Position auf tatsächliche Bounds clampen + // Berechne Min/Max aus Räumen und Beacons + val allX = mutableListOf() + val allY = mutableListOf() + + rooms.forEach { room -> + allX.add(room.x) + allX.add(room.x + room.width) + allY.add(room.y) + allY.add(room.y + room.height) + } + + beacons.forEach { beacon -> + allX.add(beacon.x) + allY.add(beacon.y) + } + + // Fallback auf Stockwerk-Abmessungen wenn keine Objekte konfiguriert + val minX = allX.minOrNull() ?: 0.0 + val minY = allY.minOrNull() ?: 0.0 + val maxX = allX.maxOrNull() ?: floorWidth + val maxY = allY.maxOrNull() ?: floorHeight + + val finalPosition = Position( + x = blendedPosition.x.coerceIn(minX, maxX), + y = blendedPosition.y.coerceIn(minY, maxY) + ) + + return PositionResult( + position = finalPosition, + distances = distances, + usedBeacons = available.map { it.minor } + ) + } + + /** + * Inverse Distance Weighting. + * Bei d → 0 konvergiert das Ergebnis exakt gegen die Beacon-Position. + */ + private fun calculateIdw( + beaconList: List, + distances: Map, + exponent: Double + ): Position { + var sumX = 0.0 + var sumY = 0.0 + var sumW = 0.0 + + for (beacon in beaconList) { + val d = distances[beacon.minor] ?: continue + // Wenn d quasi 0: Position ist Beacon-Position + if (d < 0.01) { + return Position(beacon.x, beacon.y) + } + val w = 1.0 / d.pow(exponent) + sumX += beacon.x * w + sumY += beacon.y * w + sumW += w + } + + return Position(sumX / sumW, sumY / sumW) + } + + /** + * Gauss-Newton-Optimierung mit distanzabhängiger Gewichtung. + * Gibt null zurück wenn die Lösung nicht konvergiert. + */ + private fun calculateGaussNewton( + beaconList: List, + distances: Map, + startPosition: Position + ): Position? { + var x = startPosition.x + var y = startPosition.y + + val maxIterations = 50 + val convergenceThreshold = 1e-6 + + for (iteration in 0 until maxIterations) { + var jtj00 = 0.0; var jtj01 = 0.0; var jtj11 = 0.0 + var jtr0 = 0.0; var jtr1 = 0.0 + + for (beacon in beaconList) { + val di = distances[beacon.minor] ?: continue + val dx = x - beacon.x + val dy = y - beacon.y + val dist = sqrt(dx * dx + dy * dy) + + if (dist < 1e-10) continue + + val residual = dist - di + val jx = dx / dist + val jy = dy / dist + + // Gewichtung: 1/d^3 – stärker als 1/d^2, + // damit nahe Beacons die Lösung dominieren + val w = 1.0 / (di.pow(3) + 0.001) + + jtj00 += w * jx * jx + jtj01 += w * jx * jy + jtj11 += w * jy * jy + jtr0 += w * jx * residual + jtr1 += w * jy * residual + } + + val det = jtj00 * jtj11 - jtj01 * jtj01 + if (abs(det) < 1e-20) return null + + val deltaX = -(jtj11 * jtr0 - jtj01 * jtr1) / det + val deltaY = -(jtj00 * jtr1 - jtj01 * jtr0) / det + + x += deltaX + y += deltaY + + if (deltaX * deltaX + deltaY * deltaY < convergenceThreshold) { + break + } + } + + return Position(x, y) + } +} diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/positioning/RssiFilter.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/positioning/RssiFilter.kt new file mode 100644 index 0000000..6effbc7 --- /dev/null +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/positioning/RssiFilter.kt @@ -0,0 +1,158 @@ +package com.erik.bleindoorpositioning.positioning + +/** + * Schnittstelle für RSSI-Filter. + */ +interface RssiFilter { + fun update(rssi: Int): Double + fun getCurrentValue(): Double? + fun reset() +} + +/** + * Gleitender Mittelwert. + * Einfach, stabil, aber träge bei Änderungen. + */ +class MovingAverageFilter(private val windowSize: Int = 10) : RssiFilter { + + private val buffer = ArrayDeque(windowSize) + private var currentAverage: Double? = null + + override fun update(rssi: Int): Double { + if (buffer.size >= windowSize) { + buffer.removeFirst() + } + buffer.addLast(rssi) + currentAverage = buffer.average() + return currentAverage!! + } + + override fun getCurrentValue(): Double? = currentAverage + + override fun reset() { + buffer.clear() + currentAverage = null + } +} + +/** + * Exponentieller gleitender Mittelwert. + * Guter Kompromiss zwischen Glättung und Reaktionszeit. + * + * @param alpha Glättungsfaktor 0 < alpha <= 1. + * Klein = starke Glättung, groß = schnelle Reaktion. + */ +class ExponentialMovingAverageFilter(private val alpha: Double = 0.01) : RssiFilter { + + init { + require(alpha in 0.0..1.0) { "alpha muss in [0, 1] liegen, war: $alpha" } + } + + private var ema: Double? = null + + override fun update(rssi: Int): Double { + val current = ema + val newEma = if (current == null) { + rssi.toDouble() + } else { + alpha * rssi + (1.0 - alpha) * current + } + ema = newEma + return newEma + } + + override fun getCurrentValue(): Double? = ema + + override fun reset() { + ema = null + } +} + +/** + * 1D-Kalman-Filter für RSSI-Glättung. + * + * Modell: Zustand = wahrer RSSI-Wert, Prozess = Random Walk. + * Optimal bei Gauß'schem Rauschen; in der Praxis eine Näherung. + * + * @param processNoise Q – erwartete Varianz der Zustandsänderung pro Schritt. + * @param measurementNoise R – Varianz des Messrauschens (aus stationärer Messung). + */ +class KalmanFilter( + private val processNoise: Double = 1.0, + private val measurementNoise: Double = 5.0 +) : RssiFilter { + + private var estimate: Double? = null + private var errorCovariance: Double = 1.0 + + override fun update(rssi: Int): Double { + val currentEstimate = estimate + + if (currentEstimate == null) { + estimate = rssi.toDouble() + errorCovariance = measurementNoise + return estimate!! + } + + // Predict + val predictedEstimate = currentEstimate + val predictedError = errorCovariance + processNoise + + // Update + val kalmanGain = predictedError / (predictedError + measurementNoise) + val newEstimate = predictedEstimate + kalmanGain * (rssi - predictedEstimate) + val newError = (1.0 - kalmanGain) * predictedError + + estimate = newEstimate + errorCovariance = newError + return newEstimate + } + + override fun getCurrentValue(): Double? = estimate + + override fun reset() { + estimate = null + errorCovariance = 1.0 + } +} + + +/** + * Verwaltet einen Filter pro Beacon (identifiziert über Minor-ID). + * Verwirft RSSI-Ausreißer die zu weit vom aktuellen Filterwert abweichen. + * + * @param outlierThreshold Max. erlaubte Abweichung vom Filterwert in dBm. + */ +class BeaconFilterManager( + private val outlierThreshold: Double = 20.0, + private val filterFactory: () -> RssiFilter +) { + + private val filters = mutableMapOf() + + fun update(minor: Int, rssi: Int): Double { + val filter = filters.getOrPut(minor) { filterFactory() } + + // Ausreißer verwerfen wenn zu weit vom aktuellen Filterwert entfernt + val currentValue = filter.getCurrentValue() + if (currentValue != null && kotlin.math.abs(rssi - currentValue) > outlierThreshold) { + return currentValue + } + + return filter.update(rssi) + } + + fun getFilteredValue(minor: Int): Double? { + return filters[minor]?.getCurrentValue() + } + + fun reset() { + filters.values.forEach { it.reset() } + filters.clear() + } + + fun resetBeacon(minor: Int) { + filters[minor]?.reset() + filters.remove(minor) + } +} diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/screens/ConfigScreen.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/screens/ConfigScreen.kt new file mode 100644 index 0000000..3297e50 --- /dev/null +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/screens/ConfigScreen.kt @@ -0,0 +1,632 @@ +package com.erik.bleindoorpositioning.ui.screens + +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.erik.bleindoorpositioning.ble.BleScanner +import com.erik.bleindoorpositioning.data.AppConfig +import com.erik.bleindoorpositioning.data.ConfigRepository +import com.erik.bleindoorpositioning.model.BeaconConfig +import com.erik.bleindoorpositioning.model.RoomConfig +import com.erik.bleindoorpositioning.model.ScannedBeacon + +/** + * Konfigurationsbildschirm für Raum und Beacons. + * + * @param bleScanner Der [BleScanner] für BLE-Operationen. + * @param configRepository Das [ConfigRepository] für den Zugriff auf die Konfiguration. + * @param paddingValues Die Padding-Werte vom Scaffold. + */ +@Composable +fun ConfigScreen( + bleScanner: BleScanner, + configRepository: ConfigRepository, + paddingValues: PaddingValues +) { + val context = LocalContext.current + + var floorWidth by rememberSaveable { mutableStateOf("") } + var floorHeight by rememberSaveable { mutableStateOf("") } + var rooms by remember { mutableStateOf>(emptyList()) } + var configuredBeacons by remember { mutableStateOf>(emptyList()) } + var showAddBeaconDialog by remember { mutableStateOf(null) } + var showEditBeaconDialog by remember { mutableStateOf(null) } + var showAddRoomDialog by remember { mutableStateOf(false) } + var showEditRoomDialog by remember { mutableStateOf(null) } + + // Einklapp-States + var floorSectionExpanded by rememberSaveable { mutableStateOf(true) } + var roomsSectionExpanded by rememberSaveable { mutableStateOf(true) } + var beaconsSectionExpanded by rememberSaveable { mutableStateOf(true) } + var learningSectionExpanded by rememberSaveable { mutableStateOf(true) } + + val isScanning by bleScanner.isScanning.collectAsState() + val scannedBeaconsForDiscovery by bleScanner.beacons.collectAsState() + + LaunchedEffect(Unit) { + val config = configRepository.loadConfig() + floorWidth = config.floorWidth.toString() + floorHeight = config.floorHeight.toString() + rooms = config.rooms + configuredBeacons = config.beacons + } + + // Auto-Save Funktion + fun saveConfig() { + val width = floorWidth.replace(',', '.').toDoubleOrNull() + val height = floorHeight.replace(',', '.').toDoubleOrNull() + if (width != null && height != null) { + val newConfig = AppConfig(width, height, rooms, configuredBeacons) + configRepository.saveConfig(newConfig) + if (isScanning) { + bleScanner.reloadConfig() + } + } + } + + // Auto-Save bei Änderungen der Listen + LaunchedEffect(rooms, configuredBeacons) { + if (floorWidth.isNotBlank() && floorHeight.isNotBlank()) { + saveConfig() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + // Stockwerkkonfiguration + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { floorSectionExpanded = !floorSectionExpanded }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Stockwerkkonfiguration", style = MaterialTheme.typography.titleMedium) + Icon( + imageVector = if (floorSectionExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (floorSectionExpanded) "Einklappen" else "Ausklappen" + ) + } + + if (floorSectionExpanded) { + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = floorWidth, + onValueChange = { + floorWidth = it + saveConfig() + }, + label = { Text("Breite (m)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Next), + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = floorHeight, + onValueChange = { + floorHeight = it + saveConfig() + }, + label = { Text("Höhe (m)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done), + modifier = Modifier.weight(1f) + ) + } + } + } + } + Spacer(Modifier.height(12.dp)) + + // Raum-Sektion + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { roomsSectionExpanded = !roomsSectionExpanded }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Räume", style = MaterialTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + if (roomsSectionExpanded) { + Button(onClick = { showAddRoomDialog = true }) { + Text("Raum hinzufügen") + } + Spacer(Modifier.width(8.dp)) + } + Icon( + imageVector = if (roomsSectionExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (roomsSectionExpanded) "Einklappen" else "Ausklappen" + ) + } + } + + if (roomsSectionExpanded) { + Spacer(Modifier.height(8.dp)) + if (rooms.isEmpty()) { + Text("Noch keine Räume hinzugefügt.", style = MaterialTheme.typography.bodySmall) + } else { + rooms.forEach { room -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp).clickable { showEditRoomDialog = room } + ) { + Text( + text = "${room.name}: (${room.x}, ${room.y}) - ${room.width}x${room.height}m", + modifier = Modifier.weight(1f) + ) + OutlinedButton(onClick = { + rooms = rooms.filter { it != room } + }) { + Text("Löschen") + } + } + } + } + } + } + } + Spacer(Modifier.height(12.dp)) + + // Konfigurierte Beacons + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { beaconsSectionExpanded = !beaconsSectionExpanded }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Konfigurierte Beacons", style = MaterialTheme.typography.titleMedium) + Icon( + imageVector = if (beaconsSectionExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (beaconsSectionExpanded) "Einklappen" else "Ausklappen" + ) + } + + if (beaconsSectionExpanded) { + Spacer(Modifier.height(8.dp)) + if (configuredBeacons.isEmpty()) { + Text("Noch keine Beacons konfiguriert.", style = MaterialTheme.typography.bodySmall) + } else { + configuredBeacons.sortedBy { it.minor }.forEach { beacon -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp).clickable { showEditBeaconDialog = beacon } + ) { + Text( + text = "Minor: ${beacon.minor}, Pos: (${beacon.x}, ${beacon.y}), " + + "RSSI@1m: ${beacon.rssiAtOneMeter}, PLE: ${beacon.pathLossExponent}", + modifier = Modifier.weight(1f) + ) + OutlinedButton(onClick = { + configuredBeacons = configuredBeacons.filter { it.minor != beacon.minor } + }) { + Text("Löschen") + } + } + } + } + } + } + } + Spacer(Modifier.height(12.dp)) + + // Beacon-Anlern-Sektion + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { learningSectionExpanded = !learningSectionExpanded }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Beacons anlernen", style = MaterialTheme.typography.titleMedium) + Icon( + imageVector = if (learningSectionExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (learningSectionExpanded) "Einklappen" else "Ausklappen" + ) + } + + if (learningSectionExpanded) { + Spacer(Modifier.height(8.dp)) + Button(onClick = { + if (isScanning) bleScanner.stopScanning() else bleScanner.startScanning() + }) { + Text(if (isScanning) "Scan stoppen" else "Nach Beacons scannen") + } + + if (isScanning) { + Spacer(Modifier.height(8.dp)) + scannedBeaconsForDiscovery.values.toList().sortedBy { it.minor }.forEach { beacon -> + val isConfigured = configuredBeacons.any { it.minor == beacon.minor } + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable(enabled = !isConfigured) { showAddBeaconDialog = beacon }, + colors = if (isConfigured) CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + else CardDefaults.cardColors() + ) { + Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Text("Minor: ${beacon.minor} | RSSI: ${beacon.rssi}", modifier = Modifier.weight(1f)) + if (isConfigured) { + Text("Konfiguriert", style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + } + } + } + + // Dialog zum Hinzufügen eines Beacons + if (showAddBeaconDialog != null) { + AddBeaconDialog( + beacon = showAddBeaconDialog!!, + onDismiss = { showAddBeaconDialog = null }, + onAddBeacon = { newBeaconConfig -> + configuredBeacons = (configuredBeacons + newBeaconConfig).distinctBy { it.minor }.sortedBy { it.minor } + showAddBeaconDialog = null + } + ) + } + + // Dialog zum Bearbeiten eines Beacons + if (showEditBeaconDialog != null) { + EditBeaconDialog( + beacon = showEditBeaconDialog!!, + onDismiss = { showEditBeaconDialog = null }, + onUpdateBeacon = { updatedBeacon -> + configuredBeacons = configuredBeacons.map { + if (it.minor == updatedBeacon.minor) updatedBeacon else it + } + showEditBeaconDialog = null + } + ) + } + + // Dialog zum Hinzufügen eines Raumes + if (showAddRoomDialog) { + AddEditRoomDialog( + onDismiss = { showAddRoomDialog = false }, + onConfirm = { newRoom -> + rooms = (rooms + newRoom).distinctBy { it.name } + showAddRoomDialog = false + } + ) + } + + // Dialog zum Bearbeiten eines Raumes + if (showEditRoomDialog != null) { + AddEditRoomDialog( + room = showEditRoomDialog, + onDismiss = { showEditRoomDialog = null }, + onConfirm = { updatedRoom -> + rooms = rooms.map { + if (it.name == showEditRoomDialog!!.name) updatedRoom else it + } + showEditRoomDialog = null + } + ) + } + } +} + +/** + * Dialog zum Hinzufügen eines Beacons mit Positionsangabe. + */ +@Composable +private fun AddBeaconDialog( + beacon: ScannedBeacon, + onDismiss: () -> Unit, + onAddBeacon: (BeaconConfig) -> Unit +) { + var x by rememberSaveable { mutableStateOf("") } + var y by rememberSaveable { mutableStateOf("") } + var rssiAtOneMeter by rememberSaveable { mutableStateOf("-65") } + var pathLossExponent by rememberSaveable { mutableStateOf("5.5") } + val context = LocalContext.current + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(16.dp), + tonalElevation = 8.dp + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Beacon ${beacon.minor} hinzufügen", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(16.dp)) + + Text("Gescannte UUID: ${beacon.uuid}", style = MaterialTheme.typography.bodySmall) + Text("Aktuelles RSSI: ${beacon.rssi}", style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = x, + onValueChange = { x = it }, + label = { Text("X-Koordinate (m)") }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ) + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = y, + onValueChange = { y = it }, + label = { Text("Y-Koordinate (m)") }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ) + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = rssiAtOneMeter, + onValueChange = { rssiAtOneMeter = it }, + label = { Text("RSSI bei 1m (dBm)") }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ) + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = pathLossExponent, + onValueChange = { pathLossExponent = it }, + label = { Text("Path Loss Exponent") }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done + ) + ) + Spacer(Modifier.height(24.dp)) + Row( + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text("Abbrechen") + } + Spacer(Modifier.width(8.dp)) + Button(onClick = { + val xPos = x.replace(',', '.').toDoubleOrNull() + val yPos = y.replace(',', '.').toDoubleOrNull() + val rssi = rssiAtOneMeter.replace(',', '.').toDoubleOrNull() + val ple = pathLossExponent.replace(',', '.').toDoubleOrNull() + if (xPos != null && yPos != null && rssi != null && ple != null) { + onAddBeacon(BeaconConfig(beacon.minor, xPos, yPos, rssi, ple)) + } else { + Toast.makeText(context, "Ungültige Eingaben", Toast.LENGTH_SHORT).show() + } + }) { + Text("Hinzufügen") + } + } + } + } + } +} + +@Composable +private fun EditBeaconDialog( + beacon: BeaconConfig, + onDismiss: () -> Unit, + onUpdateBeacon: (BeaconConfig) -> Unit +) { + var x by rememberSaveable { mutableStateOf(beacon.x.toString()) } + var y by rememberSaveable { mutableStateOf(beacon.y.toString()) } + var rssiAtOneMeter by rememberSaveable { mutableStateOf(beacon.rssiAtOneMeter.toString()) } + var pathLossExponent by rememberSaveable { mutableStateOf(beacon.pathLossExponent.toString()) } + val context = LocalContext.current + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(16.dp), + tonalElevation = 8.dp + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Beacon ${beacon.minor} bearbeiten", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = x, + onValueChange = { x = it }, + label = { Text("X-Koordinate (m)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next) + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = y, + onValueChange = { y = it }, + label = { Text("Y-Koordinate (m)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next) + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = rssiAtOneMeter, + onValueChange = { rssiAtOneMeter = it }, + label = { Text("RSSI bei 1m (dBm)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next) + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = pathLossExponent, + onValueChange = { pathLossExponent = it }, + label = { Text("Path Loss Exponent") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done) + ) + Spacer(Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text("Abbrechen") + } + Spacer(Modifier.width(8.dp)) + Button(onClick = { + val xPos = x.replace(',', '.').toDoubleOrNull() + val yPos = y.replace(',', '.').toDoubleOrNull() + val rssi = rssiAtOneMeter.replace(',', '.').toDoubleOrNull() + val ple = pathLossExponent.replace(',', '.').toDoubleOrNull() + if (xPos != null && yPos != null && rssi != null && ple != null) { + onUpdateBeacon(beacon.copy(x = xPos, y = yPos, rssiAtOneMeter = rssi, pathLossExponent = ple)) + } else { + Toast.makeText(context, "Ungültige Eingaben", Toast.LENGTH_SHORT).show() + } + }) { + Text("Speichern") + } + } + } + } + } +} + +/** + * Dialog zum Hinzufügen oder Bearbeiten eines Raums. + */ +@Composable +private fun AddEditRoomDialog( + room: RoomConfig? = null, + onDismiss: () -> Unit, + onConfirm: (RoomConfig) -> Unit +) { + var name by rememberSaveable { mutableStateOf(room?.name ?: "") } + var x by rememberSaveable { mutableStateOf(room?.x?.toString() ?: "") } + var y by rememberSaveable { mutableStateOf(room?.y?.toString() ?: "") } + var width by rememberSaveable { mutableStateOf(room?.width?.toString() ?: "") } + var height by rememberSaveable { mutableStateOf(room?.height?.toString() ?: "") } + val context = LocalContext.current + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(16.dp), + tonalElevation = 8.dp + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + if (room == null) "Raum hinzufügen" else "Raum bearbeiten", + style = MaterialTheme.typography.titleLarge + ) + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = x, + onValueChange = { x = it }, + label = { Text("X-Position (m)") }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ), + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = y, + onValueChange = { y = it }, + label = { Text("Y-Position (m)") }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ), + modifier = Modifier.weight(1f) + ) + } + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = width, + onValueChange = { width = it }, + label = { Text("Breite (m)") }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Next + ), + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = height, + onValueChange = { height = it }, + label = { Text("Höhe (m)") }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done + ), + modifier = Modifier.weight(1f) + ) + } + Spacer(Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text("Abbrechen") + } + Spacer(Modifier.width(8.dp)) + Button(onClick = { + val xPos = x.replace(',', '.').toDoubleOrNull() + val yPos = y.replace(',', '.').toDoubleOrNull() + val widthVal = width.replace(',', '.').toDoubleOrNull() + val heightVal = height.replace(',', '.').toDoubleOrNull() + + if (name.isNotBlank() && xPos != null && yPos != null && widthVal != null && heightVal != null) { + onConfirm( + RoomConfig( + name = name, + x = xPos, + y = yPos, + width = widthVal, + height = heightVal + ) + ) + } else { + Toast.makeText(context, "Ungültige Eingaben", Toast.LENGTH_SHORT) + .show() + } + }) { + Text("Speichern") + } + } + } + } + } +} diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/screens/MainScreen.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/screens/MainScreen.kt new file mode 100644 index 0000000..3fadb77 --- /dev/null +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/screens/MainScreen.kt @@ -0,0 +1,117 @@ +package com.erik.bleindoorpositioning.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.erik.bleindoorpositioning.ble.BleScanner +import com.erik.bleindoorpositioning.data.ConfigRepository + +/** + * Hauptbildschirm der App, der die Tab-Navigation verwaltet. + * + * @param bleScanner Der [BleScanner] für BLE-Operationen. + * @param configRepository Das [ConfigRepository] für den Zugriff auf die Konfiguration. + * @param isDarkMode Gibt an, ob Dark Mode aktiv ist. + * @param onToggleDarkMode Callback zum Umschalten zwischen Dark und Light Mode. + */ +@Composable +fun MainScreen( + bleScanner: BleScanner, + configRepository: ConfigRepository, + isDarkMode: Boolean, + onToggleDarkMode: () -> Unit +) { + var tabIndex by rememberSaveable { mutableIntStateOf(0) } + + val tabs = listOf( + TabItem("Positioning", Icons.Default.Place), + TabItem("Config", Icons.Default.Settings) + ) + + Scaffold( + topBar = { + Column { + // Header-Zeile mit Titel und Toggle-Button + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "BLE Indoor Positioning", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = onToggleDarkMode) { + Icon( + imageVector = if (isDarkMode) Icons.Filled.LightMode else Icons.Filled.DarkMode, + contentDescription = if (isDarkMode) "Switch to Light Mode" else "Switch to Dark Mode", + tint = MaterialTheme.colorScheme.primary + ) + } + } + // Tab-Leiste + TabRow(selectedTabIndex = tabIndex, modifier = Modifier.fillMaxWidth()) { + tabs.forEachIndexed { index, tab -> + Tab( + selected = tabIndex == index, + onClick = { tabIndex = index }, + text = { Text(tab.title) }, + icon = { Icon(tab.icon, contentDescription = null) } + ) + } + } + } + } + ) { paddingValues -> + when (tabIndex) { + 0 -> PositioningScreen( + bleScanner = bleScanner, + configRepository = configRepository, + paddingValues = paddingValues + ) + + 1 -> ConfigScreen( + bleScanner = bleScanner, + configRepository = configRepository, + paddingValues = paddingValues + ) + } + } +} + +/** + * Datenklasse zur Darstellung eines Tabs in der Tab-Leiste. + * + * @param title Der Titel des Tabs. + * @param icon Das Icon des Tabs. + */ +private data class TabItem( + val title: String, + val icon: ImageVector +) diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/screens/PositioningScreen.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/screens/PositioningScreen.kt new file mode 100644 index 0000000..e45fc91 --- /dev/null +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/screens/PositioningScreen.kt @@ -0,0 +1,282 @@ +package com.erik.bleindoorpositioning.ui.screens + +import android.Manifest +import android.os.Build +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material3.* +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.erik.bleindoorpositioning.ble.BleScanner +import com.erik.bleindoorpositioning.data.ConfigRepository +import com.erik.bleindoorpositioning.map.FloorplanView +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState + +/** + * Bildschirm für die Anzeige der Echtzeit-Positionierung. + * + * @param bleScanner Der [BleScanner] für BLE-Operationen. + * @param configRepository Das [ConfigRepository] für den Zugriff auf die Konfiguration. + * @param paddingValues Die Padding-Werte vom Scaffold. + */ +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun PositioningScreen( + bleScanner: BleScanner, + configRepository: ConfigRepository, + paddingValues: PaddingValues +) { + val permissions = buildList { + add(Manifest.permission.ACCESS_FINE_LOCATION) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + add(Manifest.permission.BLUETOOTH_SCAN) + add(Manifest.permission.BLUETOOTH_CONNECT) + } + } + + val permissionState = rememberMultiplePermissionsState(permissions) + val beacons by bleScanner.beacons.collectAsState() + val isScanning by bleScanner.isScanning.collectAsState() + val positionResult by bleScanner.position.collectAsState() + val config = configRepository.loadConfig() + + val isBeaconListExpanded = remember { mutableStateOf(false) } + val showFloorplanFullscreen = remember { mutableStateOf(false) } + + LaunchedEffect(key1 = true) { + permissionState.launchMultiplePermissionRequest() + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp) + ) { + // --- Header + Scan-Kontrolle --- + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "BLE Indoor Positioning", + style = MaterialTheme.typography.headlineMedium + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (!permissionState.allPermissionsGranted) { + Button(onClick = { permissionState.launchMultiplePermissionRequest() }) { + Text("Berechtigungen anfordern") + } + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Button( + onClick = { + if (isScanning) bleScanner.stopScanning() + else bleScanner.startScanning() + } + ) { + Text(if (isScanning) "Stop" else "Start") + } + + Text( + text = if (isScanning) "Scannt..." else "Gestoppt", + color = if (isScanning) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.outline + ) + } + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // --- Positionsanzeige --- + positionResult?.let { result -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "Position: (%.2f, %.2f) m".format( + result.position.x, result.position.y + ), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Beacons: ${result.usedBeacons.joinToString(", ")}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // --- Grundriss --- + Card( + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + ) { + Box(modifier = Modifier.fillMaxSize()) { + FloorplanView( + beacons = beacons, + positionResult = positionResult, + floorWidthMeters = config.floorWidth, + floorHeightMeters = config.floorHeight, + roomConfigs = config.rooms, + beaconConfigs = config.beacons, + modifier = Modifier.fillMaxSize() + ) + // Fullscreen-Button für FloorplanView + IconButton( + onClick = { showFloorplanFullscreen.value = true }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + ) { + Icon( + imageVector = Icons.Default.Fullscreen, + contentDescription = "Vollbildmodus" + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // --- Beacon-Liste --- + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Beacons (${beacons.size}/${config.beacons.size}):", + style = MaterialTheme.typography.titleMedium + ) + // Expand/Collapse Button für Beacon-Liste + IconButton(onClick = { isBeaconListExpanded.value = !isBeaconListExpanded.value }) { + Icon( + imageVector = if (isBeaconListExpanded.value) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isBeaconListExpanded.value) "Beacon-Liste einklappen" else "Beacon-Liste ausklappen" + ) + } + } + + // Animierte Sichtbarkeit für die Beacon-Liste + AnimatedVisibility( + visible = isBeaconListExpanded.value, + enter = expandVertically(), + exit = shrinkVertically() + ) { + Column { + Spacer(modifier = Modifier.height(8.dp)) + + LazyColumn { + items(beacons.values.sortedBy { it.minor }) { beacon -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + ) { + Row( + modifier = Modifier + .padding(12.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = "Beacon ${beacon.minor}", + style = MaterialTheme.typography.titleSmall + ) + Text( + text = "Distanz: ${"%.2f".format( + beacon.estimatedDistance ?: 0.0 + )} m", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + } + Column(horizontalAlignment = Alignment.End) { + Text(text = "RSSI: ${beacon.rssi} dBm") + Text( + text = "Filtered: ${"%.1f".format(beacon.filteredRssi)} dBm", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + } + } + } + } + } + } + } + } + } + } + + // Vollbild-Dialog für FloorplanView + if (showFloorplanFullscreen.value) { + Dialog(onDismissRequest = { showFloorplanFullscreen.value = false }) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.surface + ) { + Box(modifier = Modifier.fillMaxSize()) { + FloorplanView( + beacons = beacons, + positionResult = positionResult, + floorWidthMeters = config.floorWidth, + floorHeightMeters = config.floorHeight, + roomConfigs = config.rooms, + beaconConfigs = config.beacons, + modifier = Modifier.fillMaxSize() + ) + // Schließen-Button im Vollbildmodus + IconButton( + onClick = { showFloorplanFullscreen.value = false }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Vollbildmodus schließen" + ) + } + } + } + } + } +} diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/theme/Color.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/theme/Color.kt new file mode 100644 index 0000000..fd1ea76 --- /dev/null +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/theme/Color.kt @@ -0,0 +1,19 @@ +package com.erik.bleindoorpositioning.ui.theme + +import androidx.compose.ui.graphics.Color + +// Dark Mode Farben (Schwarz + Lila) +val DarkBackground = Color.Black +val DarkPrimary = Color(0xFFBB86FC) // Helles Lila für Dark Mode +val DarkSurface = Color(0xFF121212) // Dunkelgrau für Surfaces +val DarkOnPrimary = Color.Black +val DarkSecondary = Color(0xFF6A5ACD) // Dunkles Lila-ish +val DarkTertiary = Color(0xFFDA70D6) // Orchid + +// Light Mode Farben (Weiß + Lila) +val LightBackground = Color.White +val LightPrimary = Color(0xFF6200EE) // Dunkles Lila für Light Mode +val LightSurface = Color(0xFFF5F5F5) // Hellgrau für Surfaces +val LightOnPrimary = Color.White +val LightSecondary = Color(0xFF6A5ACD) // Slate Blue +val LightTertiary = Color(0xFFDA70D6) // Orchid \ No newline at end of file diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/theme/Theme.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/theme/Theme.kt new file mode 100644 index 0000000..44534e5 --- /dev/null +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/theme/Theme.kt @@ -0,0 +1,43 @@ +package com.erik.bleindoorpositioning.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val DarkColorScheme = darkColorScheme( + primary = DarkPrimary, + onPrimary = DarkOnPrimary, + secondary = DarkSecondary, + tertiary = DarkTertiary, + background = DarkBackground, + surface = DarkSurface, + onBackground = Color.White, + onSurface = Color.White +) + +private val LightColorScheme = lightColorScheme( + primary = LightPrimary, + onPrimary = LightOnPrimary, + secondary = LightSecondary, + tertiary = LightTertiary, + background = LightBackground, + surface = LightSurface, + onBackground = Color.Black, + onSurface = Color.Black +) + +@Composable +fun BleIndoorPositioningTheme( + darkTheme: Boolean, + content: @Composable () -> Unit +) { + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/theme/Type.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/theme/Type.kt new file mode 100644 index 0000000..bafdf68 --- /dev/null +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.erik.bleindoorpositioning.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/App v2/app/src/main/res/drawable/ic_launcher_background.xml b/App v2/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/App v2/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/App v2/app/src/main/res/drawable/ic_launcher_foreground.xml b/App v2/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/App v2/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/App v2/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/App v2/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/App v2/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/App v2/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/App v2/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/App v2/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/App v2/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/App v2/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/App v2/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/App v2/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 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 literal 0 HcmV?d00001 diff --git a/App v2/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/App v2/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/App v2/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/App v2/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/App v2/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/App v2/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 zcmV-U53%r4Nk&FS4*&pHMM6+kP&il$0000G0001A003VA06|PpNSy@$00HoY|G(*G z+qV7x14$dSO^Re!iqt-AAIE9iwr$(CZQJL$blA4B`>;C3fBY6Q8_YSjb2%a=fc}4E zrSzssacq<^nmW|Rs93PJni30R<8w<(bK_$LO4L?!_OxLl$}K$MUEllnMK|rg=f3;y z*?;3j|Nh>)p0JQ3A~rf(MibH2r+)3cyV1qF&;8m{w-S*y+0mM){KTK^M5}ksc`qX3 zy>rf^b>~l>SSHds8(I@hz3&PD@LmEs4&prkT=BjsBCXTMhN$_)+kvnl0bLKW5rEsj z*d#KXGDB4P&>etx0X+`R19yC=LS)j!mgs5M0L~+o-T~Jl!p!AJxnGAhV%~rhYUL4hlWhgES3Kb5oA&X z{}?3OBSS-{!v$nCIGj->(-TAG)8LR{htr41^gxsT8yqt2@DEG6Yl`Uma3Nd4;YUoW zTbkYl3CMU5ypMF3EIkYmWL|*BknM`0+Kq6CpvO(y$#j94e+q{vI{Zp8cV_6RK!`&C zob$*5Q|$IZ09dW=L!V zw@#2wviu|<#3lgGE8GEhcx+zBt`} zOwP8j9X%^f7i_bth4PiJ$LYtFJSCN$3xwDN;8mr*B;CJwBP2G0TMq0uNt7S^DO_wE zepk!Wrn#Z#03j{`c*Rf~y3o7?J}w?tEELRUR2cgxB*Y{LzA#pxHgf}q?u5idu>077 zd^=p)`nA}6e`|@`p?u}YU66PP_MA}Zqqe!c{nK&z%Jwq1N4e_q<#4g^xaz=ao;u|6 zwpRcW2Lax=ZGbx=Q*HhlJ`Ns#Y*r0*%!T?P*TTiX;rb)$CGLz=rSUum$)3Qyv{BL2 zO*=OI2|%(Yz~`pNEOnLp>+?T@glq-DujlIp?hdJeZ7ctP4_OKx|5@EOps3rr(pWzg zK4d3&oN-X2qN(d_MkfwB4I)_)!I_6nj2iA9u^pQ{;GckGLxBGrJUM2Wdda!k)Y>lq zmjws>dVQ*vW9lvEMkiN3wE-__6OWD0txS&Qn0n22cyj4Q*8(nG4!G{6OOwNvsrPIL zCl-$W9UwkEUVuLwyD%|inbOF*xMODZ4VMEVAq_zUxZ+K#Gdqf!DW$5f)?7UNOFMz! zrB~tuu=6X2FE(p^iqgxr+?ZK;=yz`e;C$#_@D9Lj-+TDVOrva>(#*PVbaHO>A)mhl z07OJWCqYC60518$!&c`eNBcBW%GnfaQ*$eazV^2_AW?j)h;J1nUjN(I9=0+!RVx~% z3@Tf!P0TE+98jA?WceK-}A1% zW!K)lyKcGqy#M~})315-A#2NXQ`?6NR#Apo=S!oF=JfpX>iR*49ec{7AN$xxpK{D$ z2d%Fz&rdfSqourN$~Y^NFIMV1CZ?J*bMx~H3k&meGtH@q9ra2vZxmA$S(#jaaj-g4 ztJmxG+DLV<*q<|sDXPp$X>E)#S}Vm&sRaO5P&goh2><}FEdZSXDqsL$06sAkh(e+v zAsBhKSRexgwg6tIy~GFJzaTxXD(}|+0eOwFDA%rn`X;MVwDHT9=4=g%OaJ9s%3b9>9EUTnnp0t;2Zpa{*>mk~hZqItE_!dQ zOtC>8`$l|mV43Jbudf0N6&&X;{=z}Zi}d1`2qmJ}i|0*GsulD3>GgQXHN)pkR6sf1 z?5ZU%&xtL}oH;YiAA)d*^Ndw2T$+Mjuzyzz@-SM`9df7LqTxLuIwC~S0092~+=qYv z@*ja;?Wt!T!{U?c*Z0YtGe)XbI&y-?B&G2$`JDM)(dIV9G`Sc#6?sI60de6kv+)Qb zUW~2|WjvJq3TA8`0+sWA3zRhY9a~ow)O~&StBkG2{*{TGiY~S8ep{V&Vo2l<6LWsu z^#p0-v*t2?3&aA1)ozu|%efSR=XnpX$lvTeRdKlvM!@|pM5p2w3u-6 zU>}t2xiYLS+{|%C65AzX+23Mtlq?BS&YdYcYsVjoiE&rT>;Necn6l^K)T^lmE`5u{ zm1i+-a-gc;Z&v-{;8r)z6NYfBUv+=_L}ef}qa9FX01)+Aaf+;xj(mL6|JUzGJR1|fnanb%?BPPIp>SCjP|8qE5qJ{=n5ZGw?81z3(k;pzH%1CtlX50{E7h)$h{qGKfzC`e2o`*IqA#tjA z`Fz&^%$b9F*N`)U-#6>a)Z`55`$Dd0cfcs0$d13^ONrdCu9xcv_=n#WQo8stcz3jP9|2EvdI-RhJM3%Q%oM&!OlShM|0 z?gz?wHZSnm45njLtsz8PVT1S&jAlbKg5kVam$p16=EK@Sj4EP0OtH zmJDmdc^v)x>56Qg_wmYHz6h)>kl_h$>0@J!ypv%APmjZTAQVLy6Fu50RGY&JAVNhx zrF_qG6`x9MkT;1SFWo$)l{M$;3qUDn9JwE}z zRl#E_bDRJFii61kPgBybIgp8dNW!Cc1b*^YYk-#oWLJvtM_v^hQx~9?8LD4VFFxBF z3MlrsSC%f9Oupn*ctPL0U1fwfX?`tRhPD{PSLFPQOmIt$mDy0SgpNVvHS+f#Do>h1Gn?LZU9(KaN>Q_=Y*_T zvtD7%_u^^+{g`0VGzg(VZrpVQ6Ub5M=tI_p7T93R8@3Zulu3|#{iNcu!oiHxZ4Rf*( zfmiN$$ru(*_Zqn=`Gq#OuHRTSwp7uH_SokR&|)RuW5yo=Z|_4?qU-JU+tpt>!B&Is z@N(=SG;bpVc;AO@zbmMM zScqq1)b-ZQIrs={oD}|?6y{$HNB1U0^LsBh8JI&3!GBZxOXI<}&5-$lgkAaYqhOTb z?2vEnZ$-kk;*M_17(upJF3%+iH*s0-r{vttXVB2OUwI1s^+G(Ft(U8gYFXC}#P&E^ z>T@C^tS`Z7{6HT4_nF~n>JlZtk5&qDBl6r|^kzQYe`wq!C)n@$c>WOPA61NDFj<<6 zGW71NMMhwAl!U-yqrq2xrSFqRCI8acw7?}3j;ynxo*-b7Co;g5r%^j=H@9({PXXBf z@r>U>>N;E)81wx`B4f%{PB~MHka_);%kBCb(d|Jy5!MqJ%2p`t&@L)4$T2j&-WHvG zv3(uyA_gwqNu(k?jQTtv3dgPKRZoH8prxe7>pQBW5L&dpumS&5Ld2?(sCpJjvc4L5 zEnh&?91WVm)ZdTj=fjJ$pPDdgAttLXuke+?KdKxu*;kTC(r!tQk6;gxj4h%FdHAt(^M3YvYj(!tOeN)+Hvj6+< zzyJRG?^lZfWuR#t!tUKP&(?%3v&Zd$R2YN>lB(Lq`OInY48%4%yTv2 zYe1{G`3)(PDEio5Y@-I5tUf`c%%OCJMtSW56g3iEg%3`$7XSJJHyA z<|7&N)5Xrlgv~%BO24eFd;Hd;uiK%D`EdK|quUeRZDqbh9l)%j%J#0lfrZumvA<_w zu&=AVvdChf6}eqh(bUz`(`Ue*p01{fBAcTgKyDYLs_I+YyJEk+rM@avU~>fB$n)HS zM7pfJydu`i%gfS<{PF94kZDv$t>06sAkheDzu40NJ$5CMW%n^Lls?8^p^QGWURbKu3ZduZQZ((s2? zzE`}<{;Zt7<$C|9R8A~DJ~@%x>TfP zF>TX8)@v|t)q4GjRt<}5s6hLHwRel7>V@&r-O|Av(yh;Q1A{E>Ir>p+%dHD|=l+lT zpr(Dg&>#Nu=!)6bCLr-ZS%|;h)Ij$+e@r8_{qO19QvDe=&1tmpY*0lcA^Cc-#{9fQ z<~$*<&P$Q<_jy#<$40PMofM7aQ}C=jphI`4kLg}Z7CIN#26D{-4v-_CA-LiE@(%{y!BzsU%gG`Q?sjLUf%qFSl0y)2#ae*+EI>s|i`d^V$Dn)qmzqRq6VJRY|{4ujsIU%#bnqU6MR&-1I_43=|5(6Jr;Jvert) zE?S|Tmn}Tv<-??sxV5@9t}3D=>YZ0JrQe$CO~|EY=Lj9RM&4svQHPQL6%pV5fPFiH zfXDx;l@~et{*{U*#c#Dvzu)|znDO7$#CRx)Z&yp-}SrD{&|(MQtfUz~n35@RLfUy=aqrhCX0M}J_r5QsK~NmRCR|Nm&L z41UdsLjWxSUlL41r^0K&nCCK>fdR-!MYjFg(z9_mF^C|#ZQw?`)f6uVzF^`bRnVY& zo}@M06J&_+>w9@jpaO4snmU;0t-(zYW1qVBHtuD!d?%?AtN7Plp><-1Y8Rqb20ZaP zTCgn*-Sri4Q8Xn>=gNaWQ57%!D35UkA@ksOlPB*Dvw}t02ENAqw|kFhn%ZyyW%+t{ zNdM!uqEM^;2}f+tECHbwLmH*!nZVrb$-az%t50Y2pg(HqhvY-^-lb}>^6l{$jOI6} zo_kBzj%8aX|6H5M0Y<)7pzz_wLkIpRm!;PzY)9+24wk2&TT{w--phDGDCOz{cN_ca zpnm7`$oDy=HX%0i-`769*0M6(e5j-?(?24%)<)&46y0e&6@HCDZAm9W6Ib#Y#BF6- z=30crHGg+RRTe%VBC>T00OV6F+gQDAK38Ne3N9bm|62tPccBJi)5{B z4zc^Db72XiBd}v$CF|yU{Z=M|DZ%-(XarYNclODlb1Kz1_EKLy(NSLCN`eUl(rBCL zT*jx@wNvze0|TSqgE(QArOZU)_?qH(sj#TwzElLs9q)(0u!_P|R%Cy_0JFQxgGV>1 zz4?_uq<8_gM0`c*Hh|;UMz~vrg1gQXp{ufg`hM_qU;U>+zmvc5blCLSq@PrEBSGR# z&8=2Z4uXN`F3p73ueD1l{s{k$WipAvSh5W7ABe?4)t;r@V?y`bNB5FvBuE|0VRTb< zM1Hn^?DSsJY+sX@T5xW=#>T9VEV|?<(=6|ge$X6Sb05!LFdjDcoq*gM(Zq=t;_)Le&jyt(&9jzR73noru`a# zN*<`KwGa^gZU3-)MSLF0aFag#f0<>E(bYTeHmtdbns#|I)-$)mJ`q9ctQ8g0=ET?| zdO}eZ*b_p>ygRTtR^5Ggdam=Zb5wmd{}np+Jn1d_=M`~P=M67jj})fH4ztb5yQqQW z^C|C&^LHAK-u+ooIK)yM)QM?t;|<{P;;{`p=BclzAN#JzL4jCwXkQB1Dy{=^KR`=~ zTrr)y7eiYBzSNs_DvO=4A6#EgGS-zY%Vi)N*Yb`U;6o}KR}dq{r9pT5wqZ@3NOE8- z9-(}D|Nc5732CSYQbL)!gPQ#RbD8BhK3dl{sUuPvei0tkvnJBxDEAYTesU8H$)g(Plra{VH(v3u^CO1~(+ zU0O7#)jaS4{NcwA+LuSm&VBcX2#Im3xg)W}ySNw%->orn1taZ&+d)}8gJTqA!u|5P z{yv?zol_3|(1(%M(EVU=cp?L`{Pi|ixk{U)*guFML3P!OSlz;zGA#T+E@8@cgQ_mv1o7RSU=Zo_82F?&&2r;WE z@wk}JHYEZ9nYUc(Vv~iTCa3u8e4q(yq<29VoNbKk|`mq%I6u)My=gPIDuUb&lzf4`MEA9^g8u z)vp8|$$HE9m_BTV?lOosIGa4jud=jIbw)O2eCMfyw2*S8?hjWw^nqws$O*M$3I1)x zR0PWFb3$ySOcGTe1dz%N0l;RPc`x%05FtT^f^j{YCP}*Q=lvp4$ZXrTZQHhO+w%wJn3c8j%+5C3UAFD&%8dBl_qi9D5g8fry}6Ev z2_Q~)5^N$!IU`BPh1O|=BxQ#*C5*}`lluC515$lxc-vNC)IgW=K|=z7o%cWFpndn= zX}f{`!VK02_kU+Q5a3m37J;c} zTzbxteE{GNf?yLt5X=Bzc-mio^Up0nunMCgp*ZJ;%MJvPM3QK)BryP(_v@ei4UvHr z6+sbCifQaOkL6-;5fL8$W($zZ_;CZp305C;~$hhRquZr-r)jjd1z z31%ZK{-(`P#|Um_Sivn@p$-vz46uqT>QG0B1w9znfS9A8PB2LaHdzA|_)yjXVR*l{ zkcu3@vEf7bxH0nkh`q?8FmoO_Ucui*>_a~P?qQrlZ9@+D7%MTpSnztpylXrt5!-k8_QPB?YL8Kx_On8WD zgT+111d(Op$^$&KLAN5+@?>f7F4~wFi(8TL8+szgVmcMDTp5l&k6~=rA{Dt}!gb^r zSWY<)M7D|Z2P0cEodj6E42PV>&>DFmQpgt)E-|#sSUU@uKed+F680H@<;-x{p|nuH4!_mn85rx>wz;0mPi2ZkL#k6;sznu?cXh!T0S>{w6 zL^gvR05NY64l*<+_L>On$rjx9!US;l;LX6@z}yi#2XHh)F@Oo+l)h%fq$v}DNmF2> zfs^_t0)3N-W<9-N?uedVv{)-J0W5mh#29QM5R5h&KuiRM=0Zvnf#lF=K#WlCgc#9c zS;qvh(P$!_a8JwyhI^ZJV2k+B6Z^64?w|1?5gyo6y{}923CRZfYVe1#?F% z7h2SUiNO3;T#JUOyovSs@@C1GtwipycA=*x5{BpIZ_#GCMuV8XK=x;qCNy{d7?wA~ zC+=vjls;ci&zW=6$H~4^K%v{p}Ab?U%C6Z4p%eC<3ExqU$XR<}LLF67A$Sr20DR_pJ3yeBa~ z^sw{V0FI5;UpwXsScYuhbqGQ`YQ25;6p6W^+tgL&;Ml;>S3CGpSZ>VrTn0m1$y$HU z&65)I!c?oREz};c=nLCliriqQX->4uivHTgd${GqeAlf*!P^B|jkU|*IdNP(&6C>4 zqOW$)Nw9nvjy^&`?E|gotDV{JmJ9Q~vuhy<`^C4XIUDt|j4o6rK^e8_(=YqC zuaR6TRVf@tUFHB079o4MBIh{M~4>WwnGgesQH*3?w(RA%hCZ*7)b!aNV=yOQ%o_Y=Lt0Sl*(9^jfRnC210Om$=y>*o|3z} zAR&vAdrB#mWoaB0fJSw9xw|Am$fzK>rx-~R#7IFSAwdu_EI|SRfB*yl0w8oX09H^q zAjl2?0I)v*odGJ40FVGaF&2qJq9Gv`>V>2r0|c`GX8h>CX8eHcOy>S0@<;M3<_6UM z7yCEpug5NZL!H_0>Hg_HasQGxR`rY&Z{geOy?N92Z z{lER^um|$*?*G63*njwc(R?NT)Bei*3jVzR>FWUDb^gKhtL4A=kE_1p-%Fo2`!8M} z(0AjuCiS;G{?*^1tB-uY%=)SRx&D)pK4u@>f6@KPe3}2j_har$>HqzH;UCR^ssFD0 z7h+VLO4o@_Yt>>AeaZKUxqyvxWCAjKB>qjQ30UA)#w z&=RmdwlT`7a8J8Yae=7*c8XL|{@%wA8uvCqfsNX^?UZsS>wX}QD{K}ad4y~iO*p%4 z_cS{u7Ek%?WV6em2(U9#d8(&JDirb^u~7wK4+xP$iiI6IlD|a&S)6o=kG;59N|>K1 zn(0mUqbG3YIY7dQd+*4~)`!S9m7H6HP6YcKHhBc#b%1L}VIisp%;TckEkcu0>lo@u995$<*Em;XNodjTiCdC%R+TX|_ZR#|1`RR|`^@Teh zl#w@8fI1FTx2Dy+{blUT{`^kY*V-AZUd?ZZqCS4gW(kY5?retkLbF=>p=59Nl|=sf zo1Pc|{{N4>5nt#627ylGF`3n>X%`w%bw-Y~zWM_{Si$dc82|=YhISal{N7OY?O`C4 zD|qb}6nLWJ`hUyL+E>-;ricg9J@ZNYP(x(Sct&OI$Y!QWr*=^VN;G3#i>^1n4e#Je zOVhbFbLpXVu*16enDM+ic;97@R~u&kh__kgP#!R`*rQEnA+_dLkNP~L`0alC|J;c; zeiK=s8;BsLE)KbG3BD&Br@(Ha@SBT&$?xX`=$;eeel=|R_dIr6-Ro?=HEjnsJ_b`1 zK6Yg^-6;^2aW!xeTK)A~3Rm|L^FCHB_I>jIju7ZGo&N_1*QHkxH2!!%@o4iZ?vntS;&zJdPe1dH#04YD93A44o-MpfD zP{rn_aq>U%RDvC2+bp;xPlsOzauIi3*Lf42`jVKKZCRuKdYhi>FDuL2l=v{$BCN#Q6796s%r-AG$Q^t(3c@ zD?w0UhYr11@feiyl9kY_@H8~|xlmO<8PfQmj1!$@WieW@VxR@Psxfe-v9WCi1+f>F4VL?0O~K7T?m4-u|pSkBpUJZZe*16_wAp zSYZ@;k`3;W3UHKUWc8QeI}0jH5Ly=cGWQPw(Kr2fm=-5L(d`lcXofy8tJY3@Tuadz zYWXR{mW7XT!RF#RVCe%}=tM*O6!AD3^(!8un~opNI%Uko7$5t@<8+?; zTxDys(MyyGsUjtSu9$+|_-t!U3fVb1dkK?l`17<+jfl=hrBHnDSV>^R1=TnQeyqbW z>ov#l%!1|S!1>8UUxIdhQq`_klcHVx0{?#>K3#$4GlXncwldt!g17TcvKq-jo_996 z>oA=tH9CqRl6Yw?Uc`am!V?lHJbizOJaVaScf1UP5e7Dbgabq=b!B~T&_F6?ooU>w%x0A zH~&MHJ=q`fCH{U<7MDXE4SD32cDZA)WJeWkllJ`UspWaS#eDe^kg^oU_A14UE9zG-a^g{xaXf$})Wik>gT zl#dkzGr(;h0JZDuFn(+k8wNq?PZ5grQ<+sM?wBGt@JnH6v0#or-5wBQWKU~(S_> zkE!tc*ZJ1Y&*p(xX84POb3cClRMd!^qJ#CAZfIepEj-<`VURS_yCz0(?*Ixcj4 z-!zV1_QZhpm=0<;*(nm+F>T=)o?ep@CK5I%g^VAA+RB25ab?7)A~z~egru=I1S|@v zH7tXV!0wmGS^qj#e+MY;C5eUjEAp$Y?LDkS^QPZ}8WN85?r$u<-Epi;yZ1|J2J`se z$D6DpH~2F=eI0B&=UFAUnJvZAmClJlK)sutJ?M>xpZiWV&0=G4MZP+x+p>EX=HbCz zxls%Mw?*u^;LbHWIWCyq+yi)`GmFn9J112CZda_u@YIP%i;srFg_paU02Ifij*7}l z&CF-(3|>*a|+vbNR`^RP=9G?ymEJ0Z~)d&c*UE$UMepZ zcITr{0WqhxkjUnM15js_gW=e3Uh|y6ZReaXHIz-=p`x5VvB&rH9y>Amv@^WmXFEw) zQXYrk3feir=a{jMQ+wDIkkFnZ$k{sJakHn*?u za%4b!00ev8NVLM1TY=cl?KB&55BY_MU-sg?c>=Dbz_W{(Z~c?HJi*XpYL)C6Bd8WH zt+v-#0&o~@t4qESi*)+eW%@VD0|o^yF)n0hME$UtXF$*Lvh}7sso{`|pn*JDIy5^Fm3s$5*zEE=?u5<=l8FJc3r%+H} zdfoNl2J0^~!-*mOL5o-x32|e0Im*E!yY7F7E5N)W3>+v_LBydlEx?4$RL5f2oYRD# zaR0wv(-p~wO0eLDl3K=%`{5+0Gd$ktO=W)gWlGZJ0`K z$_RNA=ckrfa;H0KA~dR^p�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/App v2/app/src/main/res/values/colors.xml b/App v2/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/App v2/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/App v2/app/src/main/res/values/strings.xml b/App v2/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..a32ef67 --- /dev/null +++ b/App v2/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + BleIndoorPositioning + \ No newline at end of file diff --git a/App v2/app/src/main/res/values/themes.xml b/App v2/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..33a26ed --- /dev/null +++ b/App v2/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +