feat(android): add status pill overlay
This commit is contained in:
@@ -55,6 +55,7 @@ dependencies {
|
|||||||
implementation("androidx.compose.ui:ui")
|
implementation("androidx.compose.ui:ui")
|
||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
implementation("androidx.navigation:navigation-compose:2.9.6")
|
implementation("androidx.navigation:navigation-compose:2.9.6")
|
||||||
|
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
|||||||
@@ -1,25 +1,31 @@
|
|||||||
package com.steipete.clawdis.node.ui
|
package com.steipete.clawdis.node.ui
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.only
|
import androidx.compose.foundation.layout.only
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ChatBubble
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
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.remember
|
||||||
@@ -31,6 +37,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.compose.ui.window.Popup
|
import androidx.compose.ui.window.Popup
|
||||||
import androidx.compose.ui.window.PopupProperties
|
import androidx.compose.ui.window.PopupProperties
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import com.steipete.clawdis.node.MainViewModel
|
import com.steipete.clawdis.node.MainViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -38,25 +45,55 @@ import com.steipete.clawdis.node.MainViewModel
|
|||||||
fun RootScreen(viewModel: MainViewModel) {
|
fun RootScreen(viewModel: MainViewModel) {
|
||||||
var sheet by remember { mutableStateOf<Sheet?>(null) }
|
var sheet by remember { mutableStateOf<Sheet?>(null) }
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
val safeButtonInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||||
|
val context = LocalContext.current
|
||||||
|
val serverName by viewModel.serverName.collectAsState()
|
||||||
|
val statusText by viewModel.statusText.collectAsState()
|
||||||
|
|
||||||
|
val bridgeState =
|
||||||
|
remember(serverName, statusText) {
|
||||||
|
when {
|
||||||
|
serverName != null -> BridgeState.Connected
|
||||||
|
statusText.contains("connecting", ignoreCase = true) ||
|
||||||
|
statusText.contains("reconnecting", ignoreCase = true) -> BridgeState.Connecting
|
||||||
|
statusText.contains("error", ignoreCase = true) -> BridgeState.Error
|
||||||
|
else -> BridgeState.Disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val voiceEnabled =
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches.
|
// Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches.
|
||||||
Popup(alignment = Alignment.TopCenter, properties = PopupProperties(focusable = false)) {
|
Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) {
|
||||||
Row(
|
StatusPill(
|
||||||
modifier =
|
bridge = bridgeState,
|
||||||
Modifier
|
voiceEnabled = voiceEnabled,
|
||||||
.fillMaxWidth()
|
onClick = { sheet = Sheet.Settings },
|
||||||
.windowInsetsPadding(safeButtonInsets)
|
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp),
|
||||||
.padding(12.dp),
|
)
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
}
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
|
Popup(alignment = Alignment.TopEnd, properties = PopupProperties(focusable = false)) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(end = 12.dp, top = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
horizontalAlignment = Alignment.End,
|
||||||
) {
|
) {
|
||||||
Button(onClick = { sheet = Sheet.Chat }) { Text("Chat") }
|
OverlayIconButton(
|
||||||
Button(onClick = { sheet = Sheet.Settings }) { Text("Settings") }
|
onClick = { sheet = Sheet.Chat },
|
||||||
|
icon = { Icon(Icons.Default.ChatBubble, contentDescription = "Chat") },
|
||||||
|
)
|
||||||
|
|
||||||
|
OverlayIconButton(
|
||||||
|
onClick = { sheet = Sheet.Settings },
|
||||||
|
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +116,19 @@ private enum class Sheet {
|
|||||||
Settings,
|
Settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OverlayIconButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
icon: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.size(44.dp),
|
||||||
|
) {
|
||||||
|
icon()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
@Composable
|
@Composable
|
||||||
private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.steipete.clawdis.node.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
|
import androidx.compose.material.icons.filled.MicOff
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.VerticalDivider
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StatusPill(
|
||||||
|
bridge: BridgeState,
|
||||||
|
voiceEnabled: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.86f),
|
||||||
|
shadowElevation = 10.dp,
|
||||||
|
border = BorderStroke(0.5.dp, Color.White.copy(alpha = 0.18f)),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.size(9.dp),
|
||||||
|
shape = CircleShape,
|
||||||
|
color = bridge.color,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = bridge.title,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalDivider(
|
||||||
|
modifier = Modifier.height(14.dp).alpha(0.35f),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff,
|
||||||
|
contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled",
|
||||||
|
tint =
|
||||||
|
if (voiceEnabled) {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class BridgeState(val title: String, val color: Color) {
|
||||||
|
Connected("Connected", Color(0xFF2ECC71)),
|
||||||
|
Connecting("Connecting…", Color(0xFFF1C40F)),
|
||||||
|
Error("Error", Color(0xFFE74C3C)),
|
||||||
|
Disconnected("Offline", Color(0xFF9E9E9E)),
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user