Update MEMORY.md, MEMORY.md, bachelor-thesis-summary.md, and 76 more files

This commit is contained in:
erik committed 2026-02-13 21:07:55 +01:00
commit 4426388063
79 files changed
+6169

No files matched your search

+1
View File
@@ -0,0 +1 @@
/build
+77
View File
@@ -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)
}
+21
View File
@@ -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
@@ -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)
}
}
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Bluetooth-Berechtigungen -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- Ab Android 12 (API 31)-->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="31" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Für BLE-Positionierung benötigt-->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- BLE-Hardware voraussetzen -->
<uses-feature android:name="android.hardware.bluetooth_le"
android:required="true" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.BleIndoorPositioning">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.BleIndoorPositioning">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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()
}
}
@@ -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
}
@@ -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<Map<Int, ScannedBeacon>>(emptyMap())
val beacons: StateFlow<Map<Int, ScannedBeacon>> = _beacons.asStateFlow()
private val _isScanning = MutableStateFlow(false)
val isScanning: StateFlow<Boolean> = _isScanning.asStateFlow()
private val _position = MutableStateFlow<PositionResult?>(null)
val position: StateFlow<PositionResult?> = _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")
}
}
@@ -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<RoomConfig>,
val beacons: List<BeaconConfig>
)
@@ -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<AppConfig>(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<LegacyAppConfig>(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<BeaconConfig>
)
}
@@ -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<Float, Float> {
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()
}
}
@@ -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<Int, ScannedBeacon>,
positionResult: PositionResult?,
floorWidthMeters: Double,
floorHeightMeters: Double,
roomConfigs: List<RoomConfig>,
beaconConfigs: List<BeaconConfig> = 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<Double>()
val allY = mutableListOf<Double>()
// 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<RoomConfig>,
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)
)
}
}
@@ -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
)
@@ -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<Int, Double>,
val usedBeacons: List<Int>
)
@@ -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
}
}
@@ -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<BeaconConfig>,
private val floorWidth: Double = Double.MAX_VALUE,
private val floorHeight: Double = Double.MAX_VALUE,
private val rooms: List<RoomConfig> = 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<Int, Double>): 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<Double>()
val allY = mutableListOf<Double>()
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<BeaconConfig>,
distances: Map<Int, Double>,
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<BeaconConfig>,
distances: Map<Int, Double>,
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)
}
}
@@ -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<Int>(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<Int, RssiFilter>()
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)
}
}
@@ -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<List<RoomConfig>>(emptyList()) }
var configuredBeacons by remember { mutableStateOf<List<BeaconConfig>>(emptyList()) }
var showAddBeaconDialog by remember { mutableStateOf<ScannedBeacon?>(null) }
var showEditBeaconDialog by remember { mutableStateOf<BeaconConfig?>(null) }
var showAddRoomDialog by remember { mutableStateOf(false) }
var showEditRoomDialog by remember { mutableStateOf<RoomConfig?>(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")
}
}
}
}
}
}
@@ -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
)
@@ -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"
)
}
}
}
}
}
}
@@ -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
@@ -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
)
}
@@ -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
)
*/
)
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">BleIndoorPositioning</string>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.BleIndoorPositioning" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,17 @@
package com.erik.bleindoorpositioning
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}