Add create call link sheet.

This commit is contained in:
Alex Hart
2023-04-04 12:51:06 -03:00
parent d8ac5a390a
commit 9d575650d1
15 changed files with 717 additions and 9 deletions

View File

@@ -0,0 +1,242 @@
package org.thoughtcrime.securesms.calls.links
import android.content.ActivityNotFoundException
import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
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.layout.wrapContentSize
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Alignment.Companion.End
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.app.ShareCompat
import androidx.fragment.app.viewModels
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dividers
import org.signal.core.ui.Rows
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.util.Util
/**
* Bottom sheet for creating call links
*/
class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment() {
private val viewModel: CreateCallLinkViewModel by viewModels()
override val peekHeightPercentage: Float = 1f
@Composable
override fun SheetContent() {
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize(Alignment.Center)
) {
val callName: String by viewModel.callName
val callLink: String by viewModel.callLink
val approveAllMembers: Boolean by viewModel.approveAllMembers
Handle(modifier = Modifier.align(CenterHorizontally))
Spacer(modifier = Modifier.height(20.dp))
Text(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__create_call_link),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(24.dp))
SignalCallRow(
callName = callName,
callLink = callLink,
onJoinClicked = this@CreateCallLinkBottomSheetDialogFragment::onJoinClicked
)
Spacer(modifier = Modifier.height(12.dp))
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__add_call_name),
modifier = Modifier.clickable(onClick = this@CreateCallLinkBottomSheetDialogFragment::onAddACallNameClicked)
)
Rows.ToggleRow(
checked = approveAllMembers,
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__approve_all_members),
onCheckChanged = viewModel::setApproveAllMembers,
modifier = Modifier.clickable(onClick = viewModel::toggleApproveAllMembers)
)
Dividers.Default()
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link_via_signal),
icon = ImageVector.vectorResource(id = R.drawable.symbol_forward_24),
modifier = Modifier.clickable(onClick = this@CreateCallLinkBottomSheetDialogFragment::onShareViaSignalClicked)
)
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__copy_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_copy_android_24),
modifier = Modifier.clickable(onClick = this@CreateCallLinkBottomSheetDialogFragment::onCopyLinkClicked)
)
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_share_android_24),
modifier = Modifier.clickable(onClick = this@CreateCallLinkBottomSheetDialogFragment::onShareLinkClicked)
)
Buttons.MediumTonal(
onClick = this@CreateCallLinkBottomSheetDialogFragment::onDoneClicked,
modifier = Modifier
.padding(end = dimensionResource(id = R.dimen.core_ui__gutter))
.align(End)
) {
Text(text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__done))
}
Spacer(modifier = Modifier.size(16.dp))
}
}
private fun onAddACallNameClicked() {
EditCallLinkNameDialogFragment().show(childFragmentManager, null)
}
private fun onJoinClicked() {
}
private fun onDoneClicked() {
}
private fun onShareViaSignalClicked() {
val snapshot = viewModel.callLink.value
MultiselectForwardFragment.showFullScreen(
childFragmentManager,
MultiselectForwardFragmentArgs(
canSendToNonPush = false,
multiShareArgs = listOf(
MultiShareArgs.Builder()
.withDraftText(snapshot)
.build()
)
)
)
}
private fun onCopyLinkClicked() {
val snapshot = viewModel.callLink.value
Util.copyToClipboard(requireContext(), snapshot)
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
}
private fun onShareLinkClicked() {
val snapshot = viewModel.callLink.value
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(snapshot)
.setType(mimeType)
.createChooserIntent()
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
try {
startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
}
}
}
@Composable
private fun SignalCallRow(
callName: String,
callLink: String,
onJoinClicked: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
.border(
width = 1.25.dp,
color = MaterialTheme.colorScheme.outline,
shape = RoundedCornerShape(18.dp)
)
.padding(16.dp)
) {
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_video_display_bold_40),
contentScale = ContentScale.Inside,
contentDescription = null,
colorFilter = ColorFilter.tint(Color(0xFF5151F6)),
modifier = Modifier
.size(64.dp)
.background(
color = Color(0xFFE5E5FE),
shape = CircleShape
)
)
Spacer(modifier = Modifier.width(10.dp))
Column(
modifier = Modifier
.weight(1f)
.align(CenterVertically)
) {
Text(
text = callName.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
)
Text(
text = callLink,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.width(10.dp))
Buttons.Small(
onClick = onJoinClicked,
modifier = Modifier.align(CenterVertically)
) {
Text(text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__join))
}
}
}

View File

@@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.calls.links
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
class CreateCallLinkViewModel : ViewModel() {
private val _callName: MutableState<String> = mutableStateOf("")
private val _callLink: MutableState<String> = mutableStateOf("")
private val _approveAllMembers: MutableState<Boolean> = mutableStateOf(false)
val callName: State<String> = _callName
val callLink: State<String> = _callLink
val approveAllMembers: State<Boolean> = _approveAllMembers
fun setApproveAllMembers(approveAllMembers: Boolean) {
_approveAllMembers.value = approveAllMembers
}
fun toggleApproveAllMembers() {
_approveAllMembers.value = !_approveAllMembers.value
}
fun setCallName(callName: String) {
_callName.value = callName
}
fun setCallLink(callLink: String) {
_callLink.value = callLink
}
}

View File

@@ -0,0 +1,114 @@
package org.thoughtcrime.securesms.calls.links
import android.app.Dialog
import android.os.Bundle
import android.view.WindowManager
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.End
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import org.signal.core.ui.Buttons
import org.signal.core.ui.Scaffolds
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
private val viewModel: CreateCallLinkViewModel by viewModels(
ownerProducer = { requireParentFragment() }
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
return dialog
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
override fun DialogContent() {
val viewModelCallName by viewModel.callName
var callName by remember {
mutableStateOf(
TextFieldValue(
text = viewModelCallName,
selection = TextRange(viewModelCallName.length)
)
)
}
Scaffolds.Settings(
title = stringResource(id = R.string.EditCallLinkNameDialogFragment__edit_call_name),
onNavigationClick = this::dismiss,
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) { paddingValues ->
val focusRequester = remember { FocusRequester() }
Surface(modifier = Modifier.padding(paddingValues)) {
Column(
modifier = Modifier
.padding(
horizontal = dimensionResource(id = org.signal.core.ui.R.dimen.core_ui__gutter)
)
.padding(top = 20.dp, bottom = 16.dp)
) {
TextField(
value = callName,
label = {
Text(text = stringResource(id = R.string.EditCallLinkNameDialogFragment__call_name))
},
onValueChange = { callName = it },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
Spacer(modifier = Modifier.weight(1f))
Buttons.MediumTonal(
onClick = {
viewModel.setCallName(callName.text)
dismiss()
},
modifier = Modifier.align(End)
) {
Text(text = stringResource(id = R.string.EditCallLinkNameDialogFragment__save))
}
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}
}

View File

@@ -9,6 +9,7 @@ import androidx.core.widget.TextViewCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.databinding.CallLogAdapterItemBinding
import org.thoughtcrime.securesms.databinding.CallLogCreateCallLinkItemBinding
import org.thoughtcrime.securesms.databinding.ConversationListItemClearFilterBinding
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.recipients.Recipient
@@ -51,6 +52,13 @@ class CallLogAdapter(
inflater = ConversationListItemClearFilterBinding::inflate
)
)
registerFactory(
CreateCallLinkModel::class.java,
BindingFactory(
creator = { CreateCallLinkViewHolder(it, callbacks::onCreateACallLinkClicked) },
inflater = CallLogCreateCallLinkItemBinding::inflate
)
)
}
fun submitCallRows(
@@ -65,6 +73,7 @@ class CallLogAdapter(
when (it) {
is CallLogRow.Call -> CallModel(it, selectionState, itemCount)
is CallLogRow.ClearFilter -> ClearFilterModel()
is CallLogRow.CreateCallLink -> CreateCallLinkModel()
}
}
@@ -112,6 +121,12 @@ class CallLogAdapter(
override fun areContentsTheSame(newItem: ClearFilterModel): Boolean = true
}
private class CreateCallLinkModel : MappingModel<CreateCallLinkModel> {
override fun areItemsTheSame(newItem: CreateCallLinkModel): Boolean = true
override fun areContentsTheSame(newItem: CreateCallLinkModel): Boolean = true
}
private class CallModelViewHolder(
binding: CallLogAdapterItemBinding,
private val onCallClicked: (CallLogRow.Call) -> Unit,
@@ -234,7 +249,23 @@ class CallLogAdapter(
override fun bind(model: ClearFilterModel) = Unit
}
private class CreateCallLinkViewHolder(
binding: CallLogCreateCallLinkItemBinding,
onClick: () -> Unit
) : BindingViewHolder<CreateCallLinkModel, CallLogCreateCallLinkItemBinding>(binding) {
init {
binding.root.setOnClickListener { onClick() }
}
override fun bind(model: CreateCallLinkModel) = Unit
}
interface Callbacks {
/**
* Invoked when 'Create a call link' is clicked
*/
fun onCreateACallLinkClicked()
/**
* Invoked when a call row is clicked
*/

View File

@@ -14,6 +14,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -268,6 +269,10 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
}
}
override fun onCreateACallLinkClicked() {
findNavController().navigate(R.id.createCallLinkBottomSheet)
}
override fun onCallClicked(callLogRow: CallLogRow.Call) {
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
viewModel.toggleSelected(callLogRow.id)

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.calls.log
import org.signal.paging.PagedDataSource
import org.thoughtcrime.securesms.util.FeatureFlags
class CallLogPagedDataSource(
private val query: String?,
@@ -9,16 +10,24 @@ class CallLogPagedDataSource(
) : PagedDataSource<CallLogRow.Id, CallLogRow> {
private val hasFilter = filter == CallLogFilter.MISSED
private val hasCallLinkRow = FeatureFlags.adHocCalling() && filter == CallLogFilter.ALL && query.isNullOrEmpty()
var callsCount = 0
private var callsCount = 0
override fun size(): Int {
callsCount = repository.getCallsCount(query, filter)
return callsCount + (if (hasFilter) 1 else 0)
return callsCount + hasFilter.toInt() + hasCallLinkRow.toInt()
}
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<CallLogRow> {
val calls: MutableList<CallLogRow> = repository.getCalls(query, filter, start, length).toMutableList()
val calls = mutableListOf<CallLogRow>()
val callLimit = length - hasCallLinkRow.toInt()
if (start == 0 && length >= 1 && hasCallLinkRow) {
calls.add(CallLogRow.CreateCallLink)
}
calls.addAll(repository.getCalls(query, filter, start, callLimit).toMutableList())
if (calls.size < length && hasFilter) {
calls.add(CallLogRow.ClearFilter)
@@ -31,6 +40,10 @@ class CallLogPagedDataSource(
override fun load(key: CallLogRow.Id?): CallLogRow = error("Not supported")
private fun Boolean.toInt(): Int {
return if (this) 1 else 0
}
interface CallRepository {
fun getCallsCount(query: String?, filter: CallLogFilter): Int
fun getCalls(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow>

View File

@@ -27,8 +27,13 @@ sealed class CallLogRow {
override val id: Id = Id.ClearFilter
}
object CreateCallLink : CallLogRow() {
override val id: Id = Id.CreateCallLink
}
sealed class Id {
data class Call(val callId: Long) : Id()
object ClearFilter : Id()
object CreateCallLink : Id()
}
}

View File

@@ -57,9 +57,9 @@ abstract class ComposeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetD
* ```
*/
@Composable
protected fun Handle() {
protected fun Handle(modifier: Modifier = Modifier) {
Box(
modifier = Modifier
modifier = modifier
.size(width = 48.dp, height = 22.dp)
.padding(vertical = 10.dp)
.clip(RoundedCornerShape(1000.dp))

View File

@@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.compose
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.DialogFragment
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.util.DynamicTheme
/**
* Generic ComposeFragment which can be subclassed to build UI with compose.
*/
abstract class ComposeDialogFragment : DialogFragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
SignalTheme(
isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current)
) {
DialogContent()
}
}
}
}
@Composable
abstract fun DialogContent()
}