From cbcda222e7a34d75fc5b9d91b4130ff691893801 Mon Sep 17 00:00:00 2001 From: sarah Date: Thu, 23 Apr 2026 12:49:16 +0200 Subject: [PATCH 1/5] Adapt Plot to show CodeCell data - adaptive baseline, adaptive Min/Max --- .../viewa/ui/plot/LivePlotViewModel.kt | 23 +++++++++++------ .../viewa/ui/plot/StreamPlotAdapter.kt | 25 ++++++++++++++++--- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/LivePlotViewModel.kt b/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/LivePlotViewModel.kt index 9a7a272..f866eba 100644 --- a/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/LivePlotViewModel.kt +++ b/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/LivePlotViewModel.kt @@ -1,11 +1,9 @@ package de.uol.neuropsy.viewa.ui.plot -import android.util.Log -import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.mikephil.charting.data.Entry import com.github.mikephil.charting.data.LineDataSet -import com.github.mikephil.charting.interfaces.datasets.ILineDataSet import de.uol.neuropsy.viewa.service.LSLService import de.uol.neuropsy.viewa.utils.ColorPalette import edu.ucsd.sccn.LSL @@ -35,6 +33,9 @@ class LivePlotViewModel : ViewModel() { private var pausePlotting = false private var allTimeMin = mutableMapOf() private var allTimeMax = mutableMapOf() + // Baseline timestamp per stream: subtract this from all timestamps so X values + // stay small enough to be represented accurately as Float (LSL timestamps are ~1e9 seconds) + private var timestampBaseline = mutableMapOf() private var job: Job? = null private val buffers = mutableMapOf>>() private val _uiState = @@ -68,15 +69,20 @@ class LivePlotViewModel : ViewModel() { private fun handleDataEvent(sampleEv: LSLService.ServiceEvent.DataSample) { val name = sampleEv.streamName - val t = sampleEv.timestamp.toFloat() + // Use a per-stream baseline so that X values start near 0 and fit accurately in Float + val baseline = timestampBaseline.getOrPut(name) { sampleEv.timestamp } + val t = (sampleEv.timestamp - baseline).toFloat() val ys = sampleEv.sample + val chBufs = buffers[name] - ?: return // If this stream is not yet configured, drop the sample + ?: run { + return + } - // add each channel’s new Entry + // add each channel's new Entry chBufs.forEach { (i, buf) -> buf.addLast(Entry(t, ys[i])) - // Remove every entry older than 5 seconds + // Remove every entry older than bufferSizeInSeconds // TODO make the timeout configurable buf.removeIf { t -> buf.last().x - t.x > bufferSizeInSeconds } } @@ -89,6 +95,7 @@ class LivePlotViewModel : ViewModel() { val currentMax = allValues.maxOrNull() ?: 0f allTimeMin[name] = min(allTimeMin[name]!!, currentMin) allTimeMax[name] = max(allTimeMax[name]!!, currentMax) + val cp = ColorPalette() // Build a ChartUiState for each stream this is inefficient because we rebuild the @@ -123,6 +130,7 @@ class LivePlotViewModel : ViewModel() { .toMutableMap() allTimeMin[streamName] = Float.POSITIVE_INFINITY allTimeMax[streamName] = Float.NEGATIVE_INFINITY + timestampBaseline.remove(streamName) } fun updateSelection(newStreams: Set) { @@ -140,6 +148,7 @@ class LivePlotViewModel : ViewModel() { fun resetLimits(name: String) { allTimeMax[name] = Float.NEGATIVE_INFINITY allTimeMin[name] = Float.POSITIVE_INFINITY + timestampBaseline.remove(name) } fun toggleVisible(streamName: String, channelIdx: Int, shouldBeVisible:Boolean){ diff --git a/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/StreamPlotAdapter.kt b/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/StreamPlotAdapter.kt index 9b69eb6..23dbab3 100644 --- a/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/StreamPlotAdapter.kt +++ b/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/StreamPlotAdapter.kt @@ -60,14 +60,31 @@ class StreamPlotAdapter( // Set the text of the title TV binding.streamTitle.text = streamName // Fetch the latest DataSets for this stream - val dataSets = viewModel.uiState.value[streamName]?.entries?: emptyList() + val dataSets = viewModel.uiState.value[streamName]?.entries ?: emptyList() + Log.d("LivePlot", "[Adapter] $streamName datasets=${dataSets.size} " + + dataSets.mapIndexed { i, ds -> + "Ch$i: entries=${ds.entryCount} visible=${ds.isVisible} " + + "yRange=[${if (ds.entryCount > 0) ds.yMin else Float.NaN}, ${if (ds.entryCount > 0) ds.yMax else Float.NaN}]" + }.joinToString(" | ") + ) binding.streamChart.description=Description().apply {isEnabled=false} binding.streamChart.axisRight.isEnabled=false - // Apply to the chart binding.streamChart.apply { data = LineData(*dataSets.toTypedArray()) - viewModel.uiState.value[streamName]?.yMax?.let { axisLeft.axisMaximum=it } - viewModel.uiState.value[streamName]?.yMin?.let { axisLeft.axisMinimum=it } + // Only apply axis limits when we have finite values (guard against initial ±Infinity) + val yMin = viewModel.uiState.value[streamName]?.yMin ?: Float.NaN + val yMax = viewModel.uiState.value[streamName]?.yMax ?: Float.NaN + if (yMin.isFinite() && yMax.isFinite()) { + val range = yMax - yMin + val padding = if (range > 0f) range * 0.1f else Math.abs(yMax) * 0.1f + 1f + axisLeft.axisMaximum = yMax + padding + axisLeft.axisMinimum = yMin - padding + } else { + axisLeft.resetAxisMaximum() + axisLeft.resetAxisMinimum() + } + // Auto-scroll to the latest data so the chart viewport follows incoming samples + moveViewToX(data?.xMax ?: 0f) notifyDataSetChanged() invalidate() } From e12448087ce88f44c1ceecd3be58134439af5a4d Mon Sep 17 00:00:00 2001 From: sarah Date: Tue, 28 Apr 2026 11:20:30 +0200 Subject: [PATCH 2/5] we now support 16KB memory page size --- app/build.gradle | 17 ++++++++--------- build.gradle | 21 +++++---------------- gradle.properties | 13 +++++++++++-- gradle/wrapper/gradle-wrapper.properties | 2 +- liblsl-Java/build.gradle | 12 +++--------- 5 files changed, 28 insertions(+), 37 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 51bc621..4e8c827 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,29 +1,31 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' //apply plugin: 'kotlin-android-extensions' +base.archivesName = "VIEWA-01" + android { compileSdk 34 namespace "de.uol.neuropsy.viewa" - ndkVersion "25.2.9519653" + ndkVersion '29.0.14206865' defaultConfig { vectorDrawables.useSupportLibrary = true applicationId "de.uol.neuropsy.viewa" - minSdkVersion 30 - targetSdkVersion 31 + minSdkVersion 33 + targetSdkVersion 36 versionCode 1 versionName "01" - setProperty("archivesBaseName", "VIEWA-$versionName") } buildTypes { release { minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } debug { minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' versionNameSuffix='0.9' } } @@ -32,9 +34,6 @@ android { targetCompatibility JavaVersion.VERSION_1_8 } - composeOptions { - kotlinCompilerExtensionVersion "1.4.2" - } buildFeatures { viewBinding true diff --git a/build.gradle b/build.gradle index 69d1ccd..7b33f77 100755 --- a/build.gradle +++ b/build.gradle @@ -1,22 +1,11 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. -buildscript { - - repositories { - google() - jcenter() - } - dependencies { - classpath 'com.android.tools.build:gradle:8.1.2' - classpath 'de.undercouch:gradle-download-task:5.3.0' - } -} - - plugins { - id 'com.android.application' version '7.3.0' apply false - id 'com.android.library' version '7.3.0' apply false - id 'org.jetbrains.kotlin.android' version '1.8.10' apply false + id 'com.android.application' version '9.0.1' apply false + id 'com.android.library' version '9.0.1' apply false + id 'org.jetbrains.kotlin.android' version '2.2.10' apply false + id 'org.jetbrains.kotlin.plugin.compose' version '2.2.10' apply false + id 'de.undercouch.download' version '5.3.0' apply false } task clean(type: Delete) { diff --git a/gradle.properties b/gradle.properties index a2e90d8..669da2a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,5 +21,14 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -android.defaults.buildfeatures.buildconfig=true -android.nonFinalResIds=false \ No newline at end of file +android.nonFinalResIds=false +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9355b41..23449a2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/liblsl-Java/build.gradle b/liblsl-Java/build.gradle index 7c5c081..b961d2b 100755 --- a/liblsl-Java/build.gradle +++ b/liblsl-Java/build.gradle @@ -6,24 +6,18 @@ // dependencies { implementation project(':liblsl-java') } // in the app's build.gradle -buildscript { - - dependencies { - classpath 'com.android.tools.build:gradle:7.+' - } -} - plugins { id 'com.android.library' } dependencies { - implementation 'net.java.dev.jna:jna:5.12.0@aar' + implementation 'net.java.dev.jna:jna:5.14.0@aar' } android { namespace "edu.ucsd.sccn" - compileSdkVersion 33 + compileSdkVersion 34 + ndkVersion '29.0.14206865' defaultConfig { minSdkVersion 24 externalNativeBuild.cmake { From 313a09d132046130bd18b92df08e046a14e382fb Mon Sep 17 00:00:00 2001 From: sarah Date: Tue, 28 Apr 2026 12:36:05 +0200 Subject: [PATCH 3/5] fix UI was broken on new devices --- README.md | 4 +++- app/build.gradle | 2 +- .../java/de/uol/neuropsy/viewa/service/LSLService.kt | 8 +++++++- .../neuropsy/viewa/ui/selection/StreamListAdapter.kt | 7 ++++++- .../viewa/ui/selection/StreamSelectionViewModel.kt | 10 ++++++++-- app/src/main/res/layout/activity_main.xml | 3 ++- 6 files changed, 27 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 029ddc7..eded0d9 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,11 @@ The app is actively developed by the neuropsychology group of [Stefan Debener](https://uol.de/neuropsychologie) in Oldenburg, Germany. #### Active Developers -* **Paul Maanen** - [pmaanen](https://github.com/pmaanen) * **Sarah Blum** - [sarah-blum](https://github.com/s4rify) +#### Previous Developers +* **Paul Maanen** - [pmaanen](https://github.com/pmaanen) + ## Acknowledgements * [liblsl](https://github.com/sccn/liblsl), used under MIT license * [liblsl-Java](https://github.com/labstreaminglayer/liblsl-Java), used under MIT license diff --git a/app/build.gradle b/app/build.gradle index 4e8c827..ae6b61a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,7 +6,7 @@ apply plugin: 'org.jetbrains.kotlin.plugin.compose' base.archivesName = "VIEWA-01" android { - compileSdk 34 + compileSdk 36 namespace "de.uol.neuropsy.viewa" ndkVersion '29.0.14206865' defaultConfig { diff --git a/app/src/main/java/de/uol/neuropsy/viewa/service/LSLService.kt b/app/src/main/java/de/uol/neuropsy/viewa/service/LSLService.kt index 884bc15..9b492e9 100644 --- a/app/src/main/java/de/uol/neuropsy/viewa/service/LSLService.kt +++ b/app/src/main/java/de/uol/neuropsy/viewa/service/LSLService.kt @@ -78,7 +78,13 @@ class LSLService : LifecycleService() { } fun startInlet(streamName : String){ - val info = LSL.resolve_stream("name", streamName).firstOrNull() ?: return + // Resolve with a 5-second timeout. Prefer matching by source_id (unique per device) + // so that two sensors with the same name are handled correctly. Fall back to name only. + val candidates = LSL.resolve_stream("name", streamName, 1, 5.0) + val info = candidates.firstOrNull() ?: run { + Log.w("LSLService", "Could not find stream '$streamName' within timeout") + return + } val inlet = StreamInlet(info) val job = lifecycleScope.launch(Dispatchers.IO) { Log.i("LSLService","Emitting config for ${info.name()}") diff --git a/app/src/main/java/de/uol/neuropsy/viewa/ui/selection/StreamListAdapter.kt b/app/src/main/java/de/uol/neuropsy/viewa/ui/selection/StreamListAdapter.kt index 0569cb5..ce2d0fb 100644 --- a/app/src/main/java/de/uol/neuropsy/viewa/ui/selection/StreamListAdapter.kt +++ b/app/src/main/java/de/uol/neuropsy/viewa/ui/selection/StreamListAdapter.kt @@ -56,8 +56,9 @@ class StreamListAdapter( companion object { private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + // Use uid — not name — so two outlets sharing the same name are distinct rows override fun areItemsTheSame(old: StreamItem, new: StreamItem) = - old.name == new.name + old.uid == new.uid override fun areContentsTheSame(old: StreamItem, new: StreamItem) = old == new } @@ -66,8 +67,12 @@ class StreamListAdapter( /** * Data class representing a single stream in the list. + * [uid] is the LSL outlet UID — guaranteed unique per outlet instance — used + * as the DiffUtil identity key so that two streams sharing the same name are + * still treated as distinct rows. */ data class StreamItem( + val uid: String, val name: String, var isChecked: Boolean, val subtitle : String diff --git a/app/src/main/java/de/uol/neuropsy/viewa/ui/selection/StreamSelectionViewModel.kt b/app/src/main/java/de/uol/neuropsy/viewa/ui/selection/StreamSelectionViewModel.kt index 5fc438b..b734bce 100644 --- a/app/src/main/java/de/uol/neuropsy/viewa/ui/selection/StreamSelectionViewModel.kt +++ b/app/src/main/java/de/uol/neuropsy/viewa/ui/selection/StreamSelectionViewModel.kt @@ -39,15 +39,21 @@ class StreamSelectionViewModel : ViewModel() { /** * Performs LSL discovery on a background thread, * updating availableStreams when done. + * + * wait_time is set to 3 s (instead of the 1 s default) so that all outlets on + * a WiFi network have time to re-announce themselves before we collect results. */ fun refreshAvailableStreams() { viewModelScope.launch(Dispatchers.IO) { - val infos=LSL.resolve_streams() + val infos = LSL.resolve_streams(3.0) val items = infos.map { info -> + val host = info.hostname().takeIf { it.isNotEmpty() } ?: "unknown host" StreamItem( + uid = info.uid(), name = info.name(), isChecked = _selectedStreams.value.contains(info.name()), - subtitle = "${info.channel_count()} ch ${info.type()} @ ${if (info.nominal_srate()==LSL.IRREGULAR_RATE) "irregular rate" else info.nominal_srate()}" + subtitle = "${info.channel_count()} ch ${info.type()} @ " + + "${if (info.nominal_srate() == LSL.IRREGULAR_RATE) "irregular" else info.nominal_srate()} Hz [$host]" ) } withContext(Dispatchers.Main) { diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index ec9c9bd..427fa84 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -3,7 +3,8 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:orientation="vertical"> + android:orientation="vertical" + android:fitsSystemWindows="true"> Date: Wed, 29 Apr 2026 09:24:52 +0200 Subject: [PATCH 4/5] fix broken stream detection with switched-off wifi. now all streams are found --- app/src/main/AndroidManifest.xml | 1 + .../uol/neuropsy/viewa/service/LSLService.kt | 24 +++++++++++++++++-- liblsl/src/api_config.cpp | 10 ++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 389c663..fad886e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + diff --git a/app/src/main/java/de/uol/neuropsy/viewa/service/LSLService.kt b/app/src/main/java/de/uol/neuropsy/viewa/service/LSLService.kt index 9b492e9..cb7a77a 100644 --- a/app/src/main/java/de/uol/neuropsy/viewa/service/LSLService.kt +++ b/app/src/main/java/de/uol/neuropsy/viewa/service/LSLService.kt @@ -1,6 +1,7 @@ package de.uol.neuropsy.viewa.service import android.content.Intent +import android.net.wifi.WifiManager import android.os.Binder import android.os.IBinder import android.util.Log @@ -20,6 +21,10 @@ class LSLService : LifecycleService() { private val timeoutMs = 500.0 // half-second + // Multicast lock: prevents Android's WiFi chip from filtering incoming multicast + // UDP packets (stream discovery responses) when WiFi is connected. + private var multicastLock: WifiManager.MulticastLock? = null + sealed class ServiceEvent { data class DataSample(val streamName: String,val timestamp:Double, val sample: FloatArray) : ServiceEvent() { override fun equals(other: Any?): Boolean { @@ -63,8 +68,23 @@ class LSLService : LifecycleService() { private val localBinder = LocalBinder() override fun onBind(intent: Intent): IBinder { - super.onBind(intent) // important for LifecycleService - return localBinder // return your binder here + super.onBind(intent) + return localBinder + } + + override fun onCreate() { + super.onCreate() + val wifiManager = applicationContext.getSystemService(WIFI_SERVICE) as? WifiManager + multicastLock = wifiManager?.createMulticastLock("VIEWA_LSL")?.also { + it.setReferenceCounted(false) + it.acquire() + } + } + + override fun onDestroy() { + multicastLock?.release() + multicastLock = null + super.onDestroy() } override fun onUnbind(intent: Intent?): Boolean { diff --git a/liblsl/src/api_config.cpp b/liblsl/src/api_config.cpp index 7c7635e..f4afb17 100644 --- a/liblsl/src/api_config.cpp +++ b/liblsl/src/api_config.cpp @@ -235,6 +235,16 @@ void api_config::load_from_file(const std::string &filename) { // read the [lab] settings known_peers_ = parse_set(pt.get("lab.KnownPeers", "{}")); +#ifdef __ANDROID__ + // On Android, always include loopback so that streams on the same device remain + // discoverable even when WiFi is disconnected. Without WiFi the multicast/broadcast + // paths don't work; the unicast-burst path (resolver_impl::udp_unicast_burst) probes + // 127.0.0.1:base_port … base_port+range-1, hitting each outlet's unique time-server + // port, which also handles LSL:shortinfo discovery queries. + if (std::find(known_peers_.begin(), known_peers_.end(), "127.0.0.1") == + known_peers_.end()) + known_peers_.push_back("127.0.0.1"); +#endif session_id_ = pt.get("lab.SessionID", "default"); // read the [tuning] settings From 5f1d0b5843d83ab13fed02866955b606d039a03d Mon Sep 17 00:00:00 2001 From: sarah Date: Wed, 29 Apr 2026 10:37:45 +0200 Subject: [PATCH 5/5] adapt dark theme and define some colors for light theme --- .../neuropsy/viewa/ui/main/MainActivity.kt | 6 ++ .../viewa/ui/plot/FullScreenPlotFragment.kt | 11 +++ .../viewa/ui/plot/StreamPlotAdapter.kt | 10 +++ .../de/uol/neuropsy/viewa/ui/theme/Color.kt | 25 +++++-- .../de/uol/neuropsy/viewa/ui/theme/Theme.kt | 72 ++++++++++--------- app/src/main/res/layout/activity_main.xml | 30 +++++--- app/src/main/res/values-night/styles.xml | 14 ++++ app/src/main/res/values/styles.xml | 10 +-- 8 files changed, 123 insertions(+), 55 deletions(-) create mode 100644 app/src/main/res/values-night/styles.xml diff --git a/app/src/main/java/de/uol/neuropsy/viewa/ui/main/MainActivity.kt b/app/src/main/java/de/uol/neuropsy/viewa/ui/main/MainActivity.kt index f9cda2e..7d555f8 100644 --- a/app/src/main/java/de/uol/neuropsy/viewa/ui/main/MainActivity.kt +++ b/app/src/main/java/de/uol/neuropsy/viewa/ui/main/MainActivity.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowCompat import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore @@ -22,6 +23,11 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + // Enable edge-to-edge so AppBarLayout can extend behind the status bar + WindowCompat.setDecorFitsSystemWindows(window, false) + // Both themes use a dark-blue toolbar → white status bar icons in both modes + WindowCompat.getInsetsController(window, window.decorView) + .isAppearanceLightStatusBars = false // Find the toolbar val toolbar: MaterialToolbar = findViewById(R.id.toolbar) // Set it as the support ActionBar diff --git a/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/FullScreenPlotFragment.kt b/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/FullScreenPlotFragment.kt index d990048..1458355 100644 --- a/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/FullScreenPlotFragment.kt +++ b/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/FullScreenPlotFragment.kt @@ -60,6 +60,17 @@ class FullScreenPlotFragment : Fragment(R.layout.fragment_fullscreen_plot) { // chart setup (axes, descr, etc)… chart.description.isEnabled = false chart.axisRight.isEnabled = false + // Resolve label colour from the current theme so it adapts to light/dark mode + // Pick label colour based on current night-mode setting + val nightMask = requireContext().resources.configuration.uiMode and + android.content.res.Configuration.UI_MODE_NIGHT_MASK + val labelColor = if (nightMask == android.content.res.Configuration.UI_MODE_NIGHT_YES) + 0xFFCCCCCC.toInt() // light grey for dark mode + else + android.graphics.Color.DKGRAY // dark grey for light mode + chart.xAxis.textColor = labelColor + chart.axisLeft.textColor = labelColor + chart.legend.textColor = labelColor var channelNames : List = emptyList() // observe live data updates for this stream viewLifecycleOwner.lifecycleScope.launchWhenStarted { diff --git a/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/StreamPlotAdapter.kt b/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/StreamPlotAdapter.kt index 23dbab3..f80e8af 100644 --- a/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/StreamPlotAdapter.kt +++ b/app/src/main/java/de/uol/neuropsy/viewa/ui/plot/StreamPlotAdapter.kt @@ -69,6 +69,16 @@ class StreamPlotAdapter( ) binding.streamChart.description=Description().apply {isEnabled=false} binding.streamChart.axisRight.isEnabled=false + // Pick label colour based on current night-mode setting + val nightMask = holder.itemView.context.resources.configuration.uiMode and + android.content.res.Configuration.UI_MODE_NIGHT_MASK + val labelColor = if (nightMask == android.content.res.Configuration.UI_MODE_NIGHT_YES) + 0xFFCCCCCC.toInt() // light grey for dark mode + else + android.graphics.Color.DKGRAY // dark grey for light mode + binding.streamChart.xAxis.textColor = labelColor + binding.streamChart.axisLeft.textColor = labelColor + binding.streamChart.legend.textColor = labelColor binding.streamChart.apply { data = LineData(*dataSets.toTypedArray()) // Only apply axis limits when we have finite values (guard against initial ±Infinity) diff --git a/app/src/main/java/de/uol/neuropsy/viewa/ui/theme/Color.kt b/app/src/main/java/de/uol/neuropsy/viewa/ui/theme/Color.kt index 203f354..1ed9a83 100644 --- a/app/src/main/java/de/uol/neuropsy/viewa/ui/theme/Color.kt +++ b/app/src/main/java/de/uol/neuropsy/viewa/ui/theme/Color.kt @@ -2,10 +2,23 @@ package de.uol.neuropsy.viewa.ui.theme import androidx.compose.ui.graphics.Color -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) +// Primary – deep ocean blue +val Blue10 = Color(0xFF001F33) +val Blue20 = Color(0xFF003D66) +val Blue40 = Color(0xFF0077B6) // main primary (matches toolbar) +val Blue80 = Color(0xFF90CAF9) // primary in dark mode +val Blue90 = Color(0xFFBBDEFB) // primary container -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +// Secondary – bright teal +val Teal40 = Color(0xFF00838F) +val Teal80 = Color(0xFF80DEEA) +val Teal90 = Color(0xFFB2EBF2) + +// Tertiary – warm amber accent +val Amber40 = Color(0xFFB45309) +val Amber80 = Color(0xFFFFCC80) + +// Neutrals +val Neutral10 = Color(0xFF1A1C1E) +val Neutral90 = Color(0xFFE1E2E8) +val Neutral99 = Color(0xFFFAFCFF) diff --git a/app/src/main/java/de/uol/neuropsy/viewa/ui/theme/Theme.kt b/app/src/main/java/de/uol/neuropsy/viewa/ui/theme/Theme.kt index 5a8487f..18abd40 100644 --- a/app/src/main/java/de/uol/neuropsy/viewa/ui/theme/Theme.kt +++ b/app/src/main/java/de/uol/neuropsy/viewa/ui/theme/Theme.kt @@ -1,64 +1,70 @@ package de.uol.neuropsy.viewa.ui.theme import android.app.Activity -import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 -) - private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 + primary = Blue40, + onPrimary = Color.White, + primaryContainer = Blue90, + onPrimaryContainer = Blue10, + secondary = Teal40, + onSecondary = Color.White, + secondaryContainer = Teal90, + onSecondaryContainer = Blue10, + tertiary = Amber40, + onTertiary = Color.White, + background = Neutral99, + onBackground = Neutral10, + surface = Color.White, + onSurface = Neutral10, + surfaceVariant = Blue90, + onSurfaceVariant = Blue10, +) - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ +private val DarkColorScheme = darkColorScheme( + primary = Blue80, + onPrimary = Blue20, + primaryContainer = Blue40, + onPrimaryContainer = Blue90, + secondary = Teal80, + onSecondary = Blue10, + secondaryContainer = Teal40, + onSecondaryContainer = Teal90, + tertiary = Amber80, + background = Neutral10, + onBackground = Neutral90, + surface = Color(0xFF1C2B36), + onSurface = Neutral90, ) @Composable fun ViewaTheme( darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, + // Dynamic colour disabled: device wallpaper colours are unpredictable and + // can produce very dark or low-contrast surfaces that break chart labels. + dynamicColor: Boolean = false, content: @Composable () -> Unit ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme - darkTheme -> DarkColorScheme - else -> LightColorScheme - } val view = LocalView.current if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window window.statusBarColor = colorScheme.primary.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + // Use dark icons when the status-bar background is light, white icons when it's dark + val useDarkIcons = android.graphics.Color.luminance(colorScheme.primary.toArgb()) > 0.5f + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = useDarkIcons } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 427fa84..ef0eddf 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -3,24 +3,32 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:orientation="vertical" - android:fitsSystemWindows="true"> + android:orientation="vertical"> - + + android:fitsSystemWindows="true"> - + + + + + android:layout_height="match_parent" + android:fitsSystemWindows="true" /> \ No newline at end of file diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..5ead2e0 --- /dev/null +++ b/app/src/main/res/values-night/styles.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 23e5288..1995236 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,10 +1,10 @@ - +