Initial commit

This commit is contained in:
defiQUG
2025-12-26 10:48:33 -08:00
commit 97f75e144f
270 changed files with 35886 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt")
id("dagger.hilt.android.plugin")
}
android {
namespace = "com.smoa.modules.communications"
compileSdk = AppConfig.compileSdk
defaultConfig {
minSdk = AppConfig.minSdk
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}
}
dependencies {
implementation(project(":core:common"))
implementation(project(":core:auth"))
implementation(project(":core:security"))
implementation(platform(Dependencies.composeBom))
implementation(Dependencies.composeUi)
implementation(Dependencies.composeUiGraphics)
implementation(Dependencies.composeMaterial3)
implementation(Dependencies.androidxCoreKtx)
implementation(Dependencies.androidxLifecycleRuntimeKtx)
implementation(Dependencies.hiltAndroid)
kapt(Dependencies.hiltAndroidCompiler)
implementation(Dependencies.retrofit)
implementation(Dependencies.okHttp)
// WebRTC - TODO: Configure WebRTC dependency
// WebRTC library needs to be built from source or obtained separately
// Uncomment when WebRTC is available:
// implementation(Dependencies.webrtc)
}

View File

@@ -0,0 +1,27 @@
package com.smoa.modules.communications
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.smoa.core.auth.RBACFramework
import com.smoa.modules.communications.domain.CommunicationsService
import com.smoa.modules.communications.ui.CommunicationsScreen
/**
* Communications module - Mission voice communications using channelized, unit-based access.
*/
@Composable
fun CommunicationsModule(
communicationsService: CommunicationsService,
userRole: RBACFramework.Role,
userUnit: String?,
modifier: Modifier = Modifier
) {
CommunicationsScreen(
communicationsService = communicationsService,
userRole = userRole,
userUnit = userUnit,
modifier = modifier.fillMaxSize()
)
}

View File

@@ -0,0 +1,57 @@
package com.smoa.modules.communications.di
import android.content.Context
import com.smoa.core.security.AuditLogger
import com.smoa.modules.communications.domain.ChannelManager
import com.smoa.modules.communications.domain.CommunicationsService
import com.smoa.modules.communications.domain.VoiceTransport
import com.smoa.modules.communications.domain.WebRTCManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object CommunicationsModule {
@Provides
@Singleton
fun provideWebRTCManager(
@ApplicationContext context: Context
): WebRTCManager {
return WebRTCManager(context)
}
@Provides
@Singleton
fun provideChannelManager(): ChannelManager {
return ChannelManager()
}
@Provides
@Singleton
fun provideVoiceTransport(
webRTCManager: WebRTCManager
): VoiceTransport {
return VoiceTransport(webRTCManager)
}
@Provides
@Singleton
fun provideCommunicationsService(
channelManager: ChannelManager,
voiceTransport: VoiceTransport,
auditLogger: AuditLogger,
rbacFramework: com.smoa.core.auth.RBACFramework
): CommunicationsService {
return CommunicationsService(
channelManager,
voiceTransport,
auditLogger,
rbacFramework
)
}
}

View File

@@ -0,0 +1,103 @@
package com.smoa.modules.communications.domain
import com.smoa.core.auth.RBACFramework
import javax.inject.Inject
import javax.inject.Singleton
/**
* Channel manager for communication channels.
* Manages channel access based on role and unit authorization.
*/
@Singleton
class ChannelManager @Inject constructor() {
private val channels = mutableMapOf<String, Channel>()
init {
// Initialize default channels (can be loaded from policy/config)
// Example channels would be added here
}
/**
* Get channel by ID.
*/
fun getChannel(channelId: String): Channel? {
return channels[channelId]
}
/**
* Get available channels for user based on role and unit.
*/
fun getAvailableChannels(
userRole: RBACFramework.Role,
userUnit: String?
): List<Channel> {
return channels.values.filter { channel ->
hasAccess(channel, userRole, userUnit)
}
}
/**
* Check if user has access to channel.
*/
fun hasAccess(
channel: Channel,
userRole: RBACFramework.Role,
userUnit: String?
): Boolean {
// Admins can access all channels
if (userRole == RBACFramework.Role.ADMIN) {
return true
}
// Check unit authorization
if (channel.unitRestricted && userUnit != null && channel.allowedUnits.contains(userUnit)) {
return true
}
// Check role authorization
if (channel.allowedRoles.contains(userRole)) {
return true
}
return false
}
/**
* Register a channel.
*/
fun registerChannel(channel: Channel) {
channels[channel.id] = channel
}
/**
* Remove a channel.
*/
fun removeChannel(channelId: String) {
channels.remove(channelId)
}
}
/**
* Communication channel.
*/
data class Channel(
val id: String,
val name: String,
val description: String?,
val unitRestricted: Boolean,
val allowedUnits: Set<String>,
val allowedRoles: Set<RBACFramework.Role>,
val priority: ChannelPriority = ChannelPriority.NORMAL,
val encrypted: Boolean = true
)
/**
* Channel priority levels.
*/
enum class ChannelPriority {
LOW,
NORMAL,
HIGH,
ALERT
}

View File

@@ -0,0 +1,154 @@
package com.smoa.modules.communications.domain
import com.smoa.core.auth.RBACFramework
import com.smoa.core.common.Result
import com.smoa.core.security.AuditLogger
import com.smoa.core.security.AuditEventType
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton
/**
* Communications service for radio-style voice communications.
* Supports multi-channel push-to-talk (PTT) with encrypted voice transport.
*/
@Singleton
class CommunicationsService @Inject constructor(
private val channelManager: ChannelManager,
private val voiceTransport: VoiceTransport,
private val auditLogger: AuditLogger,
private val rbacFramework: RBACFramework
) {
private val _currentChannel = MutableStateFlow<Channel?>(null)
val currentChannel: StateFlow<Channel?> = _currentChannel.asStateFlow()
private val _isPTTActive = MutableStateFlow(false)
val isPTTActive: StateFlow<Boolean> = _isPTTActive.asStateFlow()
/**
* Join a communication channel.
*/
suspend fun joinChannel(
channelId: String,
userRole: RBACFramework.Role,
userUnit: String?
): Result<Channel> {
val channel = channelManager.getChannel(channelId) ?: return Result.Error(
IllegalArgumentException("Channel not found: $channelId")
)
// Check authorization
if (!channelManager.hasAccess(channel, userRole, userUnit)) {
return Result.Error(SecurityException("Access denied to channel: $channelId"))
}
// Leave current channel if any
_currentChannel.value?.let { leaveChannel(it.id) }
// Join new channel
val joinResult = voiceTransport.joinChannel(channelId)
return when (joinResult) {
is Result.Success -> {
_currentChannel.value = channel
auditLogger.logEvent(
AuditEventType.CHANNEL_JOINED,
mapOf(
"channelId" to channelId,
"channelName" to channel.name,
"timestamp" to Date().toString()
)
)
Result.Success(channel)
}
is Result.Error -> joinResult
is Result.Loading -> Result.Error(Exception("Unexpected loading state"))
}
}
/**
* Leave current channel.
*/
suspend fun leaveChannel(channelId: String): Result<Unit> {
val result = voiceTransport.leaveChannel(channelId)
when (result) {
is Result.Success -> {
_currentChannel.value = null
auditLogger.logEvent(
AuditEventType.CHANNEL_LEFT,
mapOf(
"channelId" to channelId,
"timestamp" to Date().toString()
)
)
}
else -> {}
}
return result
}
/**
* Start push-to-talk (PTT).
*/
suspend fun startPTT(): Result<Unit> {
val channel = _currentChannel.value ?: return Result.Error(
IllegalStateException("Not connected to any channel")
)
val result = voiceTransport.startTransmission(channel.id)
when (result) {
is Result.Success -> {
_isPTTActive.value = true
auditLogger.logEvent(
AuditEventType.PTT_STARTED,
mapOf(
"channelId" to channel.id,
"timestamp" to Date().toString()
)
)
}
else -> {}
}
return result
}
/**
* Stop push-to-talk (PTT).
*/
suspend fun stopPTT(): Result<Unit> {
val channel = _currentChannel.value ?: return Result.Error(
IllegalStateException("Not connected to any channel")
)
val result = voiceTransport.stopTransmission(channel.id)
when (result) {
is Result.Success -> {
_isPTTActive.value = false
auditLogger.logEvent(
AuditEventType.PTT_STOPPED,
mapOf(
"channelId" to channel.id,
"timestamp" to Date().toString()
)
)
}
else -> {}
}
return result
}
/**
* Get available channels for user.
*/
suspend fun getAvailableChannels(
userRole: RBACFramework.Role,
userUnit: String?
): List<Channel> {
return channelManager.getAvailableChannels(userRole, userUnit)
}
}

View File

@@ -0,0 +1,140 @@
package com.smoa.modules.communications.domain
import com.smoa.core.common.Result
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Voice transport for encrypted voice communication.
* Uses WebRTC for peer-to-peer encrypted voice transmission.
*/
@Singleton
class VoiceTransport @Inject constructor(
private val webRTCManager: WebRTCManager
) {
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private var currentChannelId: String? = null
private var isTransmitting = false
private var peerConnection: WebRTCPeerConnection? = null
/**
* Join a communication channel.
*/
suspend fun joinChannel(channelId: String): Result<Unit> {
return try {
_connectionState.value = ConnectionState.Connecting(channelId)
// Initialize WebRTC peer connection (audio only for voice)
val connectionResult = webRTCManager.initializePeerConnection(channelId, isAudioOnly = true)
when (connectionResult) {
is Result.Success -> {
peerConnection = connectionResult.data
currentChannelId = channelId
_connectionState.value = ConnectionState.Connected(channelId)
Result.Success(Unit)
}
is Result.Error -> {
_connectionState.value = ConnectionState.Error(connectionResult.exception.message ?: "Failed to connect")
Result.Error(connectionResult.exception)
}
is Result.Loading -> {
_connectionState.value = ConnectionState.Error("Unexpected loading state")
Result.Error(Exception("Unexpected loading state"))
}
}
} catch (e: Exception) {
_connectionState.value = ConnectionState.Error(e.message ?: "Unknown error")
Result.Error(e)
}
}
/**
* Leave current channel.
*/
suspend fun leaveChannel(channelId: String): Result<Unit> {
return try {
if (isTransmitting) {
stopTransmission(channelId)
}
// Close WebRTC peer connection
peerConnection?.let { connection ->
webRTCManager.closePeerConnection(connection)
}
peerConnection = null
currentChannelId = null
_connectionState.value = ConnectionState.Disconnected
Result.Success(Unit)
} catch (e: Exception) {
Result.Error(e)
}
}
/**
* Start voice transmission (PTT).
*/
suspend fun startTransmission(channelId: String): Result<Unit> {
return try {
if (currentChannelId != channelId) {
return Result.Error(IllegalStateException("Not connected to channel: $channelId"))
}
val connection = peerConnection ?: return Result.Error(
IllegalStateException("No active peer connection")
)
// Start audio transmission via WebRTC
val result = webRTCManager.startAudioTransmission(connection)
when (result) {
is Result.Success -> {
isTransmitting = true
}
else -> {}
}
result
} catch (e: Exception) {
Result.Error(e)
}
}
/**
* Stop voice transmission (PTT release).
*/
suspend fun stopTransmission(channelId: String): Result<Unit> {
return try {
val connection = peerConnection ?: return Result.Error(
IllegalStateException("No active peer connection")
)
// Stop audio transmission via WebRTC
val result = webRTCManager.stopAudioTransmission(connection)
when (result) {
is Result.Success -> {
isTransmitting = false
}
else -> {}
}
result
} catch (e: Exception) {
Result.Error(e)
}
}
}
/**
* Connection state.
*/
sealed class ConnectionState {
object Disconnected : ConnectionState()
data class Connecting(val channelId: String) : ConnectionState()
data class Connected(val channelId: String) : ConnectionState()
data class Error(val message: String) : ConnectionState()
}

View File

@@ -0,0 +1,46 @@
package com.smoa.modules.communications.domain
/**
* WebRTC configuration for STUN/TURN servers and signaling.
*/
data class WebRTCConfig(
val stunServers: List<StunServer>,
val turnServers: List<TurnServer>,
val signalingServerUrl: String,
val iceCandidatePoolSize: Int = 10
) {
companion object {
/**
* Default configuration with public STUN servers.
* In production, use organization-specific STUN/TURN servers.
*/
fun default(): WebRTCConfig {
return WebRTCConfig(
stunServers = listOf(
StunServer("stun:stun.l.google.com:19302"),
StunServer("stun:stun1.l.google.com:19302")
),
turnServers = emptyList(), // TURN servers should be configured per deployment
signalingServerUrl = "", // Should be configured per deployment
iceCandidatePoolSize = 10
)
}
}
}
/**
* STUN server configuration.
*/
data class StunServer(
val url: String
)
/**
* TURN server configuration.
*/
data class TurnServer(
val url: String,
val username: String? = null,
val credential: String? = null
)

View File

@@ -0,0 +1,238 @@
package com.smoa.modules.communications.domain
import android.content.Context
import com.smoa.core.common.Result
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* WebRTC Manager for voice and video communication.
* Provides WebRTC peer connection management for Communications and Meetings modules.
*/
@Singleton
class WebRTCManager @Inject constructor(
private val context: Context
) {
private val config = WebRTCConfig.default()
private val peerConnections = mutableMapOf<String, WebRTCPeerConnection>()
private val _connectionState = MutableStateFlow<WebRTCConnectionState>(WebRTCConnectionState.Disconnected)
val connectionState: StateFlow<WebRTCConnectionState> = _connectionState.asStateFlow()
/**
* Initialize WebRTC peer connection.
*/
suspend fun initializePeerConnection(
channelId: String,
isAudioOnly: Boolean = false
): Result<WebRTCPeerConnection> {
return try {
_connectionState.value = WebRTCConnectionState.Connecting(channelId)
// Create peer connection configuration
val rtcConfig = createRTCConfiguration()
// TODO: Initialize actual WebRTC PeerConnection when library is fully integrated
// This would:
// 1. Initialize PeerConnectionFactory with options
// 2. Create PeerConnection with rtcConfig
// 3. Set up audio/video tracks based on isAudioOnly
// 4. Configure ICE candidates
// 5. Set up signaling channel
val peerConnection = WebRTCPeerConnection(
channelId = channelId,
isAudioOnly = isAudioOnly,
config = rtcConfig
)
peerConnections[channelId] = peerConnection
_connectionState.value = WebRTCConnectionState.Connected(channelId)
Result.Success(peerConnection)
} catch (e: Exception) {
_connectionState.value = WebRTCConnectionState.Error(e.message ?: "Unknown error")
Result.Error(e)
}
}
/**
* Create RTC configuration with STUN/TURN servers.
*/
private fun createRTCConfiguration(): RTCConfiguration {
val iceServers = mutableListOf<IceServer>()
// Add STUN servers
config.stunServers.forEach { stunServer ->
iceServers.add(IceServer(stunServer.url))
}
// Add TURN servers
config.turnServers.forEach { turnServer ->
iceServers.add(
IceServer(
url = turnServer.url,
username = turnServer.username,
credential = turnServer.credential
)
)
}
return RTCConfiguration(
iceServers = iceServers,
iceCandidatePoolSize = config.iceCandidatePoolSize
)
}
/**
* Close peer connection.
*/
suspend fun closePeerConnection(peerConnection: WebRTCPeerConnection): Result<Unit> {
return try {
// Stop all tracks
if (peerConnection.isAudioActive) {
stopAudioTransmission(peerConnection)
}
if (peerConnection.isVideoActive) {
stopVideoTransmission(peerConnection)
}
// TODO: Close actual WebRTC PeerConnection when library is fully integrated
// This would:
// 1. Close peer connection
// 2. Release all tracks
// 3. Dispose of resources
peerConnections.remove(peerConnection.channelId)
_connectionState.value = WebRTCConnectionState.Disconnected
Result.Success(Unit)
} catch (e: Exception) {
Result.Error(e)
}
}
/**
* Start audio capture and transmission.
*/
suspend fun startAudioTransmission(peerConnection: WebRTCPeerConnection): Result<Unit> {
return try {
// TODO: Start audio capture when WebRTC library is fully integrated
// This would:
// 1. Create AudioSource with constraints
// 2. Create AudioTrack from source
// 3. Add track to peer connection's sender
// 4. Enable track
// 5. Start audio capture
peerConnection.isAudioActive = true
Result.Success(Unit)
} catch (e: Exception) {
Result.Error(e)
}
}
/**
* Stop audio transmission.
*/
suspend fun stopAudioTransmission(peerConnection: WebRTCPeerConnection): Result<Unit> {
return try {
// TODO: Stop audio capture when WebRTC library is fully integrated
// This would:
// 1. Disable audio track
// 2. Remove track from peer connection sender
// 3. Stop track
// 4. Release audio source
peerConnection.isAudioActive = false
Result.Success(Unit)
} catch (e: Exception) {
Result.Error(e)
}
}
/**
* Start video capture and transmission.
*/
suspend fun startVideoTransmission(peerConnection: WebRTCPeerConnection): Result<Unit> {
return try {
if (peerConnection.isAudioOnly) {
return Result.Error(IllegalStateException("Video not supported for audio-only connection"))
}
// TODO: Start video capture when WebRTC library is fully integrated
// This would:
// 1. Create VideoSource with camera constraints
// 2. Create VideoTrack from source
// 3. Add track to peer connection's sender
// 4. Enable track
// 5. Start camera capture
peerConnection.isVideoActive = true
Result.Success(Unit)
} catch (e: Exception) {
Result.Error(e)
}
}
/**
* Stop video transmission.
*/
suspend fun stopVideoTransmission(peerConnection: WebRTCPeerConnection): Result<Unit> {
return try {
// TODO: Stop video capture when WebRTC library is fully integrated
// This would:
// 1. Disable video track
// 2. Remove track from peer connection sender
// 3. Stop track
// 4. Release video source and camera
peerConnection.isVideoActive = false
Result.Success(Unit)
} catch (e: Exception) {
Result.Error(e)
}
}
}
/**
* WebRTC peer connection wrapper.
*/
data class WebRTCPeerConnection(
val channelId: String,
val isAudioOnly: Boolean = false,
val config: RTCConfiguration,
var isAudioActive: Boolean = false,
var isVideoActive: Boolean = false
// TODO: Add actual WebRTC PeerConnection instance when library is integrated
// private val peerConnection: PeerConnection
)
/**
* RTC configuration for peer connections.
*/
data class RTCConfiguration(
val iceServers: List<IceServer>,
val iceCandidatePoolSize: Int = 10
)
/**
* ICE server configuration.
*/
data class IceServer(
val url: String,
val username: String? = null,
val credential: String? = null
)
/**
* WebRTC connection state.
*/
sealed class WebRTCConnectionState {
object Disconnected : WebRTCConnectionState()
data class Connecting(val channelId: String) : WebRTCConnectionState()
data class Connected(val channelId: String) : WebRTCConnectionState()
data class Error(val message: String) : WebRTCConnectionState()
}

View File

@@ -0,0 +1,213 @@
package com.smoa.modules.communications.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.smoa.core.auth.RBACFramework
import com.smoa.modules.communications.domain.Channel
import com.smoa.modules.communications.domain.CommunicationsService
/**
* Communications screen with channel list and PTT controls.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CommunicationsScreen(
communicationsService: CommunicationsService,
userRole: RBACFramework.Role,
userUnit: String?,
modifier: Modifier = Modifier
) {
var channels by remember { mutableStateOf<List<Channel>>(emptyList()) }
var currentChannel by remember { mutableStateOf<Channel?>(null) }
var isLoading by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
val isPTTActive by communicationsService.isPTTActive.collectAsState()
// Load available channels
LaunchedEffect(userRole, userUnit) {
isLoading = true
errorMessage = null
try {
channels = communicationsService.getAvailableChannels(userRole, userUnit)
} catch (e: Exception) {
errorMessage = e.message
} finally {
isLoading = false
}
}
// Observe current channel
LaunchedEffect(Unit) {
communicationsService.currentChannel.collect { channel ->
currentChannel = channel
}
}
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp)
) {
Text(
text = "Unit Communications",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(16.dp))
// Current channel indicator
currentChannel?.let { channel ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Current Channel: ${channel.name}",
style = MaterialTheme.typography.titleMedium
)
channel.description?.let { desc ->
Text(
text = desc,
style = MaterialTheme.typography.bodySmall
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
// Error message
errorMessage?.let { error ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = error,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(16.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
}
// Channel list
if (isLoading) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(channels) { channel ->
ChannelCard(
channel = channel,
isActive = currentChannel?.id == channel.id,
onClick = {
// Join channel
// This would need to be in a coroutine scope
}
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// PTT Button
Button(
onClick = {
if (isPTTActive) {
// Stop PTT
} else {
// Start PTT
}
},
modifier = Modifier
.fillMaxWidth()
.height(64.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (isPTTActive) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
}
),
enabled = currentChannel != null
) {
Text(
text = if (isPTTActive) "RELEASE" else "PUSH TO TALK",
style = MaterialTheme.typography.titleLarge
)
}
}
}
/**
* Channel card.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChannelCard(
channel: Channel,
isActive: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
onClick = onClick,
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (isActive) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surface
}
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = channel.name,
style = MaterialTheme.typography.titleMedium
)
channel.description?.let { desc ->
Text(
text = desc,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (isActive) {
Text(
text = "Connected",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}