v0.2.1 — UI polish on the map widget + Settings
- Center the OVERWATCH header (was left-aligned with the gear pushed
by SpaceBetween — now Box-aligned so the title sits dead-center
regardless of icon width).
- Settings + drill-down: rename the MIC source label to "COMMERCIAL"
("Nest, Ring, Echo devices") for clarity. Internal enum stays MIC.
- Drop the GREEN/YELLOW/ORANGE/RED text inside the threat circle and
bump the tier scrim alpha (0.40-0.65 vs. 0.18-0.50) so the color
reads at a glance over OSM tiles.
- Force the per-event "Open in Maps" pin to use Google Maps instead
of whichever geo: handler the user has set as default (Waze, etc.
could intercept). setPackage("com.google.android.apps.maps") + a
matching <queries> entry in the manifest so it works on Android
11+; web fallback if Maps isn't installed.
- Add a blue user-position dot at the center of the map circle, drawn
on top of any ALPR pins.
- Auto-fit the visible map radius to max(deflockProximityM,
citizenProximityM) via zoomToBoundingBox so the circle's edge
literally represents the alert distance the user has chosen.
This commit is contained in:
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "org.soulstone.overwatch"
|
applicationId = "org.soulstone.overwatch"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 9
|
versionCode = 10
|
||||||
versionName = "0.2.0"
|
versionName = "0.2.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -40,6 +40,13 @@
|
|||||||
|
|
||||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||||
|
|
||||||
|
<!-- Allow Intent.setPackage("com.google.android.apps.maps") on Android 11+
|
||||||
|
(package visibility) so we can force "Open in Maps" pins to land in
|
||||||
|
Google Maps regardless of the user's default geo: handler. -->
|
||||||
|
<queries>
|
||||||
|
<package android:name="com.google.android.apps.maps" />
|
||||||
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
|||||||
@@ -92,6 +92,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
val maxScore by DetectionService.store.maxScore.collectAsState()
|
val maxScore by DetectionService.store.maxScore.collectAsState()
|
||||||
val mapPoints by DetectionService.mapPoints.collectAsState()
|
val mapPoints by DetectionService.mapPoints.collectAsState()
|
||||||
val userLocation by DetectionService.location.collectAsState()
|
val userLocation by DetectionService.location.collectAsState()
|
||||||
|
// Visible map radius = max of the two proximity sliders
|
||||||
|
// so the user sees the full area where a detection
|
||||||
|
// can fire. Using the raw setting values regardless of
|
||||||
|
// enabled-state keeps the visualization stable when a
|
||||||
|
// source is briefly toggled.
|
||||||
|
val deflockProx by settings.deflockProximityM.collectAsState()
|
||||||
|
val citizenProx by settings.citizenProximityM.collectAsState()
|
||||||
|
val mapRadiusM = maxOf(deflockProx, citizenProx).toFloat()
|
||||||
val granted by permissionsGranted
|
val granted by permissionsGranted
|
||||||
val denied by permanentlyDenied
|
val denied by permanentlyDenied
|
||||||
|
|
||||||
@@ -108,6 +116,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
events = events,
|
events = events,
|
||||||
mapPoints = mapPoints,
|
mapPoints = mapPoints,
|
||||||
userLocation = userLocation,
|
userLocation = userLocation,
|
||||||
|
mapRadiusMeters = mapRadiusM,
|
||||||
canStart = true,
|
canStart = true,
|
||||||
permissionMessage = message,
|
permissionMessage = message,
|
||||||
showOpenAppSettings = denied && !granted,
|
showOpenAppSettings = denied && !granted,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -53,8 +54,9 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import kotlinx.coroutines.launch
|
import kotlin.math.cos
|
||||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||||
|
import org.osmdroid.util.BoundingBox
|
||||||
import org.osmdroid.util.GeoPoint
|
import org.osmdroid.util.GeoPoint
|
||||||
import org.osmdroid.views.MapView
|
import org.osmdroid.views.MapView
|
||||||
import org.osmdroid.views.overlay.Marker
|
import org.osmdroid.views.overlay.Marker
|
||||||
@@ -74,6 +76,10 @@ fun MainScreen(
|
|||||||
events: List<DetectionEvent>,
|
events: List<DetectionEvent>,
|
||||||
mapPoints: List<DeflockClient.AlprPoint>,
|
mapPoints: List<DeflockClient.AlprPoint>,
|
||||||
userLocation: Location?,
|
userLocation: Location?,
|
||||||
|
/** Visible radius of the map circle, in meters. Driven by the larger of
|
||||||
|
* the DeFlock and Citizen proximity sliders so the user sees the full
|
||||||
|
* area where a detection could fire. */
|
||||||
|
mapRadiusMeters: Float,
|
||||||
onStartStop: () -> Unit,
|
onStartStop: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
canStart: Boolean,
|
canStart: Boolean,
|
||||||
@@ -90,12 +96,12 @@ fun MainScreen(
|
|||||||
.background(MaterialTheme.colorScheme.background)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
.padding(horizontal = 24.dp)
|
.padding(horizontal = 24.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
// Box (rather than Row + SpaceBetween) so the title is truly centered
|
||||||
|
// regardless of the gear icon's width.
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 8.dp),
|
.padding(top = 8.dp)
|
||||||
verticalAlignment = Alignment.Top,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "OVERWATCH",
|
text = "OVERWATCH",
|
||||||
@@ -103,9 +109,13 @@ fun MainScreen(
|
|||||||
fontSize = 26.sp,
|
fontSize = 26.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
letterSpacing = 4.sp
|
letterSpacing = 4.sp,
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
)
|
)
|
||||||
IconButton(onClick = onOpenSettings) {
|
IconButton(
|
||||||
|
onClick = onOpenSettings,
|
||||||
|
modifier = Modifier.align(Alignment.CenterEnd)
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Settings,
|
Icons.Filled.Settings,
|
||||||
contentDescription = "Settings",
|
contentDescription = "Settings",
|
||||||
@@ -125,6 +135,7 @@ fun MainScreen(
|
|||||||
animating = running,
|
animating = running,
|
||||||
userLocation = userLocation,
|
userLocation = userLocation,
|
||||||
mapPoints = mapPoints,
|
mapPoints = mapPoints,
|
||||||
|
mapRadiusMeters = mapRadiusMeters,
|
||||||
onTap = { showSheet = true }
|
onTap = { showSheet = true }
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -213,12 +224,33 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Builds a small white-bordered blue dot used as the user-position Marker. */
|
||||||
|
private fun userDotDrawable(
|
||||||
|
resources: android.content.res.Resources
|
||||||
|
): android.graphics.drawable.BitmapDrawable {
|
||||||
|
val sizePx = 36
|
||||||
|
val bitmap = android.graphics.Bitmap.createBitmap(
|
||||||
|
sizePx, sizePx, android.graphics.Bitmap.Config.ARGB_8888
|
||||||
|
)
|
||||||
|
val canvas = android.graphics.Canvas(bitmap)
|
||||||
|
val outline = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = 0xFFFFFFFF.toInt()
|
||||||
|
}
|
||||||
|
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 2f, outline)
|
||||||
|
val core = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = 0xFF2196F3.toInt()
|
||||||
|
}
|
||||||
|
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 6f, core)
|
||||||
|
return android.graphics.drawable.BitmapDrawable(resources, bitmap)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ThreatMapCircle(
|
private fun ThreatMapCircle(
|
||||||
level: ThreatLevel,
|
level: ThreatLevel,
|
||||||
animating: Boolean,
|
animating: Boolean,
|
||||||
userLocation: Location?,
|
userLocation: Location?,
|
||||||
mapPoints: List<DeflockClient.AlprPoint>,
|
mapPoints: List<DeflockClient.AlprPoint>,
|
||||||
|
mapRadiusMeters: Float,
|
||||||
onTap: () -> Unit
|
onTap: () -> Unit
|
||||||
) {
|
) {
|
||||||
val idleColor = MaterialTheme.colorScheme.surfaceVariant
|
val idleColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
@@ -278,69 +310,81 @@ private fun ThreatMapCircle(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// OSM map snapshot, centered on the user, with red ALPR pins.
|
// OSM map snapshot, centered on the user, with red ALPR pins and
|
||||||
// Non-interactive — touches are captured by the click overlay above
|
// a blue user-position dot. Non-interactive — touches are captured
|
||||||
// so a tap opens the source-details bottom sheet (matching the old
|
// by the click overlay above, so a tap opens the source-details
|
||||||
// circle's UX). Pan/zoom controls stay off.
|
// bottom sheet. Pan/zoom controls stay off.
|
||||||
// Capture into a local non-null val so the AndroidView update
|
// Capture into a local non-null val so the AndroidView update
|
||||||
// lambda doesn't run afoul of smart-cast-into-closure rules.
|
// lambda doesn't run afoul of smart-cast-into-closure rules.
|
||||||
val fix: Location = userLocation
|
val fix: Location = userLocation
|
||||||
|
val ctx = LocalContext.current
|
||||||
|
// Build the user-dot drawable once per Composition rather than
|
||||||
|
// every recomposition — bitmap allocation isn't free.
|
||||||
|
val userDot = remember(ctx) { userDotDrawable(ctx.resources) }
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
factory = { ctx ->
|
factory = { c ->
|
||||||
MapView(ctx).apply {
|
MapView(c).apply {
|
||||||
setTileSource(TileSourceFactory.MAPNIK)
|
setTileSource(TileSourceFactory.MAPNIK)
|
||||||
setMultiTouchControls(false)
|
setMultiTouchControls(false)
|
||||||
setBuiltInZoomControls(false)
|
setBuiltInZoomControls(false)
|
||||||
isClickable = false
|
isClickable = false
|
||||||
isFocusable = false
|
isFocusable = false
|
||||||
controller.setZoom(17.0)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = { map ->
|
update = { map ->
|
||||||
map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude))
|
map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude))
|
||||||
map.overlays.clear()
|
map.overlays.clear()
|
||||||
|
|
||||||
|
// ALPR pins first, user dot last so the dot draws on top.
|
||||||
for (p in mapPoints) {
|
for (p in mapPoints) {
|
||||||
val m = Marker(map).apply {
|
map.overlays.add(
|
||||||
position = GeoPoint(p.lat, p.lon)
|
Marker(map).apply {
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
position = GeoPoint(p.lat, p.lon)
|
||||||
title = p.operator ?: p.manufacturer ?: "ALPR"
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||||
// Disable osmdroid's per-marker info popup since
|
title = p.operator ?: p.manufacturer ?: "ALPR"
|
||||||
// the map isn't interactive — the bottom sheet is
|
setInfoWindow(null)
|
||||||
// the canonical "details" surface.
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
map.overlays.add(
|
||||||
|
Marker(map).apply {
|
||||||
|
position = GeoPoint(fix.latitude, fix.longitude)
|
||||||
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
|
icon = userDot
|
||||||
setInfoWindow(null)
|
setInfoWindow(null)
|
||||||
}
|
}
|
||||||
map.overlays.add(m)
|
)
|
||||||
}
|
|
||||||
|
// Fit the visible radius to the larger of the two proximity
|
||||||
|
// settings. Defer to map.post so the call lands after layout
|
||||||
|
// — zoomToBoundingBox needs measured dimensions to compute
|
||||||
|
// the right zoom level. Latitude-aware longitude scaling so
|
||||||
|
// the bbox stays roughly square in real meters at any lat.
|
||||||
|
val r = mapRadiusMeters.toDouble().coerceAtLeast(50.0)
|
||||||
|
val latDegPerMeter = 1.0 / 111_000.0
|
||||||
|
val lonDegPerMeter = 1.0 /
|
||||||
|
(111_000.0 * cos(Math.toRadians(fix.latitude)).coerceAtLeast(0.01))
|
||||||
|
val bbox = BoundingBox(
|
||||||
|
fix.latitude + r * latDegPerMeter,
|
||||||
|
fix.longitude + r * lonDegPerMeter,
|
||||||
|
fix.latitude - r * latDegPerMeter,
|
||||||
|
fix.longitude - r * lonDegPerMeter
|
||||||
|
)
|
||||||
|
map.post { map.zoomToBoundingBox(bbox, false, 0) }
|
||||||
map.invalidate()
|
map.invalidate()
|
||||||
},
|
},
|
||||||
onRelease = { map -> map.onDetach() }
|
onRelease = { map -> map.onDetach() }
|
||||||
)
|
)
|
||||||
// Threat-tier scrim — pulses while scanning, dims tiles to keep
|
// Threat-tier scrim — pulses while scanning. Heavier alpha than
|
||||||
// the dark theme aesthetic and signals tier without text.
|
// the first cut so the tier color reads at a glance over OSM
|
||||||
val scrimAlpha = (0.35f * pulse).coerceIn(0.18f, 0.5f)
|
// tiles, which are themselves cream/light by default.
|
||||||
|
val scrimAlpha = (0.55f * pulse).coerceIn(0.40f, 0.65f)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(activeColor.copy(alpha = scrimAlpha))
|
.background(activeColor.copy(alpha = scrimAlpha))
|
||||||
)
|
)
|
||||||
// Tier label, top-center. Smaller than the old text so the map
|
|
||||||
// remains readable underneath.
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(top = 14.dp),
|
|
||||||
contentAlignment = Alignment.TopCenter
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = level.name,
|
|
||||||
color = Color.White,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Black,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
letterSpacing = 2.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Click capture sits on top so taps reach onTap regardless of which
|
// Click capture sits on top so taps reach onTap regardless of which
|
||||||
// visual layer was painted underneath.
|
// visual layer was painted underneath.
|
||||||
@@ -395,6 +439,14 @@ private fun SourcesPanel(events: List<DetectionEvent>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** User-facing label for a detection source. The internal enum stays MIC
|
||||||
|
* (mic-bearing devices is the technical concept) while the UI shows the
|
||||||
|
* friendlier "COMMERCIAL" — Nest/Ring/Echo are commercial smart-home gear. */
|
||||||
|
private fun DetectionSource.displayLabel(): String = when (this) {
|
||||||
|
DetectionSource.MIC -> "COMMERCIAL"
|
||||||
|
else -> name
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
|
private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
|
||||||
val health by SourceHealth.flowFor(source).collectAsState()
|
val health by SourceHealth.flowFor(source).collectAsState()
|
||||||
@@ -414,7 +466,7 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = source.name,
|
text = source.displayLabel(),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
@@ -483,23 +535,22 @@ private fun EventRow(e: DetectionEvent) {
|
|||||||
if (e.hasGeo) {
|
if (e.hasGeo) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
// resolveActivity returns null on Android 11+ without a matching
|
// Force the pin to open in Google Maps rather than whichever
|
||||||
// <queries> entry even when Google Maps is installed. Skip the
|
// app holds the user's default geo: handler — Waze, etc. can
|
||||||
// pre-check and let startActivity handle it; catch the rare
|
// intercept geo: intents and we don't want that here. Falls
|
||||||
// "no app at all" case instead of silently no-op'ing.
|
// back to a generic browser intent if Maps isn't installed.
|
||||||
val uri = Uri.parse("geo:${e.lat},${e.lon}?q=${e.lat},${e.lon}(${Uri.encode(e.label)})")
|
val mapsUri = Uri.parse(
|
||||||
val intent = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
"https://www.google.com/maps/search/?api=1&query=${e.lat},${e.lon}"
|
||||||
|
)
|
||||||
|
val mapsIntent = Intent(Intent.ACTION_VIEW, mapsUri)
|
||||||
|
.setPackage("com.google.android.apps.maps")
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
try {
|
try {
|
||||||
ctx.startActivity(intent)
|
ctx.startActivity(mapsIntent)
|
||||||
} catch (_: android.content.ActivityNotFoundException) {
|
} catch (_: android.content.ActivityNotFoundException) {
|
||||||
// Fall back to a Google Maps URL — works even on devices
|
val fallback = Intent(Intent.ACTION_VIEW, mapsUri)
|
||||||
// without a registered geo: handler.
|
|
||||||
val webUri = Uri.parse(
|
|
||||||
"https://www.google.com/maps/search/?api=1&query=${e.lat},${e.lon}"
|
|
||||||
)
|
|
||||||
val webIntent = Intent(Intent.ACTION_VIEW, webUri)
|
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
try { ctx.startActivity(webIntent) } catch (_: android.content.ActivityNotFoundException) {}
|
try { ctx.startActivity(fallback) } catch (_: android.content.ActivityNotFoundException) {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(28.dp)
|
modifier = Modifier.size(28.dp)
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ fun SettingsScreen(
|
|||||||
SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(it) }
|
SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(it) }
|
||||||
SourceToggle("DEFLOCK • ALPR map (Overpass)", deflock) { settings.setDeflockEnabled(it) }
|
SourceToggle("DEFLOCK • ALPR map (Overpass)", deflock) { settings.setDeflockEnabled(it) }
|
||||||
SourceToggle("CITIZEN • Real-time incident feed", citizen) { settings.setCitizenEnabled(it) }
|
SourceToggle("CITIZEN • Real-time incident feed", citizen) { settings.setCitizenEnabled(it) }
|
||||||
SourceToggle("MIC • Smart speakers / cams (Echo, Ring, Nest)", mic) { settings.setMicEnabled(it) }
|
SourceToggle("COMMERCIAL • Nest, Ring, Echo devices", mic) { settings.setMicEnabled(it) }
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
Button(
|
Button(
|
||||||
|
|||||||
Reference in New Issue
Block a user