diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt index 66bd4efcd1..0513a0e3a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt @@ -1,7 +1,8 @@ -@file:OptIn(ExperimentalMaterial3Api::class) +@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main +import android.app.Activity import android.content.Intent import android.content.res.Configuration import android.graphics.Bitmap @@ -11,6 +12,7 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth @@ -45,6 +47,8 @@ import androidx.compose.ui.unit.dp import androidx.core.app.ShareCompat import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner import androidx.navigation.NavController import androidx.navigation.fragment.findNavController import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -52,6 +56,7 @@ import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState +import io.reactivex.rxjava3.disposables.CompositeDisposable import kotlinx.coroutines.CoroutineScope import org.signal.core.ui.Buttons import org.signal.core.ui.Dialogs @@ -59,6 +64,9 @@ import org.signal.core.ui.Snackbars import org.signal.core.ui.theme.SignalTheme import org.signal.core.util.concurrent.LifecycleDisposable import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab import org.thoughtcrime.securesms.compose.ComposeFragment import org.thoughtcrime.securesms.providers.BlobProvider @@ -80,91 +88,33 @@ class UsernameLinkSettingsFragment : ComposeFragment() { } } - @OptIn(ExperimentalMaterial3Api::class) @Composable override fun FragmentContent() { val state by viewModel.state - val snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } - val scope: CoroutineScope = rememberCoroutineScope() val navController: NavController by remember { mutableStateOf(findNavController()) } - var showResetDialog: Boolean by remember { mutableStateOf(false) } - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val linkCopiedEvent: UUID? by viewModel.linkCopiedEvent + val helpText = stringResource(id = R.string.UsernameLinkSettings_scan_this_qr_code) + val cameraPermissionState: PermissionState = rememberPermissionState(permission = android.Manifest.permission.CAMERA) { viewModel.onTabSelected(ActiveTab.Scan) } - val linkCopiedEvent: UUID? by viewModel.linkCopiedEvent - val linkCopiedString = stringResource(R.string.UsernameLinkSettings_link_copied_toast) - - LaunchedEffect(linkCopiedEvent) { - if (linkCopiedEvent != null) { - snackbarHostState.showSnackbar(linkCopiedString) - } - } - - Scaffold( - snackbarHost = { Snackbars.Host(snackbarHostState) }, - topBar = { - TopAppBarContent( - activeTab = state.activeTab, - scrollBehavior = scrollBehavior, - onCodeTabSelected = { viewModel.onTabSelected(ActiveTab.Code) }, - onScanTabSelected = { viewModel.onTabSelected(ActiveTab.Scan) }, - cameraPermissionState = cameraPermissionState - ) - }, - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) - ) { contentPadding -> - - if (state.indeterminateProgress) { - Dialogs.IndeterminateProgressDialog() - } - - AnimatedVisibility( - visible = state.activeTab == ActiveTab.Code, - enter = slideInHorizontally(initialOffsetX = { fullWidth -> -fullWidth }), - exit = slideOutHorizontally(targetOffsetX = { fullWidth -> -fullWidth }) - ) { - val helpText = stringResource(id = R.string.UsernameLinkSettings_scan_this_qr_code) - UsernameLinkShareScreen( - state = state, - snackbarHostState = snackbarHostState, - scope = scope, - modifier = Modifier.padding(contentPadding), - navController = navController, - onShareBadge = { - shareQrBadge(viewModel.generateQrCodeImage(helpText)) - }, - onResetClicked = { showResetDialog = true }, - onLinkResultHandled = { viewModel.onUsernameLinkResetResultHandled() } - ) - } - - AnimatedVisibility( - visible = state.activeTab == ActiveTab.Scan, - enter = slideInHorizontally(initialOffsetX = { fullWidth -> fullWidth }), - exit = slideOutHorizontally(targetOffsetX = { fullWidth -> fullWidth }) - ) { - UsernameQrScanScreen( - lifecycleOwner = viewLifecycleOwner, - disposables = disposables.disposables, - qrScanResult = state.qrScanResult, - onQrCodeScanned = { data -> viewModel.onQrCodeScanned(data) }, - onQrResultHandled = { viewModel.onQrResultHandled() }, - modifier = Modifier.padding(contentPadding) - ) - } - } - - if (showResetDialog) { - ResetDialog( - onConfirm = { - viewModel.onUsernameLinkReset() - showResetDialog = false - }, - onDismiss = { showResetDialog = false } - ) - } + MainScreen( + state = state, + navController = navController, + lifecycleOwner = viewLifecycleOwner, + disposables = disposables.disposables, + cameraPermissionState = cameraPermissionState, + onCodeTabSelected = { viewModel.onTabSelected(ActiveTab.Code) }, + onScanTabSelected = { viewModel.onTabSelected(ActiveTab.Scan) }, + onUsernameLinkResetResultHandled = { viewModel.onUsernameLinkResetResultHandled() }, + onShareBadge = { shareQrBadge(requireActivity(), viewModel.generateQrCodeImage(helpText)) }, + onQrCodeScanned = { data -> viewModel.onQrCodeScanned(data) }, + onQrResultHandled = { viewModel.onQrResultHandled() }, + onLinkReset = { viewModel.onUsernameLinkReset() }, + onBackNavigationPressed = { requireActivity().onBackPressed() }, + linkCopiedEvent = linkCopiedEvent + ) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -175,153 +125,266 @@ class UsernameLinkSettingsFragment : ComposeFragment() { super.onResume() viewModel.onResume() } +} - @OptIn(ExperimentalMaterial3Api::class) - @Composable - private fun TopAppBarContent( - activeTab: ActiveTab, - scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), - onCodeTabSelected: () -> Unit = {}, - onScanTabSelected: () -> Unit = {}, - cameraPermissionState: PermissionState = previewPermissionState() - ) { - CenterAlignedTopAppBar( - modifier = Modifier - .fillMaxWidth(), - title = { - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - TabButton( - label = stringResource(R.string.UsernameLinkSettings_code_tab_name), - active = activeTab == ActiveTab.Code, - onClick = onCodeTabSelected, - modifier = Modifier.padding(end = 8.dp) - ) - TabButton( - label = stringResource(R.string.UsernameLinkSettings_scan_tab_name), - active = activeTab == ActiveTab.Scan, - onClick = { - if (cameraPermissionState.status.isGranted) { - onScanTabSelected() - } else { - cameraPermissionState.launchPermissionRequest() - } - }, - modifier = Modifier.padding(end = 8.dp) - ) - } - }, - navigationIcon = { - IconButton( - onClick = { requireActivity().onBackPressed() } - ) { - Icon( - painter = painterResource(R.drawable.symbol_x_24), - contentDescription = stringResource(android.R.string.cancel) - ) - } - }, - scrollBehavior = scrollBehavior - ) +@Composable +private fun MainScreen( + state: UsernameLinkSettingsState, + navController: NavController? = null, + lifecycleOwner: LifecycleOwner = previewLifecycleOwner, + disposables: CompositeDisposable = CompositeDisposable(), + cameraPermissionState: PermissionState = previewPermissionState(), + onCodeTabSelected: () -> Unit = {}, + onScanTabSelected: () -> Unit = {}, + onUsernameLinkResetResultHandled: () -> Unit = {}, + onShareBadge: () -> Unit = {}, + onQrCodeScanned: (String) -> Unit = {}, + onQrResultHandled: () -> Unit = {}, + onLinkReset: () -> Unit = {}, + onBackNavigationPressed: () -> Unit = {}, + linkCopiedEvent: UUID? = null +) { + val snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } + val scope: CoroutineScope = rememberCoroutineScope() + var showResetDialog: Boolean by remember { mutableStateOf(false) } + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + + val linkCopiedString = stringResource(R.string.UsernameLinkSettings_link_copied_toast) + + LaunchedEffect(linkCopiedEvent) { + if (linkCopiedEvent != null) { + snackbarHostState.showSnackbar(linkCopiedString) + } } - @Composable - private fun TabButton(label: String, active: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { - val colors = if (active) { - ButtonDefaults.filledTonalButtonColors() - } else { - ButtonDefaults.buttonColors( - containerColor = SignalTheme.colors.colorSurface2, - contentColor = MaterialTheme.colorScheme.onSurface + Scaffold( + snackbarHost = { Snackbars.Host(snackbarHostState) }, + topBar = { + TopAppBarContent( + activeTab = state.activeTab, + scrollBehavior = scrollBehavior, + onCodeTabSelected = onCodeTabSelected, + onScanTabSelected = onScanTabSelected, + cameraPermissionState = cameraPermissionState, + onBackNavigationPressed = onBackNavigationPressed + ) + }, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + ) { contentPadding -> + + if (state.indeterminateProgress) { + Dialogs.IndeterminateProgressDialog() + } + + AnimatedVisibility( + visible = state.activeTab == ActiveTab.Code, + enter = slideInHorizontally(initialOffsetX = { fullWidth -> -fullWidth }), + exit = slideOutHorizontally(targetOffsetX = { fullWidth -> -fullWidth }) + ) { + UsernameLinkShareScreen( + state = state, + snackbarHostState = snackbarHostState, + scope = scope, + modifier = Modifier.padding(contentPadding), + navController = navController, + onShareBadge = onShareBadge, + onResetClicked = { showResetDialog = true }, + onLinkResultHandled = onUsernameLinkResetResultHandled ) } - Buttons.Small( - onClick = onClick, - modifier = modifier.defaultMinSize(minWidth = 100.dp), - shape = RoundedCornerShape(12.dp), - colors = colors + + AnimatedVisibility( + visible = state.activeTab == ActiveTab.Scan, + enter = slideInHorizontally(initialOffsetX = { fullWidth -> fullWidth }), + exit = slideOutHorizontally(targetOffsetX = { fullWidth -> fullWidth }) ) { - Text(label) + UsernameQrScanScreen( + lifecycleOwner = lifecycleOwner, + disposables = disposables, + qrScanResult = state.qrScanResult, + onQrCodeScanned = onQrCodeScanned, + onQrResultHandled = onQrResultHandled, + modifier = Modifier.padding(contentPadding) + ) } } - @Composable - private fun ResetDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { - Dialogs.SimpleAlertDialog( - title = stringResource(id = R.string.UsernameLinkSettings_reset_link_dialog_title), - body = stringResource(id = R.string.UsernameLinkSettings_reset_link_dialog_body), - confirm = stringResource(id = R.string.UsernameLinkSettings_reset_link_dialog_confirm_button), - dismiss = stringResource(id = android.R.string.cancel), - onConfirm = onConfirm, - onDismiss = onDismiss + if (showResetDialog) { + ResetDialog( + onConfirm = { + onLinkReset() + showResetDialog = false + }, + onDismiss = { showResetDialog = false } ) } +} - @Preview - @Composable - private fun PreviewAppBar() { - SignalTheme { - Surface { +@Composable +private fun TopAppBarContent( + activeTab: ActiveTab, + scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), + onCodeTabSelected: () -> Unit = {}, + onScanTabSelected: () -> Unit = {}, + cameraPermissionState: PermissionState = previewPermissionState(), + onBackNavigationPressed: () -> Unit = {} +) { + CenterAlignedTopAppBar( + modifier = Modifier + .fillMaxWidth(), + title = { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + TabButton( + label = stringResource(R.string.UsernameLinkSettings_code_tab_name), + active = activeTab == ActiveTab.Code, + onClick = onCodeTabSelected, + modifier = Modifier.padding(end = 8.dp) + ) + TabButton( + label = stringResource(R.string.UsernameLinkSettings_scan_tab_name), + active = activeTab == ActiveTab.Scan, + onClick = { + if (cameraPermissionState.status.isGranted) { + onScanTabSelected() + } else { + cameraPermissionState.launchPermissionRequest() + } + }, + modifier = Modifier.padding(end = 8.dp) + ) + } + }, + navigationIcon = { + IconButton( + onClick = onBackNavigationPressed + ) { + Icon( + painter = painterResource(R.drawable.symbol_x_24), + contentDescription = stringResource(android.R.string.cancel) + ) + } + }, + scrollBehavior = scrollBehavior + ) +} + +@Composable +private fun TabButton(label: String, active: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val colors = if (active) { + ButtonDefaults.filledTonalButtonColors() + } else { + ButtonDefaults.buttonColors( + containerColor = SignalTheme.colors.colorSurface2, + contentColor = MaterialTheme.colorScheme.onSurface + ) + } + Buttons.Small( + onClick = onClick, + modifier = modifier.defaultMinSize(minWidth = 100.dp), + shape = RoundedCornerShape(12.dp), + colors = colors + ) { + Text(label) + } +} + +@Composable +private fun ResetDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + Dialogs.SimpleAlertDialog( + title = stringResource(id = R.string.UsernameLinkSettings_reset_link_dialog_title), + body = stringResource(id = R.string.UsernameLinkSettings_reset_link_dialog_body), + confirm = stringResource(id = R.string.UsernameLinkSettings_reset_link_dialog_confirm_button), + dismiss = stringResource(id = android.R.string.cancel), + onConfirm = onConfirm, + onDismiss = onDismiss + ) +} + +@Preview(name = "Light Theme", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AppBarPreview() { + SignalTheme { + Surface { + Column { TopAppBarContent(activeTab = ActiveTab.Code) + TopAppBarContent(activeTab = ActiveTab.Scan) } } } - - @Preview(name = "Light Theme", uiMode = Configuration.UI_MODE_NIGHT_NO) - @Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) - @Composable - private fun PreviewAll() { - FragmentContent() - } - - @Preview - @Composable - private fun PreviewResetDialog() { - SignalTheme { - Surface { - ResetDialog(onConfirm = {}, onDismiss = {}) - } - } - } - - private fun previewPermissionState(): PermissionState { - return object : PermissionState { - override val permission: String = "" - override val status: PermissionStatus = PermissionStatus.Granted - override fun launchPermissionRequest() = Unit - } - } - - private fun shareQrBadge(badge: Bitmap?) { - if (badge == null) { - return - } - - try { - ByteArrayOutputStream().use { byteArrayOutputStream -> - badge.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream) - byteArrayOutputStream.flush() - val bytes = byteArrayOutputStream.toByteArray() - val shareUri = BlobProvider.getInstance() - .forData(bytes) - .withMimeType("image/png") - .withFileName("SignalGroupQr.png") - .createForSingleSessionInMemory() - - val intent = ShareCompat.IntentBuilder.from(requireActivity()) - .setType("image/png") - .setStream(shareUri) - .createChooserIntent() - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - - startActivity(intent) - } - } finally { - badge.recycle() - } - } +} + +@Preview(name = "Light Theme", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun MainScreenPreview() { + SignalTheme { + MainScreen( + state = UsernameLinkSettingsState( + activeTab = ActiveTab.Code, + username = "PeterParker.42", + usernameLinkState = UsernameLinkState.Present("https://signal.org"), + qrCodeState = QrCodeState.Present(QrCodeData.forData("PeterParker.42", 64)), + qrCodeColorScheme = UsernameQrCodeColorScheme.Orange + ) + ) + } +} + +@Preview(name = "Light Theme", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ResetDialogPreview() { + SignalTheme { + Surface { + ResetDialog(onConfirm = {}, onDismiss = {}) + } + } +} + +private fun previewPermissionState(): PermissionState { + return object : PermissionState { + override val permission: String = "" + override val status: PermissionStatus = PermissionStatus.Granted + override fun launchPermissionRequest() = Unit + } +} + +private val previewLifecycleOwner: LifecycleOwner = object : LifecycleOwner { + override val lifecycle: Lifecycle + get() = TODO("Not yet implemented") +} + +private fun shareQrBadge(activity: Activity, badge: Bitmap?) { + if (badge == null) { + return + } + + try { + ByteArrayOutputStream().use { byteArrayOutputStream -> + badge.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream) + byteArrayOutputStream.flush() + val bytes = byteArrayOutputStream.toByteArray() + val shareUri = BlobProvider.getInstance() + .forData(bytes) + .withMimeType("image/png") + .withFileName("SignalGroupQr.png") + .createForSingleSessionInMemory() + + val intent = ShareCompat.IntentBuilder(activity) + .setType("image/png") + .setStream(shareUri) + .createChooserIntent() + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + + activity.startActivity(intent) + } + } finally { + badge.recycle() + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt index e27bb57846..7fe6624d2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt @@ -54,7 +54,7 @@ fun UsernameLinkShareScreen( onLinkResultHandled: () -> Unit, snackbarHostState: SnackbarHostState, scope: CoroutineScope, - navController: NavController, + navController: NavController?, onShareBadge: () -> Unit, modifier: Modifier = Modifier, onResetClicked: () -> Unit @@ -93,9 +93,9 @@ fun UsernameLinkShareScreen( ButtonBar( onShareClicked = onShareBadge, - onColorClicked = { navController.safeNavigate(UsernameLinkSettingsFragmentDirections.actionUsernameLinkSettingsFragmentToUsernameLinkQrColorPickerFragment()) }, + onColorClicked = { navController?.safeNavigate(UsernameLinkSettingsFragmentDirections.actionUsernameLinkSettingsFragmentToUsernameLinkQrColorPickerFragment()) }, onLinkClicked = { - navController.safeNavigate(UsernameLinkSettingsFragmentDirections.actionUsernameLinkSettingsFragmentToUsernameLinkShareBottomSheet()) + navController?.safeNavigate(UsernameLinkSettingsFragmentDirections.actionUsernameLinkSettingsFragmentToUsernameLinkShareBottomSheet()) }, linkState = state.usernameLinkState )