Browse Source

Add unified Android dark theme and improve UI contrast

master
evgeny 24 hours ago
parent
commit
752c1f140c
  1. 493
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  2. 2
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ActiveCallBanner.kt
  3. 43
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
  4. 14
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt
  5. 10
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt
  6. 4
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  7. 8
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/IncomingCallDialog.kt
  8. 12
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/LogScreen.kt
  9. 38
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt
  10. 12
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoRecordScreen.kt
  11. 6
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  12. 6
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoPlayerScreen.kt
  13. 16
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoRecordScreen.kt
  14. 50
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/theme/Theme.kt
  15. 10
      tools/chatgui-android/app/src/main/res/values/themes.xml

493
tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt

@ -35,6 +35,7 @@ import com.utun.chat.data.Message
import com.utun.chat.data.NativeLib
import com.utun.chat.data.RadioAudioEngine
import com.utun.chat.headless.HeadlessService
import com.utun.chat.ui.theme.VibeChatTheme
import com.utun.chat.ui.components.ActiveCallBanner
import com.utun.chat.ui.screens.*
import com.utun.chat.viewmodel.ChatViewModel
@ -58,10 +59,10 @@ class MainActivity : ComponentActivity() {
showOverLockScreen()
}
/* Edge-to-edge with dark status/navigation bar icons (light app theme) */
/* Светлые системные значки на тёмном фоне приложения. */
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT)
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT)
)
/* Start uTun headless service (must be from foreground Activity on API 34+).
@ -71,288 +72,290 @@ class MainActivity : ComponentActivity() {
}
setContent {
val vm: ChatViewModel = viewModel(factory = ChatViewModel.Factory())
/* Pause GUI polling (health-check, recording/playback timers) when backgrounded,
refresh statuses on resume. */
LifecycleStartEffect(Unit) {
vm.setForeground(true)
onStopOrDispose { vm.setForeground(false) }
}
var screen by remember { mutableStateOf(Screen.ChannelList) }
var qrResult by remember { mutableStateOf<String?>(null) }
var showJoinDialog by remember { mutableStateOf(false) }
var joinAutoLink by remember { mutableStateOf<String?>(null) }
var inviteChannelId by remember { mutableStateOf<String?>(null) }
var videoMessage by remember { mutableStateOf<Message?>(null) }
var photoMessage by remember { mutableStateOf<Message?>(null) }
var hadCall by remember { mutableStateOf(false) }
var screenBeforeCall by remember { mutableStateOf<Screen?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val callState by vm.callState.collectAsState()
/* Пока идёт звонок — держим экран включённым; после завершения снимаем
и showWhenLocked (если он был выставлен для входящего поверх блокировки). */
LaunchedEffect(callState) {
val st = callState
val inCall = st != null && st.phase != ChatViewModel.CallPhase.NONE &&
st.phase != ChatViewModel.CallPhase.ENDED
if (inCall) {
hadCall = true
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (hadCall) releaseLockScreen()
VibeChatTheme {
val vm: ChatViewModel = viewModel(factory = ChatViewModel.Factory())
/* Pause GUI polling (health-check, recording/playback timers) when backgrounded,
refresh statuses on resume. */
LifecycleStartEffect(Unit) {
vm.setForeground(true)
onStopOrDispose { vm.setForeground(false) }
}
}
/* Звонок управляет навигацией: OUTGOING/RINGING/ACTIVE → экран Call (запоминаем
откуда пришли, чтобы свернуться обратно). ENDED не навигирует — CallScreen сам
показывает «Завершён», если пользователь уже на нём. INCOMING → диалог поверх. */
LaunchedEffect(callState?.phase) {
when (callState?.phase) {
ChatViewModel.CallPhase.OUTGOING, ChatViewModel.CallPhase.RINGING,
ChatViewModel.CallPhase.ACTIVE -> {
if (screen != Screen.Call) screenBeforeCall = screen
screen = Screen.Call
var screen by remember { mutableStateOf(Screen.ChannelList) }
var qrResult by remember { mutableStateOf<String?>(null) }
var showJoinDialog by remember { mutableStateOf(false) }
var joinAutoLink by remember { mutableStateOf<String?>(null) }
var inviteChannelId by remember { mutableStateOf<String?>(null) }
var videoMessage by remember { mutableStateOf<Message?>(null) }
var photoMessage by remember { mutableStateOf<Message?>(null) }
var hadCall by remember { mutableStateOf(false) }
var screenBeforeCall by remember { mutableStateOf<Screen?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val callState by vm.callState.collectAsState()
/* Пока идёт звонок — держим экран включённым; после завершения снимаем
и showWhenLocked (если он был выставлен для входящего поверх блокировки). */
LaunchedEffect(callState) {
val st = callState
val inCall = st != null && st.phase != ChatViewModel.CallPhase.NONE &&
st.phase != ChatViewModel.CallPhase.ENDED
if (inCall) {
hadCall = true
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (hadCall) releaseLockScreen()
}
null -> if (screen == Screen.Call) screen = screenBeforeCall ?: Screen.ChannelList
else -> {}
}
}
/* Автооткрытие DM-беседы после нажатия «Message» в списке участников. */
LaunchedEffect(Unit) {
vm.dmOpen.collect { convId ->
if (convId != null) {
vm.consumeDmOpen()
screen = Screen.Dm
/* Звонок управляет навигацией: OUTGOING/RINGING/ACTIVE → экран Call (запоминаем
откуда пришли, чтобы свернуться обратно). ENDED не навигирует — CallScreen сам
показывает «Завершён», если пользователь уже на нём. INCOMING → диалог поверх. */
LaunchedEffect(callState?.phase) {
when (callState?.phase) {
ChatViewModel.CallPhase.OUTGOING, ChatViewModel.CallPhase.RINGING,
ChatViewModel.CallPhase.ACTIVE -> {
if (screen != Screen.Call) screenBeforeCall = screen
screen = Screen.Call
}
null -> if (screen == Screen.Call) screen = screenBeforeCall ?: Screen.ChannelList
else -> {}
}
}
}
val showInvite: (Channel) -> Unit = { ch ->
if (ch.id.toULongOrNull() == null) {
LogManager.addLog("ERROR", "INVITE", "channel id is not numeric: ${ch.id}")
scope.launch { snackbarHostState.showSnackbar("Invalid channel id") }
} else {
inviteChannelId = ch.id
vm.requestInvite(ch.id)
/* Автооткрытие DM-беседы после нажатия «Message» в списке участников. */
LaunchedEffect(Unit) {
vm.dmOpen.collect { convId ->
if (convId != null) {
vm.consumeDmOpen()
screen = Screen.Dm
}
}
}
}
BackHandler(enabled = screen != Screen.ChannelList) {
when (screen) {
Screen.MemberList -> screen = Screen.Chat
Screen.VideoRecord -> screen = Screen.Chat
Screen.PhotoRecord -> screen = Screen.Chat
Screen.Chat -> screen = Screen.ChannelList
Screen.Dm -> { vm.closeDm(); screen = Screen.ChannelList }
Screen.Call -> screen = screenBeforeCall ?: Screen.ChannelList
else -> screen = Screen.ChannelList
val showInvite: (Channel) -> Unit = { ch ->
if (ch.id.toULongOrNull() == null) {
LogManager.addLog("ERROR", "INVITE", "channel id is not numeric: ${ch.id}")
scope.launch { snackbarHostState.showSnackbar("Invalid channel id") }
} else {
inviteChannelId = ch.id
vm.requestInvite(ch.id)
}
}
}
BackHandler(enabled = videoMessage != null) {
videoMessage = null
}
BackHandler(enabled = screen != Screen.ChannelList) {
when (screen) {
Screen.MemberList -> screen = Screen.Chat
Screen.VideoRecord -> screen = Screen.Chat
Screen.PhotoRecord -> screen = Screen.Chat
Screen.Chat -> screen = Screen.ChannelList
Screen.Dm -> { vm.closeDm(); screen = Screen.ChannelList }
Screen.Call -> screen = screenBeforeCall ?: Screen.ChannelList
else -> screen = Screen.ChannelList
}
}
BackHandler(enabled = photoMessage != null) {
photoMessage = null
}
BackHandler(enabled = videoMessage != null) {
videoMessage = null
}
val connected = NativeLib.isReady()
BackHandler(enabled = photoMessage != null) {
photoMessage = null
}
/* Subscribe native logs only when LogScreen is open */
DisposableEffect(screen) {
if (screen == Screen.Logs) NativeLib.subscribeNativeLogs(true)
onDispose { NativeLib.subscribeNativeLogs(false) }
}
val connected = NativeLib.isReady()
Column(modifier = Modifier.fillMaxSize()) {
val callPhase = callState?.phase
val showCallBanner = screen != Screen.Call &&
(callPhase == ChatViewModel.CallPhase.OUTGOING ||
callPhase == ChatViewModel.CallPhase.RINGING ||
callPhase == ChatViewModel.CallPhase.ACTIVE)
if (showCallBanner) {
ActiveCallBanner(
viewModel = vm,
onOpen = { screen = Screen.Call },
onHangup = { vm.hangupCall() }
)
/* Subscribe native logs only when LogScreen is open */
DisposableEffect(screen) {
if (screen == Screen.Logs) NativeLib.subscribeNativeLogs(true)
onDispose { NativeLib.subscribeNativeLogs(false) }
}
Box(
modifier = Modifier.weight(1f)
.then(if (showCallBanner) Modifier.consumeWindowInsets(WindowInsets.statusBars) else Modifier)
) {
when (screen) {
Screen.ChannelList -> {
val channels by vm.channels.collectAsState()
val dmConversations by vm.dmConversations.collectAsState()
val stuck by vm.isStuck.collectAsState()
ChannelListScreen(
channels = channels,
dmConversations = dmConversations,
connected = connected,
isStuck = stuck,
onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat },
onSelectDm = { dm -> vm.openDm(dm.convId, dm.peerNodeId, dm.peerName); screen = Screen.Dm },
onCreateChannel = { name -> vm.createChannel(name, "channel_1") },
onJoinChannel = { showJoinDialog = true; joinAutoLink = null },
onInviteClick = showInvite,
onDeleteChannel = { vm.deleteChannel(it.id) },
onSettings = { screen = Screen.Settings },
onLogs = { screen = Screen.Logs },
onRefresh = { vm.requestRefresh() }
)
}
Screen.Chat -> {
val messages by vm.messages.collectAsState()
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
ChatScreen(
channel = channel,
messages = messages,
Column(modifier = Modifier.fillMaxSize()) {
val callPhase = callState?.phase
val showCallBanner = screen != Screen.Call &&
(callPhase == ChatViewModel.CallPhase.OUTGOING ||
callPhase == ChatViewModel.CallPhase.RINGING ||
callPhase == ChatViewModel.CallPhase.ACTIVE)
if (showCallBanner) {
ActiveCallBanner(
viewModel = vm,
onOpen = { screen = Screen.Call },
onHangup = { vm.hangupCall() }
)
}
Box(
modifier = Modifier.weight(1f)
.then(if (showCallBanner) Modifier.consumeWindowInsets(WindowInsets.statusBars) else Modifier)
) {
when (screen) {
Screen.ChannelList -> {
val channels by vm.channels.collectAsState()
val dmConversations by vm.dmConversations.collectAsState()
val stuck by vm.isStuck.collectAsState()
ChannelListScreen(
channels = channels,
dmConversations = dmConversations,
connected = connected,
viewModel = vm,
onSendMessage = { vm.sendMessage(it) },
onMembersClick = { screen = Screen.MemberList },
onInvite = { showInvite(channel) },
onBack = { screen = Screen.ChannelList },
onPlayVideo = { msg -> videoMessage = msg },
onViewPhoto = { msg -> photoMessage = msg },
onRecordVideo = { screen = Screen.VideoRecord },
onCapturePhoto = { screen = Screen.PhotoRecord }
isStuck = stuck,
onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat },
onSelectDm = { dm -> vm.openDm(dm.convId, dm.peerNodeId, dm.peerName); screen = Screen.Dm },
onCreateChannel = { name -> vm.createChannel(name, "channel_1") },
onJoinChannel = { showJoinDialog = true; joinAutoLink = null },
onInviteClick = showInvite,
onDeleteChannel = { vm.deleteChannel(it.id) },
onSettings = { screen = Screen.Settings },
onLogs = { screen = Screen.Logs },
onRefresh = { vm.requestRefresh() }
)
}
}
Screen.MemberList -> {
val members by vm.members.collectAsState()
val selectedMember by vm.selectedMember.collectAsState()
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
MemberListScreen(
channel = channel,
members = members,
selectedMember = selectedMember,
viewModel = vm,
onBack = { screen = Screen.Chat }
)
Screen.Chat -> {
val messages by vm.messages.collectAsState()
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
ChatScreen(
channel = channel,
messages = messages,
connected = connected,
viewModel = vm,
onSendMessage = { vm.sendMessage(it) },
onMembersClick = { screen = Screen.MemberList },
onInvite = { showInvite(channel) },
onBack = { screen = Screen.ChannelList },
onPlayVideo = { msg -> videoMessage = msg },
onViewPhoto = { msg -> photoMessage = msg },
onRecordVideo = { screen = Screen.VideoRecord },
onCapturePhoto = { screen = Screen.PhotoRecord }
)
}
}
}
Screen.Dm -> {
val dmMessages by vm.dmMessages.collectAsState()
val dm = vm.currentDm.collectAsState().value
if (dm != null) {
DmChatScreen(
conv = dm,
messages = dmMessages,
onSendMessage = { vm.sendDmMessage(it) },
onBack = { vm.closeDm(); screen = Screen.ChannelList }
)
Screen.MemberList -> {
val members by vm.members.collectAsState()
val selectedMember by vm.selectedMember.collectAsState()
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
MemberListScreen(
channel = channel,
members = members,
selectedMember = selectedMember,
viewModel = vm,
onBack = { screen = Screen.Chat }
)
}
}
}
Screen.Settings -> SettingsScreen(vm = vm, onBack = { screen = Screen.ChannelList }, onExit = { finish() })
Screen.Logs -> LogScreen(onBack = { screen = Screen.ChannelList })
Screen.Call -> CallScreen(
viewModel = vm,
onClose = { vm.dismissCall(); screen = screenBeforeCall ?: Screen.ChannelList },
onMinimize = { screen = screenBeforeCall ?: Screen.ChannelList }
)
Screen.QrScan -> {
QrScanScreen(
onResult = { link ->
qrResult = link
screen = Screen.ChannelList
},
onBack = { screen = Screen.ChannelList }
Screen.Dm -> {
val dmMessages by vm.dmMessages.collectAsState()
val dm = vm.currentDm.collectAsState().value
if (dm != null) {
DmChatScreen(
conv = dm,
messages = dmMessages,
onSendMessage = { vm.sendDmMessage(it) },
onBack = { vm.closeDm(); screen = Screen.ChannelList }
)
}
}
Screen.Settings -> SettingsScreen(vm = vm, onBack = { screen = Screen.ChannelList }, onExit = { finish() })
Screen.Logs -> LogScreen(onBack = { screen = Screen.ChannelList })
Screen.Call -> CallScreen(
viewModel = vm,
onClose = { vm.dismissCall(); screen = screenBeforeCall ?: Screen.ChannelList },
onMinimize = { screen = screenBeforeCall ?: Screen.ChannelList }
)
}
Screen.VideoRecord -> {
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
VideoRecordScreen(
onSend = { path ->
vm.sendVideoMessage(path)
screen = Screen.Chat
Screen.QrScan -> {
QrScanScreen(
onResult = { link ->
qrResult = link
screen = Screen.ChannelList
},
onClose = { screen = Screen.Chat }
onBack = { screen = Screen.ChannelList }
)
}
Screen.VideoRecord -> {
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
VideoRecordScreen(
onSend = { path ->
vm.sendVideoMessage(path)
screen = Screen.Chat
},
onClose = { screen = Screen.Chat }
)
}
}
Screen.PhotoRecord -> {
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
PhotoRecordScreen(
onSend = { path, width, height ->
vm.sendPhotoMessage(path, width, height)
screen = Screen.Chat
},
onClose = { screen = Screen.Chat }
)
}
}
}
Screen.PhotoRecord -> {
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
PhotoRecordScreen(
onSend = { path, width, height ->
vm.sendPhotoMessage(path, width, height)
screen = Screen.Chat
},
onClose = { screen = Screen.Chat }
)
SnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter).navigationBarsPadding())
videoMessage?.let { msg ->
if (msg.filePath.isNotEmpty()) {
VideoPlayerScreen(filePath = msg.filePath, onClose = { videoMessage = null })
}
}
}
SnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter).navigationBarsPadding())
videoMessage?.let { msg ->
if (msg.filePath.isNotEmpty()) {
VideoPlayerScreen(filePath = msg.filePath, onClose = { videoMessage = null })
photoMessage?.let { msg ->
if (msg.filePath.isNotEmpty()) {
PhotoViewerScreen(filePath = msg.filePath, onClose = { photoMessage = null })
}
}
}
photoMessage?.let { msg ->
if (msg.filePath.isNotEmpty()) {
PhotoViewerScreen(filePath = msg.filePath, onClose = { photoMessage = null })
if (callState?.phase == ChatViewModel.CallPhase.INCOMING) {
IncomingCallDialog(viewModel = vm)
}
}
}
if (callState?.phase == ChatViewModel.CallPhase.INCOMING) {
IncomingCallDialog(viewModel = vm)
/* Show join dialog after QR scan returns */
LaunchedEffect(qrResult) {
qrResult?.let { link ->
joinAutoLink = link
showJoinDialog = true
qrResult = null
}
}
}
/* Show join dialog after QR scan returns */
LaunchedEffect(qrResult) {
qrResult?.let { link ->
joinAutoLink = link
showJoinDialog = true
qrResult = null
if (showJoinDialog) {
JoinChannelDialog(
autoLink = joinAutoLink,
onScanQr = {
screen = Screen.QrScan
showJoinDialog = false
},
onConnect = { data: InviteData ->
val addrs = data.serializeAddrs()
NativeLib.joinChannel(data.channelId, data.nodeId, data.joinKey, data.pubkey, addrs, data.addrs.size)
showJoinDialog = false
},
onDismiss = { showJoinDialog = false }
)
}
}
if (showJoinDialog) {
JoinChannelDialog(
autoLink = joinAutoLink,
onScanQr = {
screen = Screen.QrScan
showJoinDialog = false
},
onConnect = { data: InviteData ->
val addrs = data.serializeAddrs()
NativeLib.joinChannel(data.channelId, data.nodeId, data.joinKey, data.pubkey, addrs, data.addrs.size)
showJoinDialog = false
},
onDismiss = { showJoinDialog = false }
)
}
inviteChannelId?.let { chId ->
val candidates by vm.inviteCandidates.collectAsState()
val link by vm.inviteLink.collectAsState()
val pending by vm.inviteLinkPending.collectAsState()
InviteLinkDialog(
candidates = candidates,
link = link,
pending = pending,
onSelectNode = { nodeId -> vm.requestInviteLink(chId, nodeId) },
onDismiss = { inviteChannelId = null; vm.clearInvite() }
)
inviteChannelId?.let { chId ->
val candidates by vm.inviteCandidates.collectAsState()
val link by vm.inviteLink.collectAsState()
val pending by vm.inviteLinkPending.collectAsState()
InviteLinkDialog(
candidates = candidates,
link = link,
pending = pending,
onSelectNode = { nodeId -> vm.requestInviteLink(chId, nodeId) },
onDismiss = { inviteChannelId = null; vm.clearInvite() }
)
}
}
}
}

2
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ActiveCallBanner.kt

@ -83,7 +83,7 @@ fun ActiveCallBanner(
}
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier.size(40.dp).background(Color(0xFFE53935), CircleShape),
modifier = Modifier.size(40.dp).background(Color(0xFFB32632), CircleShape),
contentAlignment = Alignment.Center
) {
IconButton(onClick = onHangup) {

43
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt

@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import com.utun.chat.ui.theme.ChatColors
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInput
@ -82,8 +83,8 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
onOpenFile: (Message) -> Unit = {},
onOpenImage: (Message) -> Unit = {},
onPlayVideo: (Message) -> Unit = {}) {
val bgColor = if (message.isOutgoing) Color(0xFF4A90D9) else Color(0xFFE8E8E8)
val textColor = if (message.isOutgoing) Color.White else Color.Black
val bgColor = if (message.isOutgoing) ChatColors.Outgoing else ChatColors.Raised
val textColor = ChatColors.Text
val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start
val minBubbleWidth = (LocalConfiguration.current.screenWidthDp / 3).dp
val voiceMaxWidth = (LocalConfiguration.current.screenWidthDp * 0.75f).dp
@ -120,7 +121,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
.background(bgColor)
.padding(12.dp)
) {
if (!isVoice) Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f))
if (!isVoice) Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ChatColors.Secondary)
Spacer(Modifier.height(2.dp))
when {
@ -138,7 +139,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
Text(
formatTime(message.ts),
fontSize = 11.sp,
color = textColor.copy(alpha = 0.5f),
color = ChatColors.Secondary,
modifier = Modifier.align(Alignment.End)
)
}
@ -164,7 +165,7 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean,
Column {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier.size(32.dp).background(Color.White, CircleShape)
modifier = Modifier.size(32.dp).background(ChatColors.Background, CircleShape)
.clickable {
when {
downloading -> {}
@ -181,7 +182,7 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean,
playing && !paused -> "\u23F8"
else -> "\u25B6"
},
fontSize = 14.sp, color = Color.Black
fontSize = 14.sp, color = ChatColors.Text
)
}
Spacer(Modifier.width(8.dp))
@ -212,7 +213,7 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean,
val y = ph - barH
val played = showCursor && i < playedIdx
drawRect(
color = when { played -> Color.White; else -> Color(0xFFCCCCD4).copy(alpha = 0.8f) },
color = if (played) ChatColors.Accent else ChatColors.Secondary,
topLeft = Offset(x, y),
size = Size(barW, barH)
)
@ -241,12 +242,12 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean,
else -> formatDurationMs(durMs)
},
fontSize = 12.sp,
color = textColor.copy(alpha = 0.55f)
color = ChatColors.Secondary
)
Text(
formatTime(message.ts),
fontSize = 11.sp,
color = textColor.copy(alpha = 0.4f),
color = ChatColors.Secondary,
modifier = Modifier.align(Alignment.CenterVertically).padding(start = 8.dp)
)
}
@ -287,7 +288,7 @@ private fun FileBubble(message: Message, textColor: Color, onDownload: (Message)
Text(
"${formatFileSize(message.fileSize)} · $stateText",
fontSize = 12.sp,
color = textColor.copy(alpha = 0.5f)
color = ChatColors.Secondary
)
}
}
@ -357,7 +358,7 @@ private fun VideoBubble(message: Message, textColor: Color,
) {
thumb?.let { Image(it.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.size(44.dp).background(Color(0x88000000), CircleShape), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.size(44.dp).background(Color(0xCC000000), CircleShape), contentAlignment = Alignment.Center) {
Text(
when { downloading -> "\u23F3"; isError -> "\u267B"; !downloaded -> "\u2B07"; else -> "\u25B6" },
fontSize = 18.sp, color = Color.White
@ -368,7 +369,7 @@ private fun VideoBubble(message: Message, textColor: Color,
Text(
formatDurationMs(durMs), fontSize = 11.sp, color = Color.White,
modifier = Modifier.align(Alignment.BottomEnd).padding(6.dp)
.background(Color(0x88000000), RoundedCornerShape(4.dp))
.background(Color(0xCC000000), RoundedCornerShape(4.dp))
.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
@ -392,7 +393,7 @@ private fun VideoBubble(message: Message, textColor: Color,
else -> "Tap to play"
})
}.trimEnd(),
fontSize = 12.sp, color = textColor.copy(alpha = 0.5f)
fontSize = 12.sp, color = ChatColors.Secondary
)
}
}
@ -434,7 +435,7 @@ private fun ImageBubble(message: Message, textColor: Color,
) {
bmp?.let { Image(it.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.size(44.dp).background(Color(0x88000000), CircleShape), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.size(44.dp).background(Color(0xCC000000), CircleShape), contentAlignment = Alignment.Center) {
Text(
when { downloading -> "\u23F3"; isError -> "\u267B"; !downloaded -> "\u2B07"; else -> "" },
fontSize = 18.sp, color = Color.White
@ -453,7 +454,7 @@ private fun ImageBubble(message: Message, textColor: Color,
!downloaded -> "Tap to download"
else -> "Tap to open"
},
fontSize = 12.sp, color = textColor.copy(alpha = 0.5f)
fontSize = 12.sp, color = ChatColors.Secondary
)
}
}
@ -548,7 +549,7 @@ fun InputBar(
Text(
formatFileSize(pendingAttachmentSize),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f)
color = ChatColors.Secondary
)
Spacer(Modifier.width(8.dp))
}
@ -577,7 +578,7 @@ fun InputBar(
Surface(
modifier = Modifier.weight(1f).height(48.dp),
shape = RoundedCornerShape(8.dp),
color = Color(0xFFE53935)
color = Color(0xFF54252A)
) {
Row(
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
@ -615,10 +616,10 @@ fun InputBar(
if (isRecording && isRecordingLocked) {
/* Locked: cancel + send voice */
IconButton(onClick = onVoiceCancel) {
Text("\u2715", fontSize = 20.sp, color = Color(0xFFE53935))
Text("\u2715", fontSize = 20.sp, color = Color(0xFFFFB4AB))
}
IconButton(onClick = onVoiceSend) {
Text("\u25B6", fontSize = 20.sp, color = Color(0xFF4CAF50))
Text("\u25B6", fontSize = 20.sp, color = Color(0xFF81C784))
}
} else if (!isRecording && (text.isNotBlank() || hasAttachment)) {
/* Send button — round, primary color */
@ -630,11 +631,11 @@ fun InputBar(
.clickable { onSend() },
contentAlignment = Alignment.Center
) {
Text("\u27A4", fontSize = 22.sp, color = Color.White)
Text("\u27A4", fontSize = 22.sp, color = MaterialTheme.colorScheme.onPrimary)
}
} else {
/* Unified mode button — short tap toggles voice/video, hold activates */
val pttBg = if (isRecording) Color(0xFFE53935) else Color.Transparent
val pttBg = if (isRecording) Color(0xFFB32632) else Color.Transparent
Box(
modifier = Modifier
.size(48.dp)

14
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt

@ -84,7 +84,7 @@ private fun CallControlButton(
}
}
Spacer(Modifier.height(6.dp))
Text(label, color = if (active) Color.White else Color(0xFF9BA6AD), fontSize = 11.sp)
Text(label, color = if (active) Color.White else Color(0xFFAEBCC8), fontSize = 11.sp)
}
}
@ -168,7 +168,7 @@ fun CallScreen(
Spacer(Modifier.height(8.dp))
Text(
if (phase == CallPhase.ACTIVE) fmtDuration(duration) else callStatusText(phase),
color = Color(0xFF9BA6AD),
color = Color(0xFFAEBCC8),
fontSize = 16.sp
)
val bgpPath = st?.bgpPath ?: ""
@ -176,7 +176,7 @@ fun CallScreen(
Spacer(Modifier.height(8.dp))
Text(
bgpPath,
color = Color(0xFF9BA6AD),
color = Color(0xFFAEBCC8),
fontSize = 13.sp
)
}
@ -231,13 +231,13 @@ fun CallScreen(
contentDescription = if (muted) "Включить микрофон" else "Выключить микрофон",
label = if (muted) "Вкл. микрофон" else "Mute",
active = muted,
activeColor = Color(0xFFE53935),
activeColor = Color(0xFFB32632),
onClick = { viewModel.toggleMute() }
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier.size(72.dp).background(Color(0xFFE53935), CircleShape),
modifier = Modifier.size(72.dp).background(Color(0xFFB32632), CircleShape),
contentAlignment = Alignment.Center
) {
IconButton(onClick = { viewModel.hangupCall() }) {
@ -245,7 +245,7 @@ fun CallScreen(
}
}
Spacer(Modifier.height(6.dp))
Text("Завершить", color = Color(0xFF9BA6AD), fontSize = 12.sp)
Text("Завершить", color = Color(0xFFAEBCC8), fontSize = 12.sp)
}
}
}
@ -268,7 +268,7 @@ fun CallScreen(
Spacer(Modifier.height(16.dp))
Text("Экран заблокирован", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(8.dp))
Text("Отведите телефон от уха, чтобы разблокировать", color = Color(0xFF9BA6AD), fontSize = 14.sp)
Text("Отведите телефон от уха, чтобы разблокировать", color = Color(0xFFAEBCC8), fontSize = 14.sp)
}
}
}

10
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt

@ -59,7 +59,7 @@ fun ChannelListScreen(
actions = {
Box(
Modifier.size(10.dp)
.background(if (isStuck) Color.Red else Color(0xFF4CAF50), CircleShape)
.background(if (isStuck) Color(0xFFFFB4AB) else Color(0xFF81C784), CircleShape)
)
Spacer(Modifier.width(4.dp))
IconButton(onClick = onJoinChannel) { Icon(Icons.Default.QrCode, "Join") }
@ -86,7 +86,7 @@ fun ChannelListScreen(
} else {
LazyColumn {
items(channels) { ch ->
val barColor = if (ch.peersOnline > 0) Color(0xFF4CAF50) else Color(0xFF9E9E9E)
val barColor = if (ch.peersOnline > 0) Color(0xFF81C784) else Color(0xFFAEBCC8)
val badgeText = when {
ch.peersOnline > 99 -> "99+"
ch.peersOnline > 0 -> ch.peersOnline.toString()
@ -126,7 +126,7 @@ fun ChannelListScreen(
.offset(x = (-4).dp, y = (-6).dp)
.size(badgeSize)
.clip(CircleShape)
.background(Color(0xFF4CAF50)),
.background(Color(0xFF24633D)),
contentAlignment = Alignment.Center
) {
Text(
@ -164,10 +164,10 @@ fun ChannelListScreen(
items(dmConversations) { dm ->
Surface(modifier = Modifier.clickable { onSelectDm(dm) }) {
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
Box(Modifier.width(4.dp).fillMaxHeight().background(Color(0xFF8E44AD)))
Box(Modifier.width(4.dp).fillMaxHeight().background(Color(0xFFD2A8FF)))
Row(Modifier.fillMaxWidth().padding(12.dp)) {
Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) {
Text("@", fontSize = 22.sp, color = Color(0xFF8E44AD))
Text("@", fontSize = 22.sp, color = Color(0xFFD2A8FF))
}
Spacer(Modifier.width(10.dp))
Column {

4
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt

@ -366,7 +366,7 @@ private fun RadioPttButton(
}
},
shape = CircleShape,
color = if (isTalking) Color(0xFFE53935) else Color(0xFF81C784),
color = if (isTalking) Color(0xFFFFB4AB) else Color(0xFF81C784),
shadowElevation = 6.dp
) {
Box(contentAlignment = Alignment.Center) {
@ -459,7 +459,7 @@ private fun RadioControlButton(
onCheckedChange = { onClick() },
modifier = Modifier.size(48.dp),
colors = IconButtonDefaults.iconToggleButtonColors(
containerColor = Color(0xFF24323D), contentColor = Color(0xFF9BA6AD),
containerColor = Color(0xFF24323D), contentColor = Color(0xFFAEBCC8),
checkedContainerColor = Color(0xFF1F6FEB), checkedContentColor = Color.White)
) {
if (floating) {

8
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/IncomingCallDialog.kt

@ -49,20 +49,20 @@ fun IncomingCallDialog(
Spacer(Modifier.height(20.dp))
Text(name, color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(6.dp))
Text("Входящий звонок…", color = Color(0xFF9BA6AD), fontSize = 14.sp)
Text("Входящий звонок…", color = Color(0xFFAEBCC8), fontSize = 14.sp)
Spacer(Modifier.height(28.dp))
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier.size(64.dp).background(Color(0xFFE53935), CircleShape),
modifier = Modifier.size(64.dp).background(Color(0xFFB32632), CircleShape),
contentAlignment = Alignment.Center
) {
IconButton(onClick = { viewModel.declineCall() }) {
Icon(Icons.Filled.CallEnd, "Отклонить", tint = Color.White, modifier = Modifier.size(30.dp))
}
}
Text("Отклонить", color = Color(0xFF9BA6AD), fontSize = 12.sp)
Text("Отклонить", color = Color(0xFFAEBCC8), fontSize = 12.sp)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
@ -73,7 +73,7 @@ fun IncomingCallDialog(
Icon(Icons.Filled.Call, "Принять", tint = Color.White, modifier = Modifier.size(30.dp))
}
}
Text("Принять", color = Color(0xFF9BA6AD), fontSize = 12.sp)
Text("Принять", color = Color(0xFFAEBCC8), fontSize = 12.sp)
}
}
}

12
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/LogScreen.kt

@ -52,7 +52,7 @@ fun LogScreen(onBack: () -> Unit) {
val selected = filterLevel == lvl
Text(
label,
color = if (selected) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
modifier = Modifier
.clickable { filterLevel = lvl }
@ -77,12 +77,12 @@ fun LogScreen(onBack: () -> Unit) {
items(filtered.size) { idx -> // index-based to avoid recomposition on each new entry
val entry = filtered[idx]
val color = when (entry.level) {
"ERROR" -> Color(0xFFFF4444)
"ERROR" -> Color(0xFFFFB4AB)
"WARN" -> Color(0xFFFFAA00)
"INFO" -> Color(0xFF4CAF50)
"DEBUG" -> Color(0xFF2196F3)
"TRACE" -> Color(0xFF9E9E9E)
else -> Color.Gray
"INFO" -> Color(0xFF81C784)
"DEBUG" -> Color(0xFF79B8FF)
"TRACE" -> Color(0xFFAEBCC8)
else -> Color(0xFFAEBCC8)
}
Text(
text = "${entry.level} [${entry.category}] ${entry.message}",

38
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt

@ -143,8 +143,8 @@ private fun MemberListItem(
) {
val onlineColor = when {
member.connecting -> Color(0xFFFFEB3B) // yellow: connecting
member.online -> Color(0xFF4CAF50) // green: reachable (direct/indirect/relay)
else -> Color(0xFF9E9E9E) // gray: offline
member.online -> Color(0xFF81C784) // green: reachable (direct/indirect/relay)
else -> Color(0xFFAEBCC8) // gray: offline
}
val bgColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent
@ -160,7 +160,7 @@ private fun MemberListItem(
verticalAlignment = Alignment.CenterVertically
) {
if (member.isSelf) {
Text("✓", fontSize = 12.sp, color = Color(0xFF4CAF50))
Text("✓", fontSize = 12.sp, color = Color(0xFF81C784))
} else {
Box(
modifier = Modifier
@ -182,7 +182,7 @@ private fun MemberListItem(
Spacer(Modifier.width(4.dp))
}
if (member.isModer) {
Icon(Icons.Filled.Shield, "Moderator", tint = Color(0xFF2196F3), modifier = Modifier.size(16.dp))
Icon(Icons.Filled.Shield, "Moderator", tint = Color(0xFF79B8FF), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
}
Text(
@ -192,7 +192,7 @@ private fun MemberListItem(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textDecoration = if (member.isDeleted) TextDecoration.LineThrough else TextDecoration.None,
color = if (member.isSelf) Color(0xFF4CAF50) else Color.Unspecified,
color = if (member.isSelf) Color(0xFF81C784) else Color.Unspecified,
modifier = Modifier.weight(1f)
)
}
@ -215,7 +215,7 @@ private fun MemberListItem(
text = parts.joinToString(" "),
fontSize = 11.sp,
color = if (member.isSelf || member.online)
Color(0xFF4CAF50) else Color.Gray
Color(0xFF81C784) else Color(0xFFAEBCC8)
)
}
}
@ -260,7 +260,7 @@ private fun MemberDetailCard(
) {
Column(modifier = Modifier.padding(12.dp).verticalScroll(rememberScrollState())) {
Row(verticalAlignment = Alignment.CenterVertically) {
val dotColor = if (detail.online) Color(0xFF4CAF50) else Color(0xFF9E9E9E)
val dotColor = if (detail.online) Color(0xFF81C784) else Color(0xFFAEBCC8)
Box(
modifier = Modifier
.size(10.dp)
@ -271,7 +271,7 @@ private fun MemberDetailCard(
Text(detail.name, fontWeight = FontWeight.Bold, fontSize = 15.sp)
if (isSelf) {
Spacer(Modifier.width(4.dp))
Text("✓", fontSize = 12.sp, color = Color(0xFF4CAF50))
Text("✓", fontSize = 12.sp, color = Color(0xFF81C784))
}
}
@ -299,7 +299,7 @@ private fun MemberDetailCard(
text = headerParts.joinToString(" · "),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = Color.Gray
color = Color(0xFFAEBCC8)
)
if (isSelf) {
@ -307,7 +307,7 @@ private fun MemberDetailCard(
Spacer(Modifier.height(6.dp))
Text("Listen sockets:", fontSize = 13.sp, fontWeight = FontWeight.Medium)
localSocketStates.forEach { sk ->
val dot = if (sk.state == 2) Color(0xFF4CAF50) else Color(0xFF9E9E9E)
val dot = if (sk.state == 2) Color(0xFF81C784) else Color(0xFFAEBCC8)
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape).background(dot))
Spacer(Modifier.width(6.dp))
@ -338,14 +338,14 @@ private fun MemberDetailCard(
Spacer(Modifier.height(6.dp))
Text("Connections:", fontSize = 13.sp, fontWeight = FontWeight.Medium)
memberLinks.links.forEachIndexed { idx, lk ->
val dcol = if (lk.linkState == 3 && lk.linkStatus == 1) Color(0xFF4CAF50) else Color(0xFFE53935)
val dcol = if (lk.linkState == 3 && lk.linkStatus == 1) Color(0xFF81C784) else Color(0xFFFFB4AB)
val arrow = if (lk.isServer) "◀" else "▶"
val arrowColor = if (lk.isServer) Color(0xFFFF9800) else Color(0xFF2196F3)
val arrowColor = if (lk.isServer) Color(0xFFFF9800) else Color(0xFF79B8FF)
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape).background(dcol))
Spacer(Modifier.width(6.dp))
Text(lk.protocol, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = Color.Gray)
Text(lk.protocol, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = Color(0xFFAEBCC8))
Spacer(Modifier.width(6.dp))
Text("${formatAddr(lk.localAddr)}:${lk.localPort}",
modifier = Modifier.weight(1f),
@ -363,7 +363,7 @@ private fun MemberDetailCard(
if (cleanSn.isNotEmpty()) {
Row {
Spacer(Modifier.width(14.dp))
Text(cleanSn, fontSize = 12.sp, color = Color.Gray)
Text(cleanSn, fontSize = 12.sp, color = Color(0xFFAEBCC8))
}
}
}
@ -376,7 +376,7 @@ private fun MemberDetailCard(
if (detail.directPresence) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape)
.background(if (detail.directUp) Color(0xFF4CAF50) else Color(0xFFE53935)))
.background(if (detail.directUp) Color(0xFF81C784) else Color(0xFFFFB4AB)))
Spacer(Modifier.width(6.dp))
Text("Direct: ${if (detail.directUp) "UP" else "DOWN"}" +
(if (detail.directUp && detail.bestRtt > 0) " ${detail.bestRtt / 10}ms" else ""),
@ -386,7 +386,7 @@ private fun MemberDetailCard(
if (detail.indirectPresence) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape)
.background(if (detail.indirectUp) Color(0xFF4CAF50) else Color(0xFFE53935)))
.background(if (detail.indirectUp) Color(0xFF81C784) else Color(0xFFFFB4AB)))
Spacer(Modifier.width(6.dp))
Text("Indirect: ${if (detail.indirectUp) "UP" else "DOWN"}",
fontSize = 13.sp, color = Color.DarkGray)
@ -394,7 +394,7 @@ private fun MemberDetailCard(
}
if (detail.bgpPresence && detail.bgpNodes > 0) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape).background(Color(0xFF4CAF50)))
Box(Modifier.size(8.dp).clip(CircleShape).background(Color(0xFF81C784)))
Spacer(Modifier.width(6.dp))
Text("BGP: ${detail.bgpNodes} nodes" +
(if (detail.bestRtt > 0) " ${detail.bestRtt / 10}ms" else ""),
@ -402,7 +402,7 @@ private fun MemberDetailCard(
}
}
if (!detail.directPresence && !detail.indirectPresence && !detail.bgpPresence) {
Text("offline", fontSize = 13.sp, color = Color.Gray)
Text("offline", fontSize = 13.sp, color = Color(0xFFAEBCC8))
}
}
}
@ -478,7 +478,7 @@ private fun MemberEditDialog(
Text(
"Admin/Super require channel private key",
fontSize = 11.sp,
color = Color.Gray
color = Color(0xFFAEBCC8)
)
}
}

12
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoRecordScreen.kt

@ -264,12 +264,12 @@ fun PhotoRecordScreen(
val selected = selectedLens?.cameraId == lens.cameraId
Surface(
shape = RoundedCornerShape(16.dp),
color = if (selected) Color(0xFFFFD600) else Color(0x66000000),
color = if (selected) Color(0xFF214563) else Color(0xFF24323D),
modifier = Modifier.clickable { selectLens(lens) }
) {
Text(
lens.label,
color = if (selected) Color.Black else Color.White,
color = Color(0xFFE6EDF3),
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
@ -282,12 +282,12 @@ fun PhotoRecordScreen(
val selected = r == selectedResolution
Surface(
shape = RoundedCornerShape(16.dp),
color = if (selected) Color(0xFFFFD600) else Color(0x66000000),
color = if (selected) Color(0xFF214563) else Color(0xFF24323D),
modifier = Modifier.clickable { selectedResolution = r }
) {
Text(
r.label,
color = if (selected) Color.Black else Color.White,
color = Color(0xFFE6EDF3),
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
@ -308,7 +308,7 @@ fun PhotoRecordScreen(
if (phase == PhotoPhase.CAPTURED) {
Box(
modifier = Modifier.size(52.dp)
.background(Color(0x66000000), CircleShape)
.background(Color(0xCC000000), CircleShape)
.clickable { discardAndReset() },
contentAlignment = Alignment.Center
) {
@ -317,7 +317,7 @@ fun PhotoRecordScreen(
Spacer(Modifier.width(28.dp))
Box(
modifier = Modifier.size(52.dp)
.background(Color(0xFF4CAF50), CircleShape)
.background(Color(0xFF24633D), CircleShape)
.clickable {
outputFile?.let { f -> if (f.length() > 0) onSend(f.absolutePath, photoWidth, photoHeight) else onClose() }
},

6
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt

@ -248,7 +248,7 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
Text("No sockets detected", color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
localSocketStates.forEach { s ->
val stateColor = when (s.state) { 2 -> Color(0xFF27AE60); 1 -> Color(0xFFF1C40F); else -> Color(0xFFE53935) }
val stateColor = when (s.state) { 2 -> Color(0xFF81C784); 1 -> Color(0xFFF1C40F); else -> Color(0xFFFFB4AB) }
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("${s.bindIp}:${s.port}", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall)
@ -405,7 +405,7 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
Text("Service", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(if (serviceEnabled) "Running" else "Stopped", modifier = Modifier.weight(1f),
color = if (serviceEnabled) Color(0xFF27AE60) else MaterialTheme.colorScheme.error,
color = if (serviceEnabled) Color(0xFF81C784) else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium)
Button(onClick = {
runBlocking { provider.setServiceEnabled(true) }
@ -798,7 +798,7 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
LinearProgressIndicator(
progress = { lvlPct },
modifier = Modifier.weight(1f).height(16.dp),
color = when { lvlPct < 0.6f -> Color(0xFF27AE60); lvlPct < 0.85f -> Color(0xFFF1C40F); else -> Color(0xFFE53935) },
color = when { lvlPct < 0.6f -> Color(0xFF81C784); lvlPct < 0.85f -> Color(0xFFF1C40F); else -> Color(0xFFFFB4AB) },
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
Button(onClick = {

6
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoPlayerScreen.kt

@ -387,7 +387,7 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) {
if (error == null) {
IconButton(
onClick = { togglePlay() },
modifier = Modifier.size(72.dp).background(Color(0x66000000), MaterialTheme.shapes.extraLarge)
modifier = Modifier.size(72.dp).background(Color(0xCC000000), MaterialTheme.shapes.extraLarge)
) {
Icon(
if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
@ -409,7 +409,7 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) {
onClick = { flipLandscape() },
modifier = Modifier.padding(start = 12.dp)
.size(56.dp)
.background(Color(0x66000000), MaterialTheme.shapes.extraLarge)
.background(Color(0xCC000000), MaterialTheme.shapes.extraLarge)
) {
Icon(
Icons.Filled.ScreenRotation,
@ -427,7 +427,7 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) {
onClick = { flipLandscape() },
modifier = Modifier.padding(end = 12.dp)
.size(56.dp)
.background(Color(0x66000000), MaterialTheme.shapes.extraLarge)
.background(Color(0xCC000000), MaterialTheme.shapes.extraLarge)
) {
Icon(
Icons.Filled.ScreenRotation,

16
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoRecordScreen.kt

@ -279,7 +279,7 @@ fun VideoRecordScreen(
val selected = selectedLens?.cameraId == lens.cameraId
Surface(
shape = RoundedCornerShape(16.dp),
color = if (selected) Color(0xFFFFD600) else Color(0x66000000),
color = if (selected) Color(0xFF214563) else Color(0xFF24323D),
modifier = Modifier.clickable {
selectedLens = lens
bindCamera(lens.selector())
@ -291,13 +291,13 @@ fun VideoRecordScreen(
) {
Text(
lens.label,
color = if (selected) Color.Black else Color.White,
color = Color(0xFFE6EDF3),
fontSize = 13.sp
)
if (lens.mm.isNotEmpty()) {
Text(
lens.mm,
color = (if (selected) Color.Black else Color.White).copy(alpha = 0.7f),
color = Color(0xFFAEBCC8),
fontSize = 11.sp
)
}
@ -311,12 +311,12 @@ fun VideoRecordScreen(
val selected = q == quality
Surface(
shape = RoundedCornerShape(16.dp),
color = if (selected) Color(0xFFFFD600) else Color(0x66000000),
color = if (selected) Color(0xFF214563) else Color(0xFF24323D),
modifier = Modifier.clickable { quality = q }
) {
Text(
q.label,
color = if (selected) Color.Black else Color.White,
color = Color(0xFFE6EDF3),
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
@ -346,7 +346,7 @@ fun VideoRecordScreen(
/* discard / close */
Box(
modifier = Modifier.size(52.dp)
.background(Color(0x66000000), CircleShape)
.background(Color(0xCC000000), CircleShape)
.clickable { if (phase == Phase.IDLE) onClose() else discardAndReset() },
contentAlignment = Alignment.Center
) {
@ -358,7 +358,7 @@ fun VideoRecordScreen(
Box(
modifier = Modifier.size(76.dp)
.background(
when (phase) { Phase.RECORDING -> Color(0xFFE53935); else -> Color(0xFFE53935) },
when (phase) { Phase.RECORDING -> Color(0xFFB32632); else -> Color(0xFFB32632) },
CircleShape
)
.clickable {
@ -389,7 +389,7 @@ fun VideoRecordScreen(
val canSend = (phase == Phase.RECORDING || phase == Phase.PAUSED || phase == Phase.STOPPED)
Box(
modifier = Modifier.size(52.dp)
.background(if (canSend) Color(0xFF4CAF50) else Color(0x33000000), CircleShape)
.background(if (canSend) Color(0xFF24633D) else Color(0x33000000), CircleShape)
.clickable(enabled = canSend) {
if (phase == Phase.STOPPED) {
outputFile?.let { onSend(it.absolutePath) }

50
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/theme/Theme.kt

@ -0,0 +1,50 @@
package com.utun.chat.ui.theme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.utun.chat.data.LogManager
// Общая палитра экранов, диалогов и сообщений; подписи не ослабляем прозрачностью.
object ChatColors {
val Background = Color(0xFF0F1B22)
val Panel = Color(0xFF1B2733)
val Raised = Color(0xFF24323D)
val Outgoing = Color(0xFF214563)
val Text = Color(0xFFE6EDF3)
val Secondary = Color(0xFFAEBCC8)
val Accent = Color(0xFF79B8FF)
val Success = Color(0xFF81C784)
val Error = Color(0xFFFFB4AB)
}
private val DarkColors = darkColorScheme(
primary = ChatColors.Accent, onPrimary = ChatColors.Background,
primaryContainer = ChatColors.Outgoing, onPrimaryContainer = ChatColors.Text,
secondary = ChatColors.Accent, onSecondary = ChatColors.Background,
secondaryContainer = ChatColors.Raised, onSecondaryContainer = ChatColors.Text,
tertiary = ChatColors.Success, onTertiary = ChatColors.Background,
tertiaryContainer = Color(0xFF203C2C), onTertiaryContainer = ChatColors.Text,
background = ChatColors.Background, onBackground = ChatColors.Text,
surface = ChatColors.Panel, onSurface = ChatColors.Text,
surfaceVariant = ChatColors.Raised, onSurfaceVariant = ChatColors.Secondary,
surfaceTint = Color.Transparent,
inverseSurface = ChatColors.Raised, inverseOnSurface = ChatColors.Text, inversePrimary = ChatColors.Accent,
outline = Color(0xFF8293A1), outlineVariant = Color(0xFF435563),
error = ChatColors.Error, onError = Color(0xFF4A1518),
errorContainer = Color(0xFF54252A), onErrorContainer = ChatColors.Text,
scrim = Color.Black
)
@Composable
fun VibeChatTheme(content: @Composable () -> Unit) {
LaunchedEffect(Unit) { LogManager.addLog("INFO", "UI", "Dark theme active: screens, dialogs and message palette") }
MaterialTheme(colorScheme = DarkColors) {
Surface(modifier = Modifier.fillMaxSize(), color = ChatColors.Background, content = content)
}
}

10
tools/chatgui-android/app/src/main/res/values/themes.xml

@ -1,4 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.UTun" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.UTun" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">#0F1B22</item>
<item name="android:colorBackground">#0F1B22</item>
<item name="android:statusBarColor">#0F1B22</item>
<item name="android:navigationBarColor">#0F1B22</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">false</item>
<item name="android:colorAccent">#79B8FF</item>
</style>
</resources>

Loading…
Cancel
Save