diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index e3753e98..eee3762c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -201,6 +201,14 @@ class MainActivity : AppCompatActivity() { var screenBeforeCall by rememberSaveable { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() + val pmInvite by vm.pmInvite.collectAsState() + val linkJoin by vm.linkJoin.collectAsState() + val joinedChannel by vm.joinedChannel.collectAsState() + LaunchedEffect(joinedChannel) { + joinedChannel?.let { channel -> + vm.consumeJoinedChannel(); vm.selectChannel(channel); screen = Screen.Chat + } + } val callState by vm.callState.collectAsState() @@ -440,6 +448,34 @@ class MainActivity : AppCompatActivity() { } } + pmInvite?.let { state -> + PmInviteDialog(state, vm::selectPmInviteGroup, vm::sendPmInvite, vm::dismissPmInvite) + } + linkJoin?.let { state -> + AlertDialog( + onDismissRequest = vm::dismissLinkJoin, + title = { Text(stringResource(R.string.join_channel)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.invite_lifetime)) + if (state.pending) { + LinearProgressIndicator(Modifier.fillMaxWidth()) + Text(stringResource(R.string.connecting_peer)) + } else { + Text(if (state.channelId == 0L) stringResource(R.string.invalid_invite_link) + else stringResource(R.string.invite_join_failed, state.result ?: -1), + color = MaterialTheme.colorScheme.error) + } + } + }, + confirmButton = { + if (!state.pending && state.channelId != 0L) TextButton(onClick = { vm.openInviteLink(state.link) }) { + Text(stringResource(R.string.retry)) + } + }, + dismissButton = { TextButton(onClick = vm::dismissLinkJoin) { Text(stringResource(R.string.close)) } } + ) + } if (showJoinDialog) { JoinChannelDialog( onConnect = { data: InviteData -> diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt index fd2774ed..9cedca22 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Call +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.* import androidx.compose.runtime.* @@ -70,6 +71,9 @@ fun DmChatScreen(conv: DmConversation, messages: List, viewModel: ChatV }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) } }, actions = { + IconButton(onClick = { viewModel.showPmInvite(conv.peerNodeId, conv.displayName) }) { + Icon(Icons.Filled.Add, stringResource(R.string.invite_to_group)) + } IconButton(onClick = { viewModel.startDmCall() }, enabled = callAvailable && callState?.ongoing != true) { Icon(Icons.Filled.Call, stringResource(if (callAvailable) R.string.call else R.string.peer_unavailable)) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt index 0d284bc6..7f2ae30b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt @@ -92,7 +92,8 @@ fun MemberListScreen( isSelected = selectedMember?.nodeId == m.nodeId, onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) }, onCall = { viewModel.startCall(channel.id, m.nodeId, m.name) }, - onMessage = { viewModel.startDm(channel.id, m.nodeId, m.name) } + onMessage = { viewModel.startDm(channel.id, m.nodeId, m.name) }, + onInvite = { viewModel.showPmInvite(m.nodeId, m.name, channel.id) } ) } } @@ -152,7 +153,8 @@ private fun MemberListItem( isSelected: Boolean = false, onClick: () -> Unit, onCall: () -> Unit, - onMessage: () -> Unit + onMessage: () -> Unit, + onInvite: () -> Unit ) { val onlineColor = when { member.connecting -> Color(0xFFFFEB3B) // yellow: connecting @@ -232,6 +234,7 @@ private fun MemberListItem( } if (!member.isSelf) { + IconButton(onClick = onInvite) { Icon(Icons.Filled.Add, stringResource(R.string.invite_to_group)) } IconButton(onClick = onCall, enabled = member.online) { Icon(Icons.Filled.Call, stringResource(R.string.call)) } IconButton(onClick = onMessage) { Icon(Icons.AutoMirrored.Filled.Message, stringResource(R.string.message)) } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PmInviteDialog.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PmInviteDialog.kt new file mode 100644 index 00000000..78f1d3f3 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PmInviteDialog.kt @@ -0,0 +1,46 @@ +package com.utun.chat.ui.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.utun.chat.R +import com.utun.chat.viewmodel.ChatViewModel + +/** Один диалог для строки участника и шапки PM; адресат и выбор переживают пересоздание Activity. */ +@Composable +fun PmInviteDialog(state: ChatViewModel.PmInviteState, onChoose: (String) -> Unit, onSend: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.invite_to_group)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.invite_recipient, state.name)) + Text(stringResource(R.string.invite_lifetime), style = MaterialTheme.typography.bodySmall) + Text(stringResource(if (state.groups.isEmpty()) R.string.invite_no_groups else R.string.invite_choose_group)) + state.groups.forEach { group -> + Row(Modifier.fillMaxWidth().clickable(enabled = !state.pending) { onChoose(group.id) }, + verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = state.groupId == group.id, onClick = { onChoose(group.id) }, enabled = !state.pending) + Text(group.name, modifier = Modifier.weight(1f)) + } + } + if (state.pending) { + LinearProgressIndicator(Modifier.fillMaxWidth()) + Text(stringResource(R.string.invite_sending)) + } + if (state.errorRes != 0) Text(stringResource(state.errorRes), color = MaterialTheme.colorScheme.error) + } + }, + confirmButton = { + TextButton(onClick = onSend, enabled = !state.pending && state.groupId.isNotEmpty()) { Text(stringResource(R.string.invite)) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } } + ) +}